trantor 0.18.76 → 0.18.78

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.76",
3
+ "version": "0.18.78",
4
4
  "description": "Trantor — the hub-world for AI agent crews: live message bus, presence, project Kanban/flow board + crew orchestration for independent AI coding agents (Claude, Codex, Gemini, Kimi, DeepSeek)",
5
5
  "mcpServers": {
6
6
  "relay": {
package/bin/agents.mjs CHANGED
@@ -1,16 +1,8 @@
1
1
  #!/usr/bin/env node
2
- // trantor agents [sessionId] [--json] — the LIVE sub-agent manifest for a session.
3
- //
4
- // What were this session's sub-agents (Agent/Task tool, Workflow swarms, agent-teams) doing —
5
- // what was each tasked with, did it return, what did it write, and do those files still survive
6
- // on disk? Derived fresh from the on-disk transcripts every run (so it reflects CURRENT disk,
7
- // catching files an agent finished that were later clobbered — the 2026-06-21 kill corrupted a
8
- // completed 30KB lib down to a 17-byte stub).
9
- //
10
- // trantor agents → the session of the newest handoff for THIS project (the
11
- // predecessor a fresh session is taking over from)
12
- // trantor agents <sessionId> → that specific session
13
- // trantor agents --json → structured manifest (for tools)
2
+ // trantor agents [sessionId] [--json] [--since <epochMs>] — the LIVE sub-agent manifest for a
3
+ // session: what was each sub-agent tasked with, did it return, what did it write, did it survive
4
+ // on disk — re-derived fresh from the transcripts every run. `--since <epochMs>` (#10007) reads
5
+ // agents launched before that instant "stale", never "in-flight"; the app polls this for liveness.
14
6
  import { readFileSync, readdirSync, existsSync } from "node:fs";
15
7
  import { join, basename } from "node:path";
16
8
  import { homedir } from "node:os";
@@ -18,7 +10,10 @@ import { deriveSubagentManifest, formatSubagentManifest, resolveTranscriptForSid
18
10
 
19
11
  const args = process.argv.slice(2);
20
12
  const json = args.includes("--json");
21
- const sid = args.find((a) => !a.startsWith("--"));
13
+ const sinceIdx = args.indexOf("--since");
14
+ const sinceVal = sinceIdx !== -1 ? args[sinceIdx + 1] : undefined;
15
+ const sinceMs = sinceVal != null ? Number(sinceVal) : undefined;
16
+ const sid = args.find((a) => !a.startsWith("--") && a !== sinceVal);
22
17
 
23
18
  const HANDOFF_DIR = join(process.env.RELAY_DATA_DIR || join(homedir(), ".agent-bus"), "handoffs");
24
19
 
@@ -55,7 +50,7 @@ if (sid) {
55
50
  projectRoot = h.project || projectRoot;
56
51
  }
57
52
 
58
- const manifest = deriveSubagentManifest(transcript, { projectRoot });
53
+ const manifest = deriveSubagentManifest(transcript, { projectRoot, sinceMs: Number.isFinite(sinceMs) ? sinceMs : undefined });
59
54
  if (json) {
60
55
  process.stdout.write(JSON.stringify(manifest, null, 2) + "\n");
61
56
  } else {
@@ -16,13 +16,13 @@ import { applyWorktreeDeclaration, ensureSeatWorktree, provisioningLines, readWo
16
16
  import {
17
17
  AUTH_MARKER_RE, classifyFailure, looksLikeAuthDeath,
18
18
  verdictFor, substantiveOutput, stallVerdict, cutSignalFor,
19
- BILLING_RE, looksLikeBillingDeath, usageSaysNoWork, zeroUsageVerdict,
19
+ BILLING_RE, looksLikeBillingDeath, usageSaysNoWork, zeroUsageVerdict, noDeliveryVerdict,
20
20
  permissionRejection, looksLikePermissionDeath,
21
21
  readPromptText, stripPromptEcho,
22
22
  } from "../lib/classify-failure.mjs";
23
23
  import { capWake, capBcast, pickLessons, composePrompt, contractBase, baseLine } from "./crew-payload.mjs";
24
24
  import {
25
- cardRefs, wakeCard, carriesWork, parseTurnTokens, parseResetAt, reasonWithBalances, quotaResetAt, PARKING_REASONS,
25
+ cardRefs, servedContractCard, carriesWork, parseTurnTokens, parseResetAt, reasonWithBalances, quotaResetAt, PARKING_REASONS,
26
26
  senderProjectOf, isLinkedProject, stateSkipReason, isMessageCardTitle, OPEN_CARD_STATUSES,
27
27
  CUT_CHAIN_PARK_MIN, cutChainEvidence, isBoundedPark,
28
28
  } from "../lib/turn-policy.mjs";
@@ -279,6 +279,11 @@ let lastEmptyOutput = false;
279
279
  // the CLI printed its banner and quit. The both-streams-silent rule above stays; this is the
280
280
  // second, harder-to-see empty shape.
281
281
  let lastEmptyTurn = false;
282
+ // #10197: exit 0 on a turn BOUND to a card contract with neither a new commit nor a card move to
283
+ // testing/done — the ibkr shape (48s, prose, card still todo, announced done). The card's status
284
+ // at turn end rides along so the assigner's notice can name it.
285
+ let lastNoDelivery = false;
286
+ let lastNoDeliveryStatus = "";
282
287
  const ERRF = join(homedir(), ".agent-bus", `err-${AGENT}-${PROJ}.txt`);
283
288
  const DUTY_NUDGES = process.env.RUNNER_DUTY_NUDGES === "1";
284
289
  const DUTY_NUDGE_STATE = process.env.RUNNER_DUTY_NUDGE_STATE
@@ -742,6 +747,16 @@ async function runTurn(prompt, isFirst, trigger = "kickoff", opts = {}) {
742
747
  // #7759: the worktree snapshot the turn is judged against at its end — porcelain covers edits
743
748
  // AND new untracked files, which a HEAD-only comparison misses.
744
749
  const statusBefore = gitOut(["status", "--porcelain"], TURN_DIR);
750
+ // #10197: the card status snapshot the turn is judged against at its end — a move to
751
+ // testing/done under the turn is delivery even without a commit. null = the hub read failed:
752
+ // the delivery check stands down rather than claim no-delivery on a transport error.
753
+ let cardStatusBefore = null;
754
+ if (!opts.state && sessionCard > 0) {
755
+ try {
756
+ const { task } = await api(`/card?project=${encodeURIComponent(PROJ)}&id=${sessionCard}`);
757
+ cardStatusBefore = task ? String(task.status || "") : null;
758
+ } catch { cardStatusBefore = null; }
759
+ }
745
760
  // #9778: the docker snapshot the turn is judged against — containers the CLI spawns are recorded
746
761
  // against the seat's card at turn end, then stopped when the card closes or the seat parks.
747
762
  const dockerBefore = listContainers();
@@ -1011,16 +1026,38 @@ exit $turn_exit`;
1011
1026
  effExit = 1;
1012
1027
  log("\x1b[31mexit 0 but the session usage record resolved to all zeros — no model work happened; treating as FAILED\x1b[0m");
1013
1028
  }
1029
+ // #10197: a turn BOUND to a card contract reads done only on DELIVERY — a NEW commit since the
1030
+ // turn started, or the card moved to testing/done under it. The ibkr shape (exit 0, output, no
1031
+ // commit, card still todo) is NO-DELIVERY: the ledger names it and the contract stays owed.
1032
+ // Judged on hub reads of the card status; a failed read keeps today's label, never no-delivery.
1033
+ let cardStatusAtEnd = "", noDelivery = false;
1034
+ if (!cut && !opts.state && realExit === 0 && effExit === 0 && sessionCard > 0 && cardStatusBefore !== null) {
1035
+ let endStatus = null;
1036
+ try {
1037
+ const { task } = await api(`/card?project=${encodeURIComponent(PROJ)}&id=${sessionCard}`);
1038
+ endStatus = task ? String(task.status || "") : null;
1039
+ } catch { endStatus = null; }
1040
+ if (endStatus !== null) {
1041
+ cardStatusAtEnd = endStatus;
1042
+ const moved = ["testing", "done"].includes(endStatus) && !["testing", "done"].includes(cardStatusBefore);
1043
+ noDelivery = !newCommit && !moved;
1044
+ }
1045
+ }
1046
+ lastNoDelivery = noDelivery;
1047
+ lastNoDeliveryStatus = cardStatusAtEnd;
1048
+ if (noDelivery) log(`\x1b[33mexit 0 but the turn DELIVERED NOTHING on #${sessionCard} — no new commit, card still ${cardStatusAtEnd || "unknown"}\x1b[0m`);
1014
1049
  // #5868: the verdict rides the telemetry row so a classification survives the pane scrolling
1015
1050
  // away — the same "classified X because Y" shape the runner logs. A silent cut names its own
1016
1051
  // verdict (#7752): the exit under it is the sweep's SIGPIPE. A box cut passes `cut` so its 137
1017
1052
  // reads as the sweep's SIGKILL (#7099); a zero-usage turn names its own verdict too (#9724).
1018
- const verdict = stallCut ? stallVerdict() : zeroUsage ? zeroUsageVerdict() : verdictFor(realExit, effExit, lastEmptyOutput, ownOut, lastEmptyTurn, cut);
1053
+ const verdict = stallCut ? stallVerdict() : zeroUsage ? zeroUsageVerdict()
1054
+ : noDelivery ? noDeliveryVerdict(sessionCard, cardStatusAtEnd || "unknown")
1055
+ : verdictFor(realExit, effExit, lastEmptyOutput, ownOut, lastEmptyTurn, cut);
1019
1056
  // #6289: every ledger row names in ONE field what happened to the turn — cut, stalled (#7752),
1020
1057
  // api-error, completed — and what it cost (0 means "not reported", never "free"). #7762: `card`
1021
1058
  // binds the row to the card the turn worked (0 = kickoff/pulse) so the seat record attributes
1022
1059
  // empty/stalled turns to the card that produced nothing. `cut` stays too: the drills read it.
1023
- const outcome = cut ? (stallCut ? "stalled" : "cut") : (effExit !== 0 ? "api-error" : lastEmptyTurn ? "empty" : "completed");
1060
+ const outcome = cut ? (stallCut ? "stalled" : "cut") : (effExit !== 0 ? "api-error" : noDelivery ? "no-delivery" : lastEmptyTurn ? "empty" : "completed");
1024
1061
  // #7756: a clean turn that ASKED its assigner is demoted-but-owed, not "completed". The judge
1025
1062
  // (deliverWake's /contracts read) renames the ledger row, so "asked" is what the log keeps.
1026
1063
  let finalOutcome = outcome;
@@ -1251,7 +1288,9 @@ async function newestOwnCard(session, why) {
1251
1288
  } catch { return 0; } // board unreadable: no binding beats a guessed one
1252
1289
  }
1253
1290
  async function resolveWakeCard(messages, { session }) {
1254
- const cited = wakeCard(messages, { session });
1291
+ // #10824: the contract message BEING SERVED names the card — the newest direct message's own
1292
+ // binding — never a contract still queued behind it and never the session's previous card.
1293
+ const cited = servedContractCard(messages, { session });
1255
1294
  if (!(cited > 0)) return 0;
1256
1295
  try {
1257
1296
  const { task } = await api(`/card?project=${encodeURIComponent(PROJ)}&id=${cited}`);
@@ -1312,6 +1351,7 @@ async function resolveWakeCard(messages, { session }) {
1312
1351
  }
1313
1352
  let retryAt = 0; // 0 = deliver at the next opportunity
1314
1353
  let deliveryFails = 0; // consecutive failed attempts at the SAME pending batch
1354
+ let noDeliveryRetry = false;
1315
1355
  if (pendingWake.length) log(`\x1b[33m${pendingWake.length} message(s) survived from a previous run — redelivering\x1b[0m`);
1316
1356
 
1317
1357
  // #7060: the turn the boot line was read as a promise about. It is a transcript turn by
@@ -1455,6 +1495,12 @@ async function resolveWakeCard(messages, { session }) {
1455
1495
  // a bounded park instead of queueing behind the window; a mention does not, because the room
1456
1496
  // talking is not a request. An exhausted or auth park is a wall and stands anyway (#6134).
1457
1497
  const directWake = wake.filter(m => m.to === SESSION);
1498
+ // #10197: no-delivery owes the old contract, but its retry delay must not batch away
1499
+ // fresh contracts. Only actual CLI failures impose backoff on a new wake.
1500
+ if (noDeliveryRetry) {
1501
+ unpark("new wake after no-delivery");
1502
+ retryAt = 0; deliveryFails = 0; noDeliveryRetry = false;
1503
+ }
1458
1504
  if (parkState && directWake.length) {
1459
1505
  if (isBoundedPark(parkState.reason)) {
1460
1506
  unpark(`direct message from ${directWake[0].from || "?"}`);
@@ -1491,6 +1537,7 @@ async function resolveWakeCard(messages, { session }) {
1491
1537
  } catch { return 0; }
1492
1538
  }
1493
1539
  async function deliverWake() {
1540
+ noDeliveryRetry = false;
1494
1541
  // #7756: captured and cleared HERE so an early return below never leaks the flag into a
1495
1542
  // later turn — only the turn the answer released reads it.
1496
1543
  const resumedAfterAsk = askReleased;
@@ -1560,12 +1607,13 @@ async function resolveWakeCard(messages, { session }) {
1560
1607
  // #6134: ONE SESSION PER CARD; a different card starts a fresh CLI session and the seat is told.
1561
1608
  // #7061: bound by SHAPE, not position, so an order opening with what shipped binds the right card.
1562
1609
  // #7763: a message-card (or phantom) citation never binds — the seat's own newest card does.
1563
- const card = await resolveWakeCard(wakeForTurn, { session: SESSION });
1610
+ // #10824: the binding is the SERVED contract's; #7756's answer-released turn keeps the held card.
1611
+ const card = resumedAfterAsk ? sessionCard : await resolveWakeCard(wakeForTurn, { session: SESSION });
1564
1612
  // #7754: a `base:` sha this worktree cannot resolve is never worked from origin/main — the card
1565
1613
  // goes to blocked, the assigner is told, and no model turn is spent. The check keeps the id the
1566
- // wake cites in its text: #7763's rebinding covers confirmed message-card ids only, never a
1614
+ // served contract cites: #7763's rebinding covers confirmed message-card ids only, never a
1567
1615
  // card cited in the text, so the unresolvable-base branch blocks the cited card.
1568
- const baseCard = card || wakeCard(wakeForTurn, { session: SESSION });
1616
+ const baseCard = card || servedContractCard(wakeForTurn, { session: SESSION });
1569
1617
  const base = contractBase(wakeForTurn) || localMainHead();
1570
1618
  if (base && baseCard && !resolvesHere(base)) {
1571
1619
  const text = `cannot resolve base ${base} in ${TURN_DIR} (git cat-file -e failed) — #${baseCard} moved to blocked; push or fetch that sha, or name one this worktree can reach`;
@@ -1579,13 +1627,13 @@ async function resolveWakeCard(messages, { session }) {
1579
1627
  return;
1580
1628
  }
1581
1629
  const baseText = base ? `\n${baseLine(base)}\n` : "";
1582
- // #6289: the card the SESSION belongs to reads the citation when the rebinding binds nothing —
1583
- // #7763 decides WHICH card work binds to, never the turn shape. #7756: the turn an ask's
1584
- // ANSWER releases is the exception — it resumes the session the held contract already owns,
1585
- // whatever card the batch cites; only a new contract for a different card opens fresh.
1630
+ // #6289: the session SHAPE (fresh vs resume) reads the served contract's raw citation — a wake
1631
+ // whose card the board lacks still opens fresh, never a bare resume of the kickoff's CLI.
1632
+ // #10824: the LABEL is the binding, 0 included — a wake that binds nothing labels its turn 0
1633
+ // and never inherits the previous contract's card (#10197's judge then reads the right card).
1586
1634
  const sessionCardForTurn = card || baseCard;
1587
1635
  const fresh = !resumedAfterAsk && sessionCardForTurn > 0 && sessionCardForTurn !== sessionCard;
1588
- if (card) sessionCard = card;
1636
+ sessionCard = card;
1589
1637
  const cited = [...new Set(wakeForTurn.flatMap(m => cardRefs(m.text)))];
1590
1638
  const freshText = fresh
1591
1639
  ? `\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`
@@ -1602,6 +1650,7 @@ async function resolveWakeCard(messages, { session }) {
1602
1650
  const stopDutyNudgeWatcher = startDutyNudgeWatcher(dutyPlan, tStart);
1603
1651
  let ec;
1604
1652
  turnAsk = null; // #7756: the judge reads THIS turn's ending, never a previous turn's ask
1653
+ lastNoDelivery = false; lastNoDeliveryStatus = ""; // #10197: runTurn judges THIS turn only
1605
1654
  const stateStep = !stateSkip("wake", card);
1606
1655
  try {
1607
1656
  ec = stateStep
@@ -1723,6 +1772,27 @@ async function resolveWakeCard(messages, { session }) {
1723
1772
  savePending(pendingWake, pendingBcast);
1724
1773
  await reportHealthy();
1725
1774
  log(`turn asked its assigner — holding the contract until ask #${awaitingAsk.id} is answered`);
1775
+ } else if (lastNoDelivery) {
1776
+ // #10197: the turn exited 0 but DELIVERED NOTHING on its card — no new commit, the card
1777
+ // still short of testing/done. "⚠ no commit" is not a receipt, so the sender's ledger keeps
1778
+ // the contract owed; the queue is kept and the ladder retries — a second no-delivery
1779
+ // attempt parks, exactly like the empty-turn ladder it sits beside.
1780
+ deliveryFails++;
1781
+ noDeliveryRetry = true;
1782
+ savePending(pendingWake, pendingBcast);
1783
+ const still = lastNoDeliveryStatus || "unresolved";
1784
+ if (deliveryFails >= 2) {
1785
+ retryAt = await parkSeat("no-delivery", pendingWake.length);
1786
+ await notifyAssigners(assigners,
1787
+ `⛔ your contract is PARKED on ${SESSION} (no-delivery: two exit-0 turns with no commit and card #${sessionCard} unmoved) · asked: "${asked}"`);
1788
+ lastTurnAt = Date.now();
1789
+ return;
1790
+ }
1791
+ const wait = RETRY_MS[Math.min(deliveryFails - 1, RETRY_MS.length - 1)];
1792
+ retryAt = Date.now() + wait;
1793
+ log(`\x1b[33mno commit, card #${sessionCard} still ${still} — wake not consumed, ${pendingWake.length} message(s) stay owed; retrying in ${Math.round(wait / 1000)}s\x1b[0m`);
1794
+ await notifyAssigners(assigners,
1795
+ `⚠ no commit, card #${sessionCard} still ${still} on ${SESSION} (exit 0, ${secs}s) · asked: "${asked}"`);
1726
1796
  } else if (lastEmptyTurn) {
1727
1797
  // #7759: the turn exited 0 but was HOLLOW — a banner is not work. The wake is NOT
1728
1798
  // consumed: the queue is kept and the ladder retries, and the assigner hears EMPTY,
package/bin/summarize.mjs CHANGED
@@ -1,18 +1,8 @@
1
1
  #!/usr/bin/env node
2
- // trantor summarize — narrative cards: what was ASSIGNED and what was DONE, in plain language.
3
- //
4
- // trantor summarize [--project <p>] [--limit N] [--all] [--dry] [--quiet]
5
- //
6
- // Title-cleaning makes machine-generated cards readable; it cannot make them MEAN anything. This
7
- // gives each card a one-line narrative ("assigned — did") written by a CHEAP model from the card's
8
- // own thread (its events + the messages that cite it). The board then reads as a story a human can
9
- // follow, which was the ask: "what the agent was assigned, what the agent did, what the flow was."
10
- //
11
- // Economics by design (the Scrooge doctrine): candidates are MACHINE-TITLED cards without a
12
- // summary (--all widens to every unsummarized card), batched into ONE cheap-model call per hub,
13
- // difficulty easy, capped per run. The summary lands via /task/update and rides the tasks.extra
14
- // column — permanent, never recomputed. Runs ambiently from the heartbeat (hourly, detached) and
15
- // on demand as `trantor summarize`.
2
+ // trantor summarize [--project <p>] [--limit N] [--all] [--dry] [--quiet] — gives each machine-titled
3
+ // card a one-line "assigned — did" narrative written by ONE cheap-model call per hub from the card's
4
+ // own thread; lands via /task/update on tasks.extra (permanent, never recomputed). Runs ambiently
5
+ // from the heartbeat (hourly, detached) and on demand as `trantor summarize`.
16
6
  import { execSync, spawnSync } from "node:child_process";
17
7
  import { readFileSync, existsSync } from "node:fs";
18
8
  import { join } from "node:path";
@@ -40,7 +30,7 @@ const scroogeBin = () => process.env.SCROOGE_BIN
40
30
  // A title no human wrote: protocol frames, dumped prompts, id soup. These are the cards whose
41
31
  // board presence is noise until a narrative replaces them.
42
32
  const machineTitled = (t) =>
43
- /^\s*[<{[]/.test(t) || /<task-notification>|<system-reminder>|toolu_[A-Za-z0-9]/.test(t) ||
33
+ /^\s*[<{[]/.test(t) || /<task-notification>|<system-reminder>|<trantor-succession>|<trantor-takeover>|toolu_[A-Za-z0-9]/.test(t) ||
44
34
  /^\s*(subagent|general-purpose|Explore|Task|Plan):/i.test(t) || t.length > 130;
45
35
 
46
36
  const hubs = new Set([config.url || "http://127.0.0.1:4477", ...Object.values(config.hubs || {})]);
@@ -119,10 +119,12 @@ async function maybeEarlyWarn(stdinRaw, session) {
119
119
  // It is marked where the baton fires (here on the backstop, in Stop on the normal path).
120
120
  armBaton(sessionId, { projectDir, transcript, reason: "context-warn", windowId, tty, tokens: usage.tokens });
121
121
  // Tell the RUNNING agent (once, at arm time): wrap up and author the boundary handoff yourself.
122
- ARM_CTX = `<system-reminder>TRANTOR SUCCESSION ARMED — this session is at ${Math.round(usage.frac * 100)}% of its context window, and the baton is armed: your NEXT STOP fires the handoff. You are now responsible for your own succession:\n`
122
+ // NOT <system-reminder>: CC 2.1.292 escapes that tag in hook output, so the notice would
123
+ // arrive as literal text (#11110). Our own <trantor-…> tags pass through like the other hooks'.
124
+ ARM_CTX = `<trantor-succession>TRANTOR SUCCESSION ARMED — this session is at ${Math.round(usage.frac * 100)}% of its context window, and the baton is armed: your NEXT STOP fires the handoff. You are now responsible for your own succession:\n`
123
125
  + `1. Reach a real task boundary — finish, pause, or checkpoint your in-flight work NOW; do not start new work.\n`
124
126
  + `2. Author (or refresh) the rich handoff from your CURRENT state — via the handoff skill or the relay_handoff MCP tool — so the successor gets the freshest picture, not a digest of a session 36 seconds stale. A fresh model-authored handoff supersedes the auto-digest.\n`
125
- + `3. Then end your turn. The Stop hook fires the baton at that boundary.</system-reminder>`;
127
+ + `3. Then end your turn. The Stop hook fires the baton at that boundary.</trantor-succession>`;
126
128
  } catch {}
127
129
  }
128
130
 
@@ -18,6 +18,25 @@ function readStdin() {
18
18
  }
19
19
  // A prompt that is JUST an acknowledgement/continuation (anchored to end) — not a new focus.
20
20
  const ACK = /^(y|yes|yep|yeah|ok|okay|sure|go|go ahead|continue|proceed|do it|please|thanks|thank you|ty|next|k|cool|nice|great|perfect|sounds good|👍)[\s.!]*$/i;
21
+ // A paste arrives wrapped in `<pasted_content id="…">…</pasted_content>`: the wrapper is transport,
22
+ // not content — the operator typed what is inside (#10027's chat.rs rule, same channel). Peel it so
23
+ // the marker check and the focus title see the words, never the tag. Unwrapped text passes through.
24
+ function unwrapPasted(t) {
25
+ const s = String(t || "").trim();
26
+ if (!s.startsWith("<pasted_content")) return s;
27
+ const gt = s.indexOf(">");
28
+ if (gt < 0) return s;
29
+ const close = s.lastIndexOf("</pasted_content>");
30
+ return (close > gt ? s.slice(gt + 1, close) : s.slice(gt + 1)).trim();
31
+ }
32
+ // HARNESS-INJECTED prompts are not a human's focus. A CLOSED list of known prefixes, not "starts
33
+ // with <": a paste merely happens to start with a tag, and a human quoting JSON is still a human.
34
+ // (The old ^\s*[<{[] heuristic silently dropped every pasted operator prompt, #11110.)
35
+ const HARNESS_MARKERS = [
36
+ "<task-notification", "<system-reminder", "<teammate-message",
37
+ "<command-name", "<environment_context", "[SYSTEM NOTIFICATION",
38
+ ];
39
+ const isHarnessInjection = (t) => HARNESS_MARKERS.some(m => t.startsWith(m));
21
40
  function titleFrom(prompt) {
22
41
  let s = String(prompt || "").replace(/\s+/g, " ").trim();
23
42
  // strip a leading politeness/imperative wrapper so the card reads as the WORK, not "can you please…"
@@ -42,7 +61,7 @@ function loadRecapCtx(sessionId) {
42
61
  const mandate = rec.mode === "unattended"
43
62
  ? " Then RESUME the handoff's OPEN THREADS immediately — they are your work order; this succession is unattended, do NOT wait for the user."
44
63
  : " Then WAIT for the user.";
45
- return `<system-reminder>You took over via handoff ${rec.handoffId}. If you have not yet recapped it, your reply MUST begin with the ≤3-sentence recap (task, state, next step) before anything else — including before answering this message.${mandate}</system-reminder>`;
64
+ return `<trantor-takeover>You took over via handoff ${rec.handoffId}. If you have not yet recapped it, your reply MUST begin with the ≤3-sentence recap (task, state, next step) before anything else — including before answering this message.${mandate}</trantor-takeover>`;
46
65
  } catch { return ""; }
47
66
  }
48
67
  function emitAndExit() {
@@ -61,17 +80,16 @@ try {
61
80
  // don't card home-dir sessions (matches sessionstart's phantom-project guard)
62
81
  if (!process.env.RELAY_SESSION && !process.env.RELAY_PROJECT && cwd === homedir()) { emitAndExit(); }
63
82
  const trimmed = prompt.replace(/\s+/g, " ").trim();
83
+ const text = unwrapPasted(trimmed); // a paste's wrapper is transport; judge the words inside
64
84
  // skip empties, tiny continuations, and pure acks — they're not a new focus
65
- if (!trimmed || trimmed.length < 12 || ACK.test(trimmed)) { emitAndExit(); }
85
+ if (!text || text.length < 12 || ACK.test(text)) { emitAndExit(); }
66
86
  // The Accounts ask drill launches a real Claude session, so its scripted prompt traverses this
67
87
  // hook just like operator work. It is harness traffic, though, and must never become a focus card.
68
- if (/\bTRANTOR ASK DRILL\b/.test(trimmed)) { emitAndExit(); }
69
- // HARNESS-INJECTED prompts are not a human's focus. Task notifications, hook system-reminders and
70
- // protocol frames arrive through the same UserPromptSubmit channel, and carding one titled a board
71
- // card "<task-notification> <task-id>bavlqfmzq</task-id>…" — pure noise a human cannot read.
72
- if (/^\s*[<{[]/.test(trimmed) || /<task-notification>|<system-reminder>|<teammate-message/i.test(trimmed)) {
73
- emitAndExit();
74
- }
88
+ if (/\bTRANTOR ASK DRILL\b/.test(text)) { emitAndExit(); }
89
+ // Task notifications, hook reminders and protocol frames arrive through the same UserPromptSubmit
90
+ // channel, and carding one titled a board card "<task-notification> <task-id>bavlqfmzq</task-id>…"
91
+ // — pure noise a human cannot read.
92
+ if (isHarnessInjection(text)) { emitAndExit(); }
75
93
  const project = resolveProject(cwd);
76
94
  const session = process.env.RELAY_SESSION
77
95
  || (process.env.RELAY_AGENT ? `${process.env.RELAY_AGENT}:${project}` : `${hostId()}:${project}`);
@@ -79,18 +97,18 @@ try {
79
97
  // two Claude sessions in one project share (and fight over) a single focus card, and sub-agent
80
98
  // cards, whose `parent` is exactly this UUID, have nothing to nest under.
81
99
  const cc = String(input.session_id || "").slice(0, 120);
82
- const r = await signedPost("/focus", { session, project, title: titleFrom(trimmed), by: session, cc }, { session });
100
+ const r = await signedPost("/focus", { session, project, title: titleFrom(text), by: session, cc }, { session });
83
101
 
84
102
  // Only pay a model when the heuristic actually mangles the prompt. A short, already-clear ask
85
103
  // ("fix the login redirect") reads fine as-is and buying a rewrite for it is exactly the kind of
86
104
  // reflexive spend the economics doctrine exists to stop.
87
105
  const id = r?.json?.id;
88
- if (id && trimmed.length > 90 && process.env.TRANTOR_NO_SCROOGE_TITLES !== "1") {
106
+ if (id && text.length > 90 && process.env.TRANTOR_NO_SCROOGE_TITLES !== "1") {
89
107
  try {
90
108
  const busDir = process.env.AGENT_BUS_DIR || join(homedir(), ".agent-bus");
91
109
  mkdirSync(busDir, { recursive: true });
92
110
  const pf = join(busDir, `focus-prompt-${String(cc || session).replace(/[^A-Za-z0-9_.-]/g, "_")}.txt`);
93
- writeFileSync(pf, trimmed);
111
+ writeFileSync(pf, text);
94
112
  const worker = join(dirname(dirname(fileURLToPath(import.meta.url))), "bin", "focus-title.mjs");
95
113
  // Detached + unref'd + stdio ignored: the hook returns NOW. Nothing downstream waits on this,
96
114
  // and a worker that dies takes the heuristic title with it, which is a fine outcome.
@@ -200,6 +200,13 @@ export function zeroUsageVerdict() {
200
200
  return "classified zero-usage because the session usage record resolved to all zeros — no model work happened";
201
201
  }
202
202
 
203
+ /** #10197: the verdict line for a card-bound turn that DELIVERED NOTHING — exit 0 with output but
204
+ * no new commit since turn start and the card unmoved (the ibkr specimen: 48s, prose, card still
205
+ * todo, announced done). Named here so the drill pins the string. */
206
+ export function noDeliveryVerdict(card, status) {
207
+ return `classified no-delivery because exit 0 with no new commit and card #${card} still ${status}`;
208
+ }
209
+
203
210
  /** Classify one failed turn. Returns { reason, matched } — matched is the evidence excerpt the
204
211
  * runner logs, so the next misclassification is diagnosable from the seat log alone. `cut` is the
205
212
  * runner's box/stall marker: 137 reads as the sweep's SIGKILL only under it — without the marker
@@ -68,11 +68,12 @@ function reconcileFile(absPath, agentBytes, projectRoot) {
68
68
  return { path: shortPath(absPath, projectRoot), agentBytes, onDiskNow, suspect };
69
69
  }
70
70
 
71
- function analyzeAgent(meta, completedIds, projectRoot) {
71
+ function analyzeAgent(meta, completedIds, projectRoot, sinceMs) {
72
72
  const wrote = new Map(); // absPath → last-write byte length
73
- let lastMs = 0, result = "";
73
+ let lastMs = 0, launchMs = 0, result = "";
74
74
  for (const r of parseLines(meta.jsonlPath)) {
75
75
  const ts = r.timestamp ? Date.parse(r.timestamp) || 0 : 0;
76
+ if (!launchMs && ts) launchMs = ts; // jsonl is append-ordered: first timestamped line = launch
76
77
  if (ts > lastMs) lastMs = ts;
77
78
  const c = r?.message?.content;
78
79
  if (!Array.isArray(c)) continue;
@@ -84,13 +85,22 @@ function analyzeAgent(meta, completedIds, projectRoot) {
84
85
  if (b?.type === "text") { const text = asString(b.text)?.trim(); if (text) result = text; }
85
86
  }
86
87
  }
87
- const status = meta.toolUseId && completedIds.has(meta.toolUseId) ? "completed" : "in-flight";
88
+ // #10007 liveness: "in-flight" means launched and never returned a result. But an agent whose
89
+ // launch predates the session's CURRENT process (sinceMs = process start) can never return —
90
+ // its turn died with the old process — so it reads "stale" and never counts as live.
91
+ const returned = meta.toolUseId && completedIds.has(meta.toolUseId);
92
+ let status = returned ? "completed" : "in-flight";
93
+ if (!returned) {
94
+ if (!launchMs) { try { launchMs = statSync(meta.jsonlPath).birthtimeMs || 0; } catch {} }
95
+ if (sinceMs != null && launchMs < sinceMs) status = "stale";
96
+ }
88
97
  return {
89
98
  name: meta.name || basename(meta.jsonlPath).replace(/\.jsonl$/, ""),
90
99
  agentType: meta.agentType || "",
91
100
  task: meta.description || "",
92
101
  workflow: meta.workflow || null,
93
102
  status,
103
+ launchMs: launchMs || null,
94
104
  wrote: [...wrote.entries()].map(([p, n]) => reconcileFile(p, n, projectRoot)),
95
105
  lastActivity: lastMs ? new Date(lastMs).toISOString() : null,
96
106
  lastActivityMs: lastMs,
@@ -99,10 +109,12 @@ function analyzeAgent(meta, completedIds, projectRoot) {
99
109
  };
100
110
  }
101
111
 
102
- // Derive the full manifest for a session from its parent transcript path. projectRoot (the repo
103
- // dir) is used only to shorten displayed file paths. Returns { sessionId, subagents[], counts }.
104
- export function deriveSubagentManifest(parentTranscript, { projectRoot } = {}) {
105
- const out = { sessionId: "", subagents: [], counts: { total: 0, completed: 0, inFlight: 0, suspectFiles: 0 } };
112
+ // Derive the full manifest for a session from its parent transcript path. projectRoot shortens
113
+ // displayed file paths. sinceMs (epoch ms, #10007) marks agents launched before it "stale" instead
114
+ // of "in-flight" — pass the session process's start when the answer feeds a LIVE claim (ACTIVE
115
+ // NOW), never for a handoff record. Returns { sessionId, subagents[], counts }.
116
+ export function deriveSubagentManifest(parentTranscript, { projectRoot, sinceMs } = {}) {
117
+ const out = { sessionId: "", subagents: [], counts: { total: 0, completed: 0, inFlight: 0, stale: 0, suspectFiles: 0 } };
106
118
  try {
107
119
  if (!parentTranscript || !existsSync(parentTranscript)) return out;
108
120
  const sid = basename(parentTranscript).replace(/\.jsonl$/i, "");
@@ -118,12 +130,13 @@ export function deriveSubagentManifest(parentTranscript, { projectRoot } = {}) {
118
130
  }
119
131
 
120
132
  const metas = collectMetas(subdir);
121
- out.subagents = metas.map((m) => analyzeAgent(m, completedIds, projectRoot))
133
+ out.subagents = metas.map((m) => analyzeAgent(m, completedIds, projectRoot, sinceMs))
122
134
  .sort((a, b) => (a.lastActivityMs || 0) - (b.lastActivityMs || 0));
123
135
 
124
136
  out.counts.total = out.subagents.length;
125
137
  out.counts.completed = out.subagents.filter((s) => s.status === "completed").length;
126
138
  out.counts.inFlight = out.subagents.filter((s) => s.status === "in-flight").length;
139
+ out.counts.stale = out.subagents.filter((s) => s.status === "stale").length;
127
140
  out.counts.suspectFiles = out.subagents.reduce((n, s) => n + s.wrote.filter((w) => w.suspect).length, 0);
128
141
  } catch {}
129
142
  return out;
@@ -140,7 +153,9 @@ export function formatSubagentManifest(m, { heading = true } = {}) {
140
153
  }
141
154
  }
142
155
  for (const s of m.subagents) {
143
- const badge = s.status === "completed" ? "✅ completed" : "🛑 IN-FLIGHT at handoff";
156
+ const badge = s.status === "completed" ? "✅ completed"
157
+ : s.status === "stale" ? "⏸ never returned, and launched before the current session process — stale"
158
+ : "🛑 IN-FLIGHT at handoff";
144
159
  L.push("");
145
160
  L.push(`• ${s.name} [${s.agentType}]${s.workflow ? ` · wf:${s.workflow}` : ""} ${badge}`);
146
161
  if (s.task) L.push(` task: ${s.task}`);
@@ -53,6 +53,29 @@ export function assignedCardRef(text) {
53
53
  return m ? Number(m.slice(1).find((g) => g != null)) : 0;
54
54
  }
55
55
 
56
+ /// #10824: the CONTRACT MESSAGE BEING SERVED decides the card — the newest direct message's own
57
+ /// binding: a hub-stamped `card` field, else the id its own text ASSIGNS (#7061 shape), else its
58
+ /// first citation — never a contract queued behind it or the previous turn's card. Deference: a
59
+ /// newest message that re-cites an older assignment is commentary on it, and #7061's reading stands.
60
+ export function servedContractCard(messages, { session = "" } = {}) {
61
+ const all = (Array.isArray(messages) ? messages : []).filter(Boolean);
62
+ if (!all.length) return 0;
63
+ const direct = session ? all.filter((m) => m.to === session) : [];
64
+ if (!direct.length) return wakeCard(all, { session });
65
+ const sorted = newestLast(direct);
66
+ const served = sorted[sorted.length - 1];
67
+ const stamped = Number(served?.card);
68
+ if (Number.isFinite(stamped) && stamped > 0) return stamped;
69
+ const own = assignedCardRef(served?.text);
70
+ if (own) return own;
71
+ const named = cardRefs(served?.text);
72
+ if (named.length) {
73
+ const olderAssigned = new Set(sorted.slice(0, -1).map((m) => assignedCardRef(m?.text)).filter(Boolean));
74
+ if (!named.some((c) => olderAssigned.has(c))) return named[0];
75
+ }
76
+ return wakeCard(all, { session });
77
+ }
78
+
56
79
  /// #7061: the card a wake batch binds to. Direct messages outrank @mentions, the NEWEST assignment wins,
57
80
  /// and only an assignment-free batch falls back to the newest message's first citation.
58
81
  export function wakeCard(messages, { session = "" } = {}) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trantor",
3
- "version": "0.18.76",
3
+ "version": "0.18.78",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "trantor": "bin/cli.mjs"