trantor 0.18.9 → 0.18.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/crew.sh CHANGED
@@ -3,6 +3,8 @@
3
3
  # session's teardown can't nuke out from under ANOTHER session's crew.
4
4
  #
5
5
  # bin/crew.sh up codex glm kimi deepseek # bring up THIS project's crew (tmux panes, or Terminal windows)
6
+ # bin/crew.sh open [<project>] # host the operator's claude as the `orchestrator · <project>`
7
+ # # pane in herdr (reattaches; never stacks a second one)
6
8
  # bin/crew.sh down # tear down ONLY THIS PROJECT's crew (scoped, prints what it kills)
7
9
  # bin/crew.sh down codex # tear down ONE seat
8
10
  # bin/crew.sh down --all --yes # tear down EVERY project's crew (global; --yes required)
@@ -46,11 +48,21 @@ HAVE_TMUX=0; command -v tmux >/dev/null 2>&1 && HAVE_TMUX=1
46
48
  # Events) — NOT the control socket — so it needs NO socket password (the socket denies external processes
47
49
  # by default; AppleScript bypasses that, exactly like we already script Terminal.app).
48
50
  HAVE_CMUX=0; [ -d "/Applications/cmux.app" ] && HAVE_CMUX=1
51
+ # PRESENCE vs PREFERENCE: HAVE_* answer "which mux do NEW crews get" and are stomped by CREW_MUX and
52
+ # the herdr auto-preference below. *_PRESENT answer "is this mux on the machine at all" and are what
53
+ # PRUNE keys on — a row is validated by ITS OWN kind's liveness, never by which mux new crews prefer.
54
+ # Conflating them let a dead cmux row survive prune the moment herdr became the preferred default.
55
+ CMUX_PRESENT=$HAVE_CMUX
56
+ HERDR_PRESENT=0; command -v herdr >/dev/null 2>&1 && HERDR_PRESENT=1
49
57
  # 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.
58
+ # crew survives the launcher exiting) is the PREFERRED mux when its binary is installed: it is the
59
+ # only backend the desktop app's Workspace pane can render, so an auto-detected herdr means every
60
+ # `trantor up` on every project shows up live in the app with no flag. (It was opt-in for exactly one
61
+ # wave; the first thing the operator noticed was that other projects' crews stayed invisible.)
62
+ # CREW_MUX=cmux|tmux|terminal still forces the old dispatch; CREW_MUX=herdr without the binary stays
63
+ # a HARD ERROR, because an explicit ask must never silently fall back.
53
64
  HAVE_HERDR=0
65
+ [ -z "${CREW_MUX:-}" ] && command -v herdr >/dev/null 2>&1 && { HAVE_HERDR=1; HAVE_CMUX=0; HAVE_TMUX=0; }
54
66
  # explicit override (user preference or tests): CREW_MUX=cmux|tmux|terminal|herdr forces the grouping UI.
55
67
  case "${CREW_MUX:-}" in
56
68
  cmux) HAVE_CMUX=1; HAVE_TMUX=0 ;;
@@ -100,7 +112,7 @@ _cmux() { CMUX_QUIET=1 "$CMUX_BIN" "$@"; } # quiet CLI wrapper (suppr
100
112
  _CMUX_OK="" # cached: does the control socket accept us (allowAll)?
101
113
  _cmux_ok() {
102
114
  [ -n "$_CMUX_OK" ] && { [ "$_CMUX_OK" = "1" ] && return 0 || return 1; }
103
- if [ "$HAVE_CMUX" = "1" ] && _cmux ping >/dev/null 2>&1; then _CMUX_OK=1; return 0; fi
115
+ if [ "$CMUX_PRESENT" = "1" ] && _cmux ping >/dev/null 2>&1; then _CMUX_OK=1; return 0; fi
104
116
  _CMUX_OK=0; return 1
105
117
  }
106
118
  # resolve a freshly-created workspace REF (workspace:N) → its stable UUID (survives index shifts).
