trantor 0.18.53 → 0.18.55

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.
Files changed (62) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +2 -0
  3. package/bin/crew/herdr.mjs +60 -4
  4. package/bin/crew/open.mjs +16 -6
  5. package/bin/crew/state.mjs +10 -1
  6. package/bin/crew-runner.mjs +111 -320
  7. package/bin/slop-gate.mjs +69 -41
  8. package/hooks/agent-notify.mjs +4 -13
  9. package/hooks/file-claim.mjs +6 -22
  10. package/hooks/handoff-now.mjs +5 -9
  11. package/hooks/heartbeat.mjs +16 -51
  12. package/hooks/inbox-deliver.mjs +8 -36
  13. package/hooks/lib/api.mjs +17 -66
  14. package/hooks/lib/handoff.mjs +69 -217
  15. package/hooks/lib/inbox-ledger.mjs +3 -11
  16. package/hooks/lib/resources.mjs +6 -16
  17. package/hooks/lib/update-check.mjs +4 -12
  18. package/hooks/overseer-warn.mjs +4 -16
  19. package/hooks/precompact.mjs +5 -10
  20. package/hooks/prompt-focus.mjs +6 -12
  21. package/hooks/sessionstart.mjs +38 -125
  22. package/hooks/statusline.mjs +4 -9
  23. package/hooks/stop-inbox.mjs +9 -49
  24. package/hooks/subagent-cost.mjs +2 -6
  25. package/hooks/subagent-start.mjs +4 -18
  26. package/hooks/todo-sync.mjs +2 -5
  27. package/hub/reaper.mjs +7 -2
  28. package/lib/autonomy.mjs +6 -30
  29. package/lib/balances.mjs +12 -48
  30. package/lib/classify-failure.mjs +6 -36
  31. package/lib/duty-nudges.mjs +2 -13
  32. package/lib/enroll.mjs +2 -12
  33. package/lib/identity.mjs +4 -22
  34. package/lib/integrate.mjs +6 -20
  35. package/lib/overseer.mjs +2 -15
  36. package/lib/project.mjs +22 -72
  37. package/lib/provider-keys.mjs +2 -5
  38. package/lib/providers.mjs +6 -27
  39. package/lib/redact.mjs +4 -24
  40. package/lib/same-project.mjs +2 -15
  41. package/lib/scrub.mjs +4 -13
  42. package/lib/seat-why.mjs +2 -9
  43. package/lib/seats.mjs +3 -18
  44. package/lib/signed-fetch.mjs +2 -8
  45. package/lib/splitbrain.mjs +4 -24
  46. package/lib/state/apply.mjs +7 -26
  47. package/lib/state/assemble.mjs +10 -33
  48. package/lib/state/cost.mjs +10 -32
  49. package/lib/state/derive.mjs +14 -52
  50. package/lib/state/driver.mjs +26 -108
  51. package/lib/state/gate.mjs +13 -71
  52. package/lib/state/migrate.mjs +3 -6
  53. package/lib/state/promote.mjs +7 -27
  54. package/lib/state/schema.mjs +12 -33
  55. package/lib/state/store.mjs +35 -121
  56. package/lib/state/validate.mjs +7 -31
  57. package/lib/store-contract.mjs +17 -40
  58. package/lib/store-pg.mjs +27 -32
  59. package/lib/subagent-manifest.mjs +2 -17
  60. package/lib/subagent-scan.mjs +0 -0
  61. package/lib/turn-policy.mjs +10 -40
  62. package/package.json +1 -1
@@ -1,13 +1,6 @@
1
1
  #!/usr/bin/env node
2
- // trantor crew runner — keeps a crew agent alive forever without burning tokens.
3
- //
4
- // node crew-runner.mjs <agent> [project-dir]
5
- //
6
- // The park problem: CLIs end their turn no matter what you prompt (harnesses actively kill
7
- // "call relay_wait repeatedly" loops). So the runner owns the waiting: it long-polls the bus
8
- // over plain HTTP (zero tokens, doubles as a heartbeat), and when a message addressed to this
9
- // agent arrives it RESUMES the CLI session (native resume = full context kept) with that
10
- // message as the prompt. The model just works and ends its turn; the runner does the rest.
2
+ // trantor crew runner — keeps a crew agent alive without burning tokens: it long-polls the bus
3
+ // (zero tokens) and resumes the CLI with each message. Usage: node crew-runner.mjs <agent> [dir]
11
4
  import { execSync, spawnSync, spawn } from "node:child_process";
12
5
  import { readFileSync, writeFileSync, unlinkSync, existsSync, appendFileSync, mkdirSync, realpathSync } from "node:fs";
13
6
  import { join, basename } from "node:path";
