trantor 0.18.7 → 0.18.9

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.7",
3
+ "version": "0.18.9",
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": {
package/bin/app.mjs CHANGED
@@ -77,9 +77,21 @@ await pipeline(Readable.fromWeb(dl.body), createWriteStream(dmg));
77
77
 
78
78
  let mount = "";
79
79
  try {
80
- // -nobrowse keeps the volume out of Finder; mount point is the last tab-field of the last line.
81
- const out = sh("hdiutil", ["attach", "-nobrowse", "-readonly", dmg]);
82
- mount = (out.trim().split("\n").pop() || "").split("\t").pop().trim();
80
+ // diskutil first: on macOS 26 the deprecated hdiutil shim IGNORES -nobrowse, so the mounted
81
+ // volume popped a Finder window mid-update and read as an install prompt (2026-08-27). Parse
82
+ // the mount point as everything after the last " at " volume names can contain spaces.
83
+ try {
84
+ // real output (verified 2026-08-27): tab-separated, same shape as hdiutil —
85
+ // "/dev/disk12s1\tApple_HFS \t/Volumes/Trantor" — last tab field is the mount.
86
+ const out = sh("diskutil", ["image", "attach", "--mountOptions", "nobrowse", "--readOnly", dmg]);
87
+ const line = out.trim().split("\n").filter(l => l.includes("/Volumes/")).pop() || "";
88
+ mount = line.split("\t").pop().trim();
89
+ if (!mount.startsWith("/Volumes/")) throw new Error("no mount point in diskutil output");
90
+ } catch {
91
+ // older macOS: the original hdiutil path, tab-field parse (robust to spaces)
92
+ const out = sh("hdiutil", ["attach", "-nobrowse", "-readonly", dmg]);
93
+ mount = (out.trim().split("\n").pop() || "").split("\t").pop().trim();
94
+ }
83
95
  const src = join(mount, "Trantor.app");
84
96
  if (!mount.startsWith("/Volumes/") || !existsSync(src)) throw new Error(`unexpected DMG layout (mount: ${mount || "none"})`);
85
97
  if (existsSync(APP)) { console.log(`replacing ${APP} (was ${have || "unknown"})`); rmSync(APP, { recursive: true, force: true }); }
@@ -91,6 +103,9 @@ try {
91
103
  } catch (e) {
92
104
  console.error(`install failed: ${e.message}`); process.exitCode = 1;
93
105
  } finally {
94
- if (mount) try { sh("hdiutil", ["detach", mount, "-quiet"]); } catch {}
106
+ if (mount) {
107
+ try { sh("diskutil", ["eject", mount]); }
108
+ catch { try { sh("hdiutil", ["detach", mount, "-quiet"]); } catch {} }
109
+ }
95
110
  try { rmSync(dmg, { force: true }); } catch {}
96
111
  }
@@ -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,7 +361,7 @@ 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",
364
+ cwd: TURN_DIR, encoding: "utf8", stdio: cli.sid ? ["ignore", "pipe", "inherit"] : "inherit",
329
365
  env: { ...process.env, RELAY_URL: HUB, RELAY_AGENT: AGENT, RELAY_PROJECT: PROJ,
330
366
  // A RUNNER-MANAGED SEAT MUST NEVER HAND ITSELF A BATON.
331
367
  //
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/hub.mjs CHANGED
@@ -343,32 +343,46 @@ function overseerTick() {
343
343
  overseerLastTick = t;
344
344
  const pol = overseerPolicy();
345
345
  const seen = new Set();
346
+ // Hand each party the others' session ids at the moment coordination is warranted. Telling two
347
+ // sessions to "coordinate over the bus" is useless if neither knows the other's id, and the
348
+ // warning alone went only to the duty seat and the log — so coordination needed a human to carry
349
+ // the ids across. Shared by the episode-start branch (all parties) and the standing branch
350
+ // (newcomers only): existing members never re-hear it, so a standing condition must not re-wake
351
+ // every party every tick.
352
+ const intro = (c, me, others) => {
353
+ const rest = others.filter(p => p !== me);
354
+ if (rest.length === 0) return;
355
+ hubSend(me,
356
+ `🤝 OVERSEER ${c.kind}: you and ${rest.join(", ")} are working on overlapping ground${c.files?.length ? ` (${c.files.slice(0, 3).join(", ")})` : ""}. ${c.detail || ""} Coordinate directly — relay_send to ${rest[0]} — and split the work between you. No human needs to relay this.`,
357
+ c.project);
358
+ };
346
359
  for (const c of collisions) {
347
- const key = `${c.project} ${c.kind} ${(c.sessions || []).join(",")} ${(c.files || []).join(",")}`;
360
+ // Episode identity is the CONDITION (project+kind+files), never the session list (#5350):
361
+ // membership is volatile — a third seat bouncing in and out of a standing collision minted a
362
+ // fresh key, so a fresh episode, so a fresh warn (+ duty wake + party intros) per permutation.
363
+ // Sessions are participants, not identity; current membership still rides every warn payload.
364
+ const key = `${c.project} ${c.kind} ${(c.files || []).join(",")}`;
348
365
  c.key = key;
349
366
  seen.add(key);
367
+ const parties = [...new Set(c.sessions || [])].filter(s => s && s !== DUTY_SESSION);
350
368
  const standing = overseerActive.get(key);
351
- if (standing) { standing.lastTick = t; c.since = standing.since; continue; } // holds — stay quiet
352
- overseerActive.set(key, { since: t, lastTick: t });
369
+ if (standing) {
370
+ // The episode HOLDS — no new warn. But a NEWCOMER to a standing collision still needs the
371
+ // intro: it was not present when the episode started, so it never learned the others' ids.
372
+ // Diff the current membership against the set the episode has already introduced, hand the
373
+ // intro only to newly arrived sessions, and remember them so they are not re-introduced.
374
+ standing.lastTick = t;
375
+ c.since = standing.since;
376
+ for (const me of parties) if (!standing.sessions.has(me)) intro(c, me, parties);
377
+ for (const me of parties) standing.sessions.add(me);
378
+ continue;
379
+ }
380
+ overseerActive.set(key, { since: t, lastTick: t, sessions: new Set(parties) });
353
381
  c.since = t;
354
382
  appendEvent("overseer.warn", c.project, "overseer",
355
383
  { kind: c.kind, sessions: c.sessions || [], files: c.files || [], detail: c.detail || "", narrated: false });
356
384
  if (DUTY_SESSION) hubSend(DUTY_SESSION, `⚠️ OVERSEER ${c.kind} [${c.project}]: ${c.detail || ""} — if the parties are not already coordinating, message them.`, c.project);
357
- // INTRODUCE the parties to each other. Telling two sessions to "coordinate over the bus" is
358
- // useless if neither knows the other's session id, and until now the warning went only to the
359
- // duty seat and the log — so coordination needed a human to carry the ids across. Hand each
360
- // party the others' ids at the moment coordination is warranted. This sits inside the
361
- // episode-start branch, so it fires ONCE per episode, not once per tick: a standing condition
362
- // must not re-wake two sessions every 30 seconds.
363
- const parties = [...new Set(c.sessions || [])].filter(s => s && s !== DUTY_SESSION);
364
- if (parties.length > 1) {
365
- for (const me of parties) {
366
- const others = parties.filter(p => p !== me);
367
- hubSend(me,
368
- `🤝 OVERSEER ${c.kind}: you and ${others.join(", ")} are working on overlapping ground${c.files?.length ? ` (${c.files.slice(0, 3).join(", ")})` : ""}. ${c.detail || ""} Coordinate directly — relay_send to ${others[0]} — and split the work between you. No human needs to relay this.`,
369
- c.project);
370
- }
371
- }
385
+ if (parties.length > 1) for (const me of parties) intro(c, me, parties);
372
386
  const level = _overseer.levelFor ? _overseer.levelFor(c.project, pol.autonomy) : 1;
373
387
  if (level >= 3 && c.kind === "file-conflict") {
374
388
  const g = { id: ++state.verifyGateSeq, project: c.project, status: "open", ts: now(),
package/lib/project.mjs CHANGED
@@ -21,6 +21,20 @@ export function gitRoot(dir) {
21
21
  export function resolveProject(cwd = process.cwd()) {
22
22
  if (process.env.RELAY_PROJECT) return process.env.RELAY_PROJECT.slice(0, 80);
23
23
  const root = gitRoot(cwd);
24
+ // A LINKED WORKTREE must resolve to its MAIN repo's name, not its own directory name. Seat
25
+ // worktrees live at ~/.agent-bus/worktrees/<project>/<agent>, so the old basename rule named the
26
+ // project after the AGENT — codex's seat registered as codex:codex the first time a worktree crew
27
+ // came up (2026-08-27), because codex spawns its MCP with a sanitized env, so the RELAY_PROJECT
28
+ // guard above never arrives there. git-common-dir points at the main repo's .git from any
29
+ // worktree; in the main repo it equals its own .git, so this is a no-op for normal checkouts.
30
+ if (root) {
31
+ try {
32
+ const common = execSync("git rev-parse --path-format=absolute --git-common-dir", {
33
+ cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 4000,
34
+ }).trim();
35
+ if (common.endsWith("/.git")) return basename(dirname(common)).slice(0, 80);
36
+ } catch {}
37
+ }
24
38
  return basename(root || cwd).slice(0, 80);
25
39
  }
26
40
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trantor",
3
- "version": "0.18.7",
3
+ "version": "0.18.9",
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": [