@@ -170,6 +182,103 @@ console.log("\u0001"+a.map(x=>x.label||x.name||x.custom_title||"").filter(Boolea
170
182
  # Teardown works regardless of CREW_MUX: you must never need to remember the flag to tear a crew down.
171
183
  _herdr_close_ws() { [ "$DRY" = "1" ] && { echo "[dry] herdr workspace close $1"; return 0; }; _herdr workspace close "$1" >/dev/null 2>&1; }
172
184
  _herdr_close_pane() { [ "$DRY" = "1" ] && { echo "[dry] herdr pane close $1"; return 0; }; _herdr pane close "$1" >/dev/null 2>&1; }
185
+ # Tell herdr a pane hosts an agent. WITHOUT this the pane is just a shell to herdr, `herdr agent
186
+ # attach` answers agent_not_found, and the app's terminal shows that error instead of the seat —
187
+ # observed 2026-08-27 on every seat while the orchestrator (claude, which herdr detects on its own)
188
+ # streamed fine. NOTE the argument order: the pane id must come FIRST, before the flags.
189
+ # ── orchestrator continuity ────────────────────────────────────────────────────────────────────
190
+ # herdr keeps the PANE alive across an app quit, and launchd keeps its server alive across a
191
+ # reboot. Neither preserves the CONVERSATION: a pty that dies comes back empty, because the
192
+ # transcript was never a property of the terminal. That part is ours.
193
+ #
194
+ # So the project gets ONE claude session id, chosen by us and remembered. First open starts claude
195
+ # under it; every later open resumes it. Discovering the id afterwards would be guesswork, and
196
+ # `--continue` would grab whatever ran last in this directory, which may be a different window.
197
+ _orch_sid() { # $1=project → the project's session uuid, minting one on first use
198
+ local f="${STATE%/*}/orch-sessions.txt" p sid
199
+ if [ -f "$f" ]; then
200
+ while IFS="$(printf '\t')" read -r p sid; do
201
+ [ "$p" = "$1" ] && [ -n "$sid" ] && { printf '%s' "$sid"; return 0; }
202
+ done < "$f"
203
+ fi
204
+ sid="$(uuidgen 2>/dev/null | tr 'A-Z' 'a-z')"
205
+ [ -n "$sid" ] || return 1
206
+ [ "$DRY" = "1" ] || { mkdir -p "${f%/*}"; printf '%s\t%s\n' "$1" "$sid" >> "$f"; }
207
+ printf '%s' "$sid"
208
+ }
209
+
210
+ # claude writes each conversation to ~/.claude/projects/<slug>/<session-id>.jsonl, where the slug
211
+ # is the working directory with every / and . turned into -.
212
+ _orch_transcript() { printf '%s' "$HOME/.claude/projects/$(printf '%s' "$1" | tr '/.' '--')/$2.jsonl"; }
213
+
214
+ # A herdr server started from inside a Claude Code session hands its whole environment to every
215
+ # pane it later spawns, and those markers are session-scoped, not machine-scoped. The damage is
216
+ # quiet and total: claude sees CLAUDE_CODE_CHILD_SESSION, decides it is a sub-session and TURNS
217
+ # TRANSCRIPT SAVING OFF — so --resume would have nothing to resume and the persistence above would
218
+ # be theatre. CLAUDE_CODE_MESSAGING_SOCKET/TOKEN are worse: they point the new session at the
219
+ # ORIGINATING session's socket, which is how a fresh pane ends up claiming another session's baton.
220
+ #
221
+ # Feature flags the operator actually set (AGENT_TEAMS, FORK_SUBAGENT, EFFORT) are deliberately
222
+ # left alone. Only identity is stripped.
223
+ ORCH_STRIP="CLAUDECODE CLAUDE_CODE_ENTRYPOINT CLAUDE_CODE_SESSION_ID CLAUDE_CODE_CHILD_SESSION CLAUDE_CODE_BRIDGE_SESSION_ID CLAUDE_CODE_MESSAGING_SOCKET CLAUDE_CODE_MESSAGING_TOKEN CLAUDE_CODE_EXECPATH CLAUDE_PID"
224
+ # TRANTOR_ORCH marks the pane's claude as THE orchestrator pane for $PROJ: the sessionstart hook
225
+ # uses it to claim a held orchestrator baton immediately and to record its own session id in
226
+ # orch-sessions.txt (project-matched on the hook side, so a child claude elsewhere can't inherit it).
227
+ _orch_env() { local v out="env"; for v in $ORCH_STRIP; do out="$out -u $v"; done; printf '%s TRANTOR_ORCH=%s' "$out" "$PROJ"; }
228
+
229
+ # The HARNESS dial (trantor autonomy). "bypass" is what the operator means by letting the agent
230
+ # just work; "prompt" is the default because a fresh install must never silently skip a permission
231
+ # the operator has not chosen to skip. Read per open, so changing it in the app takes effect the
232
+ # next time the session is started rather than needing a reinstall.
233
+ _orch_flags() {
234
+ local mode
235
+ mode="$(node "$(dirname "$0")/autonomy.mjs" get harness --project "$PROJ" 2>/dev/null)"
236
+ [ "$mode" = "bypass" ] && printf ' --dangerously-skip-permissions'
237
+ }
238
+
239
+ # Resume when the conversation already exists on disk; otherwise start it under the id we picked so
240
+ # the NEXT open can resume it.
241
+ _orch_cmd() { # $1=dir $2=sid
242
+ if [ -f "$(_orch_transcript "$1" "$2")" ]; then printf '%s claude%s --resume %s' "$(_orch_env)" "$(_orch_flags)" "$2"
243
+ else printf '%s claude%s --session-id %s' "$(_orch_env)" "$(_orch_flags)" "$2"; fi
244
+ }
245
+
246
+ # A recorded thread that HANDED OFF has ended: resuming it replays a dead conversation while the
247
+ # handoff waits for a successor (the 2026-08-27 seam — "Trantor resumes the wrong thread"). When the
248
+ # newest unconsumed handoff for the project was written BY the recorded session, open must start a
249
+ # FRESH id instead; the sessionstart hook then claims the baton and records the fresh id in
250
+ # orch-sessions.txt (single writer for the map: the hook + adopt — this function writes nothing).
251
+ _orch_takeover_sid() { # $1=project $2=recorded-sid → prints the sid open should use
252
+ local fresh
253
+ if node -e '
254
+ const fs=require("fs"),path=require("path"),os=require("os");
255
+ const dir=path.join(process.env.AGENT_BUS_DIR||process.env.RELAY_DATA_DIR||path.join(os.homedir(),".agent-bus"),"handoffs");
256
+ const [proj,sid]=process.argv.slice(1);
257
+ try{
258
+ const re=new RegExp("^"+proj.replace(/[.*+?^${}()|[\]\\]/g,"\\$&")+"-(\\d+)\\.json$");
259
+ const files=fs.readdirSync(dir).map(f=>{const m=re.exec(f);return m?{f,s:Number(m[1])}:null}).filter(Boolean).sort((a,b)=>b.s-a.s);
260
+ for(const {f} of files){
261
+ const r=JSON.parse(fs.readFileSync(path.join(dir,f),"utf8"));
262
+ if(r.consumed) continue;
263
+ process.exit(r.session_id&&r.session_id===sid?0:1); // newest UNCONSUMED decides
264
+ }
265
+ }catch(e){}
266
+ process.exit(1);' "$1" "$2" 2>/dev/null; then
267
+ fresh="$(uuidgen 2>/dev/null | tr 'A-Z' 'a-z')"
268
+ [ -n "$fresh" ] && { echo "— recorded session $2 handed off: starting fresh as $fresh to claim it —" >&2; printf '%s' "$fresh"; return 0; }
269
+ fi
270
+ printf '%s' "$2"
271
+ }
272
+
273
+ # Does herdr see a LIVE agent in this pane? A pane answering `rename` only proves the pane exists;
274
+ # the process inside it can be long gone, and reattaching to a dead shell shows the operator an
275
+ # empty terminal that never fills.
276
+ _herdr_pane_has_agent() { _herdr agent list 2>/dev/null | grep -q "\"pane_id\":\"$1\""; }
277
+
278
+ _herdr_report_agent() { # $1=pane $2=agent-label
279
+ [ "$DRY" = "1" ] && { echo "[dry] herdr pane report-agent $1 --source crew --agent $2 --state working"; return 0; }
280
+ _herdr pane report-agent "$1" --source crew --agent "$2" --state working >/dev/null 2>&1
281
+ }
173
282
 
174
283
  # ── herdr spawn: ONE workspace `trantor:$PROJ`, one named pane per seat ─────────────────────────────
175
284
  # Topology = workspace create (its root pane runs seat 1) + pane split for the rest, same ceil(√N) grid
@@ -267,6 +376,9 @@ spawn_herdr() { # $@ = specs
267
376
  [ -n "$surf" ] && { _herdr pane rename "$surf" "$AGENT · $PROJ" >/dev/null 2>&1; _herdr pane run "$surf" "$cmd" >/dev/null 2>&1; }
268
377
  fi
269
378
  fi
379
+ # Every path, dry included: herdr must know this pane hosts an agent or `agent attach` — which
380
+ # is how the app streams it — answers agent_not_found and the seat renders as an error.
381
+ [ -n "$surf" ] && _herdr_report_agent "$surf" "$AGENT"
270
382
  surfs+=("$surf")
271
383
  record_state "$PROJ" "herdr" "$AGENT" "$surf"
272
384
  echo " → $AGENT seat in herdr workspace ($PROJ)"
@@ -328,7 +440,7 @@ down() {
328
440
 
329
441
  # collect the rows in scope + build a human kill-list. `scoped` entries are joined with '|' (NOT a tab):
330
442
  # tab is IFS whitespace, so re-splitting a tab-joined string would collapse an empty leading PROJECT.
331
- local scoped=() line killlist="" seentmux=""
443
+ local scoped=() line killlist="" seentmux="" orch_scopes=""
332
444
  while IFS= read -r line; do
333
445
  [ -n "$line" ] || continue
334
446
  _parse_row "$line" # → RP RK RA RH
@@ -336,14 +448,19 @@ down() {
336
448
  # SKIPPED by a scoped down — we can't prove they're ours, and killing another project's crew is the exact
337
449
  # bug we're fixing. `trantor down --all` reaches them; otherwise they self-heal via prune_dead_state.
338
450
  if [ "$WANT_ALL" != "1" ] && [ "$RP" != "$PROJ" ]; then continue; fi
451
+ # a project hosting the orchestrator pane degrades to PER-SEAT teardown: its workspace must survive
452
+ # (the orch pane lives inside it — closing the workspace closes the terminal the operator is typing
453
+ # in, from inside that terminal), so only the seat panes are closed.
454
+ [ "$RK" = "orch" ] && case ";$orch_scopes;" in *";$RP;"*) : ;; *) orch_scopes="$orch_scopes;$RP;" ;; esac
339
455
  if [ "${#AGENTS[@]}" -gt 0 ]; then # agent filter (if specific seats named)
340
456
  local match=0 want; for want in "${AGENTS[@]}"; do [ "$RA" = "$want" ] && match=1; done
341
457
  [ "$match" = "1" ] || continue
342
458
  fi
343
459
  scoped+=("$RP|$RK|$RA|$RH")
344
- case "$RK" in attach|cmuxws|herdrws) continue ;; esac # infra rows (attach/cmux/herdr workspace) — not seats
460
+ case "$RK" in attach|cmuxws|herdrws|orch) continue ;; esac # infra rows (attach/cmux/herdr workspace/orch) — not seats; `orch` is SPARED entirely
345
461
  killlist="$killlist • ${RP:-<legacy>} · $RA ($RK)"$'\n'
346
462
  done < "$STATE"
463
+ _has_orch() { case "$orch_scopes" in *";$1;"*) return 0 ;; esac; return 1; }
347
464
 
