trantor 0.18.6 → 0.18.8

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trantor",
3
- "version": "0.18.6",
3
+ "version": "0.18.8",
4
4
  "description": "Trantor \u2014 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": {
@@ -9,7 +9,7 @@
9
9
  // agent arrives it RESUMES the CLI session (native resume = full context kept) with that
10
10
  // message as the prompt. The model just works and ends its turn; the runner does the rest.
11
11
  import { execSync, spawnSync } from "node:child_process";
12
- import { readFileSync, writeFileSync, unlinkSync, existsSync, appendFileSync } from "node:fs";
12
+ import { readFileSync, writeFileSync, unlinkSync, existsSync, appendFileSync, mkdirSync } from "node:fs";
13
13
  import { join, basename } from "node:path";
14
14
  import { homedir } from "node:os";
15
15
  import { resolveProject, resolveHub, withEnvFiles } from "../lib/project.mjs";
@@ -24,6 +24,41 @@ const DIR = process.argv[3] || process.cwd();
24
24
  // back to the git-repo-root basename — never a loose dir basename that could
25
25
  // fork the host's "builtbetter.ai" into a separate "builtbetter" lane.
26
26
  const PROJ = process.env.RELAY_PROJECT || resolveProject(DIR);
27
+
28
+ function safePathSegment(s) {
29
+ return String(s).replace(/\.{2,}/g, "_").replace(/[^A-Za-z0-9_.-]/g, "_");
30
+ }
31
+
32
+ function gitOut(args, cwd = DIR) {
33
+ const r = spawnSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 8000 });
34
+ return r.status === 0 ? String(r.stdout || "").trim() : "";
35
+ }
36
+
37
+ function ensureSeatWorktree(sourceDir) {
38
+ if (process.env.TRANTOR_NO_WORKTREE === "1") return sourceDir;
39
+ const root = gitOut(["-C", sourceDir, "rev-parse", "--show-toplevel"], sourceDir);
40
+ if (!root) return sourceDir;
41
+
42
+ spawnSync("git", ["-C", root, "worktree", "prune"], { stdio: "ignore", timeout: 8000 });
43
+ const seatDir = join(homedir(), ".agent-bus", "worktrees", safePathSegment(PROJ), safePathSegment(AGENT));
44
+ const branch = `seat/${AGENT}`;
45
+ if (existsSync(seatDir)) {
46
+ const ok = gitOut(["-C", seatDir, "rev-parse", "--is-inside-work-tree"], seatDir) === "true";
47
+ if (ok) return seatDir;
48
+ console.log(`\x1b[33m[runner]\x1b[0m worktree path exists but is not a git worktree: ${seatDir} — using ${sourceDir}`);
49
+ return sourceDir;
50
+ }
51
+
52
+ try { mkdirSync(join(homedir(), ".agent-bus", "worktrees", safePathSegment(PROJ)), { recursive: true }); } catch {}
53
+ const r = spawnSync("git", ["-C", root, "worktree", "add", "-B", branch, seatDir, "HEAD"], {
54
+ encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 30000,
55
+ });
56
+ if (r.status === 0) return seatDir;
57
+ console.log(`\x1b[33m[runner]\x1b[0m could not create ${branch} worktree — using ${sourceDir}`);
58
+ return sourceDir;
59
+ }
60
+
61
+ const TURN_DIR = ensureSeatWorktree(DIR);
27
62
  // RUNNER_SESSION override: an orchestrator seat (bin/orchestrate.mjs) runs the same CLI as a crew
28
63
  // seat but must live on the bus under its own name (claude-orch:proj), or it would collide with a
29
64
  // plain claude crew seat on the same project.
