trantor 0.17.49 → 0.17.51

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.
@@ -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.49"
9
+ "version": "0.17.51"
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.49",
16
+ "version": "0.17.51",
17
17
  "author": {
18
18
  "name": "Sasha Bogojevic"
19
19
  },
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trantor",
3
- "version": "0.17.49",
3
+ "version": "0.17.51",
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 one titled
139
- terminal window per agent. The seats aren't a fixed list they're **whatever you've got**:
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,8 @@ 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;
35
+ case "reconcile": run("bin/reconcile.mjs"); break;
34
36
  case "init-hooks": run("bin/init-hooks.mjs"); break;
35
37
  case "balances": case "balance": case "credits": run("bin/balances.mjs"); break;
36
38
  case "recost": run("bin/recost.mjs"); break;
@@ -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 v2 — visible terminal windows that CANNOT silently die or silently fail.
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 gemini kimi deepseek # one window per agent, in the CURRENT project dir
5
- # bin/crew.sh down # kill crew processes + close windows (no dialogs)
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
- # Each window runs bin/crew-runner.mjs: the CLI does one turn and exits; the RUNNER long-polls
8
- # the bus (free, doubles as heartbeat) and resumes the CLI with full context whenever a
9
- # message arrives. No model-side parking, no harness fights, no token burn while idle.
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
- # Spawns are SERIALIZED and then VERIFIED on the bus (crew-verify.mjs); failures retry once and
12
- # are reported loudly the orchestrator never gets a green lie.
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
- # Geometry: "crewRect": "X,Y,W,H" in ~/.agent-bus/config.json (or CREW_RECT env) set once per
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
- # basename (stable across subdirs), else the cwd basename. The crew inherits this exact
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
- while IFS=$'\t' read -r a wid; do
30
- [ -n "${wid:-}" ] || wid="$a" # back-compat: old STATE stored bare window ids
31
- TTY=$(osascript -e "tell application \"Terminal\" to get tty of (first window whose id is $wid)" 2>/dev/null)
32
- if [ -n "$TTY" ]; then
33
- # SIGKILL everything on the tty, login included — TUIs trap SIGTERM, and a live login
34
- # makes Terminal raise the "Terminate running processes?" dialog on close.
35
- for pid in $(ps -t "${TTY#/dev/}" -o pid= 2>/dev/null); do kill -9 "$pid" 2>/dev/null; done
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
- sleep 1
39
- while IFS=$'\t' read -r a wid; do
40
- [ -n "${wid:-}" ] || wid="$a"
41
- osascript -e "tell application \"Terminal\" to close (first window whose id is $wid)" 2>/dev/null
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
- sleep 0.5
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 gemini kimi deepseek (agent:provider picks a live model; agent:provider/model pins one)"; exit 1; }
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
- if [ "$(uname)" != "Darwin" ]; then
99
- echo "Window spawning is macOS-only. Run one per terminal, in $DIR:"
100
- for a in "$@"; do echo " node $BUS_DIR/bin/crew-runner.mjs $a $DIR"; done
101
- exit 0
102
- fi
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
- # one-time wiring for every detected CLI (idempotent, backed up)
105
- node "$BUS_DIR/bin/connect.mjs" | tail -n +2
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
- # ---- geometry: AUTO-DETECTED from the screen you're working on (never hard-coded coords).
108
- # NSScreen.mainScreen = the display with keyboard focus; visibleFrame excludes menu bar/Dock.
109
- # Crew tiles into the RIGHT 58% of THAT screen (left side stays for dashboard/main terminal).
110
- # One-off override: CREW_RECT="X,Y,W,H" env (no persistent config display setups change).
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
- const x=Math.round(f.origin.x), y=Math.round(yTop), w=Math.round(f.size.width), h=Math.round(f.size.height);
119
- `SX=${x} SY=${y} SW=${w} SH=${h}`' 2>/dev/null)"
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
- fi
126
- echo "— crew area: ${GW}x${GH} at ${GX},${GY} (focused screen, auto-detected) —"
127
-
128
- spawn_grid() { # $@ = agents (re)computes the grid for THIS batch and spawns serially
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
- AGENT="${SPEC%%:*}" # agent[:provider[/model]] — model rides in as CREW_MODEL
136
- FIELD=""; [ "$SPEC" != "$AGENT" ] && FIELD="${SPEC#*:}"
137
- # `trantor up openrouter` (no provider) default the opencode provider to openrouter so it
138
- # live-selects from the OpenRouter catalog instead of falling back to opencode's default model.
139
- [ "$AGENT" = "openrouter" ] && [ -z "$FIELD" ] && FIELD="openrouter"
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 \"cd $DIR && clear && CREW_MODEL=$MODEL RELAY_PROJECT=$PROJ node $BUS_DIR/bin/crew-runner.mjs $AGENT $DIR\"" \
154
- -e " set custom title of w to \"$(echo "$AGENT" | tr '[:lower:]' '[:upper:]') — trantor crew\"" \
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 printf '%s\t%s\n' "$AGENT" "$WID" >> "$STATE"; echo " → $AGENT window spawned"; else echo " ✗ $AGENT osascript spawn ERROR"; fi
160
- sleep 1.2 # serialize — rapid-fire 'do script' calls race and silently drop windows
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
- # swap <oldAgent> <newSpec>: replace a live agent (e.g. one reported exhausted) with a fresh
166
- # one whose model is live-selected. Tears down the old agent's window, spawns the new spec.
167
- swap() {
168
- local OLD="${1:-}" NEWSPEC="${2:-}"
169
- [ -n "$OLD" ] && [ -n "$NEWSPEC" ] || { echo "usage: trantor swap <oldAgent> <newAgent[:provider[/model]]> [--task K --difficulty D]"; exit 1; }
170
- if [ -f "$STATE" ]; then
171
- local tmp="$STATE.tmp"; : > "$tmp"
172
- while IFS=$'\t' read -r a wid; do
173
- [ -n "${wid:-}" ] || { wid="$a"; a=""; }
174
- if [ "$a" = "$OLD" ]; then
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
- printf '%s\t%s\n' "$a" "$wid" >> "$tmp"
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
- done < "$STATE"
184
- mv "$tmp" "$STATE"
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
- spawn_grid "$NEWSPEC"
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
- # spec_for_agent <agent> <spec...>: echo the FULL original spec (agent:provider[/model]) whose
198
- # agent part matches — so a retry respawns on the SAME live-selected model, not the CLI default.
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
- echo " spawning crew (serialized) "
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
- spawn_grid "$@"
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
- spawn_grid $RETRY_SPECS
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 "✗✗ CREW INCOMPLETE these agents are NOT on the bus: $STILL"
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: crew.sh down —"
529
+ echo "— crew verified on the bus. Send contracts with relay_send; runners keep agents alive for free. Teardown (this project only): trantor down —"
@@ -0,0 +1,124 @@
1
+ #!/usr/bin/env node
2
+ // trantor reconcile — INTELLIGENT board cleanup. The mechanical reaper only knows "owner offline → stale".
3
+ // reconcile reads the STUCK cards + recent git commits + project memory and, for each, asks a CHEAP model
4
+ // (Scrooge) whether it is DONE (already implemented/merged → close it so no future session re-does the work),
5
+ // truly STALE (abandoned), or still ACTIVE (leave it). The judgment is grunt classification → routed to a
6
+ // cheap model, never frontier tokens. Preview-first; changes nothing until --yes.
7
+ // trantor reconcile # preview verdicts for this project's stuck cards
8
+ // trantor reconcile --yes # apply: DONE→done, abandoned→stale, ACTIVE left alone
9
+ // trantor reconcile --older 6h # only cards untouched this long are candidates (default 2h)
10
+ // trantor reconcile --difficulty hard # spend a stronger model on the judgment (default medium)
11
+ import { readFileSync, existsSync } from "node:fs";
12
+ import { join } from "node:path";
13
+ import { homedir } from "node:os";
14
+ import { execSync, spawnSync } from "node:child_process";
15
+ import { resolveProject } from "../lib/project.mjs";
16
+
17
+ function relayUrl() {
18
+ if (process.env.RELAY_URL) return process.env.RELAY_URL;
19
+ 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 {}
20
+ return "http://127.0.0.1:4477";
21
+ }
22
+ function parseDur(s, def) {
23
+ if (!s) return def;
24
+ const m = String(s).match(/^(\d+(?:\.\d+)?)\s*(ms|s|m|h|d)?$/i);
25
+ if (!m) return def;
26
+ return Math.round(Number(m[1]) * ({ ms: 1, s: 1000, m: 60000, h: 3600000, d: 86400000 }[(m[2] || "h").toLowerCase()]));
27
+ }
28
+ const fmtAge = ms => { const m = Math.round(ms / 60000); return m >= 1440 ? `${Math.floor(m / 1440)}d` : m >= 60 ? `${Math.floor(m / 60)}h` : `${m}m`; };
29
+ const scroogeBin = () => process.env.SCROOGE_BIN
30
+ || (() => { try { return execSync("command -v scrooge", { encoding: "utf8" }).trim(); } catch {} })()
31
+ || (existsSync(new URL("../engine/bin/scrooge", import.meta.url)) ? new URL("../engine/bin/scrooge", import.meta.url).pathname : "");
32
+
33
+ const argv = process.argv.slice(2);
34
+ const has = (...f) => f.some(x => argv.includes(x));
35
+ const val = (...f) => { for (const x of f) { const i = argv.indexOf(x); if (i >= 0) return argv[i + 1]; } return undefined; };
36
+ const olderMs = parseDur(val("--older", "-o"), 2 * 3600 * 1000);
37
+ const doIt = has("--yes", "-y");
38
+ const difficulty = ["easy", "medium", "hard"].includes(val("--difficulty", "-d")) ? val("--difficulty", "-d") : "medium";
39
+ const dir = process.cwd();
40
+ const project = resolveProject(dir);
41
+ const url = relayUrl();
42
+
43
+ async function tasks() {
44
+ const r = await fetch(`${url}/tasks?project=${encodeURIComponent(project)}`, { signal: AbortSignal.timeout(6000) });
45
+ const j = await r.json();
46
+ return Array.isArray(j) ? j : (j.tasks || j.cards || []);
47
+ }
48
+ async function move(id, status) {
49
+ await fetch(`${url}/task/update`, { method: "POST", headers: { "content-type": "application/json" },
50
+ body: JSON.stringify({ id, status, by: "reconcile" }), signal: AbortSignal.timeout(4000) }).catch(() => {});
51
+ }
52
+ // the memory record for THIS project (Claude Code stores it per encoded-cwd); optional context.
53
+ function memoryExcerpt() {
54
+ try {
55
+ const p = join(homedir(), ".claude", "projects", dir.replaceAll("/", "-"), "memory", "MEMORY.md");
56
+ if (existsSync(p)) return readFileSync(p, "utf8").slice(0, 6000);
57
+ } catch {}
58
+ return "";
59
+ }
60
+
61
+ console.log(`\n🧠 trantor reconcile — "${project}" · stuck > ${fmtAge(olderMs)} · judge=scrooge/${difficulty}\n${"─".repeat(60)}`);
62
+
63
+ // candidates: real work cards (not ephemeral sub-agent infra, not the session focus card) that are stuck.
64
+ const cut = Date.now() - olderMs;
65
+ const all = await tasks().catch(e => { console.error(`could not reach hub at ${url}: ${e.message}`); process.exit(1); });
66
+ const cand = all.filter(t =>
67
+ ["todo", "doing", "testing", "stale"].includes(t.status) &&
68
+ !["cc-subagent", "cc-bg-agent", "session"].includes(t.source) &&
69
+ (t.updated || t.ts || 0) < cut);
70
+
71
+ if (!cand.length) { console.log(" nothing stuck to reconcile — the board is current.\n"); process.exit(0); }
72
+
73
+ const bin = scroogeBin();
74
+ if (!bin) { console.error(" scrooge not found (install it or set SCROOGE_BIN) — can't judge. Leaving the board untouched.\n"); process.exit(1); }
75
+
76
+ let gitlog = "";
77
+ try { gitlog = execSync(`git -C ${JSON.stringify(dir)} log --oneline -80 2>/dev/null`, { encoding: "utf8", timeout: 3000 }).trim(); } catch {}
78
+ const mem = memoryExcerpt();
79
+
80
+ const cardLines = cand.map(t => `#${t.id} [${t.status}] ${String(t.title).replace(/\s+/g, " ").slice(0, 110)} (updated ${fmtAge(Date.now() - (t.updated || t.ts || 0))} ago)`).join("\n");
81
+ const prompt = `You reconcile a Kanban board against reality. For EACH card, decide if the work is:
82
+ - "done": clearly completed/merged — a recent commit or the memory shows it shipped;
83
+ - "stale": genuinely abandoned — queued/in-progress but no evidence of completion AND clearly old/superseded;
84
+ - "active": still legitimately pending or in progress — LEAVE IT.
85
+ Be CONSERVATIVE: mark "done" ONLY with real evidence (name the matching commit sha), and when unsure use "active".
86
+ Return ONLY a JSON array, no prose: [{"id":<number>,"verdict":"done"|"stale"|"active","reason":"<=90 chars","commit":"<sha or empty>"}]
87
+
88
+ RECENT COMMITS (newest first):
89
+ ${gitlog || "(none)"}
90
+
91
+ ${mem ? `PROJECT MEMORY (durable record):\n${mem}\n` : ""}
92
+ CARDS TO JUDGE:
93
+ ${cardLines}`;
94
+
95
+ process.stdout.write(" judging with a cheap model… ");
96
+ const res = spawnSync(bin, ["-t", "reason", "-d", difficulty, "--json"], { input: prompt, encoding: "utf8", timeout: 90000 });
97
+ if (res.error || (res.status !== 0 && !res.stdout)) { console.error(`\n scrooge unavailable/failed (${res.error ? res.error.code : (res.stderr || "").slice(-200)}) — leaving the board untouched.\n`); process.exit(1); }
98
+ let verdicts = [];
99
+ try {
100
+ const out = res.stdout || "";
101
+ const s = out.indexOf("["), e = out.lastIndexOf("]");
102
+ verdicts = JSON.parse(out.slice(s, e + 1));
103
+ } catch (e) { console.error(`\n couldn't parse the judge's output — leaving the board untouched.\n raw: ${(res.stdout || "").slice(0, 300)}\n`); process.exit(1); }
104
+ console.log("done.\n");
105
+
106
+ const byId = new Map(cand.map(t => [t.id, t]));
107
+ const done = [], stale = [], active = [];
108
+ for (const v of verdicts) {
109
+ const t = byId.get(Number(v.id)); if (!t) continue;
110
+ (v.verdict === "done" ? done : v.verdict === "stale" ? stale : active).push({ ...v, t });
111
+ }
112
+ const show = (label, arr) => { if (!arr.length) return; console.log(` ${label} (${arr.length}):`); for (const x of arr) console.log(` #${x.t.id} [${x.t.status}] ${String(x.t.title).slice(0, 62)}\n → ${x.verdict}${x.commit ? ` (${x.commit})` : ""}: ${x.reason || ""}`); };
113
+ show("✓ DONE — already shipped, will close", done);
114
+ show("🗑 STALE — abandoned, will move to Stale", stale);
115
+ show("• ACTIVE — still relevant, leaving alone", active);
116
+
117
+ if (!done.length && !stale.length) { console.log("\n nothing to change — all stuck cards judged still-active.\n"); process.exit(0); }
118
+ if (!doIt) {
119
+ console.log(`\n ${done.length} card(s) → done, ${stale.length} → stale. Re-run to apply:\n trantor reconcile${val("--older", "-o") ? ` --older ${val("--older", "-o")}` : ""} --yes\n`);
120
+ process.exit(0);
121
+ }
122
+ for (const x of done) await move(x.t.id, "done");
123
+ for (const x of stale) await move(x.t.id, "stale");
124
+ console.log(`\n ✓ reconciled: ${done.length} closed as done, ${stale.length} moved to stale. ${active.length} left active.\n`);
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
+ }
@@ -179,6 +179,12 @@ try {
179
179
  if (cu.blocked?.length) additionalContext += `\n_Blocked:_\n ${sanitize(line(cu.blocked))}\n`;
180
180
  if (cu.todo?.length) additionalContext += `\n_Queued (todo):_\n ${sanitize(line(cu.todo))}\n`;
181
181
  if (cu.recentDone?.length) additionalContext += `\n_Recently done:_\n ${sanitize(line(cu.recentDone))}\n`;
182
+ // Intelligent-cleanup nudge: if the board has a pile of in-flight/stale cards, some are probably
183
+ // already shipped (a stuck card ≠ unfinished work). Point the session at `trantor reconcile`, which
184
+ // judges each stuck card against git + memory (cheap model) and closes what's done — so no session
185
+ // burns tokens re-doing finished work — and stales what's truly abandoned.
186
+ const stuck = (c.doing || 0) + (c.testing || 0) + (c.stale || 0);
187
+ if (stuck >= 4 || (c.stale || 0) > 0) additionalContext += `\n🧠 **${stuck} card(s) look stuck** (doing/testing/stale). Some may already be shipped. Run \`trantor reconcile\` — it checks git + memory (cheap model), closes anything already done so you don't re-do it, and stales what's abandoned. Preview first; \`--yes\` applies.\n`;
182
188
  }
183
189
  if (gitlog) additionalContext += `\n**Recent commits:**\n\`\`\`\n${sanitize(gitlog)}\n\`\`\`\n`;
184
190
  additionalContext += `\nFor a synthesized "where are we" narrative on demand, run \`trantor catchup\`.\n`;
package/hub.mjs CHANGED
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trantor",
3
- "version": "0.17.49",
3
+ "version": "0.17.51",
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 && node test-reconcile.mjs && 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(5,1fr);gap:10px;padding:12px 16px}
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=>({'&':'&amp;','<':'&lt;','>':'&gt;',
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
  }