348
465
  if [ "${#scoped[@]}" -eq 0 ]; then echo "nothing to tear down for $SCOPE_DESC"; return 0; fi
349
466
  echo "— tearing down ($SCOPE_DESC):"; printf '%s' "$killlist"
@@ -357,10 +474,10 @@ down() {
357
474
  for s in "${scoped[@]}"; do
358
475
  IFS='|' read -r P2 K2 A2 H2 <<< "$s"
359
476
  case "$K2" in
360
- cmuxws) [ "${#AGENTS[@]}" -gt 0 ] || _cmux_close_tab "$H2" ;; # whole workspace (no specific seats)
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
477
+ cmuxws) { [ "${#AGENTS[@]}" -gt 0 ] || _has_orch "$P2"; } || _cmux_close_tab "$H2" ;; # whole workspace (unless seats are named or an orch pane lives in it)
478
+ cmux) { [ "${#AGENTS[@]}" -gt 0 ] || _has_orch "$P2"; } && _cmux_close_term "$H2" ;; # a single seat's pane
479
+ herdrws) { [ "${#AGENTS[@]}" -gt 0 ] || _has_orch "$P2"; } || _herdr_close_ws "$H2" ;; # whole workspace (unless seats are named or an orch pane lives in it)
480
+ herdr) { [ "${#AGENTS[@]}" -gt 0 ] || _has_orch "$P2"; } && _herdr_close_pane "$H2" ;;# a single seat's pane
364
481
  tmux)
