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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +5 -3
- package/bin/crew-runner.mjs +78 -9
- package/hooks/lib/handoff.mjs +95 -55
- package/hooks/sessionstart.mjs +6 -7
- package/lib/seat-why.mjs +33 -1
- package/lib/turn-policy.mjs +36 -0
- package/package.json +1 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "trantor",
|
|
3
|
-
"version": "0.18.
|
|
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
|
|
382
|
-
|
|
383
|
-
|
|
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
|
package/bin/crew-runner.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
442
|
-
|
|
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)
|
|
1571
|
-
: `⛔ your contract is PARKED on ${SESSION} (${parkReason})
|
|
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([], []);
|
package/hooks/lib/handoff.mjs
CHANGED
|
@@ -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:
|
|
386
|
-
//
|
|
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
|
-
|
|
392
|
-
const
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
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
|
-
//
|
|
621
|
-
//
|
|
622
|
-
//
|
|
623
|
-
|
|
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
|
-
//
|
|
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
|
|
755
|
-
//
|
|
756
|
-
//
|
|
757
|
-
//
|
|
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
|
|
776
|
-
//
|
|
777
|
-
//
|
|
778
|
-
//
|
|
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
|
|
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
|
-
/**
|
|
788
|
-
*
|
|
789
|
-
*
|
|
790
|
-
*
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
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
|
-
*
|
|
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
|
|
850
|
-
//
|
|
851
|
-
//
|
|
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
|
package/hooks/sessionstart.mjs
CHANGED
|
@@ -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
|
|
122
|
-
//
|
|
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 (
|
|
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).";
|
package/lib/turn-policy.mjs
CHANGED
|
@@ -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
|
+
}
|