trantor 0.17.59 → 0.17.61

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.
@@ -13,7 +13,7 @@
13
13
  "name": "trantor",
14
14
  "source": "./",
15
15
  "description": "The hub-world for AI agent crews. Say \"fire up the crew\" and Claude becomes the architect: a plan-aware Advisor routes the work (solo / cheap inline calls / live crew of Codex, GLM, Kimi & DeepSeek in their own terminal windows), a Kanban/flow command center with a testing gate tracks it, and an economics brain (Scrooge) keeps the receipts. Includes the relay MCP, a SessionStart auto-discovery hook, and a PreCompact context-handoff so a fresh session can take over a full window instead of compacting.",
16
- "version": "0.17.59",
16
+ "version": "0.17.60",
17
17
  "author": {
18
18
  "name": "Sasha Bogojevic"
19
19
  },
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trantor",
3
- "version": "0.17.59",
3
+ "version": "0.17.61",
4
4
  "description": "Trantor — the hub-world for AI agent crews: live message bus, presence, project Kanban/flow board + crew orchestration for independent AI coding agents (Claude, Codex, Gemini, Kimi, DeepSeek)",
5
5
  "mcpServers": {
6
6
  "relay": {
package/bin/cli.mjs CHANGED
@@ -25,6 +25,7 @@ switch (cmd) {
25
25
  case "up": process.argv.splice(2, 1); spawn("/bin/bash", [join(ROOT, "bin/crew.sh"), "up", ...args], { stdio: "inherit", cwd: process.cwd() }).on("exit", c => process.exit(c ?? 0)); break;
26
26
  case "down": spawn("/bin/bash", [join(ROOT, "bin/crew.sh"), "down", ...args], { stdio: "inherit", cwd: process.cwd() }).on("exit", c => process.exit(c ?? 0)); break;
27
27
  case "swap": spawn("/bin/bash", [join(ROOT, "bin/crew.sh"), "swap", ...args], { stdio: "inherit", cwd: process.cwd() }).on("exit", c => process.exit(c ?? 0)); break;
28
+ case "prune": spawn("/bin/bash", [join(ROOT, "bin/crew.sh"), "prune", ...args], { stdio: "inherit", cwd: process.cwd() }).on("exit", c => process.exit(c ?? 0)); break;
28
29
  case "hub": {
29
30
  const sub = args[0];
30
31
  // Per-project hub routing (TDD §12.1): a project lives on exactly ONE hub; codependent
@@ -70,6 +71,7 @@ switch (cmd) {
70
71
  case "summarize": run("bin/summarize.mjs"); break;
71
72
  case "policy": run("bin/policy.mjs"); break;
72
73
  case "inbox": run("bin/inbox.mjs"); break;
74
+ case "duty": run("bin/duty.mjs"); break;
73
75
  case "identity": {
74
76
  const { load, publicView, generate, keyPath } = await import(join(ROOT, "lib/identity.mjs"));
75
77
  const sub = args[0], name = args[1] || "human";
@@ -166,6 +168,7 @@ switch (cmd) {
166
168
  trantor watch live bus feed in the terminal
167
169
  trantor inbox THIS session's unread bus messages, signed (works under enforce) — [--all] [--consume] [--json]
168
170
  trantor policy the autonomy ladder: show | set <project> <1-4> | link <a> <b> --reason "<why>"
171
+ trantor duty the always-on fleet duty agent: up | down | status — hub-escalated triage so you are not the switchboard
169
172
 
170
173
  Claude Code plugin (the orchestrator side):
171
174
  claude plugin marketplace add sashabogi/trantor && claude plugin install trantor
@@ -136,7 +136,9 @@ const cli = CLI[AGENT] || (NATIVE.has(AGENT) ? null : CLI.opencode);
136
136
  if (!cli) { console.error(`unknown agent '${AGENT}' (native: ${[...NATIVE].join(", ")}; any other name = an opencode provider seat)`); process.exit(1); }
137
137
  if (!CLI[AGENT]) log(`'${AGENT}' is not a built-in seat — running it as an opencode provider (BYOM)`);
138
138
 
139
- const RULES = `Rules: you are ${SESSION} on the trantor crew. Work your assigned file(s), report on the bus (relay_send, <280 chars), move your Kanban card as you go (doing -> testing -> done; run the tests in 'testing', use 'failed' + a report if they break). When your work for THIS message is finished, END YOUR TURN — do NOT park, do NOT loop relay_wait; the runner waits for you and will wake you with the next message.`;
139
+ // RUNNER_RULES / RUNNER_KICKOFF env overrides: the runner is also the substrate for non-crew
140
+ // always-on seats (the fleet DUTY agent, bin/duty.mjs) whose doctrine is not "work your card".
141
+ const RULES = process.env.RUNNER_RULES || `Rules: you are ${SESSION} on the trantor crew. Work your assigned file(s), report on the bus (relay_send, <280 chars), move your Kanban card as you go (doing -> testing -> done; run the tests in 'testing', use 'failed' + a report if they break). When your work for THIS message is finished, END YOUR TURN — do NOT park, do NOT loop relay_wait; the runner waits for you and will wake you with the next message.`;
140
142
 
141
143
  // ---- failure visibility ----------------------------------------------------
142
144
  // A turn's CLI can fail (credits exhausted, auth, crash) and the runner would just
package/bin/crew.sh CHANGED
@@ -125,6 +125,36 @@ end tell
125
125
  OSA
126
126
  }
127
127
 
128
+ # surgical STATE row removal: drop every row matching PROJECT+KIND (+AGENT/+HANDLE when given).
129
+ # Empty $3/$4 = wildcard. Callers beware: uses _parse_row, which clobbers the RP/RK/RA/RH globals.
130
+ _state_drop() { # $1=project $2=kind $3=agent(''=any) $4=handle(''=any)
131
+ [ "$DRY" = "1" ] && return 0
132
+ [ -f "$STATE" ] || return 0
133
+ local tmp="$STATE.tmp" line
134
+ : > "$tmp"
135
+ while IFS= read -r line; do
136
+ [ -n "$line" ] || continue
137
+ _parse_row "$line"
138
+ if [ "$RP" = "$1" ] && [ "$RK" = "$2" ] && { [ -z "$3" ] || [ "$RA" = "$3" ]; } && { [ -z "$4" ] || [ "$RH" = "$4" ]; }; then continue; fi
139
+ printf '%s\t%s\t%s\t%s\n' "$RP" "$RK" "$RA" "$RH" >> "$tmp"
140
+ done < "$STATE"
141
+ mv "$tmp" "$STATE"; [ -s "$STATE" ] || rm -f "$STATE"
142
+ }
143
+
144
+ # Belt-and-suspenders on seat teardown: closing the pane/window SHOULD take the runner with it, but a
145
+ # STALE handle (the 2026-07-30 duplicate-row incident) closes nothing while teardown thinks it's done —
146
+ # the runner survives invisible, still long-polling the inbox. So teardown also kills the seat's
147
+ # processes directly: the per-seat launcher shell, and the runner via the same ANCHORED pattern
148
+ # doctrine as reap_seat (…/<project>$ — a sibling project can never match). CREW_NO_PROC_KILL=1 is the
149
+ # test-suite escape hatch: the suite seeds real project names, and a live crew must survive `npm test`.
150
+ _kill_seat_procs() { # $1=project $2=agent
151
+ [ "${CREW_NO_PROC_KILL:-0}" = "1" ] && return 0
152
+ [ -n "$1" ] && [ -n "$2" ] || return 0
153
+ local pid
154
+ for pid in $(pgrep -f "seats/$1-$2\.sh" 2>/dev/null); do run "kill -9 $pid 2>/dev/null"; done
155
+ for pid in $(pgrep -f "crew-runner\.mjs $2 .*/$1"'$' 2>/dev/null); do run "kill -9 $pid 2>/dev/null"; done
156
+ }
157
+
128
158
  # ── down: PROJECT-SCOPED teardown (never touches another project's crew) ─────────────────────────────
129
159
  usage_down() {
130
160
  cat <<EOF
@@ -187,6 +217,7 @@ down() {
187
217
  fi ;;
188
218
  win|attach) { [ "${#AGENTS[@]}" -gt 0 ] && [ "$K2" = "attach" ]; } && continue; _kill_win "$H2" ;;
189
219
  esac
220
+ case "$K2" in cmuxws|attach) : ;; *) _kill_seat_procs "$P2" "$A2" ;; esac
190
221
  done
191
222
 
192
223
  # rewrite STATE minus the rows we tore down (leaves OTHER projects' rows intact)
@@ -205,13 +236,26 @@ down() {
205
236
  echo "— crew torn down ($SCOPE_DESC)"
206
237
  }
207
238
  [ "$CMD" = "down" ] && { down "$@"; exit $?; }
208
- case "$CMD" in up|swap) ;; *) echo "usage: crew.sh up <agent...> | crew.sh swap <old> <new[:provider[/model]]> | crew.sh down [<agent>...] [--all --yes]"; exit 1 ;; esac
239
+ 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
209
240
 
210
241
  # self-heal: drop STATE rows whose Terminal window is already gone (dead crews from past sessions), so the
211
- # file doesn't accumulate ghosts across ups. tmux rows are validated by their session existing.
242
+ # file doesn't accumulate ghosts across ups. tmux rows are validated by their session existing; cmux rows
243
+ # by the workspace/surface uuid still existing on the control socket. Socket off (or an empty/unparsable
244
+ # workspace list) ⇒ cmux rows are KEPT — we can't prove death, and they self-heal next time it answers.
212
245
  prune_dead_state() {
213
246
  [ -f "$STATE" ] || return 0
214
247
  [ "$DRY" = "1" ] && return 0
248
+ local CLIVE=""
249
+ if _cmux_ok; then
250
+ local wids wid
251
+ wids="$(_cmux workspace list --id-format both --json 2>/dev/null | node -e 'let d="";process.stdin.on("data",c=>d+=c).on("end",()=>{try{const o=JSON.parse(d.slice(d.search(/[\[{]/)));const a=Array.isArray(o)?o:(o.workspaces||[]);console.log(a.map(x=>x.id).filter(Boolean).join(" "))}catch(e){}})')"
252
+ if [ -n "$wids" ]; then
253
+ CLIVE="$wids"
254
+ for wid in $wids; do
255
+ CLIVE="$CLIVE $(_cmux list-pane-surfaces --workspace "$wid" --id-format uuids --json 2>/dev/null | node -e 'let d="";process.stdin.on("data",c=>d+=c).on("end",()=>{try{const o=JSON.parse(d.slice(d.search(/[\[{]/)));const a=o.surfaces||o.panes||o;console.log((Array.isArray(a)?a:[]).map(s=>s.id||s.surface_id).filter(Boolean).join(" "))}catch(e){}})')"
256
+ done
257
+ fi
258
+ fi
215
259
  local tmp="$STATE.tmp" line alive
216
260
  : > "$tmp"
217
261
  while IFS= read -r line; do
@@ -222,11 +266,15 @@ prune_dead_state() {
222
266
  [ -n "$(osascript -e "tell application \"Terminal\" to get id of (first window whose id is $RH)" 2>/dev/null)" ] || alive=0
223
267
  elif [ "$RK" = "tmux" ]; then
224
268
  tmux has-session -t "trantor:$RP" 2>/dev/null || alive=0
269
+ elif [ "$RK" = "cmuxws" ] || [ "$RK" = "cmux" ]; then
270
+ if [ -n "$CLIVE" ]; then case " $CLIVE " in *" $RH "*) : ;; *) alive=0 ;; esac; fi
225
271
  fi
226
272
  [ "$alive" = "1" ] && printf '%s\t%s\t%s\t%s\n' "$RP" "$RK" "$RA" "$RH" >> "$tmp"
227
273
  done < "$STATE"
228
274
  mv "$tmp" "$STATE"; [ -s "$STATE" ] || rm -f "$STATE"
229
275
  }
276
+ # `crew.sh prune` — run the self-heal on demand (ops: clean ghost rows without spawning anything).
277
+ [ "$CMD" = "prune" ] && { prune_dead_state; echo "— pruned dead crew rows ($STATE) —"; exit 0; }
230
278
 
231
279
  # --task/--difficulty drive LAZY live-model selection for provider-only specs (agent:provider).
232
280
  TASK="code"; DIFF="medium"; _ARGS=()
@@ -395,17 +443,86 @@ spawn_cmux() { # $@ = specs
395
443
  echo " ~/.config/cmux/cmux.json (cmux auto-reloads). —"
396
444
  spawn_cmux_applescript "$@"; return
397
445
  fi
446
+ # ── replace, never stack (workspace edition, 2026-08-07) ──────────────────────────────────────────
447
+ # Every `up` used to create a brand-new workspace — no awareness of a crew already on screen — so
448
+ # repeated ups (and the bus-verify RETRY path, which re-enters spawn_crew) stacked dead
449
+ # trantor:<proj> tabs in the sidebar. Observed six deep on crebral-health. Now the NEWEST tracked
450
+ # workspace for this project is REUSED: each spec becomes a fresh pane inside it, replacing any old
451
+ # pane for the same agent; older stacked workspaces are closed. Rows are read even in DRY mode
452
+ # (read-only) so tests can seed a board.
453
+ local REUSE_WS="" line w
454
+ local stale_ws=()
455
+ if [ -f "$STATE" ]; then
456
+ while IFS= read -r line; do
457
+ [ -n "$line" ] || continue
458
+ _parse_row "$line"
459
+ { [ "$RP" = "$PROJ" ] && [ "$RK" = "cmuxws" ]; } || continue
460
+ [ -n "$REUSE_WS" ] && stale_ws+=("$REUSE_WS")
461
+ REUSE_WS="$RH"
462
+ done < "$STATE"
463
+ fi
464
+ if [ "${#stale_ws[@]}" -gt 0 ]; then
465
+ for w in "${stale_ws[@]}"; do
466
+ echo " → closing stale stacked crew workspace for $PROJ ($w)"
467
+ _cmux_close_tab "$w"; _state_drop "$PROJ" "cmuxws" "" "$w"
468
+ done
469
+ prune_dead_state # the closed workspaces' seat rows just died with them
470
+ fi
471
+ # Untracked strays: LIVE workspaces named trantor:<proj> that STATE doesn't know (rows lost, or a
472
+ # pre-fix pileup). Adopt one as the reuse target if we have none; close the rest.
473
+ if [ "$DRY" != "1" ]; then
474
+ local named nid
475
+ named="$(_cmux workspace list --id-format both --json 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||[]);console.log(a.filter(x=>x.custom_title===process.env.WSNAME||x.name===process.env.WSNAME).map(x=>x.id).filter(Boolean).join(" "))}catch(e){}})')"
476
+ for nid in $named; do
477
+ [ "$nid" = "$REUSE_WS" ] && continue
478
+ [ -f "$STATE" ] && grep -qF "$nid" "$STATE" && continue # tracked → handled above
479
+ if [ -z "$REUSE_WS" ]; then
480
+ echo " → adopting existing untracked crew workspace for $PROJ ($nid)"
481
+ REUSE_WS="$nid"; record_state "$PROJ" "cmuxws" "__ws__" "$nid"
482
+ else
483
+ echo " → closing stray crew workspace for $PROJ ($nid)"
484
+ _cmux_close_tab "$nid"
485
+ fi
486
+ done
487
+ fi
398
488
  # Grid tiling: COLS = ceil(sqrt(N)) → 2 seats side-by-side, 4 = 2×2, 6 = 3×2. Row 0 is built with
399
489
  # RIGHT splits off the previous column; each later row splits DOWN from the pane directly above it.
400
490
  # Every split TARGETS a recorded surface id (--surface) — never "whatever pane happens to be focused",
401
- # which is what produced the old staircase layout.
491
+ # which is what produced the old staircase layout. (In REUSE mode a replaced seat splits off its OWN
492
+ # old pane — keeping its spot — and added seats split right off the previous new pane; the fresh-grid
493
+ # math only applies to a fresh workspace.)
402
494
  local N=$# COLS=1; while [ $(( COLS * COLS )) -lt "$N" ]; do COLS=$(( COLS + 1 )); done
403
495
  local SPEC wsid="" surf="" i=0
404
496
  local surfs=()
497
+ [ -n "$REUSE_WS" ] && wsid="$REUSE_WS"
405
498
  for SPEC in "$@"; do
406
499
  resolve_spec "$SPEC"
407
500
  local cmd launcher; cmd="$(RUN_CMD)"; launcher="$(_seat_launcher "$AGENT" "$cmd")"
408
- if [ "$i" = "0" ]; then
501
+ if [ -n "$REUSE_WS" ]; then
502
+ # replace-in-place: split the fresh pane FIRST (targeting the agent's old pane when tracked),
503
+ # then close the old pane — split-first so the workspace can never hit zero surfaces mid-swap.
504
+ local OLD_SURF=""
505
+ if [ -f "$STATE" ]; then
506
+ while IFS= read -r line; do
507
+ [ -n "$line" ] || continue
508
+ _parse_row "$line"
509
+ [ "$RP" = "$PROJ" ] && [ "$RK" = "cmux" ] && [ "$RA" = "$AGENT" ] && OLD_SURF="$RH"
510
+ done < "$STATE"
511
+ fi
512
+ if [ "$DRY" = "1" ]; then
513
+ echo "[dry] cmux: reuse workspace $wsid — new-split for $AGENT${OLD_SURF:+ (replacing $OLD_SURF)}"
514
+ surf="%DRYT$i"
515
+ [ -n "$OLD_SURF" ] && _cmux_close_term "$OLD_SURF"
516
+ else
517
+ local tflag=()
518
+ if [ -n "$OLD_SURF" ]; then tflag=(--surface "$OLD_SURF")
519
+ elif [ "$i" -gt 0 ] && [ -n "${surfs[$(( i - 1 ))]}" ]; then tflag=(--surface "${surfs[$(( i - 1 ))]}"); fi
520
+ surf="$(_cmux new-split right --workspace "$wsid" ${tflag[@]+"${tflag[@]}"} --id-format uuids --json 2>/dev/null | _cmux_surf_json)"
521
+ [ -n "$surf" ] || surf="$(_cmux new-split right --workspace "$wsid" --id-format uuids --json 2>/dev/null | _cmux_surf_json)"
522
+ [ -n "$surf" ] && { _cmux send --surface "$surf" "bash $launcher" >/dev/null 2>&1; _cmux send-key --surface "$surf" enter >/dev/null 2>&1; }
523
+ if [ -n "$OLD_SURF" ]; then _cmux_close_term "$OLD_SURF"; _state_drop "$PROJ" "cmux" "$AGENT" ""; fi
524
+ fi
525
+ elif [ "$i" = "0" ]; then
409
526
  if [ "$DRY" = "1" ]; then
410
527
  echo "[dry] cmux: new-workspace (cwd $DIR) --command 'bash $launcher' → rename 'trantor:$PROJ'"
411
528
  wsid="%DRYWS"; surf="%DRYT0"
@@ -444,10 +561,54 @@ spawn_cmux_applescript() { # $@ = specs
444
561
  local N=$# COLS=1; while [ $(( COLS * COLS )) -lt "$N" ]; do COLS=$(( COLS + 1 )); done
445
562
  local SPEC tabid="" termid="" i=0
446
563
  local terms=()
564
+ # replace, never stack — AppleScript edition. No socket ⇒ no liveness list, so the newest tracked
565
+ # tab is validated by asking cmux for it directly; a dead tracked tab is dropped, older stacked
566
+ # tabs are closed. Reuse then rides the normal split path (i>0) from the first seat.
567
+ local line t REUSE_TAB=""
568
+ local stale_tabs=()
569
+ if [ -f "$STATE" ]; then
570
+ while IFS= read -r line; do
571
+ [ -n "$line" ] || continue
572
+ _parse_row "$line"
573
+ { [ "$RP" = "$PROJ" ] && [ "$RK" = "cmuxws" ]; } || continue
574
+ [ -n "$REUSE_TAB" ] && stale_tabs+=("$REUSE_TAB")
575
+ REUSE_TAB="$RH"
576
+ done < "$STATE"
577
+ fi
578
+ if [ "${#stale_tabs[@]}" -gt 0 ]; then
579
+ for t in "${stale_tabs[@]}"; do
580
+ echo " → closing stale stacked crew workspace for $PROJ ($t)"
581
+ _cmux_close_tab "$t"; _state_drop "$PROJ" "cmuxws" "" "$t"
582
+ done
583
+ fi
584
+ if [ -n "$REUSE_TAB" ] && [ "$DRY" != "1" ]; then
585
+ local ok; ok="$(osascript 2>/dev/null <<OSA
586
+ tell application "cmux"
587
+ repeat with w in windows
588
+ repeat with tt in tabs of w
589
+ if (id of tt) is "$REUSE_TAB" then return "OK"
590
+ end repeat
591
+ end repeat
592
+ return "ERR"
593
+ end tell
594
+ OSA
595
+ )"
596
+ [ "$ok" = "OK" ] || { _state_drop "$PROJ" "cmuxws" "" "$REUSE_TAB"; _state_drop "$PROJ" "cmux" "" ""; REUSE_TAB=""; }
597
+ fi
598
+ [ -n "$REUSE_TAB" ] && { tabid="$REUSE_TAB"; echo " → reusing existing crew workspace for $PROJ ($tabid)"; }
447
599
  for SPEC in "$@"; do
448
600
  resolve_spec "$SPEC"
449
601
  local cmd launcher; cmd="$(RUN_CMD)"; launcher="$(_seat_launcher "$AGENT" "$cmd")"
450
- if [ "$i" = "0" ]; then
602
+ # In REUSE mode, replace-in-place: split off the agent's old terminal when tracked, close it after.
603
+ local OLD_SURF=""
604
+ if [ -n "$REUSE_TAB" ] && [ -f "$STATE" ]; then
605
+ while IFS= read -r line; do
606
+ [ -n "$line" ] || continue
607
+ _parse_row "$line"
608
+ [ "$RP" = "$PROJ" ] && [ "$RK" = "cmux" ] && [ "$RA" = "$AGENT" ] && OLD_SURF="$RH"
609
+ done < "$STATE"
610
+ fi
611
+ if [ -z "$tabid" ] && [ "$i" = "0" ]; then
451
612
  if [ "$DRY" = "1" ]; then
452
613
  echo "[dry] cmux(AppleScript): new tab (trantor:$PROJ) + run 'bash $launcher'"; tabid="%DRYTAB"; termid="%DRYT0"
453
614
  else
@@ -470,11 +631,15 @@ OSA
470
631
  fi
471
632
  record_state "$PROJ" "cmuxws" "__ws__" "$tabid"
472
633
  else
473
- local dir target
474
- if [ $(( i / COLS )) = "0" ]; then dir="right"; target="${terms[$(( i - 1 ))]}"
475
- else dir="down"; target="${terms[$(( i - COLS ))]}"; fi
634
+ local dir target=""
635
+ if [ -n "$OLD_SURF" ]; then dir="right"; target="$OLD_SURF" # replace-in-place: keep the seat's spot
636
+ elif [ "$i" -gt 0 ]; then
637
+ if [ $(( i / COLS )) = "0" ]; then dir="right"; target="${terms[$(( i - 1 ))]}"
638
+ else dir="down"; target="${terms[$(( i - COLS ))]}"; fi
639
+ else dir="right"; fi # reuse mode, first seat, nothing tracked → focused terminal
476
640
  if [ "$DRY" = "1" ]; then
477
641
  echo "[dry] cmux(AppleScript): split $dir from ${target:-<focused>} + run 'bash $launcher'"; termid="%DRYT$i"
642
+ [ -n "$OLD_SURF" ] && _cmux_close_term "$OLD_SURF"
478
643
  else
479
644
  termid="$(osascript 2>/dev/null <<OSA
480
645
  tell application "cmux"
@@ -497,6 +662,7 @@ tell application "cmux"
497
662
  end tell
498
663
  OSA
499
664
  )"
665
+ [ -n "$OLD_SURF" ] && [ -n "$termid" ] && [ "$termid" != "ERR" ] && { _cmux_close_term "$OLD_SURF"; _state_drop "$PROJ" "cmux" "$AGENT" ""; }
500
666
  fi
501
667
  fi
502
668
  terms+=("$termid")
package/bin/duty.mjs ADDED
@@ -0,0 +1,111 @@
1
+ #!/usr/bin/env node
2
+ // trantor duty — the always-on fleet DUTY AGENT: one seat that watches the whole hub and triages
3
+ // so the human never has to be a switchboard.
4
+ //
5
+ // trantor duty up [--hub <url>] [--agent claude] start (idempotent — reaps a prior seat)
6
+ // trantor duty down stop
7
+ // trantor duty status pid + last turns + presence
8
+ //
9
+ // Division of labor (the overseer doctrine, extended): DETECTION stays mechanical and hub-side —
10
+ // RELAY_DUTY_SESSION makes the hub DM this seat when a direct message sits undelivered past
11
+ // RELAY_DUTY_UNDELIVERED_MS or the overseer emits a warning. The SEAT only triages: relay, wake,
12
+ // annotate, and only involves the human when a real decision is needed. It runs under the same
13
+ // crew-runner that keeps crew seats alive (long-poll wake, turn telemetry, failure reporting) —
14
+ // just with a triage doctrine instead of "work your card" (RUNNER_RULES / CREW_KICKOFF).
15
+ import { spawn, execSync } from "node:child_process";
16
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, openSync, rmSync } from "node:fs";
17
+ import { join, dirname } from "node:path";
18
+ import { homedir } from "node:os";
19
+ import { fileURLToPath } from "node:url";
20
+ import { loadOrCreate } from "../lib/identity.mjs";
21
+ import { sfetchJson } from "../lib/signed-fetch.mjs";
22
+
23
+ const ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
24
+ const BUS = process.env.AGENT_BUS_DIR || join(homedir(), ".agent-bus");
25
+ const DIR = join(BUS, "fleet"); // the seat's cwd — project name "fleet"
26
+ const PIDF = join(BUS, "duty.pid");
27
+ const LOGF = join(BUS, "duty.log");
28
+
29
+ const argv = process.argv.slice(2);
30
+ const cmd = argv[0] || "status";
31
+ const val = (k, d) => { const i = argv.indexOf(`--${k}`); return i >= 0 ? (argv[i + 1] || d) : d; };
32
+
33
+ let config = {}; try { config = JSON.parse(readFileSync(join(BUS, "config.json"), "utf8")); } catch {}
34
+ // The fleet hub: where the projects live. Default = the most common per-project hub in config,
35
+ // falling back to the global url — a fleet watcher belongs where the fleet is.
36
+ function fleetHub() {
37
+ const counts = new Map();
38
+ for (const u of Object.values(config.hubs || {})) counts.set(u, (counts.get(u) || 0) + 1);
39
+ const top = [...counts.entries()].sort((a, b) => b[1] - a[1])[0];
40
+ return val("hub", top?.[0] || config.url || "http://127.0.0.1:4477");
41
+ }
42
+
43
+ const AGENT = val("agent", "claude");
44
+ const SESSION = `${AGENT}:fleet`;
45
+
46
+ const RULES = `Rules: you are ${SESSION}, the trantor fleet DUTY AGENT — the always-on triage seat. You NEVER write code and NEVER edit project files. On every wake: (1) read the message(s) that woke you; (2) triage with your relay tools — relay_peers for who is live/down, relay_board with the project param for any board, relay_inbox for your own backlog; runner logs live at ~/.agent-bus/logs/<agent>-<project>.jsonl if a seat looks dead; (3) ACT: an UNDELIVERED escalation means the recipient is idle, deaf (wrong hub / stale hooks — a known failure mode), or gone — relay the content to a live session that can act, wake a crew seat with a direct message, or post the information into the project lane so the human's app notifies them; an OVERSEER warning means two parties may collide — message them to coordinate; a seat reported down/errored — check its log tail and either resend its contract or report exactly what is needed. (4) Report each action in ONE bus message (<280 chars) to the lane it concerns. If only a human can decide, say exactly that, in that lane, once. Then END YOUR TURN — the runner wakes you for the next event.`;
47
+
48
+ const KICKOFF = `You are ${SESSION}, the fleet duty agent, freshly started. Do a short patrol: relay_peers (note anything down/errored), then relay_inbox. Handle what is actionable per the Rules, post one line to the bus saying the duty seat is on watch, and end your turn.\n\n${RULES}`;
49
+
50
+ async function ensureFleetIdentity(hub) {
51
+ const id = loadOrCreate(SESSION, "agent");
52
+ const probe = await sfetchJson(`${hub}/peers`, { method: "GET", identity: id, signal: AbortSignal.timeout(5000) }).catch(() => null);
53
+ if (probe && probe.status !== 401) return true; // enrolled (or hub not enforcing)
54
+ // Not enrolled: mint an owner invite with FLEET-WIDE write (write ⊇ read) and enroll with it.
55
+ const owner = loadOrCreate(config.ownerIdentity || "admin", "human");
56
+ const inv = await sfetchJson(`${hub}/invite`, { identity: owner, payload: { scopes: [{ project: "*", role: "write" }], ttlSec: 3600 }, signal: AbortSignal.timeout(5000) });
57
+ const invJson = await inv.json().catch(() => ({}));
58
+ if (!inv.ok || !invJson.token) { console.error(`could not mint fleet invite on ${hub}: ${invJson.error || inv.status} — is ${config.ownerIdentity || "admin"} the owner there?`); return false; }
59
+ const enr = await sfetchJson(`${hub}/enroll`, { identity: id, payload: { token: invJson.token, pubkey: id.pubkey, name: SESSION, kind: "agent" }, signal: AbortSignal.timeout(5000) });
60
+ const enrJson = await enr.json().catch(() => ({}));
61
+ if (!enr.ok) { console.error(`fleet enroll failed: ${enrJson.error || enr.status}`); return false; }
62
+ console.log(`— enrolled ${SESSION} on ${hub} with fleet-wide write —`);
63
+ return true;
64
+ }
65
+
66
+ function alivePid() {
67
+ try {
68
+ const pid = Number(readFileSync(PIDF, "utf8"));
69
+ if (pid && process.kill(pid, 0) === undefined) return pid;
70
+ } catch {}
71
+ return 0;
72
+ }
73
+
74
+ if (cmd === "up") {
75
+ const hub = fleetHub();
76
+ mkdirSync(DIR, { recursive: true });
77
+ const prior = alivePid();
78
+ if (prior) { console.log(`— reaping prior duty seat (pid ${prior}) —`); try { process.kill(prior); } catch {} }
79
+ try { execSync(`pkill -f "crew-runner.mjs ${AGENT} ${DIR}"`, { stdio: "ignore" }); } catch {}
80
+ if (!(await ensureFleetIdentity(hub))) process.exit(1);
81
+ const out = openSync(LOGF, "a");
82
+ const child = spawn(process.execPath, [join(ROOT, "bin", "crew-runner.mjs"), AGENT, DIR], {
83
+ detached: true, stdio: ["ignore", out, out],
84
+ env: { ...process.env, RELAY_URL: hub, RUNNER_RULES: RULES, CREW_KICKOFF: KICKOFF },
85
+ });
86
+ child.unref();
87
+ writeFileSync(PIDF, String(child.pid));
88
+ console.log(`— duty agent up: ${SESSION} (pid ${child.pid}) watching ${hub} — log: ${LOGF}`);
89
+ console.log(` hub feeds it: undelivered DMs (>${Math.round(Number(process.env.RELAY_DUTY_UNDELIVERED_MS || 600000) / 60000)}m) + overseer warnings — set RELAY_DUTY_SESSION=${SESSION} on the hub service.`);
90
+ process.exit(0);
91
+ }
92
+
93
+ if (cmd === "down") {
94
+ const pid = alivePid();
95
+ if (pid) { try { process.kill(pid); } catch {} console.log(`— duty seat stopped (pid ${pid}) —`); }
96
+ else console.log("no duty seat running");
97
+ try { execSync(`pkill -f "crew-runner.mjs ${AGENT} ${DIR}"`, { stdio: "ignore" }); } catch {}
98
+ try { rmSync(PIDF, { force: true }); } catch {}
99
+ process.exit(0);
100
+ }
101
+
102
+ // status
103
+ {
104
+ const pid = alivePid();
105
+ console.log(pid ? `duty seat RUNNING (pid ${pid}) as ${SESSION}` : "duty seat NOT running");
106
+ try {
107
+ const lines = readFileSync(join(BUS, "logs", `${AGENT}-fleet.jsonl`), "utf8").trim().split("\n").slice(-3);
108
+ console.log("last turns:"); for (const l of lines) console.log(` ${l}`);
109
+ } catch { console.log("(no turns logged yet)"); }
110
+ process.exit(0);
111
+ }
package/hub.mjs CHANGED
@@ -245,6 +245,7 @@ function overseerTick() {
245
245
  overseerWarned.set(key, now());
246
246
  appendEvent("overseer.warn", c.project, "overseer",
247
247
  { kind: c.kind, sessions: c.sessions || [], files: c.files || [], detail: c.detail || "", narrated: false });
248
+ if (DUTY_SESSION) hubSend(DUTY_SESSION, `⚠️ OVERSEER ${c.kind} [${c.project}]: ${c.detail || ""} — if the parties are not already coordinating, message them.`, c.project);
248
249
  const level = _overseer.levelFor ? _overseer.levelFor(c.project, pol.autonomy) : 1;
249
250
  if (level >= 3 && c.kind === "file-conflict") {
250
251
  const g = { id: ++state.verifyGateSeq, project: c.project, status: "open", ts: now(),
@@ -257,6 +258,41 @@ function overseerTick() {
257
258
  }
258
259
  setInterval(overseerTick, OVERSEER_TICK_MS).unref?.();
259
260
 
261
+ // --- the DUTY AGENT feed (deterministic escalation; the seat itself is bin/duty.mjs) -----------
262
+ // RELAY_DUTY_SESSION names the always-on triage seat (e.g. "claude:fleet"). The hub DMs it when:
263
+ // 1. a DIRECT message has sat undelivered past RELAY_DUTY_UNDELIVERED_MS — the sender believes
264
+ // it was heard and nobody is listening (the crebral-health 4-day failure mode);
265
+ // 2. the overseer emits a warning (wired inside overseerTick below).
266
+ // Escalations are hub-authored ("hub:duty") — they never impersonate a session — and dedup per
267
+ // message id so a standing outage escalates once, not every tick.
268
+ const DUTY_SESSION = String(process.env.RELAY_DUTY_SESSION || "");
269
+ const DUTY_UNDELIVERED_MS = Number(process.env.RELAY_DUTY_UNDELIVERED_MS || 10 * 60 * 1000);
270
+ const dutyEscalated = new Set();
271
+ function hubSend(to, text, project) {
272
+ const msg = { id: ++state.seq, ts: now(), from: "hub:duty", to, text: String(text).slice(0, 2000), project: String(project || "").slice(0, 80) };
273
+ state.messages.push(msg); if (state.messages.length > 5000) state.messages.splice(0, 1000);
274
+ dirty = true; pushToStreams(msg);
275
+ appendEvent("message", msg.project, msg.from, { msgId: msg.id, toSession: msg.to, text: msg.text.slice(0, 2000), refs: [] });
276
+ return msg;
277
+ }
278
+ function dutyTick() {
279
+ if (!DUTY_SESSION) return;
280
+ const cutoff = now() - DUTY_UNDELIVERED_MS;
281
+ const floor = now() - 24 * 3600 * 1000; // never escalate ancient history
282
+ for (const m of state.messages) {
283
+ if (m.ts > cutoff || m.ts < floor) continue;
284
+ if (!m.to || m.to === "all" || m.to === DUTY_SESSION || m.from === "hub:duty") continue;
285
+ if (dutyEscalated.has(m.id)) continue;
286
+ if ((state.peers[m.to]?.deliveredUpTo || 0) >= m.id) continue;
287
+ dutyEscalated.add(m.id);
288
+ hubSend(DUTY_SESSION,
289
+ `⚠️ UNDELIVERED for ${Math.round((now() - m.ts) / 60000)}m: #${m.id} ${m.from} -> ${m.to} — "${String(m.text).slice(0, 280)}" — the recipient has not been handed this. Triage: is the recipient's session idle, deaf (wrong hub / old hooks), or gone? Relay, wake, or note it on their board.`,
290
+ m.project || "");
291
+ }
292
+ if (dutyEscalated.size > 5000) { let n = dutyEscalated.size - 4000; for (const k of dutyEscalated) { dutyEscalated.delete(k); if (--n <= 0) break; } }
293
+ }
294
+ setInterval(dutyTick, OVERSEER_TICK_MS).unref?.();
295
+
260
296
  if (durableStore?.subscribeChanges) {
261
297
  durableStore.subscribeChanges((p) => {
262
298
  if (p && p.src === HUB_SRC) return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trantor",
3
- "version": "0.17.59",
3
+ "version": "0.17.61",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "trantor": "bin/cli.mjs"
@@ -11,7 +11,7 @@
11
11
  "pg": "^8.22.0"
12
12
  },
13
13
  "scripts": {
14
- "test": "node test.mjs && node test-scenarios.mjs && node test-failure.mjs && node test-handoff.mjs && node test-agents.mjs && node test-update.mjs && node test-handoff-guard.mjs && node test-balances.mjs && node test-subagent-cost.mjs && node test-inbox.mjs && node test-inflight.mjs && node test-notify.mjs && node test-focus.mjs && node test-reaper.mjs && node test-events.mjs && node test-scrub.mjs && node test-store-delta.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-inbox-delivery.mjs && node test-hub-routing.mjs && python3 engine/test-routing.py && node test-reconcile.mjs && bash test-crew.sh"
14
+ "test": "node test.mjs && node test-scenarios.mjs && node test-failure.mjs && node test-handoff.mjs && node test-agents.mjs && node test-update.mjs && node test-handoff-guard.mjs && node test-balances.mjs && node test-subagent-cost.mjs && node test-inbox.mjs && node test-inflight.mjs && node test-notify.mjs && node test-focus.mjs && node test-reaper.mjs && node test-events.mjs && node test-scrub.mjs && node test-store-delta.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-inbox-delivery.mjs && node test-hub-routing.mjs && python3 engine/test-routing.py && node test-reconcile.mjs && bash test-crew.sh"
15
15
  },
16
16
  "description": "The hub-world for AI agent crews — orchestrate Claude Code, Codex, GLM, Kimi, DeepSeek & any OpenRouter model as live crews with a plan-aware Advisor, a Kanban/flow command center, a testing gate, and an economics brain (Scrooge).",
17
17
  "files": [