365
482
  if [ "${#AGENTS[@]}" -gt 0 ]; then run "tmux kill-pane -t '$H2' 2>/dev/null" # per-seat
366
483
  else
@@ -372,7 +489,8 @@ down() {
372
489
  case "$K2" in cmuxws|attach|herdrws) : ;; *) _kill_seat_procs "$P2" "$A2" ;; esac
373
490
  done
374
491
 
375
- # rewrite STATE minus the rows we tore down (leaves OTHER projects' rows intact)
492
+ # rewrite STATE minus the rows we tore down (leaves OTHER projects' rows intact). An orch-hosting
493
+ # project's workspace rows survive too — the workspace is still needed by the spared orch pane.
376
494
  if [ "$DRY" != "1" ]; then
377
495
  local tmp="$STATE.tmp"; : > "$tmp"
378
496
  while IFS= read -r line; do
@@ -380,6 +498,10 @@ down() {
380
498
  _parse_row "$line"
381
499
  local drop=0
382
500
  for s in "${scoped[@]}"; do [ "$s" = "$RP|$RK|$RA|$RH" ] && drop=1; done
501
+ # `orch` itself is spared alongside the workspace that hosts it: down never closes the
502
+ # orchestrator pane, so dropping its row would orphan a live pane and let the next `open`
503
+ # stack a second orchestrator on top of it.
504
+ [ "$drop" = "1" ] && _has_orch "$RP" && case "$RK" in herdrws|cmuxws|orch) drop=0 ;; esac
383
505
  [ "$drop" = "1" ] || printf '%s\t%s\t%s\t%s\n' "$RP" "$RK" "$RA" "$RH" >> "$tmp"
384
506
  done < "$STATE"
385
507
  mv "$tmp" "$STATE"
@@ -388,7 +510,7 @@ down() {
388
510
  echo "— crew torn down ($SCOPE_DESC)"
389
511
  }
390
512
  [ "$CMD" = "down" ] && { down "$@"; exit $?; }
391
- case "$CMD" in up|swap|prune) ;; *) echo "usage: crew.sh up <agent...> | crew.sh swap <old> <new[:provider[/model]]> | crew.sh down [<agent>...] [--all --yes] | crew.sh prune"; exit 1 ;; esac
513
+ case "$CMD" in up|open|swap|prune) ;; *) echo "usage: crew.sh up <agent...> | crew.sh open [<project>] | crew.sh swap <old> <new[:provider[/model]]> | crew.sh down [<agent>...] [--all --yes] | crew.sh prune"; exit 1 ;; esac
392
514
 
393
515
  # self-heal: drop STATE rows whose Terminal window is already gone (dead crews from past sessions), so the
394
516
  # file doesn't accumulate ghosts across ups. tmux rows are validated by their session existing; cmux rows
@@ -409,10 +531,11 @@ prune_dead_state() {
409
531
  CLIVE="$(printf '%s' "$pair" | sed -n 1p)"
410
532
  CLIVE_NAMES="$(printf '%s' "$pair" | sed -n 2p)"
411
533
  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.
534
+ # herdr liveness is queried whenever the BINARY is present (herdr rows deserve validation no
535
+ # matter which mux new crews prefer). Same keep-when-unprovable doctrine as cmux — no answer,
536
+ # or no binary at all ⇒ rows are KEPT.
414
537
  local HLIVE="" HLIVE_NAMES=""
415
- if [ "$HAVE_HERDR" = "1" ]; then
538
+ if [ "$HERDR_PRESENT" = "1" ]; then
416
539
  local hpair
417
540
  hpair="$(_herdr_ws_live)"
418
541
  HLIVE="$(printf '%s' "$hpair" | sed -n 1p)"
@@ -435,9 +558,9 @@ prune_dead_state() {
435
558
  if [ -n "$CLIVE" ]; then case "$CLIVE_NAMES" in *$'\x01'"trantor:$RP"$'\x01'*) : ;; *) alive=0 ;; esac; fi
436
559
  elif [ "$RK" = "herdrws" ]; then
437
560
  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)
561
+ elif [ "$RK" = "herdr" ] || [ "$RK" = "orch" ]; then
562
+ # seat + orchestrator rows live exactly as long as their project still has a live crew workspace
563
+ # — validated at WORKSPACE granularity, never per pane (the cmux 0.17.61 lesson generalized)
441
564
  if [ -n "$HLIVE" ]; then case "$HLIVE_NAMES" in *$'\x01'"trantor:$RP"$'\x01'*) : ;; *) alive=0 ;; esac; fi
