diff --git a/.gitignore b/.gitignore index bcd5014..1003988 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..69eeeb8 --- /dev/null +++ b/CONTRIBUTING.md @@ -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. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..e8a8877 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md index 196cd9d..8853ea1 100644 --- a/README.md +++ b/README.md @@ -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//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. diff --git a/constant/__init__.py b/constant/__init__.py new file mode 100644 index 0000000..a05eb9a --- /dev/null +++ b/constant/__init__.py @@ -0,0 +1,3 @@ +__all__ = ["__version__"] + +__version__ = "0.1.0" diff --git a/constant/__main__.py b/constant/__main__.py new file mode 100644 index 0000000..a049ad7 --- /dev/null +++ b/constant/__main__.py @@ -0,0 +1,5 @@ +from .cli import main + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/constant/cli.py b/constant/cli.py new file mode 100644 index 0000000..9a88b76 --- /dev/null +++ b/constant/cli.py @@ -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()) diff --git a/constant/daemon.py b/constant/daemon.py new file mode 100644 index 0000000..255e084 --- /dev/null +++ b/constant/daemon.py @@ -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"] diff --git a/constant/executors.py b/constant/executors.py new file mode 100644 index 0000000..c0da48f --- /dev/null +++ b/constant/executors.py @@ -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, + } diff --git a/constant/memory.py b/constant/memory.py new file mode 100644 index 0000000..0f2b3d6 --- /dev/null +++ b/constant/memory.py @@ -0,0 +1,1097 @@ +from __future__ import annotations + +import hashlib +import json +import math +import os +import re +import sqlite3 +import urllib.error +import urllib.request +from collections import Counter +from pathlib import Path +from typing import Any + +from .paths import memory_store_path, persona_path +from .state import ( + append_event, + load_memory_config, + load_mission, + now_utc, + save_memory_config, +) + + +TEXT_SUFFIXES = { + ".c", + ".cc", + ".cfg", + ".conf", + ".cpp", + ".css", + ".go", + ".h", + ".hpp", + ".html", + ".java", + ".js", + ".json", + ".jsx", + ".md", + ".prompt", + ".py", + ".rb", + ".rs", + ".sh", + ".sql", + ".swift", + ".toml", + ".ts", + ".tsx", + ".txt", + ".yaml", + ".yml", + ".zsh", +} + +IGNORED_DIR_NAMES = { + ".git", + ".hg", + ".idea", + ".next", + ".venv", + "__pycache__", + "build", + "cache", + "dist", + "ide", + "logs", + "node_modules", + "output", + "session-state", + "target", + "tmp", + "venv", +} + +INSTRUCTION_DIRS = (".claude", ".copilot", ".agents", ".agent") +INSTRUCTION_FILES = ("CLAUDE.md", "AGENTS.md") +INSTRUCTION_SUFFIXES = {".json", ".md", ".prompt", ".toml", ".txt", ".yaml", ".yml"} +MAX_TEXT_BYTES = 256 * 1024 +MAX_INSTRUCTION_BYTES = 128 * 1024 + + +def _connect() -> sqlite3.Connection: + path = memory_store_path() + path.parent.mkdir(parents=True, exist_ok=True) + connection = sqlite3.connect(path) + connection.row_factory = sqlite3.Row + connection.execute("pragma journal_mode = wal") + connection.execute("pragma foreign_keys = on") + _ensure_schema(connection) + return connection + + +def _ensure_schema(connection: sqlite3.Connection) -> None: + connection.executescript( + """ + create table if not exists workspaces ( + path text primary key, + repo_root text not null, + enrolled_at text not null, + last_indexed_at text, + last_summary_at text + ); + + create table if not exists documents ( + id integer primary key, + workspace text not null, + path text not null unique, + rel_path text not null, + kind text not null, + language text, + source_type text not null, + content_hash text not null, + mtime real not null, + size integer not null, + weight real not null default 0, + updated_at text not null + ); + + create table if not exists chunks ( + id integer primary key, + document_id integer not null references documents(id) on delete cascade, + workspace text not null, + path text not null, + chunk_index integer not null, + content text not null, + content_hash text not null, + token_count integer not null, + updated_at text not null, + unique(document_id, chunk_index) + ); + + create virtual table if not exists chunk_fts using fts5( + chunk_id unindexed, + workspace, + path, + content + ); + + create table if not exists embeddings ( + chunk_id integer primary key references chunks(id) on delete cascade, + dims integer not null, + vector_json text not null + ); + + create table if not exists instruction_sources ( + id integer primary key, + workspace text, + path text not null unique, + scope text not null, + source_kind text not null, + weight real not null, + content text not null, + content_hash text not null, + updated_at text not null + ); + + create virtual table if not exists instruction_fts using fts5( + source_id unindexed, + path, + content + ); + + create table if not exists persona_facts ( + id integer primary key, + fact text not null unique, + weight real not null, + source_path text, + updated_at text not null + ); + + create table if not exists mission_summaries ( + mission_id text primary key, + workspace text not null, + title text not null, + status text not null, + summary text not null, + keywords_json text not null, + updated_at text not null + ); + + create virtual table if not exists mission_fts using fts5( + mission_id unindexed, + workspace, + title, + summary + ); + + create table if not exists decisions ( + decision_id text primary key, + mission_id text not null, + step_id text, + workspace text not null, + title text not null, + summary text not null, + status text not null, + weight real not null, + updated_at text not null + ); + + create virtual table if not exists decision_fts using fts5( + decision_id unindexed, + workspace, + title, + summary + ); + + create table if not exists decision_edges ( + id integer primary key, + from_decision_id text not null, + to_decision_id text not null, + edge_type text not null, + created_at text not null, + unique(from_decision_id, to_decision_id, edge_type) + ); + + create table if not exists entities ( + id integer primary key, + name text not null, + entity_type text not null, + source text, + updated_at text not null, + unique(name, entity_type) + ); + """ + ) + connection.commit() + + +def _normalize_workspace(path: str | Path) -> Path: + return Path(path).expanduser().resolve() + + +def _detect_repo_root(workspace: Path) -> Path: + for candidate in [workspace, *workspace.parents]: + if (candidate / ".git").exists(): + return candidate + return workspace + + +def _sanitize_fts_query(query: str) -> str: + tokens = re.findall(r"[A-Za-z0-9_.:/-]+", query.lower()) + return " ".join(tokens[:12]) or "constant" + + +def _tokenize(text: str) -> list[str]: + return re.findall(r"[A-Za-z0-9_./:-]{2,}", text.lower()) + + +def _sha256_text(text: str) -> str: + return hashlib.sha256(text.encode("utf-8")).hexdigest() + + +def _embed_text(text: str, dims: int) -> list[float]: + vector = [0.0] * dims + if dims <= 0: + return vector + for token, count in Counter(_tokenize(text)).items(): + digest = hashlib.sha256(token.encode("utf-8")).digest() + index = int.from_bytes(digest[:4], "big") % dims + sign = 1.0 if digest[4] % 2 == 0 else -1.0 + vector[index] += sign * (1.0 + math.log1p(count)) + norm = math.sqrt(sum(value * value for value in vector)) + if norm == 0: + return vector + return [round(value / norm, 6) for value in vector] + + +def _cosine_similarity(vec_a: list[float], vec_b: list[float]) -> float: + if not vec_a or not vec_b or len(vec_a) != len(vec_b): + return 0.0 + return sum(a * b for a, b in zip(vec_a, vec_b)) + + +def _relative_path(path: Path, root: Path) -> str: + try: + return str(path.relative_to(root)) + except ValueError: + return str(path) + + +def _language_for(path: Path) -> str: + suffix = path.suffix.lower() + return { + ".py": "python", + ".sh": "shell", + ".zsh": "shell", + ".js": "javascript", + ".jsx": "javascript", + ".ts": "typescript", + ".tsx": "typescript", + ".json": "json", + ".yaml": "yaml", + ".yml": "yaml", + ".md": "markdown", + ".toml": "toml", + ".rs": "rust", + ".go": "go", + ".swift": "swift", + ".html": "html", + ".css": "css", + }.get(suffix, suffix.lstrip(".") or "text") + + +def _is_probably_text(path: Path, max_bytes: int) -> bool: + if not path.is_file(): + return False + try: + if path.stat().st_size > max_bytes: + return False + with path.open("rb") as handle: + sample = handle.read(2048) + return b"\x00" not in sample + except OSError: + return False + + +def _read_text(path: Path, max_bytes: int) -> str | None: + if not _is_probably_text(path, max_bytes): + return None + try: + return path.read_text(encoding="utf-8") + except UnicodeDecodeError: + try: + return path.read_text(encoding="utf-8", errors="ignore") + except OSError: + return None + except OSError: + return None + + +def _should_index_repo_file(path: Path) -> bool: + if path.name.startswith(".") and path.name not in {".gitignore", ".env.example"}: + return path.name in INSTRUCTION_FILES + if path.suffix.lower() in TEXT_SUFFIXES: + return True + return path.name in {"Dockerfile", "Makefile", "justfile"} + + +def _walk_repo_files(workspace: Path) -> list[Path]: + files: list[Path] = [] + for root, dirnames, filenames in os.walk(workspace): + dirnames[:] = sorted(name for name in dirnames if name not in IGNORED_DIR_NAMES) + for filename in sorted(filenames): + path = Path(root) / filename + if _should_index_repo_file(path): + files.append(path) + return files + + +def _iter_instruction_candidates(base: Path) -> list[Path]: + candidates: list[Path] = [] + for name in INSTRUCTION_FILES: + candidate = base / name + if candidate.exists(): + candidates.append(candidate) + + for dirname in INSTRUCTION_DIRS: + directory = base / dirname + if not directory.exists() or not directory.is_dir(): + continue + for root, dirnames, filenames in os.walk(directory): + dirnames[:] = sorted(name for name in dirnames if name not in IGNORED_DIR_NAMES) + depth = len(Path(root).relative_to(directory).parts) + if depth > 3: + dirnames[:] = [] + continue + for filename in sorted(filenames): + path = Path(root) / filename + if path.suffix.lower() in INSTRUCTION_SUFFIXES or filename in {"config.json", "settings.json"}: + candidates.append(path) + return candidates + + +def _instruction_scope(path: Path, workspace: Path, repo_root: Path, config: dict[str, Any]) -> tuple[str, float]: + weights = config["instruction_weights"] + home = Path.home().resolve() + if path == workspace or workspace in path.parents: + return "workspace", float(weights["workspace"]) + if path == repo_root or repo_root in path.parents: + return "repo", float(weights["repo"]) + if home == path.parent or home in path.parents: + return "user", float(weights["user"]) + return "ancestor", float(weights["ancestor"]) + + +def _discover_instruction_files(workspace: Path) -> list[dict[str, Any]]: + config = load_memory_config() + repo_root = _detect_repo_root(workspace) + seen: set[str] = set() + entries: list[dict[str, Any]] = [] + bases = [workspace, repo_root, *repo_root.parents] + home = Path.home().resolve() + if home not in bases: + bases.append(home) + + for base in bases: + if not str(base).startswith(str(home)) and base != workspace and base != repo_root: + continue + for candidate in _iter_instruction_candidates(base): + key = str(candidate.resolve()) + if key in seen: + continue + seen.add(key) + content = _read_text(candidate, MAX_INSTRUCTION_BYTES) + if not content or not content.strip(): + continue + scope, weight = _instruction_scope(candidate.resolve(), workspace, repo_root, config) + entries.append( + { + "path": candidate.resolve(), + "scope": scope, + "weight": weight, + "kind": candidate.parent.name if candidate.parent != candidate.parent.parent else candidate.name, + "content": content, + } + ) + return sorted(entries, key=lambda item: (-item["weight"], str(item["path"]))) + + +def _chunk_text(text: str, target_chars: int = 1200, overlap_lines: int = 3) -> list[str]: + lines = text.splitlines() + if not lines: + return [] + + chunks: list[str] = [] + start = 0 + while start < len(lines): + total = 0 + end = start + while end < len(lines) and total < target_chars: + total += len(lines[end]) + 1 + end += 1 + chunk = "\n".join(lines[start:end]).strip() + if chunk: + chunks.append(chunk) + if end >= len(lines): + break + start = max(end - overlap_lines, start + 1) + return chunks + + +def _upsert_workspace(connection: sqlite3.Connection, workspace: Path) -> None: + repo_root = _detect_repo_root(workspace) + connection.execute( + """ + insert into workspaces(path, repo_root, enrolled_at, last_indexed_at, last_summary_at) + values (?, ?, ?, null, null) + on conflict(path) do update set repo_root=excluded.repo_root + """, + (str(workspace), str(repo_root), now_utc()), + ) + + +def _prune_missing_documents(connection: sqlite3.Connection, workspace: Path, keep_paths: set[str]) -> int: + removed = 0 + rows = connection.execute("select id, path from documents where workspace = ?", (str(workspace),)).fetchall() + for row in rows: + if row["path"] not in keep_paths: + connection.execute("delete from chunk_fts where chunk_id in (select id from chunks where document_id = ?)", (row["id"],)) + connection.execute("delete from documents where id = ?", (row["id"],)) + removed += 1 + return removed + + +def _index_repo_documents(connection: sqlite3.Connection, workspace: Path, dims: int) -> dict[str, int]: + indexed = 0 + skipped = 0 + chunks_written = 0 + files = _walk_repo_files(workspace) + keep_paths = {str(path.resolve()) for path in files} + pruned = _prune_missing_documents(connection, workspace, keep_paths) + + for path in files: + resolved = path.resolve() + stat = resolved.stat() + existing = connection.execute( + "select id, mtime, size from documents where path = ?", + (str(resolved),), + ).fetchone() + if existing and float(existing["mtime"]) == stat.st_mtime and int(existing["size"]) == stat.st_size: + skipped += 1 + continue + + content = _read_text(resolved, MAX_TEXT_BYTES) + if not content or not content.strip(): + skipped += 1 + continue + + digest = _sha256_text(content) + if existing: + connection.execute("delete from chunk_fts where chunk_id in (select id from chunks where document_id = ?)", (existing["id"],)) + connection.execute("delete from documents where id = ?", (existing["id"],)) + + cursor = connection.execute( + """ + insert into documents(workspace, path, rel_path, kind, language, source_type, content_hash, mtime, size, weight, updated_at) + values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + str(workspace), + str(resolved), + _relative_path(resolved, workspace), + "repo", + _language_for(resolved), + "repo", + digest, + stat.st_mtime, + stat.st_size, + 1.0, + now_utc(), + ), + ) + document_id = int(cursor.lastrowid) + for chunk_index, chunk in enumerate(_chunk_text(content), start=1): + chunk_hash = _sha256_text(chunk) + chunk_cursor = connection.execute( + """ + insert into chunks(document_id, workspace, path, chunk_index, content, content_hash, token_count, updated_at) + values (?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + document_id, + str(workspace), + str(resolved), + chunk_index, + chunk, + chunk_hash, + len(_tokenize(chunk)), + now_utc(), + ), + ) + chunk_id = int(chunk_cursor.lastrowid) + connection.execute( + "insert into chunk_fts(chunk_id, workspace, path, content) values (?, ?, ?, ?)", + (chunk_id, str(workspace), str(resolved), chunk), + ) + connection.execute( + "insert into embeddings(chunk_id, dims, vector_json) values (?, ?, ?)", + (chunk_id, dims, json.dumps(_embed_text(chunk, dims))), + ) + chunks_written += 1 + indexed += 1 + + connection.execute( + "update workspaces set last_indexed_at = ? where path = ?", + (now_utc(), str(workspace)), + ) + return {"indexed": indexed, "skipped": skipped, "pruned": pruned, "chunks": chunks_written} + + +def _extract_persona_facts(text: str) -> list[str]: + facts: list[str] = [] + for raw_line in text.splitlines(): + line = raw_line.strip() + if not line: + continue + if line.startswith(("#", "```", "{", "}", "[", "]")): + continue + if len(line) < 12 or len(line) > 220: + continue + cleaned = re.sub(r"^[*-]\s*", "", line) + if cleaned and cleaned not in facts: + facts.append(cleaned) + if len(facts) >= 24: + break + return facts + + +def _refresh_instruction_sources(connection: sqlite3.Connection, workspace: Path) -> dict[str, int]: + discovered = _discover_instruction_files(workspace) + keep_paths = {str(entry["path"]) for entry in discovered} + current = connection.execute("select path from instruction_sources").fetchall() + removed = 0 + for row in current: + if row["path"] not in keep_paths: + connection.execute("delete from instruction_fts where path = ?", (row["path"],)) + connection.execute("delete from instruction_sources where path = ?", (row["path"],)) + removed += 1 + + refreshed = 0 + facts = 0 + for entry in discovered: + path = entry["path"] + content = entry["content"] + digest = _sha256_text(content) + existing = connection.execute( + "select content_hash from instruction_sources where path = ?", + (str(path),), + ).fetchone() + if existing and existing["content_hash"] == digest: + continue + connection.execute("delete from instruction_fts where path = ?", (str(path),)) + connection.execute( + """ + insert into instruction_sources(workspace, path, scope, source_kind, weight, content, content_hash, updated_at) + values (?, ?, ?, ?, ?, ?, ?, ?) + on conflict(path) do update set + workspace=excluded.workspace, + scope=excluded.scope, + source_kind=excluded.source_kind, + weight=excluded.weight, + content=excluded.content, + content_hash=excluded.content_hash, + updated_at=excluded.updated_at + """, + ( + str(workspace), + str(path), + entry["scope"], + entry["kind"], + float(entry["weight"]), + content, + digest, + now_utc(), + ), + ) + connection.execute( + "insert into instruction_fts(source_id, path, content) values ((select id from instruction_sources where path = ?), ?, ?)", + (str(path), str(path), content), + ) + for fact in _extract_persona_facts(content): + connection.execute( + """ + insert into persona_facts(fact, weight, source_path, updated_at) + values (?, ?, ?, ?) + on conflict(fact) do update set + weight=max(weight, excluded.weight), + source_path=excluded.source_path, + updated_at=excluded.updated_at + """, + (fact, float(entry["weight"]), str(path), now_utc()), + ) + facts += 1 + refreshed += 1 + + return {"sources": len(discovered), "refreshed": refreshed, "removed": removed, "persona_facts": facts} + + +def _render_persona(connection: sqlite3.Connection) -> str: + rows = connection.execute( + "select fact, weight, source_path from persona_facts order by weight desc, fact asc limit 32" + ).fetchall() + mission_rows = connection.execute( + "select mission_id, title, summary from mission_summaries order by updated_at desc limit 6" + ).fetchall() + decision_rows = connection.execute( + "select decision_id, title, summary from decisions order by updated_at desc limit 8" + ).fetchall() + + lines = [ + "# Constant Persona", + "", + "## Durable Rules", + ] + if rows: + for row in rows: + lines.append(f"- {row['fact']} ({Path(row['source_path']).name})") + else: + lines.append("- No durable rules extracted yet.") + + lines.extend(["", "## Recent Mission Summaries"]) + if mission_rows: + for row in mission_rows: + lines.append(f"- `{row['mission_id']}` {row['title']}: {row['summary']}") + else: + lines.append("- No mission summaries yet.") + + lines.extend(["", "## Decision Graph Snapshot"]) + if decision_rows: + for row in decision_rows: + lines.append(f"- `{row['decision_id']}` {row['title']}: {row['summary']}") + else: + lines.append("- No decisions captured yet.") + return "\n".join(lines).strip() + "\n" + + +def rebuild_workspace_memory(workspace: str | Path, enroll: bool = True) -> dict[str, Any]: + workspace_path = _normalize_workspace(workspace) + config = load_memory_config() + if enroll and str(workspace_path) not in config["workspace_enrollments"]: + config["workspace_enrollments"].append(str(workspace_path)) + config["workspace_enrollments"] = sorted(set(config["workspace_enrollments"])) + save_memory_config(config) + + connection = _connect() + try: + _upsert_workspace(connection, workspace_path) + dims = int(config.get("vector_dimensions", 96)) + repo_stats = _index_repo_documents(connection, workspace_path, dims) + instruction_stats = _refresh_instruction_sources(connection, workspace_path) + persona_markdown = _render_persona(connection) + persona_path().parent.mkdir(parents=True, exist_ok=True) + persona_path().write_text(persona_markdown, encoding="utf-8") + connection.commit() + return { + "workspace": str(workspace_path), + "repo_root": str(_detect_repo_root(workspace_path)), + "repo": repo_stats, + "instructions": instruction_stats, + "persona_path": str(persona_path()), + "store_path": str(memory_store_path()), + } + finally: + connection.close() + + +def memory_status(workspace: str | None = None) -> dict[str, Any]: + connection = _connect() + try: + payload = { + "store_path": str(memory_store_path()), + "persona_path": str(persona_path()), + "enrollments": load_memory_config()["workspace_enrollments"], + "counts": { + "workspaces": connection.execute("select count(*) from workspaces").fetchone()[0], + "documents": connection.execute("select count(*) from documents").fetchone()[0], + "chunks": connection.execute("select count(*) from chunks").fetchone()[0], + "instruction_sources": connection.execute("select count(*) from instruction_sources").fetchone()[0], + "persona_facts": connection.execute("select count(*) from persona_facts").fetchone()[0], + "mission_summaries": connection.execute("select count(*) from mission_summaries").fetchone()[0], + "decisions": connection.execute("select count(*) from decisions").fetchone()[0], + "decision_edges": connection.execute("select count(*) from decision_edges").fetchone()[0], + }, + } + if workspace: + payload["workspace"] = str(_normalize_workspace(workspace)) + payload["workspace_counts"] = { + "documents": connection.execute("select count(*) from documents where workspace = ?", (payload["workspace"],)).fetchone()[0], + "mission_summaries": connection.execute("select count(*) from mission_summaries where workspace = ?", (payload["workspace"],)).fetchone()[0], + "decisions": connection.execute("select count(*) from decisions where workspace = ?", (payload["workspace"],)).fetchone()[0], + } + return payload + finally: + connection.close() + + +def enroll_workspace(workspace: str | Path) -> dict[str, Any]: + workspace_path = str(_normalize_workspace(workspace)) + config = load_memory_config() + config["workspace_enrollments"] = sorted(set(config["workspace_enrollments"] + [workspace_path])) + save_memory_config(config) + return rebuild_workspace_memory(workspace_path, enroll=False) + + +def _snippet(text: str, limit: int = 220) -> str: + compact = " ".join(text.split()) + return compact[:limit] + ("..." if len(compact) > limit else "") + + +def search_memory(query: str, workspace: str | None = None, limit: int | None = None) -> dict[str, Any]: + config = load_memory_config() + max_hits = limit or int(config.get("max_chunks_per_query", 8)) + workspace_path = str(_normalize_workspace(workspace)) if workspace else None + connection = _connect() + try: + hits: list[dict[str, Any]] = [] + fts_query = _sanitize_fts_query(query) + params: list[Any] = [fts_query] + chunk_sql = ( + "select chunk_id, path, content, bm25(chunk_fts) as rank from chunk_fts where chunk_fts match ?" + ) + if workspace_path: + chunk_sql += " and workspace = ?" + params.append(workspace_path) + chunk_sql += " order by rank limit ?" + params.append(max_hits * 3) + for row in connection.execute(chunk_sql, tuple(params)).fetchall(): + hits.append( + { + "kind": "repo", + "path": row["path"], + "score": round(2.0 - float(row["rank"]), 4), + "snippet": _snippet(row["content"]), + } + ) + + query_vector = _embed_text(query, int(config.get("vector_dimensions", 96))) + vector_rows = connection.execute( + "select chunks.path, chunks.content, embeddings.vector_json from chunks join embeddings on embeddings.chunk_id = chunks.id" + + (" where chunks.workspace = ?" if workspace_path else ""), + ((workspace_path,) if workspace_path else ()), + ).fetchall() + for row in vector_rows: + score = _cosine_similarity(query_vector, json.loads(row["vector_json"])) + if score <= 0: + continue + hits.append( + { + "kind": "repo-vector", + "path": row["path"], + "score": round(score, 4), + "snippet": _snippet(row["content"]), + } + ) + + source_rows = connection.execute( + "select path, scope, weight, content from instruction_sources order by weight desc" + ).fetchall() + for row in source_rows: + haystack = row["content"].lower() + if query.lower() in haystack or any(token in haystack for token in _tokenize(query)[:4]): + hits.append( + { + "kind": "instruction", + "path": row["path"], + "score": round(float(row["weight"]) + 1.0, 4), + "snippet": _snippet(row["content"]), + "scope": row["scope"], + } + ) + + summary_rows = connection.execute( + "select mission_id, title, summary from mission_summaries order by updated_at desc" + + (" limit 64" if not workspace_path else ""), + ).fetchall() + for row in summary_rows: + text = f"{row['title']} {row['summary']}".lower() + if query.lower() in text or any(token in text for token in _tokenize(query)[:4]): + hits.append( + { + "kind": "mission", + "path": row["mission_id"], + "score": 1.25, + "snippet": _snippet(row["summary"]), + } + ) + + decision_rows = connection.execute( + "select decision_id, title, summary, status from decisions order by updated_at desc limit 128" + ).fetchall() + for row in decision_rows: + text = f"{row['title']} {row['summary']}".lower() + if query.lower() in text or any(token in text for token in _tokenize(query)[:4]): + hits.append( + { + "kind": "decision", + "path": row["decision_id"], + "score": 1.1, + "snippet": _snippet(row["summary"]), + "status": row["status"], + } + ) + + ranked = sorted(hits, key=lambda item: item["score"], reverse=True) + deduped: list[dict[str, Any]] = [] + seen: set[tuple[str, str]] = set() + for hit in ranked: + key = (hit["kind"], hit["path"]) + if key in seen: + continue + seen.add(key) + deduped.append(hit) + if len(deduped) >= max_hits: + break + + return { + "query": query, + "workspace": workspace_path, + "hits": deduped, + } + finally: + connection.close() + + +def persona_markdown() -> str: + if persona_path().exists(): + return persona_path().read_text(encoding="utf-8") + rebuild_workspace_memory(Path.cwd(), enroll=False) + return persona_path().read_text(encoding="utf-8") if persona_path().exists() else "# Constant Persona\n" + + +def list_decisions(workspace: str | None = None, mission_id: str | None = None) -> dict[str, Any]: + connection = _connect() + try: + clauses = [] + params: list[Any] = [] + if workspace: + clauses.append("workspace = ?") + params.append(str(_normalize_workspace(workspace))) + if mission_id: + clauses.append("mission_id = ?") + params.append(mission_id) + sql = "select decision_id, mission_id, step_id, workspace, title, summary, status, weight, updated_at from decisions" + if clauses: + sql += " where " + " and ".join(clauses) + sql += " order by updated_at desc, decision_id asc" + rows = connection.execute(sql, tuple(params)).fetchall() + return {"decisions": [dict(row) for row in rows]} + finally: + connection.close() + + +def _mission_keywords(mission: dict[str, Any]) -> list[str]: + tokens = set(_tokenize(mission["title"] + " " + mission["goal"])) + for step in mission["steps"]: + tokens.update([step.get("machine", ""), step.get("cli", ""), step.get("backend", ""), step.get("status", "")]) + return sorted(token for token in tokens if token)[:24] + + +def summarize_mission(mission_id: str) -> dict[str, Any]: + mission = load_mission(mission_id) + workspace = str(_normalize_workspace(mission["workspace"])) + connection = _connect() + try: + _upsert_workspace(connection, Path(workspace)) + status_counts = Counter(step.get("status", "unknown") for step in mission["steps"]) + route_bits = [] + for step in mission["steps"]: + route_bits.append(f"{step['step_id']}={step['machine']}/{step['cli']}/{step['backend']}:{step['status']}") + summary = ( + f"Mission {mission['title']} ended as {mission['status']}. " + f"Steps={len(mission['steps'])}. " + f"Status mix={dict(status_counts)}. " + f"Routes: {'; '.join(route_bits[:6]) or 'none'}." + ) + keywords = _mission_keywords(mission) + connection.execute( + """ + insert into mission_summaries(mission_id, workspace, title, status, summary, keywords_json, updated_at) + values (?, ?, ?, ?, ?, ?, ?) + on conflict(mission_id) do update set + workspace=excluded.workspace, + title=excluded.title, + status=excluded.status, + summary=excluded.summary, + keywords_json=excluded.keywords_json, + updated_at=excluded.updated_at + """, + ( + mission_id, + workspace, + mission["title"], + mission["status"], + summary, + json.dumps(keywords), + now_utc(), + ), + ) + connection.execute( + "delete from mission_fts where mission_id = ?", + (mission_id,), + ) + connection.execute( + "insert into mission_fts(mission_id, workspace, title, summary) values (?, ?, ?, ?)", + (mission_id, workspace, mission["title"], summary), + ) + + previous_decision_id: str | None = None + decision_count = 0 + for step in mission["steps"]: + decision_id = f"{mission_id}:{step['step_id']}" + decision_summary = ( + f"Route {step['machine']}/{step['cli']}/{step['backend']} ended as {step['status']}. " + f"{step.get('result_summary', '')}".strip() + ) + connection.execute( + """ + insert into decisions(decision_id, mission_id, step_id, workspace, title, summary, status, weight, updated_at) + values (?, ?, ?, ?, ?, ?, ?, ?, ?) + on conflict(decision_id) do update set + mission_id=excluded.mission_id, + step_id=excluded.step_id, + workspace=excluded.workspace, + title=excluded.title, + summary=excluded.summary, + status=excluded.status, + weight=excluded.weight, + updated_at=excluded.updated_at + """, + ( + decision_id, + mission_id, + step["step_id"], + workspace, + step["title"], + decision_summary, + step["status"], + 1.0 if step["status"] == "done" else 0.7, + now_utc(), + ), + ) + connection.execute("delete from decision_fts where decision_id = ?", (decision_id,)) + connection.execute( + "insert into decision_fts(decision_id, workspace, title, summary) values (?, ?, ?, ?)", + (decision_id, workspace, step["title"], decision_summary), + ) + if previous_decision_id: + connection.execute( + """ + insert or ignore into decision_edges(from_decision_id, to_decision_id, edge_type, created_at) + values (?, ?, ?, ?) + """, + (previous_decision_id, decision_id, "depends_on", now_utc()), + ) + previous_decision_id = decision_id + decision_count += 1 + + connection.execute( + "update workspaces set last_summary_at = ? where path = ?", + (now_utc(), workspace), + ) + persona_markdown_value = _render_persona(connection) + persona_path().parent.mkdir(parents=True, exist_ok=True) + persona_path().write_text(persona_markdown_value, encoding="utf-8") + connection.commit() + append_event(mission_id, "memory.summary_written", {"summary": summary, "decisions": decision_count}) + return { + "mission_id": mission_id, + "summary": summary, + "keywords": keywords, + "decisions": decision_count, + "persona_path": str(persona_path()), + } + finally: + connection.close() + + +def planner_context(workspace: str | Path, query: str) -> dict[str, Any]: + workspace_path = str(_normalize_workspace(workspace)) + rebuild_workspace_memory(workspace_path, enroll=True) + search = search_memory(query, workspace_path) + connection = _connect() + try: + instruction_rows = connection.execute( + """ + select path, scope, weight, content + from instruction_sources + where workspace = ? or scope = 'user' + order by weight desc, updated_at desc + limit 8 + """, + (workspace_path,), + ).fetchall() + decision_rows = connection.execute( + """ + select decision_id, title, summary, status + from decisions + where workspace = ? + order by updated_at desc + limit 6 + """, + (workspace_path,), + ).fetchall() + persona_rows = connection.execute( + "select fact from persona_facts order by weight desc, updated_at desc limit 12" + ).fetchall() + return { + "workspace": workspace_path, + "instruction_excerpt": [ + { + "path": row["path"], + "scope": row["scope"], + "weight": row["weight"], + "snippet": _snippet(row["content"], 180), + } + for row in instruction_rows + ], + "repo_hits": search["hits"], + "recent_decisions": [dict(row) for row in decision_rows], + "persona_facts": [row["fact"] for row in persona_rows], + } + finally: + connection.close() + + +def sync_qdrant(workspace: str | None = None) -> dict[str, Any]: + config = load_memory_config() + url = str(config.get("qdrant_url", "")).strip() + if not url: + return {"ok": False, "skipped": True, "reason": "qdrant_url is not configured"} + + search_payload = search_memory("*", workspace, limit=32) + collection = config.get("qdrant_collection", "constant_memory") + request_body = { + "points": [ + { + "id": abs(hash((hit["kind"], hit["path"]))) % 2_147_483_647, + "vector": _embed_text(hit["snippet"], int(config.get("vector_dimensions", 96))), + "payload": hit, + } + for hit in search_payload["hits"] + ] + } + req = urllib.request.Request( + url.rstrip("/") + f"/collections/{collection}/points?wait=true", + data=json.dumps(request_body).encode("utf-8"), + headers={"Content-Type": "application/json"}, + method="PUT", + ) + try: + with urllib.request.urlopen(req, timeout=5) as response: + payload = json.loads(response.read().decode("utf-8")) + return {"ok": True, "response": payload, "points": len(request_body["points"])} + except urllib.error.URLError as exc: + return {"ok": False, "skipped": False, "reason": str(exc), "points": len(request_body["points"])} diff --git a/constant/paths.py b/constant/paths.py new file mode 100644 index 0000000..cc97690 --- /dev/null +++ b/constant/paths.py @@ -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" diff --git a/constant/planner.py b/constant/planner.py new file mode 100644 index 0000000..d19752b --- /dev/null +++ b/constant/planner.py @@ -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 diff --git a/constant/state.py b/constant/state.py new file mode 100644 index 0000000..9a908c9 --- /dev/null +++ b/constant/state.py @@ -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 diff --git a/scripts/Constant b/scripts/Constant new file mode 100755 index 0000000..a9864d9 --- /dev/null +++ b/scripts/Constant @@ -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 "$@" diff --git a/scripts/ai-bridge.sh b/scripts/ai-bridge.sh new file mode 100755 index 0000000..22ef8f7 --- /dev/null +++ b/scripts/ai-bridge.sh @@ -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 < [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 diff --git a/scripts/ai-copy.sh b/scripts/ai-copy.sh new file mode 100755 index 0000000..b57efe8 --- /dev/null +++ b/scripts/ai-copy.sh @@ -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' diff --git a/scripts/ai-msg.sh b/scripts/ai-msg.sh new file mode 100755 index 0000000..0a71de1 --- /dev/null +++ b/scripts/ai-msg.sh @@ -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 [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 diff --git a/scripts/constant-fleet-install.sh b/scripts/constant-fleet-install.sh new file mode 100755 index 0000000..896f86c --- /dev/null +++ b/scripts/constant-fleet-install.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="$(basename "$0")" +exec "$script_dir/zellij-ai-fleet-install.sh" "$@" diff --git a/scripts/constant-fleet.sh b/scripts/constant-fleet.sh new file mode 100755 index 0000000..212c4a1 --- /dev/null +++ b/scripts/constant-fleet.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="$(basename "$0")" +exec "$script_dir/zellij-ai-fleet.sh" "$@" diff --git a/scripts/constant-machine.sh b/scripts/constant-machine.sh new file mode 100755 index 0000000..d3acc31 --- /dev/null +++ b/scripts/constant-machine.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="$(basename "$0")" +exec "$script_dir/zellij-ai-triple.sh" "$@" diff --git a/scripts/zellij-ai-bootstrap.sh b/scripts/zellij-ai-bootstrap.sh index 0528b5b..8f413e4 100755 --- a/scripts/zellij-ai-bootstrap.sh +++ b/scripts/zellij-ai-bootstrap.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 diff --git a/scripts/zellij-ai-claude-pane.sh b/scripts/zellij-ai-claude-pane.sh index 63d23f2..11400c1 100755 --- a/scripts/zellij-ai-claude-pane.sh +++ b/scripts/zellij-ai-claude-pane.sh @@ -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 diff --git a/scripts/zellij-ai-codex-pane.sh b/scripts/zellij-ai-codex-pane.sh index 62a0d95..0ac5307 100755 --- a/scripts/zellij-ai-codex-pane.sh +++ b/scripts/zellij-ai-codex-pane.sh @@ -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 +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 </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" +} diff --git a/scripts/zellij-ai-copilot-pane.sh b/scripts/zellij-ai-copilot-pane.sh new file mode 100755 index 0000000..c559d9f --- /dev/null +++ b/scripts/zellij-ai-copilot-pane.sh @@ -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" diff --git a/scripts/zellij-ai-fleet-bootstrap.sh b/scripts/zellij-ai-fleet-bootstrap.sh new file mode 100755 index 0000000..e116efb --- /dev/null +++ b/scripts/zellij-ai-fleet-bootstrap.sh @@ -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" diff --git a/scripts/zellij-ai-fleet-install.sh b/scripts/zellij-ai-fleet-install.sh new file mode 100755 index 0000000..751e737 --- /dev/null +++ b/scripts/zellij-ai-fleet-install.sh @@ -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 < [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" diff --git a/scripts/zellij-ai-fleet.sh b/scripts/zellij-ai-fleet.sh new file mode 100755 index 0000000..31600da --- /dev/null +++ b/scripts/zellij-ai-fleet.sh @@ -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 <&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" diff --git a/scripts/zellij-ai-machine-install.sh b/scripts/zellij-ai-machine-install.sh new file mode 100755 index 0000000..5508ad5 --- /dev/null +++ b/scripts/zellij-ai-machine-install.sh @@ -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 < [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 diff --git a/scripts/zellij-ai-remote-tab.sh b/scripts/zellij-ai-remote-tab.sh new file mode 100755 index 0000000..b653770 --- /dev/null +++ b/scripts/zellij-ai-remote-tab.sh @@ -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 <&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")" diff --git a/scripts/zellij-ai-triple.sh b/scripts/zellij-ai-triple.sh index 0fdc058..0e3a383 100755 --- a/scripts/zellij-ai-triple.sh +++ b/scripts/zellij-ai-triple.sh @@ -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 </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" <&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