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