@@ -120,11 +113,8 @@ function ensureSeatWorktree(sourceDir) {
120
113
 
121
114
  const TURN_DIR = ensureSeatWorktree(DIR);
122
115
 
123
- // #6154: opencode prints no session id on stdout, but it records every session in its own sqlite
124
- // DB with the directory the session was created in. The newest row for OUR worktree is the only
125
- // session a resume may pin — anything else in that DB belongs to another project on this machine,
126
- // which is exactly what `run -c` used to hand us. Read-only, fail-open: no DB or no row means the
127
- // next turn starts fresh, which is always safe, instead of resuming a stranger, which never is.
116
+ // #6154: opencode records each session with its directory; the newest row for OUR worktree is the
117
+ // only session a resume may pin. Fail-open: no row means a fresh turn, never a stranger's session.
128
118
  const OC_DB = join(process.env.XDG_DATA_HOME || join(homedir(), ".local", "share"), "opencode", "opencode.db");
129
119
  function ocSid(dir) {
130
120
  try {
@@ -171,8 +161,7 @@ const LOGDIR = join(homedir(), ".agent-bus", "logs");
171
161
  try { mkdirSync(LOGDIR, { recursive: true }); } catch {}
172
162
  let TURN = 0;
173
163
  const telemetry = (rec) => { try { appendFileSync(join(LOGDIR, `${AGENT}-${PROJ}.jsonl`), JSON.stringify(rec) + "\n"); } catch {} };
174
- // Boot line records the HUB this runner bound to — the 2026-08-14 split-brain took an hour to
175
- // diagnose because nothing on disk said which hub a seat was talking to.
164
+ // The boot line records the HUB this runner bound to, so a split-brain is diagnosable from disk.
176
165
  telemetry({ ts: Date.now(), agent: AGENT, project: PROJ, boot: true, hub: HUB });
177
166
  // A seat can open a terminal window on a machine whose owner never asked for one and does not know
178
167
  // what they are looking at. "◤ CLAUDE ◢ trantor crew · fleet" tells that person nothing: not what
@@ -194,11 +183,8 @@ async function api(path, body) {
194
183
  // that shows up as a seat that quietly records nothing rather than one that errors.
195
184
  const url = HUB + path;
196
185
  const sig = signedHeaders(identity, url, opts);
197
- // HARD DEADLINE on every call (2026-08-01, crebral-health kimi seat): a long-poll whose socket
198
- // dies silently (idle NAT/tailscale reset, no RST delivered) otherwise hangs fetch FOREVER —
199
- // the runner sat "parked" with zero connections and zero retries while its crew was rebuilt
200
- // around it. Deadline = the poll's own wait window + slack, so a healthy long-poll never trips
201
- // it and a dead one surfaces as a catchable error that the main loop retries in 5s.
186
+ // A long-poll whose socket dies silently would hang fetch forever, so every call carries a
187
+ // deadline of the poll's own wait window plus slack; a dead poll surfaces as a retryable error.
202
188
  const waitS = Number((path.match(/[?&]wait=(\d+)/) || [])[1] || 0);
203
189
  const r = await fetch(url, { ...opts, headers: { ...opts.headers, ...sig }, signal: AbortSignal.timeout((waitS + 30) * 1000) });
204
190
  return r.json();
@@ -220,24 +206,13 @@ const BRAND_HEX = { claude: "#D97757", codex: "#e8e8ee", openai: "#e8e8ee", deep
220
206
  kimi: "#8b8bf5", moonshot: "#8b8bf5", glm: "#5ea0f5", zai: "#5ea0f5", gemini: "#8E75B2", openrouter: "#94A3B8" };
221
207
  function cmuxStatus(value, color, icon = "robot", opts = {}) {
222
208
  if (!inCmux()) return;
223
- // Label with the REAL seat identity, not a literal. This was hardcoded to "trantor", so every seat
224
- // in every project reported under one name — four different agents (and their duplicates) rendered
225
- // identically in the sidebar, which is why a runner leak looked like mystery sessions instead of
226
- // obvious duplicates. Note this is the DISPLAY path; two previous fixes to the crossed-label
227
- // symptom both landed on the *bus* identity and never touched this line.
228
- // Pill = "<agent> · <state>" in the agent's BRAND color (alerts keep their alarm color — a red
229
- // error must read as red at a glance); errors sort first via --priority.
209
+ // Label with the REAL seat identity (this is the DISPLAY path, distinct from the bus identity).
210
+ // Pill = "<agent> · <state>" in the agent's brand color; alerts keep their alarm color.
230
211
  const col = opts.alert ? color : (BRAND_HEX[AGENT.toLowerCase()] || color);
231
212
  try { spawnSync(CMUX_BIN, ["set-status", SESSION, `${AGENT} · ${value}`, "--color", col, "--icon", icon, "--priority", String(opts.priority ?? 0)], { stdio: "ignore", timeout: 1500, env: { ...process.env, CMUX_QUIET: "1" } }); } catch {}
232
213
  }
233
- // herdr drops a pane's agent registration when the process inside it exits — and a seat's CLI
234
- // exits at the END OF EVERY TURN. Reporting once when the pane is created is therefore not enough:
235
- // the seat vanishes from `herdr agent list` after its first turn, `herdr agent attach` starts
236
- // answering agent_not_found, and the app renders that raw error where the terminal should be.
237
- // Observed 2026-08-27 on codex, which was crash-looping on an exhausted quota.
238
- //
239
- // So re-report at every turn boundary, which also gives herdr a truthful working/idle state.
240
- // NOTE the argument order: the pane id comes FIRST, before the flags.
214
+ // herdr drops a pane's agent registration when the process inside exits, and a seat's CLI exits
215
+ // every turn, so re-report at each turn boundary. Argument order: pane id FIRST, then the flags.
241
216
  function herdrAgent(state) {
242
217
  try {
243
218
  const f = join(homedir(), ".agent-bus", "crew-windows.txt");
@@ -274,15 +249,8 @@ const CLI = {
274
249
  // --yolo in prompt mode (prompt mode auto-approves tools), and emits session_-prefixed ids.
275
250
  kimi: { first: `kimi{M} -p "$(cat {P})" < /dev/null`,
276
251
  next: `kimi{M} -r {SID} -p "$(cat {P})" < /dev/null`, mflag: " --model ", sid: /To resume this session: kimi -r (\S+)/ },
277
- // #6154: the opencode family never resumes blind. `run -c` continues the GLOBALLY last session
278
- // on this machine — any project's (opencode.db showed a pr-os session interleaved between two
279
- // trantor ones) — and the resumed session's stored directory becomes the Location every relative
280
- // path resolves against. A seat then `cd desktop/src-tauri` inside its own worktree while
281
- // opencode resolves it against a stranger's root, the bash tool reads it as external_directory
282
- // and auto-rejects, and the turn dies mid-work with everything uncommitted. So: every spawn
283
- // pins --dir to the seat worktree, and a resume pins -s to the session id looked up from
284
- // opencode's own DB by directory — the session CREATED here (ocSid below). A missed lookup
285
- // degrades to a fresh session, never to a foreign one.
252
+ // #6154: `run -c` resumes the globally last session on this machine, any project's, and its stored
253
+ // directory becomes the path root. So every spawn pins --dir and a resume pins -s (ocSid below).
286
254
  deepseek: { first: `opencode run --dir {DIR}{M} "$(cat {P})"`,
287
255
  next: `opencode run --dir {DIR} -s {SID}{M} "$(cat {P})"`, mflag: " -m ", pinned: true, env: join(homedir(), ".token-scrooge", ".env") },
288
256
  opencode: { first: `opencode run --dir {DIR}{M} "$(cat {P})"`,
@@ -295,23 +263,12 @@ const CLI = {
295
263
  next: `opencode run --dir {DIR} -s {SID}{M} "$(cat {P})"`, mflag: " -m ", pinned: true, env: join(homedir(), ".token-scrooge", ".env") },
296
264
  claude: { first: `claude{M} -p "$(cat {P})" --dangerously-skip-permissions`,
297
265
  next: `claude -c{M} -p "$(cat {P})" --dangerously-skip-permissions`,
298
- // TRANTOR_STATE_ASSEMBLE=1 — Trantor State Phase 2a (TDD §4.6). Note what is GONE:
299
- // `-c`. The resumed transcript is the thing this path exists to stop re-sending, so a
300
- // state step is a fresh `claude -p` carrying the assembled prefix instead, and
301
- // `--json-schema` holds the seat to the TurnResult grammar. Used for the first step
302
- // of a card too: with no `-c` it IS the `first` shape, and a first step that returned
303
- // no TurnResult would leave the run recorder a hole on turn 1.
304
- //
305
- // The flag is off by default and nothing above changes, so the transcript path stays
306
- // byte-identical — test/state/test-runner-state.mjs asserts that against these very
307
- // strings rather than against a reading of this comment.
266
+ // TDD §4.6: a state step is a fresh `claude -p` (no `-c`) carrying the assembled prefix, held
267
+ // to the TurnResult grammar by --json-schema; test/state/test-runner-state.mjs pins the strings.
308
268
  stateNext: `claude{M} -p "$(cat {P})" --dangerously-skip-permissions --output-format json --json-schema "$(cat {S})"`,
309
269
  mflag: " --model " },
310
- // DeepSeek Harness. Every turn is a FRESH session — headless has no resume yet — so the seat
311
- // relies on the wake prompt + the board (via the relay tools its profile mounts) rather than
312
- // conversation memory. `trantor connect` builds the ~/.dsh/profiles/trantor composition: their
313
- // CC-hooks bridge running OUR hooks + their MCP client running our relay server. No model flag:
314
- // headless takes only the task; the model is profile config.
270
+ // DeepSeek Harness: every turn is a fresh session (headless has no resume), so the seat leans on
271
+ // the wake prompt and the board. `trantor connect` builds ~/.dsh/profiles/trantor; no model flag.
315
272
  dsh: { first: `dsh --profile trantor "$(cat {P})" < /dev/null`,
316
273
  next: `dsh --profile trantor "$(cat {P})" < /dev/null`, mflag: "", env: join(homedir(), ".token-scrooge", ".env") },
317
274
  };
@@ -328,27 +285,19 @@ if (!CLI[AGENT]) log(`'${AGENT}' is not a built-in seat — running it as an ope
328
285
  // always-on seats (the fleet DUTY agent, bin/duty.mjs) whose doctrine is not "work your card".
329
286
  const RULES = process.env.RUNNER_RULES || `Rules: you are ${SESSION} on the trantor crew. Before starting a card, read YOUR card: relay_board with card:<id> (the card, its deps, its notes, and the last five done cards whose title shares a word); never the whole board. Work your assigned file(s), report on the bus (relay_send, <280 chars), move your Kanban card as you go with a NOTE saying what you did (doing -> testing -> done; in 'testing' run YOUR OWN test file — never the full npm test, suites collide across seats — plus \`node bin/slop-gate.mjs\` when the repo has one: it lints ONLY your changed files against the anti-slop rules, and a card must not reach done with slop-gate failing; use 'failed' + a report if anything breaks). If you need something from another session, message THAT SESSION (relay_peers to find its id, relay_send to reach it) — never ask the human to pass it along; carrying messages between agents is the job this bus exists to remove. 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. Path discipline: build/test from your worktree root ${TURN_DIR} with absolute paths or --manifest-path/--prefix instead of cd-ing into subdirs, and put anything that must land outside the repo under ${TURN_DIR}/.agent-bus-out/ (gitignored) — never ~/.agent-bus. Cross-project action is a breach: never \`trantor up\` a crew, register a seat, or send a card/contract into a project other than ${PROJ} unless the operator ran \`trantor policy link ${PROJ} <other> --reason "<why>"\` first — the hub, the CLI and this runner all refuse it mechanically, so ask the operator to link the projects instead of routing around the refusal.`;
330
287
 
331
- // ---- the pulse (Scape's Lloyd/Argus loop, Trantor-shaped) --------------------
332
- // A message-driven seat is DEAF between messages. An orchestrator seat with a mission needs a
333
- // metronome: RUNNER_PULSE_MS re-runs its mission note on a cadence even when the bus is silent.
334
- // The pulse prompt is deliberately almost verbatim the one that works in the wild: re-read the
335
- // note, continue, check your children, record. Boot discipline rides with it — an empty mission
336
- // means STAND BY, never invented work.
288
+ // ---- the pulse --------------------------------------------------------------
289
+ // RUNNER_PULSE_MS re-runs an orchestrator seat's mission note on a cadence when the bus is silent;
290
+ // an empty mission means STAND BY, never invented work.
337
291
  const PULSE_MS = Math.max(0, Number(process.env.RUNNER_PULSE_MS || 0));
338
292
  const MISSION_FILE = process.env.RUNNER_MISSION_FILE || "MISSION.md";
339
293
  const PULSE_PROMPT = `[pulse] Re-read your mission note (${MISSION_FILE} in your working directory) and continue your mission. Check on your children and your board, unblock what is stuck, and record what you did. If the mission note is missing, empty, or has no actionable mission, reply ONLY that you are standing by and end your turn — do NOT invent work, create files, or spawn anything.`;
340
294
 
341
295
  // ---- failure visibility ----------------------------------------------------
342
- // A turn's CLI can fail (credits exhausted, auth, crash) and the runner would just
343
- // re-park — staying green on the bus, telling the orchestrator NOTHING. These surface
344
- // every non-zero turn to the bus in real time so the orchestrator (and `trantor swap`)
345
- // can react, and flip presence to errored/down.
296
+ // A failed turn would otherwise re-park green on the bus; every non-zero turn is surfaced in real
297
+ // time so the orchestrator and `trantor swap` can react, and presence flips to errored/down.
346
298
  let consecFails = 0;
347
- // The failure STATE the room has already been told about. A seat that is down stays down, and
348
- // saying so again every retry is repetition, not news — the monitoring doctrine this project holds
349
- // everyone else to says report duration, not repetition. Observed cost: a permanently exhausted
350
- // codex seat broadcast "DOWN" to `all` 31 times over six hours, and every broadcast is a turn for
351
- // every live seat, so two working agents spent the evening reading the same sentence.
299
+ // The failure state the room has already been told: a seat that is down stays down, and repeating
300
+ // it every retry costs a turn for every live seat (monitoring doctrine: duration, not repetition).
352
301
  let announced = "";
353
302
  let lastErrText = "";
354
303
  // #5481: the turn exited 0 with a NULL/empty transcript — the Inception/Mercury trap. The provider
@@ -374,21 +323,14 @@ function startDutyNudgeWatcher(plan, sinceMs) {
374
323
  }
375
324
 
376
325
  // ---- undelivered wake messages (the runner owns delivery, not the hub) ----
377
- // The hub hands a message out exactly ONCE: the poll cursor advances the instant we read it, and
378
- // nothing ever re-fires. So a turn that died — API outage, quota wall, crashed CLI — used to take
379
- // its wake message down with it, and an escalation addressed to this seat was gone forever with
380
- // no trace anywhere. The queue below makes delivery the runner's job: a message is not consumed
381
- // until a turn actually exits 0. It survives a runner restart on disk, retries on its own backoff
382
- // so a silent bus still gets it through, and says how many are outstanding every time it reports.
326
+ // The hub hands a message out exactly once, so a turn that died took its wake with it. Here a
327
+ // message is consumed only when a turn exits 0; the queue lives on disk and retries on backoff.
383
328
  const PENDF = join(homedir(), ".agent-bus", `pending-${AGENT}-${PROJ}.json`);
384
329
  // A cap, so a long outage cannot grow the queue without bound. Overflow drops the OLDEST and says
385
330
  // so on the bus — a silent drop is the exact failure this whole mechanism exists to end.
386
331
  const PENDING_MAX = 50;
387
- // Backoff between redelivery attempts. Starts fast (a blip clears in 30s) and lands at 15 minutes,
388
- // which is the cadence for "this seat is properly down" rather than a retry storm against a hub
389
- // that is already refusing us.
390
- // TRANTOR_RETRY_MS (comma-separated ms) shortens the ladder so the redelivery drill can exercise
391
- // a real backoff in seconds instead of waiting out the production one.
332
+ // Redelivery backoff: fast first, landing at 15 minutes ("properly down", not a retry storm).
333
+ // TRANTOR_RETRY_MS (comma-separated ms) shortens the ladder for the redelivery drill.
392
334
  const RETRY_MS = (() => {
393
335
  // Guard the UNSET case explicitly: "".split(",") is [""], Number("") is 0, and a >=0 filter
394
336
  // accepted it — so every production runner got a ZERO backoff and a failing seat became a
@@ -411,12 +353,8 @@ function loadPending() {
411
353
  } catch { return { wake: [], bcast: [] }; }
412
354
  }
413
355
 
414
- // Auth-failure markers in TURN OUTPUT. opencode prints its auth error ("401 Unauthorized" /
415
- // "Invalid API key") and STILL exits 0, so a bare 0 from the CLI is not proof the turn ran
416
- // (card #5405). The rules live in lib/classify-failure.mjs (#5868) so they are testable against
417
- // the real specimens; classify() wraps them with the one-line verdict the seat log carries, and
418
- // runTurn judges only the CLI's OWN output (the prompt echo is replay, not speech — the rules
419
- // line "…deleting failing tests is forbidden." once classified healthy codex turns as auth).
356
+ // Auth failures in TURN OUTPUT: opencode prints its auth error and still exits 0 (#5405). The rules
357
+ // live in lib/classify-failure.mjs (#5868); runTurn judges only the CLI's own output, not the echo.
420
358
  function classify(exit) {
421
359
  const { reason, matched } = classifyFailure(exit, lastErrText, lastEmptyOutput);
422
360
  log(`classified ${reason} because ${matched}`);
@@ -468,10 +406,8 @@ async function reportFailure(exit, trigger, undelivered = 0, reasonOverride = ""
468
406
  }
469
407
 
470
408
  // ---- a dead seat is not retried (#6134) -------------------------------------------------------
471
- // The redelivery ladder assumes the next attempt might work. Against a spent plan or a rejected
472
- // key it never will, and the cost is real: codex burned 60 turns on 09-02 doing nothing but being
473
- // redelivered to. So those two reasons PARK — the queue is kept, the ladder stops, and the room is
474
- // told once, with the reset time when the CLI printed one. `trantor up` (a restart) resumes.
409
+ // Against a spent plan or a rejected key the ladder never succeeds, so those two reasons PARK:
410
+ // queue kept, ladder stopped, room told once with the reset time. `trantor up` resumes.
475
411
  let parkAnnounced = false;
476
412
  async function parkSeat(reason, undelivered, resetHint = 0) {
477
413
  // A seat that went QUIET printed no wall message to parse (#6131), so its own balance row is the
@@ -486,11 +422,8 @@ async function parkSeat(reason, undelivered, resetHint = 0) {
486
422
  if (orch !== SESSION) await api("/send", { from: SESSION, to: orch, text, project: PROJ, kind: "alert" }).catch(() => {});
487
423
  }
488
424
  log(`\x1b[31mparked (${reason})${when ? ` — retrying after ${when}` : " — no reset time in the output; waiting for a restart"}\x1b[0m`);
489
- // The two /send calls above are the whole escalation, and on 2026-09-09 that was not enough:
490
- // the DUTY seat parked on a quota read, held 48 messages for 21.9 hours, and announced it over
491
- // the very bus that had stopped moving, to an orchestrator that was idle and therefore could not
492
- // receive it. The alarm for "the bus is stuck" cannot itself be a bus message. So park also
493
- // rings a bell the operator can actually hear, out of band, once per park.
425
+ // The alarm for "the bus is stuck" cannot itself be a bus message, so a park also rings a bell
426
+ // the operator can hear out of band, once per park.
494
427
  notifyOperator(`Trantor: ${SESSION} PARKED (${reason})`,
495
428
  `${undelivered} message(s) held${when ? ` — retrying after ${when}` : ` — needs \`trantor up ${AGENT}\``}`);
496
429
  // No reset time means no timer can clear it: hold until the operator restarts the seat.
@@ -498,9 +431,8 @@ async function parkSeat(reason, undelivered, resetHint = 0) {
498
431
  }
499
432
 
500
433
  /**
501
- * Reach the operator on a channel that does not depend on the bus, the hub, or a live session.
502
- * Best-effort and strictly non-fatal: a seat must never die because a notifier is missing.
503
- * Silence-able with TRANTOR_NO_DESKTOP_NOTIFY=1 for headless boxes and test runs.
434
+ * Reach the operator on a channel independent of the bus, the hub and any session. Best-effort,
435
+ * never fatal; TRANTOR_NO_DESKTOP_NOTIFY=1 silences it for headless boxes and test runs.
504
436
  */
505
437
  function notifyOperator(title, body) {
506
438
  if (process.env.TRANTOR_NO_DESKTOP_NOTIFY === "1") return;
@@ -538,13 +470,8 @@ async function balanceRows() {
538
470
  }
539
471
 
540
472
  // ---- activity truth (#5965): the RUNNER is the source for this seat ----------------
541
- // The app pulses a seat from its hub peer status. The runner is what actually knows when a
542
- // turn starts and ends, so it reports the boundaries: `working · <trigger>` the moment a turn
543
- // begins and `idle` the instant it lands clean. herdr's screen detection cannot see a
544
- // runner-driven CLI mid-turn (it sets screen_detection_skipped for those panes), which is why
545
- // seats used to read as idle while genuinely working — the desktop's herdr row is unreliable
546
- // for runner seats, so it falls back to this hub status. Bounded 5s so a slow hub never delays
547
- // the very turn it is reporting; one HTTP call per transition, never a poll.
473
+ // herdr cannot see a runner-driven CLI mid-turn, so the runner reports turn boundaries to the hub:
474
+ // `working · <trigger>` at start, `idle` on a clean landing. Bounded 5s, one call per transition.
548
475
  async function registerStatus(status) {
549
476
  const url = HUB + "/register";
550
477
  const body = JSON.stringify({ session: SESSION, project: PROJ, status, llm: AGENT, model: MODEL });
@@ -555,14 +482,8 @@ async function registerStatus(status) {
555
482
  }
556
483
 
557
484
  // ---- telling the ASSIGNER, mechanically ------------------------------------
558
- // A seat used to finish its contract and say nothing. Completion lived only in the RULES prompt
559
- // ("report on the bus"), so a cheap model that did the work and ended its turn left the
560
- // orchestrator blind, and nothing watched for the omission. Failures were mechanical but went to
561
- // "all", and a plain broadcast does not wake anyone (see the wake policy in the main loop). From
562
- // the orchestrator's seat a finished crew and a crew that never started looked identical.
563
- //
564
- // So: whoever sent the message that woke this seat gets told DIRECTLY what became of it. Direct
565
- // messages wake; that is the whole difference. Kept short, like every other bus line.
485
+ // Whoever sent the wake is told directly what became of it: a direct message wakes, a broadcast
486
+ // does not, and a seat that finished silently left the orchestrator blind.
566
487
  async function notifyAssigners(pairs, text) {
567
488
  text = redactKeys(text); // #5869: the "asked" excerpt quotes the wake message — keys stay off the bus
568
489
  const seen = new Set();
@@ -577,7 +498,10 @@ async function notifyAssigners(pairs, text) {
577
498
  // right one instead of guessing from timing.
578
499
  const payload = { from: SESSION, to: f, text: text.slice(0, 280), project: PROJ, kind: "receipt" };
579
500
  if (id) payload.re = id;
580
- await api("/send", payload).catch(() => {});
501
+ const ack = await api("/send", payload).catch(() => ({}));
502
+ // #7288: remember the id, so a reply threaded onto THIS outcome can later be told apart from a
503
+ // work order that merely rides `re`.
504
+ if (Number(ack?.id) > 0) sentOutcomes.add(Number(ack.id));
581
505
  }
582
506
  if (seen.size) log(`reported outcome to ${[...seen].join(", ")}`);
583
507
  }
@@ -594,12 +518,8 @@ async function reportHealthy() {
594
518
  }
595
519
 
596
520
  // ---- Trantor State Phase 2a — the flagged path (TDD §4.1, §4.6, §7.3) -----------------------
597
- //
598
- // OFF BY DEFAULT, and off means the transcript path runs unchanged. Three things have to be true
599
- // before a single byte of this is reachable: the operator set TRANTOR_STATE_ASSEMBLE=1, the seat is
600
- // `claude` (§7.3 — it is the only row whose CLI can enforce the grammar), and the installed CLI
601
- // actually carries `--json-schema` (§6 — the minimum version is unconfirmed, so this PROBES rather
602
- // than assuming; no flag, no state mode, and the runner says so once).
521
+ // Off by default, and off means the transcript path runs unchanged. Reachable only when the operator
522
+ // set TRANTOR_STATE_ASSEMBLE=1, the seat is `claude` (§7.3) and the CLI carries --json-schema (§6).
603
523
  const STATE_FLAG_ON = process.env[STATE_ENV] === "1";
604
524
  const STATE_SCHEMA_FILE = join(homedir(), ".agent-bus", `state-schema-${AGENT}-${PROJ}.json`);
605
525
  const STATE_MODE = (() => {
@@ -611,25 +531,15 @@ const STATE_MODE = (() => {
611
531
  mkdirSync(join(homedir(), ".agent-bus"), { recursive: true, mode: 0o700 });
612
532
  writeFileSync(STATE_SCHEMA_FILE, JSON.stringify(TURN_RESULT_SCHEMA), { mode: 0o600 });
613
533
  } catch (e) { log(`\x1b[33mstate mode OFF — could not write ${STATE_SCHEMA_FILE}: ${e.message}\x1b[0m`); return false; }
614
- // #7060: this line used to read "ASSEMBLE mode ON for this seat", which is a claim about the
615
- // PROMPT that nothing here established. What the four checks above prove is CONFIGURATION, and
616
- // the two come apart on the literal next turn: the kickoff runs before any message exists, so it
617
- // belongs to no card and cannot be a state step. So say what was proved — armed — and name the
618
- // one thing that engages it. Each turn then reports which path it actually took.
534
+ // #7060: the checks above prove CONFIGURATION, not the prompt, so say "armed" and name the one
535
+ // thing that engages it; each turn then reports which path it took.
619
536
  log(`\x1b[36mTrantor State: ASSEMBLE armed for this seat (schema ${STATE_SCHEMA_FILE})\x1b[0m`);
620
537
  log(`\x1b[36m a turn is assembled only when a wake ASSIGNS it a card — the kickoff and every pulse run the transcript path, and each turn says which one it took\x1b[0m`);
621
538
  return true;
622
539
  })();
623
540
 
624
- // #7060: the one place a turn decides whether it is a state step, and the one place a skip is
625
- // spoken. Returns the reason the turn is NOT assembled (already logged), or null when it is — so
626
- // the runner reads `if (!stateSkip(...))` and cannot drift from what the operator was just told.
627
- //
628
- // It speaks on CHANGE, not on repetition. A pulse fires on a timer and skips for the same reason
629
- // every time; printing that line forever is the repetition the monitoring doctrine rules out, and
630
- // it would bury the turn where the path actually flipped. Assembling a turn clears the memory, so
631
- // the next skip after real work always speaks. Silent when state mode is off: the IIFE above
632
- // already said why, once, and a transcript seat has no claim here to mistake for proof.
541
+ // #7060: the one place a turn decides whether it is a state step and the one place a skip is
542
+ // spoken. Speaks on CHANGE, not repetition; assembling a turn clears the memory so the next skip speaks.
633
543
  let spokenStateSkip = null;
634
544
  const stateSkip = (kind, card = 0) => {
635
545
  const why = stateSkipReason({ mode: STATE_MODE, kind, breakerTripped, card });
@@ -677,10 +587,8 @@ let breakerTripped = false;
677
587
  let statePromotedHash;
678
588
 
679
589
  // ---- the time box (#6134) --------------------------------------------------------------------
680
- // A turn with no ceiling is how a seat spends an afternoon on one card: the 09-02 baseline was 151
681
- // turns and ~16 agentic hours across the fleet. TRANTOR_TURN_MAX_MS ends the CLI's process group
682
- // at the box and runs ONE follow-up turn in the SAME session — "commit what is done, move the
683
- // card, report in one line" — so a cut turn lands its work instead of losing it.
590
+ // TRANTOR_TURN_MAX_MS ends the CLI's process group at the box and runs ONE follow-up turn in the
591
+ // same session ("commit what is done, move the card, report") so a cut turn lands its work.
684
592
  const TURN_MAX_MS = Math.max(0, Number(process.env.TRANTOR_TURN_MAX_MS || 20 * 60 * 1000));
685
593
  const TIME_BOX_PROMPT = "your previous turn was cut at the time box; commit what is done, move the card with a note, report in one line";
686
594
  let inFollowUp = false;
@@ -694,7 +602,7 @@ let sessionCard = 0;
694
602
 
695
603
  let sid = "";
696
604
  // #6206: the watchdog is DETACHED, so a runner that dies without ending it leaves an orphan
697
- // sleeping toward a false alarm against whatever runner comes next (22 found on 2026-09-03).
605
+ // sleeping toward a false alarm against whatever runner comes next.
698
606
  // Every exit path therefore kills it, and the stamp carries this runner's instance id so any
699
607
  // survivor that outlives the kill still refuses to speak for a runner it never belonged to.
700
608
  const RUNNER_ID = `${process.pid}.${Date.now()}`;
@@ -732,14 +640,8 @@ async function runTurn(prompt, isFirst, trigger = "kickoff", opts = {}) {
732
640
  const mfrag = MODEL && cli.mflag ? `${cli.mflag}${MODEL}` : "";
733
641
  cmd = cmd.replaceAll("{M}", mfrag).replaceAll("{P}", pf).replaceAll("{SID}", sid).replaceAll("{DIR}", TURN_DIR)
734
642
  .replaceAll("{S}", STATE_SCHEMA_FILE);
735
- // PRECEDENCE, and it is easy to get backwards — this is the second time.
736
- // Each file is PREPENDED, so the one prepended LAST runs FIRST, and in shell the file that runs
737
- // LAST wins. To make ~/.agent-bus/.env (the CREW layer) win it must be prepended FIRST, i.e.
738
- // iterate the list in its written order — highest priority first. A `.reverse()` here inverted it
739
- // and handed every seat Scrooge's key instead of the crew's, which is why one key was paying for
740
- // both and no provider bill could tell them apart. `.reverse()` also mutated the array in place.
741
- // Verified by test-crew-env.mjs, which runs the real shell rather than reading this comment.
742
- // Priority order: the CREW layer first, the agent's own fallback (Scrooge's .env) after it.
643
+ // PRECEDENCE: each file is PREPENDED, so the list is iterated in written order, highest priority
644
+ // first, and the CREW layer (~/.agent-bus/.env) wins. test-crew-env.mjs runs the real shell.
743
645
  const envs = [join(homedir(), ".agent-bus", ".env"), cli.env].filter(f => f && existsSync(f));
744
646
  cmd = withEnvFiles(cmd, envs);
745
647
  log(`turn starting (${isFirst ? "fresh session" : "resume"})${MODEL ? ` · model=${MODEL}` : ""}`);
@@ -748,18 +650,8 @@ async function runTurn(prompt, isFirst, trigger = "kickoff", opts = {}) {
748
650
  // Tee stderr to ERRF (still shown live in the window) so a failed turn can be classified.
749
651
  try { appendFileSync(ERRF, "", { flag: "w" }); } catch {} // truncate
750
652
  lastEmptyOutput = false;
751
- // pipefail: without it the sid-capture `| tee` makes a FAILED turn exit 0 (tee's status),
752
- // so the failure reporter never fires and a dead seat heartbeats green on the bus.
753
- // A CLI's own explanation for quitting often goes to STDOUT, not stderr — Claude's usage-limit
754
- // notice is the case that bit us: ERRF stayed empty, so a plainly exhausted seat was reported as
755
- // `crashed` and nobody knew to swap it. sid seats already fold stdout into the ERRF stream via
756
- // `tee /dev/stderr`; the rest now tee straight into ERRF. A real pipeline (not a process
757
- // substitution) so bash waits for tee to flush before we read the file back.
758
- // #5869: redaction rides IN the pipeline — lib/redact.mjs is a tee replacement that echoes
759
- // stdin verbatim to the live window and appends only REDACTED bytes to ERRF, so a CLI that
760
- // echoes its environment never parks a provider key in a file every seat can read. The tee
761
- // topology is load-bearing (#5481): stdout+stderr must still BOTH land in ERRF, and the sid
762
- // path still folds stdout in via /dev/stderr → the --tee2 hop below.
653
+ // pipefail so a failed CLI behind `| tee` still exits non-zero. Every stream lands in ERRF via
654
+ // lib/redact.mjs (#5869, redacted bytes only); the tee topology is load-bearing (#5481).
763
655
  const SCRUB = `node ${join(import.meta.dirname, "..", "lib", "redact.mjs")}`;
764
656
  // §4.6 names a real cost of `--output-format json`: the seat's window would print a JSON blob
765
657
  // instead of prose, and the operator watches that window. So on a state step stdout goes to a
@@ -770,19 +662,12 @@ async function runTurn(prompt, isFirst, trigger = "kickoff", opts = {}) {
770
662
  const inner = opts.state
771
663
  ? `${cmd} > ${ENVF}`
772
664
  : (cli.sid ? `${cmd} | tee /dev/stderr` : `${cmd} | ${SCRUB} --tee ${ERRF}`);
773
- // #5684: runTurn is spawnSync, so the runner cannot watch its own turn — a DETACHED watchdog
774
- // does. Armed by a stamp file, disarmed when the turn ends (stamp removed below); a turn past
775
- // the window with no activity (transcript, worktree, or stderr — #6206: stdout silence alone
776
- // is never a stall) earns ONE direct stall report to the foreman, never a kill.
777
- // #6206: the window's floor is 10 minutes and is never derived from TRANTOR_TURN_MAX_MS —
778
- // a 1-minute alarm is a false alarm by construction. The env override exists for drills.
665
+ // #5684: runTurn is spawnSync, so a DETACHED watchdog (stamp-armed) watches the turn and sends
666
+ // ONE stall report, never a kill. #6206: floor 10 min, never derived from TRANTOR_TURN_MAX_MS.
779
667
  const WD_MS = Number(process.env.TRANTOR_TURN_WATCHDOG_MS) || 10 * 60 * 1000;
780
668
  const STAMPF = join(homedir(), ".agent-bus", `turnstamp-${AGENT}-${PROJ}.json`);
781
- // #6206: where the CLI appends its session transcript (claude's project dir; other CLIs may
782
- // not have one — the watchdog treats a missing dir as a quiet channel). Also exported to the
783
- // CLI's env so a drill's fake CLI can write transcript lines the watchdog will see.
784
- // Written by the shell's own time box (below) and read back here — the only honest signal that
785
- // the turn was CUT rather than that the CLI failed on its own. Cleared before every turn.
669
+ // #6206: the CLI's transcript dir (a missing dir is a quiet channel), exported so a drill's fake
670
+ // CLI can write lines the watchdog sees. CUTF is written by the shell's time box: cut, not crashed.
786
671
  const CUTF = join(homedir(), ".agent-bus", `turncut-${AGENT}-${PROJ}`);
787
672
  try { unlinkSync(CUTF); } catch {}
788
673
  // Touched by the stderr scrubber as its LAST act (the shell below); node waits for it after
@@ -796,22 +681,8 @@ async function runTurn(prompt, isFirst, trigger = "kickoff", opts = {}) {
796
681
  WD_CHILD = wd;
797
682
  wd.unref();
798
683
  } catch {}
799
- // Preserve the CLI's exit before waiting for the stderr process substitution. Without the
800
- // explicit wait, a short failing CLI can return while its error is still in the scrub pipe;
801
- // under load the classifier then reads an empty ERRF and reports the wrong failure reason.
802
- // #6134-followup: the time box has to fire from INSIDE the shell, while the process tree is
803
- // still standing. Killing the turn's process group from node missed a grandchild — codex runs
804
- // its own commands via setsid, so `sleep 400` sat in a different group and survived
805
- // process.kill(-pid). Worse, by the time node's timeout has killed bash the survivors have been
806
- // reparented to init, so there is no tree left to walk and nothing to sweep.
807
- //
808
- // So bash boxes itself: at the deadline it walks its own descendants and kills them bottom-up.
809
- // setsid changes a process's group and session but NEVER its parent, so `pgrep -P` recursion
810
- // reaches exactly the children that a group signal cannot. Children first, then the parent, so
811
- // nothing gets reparented mid-sweep and escapes the walk.
812
- //
813
- // The marker file is how node learns the turn was cut rather than merely failing: an exit status
814
- // alone cannot tell "killed at the box" from "the CLI died on its own".
684
+ // #6134: the box fires from INSIDE the shell, walking its own descendants bottom-up with `pgrep -P`
685
+ // (setsid escapes a group signal, never its parent). The marker file tells node "cut", not "crashed".
815
686
  const sweep = `sweep() { local p; for p in $(pgrep -P $1 2>/dev/null); do sweep $p; done; kill -KILL $1 2>/dev/null; }`;
816
687
  const box = TURN_MAX_MS ? `
817
688
  ${sweep}
@@ -828,31 +699,16 @@ wait $job; turn_exit=$?
828
699
  wait
829
700
  exit $turn_exit`;
830
701
  const spawnOpts = {
831
- // detached: bash leads its OWN process group, so the time box can kill the CLI and everything
832
- // it spawned with one signal instead of orphaning the model process behind a dead shell.
833
- // stdin is /dev/null for every seat (it already was for codex/kimi/dsh via `< /dev/null`):
834
- // a detached group is a BACKGROUND group, and a background process that reads the terminal
835
- // takes SIGTTIN and stops forever. Nothing here runs interactively — every CLI is in -p /
836
- // exec / run mode — so closing stdin is what makes the group safe.
702
+ // detached: bash leads its own process group so the box can kill the CLI and everything it
703
+ // spawned. stdin is /dev/null: a background group that reads the terminal stops on SIGTTIN.
837
704
  detached: true,
838
705
  cwd: TURN_DIR, encoding: "utf8", stdio: cli.sid ? ["ignore", "pipe", "inherit"] : ["ignore", "inherit", "inherit"],
839
706
  env: { ...process.env, RELAY_URL: HUB, RELAY_AGENT: AGENT, RELAY_SESSION: SESSION, RELAY_PROJECT: PROJ,
840
- // #6228: badges this seat's env as belonging to PROJ, distinctly from a one-off RELAY_PROJECT
841
- // override (bin/crew.mjs's own tests, and any deliberate `RELAY_PROJECT=x trantor up` from a
842
- // plain shell, set RELAY_PROJECT alone and must keep working — only THIS marker means "the
843
- // env I'm running in already has a project home"). crew.mjs's `up` guard refuses to bring up a
844
- // DIFFERENT project's crew from a shell carrying this badge, same as it refuses TRANTOR_ORCH.
707
+ // #6228: marks this env as belonging to PROJ, unlike a one-off RELAY_PROJECT override; crew.mjs's
708
+ // `up` guard refuses to bring up another project's crew from a shell carrying this badge.
845
709
  TRANTOR_SEAT: PROJ,
846
- // A RUNNER-MANAGED SEAT MUST NEVER HAND ITSELF A BATON.
847
- //
848
- // The handoff machinery exists for an INTERACTIVE session: near its context limit it writes a
849
- // handoff and opens a fresh window to carry on. A seat has no use for that — the runner is its
850
- // lifecycle manager and wakes it per event — so the spawn just leaks an unmanaged interactive
851
- // session into a window nobody asked for.
852
- //
853
- // Observed on the duty seat: handoff records at 17:24 and 18:59 on 2026-08-24, and two stray
854
- // `claude` processes in ~/.agent-bus/trantor-duty started at 17:24:57 and 18:59:50, still
855
- // sitting there days later. To the operator that reads as "why are there two duty agents".
710
+ // A runner-managed seat must never hand itself a baton: the runner is its lifecycle manager,
711
+ // so a handoff spawn would only leak an unmanaged interactive window.
856
712
  TRANTOR_NO_HANDOFF_SPAWN: "1", TRANTOR_NO_BATON_SPAWN: "1",
857
713
  // #6206: the seat's transcript dir — a real CLI ignores it, a drill's fake CLI writes
858
714
  // its transcript lines there so the watchdog sees the liveness a real claude shows.
@@ -868,14 +724,8 @@ exit $turn_exit`;
868
724
  // cut, not merely failed.
869
725
  const boxed = existsSync(CUTF);
870
726
  const cut = !!TURN_MAX_MS && (boxed || r.error?.code === "ETIMEDOUT");
871
- // DRAIN before classifying — but never on a CUT turn: the box's sweep killed the scrubber
872
- // mid-flight, so its marker can never appear and waiting is pure stall. bash 3.2 (macOS's
873
- // /bin/bash) `wait` does NOT wait for process substitutions — verified 2026-09-05 — so when
874
- // spawnSync returns on a LIVE turn, the stderr scrubber can still be draining, and an auth
875
- // line still in the pipe reads as an EMPTY ERRF: the turn is then mislabelled "empty-output",
876
- // which breaks the seat-down contract (wrong DOWN label, retry ladder instead of a park) and
877
- // cost run 33940247163 three CI-only drill-6 failures. The scrubber touches DRAINF as its
878
- // last act; wait for it, bounded.
727
+ // DRAIN before classifying, never on a CUT turn (the sweep killed the scrubber, its marker never
728
+ // comes). bash 3.2 `wait` skips process substitutions, so wait for DRAINF, bounded.
879
729
  if (!cut) {
880
730
  const drainStart = Date.now();
881
731
  while (!existsSync(DRAINF) && Date.now() - drainStart < 3000) await new Promise(s => setTimeout(s, 50));
@@ -912,13 +762,8 @@ exit $turn_exit`;
912
762
  // and the next turn starts fresh rather than resuming whatever other project ran last.
913
763
  if (cli.pinned) { const found = ocSid(TURN_DIR); if (found) sid = found; }
914
764
  const realExit = r.status;
915
- // A zero exit is NOT proof the turn ran: opencode prints "401 Unauthorized" / "Invalid API key"
916
- // and exits 0, so a bare 0 made the runner ack "✅ done", clear the pending queue and heartbeat
917
- // green through an auth outage (card #5405). Cross-check the turn output and treat an
918
- // exit-0-with-auth turn as FAILED — but ONLY when the CLI's own output is short enough to be
919
- // just the error (#5868): a long output is a real answer, and a warning inside it must not
920
- // fail the turn. Telemetry keeps the REAL exit; the returned code is the effective one every
921
- // call site branches on (kickoff, pulse, deliverWake).
765
+ // A zero exit is not proof the turn ran (#5405): an exit-0 auth turn is FAILED when the CLI's own
766
+ // output is short enough to be just the error (#5868). Telemetry keeps the real exit.
922
767
  let effExit = realExit;
923
768
  let authHit = "";
924
769
  // #5868: a NEW commit since turn start is real work, and an exit-0 turn with real output is
@@ -930,18 +775,8 @@ exit $turn_exit`;
930
775
  authHit = AUTH_MARKER_RE.exec(ownOut)[0];
931
776
  log(`\x1b[31mexit 0 but the turn output IS an auth failure — treating as FAILED (auth, "${authHit}")\x1b[0m`);
932
777
  }
933
- // #5481: the Inception/Mercury trap — exit 0 with a NULL completion. ERRF is the TOTAL output
934
- // capture, not just stderr: every seat's stdout is tee'd into it (`| tee -a ERRF` for the
935
- // opencode family, `| tee /dev/stderr` + the stderr tee for sid seats — line ~448). So an
936
- // empty ERRF on a clean exit means the turn produced nothing on EITHER stream — and every
937
- // real CLI prints something on success (drill C pins that), so silence is the trap, not a
938
- // quiet victory. (Integration note: this was nearly "fixed" into stdout-only detection that
939
- // never fired — the tee topology is the load-bearing fact; keep this comment with it.)
940
- // The judgment now runs on the ECHO-STRIPPED text (#5868): a CLI that replays the prompt but
941
- // does no work has still produced nothing of its own.
942
- // #6969: on a state step the CLI's whole answer is the envelope, so ERRF holds only stderr and
943
- // silence there is the NORMAL shape of a healthy turn. Judging it "empty-output" would park a
944
- // working seat on its first clean state step.
778
+ // #5481: exit 0 with an empty ERRF (the TOTAL capture, both streams) is the null-completion trap,
779
+ // judged on echo-stripped text (#5868). #6969: on a state step ERRF is stderr only, so silence is normal.
945
780
  if (realExit === 0 && effExit === 0 && !lastErrText.trim() && !lastEnvelope.trim()) {
946
781
  effExit = 1;
947
782
  lastEmptyOutput = true;
@@ -971,14 +806,8 @@ exit $turn_exit`;
971
806
  // #5965 — TURN END. A clean exit means the seat is idle again; say so right away so the app stops
972
807
  // pulsing it even before the next /poll heartbeat. Failure keeps reportFailure's down/errored.
973
808
  if (realExit === 0 && effExit === 0) await registerStatus("idle");
974
- // The follow-up rides the SAME session, so the model still has the turn it was cut out of and
975
- // only has to land it. Exactly one — a follow-up that runs long is itself boxed, and boxing a
976
- // boxed turn forever is the loop this card exists to end.
977
- // A state step gets no prose follow-up: TIME_BOX_PROMPT is not a TurnResult prompt, and feeding
978
- // it would break the byte-identical prefix the cost claim rests on. It is also unnecessary —
979
- // §4.4 is explicit that a cut turn has never partially applied a patch, so what was lost is the
980
- // dead turn's observations, and store.recover() rebuilds those from git on the next readState.
981
- // The step is recorded with `cut: true` so §8.7 can still count it.
809
+ // The follow-up rides the SAME session, exactly once. A state step gets none: TIME_BOX_PROMPT
810
+ // would break the byte-identical prefix, and §4.4 says a cut turn never partially applied a patch.
982
811
  if (cut && !inFollowUp && !opts.state) {
983
812
  inFollowUp = true;
984
813
  try { return await runTurn(TIME_BOX_PROMPT, false, "time-box follow-up"); }
@@ -992,10 +821,8 @@ exit $turn_exit`;
992
821
  let stateObservation = "";
993
822
 
994
823
  // ---- Phase 2a: one state step, driven by lib/state/driver.mjs -------------------------------
995
- //
996
- // The runner's whole job here is transport and side effects: it hands the driver a way to run the
997
- // CLI and a way to act on the returned action, and the driver holds the §4.1 order. Returns an
998
- // exit code so deliverWake's success/failure ladder is untouched.
824
+ // The runner is transport and side effects; the driver holds the §4.1 order. Returns an exit code
825
+ // so deliverWake's success/failure ladder is untouched.
999
826
  async function stateTurn({ card, observation, trigger, assigners = [] }) {
1000
827
  const tail = await cardTail(card);
1001
828
  const r = await runStep({
@@ -1063,11 +890,8 @@ async function loadLessons() {
1063
890
  } catch {}
1064
891
  }
1065
892
 
1066
- // card #5683: every section of a turn prompt is capped (bin/crew-payload.mjs) and the whole
1067
- // payload has ONE hard total cap. Codex burned 306k tokens into a remote-compact 404 crash-loop
1068
- // because a resumed session re-fed the full lessons block (22,298 of the 24,698 chars in its last
1069
- // turn file — 90%) plus an unbounded broadcast backlog on EVERY turn, redelivery after redelivery.
1070
- // Below the caps the composition is byte-identical to the old concatenation.
893
+ // #5683: every prompt section is capped (bin/crew-payload.mjs) and the payload has ONE hard total
894
+ // cap; below the caps the composition is byte-identical to the old concatenation.
1071
895
  function composedTurn({ base = "", wakeText = "", ctxText = "", againText = "", tailText = "", rulesText = "", lessons = null }) {
1072
896
  const built = composePrompt([
1073
897
  { name: "base", text: base },
@@ -1087,11 +911,20 @@ function composedTurn({ base = "", wakeText = "", ctxText = "", againText = "",
1087
911
  const RECEIPT_MARKER = "✅ done on";
1088
912
  const CARD_REF_RE = /#\d{1,7}(?!\d)/;
1089
913
 
914
+ // Outcome ids this seat SENT (notifyAssigners records them): a reply threaded onto one of these is
915
+ // about our own outcome, not a new contract.
916
+ const sentOutcomes = new Set();
917
+
1090
918
  // Runner-authored metadata is bus state, not work. Typed messages are authoritative; `re` and the
1091
919
  // stable text marker keep a mixed-version crew safe while older runners are still on the bus.
1092
920
  function isReceipt(message) {
1093
921
  const text = String(message?.text || "").trimStart();
1094
- return message?.kind === "receipt" || Number(message?.re) > 0 || text.startsWith(RECEIPT_MARKER);
922
+ if (message?.kind === "receipt" || text.startsWith(RECEIPT_MARKER)) return true;
923
+ // #7288: `re` alone is NOT a receipt — senders are told to thread EVERY reply, so a corrected
924
+ // contract riding `re` must wake. A reply is a receipt only when it threads an outcome THIS seat
925
+ // sent and its own text carries no work (an ack/closure, #7079).
926
+ if (!(Number(message?.re) > 0)) return false;
927
+ return sentOutcomes.has(Number(message.re)) && !carriesWork(text) && !isContract(message);
1095
928
  }
1096
929
 
1097
930
  function isStatusBroadcast(message) {
@@ -1117,16 +950,8 @@ function isRunnerSession(session) {
1117
950
  return /^[a-z0-9_.-]+$/.test(label) && !label.startsWith("hub:");
1118
951
  }
1119
952
 
1120
- // A hub staleness alert describes a condition that was true for a moment: "#16909 has been
1121
- // UNDELIVERED for 2m — go nudge someone". Acting on it 22 hours later is meaningless, and the queue
1122
- // had no expiry, so on 2026-09-09 the duty seat's backlog became SELF-POISONING: the hub kept
1123
- // noticing undelivered mail and sending more alerts, duty could not work them off, and a restart
1124
- // faithfully redelivered 49 dead nudges and re-wedged the seat. 46 of those 49 were hub alerts, the
1125
- // oldest 22.1 hours old, every one describing a two-minute condition.
1126
- //
1127
- // So these EXPIRE. Deliberately narrow: only messages the HUB generated about staleness, never a
1128
- // message from a peer. A real contract is never dropped for being old — a seat that misses a
1129
- // teammate's request is the failure this bus exists to prevent, and no backlog is worth causing it.
953
+ // A hub staleness alert describes a moment, so it EXPIRES; a peer's message never does, because a
954
+ // seat missing a teammate's request is the failure this bus exists to prevent.
1130
955
  const HUB_ALERT_TTL_MS = Number(process.env.TRANTOR_HUB_ALERT_TTL_MS || 30 * 60_000);
1131
956
  const isExpiredHubAlert = (m) =>
1132
957
  m?.from === "hub:duty" &&
@@ -1141,12 +966,8 @@ function shouldWake(message) {
1141
966
  if (message?.wake === false) return false;
1142
967
  if (message?.to === SESSION) {
1143
968
  if (message?.kind === "status") return false;
1144
- // The safety net for every sender that never set the flag: a direct message carrying no card
1145
- // and no instruction is an ack, an FYI or a queue note. Those made up most of the 09-02 burn.
1146
- // Two exemptions, both because the shape net reads WORDS and these carry their meaning in
1147
- // their type: a typed alert (a failure escalation, a bounce), and an OVERSEER warning that got
1148
- // this far — the one chatty overseer kind is already batched by name upstream, so anything
1149
- // still here is file-conflict or linked-activity, which #5760 deliberately kept waking.
969
+ // Safety net for senders that never set the flag: a direct message carrying no card and no
970
+ // instruction is context. Typed alerts and overseer warnings still wake (#5760).
1150
971
  const typed = message?.kind === "alert" || /^🤝 OVERSEER /.test(String(message?.text || ""));
1151
972
  if (!typed && !isContract(message) && !carriesWork(message?.text)) return false;
1152
973
  return !isRunnerSession(message?.from) || isContract(message);
@@ -1187,9 +1008,8 @@ function askedExcerpt(message) {
1187
1008
  // no crew-windows.txt to fall back to. /register preserves absent fields, so a seat running an
1188
1009
  // older runner never loses a kind an updated one stamped.
1189
1010
  await api("/register", { session: SESSION, project: PROJ, status: "crew member booting", llm: AGENT, model: MODEL, kind: "agent" }).catch(() => {});
1190
- // Announce runner-side, signed as THIS seat. Asking the seat to announce itself sent glm's hello
1191
- // out under deepseek's identity whenever opencode seats shared one MCP daemon (lesson on the bus,
1192
- // 2026-07-29): the runner process is per-seat by construction, so its signature cannot be borrowed.
1011
+ // Announce runner-side, signed as THIS seat: the runner process is per-seat by construction, so
1012
+ // its signature cannot be borrowed the way a shared opencode MCP daemon once borrowed identities.
1193
1013
  try {
1194
1014
  const { sfetchJson } = await import("../lib/signed-fetch.mjs");
1195
1015
  const { loadOrCreate } = await import("../lib/identity.mjs");
@@ -1213,11 +1033,8 @@ function askedExcerpt(message) {
1213
1033
  let pendingBcast = restored.bcast.filter(m => !isExpiredHubAlert(m) && !isReceipt(m) && !isStatusBroadcast(m));
1214
1034
  if (shed) {
1215
1035
  log(`\x1b[33mdropped ${shed} expired hub staleness alert(s) older than ${Math.round(HUB_ALERT_TTL_MS / 60000)}m — they describe conditions that have long since changed\x1b[0m`);
1216
- // Write the shed queue back NOW rather than waiting for the next failed delivery to persist it.
1217
- // Caught live on 2026-09-09: after a restart shed 3 of 4, `trantor duty status` still reported
1218
- // 4 held, because status reads the FILE and the file was still the pre-shed one. Disk and memory
1219
- // disagreeing is the whole class of bug this day was about — a health check cannot be honest if
1220
- // the state it reads is stale.
1036
+ // Persist the shed queue NOW: `trantor duty status` reads the FILE, and disk and memory
1037
+ // disagreeing makes a health check lie.
1221
1038
  savePending(pendingWake, pendingBcast);
1222
1039
  }
1223
1040
  let retryAt = 0; // 0 = deliver at the next opportunity
@@ -1279,11 +1096,8 @@ function askedExcerpt(message) {
1279
1096
  // reply-linked outcomes, and the old stable marker before direct-address logic sees them. Status
1280
1097
  // broadcasts are presence chatter and are dropped rather than saved as future prompt context.
1281
1098
  msgs = msgs.filter(m => !isReceipt(m) && !isStatusBroadcast(m));
1282
- // #5760 (the night of 08-31): the hub's hourly "same-project-sessions" FYI woke every seat
1283
- // into a real CLI turn — three wedged for hours mid-chatter, one on the metered pool. That
1284
- // kind is pure coordination CONTEXT ("no human needs to relay this" — and no turn needs to
1285
- // burn on it either): batch it like a broadcast. file-conflict and linked-activity overseer
1286
- // warnings still wake — those are actionable by the seat right now.
1099
+ // #5760: the hub's hourly same-project-sessions FYI is coordination context, batched like a
1100
+ // broadcast; file-conflict and linked-activity overseer warnings still wake.
1287
1101
  const fyi = msgs.filter(m => m.from === "hub:duty" && String(m.text || "").startsWith("🤝 OVERSEER same-project-sessions"));
1288
1102
  const rest = msgs.filter(m => !fyi.includes(m));
1289
1103
  const direct = rest.filter(m => m.to === SESSION && shouldWake(m));
@@ -1294,14 +1108,8 @@ function askedExcerpt(message) {
1294
1108
  const bcast = [...rest.filter(m => !direct.includes(m) && !mentions.includes(m)), ...fyi];
1295
1109
  pendingBcast.push(...bcast); // wake-policy: plain broadcasts batch, they don't wake
1296
1110
  const wakeCandidates = [...direct, ...mentions];
1297
- // #6228: a wake naming another project (its sender's home project, not this seat's, and the
1298
- // two are not `trantor policy link`ed) is dropped without acting — never queued, never folded
1299
- // into context. One report goes back to the sender so it does not just look like silence.
1300
- // The hub's OWN agents (`hub:duty` et al.) are exempt: they speak for this hub's projects,
1301
- // not a foreign one, and fencing them made every seat deaf to #5760's actionable
1302
- // file-conflict warnings — the same class of pseudo-id notifyAssigners already treats
1303
- // specially. Found by test-failure.mjs (#6301): the fence refused the drill's duty-agent
1304
- // wake exactly as it refused real cross-project traffic.
1111
+ // #6228: a wake naming an unlinked foreign project is dropped, with one report to the sender.
1112
+ // The hub's own agents (`hub:duty` et al.) are exempt: they speak for this hub's projects (#6301).
1305
1113
  const links = wakeCandidates.length ? await currentLinks() : [];
1306
1114
  const crossProject = wakeCandidates.filter(m => !String(m.from || "").startsWith("hub:") && !isLinkedProject(senderProjectOf(m.from), PROJ, links));
1307
1115
  for (const m of crossProject) {
@@ -1338,11 +1146,8 @@ function askedExcerpt(message) {
1338
1146
  messages: wake,
1339
1147
  statePath: DUTY_NUDGE_STATE,
1340
1148
  owner: `${RUNNER_ID}:${TURN + 1}`,
1341
- // Has the recipient already read it? /peer — SINGULAR — is the only endpoint that serialises
1342
- // deliveredUpTo (/peers does not, a gap that already cost one wrong diagnosis today). The
1343
- // cursor is monotonic, so `>= id` means the message was handed over and there is nothing to
1344
- // nudge about. Best-effort by design: any failure here leaves the nudge standing, because a
1345
- // missed nudge is worse than a redundant one.
1149
+ // /peer (singular) is the only endpoint that serialises deliveredUpTo; the cursor is monotonic,
1150
+ // so `>= id` means handed over. Best-effort: a missed nudge is worse than a redundant one.
1346
1151
  isDelivered: async ({ id, recipient }) => {
1347
1152
  if (!recipient || !/^\d+$/.test(String(id))) return false;
1348
1153
  const r = await api(`/peer?session=${encodeURIComponent(recipient)}`).catch(() => null);
@@ -1385,13 +1190,8 @@ function askedExcerpt(message) {
1385
1190
  for (const m of wakeForTurn) if (m.from && !assigners.some(a => a.from === m.from)) assigners.push({ from: m.from, id: m.id });
1386
1191
  const asked = askedExcerpt(wakeForTurn[0]);
1387
1192
  const tStart = Date.now();
1388
- // #6134: ONE SESSION PER CARD. A seat that resumes forever carries every card it ever worked
1389
- // into every later turn — qwen's 85.7M tokens were 96.7% cached, i.e. replayed history. The
1390
- // card that moved this wake decides: a different one starts a fresh CLI session, and the seat
1391
- // is told so, because a fresh session remembers nothing and must be sent to its card.
1392
- // #7061: bound by SHAPE, not by position. `cardRef` alone took the earliest id in the wake
1393
- // TEXT, and an order that opens with what shipped ("#7037 is merged as a01f629 … YOUR CARD:
1394
- // #6983") binds the turn — its state sidecar, its card log, its run record — to a done card.
1193
+ // #6134: ONE SESSION PER CARD; a different card starts a fresh CLI session and the seat is told.
1194
+ // #7061: bound by SHAPE, not position, so an order opening with what shipped binds the right card.
1395
1195
  const card = wakeCard(wakeForTurn, { session: SESSION });
1396
1196
  const fresh = card > 0 && card !== sessionCard;
1397
1197
  if (card) sessionCard = card;
@@ -1469,22 +1269,13 @@ function askedExcerpt(message) {
1469
1269
  }
1470
1270
  savePending(pendingWake, pendingBcast);
1471
1271
  await reportFailure(ec, "message", pendingWake.length, reason);
1472
- // #6289: TWO consecutive exit-1 turns on one contract PARK the seat — no third attempt.
1473
- // The burn this stops (card #6270, 2026-09-03): an exit-1 turn rode the redelivery ladder,
1474
- // and every rung re-sent the SAME contract as a fresh full turn — the seat re-read and
1475
- // re-did finished work, then died to the same API error or the box again; five cycles,
1476
- // 4.7h, for a 34-line change. The first failure still retries; the second parks with a
1477
- // reason (time-box when the chain died to box cuts, api-error otherwise), holds the queue,
1478
- // and is woken again only by `trantor up` (a restart).
1272
+ // #6289: TWO consecutive exit-1 turns on one contract PARK the seat (time-box when the chain
1273
+ // died to cuts, api-error otherwise), holding the queue until `trantor up`.
1479
1274
  const parkReason = PARKING_REASONS.has(reason) ? reason : (lastTurnCut ? "time-box" : "api-error");
1480
1275
  if (PARKING_REASONS.has(reason) || deliveryFails >= 2) {
1481
1276
  retryAt = await parkSeat(parkReason, pendingWake.length, quotaReset);
1482
- // A supervised seat does not have to sit parked until someone notices. RUNNER_PARK_MAX_MS
1483
- // is set only by `trantor duty up`, which runs the seat under a launchd keepalive: past the
1484
- // ceiling, exit and let the supervisor restart it clean — a fresh process re-reads auth and
1485
- // redelivers the queue from disk, which is exactly what un-wedged the 2026-09-09 incident
1486
- // when the operator finally ran `trantor duty up` by hand 21.9 hours late.
1487
- // Unsupervised seats keep the old behaviour: exiting would just kill them for good.
1277
+ // RUNNER_PARK_MAX_MS is set only by `trantor duty up` (launchd keepalive): past the ceiling,
1278
+ // exit so the supervisor restarts clean. Unsupervised seats stay parked; exiting would kill them.
1488
1279
  const parkMax = Number(process.env.RUNNER_PARK_MAX_MS || 0);
1489
1280
  if (parkMax > 0) {
1490
1281
  const wakeIn = Math.max(0, Math.min(retryAt - Date.now(), parkMax));