trantor 0.18.67 → 0.18.68

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.67",
3
+ "version": "0.18.68",
4
4
  "description": "Trantor — the hub-world for AI agent crews: live message bus, presence, project Kanban/flow board + crew orchestration for independent AI coding agents (Claude, Codex, Gemini, Kimi, DeepSeek)",
5
5
  "mcpServers": {
6
6
  "relay": {
package/README.md CHANGED
@@ -378,9 +378,11 @@ agent via `relay_handoff`. Optional macOS auto-prompt (`autoHandoffPrompt` in
378
378
  Since 0.18.18 the succession is a machine, not a ritual: at 90% context the running agent is
379
379
  told to finish or checkpoint and author the boundary handoff itself; the app's banner counts
380
380
  down ("handing off in 10s") when the dial allows; an automatic digest defers to a fresh
381
- model-authored handoff instead of superseding it; the successor is injected a capped ≤4KB recap
382
- (the verbatim tail stays on disk, one path away) and gets a kickoff prompt so it recaps without
383
- being spoken to; and a session hosted in a Workspace pane is replaced in place by a detached
381
+ model-authored handoff instead of superseding it; the record on disk keeps the author's summary
382
+ whole and the ~4KB budget applies only to what is injected, where the cut is section-aware so the
383
+ successor's open threads survive it (0.18.68 before that the cut ran at write time and ate the
384
+ middle, which is exactly where a work order sits); the successor gets a kickoff prompt so it recaps
385
+ without being spoken to; and a session hosted in a Workspace pane is replaced in place by a detached
384
386
  driver — the same chain the app's [Hand off now] button runs.
385
387
 
386
388
  Waking works the same way from the other end: hover a sleeping project in the app's sidebar and
@@ -21,6 +21,7 @@ import { capWake, capBcast, pickLessons, composePrompt, contractBase, baseLine }
21
21
  import {
22
22
  cardRefs, wakeCard, carriesWork, parseTurnTokens, parseResetAt, reasonWithBalances, quotaResetAt, PARKING_REASONS,
23
23
  senderProjectOf, isLinkedProject, stateSkipReason, isMessageCardTitle, OPEN_CARD_STATUSES,
24
+ CUT_CHAIN_PARK_MIN, cutChainEvidence, isBoundedPark,
24
25
  } from "../lib/turn-policy.mjs";
25
26
  import {
26
27
  auditDutyNudges, claimDutyNudges, claudeTranscriptDir, dutyEscalations, dutyNudgeDirective,
@@ -295,6 +296,11 @@ function startDutyNudgeWatcher(plan, sinceMs) {
295
296
  // The hub hands a message out exactly once, so a turn that died took its wake with it. Here a
296
297
  // message is consumed only when a turn exits 0; the queue lives on disk and retries on backoff.
297
298
  const PENDF = join(homedir(), ".agent-bus", `pending-${AGENT}-${PROJ}.json`);
299
+ // #7914: the park, on disk, for everyone who is not on the bus. A park announces itself once and
300
+ // then the seat looks exactly like an idle one — which is how three seats sat 46 minutes while
301
+ // `trantor seat-why` read "live, last turn failed". This file is the state behind that event, so a
302
+ // reader who missed the message still finds the reason, the evidence and when it lifts.
303
+ const PARKF = join(homedir(), ".agent-bus", `park-${AGENT}-${PROJ}.json`);
298
304
  // #7778: message ids this seat already consumed through its OWN inbox path (relay_inbox, the
299
305
  // PostToolUse hook) — reconciled from the hub's deliveredUpTo ledger at each clean turn boundary
300
306
  // and persisted in the pending file, so a message the session read mid-turn is never re-woken by
@@ -418,28 +424,53 @@ async function reportFailure(exit, trigger, undelivered = 0, reasonOverride = ""
418
424
  // Against a spent plan or a rejected key the ladder never succeeds, so those two reasons PARK:
419
425
  // queue kept, ladder stopped, room told once with the reset time. `trantor up` resumes.
420
426
  let parkAnnounced = false;
421
- async function parkSeat(reason, undelivered, resetHint = 0) {
427
+ // #7914: the park this seat is currently sitting in, or null. Read by the poll loop, which ends a
428
+ // bounded one the moment somebody sends a direct message, and cleared by any turn that lands.
429
+ let parkState = null;
430
+ async function parkSeat(reason, undelivered, resetHint = 0, { evidence = "" } = {}) {
422
431
  // A seat that went QUIET printed no wall message to parse (#6131), so its own balance row is the
423
432
  // only place the reset time exists. Output first when there is any: it is this turn's evidence.
424
433
  const resetAt = parseResetAt(lastErrText) || resetHint;
425
- const when = resetAt ? new Date(resetAt).toLocaleString() : "";
434
+ // #7914: a park a timer can clear resumes on its own. The box ending a turn twice says nothing
435
+ // about the plan or the key, so holding that queue until `trantor up` was the seat going silent;
436
+ // exhaustion and auth keep the old shape, because no window makes a spent plan usable.
437
+ const bounded = isBoundedPark(reason) && PARK_WINDOW_MS > 0;
438
+ const until = bounded ? Date.now() + PARK_WINDOW_MS : (resetAt || Number.MAX_SAFE_INTEGER);
439
+ const when = bounded || resetAt ? new Date(bounded ? until : resetAt).toLocaleString() : "";
440
+ parkState = { reason, until, evidence, held: undelivered, ts: Date.now(), bounded };
441
+ // The park is a STATE, and every reader of it is off the bus: `trantor seat-why`, the app, the
442
+ // operator at a terminal an hour later. The message below is the event; this file is the state.
443
+ try { writeFileSync(PARKF, JSON.stringify({ session: SESSION, agent: AGENT, project: PROJ, ...parkState })); } catch {}
426
444
  if (!parkAnnounced) {
427
445
  parkAnnounced = true;
428
446
  // #7752: a stalled park names the CLI and its model — the swap decision needs both, and this
429
447
  // line is the one place the room reads them together.
430
448
  const named = reason === "stalled" ? ` — ${AGENT}${MODEL ? `, model ${MODEL}` : " (cli default model)"}` : "";
431
- const text = redactKeys(`⛔ ${SESSION} PARKED (${reason}${named}) holding ${undelivered} message(s), redelivery stopped ${when ? `until ${when}` : `until \`trantor up ${AGENT}\``}`);
449
+ // #7914: the notice carries its own evidence. A reader who has to open three logs to learn
450
+ // which turns died and how is a reader who does not look, which is how 46 minutes passed.
451
+ const why = evidence ? ` · ${evidence}` : "";
452
+ const text = redactKeys(`⛔ ${SESSION} PARKED (${reason}${named}) — holding ${undelivered} message(s), redelivery stopped ${when ? `until ${when}` : `until \`trantor up ${AGENT}\``}${bounded ? " · a direct message ends the park now" : ""}${why}`);
432
453
  await api("/send", { from: SESSION, to: "all", text, project: PROJ, kind: "status" }).catch(() => {});
433
454
  const orch = `${hostId()}:${PROJ}`;
434
455
  if (orch !== SESSION) await api("/send", { from: SESSION, to: orch, text, project: PROJ, kind: "alert" }).catch(() => {});
435
456
  }
436
- log(`\x1b[31mparked (${reason})${when ? ` — retrying after ${when}` : " — no reset time in the output; waiting for a restart"}\x1b[0m`);
457
+ log(`\x1b[31mparked (${reason})${when ? ` — retrying after ${when}` : " — no reset time in the output; waiting for a restart"}${evidence ? `\n evidence: ${evidence}` : ""}\x1b[0m`);
437
458
  // The alarm for "the bus is stuck" cannot itself be a bus message, so a park also rings a bell
438
459
  // the operator can hear out of band, once per park.
439
460
  notifyOperator(`Trantor: ${SESSION} PARKED (${reason})`,
440
461
  `${undelivered} message(s) held${when ? ` — retrying after ${when}` : ` — needs \`trantor up ${AGENT}\``}`);
441
- // No reset time means no timer can clear it: hold until the operator restarts the seat.
442
- return resetAt || Number.MAX_SAFE_INTEGER;
462
+ return until;
463
+ }
464
+
465
+ // #7914: the park is over — because a direct message asked for a turn now, or because one landed.
466
+ // The record goes with it, and the announce latch reopens so the NEXT park is heard too (a latch
467
+ // that never reopens turns a repeating park into one message and then silence).
468
+ function unpark(why) {
469
+ if (!parkState) return;
470
+ log(`\x1b[33mpark ended (${parkState.reason}) — ${why}\x1b[0m`);
471
+ parkState = null;
472
+ parkAnnounced = false;
473
+ try { unlinkSync(PARKF); } catch {}
443
474
  }
444
475
 
445
476
  /**
@@ -639,6 +670,12 @@ let lastTurnCut = false;
639
670
  // liveness channel quiet for the whole window), not the box. Same overwrite semantics as
640
671
  // lastTurnCut: the follow-up's own ending is what the ladder reads.
641
672
  let lastTurnStalled = false;
673
+ // #7914: one row per CUT turn in the CURRENT chain — the evidence a time-box park owes the
674
+ // orchestrator (which turns died, their exit codes, how the sweep ended them). Any turn that was
675
+ // not cut empties it, so the rows always describe the chain that is parking and never a cut from
676
+ // an hour ago. TRANTOR_PARK_WINDOW_MS is how long that park lasts before the seat resumes itself.
677
+ let cutChain = [];
678
+ const PARK_WINDOW_MS = Math.max(0, Number(process.env.TRANTOR_PARK_WINDOW_MS || 15 * 60 * 1000));
642
679
  // The card the CURRENT CLI session belongs to (#6134). 0 = the kickoff session, which belongs to
643
680
  // no card, so the first contract that names one starts a session of its own.
644
681
  let sessionCard = 0;
@@ -922,6 +959,10 @@ exit $turn_exit`;
922
959
  // the cut signal — never read as a quota or crash pattern downstream.
923
960
  if (cut) { const sig = cutSignalFor(realExit); if (sig) telemetryRow.cutSignal = sig; }
924
961
  telemetry(telemetryRow);
962
+ // #7914: the same facts the ledger row keeps, held in memory for the park notice. Only a BOX cut
963
+ // joins the chain — a turn the stall watchdog ended has its own ladder and park reason (#7752).
964
+ if (cut && !stallCut) cutChain.push({ turn: TURN, trigger, exit: realExit, signal: cutSignalFor(realExit) || "", boxMs, extensions });
965
+ else cutChain = [];
925
966
  log(`turn ended (exit ${realExit}${effExit !== realExit ? ` → effective ${effExit} (${lastEmptyOutput ? "empty-output" : "auth"})` : ""}, ${((Date.now() - t0) / 1000).toFixed(0)}s)`);
926
967
  if (realExit === 0 && effExit === 0) { cmuxStatus("idle", "#8a94a6", "robot"); herdrAgent("idle"); } // finished this turn, waiting for the next
927
968
  // #5965 — TURN END. A clean exit means the seat is idle again; say so right away so the app stops
@@ -1179,6 +1220,10 @@ async function resolveWakeCard(messages, { session }) {
1179
1220
  // broadcasts batched behind them. Restored from disk first: a runner that was killed mid-turn
1180
1221
  // (or a machine that rebooted) still owes those messages, and the hub will never send them again.
1181
1222
  const restored = loadPending();
1223
+ // #7914: a park belongs to the runner that made it. This process is starting fresh and will
1224
+ // re-park if the CLI is still cutting, so a record left by the runner before it is stale state —
1225
+ // and stale state in a health read is the same failure as no state at all.
1226
+ try { unlinkSync(PARKF); } catch {}
1182
1227
  seenLedger = restored.seen;
1183
1228
  if (seenLedger.length) log(`\x1b[33m${seenLedger.length} consumed message id(s) restored from the pending file — the poll keeps standing down on them\x1b[0m`);
1184
1229
  if (restored.ask) {
@@ -1339,6 +1384,18 @@ async function resolveWakeCard(messages, { session }) {
1339
1384
  savePending(pendingWake, pendingBcast);
1340
1385
  }
1341
1386
  if (awaitingAsk) { log(`holding for the answer to ask #${awaitingAsk.id} — ${wake.length} new message(s) queued behind it`); continue; }
1387
+ // #7914: a fresh DIRECT message to a parked seat is somebody asking for a turn now, so it ends
1388
+ // a bounded park instead of queueing behind the window; a mention does not, because the room
1389
+ // talking is not a request. An exhausted or auth park is a wall and stands anyway (#6134).
1390
+ const directWake = wake.filter(m => m.to === SESSION);
1391
+ if (parkState && directWake.length) {
1392
+ if (isBoundedPark(parkState.reason)) {
1393
+ unpark(`direct message from ${directWake[0].from || "?"}`);
1394
+ retryAt = 0; deliveryFails = 0; cutChain = [];
1395
+ } else {
1396
+ log(`\x1b[33mparked (${parkState.reason}) — a direct message cannot clear this park; it holds until the reset or \`trantor up ${AGENT}\`\x1b[0m`);
1397
+ }
1398
+ }
1342
1399
  // Respect an active backoff: a new message during an outage joins the batch, it does not
1343
1400
  // reset the clock and hammer a CLI that is already failing.
1344
1401
  if (Date.now() < retryAt) { log(`queued — ${pendingWake.length} undelivered, next attempt in ${Math.max(0, Math.round((retryAt - Date.now()) / 1000))}s`); continue; }
@@ -1552,8 +1609,12 @@ async function resolveWakeCard(messages, { session }) {
1552
1609
  // silent chain (#7752), time-box when the chain died to cuts, api-error otherwise —
1553
1610
  // holding the queue until `trantor up`.
1554
1611
  const parkReason = lastTurnStalled ? "stalled" : (PARKING_REASONS.has(reason) ? reason : (lastTurnCut ? "time-box" : "api-error"));
1612
+ // #7914: the park owes its evidence — which turns the box killed, their exits, the box that
1613
+ // ended them — whatever rung fired it. The THRESHOLD is #6289's and stays #6289's: a chain
1614
+ // retries once and the second failed chain parks, never the first (its drill B pins that).
1615
+ const evidence = cutChain.length >= CUT_CHAIN_PARK_MIN ? cutChainEvidence(cutChain) : "";
1555
1616
  if (PARKING_REASONS.has(reason) || deliveryFails >= 2) {
1556
- retryAt = await parkSeat(parkReason, pendingWake.length, quotaReset);
1617
+ retryAt = await parkSeat(parkReason, pendingWake.length, quotaReset, { evidence });
1557
1618
  // RUNNER_PARK_MAX_MS is set only by `trantor duty up` (launchd keepalive): past the ceiling,
1558
1619
  // exit so the supervisor restarts clean. Unsupervised seats stay parked; exiting would kill them.
1559
1620
  const parkMax = Number(process.env.RUNNER_PARK_MAX_MS || 0);
@@ -1565,10 +1626,17 @@ async function resolveWakeCard(messages, { session }) {
1565
1626
  process.exit(0); // 0, not 1: this is a deliberate hand-off, not a crash
1566
1627
  }, wakeIn).unref?.();
1567
1628
  }
1629
+ // #7914: what the park is, when it lifts, and the evidence it rests on — in the message
1630
+ // that reaches the one who is actually blocked, not only in the card log they never read.
1631
+ const lifts = parkState?.bounded
1632
+ ? ` · resumes ${new Date(parkState.until).toLocaleTimeString()} (a direct message ends it now)`
1633
+ : " — not retrying";
1634
+ // The lift time goes BEFORE the evidence: the transport caps the line, and what the reader
1635
+ // has to act on must never be the part that gets eaten.
1568
1636
  await notifyAssigners(assigners,
1569
1637
  reason === "stalled"
1570
- ? `⛔ your contract is PARKED on ${SESSION} (stalled: two turns silent for the whole watchdog window) — not retrying · asked: "${asked}"`
1571
- : `⛔ your contract is PARKED on ${SESSION} (${parkReason}) not retrying · asked: "${asked}"`);
1638
+ ? `⛔ your contract is PARKED on ${SESSION} (stalled: two turns silent for the whole watchdog window)${lifts} · asked: "${asked}"`
1639
+ : `⛔ your contract is PARKED on ${SESSION} (${parkReason})${lifts}${evidence ? ` · ${evidence}` : ""} · asked: "${asked}"`);
1572
1640
  lastTurnAt = Date.now();
1573
1641
  return;
1574
1642
  }
@@ -1608,6 +1676,7 @@ async function resolveWakeCard(messages, { session }) {
1608
1676
  `🫥 EMPTY turn on ${SESSION} (exit 0, ${secs}s — no worktree change, no substantive output, no bus activity) · wake stays owed · retrying in ${Math.round(wait / 1000)}s · asked: "${asked}"`);
1609
1677
  } else {
1610
1678
  pendingWake = []; pendingBcast = []; deliveryFails = 0; retryAt = 0;
1679
+ unpark("a turn landed"); // #7914: the seat is working again — the park record must not outlive it
1611
1680
  // #7778: reconcile BEFORE the write below so the persisted snapshot carries the seen-set.
1612
1681
  await reconcileSessionReads(cursor);
1613
1682
  savePending([], []);
@@ -340,7 +340,7 @@ export function buildSummary(transcriptPath) {
340
340
  let convo = "";
341
341
  try { convo = digest(collectTurns(transcriptPath)); } catch { convo = ""; }
342
342
  if (!convo) return "*(transcript unreadable)*";
343
- const sys = "You are writing a SESSION HANDOFF so a fresh Claude Code session can take over without losing context. The text spans an entire (possibly multi-hour) session: opening turns, an even sample of the middle, and the recent tail. Produce a concise but COMPLETE markdown handoff with these sections: TASK (what we're doing + the goal), STATE (done / in-progress), KEY DECISIONS, OPEN THREADS & NEXT STEPS (concrete actions), KEY FILES & locations (exact paths). Be specific. Cover the whole arc, not just the end. The finished handoff must fit ~3500 characters — anything longer is capped with an elision marker and the elided middle (usually STATE) is exactly what the successor needed (#6528), so compress the arc, never drop a section. Do not pad.";
343
+ const sys = "You are writing a SESSION HANDOFF so a fresh Claude Code session can take over without losing context. The text spans an entire (possibly multi-hour) session: opening turns, an even sample of the middle, and the recent tail. Produce a concise but COMPLETE markdown handoff with these sections: TASK (what we're doing + the goal), STATE (done / in-progress), READ FIRST (the files a successor must open before acting — name the project's memory index and, where it has them, its PRD and its TDD, by exact path), KEY DECISIONS, OPEN THREADS & NEXT STEPS (concrete actions), KEY FILES & locations (exact paths). Be specific. Cover the whole arc, not just the end. The finished handoff must fit ~3500 characters — anything longer is capped with an elision marker and the elided middle (usually STATE) is exactly what the successor needed (#6528), so compress the arc, never drop a section. Do not pad.";
344
344
  // Cut the raw tail on a TURN boundary: a blind slice opens mid-sentence, and a successor cannot
345
345
  // tell a truncated thought from a complete one.
346
346
  const tail = (n) => {
@@ -382,19 +382,59 @@ export function verbatimRecentTail(transcript, chars = 7000) {
382
382
  try { return collectTurns(transcript).join("\n\n").slice(-chars); } catch { return ""; }
383
383
  }
384
384
 
385
- // ---- #5648: handoff writer discipline. The inline summary is the RECAP, not the record: capped at
386
- // ~4KB, keeping BOTH ends (goal framing and current state) cut on paragraph boundaries with an elision marker.
385
+ // ---- #5648: the inline summary is the RECAP, not the record; #8222: the ~4KB budget is an
386
+ // INJECTION budget the record persists the text UNCAPPED, this cut runs only at render time
387
+ // (sessionstart.mjs). Section-aware: every handoff shares the five-section shape, so a head+tail
388
+ // cut elides the middle — TASK / STATE / OPEN THREADS survive in full, KEY DECISIONS / KEY FILES elide.
389
+ const IS_SECTION_HEADER = /^\s*#{1,6}\s+\S/;
390
+ const MUST_KEEP_SECTION = /^\s*#{1,6}\s*(task|state|open threads|next steps|read[\s-]?first)\b/i;
391
+
392
+ // `cap` bounds the ELIDABLE sections, not the return value: must-keeps are never cut (#8222).
387
393
  export function capSummary(text, cap = 4096) {
388
394
  const s = String(text || "");
389
395
  if (s.length <= cap) return s;
390
396
  const elide = "\n\n[…]\n\n";
391
- const headRaw = s.slice(0, Math.max(0, cap - elide.length - 2048));
392
- const hCut = headRaw.lastIndexOf("\n\n");
393
- const head = hCut > 200 ? headRaw.slice(0, hCut) : headRaw;
394
- let tail = s.slice(s.length - (cap - head.length - elide.length));
395
- const tCut = tail.indexOf("\n\n");
396
- if (tCut > 0 && tCut < 2000) tail = tail.slice(tCut + 2); // drop the partial opening line
397
- return head + elide + tail;
397
+ // Split into blocks at markdown headers; text before the first header is the title block.
398
+ const blocks = [];
399
+ let cur = null;
400
+ for (const line of s.split("\n")) {
401
+ if (IS_SECTION_HEADER.test(line)) { cur = { title: line, lines: [] }; blocks.push(cur); }
402
+ else { if (!cur) { cur = { title: "", lines: [] }; blocks.push(cur); } cur.lines.push(line); }
403
+ }
404
+ const render = (b) => ((b.title ? `${b.title}\n` : "") + b.lines.join("\n")).replace(/^\n+/, "").replace(/\n+$/, "");
405
+ // Must-keeps claim the budget first and are never cut (the leading H1 title rides with them);
406
+ // headerless text has no sections to protect, so it is one elidable block like any other.
407
+ const kept = new Map();
408
+ let budget = cap;
409
+ blocks.forEach((b, i) => {
410
+ if ((i === 0 && b.title) || MUST_KEEP_SECTION.test(b.title)) {
411
+ const r = render(b);
412
+ kept.set(i, r);
413
+ budget -= r.length + 2; // the "\n\n" join
414
+ }
415
+ });
416
+ // Elidables (KEY DECISIONS, KEY FILES, anything unrecognized) fill what remains, in order; past
417
+ // the first one that does not fit, the marker stands for the rest. Must-keeps after that point
418
+ // still render — a work order is never dropped for sitting behind a KEY DECISIONS.
419
+ const parts = [];
420
+ let over = false;
421
+ blocks.forEach((b, i) => {
422
+ if (kept.has(i)) { parts.push(kept.get(i)); return; }
423
+ if (over) return;
424
+ const r = render(b);
425
+ if (r.length + 2 <= budget) { parts.push(r); budget -= r.length + 2; return; }
426
+ over = true;
427
+ let acc = "";
428
+ for (const p of r.split("\n\n")) {
429
+ if (acc.length + p.length + 2 > budget - elide.length) break;
430
+ acc = acc ? `${acc}\n\n${p}` : p;
431
+ }
432
+ // One unbreakable paragraph bigger than the whole budget (a pathological summary) still keeps
433
+ // its opening — a hard slice beats injecting nothing but the marker.
434
+ if (!acc) acc = r.slice(0, Math.max(0, budget - elide.length));
435
+ if (acc) parts.push(acc);
436
+ });
437
+ return parts.join("\n\n") + (over ? elide : "");
398
438
  }
399
439
 
400
440
  // Trantor State — the structured field on the record (TDD §4.5). `summary` is written as before;
@@ -617,10 +657,19 @@ export function writeHandoff({ projectDir, sessionId, transcript, trigger, summa
617
657
  const stamp = nowSec() || Date.now();
618
658
  let gitStatus = "";
619
659
  try { gitStatus = execSync("git -C " + JSON.stringify(projectDir) + " status --short 2>/dev/null | head -30", { encoding: "utf8" }).trim(); } catch {}
620
- // Cap the composed narrative to the injection budget (~4KB). The verbatim tail is deliberately
621
- // NOT embedded anymore: the record's transcript_path points at the full exchange, and embedding
622
- // it here doubled the successor's read for state that was already one path away (#5648).
623
- const narrative = capSummary(summary ?? buildSummary(transcript));
660
+ // #8222: persist the narrative UNCAPPED a record has no context budget, and cutting here
661
+ // DESTROYS the work order rather than hiding it (trantor-1789869270 lost its numbered OPEN
662
+ // THREADS this way). The ~4KB budget is enforced at injection (sessionstart.mjs); the verbatim
663
+ // tail is not embedded — transcript_path points at the full exchange (#5648).
664
+ let narrative = summary ?? buildSummary(transcript);
665
+ // #8232 floor: readFirstPaths short-circuits on an empty list, so a summary naming no read-first
666
+ // paths disarms the #8162 gate — every check downstream passes vacuously, and the summarizer
667
+ // produced exactly that before #8232 asked it for the section. The memory index is knowable
668
+ // without the model; PRD and TDD stay the summarizer's to find (only it knows they exist).
669
+ if (!readFirstPaths(narrative).length) {
670
+ const mem = memoryIndexPath(projectDir);
671
+ if (mem) narrative += `\n\n## READ FIRST\n${mem}`;
672
+ }
624
673
  // Sub-agent manifest SNAPSHOT (fallback). The successor should re-derive it LIVE via
625
674
  // `trantor agents <sid>` (catches files an agent finished that were clobbered AFTER this
626
675
  // snapshot — the kill that motivated this corrupted a completed 30KB lib post-handoff). This
@@ -640,7 +689,8 @@ export function writeHandoff({ projectDir, sessionId, transcript, trigger, summa
640
689
  project: projectDir, projectName, machine: hostname(),
641
690
  session_id: sessionId || "", trigger: trigger || "auto",
642
691
  transcript_path: transcript || "", stamp: Number(stamp) || 0,
643
- // recap-sufficient inline summary, capped ~4KB — the full story lives at transcript_path
692
+ // model-authored summary, persisted UNCAPPED (#8222) — the ~4KB cap lives at injection time;
693
+ // the full story also lives at transcript_path
644
694
  summary: narrative,
645
695
  // attended|unattended — who pulls the baton trigger (resolved autonomy `baton` dial)
646
696
  mode: handoffMode(projectName),
@@ -751,14 +801,10 @@ export function maybeSpawn(projectDir, conf = readConfig(), handoffFile = "", de
751
801
  try {
752
802
  if (_platform !== "darwin") return false;
753
803
  if (_env.TRANTOR_NO_HANDOFF_SPAWN === "1") return false;
754
- // #8089 REVERTED 2026-09-19, and the revert is the point. A pane session gets NO Terminal
755
- // window AND no baton driver from here: the APP owns the replacement. app handoff_now runs
756
- // `trantor handoff --write-only`, waits for the record (including the armed-mid-turn case it
757
- // explicitly handles), then does its OWN idle gate, kill and reopen (#6081, #5509). Making this
758
- // spawn a pane baton put a SECOND driver on the same pane, racing the app's — which broke the
759
- // chat handoff button and the skill, both of which had been working. The stranding this was
760
- // meant to fix is real but lives in the SKILL path, where no app driver exists; fixing it here
761
- // cannot tell the two flows apart, so it must be fixed where the flows are distinguishable.
804
+ // #8089 REVERTED the APP owns pane replacement: handoff_now runs `trantor handoff --write-only`
805
+ // then its OWN idle gate/kill/reopen (#6081, #5509). A pane gets no Terminal window and no baton
806
+ // driver from here; spawning one put a SECOND driver on the pane, racing the app's and breaking
807
+ // the chat button + skill. #8089's stranding lives in the SKILL path fix it there or not at all.
762
808
  if (_pane(_env)) {
763
809
  _log(`[trantor] session lives in herdr pane ${_pane(_env)} — no Terminal window and no baton driver from here; the app (or the skill path) owns the replacement\n`);
764
810
  return false;
@@ -772,30 +818,31 @@ export function maybeSpawn(projectDir, conf = readConfig(), handoffFile = "", de
772
818
  const script = join(HERE, "..", "..", "bin", "handoff-prompt.sh");
773
819
  if (!existsSync(script)) { _log(`[trantor] handoff-prompt.sh missing\n`); return false; }
774
820
  const timeout = String(conf.handoffPromptTimeout || 25);
775
- // Injectable for the same reason the pane legs are: this line opens a REAL Terminal window, and
776
- // a drill that reaches it opens one per run. That is not hypothetical — test-pane-baton-spawn's
777
- // "no pane" case fell through to here and opened a window on every `npm test`, with a comment
778
- // above it claiming the drill did not exercise this leg. Four of them were sitting on the
779
- // operator's desktop before anyone noticed, and only a non-existent fixture path stopped each
780
- // one from starting a live billable session.
821
+ // Injectable (deps.spawnPrompt): this line opens a REAL Terminal window, and a drill reaching it
822
+ // opens one per run — test-pane-baton-spawn's "no pane" case fell through here on every npm test
823
+ // and four windows landed on the operator's desktop before anyone noticed; only a non-existent
824
+ // fixture path stopped each from starting a live billable session.
781
825
  const child = (deps.spawnPrompt || spawn)("/bin/bash", [script, projectDir, timeout], { detached: true, stdio: "ignore" });
782
- if (child && typeof child.unref === "function") child.unref();
826
+ if (child?.unref) child.unref();
783
827
  return true;
784
828
  } catch (e) { process.stderr.write(`[trantor] maybeSpawn error: ${e?.message}\n`); return false; }
785
829
  }
786
830
 
787
- /** The files a handoff tells its successor to read before doing anything (#8162).
788
- *
789
- * A handoff has always been able to SAY "read these first" crebral-health's named three memory
790
- * files on 2026-09-19 and the successor opened none of them. Saying it was the whole mechanism.
791
- * This pulls the list out as data so something downstream can check it.
792
- *
793
- * Recognised: a `READ FIRST` / `READ-FIRST` / `Read first:` heading or line, and every path-looking
794
- * token on it and the lines beneath it until the next blank line or heading. Deliberately narrow —
795
- * a handoff that mentions a file in passing is not asking anyone to read it, and a gate that fires
796
- * on every path in a 4k summary would be noise the successor learns to ignore.
797
- * @returns {string[]} project-relative or absolute paths, de-duplicated, capped
798
- */
831
+ /** #8232: the one read-first path knowable WITHOUT the model the project's own memory index, at
832
+ * the encoded-cwd location Claude Code keeps it (the same resolver bin/reconcile.mjs reads).
833
+ * "" when the project has none: naming a file that does not exist would arm a gate no successor
834
+ * could ever satisfy, and the stamp would nag at every Stop forever. */
835
+ export function memoryIndexPath(projectDir) {
836
+ try {
837
+ const p = join(homedir(), ".claude", "projects", String(projectDir || "").replaceAll("/", "-"), "memory", "MEMORY.md");
838
+ return existsSync(p) ? p : "";
839
+ } catch { return ""; }
840
+ }
841
+
842
+ /** The files a handoff tells its successor to READ FIRST (#8162) as DATA, so something downstream
843
+ * can check it. Recognised: a `READ FIRST`/`READ-FIRST`/`Read first:` heading or line, and every
844
+ * path-looking token until the next blank line or heading — narrow on purpose: a path in passing
845
+ * is not a reading order. @returns {string[]} paths, deduped, capped */
799
846
  export function readFirstPaths(summary, { max = 12 } = {}) {
800
847
  const text = String(summary || "");
801
848
  const out = [];
@@ -817,14 +864,9 @@ export function readFirstPaths(summary, { max = 12 } = {}) {
817
864
  return out;
818
865
  }
819
866
 
820
- /** Which of `paths` this session actually OPENED, read off its own transcript (#8162).
821
- *
822
- * Ground truth rather than testimony, the same rule the state gate learned the hard way: a
823
- * successor saying "I have read the handoff" is not evidence that it did. A Read/Grep/Glob tool
824
- * call naming the path is. Matches on basename as well as full path, because a handoff written by
825
- * a model may name `revenue-integrity-build.md` where the Read call carries the absolute path.
826
- * @returns {{read: string[], missed: string[]}}
827
- */
867
+ /** Which of `paths` this session actually OPENED, read off its own transcript (#8162) — ground
868
+ * truth, not testimony: a Read/Grep/Glob tool call naming the path is evidence, "I have read the
869
+ * handoff" is not. Matches basename as well as full path. @returns {{read: string[], missed: string[]}} */
828
870
  export function pathsReadIn(transcriptPath, paths) {
829
871
  const want = (paths || []).filter(Boolean);
830
872
  if (!want.length) return { read: [], missed: [] };
@@ -846,11 +888,9 @@ export function pathsReadIn(transcriptPath, paths) {
846
888
  }
847
889
 
848
890
  // The self-announcing fresh session command (single-quoted so it survives osascript→shell un-escaped).
849
- // Brevity is part of the prompt: a takeover that answers with 5k-character status dumps loses the
850
- // operator. But brevity is about what you SAY, and #8162 found it had quietly become permission not
851
- // to READ: the summary is injected at SessionStart, so a 3-sentence recap is producible without
852
- // opening a file, and a successor doing exactly as asked never opened one. Three days of takeovers
853
- // went straight to code off a summary. So the order is now read-then-recap, and the recap stays short.
891
+ // Brevity is the point, but #8162 found it had become permission not to READ: the summary is injected
892
+ // at SessionStart, so a 3-sentence recap is producible without opening a file. The order is now
893
+ // read-then-recap, and the recap stays short.
854
894
  export const RECAP_CMD = "claude 'You have just taken over via handoff. FIRST open every file the handoff names as read-first — its memory files, its PRD and TDD — and do not answer until you have. They are the context the handoff exists to carry, and the summary is a pointer to them, not a substitute. THEN recap in at most 3 sentences: task, state, next step. Then wait for me. Keep all replies short by default: no status tables, no headers, no walls of text unless I explicitly ask for detail.'";
855
895
 
856
896
  // ONE suppression check for every path that can open a terminal window: two names for it once let a
@@ -10,7 +10,7 @@ import { fileURLToPath } from "node:url";
10
10
  import { resolveProject, hostId, resolveHubInfo, knownProjects, nonSeatReason, nestedProjects, handoffDir, readOrchSession, writeOrchSession } from "../lib/project.mjs";
11
11
  import { formatSubagentManifest } from "../lib/subagent-manifest.mjs";
12
12
  import { updateAvailable, maybeNotifyDesktop, readConfig } from "./lib/update-check.mjs";
13
- import { renderStateBlock, readFirstPaths} from "./lib/handoff.mjs";
13
+ import { renderStateBlock, readFirstPaths, capSummary } from "./lib/handoff.mjs";
14
14
  import { maybeCheckBalances } from "./lib/balance-check.mjs";
15
15
  import { getJSON, signedGet, signedPost, loadIdentity } from "./lib/api.mjs";
16
16
  import { ledgerPaths, ensureStart, anchorCursor, writeCursor } from "./lib/inbox-ledger.mjs";
@@ -118,20 +118,19 @@ function sanitize(s) {
118
118
  return out;
119
119
  }
120
120
 
121
- // #5645 injection cap: the handoff injection is a POINTER, not a payload. The writer (#5648) caps
122
- // rec.summary at ~4KB; this reader enforces the same bound against older records and points at the file.
121
+ // #5645 injection cap — #8222: the writer persists rec.summary UNCAPPED (a JSON record has no
122
+ // context budget), so this reader is the ONLY place the ~4KB injection budget is enforced. The cut
123
+ // is section-aware (TASK / STATE / OPEN THREADS survive; KEY DECISIONS / KEY FILES elide) and the
124
+ // pointer names the full record for whatever was dropped.
123
125
  const HANDOFF_INJECT_CAP = 4096;
124
126
  function capHandoffSummary(handoff) {
125
127
  let s = String(handoff?.summary || "");
126
128
  const marker = s.indexOf("\n---\n## Verbatim recent exchange");
127
129
  if (marker > 0) s = s.slice(0, marker);
128
130
  if (s.length <= HANDOFF_INJECT_CAP) return s;
129
- const cut = s.slice(0, HANDOFF_INJECT_CAP);
130
- const nl = cut.lastIndexOf("\n");
131
- s = (nl > HANDOFF_INJECT_CAP * 0.6 ? cut.slice(0, nl) : cut).trimEnd();
132
131
  let ptr = `\n\n…(summary capped at ${HANDOFF_INJECT_CAP} chars — full record: ${join(handoffDir(), `${handoff.id}.json`)}`;
133
132
  if (handoff.transcript_path) ptr += ` · full transcript: ${handoff.transcript_path}`;
134
- return s + ptr + ")";
133
+ return capSummary(s, HANDOFF_INJECT_CAP) + ptr;
135
134
  }
136
135
 
137
136
  // Fail-silent wrapper for the optional #4214 resources detection lib (hooks/lib/resources.mjs).
package/lib/seat-why.mjs CHANGED
@@ -24,6 +24,18 @@ function readTelemetry(file) {
24
24
  return out;
25
25
  }
26
26
 
27
+ // #7914: the park record the runner writes while it is holding a queue. Present and unexpired = the
28
+ // seat is PARKED, which is a different answer from "live, last turn failed, watch it or swap" — one
29
+ // says what is happening and when it ends, the other tells the operator to go and watch a seat that
30
+ // was never going to move. Anything unreadable reads as no park: a live seat is the safe default.
31
+ function readPark(dir, project, agent, now) {
32
+ try {
33
+ const rec = JSON.parse(readF(join(dir, `park-${agent}-${project}.json`)));
34
+ if (!rec || !rec.reason) return null;
35
+ return Number(rec.until) > now ? rec : null;
36
+ } catch { return null; }
37
+ }
38
+
27
39
  function findPane(dir, project, agent) {
28
40
  const row = readF(join(dir, "crew-windows.txt")).split("\n")
29
41
  .map(l => l.split("\t"))
@@ -48,6 +60,13 @@ function scanPids(project, agent) {
48
60
  }
49
61
 
50
62
  const fmt = (ts) => { try { return new Date(ts).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); } catch { return String(ts); } };
63
+ // rel() reads backwards ("4m ago"); a park's resume time is the one timestamp here that is ahead.
64
+ const untilFmt = (ts, now = Date.now()) => {
65
+ const s = Math.max(0, Math.round((ts - now) / 1000));
66
+ if (s < 60) return `in ${s}s`;
67
+ if (s < 3600) return `in ${Math.round(s / 60)}m`;
68
+ return `in ${Math.round(s / 3600)}h`;
69
+ };
51
70
  const rel = (ts) => {
52
71
  const s = Math.max(0, Math.round((Date.now() - ts) / 1000));
53
72
  if (s < 60) return `${s}s ago`;
@@ -93,9 +112,22 @@ export function seatWhy(project, agent, opts = {}) {
93
112
 
94
113
  let state, why, advice;
95
114
 
115
+ const now = opts.now || Date.now();
116
+ const park = readPark(dir, project, agent, now);
117
+
96
118
  if (pids.length) {
97
119
  const pidList = pids.join(", ");
98
- if (!pane) {
120
+ if (park) {
121
+ // A parked seat is parked whether or not it has a pane — the queue is held either way, and
122
+ // the resume time is the one fact the operator needs before deciding to swap it.
123
+ const held = Number(park.held) || 0;
124
+ state = "parked";
125
+ why = `crew-runner pid ${pidList} alive${pane ? `, pane ${pane.source}/${pane.pane}` : " (headless)"}; PARKED (${park.reason}) `
126
+ + `holding ${held} message${held === 1 ? "" : "s"} since ${fmt(park.ts)}${park.evidence ? ` — ${park.evidence}` : ""}.`;
127
+ advice = park.bounded
128
+ ? `parked until ${fmt(park.until)} (${untilFmt(park.until, now)}) — it resumes itself; a direct message to ${agent}:${project} ends the park now.`
129
+ : `park has no timer (${park.reason}) — it holds until the wall lifts or \`trantor up ${agent}\`.`;
130
+ } else if (!pane) {
99
131
  state = "no-pane";
100
132
  why = `crew-runner pid ${pidList} alive for ${agent}:${project}, but crew-windows.txt has no pane row — the seat is headless.`;
101
133
  advice = "give the seat a window it can be read in: `trantor up <agent>` (or attach via herdr/cmux).";
@@ -227,3 +227,39 @@ export function isLinkedProject(senderProject, seatProject, links) {
227
227
  return ps.includes(String(senderProject).toLowerCase()) && ps.includes(String(seatProject).toLowerCase());
228
228
  });
229
229
  }
230
+
231
+ /// #7914: how many CUT turns a chain needs before the park notice owes evidence for them. The
232
+ /// threshold that PARKS is #6289's (a chain retries once, the second failed chain parks); this is
233
+ /// the point at which "PARKED (time-box)" stops being enough on its own and has to name which
234
+ /// turns the box killed and how.
235
+ export const CUT_CHAIN_PARK_MIN = 2;
236
+
237
+ /// #7914: what a park notice owes its reader — "PARKED (time-box)" alone sends them to three logs
238
+ /// to find out which turns died and how. notifyAssigners caps the line at 280 chars and the ask
239
+ /// sits at its tail, so the chain is named by its COUNT plus its most recent turn; the full chain
240
+ /// stays in the runner log and the ledger rows.
241
+ export const CUT_CHAIN_EVIDENCE_MAX = 1;
242
+ export function cutChainEvidence(rows) {
243
+ const all = Array.isArray(rows) ? rows : [];
244
+ const shown = all.slice(-CUT_CHAIN_EVIDENCE_MAX);
245
+ const head = all.length > shown.length ? `${all.length} cut turns, last: ` : "";
246
+ return head + shown.map((r) => {
247
+ const exit = `exit ${r.exit ?? "?"}${r.signal ? ` (${r.signal})` : ""}`;
248
+ // The box is named in the unit it was set in — a drill's seconds-long box must not round to
249
+ // "the 0m box", which is the evidence line lying about the very number it exists to carry.
250
+ const ms = Number(r.boxMs) || 0;
251
+ const box = ms >= 60000 ? `${Math.round(ms / 60000)}m` : `${Math.round(ms / 1000)}s`;
252
+ // Every row here is a BOX cut by construction — a stall never joins the chain — so the clause
253
+ // names the box, and the extensions that bought the turn more of it.
254
+ const how = `cut at the ${box} box${r.extensions ? ` after ${r.extensions} liveness extension(s)` : ""}`;
255
+ return `turn ${r.turn} (${r.trigger || "?"}) ${exit} — ${how}`;
256
+ }).join(" · ");
257
+ }
258
+
259
+ /// #7914: a park that a TIMER can clear. The box cutting a turn twice says nothing about the plan
260
+ /// or the key, so that park is a wait: it carries a window, resumes itself, and a direct message
261
+ /// ends it early. Exhaustion and a rejected key are walls — the ladder never succeeds against them
262
+ /// (#6134), so those parks hold until the reset or `trantor up`, whoever knocks.
263
+ export function isBoundedPark(reason) {
264
+ return !PARKING_REASONS.has(reason);
265
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trantor",
3
- "version": "0.18.67",
3
+ "version": "0.18.68",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "trantor": "bin/cli.mjs"