trantor 0.17.49 → 0.17.50
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +37 -2
- package/bin/cli.mjs +2 -1
- package/bin/crew-runner.mjs +20 -0
- package/bin/crew.sh +427 -124
- package/bin/sweep.mjs +62 -0
- package/hub.mjs +0 -0
- package/package.json +2 -2
- package/ui.html +25 -8
|
@@ -6,14 +6,14 @@
|
|
|
6
6
|
},
|
|
7
7
|
"metadata": {
|
|
8
8
|
"description": "Trantor — the hub-world for AI agent crews: live message bus, presence, project Kanban/flow board + context-handoff for independent AI coding agents (Claude, Codex, Gemini, …)",
|
|
9
|
-
"version": "0.17.
|
|
9
|
+
"version": "0.17.50"
|
|
10
10
|
},
|
|
11
11
|
"plugins": [
|
|
12
12
|
{
|
|
13
13
|
"name": "trantor",
|
|
14
14
|
"source": "./",
|
|
15
15
|
"description": "The hub-world for AI agent crews. Say \"fire up the crew\" and Claude becomes the architect: a plan-aware Advisor routes the work (solo / cheap inline calls / live crew of Codex, GLM, Kimi & DeepSeek in their own terminal windows), a Kanban/flow command center with a testing gate tracks it, and an economics brain (Scrooge) keeps the receipts. Includes the relay MCP, a SessionStart auto-discovery hook, and a PreCompact context-handoff so a fresh session can take over a full window instead of compacting.",
|
|
16
|
-
"version": "0.17.
|
|
16
|
+
"version": "0.17.50",
|
|
17
17
|
"author": {
|
|
18
18
|
"name": "Sasha Bogojevic"
|
|
19
19
|
},
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "trantor",
|
|
3
|
-
"version": "0.17.
|
|
3
|
+
"version": "0.17.50",
|
|
4
4
|
"description": "Trantor — the hub-world for AI agent crews: live message bus, presence, project Kanban/flow board + crew orchestration for independent AI coding agents (Claude, Codex, Gemini, Kimi, DeepSeek)",
|
|
5
5
|
"mcpServers": {
|
|
6
6
|
"relay": {
|
package/README.md
CHANGED
|
@@ -42,6 +42,12 @@ claude plugin install trantor
|
|
|
42
42
|
That's it. (Prefer source? `git clone https://github.com/sashabogi/trantor && cd trantor &&
|
|
43
43
|
npm install && bash deploy/setup.sh` — identical result.)
|
|
44
44
|
|
|
45
|
+
**Recommended companion (macOS):** [**cmux**](https://cmux.com) — `brew install --cask cmux` — a
|
|
46
|
+
native terminal built for running multiple AI agents. Trantor groups each crew into one cmux workspace
|
|
47
|
+
with live per-seat status in the sidebar and project-scoped teardown. See
|
|
48
|
+
*[Grouped crews in cmux](#grouped-crews-in-cmux-recommended)*. Optional — crews also run in tmux or plain
|
|
49
|
+
Terminal windows.
|
|
50
|
+
|
|
45
51
|
## What gets installed — footprint & safety
|
|
46
52
|
|
|
47
53
|
Trantor is a **local-first multi-agent orchestrator with a built-in cost router** — not a cloud
|
|
@@ -135,8 +141,10 @@ project takes over with a full window (and a PreCompact hook does this automatic
|
|
|
135
141
|
(`solo | scrooge | crew | hybrid`), a routing table with a **reason per package**, why
|
|
136
142
|
that many seats ("seats follow the work, not the install list"), and a real-money estimate
|
|
137
143
|
with quota-pool accounting. You say go.
|
|
138
|
-
2. **Windows open.** `trantor up codex kimi deepseek:deepseek glm:zai-coding-plan` spawns
|
|
139
|
-
|
|
144
|
+
2. **Windows open.** `trantor up codex kimi deepseek:deepseek glm:zai-coding-plan` spawns the crew in
|
|
145
|
+
visible terminals — grouped into **one [cmux](https://cmux.com) workspace per project** when cmux is
|
|
146
|
+
installed (seats tiled, live status in the sidebar; see *[Grouped crews in cmux](#grouped-crews-in-cmux-recommended)*
|
|
147
|
+
below), else tmux, else one titled Terminal window per seat. The seats aren't a fixed list — they're **whatever you've got**:
|
|
140
148
|
the native CLIs (Codex, Kimi) plus *any* provider wired through OpenCode (DeepSeek, GLM, and
|
|
141
149
|
**OpenRouter's hundreds of models, or a custom endpoint you bring** — see *Bring your own
|
|
142
150
|
model* below). `agent:model` pins a model; `agent:provider --difficulty hard` picks the
|
|
@@ -159,6 +167,33 @@ project takes over with a full window (and a PreCompact hook does this automatic
|
|
|
159
167
|
6. **It learns.** Failures become lessons (`relay_lesson`), stored on the hub and **injected
|
|
160
168
|
into every future crew's prompts** — global or per-CLI. Your crew gets smarter every run.
|
|
161
169
|
|
|
170
|
+
### Grouped crews in cmux (recommended)
|
|
171
|
+
|
|
172
|
+
Trantor runs crews in visible terminals so nothing dies or fails silently. On macOS it prefers
|
|
173
|
+
**[cmux](https://cmux.com)** — a native, Ghostty-based terminal *built for managing multiple AI coding
|
|
174
|
+
agents* — and falls back to **tmux**, then plain Terminal windows.
|
|
175
|
+
|
|
176
|
+
With cmux, each crew becomes **one workspace tab per project**, its seats tiled inside, every pane
|
|
177
|
+
labeled `<agent> · <project>`, and each seat pushes its **live state into cmux's sidebar** —
|
|
178
|
+
`building` (blue) while a turn runs, `idle` when it's waiting, `error`/`down` (red) on a failed turn —
|
|
179
|
+
plus a per-crew progress pill. Teardown is **project-scoped**: `trantor down` closes only *this*
|
|
180
|
+
project's workspace, so when you run several sessions each driving its own crew, one session's teardown
|
|
181
|
+
can't nuke another's. `trantor down <agent>` drops a single seat; `trantor down --all --yes` tears down
|
|
182
|
+
every project's crew.
|
|
183
|
+
|
|
184
|
+
**One-time setup:**
|
|
185
|
+
- Install cmux — `brew install --cask cmux` (or grab it from **[cmux.com](https://cmux.com)**).
|
|
186
|
+
- Trantor drives cmux over its control socket, which is off to outside processes by default. Enable it in
|
|
187
|
+
`~/.config/cmux/cmux.json` (cmux auto-reloads):
|
|
188
|
+
```json
|
|
189
|
+
{ "automation": { "socketControlMode": "allowAll" } }
|
|
190
|
+
```
|
|
191
|
+
This lets local processes drive your terminals. Leave it on the default `cmuxOnly` and Trantor
|
|
192
|
+
automatically falls back to AppleScript (same grouped layout, minus the native sidebar status).
|
|
193
|
+
- The `cmux` CLI is symlinked onto your PATH on your first `trantor up`.
|
|
194
|
+
|
|
195
|
+
No cmux or tmux? Crews still work — one titled Terminal window per seat, still project-scoped teardown.
|
|
196
|
+
|
|
162
197
|
## The dashboard — `trantor ui`
|
|
163
198
|
|
|
164
199
|
A live command center at `http://127.0.0.1:4477`, grouped by **project** — and a *durable,
|
package/bin/cli.mjs
CHANGED
|
@@ -23,7 +23,7 @@ switch (cmd) {
|
|
|
23
23
|
case "advise": run("bin/advise.mjs"); break;
|
|
24
24
|
case "verify": run("bin/crew-verify.mjs"); break;
|
|
25
25
|
case "up": process.argv.splice(2, 1); spawn("/bin/bash", [join(ROOT, "bin/crew.sh"), "up", ...args], { stdio: "inherit", cwd: process.cwd() }).on("exit", c => process.exit(c ?? 0)); break;
|
|
26
|
-
case "down": spawn("/bin/bash", [join(ROOT, "bin/crew.sh"), "down"], { stdio: "inherit", cwd: process.cwd() }).on("exit", c => process.exit(c ?? 0)); break;
|
|
26
|
+
case "down": spawn("/bin/bash", [join(ROOT, "bin/crew.sh"), "down", ...args], { stdio: "inherit", cwd: process.cwd() }).on("exit", c => process.exit(c ?? 0)); break;
|
|
27
27
|
case "swap": spawn("/bin/bash", [join(ROOT, "bin/crew.sh"), "swap", ...args], { stdio: "inherit", cwd: process.cwd() }).on("exit", c => process.exit(c ?? 0)); break;
|
|
28
28
|
case "hub": run("hub.mjs"); break;
|
|
29
29
|
case "watch": run("bin/relay-watch.mjs"); break;
|
|
@@ -31,6 +31,7 @@ switch (cmd) {
|
|
|
31
31
|
case "agents": run("bin/agents.mjs"); break;
|
|
32
32
|
case "gates": run("bin/gates.mjs"); break;
|
|
33
33
|
case "backfill": run("bin/git-backfill.mjs"); break;
|
|
34
|
+
case "sweep": run("bin/sweep.mjs"); break;
|
|
34
35
|
case "init-hooks": run("bin/init-hooks.mjs"); break;
|
|
35
36
|
case "balances": case "balance": case "credits": run("bin/balances.mjs"); break;
|
|
36
37
|
case "recost": run("bin/recost.mjs"); break;
|
package/bin/crew-runner.mjs
CHANGED
|
@@ -50,6 +50,22 @@ async function api(path, body) {
|
|
|
50
50
|
return r.json();
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
+
// ---- cmux sidebar integration ----
|
|
54
|
+
// When this runner is inside a cmux surface (CMUX_SURFACE_ID is auto-set there), push its live state into
|
|
55
|
+
// cmux's sidebar for THIS seat. An inside process is allowed by cmux's default cmuxOnly socket mode — no
|
|
56
|
+
// allowAll needed. Fail-silent + short timeout; must never block or slow a turn.
|
|
57
|
+
const CMUX_BIN = process.env.CMUX_BIN
|
|
58
|
+
|| (existsSync("/Applications/cmux.app/Contents/Resources/bin/cmux") ? "/Applications/cmux.app/Contents/Resources/bin/cmux" : "cmux");
|
|
59
|
+
const inCmux = () => !!process.env.CMUX_SURFACE_ID;
|
|
60
|
+
function cmuxStatus(value, color, icon = "robot") {
|
|
61
|
+
if (!inCmux()) return;
|
|
62
|
+
try { spawnSync(CMUX_BIN, ["set-status", "trantor", value, "--color", color, "--icon", icon], { stdio: "ignore", timeout: 1500, env: { ...process.env, CMUX_QUIET: "1" } }); } catch {}
|
|
63
|
+
}
|
|
64
|
+
function cmuxLog(message, level = "info") {
|
|
65
|
+
if (!inCmux()) return;
|
|
66
|
+
try { spawnSync(CMUX_BIN, ["log", String(message).slice(0, 200), "--level", level], { stdio: "ignore", timeout: 1500, env: { ...process.env, CMUX_QUIET: "1" } }); } catch {}
|
|
67
|
+
}
|
|
68
|
+
|
|
53
69
|
// ---- per-CLI invocation (first turn vs resume turn). {P} = prompt file path ----
|
|
54
70
|
// CREW_MODEL env pins the model: each CLI gets its own flag via {M} (empty when unset).
|
|
55
71
|
let MODEL = process.env.CREW_MODEL || "";
|
|
@@ -119,6 +135,7 @@ async function reportFailure(exit, trigger) {
|
|
|
119
135
|
? `🛑 ${SESSION} DOWN — ${consecFails} consecutive failures (${reason}, exit ${exit})${hint}`
|
|
120
136
|
: `⚠️ ${SESSION} turn FAILED (${trigger}, exit ${exit} · ${reason})${hint}`;
|
|
121
137
|
await api("/send", { from: SESSION, to: "all", text, project: PROJ }).catch(() => {});
|
|
138
|
+
cmuxStatus(down ? "down" : "error", "#ef6a6a", "alert"); cmuxLog(`turn failed: ${reason} (exit ${exit})`, "error");
|
|
122
139
|
log(`\x1b[31mreported failure to bus: ${reason} (exit ${exit})\x1b[0m`);
|
|
123
140
|
}
|
|
124
141
|
|
|
@@ -127,6 +144,7 @@ async function reportHealthy() {
|
|
|
127
144
|
consecFails = 0;
|
|
128
145
|
await api("/register", { session: SESSION, project: PROJ, status: `active in ${PROJ}` }).catch(() => {});
|
|
129
146
|
await api("/send", { from: SESSION, to: "all", text: `✅ ${SESSION} recovered`, project: PROJ }).catch(() => {});
|
|
147
|
+
cmuxStatus("ok", "#14b8a6", "check");
|
|
130
148
|
}
|
|
131
149
|
|
|
132
150
|
let sid = "";
|
|
@@ -142,6 +160,7 @@ function runTurn(prompt, isFirst, trigger = "kickoff") {
|
|
|
142
160
|
const envs = [join(homedir(), ".agent-bus", ".env"), cli.env].filter(f => f && existsSync(f));
|
|
143
161
|
for (const f of envs.reverse()) cmd = `set -a; source ${f}; set +a; ${cmd}`; // ~/.agent-bus/.env wins
|
|
144
162
|
log(`turn starting (${isFirst ? "fresh session" : "resume"})${MODEL ? ` · model=${MODEL}` : ""}`);
|
|
163
|
+
cmuxStatus("building", "#4a90d9", "hammer");
|
|
145
164
|
// inherit stdio so the window shows the agent working live; also capture for sid-parsing.
|
|
146
165
|
// Tee stderr to ERRF (still shown live in the window) so a failed turn can be classified.
|
|
147
166
|
try { appendFileSync(ERRF, "", { flag: "w" }); } catch {}
|
|
@@ -155,6 +174,7 @@ function runTurn(prompt, isFirst, trigger = "kickoff") {
|
|
|
155
174
|
if (cli.sid && r.stdout) { const m = r.stdout.match(cli.sid); if (m) sid = m[1]; }
|
|
156
175
|
telemetry({ ts: Date.now(), agent: AGENT, project: PROJ, turn: TURN, trigger, model: MODEL || "default", duration_ms: Date.now() - t0, exit: r.status });
|
|
157
176
|
log(`turn ended (exit ${r.status}, ${((Date.now() - t0) / 1000).toFixed(0)}s)`);
|
|
177
|
+
if (r.status === 0) cmuxStatus("idle", "#8a94a6", "robot"); // finished this turn, waiting for the next
|
|
158
178
|
return r.status;
|
|
159
179
|
}
|
|
160
180
|
|
package/bin/crew.sh
CHANGED
|
@@ -1,60 +1,234 @@
|
|
|
1
1
|
#!/bin/bash
|
|
2
|
-
# trantor crew launcher
|
|
2
|
+
# trantor crew launcher v3 — visible, GROUPED, per-project crews that can't silently die and that ONE
|
|
3
|
+
# session's teardown can't nuke out from under ANOTHER session's crew.
|
|
3
4
|
#
|
|
4
|
-
# bin/crew.sh up codex
|
|
5
|
-
# bin/crew.sh down
|
|
5
|
+
# bin/crew.sh up codex glm kimi deepseek # bring up THIS project's crew (tmux panes, or Terminal windows)
|
|
6
|
+
# bin/crew.sh down # tear down ONLY THIS PROJECT's crew (scoped, prints what it kills)
|
|
7
|
+
# bin/crew.sh down codex # tear down ONE seat
|
|
8
|
+
# bin/crew.sh down --all --yes # tear down EVERY project's crew (global; --yes required)
|
|
9
|
+
# bin/crew.sh swap <oldAgent> <newSpec> # replace one seat with a live-selected model
|
|
6
10
|
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
11
|
+
# WHY v3: v2 tracked windows in ONE global ~/.agent-bus/crew-windows.txt as bare `AGENT<TAB>WID` rows — no
|
|
12
|
+
# project — so `down` from any session killed EVERY crew on the machine, mid-flight. And N free-floating
|
|
13
|
+
# Terminal windows (2x2, then half-screen at 5+) were impossible to tell apart when several sessions each ran
|
|
14
|
+
# a crew. v3 fixes both: STATE rows are `PROJECT<TAB>KIND<TAB>AGENT<TAB>HANDLE`, teardown is PROJECT-SCOPED,
|
|
15
|
+
# and the crew launches as ONE tmux session `trantor:<project>` (named panes you see at once) — or, if tmux
|
|
16
|
+
# isn't installed, the legacy per-agent Terminal grid with the window titled `<PROJECT> · <AGENT>`.
|
|
10
17
|
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
18
|
+
# Each pane/window runs bin/crew-runner.mjs: the CLI does one turn and exits; the RUNNER long-polls the bus
|
|
19
|
+
# (free, doubles as heartbeat) and resumes the CLI — with full context — whenever a message arrives.
|
|
13
20
|
#
|
|
14
|
-
#
|
|
15
|
-
# machine; used for every spawn including respawns. Default: right half of the main display.
|
|
21
|
+
# CREW_DRY_RUN=1 prints every spawn/kill instead of doing it (used by test-crew.sh to prove teardown scoping).
|
|
16
22
|
set -u
|
|
17
23
|
CMD="${1:-up}"; shift 2>/dev/null || true
|
|
18
24
|
DIR="$(pwd)"
|
|
19
|
-
# Canonical project key: the orchestrator's RELAY_PROJECT wins, else the GIT REPO ROOT
|
|
20
|
-
#
|
|
21
|
-
# key so one repo = one lane (no host "builtbetter.ai" vs crew "builtbetter" split).
|
|
25
|
+
# Canonical project key: the orchestrator's RELAY_PROJECT wins, else the GIT REPO ROOT basename (stable
|
|
26
|
+
# across subdirs), else the cwd basename. The crew inherits this exact key so one repo = one lane.
|
|
22
27
|
PROJ="${RELAY_PROJECT:-$(basename "$(git -C "$DIR" rev-parse --show-toplevel 2>/dev/null || echo "$DIR")")}"
|
|
23
28
|
BUS_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
|
24
29
|
STATE="$HOME/.agent-bus/crew-windows.txt"
|
|
25
30
|
mkdir -p "$HOME/.agent-bus"
|
|
31
|
+
TMUX_SESS="trantor:$PROJ" # one tmux session per project
|
|
32
|
+
HAVE_TMUX=0; command -v tmux >/dev/null 2>&1 && HAVE_TMUX=1
|
|
33
|
+
# cmux (https://cmux — a Ghostty-based multiplexer) is the PREFERRED grouping when installed: one workspace
|
|
34
|
+
# TAB per project, seats tiled inside it, scoped teardown via `close tab`. Driven by AppleScript (Apple
|
|
35
|
+
# Events) — NOT the control socket — so it needs NO socket password (the socket denies external processes
|
|
36
|
+
# by default; AppleScript bypasses that, exactly like we already script Terminal.app).
|
|
37
|
+
HAVE_CMUX=0; [ -d "/Applications/cmux.app" ] && HAVE_CMUX=1
|
|
38
|
+
# explicit override (user preference or tests): CREW_MUX=cmux|tmux|terminal forces the grouping UI.
|
|
39
|
+
case "${CREW_MUX:-}" in
|
|
40
|
+
cmux) HAVE_CMUX=1; HAVE_TMUX=0 ;;
|
|
41
|
+
tmux) HAVE_CMUX=0; HAVE_TMUX=1 ;;
|
|
42
|
+
terminal) HAVE_CMUX=0; HAVE_TMUX=0 ;;
|
|
43
|
+
esac
|
|
44
|
+
SEATDIR="$HOME/.agent-bus/seats"; mkdir -p "$SEATDIR"
|
|
45
|
+
DRY="${CREW_DRY_RUN:-0}"
|
|
46
|
+
run() { if [ "$DRY" = "1" ]; then echo "[dry] $*"; else eval "$*"; fi; }
|
|
26
47
|
|
|
48
|
+
# ── STATE helpers ──────────────────────────────────────────────────────────────────────────────────
|
|
49
|
+
# Row schema (TSV): PROJECT <TAB> KIND <TAB> AGENT <TAB> HANDLE
|
|
50
|
+
# KIND=win HANDLE=Terminal window id (one row per agent)
|
|
51
|
+
# KIND=tmux HANDLE=tmux pane id (e.g. %3) (one row per agent; session = trantor:PROJECT)
|
|
52
|
+
# KIND=attach HANDLE=Terminal window id (the single window attached to the tmux session)
|
|
53
|
+
# Legacy v2 rows are bare `AGENT <TAB> WID` (2 fields, NO project) — read as PROJECT="" KIND=win.
|
|
54
|
+
record_state() { [ "$DRY" = "1" ] && return 0; printf '%s\t%s\t%s\t%s\n' "$1" "$2" "$3" "$4" >> "$STATE"; }
|
|
55
|
+
|
|
56
|
+
# parse a STATE line into globals RP/RK/RA/RH (PROJECT/KIND/AGENT/HANDLE), handling legacy 2-field rows.
|
|
57
|
+
# We split the RAW line by field COUNT rather than round-tripping through a rebuilt string: tab is IFS
|
|
58
|
+
# whitespace, so `read` COLLAPSES a leading empty field — a rebuilt "<TAB>win<TAB>…" would mis-parse an
|
|
59
|
+
# empty PROJECT as KIND. New rows always carry a non-empty PROJECT (4 fields); only legacy rows are 2 fields.
|
|
60
|
+
_parse_row() { # $1 = line
|
|
61
|
+
local -a f; IFS=$'\t' read -r -a f <<< "$1"
|
|
62
|
+
if [ "${#f[@]}" -ge 4 ]; then RP="${f[0]}"; RK="${f[1]}"; RA="${f[2]}"; RH="${f[3]}"
|
|
63
|
+
elif [ "${#f[@]}" -eq 2 ]; then RP=""; RK="win"; RA="${f[0]}"; RH="${f[1]}" # legacy AGENT<TAB>WID
|
|
64
|
+
else RP=""; RK="win"; RA="${f[0]:-}"; RH="${f[1]:-}"; fi
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
# close a Terminal window + SIGKILL everything on its tty (TUIs trap SIGTERM; a live login makes Terminal
|
|
68
|
+
# raise the "Terminate running processes?" dialog on close).
|
|
69
|
+
_kill_win() { # $1 = window id
|
|
70
|
+
local wid="$1" tty pid
|
|
71
|
+
[ -n "$wid" ] || return 0
|
|
72
|
+
tty=$(osascript -e "tell application \"Terminal\" to get tty of (first window whose id is $wid)" 2>/dev/null)
|
|
73
|
+
if [ -n "$tty" ]; then for pid in $(ps -t "${tty#/dev/}" -o pid= 2>/dev/null); do run "kill -9 $pid 2>/dev/null"; done; fi
|
|
74
|
+
run "osascript -e 'tell application \"Terminal\" to close (first window whose id is $wid)' 2>/dev/null"
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
# ── cmux helpers ─────────────────────────────────────────────────────────────────────────────────
|
|
78
|
+
# The cmux CLI lives inside the app bundle; prefer it on PATH (we symlink it on `up`), else full path.
|
|
79
|
+
CMUX_BIN="$(command -v cmux 2>/dev/null)"; [ -n "$CMUX_BIN" ] || CMUX_BIN="/Applications/cmux.app/Contents/Resources/bin/cmux"
|
|
80
|
+
_cmux() { CMUX_QUIET=1 "$CMUX_BIN" "$@"; } # quiet CLI wrapper (suppresses alias/notice chatter)
|
|
81
|
+
_CMUX_OK="" # cached: does the control socket accept us (allowAll)?
|
|
82
|
+
_cmux_ok() {
|
|
83
|
+
[ -n "$_CMUX_OK" ] && { [ "$_CMUX_OK" = "1" ] && return 0 || return 1; }
|
|
84
|
+
if [ "$HAVE_CMUX" = "1" ] && _cmux ping >/dev/null 2>&1; then _CMUX_OK=1; return 0; fi
|
|
85
|
+
_CMUX_OK=0; return 1
|
|
86
|
+
}
|
|
87
|
+
# resolve a freshly-created workspace REF (workspace:N) → its stable UUID (survives index shifts).
|
|
88
|
+
_cmux_ws_uuid() { # $1 = ref like "workspace:3"
|
|
89
|
+
_cmux workspace list --id-format both --json 2>/dev/null | REF="$1" node -e '
|
|
90
|
+
let d="";process.stdin.on("data",c=>d+=c).on("end",()=>{try{const o=JSON.parse(d.slice(d.search(/[\[{]/)));
|
|
91
|
+
const a=Array.isArray(o)?o:(o.workspaces||[]);const w=a.find(x=>x.ref===process.env.REF);process.stdout.write(w?w.id:"");}catch(e){}});'
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
# A per-seat LAUNCHER script — avoids escaping a complex command into the CLI/AppleScript, and sets the pane
|
|
95
|
+
# title via OSC (cmux surface names are read-only to rename-tab, so the seat labels itself `<AGENT> · <PROJ>`).
|
|
96
|
+
_seat_launcher() { # $1=agent $2=full runner command → echoes the launcher path
|
|
97
|
+
local f="$SEATDIR/${PROJ}-$1.sh"
|
|
98
|
+
{ printf '#!/bin/bash\n'; printf 'printf "\\033]0;%%s\\007" %q\n' "$1 · $PROJ"; printf '%s\n' "$2"; } > "$f"
|
|
99
|
+
printf '%s' "$f"
|
|
100
|
+
}
|
|
101
|
+
# Teardown prefers the control socket (close-workspace/close-surface by UUID); falls back to AppleScript
|
|
102
|
+
# (Apple Events, no allowAll needed) — object UUIDs are the same in both worlds.
|
|
103
|
+
_cmux_close_tab() { # $1 = workspace uuid → close the whole project workspace (all its seats)
|
|
104
|
+
[ "$DRY" = "1" ] && { echo "[dry] cmux close-workspace $1"; return 0; }
|
|
105
|
+
if _cmux_ok; then _cmux close-workspace --workspace "$1" >/dev/null 2>&1 && return 0; fi
|
|
106
|
+
osascript 2>/dev/null <<OSA
|
|
107
|
+
tell application "cmux"
|
|
108
|
+
repeat with w in windows
|
|
109
|
+
repeat with tt in tabs of w
|
|
110
|
+
if (id of tt) is "$1" then close tab tt
|
|
111
|
+
end repeat
|
|
112
|
+
end repeat
|
|
113
|
+
end tell
|
|
114
|
+
OSA
|
|
115
|
+
}
|
|
116
|
+
_cmux_close_term() { # $1 = surface uuid → close a single seat
|
|
117
|
+
[ "$DRY" = "1" ] && { echo "[dry] cmux close-surface $1"; return 0; }
|
|
118
|
+
if _cmux_ok; then _cmux close-surface --surface "$1" >/dev/null 2>&1 && return 0; fi
|
|
119
|
+
osascript 2>/dev/null <<OSA
|
|
120
|
+
tell application "cmux"
|
|
121
|
+
repeat with tr in terminals
|
|
122
|
+
if (id of tr) is "$1" then close tr
|
|
123
|
+
end repeat
|
|
124
|
+
end tell
|
|
125
|
+
OSA
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
# ── down: PROJECT-SCOPED teardown (never touches another project's crew) ─────────────────────────────
|
|
129
|
+
usage_down() {
|
|
130
|
+
cat <<EOF
|
|
131
|
+
usage: trantor down [<agent>...] [--all] [--yes]
|
|
132
|
+
trantor down tear down THIS project's crew ($PROJ)
|
|
133
|
+
trantor down codex glm tear down only those seats in this project
|
|
134
|
+
trantor down --all --yes tear down EVERY project's crew on this machine (--yes required)
|
|
135
|
+
EOF
|
|
136
|
+
}
|
|
27
137
|
down() {
|
|
138
|
+
local WANT_ALL=0 WANT_YES=0 AGENTS=() a
|
|
139
|
+
for a in "$@"; do case "$a" in
|
|
140
|
+
--all) WANT_ALL=1 ;;
|
|
141
|
+
--yes|-y) WANT_YES=1 ;;
|
|
142
|
+
--help|-h) usage_down; return 0 ;;
|
|
143
|
+
--*) echo "trantor down: unknown flag '$a'"; usage_down; return 1 ;;
|
|
144
|
+
*) AGENTS+=("$a") ;;
|
|
145
|
+
esac; done
|
|
28
146
|
[ -f "$STATE" ] || { echo "no tracked crew windows"; return 0; }
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
147
|
+
local SCOPE_DESC; [ "$WANT_ALL" = "1" ] && SCOPE_DESC="all projects" || SCOPE_DESC="project \"$PROJ\""
|
|
148
|
+
|
|
149
|
+
# collect the rows in scope + build a human kill-list. `scoped` entries are joined with '|' (NOT a tab):
|
|
150
|
+
# tab is IFS whitespace, so re-splitting a tab-joined string would collapse an empty leading PROJECT.
|
|
151
|
+
local scoped=() line killlist="" seentmux=""
|
|
152
|
+
while IFS= read -r line; do
|
|
153
|
+
[ -n "$line" ] || continue
|
|
154
|
+
_parse_row "$line" # → RP RK RA RH
|
|
155
|
+
# scope: --all → every row; else → ONLY this project's rows. Legacy (project-less, pre-v3) rows are
|
|
156
|
+
# SKIPPED by a scoped down — we can't prove they're ours, and killing another project's crew is the exact
|
|
157
|
+
# bug we're fixing. `trantor down --all` reaches them; otherwise they self-heal via prune_dead_state.
|
|
158
|
+
if [ "$WANT_ALL" != "1" ] && [ "$RP" != "$PROJ" ]; then continue; fi
|
|
159
|
+
if [ "${#AGENTS[@]}" -gt 0 ]; then # agent filter (if specific seats named)
|
|
160
|
+
local match=0 want; for want in "${AGENTS[@]}"; do [ "$RA" = "$want" ] && match=1; done
|
|
161
|
+
[ "$match" = "1" ] || continue
|
|
36
162
|
fi
|
|
163
|
+
scoped+=("$RP|$RK|$RA|$RH")
|
|
164
|
+
case "$RK" in attach|cmuxws) continue ;; esac # infra rows (attach window / cmux workspace) — not seats
|
|
165
|
+
killlist="$killlist • ${RP:-<legacy>} · $RA ($RK)"$'\n'
|
|
37
166
|
done < "$STATE"
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
167
|
+
|
|
168
|
+
if [ "${#scoped[@]}" -eq 0 ]; then echo "nothing to tear down for $SCOPE_DESC"; return 0; fi
|
|
169
|
+
echo "— tearing down ($SCOPE_DESC):"; printf '%s' "$killlist"
|
|
170
|
+
if [ "$WANT_ALL" = "1" ] && [ "$WANT_YES" != "1" ]; then
|
|
171
|
+
echo "— this is EVERY project's crew. Re-run with --yes to confirm: trantor down --all --yes"; return 0
|
|
172
|
+
fi
|
|
173
|
+
|
|
174
|
+
# execute. Per-project teardown: cmux → close the workspace tab; tmux → kill the session; Terminal → close
|
|
175
|
+
# each window. Per-seat teardown (agents named): cmux → close that terminal; tmux → kill that pane.
|
|
176
|
+
local s P2 K2 A2 H2
|
|
177
|
+
for s in "${scoped[@]}"; do
|
|
178
|
+
IFS='|' read -r P2 K2 A2 H2 <<< "$s"
|
|
179
|
+
case "$K2" in
|
|
180
|
+
cmuxws) [ "${#AGENTS[@]}" -gt 0 ] || _cmux_close_tab "$H2" ;; # whole workspace (no specific seats)
|
|
181
|
+
cmux) [ "${#AGENTS[@]}" -gt 0 ] && _cmux_close_term "$H2" ;; # a single seat's pane
|
|
182
|
+
tmux)
|
|
183
|
+
if [ "${#AGENTS[@]}" -gt 0 ]; then run "tmux kill-pane -t '$H2' 2>/dev/null" # per-seat
|
|
184
|
+
else
|
|
185
|
+
local sess="trantor:$P2"
|
|
186
|
+
case " $seentmux " in *" $sess "*) : ;; *) run "tmux kill-session -t '$sess' 2>/dev/null"; seentmux="$seentmux $sess" ;; esac
|
|
187
|
+
fi ;;
|
|
188
|
+
win|attach) { [ "${#AGENTS[@]}" -gt 0 ] && [ "$K2" = "attach" ]; } && continue; _kill_win "$H2" ;;
|
|
189
|
+
esac
|
|
190
|
+
done
|
|
191
|
+
|
|
192
|
+
# rewrite STATE minus the rows we tore down (leaves OTHER projects' rows intact)
|
|
193
|
+
if [ "$DRY" != "1" ]; then
|
|
194
|
+
local tmp="$STATE.tmp"; : > "$tmp"
|
|
195
|
+
while IFS= read -r line; do
|
|
196
|
+
[ -n "$line" ] || continue
|
|
197
|
+
_parse_row "$line"
|
|
198
|
+
local drop=0
|
|
199
|
+
for s in "${scoped[@]}"; do [ "$s" = "$RP|$RK|$RA|$RH" ] && drop=1; done
|
|
200
|
+
[ "$drop" = "1" ] || printf '%s\t%s\t%s\t%s\n' "$RP" "$RK" "$RA" "$RH" >> "$tmp"
|
|
201
|
+
done < "$STATE"
|
|
202
|
+
mv "$tmp" "$STATE"
|
|
203
|
+
[ -s "$STATE" ] || rm -f "$STATE"
|
|
204
|
+
fi
|
|
205
|
+
echo "— crew torn down ($SCOPE_DESC)"
|
|
206
|
+
}
|
|
207
|
+
[ "$CMD" = "down" ] && { down "$@"; exit $?; }
|
|
208
|
+
case "$CMD" in up|swap) ;; *) echo "usage: crew.sh up <agent...> | crew.sh swap <old> <new[:provider[/model]]> | crew.sh down [<agent>...] [--all --yes]"; exit 1 ;; esac
|
|
209
|
+
|
|
210
|
+
# self-heal: drop STATE rows whose Terminal window is already gone (dead crews from past sessions), so the
|
|
211
|
+
# file doesn't accumulate ghosts across ups. tmux rows are validated by their session existing.
|
|
212
|
+
prune_dead_state() {
|
|
213
|
+
[ -f "$STATE" ] || return 0
|
|
214
|
+
[ "$DRY" = "1" ] && return 0
|
|
215
|
+
local tmp="$STATE.tmp" line alive
|
|
216
|
+
: > "$tmp"
|
|
217
|
+
while IFS= read -r line; do
|
|
218
|
+
[ -n "$line" ] || continue
|
|
219
|
+
_parse_row "$line" # → RP RK RA RH
|
|
220
|
+
alive=1
|
|
221
|
+
if [ "$RK" = "win" ] || [ "$RK" = "attach" ]; then
|
|
222
|
+
[ -n "$(osascript -e "tell application \"Terminal\" to get id of (first window whose id is $RH)" 2>/dev/null)" ] || alive=0
|
|
223
|
+
elif [ "$RK" = "tmux" ]; then
|
|
224
|
+
tmux has-session -t "trantor:$RP" 2>/dev/null || alive=0
|
|
225
|
+
fi
|
|
226
|
+
[ "$alive" = "1" ] && printf '%s\t%s\t%s\t%s\n' "$RP" "$RK" "$RA" "$RH" >> "$tmp"
|
|
42
227
|
done < "$STATE"
|
|
43
|
-
|
|
44
|
-
osascript -e 'tell application "System Events" to tell process "Terminal"' \
|
|
45
|
-
-e 'repeat with w in windows' -e 'try' \
|
|
46
|
-
-e 'if exists sheet 1 of w then click button "Terminate" of sheet 1 of w' \
|
|
47
|
-
-e 'end try' -e 'end repeat' -e 'end tell' >/dev/null 2>&1
|
|
48
|
-
rm -f "$STATE"
|
|
49
|
-
echo "crew torn down"
|
|
228
|
+
mv "$tmp" "$STATE"; [ -s "$STATE" ] || rm -f "$STATE"
|
|
50
229
|
}
|
|
51
|
-
[ "$CMD" = "down" ] && { down; exit 0; }
|
|
52
|
-
case "$CMD" in up|swap) ;; *) echo "usage: crew.sh up <agent...> | crew.sh swap <oldAgent> <newAgent[:provider[/model]]> | crew.sh down"; exit 1 ;; esac
|
|
53
230
|
|
|
54
231
|
# --task/--difficulty drive LAZY live-model selection for provider-only specs (agent:provider).
|
|
55
|
-
# An agent spec is one of: `codex` (CLI default) · `glm:zai-coding-plan` (provider only → pick the
|
|
56
|
-
# best live model now) · `glm:zai-coding-plan/glm-5.2` (full pin). The agent label is the bus
|
|
57
|
-
# identity; any non-native label runs via opencode (legacy `opencode:zai-coding-plan` still works).
|
|
58
232
|
TASK="code"; DIFF="medium"; _ARGS=()
|
|
59
233
|
while [ $# -gt 0 ]; do
|
|
60
234
|
case "$1" in
|
|
@@ -64,17 +238,13 @@ while [ $# -gt 0 ]; do
|
|
|
64
238
|
esac
|
|
65
239
|
done
|
|
66
240
|
if [ ${#_ARGS[@]} -gt 0 ]; then set -- "${_ARGS[@]}"; else set --; fi
|
|
67
|
-
[ $# -eq 0 ] && { echo "usage: crew.sh up [--task K --difficulty D] codex
|
|
241
|
+
[ $# -eq 0 ] && { echo "usage: crew.sh up [--task K --difficulty D] codex glm kimi deepseek (agent:provider picks a live model; agent:provider/model pins one)"; exit 1; }
|
|
68
242
|
|
|
69
243
|
# scrooge (the model-routing brain) is bundled with this trantor install; fall back to PATH.
|
|
70
244
|
SCROOGE="$BUS_DIR/engine/bin/scrooge"
|
|
71
245
|
[ -f "$SCROOGE" ] || SCROOGE="$(command -v scrooge 2>/dev/null || echo scrooge)"
|
|
72
246
|
|
|
73
|
-
# resolve_model <agent> <provider> <task> <diff> -> echoes a runner-ready model id, or empty
|
|
74
|
-
# (→ CLI default). PROVIDER-AGNOSTIC: if opencode knows the provider (ANY of its vendors — built-in
|
|
75
|
-
# or BROUGHT), enumerate via `opencode models <provider>`; else self-enumerate via the provider's
|
|
76
|
-
# OpenAI-compatible /models. No hardcoded provider list — a newly-brought opencode provider routes
|
|
77
|
-
# with zero change here. Never guesses an endpoint.
|
|
247
|
+
# resolve_model <agent> <provider> <task> <diff> -> echoes a runner-ready model id, or empty (→ CLI default).
|
|
78
248
|
resolve_model() {
|
|
79
249
|
local agent="$1" provider="$2" task="$3" diff="$4" cands="" out=""
|
|
80
250
|
cands="$(opencode models "$provider" 2>/dev/null | tr '\n' ' ')"
|
|
@@ -89,138 +259,271 @@ try: print(json.load(sys.stdin).get("qualified") or "")
|
|
|
89
259
|
except Exception: pass' 2>/dev/null
|
|
90
260
|
}
|
|
91
261
|
|
|
92
|
-
# epoch_ms: milliseconds since the epoch, captured BEFORE a spawn so crew-verify can count an
|
|
93
|
-
# agent the moment it registers (even "booting"), instead of racing its own start time. A slow
|
|
94
|
-
# first turn (opencode+GLM cold start ~40s) means no heartbeat for the whole turn; anchoring the
|
|
95
|
-
# verifier to this pre-spawn epoch lets the early "booting" registration satisfy it.
|
|
96
262
|
epoch_ms() { python3 -c 'import time;print(int(time.time()*1000))'; }
|
|
97
263
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
264
|
+
# resolve_spec <spec> -> sets AGENT + MODEL globals (live-selects a provider-only spec).
|
|
265
|
+
AGENT=""; MODEL=""
|
|
266
|
+
resolve_spec() {
|
|
267
|
+
local SPEC="$1" FIELD
|
|
268
|
+
AGENT="${SPEC%%:*}"; MODEL=""
|
|
269
|
+
FIELD=""; [ "$SPEC" != "$AGENT" ] && FIELD="${SPEC#*:}"
|
|
270
|
+
[ "$AGENT" = "openrouter" ] && [ -z "$FIELD" ] && FIELD="openrouter"
|
|
271
|
+
if [ -n "$FIELD" ]; then
|
|
272
|
+
case "$FIELD" in
|
|
273
|
+
*/*) MODEL="$FIELD" ;;
|
|
274
|
+
*) MODEL="$(resolve_model "$AGENT" "$FIELD" "$TASK" "$DIFF")"
|
|
275
|
+
if [ -n "$MODEL" ]; then echo " → $AGENT: live model $MODEL ($FIELD · $TASK/$DIFF)"
|
|
276
|
+
else echo " → $AGENT: '$FIELD' live selection unavailable — CLI default"; fi ;;
|
|
277
|
+
esac
|
|
278
|
+
fi
|
|
279
|
+
}
|
|
280
|
+
RUN_CMD() { printf 'cd %q && CREW_MODEL=%q RELAY_PROJECT=%q node %q %q %q' "$DIR" "$MODEL" "$PROJ" "$BUS_DIR/bin/crew-runner.mjs" "$AGENT" "$DIR"; }
|
|
103
281
|
|
|
104
|
-
#
|
|
105
|
-
|
|
282
|
+
# ── tmux spawn: ONE session `trantor:$PROJ`, one named pane per seat, one Terminal window attached ────
|
|
283
|
+
spawn_tmux() { # $@ = specs
|
|
284
|
+
local first=1 SPEC
|
|
285
|
+
# a pre-existing session for THIS project = the crew is already up; add missing seats as new panes.
|
|
286
|
+
tmux has-session -t "$TMUX_SESS" 2>/dev/null && first=0
|
|
287
|
+
for SPEC in "$@"; do
|
|
288
|
+
resolve_spec "$SPEC"
|
|
289
|
+
local cmd; cmd="$(RUN_CMD)"
|
|
290
|
+
local pane=""
|
|
291
|
+
if [ "$first" = "1" ]; then
|
|
292
|
+
run "tmux new-session -d -s '$TMUX_SESS' -n crew -x 260 -y 60"
|
|
293
|
+
run "tmux send-keys -t '$TMUX_SESS' $(printf '%q' "$cmd") Enter"
|
|
294
|
+
pane="$( [ "$DRY" = "1" ] && echo "%DRY" || tmux display-message -p -t "$TMUX_SESS" '#{pane_id}' 2>/dev/null)"
|
|
295
|
+
first=0
|
|
296
|
+
else
|
|
297
|
+
run "tmux split-window -t '$TMUX_SESS' $(printf '%q' "$cmd")"
|
|
298
|
+
run "tmux select-layout -t '$TMUX_SESS' tiled >/dev/null 2>&1"
|
|
299
|
+
pane="$( [ "$DRY" = "1" ] && echo "%DRY" || tmux display-message -p -t "$TMUX_SESS" '#{pane_id}' 2>/dev/null)"
|
|
300
|
+
fi
|
|
301
|
+
run "tmux select-pane -t '${pane:-$TMUX_SESS}' -T $(printf '%q' "$(echo "$AGENT" | tr '[:lower:]' '[:upper:]')")"
|
|
302
|
+
record_state "$PROJ" "tmux" "$AGENT" "${pane:-%?}"
|
|
303
|
+
echo " → $AGENT pane in $TMUX_SESS"
|
|
304
|
+
done
|
|
305
|
+
# pane borders show the seat name; attach ONE Terminal window titled with the project
|
|
306
|
+
run "tmux set-option -t '$TMUX_SESS' pane-border-status top >/dev/null 2>&1"
|
|
307
|
+
run "tmux set-option -t '$TMUX_SESS' pane-border-format ' #{pane_title} ' >/dev/null 2>&1"
|
|
308
|
+
if [ "$DRY" = "1" ]; then echo "[dry] would attach a Terminal window to $TMUX_SESS"; record_state "$PROJ" "attach" "__win__" "%DRYWIN"; return 0; fi
|
|
309
|
+
local attach_wid
|
|
310
|
+
attach_wid="$(osascript \
|
|
311
|
+
-e 'tell application "Terminal"' \
|
|
312
|
+
-e " set w to do script \"tmux attach -t $TMUX_SESS\"" \
|
|
313
|
+
-e " set custom title of w to \"$PROJ — trantor crew\"" \
|
|
314
|
+
-e " set theWin to first window whose tabs contains w" \
|
|
315
|
+
-e " return id of theWin" \
|
|
316
|
+
-e 'end tell' 2>/dev/null)"
|
|
317
|
+
[ -n "$attach_wid" ] && record_state "$PROJ" "attach" "__win__" "$attach_wid"
|
|
318
|
+
echo "— crew grouped in tmux session $TMUX_SESS (one window: $PROJ — trantor crew). Detach with Ctrl-b d; it keeps running. —"
|
|
319
|
+
}
|
|
106
320
|
|
|
107
|
-
#
|
|
108
|
-
#
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
if [ -n "${CREW_RECT:-}" ]; then
|
|
112
|
-
IFS=',' read -r GX GY GW GH <<< "$CREW_RECT"
|
|
113
|
-
else
|
|
321
|
+
# ── Terminal-grid fallback (no tmux): per-agent windows, titled `<PROJECT> · <AGENT>` so sessions differ ──
|
|
322
|
+
# Geometry: auto-detected right 58% of the FOCUSED screen.
|
|
323
|
+
compute_rect() {
|
|
324
|
+
if [ -n "${CREW_RECT:-}" ]; then IFS=',' read -r GX GY GW GH <<< "$CREW_RECT"; return; fi
|
|
114
325
|
eval "$(osascript -l JavaScript -e '
|
|
115
326
|
ObjC.import("AppKit");
|
|
116
327
|
const f=$.NSScreen.mainScreen.visibleFrame, prim=$.NSScreen.screens.objectAtIndex(0).frame;
|
|
117
328
|
const yTop = prim.size.height - (f.origin.y + f.size.height);
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
if [ -z "${SW:-}" ]; then # fallback: primary display via Finder
|
|
121
|
-
read -r SX SY SW SH <<< "$(osascript -e 'tell application "Finder" to get bounds of window of desktop' | tr ',' ' ')"
|
|
122
|
-
SY=25; SH=$(( SH - 25 ))
|
|
123
|
-
fi
|
|
329
|
+
`SX=${Math.round(f.origin.x)} SY=${Math.round(yTop)} SW=${Math.round(f.size.width)} SH=${Math.round(f.size.height)}`' 2>/dev/null)"
|
|
330
|
+
if [ -z "${SW:-}" ]; then read -r SX SY SW SH <<< "$(osascript -e 'tell application "Finder" to get bounds of window of desktop' | tr ',' ' ')"; SY=25; SH=$(( SH - 25 )); fi
|
|
124
331
|
GX=$(( SX + SW * 42 / 100 )); GY=$SY; GW=$(( SW * 58 / 100 )); GH=$SH
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
local N=$# COLS=2
|
|
130
|
-
[ $N -le 2 ] && COLS=1
|
|
332
|
+
}
|
|
333
|
+
spawn_grid() { # $@ = specs
|
|
334
|
+
compute_rect
|
|
335
|
+
local N=$# COLS=2; [ $N -le 2 ] && COLS=1
|
|
131
336
|
local ROWS=$(( (N + COLS - 1) / COLS ))
|
|
132
|
-
local CW=$(( GW / COLS )) CH=$(( GH / ROWS ))
|
|
133
|
-
local i=0 SPEC AGENT FIELD MODEL
|
|
337
|
+
local CW=$(( GW / COLS )) CH=$(( GH / ROWS )) i=0 SPEC
|
|
134
338
|
for SPEC in "$@"; do
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
[ "$
|
|
140
|
-
MODEL=""
|
|
141
|
-
if [ -n "$FIELD" ]; then
|
|
142
|
-
case "$FIELD" in
|
|
143
|
-
*/*) MODEL="$FIELD" ;; # full pin: provider/model
|
|
144
|
-
*) MODEL="$(resolve_model "$AGENT" "$FIELD" "$TASK" "$DIFF")" # provider only: pick live now
|
|
145
|
-
if [ -n "$MODEL" ]; then echo " → $AGENT: live model $MODEL ($FIELD · $TASK/$DIFF)"
|
|
146
|
-
else echo " → $AGENT: '$FIELD' live selection unavailable — CLI default"; fi ;;
|
|
147
|
-
esac
|
|
148
|
-
fi
|
|
149
|
-
local C=$(( i % COLS )) R=$(( i / COLS ))
|
|
150
|
-
local X1=$(( GX + C * CW )) Y1=$(( GY + R * CH )) WID=""
|
|
339
|
+
resolve_spec "$SPEC"
|
|
340
|
+
local cmd; cmd="$(RUN_CMD)"
|
|
341
|
+
local C=$(( i % COLS )) R=$(( i / COLS )) X1 Y1 WID=""
|
|
342
|
+
X1=$(( GX + C * CW )); Y1=$(( GY + R * CH ))
|
|
343
|
+
if [ "$DRY" = "1" ]; then echo "[dry] Terminal window for $AGENT at $X1,$Y1"; record_state "$PROJ" "win" "$AGENT" "%DRYWIN$i"; i=$(( i + 1 )); continue; fi
|
|
151
344
|
WID="$(osascript \
|
|
152
345
|
-e 'tell application "Terminal"' \
|
|
153
|
-
-e " set w to do script \"
|
|
154
|
-
-e " set custom title of w to \"$(echo "$AGENT" | tr '[:lower:]' '[:upper:]')
|
|
346
|
+
-e " set w to do script \"clear && $cmd\"" \
|
|
347
|
+
-e " set custom title of w to \"$PROJ · $(echo "$AGENT" | tr '[:lower:]' '[:upper:]')\"" \
|
|
155
348
|
-e " set theWin to first window whose tabs contains w" \
|
|
156
349
|
-e " set bounds of theWin to {$X1, $Y1, $(( X1 + CW )), $(( Y1 + CH ))}" \
|
|
157
350
|
-e " return id of theWin" \
|
|
158
351
|
-e 'end tell' 2>/dev/null)"
|
|
159
|
-
if [ -n "$WID" ]; then
|
|
160
|
-
sleep 1.2
|
|
352
|
+
if [ -n "$WID" ]; then record_state "$PROJ" "win" "$AGENT" "$WID"; echo " → $AGENT window spawned ($PROJ · $AGENT)"; else echo " ✗ $AGENT osascript spawn ERROR"; fi
|
|
353
|
+
sleep 1.2
|
|
354
|
+
i=$(( i + 1 ))
|
|
355
|
+
done
|
|
356
|
+
}
|
|
357
|
+
# ── cmux spawn: ONE workspace TAB per project, seats tiled inside it ──────────────────────────────────
|
|
358
|
+
# PRIMARY = the control socket (structured, stable UUIDs, native sidebar status) when it's authorized
|
|
359
|
+
# (automation.socketControlMode = allowAll). new-workspace launches seat 1; new-split + send add each
|
|
360
|
+
# further seat; rename-workspace titles the sidebar tab; the runner pushes per-seat status. Whole-project
|
|
361
|
+
# teardown = close-workspace by UUID. FALLBACK = AppleScript (Apple Events, no allowAll needed) when the
|
|
362
|
+
# socket is off. Each seat's command rides a launcher script (OSC pane title + no CLI-escaping headaches).
|
|
363
|
+
_cmux_surf_json() { node -e 'let d="";process.stdin.on("data",c=>d+=c).on("end",()=>{try{const j=JSON.parse(d);process.stdout.write(j.surface_id||j.surface_ref||"")}catch(e){}})'; }
|
|
364
|
+
spawn_cmux() { # $@ = specs
|
|
365
|
+
if ! _cmux_ok; then
|
|
366
|
+
echo "— cmux control socket is OFF (mode cmuxOnly) → using AppleScript (works; no native sidebar status)."
|
|
367
|
+
echo " Enable the full integration: add \"automation\": { \"socketControlMode\": \"allowAll\" } to"
|
|
368
|
+
echo " ~/.config/cmux/cmux.json (cmux auto-reloads). —"
|
|
369
|
+
spawn_cmux_applescript "$@"; return
|
|
370
|
+
fi
|
|
371
|
+
local first=1 SPEC wsid="" surf="" i=0
|
|
372
|
+
local dirs=(down right down right down right down right)
|
|
373
|
+
for SPEC in "$@"; do
|
|
374
|
+
resolve_spec "$SPEC"
|
|
375
|
+
local cmd launcher; cmd="$(RUN_CMD)"; launcher="$(_seat_launcher "$AGENT" "$cmd")"
|
|
376
|
+
if [ "$first" = "1" ]; then
|
|
377
|
+
if [ "$DRY" = "1" ]; then
|
|
378
|
+
echo "[dry] cmux: new-workspace (cwd $DIR) --command 'bash $launcher' → rename 'trantor:$PROJ'"
|
|
379
|
+
wsid="%DRYWS"; surf="%DRYT0"
|
|
380
|
+
else
|
|
381
|
+
local out ref; out="$(_cmux new-workspace --cwd "$DIR" --command "bash $launcher" 2>&1)"
|
|
382
|
+
ref="$(printf '%s' "$out" | awk '{print $2}')"; wsid="$(_cmux_ws_uuid "$ref")"
|
|
383
|
+
[ -n "$wsid" ] && _cmux rename-workspace --workspace "$wsid" "trantor:$PROJ" >/dev/null 2>&1
|
|
384
|
+
surf="$(_cmux list-pane-surfaces --workspace "$wsid" --id-format uuids --json 2>/dev/null | node -e 'let d="";process.stdin.on("data",c=>d+=c).on("end",()=>{try{const o=JSON.parse(d.slice(d.search(/[\[{]/)));const a=o.surfaces||o.panes||o||[];const s=Array.isArray(a)?a[0]:null;process.stdout.write((s&&(s.id||s.surface_id))||"")}catch(e){}})')"
|
|
385
|
+
fi
|
|
386
|
+
record_state "$PROJ" "cmuxws" "__ws__" "$wsid"
|
|
387
|
+
first=0
|
|
388
|
+
else
|
|
389
|
+
local dir="${dirs[$(( i % ${#dirs[@]} ))]}"
|
|
390
|
+
if [ "$DRY" = "1" ]; then
|
|
391
|
+
echo "[dry] cmux: new-split $dir + send 'bash $launcher'"; surf="%DRYT$i"
|
|
392
|
+
else
|
|
393
|
+
surf="$(_cmux new-split "$dir" --workspace "$wsid" --id-format uuids --json 2>/dev/null | _cmux_surf_json)"
|
|
394
|
+
[ -n "$surf" ] && { _cmux send --surface "$surf" "bash $launcher" >/dev/null 2>&1; _cmux send-key --surface "$surf" enter >/dev/null 2>&1; }
|
|
395
|
+
fi
|
|
396
|
+
fi
|
|
397
|
+
record_state "$PROJ" "cmux" "$AGENT" "$surf"
|
|
398
|
+
echo " → $AGENT seat in cmux workspace ($PROJ)"
|
|
161
399
|
i=$(( i + 1 ))
|
|
162
400
|
done
|
|
401
|
+
[ "$DRY" = "1" ] || [ -z "$wsid" ] || _cmux set-status trantor "crew up" --icon rocket --color "#14b8a6" --workspace "$wsid" >/dev/null 2>&1
|
|
402
|
+
echo "— crew grouped in cmux: ONE workspace tab for $PROJ, seats tiled + sidebar status. Teardown (this project only): trantor down —"
|
|
163
403
|
}
|
|
164
404
|
|
|
165
|
-
#
|
|
166
|
-
#
|
|
167
|
-
|
|
168
|
-
local
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
local
|
|
172
|
-
|
|
173
|
-
[
|
|
174
|
-
|
|
175
|
-
echo "— tearing down old agent '$OLD' (window $wid) —"
|
|
176
|
-
local TTY; TTY=$(osascript -e "tell application \"Terminal\" to get tty of (first window whose id is $wid)" 2>/dev/null)
|
|
177
|
-
[ -n "$TTY" ] && for pid in $(ps -t "${TTY#/dev/}" -o pid= 2>/dev/null); do kill -9 "$pid" 2>/dev/null; done
|
|
178
|
-
sleep 0.5
|
|
179
|
-
osascript -e "tell application \"Terminal\" to close (first window whose id is $wid)" 2>/dev/null
|
|
405
|
+
# AppleScript fallback (cmux control socket off): same one-tab-per-project layout, minus native sidebar status.
|
|
406
|
+
spawn_cmux_applescript() { # $@ = specs
|
|
407
|
+
local first=1 SPEC tabid="" termid="" i=0
|
|
408
|
+
local dirs=(down right down right down right down right)
|
|
409
|
+
for SPEC in "$@"; do
|
|
410
|
+
resolve_spec "$SPEC"
|
|
411
|
+
local cmd launcher; cmd="$(RUN_CMD)"; launcher="$(_seat_launcher "$AGENT" "$cmd")"
|
|
412
|
+
if [ "$first" = "1" ]; then
|
|
413
|
+
if [ "$DRY" = "1" ]; then
|
|
414
|
+
echo "[dry] cmux(AppleScript): new tab (trantor:$PROJ) + run 'bash $launcher'"; tabid="%DRYTAB"; termid="%DRYT0"
|
|
180
415
|
else
|
|
181
|
-
|
|
416
|
+
local out; out="$(osascript 2>/dev/null <<OSA
|
|
417
|
+
tell application "cmux"
|
|
418
|
+
activate
|
|
419
|
+
if (count of windows) is 0 then
|
|
420
|
+
new window
|
|
421
|
+
delay 0.5
|
|
422
|
+
end if
|
|
423
|
+
set t to (new tab)
|
|
424
|
+
delay 0.4
|
|
425
|
+
set term1 to (focused terminal of t)
|
|
426
|
+
input text ("bash $launcher" & return) to term1
|
|
427
|
+
return (id of t) & "|" & (id of term1)
|
|
428
|
+
end tell
|
|
429
|
+
OSA
|
|
430
|
+
)"
|
|
431
|
+
tabid="${out%%|*}"; termid="${out##*|}"
|
|
182
432
|
fi
|
|
183
|
-
|
|
184
|
-
|
|
433
|
+
record_state "$PROJ" "cmuxws" "__ws__" "$tabid"
|
|
434
|
+
first=0
|
|
435
|
+
else
|
|
436
|
+
local dir="${dirs[$(( i % ${#dirs[@]} ))]}"
|
|
437
|
+
if [ "$DRY" = "1" ]; then
|
|
438
|
+
echo "[dry] cmux(AppleScript): split $dir + run 'bash $launcher'"; termid="%DRYT$i"
|
|
439
|
+
else
|
|
440
|
+
termid="$(osascript 2>/dev/null <<OSA
|
|
441
|
+
tell application "cmux"
|
|
442
|
+
set theTab to missing value
|
|
443
|
+
repeat with w in windows
|
|
444
|
+
repeat with tt in tabs of w
|
|
445
|
+
if (id of tt) is "$tabid" then set theTab to tt
|
|
446
|
+
end repeat
|
|
447
|
+
end repeat
|
|
448
|
+
if theTab is missing value then return "ERR"
|
|
449
|
+
set newterm to (split (focused terminal of theTab) direction $dir)
|
|
450
|
+
delay 0.25
|
|
451
|
+
input text ("bash $launcher" & return) to newterm
|
|
452
|
+
return (id of newterm)
|
|
453
|
+
end tell
|
|
454
|
+
OSA
|
|
455
|
+
)"
|
|
456
|
+
fi
|
|
457
|
+
fi
|
|
458
|
+
record_state "$PROJ" "cmux" "$AGENT" "$termid"
|
|
459
|
+
echo " → $AGENT seat in cmux workspace ($PROJ)"
|
|
460
|
+
i=$(( i + 1 ))
|
|
461
|
+
done
|
|
462
|
+
echo "— crew grouped in cmux (AppleScript): ONE workspace tab for $PROJ, seats tiled. Teardown: trantor down —"
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
spawn_crew() { # dispatch: cmux (preferred) → tmux → Terminal grid
|
|
466
|
+
if [ "$HAVE_CMUX" = "1" ]; then spawn_cmux "$@"
|
|
467
|
+
elif [ "$HAVE_TMUX" = "1" ]; then spawn_tmux "$@"
|
|
468
|
+
else
|
|
469
|
+
echo "— no cmux/tmux → per-agent Terminal windows. For ONE grouped, named window per crew (and"
|
|
470
|
+
echo " bulletproof scoped teardown), install cmux, or run: brew install tmux —"
|
|
471
|
+
spawn_grid "$@"
|
|
185
472
|
fi
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
# ── swap: replace a live seat (e.g. one reported exhausted) with a fresh live-selected one ────────────
|
|
476
|
+
swap() {
|
|
477
|
+
local OLD="${1:-}" NEWSPEC="${2:-}"
|
|
478
|
+
[ -n "$OLD" ] && [ -n "$NEWSPEC" ] || { echo "usage: trantor swap <oldAgent> <newAgent[:provider[/model]]> [--task K --difficulty D]"; exit 1; }
|
|
479
|
+
echo "— tearing down old seat '$OLD' in $PROJ —"
|
|
480
|
+
down "$OLD"
|
|
186
481
|
echo "— spawning replacement: $NEWSPEC ($TASK/$DIFF) —"
|
|
187
482
|
local SWAP_EPOCH; SWAP_EPOCH=$(epoch_ms)
|
|
188
|
-
|
|
483
|
+
spawn_crew "$NEWSPEC"
|
|
189
484
|
local NEWAGENT="${NEWSPEC%%:*}"
|
|
190
485
|
echo "— verifying replacement on the bus —"
|
|
191
|
-
node "$BUS_DIR/bin/crew-verify.mjs" "$PROJ" "$NEWAGENT" --since "$SWAP_EPOCH" --timeout 30
|
|
486
|
+
[ "$DRY" = "1" ] || node "$BUS_DIR/bin/crew-verify.mjs" "$PROJ" "$NEWAGENT" --since "$SWAP_EPOCH" --timeout 30
|
|
192
487
|
echo "— swapped. RESEND the contract to '$NEWAGENT' (it joined fresh with no context). —"
|
|
193
488
|
}
|
|
194
489
|
|
|
195
490
|
if [ "$CMD" = "swap" ]; then swap "$@"; exit 0; fi
|
|
196
491
|
|
|
197
|
-
#
|
|
198
|
-
|
|
492
|
+
# ── up ───────────────────────────────────────────────────────────────────────────────────────────────
|
|
493
|
+
prune_dead_state
|
|
494
|
+
# ensure the cmux CLI is on PATH (opt-in) so runner-side sidebar-status calls resolve from any shell.
|
|
495
|
+
if [ "$DRY" != "1" ] && [ "$HAVE_CMUX" = "1" ] && ! command -v cmux >/dev/null 2>&1; then
|
|
496
|
+
mkdir -p "$HOME/.local/bin"
|
|
497
|
+
ln -sf "/Applications/cmux.app/Contents/Resources/bin/cmux" "$HOME/.local/bin/cmux" 2>/dev/null \
|
|
498
|
+
&& echo "— linked cmux CLI → ~/.local/bin/cmux —"
|
|
499
|
+
fi
|
|
500
|
+
[ "$DRY" = "1" ] || node "$BUS_DIR/bin/connect.mjs" | tail -n +2 # one-time CLI wiring (idempotent)
|
|
501
|
+
|
|
199
502
|
spec_for_agent() { local want="$1"; shift; local s; for s in "$@"; do [ "${s%%:*}" = "$want" ] && { printf '%s' "$s"; return; }; done; printf '%s' "$want"; }
|
|
200
503
|
|
|
201
|
-
|
|
504
|
+
CREW_UI="Terminal windows"; [ "$HAVE_TMUX" = "1" ] && CREW_UI="tmux"; [ "$HAVE_CMUX" = "1" ] && CREW_UI="cmux"
|
|
505
|
+
echo "— bringing up crew for $PROJ ($CREW_UI) —"
|
|
202
506
|
SPAWN_EPOCH=$(epoch_ms)
|
|
203
|
-
|
|
507
|
+
spawn_crew "$@"
|
|
204
508
|
|
|
509
|
+
if [ "$DRY" = "1" ]; then echo "— dry run: no bus verify —"; exit 0; fi
|
|
205
510
|
echo "— verifying on the bus (the spawn is not the truth; the bus is) —"
|
|
206
511
|
AGENTS_ONLY=$(for a in "$@"; do printf "%s " "${a%%:*}"; done)
|
|
207
512
|
VER=$(node "$BUS_DIR/bin/crew-verify.mjs" "$PROJ" $AGENTS_ONLY --since "$SPAWN_EPOCH" --timeout 30)
|
|
208
513
|
echo "$VER"
|
|
209
514
|
RETRY=$(echo "$VER" | grep "^FAILED:" | cut -d: -f2 | tr ',' ' ')
|
|
210
515
|
if [ -n "${RETRY// }" ]; then
|
|
211
|
-
# map failed agent names back to their FULL specs (preserve provider/model on respawn)
|
|
212
516
|
RETRY_SPECS=""; for a in $RETRY; do RETRY_SPECS="$RETRY_SPECS $(spec_for_agent "$a" "$@")"; done
|
|
213
517
|
echo "— retrying failed spawns:$RETRY_SPECS —"
|
|
214
518
|
RETRY_EPOCH=$(epoch_ms)
|
|
215
|
-
|
|
519
|
+
spawn_crew $RETRY_SPECS
|
|
216
520
|
VER2=$(node "$BUS_DIR/bin/crew-verify.mjs" "$PROJ" $RETRY --since "$RETRY_EPOCH" --timeout 30)
|
|
217
521
|
echo "$VER2"
|
|
218
522
|
STILL=$(echo "$VER2" | grep "^FAILED:" | cut -d: -f2)
|
|
219
523
|
if [ -n "$STILL" ]; then
|
|
220
|
-
echo ""
|
|
221
|
-
echo "
|
|
222
|
-
echo " Do NOT assign them work. Investigate their windows or run: crew.sh up ${STILL//,/ }"
|
|
524
|
+
echo ""; echo "✗✗ CREW INCOMPLETE — these agents are NOT on the bus: $STILL"
|
|
525
|
+
echo " Do NOT assign them work. Investigate their panes/windows or run: crew.sh up ${STILL//,/ }"
|
|
223
526
|
exit 1
|
|
224
527
|
fi
|
|
225
528
|
fi
|
|
226
|
-
echo "— crew verified on the bus. Send contracts with relay_send; runners keep agents alive for free. Teardown:
|
|
529
|
+
echo "— crew verified on the bus. Send contracts with relay_send; runners keep agents alive for free. Teardown (this project only): trantor down —"
|
package/bin/sweep.mjs
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// trantor sweep — clear stuck IN-PROGRESS cards. The automatic hub reaper only stales cards whose OWNER is
|
|
3
|
+
// OFFLINE (safe: it never touches live work). `trantor sweep` is the explicit "a live seat forgot its card"
|
|
4
|
+
// broom: it moves EVERY doing/testing card idle past --older (default 30m) to the Stale lane. It PREVIEWS
|
|
5
|
+
// first and changes NOTHING until you re-run with --yes.
|
|
6
|
+
// trantor sweep # preview this project's stuck cards
|
|
7
|
+
// trantor sweep --yes # actually move them to Stale
|
|
8
|
+
// trantor sweep --older 2h # widen/narrow the idle threshold (ms|s|m|h|d, default 30m)
|
|
9
|
+
// trantor sweep --all-projects # every board, not just this one
|
|
10
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import { homedir } from "node:os";
|
|
13
|
+
import { resolveProject } from "../lib/project.mjs";
|
|
14
|
+
|
|
15
|
+
function relayUrl() {
|
|
16
|
+
if (process.env.RELAY_URL) return process.env.RELAY_URL;
|
|
17
|
+
try { const c = join(homedir(), ".agent-bus", "config.json"); if (existsSync(c)) { const u = JSON.parse(readFileSync(c, "utf8")).url; if (u) return u; } } catch {}
|
|
18
|
+
return "http://127.0.0.1:4477";
|
|
19
|
+
}
|
|
20
|
+
function parseDur(s, def) {
|
|
21
|
+
if (!s) return def;
|
|
22
|
+
const m = String(s).match(/^(\d+(?:\.\d+)?)\s*(ms|s|m|h|d)?$/i);
|
|
23
|
+
if (!m) return def;
|
|
24
|
+
const mult = { ms: 1, s: 1000, m: 60000, h: 3600000, d: 86400000 }[(m[2] || "m").toLowerCase()];
|
|
25
|
+
return Math.round(Number(m[1]) * mult);
|
|
26
|
+
}
|
|
27
|
+
const fmtAge = ms => { const m = Math.round(ms / 60000); return m >= 1440 ? `${Math.floor(m / 1440)}d` : m >= 90 ? `${Math.floor(m / 60)}h` : `${m}m`; };
|
|
28
|
+
|
|
29
|
+
const argv = process.argv.slice(2);
|
|
30
|
+
const has = (...f) => f.some(x => argv.includes(x));
|
|
31
|
+
const val = (...f) => { for (const x of f) { const i = argv.indexOf(x); if (i >= 0) return argv[i + 1]; } return undefined; };
|
|
32
|
+
const olderMs = parseDur(val("--older", "-o"), 30 * 60 * 1000);
|
|
33
|
+
const doIt = has("--yes", "-y");
|
|
34
|
+
const allProjects = has("--all-projects", "--all");
|
|
35
|
+
const project = allProjects ? null : resolveProject(process.cwd());
|
|
36
|
+
const url = relayUrl();
|
|
37
|
+
|
|
38
|
+
async function sweep(dryRun) {
|
|
39
|
+
const body = { olderMs, dryRun, by: "cli-sweep" };
|
|
40
|
+
if (project) body.project = project;
|
|
41
|
+
const r = await fetch(`${url}/sweep`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body), signal: AbortSignal.timeout(6000) });
|
|
42
|
+
return r.json();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
try {
|
|
46
|
+
const scope = project ? `project "${project}"` : "ALL projects";
|
|
47
|
+
const pre = await sweep(true);
|
|
48
|
+
const cand = pre.candidates || [];
|
|
49
|
+
console.log(`\n🧹 trantor sweep — ${scope} · idle > ${fmtAge(olderMs)}\n${"─".repeat(52)}`);
|
|
50
|
+
if (!cand.length) { console.log(" nothing stuck — every in-progress card is fresh or owned-live.\n"); process.exit(0); }
|
|
51
|
+
for (const c of cand) console.log(` #${c.id} [${c.status}] ${String(c.title).slice(0, 58)} · ${c.assignee || "?"} · idle ${fmtAge(c.ageMs)}${allProjects ? ` · ${c.project}` : ""}`);
|
|
52
|
+
if (!doIt) {
|
|
53
|
+
console.log(`\n ${cand.length} card(s) would move to the Stale lane. Re-run to confirm:`);
|
|
54
|
+
console.log(` trantor sweep${allProjects ? " --all-projects" : ""}${val("--older", "-o") ? ` --older ${val("--older", "-o")}` : ""} --yes\n`);
|
|
55
|
+
process.exit(0);
|
|
56
|
+
}
|
|
57
|
+
const res = await sweep(false);
|
|
58
|
+
console.log(`\n ✓ swept ${res.swept} card(s) → Stale. Triage on the board (click a stale card to re-queue, or move it to Done).\n`);
|
|
59
|
+
} catch (e) {
|
|
60
|
+
console.error(`could not reach hub at ${url}: ${e.message}`);
|
|
61
|
+
process.exit(1);
|
|
62
|
+
}
|
package/hub.mjs
CHANGED
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "trantor",
|
|
3
|
-
"version": "0.17.
|
|
3
|
+
"version": "0.17.50",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"bin": {
|
|
6
6
|
"trantor": "bin/cli.mjs"
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"zod": "^4.4.3"
|
|
11
11
|
},
|
|
12
12
|
"scripts": {
|
|
13
|
-
"test": "node test.mjs && node test-scenarios.mjs && node test-failure.mjs && node test-handoff.mjs && node test-agents.mjs && node test-update.mjs && node test-handoff-guard.mjs && node test-balances.mjs && node test-subagent-cost.mjs && node test-inbox.mjs && node test-inflight.mjs && node test-notify.mjs && node test-focus.mjs && python3 engine/test-routing.py"
|
|
13
|
+
"test": "node test.mjs && node test-scenarios.mjs && node test-failure.mjs && node test-handoff.mjs && node test-agents.mjs && node test-update.mjs && node test-handoff-guard.mjs && node test-balances.mjs && node test-subagent-cost.mjs && node test-inbox.mjs && node test-inflight.mjs && node test-notify.mjs && node test-focus.mjs && node test-reaper.mjs && python3 engine/test-routing.py && bash test-crew.sh"
|
|
14
14
|
},
|
|
15
15
|
"description": "The hub-world for AI agent crews — orchestrate Claude Code, Codex, GLM, Kimi, DeepSeek & any OpenRouter model as live crews with a plan-aware Advisor, a Kanban/flow command center, a testing gate, and an economics brain (Scrooge).",
|
|
16
16
|
"files": [
|
package/ui.html
CHANGED
|
@@ -91,16 +91,17 @@ main:not(.learn-open) .learn-body{display:none}
|
|
|
91
91
|
.phase.planned{color:var(--mut)}
|
|
92
92
|
.pbar{height:4px;border-radius:3px;background:#0d1320;margin:2px 16px 0;overflow:hidden}
|
|
93
93
|
.pbar i{display:block;height:100%;background:linear-gradient(90deg,var(--grn2),var(--grn));border-radius:3px;transition:width .4s}
|
|
94
|
-
.kanban{display:grid;grid-template-columns:repeat(
|
|
94
|
+
.kanban{display:grid;grid-template-columns:repeat(6,1fr);gap:10px;padding:12px 16px}
|
|
95
95
|
.col{background:#0d1320;border:1px solid var(--line);border-radius:10px;padding:9px;min-height:140px;max-height:clamp(280px,58vh,1100px);overflow-y:auto}
|
|
96
96
|
.col::-webkit-scrollbar{width:6px}.col::-webkit-scrollbar-thumb{background:#243049;border-radius:3px}
|
|
97
97
|
.col h4{position:sticky;top:-9px;background:#0d1320;padding:9px 0 6px;margin-top:-9px;z-index:1}
|
|
98
98
|
.col h4{margin:0 0 8px;font-size:10.5px;text-transform:uppercase;letter-spacing:.08em;color:var(--dim);display:flex;justify-content:space-between}
|
|
99
|
-
.col.todo h4 i{color:var(--mut)}.col.doing h4 i{color:var(--blu)}.col.testing h4 i{color:var(--amb)}.col.done h4 i{color:var(--grn)}.col.blocked h4 i{color:var(--red)}
|
|
99
|
+
.col.todo h4 i{color:var(--mut)}.col.doing h4 i{color:var(--blu)}.col.testing h4 i{color:var(--amb)}.col.done h4 i{color:var(--grn)}.col.blocked h4 i{color:var(--red)}.col.stale h4 i{color:#a99fd0}
|
|
100
100
|
.col h4 i{font-style:normal}
|
|
101
101
|
.tcard{background:var(--card);border:1px solid var(--line);border-left:3px solid var(--mut);border-radius:7px;padding:7px 9px;margin-bottom:7px;cursor:pointer;font-size:13px}
|
|
102
102
|
.tcard:hover{background:var(--card2);border-color:#2c3a52}
|
|
103
|
-
.col.doing .tcard{border-left-color:var(--blu)}.col.testing .tcard{border-left-color:var(--amb)}.col.done .tcard{border-left-color:var(--grn)}.col.blocked .tcard{border-left-color:var(--red)}
|
|
103
|
+
.col.doing .tcard{border-left-color:var(--blu)}.col.testing .tcard{border-left-color:var(--amb)}.col.done .tcard{border-left-color:var(--grn)}.col.blocked .tcard{border-left-color:var(--red)}.col.stale .tcard{border-left-color:#8a7fb0;opacity:.82}
|
|
104
|
+
.swp{cursor:pointer;font-size:11px;color:var(--dim);border:1px solid var(--line);border-radius:5px;padding:1px 7px;margin-left:6px;user-select:none}.swp:hover{color:var(--amb);border-color:#3a4458}.swp.arm{color:#0b1120;background:var(--amb);border-color:var(--amb)}
|
|
104
105
|
.tcard.failed{border-left-color:var(--red);border-color:#5a2c2c;animation:failpulse 1.1s ease-in-out infinite}
|
|
105
106
|
.tcard.failed .who{color:#ef6a6a}
|
|
106
107
|
@keyframes failpulse{0%,100%{box-shadow:0 0 0 0 rgba(239,106,106,0)}50%{box-shadow:0 0 14px 2px rgba(239,106,106,.55);border-color:#ef6a6a}}
|
|
@@ -257,6 +258,7 @@ main:not(.learn-open) .learn-body{display:none}
|
|
|
257
258
|
.tevent .tstatus.failed{color:var(--red)}
|
|
258
259
|
.tevent .tstatus.done{color:var(--grn)}
|
|
259
260
|
.tevent .tstatus.blocked{color:var(--red)}
|
|
261
|
+
.tevent .tstatus.stale{color:#a99fd0}
|
|
260
262
|
.tevent .arr{color:var(--dim);margin:0 2px}
|
|
261
263
|
/* per-project inter-agent conversation lane */
|
|
262
264
|
.proj-chat{margin:4px 16px 14px;border:1px solid var(--line);border-radius:10px;background:#0c111c}
|
|
@@ -320,7 +322,7 @@ const esc=s=>String(s).replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>',
|
|
|
320
322
|
// number; `$usd()` prefixes the currency symbol.
|
|
321
323
|
const usd=(n,dp=2)=>(Number(n)||0).toLocaleString('en-US',{minimumFractionDigits:dp,maximumFractionDigits:dp});
|
|
322
324
|
const $usd=(n,sym='$',dp=2)=>sym+usd(n,dp);
|
|
323
|
-
const COLS=[['todo','To Do'],['doing','In Progress'],['testing','Testing'],['done','Done'],['blocked','Blocked']];
|
|
325
|
+
const COLS=[['todo','To Do'],['doing','In Progress'],['testing','Testing'],['done','Done'],['blocked','Blocked'],['stale','Stale']];
|
|
324
326
|
let nmsg=0;
|
|
325
327
|
// SOLO = single-project full-window mode via deep link ?project=<name>. Parsed once (search is static
|
|
326
328
|
// per load). When set, render() shows ONLY that project's board/FLOW/TIMELINE and hides the side rails.
|
|
@@ -440,6 +442,7 @@ let PHASES = {};
|
|
|
440
442
|
function setView(proj, v){ VIEWS[proj] = v; localStorage.setItem("abViews", JSON.stringify(VIEWS)); render(); }
|
|
441
443
|
function toggleIdle(name){ if(!name)return; const s=new Set(JSON.parse(localStorage.getItem("abIdleOpen")||"[]")); s.has(name)?s.delete(name):s.add(name); localStorage.setItem("abIdleOpen",JSON.stringify([...s])); render(); }
|
|
442
444
|
let armedDel=null,armedTs=0; // pending ✕ confirmation (project name + when it was armed)
|
|
445
|
+
let armedSwp=null,armedSwpTs=0,armedSwpCount=0; // pending 🧹 sweep confirmation (project + when armed + previewed count)
|
|
443
446
|
// ▲▼: snapshot the currently displayed order, swap, persist — manual order then wins until ↺ reset
|
|
444
447
|
function moveProj(name,dir){
|
|
445
448
|
const names=[...document.querySelectorAll('#boards [data-projname]')].map(d=>d.dataset.projname);
|
|
@@ -457,7 +460,7 @@ function flowHTML(pt, proj){
|
|
|
457
460
|
const data = PHASES[proj];
|
|
458
461
|
if (!data) return '<div class="pflowwrap"><div class="empty">loading flow…</div></div>';
|
|
459
462
|
if (!data.phases || !data.phases.length) return '<div class="pflowwrap"><div class="empty">no cards yet</div></div>';
|
|
460
|
-
const SCOL = { todo:'#3a4458', doing:'#4a90d9', testing:'#f59e0b', failed:'#ef6a6a', blocked:'#ef6a6a', done:'#14b8a6' };
|
|
463
|
+
const SCOL = { todo:'#3a4458', doing:'#4a90d9', testing:'#f59e0b', failed:'#ef6a6a', blocked:'#ef6a6a', done:'#14b8a6', stale:'#8a7fb0' };
|
|
461
464
|
const NW = 168, NH = 34, RG = 11, CG = 64, PGAP = 76, MT = 64, MB = 22;
|
|
462
465
|
const MAXPC = 10; // max nodes stacked per column before collapsing into a "+N more" node
|
|
463
466
|
let pi0 = 0; // running id base so each phase's overflow nodes get unique negative ids
|
|
@@ -608,7 +611,7 @@ let cardEsc = null;
|
|
|
608
611
|
async function openCard(id){
|
|
609
612
|
let d; try { d = await (await fetch('/card?id=' + id)).json(); } catch { return; }
|
|
610
613
|
const t = d.task; if (!t) return;
|
|
611
|
-
const SC = { todo:'var(--mut)', doing:'var(--blu)', testing:'var(--amb)', failed:'var(--red)', blocked:'var(--red)', done:'var(--grn)', deleted:'var(--dim)' };
|
|
614
|
+
const SC = { todo:'var(--mut)', doing:'var(--blu)', testing:'var(--amb)', failed:'var(--red)', blocked:'var(--red)', done:'var(--grn)', deleted:'var(--dim)', stale:'#a99fd0' };
|
|
612
615
|
const items = [];
|
|
613
616
|
for (const e of (d.events||[])) items.push({ ts:e.ts, ev:e });
|
|
614
617
|
for (const m of (d.messages||[])) items.push({ ts:m.ts, m });
|
|
@@ -829,7 +832,7 @@ async function render(){
|
|
|
829
832
|
const brief=p.brief?`<span class="brief">${esc(p.brief)}</span>`:`<span class="brief dim">— no brief yet · an agent sets it with relay_project_brief</span>`;
|
|
830
833
|
const view = VIEWS[p.project] || "board";
|
|
831
834
|
const vtog = `<div class="vtog"><span class="vlbl">view</span><button class="vbtn ${view==="board"?"on":""}" data-proj="${esc(p.project)}" data-view="board">BOARD</button><button class="vbtn ${view==="flow"?"on":""}" data-proj="${esc(p.project)}" data-view="flow">FLOW</button><button class="vbtn ${view==="timeline"?"on":""}" data-proj="${esc(p.project)}" data-view="timeline">TIMELINE</button></div>`;
|
|
832
|
-
const ctl=`<span class="mv"><b class="mvup" data-proj="${esc(p.project)}" title="move up">▲</b><b class="mvdn" data-proj="${esc(p.project)}" title="move down">▼</b></span>${p.idle===true?`<span class="pdel" data-proj="${esc(p.project)}" title="forget this project (cards, peers, brief) — it returns if an agent registers it again">✕</span>`:''}`;
|
|
835
|
+
const ctl=`<span class="swp" data-proj="${esc(p.project)}" title="sweep stuck in-progress cards (idle > 30m) to the Stale lane — first click previews the count, second click within 4s confirms">🧹 sweep</span><span class="mv"><b class="mvup" data-proj="${esc(p.project)}" title="move up">▲</b><b class="mvdn" data-proj="${esc(p.project)}" title="move down">▼</b></span>${p.idle===true?`<span class="pdel" data-proj="${esc(p.project)}" title="forget this project (cards, peers, brief) — it returns if an agent registers it again">✕</span>`:''}`;
|
|
833
836
|
|
|
834
837
|
return `<div class="proj${p.idle===true?' idle':''}" data-projname="${esc(p.project)}"${p.idle===true?` data-idleproj="${esc(p.project)}"`:''}>`+
|
|
835
838
|
`<div class="proj-h"><span class="pname">📁 <b>${esc(p.project)}</b></span><a class="popout" href="?project=${encodeURIComponent(p.project)}" target="_blank" rel="noopener" title="open this project in its own window" onclick="event.stopPropagation()">↗</a>${vtog}<div class="agents">${agents||'<span class="dim">no agents</span>'}</div><span class="spacer"></span><span class="prog">${done}/${pt.length} done · ${pct}%</span>${ctl}</div>`+
|
|
@@ -867,6 +870,19 @@ async function render(){
|
|
|
867
870
|
// ▲▼ reorder (persists as a manual order); ✕ forget — armed two-step, no popup
|
|
868
871
|
el.querySelectorAll('.mvup').forEach(b=>b.onclick=e=>{e.stopPropagation();moveProj(b.dataset.proj,-1);});
|
|
869
872
|
el.querySelectorAll('.mvdn').forEach(b=>b.onclick=e=>{e.stopPropagation();moveProj(b.dataset.proj,1);});
|
|
873
|
+
// 🧹 sweep — armed two-step (no popup): first click previews the count via dryRun, second confirms.
|
|
874
|
+
el.querySelectorAll('.swp').forEach(b=>b.onclick=async e=>{
|
|
875
|
+
e.stopPropagation();
|
|
876
|
+
const p=b.dataset.proj;
|
|
877
|
+
if(!(armedSwp===p&&Date.now()-armedSwpTs<4000)){
|
|
878
|
+
const dry=await fetch('/sweep',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({project:p,olderMs:1800000,dryRun:true})}).then(r=>r.json()).catch(()=>({count:0}));
|
|
879
|
+
if(!dry.count){b.textContent='🧹 nothing idle';setTimeout(()=>{b.textContent='🧹 sweep';},1600);return;}
|
|
880
|
+
armedSwp=p;armedSwpTs=Date.now();armedSwpCount=dry.count;b.classList.add('arm');b.textContent=`🧹 sweep ${dry.count}?`;return;
|
|
881
|
+
}
|
|
882
|
+
armedSwp=null;b.classList.remove('arm');
|
|
883
|
+
await fetch('/sweep',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({project:p,olderMs:1800000,by:'dashboard'})});
|
|
884
|
+
render();
|
|
885
|
+
});
|
|
870
886
|
el.querySelectorAll('.pdel').forEach(b=>b.onclick=async e=>{
|
|
871
887
|
e.stopPropagation();
|
|
872
888
|
const p=b.dataset.proj;
|
|
@@ -878,6 +894,7 @@ async function render(){
|
|
|
878
894
|
// the 2.5s re-render rebuilds the DOM — re-apply a still-fresh armed state so the
|
|
879
895
|
// "✕ sure?" confirmation survives until it expires instead of silently resetting
|
|
880
896
|
if(armedDel&&Date.now()-armedTs<4000){const ab=el.querySelector(`.pdel[data-proj="${CSS.escape(armedDel)}"]`);if(ab){ab.classList.add('arm');ab.textContent='✕ sure?';}}
|
|
897
|
+
if(armedSwp&&Date.now()-armedSwpTs<4000){const sb=el.querySelector(`.swp[data-proj="${CSS.escape(armedSwp)}"]`);if(sb){sb.classList.add('arm');sb.textContent=`🧹 sweep ${armedSwpCount||''}?`;}}else{armedSwp=null;}
|
|
881
898
|
const sr=$('#sortreset');if(sr)sr.onclick=()=>{localStorage.removeItem('abOrder');render();};
|
|
882
899
|
wireFlow(el);wireTimeline(el);
|
|
883
900
|
// keep each timeline scrolled to the latest event
|
|
@@ -885,7 +902,7 @@ async function render(){
|
|
|
885
902
|
el.querySelectorAll('.tcard, .fnode').forEach(c=>c.onclick=async()=>{
|
|
886
903
|
const id=+c.dataset.id,t=tasks.find(x=>x.id===id);if(!t)return;
|
|
887
904
|
if(suppressClickId&&+suppressClickId===id)return; // drag-end, not a click
|
|
888
|
-
const next={todo:'doing',doing:'testing',testing:'done',failed:'doing',done:'todo',blocked:'doing'}[t.status];
|
|
905
|
+
const next={todo:'doing',doing:'testing',testing:'done',failed:'doing',done:'todo',blocked:'doing',stale:'todo'}[t.status];
|
|
889
906
|
await fetch('/task/update',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({id,status:next})});render();
|
|
890
907
|
});
|
|
891
908
|
}
|