Rename project to Constant and prepare public repo
This commit is contained in:
@@ -4,6 +4,8 @@
|
||||
.cache/
|
||||
.tmp/
|
||||
tmp/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.log
|
||||
*.pem
|
||||
*.key
|
||||
@@ -13,6 +15,7 @@ tmp/
|
||||
*.sqlite
|
||||
*.sqlite-shm
|
||||
*.sqlite-wal
|
||||
output/
|
||||
codex-profiles/
|
||||
profiles/
|
||||
state/
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
# Contributing to Constant
|
||||
|
||||
`Constant` is a terminal-first orchestration project.
|
||||
Contributions should keep that bias visible in the code and in the product shape.
|
||||
|
||||
## What We Care About
|
||||
|
||||
- local-first workflows before cloud assumptions
|
||||
- real shells, real sessions, real files
|
||||
- clean operator ergonomics
|
||||
- explicit routing and supervision
|
||||
- durable context instead of prompt spaghetti
|
||||
- strong terminal aesthetics without gimmick-only UX
|
||||
|
||||
## Good Contributions
|
||||
|
||||
- make the cockpit more reliable
|
||||
- improve mission routing or verification
|
||||
- improve durable memory and repo context
|
||||
- sharpen the TUI and the `hexapus` buddy rail
|
||||
- simplify setup without hiding the system
|
||||
- improve docs with concrete operator value
|
||||
|
||||
## Ground Rules
|
||||
|
||||
- keep the CLI useful in non-interactive environments
|
||||
- prefer robust shell and Python over magic abstractions
|
||||
- preserve local observability
|
||||
- do not hardcode personal machine names, users, IPs, or paths
|
||||
- treat clipboard, SSH, and session behavior as product features, not side details
|
||||
|
||||
## Style
|
||||
|
||||
- favor direct, composable commands
|
||||
- prefer explicit state over hidden side effects
|
||||
- keep defaults safe and public-repo friendly
|
||||
- avoid introducing heavyweight infrastructure unless it clearly pays for itself
|
||||
- maintain the visual identity: terminal-native, slightly demoscene, still readable
|
||||
|
||||
## Before Opening a PR
|
||||
|
||||
Run at least the checks relevant to your change.
|
||||
|
||||
Examples:
|
||||
|
||||
```bash
|
||||
python3 -m py_compile constant/*.py
|
||||
bash -n scripts/*.sh
|
||||
./scripts/Constant doctor
|
||||
```
|
||||
|
||||
If your change affects routing, fleet behavior, or memory shape, include a short explanation of:
|
||||
|
||||
- what changed
|
||||
- how you tested it
|
||||
- what assumptions still remain
|
||||
|
||||
## Design Notes
|
||||
|
||||
If you add a new capability, ask:
|
||||
|
||||
1. does this help an operator supervise a real machine or fleet?
|
||||
2. does it make routing or context more explicit?
|
||||
3. does it still degrade cleanly when the environment is constrained?
|
||||
|
||||
If the answer is no, it probably does not belong here yet.
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Constant contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -1,67 +1,318 @@
|
||||
# Zellij AI Triple
|
||||
# Constant
|
||||
|
||||
Three-pane Zellij workspace:
|
||||
```text
|
||||
██████╗ ██████╗ ███╗ ██╗███████╗████████╗ █████╗ ███╗ ██╗████████╗
|
||||
██╔════╝██╔═══██╗████╗ ██║██╔════╝╚══██╔══╝██╔══██╗████╗ ██║╚══██╔══╝
|
||||
██║ ██║ ██║██╔██╗ ██║███████╗ ██║ ███████║██╔██╗ ██║ ██║
|
||||
██║ ██║ ██║██║╚██╗██║╚════██║ ██║ ██╔══██║██║╚██╗██║ ██║
|
||||
╚██████╗╚██████╔╝██║ ╚████║███████║ ██║ ██║ ██║██║ ╚████║ ██║
|
||||
╚═════╝ ╚═════╝ ╚═╝ ╚═══╝╚══════╝ ╚═╝ ╚═╝ ╚═╝╚═╝ ╚═══╝ ╚═╝
|
||||
|
||||
- left pane: `claude` on the host
|
||||
- top-right pane: `codex` in Docker with profile 1
|
||||
- bottom-right pane: `codex` in Docker with profile 2
|
||||
local-first orchestration for weird builders, fleet nerds, and AI cockpit addicts
|
||||
```
|
||||
|
||||
Each Codex pane gets its own `CODEX_HOME`, so each one can log into a different ChatGPT/OpenAI account.
|
||||
`Constant` is a control room.
|
||||
|
||||
## Requirements
|
||||
Not a chatbot skin.
|
||||
Not a SaaS wrapper.
|
||||
Not another “agent framework” that only looks good in slides.
|
||||
|
||||
It is a pragmatic, local-first orchestration layer that sits on top of:
|
||||
|
||||
- a 4-pane Zellij machine cockpit
|
||||
- a multi-machine fleet view
|
||||
- host-local CLIs like `claude`, `codex`, `copilot`, and `vibe`
|
||||
- a mission runner that can route work across machines
|
||||
- a growing durable memory layer for repo context, decisions, and persona
|
||||
|
||||
The vibe is somewhere between:
|
||||
|
||||
- demoscene terminal
|
||||
- distributed workshop
|
||||
- AI pit crew
|
||||
- `electron rare` engineering energy
|
||||
|
||||
## What Ships Today
|
||||
|
||||
Current build status in this repo:
|
||||
|
||||
- a 4-pane host-local Zellij session per machine
|
||||
- a fleet cockpit with one tab per machine
|
||||
- `Constant`, the orchestration CLI on top of the cockpit
|
||||
- local mission planning and verification
|
||||
- host-local execution for `claude`, `codex`, and `vibe`
|
||||
- `copilot` as a manual lane
|
||||
- local message bus + cross-machine bridge
|
||||
- MLX-ready model plumbing for a small local orchestrator stack on macOS
|
||||
|
||||
The historical repo name may still say `zellij-ai-triple`.
|
||||
The product name is now `Constant`.
|
||||
|
||||
## Why This Exists
|
||||
|
||||
Most AI tooling is designed like a vending machine:
|
||||
|
||||
1. send a prompt
|
||||
2. get an answer
|
||||
3. pretend orchestration happened
|
||||
|
||||
That breaks the moment you want:
|
||||
|
||||
- multiple machines
|
||||
- multiple CLIs
|
||||
- persistent sessions
|
||||
- human supervision without tab hell
|
||||
- routing by task type
|
||||
- repo memory that survives a single run
|
||||
|
||||
`Constant` is for the opposite use case:
|
||||
|
||||
- the workstation is real
|
||||
- the shell is real
|
||||
- the repos are real
|
||||
- the agents are messy
|
||||
- the human is still the one in charge
|
||||
|
||||
## Core Shape
|
||||
|
||||
### 1. Machine cockpit
|
||||
|
||||
Each machine runs the same 4-pane layout:
|
||||
|
||||
- left: `claude`
|
||||
- top-right: `codex`
|
||||
- middle-right: `copilot`
|
||||
- bottom-right: `vibe`
|
||||
|
||||
Everything runs on the host by default.
|
||||
No Docker dependency is required for the standard session.
|
||||
|
||||
### 2. Fleet cockpit
|
||||
|
||||
From your command center machine, you open one Zellij tab per machine.
|
||||
Each tab attaches to the machine’s own local session.
|
||||
|
||||
That gives you:
|
||||
|
||||
- one place to supervise the whole fleet
|
||||
- local clipboard behavior on the operator machine
|
||||
- remote sessions that stay remote
|
||||
- a clean separation between orchestration and execution
|
||||
|
||||
### 3. Constant CLI
|
||||
|
||||
`Constant` sits above the fleet and handles:
|
||||
|
||||
- mission creation
|
||||
- route selection
|
||||
- backend selection
|
||||
- CLI selection
|
||||
- buddy review
|
||||
- verification
|
||||
- delegation
|
||||
- cockpit handoff
|
||||
|
||||
### 4. Durable memory
|
||||
|
||||
The memory layer is being built to support:
|
||||
|
||||
- workspace-first indexing
|
||||
- weighted instruction fusion from `.claude`, `.copilot`, `.agent`, `.agents`, `CLAUDE.md`, `AGENTS.md`
|
||||
- cross-mission summaries
|
||||
- a durable decision graph
|
||||
- persistent persona outside mission files
|
||||
|
||||
The design goal is simple:
|
||||
`Constant` should remember how you work, not just what you typed five minutes ago.
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Requirements
|
||||
|
||||
- `zellij`
|
||||
- `git`
|
||||
- `node`
|
||||
- `npm`
|
||||
- `uv`
|
||||
- `claude`
|
||||
- `codex`
|
||||
- `docker`
|
||||
- a local Docker image with `bash` and `git`
|
||||
- `copilot`
|
||||
- `vibe`
|
||||
|
||||
Default Codex container image:
|
||||
|
||||
- `codercom/code-server:latest`
|
||||
|
||||
## Usage
|
||||
Recommended CLI install channels:
|
||||
|
||||
```bash
|
||||
./scripts/zellij-ai-triple.sh
|
||||
npm install -g @anthropic-ai/claude-code
|
||||
npm install -g @openai/codex
|
||||
npm install -g @github/copilot
|
||||
uv tool install mistral-vibe
|
||||
```
|
||||
|
||||
Useful flags:
|
||||
### Start the cockpit
|
||||
|
||||
```bash
|
||||
./scripts/zellij-ai-triple.sh \
|
||||
--workspace /path/to/project \
|
||||
--session ai-triple \
|
||||
--codex-image codercom/code-server:latest \
|
||||
--codex1-home "$HOME/.codex-profiles/codex-1" \
|
||||
--codex2-home "$HOME/.codex-profiles/codex-2" \
|
||||
--recreate
|
||||
./scripts/constant-machine.sh --workspace "$PWD"
|
||||
```
|
||||
|
||||
By default, the launcher creates a fresh temporary Zellij config directory for each new session.
|
||||
This is intentional: it avoids interference from an existing user Zellij config, custom default
|
||||
layouts, plugins, or resurrected session state. If you really want to use a specific Zellij config
|
||||
directory, pass:
|
||||
### Start the fleet cockpit
|
||||
|
||||
```bash
|
||||
./scripts/zellij-ai-triple.sh --zellij-config-dir /path/to/zellij-config
|
||||
./scripts/constant-fleet.sh --workspace "$PWD"
|
||||
```
|
||||
|
||||
On first launch of each Codex pane, if `auth.json` is missing in its profile, the pane runs:
|
||||
By default, the machine launcher creates a fresh temporary Zellij config directory for each new session.
|
||||
That is deliberate: it isolates `Constant` from an existing user Zellij config, custom layouts, plugins,
|
||||
and stale resurrected state. If you want to pin a specific Zellij config dir, use:
|
||||
|
||||
```bash
|
||||
./scripts/constant-machine.sh --zellij-config-dir /path/to/zellij-config
|
||||
```
|
||||
|
||||
On first launch of the Codex pane, if `auth.json` is missing in its profile, the pane runs:
|
||||
|
||||
```bash
|
||||
codex login --device-auth
|
||||
```
|
||||
|
||||
Use a different account in each pane if you want true account separation.
|
||||
### Start Constant
|
||||
|
||||
## Security
|
||||
```bash
|
||||
./scripts/Constant
|
||||
./scripts/Constant doctor
|
||||
./scripts/Constant mission create "audit the repo" --workspace "$PWD"
|
||||
./scripts/Constant mission status
|
||||
./scripts/Constant cockpit open --workspace "$PWD"
|
||||
```
|
||||
|
||||
This repository is intended to stay secret-free:
|
||||
If `Constant` is on your `PATH`, you can also just run:
|
||||
|
||||
- no `auth.json`
|
||||
- no `.env`
|
||||
- no API keys
|
||||
- no profile directories
|
||||
```bash
|
||||
Constant
|
||||
```
|
||||
|
||||
The provided `.gitignore` excludes common secret-bearing files and local runtime state.
|
||||
## Fleet Configuration
|
||||
|
||||
For a public setup, think in terms of roles, not personal machine names.
|
||||
|
||||
Example shape:
|
||||
|
||||
```text
|
||||
command-center=local
|
||||
builder-a=dev@builder-a
|
||||
builder-b=dev@builder-b
|
||||
edge-a=dev@edge-a
|
||||
lab-a=dev@lab-a
|
||||
```
|
||||
|
||||
The command-center machine is where you run:
|
||||
|
||||
- `Constant`
|
||||
- the fleet cockpit
|
||||
- local MLX orchestration
|
||||
- any human-in-the-loop supervision
|
||||
|
||||
The workers are where execution happens.
|
||||
|
||||
## Messaging
|
||||
|
||||
Each machine exposes a local bus:
|
||||
|
||||
```text
|
||||
~/.cache/constant/zellij/<session>/bus
|
||||
```
|
||||
|
||||
Use it from any pane:
|
||||
|
||||
```bash
|
||||
ai-msg.sh send --to codex --message "look at the failing test"
|
||||
ai-msg.sh send --to vibe --message "explore two alternatives"
|
||||
ai-msg.sh broadcast --message "new constraints in README"
|
||||
ai-msg.sh inbox --for claude
|
||||
```
|
||||
|
||||
Bridge between machines from the command center:
|
||||
|
||||
```bash
|
||||
./scripts/ai-bridge.sh send \
|
||||
--from-machine command-center \
|
||||
--from claude \
|
||||
--to-machine lab-a \
|
||||
--to codex \
|
||||
--message "take over the deep refactor"
|
||||
```
|
||||
|
||||
## Clipboard
|
||||
|
||||
Clipboard behavior is split on purpose:
|
||||
|
||||
- local macOS sessions can use `pbcopy`
|
||||
- remote tabs rely on `OSC52`
|
||||
- the command center keeps ownership of the actual system clipboard
|
||||
|
||||
This matters.
|
||||
|
||||
If a remote pane copies text, it should land in the operator machine clipboard, not in some random remote shell session.
|
||||
|
||||
## Constant Philosophy
|
||||
|
||||
`Constant` is not trying to hide the terminal.
|
||||
|
||||
It assumes:
|
||||
|
||||
- the shell matters
|
||||
- sessions matter
|
||||
- state matters
|
||||
- local files matter
|
||||
- naming things matters
|
||||
- supervision matters
|
||||
|
||||
The system should feel like:
|
||||
|
||||
- a cockpit
|
||||
- a score
|
||||
- a routing table
|
||||
- a machine that keeps its shape under pressure
|
||||
|
||||
Not like a magic trick.
|
||||
|
||||
## Public Repo Roadmap
|
||||
|
||||
Planned layers for the public `Constant` repo:
|
||||
|
||||
- a real demoscene-style TUI
|
||||
- a central `hexapus` buddy rail
|
||||
- durable memory with vector + lexical search
|
||||
- mission summaries and decision graph browsing
|
||||
- richer repo context and instruction fusion
|
||||
- cleaner public fleet templates
|
||||
- screenshots, gifs, and a proper visual identity
|
||||
|
||||
## Repo Layout
|
||||
|
||||
```text
|
||||
constant/ Python orchestration core
|
||||
scripts/Constant canonical CLI entrypoint
|
||||
scripts/constant-machine.sh canonical single-machine cockpit entrypoint
|
||||
scripts/constant-fleet.sh canonical fleet cockpit entrypoint
|
||||
scripts/constant-fleet-install.sh
|
||||
scripts/ai-msg.sh local agent bus
|
||||
scripts/ai-bridge.sh inter-machine bridge
|
||||
```
|
||||
|
||||
## Current Caveats
|
||||
|
||||
- the shell runtime still keeps some historical compatibility wrappers
|
||||
- the public-facing TUI is still evolving
|
||||
- some setup paths and local assumptions are operator-oriented
|
||||
- `copilot` is manual-only in the current autonomous flow
|
||||
|
||||
That is acceptable for now.
|
||||
The system is being built in the open, from the terminal outward.
|
||||
|
||||
## Contributing
|
||||
|
||||
If you open this repo and think:
|
||||
|
||||
“this is a little unhinged, but technically serious”
|
||||
|
||||
then you probably understood the assignment.
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
__all__ = ["__version__"]
|
||||
|
||||
__version__ = "0.1.0"
|
||||
@@ -0,0 +1,5 @@
|
||||
from .cli import main
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
+477
@@ -0,0 +1,477 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from . import __version__
|
||||
from .daemon import daemon_status, request as daemon_request, serve_foreground, start_background, stop_background
|
||||
from .executors import bridge_sync, execute_step, fleet_check
|
||||
from .paths import cache_root, repo_root, scripts_dir
|
||||
from .state import (
|
||||
append_event,
|
||||
create_mission,
|
||||
first_active_step,
|
||||
fleet_machine,
|
||||
load_fleet_config,
|
||||
load_mission,
|
||||
load_models_config,
|
||||
list_missions,
|
||||
mission_events_file,
|
||||
save_mission,
|
||||
write_artifact,
|
||||
)
|
||||
|
||||
|
||||
def _print(data: Any, as_json: bool = False) -> None:
|
||||
if as_json:
|
||||
print(json.dumps(data, indent=2, sort_keys=True))
|
||||
elif isinstance(data, str):
|
||||
print(data)
|
||||
else:
|
||||
print(json.dumps(data, indent=2, sort_keys=True))
|
||||
|
||||
|
||||
def _command_exists(binary: str) -> bool:
|
||||
for path_dir in os.environ.get("PATH", "").split(":"):
|
||||
candidate = Path(path_dir) / binary
|
||||
if candidate.exists() and os.access(candidate, os.X_OK):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _ensure_planned(mission: dict[str, Any]) -> dict[str, Any]:
|
||||
if mission["steps"]:
|
||||
return mission
|
||||
|
||||
payload = daemon_request("plan", {"mission": mission})
|
||||
mission["steps"] = payload["plan"]["steps"]
|
||||
mission["title"] = payload["plan"]["title"]
|
||||
mission["status"] = "planned"
|
||||
mission["planner_summary"] = payload["plan"]["summary"]
|
||||
mission["buddy_review"] = payload["buddy_review"]
|
||||
save_mission(mission)
|
||||
append_event(mission["mission_id"], "mission.planned", payload)
|
||||
return mission
|
||||
|
||||
|
||||
def _mission_summary(mission: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
"mission_id": mission["mission_id"],
|
||||
"title": mission["title"],
|
||||
"status": mission["status"],
|
||||
"workspace": mission["workspace"],
|
||||
"steps": [
|
||||
{
|
||||
"step_id": step["step_id"],
|
||||
"status": step["status"],
|
||||
"machine": step["machine"],
|
||||
"backend": step["backend"],
|
||||
"cli": step["cli"],
|
||||
"agent": step["agent"],
|
||||
"attempt": step["attempt"],
|
||||
}
|
||||
for step in mission["steps"]
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def cmd_doctor(args: argparse.Namespace) -> int:
|
||||
status = daemon_status()
|
||||
health = daemon_request("health", auto_start=False)
|
||||
fleet = load_fleet_config()
|
||||
models = load_models_config()
|
||||
report = {
|
||||
"version": __version__,
|
||||
"repo_root": str(repo_root()),
|
||||
"cache_root": str(cache_root()),
|
||||
"commands": {
|
||||
"python3": _command_exists("python3"),
|
||||
"tmux": _command_exists("tmux"),
|
||||
"omc": _command_exists("omc"),
|
||||
"claude": _command_exists("claude"),
|
||||
"codex": _command_exists("codex"),
|
||||
"copilot": _command_exists("copilot"),
|
||||
"vibe": _command_exists("vibe"),
|
||||
"constant-fleet": (scripts_dir() / "constant-fleet.sh").exists() or (scripts_dir() / "zellij-ai-fleet.sh").exists(),
|
||||
"ai-bridge": (scripts_dir() / "ai-bridge.sh").exists(),
|
||||
},
|
||||
"daemon": status,
|
||||
"models": models,
|
||||
"health": health,
|
||||
"fleet": [{"label": entry["label"], "target": entry["target"]} for entry in fleet["machines"]],
|
||||
}
|
||||
_print(report, args.json)
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_daemon_start(_: argparse.Namespace) -> int:
|
||||
payload = start_background()
|
||||
if not payload.get("running"):
|
||||
payload["mode"] = "inline-fallback"
|
||||
_print(payload)
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_daemon_stop(_: argparse.Namespace) -> int:
|
||||
_print(stop_background())
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_daemon_status(args: argparse.Namespace) -> int:
|
||||
payload = {"status": daemon_status(), "health": daemon_request("health", auto_start=False)}
|
||||
if not payload["status"]["running"]:
|
||||
payload["mode"] = "inline-fallback"
|
||||
_print(payload, args.json)
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_daemon_logs(_: argparse.Namespace) -> int:
|
||||
from .paths import daemon_log_path
|
||||
|
||||
path = daemon_log_path()
|
||||
if not path.exists():
|
||||
print("No daemon log yet.")
|
||||
return 0
|
||||
print(path.read_text(encoding="utf-8"))
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_models_status(args: argparse.Namespace) -> int:
|
||||
payload = {"config": load_models_config(), "daemon": daemon_status(), "health": daemon_request("health", auto_start=False)}
|
||||
if not payload["daemon"]["running"]:
|
||||
payload["mode"] = "inline-fallback"
|
||||
_print(payload, args.json)
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_fleet_status(args: argparse.Namespace) -> int:
|
||||
payload = fleet_check()
|
||||
_print(payload, args.json)
|
||||
return 0 if payload["returncode"] == 0 else 1
|
||||
|
||||
|
||||
def cmd_fleet_sync(args: argparse.Namespace) -> int:
|
||||
payload = bridge_sync()
|
||||
_print(payload, args.json)
|
||||
return 0 if payload["returncode"] == 0 else 1
|
||||
|
||||
|
||||
def cmd_cockpit_open(args: argparse.Namespace) -> int:
|
||||
workspace = args.workspace or os.getcwd()
|
||||
command = [
|
||||
str((scripts_dir() / "constant-fleet.sh") if (scripts_dir() / "constant-fleet.sh").exists() else (scripts_dir() / "zellij-ai-fleet.sh")),
|
||||
"--workspace",
|
||||
workspace,
|
||||
"--local-session",
|
||||
args.local_session or "constant-fleet",
|
||||
"--session",
|
||||
args.session or "constant",
|
||||
]
|
||||
os.execv(command[0], command)
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_mission_create(args: argparse.Namespace) -> int:
|
||||
mission = create_mission(args.prompt, args.workspace)
|
||||
mission = _ensure_planned(mission)
|
||||
_print(_mission_summary(mission), args.json)
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_mission_plan(args: argparse.Namespace) -> int:
|
||||
mission = load_mission(args.mission_id)
|
||||
payload = daemon_request("plan", {"mission": mission})
|
||||
mission["steps"] = payload["plan"]["steps"]
|
||||
mission["title"] = payload["plan"]["title"]
|
||||
mission["status"] = "planned"
|
||||
mission["planner_summary"] = payload["plan"]["summary"]
|
||||
mission["buddy_review"] = payload["buddy_review"]
|
||||
save_mission(mission)
|
||||
append_event(mission["mission_id"], "mission.replanned", payload)
|
||||
_print({"summary": _mission_summary(mission), "buddy_review": payload["buddy_review"]}, args.json)
|
||||
return 0
|
||||
|
||||
|
||||
def _verify_and_update(mission: dict[str, Any], step: dict[str, Any], execution: dict[str, Any]) -> dict[str, Any]:
|
||||
verdict = daemon_request("verify", {"mission": mission, "step": step, "execution": execution})
|
||||
step["verification"] = verdict
|
||||
step["result_summary"] = verdict.get("summary", "")
|
||||
return verdict
|
||||
|
||||
|
||||
def cmd_mission_run(args: argparse.Namespace) -> int:
|
||||
mission = load_mission(args.mission_id)
|
||||
mission = _ensure_planned(mission)
|
||||
|
||||
step = first_active_step(mission)
|
||||
if step is None:
|
||||
mission["status"] = "done"
|
||||
save_mission(mission)
|
||||
_print(_mission_summary(mission), args.json)
|
||||
return 0
|
||||
|
||||
while step is not None:
|
||||
step["status"] = "running"
|
||||
step["attempt"] += 1
|
||||
mission["status"] = "running"
|
||||
save_mission(mission)
|
||||
append_event(mission["mission_id"], "step.started", {"step_id": step["step_id"], "machine": step["machine"], "backend": step["backend"], "cli": step["cli"]})
|
||||
|
||||
execution = execute_step(step, mission, load_fleet_config())
|
||||
artifact_path = write_artifact(
|
||||
mission["mission_id"],
|
||||
f"{step['step_id']}-attempt-{step['attempt']}.json",
|
||||
execution,
|
||||
)
|
||||
step["artifact_refs"].append(artifact_path)
|
||||
append_event(mission["mission_id"], "step.executed", {"step_id": step["step_id"], "artifact": artifact_path, "returncode": execution["returncode"]})
|
||||
|
||||
verdict = _verify_and_update(mission, step, execution)
|
||||
decision = verdict.get("decision", "failed")
|
||||
|
||||
if decision == "done":
|
||||
step["status"] = "done"
|
||||
elif decision == "retry" and step["attempt"] < 2:
|
||||
step["status"] = "pending"
|
||||
elif decision == "needs_human":
|
||||
step["status"] = "needs_human"
|
||||
mission["status"] = "needs_human"
|
||||
save_mission(mission)
|
||||
append_event(mission["mission_id"], "step.needs_human", {"step_id": step["step_id"], "summary": verdict.get("summary", "")})
|
||||
_print({"summary": _mission_summary(mission), "verdict": verdict}, args.json)
|
||||
return 1
|
||||
else:
|
||||
step["status"] = "failed"
|
||||
mission["status"] = "failed"
|
||||
save_mission(mission)
|
||||
append_event(mission["mission_id"], "step.failed", {"step_id": step["step_id"], "summary": verdict.get("summary", "")})
|
||||
_print({"summary": _mission_summary(mission), "verdict": verdict}, args.json)
|
||||
return 1
|
||||
|
||||
save_mission(mission)
|
||||
append_event(mission["mission_id"], "step.verified", {"step_id": step["step_id"], "verdict": verdict})
|
||||
step = first_active_step(mission)
|
||||
|
||||
mission["status"] = "done"
|
||||
save_mission(mission)
|
||||
append_event(mission["mission_id"], "mission.done", {"mission_id": mission["mission_id"]})
|
||||
_print(_mission_summary(mission), args.json)
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_mission_status(args: argparse.Namespace) -> int:
|
||||
if args.mission_id:
|
||||
mission = load_mission(args.mission_id)
|
||||
_print(_mission_summary(mission) if not args.verbose else mission, args.json)
|
||||
return 0
|
||||
|
||||
missions = [_mission_summary(mission) for mission in list_missions()]
|
||||
_print({"missions": missions}, args.json)
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_mission_tail(args: argparse.Namespace) -> int:
|
||||
path = mission_events_file(args.mission_id)
|
||||
if not path.exists():
|
||||
print(f"No events for mission {args.mission_id}")
|
||||
return 1
|
||||
|
||||
if not args.follow:
|
||||
print(path.read_text(encoding="utf-8"), end="")
|
||||
return 0
|
||||
|
||||
seen_size = 0
|
||||
while True:
|
||||
text = path.read_text(encoding="utf-8")
|
||||
if len(text) > seen_size:
|
||||
print(text[seen_size:], end="")
|
||||
seen_size = len(text)
|
||||
time.sleep(1)
|
||||
|
||||
|
||||
def cmd_mission_verify(args: argparse.Namespace) -> int:
|
||||
mission = load_mission(args.mission_id)
|
||||
step = next((entry for entry in mission["steps"] if entry["step_id"] == args.step_id), None) if args.step_id else (mission["steps"][-1] if mission["steps"] else None)
|
||||
if step is None or not step["artifact_refs"]:
|
||||
print("No step artifact available to verify.")
|
||||
return 1
|
||||
|
||||
artifact = json.loads(Path(step["artifact_refs"][-1]).read_text(encoding="utf-8"))
|
||||
verdict = _verify_and_update(mission, step, artifact)
|
||||
save_mission(mission)
|
||||
append_event(mission["mission_id"], "mission.verify", {"step_id": step["step_id"], "verdict": verdict})
|
||||
_print({"step_id": step["step_id"], "verdict": verdict}, args.json)
|
||||
return 0 if verdict.get("decision") == "done" else 1
|
||||
|
||||
|
||||
def cmd_mission_retry(args: argparse.Namespace) -> int:
|
||||
mission = load_mission(args.mission_id)
|
||||
step = next((entry for entry in mission["steps"] if entry["step_id"] == args.step_id), None) if args.step_id else first_active_step(mission) or (mission["steps"][-1] if mission["steps"] else None)
|
||||
if step is None:
|
||||
print("No step to retry.")
|
||||
return 1
|
||||
|
||||
step["status"] = "pending"
|
||||
mission["status"] = "planned"
|
||||
save_mission(mission)
|
||||
append_event(mission["mission_id"], "step.retry_requested", {"step_id": step["step_id"]})
|
||||
_print(_mission_summary(mission), args.json)
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_delegate(args: argparse.Namespace) -> int:
|
||||
mission = load_mission(args.mission_id)
|
||||
step = next((entry for entry in mission["steps"] if entry["step_id"] == args.step_id), None) if args.step_id else first_active_step(mission)
|
||||
if step is None:
|
||||
print("No active step to delegate.")
|
||||
return 1
|
||||
|
||||
if args.machine:
|
||||
fleet_machine(args.machine)
|
||||
step["machine"] = args.machine
|
||||
if args.backend:
|
||||
step["backend"] = args.backend
|
||||
if args.cli:
|
||||
step["cli"] = args.cli
|
||||
if args.agent:
|
||||
step["agent"] = args.agent
|
||||
step["status"] = "pending"
|
||||
mission["status"] = "planned"
|
||||
save_mission(mission)
|
||||
append_event(mission["mission_id"], "step.delegated", {"step_id": step["step_id"], "machine": step["machine"], "backend": step["backend"], "cli": step["cli"], "agent": step["agent"]})
|
||||
_print(_mission_summary(mission), args.json)
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_buddy_ask(args: argparse.Namespace) -> int:
|
||||
mission = load_mission(args.mission_id) if args.mission_id else None
|
||||
answer = daemon_request("buddy", {"mission": mission, "prompt": args.prompt})
|
||||
if mission:
|
||||
append_event(mission["mission_id"], "buddy.ask", {"prompt": args.prompt, "answer": answer})
|
||||
_print(answer, args.json)
|
||||
return 0
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(prog="Constant")
|
||||
parser.add_argument("-V", "--version", action="version", version=__version__)
|
||||
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
doctor = subparsers.add_parser("doctor")
|
||||
doctor.add_argument("--json", action="store_true")
|
||||
doctor.set_defaults(func=cmd_doctor)
|
||||
|
||||
daemon = subparsers.add_parser("daemon")
|
||||
daemon_sub = daemon.add_subparsers(dest="daemon_command", required=True)
|
||||
daemon_start = daemon_sub.add_parser("start")
|
||||
daemon_start.set_defaults(func=cmd_daemon_start)
|
||||
daemon_stop = daemon_sub.add_parser("stop")
|
||||
daemon_stop.set_defaults(func=cmd_daemon_stop)
|
||||
daemon_status_cmd = daemon_sub.add_parser("status")
|
||||
daemon_status_cmd.add_argument("--json", action="store_true")
|
||||
daemon_status_cmd.set_defaults(func=cmd_daemon_status)
|
||||
daemon_logs = daemon_sub.add_parser("logs")
|
||||
daemon_logs.set_defaults(func=cmd_daemon_logs)
|
||||
|
||||
models = subparsers.add_parser("models")
|
||||
models_sub = models.add_subparsers(dest="models_command", required=True)
|
||||
models_status = models_sub.add_parser("status")
|
||||
models_status.add_argument("--json", action="store_true")
|
||||
models_status.set_defaults(func=cmd_models_status)
|
||||
|
||||
fleet = subparsers.add_parser("fleet")
|
||||
fleet_sub = fleet.add_subparsers(dest="fleet_command", required=True)
|
||||
fleet_status_cmd = fleet_sub.add_parser("status")
|
||||
fleet_status_cmd.add_argument("--json", action="store_true")
|
||||
fleet_status_cmd.set_defaults(func=cmd_fleet_status)
|
||||
fleet_sync_cmd = fleet_sub.add_parser("sync")
|
||||
fleet_sync_cmd.add_argument("--json", action="store_true")
|
||||
fleet_sync_cmd.set_defaults(func=cmd_fleet_sync)
|
||||
|
||||
cockpit = subparsers.add_parser("cockpit")
|
||||
cockpit_sub = cockpit.add_subparsers(dest="cockpit_command", required=True)
|
||||
cockpit_open = cockpit_sub.add_parser("open")
|
||||
cockpit_open.add_argument("--workspace")
|
||||
cockpit_open.add_argument("--local-session")
|
||||
cockpit_open.add_argument("--session")
|
||||
cockpit_open.set_defaults(func=cmd_cockpit_open)
|
||||
|
||||
mission = subparsers.add_parser("mission")
|
||||
mission_sub = mission.add_subparsers(dest="mission_command", required=True)
|
||||
mission_create = mission_sub.add_parser("create")
|
||||
mission_create.add_argument("prompt")
|
||||
mission_create.add_argument("--workspace", default=os.getcwd())
|
||||
mission_create.add_argument("--json", action="store_true")
|
||||
mission_create.set_defaults(func=cmd_mission_create)
|
||||
mission_plan = mission_sub.add_parser("plan")
|
||||
mission_plan.add_argument("mission_id")
|
||||
mission_plan.add_argument("--json", action="store_true")
|
||||
mission_plan.set_defaults(func=cmd_mission_plan)
|
||||
mission_run = mission_sub.add_parser("run")
|
||||
mission_run.add_argument("mission_id")
|
||||
mission_run.add_argument("--json", action="store_true")
|
||||
mission_run.set_defaults(func=cmd_mission_run)
|
||||
mission_status = mission_sub.add_parser("status")
|
||||
mission_status.add_argument("mission_id", nargs="?")
|
||||
mission_status.add_argument("--verbose", action="store_true")
|
||||
mission_status.add_argument("--json", action="store_true")
|
||||
mission_status.set_defaults(func=cmd_mission_status)
|
||||
mission_tail = mission_sub.add_parser("tail")
|
||||
mission_tail.add_argument("mission_id")
|
||||
mission_tail.add_argument("--follow", action="store_true")
|
||||
mission_tail.set_defaults(func=cmd_mission_tail)
|
||||
mission_verify = mission_sub.add_parser("verify")
|
||||
mission_verify.add_argument("mission_id")
|
||||
mission_verify.add_argument("--step-id")
|
||||
mission_verify.add_argument("--json", action="store_true")
|
||||
mission_verify.set_defaults(func=cmd_mission_verify)
|
||||
mission_retry = mission_sub.add_parser("retry")
|
||||
mission_retry.add_argument("mission_id")
|
||||
mission_retry.add_argument("--step-id")
|
||||
mission_retry.add_argument("--json", action="store_true")
|
||||
mission_retry.set_defaults(func=cmd_mission_retry)
|
||||
|
||||
delegate = subparsers.add_parser("delegate")
|
||||
delegate.add_argument("mission_id")
|
||||
delegate.add_argument("--step-id")
|
||||
delegate.add_argument("--machine")
|
||||
delegate.add_argument("--backend")
|
||||
delegate.add_argument("--cli")
|
||||
delegate.add_argument("--agent")
|
||||
delegate.add_argument("--json", action="store_true")
|
||||
delegate.set_defaults(func=cmd_delegate)
|
||||
|
||||
buddy = subparsers.add_parser("buddy")
|
||||
buddy_sub = buddy.add_subparsers(dest="buddy_command", required=True)
|
||||
buddy_ask = buddy_sub.add_parser("ask")
|
||||
buddy_ask.add_argument("prompt")
|
||||
buddy_ask.add_argument("--mission-id")
|
||||
buddy_ask.add_argument("--json", action="store_true")
|
||||
buddy_ask.set_defaults(func=cmd_buddy_ask)
|
||||
|
||||
hidden = subparsers.add_parser("__serve")
|
||||
hidden.set_defaults(func=lambda _args: serve_foreground())
|
||||
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
if argv is None:
|
||||
argv = sys.argv[1:]
|
||||
if not argv:
|
||||
if sys.stdin.isatty() and sys.stdout.isatty():
|
||||
argv = ["cockpit", "open", "--workspace", os.getcwd()]
|
||||
else:
|
||||
argv = ["doctor"]
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(argv)
|
||||
return args.func(args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,222 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import signal
|
||||
import socket
|
||||
import socketserver
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from .paths import daemon_log_path, daemon_pid_path, daemon_port_path
|
||||
from .planner import PlannerEngine
|
||||
|
||||
|
||||
_INLINE_ENGINE = PlannerEngine()
|
||||
|
||||
|
||||
def _pid_is_alive(pid: int) -> bool:
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
except OSError:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def daemon_status() -> dict[str, Any]:
|
||||
pid_path = daemon_pid_path()
|
||||
port_path = daemon_port_path()
|
||||
pid = None
|
||||
running = False
|
||||
port = None
|
||||
|
||||
if pid_path.exists():
|
||||
try:
|
||||
pid = int(pid_path.read_text(encoding="utf-8").strip())
|
||||
running = _pid_is_alive(pid)
|
||||
except ValueError:
|
||||
pid = None
|
||||
|
||||
if port_path.exists():
|
||||
try:
|
||||
port = int(port_path.read_text(encoding="utf-8").strip())
|
||||
except ValueError:
|
||||
port = None
|
||||
|
||||
endpoint = f"tcp://127.0.0.1:{port}" if port else None
|
||||
return {
|
||||
"running": running,
|
||||
"pid": pid,
|
||||
"port": port,
|
||||
"endpoint": endpoint,
|
||||
"log": str(daemon_log_path()),
|
||||
}
|
||||
|
||||
|
||||
class _Handler(socketserver.StreamRequestHandler):
|
||||
engine = _INLINE_ENGINE
|
||||
|
||||
def handle(self) -> None:
|
||||
raw = self.rfile.readline()
|
||||
if not raw:
|
||||
return
|
||||
|
||||
try:
|
||||
request = json.loads(raw.decode("utf-8"))
|
||||
op = request["op"]
|
||||
if op == "health":
|
||||
payload = self.engine.health()
|
||||
elif op == "plan":
|
||||
payload = self.engine.plan_mission(request["mission"])
|
||||
elif op == "verify":
|
||||
payload = self.engine.verify_step(request["mission"], request["step"], request["execution"])
|
||||
elif op == "buddy":
|
||||
payload = self.engine.buddy_ask(request.get("mission"), request["prompt"])
|
||||
else:
|
||||
raise KeyError(f"Unsupported operation: {op}")
|
||||
response = {"ok": True, "payload": payload}
|
||||
except Exception as exc: # noqa: BLE001
|
||||
response = {"ok": False, "error": str(exc)}
|
||||
|
||||
self.wfile.write((json.dumps(response) + "\n").encode("utf-8"))
|
||||
|
||||
|
||||
class _TcpServer(socketserver.ThreadingMixIn, socketserver.TCPServer):
|
||||
daemon_threads = True
|
||||
allow_reuse_address = True
|
||||
|
||||
|
||||
def _write_port(port: int) -> None:
|
||||
daemon_port_path().write_text(str(port), encoding="utf-8")
|
||||
|
||||
|
||||
def _remove_port() -> None:
|
||||
port_path = daemon_port_path()
|
||||
if port_path.exists():
|
||||
port_path.unlink()
|
||||
|
||||
|
||||
def serve_foreground() -> int:
|
||||
pid_path = daemon_pid_path()
|
||||
log_path = daemon_log_path()
|
||||
port_path = daemon_port_path()
|
||||
port_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
log_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
_remove_port()
|
||||
server = _TcpServer(("127.0.0.1", 0), _Handler)
|
||||
_write_port(int(server.server_address[1]))
|
||||
pid_path.write_text(str(os.getpid()), encoding="utf-8")
|
||||
|
||||
def _shutdown(*_: object) -> None:
|
||||
thread = threading.Thread(target=server.shutdown, daemon=True)
|
||||
thread.start()
|
||||
|
||||
signal.signal(signal.SIGTERM, _shutdown)
|
||||
signal.signal(signal.SIGINT, _shutdown)
|
||||
|
||||
try:
|
||||
server.serve_forever()
|
||||
finally:
|
||||
server.server_close()
|
||||
_remove_port()
|
||||
if pid_path.exists():
|
||||
pid_path.unlink()
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
def start_background() -> dict[str, Any]:
|
||||
status = daemon_status()
|
||||
if status["running"]:
|
||||
return status
|
||||
|
||||
log_path = Path(status["log"])
|
||||
log_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with log_path.open("a", encoding="utf-8") as handle:
|
||||
process = subprocess.Popen(
|
||||
[sys.executable, "-m", "constant.cli", "__serve"],
|
||||
stdout=handle,
|
||||
stderr=handle,
|
||||
start_new_session=True,
|
||||
)
|
||||
|
||||
for _ in range(50):
|
||||
status = daemon_status()
|
||||
if status["running"] and status["port"]:
|
||||
return status
|
||||
time.sleep(0.1)
|
||||
|
||||
status = daemon_status()
|
||||
status["mode"] = "inline-fallback"
|
||||
return status
|
||||
|
||||
|
||||
def stop_background() -> dict[str, Any]:
|
||||
status = daemon_status()
|
||||
if not status["running"] or not status["pid"]:
|
||||
return status
|
||||
|
||||
os.kill(status["pid"], signal.SIGTERM)
|
||||
for _ in range(50):
|
||||
next_status = daemon_status()
|
||||
if not next_status["running"]:
|
||||
return next_status
|
||||
time.sleep(0.1)
|
||||
return daemon_status()
|
||||
|
||||
|
||||
def _direct_request(op: str, payload: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
request_payload = payload or {}
|
||||
if op == "health":
|
||||
return _INLINE_ENGINE.health()
|
||||
if op == "plan":
|
||||
return _INLINE_ENGINE.plan_mission(request_payload["mission"])
|
||||
if op == "verify":
|
||||
return _INLINE_ENGINE.verify_step(
|
||||
request_payload["mission"],
|
||||
request_payload["step"],
|
||||
request_payload["execution"],
|
||||
)
|
||||
if op == "buddy":
|
||||
return _INLINE_ENGINE.buddy_ask(
|
||||
request_payload.get("mission"),
|
||||
request_payload["prompt"],
|
||||
)
|
||||
raise RuntimeError(f"Unsupported operation: {op}")
|
||||
|
||||
|
||||
def request(op: str, payload: dict[str, Any] | None = None, auto_start: bool = True) -> dict[str, Any]:
|
||||
if auto_start and not daemon_status()["running"]:
|
||||
start_background()
|
||||
|
||||
status = daemon_status()
|
||||
if not status["running"] or not status["port"]:
|
||||
return _direct_request(op, payload)
|
||||
|
||||
try:
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
sock.connect(("127.0.0.1", int(status["port"])))
|
||||
with sock:
|
||||
message = {"op": op}
|
||||
if payload:
|
||||
message.update(payload)
|
||||
sock.sendall((json.dumps(message) + "\n").encode("utf-8"))
|
||||
raw = b""
|
||||
while not raw.endswith(b"\n"):
|
||||
chunk = sock.recv(65536)
|
||||
if not chunk:
|
||||
break
|
||||
raw += chunk
|
||||
except OSError:
|
||||
return _direct_request(op, payload)
|
||||
|
||||
response = json.loads(raw.decode("utf-8"))
|
||||
if not response.get("ok"):
|
||||
raise RuntimeError(response.get("error", "daemon request failed"))
|
||||
return response["payload"]
|
||||
@@ -0,0 +1,140 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import shlex
|
||||
import subprocess
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from .paths import repo_root, scripts_dir
|
||||
from .state import fleet_machine
|
||||
|
||||
|
||||
def _run_command(args: list[str], cwd: str | None = None) -> dict[str, Any]:
|
||||
started = time.time()
|
||||
process = subprocess.run(args, capture_output=True, text=True, cwd=cwd)
|
||||
return {
|
||||
"argv": args,
|
||||
"returncode": process.returncode,
|
||||
"stdout": process.stdout,
|
||||
"stderr": process.stderr,
|
||||
"duration_s": round(time.time() - started, 3),
|
||||
}
|
||||
|
||||
|
||||
def _local_agent_path_export() -> str:
|
||||
return 'export PATH="$HOME/.local/bin:$HOME/.npm-global/bin:$PATH";'
|
||||
|
||||
|
||||
def _claude_command(prompt: str) -> list[str]:
|
||||
return ["claude", "-p", "--output-format", "json", "--permission-mode", "acceptEdits", prompt]
|
||||
|
||||
|
||||
def _codex_command(prompt: str, workspace: str) -> list[str]:
|
||||
return ["codex", "exec", "--json", "--full-auto", "--skip-git-repo-check", "-C", workspace, prompt]
|
||||
|
||||
|
||||
def _vibe_command(prompt: str, workspace: str) -> list[str]:
|
||||
return ["vibe", "-p", prompt, "--output", "json", "--workdir", workspace]
|
||||
|
||||
|
||||
def _omc_command(cli: str, prompt: str) -> list[str]:
|
||||
if cli not in {"claude", "codex"}:
|
||||
raise RuntimeError(f"OMC backend only supports claude/codex in v1, not {cli}")
|
||||
return ["omc", "ask", cli, "--print", prompt]
|
||||
|
||||
|
||||
def build_local_command(step: dict[str, Any], workspace: str) -> list[str]:
|
||||
cli = step["cli"]
|
||||
prompt = step["prompt"]
|
||||
backend = step["backend"]
|
||||
|
||||
if backend == "omc":
|
||||
return _omc_command(cli, prompt)
|
||||
if cli == "claude":
|
||||
return _claude_command(prompt)
|
||||
if cli == "codex":
|
||||
return _codex_command(prompt, workspace)
|
||||
if cli == "vibe":
|
||||
return _vibe_command(prompt, workspace)
|
||||
raise RuntimeError(f"Unsupported auto CLI: {cli}")
|
||||
|
||||
|
||||
def execute_step(step: dict[str, Any], mission: dict[str, Any], fleet: dict[str, Any]) -> dict[str, Any]:
|
||||
workspace = mission["workspace"]
|
||||
machine = fleet_machine(step["machine"])
|
||||
backend = step["backend"]
|
||||
|
||||
if step["cli"] == "copilot":
|
||||
return {
|
||||
"argv": [],
|
||||
"returncode": 1,
|
||||
"stdout": "",
|
||||
"stderr": "copilot is manual-only in Constant v1",
|
||||
"duration_s": 0.0,
|
||||
}
|
||||
|
||||
if backend == "zellij":
|
||||
cockpit = str((scripts_dir() / "constant-fleet.sh") if (scripts_dir() / "constant-fleet.sh").exists() else (scripts_dir() / "zellij-ai-fleet.sh"))
|
||||
return {
|
||||
"argv": [cockpit, "--workspace", workspace],
|
||||
"returncode": 0,
|
||||
"stdout": f"Open cockpit manually with: {cockpit} --workspace {workspace}",
|
||||
"stderr": "",
|
||||
"duration_s": 0.0,
|
||||
}
|
||||
|
||||
command = build_local_command(step, workspace)
|
||||
|
||||
if backend == "cli-local" or backend == "omc":
|
||||
return _run_command(command, cwd=workspace)
|
||||
|
||||
if backend == "cli-ssh":
|
||||
quoted = shlex.join(command)
|
||||
remote_shell = f'{_local_agent_path_export()} cd {shlex.quote(workspace)} && {quoted}'
|
||||
return _run_command(["ssh", machine["target"], "bash", "-lc", remote_shell])
|
||||
|
||||
raise RuntimeError(f"Unsupported backend: {backend}")
|
||||
|
||||
|
||||
def fleet_check() -> dict[str, Any]:
|
||||
check_script = repo_root() / "scripts" / "constant-fleet-install.sh"
|
||||
if not check_script.exists():
|
||||
check_script = repo_root() / "scripts" / "zellij-ai-fleet-install.sh"
|
||||
process = subprocess.run([str(check_script), "check"], capture_output=True, text=True)
|
||||
|
||||
machines: list[dict[str, Any]] = []
|
||||
current: dict[str, Any] | None = None
|
||||
|
||||
for raw_line in process.stdout.splitlines():
|
||||
line = raw_line.strip()
|
||||
if not line:
|
||||
continue
|
||||
if line.startswith("===") and "(" in line:
|
||||
if current:
|
||||
machines.append(current)
|
||||
label = line.split()[1]
|
||||
current = {"label": label}
|
||||
continue
|
||||
if "=" in line and current is not None:
|
||||
key, value = line.split("=", 1)
|
||||
current[key] = value
|
||||
|
||||
if current:
|
||||
machines.append(current)
|
||||
|
||||
return {
|
||||
"returncode": process.returncode,
|
||||
"machines": machines,
|
||||
"stderr": process.stderr,
|
||||
}
|
||||
|
||||
|
||||
def bridge_sync() -> dict[str, Any]:
|
||||
bridge_script = repo_root() / "scripts" / "ai-bridge.sh"
|
||||
process = subprocess.run([str(bridge_script), "sync"], capture_output=True, text=True)
|
||||
return {
|
||||
"returncode": process.returncode,
|
||||
"stdout": process.stdout,
|
||||
"stderr": process.stderr,
|
||||
}
|
||||
+1097
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,71 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def repo_root() -> Path:
|
||||
return Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def scripts_dir() -> Path:
|
||||
return repo_root() / "scripts"
|
||||
|
||||
|
||||
def cache_root() -> Path:
|
||||
return Path.home() / ".cache" / "constant"
|
||||
|
||||
|
||||
def config_root() -> Path:
|
||||
return Path.home() / ".config" / "constant"
|
||||
|
||||
|
||||
def data_root() -> Path:
|
||||
return Path.home() / ".local" / "share" / "constant"
|
||||
|
||||
|
||||
def planner_dir() -> Path:
|
||||
return cache_root() / "planner"
|
||||
|
||||
|
||||
def missions_dir() -> Path:
|
||||
return cache_root() / "missions"
|
||||
|
||||
|
||||
def fleet_config_path() -> Path:
|
||||
return config_root() / "fleet.yaml"
|
||||
|
||||
|
||||
def models_config_path() -> Path:
|
||||
return config_root() / "models.yaml"
|
||||
|
||||
|
||||
def memory_config_path() -> Path:
|
||||
return config_root() / "memory.yaml"
|
||||
|
||||
|
||||
def daemon_pid_path() -> Path:
|
||||
return planner_dir() / "daemon.pid"
|
||||
|
||||
|
||||
def daemon_log_path() -> Path:
|
||||
return planner_dir() / "daemon.log"
|
||||
|
||||
|
||||
def daemon_port_path() -> Path:
|
||||
return planner_dir() / "daemon.port"
|
||||
|
||||
|
||||
def memory_store_path() -> Path:
|
||||
return data_root() / "memory.sqlite"
|
||||
|
||||
|
||||
def persona_path() -> Path:
|
||||
return data_root() / "persona.md"
|
||||
|
||||
|
||||
def indexes_dir() -> Path:
|
||||
return data_root() / "indexes"
|
||||
|
||||
|
||||
def memory_sources_dir() -> Path:
|
||||
return data_root() / "sources"
|
||||
@@ -0,0 +1,407 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import re
|
||||
import threading
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from .state import load_fleet_config, load_models_config
|
||||
|
||||
|
||||
def _fleet_labels() -> dict[str, str]:
|
||||
fleet = load_fleet_config()
|
||||
labels = [machine["label"] for machine in fleet["machines"]]
|
||||
local_machine = fleet.get("local_machine", labels[0] if labels else "command-center")
|
||||
return {
|
||||
"local": local_machine,
|
||||
"builder_a": labels[1] if len(labels) > 1 else "builder-a",
|
||||
"builder_b": labels[2] if len(labels) > 2 else "builder-b",
|
||||
"edge_a": labels[3] if len(labels) > 3 else "edge-a",
|
||||
"lab_a": labels[4] if len(labels) > 4 else "lab-a",
|
||||
}
|
||||
|
||||
|
||||
def _strip_code_fences(text: str) -> str:
|
||||
stripped = text.strip()
|
||||
if stripped.startswith("```"):
|
||||
stripped = re.sub(r"^```[a-zA-Z0-9_-]*\n", "", stripped)
|
||||
stripped = re.sub(r"\n```$", "", stripped)
|
||||
return stripped.strip()
|
||||
|
||||
|
||||
def _extract_json(text: str) -> dict[str, Any]:
|
||||
stripped = _strip_code_fences(text)
|
||||
try:
|
||||
return json.loads(stripped)
|
||||
except json.JSONDecodeError:
|
||||
start = stripped.find("{")
|
||||
end = stripped.rfind("}")
|
||||
if start >= 0 and end > start:
|
||||
return json.loads(stripped[start : end + 1])
|
||||
raise
|
||||
|
||||
|
||||
def _lower(value: str) -> str:
|
||||
return value.lower()
|
||||
|
||||
|
||||
def _route_machine(goal: str) -> str:
|
||||
labels = _fleet_labels()
|
||||
goal_l = _lower(goal)
|
||||
if any(token in goal_l for token in ("ssh", "shell", "fleet", "ops", "network", "infra")):
|
||||
return labels["edge_a"]
|
||||
if any(token in goal_l for token in ("refactor", "performance", "deep", "cuda", "compiler", "benchmark")):
|
||||
return labels["builder_b"]
|
||||
if any(token in goal_l for token in ("review", "audit", "test", "qa", "docs")):
|
||||
return labels["builder_a"]
|
||||
if any(token in goal_l for token in ("experiment", "prototype", "sandbox", "branch")):
|
||||
return labels["lab_a"]
|
||||
return labels["local"]
|
||||
|
||||
|
||||
def _route_cli(goal: str) -> str:
|
||||
goal_l = _lower(goal)
|
||||
if any(token in goal_l for token in ("brainstorm", "idea", "alternative", "explore", "compare")):
|
||||
return "vibe"
|
||||
if any(token in goal_l for token in ("spec", "summary", "summarize", "review", "docs")):
|
||||
return "claude"
|
||||
return "codex"
|
||||
|
||||
|
||||
def _route_backend(machine: str, cli: str, goal: str) -> str:
|
||||
local_machine = _fleet_labels()["local"]
|
||||
goal_l = _lower(goal)
|
||||
if machine == local_machine and cli in {"claude", "codex"} and any(token in goal_l for token in ("parallel", "team", "multi-agent", "compare")):
|
||||
return "omc"
|
||||
if machine == local_machine:
|
||||
return "cli-local"
|
||||
return "cli-ssh"
|
||||
|
||||
|
||||
def _route_agent(cli: str) -> str:
|
||||
return {
|
||||
"claude": "planner",
|
||||
"codex": "executor",
|
||||
"vibe": "analyst",
|
||||
"copilot": "assistant",
|
||||
}.get(cli, "executor")
|
||||
|
||||
|
||||
def _heuristic_plan(goal: str, workspace: str, mission_id: str) -> dict[str, Any]:
|
||||
machine = _route_machine(goal)
|
||||
cli = _route_cli(goal)
|
||||
backend = _route_backend(machine, cli, goal)
|
||||
return {
|
||||
"title": goal.strip().splitlines()[0][:80] or mission_id,
|
||||
"summary": f"Route the mission to {machine} using {cli} via {backend}.",
|
||||
"steps": [
|
||||
{
|
||||
"step_id": "step-1",
|
||||
"kind": "task",
|
||||
"title": f"Execute mission on {machine}",
|
||||
"prompt": goal,
|
||||
"machine": machine,
|
||||
"backend": backend,
|
||||
"cli": cli,
|
||||
"agent": _route_agent(cli),
|
||||
"status": "pending",
|
||||
"attempt": 0,
|
||||
"depends_on": [],
|
||||
"llama_plan": "heuristic planner fallback",
|
||||
"qwen_review": "",
|
||||
"result_summary": "",
|
||||
"artifact_refs": [],
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _heuristic_buddy_review(goal: str, plan: dict[str, Any]) -> dict[str, Any]:
|
||||
step = plan["steps"][0]
|
||||
cli = step["cli"]
|
||||
machine = step["machine"]
|
||||
local_machine = _fleet_labels()["local"]
|
||||
suggestions: list[str] = []
|
||||
agrees = True
|
||||
|
||||
if cli == "copilot":
|
||||
agrees = False
|
||||
suggestions.append("copilot is manual-only in v1; use codex or claude instead")
|
||||
if any(token in _lower(goal) for token in ("fix", "implement", "refactor", "bug")) and cli != "codex":
|
||||
agrees = False
|
||||
suggestions.append("technical execution is better routed to codex")
|
||||
if machine == local_machine and step["backend"] == "cli-ssh":
|
||||
agrees = False
|
||||
suggestions.append("local machine should use cli-local or omc, not cli-ssh")
|
||||
|
||||
summary = "Buddy review agrees with the plan." if agrees else "; ".join(suggestions)
|
||||
return {
|
||||
"agrees": agrees,
|
||||
"summary": summary,
|
||||
"suggested_cli": "codex" if any("codex" in entry for entry in suggestions) else None,
|
||||
"suggested_backend": "cli-local" if machine == local_machine and step["backend"] == "cli-ssh" else None,
|
||||
}
|
||||
|
||||
|
||||
def _heuristic_verify(step: dict[str, Any], execution: dict[str, Any]) -> dict[str, Any]:
|
||||
stdout = execution.get("stdout", "")
|
||||
stderr = execution.get("stderr", "")
|
||||
combined = f"{stdout}\n{stderr}".lower()
|
||||
return_code = execution.get("returncode", 1)
|
||||
|
||||
if any(token in combined for token in ("not logged in", "/login", "device-auth", "device auth", "authentication required", "please login", "please log in")):
|
||||
return {
|
||||
"decision": "needs_human",
|
||||
"summary": "The CLI needs an interactive login or credentials refresh.",
|
||||
"confidence": "high",
|
||||
}
|
||||
|
||||
if return_code != 0:
|
||||
if step.get("attempt", 0) <= 1:
|
||||
return {
|
||||
"decision": "retry",
|
||||
"summary": "Execution failed once; retry is reasonable.",
|
||||
"confidence": "medium",
|
||||
}
|
||||
return {
|
||||
"decision": "failed",
|
||||
"summary": "Execution failed after retry budget was exhausted.",
|
||||
"confidence": "high",
|
||||
}
|
||||
|
||||
if "error" in combined and "success" not in combined:
|
||||
return {
|
||||
"decision": "needs_human",
|
||||
"summary": "Output contains an error marker despite zero exit status.",
|
||||
"confidence": "medium",
|
||||
}
|
||||
|
||||
return {
|
||||
"decision": "done",
|
||||
"summary": "Execution completed successfully.",
|
||||
"confidence": "medium",
|
||||
}
|
||||
|
||||
|
||||
def _heuristic_buddy_answer(prompt: str, mission: dict[str, Any] | None) -> dict[str, Any]:
|
||||
title = mission["title"] if mission else "mission"
|
||||
return {
|
||||
"answer": f"Qwen buddy heuristic view for {title}: focus on route correctness, CLI fit, and whether codex should own the technical step. Prompt: {prompt}",
|
||||
"mode": "heuristic",
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class ModelHealth:
|
||||
role: str
|
||||
model_id: str
|
||||
available: bool
|
||||
loaded: bool
|
||||
backend: str
|
||||
|
||||
|
||||
class PlannerEngine:
|
||||
def __init__(self) -> None:
|
||||
self._models = load_models_config()
|
||||
self._loaded: dict[str, tuple[Any, Any]] = {}
|
||||
self._lock = threading.Lock()
|
||||
self._mlx_python = importlib.util.find_spec("mlx_lm") is not None
|
||||
|
||||
def health(self) -> dict[str, Any]:
|
||||
health = {}
|
||||
for role in ("planner", "buddy", "verify"):
|
||||
spec = self._models[role]
|
||||
health[role] = ModelHealth(
|
||||
role=role,
|
||||
model_id=spec["model_id"],
|
||||
available=self._mlx_python,
|
||||
loaded=role in self._loaded,
|
||||
backend="mlx-python" if self._mlx_python else "heuristic",
|
||||
).__dict__
|
||||
return {
|
||||
"mlx_python": self._mlx_python,
|
||||
"models": health,
|
||||
"fallback_mode": self._models.get("fallback_mode", "heuristic"),
|
||||
}
|
||||
|
||||
def _load_model(self, role: str) -> tuple[Any, Any]:
|
||||
if role in self._loaded:
|
||||
return self._loaded[role]
|
||||
|
||||
if not self._mlx_python:
|
||||
raise RuntimeError("mlx_lm is not available")
|
||||
|
||||
with self._lock:
|
||||
if role in self._loaded:
|
||||
return self._loaded[role]
|
||||
from mlx_lm import load # type: ignore
|
||||
|
||||
model_id = self._models[role]["model_id"]
|
||||
model, tokenizer = load(model_id)
|
||||
self._loaded[role] = (model, tokenizer)
|
||||
return model, tokenizer
|
||||
|
||||
def _call_model_json(self, role: str, system_prompt: str, user_prompt: str) -> dict[str, Any]:
|
||||
if not self._mlx_python:
|
||||
raise RuntimeError("mlx_lm is not available")
|
||||
|
||||
model, tokenizer = self._load_model(role)
|
||||
from mlx_lm import generate # type: ignore
|
||||
|
||||
messages = [
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": user_prompt},
|
||||
]
|
||||
prompt = tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=True)
|
||||
text = generate(
|
||||
model,
|
||||
tokenizer,
|
||||
prompt=prompt,
|
||||
max_tokens=self._models[role]["max_tokens"],
|
||||
verbose=False,
|
||||
)
|
||||
return _extract_json(text)
|
||||
|
||||
def _call_model_text(self, role: str, system_prompt: str, user_prompt: str) -> str:
|
||||
if not self._mlx_python:
|
||||
raise RuntimeError("mlx_lm is not available")
|
||||
|
||||
model, tokenizer = self._load_model(role)
|
||||
from mlx_lm import generate # type: ignore
|
||||
|
||||
messages = [
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": user_prompt},
|
||||
]
|
||||
prompt = tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=True)
|
||||
return generate(
|
||||
model,
|
||||
tokenizer,
|
||||
prompt=prompt,
|
||||
max_tokens=self._models[role]["max_tokens"],
|
||||
verbose=False,
|
||||
).strip()
|
||||
|
||||
def plan_mission(self, mission: dict[str, Any]) -> dict[str, Any]:
|
||||
plan = _heuristic_plan(mission["goal"], mission["workspace"], mission["mission_id"])
|
||||
review = _heuristic_buddy_review(mission["goal"], plan)
|
||||
fleet = load_fleet_config()
|
||||
machine_labels = [machine["label"] for machine in fleet["machines"]]
|
||||
local_machine = fleet.get("local_machine", machine_labels[0] if machine_labels else "command-center")
|
||||
|
||||
if self._mlx_python:
|
||||
system = (
|
||||
"You are Llama, the main orchestrator for a 5-machine coding fleet. "
|
||||
"Return strict JSON with keys: title, summary, steps. "
|
||||
"Each step must include: step_id, kind, title, prompt, machine, backend, cli, agent, depends_on."
|
||||
)
|
||||
user = json.dumps(
|
||||
{
|
||||
"mission_id": mission["mission_id"],
|
||||
"goal": mission["goal"],
|
||||
"workspace": mission["workspace"],
|
||||
"machines": machine_labels,
|
||||
"allowed_backends": ["omc", "cli-local", "cli-ssh", "zellij"],
|
||||
"allowed_clis": ["claude", "codex", "vibe"],
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
try:
|
||||
llama_plan = self._call_model_json("planner", system, user)
|
||||
if "steps" in llama_plan and llama_plan["steps"]:
|
||||
plan = {
|
||||
"title": llama_plan.get("title", plan["title"]),
|
||||
"summary": llama_plan.get("summary", plan["summary"]),
|
||||
"steps": [],
|
||||
}
|
||||
for index, step in enumerate(llama_plan["steps"], start=1):
|
||||
plan["steps"].append(
|
||||
{
|
||||
"step_id": step.get("step_id", f"step-{index}"),
|
||||
"kind": step.get("kind", "task"),
|
||||
"title": step.get("title", f"Step {index}"),
|
||||
"prompt": step.get("prompt", mission["goal"]),
|
||||
"machine": step.get("machine", _route_machine(mission["goal"])),
|
||||
"backend": step.get("backend", _route_backend(step.get("machine", local_machine), step.get("cli", "codex"), mission["goal"])),
|
||||
"cli": step.get("cli", _route_cli(mission["goal"])),
|
||||
"agent": step.get("agent", _route_agent(step.get("cli", "codex"))),
|
||||
"status": "pending",
|
||||
"attempt": 0,
|
||||
"depends_on": step.get("depends_on", []),
|
||||
"llama_plan": llama_plan.get("summary", ""),
|
||||
"qwen_review": "",
|
||||
"result_summary": "",
|
||||
"artifact_refs": [],
|
||||
}
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
buddy_system = (
|
||||
"You are Qwen, the local technical buddy. Return strict JSON with keys: "
|
||||
"agrees, summary, suggested_cli, suggested_backend."
|
||||
)
|
||||
buddy_user = json.dumps({"goal": mission["goal"], "plan": plan}, indent=2)
|
||||
try:
|
||||
review = self._call_model_json("buddy", buddy_system, buddy_user)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
for step in plan["steps"]:
|
||||
step["qwen_review"] = review.get("summary", "")
|
||||
if not review.get("agrees", True):
|
||||
if review.get("suggested_cli") and step["cli"] == "claude":
|
||||
step["cli"] = review["suggested_cli"]
|
||||
step["agent"] = _route_agent(step["cli"])
|
||||
if review.get("suggested_backend"):
|
||||
step["backend"] = review["suggested_backend"]
|
||||
|
||||
return {
|
||||
"plan": plan,
|
||||
"buddy_review": review,
|
||||
}
|
||||
|
||||
def verify_step(self, mission: dict[str, Any], step: dict[str, Any], execution: dict[str, Any]) -> dict[str, Any]:
|
||||
result = _heuristic_verify(step, execution)
|
||||
|
||||
if self._mlx_python:
|
||||
system = (
|
||||
"You are the orchestrator verifier. Return strict JSON with keys: "
|
||||
"decision, summary, confidence. Decisions: done, retry, failed, needs_human."
|
||||
)
|
||||
user = json.dumps(
|
||||
{
|
||||
"mission_title": mission["title"],
|
||||
"step": step,
|
||||
"execution": {
|
||||
"returncode": execution.get("returncode"),
|
||||
"stdout_tail": execution.get("stdout", "")[-4000:],
|
||||
"stderr_tail": execution.get("stderr", "")[-4000:],
|
||||
},
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
try:
|
||||
result = self._call_model_json("verify", system, user)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return result
|
||||
|
||||
def buddy_ask(self, mission: dict[str, Any] | None, prompt: str) -> dict[str, Any]:
|
||||
result = _heuristic_buddy_answer(prompt, mission)
|
||||
|
||||
if self._mlx_python:
|
||||
system = "You are Qwen, the local buddy. Give a concise technical answer."
|
||||
user = json.dumps({"mission": mission, "prompt": prompt}, indent=2)
|
||||
try:
|
||||
result = {
|
||||
"answer": self._call_model_text("buddy", system, user),
|
||||
"mode": "mlx",
|
||||
}
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return result
|
||||
@@ -0,0 +1,244 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from copy import deepcopy
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from . import __version__
|
||||
from .paths import (
|
||||
cache_root,
|
||||
config_root,
|
||||
data_root,
|
||||
fleet_config_path,
|
||||
indexes_dir,
|
||||
memory_config_path,
|
||||
memory_sources_dir,
|
||||
missions_dir,
|
||||
models_config_path,
|
||||
)
|
||||
|
||||
|
||||
DEFAULT_FLEET: dict[str, Any] = {
|
||||
"version": 1,
|
||||
"local_machine": "command-center",
|
||||
"machines": [
|
||||
{
|
||||
"label": "command-center",
|
||||
"target": "local",
|
||||
"auto_clis": ["codex", "vibe", "claude"],
|
||||
"manual_clis": ["copilot"],
|
||||
"backends": ["omc", "cli-local", "zellij"],
|
||||
},
|
||||
{
|
||||
"label": "builder-a",
|
||||
"target": "dev@builder-a",
|
||||
"auto_clis": ["codex", "vibe", "claude"],
|
||||
"manual_clis": ["copilot"],
|
||||
"backends": ["cli-ssh", "zellij"],
|
||||
},
|
||||
{
|
||||
"label": "builder-b",
|
||||
"target": "dev@builder-b",
|
||||
"auto_clis": ["codex", "vibe", "claude"],
|
||||
"manual_clis": ["copilot"],
|
||||
"backends": ["cli-ssh", "zellij"],
|
||||
},
|
||||
{
|
||||
"label": "edge-a",
|
||||
"target": "dev@edge-a",
|
||||
"auto_clis": ["codex", "vibe", "claude"],
|
||||
"manual_clis": ["copilot"],
|
||||
"backends": ["cli-ssh", "zellij"],
|
||||
},
|
||||
{
|
||||
"label": "lab-a",
|
||||
"target": "dev@lab-a",
|
||||
"auto_clis": ["codex", "vibe", "claude"],
|
||||
"manual_clis": ["copilot"],
|
||||
"backends": ["cli-ssh", "zellij"],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
DEFAULT_MODELS: dict[str, Any] = {
|
||||
"version": 1,
|
||||
"planner": {
|
||||
"role": "planner",
|
||||
"model_id": "mlx-community-staging/Llama-3.2-3B-Instruct-mlx-4Bit",
|
||||
"max_tokens": 900,
|
||||
},
|
||||
"buddy": {
|
||||
"role": "buddy",
|
||||
"model_id": "mlx-community/Qwen2.5-Coder-3B-Instruct-4bit",
|
||||
"max_tokens": 900,
|
||||
},
|
||||
"verify": {
|
||||
"role": "verify",
|
||||
"model_id": "mlx-community-staging/Llama-3.2-3B-Instruct-mlx-4Bit",
|
||||
"max_tokens": 700,
|
||||
},
|
||||
"fallback_mode": "heuristic",
|
||||
}
|
||||
|
||||
|
||||
DEFAULT_MEMORY: dict[str, Any] = {
|
||||
"version": 1,
|
||||
"local_store_path": str(data_root() / "memory.sqlite"),
|
||||
"qdrant_url": "",
|
||||
"qdrant_collection": "constant_memory",
|
||||
"workspace_enrollments": [],
|
||||
"instruction_weights": {
|
||||
"workspace": 1.0,
|
||||
"repo": 0.85,
|
||||
"ancestor": 0.65,
|
||||
"user": 0.45,
|
||||
"default": 0.2,
|
||||
},
|
||||
"max_chunks_per_query": 8,
|
||||
"vector_dimensions": 96,
|
||||
}
|
||||
|
||||
|
||||
def now_utc() -> str:
|
||||
return datetime.now(UTC).replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
||||
|
||||
|
||||
def ensure_runtime_dirs() -> None:
|
||||
for path in (cache_root(), config_root(), data_root(), missions_dir(), indexes_dir(), memory_sources_dir()):
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
def _read_json_yaml(path: Path, default: dict[str, Any]) -> dict[str, Any]:
|
||||
ensure_runtime_dirs()
|
||||
if not path.exists():
|
||||
_write_json_yaml(path, default)
|
||||
return deepcopy(default)
|
||||
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def _write_json_yaml(path: Path, payload: dict[str, Any]) -> None:
|
||||
ensure_runtime_dirs()
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
def load_fleet_config() -> dict[str, Any]:
|
||||
return _read_json_yaml(fleet_config_path(), DEFAULT_FLEET)
|
||||
|
||||
|
||||
def load_models_config() -> dict[str, Any]:
|
||||
return _read_json_yaml(models_config_path(), DEFAULT_MODELS)
|
||||
|
||||
|
||||
def load_memory_config() -> dict[str, Any]:
|
||||
return _read_json_yaml(memory_config_path(), DEFAULT_MEMORY)
|
||||
|
||||
|
||||
def save_memory_config(payload: dict[str, Any]) -> None:
|
||||
_write_json_yaml(memory_config_path(), payload)
|
||||
|
||||
|
||||
def fleet_machine(label: str) -> dict[str, Any]:
|
||||
fleet = load_fleet_config()
|
||||
for machine in fleet["machines"]:
|
||||
if machine["label"] == label or machine["target"] == label:
|
||||
return machine
|
||||
raise KeyError(f"Unknown machine: {label}")
|
||||
|
||||
|
||||
def mission_dir(mission_id: str) -> Path:
|
||||
return missions_dir() / mission_id
|
||||
|
||||
|
||||
def mission_file(mission_id: str) -> Path:
|
||||
return mission_dir(mission_id) / "mission.json"
|
||||
|
||||
|
||||
def mission_events_file(mission_id: str) -> Path:
|
||||
return mission_dir(mission_id) / "events.ndjson"
|
||||
|
||||
|
||||
def mission_artifacts_dir(mission_id: str) -> Path:
|
||||
return mission_dir(mission_id) / "artifacts"
|
||||
|
||||
|
||||
def create_mission(goal: str, workspace: str, routing_overrides: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
mission_id = uuid.uuid4().hex[:12]
|
||||
mission = {
|
||||
"mission_id": mission_id,
|
||||
"title": goal.strip().splitlines()[0][:80] or f"mission-{mission_id}",
|
||||
"goal": goal,
|
||||
"workspace": workspace,
|
||||
"status": "draft",
|
||||
"priority": "normal",
|
||||
"created_at": now_utc(),
|
||||
"updated_at": now_utc(),
|
||||
"planner_model": load_models_config()["planner"]["model_id"],
|
||||
"buddy_model": load_models_config()["buddy"]["model_id"],
|
||||
"verify_model": load_models_config()["verify"]["model_id"],
|
||||
"owner": "Constant",
|
||||
"routing_overrides": routing_overrides or {},
|
||||
"steps": [],
|
||||
"artifacts": [],
|
||||
"meta": {
|
||||
"schema_version": 1,
|
||||
"tool_version": __version__,
|
||||
},
|
||||
}
|
||||
save_mission(mission)
|
||||
append_event(mission_id, "mission.created", {"goal": goal, "workspace": workspace})
|
||||
return mission
|
||||
|
||||
|
||||
def save_mission(mission: dict[str, Any]) -> None:
|
||||
path = mission_file(mission["mission_id"])
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
mission["updated_at"] = now_utc()
|
||||
path.write_text(json.dumps(mission, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
def load_mission(mission_id: str) -> dict[str, Any]:
|
||||
return json.loads(mission_file(mission_id).read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def list_missions() -> list[dict[str, Any]]:
|
||||
ensure_runtime_dirs()
|
||||
missions: list[dict[str, Any]] = []
|
||||
for path in sorted(missions_dir().glob("*/mission.json")):
|
||||
missions.append(json.loads(path.read_text(encoding="utf-8")))
|
||||
return missions
|
||||
|
||||
|
||||
def append_event(mission_id: str, event_type: str, payload: dict[str, Any]) -> None:
|
||||
path = mission_events_file(mission_id)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
event = {
|
||||
"timestamp": now_utc(),
|
||||
"type": event_type,
|
||||
"payload": payload,
|
||||
}
|
||||
with path.open("a", encoding="utf-8") as handle:
|
||||
handle.write(json.dumps(event, sort_keys=True) + "\n")
|
||||
|
||||
|
||||
def write_artifact(mission_id: str, name: str, payload: dict[str, Any]) -> str:
|
||||
artifact_dir = mission_artifacts_dir(mission_id)
|
||||
artifact_dir.mkdir(parents=True, exist_ok=True)
|
||||
path = artifact_dir / name
|
||||
path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
||||
mission = load_mission(mission_id)
|
||||
mission["artifacts"].append(str(path))
|
||||
save_mission(mission)
|
||||
return str(path)
|
||||
|
||||
|
||||
def first_active_step(mission: dict[str, Any]) -> dict[str, Any] | None:
|
||||
for step in mission["steps"]:
|
||||
if step["status"] not in {"done", "failed", "needs_human"}:
|
||||
return step
|
||||
return None
|
||||
Executable
+24
@@ -0,0 +1,24 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
script_source="${BASH_SOURCE[0]:-$0}"
|
||||
while [[ -L "$script_source" ]]; do
|
||||
script_dir="$(cd "$(dirname "$script_source")" && pwd -P)"
|
||||
script_source="$(readlink "$script_source")"
|
||||
[[ "$script_source" != /* ]] && script_source="$script_dir/$script_source"
|
||||
done
|
||||
script_dir="$(cd "$(dirname "$script_source")" && pwd -P)"
|
||||
repo_dir="$(cd "$script_dir/.." && pwd -P)"
|
||||
default_python="$HOME/.local/share/constant/venv/bin/python3"
|
||||
|
||||
python_bin="${CONSTANT_PYTHON:-}"
|
||||
if [[ -z "$python_bin" ]]; then
|
||||
if [[ -x "$default_python" ]]; then
|
||||
python_bin="$default_python"
|
||||
else
|
||||
python_bin="python3"
|
||||
fi
|
||||
fi
|
||||
|
||||
export PYTHONPATH="$repo_dir${PYTHONPATH+:$PYTHONPATH}"
|
||||
exec "$python_bin" -m constant "$@"
|
||||
Executable
+373
@@ -0,0 +1,373 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
script_source="${BASH_SOURCE[0]:-$0}"
|
||||
while [[ -L "$script_source" ]]; do
|
||||
script_dir="$(cd "$(dirname "$script_source")" && pwd -P)"
|
||||
script_source="$(readlink "$script_source")"
|
||||
[[ "$script_source" != /* ]] && script_source="$script_dir/$script_source"
|
||||
done
|
||||
script_dir="$(cd "$(dirname "$script_source")" && pwd -P)"
|
||||
source "$script_dir/zellij-ai-common.sh"
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
Usage: $(basename "$0") <command> [options]
|
||||
|
||||
Commands:
|
||||
send Relay one message to one machine
|
||||
broadcast Relay one message to all machines
|
||||
sync Refresh a local cache of remote inbox summaries
|
||||
tail Poll all machines and print new summaries
|
||||
|
||||
Defaults:
|
||||
repo-dir: $(zellij_ai_default_repo_dir)
|
||||
session: $(zellij_ai_default_session)
|
||||
EOF
|
||||
}
|
||||
|
||||
repo_dir="$(zellij_ai_default_repo_dir)"
|
||||
session="$(zellij_ai_default_session)"
|
||||
cache_dir="$(zellij_ai_bridge_cache_dir)"
|
||||
machines=()
|
||||
|
||||
default_machine_specs_array() {
|
||||
local line
|
||||
while IFS= read -r line; do
|
||||
[[ -z "$line" ]] && continue
|
||||
printf '%s\n' "$line"
|
||||
done < <(zellij_ai_default_machine_specs)
|
||||
}
|
||||
|
||||
load_machines() {
|
||||
if [[ ${#machines[@]} -eq 0 ]]; then
|
||||
mapfile -t machines < <(default_machine_specs_array)
|
||||
fi
|
||||
}
|
||||
|
||||
resolve_machine() {
|
||||
local needle="$1"
|
||||
local resolved
|
||||
|
||||
if ! resolved="$(zellij_ai_resolve_machine_spec "$needle")"; then
|
||||
echo "Unknown machine: $needle" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
printf '%s\n' "$resolved"
|
||||
}
|
||||
|
||||
run_ai_msg() {
|
||||
local label="$1"
|
||||
local target="$2"
|
||||
shift 2
|
||||
|
||||
if zellij_ai_is_local_target "$target"; then
|
||||
ZELLIJ_AI_MACHINE_NAME="$label" \
|
||||
ZELLIJ_AI_SESSION="$session" \
|
||||
"$(zellij_ai_expand_home_path "$repo_dir")/scripts/ai-msg.sh" "$@"
|
||||
return 0
|
||||
fi
|
||||
|
||||
ssh "$target" bash -s -- "$label" "$session" "$repo_dir" "$@" <<'EOF'
|
||||
set -euo pipefail
|
||||
|
||||
label="$1"
|
||||
shift
|
||||
session="$1"
|
||||
shift
|
||||
repo_dir_input="$1"
|
||||
shift
|
||||
|
||||
expand_home_path() {
|
||||
case "$1" in
|
||||
'$HOME'|'$HOME/'*)
|
||||
printf '%s\n' "${HOME}${1#\$HOME}"
|
||||
;;
|
||||
"~")
|
||||
printf '%s\n' "$HOME"
|
||||
;;
|
||||
"~/"*)
|
||||
printf '%s\n' "$HOME/${1#~/}"
|
||||
;;
|
||||
*)
|
||||
printf '%s\n' "$1"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
repo_dir="$(expand_home_path "$repo_dir_input")"
|
||||
|
||||
export ZELLIJ_AI_MACHINE_NAME="$label"
|
||||
export ZELLIJ_AI_SESSION="$session"
|
||||
|
||||
exec "$repo_dir/scripts/ai-msg.sh" "$@"
|
||||
EOF
|
||||
}
|
||||
|
||||
sync_once() {
|
||||
local machine_spec resolved label target cache_file
|
||||
mkdir -p "$cache_dir"
|
||||
load_machines
|
||||
|
||||
for machine_spec in "${machines[@]}"; do
|
||||
IFS=$'\t' read -r label target <<<"$(resolve_machine "$machine_spec")"
|
||||
cache_file="$cache_dir/${label}.tsv"
|
||||
run_ai_msg "$label" "$target" list --for all >"$cache_file"
|
||||
done
|
||||
}
|
||||
|
||||
command_send() {
|
||||
local from_machine="$(zellij_ai_local_machine_label)"
|
||||
local from_role="bridge"
|
||||
local to_machine=""
|
||||
local to_role=""
|
||||
local message_text=""
|
||||
local message_file=""
|
||||
local resolved label target
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--from-machine)
|
||||
from_machine="$2"
|
||||
shift 2
|
||||
;;
|
||||
--from)
|
||||
from_role="$2"
|
||||
shift 2
|
||||
;;
|
||||
--to-machine)
|
||||
to_machine="$2"
|
||||
shift 2
|
||||
;;
|
||||
--to)
|
||||
to_role="$2"
|
||||
shift 2
|
||||
;;
|
||||
--message)
|
||||
message_text="$2"
|
||||
shift 2
|
||||
;;
|
||||
--file)
|
||||
message_file="$2"
|
||||
shift 2
|
||||
;;
|
||||
--machine)
|
||||
machines+=("$2")
|
||||
shift 2
|
||||
;;
|
||||
--repo-dir)
|
||||
repo_dir="$2"
|
||||
shift 2
|
||||
;;
|
||||
--session)
|
||||
session="$2"
|
||||
shift 2
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "Unknown option for send: $1" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ -z "$to_machine" || -z "$to_role" ]]; then
|
||||
echo "--to-machine and --to are required." >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
IFS=$'\t' read -r label target <<<"$(resolve_machine "$to_machine")"
|
||||
|
||||
if [[ -n "$message_text" ]]; then
|
||||
run_ai_msg "$label" "$target" send --from "$from_role" --to "$to_role" --source-machine "$from_machine" --source-role "$from_role" --relay-via "$(zellij_ai_local_machine_label)" --message "$message_text"
|
||||
elif [[ -n "$message_file" ]]; then
|
||||
run_ai_msg "$label" "$target" send --from "$from_role" --to "$to_role" --source-machine "$from_machine" --source-role "$from_role" --relay-via "$(zellij_ai_local_machine_label)" --file "$message_file"
|
||||
elif [[ ! -t 0 ]]; then
|
||||
run_ai_msg "$label" "$target" send --from "$from_role" --to "$to_role" --source-machine "$from_machine" --source-role "$from_role" --relay-via "$(zellij_ai_local_machine_label)"
|
||||
else
|
||||
echo "Provide --message, --file, or stdin." >&2
|
||||
exit 2
|
||||
fi
|
||||
}
|
||||
|
||||
command_broadcast() {
|
||||
local from_machine="$(zellij_ai_local_machine_label)"
|
||||
local from_role="bridge"
|
||||
local message_text=""
|
||||
local message_file=""
|
||||
local machine_spec resolved label target
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--from-machine)
|
||||
from_machine="$2"
|
||||
shift 2
|
||||
;;
|
||||
--from)
|
||||
from_role="$2"
|
||||
shift 2
|
||||
;;
|
||||
--message)
|
||||
message_text="$2"
|
||||
shift 2
|
||||
;;
|
||||
--file)
|
||||
message_file="$2"
|
||||
shift 2
|
||||
;;
|
||||
--machine)
|
||||
machines+=("$2")
|
||||
shift 2
|
||||
;;
|
||||
--repo-dir)
|
||||
repo_dir="$2"
|
||||
shift 2
|
||||
;;
|
||||
--session)
|
||||
session="$2"
|
||||
shift 2
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "Unknown option for broadcast: $1" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
load_machines
|
||||
|
||||
for machine_spec in "${machines[@]}"; do
|
||||
IFS=$'\t' read -r label target <<<"$(resolve_machine "$machine_spec")"
|
||||
if [[ -n "$message_text" ]]; then
|
||||
run_ai_msg "$label" "$target" send --from "$from_role" --to all --source-machine "$from_machine" --source-role "$from_role" --relay-via "$(zellij_ai_local_machine_label)" --message "$message_text"
|
||||
elif [[ -n "$message_file" ]]; then
|
||||
run_ai_msg "$label" "$target" send --from "$from_role" --to all --source-machine "$from_machine" --source-role "$from_role" --relay-via "$(zellij_ai_local_machine_label)" --file "$message_file"
|
||||
elif [[ ! -t 0 ]]; then
|
||||
run_ai_msg "$label" "$target" send --from "$from_role" --to all --source-machine "$from_machine" --source-role "$from_role" --relay-via "$(zellij_ai_local_machine_label)"
|
||||
else
|
||||
echo "Provide --message, --file, or stdin." >&2
|
||||
exit 2
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
command_sync() {
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--machine)
|
||||
machines+=("$2")
|
||||
shift 2
|
||||
;;
|
||||
--repo-dir)
|
||||
repo_dir="$2"
|
||||
shift 2
|
||||
;;
|
||||
--session)
|
||||
session="$2"
|
||||
shift 2
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "Unknown option for sync: $1" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
sync_once
|
||||
|
||||
find "$cache_dir" -type f -name '*.tsv' | sort | while IFS= read -r file; do
|
||||
cat "$file"
|
||||
done
|
||||
}
|
||||
|
||||
command_tail() {
|
||||
local interval=3
|
||||
declare -A seen=()
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--machine)
|
||||
machines+=("$2")
|
||||
shift 2
|
||||
;;
|
||||
--repo-dir)
|
||||
repo_dir="$2"
|
||||
shift 2
|
||||
;;
|
||||
--session)
|
||||
session="$2"
|
||||
shift 2
|
||||
;;
|
||||
--interval)
|
||||
interval="$2"
|
||||
shift 2
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "Unknown option for tail: $1" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
while true; do
|
||||
sync_once
|
||||
while IFS= read -r file; do
|
||||
while IFS= read -r line; do
|
||||
[[ -z "$line" ]] && continue
|
||||
if [[ -n "${seen[$line]:-}" ]]; then
|
||||
continue
|
||||
fi
|
||||
seen["$line"]=1
|
||||
printf '%s\n' "$line"
|
||||
done <"$file"
|
||||
done < <(find "$cache_dir" -type f -name '*.tsv' | sort)
|
||||
sleep "$interval"
|
||||
done
|
||||
}
|
||||
|
||||
if [[ $# -lt 1 ]]; then
|
||||
usage >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
command="$1"
|
||||
shift
|
||||
|
||||
case "$command" in
|
||||
send)
|
||||
command_send "$@"
|
||||
;;
|
||||
broadcast)
|
||||
command_broadcast "$@"
|
||||
;;
|
||||
sync)
|
||||
command_sync "$@"
|
||||
;;
|
||||
tail)
|
||||
command_tail "$@"
|
||||
;;
|
||||
-h|--help|help)
|
||||
usage
|
||||
;;
|
||||
*)
|
||||
echo "Unknown command: $command" >&2
|
||||
usage >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
Executable
+65
@@ -0,0 +1,65 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage: ai-copy.sh [options]
|
||||
|
||||
Copy text into the macOS clipboard on the current machine.
|
||||
|
||||
Options:
|
||||
--text TEXT Copy this text directly
|
||||
--file FILE Copy the contents of FILE
|
||||
-h, --help Show this help
|
||||
|
||||
If neither --text nor --file is provided, stdin is copied.
|
||||
EOF
|
||||
}
|
||||
|
||||
text=""
|
||||
file=""
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--text)
|
||||
text="$2"
|
||||
shift 2
|
||||
;;
|
||||
--file)
|
||||
file="$2"
|
||||
shift 2
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "Unknown option: $1" >&2
|
||||
usage >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ -n "$text" && -n "$file" ]]; then
|
||||
echo "Use either --text or --file, not both." >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
if ! command -v pbcopy >/dev/null 2>&1; then
|
||||
echo "pbcopy is not available on this machine." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -n "$text" ]]; then
|
||||
printf '%s' "$text" | pbcopy
|
||||
elif [[ -n "$file" ]]; then
|
||||
cat "$file" | pbcopy
|
||||
elif [[ ! -t 0 ]]; then
|
||||
cat | pbcopy
|
||||
else
|
||||
echo "Nothing to copy. Provide --text, --file, or pipe stdin." >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
printf 'Copied to the macOS clipboard.\n'
|
||||
Executable
+376
@@ -0,0 +1,376 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
script_source="${BASH_SOURCE[0]:-$0}"
|
||||
while [[ -L "$script_source" ]]; do
|
||||
script_dir="$(cd "$(dirname "$script_source")" && pwd -P)"
|
||||
script_source="$(readlink "$script_source")"
|
||||
[[ "$script_source" != /* ]] && script_source="$script_dir/$script_source"
|
||||
done
|
||||
script_dir="$(cd "$(dirname "$script_source")" && pwd -P)"
|
||||
source "$script_dir/zellij-ai-common.sh"
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage: ai-msg.sh <command> [options]
|
||||
|
||||
Commands:
|
||||
send Send a message to one role
|
||||
broadcast Send a message to all roles on the current machine
|
||||
inbox Show messages addressed to one role
|
||||
tail Poll and print new messages for one role
|
||||
list List matching messages in TSV form
|
||||
|
||||
Environment defaults:
|
||||
ZELLIJ_AI_BUS_DIR
|
||||
ZELLIJ_AI_MACHINE_NAME
|
||||
ZELLIJ_AI_ROLE
|
||||
ZELLIJ_AI_SESSION
|
||||
EOF
|
||||
}
|
||||
|
||||
machine_name="$(zellij_ai_current_machine_name)"
|
||||
session="${ZELLIJ_AI_SESSION:-$(zellij_ai_default_session)}"
|
||||
role_default="${ZELLIJ_AI_ROLE:-all}"
|
||||
bus_dir="${ZELLIJ_AI_BUS_DIR:-$(zellij_ai_session_state_dir "$session")/bus}"
|
||||
messages_dir="${bus_dir}/messages"
|
||||
|
||||
ensure_messages_dir() {
|
||||
mkdir -p "$messages_dir"
|
||||
}
|
||||
|
||||
random_suffix() {
|
||||
od -An -N4 -tx1 /dev/urandom 2>/dev/null | tr -d ' \n' || printf '%s' "$$"
|
||||
}
|
||||
|
||||
message_id() {
|
||||
printf '%s\n' "${machine_name}-${session}-$(date -u +%Y%m%dT%H%M%SZ)-$$-$(random_suffix)"
|
||||
}
|
||||
|
||||
timestamp_utc() {
|
||||
date -u +"%Y-%m-%dT%H:%M:%SZ"
|
||||
}
|
||||
|
||||
read_body_input() {
|
||||
local message_text="$1"
|
||||
local message_file="$2"
|
||||
|
||||
if [[ -n "$message_text" ]]; then
|
||||
printf '%s' "$message_text"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [[ -n "$message_file" ]]; then
|
||||
cat "$message_file"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [[ ! -t 0 ]]; then
|
||||
cat
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo "No message body provided. Use --message, --file, or stdin." >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
read_header() {
|
||||
local key="$1"
|
||||
local file="$2"
|
||||
sed -n "s/^${key}: //p" "$file" | head -n 1
|
||||
}
|
||||
|
||||
read_body() {
|
||||
awk 'seen_blank { print } /^$/ { seen_blank = 1 }' "$1"
|
||||
}
|
||||
|
||||
message_matches_role() {
|
||||
local file="$1"
|
||||
local role="$2"
|
||||
local target
|
||||
|
||||
target="$(read_header target "$file")"
|
||||
|
||||
if [[ "$role" == "all" ]]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
[[ "$target" == "$role" || "$target" == "all" ]]
|
||||
}
|
||||
|
||||
list_message_files() {
|
||||
if [[ ! -d "$messages_dir" ]]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
find "$messages_dir" -type f -name '*.msg' | sort
|
||||
}
|
||||
|
||||
print_message_tsv() {
|
||||
local file="$1"
|
||||
printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \
|
||||
"$(read_header id "$file")" \
|
||||
"$(read_header timestamp "$file")" \
|
||||
"$(read_header machine "$file")" \
|
||||
"$(read_header session "$file")" \
|
||||
"$(read_header sender "$file")" \
|
||||
"$(read_header target "$file")" \
|
||||
"$(read_header source_machine "$file")" \
|
||||
"$(read_header source_role "$file")" \
|
||||
"$file"
|
||||
}
|
||||
|
||||
print_message_pretty() {
|
||||
local file="$1"
|
||||
local id timestamp sender target stored_machine source_machine source_role relay_via
|
||||
|
||||
id="$(read_header id "$file")"
|
||||
timestamp="$(read_header timestamp "$file")"
|
||||
sender="$(read_header sender "$file")"
|
||||
target="$(read_header target "$file")"
|
||||
stored_machine="$(read_header machine "$file")"
|
||||
source_machine="$(read_header source_machine "$file")"
|
||||
source_role="$(read_header source_role "$file")"
|
||||
relay_via="$(read_header relay_via "$file")"
|
||||
|
||||
printf '[%s] %s -> %s on %s (%s)\n' "$timestamp" "$sender" "$target" "$stored_machine" "$id"
|
||||
if [[ -n "$source_machine" || -n "$source_role" ]]; then
|
||||
printf 'source: %s/%s\n' "${source_machine:-unknown}" "${source_role:-unknown}"
|
||||
fi
|
||||
if [[ -n "$relay_via" ]]; then
|
||||
printf 'relay: %s\n' "$relay_via"
|
||||
fi
|
||||
printf '%s\n' '---'
|
||||
read_body "$file"
|
||||
printf '\n'
|
||||
}
|
||||
|
||||
write_message_file() {
|
||||
local sender="$1"
|
||||
local target="$2"
|
||||
local source_machine="$3"
|
||||
local source_role="$4"
|
||||
local relay_via="$5"
|
||||
local body="$6"
|
||||
local id timestamp file tmp_file
|
||||
|
||||
ensure_messages_dir
|
||||
|
||||
id="$(message_id)"
|
||||
timestamp="$(timestamp_utc)"
|
||||
file="${messages_dir}/${id}.msg"
|
||||
tmp_file="$(mktemp "${messages_dir}/.tmp.XXXXXX")"
|
||||
|
||||
{
|
||||
printf 'id: %s\n' "$id"
|
||||
printf 'timestamp: %s\n' "$timestamp"
|
||||
printf 'machine: %s\n' "$machine_name"
|
||||
printf 'session: %s\n' "$session"
|
||||
printf 'sender: %s\n' "$sender"
|
||||
printf 'target: %s\n' "$target"
|
||||
printf 'source_machine: %s\n' "$source_machine"
|
||||
printf 'source_role: %s\n' "$source_role"
|
||||
printf 'relay_via: %s\n' "$relay_via"
|
||||
printf '\n'
|
||||
printf '%s\n' "$body"
|
||||
} >"$tmp_file"
|
||||
|
||||
mv "$tmp_file" "$file"
|
||||
printf '%s\n' "$file"
|
||||
}
|
||||
|
||||
command_send() {
|
||||
local sender="$role_default"
|
||||
local target=""
|
||||
local message_text=""
|
||||
local message_file=""
|
||||
local source_machine=""
|
||||
local source_role=""
|
||||
local relay_via=""
|
||||
local body file
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--from)
|
||||
sender="$2"
|
||||
shift 2
|
||||
;;
|
||||
--to)
|
||||
target="$2"
|
||||
shift 2
|
||||
;;
|
||||
--message)
|
||||
message_text="$2"
|
||||
shift 2
|
||||
;;
|
||||
--file)
|
||||
message_file="$2"
|
||||
shift 2
|
||||
;;
|
||||
--source-machine)
|
||||
source_machine="$2"
|
||||
shift 2
|
||||
;;
|
||||
--source-role)
|
||||
source_role="$2"
|
||||
shift 2
|
||||
;;
|
||||
--relay-via)
|
||||
relay_via="$2"
|
||||
shift 2
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "Unknown option for send: $1" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ -z "$target" ]]; then
|
||||
echo "--to is required for send." >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
body="$(read_body_input "$message_text" "$message_file")"
|
||||
file="$(write_message_file "$sender" "$target" "$source_machine" "$source_role" "$relay_via" "$body")"
|
||||
printf 'stored: %s\n' "$file"
|
||||
}
|
||||
|
||||
command_broadcast() {
|
||||
command_send --to all "$@"
|
||||
}
|
||||
|
||||
command_list() {
|
||||
local role="all"
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--for)
|
||||
role="$2"
|
||||
shift 2
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "Unknown option for list: $1" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
while IFS= read -r file; do
|
||||
message_matches_role "$file" "$role" || continue
|
||||
print_message_tsv "$file"
|
||||
done < <(list_message_files)
|
||||
}
|
||||
|
||||
command_inbox() {
|
||||
local role="$role_default"
|
||||
local found=false
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--for)
|
||||
role="$2"
|
||||
shift 2
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "Unknown option for inbox: $1" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
while IFS= read -r file; do
|
||||
message_matches_role "$file" "$role" || continue
|
||||
print_message_pretty "$file"
|
||||
found=true
|
||||
done < <(list_message_files)
|
||||
|
||||
if ! $found; then
|
||||
printf 'No messages for %s in %s\n' "$role" "$messages_dir"
|
||||
fi
|
||||
}
|
||||
|
||||
command_tail() {
|
||||
local role="$role_default"
|
||||
local interval=2
|
||||
declare -A seen=()
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--for)
|
||||
role="$2"
|
||||
shift 2
|
||||
;;
|
||||
--interval)
|
||||
interval="$2"
|
||||
shift 2
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "Unknown option for tail: $1" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
while true; do
|
||||
while IFS= read -r file; do
|
||||
message_matches_role "$file" "$role" || continue
|
||||
if [[ -n "${seen[$file]:-}" ]]; then
|
||||
continue
|
||||
fi
|
||||
seen["$file"]=1
|
||||
print_message_pretty "$file"
|
||||
done < <(list_message_files)
|
||||
sleep "$interval"
|
||||
done
|
||||
}
|
||||
|
||||
if [[ $# -lt 1 ]]; then
|
||||
usage >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
command="$1"
|
||||
shift
|
||||
|
||||
case "$command" in
|
||||
send)
|
||||
command_send "$@"
|
||||
;;
|
||||
broadcast)
|
||||
command_broadcast "$@"
|
||||
;;
|
||||
list)
|
||||
command_list "$@"
|
||||
;;
|
||||
inbox)
|
||||
command_inbox "$@"
|
||||
;;
|
||||
tail)
|
||||
command_tail "$@"
|
||||
;;
|
||||
-h|--help|help)
|
||||
usage
|
||||
;;
|
||||
*)
|
||||
echo "Unknown command: $command" >&2
|
||||
usage >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
Executable
+13
@@ -0,0 +1,13 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
script_source="${BASH_SOURCE[0]:-$0}"
|
||||
while [[ -L "$script_source" ]]; do
|
||||
script_dir="$(cd "$(dirname "$script_source")" && pwd -P)"
|
||||
script_source="$(readlink "$script_source")"
|
||||
[[ "$script_source" != /* ]] && script_source="$script_dir/$script_source"
|
||||
done
|
||||
script_dir="$(cd "$(dirname "$script_source")" && pwd -P)"
|
||||
|
||||
export CONSTANT_SCRIPT_NAME="$(basename "$0")"
|
||||
exec "$script_dir/zellij-ai-fleet-install.sh" "$@"
|
||||
Executable
+13
@@ -0,0 +1,13 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
script_source="${BASH_SOURCE[0]:-$0}"
|
||||
while [[ -L "$script_source" ]]; do
|
||||
script_dir="$(cd "$(dirname "$script_source")" && pwd -P)"
|
||||
script_source="$(readlink "$script_source")"
|
||||
[[ "$script_source" != /* ]] && script_source="$script_dir/$script_source"
|
||||
done
|
||||
script_dir="$(cd "$(dirname "$script_source")" && pwd -P)"
|
||||
|
||||
export CONSTANT_SCRIPT_NAME="$(basename "$0")"
|
||||
exec "$script_dir/zellij-ai-fleet.sh" "$@"
|
||||
Executable
+13
@@ -0,0 +1,13 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
script_source="${BASH_SOURCE[0]:-$0}"
|
||||
while [[ -L "$script_source" ]]; do
|
||||
script_dir="$(cd "$(dirname "$script_source")" && pwd -P)"
|
||||
script_source="$(readlink "$script_source")"
|
||||
[[ "$script_source" != /* ]] && script_source="$script_dir/$script_source"
|
||||
done
|
||||
script_dir="$(cd "$(dirname "$script_source")" && pwd -P)"
|
||||
|
||||
export CONSTANT_SCRIPT_NAME="$(basename "$0")"
|
||||
exec "$script_dir/zellij-ai-triple.sh" "$@"
|
||||
@@ -1,7 +1,13 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)"
|
||||
script_source="${BASH_SOURCE[0]:-$0}"
|
||||
while [[ -L "$script_source" ]]; do
|
||||
script_dir="$(cd "$(dirname "$script_source")" && pwd -P)"
|
||||
script_source="$(readlink "$script_source")"
|
||||
[[ "$script_source" != /* ]] && script_source="$script_dir/$script_source"
|
||||
done
|
||||
script_dir="$(cd "$(dirname "$script_source")" && pwd -P)"
|
||||
bootstrap_file="${ZELLIJ_AI_BOOTSTRAP_FILE:-}"
|
||||
workspace="${ZELLIJ_AI_WORKSPACE:-$PWD}"
|
||||
|
||||
@@ -12,17 +18,23 @@ fi
|
||||
if [[ -n "$bootstrap_file" && ! -f "$bootstrap_file" ]]; then
|
||||
: >"$bootstrap_file"
|
||||
|
||||
printf -v codex1_cmd '%q %q' "$script_dir/zellij-ai-codex-pane.sh" "1"
|
||||
printf -v codex2_cmd '%q %q' "$script_dir/zellij-ai-codex-pane.sh" "2"
|
||||
printf -v codex_cmd '%q' "$script_dir/zellij-ai-codex-pane.sh"
|
||||
printf -v copilot_cmd '%q' "$script_dir/zellij-ai-copilot-pane.sh"
|
||||
printf -v vibe_cmd '%q' "$script_dir/zellij-ai-vibe-pane.sh"
|
||||
|
||||
zellij action new-pane -d right -n "${ZELLIJ_AI_CODEX1_LABEL:-codex-1}" --cwd "$workspace"
|
||||
zellij action new-pane -d right -n "${ZELLIJ_AI_CODEX_LABEL:-codex}" --cwd "$workspace"
|
||||
zellij action move-focus right
|
||||
zellij action write-chars "$codex1_cmd"
|
||||
zellij action write-chars "$codex_cmd"
|
||||
zellij action write 10
|
||||
|
||||
zellij action new-pane -d down -n "${ZELLIJ_AI_CODEX2_LABEL:-codex-2}" --cwd "$workspace"
|
||||
zellij action new-pane -d down -n "${ZELLIJ_AI_COPILOT_LABEL:-copilot}" --cwd "$workspace"
|
||||
zellij action move-focus down
|
||||
zellij action write-chars "$codex2_cmd"
|
||||
zellij action write-chars "$copilot_cmd"
|
||||
zellij action write 10
|
||||
|
||||
zellij action new-pane -d down -n "${ZELLIJ_AI_VIBE_LABEL:-vibe}" --cwd "$workspace"
|
||||
zellij action move-focus down
|
||||
zellij action write-chars "$vibe_cmd"
|
||||
zellij action write 10
|
||||
|
||||
zellij action move-focus left
|
||||
|
||||
@@ -1,7 +1,20 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
script_source="${BASH_SOURCE[0]:-$0}"
|
||||
while [[ -L "$script_source" ]]; do
|
||||
script_dir="$(cd "$(dirname "$script_source")" && pwd -P)"
|
||||
script_source="$(readlink "$script_source")"
|
||||
[[ "$script_source" != /* ]] && script_source="$script_dir/$script_source"
|
||||
done
|
||||
script_dir="$(cd "$(dirname "$script_source")" && pwd -P)"
|
||||
source "$script_dir/zellij-ai-common.sh"
|
||||
|
||||
workspace="${ZELLIJ_AI_WORKSPACE:-$PWD}"
|
||||
machine_name="${ZELLIJ_AI_MACHINE_NAME:-unknown}"
|
||||
session_name="${ZELLIJ_AI_SESSION:-$(zellij_ai_default_session)}"
|
||||
repo_dir="${ZELLIJ_AI_REPO_DIR:-}"
|
||||
bus_dir="${ZELLIJ_AI_BUS_DIR:-$(zellij_ai_session_state_dir "$session_name")/bus}"
|
||||
|
||||
if [[ ! -d "$workspace" ]]; then
|
||||
echo "Workspace not found: $workspace" >&2
|
||||
@@ -12,10 +25,26 @@ if [[ -n "${ZELLIJ_AI_CLAUDE_CONFIG_DIR:-}" ]]; then
|
||||
export CLAUDE_CONFIG_DIR="$ZELLIJ_AI_CLAUDE_CONFIG_DIR"
|
||||
fi
|
||||
|
||||
export PATH="$(zellij_ai_agent_path)"
|
||||
|
||||
if [[ -n "$repo_dir" && -d "$repo_dir/scripts" ]]; then
|
||||
export PATH="$repo_dir/scripts:$PATH"
|
||||
fi
|
||||
|
||||
export ZELLIJ_AI_ROLE="claude"
|
||||
export ZELLIJ_AI_MACHINE_NAME="$machine_name"
|
||||
export ZELLIJ_AI_SESSION="$session_name"
|
||||
export ZELLIJ_AI_BUS_DIR="$bus_dir"
|
||||
|
||||
cd "$workspace"
|
||||
|
||||
echo "Claude pane"
|
||||
echo "machine: $machine_name"
|
||||
echo "workspace: $workspace"
|
||||
echo "bus: $bus_dir"
|
||||
if [[ -n "$repo_dir" ]]; then
|
||||
echo "helper: $repo_dir/scripts/ai-msg.sh"
|
||||
fi
|
||||
if [[ -n "${CLAUDE_CONFIG_DIR:-}" ]]; then
|
||||
echo "config: $CLAUDE_CONFIG_DIR"
|
||||
fi
|
||||
|
||||
+72
-121
@@ -1,160 +1,111 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)"
|
||||
script_source="${BASH_SOURCE[0]:-$0}"
|
||||
while [[ -L "$script_source" ]]; do
|
||||
script_dir="$(cd "$(dirname "$script_source")" && pwd -P)"
|
||||
script_source="$(readlink "$script_source")"
|
||||
[[ "$script_source" != /* ]] && script_source="$script_dir/$script_source"
|
||||
done
|
||||
script_dir="$(cd "$(dirname "$script_source")" && pwd -P)"
|
||||
source "$script_dir/zellij-ai-common.sh"
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage: zellij-ai-codex-pane.sh <slot>
|
||||
Usage: zellij-ai-codex-pane.sh [slot]
|
||||
|
||||
slot:
|
||||
1 Launch the first Codex container/profile
|
||||
2 Launch the second Codex container/profile
|
||||
1 Deprecated alias for the single host-local Codex pane
|
||||
2 Deprecated alias for the single host-local Codex pane
|
||||
EOF
|
||||
}
|
||||
|
||||
find_codex_binary() {
|
||||
if [[ -n "${ZELLIJ_AI_CODEX_BINARY:-}" && -x "${ZELLIJ_AI_CODEX_BINARY}" ]]; then
|
||||
printf '%s\n' "${ZELLIJ_AI_CODEX_BINARY}"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [[ -x "$HOME/.npm-global/lib/node_modules/@openai/codex/node_modules/@openai/codex-linux-x64/vendor/x86_64-unknown-linux-musl/codex/codex" ]]; then
|
||||
printf '%s\n' "$HOME/.npm-global/lib/node_modules/@openai/codex/node_modules/@openai/codex-linux-x64/vendor/x86_64-unknown-linux-musl/codex/codex"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [[ -n "$(command -v codex || true)" ]]; then
|
||||
local wrapper_dir package_dir candidate
|
||||
wrapper_dir="$(cd "$(dirname "$(command -v codex)")" && pwd -P)"
|
||||
package_dir="$(cd "$wrapper_dir/../lib/node_modules/@openai/codex/node_modules/@openai" 2>/dev/null && pwd -P || true)"
|
||||
if [[ -n "$package_dir" ]]; then
|
||||
candidate="$(find "$package_dir" -path '*/vendor/*/codex/codex' -type f -print -quit 2>/dev/null || true)"
|
||||
if [[ -n "$candidate" && -x "$candidate" ]]; then
|
||||
printf '%s\n' "$candidate"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
return 1
|
||||
warn_deprecated() {
|
||||
printf 'Warning: %s\n' "$*" >&2
|
||||
}
|
||||
|
||||
if [[ $# -ne 1 ]]; then
|
||||
if [[ $# -gt 1 ]]; then
|
||||
usage >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
slot="$1"
|
||||
|
||||
case "$slot" in
|
||||
1)
|
||||
label="${ZELLIJ_AI_CODEX1_LABEL:-codex-1}"
|
||||
profile_dir="${ZELLIJ_AI_CODEX1_HOME:-$HOME/.codex-profiles/codex-1}"
|
||||
;;
|
||||
2)
|
||||
label="${ZELLIJ_AI_CODEX2_LABEL:-codex-2}"
|
||||
profile_dir="${ZELLIJ_AI_CODEX2_HOME:-$HOME/.codex-profiles/codex-2}"
|
||||
;;
|
||||
*)
|
||||
usage >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
if [[ $# -eq 1 ]]; then
|
||||
case "$1" in
|
||||
1)
|
||||
warn_deprecated "slot 1 is deprecated; Codex now runs as a single host-local pane."
|
||||
;;
|
||||
2)
|
||||
warn_deprecated "slot 2 is deprecated; launching the single host-local Codex pane."
|
||||
;;
|
||||
-h|--help|help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
usage >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
|
||||
workspace="${ZELLIJ_AI_WORKSPACE:-$PWD}"
|
||||
image="${ZELLIJ_AI_CODEX_IMAGE:-codercom/code-server:latest}"
|
||||
profile_dir="${ZELLIJ_AI_CODEX_HOME:-$(zellij_ai_default_codex_home)}"
|
||||
label="${ZELLIJ_AI_CODEX_LABEL:-codex}"
|
||||
main_codex_config="${ZELLIJ_AI_MAIN_CODEX_CONFIG:-$HOME/.codex/config.toml}"
|
||||
codex_binary="$(find_codex_binary || true)"
|
||||
term_value="${ZELLIJ_AI_CODEX_TERM:-${TERM:-xterm-256color}}"
|
||||
color_term_value="${COLORTERM:-truecolor}"
|
||||
|
||||
if [[ "$term_value" == "dumb" ]]; then
|
||||
term_value="xterm-256color"
|
||||
fi
|
||||
repo_dir="${ZELLIJ_AI_REPO_DIR:-$(cd "$script_dir/.." && pwd -P)}"
|
||||
session_name="${ZELLIJ_AI_SESSION:-$(zellij_ai_default_session)}"
|
||||
bus_dir="${ZELLIJ_AI_BUS_DIR:-$(zellij_ai_session_state_dir "$session_name")/bus}"
|
||||
machine_name="$(zellij_ai_current_machine_name)"
|
||||
agent_path="$(zellij_ai_agent_path)"
|
||||
|
||||
if [[ ! -d "$workspace" ]]; then
|
||||
echo "Workspace not found: $workspace" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -z "$codex_binary" || ! -x "$codex_binary" ]]; then
|
||||
echo "Unable to locate the native Codex binary on the host." >&2
|
||||
echo "Set ZELLIJ_AI_CODEX_BINARY if Codex is installed elsewhere." >&2
|
||||
export PATH="$agent_path"
|
||||
|
||||
if ! command -v codex >/dev/null 2>&1; then
|
||||
echo "Codex CLI not found on the host." >&2
|
||||
echo "Install it with: npm install -g @openai/codex" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
platform_dir="$(dirname "$(dirname "$codex_binary")")"
|
||||
rg_binary="$platform_dir/path/rg"
|
||||
|
||||
if [[ ! -x "$rg_binary" ]]; then
|
||||
echo "Bundled rg binary not found next to Codex: $rg_binary" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! docker image inspect "$image" >/dev/null 2>&1; then
|
||||
echo "Docker image not found locally: $image" >&2
|
||||
echo "Pull or build the image first, or relaunch with --codex-image." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "$profile_dir"
|
||||
profile_dir="$(mkdir -p "$(zellij_ai_expand_home_path "$profile_dir")" && cd "$(zellij_ai_expand_home_path "$profile_dir")" && pwd -P)"
|
||||
mkdir -p "$bus_dir/messages"
|
||||
|
||||
if [[ ! -f "$profile_dir/config.toml" && -f "$main_codex_config" ]]; then
|
||||
cp "$main_codex_config" "$profile_dir/config.toml"
|
||||
fi
|
||||
|
||||
container_name="zellij-$(printf '%s' "$label" | tr '[:upper:]' '[:lower:]' | tr -cs '[:alnum:]._-' '-')"
|
||||
uid="$(id -u)"
|
||||
gid="$(id -g)"
|
||||
if [[ -d "$repo_dir/scripts" ]]; then
|
||||
export PATH="$repo_dir/scripts:$PATH"
|
||||
fi
|
||||
|
||||
read -r -d '' inner_script <<EOF || true
|
||||
set -euo pipefail
|
||||
cd /workspace
|
||||
export HOME=/codex-home
|
||||
export CODEX_HOME=/codex-home
|
||||
echo "Codex pane: $label"
|
||||
echo "workspace: /workspace"
|
||||
echo "profile: /codex-home"
|
||||
echo "image: $image"
|
||||
export CODEX_HOME="$profile_dir"
|
||||
export ZELLIJ_AI_ROLE="$label"
|
||||
export ZELLIJ_AI_MACHINE_NAME="$machine_name"
|
||||
export ZELLIJ_AI_SESSION="$session_name"
|
||||
export ZELLIJ_AI_BUS_DIR="$bus_dir"
|
||||
|
||||
cd "$workspace"
|
||||
|
||||
echo "Codex pane"
|
||||
echo "machine: $machine_name"
|
||||
echo "workspace: $workspace"
|
||||
echo "profile: $profile_dir"
|
||||
echo "bus: $bus_dir"
|
||||
if [[ -n "$repo_dir" ]]; then
|
||||
echo "helper: $repo_dir/scripts/ai-msg.sh"
|
||||
fi
|
||||
echo
|
||||
if [ ! -f /codex-home/auth.json ]; then
|
||||
echo "No auth.json found for $label."
|
||||
echo "Starting: codex login --device-auth"
|
||||
echo "Use a different ChatGPT/OpenAI account here than in the other pane."
|
||||
echo
|
||||
codex login --device-auth
|
||||
echo
|
||||
fi
|
||||
exec codex --no-alt-screen -C /workspace
|
||||
EOF
|
||||
|
||||
docker_args=(
|
||||
run
|
||||
--rm
|
||||
--init
|
||||
-it
|
||||
--name "$container_name"
|
||||
--hostname "$container_name"
|
||||
--user "${uid}:${gid}"
|
||||
--workdir /workspace
|
||||
--entrypoint bash
|
||||
-e HOME=/codex-home
|
||||
-e CODEX_HOME=/codex-home
|
||||
-e USER="${USER:-$(id -un)}"
|
||||
-e TERM="$term_value"
|
||||
-e COLORTERM="$color_term_value"
|
||||
-e LANG="${LANG:-C.UTF-8}"
|
||||
-v "${workspace}:/workspace"
|
||||
-v "${profile_dir}:/codex-home"
|
||||
-v "${codex_binary}:/usr/local/bin/codex:ro"
|
||||
-v "${rg_binary}:/usr/local/bin/rg:ro"
|
||||
)
|
||||
|
||||
if [[ -f "$HOME/.gitconfig" ]]; then
|
||||
docker_args+=(-v "$HOME/.gitconfig:/codex-home/.gitconfig:ro")
|
||||
if [[ ! -f "$CODEX_HOME/auth.json" ]]; then
|
||||
echo "No auth.json found for $label."
|
||||
echo "Starting: codex login --device-auth"
|
||||
echo
|
||||
codex login --device-auth
|
||||
echo
|
||||
fi
|
||||
|
||||
docker_args+=("$image" -lc "$inner_script")
|
||||
|
||||
exec docker "${docker_args[@]}"
|
||||
exec codex --no-alt-screen -C "$workspace"
|
||||
|
||||
Executable
+185
@@ -0,0 +1,185 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
zellij_ai_default_session() {
|
||||
printf '%s\n' "constant"
|
||||
}
|
||||
|
||||
zellij_ai_default_local_session() {
|
||||
printf '%s\n' "constant-fleet"
|
||||
}
|
||||
|
||||
zellij_ai_default_repo_dir() {
|
||||
printf '%s\n' '$HOME/constant'
|
||||
}
|
||||
|
||||
zellij_ai_default_codex_home() {
|
||||
printf '%s\n' '$HOME/.codex-profiles/codex'
|
||||
}
|
||||
|
||||
zellij_ai_default_codex_image() {
|
||||
printf '%s\n' "codercom/code-server:latest"
|
||||
}
|
||||
|
||||
zellij_ai_runtime_root() {
|
||||
printf '%s\n' "${ZELLIJ_AI_RUNTIME_ROOT:-$HOME/.cache/constant/zellij}"
|
||||
}
|
||||
|
||||
zellij_ai_session_state_dir() {
|
||||
printf '%s\n' "$(zellij_ai_runtime_root)/$1"
|
||||
}
|
||||
|
||||
zellij_ai_fleet_state_dir() {
|
||||
printf '%s\n' "$(zellij_ai_runtime_root)/$1-fleet"
|
||||
}
|
||||
|
||||
zellij_ai_bridge_cache_dir() {
|
||||
printf '%s\n' "$(zellij_ai_runtime_root)/bridge-cache"
|
||||
}
|
||||
|
||||
zellij_ai_default_machine_specs() {
|
||||
cat <<'EOF'
|
||||
command-center=local
|
||||
builder-a=dev@builder-a
|
||||
builder-b=dev@builder-b
|
||||
edge-a=dev@edge-a
|
||||
lab-a=dev@lab-a
|
||||
EOF
|
||||
}
|
||||
|
||||
zellij_ai_strip_ansi() {
|
||||
sed -E 's/\x1b\[[0-9;]*m//g'
|
||||
}
|
||||
|
||||
zellij_ai_session_exists() {
|
||||
zellij list-sessions 2>/dev/null | zellij_ai_strip_ansi | awk '{print $1}' | grep -Fxq "$1"
|
||||
}
|
||||
|
||||
zellij_ai_kdl_escape() {
|
||||
printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g'
|
||||
}
|
||||
|
||||
zellij_ai_expand_home_path() {
|
||||
case "$1" in
|
||||
'$HOME'|'$HOME/'*)
|
||||
printf '%s\n' "${HOME}${1#\$HOME}"
|
||||
;;
|
||||
"~")
|
||||
printf '%s\n' "$HOME"
|
||||
;;
|
||||
"~/"*)
|
||||
printf '%s\n' "$HOME/${1#~/}"
|
||||
;;
|
||||
*)
|
||||
printf '%s\n' "$1"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
zellij_ai_expand_home_path_for_home() {
|
||||
local value="$1"
|
||||
local target_home="$2"
|
||||
|
||||
case "$value" in
|
||||
'$HOME'|'$HOME/'*)
|
||||
printf '%s\n' "${target_home}${value#\$HOME}"
|
||||
;;
|
||||
"~")
|
||||
printf '%s\n' "$target_home"
|
||||
;;
|
||||
"~/"*)
|
||||
printf '%s\n' "$target_home/${value#~/}"
|
||||
;;
|
||||
*)
|
||||
printf '%s\n' "$value"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
zellij_ai_is_local_target() {
|
||||
local value="$1"
|
||||
local current_full current_short
|
||||
|
||||
current_full="$(hostname || true)"
|
||||
current_short="$(hostname -s 2>/dev/null || printf '%s' "$current_full")"
|
||||
|
||||
case "$value" in
|
||||
local|localhost|127.0.0.1|::1|"${current_full}"|"${current_short}")
|
||||
return 0
|
||||
;;
|
||||
*)
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
zellij_ai_parse_machine_spec() {
|
||||
local spec="$1"
|
||||
local label target
|
||||
|
||||
if [[ "$spec" == *"="* ]]; then
|
||||
label="${spec%%=*}"
|
||||
target="${spec#*=}"
|
||||
else
|
||||
label="$spec"
|
||||
target="$spec"
|
||||
fi
|
||||
|
||||
if [[ -z "$label" || -z "$target" ]]; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
printf '%s\t%s\n' "$label" "$target"
|
||||
}
|
||||
|
||||
zellij_ai_resolve_machine_spec() {
|
||||
local needle="$1"
|
||||
local line label target
|
||||
|
||||
while IFS= read -r line; do
|
||||
[[ -z "$line" ]] && continue
|
||||
IFS=$'\t' read -r label target <<<"$(zellij_ai_parse_machine_spec "$line")" || continue
|
||||
if [[ "$needle" == "$label" || "$needle" == "$target" || "$needle" == "$line" ]]; then
|
||||
printf '%s\t%s\n' "$label" "$target"
|
||||
return 0
|
||||
fi
|
||||
done < <(zellij_ai_default_machine_specs)
|
||||
|
||||
if IFS=$'\t' read -r label target <<<"$(zellij_ai_parse_machine_spec "$needle" 2>/dev/null)"; then
|
||||
printf '%s\t%s\n' "$label" "$target"
|
||||
return 0
|
||||
fi
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
zellij_ai_require_command() {
|
||||
if ! command -v "$1" >/dev/null 2>&1; then
|
||||
echo "Required command not found: $1" >&2
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
zellij_ai_current_machine_name() {
|
||||
if [[ -n "${ZELLIJ_AI_MACHINE_NAME:-}" ]]; then
|
||||
printf '%s\n' "${ZELLIJ_AI_MACHINE_NAME}"
|
||||
else
|
||||
hostname -s 2>/dev/null || hostname || printf 'local'
|
||||
fi
|
||||
}
|
||||
|
||||
zellij_ai_local_machine_label() {
|
||||
printf '%s\n' "command-center"
|
||||
}
|
||||
|
||||
zellij_ai_agent_path() {
|
||||
local path_value="$PATH"
|
||||
|
||||
if [[ -d "$HOME/.local/bin" ]]; then
|
||||
path_value="$HOME/.local/bin:$path_value"
|
||||
fi
|
||||
if [[ -d "$HOME/.npm-global/bin" ]]; then
|
||||
path_value="$HOME/.npm-global/bin:$path_value"
|
||||
fi
|
||||
|
||||
printf '%s\n' "$path_value"
|
||||
}
|
||||
Executable
+65
@@ -0,0 +1,65 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
script_source="${BASH_SOURCE[0]:-$0}"
|
||||
while [[ -L "$script_source" ]]; do
|
||||
script_dir="$(cd "$(dirname "$script_source")" && pwd -P)"
|
||||
script_source="$(readlink "$script_source")"
|
||||
[[ "$script_source" != /* ]] && script_source="$script_dir/$script_source"
|
||||
done
|
||||
script_dir="$(cd "$(dirname "$script_source")" && pwd -P)"
|
||||
source "$script_dir/zellij-ai-common.sh"
|
||||
|
||||
workspace="${ZELLIJ_AI_WORKSPACE:-$PWD}"
|
||||
machine_name="${ZELLIJ_AI_MACHINE_NAME:-unknown}"
|
||||
session_name="${ZELLIJ_AI_SESSION:-$(zellij_ai_default_session)}"
|
||||
repo_dir="${ZELLIJ_AI_REPO_DIR:-$(cd "$script_dir/.." && pwd -P)}"
|
||||
bus_dir="${ZELLIJ_AI_BUS_DIR:-$(zellij_ai_session_state_dir "$session_name")/bus}"
|
||||
label="${ZELLIJ_AI_COPILOT_LABEL:-copilot}"
|
||||
|
||||
if [[ ! -d "$workspace" ]]; then
|
||||
echo "Workspace not found: $workspace" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
export PATH="$(zellij_ai_agent_path)"
|
||||
|
||||
if [[ -n "$repo_dir" && -d "$repo_dir/scripts" ]]; then
|
||||
export PATH="$repo_dir/scripts:$PATH"
|
||||
fi
|
||||
|
||||
if ! command -v copilot >/dev/null 2>&1; then
|
||||
echo "GitHub Copilot CLI not found on the host." >&2
|
||||
echo "Install it with: npm install -g @github/copilot" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "$bus_dir/messages"
|
||||
|
||||
export ZELLIJ_AI_ROLE="$label"
|
||||
export ZELLIJ_AI_MACHINE_NAME="$machine_name"
|
||||
export ZELLIJ_AI_SESSION="$session_name"
|
||||
export ZELLIJ_AI_BUS_DIR="$bus_dir"
|
||||
|
||||
cd "$workspace"
|
||||
|
||||
echo "Copilot pane"
|
||||
echo "machine: $machine_name"
|
||||
echo "workspace: $workspace"
|
||||
echo "bus: $bus_dir"
|
||||
if [[ -n "$repo_dir" ]]; then
|
||||
echo "helper: $repo_dir/scripts/ai-msg.sh"
|
||||
fi
|
||||
echo "login: start copilot and use /login if needed"
|
||||
echo
|
||||
|
||||
copilot
|
||||
status=$?
|
||||
|
||||
if [[ $status -ne 0 ]]; then
|
||||
echo
|
||||
echo "Copilot exited with status $status."
|
||||
echo "If macOS reports 'SecItemCopyMatching failed -50', open copilot manually once and re-run /login."
|
||||
fi
|
||||
|
||||
exit "$status"
|
||||
Executable
+42
@@ -0,0 +1,42 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
tabs_file="${ZELLIJ_AI_FLEET_TABS_FILE:-}"
|
||||
tabs_created_file="${ZELLIJ_AI_FLEET_CREATED_FILE:-}"
|
||||
current_label="${ZELLIJ_AI_FLEET_CURRENT_LABEL:-}"
|
||||
current_wrapper="${ZELLIJ_AI_FLEET_CURRENT_WRAPPER:-}"
|
||||
|
||||
if [[ -z "$current_wrapper" || ! -x "$current_wrapper" ]]; then
|
||||
echo "Fleet bootstrap wrapper not found: $current_wrapper" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -n "$current_label" ]]; then
|
||||
zellij action rename-tab "$current_label"
|
||||
fi
|
||||
|
||||
if [[ -n "$tabs_created_file" ]]; then
|
||||
mkdir -p "$(dirname "$tabs_created_file")"
|
||||
fi
|
||||
|
||||
if [[ -n "$tabs_file" && -f "$tabs_file" && -n "$tabs_created_file" && ! -f "$tabs_created_file" ]]; then
|
||||
: >"$tabs_created_file"
|
||||
|
||||
while IFS=$'\t' read -r tab_label layout_file tab_cwd; do
|
||||
if [[ -z "$tab_label" || -z "$layout_file" ]]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
if [[ -n "$tab_cwd" ]]; then
|
||||
zellij action new-tab -n "$tab_label" -l "$layout_file" --cwd "$tab_cwd"
|
||||
else
|
||||
zellij action new-tab -n "$tab_label" -l "$layout_file"
|
||||
fi
|
||||
done <"$tabs_file"
|
||||
|
||||
if [[ -n "$current_label" ]]; then
|
||||
zellij action go-to-tab-name "$current_label"
|
||||
fi
|
||||
fi
|
||||
|
||||
exec "$current_wrapper"
|
||||
Executable
+196
@@ -0,0 +1,196 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
script_source="${BASH_SOURCE[0]:-$0}"
|
||||
while [[ -L "$script_source" ]]; do
|
||||
script_dir="$(cd "$(dirname "$script_source")" && pwd -P)"
|
||||
script_source="$(readlink "$script_source")"
|
||||
[[ "$script_source" != /* ]] && script_source="$script_dir/$script_source"
|
||||
done
|
||||
script_dir="$(cd "$(dirname "$script_source")" && pwd -P)"
|
||||
repo_source="$(cd "$script_dir/.." && pwd -P)"
|
||||
source "$script_dir/zellij-ai-common.sh"
|
||||
|
||||
usage() {
|
||||
local script_name="${CONSTANT_SCRIPT_NAME:-$(basename "$0")}"
|
||||
cat <<EOF
|
||||
Usage: ${script_name} <check|install> [options]
|
||||
|
||||
Check or install the Constant fleet runtime on one or more machines.
|
||||
|
||||
Options:
|
||||
--machine SPEC Machine definition or label to target
|
||||
--repo-dir DIR Repository path on each machine
|
||||
default: $(zellij_ai_default_repo_dir)
|
||||
--codex-image IMAGE Deprecated, ignored
|
||||
--yes Required for non-interactive install mode
|
||||
-h, --help Show this help
|
||||
EOF
|
||||
}
|
||||
|
||||
warn_deprecated() {
|
||||
printf '[fleet-install] %s\n' "$*" >&2
|
||||
}
|
||||
|
||||
require_local_tools() {
|
||||
local tool
|
||||
for tool in bash ssh tar rsync; do
|
||||
zellij_ai_require_command "$tool"
|
||||
done
|
||||
}
|
||||
|
||||
remote_home() {
|
||||
ssh "$1" 'printf "%s\n" "$HOME"'
|
||||
}
|
||||
|
||||
remote_repo_dir() {
|
||||
local target="$1"
|
||||
local repo_dir_input="$2"
|
||||
local target_home
|
||||
|
||||
if zellij_ai_is_local_target "$target"; then
|
||||
zellij_ai_expand_home_path "$repo_dir_input"
|
||||
return 0
|
||||
fi
|
||||
|
||||
target_home="$(remote_home "$target")"
|
||||
zellij_ai_expand_home_path_for_home "$repo_dir_input" "$target_home"
|
||||
}
|
||||
|
||||
sync_repo_to_remote() {
|
||||
local target="$1"
|
||||
local repo_dir_input="$2"
|
||||
local target_repo_dir local_real target_real
|
||||
|
||||
target_repo_dir="$(remote_repo_dir "$target" "$repo_dir_input")"
|
||||
|
||||
if zellij_ai_is_local_target "$target"; then
|
||||
mkdir -p "$target_repo_dir"
|
||||
local_real="$repo_source"
|
||||
target_real="$(cd "$target_repo_dir" && pwd -P)"
|
||||
if [[ "$local_real" == "$target_real" ]]; then
|
||||
return 0
|
||||
fi
|
||||
rsync -a --delete --exclude '.git/' "$repo_source/" "$target_repo_dir/"
|
||||
return 0
|
||||
fi
|
||||
|
||||
ssh "$target" "mkdir -p $(printf '%q' "$target_repo_dir")"
|
||||
|
||||
if ssh "$target" 'command -v rsync >/dev/null 2>&1'; then
|
||||
rsync -a --delete --exclude '.git/' "$repo_source/" "${target}:${target_repo_dir}/"
|
||||
else
|
||||
(
|
||||
cd "$repo_source"
|
||||
tar --exclude '.git' -cf - .
|
||||
) | ssh "$target" "tar -xf - -C $(printf '%q' "$target_repo_dir")"
|
||||
fi
|
||||
}
|
||||
|
||||
mode="${1:-}"
|
||||
if [[ -z "$mode" ]]; then
|
||||
usage >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
case "$mode" in
|
||||
-h|--help|help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
|
||||
shift || true
|
||||
|
||||
machines=()
|
||||
repo_dir="$(zellij_ai_default_repo_dir)"
|
||||
yes=false
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--machine)
|
||||
machines+=("$2")
|
||||
shift 2
|
||||
;;
|
||||
--repo-dir)
|
||||
repo_dir="$2"
|
||||
shift 2
|
||||
;;
|
||||
--codex-image)
|
||||
warn_deprecated "--codex-image is deprecated and ignored."
|
||||
shift 2
|
||||
;;
|
||||
--yes)
|
||||
yes=true
|
||||
shift
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "Unknown option: $1" >&2
|
||||
usage >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
case "$mode" in
|
||||
check|install)
|
||||
;;
|
||||
*)
|
||||
echo "Unknown mode: $mode" >&2
|
||||
usage >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
|
||||
require_local_tools
|
||||
|
||||
if [[ ${#machines[@]} -eq 0 ]]; then
|
||||
mapfile -t machines < <(zellij_ai_default_machine_specs)
|
||||
fi
|
||||
|
||||
overall_status=0
|
||||
|
||||
for machine_spec in "${machines[@]}"; do
|
||||
if ! resolved="$(zellij_ai_resolve_machine_spec "$machine_spec")"; then
|
||||
echo "Unknown machine: $machine_spec" >&2
|
||||
overall_status=1
|
||||
continue
|
||||
fi
|
||||
|
||||
IFS=$'\t' read -r label target <<<"$resolved"
|
||||
printf '=== %s (%s) ===\n' "$label" "$target"
|
||||
|
||||
target_repo_dir="$(remote_repo_dir "$target" "$repo_dir")"
|
||||
|
||||
if [[ "$mode" == "install" ]]; then
|
||||
sync_repo_to_remote "$target" "$repo_dir" || overall_status=1
|
||||
fi
|
||||
|
||||
install_args=("$mode" --repo-dir "$repo_dir")
|
||||
if $yes; then
|
||||
install_args+=(--yes)
|
||||
fi
|
||||
|
||||
if zellij_ai_is_local_target "$target"; then
|
||||
ZELLIJ_AI_MACHINE_NAME="$label" "$script_dir/zellij-ai-machine-install.sh" "${install_args[@]}" || overall_status=1
|
||||
continue
|
||||
fi
|
||||
|
||||
if [[ "$mode" == "install" ]]; then
|
||||
remote_script="${target_repo_dir}/scripts/zellij-ai-machine-install.sh"
|
||||
ssh -tt "$target" env ZELLIJ_AI_MACHINE_NAME="$label" "$remote_script" "${install_args[@]}" || overall_status=1
|
||||
continue
|
||||
fi
|
||||
|
||||
if $yes; then
|
||||
ssh "$target" env ZELLIJ_AI_MACHINE_NAME="$label" bash -s -- "${install_args[@]}" <"$script_dir/zellij-ai-machine-install.sh" || overall_status=1
|
||||
else
|
||||
ssh "$target" env ZELLIJ_AI_MACHINE_NAME="$label" bash -s -- "${install_args[@]}" <"$script_dir/zellij-ai-machine-install.sh" || overall_status=1
|
||||
fi
|
||||
done
|
||||
|
||||
exit "$overall_status"
|
||||
Executable
+250
@@ -0,0 +1,250 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
script_source="${BASH_SOURCE[0]:-$0}"
|
||||
while [[ -L "$script_source" ]]; do
|
||||
script_dir="$(cd "$(dirname "$script_source")" && pwd -P)"
|
||||
script_source="$(readlink "$script_source")"
|
||||
[[ "$script_source" != /* ]] && script_source="$script_dir/$script_source"
|
||||
done
|
||||
script_dir="$(cd "$(dirname "$script_source")" && pwd -P)"
|
||||
repo_dir="$(cd "$script_dir/.." && pwd -P)"
|
||||
source "$script_dir/zellij-ai-common.sh"
|
||||
|
||||
warn_deprecated() {
|
||||
printf 'Warning: %s\n' "$*" >&2
|
||||
}
|
||||
|
||||
usage() {
|
||||
local script_name="${CONSTANT_SCRIPT_NAME:-$(basename "$0")}"
|
||||
cat <<EOF
|
||||
Usage: ${script_name} [options]
|
||||
|
||||
Open a local Zellij cockpit with one tab per machine.
|
||||
Each tab attaches to the machine's own local Constant machine session.
|
||||
|
||||
Options:
|
||||
--machine SPEC Machine definition. Repeat once per machine.
|
||||
format: [TAB_NAME=]TARGET
|
||||
default fleet: command-center, builder-a, builder-b, edge-a, lab-a
|
||||
--session NAME Remote/local machine session name
|
||||
default: $(zellij_ai_default_session)
|
||||
--local-session NAME Cockpit session name on this machine
|
||||
default: $(zellij_ai_default_local_session)
|
||||
--repo-dir DIR Repository path on each machine
|
||||
default: $(zellij_ai_default_repo_dir)
|
||||
--workspace DIR Workspace path passed to each machine session
|
||||
required for the fixed 5-machine fleet
|
||||
--codex-image IMAGE Deprecated, ignored
|
||||
--claude-config DIR Optional Claude config override passed to each machine
|
||||
--remote-recreate Recreate each machine session before attaching
|
||||
--recreate Recreate the local cockpit session before launching
|
||||
-h, --help Show this help
|
||||
EOF
|
||||
}
|
||||
|
||||
machines=()
|
||||
remote_session="$(zellij_ai_default_session)"
|
||||
local_session="$(zellij_ai_default_local_session)"
|
||||
remote_repo_dir="$(zellij_ai_default_repo_dir)"
|
||||
workspace=""
|
||||
codex_image=""
|
||||
claude_config_dir=""
|
||||
recreate=false
|
||||
remote_recreate=false
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--machine)
|
||||
machines+=("$2")
|
||||
shift 2
|
||||
;;
|
||||
--session)
|
||||
remote_session="$2"
|
||||
shift 2
|
||||
;;
|
||||
--local-session)
|
||||
local_session="$2"
|
||||
shift 2
|
||||
;;
|
||||
--repo-dir)
|
||||
remote_repo_dir="$2"
|
||||
shift 2
|
||||
;;
|
||||
--workspace)
|
||||
workspace="$2"
|
||||
shift 2
|
||||
;;
|
||||
--codex-image)
|
||||
warn_deprecated "--codex-image is deprecated and ignored; Codex now runs on the host."
|
||||
codex_image="$2"
|
||||
shift 2
|
||||
;;
|
||||
--claude-config)
|
||||
claude_config_dir="$2"
|
||||
shift 2
|
||||
;;
|
||||
--remote-recreate)
|
||||
remote_recreate=true
|
||||
shift
|
||||
;;
|
||||
--recreate)
|
||||
recreate=true
|
||||
shift
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "Unknown option: $1" >&2
|
||||
usage >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ ${#machines[@]} -eq 0 ]]; then
|
||||
mapfile -t machines < <(zellij_ai_default_machine_specs)
|
||||
fi
|
||||
|
||||
if [[ -z "$workspace" ]]; then
|
||||
echo "--workspace is required for fleet mode." >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
workspace="$(cd "$workspace" && pwd -P)"
|
||||
|
||||
for required in \
|
||||
zellij \
|
||||
ssh \
|
||||
"$script_dir/zellij-ai-common.sh" \
|
||||
"$script_dir/zellij-ai-fleet-bootstrap.sh" \
|
||||
"$script_dir/zellij-ai-remote-tab.sh"
|
||||
do
|
||||
if [[ "$required" == */* ]]; then
|
||||
if [[ ! -x "$required" ]]; then
|
||||
echo "Helper script not executable: $required" >&2
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
zellij_ai_require_command "$required"
|
||||
fi
|
||||
done
|
||||
|
||||
state_dir="$(zellij_ai_fleet_state_dir "$local_session")"
|
||||
tabs_file="$state_dir/tabs.tsv"
|
||||
tabs_created_file="$state_dir/tabs-created"
|
||||
first_layout_file="$state_dir/first-tab.kdl"
|
||||
mkdir -p "$state_dir"
|
||||
: >"$tabs_file"
|
||||
rm -f "$tabs_created_file"
|
||||
|
||||
bootstrap_path="$(zellij_ai_kdl_escape "$script_dir/zellij-ai-fleet-bootstrap.sh")"
|
||||
|
||||
first_label=""
|
||||
first_wrapper=""
|
||||
seen_labels='|'
|
||||
machine_index=0
|
||||
|
||||
for machine_spec in "${machines[@]}"; do
|
||||
if ! parsed_spec="$(zellij_ai_parse_machine_spec "$machine_spec")"; then
|
||||
echo "Invalid machine spec: $machine_spec" >&2
|
||||
exit 2
|
||||
fi
|
||||
IFS=$'\t' read -r machine_label machine_target <<<"$parsed_spec"
|
||||
|
||||
if [[ "$seen_labels" == *"|${machine_label}|"* ]]; then
|
||||
echo "Duplicate machine label: $machine_label" >&2
|
||||
exit 2
|
||||
fi
|
||||
seen_labels+="${machine_label}|"
|
||||
|
||||
wrapper_path="$state_dir/machine-${machine_index}.sh"
|
||||
layout_path="$state_dir/machine-${machine_index}.kdl"
|
||||
|
||||
wrapper_cmd=(
|
||||
"$script_dir/zellij-ai-remote-tab.sh"
|
||||
--label "$machine_label"
|
||||
--target "$machine_target"
|
||||
--session "$remote_session"
|
||||
--repo-dir "$remote_repo_dir"
|
||||
)
|
||||
|
||||
if [[ -n "$workspace" ]]; then
|
||||
wrapper_cmd+=(--workspace "$workspace")
|
||||
fi
|
||||
if [[ -n "$codex_image" ]]; then
|
||||
wrapper_cmd+=(--codex-image "$codex_image")
|
||||
fi
|
||||
if [[ -n "$claude_config_dir" ]]; then
|
||||
wrapper_cmd+=(--claude-config "$claude_config_dir")
|
||||
fi
|
||||
if $remote_recreate; then
|
||||
wrapper_cmd+=(--remote-recreate)
|
||||
fi
|
||||
|
||||
printf -v wrapper_exec '%q ' "${wrapper_cmd[@]}"
|
||||
wrapper_exec="${wrapper_exec% }"
|
||||
|
||||
cat >"$wrapper_path" <<EOF
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
exec $wrapper_exec
|
||||
EOF
|
||||
chmod +x "$wrapper_path"
|
||||
|
||||
wrapper_path_escaped="$(zellij_ai_kdl_escape "$wrapper_path")"
|
||||
cat >"$layout_path" <<EOF
|
||||
layout {
|
||||
pane size=1 borderless=true {
|
||||
plugin location="tab-bar"
|
||||
}
|
||||
pane command="$wrapper_path_escaped"
|
||||
pane size=2 borderless=true {
|
||||
plugin location="status-bar"
|
||||
}
|
||||
}
|
||||
EOF
|
||||
|
||||
if [[ $machine_index -eq 0 ]]; then
|
||||
first_label="$machine_label"
|
||||
first_wrapper="$wrapper_path"
|
||||
else
|
||||
printf '%s\t%s\t%s\n' "$machine_label" "$layout_path" "$repo_dir" >>"$tabs_file"
|
||||
fi
|
||||
|
||||
machine_index=$((machine_index + 1))
|
||||
done
|
||||
|
||||
if [[ -z "$first_label" || -z "$first_wrapper" ]]; then
|
||||
echo "No machine tabs were generated" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cat >"$first_layout_file" <<EOF
|
||||
layout {
|
||||
pane size=1 borderless=true {
|
||||
plugin location="tab-bar"
|
||||
}
|
||||
pane command="$bootstrap_path"
|
||||
pane size=2 borderless=true {
|
||||
plugin location="status-bar"
|
||||
}
|
||||
}
|
||||
EOF
|
||||
|
||||
export ZELLIJ_AI_FLEET_TABS_FILE="$tabs_file"
|
||||
export ZELLIJ_AI_FLEET_CREATED_FILE="$tabs_created_file"
|
||||
export ZELLIJ_AI_FLEET_CURRENT_LABEL="$first_label"
|
||||
export ZELLIJ_AI_FLEET_CURRENT_WRAPPER="$first_wrapper"
|
||||
|
||||
if $recreate && zellij_ai_session_exists "$local_session"; then
|
||||
zellij kill-session "$local_session" >/dev/null 2>&1 || true
|
||||
fi
|
||||
|
||||
if zellij_ai_session_exists "$local_session"; then
|
||||
exec zellij attach "$local_session"
|
||||
fi
|
||||
|
||||
exec zellij --new-session-with-layout "$first_layout_file" --session "$local_session"
|
||||
Executable
+411
@@ -0,0 +1,411 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
script_source="${BASH_SOURCE[0]:-$0}"
|
||||
while [[ -L "$script_source" ]]; do
|
||||
script_dir="$(cd "$(dirname "$script_source")" && pwd -P)"
|
||||
script_source="$(readlink "$script_source")"
|
||||
[[ "$script_source" != /* ]] && script_source="$script_dir/$script_source"
|
||||
done
|
||||
script_dir="$(cd "$(dirname "$script_source")" && pwd -P)"
|
||||
if [[ -f "$script_dir/zellij-ai-common.sh" ]]; then
|
||||
source "$script_dir/zellij-ai-common.sh"
|
||||
else
|
||||
zellij_ai_default_repo_dir() {
|
||||
printf '%s\n' '$HOME/constant'
|
||||
}
|
||||
|
||||
zellij_ai_default_codex_home() {
|
||||
printf '%s\n' '$HOME/.codex-profiles/codex'
|
||||
}
|
||||
|
||||
zellij_ai_expand_home_path() {
|
||||
case "$1" in
|
||||
'$HOME'|'$HOME/'*)
|
||||
printf '%s\n' "${HOME}${1#\$HOME}"
|
||||
;;
|
||||
"~")
|
||||
printf '%s\n' "$HOME"
|
||||
;;
|
||||
"~/"*)
|
||||
printf '%s\n' "$HOME/${1#~/}"
|
||||
;;
|
||||
*)
|
||||
printf '%s\n' "$1"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
zellij_ai_current_machine_name() {
|
||||
if [[ -n "${ZELLIJ_AI_MACHINE_NAME:-}" ]]; then
|
||||
printf '%s\n' "${ZELLIJ_AI_MACHINE_NAME}"
|
||||
else
|
||||
hostname -s 2>/dev/null || hostname || printf 'local'
|
||||
fi
|
||||
}
|
||||
|
||||
zellij_ai_agent_path() {
|
||||
local path_value="$PATH"
|
||||
|
||||
if [[ -d "$HOME/.local/bin" ]]; then
|
||||
path_value="$HOME/.local/bin:$path_value"
|
||||
fi
|
||||
if [[ -d "$HOME/.npm-global/bin" ]]; then
|
||||
path_value="$HOME/.npm-global/bin:$path_value"
|
||||
fi
|
||||
|
||||
printf '%s\n' "$path_value"
|
||||
}
|
||||
fi
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
Usage: $(basename "$0") <check|install> [options]
|
||||
|
||||
Options:
|
||||
--repo-dir DIR Repository path on this machine
|
||||
default: $(zellij_ai_default_repo_dir)
|
||||
--codex-image IMAGE Deprecated, ignored
|
||||
--yes Required for non-interactive install mode
|
||||
-h, --help Show this help
|
||||
EOF
|
||||
}
|
||||
|
||||
log() {
|
||||
printf '[install] %s\n' "$*"
|
||||
}
|
||||
|
||||
fail() {
|
||||
printf '[install] %s\n' "$*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
confirm_install() {
|
||||
if $yes; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [[ ! -t 0 ]]; then
|
||||
fail "Install mode requires --yes when stdin is not interactive."
|
||||
fi
|
||||
|
||||
printf 'Proceed with installation changes on this machine? [y/N] '
|
||||
read -r answer
|
||||
[[ "$answer" == "y" || "$answer" == "Y" ]]
|
||||
}
|
||||
|
||||
need_sudo() {
|
||||
[[ "${EUID:-$(id -u)}" -ne 0 ]]
|
||||
}
|
||||
|
||||
run_root() {
|
||||
if ! need_sudo; then
|
||||
"$@"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if command -v sudo >/dev/null 2>&1; then
|
||||
sudo "$@"
|
||||
return 0
|
||||
fi
|
||||
|
||||
fail "sudo is required to run: $*"
|
||||
}
|
||||
|
||||
ensure_command() {
|
||||
command -v "$1" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
warn_deprecated() {
|
||||
printf '[install] %s\n' "$*" >&2
|
||||
}
|
||||
|
||||
refresh_path() {
|
||||
export PATH="$(zellij_ai_agent_path)"
|
||||
|
||||
if [[ -x /opt/homebrew/bin/brew ]]; then
|
||||
eval "$(/opt/homebrew/bin/brew shellenv)"
|
||||
elif [[ -x /usr/local/bin/brew ]]; then
|
||||
eval "$(/usr/local/bin/brew shellenv)"
|
||||
fi
|
||||
}
|
||||
|
||||
ensure_homebrew() {
|
||||
if command -v brew >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
ensure_command curl || fail "curl is required to install Homebrew."
|
||||
NONINTERACTIVE=1 /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
|
||||
refresh_path
|
||||
ensure_command brew || fail "Homebrew installation did not make brew available."
|
||||
}
|
||||
|
||||
ensure_uv() {
|
||||
if command -v uv >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
case "$os_name" in
|
||||
Darwin)
|
||||
ensure_homebrew
|
||||
brew install uv
|
||||
;;
|
||||
Linux)
|
||||
ensure_command curl || fail "curl is required to install uv."
|
||||
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
;;
|
||||
*)
|
||||
fail "Unsupported OS for uv installation: $os_name"
|
||||
;;
|
||||
esac
|
||||
|
||||
refresh_path
|
||||
ensure_command uv || fail "uv installation did not make uv available."
|
||||
}
|
||||
|
||||
ensure_npm_global() {
|
||||
local package_name="$1"
|
||||
local binary_name="$2"
|
||||
|
||||
if command -v "$binary_name" >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
mkdir -p "$HOME/.npm-global"
|
||||
export PATH="$(zellij_ai_agent_path)"
|
||||
|
||||
if npm install -g --prefix "$HOME/.npm-global" "$package_name"; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
fail "Failed to install ${package_name} with npm."
|
||||
}
|
||||
|
||||
ensure_uv_tool() {
|
||||
local package_name="$1"
|
||||
local binary_name="$2"
|
||||
|
||||
if command -v "$binary_name" >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
ensure_uv
|
||||
uv tool install "$package_name"
|
||||
refresh_path
|
||||
ensure_command "$binary_name" || fail "Failed to install ${package_name} with uv."
|
||||
}
|
||||
|
||||
os_name="$(uname -s)"
|
||||
linux_id=""
|
||||
repo_dir="$(zellij_ai_expand_home_path "$(zellij_ai_default_repo_dir)")"
|
||||
codex_home="$(zellij_ai_expand_home_path "$(zellij_ai_default_codex_home)")"
|
||||
yes=false
|
||||
|
||||
refresh_path
|
||||
|
||||
if [[ -f /etc/os-release ]]; then
|
||||
# shellcheck disable=SC1091
|
||||
source /etc/os-release
|
||||
linux_id="${ID:-}"
|
||||
fi
|
||||
|
||||
if [[ $# -lt 1 ]]; then
|
||||
usage >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
mode="$1"
|
||||
|
||||
case "$mode" in
|
||||
-h|--help|help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
|
||||
shift
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--repo-dir)
|
||||
repo_dir="$(zellij_ai_expand_home_path "$2")"
|
||||
shift 2
|
||||
;;
|
||||
--codex-image)
|
||||
warn_deprecated "--codex-image is deprecated and ignored."
|
||||
shift 2
|
||||
;;
|
||||
--yes)
|
||||
yes=true
|
||||
shift
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "Unknown option: $1" >&2
|
||||
usage >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
print_status() {
|
||||
local key="$1"
|
||||
local value="$2"
|
||||
printf '%s=%s\n' "$key" "$value"
|
||||
}
|
||||
|
||||
run_check() {
|
||||
local ok=true
|
||||
local repo_state="missing"
|
||||
local codex_home_state="missing"
|
||||
local triple_help_state="missing"
|
||||
local binary
|
||||
|
||||
refresh_path
|
||||
|
||||
print_status machine "$(zellij_ai_current_machine_name)"
|
||||
print_status os "$os_name"
|
||||
[[ -n "$linux_id" ]] && print_status linux_id "$linux_id"
|
||||
print_status repo_dir "$repo_dir"
|
||||
print_status codex_home "$codex_home"
|
||||
|
||||
for binary in bash git zellij node npm uv claude codex copilot vibe; do
|
||||
if command -v "$binary" >/dev/null 2>&1; then
|
||||
print_status "$binary" "$(command -v "$binary")"
|
||||
else
|
||||
print_status "$binary" "missing"
|
||||
ok=false
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ -x "$repo_dir/scripts/constant-machine.sh" || -x "$repo_dir/scripts/zellij-ai-triple.sh" ]]; then
|
||||
repo_state="present"
|
||||
else
|
||||
ok=false
|
||||
fi
|
||||
print_status repo "$repo_state"
|
||||
|
||||
if [[ -d "$codex_home" ]]; then
|
||||
codex_home_state="present"
|
||||
else
|
||||
ok=false
|
||||
fi
|
||||
print_status codex_profile "$codex_home_state"
|
||||
|
||||
if [[ -x "$repo_dir/scripts/constant-machine.sh" ]] && "$repo_dir/scripts/constant-machine.sh" --help >/dev/null 2>&1; then
|
||||
triple_help_state="ok"
|
||||
elif [[ -x "$repo_dir/scripts/zellij-ai-triple.sh" ]] && "$repo_dir/scripts/zellij-ai-triple.sh" --help >/dev/null 2>&1; then
|
||||
triple_help_state="ok"
|
||||
else
|
||||
ok=false
|
||||
fi
|
||||
print_status triple_help "$triple_help_state"
|
||||
|
||||
$ok
|
||||
}
|
||||
|
||||
install_darwin() {
|
||||
ensure_homebrew
|
||||
brew install git zellij node uv
|
||||
}
|
||||
|
||||
install_linux_ubuntu() {
|
||||
run_root apt-get update
|
||||
run_root apt-get install -y git curl
|
||||
|
||||
if ! command -v node >/dev/null 2>&1; then
|
||||
run_root apt-get install -y nodejs
|
||||
fi
|
||||
|
||||
if ! command -v npm >/dev/null 2>&1; then
|
||||
run_root apt-get install -y npm
|
||||
fi
|
||||
|
||||
if ! command -v zellij >/dev/null 2>&1; then
|
||||
if command -v snap >/dev/null 2>&1; then
|
||||
run_root snap install zellij --classic
|
||||
else
|
||||
run_root apt-get install -y zellij
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
install_linux_photon() {
|
||||
local packages=()
|
||||
|
||||
if ! command -v git >/dev/null 2>&1; then
|
||||
packages+=(git)
|
||||
fi
|
||||
if ! command -v curl >/dev/null 2>&1; then
|
||||
packages+=(curl)
|
||||
fi
|
||||
if ! command -v node >/dev/null 2>&1; then
|
||||
packages+=(nodejs)
|
||||
fi
|
||||
if ! command -v npm >/dev/null 2>&1; then
|
||||
packages+=(npm)
|
||||
fi
|
||||
|
||||
if [[ ${#packages[@]} -gt 0 ]]; then
|
||||
run_root tdnf install -y "${packages[@]}"
|
||||
fi
|
||||
|
||||
if ! command -v zellij >/dev/null 2>&1; then
|
||||
fail "zellij is missing on Photon OS and no native install path is scripted here."
|
||||
fi
|
||||
}
|
||||
|
||||
run_install() {
|
||||
confirm_install || fail "Installation cancelled."
|
||||
|
||||
mkdir -p "$repo_dir"
|
||||
|
||||
case "$os_name" in
|
||||
Darwin)
|
||||
install_darwin
|
||||
;;
|
||||
Linux)
|
||||
case "$linux_id" in
|
||||
ubuntu|debian)
|
||||
install_linux_ubuntu
|
||||
;;
|
||||
photon)
|
||||
install_linux_photon
|
||||
;;
|
||||
*)
|
||||
fail "Unsupported Linux distribution: ${linux_id:-unknown}"
|
||||
;;
|
||||
esac
|
||||
;;
|
||||
*)
|
||||
fail "Unsupported OS: $os_name"
|
||||
;;
|
||||
esac
|
||||
|
||||
refresh_path
|
||||
mkdir -p "$codex_home"
|
||||
ensure_npm_global "@anthropic-ai/claude-code" claude
|
||||
ensure_npm_global "@openai/codex" codex
|
||||
ensure_npm_global "@github/copilot" copilot
|
||||
ensure_uv_tool "mistral-vibe" vibe
|
||||
|
||||
run_check
|
||||
}
|
||||
|
||||
case "$mode" in
|
||||
check)
|
||||
run_check
|
||||
;;
|
||||
install)
|
||||
run_install
|
||||
;;
|
||||
*)
|
||||
echo "Unknown mode: $mode" >&2
|
||||
usage >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
Executable
+206
@@ -0,0 +1,206 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
script_source="${BASH_SOURCE[0]:-$0}"
|
||||
while [[ -L "$script_source" ]]; do
|
||||
script_dir="$(cd "$(dirname "$script_source")" && pwd -P)"
|
||||
script_source="$(readlink "$script_source")"
|
||||
[[ "$script_source" != /* ]] && script_source="$script_dir/$script_source"
|
||||
done
|
||||
script_dir="$(cd "$(dirname "$script_source")" && pwd -P)"
|
||||
source "$script_dir/zellij-ai-common.sh"
|
||||
|
||||
warn_deprecated() {
|
||||
printf 'Warning: %s\n' "$*" >&2
|
||||
}
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage: zellij-ai-remote-tab.sh [options]
|
||||
|
||||
Open or attach the local AI Zellij session for one machine.
|
||||
|
||||
Options:
|
||||
--label NAME Display label for logs and tab naming
|
||||
--target TARGET SSH target, or "local" for the current machine
|
||||
--session NAME Remote/local Zellij session name
|
||||
default: constant
|
||||
--repo-dir DIR Repository path on the target machine
|
||||
default: $HOME/constant
|
||||
--workspace DIR Workspace path passed to constant-machine.sh
|
||||
--codex-image IMAGE Deprecated, ignored
|
||||
--claude-config DIR Optional Claude config override
|
||||
--remote-recreate Recreate the target machine session before attaching
|
||||
-h, --help Show this help
|
||||
EOF
|
||||
}
|
||||
|
||||
label=""
|
||||
target=""
|
||||
session="$(zellij_ai_default_session)"
|
||||
repo_dir="$(zellij_ai_default_repo_dir)"
|
||||
workspace=""
|
||||
codex_image=""
|
||||
claude_config_dir=""
|
||||
remote_recreate=false
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--label)
|
||||
label="$2"
|
||||
shift 2
|
||||
;;
|
||||
--target)
|
||||
target="$2"
|
||||
shift 2
|
||||
;;
|
||||
--session)
|
||||
session="$2"
|
||||
shift 2
|
||||
;;
|
||||
--repo-dir)
|
||||
repo_dir="$2"
|
||||
shift 2
|
||||
;;
|
||||
--workspace)
|
||||
workspace="$2"
|
||||
shift 2
|
||||
;;
|
||||
--codex-image)
|
||||
warn_deprecated "--codex-image is deprecated and ignored; Codex now runs on the host."
|
||||
codex_image="$2"
|
||||
shift 2
|
||||
;;
|
||||
--claude-config)
|
||||
claude_config_dir="$2"
|
||||
shift 2
|
||||
;;
|
||||
--remote-recreate)
|
||||
remote_recreate=true
|
||||
shift
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "Unknown option: $1" >&2
|
||||
usage >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ -z "$target" ]]; then
|
||||
echo "--target is required" >&2
|
||||
usage >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
if [[ -z "$label" ]]; then
|
||||
label="$target"
|
||||
fi
|
||||
|
||||
if zellij_ai_is_local_target "$target"; then
|
||||
local_repo_dir="$(zellij_ai_expand_home_path "$repo_dir")"
|
||||
local_workspace="$(zellij_ai_expand_home_path "$workspace")"
|
||||
local_claude_config_dir="$(zellij_ai_expand_home_path "$claude_config_dir")"
|
||||
local_launcher="$local_repo_dir/scripts/constant-machine.sh"
|
||||
if [[ ! -x "$local_launcher" ]]; then
|
||||
local_launcher="$local_repo_dir/scripts/zellij-ai-triple.sh"
|
||||
fi
|
||||
|
||||
if [[ ! -x "$local_launcher" ]]; then
|
||||
echo "Local launcher not found for $label: $local_launcher" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cmd=("$local_launcher" --session "$session")
|
||||
|
||||
if [[ -n "$workspace" ]]; then
|
||||
cmd+=(--workspace "$local_workspace")
|
||||
fi
|
||||
if [[ -n "$codex_image" ]]; then
|
||||
cmd+=(--codex-image "$codex_image")
|
||||
fi
|
||||
if [[ -n "$claude_config_dir" ]]; then
|
||||
cmd+=(--claude-config "$local_claude_config_dir")
|
||||
fi
|
||||
if $remote_recreate; then
|
||||
cmd+=(--recreate)
|
||||
fi
|
||||
|
||||
exec env ZELLIJ_AI_MACHINE_NAME="$label" "${cmd[@]}"
|
||||
fi
|
||||
|
||||
printf -v session_q '%q' "$session"
|
||||
printf -v repo_dir_q '%q' "$repo_dir"
|
||||
printf -v workspace_q '%q' "$workspace"
|
||||
printf -v codex_image_q '%q' "$codex_image"
|
||||
printf -v claude_config_dir_q '%q' "$claude_config_dir"
|
||||
printf -v label_q '%q' "$label"
|
||||
remote_recreate_literal=false
|
||||
if $remote_recreate; then
|
||||
remote_recreate_literal=true
|
||||
fi
|
||||
|
||||
read -r -d '' remote_shell <<EOF || true
|
||||
set -euo pipefail
|
||||
|
||||
expand_home_path() {
|
||||
case "\$1" in
|
||||
'\$HOME'|'\$HOME/'*)
|
||||
printf '%s\n' "\${HOME}\${1#\\\$HOME}"
|
||||
;;
|
||||
"~")
|
||||
printf '%s\n' "\$HOME"
|
||||
;;
|
||||
"~/"*)
|
||||
printf '%s\n' "\$HOME/\${1#~/}"
|
||||
;;
|
||||
*)
|
||||
printf '%s\n' "\$1"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
session=$session_q
|
||||
repo_dir_input=$repo_dir_q
|
||||
workspace_input=$workspace_q
|
||||
codex_image_input=$codex_image_q
|
||||
claude_config_dir_input=$claude_config_dir_q
|
||||
remote_recreate=$remote_recreate_literal
|
||||
|
||||
repo_dir="\$(expand_home_path "\$repo_dir_input")"
|
||||
workspace="\$(expand_home_path "\$workspace_input")"
|
||||
claude_config_dir="\$(expand_home_path "\$claude_config_dir_input")"
|
||||
launcher="\$repo_dir/scripts/constant-machine.sh"
|
||||
if [[ ! -x "\$launcher" ]]; then
|
||||
launcher="\$repo_dir/scripts/zellij-ai-triple.sh"
|
||||
fi
|
||||
if [[ ! -x "\$launcher" ]]; then
|
||||
echo "Remote launcher not found: \$launcher" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cmd=("\$launcher" --session "\$session")
|
||||
|
||||
if [[ -n "\$workspace_input" ]]; then
|
||||
cmd+=(--workspace "\$workspace")
|
||||
fi
|
||||
if [[ -n "\$codex_image_input" ]]; then
|
||||
cmd+=(--codex-image "\$codex_image_input")
|
||||
fi
|
||||
if [[ -n "\$claude_config_dir_input" ]]; then
|
||||
cmd+=(--claude-config "\$claude_config_dir")
|
||||
fi
|
||||
if \$remote_recreate; then
|
||||
cmd+=(--recreate)
|
||||
fi
|
||||
|
||||
export ZELLIJ_AI_MACHINE_NAME=$label_q
|
||||
export ZELLIJ_AI_FORCE_OSC52=true
|
||||
exec "\${cmd[@]}"
|
||||
EOF
|
||||
|
||||
exec ssh -tt "$target" "bash -lc $(printf '%q' "$remote_shell")"
|
||||
+110
-60
@@ -1,61 +1,64 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)"
|
||||
script_source="${BASH_SOURCE[0]:-$0}"
|
||||
while [[ -L "$script_source" ]]; do
|
||||
script_dir="$(cd "$(dirname "$script_source")" && pwd -P)"
|
||||
script_source="$(readlink "$script_source")"
|
||||
[[ "$script_source" != /* ]] && script_source="$script_dir/$script_source"
|
||||
done
|
||||
script_dir="$(cd "$(dirname "$script_source")" && pwd -P)"
|
||||
repo_dir="$(cd "$script_dir/.." && pwd -P)"
|
||||
source "$script_dir/zellij-ai-common.sh"
|
||||
|
||||
usage() {
|
||||
local script_name="${CONSTANT_SCRIPT_NAME:-$(basename "$0")}"
|
||||
cat <<EOF
|
||||
Usage: $(basename "$0") [options]
|
||||
Usage: ${script_name} [options]
|
||||
|
||||
Start a 3-pane Zellij session:
|
||||
Start a 4-pane Constant machine session:
|
||||
- left pane: Claude on the host
|
||||
- top-right pane: Codex in Docker with profile 1
|
||||
- bottom-right pane: Codex in Docker with profile 2
|
||||
- top-right pane: Codex on the host
|
||||
- middle-right pane: Copilot CLI on the host
|
||||
- bottom-right pane: Mistral Vibe on the host
|
||||
|
||||
Options:
|
||||
--workspace DIR Workspace mounted in both Codex containers and used by Claude
|
||||
--workspace DIR Workspace used by all panes
|
||||
default: current directory
|
||||
--session NAME Zellij session name
|
||||
default: ai-triple
|
||||
--codex-image IMAGE Docker image used for Codex panes
|
||||
default: codercom/code-server:latest
|
||||
--codex1-home DIR Persistent CODEX_HOME for the first Codex pane
|
||||
default: \$HOME/.codex-profiles/codex-1
|
||||
--codex2-home DIR Persistent CODEX_HOME for the second Codex pane
|
||||
default: \$HOME/.codex-profiles/codex-2
|
||||
--codex1-label LABEL Display label for the first Codex pane
|
||||
default: codex-1
|
||||
--codex2-label LABEL Display label for the second Codex pane
|
||||
default: codex-2
|
||||
--session NAME Session name
|
||||
default: $(zellij_ai_default_session)
|
||||
--codex-home DIR Persistent CODEX_HOME for the Codex pane
|
||||
default: $(zellij_ai_default_codex_home)
|
||||
--codex-label LABEL Display label for the Codex pane
|
||||
default: codex
|
||||
--copilot-label LABEL Display label for the Copilot pane
|
||||
default: copilot
|
||||
--vibe-label LABEL Display label for the Vibe pane
|
||||
default: vibe
|
||||
--claude-config DIR Optional CLAUDE_CONFIG_DIR override for the Claude pane
|
||||
--zellij-config-dir DIR
|
||||
Optional isolated Zellij config dir to use for session creation
|
||||
Optional isolated Zellij config dir for session creation
|
||||
default: a fresh temporary config dir per new session
|
||||
--recreate Kill the existing session before recreating it
|
||||
--codex-image IMAGE Deprecated, ignored
|
||||
--codex1-home DIR Deprecated alias for --codex-home
|
||||
--codex2-home DIR Deprecated, ignored
|
||||
--codex1-label LABEL Deprecated alias for --codex-label
|
||||
--codex2-label LABEL Deprecated, ignored
|
||||
--recreate Recreate the session and purge stale Zellij state first
|
||||
-h, --help Show this help
|
||||
EOF
|
||||
}
|
||||
|
||||
strip_ansi() {
|
||||
sed -E 's/\x1b\[[0-9;]*m//g'
|
||||
}
|
||||
|
||||
session_exists() {
|
||||
zellij list-sessions 2>/dev/null | strip_ansi | awk '{print $1}' | grep -Fxq "$1"
|
||||
}
|
||||
|
||||
kdl_escape() {
|
||||
printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g'
|
||||
warn_deprecated() {
|
||||
printf 'Warning: %s\n' "$*" >&2
|
||||
}
|
||||
|
||||
workspace="$PWD"
|
||||
session="ai-triple"
|
||||
codex_image="codercom/code-server:latest"
|
||||
codex1_home="$HOME/.codex-profiles/codex-1"
|
||||
codex2_home="$HOME/.codex-profiles/codex-2"
|
||||
codex1_label="codex-1"
|
||||
codex2_label="codex-2"
|
||||
session="$(zellij_ai_default_session)"
|
||||
codex_home="$(zellij_ai_default_codex_home)"
|
||||
codex_label="codex"
|
||||
copilot_label="copilot"
|
||||
vibe_label="vibe"
|
||||
claude_config_dir=""
|
||||
zellij_config_dir=""
|
||||
recreate=false
|
||||
@@ -70,24 +73,42 @@ while [[ $# -gt 0 ]]; do
|
||||
session="$2"
|
||||
shift 2
|
||||
;;
|
||||
--codex-home)
|
||||
codex_home="$2"
|
||||
shift 2
|
||||
;;
|
||||
--codex-label)
|
||||
codex_label="$2"
|
||||
shift 2
|
||||
;;
|
||||
--copilot-label)
|
||||
copilot_label="$2"
|
||||
shift 2
|
||||
;;
|
||||
--vibe-label)
|
||||
vibe_label="$2"
|
||||
shift 2
|
||||
;;
|
||||
--codex-image)
|
||||
codex_image="$2"
|
||||
warn_deprecated "--codex-image is deprecated and ignored; Codex now runs on the host."
|
||||
shift 2
|
||||
;;
|
||||
--codex1-home)
|
||||
codex1_home="$2"
|
||||
warn_deprecated "--codex1-home is deprecated; use --codex-home."
|
||||
codex_home="$2"
|
||||
shift 2
|
||||
;;
|
||||
--codex2-home)
|
||||
codex2_home="$2"
|
||||
warn_deprecated "--codex2-home is deprecated and ignored."
|
||||
shift 2
|
||||
;;
|
||||
--codex1-label)
|
||||
codex1_label="$2"
|
||||
warn_deprecated "--codex1-label is deprecated; use --codex-label."
|
||||
codex_label="$2"
|
||||
shift 2
|
||||
;;
|
||||
--codex2-label)
|
||||
codex2_label="$2"
|
||||
warn_deprecated "--codex2-label is deprecated and ignored."
|
||||
shift 2
|
||||
;;
|
||||
--claude-config)
|
||||
@@ -115,11 +136,14 @@ while [[ $# -gt 0 ]]; do
|
||||
done
|
||||
|
||||
workspace="$(cd "$workspace" && pwd -P)"
|
||||
codex1_home="$(mkdir -p "$codex1_home" && cd "$codex1_home" && pwd -P)"
|
||||
codex2_home="$(mkdir -p "$codex2_home" && cd "$codex2_home" && pwd -P)"
|
||||
codex_home="$(mkdir -p "$(zellij_ai_expand_home_path "$codex_home")" && cd "$(zellij_ai_expand_home_path "$codex_home")" && pwd -P)"
|
||||
|
||||
bootstrap_file="$HOME/.cache/zellij-ai/${session}.bootstrapped"
|
||||
layout_file="/tmp/zellij-ai-layout-${session}.kdl"
|
||||
machine_name="$(zellij_ai_current_machine_name)"
|
||||
state_dir="$(zellij_ai_session_state_dir "$session")"
|
||||
bus_dir="$state_dir/bus"
|
||||
bootstrap_file="$state_dir/bootstrapped"
|
||||
layout_file="$state_dir/layout.kdl"
|
||||
clipboard_config_file="$state_dir/zellij.config.kdl"
|
||||
|
||||
if [[ ! -d "$workspace" ]]; then
|
||||
echo "Workspace not found: $workspace" >&2
|
||||
@@ -129,7 +153,10 @@ fi
|
||||
for required in \
|
||||
"$script_dir/zellij-ai-claude-pane.sh" \
|
||||
"$script_dir/zellij-ai-codex-pane.sh" \
|
||||
"$script_dir/zellij-ai-bootstrap.sh"
|
||||
"$script_dir/zellij-ai-copilot-pane.sh" \
|
||||
"$script_dir/zellij-ai-vibe-pane.sh" \
|
||||
"$script_dir/zellij-ai-bootstrap.sh" \
|
||||
"$script_dir/zellij-ai-common.sh"
|
||||
do
|
||||
if [[ ! -x "$required" ]]; then
|
||||
echo "Helper script not executable: $required" >&2
|
||||
@@ -137,36 +164,58 @@ do
|
||||
fi
|
||||
done
|
||||
|
||||
mkdir -p "$bus_dir/messages"
|
||||
|
||||
if [[ "${ZELLIJ_AI_FORCE_OSC52:-false}" == "true" || "${ZELLIJ_AI_FORCE_OSC52:-0}" == "1" ]]; then
|
||||
cat >"$clipboard_config_file" <<'EOF'
|
||||
copy_clipboard "system"
|
||||
EOF
|
||||
elif [[ "$(uname -s)" == "Darwin" && -x /usr/bin/pbcopy ]]; then
|
||||
cat >"$clipboard_config_file" <<'EOF'
|
||||
copy_command "pbcopy"
|
||||
copy_clipboard "system"
|
||||
EOF
|
||||
else
|
||||
cat >"$clipboard_config_file" <<'EOF'
|
||||
copy_clipboard "system"
|
||||
EOF
|
||||
fi
|
||||
|
||||
export ZELLIJ_AI_WORKSPACE="$workspace"
|
||||
export ZELLIJ_AI_CODEX_IMAGE="$codex_image"
|
||||
export ZELLIJ_AI_CODEX1_HOME="$codex1_home"
|
||||
export ZELLIJ_AI_CODEX2_HOME="$codex2_home"
|
||||
export ZELLIJ_AI_CODEX1_LABEL="$codex1_label"
|
||||
export ZELLIJ_AI_CODEX2_LABEL="$codex2_label"
|
||||
export ZELLIJ_AI_CODEX_HOME="$codex_home"
|
||||
export ZELLIJ_AI_CODEX_LABEL="$codex_label"
|
||||
export ZELLIJ_AI_COPILOT_LABEL="$copilot_label"
|
||||
export ZELLIJ_AI_VIBE_LABEL="$vibe_label"
|
||||
export ZELLIJ_AI_MAIN_CODEX_CONFIG="$HOME/.codex/config.toml"
|
||||
export ZELLIJ_AI_BOOTSTRAP_FILE="$bootstrap_file"
|
||||
export ZELLIJ_AI_REPO_DIR="$repo_dir"
|
||||
export ZELLIJ_AI_SESSION="$session"
|
||||
export ZELLIJ_AI_STATE_DIR="$state_dir"
|
||||
export ZELLIJ_AI_BUS_DIR="$bus_dir"
|
||||
export ZELLIJ_AI_MACHINE_NAME="$machine_name"
|
||||
|
||||
if [[ -n "$claude_config_dir" ]]; then
|
||||
export ZELLIJ_AI_CLAUDE_CONFIG_DIR="$claude_config_dir"
|
||||
fi
|
||||
|
||||
if $recreate && session_exists "$session"; then
|
||||
zellij_attach_cmd=(zellij --config "$clipboard_config_file")
|
||||
|
||||
if $recreate && zellij_ai_session_exists "$session"; then
|
||||
zellij delete-session -f "$session" >/dev/null 2>&1 || true
|
||||
fi
|
||||
|
||||
if session_exists "$session"; then
|
||||
exec zellij attach "$session"
|
||||
if zellij_ai_session_exists "$session"; then
|
||||
exec "${zellij_attach_cmd[@]}" attach "$session"
|
||||
fi
|
||||
|
||||
zellij delete-session "$session" >/dev/null 2>&1 || true
|
||||
|
||||
mkdir -p "$(dirname "$bootstrap_file")"
|
||||
mkdir -p "$state_dir"
|
||||
rm -f "$bootstrap_file"
|
||||
|
||||
bootstrap_path="$(kdl_escape "$script_dir/zellij-ai-bootstrap.sh")"
|
||||
bootstrap_path="$(zellij_ai_kdl_escape "$script_dir/zellij-ai-bootstrap.sh")"
|
||||
cat >"$layout_file" <<EOF
|
||||
layout {
|
||||
tab name="AI Triple" {
|
||||
tab name="Constant" {
|
||||
pane size=1 borderless=true {
|
||||
plugin location="tab-bar"
|
||||
}
|
||||
@@ -179,9 +228,10 @@ layout {
|
||||
EOF
|
||||
|
||||
if [[ -z "$zellij_config_dir" ]]; then
|
||||
zellij_config_dir="$(mktemp -d "${TMPDIR:-/tmp}/zellij-ai-triple-${session}.XXXXXX")"
|
||||
zellij_config_dir="$(mktemp -d "${TMPDIR:-/tmp}/constant-zellij-${session}.XXXXXX")"
|
||||
else
|
||||
zellij_config_dir="$(zellij_ai_expand_home_path "$zellij_config_dir")"
|
||||
mkdir -p "$zellij_config_dir"
|
||||
fi
|
||||
|
||||
exec zellij --config-dir "$zellij_config_dir" --new-session-with-layout "$layout_file" --session "$session"
|
||||
exec zellij --config "$clipboard_config_file" --config-dir "$zellij_config_dir" --new-session-with-layout "$layout_file" --session "$session"
|
||||
|
||||
Executable
+55
@@ -0,0 +1,55 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
script_source="${BASH_SOURCE[0]:-$0}"
|
||||
while [[ -L "$script_source" ]]; do
|
||||
script_dir="$(cd "$(dirname "$script_source")" && pwd -P)"
|
||||
script_source="$(readlink "$script_source")"
|
||||
[[ "$script_source" != /* ]] && script_source="$script_dir/$script_source"
|
||||
done
|
||||
script_dir="$(cd "$(dirname "$script_source")" && pwd -P)"
|
||||
source "$script_dir/zellij-ai-common.sh"
|
||||
|
||||
workspace="${ZELLIJ_AI_WORKSPACE:-$PWD}"
|
||||
machine_name="${ZELLIJ_AI_MACHINE_NAME:-unknown}"
|
||||
session_name="${ZELLIJ_AI_SESSION:-$(zellij_ai_default_session)}"
|
||||
repo_dir="${ZELLIJ_AI_REPO_DIR:-$(cd "$script_dir/.." && pwd -P)}"
|
||||
bus_dir="${ZELLIJ_AI_BUS_DIR:-$(zellij_ai_session_state_dir "$session_name")/bus}"
|
||||
label="${ZELLIJ_AI_VIBE_LABEL:-vibe}"
|
||||
|
||||
if [[ ! -d "$workspace" ]]; then
|
||||
echo "Workspace not found: $workspace" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
export PATH="$(zellij_ai_agent_path)"
|
||||
|
||||
if [[ -n "$repo_dir" && -d "$repo_dir/scripts" ]]; then
|
||||
export PATH="$repo_dir/scripts:$PATH"
|
||||
fi
|
||||
|
||||
if ! command -v vibe >/dev/null 2>&1; then
|
||||
echo "Mistral Vibe CLI not found on the host." >&2
|
||||
echo "Install it with: uv tool install mistral-vibe" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "$bus_dir/messages"
|
||||
|
||||
export ZELLIJ_AI_ROLE="$label"
|
||||
export ZELLIJ_AI_MACHINE_NAME="$machine_name"
|
||||
export ZELLIJ_AI_SESSION="$session_name"
|
||||
export ZELLIJ_AI_BUS_DIR="$bus_dir"
|
||||
|
||||
cd "$workspace"
|
||||
|
||||
echo "Vibe pane"
|
||||
echo "machine: $machine_name"
|
||||
echo "workspace: $workspace"
|
||||
echo "bus: $bus_dir"
|
||||
if [[ -n "$repo_dir" ]]; then
|
||||
echo "helper: $repo_dir/scripts/ai-msg.sh"
|
||||
fi
|
||||
echo
|
||||
|
||||
exec vibe
|
||||
Reference in New Issue
Block a user