diff --git a/README.md b/README.md index 5bf3432..2ff2499 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ 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 4-pane `tmux` 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 @@ -36,10 +36,11 @@ The vibe is somewhere between: Current build status in this repo: -- a 4-pane host-local Zellij session per machine -- a fleet cockpit with one tab per machine +- a 4-pane host-local `tmux` session per machine +- a fleet cockpit with one window per machine plus a central `Constant` window - `Constant`, the orchestration CLI on top of the cockpit - a terminal TUI with a central `hexapus` buddy rail +- live cockpit controls for focus, capture, send, and pane restart - local mission planning and verification - host-local execution for `claude`, `codex`, and `vibe` - `copilot` as a manual lane @@ -47,8 +48,8 @@ Current build status in this repo: - MLX-ready model plumbing for a small local orchestrator stack on macOS - workspace-first durable memory with lexical + local vector search -The historical repo name may still say `zellij-ai-triple`. -The product name is now `Constant`. +The canonical script surface is `constant-*`. +Legacy `zellij-ai-*` aliases remain only as compatibility shims. ## Why This Exists @@ -91,12 +92,13 @@ 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. +From your command center machine, you open one `tmux` window per machine plus a central `Constant` window. +Each machine window attaches to that machine’s own local `tmux` session. That gives you: - one place to supervise the whole fleet +- one active orchestration surface in the `Constant` window - local clipboard behavior on the operator machine - remote sessions that stay remote - a clean separation between orchestration and execution @@ -132,7 +134,7 @@ The design goal is simple: ### Requirements -- `zellij` +- `tmux` - `git` - `node` - `npm` @@ -163,12 +165,11 @@ uv tool install mistral-vibe ./scripts/constant-fleet.sh --workspace "$PWD" ``` -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: +For non-interactive setup or tests, you can create the sessions without attaching: ```bash -./scripts/constant-machine.sh --zellij-config-dir /path/to/zellij-config +./scripts/constant-machine.sh --workspace "$PWD" --ensure-only +./scripts/constant-fleet.sh --workspace "$PWD" --ensure-only ``` On first launch of the Codex pane, if `auth.json` is missing in its profile, the pane runs: @@ -183,6 +184,10 @@ codex login --device-auth ./scripts/Constant ./scripts/Constant tui ./scripts/Constant doctor +./scripts/Constant cockpit doctor --json +./scripts/Constant cockpit status --json +./scripts/Constant cockpit focus --machine command-center --pane codex +./scripts/Constant cockpit capture --machine command-center --pane claude ./scripts/Constant mission create "audit the repo" --workspace "$PWD" ./scripts/Constant mission status ./scripts/Constant memory rebuild --workspace "$PWD" @@ -252,12 +257,18 @@ Constant fleet configure \ - a mission deck - a mission board +- a runtime view of machine windows and pane states - a `hexapus` buddy rail - a bottom timeline mixed with memory echoes Useful keys: - `j` / `k`: move between missions +- `[` / `]`: move between machines in the runtime view +- `1`..`4`: focus `claude`, `codex`, `copilot`, `vibe` on the selected machine +- `o`: jump to the selected machine window +- `r`: restart the selected pane +- `x`: capture a short preview of the selected pane - `e`: rebuild memory for the current workspace - `s`: summarize the selected mission into durable memory - `z`: open the full fleet cockpit @@ -301,7 +312,7 @@ The shell launchers also read `fleet.json` now, so fleet tabs, install/check, an Each machine exposes a local bus: ```text -~/.cache/constant/zellij//bus +~/.cache/constant/cockpit//bus ``` Use it from any pane: @@ -332,9 +343,7 @@ Clipboard behavior is split on purpose: - 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. +The current runtime relies on `tmux` clipboard forwarding and terminal `OSC52` behavior when you are driving remote sessions from the command center. ## Constant Philosophy @@ -390,6 +399,7 @@ scripts/ai-bridge.sh inter-machine bridge - 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 +- remote `tmux` introspection is best when targets expose `tmux` on a non-interactive shell `PATH` That is acceptable for now. The system is being built in the open, from the terminal outward. diff --git a/constant/cli.py b/constant/cli.py index 99aab79..61f449b 100644 --- a/constant/cli.py +++ b/constant/cli.py @@ -9,6 +9,7 @@ from pathlib import Path from typing import Any from . import __version__ +from .cockpit import capture_pane, cockpit_doctor, focus_machine, restart_pane, runtime_status, send_to_pane 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 .memory import ( @@ -110,6 +111,7 @@ def cmd_doctor(args: argparse.Namespace) -> int: health = daemon_request("health", auto_start=False) fleet = load_fleet_config() models = load_models_config() + cockpit = cockpit_doctor() report = { "version": __version__, "repo_root": str(repo_root()), @@ -122,7 +124,7 @@ def cmd_doctor(args: argparse.Namespace) -> int: "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(), + "constant-fleet": (scripts_dir() / "constant-fleet.sh").exists(), "ai-bridge": (scripts_dir() / "ai-bridge.sh").exists(), }, "daemon": status, @@ -130,6 +132,7 @@ def cmd_doctor(args: argparse.Namespace) -> int: "health": health, "memory": memory_status(), "fleet": [{"label": entry["label"], "target": entry["target"]} for entry in fleet["machines"]], + "cockpit": cockpit, } _print(report, args.json) return 0 @@ -235,7 +238,7 @@ def cmd_fleet_deploy(args: argparse.Namespace) -> int: 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")), + str(scripts_dir() / "constant-fleet.sh"), "--workspace", workspace, "--local-session", @@ -243,19 +246,81 @@ def cmd_cockpit_open(args: argparse.Namespace) -> int: "--session", args.session or "constant", ] + if getattr(args, "recreate", False): + command.append("--recreate") + if getattr(args, "remote_recreate", False): + command.append("--remote-recreate") os.execv(command[0], command) return 0 +def cmd_cockpit_attach(args: argparse.Namespace) -> int: + command = [ + str(scripts_dir() / "constant-fleet.sh"), + "--attach-only", + "--local-session", + args.local_session or "constant-fleet", + ] + os.execv(command[0], command) + return 0 + + +def cmd_cockpit_doctor(args: argparse.Namespace) -> int: + payload = cockpit_doctor(local_session=args.local_session or "constant-fleet", machine_session=args.session or "constant") + _print(payload, args.json) + return 0 if payload["tmux"]["available"] else 1 + + +def cmd_cockpit_focus(args: argparse.Namespace) -> int: + payload = focus_machine( + args.machine, + args.pane, + local_session=args.local_session or "constant-fleet", + machine_session=args.session or "constant", + ) + _print(payload, args.json) + return 0 if payload["returncode"] == 0 else 1 + + +def cmd_cockpit_send(args: argparse.Namespace) -> int: + payload = send_to_pane(args.machine, args.pane, args.command, machine_session=args.session or "constant") + _print(payload, args.json) + return 0 if payload["returncode"] == 0 else 1 + + +def cmd_cockpit_capture(args: argparse.Namespace) -> int: + payload = capture_pane(args.machine, args.pane, lines=args.lines, machine_session=args.session or "constant") + _print(payload if args.json else payload.get("stdout", ""), args.json) + return 0 if payload["returncode"] == 0 else 1 + + +def cmd_cockpit_restart(args: argparse.Namespace) -> int: + payload = restart_pane(args.machine, args.pane, machine_session=args.session or "constant") + _print(payload, args.json) + return 0 if payload["returncode"] == 0 else 1 + + +def cmd_cockpit_status(args: argparse.Namespace) -> int: + payload = runtime_status(local_session=args.local_session or "constant-fleet", machine_session=args.session or "constant") + _print(payload, args.json) + return 0 + + def cmd_tui(args: argparse.Namespace) -> int: workspace = str(Path(args.workspace or os.getcwd()).expanduser().resolve()) - action = run_tui(workspace) + action = run_tui( + workspace, + local_session=args.local_session or "constant-fleet", + machine_session=args.session or "constant", + ) if action and action.get("action") == "cockpit": return cmd_cockpit_open( argparse.Namespace( workspace=action.get("workspace", workspace), local_session=args.local_session, session=args.session, + recreate=False, + remote_recreate=False, ) ) return 0 @@ -560,7 +625,49 @@ def build_parser() -> argparse.ArgumentParser: cockpit_open.add_argument("--workspace") cockpit_open.add_argument("--local-session") cockpit_open.add_argument("--session") + cockpit_open.add_argument("--recreate", action="store_true") + cockpit_open.add_argument("--remote-recreate", action="store_true") cockpit_open.set_defaults(func=cmd_cockpit_open) + cockpit_attach = cockpit_sub.add_parser("attach") + cockpit_attach.add_argument("--local-session") + cockpit_attach.set_defaults(func=cmd_cockpit_attach) + cockpit_doctor_cmd = cockpit_sub.add_parser("doctor") + cockpit_doctor_cmd.add_argument("--local-session") + cockpit_doctor_cmd.add_argument("--session") + cockpit_doctor_cmd.add_argument("--json", action="store_true") + cockpit_doctor_cmd.set_defaults(func=cmd_cockpit_doctor) + cockpit_status_cmd = cockpit_sub.add_parser("status") + cockpit_status_cmd.add_argument("--local-session") + cockpit_status_cmd.add_argument("--session") + cockpit_status_cmd.add_argument("--json", action="store_true") + cockpit_status_cmd.set_defaults(func=cmd_cockpit_status) + cockpit_focus_cmd = cockpit_sub.add_parser("focus") + cockpit_focus_cmd.add_argument("--machine", required=True) + cockpit_focus_cmd.add_argument("--pane") + cockpit_focus_cmd.add_argument("--local-session") + cockpit_focus_cmd.add_argument("--session") + cockpit_focus_cmd.add_argument("--json", action="store_true") + cockpit_focus_cmd.set_defaults(func=cmd_cockpit_focus) + cockpit_send_cmd = cockpit_sub.add_parser("send") + cockpit_send_cmd.add_argument("--machine", required=True) + cockpit_send_cmd.add_argument("--pane", required=True) + cockpit_send_cmd.add_argument("--command", required=True) + cockpit_send_cmd.add_argument("--session") + cockpit_send_cmd.add_argument("--json", action="store_true") + cockpit_send_cmd.set_defaults(func=cmd_cockpit_send) + cockpit_capture_cmd = cockpit_sub.add_parser("capture") + cockpit_capture_cmd.add_argument("--machine", required=True) + cockpit_capture_cmd.add_argument("--pane", required=True) + cockpit_capture_cmd.add_argument("--lines", type=int, default=120) + cockpit_capture_cmd.add_argument("--session") + cockpit_capture_cmd.add_argument("--json", action="store_true") + cockpit_capture_cmd.set_defaults(func=cmd_cockpit_capture) + cockpit_restart_cmd = cockpit_sub.add_parser("restart") + cockpit_restart_cmd.add_argument("--machine", required=True) + cockpit_restart_cmd.add_argument("--pane", required=True) + cockpit_restart_cmd.add_argument("--session") + cockpit_restart_cmd.add_argument("--json", action="store_true") + cockpit_restart_cmd.set_defaults(func=cmd_cockpit_restart) mission = subparsers.add_parser("mission") mission_sub = mission.add_subparsers(dest="mission_command", required=True) diff --git a/constant/cockpit.py b/constant/cockpit.py new file mode 100644 index 0000000..6374904 --- /dev/null +++ b/constant/cockpit.py @@ -0,0 +1,192 @@ +from __future__ import annotations + +import shlex +import subprocess +import socket +from pathlib import Path +from typing import Any + +from .paths import scripts_dir +from .state import fleet_machine, load_fleet_config + +ROLES = ("claude", "codex", "copilot", "vibe") + + +def _run(args: list[str]) -> dict[str, Any]: + process = subprocess.run(args, capture_output=True, text=True) + return { + "argv": args, + "returncode": process.returncode, + "stdout": process.stdout, + "stderr": process.stderr, + } + + +def _tmux_list_command(session_name: str) -> list[str]: + return [ + "tmux", + "list-panes", + "-t", + session_name, + "-F", + "#{session_name}\t#{window_name}\t#{pane_id}\t#{pane_index}\t#{@constant_role}\t#{pane_title}\t#{pane_current_command}\t#{pane_active}\t#{pane_dead}\t#{pane_dead_status}", + ] + + +def _ssh_command(target: str, inner: str) -> list[str]: + prefixed = ( + 'export PATH="$HOME/.local/bin:$HOME/.npm-global/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"; ' + f"{inner}" + ) + return ["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=1", target, "bash", "--noprofile", "--norc", "-lc", prefixed] + + +def _machine_session_name(session_name: str, machine_label: str) -> str: + return f"{session_name}:{machine_label}" + + +def _parse_panes(stdout: str) -> list[dict[str, Any]]: + panes: list[dict[str, Any]] = [] + for raw in stdout.splitlines(): + parts = raw.split("\t") + if len(parts) != 10: + continue + session_name, window_name, pane_id, pane_index, pane_role, pane_title, pane_command, pane_active, pane_dead, pane_dead_status = parts + role = pane_role or (pane_title if pane_title in ROLES else pane_command) + panes.append( + { + "session_name": session_name, + "window_name": window_name, + "pane_id": pane_id, + "pane_index": int(pane_index), + "role": role, + "pane_role": pane_role, + "pane_title": pane_title, + "pane_command": pane_command, + "active": pane_active == "1", + "dead": pane_dead == "1", + "dead_status": int(pane_dead_status or "0"), + } + ) + return sorted(panes, key=lambda item: item["pane_index"]) + + +def _machine_tmux_status(machine: dict[str, Any], session: str) -> dict[str, Any]: + label = machine["label"] + target = machine["target"] + local_names = {socket.gethostname(), socket.getfqdn(), socket.gethostname().split(".")[0], "local", "localhost", "127.0.0.1", "::1"} + is_local = target in local_names + session_target = _machine_session_name(session, label) + + if is_local: + result = _run(_tmux_list_command(session_target)) + if result["returncode"] != 0: + for fallback_label in (socket.gethostname().split(".")[0], socket.getfqdn(), socket.gethostname()): + fallback_target = _machine_session_name(session, fallback_label) + result = _run(_tmux_list_command(fallback_target)) + if result["returncode"] == 0: + session_target = fallback_target + break + else: + inner = shlex.join(_tmux_list_command(session_target)) + result = _run(_ssh_command(target, inner)) + + panes = _parse_panes(result["stdout"]) if result["returncode"] == 0 else [] + roles = { + role: next((pane for pane in panes if pane["role"] == role), None) + for role in ROLES + } + return { + "label": label, + "target": target, + "session": session_target, + "reachable": result["returncode"] == 0 or is_local, + "attached_window": label, + "session_exists": result["returncode"] == 0, + "panes": panes, + "roles": roles, + "stderr": result["stderr"].strip(), + } + + +def runtime_status(local_session: str = "constant-fleet", machine_session: str = "constant") -> dict[str, Any]: + fleet = load_fleet_config() + local_tmux = _run(["tmux", "list-windows", "-t", local_session, "-F", "#{window_name}"]) + fleet_windows = local_tmux["stdout"].splitlines() if local_tmux["returncode"] == 0 else [] + machines = [_machine_tmux_status(machine, machine_session) for machine in fleet["machines"]] + return { + "local_session": local_session, + "machine_session": machine_session, + "fleet_session_exists": local_tmux["returncode"] == 0, + "fleet_windows": fleet_windows, + "machines": machines, + "fleet_stderr": local_tmux["stderr"].strip(), + } + + +def cockpit_doctor(local_session: str = "constant-fleet", machine_session: str = "constant") -> dict[str, Any]: + tmux_check = _run(["tmux", "-V"]) + payload = runtime_status(local_session=local_session, machine_session=machine_session) + payload["tmux"] = { + "available": tmux_check["returncode"] == 0, + "stdout": tmux_check["stdout"].strip(), + "stderr": tmux_check["stderr"].strip(), + } + return payload + + +def _machine_control_script(machine_label: str, machine_session: str) -> tuple[dict[str, Any], Path]: + machine = fleet_machine(machine_label) + script = scripts_dir() / "constant-machine.sh" + return machine, script + + +def _run_machine_command(machine: dict[str, Any], args: list[str]) -> dict[str, Any]: + target = machine["target"] + local_names = {socket.gethostname(), socket.getfqdn(), socket.gethostname().split(".")[0], "local", "localhost", "127.0.0.1", "::1"} + if target in local_names: + return _run(args) + + quoted = shlex.join(args) + return _run(_ssh_command(target, quoted)) + + +def focus_machine(machine_label: str, pane_role: str | None, local_session: str = "constant-fleet", machine_session: str = "constant") -> dict[str, Any]: + machine, script = _machine_control_script(machine_label, machine_session) + if pane_role: + payload = _run_machine_command(machine, [str(script), "--session", machine_session, "--focus-pane", pane_role]) + if payload["returncode"] != 0: + return payload + + select_window = _run(["tmux", "select-window", "-t", f"{local_session}:{machine_label}"]) + return { + "returncode": select_window["returncode"], + "stdout": select_window["stdout"], + "stderr": select_window["stderr"], + "machine": machine_label, + "pane": pane_role, + } + + +def send_to_pane(machine_label: str, pane_role: str, command: str, machine_session: str = "constant") -> dict[str, Any]: + machine, script = _machine_control_script(machine_label, machine_session) + return _run_machine_command( + machine, + [str(script), "--session", machine_session, "--send-pane", pane_role, "--command", command], + ) + + +def capture_pane(machine_label: str, pane_role: str, lines: int = 120, machine_session: str = "constant") -> dict[str, Any]: + machine, script = _machine_control_script(machine_label, machine_session) + return _run_machine_command( + machine, + [str(script), "--session", machine_session, "--capture-pane", pane_role, "--lines", str(lines)], + ) + + +def restart_pane(machine_label: str, pane_role: str, machine_session: str = "constant") -> dict[str, Any]: + machine, script = _machine_control_script(machine_label, machine_session) + return _run_machine_command( + machine, + [str(script), "--session", machine_session, "--restart-pane", pane_role], + ) diff --git a/constant/executors.py b/constant/executors.py index c0da48f..fd4ea3d 100644 --- a/constant/executors.py +++ b/constant/executors.py @@ -74,8 +74,8 @@ def execute_step(step: dict[str, Any], mission: dict[str, Any], fleet: dict[str, "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")) + if backend in {"zellij", "cockpit"}: + cockpit = str(scripts_dir() / "constant-fleet.sh") return { "argv": [cockpit, "--workspace", workspace], "returncode": 0, @@ -99,8 +99,6 @@ def execute_step(step: dict[str, Any], mission: dict[str, Any], fleet: dict[str, 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]] = [] diff --git a/constant/planner.py b/constant/planner.py index d19752b..6eab5e8 100644 --- a/constant/planner.py +++ b/constant/planner.py @@ -303,7 +303,7 @@ class PlannerEngine: "goal": mission["goal"], "workspace": mission["workspace"], "machines": machine_labels, - "allowed_backends": ["omc", "cli-local", "cli-ssh", "zellij"], + "allowed_backends": ["omc", "cli-local", "cli-ssh", "cockpit"], "allowed_clis": ["claude", "codex", "vibe"], }, indent=2, diff --git a/constant/state.py b/constant/state.py index c6368a0..1ba2bf5 100644 --- a/constant/state.py +++ b/constant/state.py @@ -31,35 +31,35 @@ DEFAULT_FLEET: dict[str, Any] = { "target": "local", "auto_clis": ["codex", "vibe", "claude"], "manual_clis": ["copilot"], - "backends": ["omc", "cli-local", "zellij"], + "backends": ["omc", "cli-local", "cockpit"], }, { "label": "builder-a", "target": "dev@builder-a", "auto_clis": ["codex", "vibe", "claude"], "manual_clis": ["copilot"], - "backends": ["cli-ssh", "zellij"], + "backends": ["cli-ssh", "cockpit"], }, { "label": "builder-b", "target": "dev@builder-b", "auto_clis": ["codex", "vibe", "claude"], "manual_clis": ["copilot"], - "backends": ["cli-ssh", "zellij"], + "backends": ["cli-ssh", "cockpit"], }, { "label": "edge-a", "target": "dev@edge-a", "auto_clis": ["codex", "vibe", "claude"], "manual_clis": ["copilot"], - "backends": ["cli-ssh", "zellij"], + "backends": ["cli-ssh", "cockpit"], }, { "label": "lab-a", "target": "dev@lab-a", "auto_clis": ["codex", "vibe", "claude"], "manual_clis": ["copilot"], - "backends": ["cli-ssh", "zellij"], + "backends": ["cli-ssh", "cockpit"], }, ], } @@ -140,7 +140,13 @@ def _write_json_yaml(path: Path, payload: dict[str, Any]) -> None: def load_fleet_config() -> dict[str, Any]: - return _read_json_yaml(fleet_config_path(), DEFAULT_FLEET) + payload = _read_json_yaml(fleet_config_path(), DEFAULT_FLEET) + for machine in payload.get("machines", []): + backends = [] + for backend in machine.get("backends", []): + backends.append("cockpit" if backend == "zellij" else backend) + machine["backends"] = backends + return payload def load_models_config() -> dict[str, Any]: diff --git a/constant/tui.py b/constant/tui.py index 3acf42c..86fc05b 100644 --- a/constant/tui.py +++ b/constant/tui.py @@ -7,6 +7,7 @@ import time from pathlib import Path from typing import Any +from .cockpit import ROLES, capture_pane, focus_machine, restart_pane, runtime_status from .daemon import request as daemon_request from .memory import list_decisions, memory_status, persona_markdown, rebuild_workspace_memory, summarize_mission from .state import list_missions, load_fleet_config, mission_events_file @@ -46,6 +47,18 @@ def _safe_decision_lines(workspace: str) -> list[str]: return [_error_line("decisions", exc)] +def _safe_runtime_status(local_session: str, machine_session: str) -> dict[str, Any]: + try: + return runtime_status(local_session=local_session, machine_session=machine_session) + except Exception as exc: # noqa: BLE001 + return { + "fleet_session_exists": False, + "machines": [], + "fleet_windows": [], + "fleet_stderr": _error_line("cockpit", exc), + } + + def _clip(text: str, width: int) -> str: if width <= 0: return "" @@ -219,17 +232,29 @@ def _status_tag(status: str) -> str: }.get(status, status[:4].upper()) -def _draw_header(stdscr: Any, workspace: str, mission_count: int, fleet_count: int, memory: dict[str, Any], health: dict[str, Any], colors: dict[str, int]) -> None: +def _role_state(machine: dict[str, Any], role: str) -> str: + pane = machine.get("roles", {}).get(role) + if not pane: + return ".." + if pane.get("dead"): + return "xx" + if pane.get("active"): + return ">>" + return "ok" + + +def _draw_header(stdscr: Any, workspace: str, mission_count: int, fleet_count: int, runtime: dict[str, Any], memory: dict[str, Any], health: dict[str, Any], colors: dict[str, int]) -> None: memory_counts = memory.get("counts", {}) planner_backend = health.get("models", {}).get("planner", {}).get("backend", "heuristic") buddy_backend = health.get("models", {}).get("buddy", {}).get("backend", "heuristic") + cockpit_state = "up" if runtime.get("fleet_session_exists") else "down" _safe_addstr(stdscr, 0, 2, "########## CONSTANT::DEMOSCENE::TUI ##########", colors["accent"]) _safe_addstr( stdscr, 1, 2, _clip( - f"workspace={workspace} fleet={fleet_count} missions={mission_count} docs={memory_counts.get('documents', 0)} chunks={memory_counts.get('chunks', 0)} decisions={memory_counts.get('decisions', 0)}", + f"workspace={workspace} fleet={fleet_count} cockpit={cockpit_state} missions={mission_count} docs={memory_counts.get('documents', 0)} chunks={memory_counts.get('chunks', 0)} decisions={memory_counts.get('decisions', 0)}", max(20, stdscr.getmaxyx()[1] - 4), ), colors["base"], @@ -239,7 +264,7 @@ def _draw_header(stdscr: Any, workspace: str, mission_count: int, fleet_count: i 2, 2, _clip( - f"planner={planner_backend} buddy={buddy_backend} keys: j/k move z cockpit e index s summarize q quit", + f"planner={planner_backend} buddy={buddy_backend} keys: j/k mission [/ ] machine 1..4 pane o jump r restart z cockpit q quit", max(20, stdscr.getmaxyx()[1] - 4), ), colors["muted"], @@ -304,6 +329,46 @@ def _draw_board(stdscr: Any, y: int, x: int, height: int, width: int, mission: d break +def _draw_runtime( + stdscr: Any, + y: int, + x: int, + height: int, + width: int, + runtime: dict[str, Any], + selected_machine: int, + selected_role: str, + colors: dict[str, int], +) -> None: + _draw_box(stdscr, y, x, height, width, "Cockpit Runtime", colors["accent"]) + row = y + 1 + machines = runtime.get("machines", []) + if not machines: + _safe_addstr(stdscr, row, x + 2, "No runtime data yet. Open the cockpit with z.", colors["muted"]) + return + + header = "machine claude codex copilot vibe session target" + _safe_addstr(stdscr, row, x + 2, _clip(header, width - 4), colors["hot"]) + row += 1 + + for index, machine in enumerate(machines[: max(1, height - 3)]): + marker = ">" if index == selected_machine else " " + attr = colors["accent"] if index == selected_machine else colors["base"] + status = "up" if machine.get("session_exists") else "down" + role_bits = [] + for role in ROLES: + state = _role_state(machine, role) + token = f"[{state}]" + if role == selected_role and index == selected_machine: + token = f"<{state}>" + role_bits.append(token) + line = f"{marker} {machine['label']:<16} {' '.join(role_bits)} {status:<7} {machine['target']}" + _safe_addstr(stdscr, row, x + 1, _clip(line, width - 3), attr) + row += 1 + if row >= y + height - 1: + break + + def _draw_buddy(stdscr: Any, y: int, x: int, height: int, width: int, review: dict[str, str], memory: dict[str, Any], colors: dict[str, int]) -> None: _draw_box(stdscr, y, x, height, width, "Hexapus Buddy Rail", colors["accent"]) row = y + 1 @@ -359,7 +424,7 @@ def _draw_timeline(stdscr: Any, y: int, x: int, height: int, width: int, events: break -def _collect_snapshot(default_workspace: str, selected_index: int) -> dict[str, Any]: +def _collect_snapshot(default_workspace: str, selected_index: int, local_session: str, machine_session: str, runtime_snapshot: dict[str, Any] | None = None) -> dict[str, Any]: missions = sorted(list_missions(), key=lambda item: item.get("updated_at", ""), reverse=True) if missions: selected_index = max(0, min(selected_index, len(missions) - 1)) @@ -376,6 +441,7 @@ def _collect_snapshot(default_workspace: str, selected_index: int) -> dict[str, decision_lines = _safe_decision_lines(workspace) events = _recent_events(selected_mission["mission_id"] if selected_mission else None) fleet = load_fleet_config() + runtime = runtime_snapshot if runtime_snapshot is not None else _safe_runtime_status(local_session, machine_session) return { "missions": missions, "selected_index": selected_index, @@ -388,10 +454,11 @@ def _collect_snapshot(default_workspace: str, selected_index: int) -> dict[str, "decision_lines": decision_lines, "events": events, "fleet_count": len(fleet["machines"]), + "runtime": runtime, } -def _run(stdscr: Any, workspace: str) -> dict[str, Any] | None: +def _run(stdscr: Any, workspace: str, local_session: str, machine_session: str) -> dict[str, Any] | None: try: curses.curs_set(0) except curses.error: @@ -400,12 +467,25 @@ def _run(stdscr: Any, workspace: str) -> dict[str, Any] | None: stdscr.timeout(250) colors = _init_colors() selected_index = 0 + selected_machine = 0 + selected_role_index = 1 flash = "" flash_until = 0.0 + runtime_cache: dict[str, Any] | None = None + runtime_refresh_at = 0.0 while True: - snapshot = _collect_snapshot(workspace, selected_index) + now = time.time() + if runtime_cache is None or now >= runtime_refresh_at: + runtime_cache = _safe_runtime_status(local_session, machine_session) + runtime_refresh_at = now + 2.0 + snapshot = _collect_snapshot(workspace, selected_index, local_session, machine_session, runtime_snapshot=runtime_cache) selected_index = snapshot["selected_index"] + runtime_machines = snapshot["runtime"].get("machines", []) + if runtime_machines: + selected_machine = max(0, min(selected_machine, len(runtime_machines) - 1)) + else: + selected_machine = 0 stdscr.erase() height, width = stdscr.getmaxyx() @@ -414,6 +494,7 @@ def _run(stdscr: Any, workspace: str) -> dict[str, Any] | None: snapshot["workspace"], len(snapshot["missions"]), snapshot["fleet_count"], + snapshot["runtime"], snapshot["memory"], snapshot["health"], colors, @@ -425,9 +506,22 @@ def _run(stdscr: Any, workspace: str) -> dict[str, Any] | None: left_width = max(28, min(34, width // 4)) right_width = max(34, min(42, width // 3)) center_width = max(28, width - left_width - right_width - 4) + board_height = max(8, int(main_height * 0.52)) + runtime_height = max(6, main_height - board_height) _draw_missions(stdscr, top, 0, main_height, left_width, snapshot["missions"], selected_index, colors) - _draw_board(stdscr, top, left_width + 1, main_height, center_width, snapshot["selected_mission"], colors) + _draw_board(stdscr, top, left_width + 1, board_height, center_width, snapshot["selected_mission"], colors) + _draw_runtime( + stdscr, + top + board_height, + left_width + 1, + runtime_height, + center_width, + snapshot["runtime"], + selected_machine, + ROLES[selected_role_index], + colors, + ) _draw_buddy(stdscr, top, left_width + center_width + 2, main_height, right_width, snapshot["review"], snapshot["memory"], colors) _draw_timeline(stdscr, top + main_height, 0, timeline_height, width, snapshot["events"], snapshot["persona_lines"], snapshot["decision_lines"], colors) @@ -435,7 +529,7 @@ def _run(stdscr: Any, workspace: str) -> dict[str, Any] | None: _safe_addstr(stdscr, height - 1, 2, _clip(flash, width - 4), colors["warn"]) else: flash = "" - _safe_addstr(stdscr, height - 1, 2, "q quit | j/k select mission | z open cockpit | e rebuild memory | s summarize mission", colors["muted"]) + _safe_addstr(stdscr, height - 1, 2, "q quit | j/k mission | [/] machine | 1..4 pane | o jump | r restart | x capture | z open cockpit", colors["muted"]) stdscr.refresh() key = stdscr.getch() @@ -451,6 +545,60 @@ def _run(stdscr: Any, workspace: str) -> dict[str, Any] | None: if snapshot["missions"]: selected_index = max(0, selected_index - 1) continue + if key == ord("["): + if snapshot["runtime"]["machines"]: + selected_machine = max(0, selected_machine - 1) + continue + if key == ord("]"): + if snapshot["runtime"]["machines"]: + selected_machine = min(len(snapshot["runtime"]["machines"]) - 1, selected_machine + 1) + continue + if key in (ord("1"), ord("2"), ord("3"), ord("4")) and snapshot["runtime"]["machines"]: + selected_role_index = int(chr(key)) - 1 + machine = snapshot["runtime"]["machines"][selected_machine] + try: + focus_machine( + machine["label"], + ROLES[selected_role_index], + local_session=local_session, + machine_session=machine_session, + ) + runtime_cache = None + flash = f"focused {machine['label']}:{ROLES[selected_role_index]}" + except Exception as exc: # noqa: BLE001 + flash = _error_line("focus failed", exc) + flash_until = time.time() + 2.5 + continue + if key == ord("o") and snapshot["runtime"]["machines"]: + machine = snapshot["runtime"]["machines"][selected_machine] + try: + focus_machine(machine["label"], None, local_session=local_session, machine_session=machine_session) + runtime_cache = None + flash = f"jumped to {machine['label']}" + except Exception as exc: # noqa: BLE001 + flash = _error_line("jump failed", exc) + flash_until = time.time() + 2.5 + continue + if key == ord("r") and snapshot["runtime"]["machines"]: + machine = snapshot["runtime"]["machines"][selected_machine] + try: + restart_pane(machine["label"], ROLES[selected_role_index], machine_session=machine_session) + runtime_cache = None + flash = f"restarted {machine['label']}:{ROLES[selected_role_index]}" + except Exception as exc: # noqa: BLE001 + flash = _error_line("restart failed", exc) + flash_until = time.time() + 2.5 + continue + if key == ord("x") and snapshot["runtime"]["machines"]: + machine = snapshot["runtime"]["machines"][selected_machine] + try: + capture = capture_pane(machine["label"], ROLES[selected_role_index], lines=12, machine_session=machine_session) + preview = (capture.get("stdout") or "").splitlines()[-1:] or ["(empty capture)"] + flash = f"{machine['label']}:{ROLES[selected_role_index]} :: {preview[0]}" + except Exception as exc: # noqa: BLE001 + flash = _error_line("capture failed", exc) + flash_until = time.time() + 3.5 + continue if key == ord("z"): return {"action": "cockpit", "workspace": snapshot["workspace"]} if key == ord("e"): @@ -471,9 +619,9 @@ def _run(stdscr: Any, workspace: str) -> dict[str, Any] | None: continue -def run_tui(workspace: str) -> dict[str, Any] | None: +def run_tui(workspace: str, local_session: str = "constant-fleet", machine_session: str = "constant") -> dict[str, Any] | None: try: rebuild_workspace_memory(workspace, enroll=True) except Exception: pass - return curses.wrapper(lambda stdscr: _run(stdscr, str(Path(workspace).expanduser().resolve()))) + return curses.wrapper(lambda stdscr: _run(stdscr, str(Path(workspace).expanduser().resolve()), local_session, machine_session)) diff --git a/examples/fleet.example.json b/examples/fleet.example.json index e2da9ca..0673aa1 100644 --- a/examples/fleet.example.json +++ b/examples/fleet.example.json @@ -8,35 +8,35 @@ "target": "local", "auto_clis": ["codex", "vibe", "claude"], "manual_clis": ["copilot"], - "backends": ["omc", "cli-local", "zellij"] + "backends": ["omc", "cli-local", "cockpit"] }, { "label": "builder-a", "target": "dev@builder-a", "auto_clis": ["codex", "vibe", "claude"], "manual_clis": ["copilot"], - "backends": ["cli-ssh", "zellij"] + "backends": ["cli-ssh", "cockpit"] }, { "label": "builder-b", "target": "dev@builder-b", "auto_clis": ["codex", "vibe", "claude"], "manual_clis": ["copilot"], - "backends": ["cli-ssh", "zellij"] + "backends": ["cli-ssh", "cockpit"] }, { "label": "edge-a", "target": "dev@edge-a", "auto_clis": ["codex", "vibe", "claude"], "manual_clis": ["copilot"], - "backends": ["cli-ssh", "zellij"] + "backends": ["cli-ssh", "cockpit"] }, { "label": "lab-a", "target": "dev@lab-a", "auto_clis": ["codex", "vibe", "claude"], "manual_clis": ["copilot"], - "backends": ["cli-ssh", "zellij"] + "backends": ["cli-ssh", "cockpit"] } ] } diff --git a/scripts/ai-bridge.sh b/scripts/ai-bridge.sh index 22ef8f7..4323de9 100755 --- a/scripts/ai-bridge.sh +++ b/scripts/ai-bridge.sh @@ -8,7 +8,7 @@ while [[ -L "$script_source" ]]; do [[ "$script_source" != /* ]] && script_source="$script_dir/$script_source" done script_dir="$(cd "$(dirname "$script_source")" && pwd -P)" -source "$script_dir/zellij-ai-common.sh" +source "$script_dir/constant-common.sh" usage() { cat <&2 + exit 2 + fi +} + +build_constant_window_command() { + local inner + printf -v inner 'cd %q && export PATH=%q; exec %q tui --workspace %q --local-session %q --session %q' \ + "$workspace" \ + "$(zellij_ai_agent_path)" \ + "$repo_dir/scripts/Constant" \ + "$workspace" \ + "$local_session" \ + "$remote_session" + printf 'bash -lc %q' "$inner" +} + +build_remote_window_command() { + local label="$1" + local target="$2" + local inner remote_script cmd cmd_joined + + remote_script="$repo_dir/scripts/constant-tmux-remote-window.sh" + cmd=( + "$remote_script" + --label "$label" + --target "$target" + --workspace "$workspace_spec" + --repo-dir "$repo_dir_spec" + --session "$remote_session" + ) + if [[ -n "$claude_config_dir" ]]; then + cmd+=(--claude-config-dir "$claude_config_dir") + fi + if $remote_recreate; then + cmd+=(--recreate) + fi + + printf -v cmd_joined '%q ' "${cmd[@]}" + printf -v inner 'cd %q && export PATH=%q; exec %s' \ + "$repo_dir" \ + "$(zellij_ai_agent_path)" \ + "$cmd_joined" + printf 'bash -lc %q' "$inner" +} + +link_local_window() { + local label="$1" + local launcher="$repo_dir/scripts/constant-machine.sh" + local args=( + --workspace "$workspace" + --session "$remote_session" + --repo-dir "$repo_dir" + --ensure-only + ) + + if [[ ! -x "$launcher" ]]; then + launcher="$repo_dir/scripts/constant-tmux-machine.sh" + fi + if [[ -n "$claude_config_dir" ]]; then + args+=(--claude-config-dir "$claude_config_dir") + fi + if $remote_recreate; then + args+=(--recreate) + fi + + ZELLIJ_AI_MACHINE_NAME="$label" "$launcher" "${args[@]}" + if ! constant_tmux_window_exists "$local_session" "$label"; then + tmux link-window -d -s "${remote_session}:${label}" -t "${local_session}:" + fi +} + +constant_tmux_require +zellij_ai_require_command ssh + +workspace="" +workspace_spec="" +remote_session="$(zellij_ai_default_session)" +local_session="$(zellij_ai_default_local_session)" +repo_dir="$(zellij_ai_expand_home_path "$(zellij_ai_default_repo_dir)")" +repo_dir_spec="$(zellij_ai_default_repo_dir)" +claude_config_dir="" +recreate=false +remote_recreate=false +attach_only=false +ensure_only=false +machines=() + +while [[ $# -gt 0 ]]; do + case "$1" in + --workspace) + require_arg "$1" "${2:-}" + workspace_spec="$2" + workspace="$(zellij_ai_expand_home_path "$2")" + shift 2 + ;; + --session) + require_arg "$1" "${2:-}" + remote_session="$2" + shift 2 + ;; + --local-session) + require_arg "$1" "${2:-}" + local_session="$2" + shift 2 + ;; + --repo-dir) + require_arg "$1" "${2:-}" + repo_dir_spec="$2" + repo_dir="$(zellij_ai_expand_home_path "$2")" + shift 2 + ;; + --machine) + require_arg "$1" "${2:-}" + machines+=("$2") + shift 2 + ;; + --claude-config-dir) + require_arg "$1" "${2:-}" + claude_config_dir="$(zellij_ai_expand_home_path "$2")" + shift 2 + ;; + --recreate) + recreate=true + shift + ;; + --remote-recreate) + remote_recreate=true + shift + ;; + --attach-only) + attach_only=true + shift + ;; + --ensure-only) + ensure_only=true + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Unknown option: $1" >&2 + usage >&2 + exit 2 + ;; + esac +done + +if $attach_only; then + if [[ -n "${TMUX:-}" ]]; then + exec tmux switch-client -t "$local_session" + fi + exec tmux attach-session -t "$local_session" +fi + +if [[ -z "$workspace" ]]; then + usage >&2 + exit 2 +fi +if [[ ! -d "$workspace" ]]; then + echo "Workspace not found: $workspace" >&2 + exit 1 +fi + +if [[ ${#machines[@]} -eq 0 ]]; then + mapfile -t machines < <(zellij_ai_default_machine_specs) +fi + +if $recreate && constant_tmux_session_exists "$local_session"; then + tmux kill-session -t "$local_session" >/dev/null 2>&1 || true +fi + +if constant_tmux_session_exists "$local_session"; then + if [[ -n "${TMUX:-}" ]]; then + exec tmux switch-client -t "$local_session" + fi + exec tmux attach-session -t "$local_session" +fi + +mkdir -p "$(zellij_ai_fleet_state_dir "$local_session")" +tmux new-session -d -s "$local_session" -n Constant "$(build_constant_window_command)" +tmux set-option -t "$local_session" -g set-clipboard on >/dev/null 2>&1 || true +tmux set-option -t "$local_session" -g remain-on-exit on >/dev/null 2>&1 || true +tmux set-option -t "$local_session" -g mouse on >/dev/null 2>&1 || true + +for machine_spec in "${machines[@]}"; do + parsed_spec="$(zellij_ai_parse_machine_spec "$machine_spec")" + IFS=$'\t' read -r label target <<<"$parsed_spec" + + if [[ "$label" == "Constant" ]]; then + continue + fi + + if zellij_ai_is_local_target "$target"; then + link_local_window "$label" + else + tmux new-window -d -t "${local_session}:" -n "$label" "$(build_remote_window_command "$label" "$target")" + fi +done + +tmux select-window -t "${local_session}:Constant" >/dev/null 2>&1 || true + +if $ensure_only; then + exit 0 +fi + +if [[ -n "${TMUX:-}" ]]; then + exec tmux switch-client -t "$local_session" +else + exec tmux attach-session -t "$local_session" +fi diff --git a/scripts/constant-tmux-machine.sh b/scripts/constant-tmux-machine.sh new file mode 100755 index 0000000..8649180 --- /dev/null +++ b/scripts/constant-tmux-machine.sh @@ -0,0 +1,318 @@ +#!/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/constant-common.sh" + +usage() { + local script_name="${CONSTANT_SCRIPT_NAME:-$(basename "$0")}" + cat <&2 + exit 2 + fi +} + +tmux_target_window() { + printf '%s:%s\n' "$session" "$machine_name" +} + +role_pane_id() { + local role="$1" + tmux list-panes -t "$(tmux_target_window)" -F '#{pane_id} #{@constant_role} #{pane_title}' 2>/dev/null | awk -F '\t' -v role="$role" '$2 == role || $3 == role {print $1; exit}' +} + +ensure_workspace() { + if [[ ! -d "$workspace" ]]; then + echo "Workspace not found: $workspace" >&2 + exit 1 + fi +} + +pane_command() { + constant_tmux_role_command "$1" "$script_dir" "$workspace" "$repo_dir" "$session" "$bus_dir" "$machine_name" "$codex_home" "$claude_config_dir" +} + +set_pane_meta() { + local pane_id="$1" + local role="$2" + tmux select-pane -t "$pane_id" -T "$role" >/dev/null 2>&1 || true + tmux set-option -p -t "$pane_id" @constant_role "$role" >/dev/null 2>&1 || true +} + +create_session() { + local target_window claude_cmd codex_cmd copilot_cmd vibe_cmd + local claude_pane codex_pane copilot_pane vibe_pane + + target_window="$(tmux_target_window)" + claude_cmd="$(pane_command claude)" + codex_cmd="$(pane_command codex)" + copilot_cmd="$(pane_command copilot)" + vibe_cmd="$(pane_command vibe)" + + tmux new-session -d -s "$session" -n "$machine_name" "$claude_cmd" + tmux set-option -t "$session" -g set-clipboard on >/dev/null 2>&1 || true + tmux set-option -t "$session" -g remain-on-exit on >/dev/null 2>&1 || true + tmux set-option -t "$session" -g mouse on >/dev/null 2>&1 || true + tmux set-option -t "$session" -g status-position top >/dev/null 2>&1 || true + tmux set-window-option -t "$target_window" -g pane-base-index 0 >/dev/null 2>&1 || true + + claude_pane="$(tmux list-panes -t "$target_window" -F '#{pane_id}' | head -n 1)" + codex_pane="$(tmux split-window -h -P -F '#{pane_id}' -t "$claude_pane" "$codex_cmd")" + copilot_pane="$(tmux split-window -v -P -F '#{pane_id}' -t "$codex_pane" "$copilot_cmd")" + vibe_pane="$(tmux split-window -v -P -F '#{pane_id}' -t "$copilot_pane" "$vibe_cmd")" + + tmux select-layout -t "$target_window" main-vertical >/dev/null + tmux resize-pane -t "$claude_pane" -x 120 >/dev/null 2>&1 || true + set_pane_meta "$claude_pane" claude + set_pane_meta "$codex_pane" codex + set_pane_meta "$copilot_pane" copilot + set_pane_meta "$vibe_pane" vibe + tmux select-pane -t "$claude_pane" >/dev/null +} + +ensure_session() { + mkdir -p "$state_dir" "$bus_dir/messages" + + if $recreate && constant_tmux_session_exists "$session"; then + tmux kill-session -t "$session" >/dev/null 2>&1 || true + fi + + if ! constant_tmux_session_exists "$session"; then + create_session + return 0 + fi + + if ! constant_tmux_window_exists "$session" "$machine_name"; then + recreate=true + tmux kill-session -t "$session" >/dev/null 2>&1 || true + create_session + fi +} + +focus_role() { + local role="$1" + local pane_id + + ensure_session + pane_id="$(role_pane_id "$role")" + if [[ -z "$pane_id" ]]; then + echo "No pane found for role: $role" >&2 + exit 1 + fi + + tmux select-window -t "$(tmux_target_window)" >/dev/null + tmux select-pane -t "$pane_id" >/dev/null +} + +restart_role() { + local role="$1" + local pane_id command_string + + ensure_session + pane_id="$(role_pane_id "$role")" + if [[ -z "$pane_id" ]]; then + echo "No pane found for role: $role" >&2 + exit 1 + fi + + command_string="$(pane_command "$role")" + tmux respawn-pane -k -t "$pane_id" "$command_string" + set_pane_meta "$pane_id" "$role" + tmux select-layout -t "$(tmux_target_window)" main-vertical >/dev/null +} + +capture_role() { + local role="$1" + local pane_id + + ensure_session + pane_id="$(role_pane_id "$role")" + if [[ -z "$pane_id" ]]; then + echo "No pane found for role: $role" >&2 + exit 1 + fi + + tmux capture-pane -p -S "-${lines}" -t "$pane_id" +} + +send_role() { + local role="$1" + local pane_id + + require_arg --command "$send_command" + ensure_session + pane_id="$(role_pane_id "$role")" + if [[ -z "$pane_id" ]]; then + echo "No pane found for role: $role" >&2 + exit 1 + fi + + tmux send-keys -t "$pane_id" -l "$send_command" + tmux send-keys -t "$pane_id" Enter +} + +session="$(zellij_ai_default_session)" +workspace="$PWD" +repo_dir="$(zellij_ai_expand_home_path "$(zellij_ai_default_repo_dir)")" +codex_home="$(zellij_ai_expand_home_path "$(zellij_ai_default_codex_home)")" +claude_config_dir="" +machine_name="${ZELLIJ_AI_MACHINE_NAME:-$(zellij_ai_local_machine_label)}" +recreate=false +ensure_only=false +focus_pane="" +capture_pane="" +restart_pane="" +send_pane="" +send_command="" +lines=120 + +while [[ $# -gt 0 ]]; do + case "$1" in + --workspace) + require_arg "$1" "${2:-}" + workspace="$(zellij_ai_expand_home_path "$2")" + shift 2 + ;; + --session) + require_arg "$1" "${2:-}" + session="$2" + shift 2 + ;; + --repo-dir) + require_arg "$1" "${2:-}" + repo_dir="$(zellij_ai_expand_home_path "$2")" + shift 2 + ;; + --codex-home) + require_arg "$1" "${2:-}" + codex_home="$(zellij_ai_expand_home_path "$2")" + shift 2 + ;; + --claude-config-dir) + require_arg "$1" "${2:-}" + claude_config_dir="$(zellij_ai_expand_home_path "$2")" + shift 2 + ;; + --recreate) + recreate=true + shift + ;; + --ensure-only) + ensure_only=true + shift + ;; + --focus-pane) + require_arg "$1" "${2:-}" + focus_pane="$2" + shift 2 + ;; + --capture-pane) + require_arg "$1" "${2:-}" + capture_pane="$2" + shift 2 + ;; + --send-pane) + require_arg "$1" "${2:-}" + send_pane="$2" + shift 2 + ;; + --restart-pane) + require_arg "$1" "${2:-}" + restart_pane="$2" + shift 2 + ;; + --command) + require_arg "$1" "${2:-}" + send_command="$2" + shift 2 + ;; + --lines) + require_arg "$1" "${2:-}" + lines="$2" + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Unknown option: $1" >&2 + usage >&2 + exit 2 + ;; + esac +done + +constant_tmux_require +ensure_workspace +state_dir="$(zellij_ai_session_state_dir "$session")" +bus_dir="$state_dir/bus" + +if [[ -n "$focus_pane" ]]; then + focus_role "$focus_pane" + exit 0 +fi + +if [[ -n "$capture_pane" ]]; then + capture_role "$capture_pane" + exit 0 +fi + +if [[ -n "$send_pane" ]]; then + send_role "$send_pane" + exit 0 +fi + +if [[ -n "$restart_pane" ]]; then + restart_role "$restart_pane" + exit 0 +fi + +ensure_session + +if $ensure_only; then + exit 0 +fi + +if [[ -n "${TMUX:-}" ]]; then + exec tmux switch-client -t "$session" +else + exec tmux attach-session -t "$session" +fi diff --git a/scripts/constant-tmux-remote-window.sh b/scripts/constant-tmux-remote-window.sh new file mode 100755 index 0000000..6e5fcc0 --- /dev/null +++ b/scripts/constant-tmux-remote-window.sh @@ -0,0 +1,136 @@ +#!/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/constant-common.sh" + +usage() { + local script_name="${CONSTANT_SCRIPT_NAME:-$(basename "$0")}" + cat <&2 + exit 2 + fi +} + +label="" +target="" +workspace="" +repo_dir="$(zellij_ai_default_repo_dir)" +session="$(zellij_ai_default_session)" +claude_config_dir="" +recreate=false + +while [[ $# -gt 0 ]]; do + case "$1" in + --label) + require_arg "$1" "${2:-}" + label="$2" + shift 2 + ;; + --target) + require_arg "$1" "${2:-}" + target="$2" + shift 2 + ;; + --workspace) + require_arg "$1" "${2:-}" + workspace="$2" + shift 2 + ;; + --repo-dir) + require_arg "$1" "${2:-}" + repo_dir="$2" + shift 2 + ;; + --session) + require_arg "$1" "${2:-}" + session="$2" + shift 2 + ;; + --claude-config-dir) + require_arg "$1" "${2:-}" + claude_config_dir="$2" + shift 2 + ;; + --recreate) + recreate=true + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Unknown option: $1" >&2 + usage >&2 + exit 2 + ;; + esac +done + +[[ -n "$label" ]] || { usage >&2; exit 2; } +[[ -n "$target" ]] || { usage >&2; exit 2; } +[[ -n "$workspace" ]] || { usage >&2; exit 2; } + +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")" + launcher="$local_repo_dir/scripts/constant-machine.sh" + args=( + --workspace "$local_workspace" + --session "$session" + --repo-dir "$local_repo_dir" + ) + [[ -x "$launcher" ]] || launcher="$local_repo_dir/scripts/constant-tmux-machine.sh" + if [[ -n "$claude_config_dir" ]]; then + args+=(--claude-config-dir "$claude_config_dir") + fi + if $recreate; then + args+=(--recreate) + fi + exec env ZELLIJ_AI_MACHINE_NAME="$label" "$launcher" "${args[@]}" +fi + +printf -v remote_inner 'repo_dir=%q; workspace=%q; session_name=%q; repo_dir="${repo_dir#\\}"; workspace="${workspace#\\}"; case "$repo_dir" in \$HOME|\$HOME/*) repo_dir="${HOME}${repo_dir#\$HOME}" ;; "~") repo_dir="$HOME" ;; ~/*) repo_dir="$HOME/${repo_dir#~/}" ;; esac; case "$workspace" in \$HOME|\$HOME/*) workspace="${HOME}${workspace#\$HOME}" ;; "~") workspace="$HOME" ;; ~/*) workspace="$HOME/${workspace#~/}" ;; esac; launcher="$repo_dir/scripts/constant-machine.sh"; if [[ ! -x "$launcher" ]]; then launcher="$repo_dir/scripts/constant-tmux-machine.sh"; fi; args=(--workspace "$workspace" --session "$session_name" --repo-dir "$repo_dir");' \ + "$repo_dir" \ + "$workspace" \ + "$session" + +if [[ -n "$claude_config_dir" ]]; then + printf -v remote_inner '%s args+=(--claude-config-dir %q);' "$remote_inner" "$claude_config_dir" +fi + +if $recreate; then + printf -v remote_inner '%s args+=(--recreate);' "$remote_inner" +fi + +printf -v remote_inner '%s exec env ZELLIJ_AI_MACHINE_NAME=%q ZELLIJ_AI_FORCE_OSC52=true "$launcher" "${args[@]}"' "$remote_inner" "$label" +exec ssh -tt "$target" bash -lc "$remote_inner" diff --git a/scripts/constant-vibe-pane.sh b/scripts/constant-vibe-pane.sh new file mode 100755 index 0000000..8ebaa43 --- /dev/null +++ b/scripts/constant-vibe-pane.sh @@ -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="${CONSTANT_SCRIPT_NAME:-$(basename "$0")}" +exec "$script_dir/zellij-ai-vibe-pane.sh" "$@" diff --git a/scripts/zellij-ai-bootstrap.sh b/scripts/zellij-ai-bootstrap.sh index 8f413e4..77b46f1 100755 --- a/scripts/zellij-ai-bootstrap.sh +++ b/scripts/zellij-ai-bootstrap.sh @@ -18,9 +18,9 @@ fi if [[ -n "$bootstrap_file" && ! -f "$bootstrap_file" ]]; then : >"$bootstrap_file" - 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" + printf -v codex_cmd '%q' "$script_dir/constant-codex-pane.sh" + printf -v copilot_cmd '%q' "$script_dir/constant-copilot-pane.sh" + printf -v vibe_cmd '%q' "$script_dir/constant-vibe-pane.sh" zellij action new-pane -d right -n "${ZELLIJ_AI_CODEX_LABEL:-codex}" --cwd "$workspace" zellij action move-focus right @@ -40,4 +40,4 @@ if [[ -n "$bootstrap_file" && ! -f "$bootstrap_file" ]]; then zellij action move-focus left fi -exec "$script_dir/zellij-ai-claude-pane.sh" +exec "$script_dir/constant-claude-pane.sh" diff --git a/scripts/zellij-ai-claude-pane.sh b/scripts/zellij-ai-claude-pane.sh index 11400c1..26f2750 100755 --- a/scripts/zellij-ai-claude-pane.sh +++ b/scripts/zellij-ai-claude-pane.sh @@ -8,7 +8,7 @@ while [[ -L "$script_source" ]]; do [[ "$script_source" != /* ]] && script_source="$script_dir/$script_source" done script_dir="$(cd "$(dirname "$script_source")" && pwd -P)" -source "$script_dir/zellij-ai-common.sh" +source "$script_dir/constant-common.sh" workspace="${ZELLIJ_AI_WORKSPACE:-$PWD}" machine_name="${ZELLIJ_AI_MACHINE_NAME:-unknown}" diff --git a/scripts/zellij-ai-codex-pane.sh b/scripts/zellij-ai-codex-pane.sh index 0ac5307..d558370 100755 --- a/scripts/zellij-ai-codex-pane.sh +++ b/scripts/zellij-ai-codex-pane.sh @@ -8,11 +8,12 @@ while [[ -L "$script_source" ]]; do [[ "$script_source" != /* ]] && script_source="$script_dir/$script_source" done script_dir="$(cd "$(dirname "$script_source")" && pwd -P)" -source "$script_dir/zellij-ai-common.sh" +source "$script_dir/constant-common.sh" usage() { - cat <<'EOF' -Usage: zellij-ai-codex-pane.sh [slot] + local script_name="${CONSTANT_SCRIPT_NAME:-$(basename "$0")}" + cat </dev/null | zellij_ai_strip_ansi | awk '{print $1}' | grep -Fxq "$1" + tmux has-session -t "$1" >/dev/null 2>&1 } zellij_ai_kdl_escape() { @@ -271,3 +271,86 @@ zellij_ai_agent_path() { printf '%s\n' "$path_value" } + +constant_tmux_require() { + zellij_ai_require_command tmux +} + +constant_tmux_session_exists() { + tmux has-session -t "$1" >/dev/null 2>&1 +} + +constant_tmux_window_exists() { + local session_name="$1" + local window_name="$2" + tmux list-windows -t "$session_name" -F '#{window_name}' 2>/dev/null | grep -Fxq "$window_name" +} + +constant_tmux_base_command() { + local role="$1" + local launcher="$2" + local workspace="$3" + local repo_dir="$4" + local session_name="$5" + local bus_dir="$6" + local machine_name="$7" + local codex_home="$8" + local claude_config_dir="$9" + local inner + + printf -v inner \ + 'cd %q && export ZELLIJ_AI_WORKSPACE=%q ZELLIJ_AI_REPO_DIR=%q ZELLIJ_AI_SESSION=%q ZELLIJ_AI_BUS_DIR=%q ZELLIJ_AI_MACHINE_NAME=%q ZELLIJ_AI_CODEX_HOME=%q ZELLIJ_AI_CODEX_LABEL=%q ZELLIJ_AI_COPILOT_LABEL=%q ZELLIJ_AI_VIBE_LABEL=%q ZELLIJ_AI_ROLE=%q PATH=%q;' \ + "$workspace" \ + "$workspace" \ + "$repo_dir" \ + "$session_name" \ + "$bus_dir" \ + "$machine_name" \ + "$codex_home" \ + "codex" \ + "copilot" \ + "vibe" \ + "$role" \ + "$(zellij_ai_agent_path)" + + if [[ -n "$claude_config_dir" ]]; then + printf -v inner '%s export ZELLIJ_AI_CLAUDE_CONFIG_DIR=%q;' "$inner" "$claude_config_dir" + fi + + printf -v inner '%s exec %q' "$inner" "$launcher" + printf 'bash -lc %q' "$inner" +} + +constant_tmux_role_command() { + local role="$1" + local script_dir="$2" + local workspace="$3" + local repo_dir="$4" + local session_name="$5" + local bus_dir="$6" + local machine_name="$7" + local codex_home="$8" + local claude_config_dir="$9" + local launcher="" + + case "$role" in + claude) + launcher="$script_dir/constant-claude-pane.sh" + ;; + codex) + launcher="$script_dir/constant-codex-pane.sh" + ;; + copilot) + launcher="$script_dir/constant-copilot-pane.sh" + ;; + vibe) + launcher="$script_dir/constant-vibe-pane.sh" + ;; + *) + echo "Unknown Constant pane role: $role" >&2 + return 1 + ;; + esac + + constant_tmux_base_command "$role" "$launcher" "$workspace" "$repo_dir" "$session_name" "$bus_dir" "$machine_name" "$codex_home" "$claude_config_dir" +} diff --git a/scripts/zellij-ai-copilot-pane.sh b/scripts/zellij-ai-copilot-pane.sh index c559d9f..2be3b57 100755 --- a/scripts/zellij-ai-copilot-pane.sh +++ b/scripts/zellij-ai-copilot-pane.sh @@ -8,7 +8,7 @@ while [[ -L "$script_source" ]]; do [[ "$script_source" != /* ]] && script_source="$script_dir/$script_source" done script_dir="$(cd "$(dirname "$script_source")" && pwd -P)" -source "$script_dir/zellij-ai-common.sh" +source "$script_dir/constant-common.sh" workspace="${ZELLIJ_AI_WORKSPACE:-$PWD}" machine_name="${ZELLIJ_AI_MACHINE_NAME:-unknown}" diff --git a/scripts/zellij-ai-fleet-deploy.sh b/scripts/zellij-ai-fleet-deploy.sh index 12aed9c..e6361aa 100755 --- a/scripts/zellij-ai-fleet-deploy.sh +++ b/scripts/zellij-ai-fleet-deploy.sh @@ -8,7 +8,7 @@ while [[ -L "$script_source" ]]; do [[ "$script_source" != /* ]] && script_source="$script_dir/$script_source" done script_dir="$(cd "$(dirname "$script_source")" && pwd -P)" -source "$script_dir/zellij-ai-common.sh" +source "$script_dir/constant-common.sh" usage() { local script_name="${CONSTANT_SCRIPT_NAME:-$(basename "$0")}" @@ -450,7 +450,7 @@ for raw in finalized.read_text(encoding="utf-8").splitlines(): "target": "local", "auto_clis": ["codex", "vibe", "claude"], "manual_clis": ["copilot"], - "backends": ["omc", "cli-local", "zellij"], + "backends": ["omc", "cli-local", "cockpit"], } ) else: @@ -460,7 +460,7 @@ for raw in finalized.read_text(encoding="utf-8").splitlines(): "target": f"{user}@{seed}", "auto_clis": ["codex", "vibe", "claude"], "manual_clis": ["copilot"], - "backends": ["cli-ssh", "zellij"], + "backends": ["cli-ssh", "cockpit"], } ) diff --git a/scripts/zellij-ai-fleet-install.sh b/scripts/zellij-ai-fleet-install.sh index 751e737..c430de4 100755 --- a/scripts/zellij-ai-fleet-install.sh +++ b/scripts/zellij-ai-fleet-install.sh @@ -9,7 +9,7 @@ while [[ -L "$script_source" ]]; do done script_dir="$(cd "$(dirname "$script_source")" && pwd -P)" repo_source="$(cd "$script_dir/.." && pwd -P)" -source "$script_dir/zellij-ai-common.sh" +source "$script_dir/constant-common.sh" usage() { local script_name="${CONSTANT_SCRIPT_NAME:-$(basename "$0")}" @@ -176,20 +176,20 @@ for machine_spec in "${machines[@]}"; do 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 + ZELLIJ_AI_MACHINE_NAME="$label" "$script_dir/constant-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" + remote_script="${target_repo_dir}/scripts/constant-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 + ssh "$target" env ZELLIJ_AI_MACHINE_NAME="$label" bash -s -- "${install_args[@]}" <"$script_dir/constant-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 + ssh "$target" env ZELLIJ_AI_MACHINE_NAME="$label" bash -s -- "${install_args[@]}" <"$script_dir/constant-machine-install.sh" || overall_status=1 fi done diff --git a/scripts/zellij-ai-fleet.sh b/scripts/zellij-ai-fleet.sh index 31600da..73f1875 100755 --- a/scripts/zellij-ai-fleet.sh +++ b/scripts/zellij-ai-fleet.sh @@ -8,243 +8,6 @@ while [[ -L "$script_source" ]]; do [[ "$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 <&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" <"$layout_path" <>"$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" </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" +export CONSTANT_SCRIPT_NAME="$(basename "$0")" +exec "$script_dir/constant-tmux-fleet.sh" "$@" diff --git a/scripts/zellij-ai-machine-install.sh b/scripts/zellij-ai-machine-install.sh index 5508ad5..fd5b72a 100755 --- a/scripts/zellij-ai-machine-install.sh +++ b/scripts/zellij-ai-machine-install.sh @@ -8,8 +8,8 @@ while [[ -L "$script_source" ]]; do [[ "$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" +if [[ -f "$script_dir/constant-common.sh" ]]; then + source "$script_dir/constant-common.sh" else zellij_ai_default_repo_dir() { printf '%s\n' '$HOME/constant' @@ -273,7 +273,7 @@ run_check() { 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 + for binary in bash git tmux node npm uv claude codex copilot vibe; do if command -v "$binary" >/dev/null 2>&1; then print_status "$binary" "$(command -v "$binary")" else @@ -310,7 +310,7 @@ run_check() { install_darwin() { ensure_homebrew - brew install git zellij node uv + brew install git tmux node uv } install_linux_ubuntu() { @@ -325,12 +325,8 @@ install_linux_ubuntu() { 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 + if ! command -v tmux >/dev/null 2>&1; then + run_root apt-get install -y tmux fi } @@ -354,8 +350,14 @@ install_linux_photon() { 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." + if ! command -v tmux >/dev/null 2>&1; then + if command -v tdnf >/dev/null 2>&1; then + run_root tdnf install -y tmux + fi + fi + + if ! command -v tmux >/dev/null 2>&1; then + fail "tmux is missing on Photon OS and no native install path is scripted here." fi } diff --git a/scripts/zellij-ai-remote-tab.sh b/scripts/zellij-ai-remote-tab.sh index b653770..58870f5 100755 --- a/scripts/zellij-ai-remote-tab.sh +++ b/scripts/zellij-ai-remote-tab.sh @@ -8,22 +8,23 @@ while [[ -L "$script_source" ]]; do [[ "$script_source" != /* ]] && script_source="$script_dir/$script_source" done script_dir="$(cd "$(dirname "$script_source")" && pwd -P)" -source "$script_dir/zellij-ai-common.sh" +source "$script_dir/constant-common.sh" warn_deprecated() { printf 'Warning: %s\n' "$*" >&2 } usage() { - cat <<'EOF' -Usage: zellij-ai-remote-tab.sh [options] + local script_name="${CONSTANT_SCRIPT_NAME:-$(basename "$0")}" + cat <&2 -} - -workspace="$PWD" -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 - -while [[ $# -gt 0 ]]; do - case "$1" in - --workspace) - workspace="$2" - shift 2 - ;; - --session) - 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) - warn_deprecated "--codex-image is deprecated and ignored; Codex now runs on the host." - shift 2 - ;; - --codex1-home) - warn_deprecated "--codex1-home is deprecated; use --codex-home." - codex_home="$2" - shift 2 - ;; - --codex2-home) - warn_deprecated "--codex2-home is deprecated and ignored." - shift 2 - ;; - --codex1-label) - warn_deprecated "--codex1-label is deprecated; use --codex-label." - codex_label="$2" - shift 2 - ;; - --codex2-label) - warn_deprecated "--codex2-label is deprecated and ignored." - shift 2 - ;; - --claude-config) - claude_config_dir="$2" - shift 2 - ;; - --zellij-config-dir) - zellij_config_dir="$2" - shift 2 - ;; - --recreate) - recreate=true - shift - ;; - -h|--help) - usage - exit 0 - ;; - *) - echo "Unknown option: $1" >&2 - usage >&2 - exit 2 - ;; - esac -done - -workspace="$(cd "$workspace" && pwd -P)" -codex_home="$(mkdir -p "$(zellij_ai_expand_home_path "$codex_home")" && cd "$(zellij_ai_expand_home_path "$codex_home")" && pwd -P)" - -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 - exit 1 -fi - -for required in \ - "$script_dir/zellij-ai-claude-pane.sh" \ - "$script_dir/zellij-ai-codex-pane.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 - exit 1 - 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_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 - -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 zellij_ai_session_exists "$session"; then - exec "${zellij_attach_cmd[@]}" attach "$session" -fi - -zellij delete-session "$session" >/dev/null 2>&1 || true -mkdir -p "$state_dir" -rm -f "$bootstrap_file" - -bootstrap_path="$(zellij_ai_kdl_escape "$script_dir/zellij-ai-bootstrap.sh")" -cat >"$layout_file" <