442
565
  fi
443
566
  [ "$alive" = "1" ] && printf '%s\t%s\t%s\t%s\n' "$RP" "$RK" "$RA" "$RH" >> "$tmp"
@@ -447,6 +570,123 @@ prune_dead_state() {
447
570
  # `crew.sh prune` — run the self-heal on demand (ops: clean ghost rows without spawning anything).
448
571
  [ "$CMD" = "prune" ] && { prune_dead_state; echo "— pruned dead crew rows ($STATE) —"; exit 0; }
449
572
 
573
+ # ── open: host the OPERATOR's session as the `orchestrator · <project>` pane (card #5396) ────────────
574
+ # The session a developer actually works in used to be an unowned Terminal window — invisible to the
575
+ # app, and killed by any whole-project `down` that closed the workspace under it. `trantor open`
576
+ # hosts it as a herdr pane in the SAME `trantor:<project>` workspace the crew uses (herdr keeps the
577
+ # pane alive server-side; Terminal attaches to the SAME session), and prints the pane TARGET on
578
+ # stdout as ONE line: herdr:<workspace_id>/<pane_id> (human chatter goes to stderr, so the
579
+ # target is capturable). REATTACH, NEVER STACK: a second open finds the tracked `orch` row alive and
580
+ # reprints the target — two orchestrator claude processes on one project would fight over one bus
581
+ # identity (the duplicated-runner lesson, crew.sh edition). The liveness probe is an idempotent
582
+ # rename to the pane's own title: no per-pane list API is trusted (the 0.17.61 workspace-granularity
583
+ # lesson), and a closed pane can never pass it. `down` SPARES `orch` rows for the same reason.
584
+ usage_open() {
585
+ cat <<EOF
586
+ usage: trantor open [<project>]
587
+ host THIS project's orchestrator session as a herdr pane in workspace trantor:<project>
588
+ (creating the workspace if needed) and print its TARGET on stdout. A second open REATTACHES:
589
+ it prints the existing target and exits 0 without spawning a second orchestrator.
590
+ EOF
591
+ }
592
+ open_orchestrator() {
593
+ local a
594
+ for a in "$@"; do case "$a" in
595
+ --help|-h) usage_open; return 0 ;;
596
+ --*) echo "trantor open: unknown flag '$a'"; usage_open; return 1 ;;
597
+ *) PROJ="$a" ;;
598
+ esac; done
599
+ command -v herdr >/dev/null 2>&1 || { echo "trantor open needs herdr (the pane host) — install: curl -fsSL https://herdr.dev/install.sh | sh"; exit 1; }
600
+ local wsid="" orch="" live_ids="" live_names="" pair="" line fresh=0
601
+ local sid; sid="$(_orch_sid "$PROJ")" || { echo "trantor open: could not mint a session id (uuidgen missing?)" >&2; exit 1; }
602
+ sid="$(_orch_takeover_sid "$PROJ" "$sid")"
603
+ if [ "$DRY" != "1" ]; then
604
+ pair="$(_herdr_ws_live)"
605
+ live_ids="$(printf '%s' "$pair" | sed -n 1p)"
606
+ live_names="$(printf '%s' "$pair" | sed -n 2p)"
607
+ fi
608
+ if [ -f "$STATE" ]; then
609
+ while IFS= read -r line; do
610
+ [ -n "$line" ] || continue
611
+ _parse_row "$line"
612
+ [ "$RP" = "$PROJ" ] || continue
613
+ case "$RK" in
614
+ herdrws)
615
+ # a tracked workspace is only usable while LIVE (label match); a provably dead row is dropped.
616
+ # No live answer ⇒ kept (the keep-when-unprovable doctrine shared with prune).
617
+ if [ "$DRY" != "1" ] && [ -n "$live_ids" ]; then
618
+ case " $live_ids " in *" $RH "*) wsid="$RH" ;; *) _state_drop "$PROJ" "herdrws" "" "$RH" ;; esac
619
+ else wsid="$RH"; fi ;;
620
+ orch) orch="$RH" ;;
621
+ esac
622
+ done < "$STATE"
623
+ fi
624
+ # REATTACH, never stack: a tracked orch pane that still answers is reused as-is.
625
+ if [ -n "$orch" ]; then
626
+ if [ "$DRY" = "1" ]; then
627
+ echo "herdr:${wsid:-%DRYWS}/$orch"
628
+ return 0
629
+ fi
630
+ if _herdr pane rename "$orch" "orchestrator · $PROJ" >/dev/null 2>&1; then
631
+ if _herdr_pane_has_agent "$orch"; then
632
+ echo "herdr:${wsid:-?}/$orch"
633
+ echo "— orchestrator already hosted: reattached to herdr:${wsid:-?}/$orch —" >&2
634
+ return 0
635
+ fi
636
+ # The pane outlived its claude (a crash, or a reboot that took the process but not the row).
637
+ # Restart the CONVERSATION in the same pane rather than handing back an empty terminal.
638
+ _herdr pane run "$orch" "$(_orch_cmd "$DIR" "$sid")" >/dev/null 2>&1
639
+ _herdr_report_agent "$orch" "claude"
640
+ echo "herdr:${wsid:-?}/$orch"
641
+ echo "— orchestrator pane was empty: resumed session $sid in herdr:${wsid:-?}/$orch —" >&2
642
+ return 0
643
+ fi
644
+ _state_drop "$PROJ" "orch" "" ""; orch="" # stale row → healed by the create path below
645
+ fi
646
+ # No usable workspace yet: adopt an untracked LIVE crew workspace before creating a second one
647
+ # (spawn_herdr's adopt-else-create doctrine — open must not stack trantor:<project> workspaces).
648
+ if [ -z "$wsid" ]; then
649
+ if [ "$DRY" = "1" ]; then
650
+ wsid="%DRYWS"
651
+ echo "[dry] herdr: workspace create (cwd $DIR) --label 'trantor:$PROJ'" >&2
652
+ else
653
+ local found=""
654
+ case "$live_names" in
655
+ *$'\x01'"trantor:$PROJ"$'\x01'*)
656
+ found="$(_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)[0]||"")}catch(e){}})')"
657
+ [ -n "$found" ] && echo "— adopting existing crew workspace for $PROJ ($found) —" >&2 ;;
658
+ esac
659
+ if [ -n "$found" ]; then wsid="$found"
660
+ else
661
+ pair="$(_herdr_ws_create "$DIR" "trantor:$PROJ")"
662
+ wsid="${pair%%$'\t'*}"; fresh=1
663
+ fi
664
+ [ -n "$wsid" ] || { echo "trantor open: herdr workspace create failed" >&2; exit 1; }
665
+ fi
666
+ record_state "$PROJ" "herdrws" "__ws__" "$wsid"
667
+ fi
668
+ # Host the pane: a JUST-created workspace's root pane IS the orchestrator pane (claude rides it);
669
+ # an existing workspace gets a split — the crew's seats tile off it on the next `up`, never
670
+ # replacing it (spawn_herdr's REUSE mode splits seats off their own old pane / the previous one).
671
+ if [ "$DRY" = "1" ]; then
672
+ orch="%DRYORCH"
673
+ echo "[dry] herdr: ${fresh:+root pane + }pane rename $orch 'orchestrator · $PROJ' + run '$(_orch_cmd "$DIR" "$sid")'" >&2
674
+ else
675
+ if [ "$fresh" = "1" ]; then orch="${pair##*$'\t'}"
676
+ else orch="$(_herdr_split "" right)"; fi
677
+ [ -n "$orch" ] || { echo "trantor open: could not create the orchestrator pane" >&2; exit 1; }
678
+ _herdr pane rename "$orch" "orchestrator · $PROJ" >/dev/null 2>&1
679
+ _herdr pane run "$orch" "$(_orch_cmd "$DIR" "$sid")" >/dev/null 2>&1
680
+ _herdr_report_agent "$orch" "claude"
681
+ fi
682
+ _state_drop "$PROJ" "orch" "" "" # replace-never-stack: exactly one orch row per project
683
+ record_state "$PROJ" "orch" "__orch__" "$orch"
684
+ echo "herdr:$wsid/$orch"
685
+ echo "— orchestrator pane hosted: herdr:$wsid/$orch (claude in $DIR). 'trantor down' spares it. —" >&2
686
+ return 0
687
+ }
688
+ [ "$CMD" = "open" ] && { open_orchestrator "$@"; exit $?; }
689
+
450
690
  # --task/--difficulty drive LAZY live-model selection for provider-only specs (agent:provider).
