trantor 0.18.34 → 0.18.35

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trantor",
3
- "version": "0.18.34",
3
+ "version": "0.18.35",
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": {
@@ -23,6 +23,9 @@ import {
23
23
  readPromptText, stripPromptEcho,
24
24
  } from "../lib/classify-failure.mjs";
25
25
  import { capWake, capBcast, pickLessons, composePrompt } from "./crew-payload.mjs";
26
+ import {
27
+ cardRef, carriesWork, parseTurnTokens, parseResetAt, reasonWithBalances, PARKING_REASONS,
28
+ } from "../lib/turn-policy.mjs";
26
29
 
27
30
  const AGENT = process.argv[2];
28
31
  const DIR = process.argv[3] || process.cwd();
@@ -272,7 +275,7 @@ if (!CLI[AGENT]) log(`'${AGENT}' is not a built-in seat — running it as an ope
272
275
 
273
276
  // RUNNER_RULES / RUNNER_KICKOFF env overrides: the runner is also the substrate for non-crew
274
277
  // always-on seats (the fleet DUTY agent, bin/duty.mjs) whose doctrine is not "work your card".
275
- const RULES = process.env.RUNNER_RULES || `Rules: you are ${SESSION} on the trantor crew. Before starting a card, query the board for related PAST cards and lessons (relay_board 1900+ cards of tribal knowledge; prior work may already answer half of it). 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.`;
278
+ 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.`;
276
279
 
277
280
  // ---- the pulse (Scape's Lloyd/Argus loop, Trantor-shaped) --------------------
278
281
  // A message-driven seat is DEAF between messages. An orchestrator seat with a mission needs a
@@ -353,9 +356,9 @@ function classify(exit) {
353
356
  return reason;
354
357
  }
355
358
 
356
- async function reportFailure(exit, trigger, undelivered = 0) {
359
+ async function reportFailure(exit, trigger, undelivered = 0, reasonOverride = "") {
357
360
  consecFails++;
358
- const reason = classify(exit);
361
+ const reason = reasonOverride || classify(exit);
359
362
  const down = consecFails >= 2;
360
363
  const status = down ? `down: ${reason} · ${consecFails} fails` : `errored: ${reason}`;
361
364
  await api("/register", { session: SESSION, project: PROJ, status, llm: AGENT, model: MODEL, kind: "agent" }).catch(() => {});
@@ -394,6 +397,41 @@ async function reportFailure(exit, trigger, undelivered = 0) {
394
397
  }
395
398
  cmuxStatus(down ? "down" : "error", "#ef6a6a", "alert", { alert: true, priority: 90 }); herdrAgent("blocked"); cmuxLog(`turn failed: ${reason} (exit ${exit})`, "error");
396
399
  log(`\x1b[31mreported failure to bus: ${reason} (exit ${exit})\x1b[0m`);
400
+ return reason;
401
+ }
402
+
403
+ // ---- a dead seat is not retried (#6134) -------------------------------------------------------
404
+ // The redelivery ladder assumes the next attempt might work. Against a spent plan or a rejected
405
+ // key it never will, and the cost is real: codex burned 60 turns on 09-02 doing nothing but being
406
+ // redelivered to. So those two reasons PARK — the queue is kept, the ladder stops, and the room is
407
+ // told once, with the reset time when the CLI printed one. `trantor up` (a restart) resumes.
408
+ let parkAnnounced = false;
409
+ async function parkSeat(reason, undelivered) {
410
+ const resetAt = parseResetAt(lastErrText);
411
+ const when = resetAt ? new Date(resetAt).toLocaleString() : "";
412
+ if (!parkAnnounced) {
413
+ parkAnnounced = true;
414
+ const text = redactKeys(`⛔ ${SESSION} PARKED (${reason}) — holding ${undelivered} message(s), redelivery stopped ${when ? `until ${when}` : `until \`trantor up ${AGENT}\``}`);
415
+ await api("/send", { from: SESSION, to: "all", text, project: PROJ, kind: "status" }).catch(() => {});
416
+ const orch = `${hostId()}:${PROJ}`;
417
+ if (orch !== SESSION) await api("/send", { from: SESSION, to: orch, text, project: PROJ, kind: "alert" }).catch(() => {});
418
+ }
419
+ log(`\x1b[31mparked (${reason})${when ? ` — retrying after ${when}` : " — no reset time in the output; waiting for a restart"}\x1b[0m`);
420
+ // No reset time means no timer can clear it: hold until the operator restarts the seat.
421
+ return resetAt || Number.MAX_SAFE_INTEGER;
422
+ }
423
+
424
+ // The seat's own balance rows, for the #6131 read: a stalled turn that printed nothing on a seat
425
+ // whose plan is spent is exhaustion, not a crash. Bounded and best-effort — a slow provider API
426
+ // must never hold up the failure path, and an unreachable one just leaves the reason as it was.
427
+ async function balanceRows() {
428
+ try {
429
+ const { fetchBalances } = await import("../lib/balances.mjs");
430
+ return await Promise.race([
431
+ fetchBalances(process.env, { only: [AGENT] }),
432
+ new Promise((r) => setTimeout(() => r([]), 4000)),
433
+ ]);
434
+ } catch { return []; }
397
435
  }
398
436
 
399
437
  // ---- activity truth (#5965): the RUNNER is the source for this seat ----------------
@@ -444,17 +482,33 @@ async function notifyAssigners(pairs, text) {
444
482
  async function reportHealthy() {
445
483
  if (consecFails === 0) return; // already healthy — don't spam
446
484
  consecFails = 0;
447
- // Recovery is a change too, so the next failure is news again.
485
+ // Recovery is a change too, so the next failure is news again — a park included.
448
486
  announced = "";
487
+ parkAnnounced = false;
449
488
  await api("/register", { session: SESSION, project: PROJ, status: `active in ${PROJ}`, llm: AGENT, model: MODEL, kind: "agent" }).catch(() => {});
450
489
  await api("/send", { from: SESSION, to: "all", text: `✅ ${SESSION} recovered`, project: PROJ, kind: "status" }).catch(() => {});
451
490
  cmuxStatus("ok", "#14b8a6", "check"); herdrAgent("idle");
452
491
  }
453
492
 
493
+ // ---- the time box (#6134) --------------------------------------------------------------------
494
+ // A turn with no ceiling is how a seat spends an afternoon on one card: the 09-02 baseline was 151
495
+ // turns and ~16 agentic hours across the fleet. TRANTOR_TURN_MAX_MS ends the CLI's process group
496
+ // at the box and runs ONE follow-up turn in the SAME session — "commit what is done, move the
497
+ // card, report in one line" — so a cut turn lands its work instead of losing it.
498
+ const TURN_MAX_MS = Math.max(0, Number(process.env.TRANTOR_TURN_MAX_MS || 20 * 60 * 1000));
499
+ 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";
500
+ let inFollowUp = false;
501
+ // The card the CURRENT CLI session belongs to (#6134). 0 = the kickoff session, which belongs to
502
+ // no card, so the first contract that names one starts a session of its own.
503
+ let sessionCard = 0;
504
+
454
505
  let sid = "";
455
506
  async function runTurn(prompt, isFirst, trigger = "kickoff") {
456
507
  TURN++; banner(trigger);
457
508
  const t0 = Date.now();
509
+ // A fresh session must not resume the old one's id: `first` is chosen by isFirst OR a missing
510
+ // sid, so a stale sid would quietly resume the session this turn exists to leave behind.
511
+ if (isFirst) sid = "";
458
512
  // #5965 — TURN START. The hub peer row is where the app reads activity from, and the runner is
459
513
  // the only one who knows a turn is starting, so say so before the CLI spawn (awaited: the spawn
460
514
  // below blocks the loop, an unawaited fetch would not leave the machine until the turn ended).
@@ -515,7 +569,15 @@ async function runTurn(prompt, isFirst, trigger = "kickoff") {
515
569
  // under load the classifier then reads an empty ERRF and reports the wrong failure reason.
516
570
  const shell = `set -o pipefail; { ${inner} ; } 2> >(${SCRUB} --tee2 ${ERRF}); turn_exit=$?; wait; exit $turn_exit`;
517
571
  const r = spawnSync("/bin/bash", ["-c", shell], {
518
- cwd: TURN_DIR, encoding: "utf8", stdio: cli.sid ? ["ignore", "pipe", "inherit"] : "inherit",
572
+ // detached: bash leads its OWN process group, so the time box can kill the CLI and everything
573
+ // it spawned with one signal instead of orphaning the model process behind a dead shell.
574
+ // stdin is /dev/null for every seat (it already was for codex/kimi/dsh via `< /dev/null`):
575
+ // a detached group is a BACKGROUND group, and a background process that reads the terminal
576
+ // takes SIGTTIN and stops forever. Nothing here runs interactively — every CLI is in -p /
577
+ // exec / run mode — so closing stdin is what makes the group safe.
578
+ detached: true,
579
+ ...(TURN_MAX_MS ? { timeout: TURN_MAX_MS, killSignal: "SIGKILL" } : {}),
580
+ cwd: TURN_DIR, encoding: "utf8", stdio: cli.sid ? ["ignore", "pipe", "inherit"] : ["ignore", "inherit", "inherit"],
519
581
  env: { ...process.env, RELAY_URL: HUB, RELAY_AGENT: AGENT, RELAY_SESSION: SESSION, RELAY_PROJECT: PROJ,
520
582
  // A RUNNER-MANAGED SEAT MUST NEVER HAND ITSELF A BATON.
521
583
  //
@@ -531,6 +593,14 @@ async function runTurn(prompt, isFirst, trigger = "kickoff") {
531
593
  maxBuffer: 16 * 1024 * 1024,
532
594
  });
533
595
  try { unlinkSync(STAMPF); } catch {} // turn over — disarm the watchdog
596
+ // #6134: node's timeout signals only the shell it spawned. The CLI and its children share the
597
+ // detached group, so sweep the whole group — otherwise a boxed turn leaves a model still running
598
+ // (and still billing) against a runner that has moved on.
599
+ const cut = !!TURN_MAX_MS && (r.error?.code === "ETIMEDOUT" || (r.signal === "SIGKILL" && Date.now() - t0 >= TURN_MAX_MS));
600
+ if (cut && r.pid) {
601
+ try { process.kill(-r.pid, "SIGKILL"); } catch {}
602
+ log(`\x1b[33mturn cut at the ${Math.round(TURN_MAX_MS / 1000)}s time box — CLI process group ended\x1b[0m`);
603
+ }
534
604
  // #5869: scrub AT REST, synchronously, before anything reads the file back. The explicit shell
535
605
  // wait above drains the live stderr scrubber first; this pass is defense in depth for redaction.
536
606
  try { writeFileSync(ERRF, redactKeys(readFileSync(ERRF, "utf8"))); } catch {}
@@ -578,12 +648,23 @@ async function runTurn(prompt, isFirst, trigger = "kickoff") {
578
648
  // #5868: the verdict rides the telemetry row so a classification survives the pane scrolling
579
649
  // away — the same "classified X because Y" shape the runner logs, in the seat's jsonl forever.
580
650
  const verdict = verdictFor(realExit, effExit, lastEmptyOutput, ownOut);
581
- telemetry({ ts: Date.now(), agent: AGENT, project: PROJ, turn: TURN, trigger, model: MODEL || "cli-default", duration_ms: Date.now() - t0, exit: realExit, effExit, authFailed: effExit !== realExit, emptyOutput: lastEmptyOutput, verdict });
651
+ // #6134: what the turn COST, from the CLI's own usage line. Zero means this CLI printed none
652
+ // never that the turn was free. `trantor seat-why` totals these into today's spend per seat.
653
+ const tokens = parseTurnTokens(ownOut);
654
+ telemetry({ ts: Date.now(), agent: AGENT, project: PROJ, turn: TURN, trigger, model: MODEL || "cli-default", duration_ms: Date.now() - t0, exit: realExit, effExit, authFailed: effExit !== realExit, emptyOutput: lastEmptyOutput, verdict, ...(tokens ? { tokens } : {}), ...(cut ? { cut: true } : {}) });
582
655
  log(`turn ended (exit ${realExit}${effExit !== realExit ? ` → effective ${effExit} (${lastEmptyOutput ? "empty-output" : "auth"})` : ""}, ${((Date.now() - t0) / 1000).toFixed(0)}s)`);
583
656
  if (realExit === 0 && effExit === 0) { cmuxStatus("idle", "#8a94a6", "robot"); herdrAgent("idle"); } // finished this turn, waiting for the next
584
657
  // #5965 — TURN END. A clean exit means the seat is idle again; say so right away so the app stops
585
658
  // pulsing it even before the next /poll heartbeat. Failure keeps reportFailure's down/errored.
586
659
  if (realExit === 0 && effExit === 0) await registerStatus("idle");
660
+ // The follow-up rides the SAME session, so the model still has the turn it was cut out of and
661
+ // only has to land it. Exactly one — a follow-up that runs long is itself boxed, and boxing a
662
+ // boxed turn forever is the loop this card exists to end.
663
+ if (cut && !inFollowUp) {
664
+ inFollowUp = true;
665
+ try { return await runTurn(TIME_BOX_PROMPT, false, "time-box follow-up"); }
666
+ finally { inFollowUp = false; }
667
+ }
587
668
  return effExit;
588
669
  }
589
670
 
@@ -655,8 +736,19 @@ function isRunnerSession(session) {
655
736
 
656
737
  function shouldWake(message) {
657
738
  if (isReceipt(message) || isStatusBroadcast(message)) return false;
739
+ // #6134: the SENDER decides. `wake:false` says "this is context, not a contract" — it batches
740
+ // into the next turn's prompt like a broadcast and never buys a CLI session of its own.
741
+ if (message?.wake === false) return false;
658
742
  if (message?.to === SESSION) {
659
743
  if (message?.kind === "status") return false;
744
+ // The safety net for every sender that never set the flag: a direct message carrying no card
745
+ // and no instruction is an ack, an FYI or a queue note. Those made up most of the 09-02 burn.
746
+ // Two exemptions, both because the shape net reads WORDS and these carry their meaning in
747
+ // their type: a typed alert (a failure escalation, a bounce), and an OVERSEER warning that got
748
+ // this far — the one chatty overseer kind is already batched by name upstream, so anything
749
+ // still here is file-conflict or linked-activity, which #5760 deliberately kept waking.
750
+ const typed = message?.kind === "alert" || /^🤝 OVERSEER /.test(String(message?.text || ""));
751
+ if (!typed && !isContract(message) && !carriesWork(message?.text)) return false;
660
752
  return !isRunnerSession(message?.from) || isContract(message);
661
753
  }
662
754
  return message?.to === "all"
@@ -764,7 +856,10 @@ function askedExcerpt(message) {
764
856
  const rest = msgs.filter(m => !fyi.includes(m));
765
857
  const direct = rest.filter(m => m.to === SESSION && shouldWake(m));
766
858
  const mentions = rest.filter(m => m.to === "all" && shouldWake(m));
767
- const bcast = [...rest.filter(m => m.to === "all" && !mentions.includes(m)), ...fyi];
859
+ // Everything that did not earn a turn still becomes CONTEXT — including a DIRECT message that
860
+ // batched (wake:false, or an ack by shape). Dropping those would trade a token problem for a
861
+ // deafness problem: the seat would never learn what it was told (#6134).
862
+ const bcast = [...rest.filter(m => !direct.includes(m) && !mentions.includes(m)), ...fyi];
768
863
  pendingBcast.push(...bcast); // wake-policy: plain broadcasts batch, they don't wake
769
864
  const wake = [...direct, ...mentions];
770
865
  if (!wake.length) { if (bcast.length) { savePending(pendingWake, pendingBcast); log(`${bcast.length} broadcast(s) batched (no wake) — ${pendingBcast.length} pending`); } continue; }
@@ -810,22 +905,43 @@ function askedExcerpt(message) {
810
905
  for (const m of wake) if (m.from && !assigners.some(a => a.from === m.from)) assigners.push({ from: m.from, id: m.id });
811
906
  const asked = askedExcerpt(wake[0]);
812
907
  const tStart = Date.now();
908
+ // #6134: ONE SESSION PER CARD. A seat that resumes forever carries every card it ever worked
909
+ // into every later turn — qwen's 85.7M tokens were 96.7% cached, i.e. replayed history. The
910
+ // card that moved this wake decides: a different one starts a fresh CLI session, and the seat
911
+ // is told so, because a fresh session remembers nothing and must be sent to its card.
912
+ const card = wake.map(m => cardRef(m.text)).find(Boolean) || 0;
913
+ const fresh = card > 0 && card !== sessionCard;
914
+ if (card) sessionCard = card;
915
+ const freshText = fresh
916
+ ? `\n(FRESH SESSION for card #${card} — you are not the session that worked earlier cards and you remember none of them. Read your card first: relay_board with card:${card}.)\n`
917
+ : "";
813
918
  const prompt = composedTurn({
814
- wakeText, ctxText, againText,
919
+ wakeText, ctxText, againText: againText + freshText,
815
920
  tailText: "\nAct on what's addressed to you, then end your turn.\n\n",
816
921
  rulesText: RULES, lessons,
817
922
  });
818
- const ec = await runTurn(prompt, false, deliveryFails ? `${trigger} (redelivery)` : trigger);
923
+ const ec = await runTurn(prompt, fresh, deliveryFails ? `${trigger} (redelivery)` : trigger);
819
924
  const secs = Math.round((Date.now() - tStart) / 1000);
820
925
  if (ec) {
821
926
  deliveryFails++;
927
+ // #6131: a silent turn on a seat whose plan reads spent is exhaustion wearing a crash's
928
+ // clothes. Only that one reason is ever re-read, and only from the seat's own balance rows.
929
+ let reason = classify(ec);
930
+ if (reason === "empty-output") reason = reasonWithBalances(reason, await balanceRows());
931
+ savePending(pendingWake, pendingBcast);
932
+ await reportFailure(ec, "message", pendingWake.length, reason);
933
+ if (PARKING_REASONS.has(reason)) {
934
+ retryAt = await parkSeat(reason, pendingWake.length);
935
+ await notifyAssigners(assigners,
936
+ `⛔ your contract is PARKED on ${SESSION} (${reason}) — not retrying · asked: "${asked}"`);
937
+ lastTurnAt = Date.now();
938
+ return;
939
+ }
822
940
  const wait = RETRY_MS[Math.min(deliveryFails - 1, RETRY_MS.length - 1)];
823
941
  retryAt = Date.now() + wait;
824
- savePending(pendingWake, pendingBcast);
825
- await reportFailure(ec, "message", pendingWake.length);
826
942
  // The room hears the broadcast above; the one who is actually blocked hears it directly.
827
943
  await notifyAssigners(assigners,
828
- `⚠️ your contract FAILED on ${SESSION} (exit ${ec}, ${classify(ec)}) · retrying in ${Math.round(wait / 1000)}s · asked: "${asked}"`);
944
+ `⚠️ your contract FAILED on ${SESSION} (exit ${ec}, ${reason}) · retrying in ${Math.round(wait / 1000)}s · asked: "${asked}"`);
829
945
  log(`\x1b[31m${pendingWake.length} message(s) still UNDELIVERED — next attempt in ${Math.round(wait / 1000)}s\x1b[0m`);
830
946
  } else {
831
947
  pendingWake = []; pendingBcast = []; deliveryFails = 0; retryAt = 0;
package/bin/crew.sh CHANGED
@@ -792,12 +792,16 @@ resolve_spec() {
792
792
  if [ -n "$FIELD" ]; then
793
793
  case "$FIELD" in
794
794
  */*) MODEL="$FIELD" ;;
795
- *) MODEL="$(resolve_model "$AGENT" "$FIELD" "$TASK" "$DIFF")" || {
795
+ # A NATIVE CLI takes its model id verbatim: `claude:opus`, `codex:o3`, `kimi:k2`. The router
796
+ # only knows opencode providers, so it refused `claude:opus` outright (2026-09-03) and the
797
+ # seat silently ran the CLI default, which for claude was the most expensive model on the plan.
798
+ *) if [[ "$AGENT" =~ ^(claude|codex|kimi|gemini)$ ]]; then MODEL="$FIELD"; echo " → $AGENT: model $MODEL (native pin)"; else
799
+ MODEL="$(resolve_model "$AGENT" "$FIELD" "$TASK" "$DIFF")" || {
796
800
  echo "[crew] ✗ skipping seat '$AGENT' — model resolution failed for $FIELD ($TASK/$DIFF); remaining seats still launch" >&2
797
801
  SKIPPED_SEATS+=("$AGENT: model resolution failed for $FIELD ($TASK/$DIFF)")
798
802
  return 1
799
803
  }
800
- echo " → $AGENT: live model $MODEL ($FIELD · $TASK/$DIFF)" ;;
804
+ echo " → $AGENT: live model $MODEL ($FIELD · $TASK/$DIFF)"; fi ;;
801
805
  esac
802
806
  fi
803
807
  }
package/bin/seat-why.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  // trantor seat-why <agent> [--json] — explain why a crew seat is (not) working, straight from
3
3
  // local ~/.agent-bus evidence. No hub needed; works even when the whole fleet is down.
4
- import { seatWhy } from "../lib/seat-why.mjs";
4
+ import { seatWhy, fmtSpend } from "../lib/seat-why.mjs";
5
5
  import { resolveProject } from "../lib/project.mjs";
6
6
 
7
7
  const args = process.argv.slice(2);
@@ -23,5 +23,6 @@ if (asJson) {
23
23
  } else {
24
24
  console.log(`seat ${agent}:${project} -> ${out.state}`);
25
25
  console.log(`why: ${out.why}`);
26
+ console.log(`today: ${fmtSpend(out.today)}`);
26
27
  console.log(`advice: ${out.advice}`);
27
28
  }
package/hub.mjs CHANGED
@@ -2786,7 +2786,11 @@ const server = http.createServer(async (req, res) => {
2786
2786
  // side, which is how an orchestrator ends up waiting forever on a dead peer.
2787
2787
  const re = Number.isFinite(Number(b.re)) && Number(b.re) > 0 ? Number(b.re) : 0;
2788
2788
  const kind = String(b.kind || "").slice(0, 40);
2789
- const msg = { id: ++state.seq, ts: now(), from: b.from || "anon", to: b.to || "all", text, project: String(b.project || fromProj || "").slice(0, 80), ...(re ? { re } : {}), ...(kind ? { kind } : {}) };
2789
+ // `wake:false` is the SENDER saying this message is context, not a contract: the receiving
2790
+ // runner batches it into that seat's next turn instead of spending a whole CLI session on
2791
+ // it (#6134). Stored only when false — absent means wake, so every older client is unchanged.
2792
+ const wake = b.wake === false ? { wake: false } : {};
2793
+ const msg = { id: ++state.seq, ts: now(), from: b.from || "anon", to: b.to || "all", text, project: String(b.project || fromProj || "").slice(0, 80), ...(re ? { re } : {}), ...(kind ? { kind } : {}), ...wake };
2790
2794
  state.messages.push(msg); if (state.messages.length > 5000) state.messages.splice(0, 1000);
2791
2795
  dirty = true; pushToStreams(msg); // <-- instant push to live watchers
2792
2796
  // Mirror onto the unified log. `refs` = the card ids this message cites (#3701), which is what
package/lib/seat-why.mjs CHANGED
@@ -61,6 +61,32 @@ const rel = (ts) => {
61
61
  return `${Math.round(s / 3600)}h ago`;
62
62
  };
63
63
 
64
+ // What this seat has SPENT today (#6134). The turn count is the number that mattered on 09-02:
65
+ // 151 turns and ~16 agentic hours across the fleet, most of it redelivery and coordination noise.
66
+ // Tokens come from each CLI's own usage line (the runner parses it onto the turn's row); CLIs that
67
+ // print none contribute 0, which is why the count is reported alongside — "3 of 7 turns reported".
68
+ export function todaySpend(telemetry, now = Date.now()) {
69
+ const midnight = new Date(now); midnight.setHours(0, 0, 0, 0);
70
+ const rows = telemetry.filter(r => typeof r.turn === "number" && r.ts >= midnight.getTime());
71
+ const withTokens = rows.filter(r => Number(r.tokens) > 0);
72
+ return {
73
+ turns: rows.length,
74
+ minutes: Math.round(rows.reduce((a, r) => a + (Number(r.duration_ms) || 0), 0) / 60000),
75
+ tokens: withTokens.reduce((a, r) => a + Number(r.tokens), 0),
76
+ reported: withTokens.length,
77
+ cut: rows.filter(r => r.cut).length,
78
+ };
79
+ }
80
+
81
+ export function fmtSpend(s) {
82
+ const bits = [`${s.turns} turn${s.turns === 1 ? "" : "s"}`, `${s.minutes}m`];
83
+ bits.push(s.reported
84
+ ? `${s.tokens.toLocaleString("en-US")} tokens (${s.reported}/${s.turns} turns reported)`
85
+ : "tokens not reported by this CLI");
86
+ if (s.cut) bits.push(`${s.cut} cut at the time box`);
87
+ return bits.join(" · ");
88
+ }
89
+
64
90
  export function seatWhy(project, agent, opts = {}) {
65
91
  const dir = opts.dir || busDir();
66
92
  const errText = readF(join(dir, `err-${agent}-${project}.txt`));
@@ -117,7 +143,7 @@ export function seatWhy(project, agent, opts = {}) {
117
143
  }
118
144
  }
119
145
 
120
- return { state, why, advice };
146
+ return { state, why, advice, today: todaySpend(telemetry, opts.now) };
121
147
  }
122
148
 
123
149
  export { fmt, rel };
@@ -0,0 +1,114 @@
1
+ // Per-turn policy for a crew seat (#6134) — the rules that decide whether a bus message is worth
2
+ // a CLI turn at all, which card a session belongs to, what a turn cost, and when an exhausted seat
3
+ // is allowed to be woken again.
4
+ //
5
+ // These live here rather than in bin/crew-runner.mjs because the runner is a self-executing script
6
+ // (top-level await, spawns turns on import), so nothing in it can be unit-tested directly. The
7
+ // drill still drives the REAL runner end to end; these functions are what it can assert on cheaply.
8
+ //
9
+ // The burn this exists to stop: on 09-02 the fleet spent 151 turns and ~16 agentic hours, codex
10
+ // alone taking 60 turns of pure redelivery, and qwen 85.7M tokens at 96.7% cached — i.e. the same
11
+ // history replayed over and over. Every rule below removes one class of turn that never had work
12
+ // in it.
13
+
14
+ /// The first card a message cites. A turn belongs to exactly one card, and this is how the runner
15
+ /// knows when a wake has moved to a different one.
16
+ export const CARD_REF_RE = /#(\d{1,7})(?!\d)/;
17
+
18
+ /// Words that make a direct message an instruction rather than conversation. Deliberately short:
19
+ /// the point is to catch a contract that forgot to cite its card, not to parse English.
20
+ export const IMPERATIVE_RE = /\b(deliver|fix|bounce|contract|next|resume)\b/i;
21
+
22
+ export function cardRef(text) {
23
+ const m = CARD_REF_RE.exec(String(text || ""));
24
+ return m ? Number(m[1]) : 0;
25
+ }
26
+
27
+ export function hasImperative(text) {
28
+ return IMPERATIVE_RE.test(String(text || ""));
29
+ }
30
+
31
+ /// The safety net for a sender that never set `wake`: a direct message with no card and no
32
+ /// imperative is an ack, an FYI or a queue note, and it batches into the next turn's context.
33
+ export function carriesWork(text) {
34
+ return cardRef(text) > 0 || hasImperative(text);
35
+ }
36
+
37
+ // ---- what a turn cost -------------------------------------------------------------------------
38
+ // Each CLI reports its own usage in its own words, and some report none at all. Ordered most
39
+ // specific first; the LAST match of the first pattern that hits wins, because a CLI that prints a
40
+ // running total prints the real one last. Zero means "this CLI said nothing", never "free".
41
+ const TOKEN_PATTERNS = [
42
+ /tokens used[:\s]+([\d,]+)/gi, // codex
43
+ /\btotal tokens[:\s]+([\d,]+)/gi, // opencode / glm / deepseek summaries
44
+ /\btokens[:\s]+([\d,]+)/gi, // "Tokens: 12,345"
45
+ /([\d,]+)\s+tokens\b/gi, // "12,345 tokens"
46
+ ];
47
+
48
+ export function parseTurnTokens(text) {
49
+ const s = String(text || "");
50
+ for (const re of TOKEN_PATTERNS) {
51
+ re.lastIndex = 0;
52
+ let last = 0;
53
+ for (const m of s.matchAll(re)) {
54
+ const n = Number(String(m[1]).replace(/,/g, ""));
55
+ if (Number.isFinite(n)) last = n;
56
+ }
57
+ if (last) return last;
58
+ }
59
+ return 0;
60
+ }
61
+
62
+ // ---- when an exhausted seat may be woken again ------------------------------------------------
63
+ // A CLI that hits its plan wall usually says when the wall lifts. Parsing it turns a blind retry
64
+ // ladder (60 redelivery turns on codex, 09-02) into one wait.
65
+ const MONTHS = "jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec";
66
+ const RESET_ABS_RE = new RegExp(
67
+ String.raw`try again (?:at|on|after)\s+((?:${MONTHS})[a-z]*\.?\s+\d{1,2}(?:st|nd|rd|th)?,?\s+\d{4},?\s+\d{1,2}:\d{2}(?::\d{2})?\s*(?:am|pm)?)`,
68
+ "i",
69
+ );
70
+ const RESET_REL_RE = /try again in\s+(\d+)\s*(second|minute|hour|day)s?/i;
71
+
72
+ /// The epoch ms at which this seat may be retried, or 0 when the output named no time.
73
+ export function parseResetAt(text, now = Date.now()) {
74
+ const s = String(text || "");
75
+
76
+ const abs = RESET_ABS_RE.exec(s);
77
+ if (abs) {
78
+ // "Sep 3rd, 2026 3:34 AM" — Date.parse rejects the ordinal suffix, so drop it.
79
+ const t = Date.parse(abs[1].replace(/(\d{1,2})(st|nd|rd|th)/i, "$1"));
80
+ if (Number.isFinite(t) && t > now) return t;
81
+ }
82
+
83
+ const rel = RESET_REL_RE.exec(s);
84
+ if (rel) {
85
+ const unit = { second: 1e3, minute: 60e3, hour: 3600e3, day: 86400e3 }[rel[2].toLowerCase()];
86
+ if (unit) return now + Number(rel[1]) * unit;
87
+ }
88
+
89
+ return 0;
90
+ }
91
+
92
+ /// #6131: a qwen seat whose token plan is spent does not error — it stalls and returns nothing, so
93
+ /// the runner classified it `empty-output` and kept the ladder running against a wall. A silent
94
+ /// turn on a seat whose own balance row reads spent IS exhaustion, and parks like one.
95
+ export function quotaSpent(rows) {
96
+ return (Array.isArray(rows) ? rows : []).some((r) => {
97
+ if (!r || !r.ok) return false;
98
+ if (r.kind === "quota") return r.remainingPct != null && r.remainingPct <= 0;
99
+ if (r.kind === "windows") {
100
+ return (r.windows || []).some((w) => w.locked || (w.usedPct != null && w.usedPct >= 100));
101
+ }
102
+ return r.remaining != null && r.remaining <= 0;
103
+ });
104
+ }
105
+
106
+ /// The failure reason a turn should be treated as, given what the seat's balances say. Only
107
+ /// `empty-output` is ever re-read this way: every other reason already carries its own evidence.
108
+ export function reasonWithBalances(reason, rows) {
109
+ return reason === "empty-output" && quotaSpent(rows) ? "exhausted" : reason;
110
+ }
111
+
112
+ /// Seats that park rather than retry. A backend error is the provider having a bad minute and the
113
+ /// ladder is exactly right for it; a spent plan or a rejected key will not fix itself on a timer.
114
+ export const PARKING_REASONS = new Set(["exhausted", "auth"]);
package/mcp.mjs CHANGED
@@ -314,12 +314,55 @@ server.tool("relay_withdraw_proposal", "Withdraw one of THIS session's PENDING p
314
314
  return { content: [{ type: "text", text: `proposal #${id} withdrawn — one queue slot free` }] };
315
315
  });
316
316
 
317
+ // ONE card, not the board (#6134). A seat used to be told to "query the board for related PAST
318
+ // cards and lessons — 1900+ cards of tribal knowledge", and it did: the whole board, every turn,
319
+ // almost all of it other people's work. This is the same intent at a thousandth of the tokens —
320
+ // the card, what it waits on, its own notes, and the handful of done cards that actually rhyme
321
+ // with it. Client-side on /tasks, so no hub change and it works against any hub version.
322
+ const STOPWORDS = new Set(["the","a","an","and","or","of","to","in","on","for","with","is","it","its","that","this","not","but","by","at","as","from","into","out","up","down","when","then","than","so","no","new","one","two","every","all","any"]);
323
+ function titleWords(title) {
324
+ return new Set(String(title || "").toLowerCase().match(/[a-z][a-z0-9_-]{2,}/g)?.filter(w => !STOPWORDS.has(w)) || []);
325
+ }
326
+ function cardView(tasks, id, proj) {
327
+ const card = tasks.find(t => t.id === id);
328
+ if (!card) return `${proj}: no card #${id}`;
329
+ const out = [`#${card.id} ${card.title}`,
330
+ `status: ${card.status}${card.assignee ? ` · @${card.assignee}` : ""}${card.difficulty ? ` · ${card.difficulty}` : ""}${card.model ? ` · ${card.model}` : ""}`];
331
+
332
+ const deps = (Array.isArray(card.deps) ? card.deps : []).map(d => {
333
+ const t = tasks.find(x => x.id === d);
334
+ return t ? `#${t.id} ${t.title} [${t.status}]` : `#${d} (not on this board)`;
335
+ });
336
+ if (deps.length) out.push(`depends on:\n ${deps.join("\n ")}`);
337
+
338
+ if (Array.isArray(card.checklist) && card.checklist.length) {
339
+ out.push(`checklist:\n ${card.checklist.map((c, i) => `[${c.done ? "x" : " "}] ${i}. ${c.text || c}`).join("\n ")}`);
340
+ }
341
+
342
+ const log = Array.isArray(card.log) ? card.log : [];
343
+ if (log.length) out.push(`notes (${log.length}):\n ${log.map(e => `${e.by || "?"}: ${String(e.text || "").replace(/\s+/g, " ")}`).join("\n ")}`);
344
+
345
+ // The prior art that is actually prior art: done cards whose title shares a real word with this
346
+ // one. Five, newest first — enough to catch "we already did this", short enough to stay cheap.
347
+ const mine = titleWords(card.title);
348
+ const kin = tasks
349
+ .filter(t => t.status === "done" && t.id !== card.id && [...titleWords(t.title)].some(w => mine.has(w)))
350
+ .sort((a, b) => (b.updated || b.ts || 0) - (a.updated || a.ts || 0))
351
+ .slice(0, 5)
352
+ .map(t => `#${t.id} ${t.title}${t.log?.length ? ` ·${t.log.length}` : ""}`);
353
+ if (kin.length) out.push(`related done cards:\n ${kin.join("\n ")}`);
354
+
355
+ return out.join("\n");
356
+ }
357
+
317
358
  server.tool("relay_board", "Show a project's Kanban board (all cards + their status + assignee). Defaults to THIS project; pass `project` to read a crew board you orchestrate from elsewhere. Cards carrying log notes show a ·N count (the card's note-log size).",
318
- { project: z.string().optional().describe("board to show (default: this session's project)") },
319
- async ({ project }) => {
359
+ { project: z.string().optional().describe("board to show (default: this session's project)"),
360
+ card: z.number().optional().describe("read ONE card instead of the board: the card itself, its notes, and the last five done cards whose title shares a word with it. This is what a seat starting a card should call — the whole board is 1900+ cards of someone else's work.") },
361
+ async ({ project, card }) => {
320
362
  const proj = project || PROJECT;
321
363
  const { tasks } = await api("GET", `/tasks?project=${encodeURIComponent(proj)}`);
322
364
  if (!tasks.length) return { content: [{ type: "text", text: `${proj}: no cards yet` }] };
365
+ if (card) return { content: [{ type: "text", text: cardView(tasks, card, proj) }] };
323
366
  const by = { todo: [], doing: [], testing: [], failed: [], done: [], blocked: [] };
324
367
  for (const t of tasks) (by[t.status] || by.todo).push(`#${t.id} ${t.title}${t.assignee ? ` (@${t.assignee})` : ""}${t.log?.length ? ` ·${t.log.length}` : ""}`);
325
368
  const cols = Object.entries(by).filter(([, v]) => v.length).map(([k, v]) => `${k.toUpperCase()}:\n ${v.join("\n ")}`);
@@ -338,16 +381,17 @@ server.tool("relay_peers", "Find who you can talk to: the live agent sessions on
338
381
  });
339
382
 
340
383
  server.tool("relay_send", "Send a live message to another agent session (or 'all' to broadcast). Reach the other agent YOURSELF: if you are about to ask the human to pass something along, tell the session directly instead — asking a person to carry a message between two agents is a failure, not politeness. Don't know the id? relay_peers lists them, linked projects included. Cross-project sends are allowed.",
341
- { to: z.string().describe("target session id, or 'all'"), text: z.string().describe("message body") },
342
- async ({ to, text }) => {
384
+ { to: z.string().describe("target session id, or 'all'"), text: z.string().describe("message body"),
385
+ wake: z.boolean().optional().describe("false = context, not a contract: the message batches into the target's next turn instead of buying it a whole CLI session. Use it for acks, FYIs and queue notes; leave it unset for anything you expect worked on.") },
386
+ async ({ to, text, wake }) => {
343
387
  // The event log is append-only — a secret in it is unrecoverable, so refuse BEFORE
344
388
  // anything reaches the hub. Returns the offending kinds so the caller can fix it.
345
389
  const scrub = assertNoSecrets(text);
346
390
  if (!scrub.ok) {
347
391
  return { content: [{ type: "text", text: `REFUSED — not sent. Credential-shaped string(s) detected: ${scrub.kinds.join(", ")}. Remove them and resend.` }], isError: true };
348
392
  }
349
- const { id } = await api("POST", "/send", { from: SESSION, to, text });
350
- return { content: [{ type: "text", text: `sent #${id} to ${to}` }] };
393
+ const { id } = await api("POST", "/send", { from: SESSION, to, text, ...(wake === false ? { wake: false } : {}) });
394
+ return { content: [{ type: "text", text: `sent #${id} to ${to}${wake === false ? " (batched — no turn)" : ""}` }] };
351
395
  });
352
396
 
353
397
  server.tool("relay_status", "Set this session's one-line status on the presence board (what you're working on / idle). Cheap — other sessions read it instantly via relay_peers without messaging you.",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trantor",
3
- "version": "0.18.34",
3
+ "version": "0.18.35",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "trantor": "bin/cli.mjs"