@@ -53,7 +88,6 @@ process.on("uncaughtException", (e) => { console.log(`\x1b[31m[runner] UNCAUGHT:
53
88
  process.on("unhandledRejection", (e) => { console.log(`\x1b[31m[runner] UNHANDLED REJECTION: ${e?.stack || e}\x1b[0m`); });
54
89
  const log = (s) => console.log(`\x1b[38;5;43m[runner]\x1b[0m ${s}`);
55
90
  const LOGDIR = join(homedir(), ".agent-bus", "logs");
56
- import { mkdirSync } from "node:fs";
57
91
  try { mkdirSync(LOGDIR, { recursive: true }); } catch {}
58
92
  let TURN = 0;
59
93
  const telemetry = (rec) => { try { appendFileSync(join(LOGDIR, `${AGENT}-${PROJ}.jsonl`), JSON.stringify(rec) + "\n"); } catch {} };
@@ -278,7 +312,9 @@ async function notifyAssigners(pairs, text) {
278
312
  seen.add(f);
279
313
  // `re` threads this outcome to the exact contract it answers, so the sender's ledger closes the
280
314
  // right one instead of guessing from timing.
281
- await api("/send", { from: SESSION, to: f, text: text.slice(0, 280), project: PROJ, ...(id ? { re: id } : {}) }).catch(() => {});
315
+ const payload = { from: SESSION, to: f, text: text.slice(0, 280), project: PROJ };
316
+ if (id) payload.re = id;
317
+ await api("/send", payload).catch(() => {});
282
318
  }
283
319
  if (seen.size) log(`reported outcome to ${[...seen].join(", ")}`);
284
320
  }
@@ -325,8 +361,19 @@ function runTurn(prompt, isFirst, trigger = "kickoff") {
325
361
  // substitution) so bash waits for tee to flush before we read the file back.
326
362
  const inner = cli.sid ? `${cmd} | tee /dev/stderr` : `${cmd} | tee -a ${ERRF}`;
327
363
  const r = spawnSync("/bin/bash", ["-c", `set -o pipefail; { ${inner} ; } 2> >(tee -a ${ERRF} >&2)`], {
328
- cwd: DIR, encoding: "utf8", stdio: cli.sid ? ["ignore", "pipe", "inherit"] : "inherit",
329
- env: { ...process.env, RELAY_URL: HUB, RELAY_AGENT: AGENT, RELAY_PROJECT: PROJ },
364
+ cwd: TURN_DIR, encoding: "utf8", stdio: cli.sid ? ["ignore", "pipe", "inherit"] : "inherit",
365
+ env: { ...process.env, RELAY_URL: HUB, RELAY_AGENT: AGENT, RELAY_PROJECT: PROJ,
366
+ // A RUNNER-MANAGED SEAT MUST NEVER HAND ITSELF A BATON.
367
+ //
368
+ // The handoff machinery exists for an INTERACTIVE session: near its context limit it writes a
369
+ // handoff and opens a fresh window to carry on. A seat has no use for that — the runner is its
370
+ // lifecycle manager and wakes it per event — so the spawn just leaks an unmanaged interactive
371
+ // session into a window nobody asked for.
372
+ //
373
+ // Observed on the duty seat: handoff records at 17:24 and 18:59 on 2026-08-24, and two stray
374
+ // `claude` processes in ~/.agent-bus/trantor-duty started at 17:24:57 and 18:59:50, still
375
+ // sitting there days later. To the operator that reads as "why are there two duty agents".
376
+ TRANTOR_NO_HANDOFF_SPAWN: "1", TRANTOR_NO_BATON_SPAWN: "1" },
330
377
  maxBuffer: 16 * 1024 * 1024,
331
378
  });
332
379
  try { lastErrText = readFileSync(ERRF, "utf8").slice(-4000); } catch { lastErrText = ""; }
package/bin/crew.sh CHANGED
@@ -46,11 +46,19 @@ HAVE_TMUX=0; command -v tmux >/dev/null 2>&1 && HAVE_TMUX=1
46
46
  # Events) — NOT the control socket — so it needs NO socket password (the socket denies external processes
47
47
  # by default; AppleScript bypasses that, exactly like we already script Terminal.app).
48
48
  HAVE_CMUX=0; [ -d "/Applications/cmux.app" ] && HAVE_CMUX=1
49
- # explicit override (user preference or tests): CREW_MUX=cmux|tmux|terminal forces the grouping UI.
49
+ # herdr (https://herdr.dev a server-held terminal runtime: panes live in its background server, so a
50
+ # crew survives the launcher exiting) is OPT-IN ONLY, never auto-detected — a machine with herdr
51
+ # installed keeps its default cmux→tmux→terminal dispatch untouched. CREW_MUX=herdr is the explicit
52
+ # switch; opting in without the binary is a HARD ERROR, because a silent fallback would defeat the ask.
53
+ HAVE_HERDR=0
54
+ # explicit override (user preference or tests): CREW_MUX=cmux|tmux|terminal|herdr forces the grouping UI.
50
55
  case "${CREW_MUX:-}" in
51
56
  cmux) HAVE_CMUX=1; HAVE_TMUX=0 ;;
52
57
  tmux) HAVE_CMUX=0; HAVE_TMUX=1 ;;
53
58
  terminal) HAVE_CMUX=0; HAVE_TMUX=0 ;;
59
+ herdr) HAVE_CMUX=0; HAVE_TMUX=0
60
+ command -v herdr >/dev/null 2>&1 || { echo "CREW_MUX=herdr but herdr is not installed — user-local (no sudo): curl -fsSL https://herdr.dev/install.sh | sh — see https://herdr.dev"; exit 1; }
61
+ HAVE_HERDR=1 ;;
54
62
  esac
55
63
  SEATDIR="$HOME/.agent-bus/seats"; mkdir -p "$SEATDIR"
56
64
  DRY="${CREW_DRY_RUN:-0}"
@@ -136,6 +144,137 @@ end tell
136
144
  OSA
137
145
  }
138
146
 
147
+ # ── herdr helpers (active ONLY under the explicit CREW_MUX=herdr opt-in) ────────────────────────────
148
+ # herdr's creation commands print JSON ids — capture them, never predict (herdr.dev/docs). All parsing
149
+ # tolerates a bare array or a .result wrapper, and a failed/empty parse yields "" (the caller records
150
+ # the empty handle and crew-verify flags the dead seat on the bus — the spawn is not the truth).
151
+ _herdr() { herdr "$@"; }
152
+ _herdr_ws_create() { # $1=cwd $2=label → "workspace_id<TAB>root_pane_id"
153
+ _herdr workspace create --cwd "$1" --label "$2" --no-focus 2>/dev/null | node -e '
154
+ let d="";process.stdin.on("data",c=>d+=c).on("end",()=>{try{const r=(JSON.parse(d.slice(d.search(/[\[{]/))).result)||{};
155
+ process.stdout.write((((r.workspace||{}).workspace_id)||"")+"\t"+(((r.root_pane||{}).pane_id)||""))}catch(e){}})'
156
+ }
157
+ _herdr_split() { # $1=pane id ("" = UI-focused pane) $2=right|down → new pane id
158
+ local a=(pane split); [ -n "$1" ] && a+=("$1"); a+=(--direction "$2" --no-focus)
159
+ _herdr "${a[@]}" 2>/dev/null | node -e '
160
+ let d="";process.stdin.on("data",c=>d+=c).on("end",()=>{try{const r=(JSON.parse(d.slice(d.search(/[\[{]/))).result)||{};
161
+ process.stdout.write(((r.pane||{}).pane_id)||"")}catch(e){}})'
162
+ }
163
+ _herdr_ws_live() { # workspace list → "ids<TABnewline>names" (names \x01-wrapped+joined, like cmux)
164
+ _herdr workspace list 2>/dev/null | node -e '
165
+ let d="";process.stdin.on("data",c=>d+=c).on("end",()=>{try{const o=JSON.parse(d.slice(d.search(/[\[{]/)));
166
+ const a=Array.isArray(o)?o:(o.workspaces||((o.result||{}).workspaces)||[]);
167
+ console.log(a.map(x=>x.workspace_id||x.id||"").filter(Boolean).join(" "));
168
+ console.log("\u0001"+a.map(x=>x.label||x.name||x.custom_title||"").filter(Boolean).join("\u0001")+"\u0001")}catch(e){}})'
169
+ }
170
+ # Teardown works regardless of CREW_MUX: you must never need to remember the flag to tear a crew down.
171
+ _herdr_close_ws() { [ "$DRY" = "1" ] && { echo "[dry] herdr workspace close $1"; return 0; }; _herdr workspace close "$1" >/dev/null 2>&1; }
172
+ _herdr_close_pane() { [ "$DRY" = "1" ] && { echo "[dry] herdr pane close $1"; return 0; }; _herdr pane close "$1" >/dev/null 2>&1; }
173
+
174
+ # ── herdr spawn: ONE workspace `trantor:$PROJ`, one named pane per seat ─────────────────────────────
175
+ # Topology = workspace create (its root pane runs seat 1) + pane split for the rest, same ceil(√N) grid
176
+ # doctrine as cmux; pane rename labels each seat; pane run submits the seat command (bracketed-paste
177
+ # safe, sends Enter). Same replace-never-stack doctrine: REUSE the newest tracked workspace for this
178
+ # project, close older stacked ones, adopt one untracked live trantor:$PROJ workspace.
179
+ spawn_herdr() { # $@ = specs
180
+ local REUSE_WS="" line w
181
+ local stale_ws=()
182
+ if [ -f "$STATE" ]; then
183
+ while IFS= read -r line; do
184
+ [ -n "$line" ] || continue
185
+ _parse_row "$line"
186
+ { [ "$RP" = "$PROJ" ] && [ "$RK" = "herdrws" ]; } || continue
187
+ [ -n "$REUSE_WS" ] && stale_ws+=("$REUSE_WS")
188
+ REUSE_WS="$RH"
189
+ done < "$STATE"
190
+ fi
191
+ if [ "${#stale_ws[@]}" -gt 0 ]; then
192
+ for w in "${stale_ws[@]}"; do
193
+ echo " → closing stale stacked crew workspace for $PROJ ($w)"
194
+ _herdr_close_ws "$w"; _state_drop "$PROJ" "herdrws" "" "$w"
195
+ done
196
+ prune_dead_state # the closed workspaces' seat rows just died with them
197
+ fi
198
+ # Untracked strays: LIVE workspaces labeled trantor:$proj that STATE doesn't know. Adopt one as the
199
+ # reuse target if we have none; close the rest. (Skipped in DRY: read-only drills seed STATE instead.)
200
+ if [ "$DRY" != "1" ]; then
201
+ local named nid
202
+ named="$(_herdr workspace list 2>/dev/null | WSNAME="trantor:$PROJ" node -e 'let d="";process.stdin.on("data",c=>d+=c).on("end",()=>{try{const o=JSON.parse(d.slice(d.search(/[\[{]/)));const a=Array.isArray(o)?o:(o.workspaces||((o.result||{}).workspaces)||[]);console.log(a.filter(x=>x.label===process.env.WSNAME||x.name===process.env.WSNAME||x.custom_title===process.env.WSNAME).map(x=>x.workspace_id||x.id||"").filter(Boolean).join(" "))}catch(e){}})')"
203
+ for nid in $named; do
204
+ [ "$nid" = "$REUSE_WS" ] && continue
205
+ [ -f "$STATE" ] && grep -qF "$nid" "$STATE" && continue # tracked → handled above
206
+ if [ -z "$REUSE_WS" ]; then
207
+ echo " → adopting existing untracked crew workspace for $PROJ ($nid)"
208
+ REUSE_WS="$nid"; record_state "$PROJ" "herdrws" "__ws__" "$nid"
209
+ else
210
+ echo " → closing stray crew workspace for $PROJ ($nid)"
211
+ _herdr_close_ws "$nid"
212
+ fi
213
+ done
214
+ fi
215
+ # Grid tiling (identical math to cmux): row 0 splits RIGHT off the previous column, later rows split
216
+ # DOWN from the pane directly above. In REUSE mode a replaced seat splits off its OWN old pane —
217
+ # keeping its spot — and added seats split right off the previous new pane.
218
+ local N=$# COLS=1; while [ $(( COLS * COLS )) -lt "$N" ]; do COLS=$(( COLS + 1 )); done
219
+ local SPEC wsid="" surf i=0
220
+ local surfs=()
221
+ [ -n "$REUSE_WS" ] && wsid="$REUSE_WS"
222
+ for SPEC in "$@"; do
223
+ resolve_spec "$SPEC"
224
+ local cmd; cmd="$(RUN_CMD)"
225
+ if [ -n "$REUSE_WS" ]; then
226
+ # replace-in-place: split the fresh pane FIRST (targeting the agent's old pane when tracked),
227
+ # then close the old pane — split-first so the workspace never dips to zero panes mid-swap.
228
+ local OLD_SURF=""
229
+ if [ -f "$STATE" ]; then
230
+ while IFS= read -r line; do
231
+ [ -n "$line" ] || continue
232
+ _parse_row "$line"
233
+ [ "$RP" = "$PROJ" ] && [ "$RK" = "herdr" ] && [ "$RA" = "$AGENT" ] && OLD_SURF="$RH"
234
+ done < "$STATE"
235
+ fi
236
+ local t=""
237
+ [ -n "$OLD_SURF" ] && t="$OLD_SURF"
238
+ { [ -z "$t" ] && [ "$i" -gt 0 ]; } && t="${surfs[$(( i - 1 ))]}"
239
+ if [ "$DRY" = "1" ]; then
240
+ echo "[dry] herdr: reuse workspace $wsid — pane split for $AGENT${OLD_SURF:+ (replacing $OLD_SURF)}"
241
+ surf="%DRYT$i"
242
+ [ -n "$OLD_SURF" ] && _herdr_close_pane "$OLD_SURF"
243
+ else
244
+ surf="$(_herdr_split "$t" right)"
245
+ [ -n "$surf" ] && { _herdr pane rename "$surf" "$AGENT · $PROJ" >/dev/null 2>&1; _herdr pane run "$surf" "$cmd" >/dev/null 2>&1; }
246
+ if [ -n "$OLD_SURF" ]; then _herdr_close_pane "$OLD_SURF"; _state_drop "$PROJ" "herdr" "$AGENT" ""; fi
247
+ fi
248
+ elif [ "$i" = "0" ]; then
249
+ if [ "$DRY" = "1" ]; then
250
+ echo "[dry] herdr: workspace create (cwd $DIR) --label 'trantor:$PROJ' → root pane + run '$cmd'"
251
+ wsid="%DRYWS"; surf="%DRYT0"
252
+ else
253
+ local pair; pair="$(_herdr_ws_create "$DIR" "trantor:$PROJ")"
254
+ wsid="${pair%%$'\t'*}"; surf="${pair##*$'\t'}"
255
+ [ -n "$surf" ] && { _herdr pane rename "$surf" "$AGENT · $PROJ" >/dev/null 2>&1; _herdr pane run "$surf" "$cmd" >/dev/null 2>&1; }
256
+ fi
257
+ record_state "$PROJ" "herdrws" "__ws__" "$wsid"
258
+ else
259
+ local dir target
260
+ if [ $(( i / COLS )) = "0" ]; then dir="right"; target="${surfs[$(( i - 1 ))]}"
261
+ else dir="down"; target="${surfs[$(( i - COLS ))]}"; fi
262
+ if [ "$DRY" = "1" ]; then
263
+ echo "[dry] herdr: pane split ${target:-<focused>} --direction $dir + run '$cmd'"
264
+ surf="%DRYT$i"
265
+ else
266
+ surf="$(_herdr_split "$target" "$dir")"
267
+ [ -n "$surf" ] && { _herdr pane rename "$surf" "$AGENT · $PROJ" >/dev/null 2>&1; _herdr pane run "$surf" "$cmd" >/dev/null 2>&1; }
268
+ fi
269
+ fi
270
+ surfs+=("$surf")
271
+ record_state "$PROJ" "herdr" "$AGENT" "$surf"
272
+ echo " → $AGENT seat in herdr workspace ($PROJ)"
273
+ i=$(( i + 1 ))
274
+ done
275
+ echo "— crew grouped in herdr: ONE workspace for $PROJ, seats as named panes in its server. Teardown (this project only): trantor down —"
276
+ }
277
+
139
278
  # surgical STATE row removal: drop every row matching PROJECT+KIND (+AGENT/+HANDLE when given).
140
279
  # Empty $3/$4 = wildcard. Callers beware: uses _parse_row, which clobbers the RP/RK/RA/RH globals.
141
280
  _state_drop() { # $1=project $2=kind $3=agent(''=any) $4=handle(''=any)
@@ -202,7 +341,7 @@ down() {
202
341
  [ "$match" = "1" ] || continue
203
342
  fi
204
343
  scoped+=("$RP|$RK|$RA|$RH")
205
- case "$RK" in attach|cmuxws) continue ;; esac # infra rows (attach window / cmux workspace) — not seats
344
+ case "$RK" in attach|cmuxws|herdrws) continue ;; esac # infra rows (attach/cmux/herdr workspace) — not seats
206
345
  killlist="$killlist • ${RP:-<legacy>} · $RA ($RK)"$'\n'
207
346
  done < "$STATE"
208
347
 
@@ -220,6 +359,8 @@ down() {
220
359
  case "$K2" in
221
360
  cmuxws) [ "${#AGENTS[@]}" -gt 0 ] || _cmux_close_tab "$H2" ;; # whole workspace (no specific seats)
222
361
  cmux) [ "${#AGENTS[@]}" -gt 0 ] && _cmux_close_term "$H2" ;; # a single seat's pane
362
+ herdrws) [ "${#AGENTS[@]}" -gt 0 ] || _herdr_close_ws "$H2" ;; # whole workspace (no specific seats)
363
+ herdr) [ "${#AGENTS[@]}" -gt 0 ] && _herdr_close_pane "$H2" ;;# a single seat's pane
223
364
  tmux)
224
365
  if [ "${#AGENTS[@]}" -gt 0 ]; then run "tmux kill-pane -t '$H2' 2>/dev/null" # per-seat
225
366
  else
@@ -228,7 +369,7 @@ down() {
228
369
  fi ;;
229
370
  win|attach) { [ "${#AGENTS[@]}" -gt 0 ] && [ "$K2" = "attach" ]; } && continue; _kill_win "$H2" ;;
230
371
  esac
231
- case "$K2" in cmuxws|attach) : ;; *) _kill_seat_procs "$P2" "$A2" ;; esac
372
+ case "$K2" in cmuxws|attach|herdrws) : ;; *) _kill_seat_procs "$P2" "$A2" ;; esac
232
373
  done
233
374
 
234
375
  # rewrite STATE minus the rows we tore down (leaves OTHER projects' rows intact)
@@ -268,6 +409,15 @@ prune_dead_state() {
268
409
  CLIVE="$(printf '%s' "$pair" | sed -n 1p)"
269
410
  CLIVE_NAMES="$(printf '%s' "$pair" | sed -n 2p)"
270
411
  fi
412
+ # herdr liveness is queried ONLY under the explicit opt-in (CREW_MUX=herdr): a default run never
413
+ # invokes herdr at all. Same keep-when-unprovable doctrine as cmux — no answer ⇒ rows are KEPT.
414
+ local HLIVE="" HLIVE_NAMES=""
415
+ if [ "$HAVE_HERDR" = "1" ]; then
416
+ local hpair
417
+ hpair="$(_herdr_ws_live)"
418
+ HLIVE="$(printf '%s' "$hpair" | sed -n 1p)"
419
+ HLIVE_NAMES="$(printf '%s' "$hpair" | sed -n 2p)"
420
+ fi
271
421
  local tmp="$STATE.tmp" line alive
272
422
  : > "$tmp"
273
423
  while IFS= read -r line; do
@@ -283,6 +433,12 @@ prune_dead_state() {
283
433
  elif [ "$RK" = "cmux" ]; then
284
434
  # seat row lives exactly as long as its project still has a live crew workspace
285
435
  if [ -n "$CLIVE" ]; then case "$CLIVE_NAMES" in *$'\x01'"trantor:$RP"$'\x01'*) : ;; *) alive=0 ;; esac; fi
436
+ elif [ "$RK" = "herdrws" ]; then
437
+ if [ -n "$HLIVE" ]; then case " $HLIVE " in *" $RH "*) : ;; *) alive=0 ;; esac; fi
438
+ elif [ "$RK" = "herdr" ]; then
439
+ # seat row lives exactly as long as its project still has a live crew workspace — validated at
440
+ # WORKSPACE granularity, never per pane (the cmux 0.17.61 lesson generalized)
441
+ if [ -n "$HLIVE" ]; then case "$HLIVE_NAMES" in *$'\x01'"trantor:$RP"$'\x01'*) : ;; *) alive=0 ;; esac; fi
286
442
  fi
287
443
  [ "$alive" = "1" ] && printf '%s\t%s\t%s\t%s\n' "$RP" "$RK" "$RA" "$RH" >> "$tmp"
288
444
  done < "$STATE"
@@ -690,8 +846,9 @@ OSA
690
846
  echo "— crew grouped in cmux (AppleScript): ONE workspace tab for $PROJ, seats tiled. Teardown: trantor down —"
691
847
  }
692
848
 
693
- spawn_crew() { # dispatch: cmux (preferred) → tmux → Terminal grid
694
- if [ "$HAVE_CMUX" = "1" ]; then spawn_cmux "$@"
849
+ spawn_crew() { # dispatch: herdr (explicit opt-in) → cmux → tmux → Terminal grid
850
+ if [ "$HAVE_HERDR" = "1" ]; then spawn_herdr "$@"
851
+ elif [ "$HAVE_CMUX" = "1" ]; then spawn_cmux "$@"
695
852
  elif [ "$HAVE_TMUX" = "1" ]; then spawn_tmux "$@"
696
853
  else
697
854
  echo "— no cmux/tmux → per-agent Terminal windows. For ONE grouped, named window per crew (and"
@@ -729,7 +886,7 @@ fi
729
886
 
730
887
  spec_for_agent() { local want="$1"; shift; local s; for s in "$@"; do [ "${s%%:*}" = "$want" ] && { printf '%s' "$s"; return; }; done; printf '%s' "$want"; }
731
888
 
732
- CREW_UI="Terminal windows"; [ "$HAVE_TMUX" = "1" ] && CREW_UI="tmux"; [ "$HAVE_CMUX" = "1" ] && CREW_UI="cmux"
889
+ CREW_UI="Terminal windows"; [ "$HAVE_TMUX" = "1" ] && CREW_UI="tmux"; [ "$HAVE_CMUX" = "1" ] && CREW_UI="cmux"; [ "$HAVE_HERDR" = "1" ] && CREW_UI="herdr"
733
890
  echo "— bringing up crew for $PROJ ($CREW_UI) —"
734
891
  SPAWN_EPOCH=$(epoch_ms)
735
892
  spawn_crew "$@"
package/bin/duty.mjs CHANGED
@@ -116,6 +116,47 @@ function alivePid() {
116
116
  return 0;
117
117
  }
118
118
 
119
+ // The cmux workspace this seat owns. One name, so `up` can find and replace its predecessor and
120
+ // `down` can take the surface away with the process.
121
+ const CMUX_WS_NAME = "trantor-duty";
122
+
123
+ // Surface override, same variable and same values bin/crew.sh already uses for crew seats:
124
+ // CREW_MUX=terminal force a Terminal window (what the window-content drills assert on)
125
+ // CREW_MUX=cmux require cmux
126
+ // unset / anything else = auto: cmux when it answers, Terminal otherwise.
127
+ const SURFACE = String(process.env.CREW_MUX || "auto").toLowerCase();
128
+
129
+ function cmuxBinary() {
130
+ if (SURFACE === "terminal") return "";
131
+ for (const c of ["cmux", "/Applications/cmux.app/Contents/Resources/bin/cmux"]) {
132
+ try { execSync(`${c} ping`, { stdio: "ignore", timeout: 3000 }); return c; } catch {}
133
+ }
134
+ return "";
135
+ }
136
+
137
+ /** Close every workspace this seat owns. No-op when cmux is absent or its socket is off. */
138
+ function closeDutyWorkspace() {
139
+ const bin = cmuxBinary();
140
+ if (!bin) return 0;
141
+ let closed = 0;
142
+ try {
143
+ const listed = JSON.parse(execSync(`${bin} workspace list --id-format both --json`,
144
+ { encoding: "utf8", timeout: 5000, env: { ...process.env, CMUX_QUIET: "1" } }));
145
+ for (const w of listed.workspaces || []) {
146
+ const title = w.custom_title || w.title || "";
147
+ // Title AND directory. Matching on title alone makes this global: a duty instance running
148
+ // with a temp HOME (which is exactly what test-duty-seat.mjs does) would close the REAL
149
+ // seat's workspace and take the production seat down with it. That happened once, on
150
+ // 2026-08-26, and the operator found their duty agent simply gone. Only ever close a
151
+ // workspace that belongs to THIS seat's bus directory.
152
+ if (title === CMUX_WS_NAME && w.id && w.current_directory === DIR) {
153
+ try { execSync(`${bin} close-workspace --workspace ${w.id}`, { stdio: "ignore", timeout: 5000 }); closed++; } catch {}
154
+ }
155
+ }
156
+ } catch {}
157
+ return closed;
158
+ }
159
+
119
160
  if (cmd === "up") {
120
161
  const hub = fleetHub();
121
162
  mkdirSync(DIR, { recursive: true });
@@ -144,9 +185,35 @@ if (cmd === "up") {
144
185
  const exports = Object.entries(env).map(([k, v]) =>
145
186
  `export ${k}=$(cat <<'TRANTOR_${k}_EOF'\n${v}\nTRANTOR_${k}_EOF\n)`).join("\n");
146
187
  writeFileSync(launcher, `#!/bin/bash\n# written by \`trantor duty up\` — safe to delete when the seat is down\n${exports}\nexec ${JSON.stringify(process.execPath)} ${JSON.stringify(join(ROOT, "bin", "crew-runner.mjs"))} ${AGENT} ${JSON.stringify(DIR)}\n`, { mode: 0o700 });
147
- const osa = `tell application "Terminal"\n do script ${JSON.stringify(`bash ${launcher}`)}\n activate\nend tell\n`;
148
- try { execSync(`osascript -e ${JSON.stringify(osa)}`, { stdio: "ignore", timeout: 8000 }); }
149
- catch (e) { console.error(`could not open a window (${e?.message || e}) falling back to headless`); }
188
+ // PREFER CMUX. Terminal.app was the only surface here, and a plain window is stacking by
189
+ // construction: every `duty up` opens another one and nothing closes the last, so restarts
190
+ // accumulate windows that all look like live duty agents. cmux gives the seat ONE named
191
+ // workspace that gets REPLACED on each up — the same "replace, never stack" rule bin/crew.sh
192
+ // already applies to crew seats, which is why they never pile up and this did.
193
+ //
194
+ // Terminal remains the fallback: no cmux, or its control socket off, and nothing changes.
195
+ const cmuxBin = cmuxBinary();
196
+
197
+ let openedInCmux = false;
198
+ if (cmuxBin) {
199
+ try {
200
+ // Replace, never stack: take the previous duty workspace away before opening this one.
201
+ // Closing first is safe here (unlike a crew pane swap) — the seat is a single surface with
202
+ // nothing to preserve.
203
+ closeDutyWorkspace();
204
+ execSync(`${cmuxBin} new-workspace --name ${JSON.stringify(CMUX_WS_NAME)} --cwd ${JSON.stringify(DIR)} --command ${JSON.stringify(`bash ${launcher}`)} --focus false`,
205
+ { stdio: "ignore", timeout: 8000, env: { ...process.env, CMUX_QUIET: "1" } });
206
+ openedInCmux = true;
207
+ } catch (e) {
208
+ console.error(`cmux launch failed (${e?.message || e}) — falling back to a Terminal window`);
209
+ }
210
+ }
211
+
212
+ if (!openedInCmux) {
213
+ const osa = `tell application "Terminal"\n do script ${JSON.stringify(`bash ${launcher}`)}\n activate\nend tell\n`;
214
+ try { execSync(`osascript -e ${JSON.stringify(osa)}`, { stdio: "ignore", timeout: 8000 }); }
215
+ catch (e) { console.error(`could not open a window (${e?.message || e}) — falling back to headless`); }
216
+ }
150
217
  // The runner lives inside Terminal, so its pid is not ours to know: find it the same way `down`
151
218
  // does. Poll briefly, since Terminal takes a moment to start the shell.
152
219
  for (let i = 0; i < 25 && !pid; i++) {
@@ -163,7 +230,7 @@ if (cmd === "up") {
163
230
  pid = child.pid;
164
231
  }
165
232
  writeFileSync(PIDF, String(pid));
166
- console.log(`— duty agent up: ${SESSION} (pid ${pid})${WINDOW ? " in a Terminal window" : " headless"} on ${DUTY_MODEL === "inherit" ? "the CLI default model" : DUTY_MODEL} watching ${hub} — log: ${LOGF}`);
233
+ console.log(`— duty agent up: ${SESSION} (pid ${pid})${WINDOW ? " in a window" : " headless"} on ${DUTY_MODEL === "inherit" ? "the CLI default model" : DUTY_MODEL} watching ${hub} — log: ${LOGF}`);
167
234
  const fed = await registerDutySeat(hub, SESSION);
168
235
  if (fed) console.log(` hub feeds it: undelivered DMs (>${Math.round(Number(process.env.RELAY_DUTY_UNDELIVERED_MS || 600000) / 60000)}m) + overseer warnings.`);
169
236
  process.exit(0); // the seat IS up; a hub that won't feed it is a warning, not a failed start
@@ -174,6 +241,10 @@ if (cmd === "down") {
174
241
  if (pid) { try { process.kill(pid); } catch {} console.log(`— duty seat stopped (pid ${pid}) —`); }
175
242
  else console.log("no duty seat running");
176
243
  try { execSync(`pkill -f "crew-runner.mjs ${AGENT} ${DIR}"`, { stdio: "ignore" }); } catch {}
244
+ // Close the seat's cmux workspace too. Killing the process leaves the surface behind, and a dead
245
+ // pane titled trantor-duty is indistinguishable from a live one at a glance — which is the exact
246
+ // confusion this whole change is about.
247
+ closeDutyWorkspace();
177
248
  try { rmSync(PIDF, { force: true }); } catch {}
178
249
  // Clear the hub's pointer too — escalations aimed at a seat that no longer exists are messages
179
250
  // sent into a hole, and the hub has no other way to learn the seat went away.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trantor",
3
- "version": "0.18.6",
3
+ "version": "0.18.8",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "trantor": "bin/cli.mjs"
@@ -11,7 +11,7 @@
11
11
  "zod": "^4.4.3"
12
12
  },
13
13
  "scripts": {
14
- "test": "node bin/slop-gate.mjs --surface desktop/src && node test.mjs && node test-scenarios.mjs && node test-failure.mjs && node test-redelivery.mjs && node test-crew-completion.mjs && node test-contracts.mjs && node test-handoff.mjs && node test-handoff-summarizer.mjs && node test-baton-turn-boundary.mjs && node test-agents.mjs && node test-update.mjs && node test-handoff-guard.mjs && node test-baton-latest.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-cardlog.mjs && node test-relay-note.mjs && node test-reaper.mjs && node test-events.mjs && node test-proposals.mjs && node test-scrub.mjs && node test-store-delta.mjs && node test-message-re.mjs && node test-claims.mjs && node test-adopt.mjs && node test-summarize.mjs && node test-identity-core.mjs && node test-identity.mjs && node test-identity-instances.mjs && node test-duty.mjs && node test-duty-seat.mjs && node test-discovery.mjs && node test-doctor.mjs && node test-crew-env.mjs && node test-desktop-transport.mjs && node test-splitbrain.mjs && node test-overseer.mjs && node test-overseer-lib.mjs && node test-overseer-warn.mjs && node test-provider-keys.mjs && node test-inbox-delivery.mjs && node test-identity-drift.mjs && node test-inbox-staleness.mjs && node test-seats.mjs && node test-seat-identity.mjs && node test-hub-routing.mjs && node test-hook-routing.mjs && node test-relay-wait.mjs && node test-dsh-seat.mjs && node test-kimi-bridge.mjs && node test-kimi-events.mjs && python3 engine/test-routing.py && node test-reconcile.mjs && node test-resources.mjs && node test-patrol.mjs && node test-bridge.mjs && bash test-crew.sh"
14
+ "test": "node bin/slop-gate.mjs --surface desktop/src && node test.mjs && node test-scenarios.mjs && node test-failure.mjs && node test-redelivery.mjs && node test-crew-completion.mjs && node test-contracts.mjs && node test-handoff.mjs && node test-handoff-summarizer.mjs && node test-baton-turn-boundary.mjs && node test-agents.mjs && node test-update.mjs && node test-handoff-guard.mjs && node test-baton-latest.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-cardlog.mjs && node test-relay-note.mjs && node test-reaper.mjs && node test-events.mjs && node test-proposals.mjs && node test-scrub.mjs && node test-store-delta.mjs && node test-message-re.mjs && node test-claims.mjs && node test-adopt.mjs && node test-summarize.mjs && node test-identity-core.mjs && node test-identity.mjs && node test-identity-instances.mjs && node test-duty.mjs && node test-duty-seat.mjs && node test-discovery.mjs && node test-doctor.mjs && node test-crew-env.mjs && node test-desktop-transport.mjs && node test-crew-worktree.mjs && bash test-crew-herdr.sh && npm --prefix desktop run test --silent && node test-splitbrain.mjs && node test-overseer.mjs && node test-overseer-lib.mjs && node test-overseer-warn.mjs && node test-provider-keys.mjs && node test-inbox-delivery.mjs && node test-identity-drift.mjs && node test-inbox-staleness.mjs && node test-seats.mjs && node test-seat-identity.mjs && node test-hub-routing.mjs && node test-hook-routing.mjs && node test-relay-wait.mjs && node test-dsh-seat.mjs && node test-kimi-bridge.mjs && node test-kimi-events.mjs && python3 engine/test-routing.py && node test-reconcile.mjs && node test-resources.mjs && node test-patrol.mjs && node test-bridge.mjs && bash test-crew.sh"
15
15
  },
16
16
  "description": "The hub-world for AI agent crews \u2014 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).",
17
17
  "files": [