451
691
  TASK="code"; DIFF="medium"; _ARGS=()
452
692
  while [ $# -gt 0 ]; do
@@ -0,0 +1,96 @@
1
+ #!/usr/bin/env node
2
+ // Keep herdr's server alive across a reboot.
3
+ //
4
+ // herdr already holds every pane in a background server, so quitting the app or closing a Terminal
5
+ // window never costs you a session. A REBOOT does: the server dies with everything else, and
6
+ // nothing brings it back, so the next `trantor open` finds no workspace and starts over. That was
7
+ // the missing half of "it survives restarts" (card #5401).
8
+ //
9
+ // This is only the SERVER's lifetime. The conversation inside a pane is a separate problem, solved
10
+ // in crew.sh: the project keeps one claude session id and `open` resumes it.
11
+ import { existsSync, writeFileSync, unlinkSync, mkdirSync } from "node:fs";
12
+ import { execFileSync } from "node:child_process";
13
+ import { homedir } from "node:os";
14
+ import { join, dirname } from "node:path";
15
+
16
+ const LABEL = "com.trantor.herdr";
17
+ const PLIST = join(homedir(), "Library", "LaunchAgents", `${LABEL}.plist`);
18
+ const LOG = "/tmp/trantor-herdr-server.log";
19
+ const D = "\x1b[2m", R = "\x1b[0m";
20
+
21
+ function herdrBin() {
22
+ // launchd runs with a minimal PATH, so the absolute path has to be baked in at install time.
23
+ for (const p of [join(homedir(), ".local/bin/herdr"), "/opt/homebrew/bin/herdr", "/usr/local/bin/herdr"]) {
24
+ if (existsSync(p)) return p;
25
+ }
26
+ try {
27
+ const found = execFileSync("/usr/bin/which", ["herdr"], { encoding: "utf8" }).trim();
28
+ if (found) return found;
29
+ } catch { /* not on PATH either */ }
30
+ return "";
31
+ }
32
+
33
+ const plistBody = (bin) => `<?xml version="1.0" encoding="UTF-8"?>
34
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
35
+ <plist version="1.0">
36
+ <dict>
37
+ <key>Label</key><string>${LABEL}</string>
38
+ <key>ProgramArguments</key>
39
+ <array>
40
+ <string>/bin/bash</string>
41
+ <string>-lc</string>
42
+ <!-- Idempotent on purpose. A second herdr server against a live one prints "already running"
43
+ and exits 1, so a plain KeepAlive would respawn it every 10s forever. Bail out cleanly
44
+ instead, and only exec when nothing is holding the socket. -->
45
+ <string>${bin} status 2>/dev/null | grep -q 'status: running' &amp;&amp; exit 0; exec ${bin} server</string>
46
+ </array>
47
+ <key>RunAtLoad</key><true/>
48
+ <!-- Restart a server that DIED, never one that stopped cleanly (herdr server stop) or one that
49
+ bailed because another was already up. -->
50
+ <key>KeepAlive</key><dict><key>SuccessfulExit</key><false/></dict>
51
+ <key>StandardOutPath</key><string>${LOG}</string>
52
+ <key>StandardErrorPath</key><string>${LOG}</string>
53
+ </dict>
54
+ </plist>
55
+ `;
56
+
57
+ const cmd = process.argv[2] || "status";
58
+ const uid = process.getuid();
59
+
60
+ if (cmd === "install") {
61
+ const bin = herdrBin();
62
+ if (!bin) {
63
+ console.error("herdr is not installed — get it first: curl -fsSL https://herdr.dev/install.sh | sh");
64
+ process.exit(1);
65
+ }
66
+ mkdirSync(dirname(PLIST), { recursive: true });
67
+ writeFileSync(PLIST, plistBody(bin));
68
+ // bootout first so a re-install replaces rather than stacks
69
+ try { execFileSync("/bin/launchctl", ["bootout", `gui/${uid}/${LABEL}`], { stdio: "ignore" }); } catch { /* not loaded */ }
70
+ try {
71
+ execFileSync("/bin/launchctl", ["bootstrap", `gui/${uid}`, PLIST], { stdio: "ignore" });
72
+ } catch (e) {
73
+ console.error(`wrote ${PLIST} but launchctl bootstrap failed: ${e?.message || e}`);
74
+ process.exit(1);
75
+ }
76
+ console.log(`herdr's server now starts at login (${bin} server)`);
77
+ console.log(`${D}plist: ${PLIST} log: ${LOG}${R}`);
78
+ } else if (cmd === "remove") {
79
+ try { execFileSync("/bin/launchctl", ["bootout", `gui/${uid}/${LABEL}`], { stdio: "ignore" }); } catch { /* not loaded */ }
80
+ if (existsSync(PLIST)) { unlinkSync(PLIST); console.log("removed the herdr login agent"); }
81
+ else console.log("no herdr login agent installed");
82
+ } else if (cmd === "status") {
83
+ const installed = existsSync(PLIST);
84
+ let loaded = false;
85
+ try {
86
+ loaded = execFileSync("/bin/launchctl", ["list"], { encoding: "utf8" }).split("\n").some(l => l.includes(LABEL));
87
+ } catch { /* launchctl unavailable */ }
88
+ let running = false;
89
+ try { running = /status:\s*running/.test(execFileSync(herdrBin() || "herdr", ["status"], { encoding: "utf8" })); } catch { /* server down */ }
90
+ console.log(`login agent: ${installed ? "installed" : "not installed"}${installed ? ` (${loaded ? "loaded" : "NOT loaded"})` : ""}`);
91
+ console.log(`herdr server: ${running ? "running" : "not running"}`);
92
+ if (!installed) console.log(`${D}install it so panes survive a reboot: trantor herdr install${R}`);
93
+ } else {
94
+ console.log("usage: trantor herdr install | remove | status");
95
+ process.exit(1);
96
+ }
@@ -0,0 +1,86 @@
1
+ #!/usr/bin/env node
2
+ // `trantor integrate` — collect the crew's work, prove it, ship it.
3
+ //
4
+ // The steps are the ones the orchestrator already performs by hand. The dials decide how far it is
5
+ // allowed to go on its own, and every stop says which dial stopped it, so "why didn't it push"
6
+ // always has an answer.
7
+ import { resolveProject } from "../lib/project.mjs";
8
+ import { resolveAutonomy } from "../lib/autonomy.mjs";
9
+ import { seatWorktrees, commitSeatWork, seatAhead, mergeSeat, verify, git } from "../lib/integrate.mjs";
10
+
11
+ const D = "\x1b[2m", B = "\x1b[1m", G = "\x1b[32m", Y = "\x1b[33m", RED = "\x1b[31m", R = "\x1b[0m";
12
+ const args = process.argv.slice(2);
13
+ const dry = args.includes("--dry-run");
14
+ const repo = process.cwd();
15
+ const project = resolveProject(repo);
16
+ const a = resolveAutonomy(project);
17
+
18
+ console.log(`${B}integrate${R} · ${project} ${D}commit=${a.commit ? "on" : "off"} push=${a.push ? "on" : "off"} deploy=${a.deploy ? "on" : "off"}${R}`);
19
+
20
+ const base = git(repo, ["rev-parse", "--abbrev-ref", "HEAD"]);
21
+ const seats = seatWorktrees(project);
22
+ if (!seats.length) {
23
+ console.log(`${D}no seat worktrees for ${project} — nothing to integrate${R}`);
24
+ process.exit(0);
25
+ }
26
+
27
+ // 1. Each seat's uncommitted work becomes a commit AUTHORED TO THAT SEAT, so git blame stays a
28
+ // truthful answer to "who wrote this" once integration stops being manual.
29
+ let blocked = false;
30
+ for (const w of seats) {
31
+ const dirty = git(w.dir, ["status", "--porcelain"]);
32
+ if (!dirty) { console.log(` ${D}${w.agent}: clean${R}`); continue; }
33
+ if (!a.commit) {
34
+ console.log(` ${Y}${w.agent}: has uncommitted work, and commit is off${R} ${D}(trantor autonomy set commit on)${R}`);
35
+ blocked = true;
36
+ continue;
37
+ }
38
+ if (dry) { console.log(` ${D}[dry] would commit ${dirty.split("\n").length} file(s) as ${w.agent}${R}`); continue; }
39
+ const r = commitSeatWork(w);
40
+ console.log(` ${G}${w.agent}: committed ${r.sha}${R}`);
41
+ }
42
+
43
+ // 2. Merge what each seat is ahead by. A conflict stops the pass: two seats on the same lines is
44
+ // the collision the overseer exists to surface, not something to resolve silently.
45
+ const merged = [];
46
+ for (const w of seats) {
47
+ const { branch, ahead } = seatAhead(w, base);
48
+ if (!ahead) continue;
49
+ if (dry) { console.log(` ${D}[dry] would merge ${branch} (${ahead} commit(s))${R}`); merged.push(branch); continue; }
50
+ const m = mergeSeat(repo, branch);
51
+ if (m.conflict) {
52
+ console.log(` ${RED}${branch}: CONFLICT — left untouched${R}\n${D}${m.detail}${R}`);
53
+ blocked = true;
54
+ continue;
55
+ }
56
+ if (m.merged) { console.log(` ${G}merged ${branch} (${ahead} commit(s))${R}`); merged.push(branch); }
57
+ }
58
+
59
+ if (blocked) {
60
+ console.log(`\n${Y}stopping here — resolve the above before anything ships${R}`);
61
+ process.exit(1);
62
+ }
63
+ if (!merged.length && !dry) {
64
+ console.log(`${D}nothing new to integrate${R}`);
65
+ process.exit(0);
66
+ }
67
+
68
+ // 3. Proof. This gate ignores the dial on purpose: unverified work does not leave the machine,
69
+ // whatever the operator turned on.
70
+ if (dry) { console.log(`${D}[dry] would verify, then push=${a.push}${R}`); process.exit(0); }
71
+ console.log(`\n${D}verifying…${R}`);
72
+ const v = verify(repo);
73
+ if (!v.ok) {
74
+ console.log(`${RED}verify FAILED (${v.cmd}) — not pushing${R}\n${D}${v.tail}${R}`);
75
+ process.exit(1);
76
+ }
77
+ console.log(`${G}verified (${v.cmd})${R}`);
78
+
79
+ // 4. Ship.
80
+ if (!a.push) {
81
+ console.log(`${D}push is off — integrated and verified locally (trantor autonomy set push on)${R}`);
82
+ process.exit(0);
83
+ }
84
+ git(repo, ["push"]);
85
+ console.log(`${G}pushed ${base}${R}`);
86
+ if (a.deploy) console.log(`${D}deploy is on, but this project has no deploy step wired here yet${R}`);
@@ -0,0 +1,27 @@
1
+ #!/usr/bin/env node
2
+ // trantor seat-why <agent> [--json] — explain why a crew seat is (not) working, straight from
3
+ // local ~/.agent-bus evidence. No hub needed; works even when the whole fleet is down.
4
+ import { seatWhy } from "../lib/seat-why.mjs";
5
+ import { resolveProject } from "../lib/project.mjs";
6
+
7
+ const args = process.argv.slice(2);
8
+ const agent = args.find(a => !a.startsWith("--"));
9
+ const asJson = args.includes("--json");
10
+ const projIdx = args.indexOf("--project");
11
+ const project = projIdx !== -1 && args[projIdx + 1]
12
+ ? args[projIdx + 1]
13
+ : process.env.RELAY_PROJECT || resolveProject(process.cwd());
14
+
15
+ if (!agent) {
16
+ console.error("usage: trantor seat-why <agent> [--json] [--project <name>]");
17
+ process.exit(2);
18
+ }
19
+
20
+ const out = await seatWhy(project, agent);
21
+ if (asJson) {
22
+ console.log(JSON.stringify({ agent, project, ...out }, null, 2));
23
+ } else {
24
+ console.log(`seat ${agent}:${project} -> ${out.state}`);
25
+ console.log(`why: ${out.why}`);
26
+ console.log(`advice: ${out.advice}`);
27
+ }
@@ -433,7 +433,9 @@ export function maybeSpawn(projectDir, conf = readConfig()) {
433
433
  }
434
434
 
435
435
  // The self-announcing fresh session command (single-quoted so it survives osascript→shell un-escaped).
436
- export const RECAP_CMD = "claude 'Recap the handoff you just took over — what was the previous session doing, and where do we continue? Then wait for me.'";
436
+ // The recap must be READABLE: on 2026-08-27 a takeover session answered with three consecutive
437
+ // 4-5k-character status dumps and the operator abandoned the app. Brevity is part of the prompt.
438
+ export const RECAP_CMD = "claude 'Recap the handoff you just took over — task, state, next step — in at most 3 sentences. Then wait for me. Keep all replies short by default: no status tables, no headers, no walls of text unless I explicitly ask for detail.'";
437
439
 
438
440
  // Spawn a fresh self-announcing session WITHOUT the dialog (manual handoff — the user already decided).
439
441
  // ONE suppression check for every path that can open a terminal window. There were two names for