trantor 0.18.72 → 0.18.73

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.72",
3
+ "version": "0.18.73",
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": {
@@ -16,6 +16,7 @@ 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
20
  readPromptText, stripPromptEcho,
20
21
  } from "../lib/classify-failure.mjs";
21
22
  import { capWake, capBcast, pickLessons, composePrompt, contractBase, baseLine } from "./crew-payload.mjs";
@@ -928,6 +929,14 @@ exit $turn_exit`;
928
929
  authHit = AUTH_MARKER_RE.exec(ownOut)[0];
929
930
  log(`\x1b[31mexit 0 but the turn output IS an auth failure — treating as FAILED (auth, "${authHit}")\x1b[0m`);
930
931
  }
932
+ // #9724: the same trap in the quota family — opencode printed the OpenRouter credit error and
933
+ // exited 0, and exit 0 + output read as success over a turn where nothing ran. A billing
934
+ // rejection on a clean exit lifts effExit like the auth death does; classifyFailure names it
935
+ // exhausted and the existing ladder parks the seat (exhausted is in PARKING_REASONS).
936
+ if (realExit === 0 && effExit === 0 && looksLikeBillingDeath(ownOut, newCommit)) {
937
+ effExit = 1;
938
+ log(`\x1b[31mexit 0 but the turn output IS a provider billing rejection — treating as FAILED (exhausted, "${BILLING_RE.exec(ownOut)[0]}")\x1b[0m`);
939
+ }
931
940
  // #5481: exit 0 with an empty ERRF (the TOTAL capture, both streams) is the null-completion trap,
932
941
  // judged on echo-stripped text (#5868). #6969: on a state step ERRF is stderr only, so silence is normal.
933
942
  if (realExit === 0 && effExit === 0 && !lastErrText.trim() && !lastEnvelope.trim()) {
@@ -944,11 +953,6 @@ exit $turn_exit`;
944
953
  lastEmptyTurn = !cut && realExit === 0 && effExit === 0 && !authHit && !opts.state
945
954
  && !worktreeChanged && !busActive && !substantiveOutput(ownOut);
946
955
  if (lastEmptyTurn) log("\x1b[33mexit 0 but the turn was EMPTY — no worktree change, no substantive output, no bus activity\x1b[0m");
947
- // #5868: the verdict rides the telemetry row so a classification survives the pane scrolling
948
- // away — the same "classified X because Y" shape the runner logs, in the seat's jsonl forever.
949
- // A silent cut names its own verdict (#7752): the exit under it is the sweep's SIGPIPE, never
950
- // a provider failure. A box cut passes `cut` so its 137 reads as the sweep's SIGKILL (#7099).
951
- const verdict = stallCut ? stallVerdict() : verdictFor(realExit, effExit, lastEmptyOutput, ownOut, lastEmptyTurn, cut);
952
956
  // #6134: what the turn COST, from the CLI's own usage line. Zero means this CLI printed none —
953
957
  // never that the turn was free. `trantor seat-why` totals these into today's spend per seat.
954
958
  let tokens = parseTurnTokens(ownOut);
@@ -965,6 +969,18 @@ exit $turn_exit`;
965
969
  : AGENT === "dsh" ? dshTurnUsage(join(homedir(), ".dsh", "sessions"), TURN_DIR, t0, Date.now())
966
970
  : null;
967
971
  if (usage) tokens = usageTotal(usage);
972
+ // #9724: usage RESOLVED to all zeros means no model work happened — never success; null stays
973
+ // unknown, never failure, and a shipped commit is real work (stale record, not the turn).
974
+ const zeroUsage = realExit === 0 && effExit === 0 && !newCommit && usageSaysNoWork(usage);
975
+ if (zeroUsage) {
976
+ effExit = 1;
977
+ log("\x1b[31mexit 0 but the session usage record resolved to all zeros — no model work happened; treating as FAILED\x1b[0m");
978
+ }
979
+ // #5868: the verdict rides the telemetry row so a classification survives the pane scrolling
980
+ // away — the same "classified X because Y" shape the runner logs. A silent cut names its own
981
+ // verdict (#7752): the exit under it is the sweep's SIGPIPE. A box cut passes `cut` so its 137
982
+ // reads as the sweep's SIGKILL (#7099); a zero-usage turn names its own verdict too (#9724).
983
+ const verdict = stallCut ? stallVerdict() : zeroUsage ? zeroUsageVerdict() : verdictFor(realExit, effExit, lastEmptyOutput, ownOut, lastEmptyTurn, cut);
968
984
  // #6289: every ledger row names in ONE field what happened to the turn — cut, stalled (#7752),
969
985
  // api-error, completed — and what it cost (0 means "not reported", never "free"). #7762: `card`
970
986
  // binds the row to the card the turn worked (0 = kickoff/pulse) so the seat record attributes
@@ -153,12 +153,18 @@ async function main(stdinRaw) {
153
153
  // Write the stamp BEFORE the network call so rapid concurrent tool calls don't all fire.
154
154
  try { writeFileSync(stamp, String(Date.now())); } catch {}
155
155
 
156
- // POST /register with no status -> hub refreshes lastSeen + project, preserves status.
157
- // llm from the seat brand when a dialect bridge runs us (RELAY_AGENT=kimi-orch etc.), else claude.
158
- await signedPost("/register", { session, project,
156
+ // POST /register with no status -> hub refreshes lastSeen + project, preserves status; llm from
157
+ // the seat brand when a dialect bridge runs us (RELAY_AGENT=kimi-orch etc.), else claude.
158
+ // #8723 bounce-3: the beat carries the kind too — "orch" for the orchestrator pane (the same
159
+ // strict test sessionstart.mjs uses), "agent" when the runner's TRANTOR_SEAT says crew seat.
160
+ const beatKind = process.env.TRANTOR_ORCH && process.env.TRANTOR_ORCH === project ? "orch"
161
+ : process.env.TRANTOR_SEAT ? "agent" : "";
162
+ const beatBody = { session, project,
159
163
  llm: process.env.RELAY_LLM || (process.env.RELAY_AGENT ? process.env.RELAY_AGENT.replace(/-orch$/, "") : "claude"),
160
164
  model: modelFromTranscript(stdinRaw),
161
- hookVersion: (() => { try { return installedVersion(); } catch { return ""; } })() }, { session, timeoutMs: Number(process.env.RELAY_HEARTBEAT_TIMEOUT_MS || 1500) });
165
+ hookVersion: (() => { try { return installedVersion(); } catch { return ""; } })() };
166
+ if (beatKind) beatBody.kind = beatKind;
167
+ await signedPost("/register", beatBody, { session, timeoutMs: Number(process.env.RELAY_HEARTBEAT_TIMEOUT_MS || 1500) });
162
168
 
163
169
  // Ambient narratives: once an hour (machine-wide stamp), spawn the summarizer detached — the
164
170
  // board's machine-titled cards gain a plain-language "assigned — did" line without anyone asking.
@@ -109,8 +109,8 @@ try {
109
109
  phase: "sub-agents",
110
110
  }, { timeoutMs: 2500 });
111
111
  process.stderr.write(`[trantor] subagent-cost: ${agentType} ${model} ~$${usd == null ? "?" : usd.toFixed(4)} (${tokens.input + tokens.output + tokens.cacheWrite + tokens.cacheRead} tok) → ${project}\n`);
112
- // surface it back to the parent's Claude inline
113
- process.stdout.write(JSON.stringify({ hookSpecificOutput: { hookEventName: "SubagentStop", additionalContext: `[trantor] logged sub-agent ${agentType} — notional $${usd == null ? "?" : usd.toFixed(4)}` } }));
112
+ // #9707: log only. CC 2.1.280 feeds SubagentStop additionalContext back to the sub-agent as input, which re-woke it in a loop.
113
+ process.stdout.write("{}");
114
114
  } catch (e) {
115
115
  process.stderr.write(`[trantor] subagent-cost error: ${e?.message || e}\n`);
116
116
  process.stdout.write("{}");
package/hub/overseer.mjs CHANGED
@@ -12,11 +12,9 @@ const OVERSEER_TICK_MS = Number(process.env.RELAY_OVERSEER_TICK_MS || 30 * 1000)
12
12
  // timer: see overseerTick.
13
13
  const OVERSEER_CLEAR_MS = Number(process.env.RELAY_OVERSEER_CLEAR_MS || process.env.RELAY_OVERSEER_DEDUP_MS || 10 * 60 * 1000);
14
14
  // Standing conditions, keyed by collision identity -> { since, lastTick }. A collision is a STATE,
15
- // not an event: it persists. Emitting on a 10-minute timer turned the watcher into a metronome —
16
- // 500 events for 4 distinct conditions (2026-08-12 audit), each one also waking the duty seat for a
17
- // full turn. Now an episode fires ONCE when it starts and stays quiet while it holds; the entry is
18
- // forgotten only after the condition has been gone for OVERSEER_CLEAR_MS, so a genuine recurrence
19
- // warns again.
15
+ // not an event: it persists (#5350 doctrine). It fires ONCE when it starts, stays quiet while it
16
+ // holds (a metronome woke duty on 500 events for 4 conditions once), and the entry is forgotten
17
+ // only after OVERSEER_CLEAR_MS of absence, so a genuine recurrence warns again.
20
18
  const overseerActive = new Map();
21
19
  // Heartbeat for the WATCHER itself: /overseer/status must distinguish "fleet is clear" from "the
22
20
  // overseer stopped ticking" — a monitor that cannot prove it is alive reads as clear when dead.
@@ -48,31 +46,29 @@ function overseerInputs() {
48
46
  }
49
47
 
50
48
  // --- #5760: the same-project warning is an EPISODE keyed by the MEMBER SET -------------------
51
- // The night of 08-31 the same-project DM re-fired hourly for a membership that never changed and
52
- // woke every seat into metered chatter turns. The rule (lib/same-project.mjs, pure) decides
53
- // fire-or-not from (previous set, current set, declared crew, last-fired-at): a declared crew is
54
- // the NORMAL state of a project and is not a collision at all; a set that never changed re-warns
55
- // never. The warn itself rides the SAME episode machinery as every other kind (#5350: one warn at
56
- // open, newcomer-only intros while standing, a genuine clear ends it) — the record below only
57
- // feeds the pure rule the set it judged last, so "unchanged" is one hash comparison and the
58
- // record line reports DURATION ("same-project for 6h"), never a count of warnings.
49
+ // lib/same-project.mjs (pure) decides from (previous set, current set, declared crew,
50
+ // last-fired-at): a declared crew is the NORMAL state of a project and not a collision at all,
51
+ // an unchanged set never re-warns (#5350 machinery), and the record reports DURATION.
59
52
  const sameProjectFired = new Map(); // project -> { hash, sessions, ts } — the set as of the last verdict
60
53
 
61
- // The declared crew: HUB state, never a file on the operator's machine (#6075). The production
62
- // hub runs on netcup, where ~/.agent-bus/crew-windows.txt does not exist — the file describes the
63
- // OPERATOR'S machine (it is written by `trantor up` there), so on the remote hub the old reader
64
- // found nothing and every same-project set looked like intruders: the crew-only exemption simply
65
- // never held remotely. What the hub itself knows is the peer row's `kind` (#6148): "agent" is a
66
- // crew seat — crew-runner stamps it on every /register its seats make — and "orch" is the
67
- // project's orchestrator pane (sessionstart stamps it when TRANTOR_ORCH names this project).
68
- // The HOST_NAME exemption is gone with the file: the hub's hostname is the hub machine's
69
- // (netcup), never the operator's, so `<HOST_NAME>:<project>` exempted a session that cannot
70
- // exist. Genesis is deliberately NOT crew (#6068: a bookkeeping identity, not a seat).
54
+ // #8723 bounce-4: peerKindOf is the ONE answer to "what is this peer" — the row's own kind,
55
+ // nothing else — shared with /peers, so roster and overseer can never disagree. The enrolled
56
+ // identity is NOT a fallback (every identity defaults kind "agent", which made every peer crew
57
+ // and hid real intruders): the beats stamp orch/agent and pg COALESCE keeps the kind instead.
58
+ function peerKindOf(p) {
59
+ return p && typeof p === "object" ? String(p.kind || "") : "";
60
+ }
61
+
62
+ // The declared crew is HUB state: the peer row's kind (#6148, #6075) — "agent" is a crew seat
63
+ // (crew-runner stamps every /register its seats make), "orch" the project's orchestrator pane
64
+ // (sessionstart stamps it when TRANTOR_ORCH names this project). A local crew-windows.txt reader
65
+ // would describe the OPERATOR's machine, never this hub's. Genesis is deliberately NOT crew (#6068).
71
66
  function declaredCrewFor(project) {
72
67
  const crew = new Set();
73
68
  for (const [sid, p] of Object.entries(state.peers)) {
74
69
  if ((p.project || "") !== project) continue;
75
- if (p.kind === "agent" || p.kind === "orch") crew.add(sid);
70
+ const k = peerKindOf(p);
71
+ if (k === "agent" || k === "orch") crew.add(sid);
76
72
  }
77
73
  return [...crew];
78
74
  }
@@ -85,12 +81,10 @@ function overseerTick() {
85
81
  overseerLastTick = t;
86
82
  const pol = overseerPolicy();
87
83
  const seen = new Set();
88
- // Hand each party the others' session ids at the moment coordination is warranted. Telling two
89
- // sessions to "coordinate over the bus" is useless if neither knows the other's id, and the
90
- // warning alone went only to the duty seat and the log — so coordination needed a human to carry
91
- // the ids across. Shared by the episode-start branch (all parties) and the standing branch
92
- // (newcomers only, same-project included): existing members never
93
- // re-hear it, so a standing condition must not re-wake every party every tick.
84
+ // The intro hands each party the others' session ids at the moment coordination is warranted —
85
+ // the warning alone went only to duty, and ids never cross the bus by themselves. Shared by the
86
+ // episode-start branch (all parties) and the standing branch (newcomers only, same-project
87
+ // included): existing members never re-hear it, so a standing condition cannot re-wake every tick.
94
88
  const intro = (c, me, others) => {
95
89
  const rest = others.filter(p => p !== me);
96
90
  if (rest.length === 0) return;
@@ -102,13 +96,10 @@ function overseerTick() {
102
96
  // project, not a collision — so not even the context feed narrates them.
103
97
  const kept = [];
104
98
  for (const c of collisions) {
105
- // #5760: same-project gets the pure episode rule (lib/same-project.mjs) ON TOP of the shared
106
- // episode machinery below: a crew-only set is not a collision at all (dropped — no warn, no
107
- // context, no state); a standing set re-warns never (a liveness flap replays the SAME set —
108
- // 08-31's metronome — and must stay silent, only genuine newcomers hear the intro once); and
109
- // the record line reports DURATION. Without the rule module this branch is invisible and
110
- // same-project rides the generic loop exactly as before — a missing rule must never
111
- // re-instate the hourly metronome, so the fallback is the pre-#5760 behavior, never stricter.
99
+ // #5760: the pure episode rule rides ON TOP of the shared machinery — a crew-only set is not
100
+ // a collision at all (dropped, no context), a liveness flap replays the SAME set and stays
101
+ // silent, the record reports DURATION. Without the rule module the fallback is the pre-#5760
102
+ // generic loop: a missing rule must never re-instate the metronome, only loosen it.
112
103
  if (c.kind === "same-project-sessions" && _sameProject?.sameProjectDecision) {
113
104
  const prior = sameProjectFired.get(c.project) || null;
114
105
  const d = _sameProject.sameProjectDecision({
@@ -205,7 +196,7 @@ setInterval(overseerTick, OVERSEER_TICK_MS).unref?.();
205
196
  setTimeout(overseerTick, 2000).unref?.();
206
197
 
207
198
  return {
208
- overseerTick, overseerPolicy, overseerInputs, declaredCrewFor,
199
+ overseerTick, overseerPolicy, overseerInputs, declaredCrewFor, peerKindOf,
209
200
  active: overseerActive,
210
201
  get engine() { return _overseer; },
211
202
  get sameProject() { return _sameProject; },
package/hub/reaper.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  export function createReaper({
2
2
  state, now, canon, appendCardEvent, appendEvent, appendTaskLog, markDirty, sweepPresence,
3
- ONLINE_MS, PEER_TTL_MS, FOCUS_OFFLINE_MS, FOCUS_IDLE_MS,
3
+ ONLINE_MS, PEER_TTL_MS, PEER_FORGET_MS, FOCUS_OFFLINE_MS, FOCUS_IDLE_MS,
4
4
  REAP_GRACE_MS, TODO_STALE_MS, REAP_INTERVAL_MS,
5
5
  CONTRACT_ABANDON_MS, CONTRACT_WINDOW_MS,
6
6
  }) {
@@ -49,11 +49,25 @@ function linkCommitToFocus(commitCard, by) {
49
49
  }
50
50
  function prunePeers() {
51
51
  const cutoff = now() - PEER_TTL_MS;
52
- let removed = false;
52
+ const forgetCut = now() - PEER_FORGET_MS;
53
+ let changed = false;
53
54
  for (const [session, peer] of Object.entries(state.peers)) {
54
- if ((peer.lastSeen || 0) < cutoff) { if (closeFocus(session)) removed = true; delete state.peers[session]; removed = true; }
55
+ const seen = peer.lastSeen || 0;
56
+ if (seen < forgetCut) { // #8723: the memory row itself is forgotten after RELAY_PEER_FORGET_MS
57
+ if (closeFocus(session)) changed = true;
58
+ delete state.peers[session];
59
+ changed = true;
60
+ continue;
61
+ }
62
+ if (seen >= cutoff) continue;
63
+ // #8723: a TTL-expired peer is a memory row, not a deleted one — deleting dropped `kind`
64
+ // (the overseer then warned about the crew's own returning seats) and `deliveredUpTo`
65
+ // (duty then escalated consumed mail as UNDELIVERED, "last seen never"). Presence readers
66
+ // already filter by lastSeen freshness, so only the offline edge is pinned here.
67
+ if (closeFocus(session)) changed = true;
68
+ if (peer._on === true) { peer._on = false; changed = true; }
55
69
  }
56
- if (removed) markDirty();
70
+ if (changed) markDirty();
57
71
  }
58
72
  setInterval(prunePeers, 60000).unref?.();
59
73
  setInterval(sweepPresence, 60000).unref?.();
@@ -10,11 +10,11 @@ const fileReads = new Map();
10
10
  export async function routeAdmin({ req, res, q, P, auth, ctx }) {
11
11
  const {
12
12
  state, body, json, crossProjectGuard, touch, canon, filterReadable,
13
- appendEvent, EVENT_CAP, overseer, ONLINE_MS, pruneClaims, fileClaims,
13
+ appendEvent, EVENT_CAP, overseer, ONLINE_MS, PEER_TTL_MS, pruneClaims, fileClaims,
14
14
  scopeAllows, duty, markDirty, HUB_VERSION, cmpSemver, prunePeers,
15
15
  filterDiscoverable, healthOf, now, canRead, AUTH_MODE, CLAIM_TTL_MS, subFp,
16
16
  } = ctx;
17
- const { overseerPolicy, overseerInputs, declaredCrewFor } = overseer;
17
+ const { overseerPolicy, overseerInputs, declaredCrewFor, peerKindOf } = overseer;
18
18
  if (req.method === "POST" && P === "/register") {
19
19
  const b = await body(req);
20
20
  const cpg = crossProjectGuard(auth, P, b);
@@ -29,7 +29,8 @@ export async function routeAdmin({ req, res, q, P, auth, ctx }) {
29
29
  // #7749: the runner's live turn phase (working/idle/cut/stalled/parked) + its start ride the
30
30
  // presence row, so /peers answers "what is the seat doing now" without inferring it from durations.
31
31
  if (pr && b.phase) { pr.phase = String(b.phase).slice(0, 20); pr.phaseSince = Number(b.phaseSince) || now(); }
32
- return json(res, 200, { ok: true, session: b.session, peers: Object.keys(state.peers) });
32
+ // #8723: memory rows (quiet past PEER_TTL_MS) are internal state — a peer listing never shows them.
33
+ return json(res, 200, { ok: true, session: b.session, peers: Object.keys(state.peers).filter(s => (state.peers[s]?.lastSeen || 0) >= now() - PEER_TTL_MS) });
33
34
  }
34
35
  if (req.method === "POST" && P === "/status") { const b = await body(req); touch(b.session, b.status ?? "", b.project, b.hookVersion, auth); return json(res, 200, { ok: true }); }
35
36
  // Single-peer lookup, including the read receipt (how far this session's inbox has actually been
@@ -318,10 +319,12 @@ export async function routeAdmin({ req, res, q, P, auth, ctx }) {
318
319
  if (req.method === "GET" && P === "/peers") {
319
320
  prunePeers();
320
321
  const cutoff = now() - ONLINE_MS;
321
- const peerRows = filterDiscoverable(auth, Object.entries(state.peers), ([, v]) => v.project || "");
322
+ const ttlCut = now() - PEER_TTL_MS; // #8723: memory rows are for declaredCrewFor + duty only, never a listing
323
+ const peerRows = filterDiscoverable(auth, Object.entries(state.peers), ([, v]) => v.project || "").filter(([, v]) => (v.lastSeen || 0) >= ttlCut);
322
324
  return json(res, 200, { hubVersion: HUB_VERSION, authMode: AUTH_MODE, peers: peerRows.map(([s, v]) => ({ session: s, lastSeen: v.lastSeen, online: v.lastSeen > cutoff, status: v.status || "", health: healthOf(v.status), project: v.project || "", phase: v.phase || "", phaseSince: v.phaseSince || 0,
323
325
  pubkey: v.pubkey || "", identity: v.identity || null, authWarning: v.authWarning || "",
324
- kind: v.kind || v.identity?.kind || "", llm: v.llm || "", model: v.model || "", hookVersion: v.hookVersion || "", staleHooks: !!(v.lastSeen > cutoff && v.hookVersion && HUB_VERSION && cmpSemver(v.hookVersion, HUB_VERSION) < 0) })) });
326
+ kind: peerKindOf(v), // #8723 bounce-3: same resolver declaredCrewFor reads — never a roster-only answer
327
+ llm: v.llm || "", model: v.model || "", hookVersion: v.hookVersion || "", staleHooks: !!(v.lastSeen > cutoff && v.hookVersion && HUB_VERSION && cmpSemver(v.hookVersion, HUB_VERSION) < 0) })) });
325
328
  }
326
329
  // --- Provider balances (prepaid credit) ---
327
330
  // The hub runs under launchd with no provider keys, so it can't fetch balances itself. Env-having
@@ -29,7 +29,7 @@ export async function routeCards({ req, res, q, P, auth, ctx }) {
29
29
  appendTaskLog, appendTaskNote, cleanChecklist, cleanDrill, hasDrillLine, linkCommitToFocus,
30
30
  derivePhases, PROPOSAL_CAP, propFp, healthOf, REAP_GRACE_MS, subFp,
31
31
  prunePeers, canRead, HUB_VERSION, cmpSemver, isCardEvent, fmtAge, hubSend,
32
- ONLINE_MS,
32
+ ONLINE_MS, PEER_TTL_MS,
33
33
  } = ctx;
34
34
  if (req.method === "POST" && P === "/task") { // create a card
35
35
  const b = await body(req);
@@ -544,7 +544,9 @@ export async function routeCards({ req, res, q, P, auth, ctx }) {
544
544
  const cutoff = now() - ONLINE_MS; const byProj = {};
545
545
  const proj = p => canon(p) || "(unassigned)";
546
546
  const mk = k => (byProj[k] ||= { project: k, brief: (state.projectMeta[k]?.brief) || "", agents: [], tasks: { todo:0,doing:0,testing:0,failed:0,done:0,blocked:0 }, doingTitles: [], lastActivity: 0 });
547
+ const ttlCut = now() - PEER_TTL_MS; // #8723: a memory row (quiet past PEER_TTL_MS) never lists as a board agent…
547
548
  for (const [s, v] of filterReadable(auth, Object.entries(state.peers), ([, v]) => v.project || "")) {
549
+ if ((v.lastSeen || 0) < ttlCut) continue; // …declaredCrewFor and the duty watermark still read it from state.
548
550
  const k = proj(v.project); const e = mk(k); e.agents.push({ session: s, online: v.lastSeen > cutoff, status: v.status || "", health: healthOf(v.status),
549
551
  llm: v.llm || "", model: v.model || "", hookVersion: v.hookVersion || "", staleHooks: !!(v.lastSeen > cutoff && v.hookVersion && HUB_VERSION && cmpSemver(v.hookVersion, HUB_VERSION) < 0) });
550
552
  if ((v.lastSeen || 0) > e.lastActivity) e.lastActivity = v.lastSeen;
@@ -6,7 +6,7 @@ export async function routeMessages({ req, res, q, P, auth, ctx }) {
6
6
  state, body, json, stripNulText, crossProjectGuard, touch, pushToStreams,
7
7
  appendEvent, appendCardEvent, appendTaskLog, markDelivered, contractsFor, canUseInboxSession, inboxWindow,
8
8
  deliverable, inboxReadable, inboxResponse, filterReadable, streams, UI,
9
- AUTH_MODE, persistHealth, duty, now, markDirty, assertNoSecrets,
9
+ AUTH_MODE, persistHealth, duty, now, markDirty, assertNoSecrets, PEER_TTL_MS,
10
10
  CONTRACT_WINDOW_MS,
11
11
  } = ctx;
12
12
  if (req.method === "POST" && P === "/send") {
@@ -157,7 +157,8 @@ export async function routeMessages({ req, res, q, P, auth, ctx }) {
157
157
  if (req.method === "GET" && (P === "/" || P === "/ui")) {
158
158
  res.writeHead(200, { "content-type": "text/html; charset=utf-8" }); return res.end(UI || "<h1>trantor</h1><p>dashboard unavailable</p>");
159
159
  }
160
- if (P === "/health") return json(res, 200, { ok: true, authMode: AUTH_MODE, peers: Object.keys(state.peers).length, messages: state.messages.length, streams: streams.length,
160
+ // #8723: the count is a listing in aggregate — memory rows (past PEER_TTL_MS) never count.
161
+ if (P === "/health") return json(res, 200, { ok: true, authMode: AUTH_MODE, peers: Object.values(state.peers).filter(v => (v.lastSeen || 0) >= now() - PEER_TTL_MS).length, messages: state.messages.length, streams: streams.length,
161
162
  persist: persistHealth.view(),
162
163
  // #5686: duty liveness rides /health so the app's Home strip and doctor read one truth.
163
164
  duty: { ...duty.dutyLiveness(), darkSinceMs: duty.darkSince ? now() - duty.darkSince : 0, queuedEscalations: duty.dutyQueuedEscalations() } });
package/hub.mjs CHANGED
@@ -33,6 +33,11 @@ const ONLINE_MS = Number(process.env.RELAY_ONLINE_MS || 5 * 60 * 1000);
33
33
  const PEER_TTL_DEFAULT_MS = 21600000;
34
34
  const peerTtlRaw = Number(process.env.RELAY_PEER_TTL_MS || PEER_TTL_DEFAULT_MS);
35
35
  const PEER_TTL_MS = Math.max(Number.isFinite(peerTtlRaw) ? peerTtlRaw : PEER_TTL_DEFAULT_MS, ONLINE_MS);
36
+ // #8723: the memory row a TTL-expired peer becomes is not immortal either — after this long quiet
37
+ // it is forgotten outright. Floored at PEER_TTL_MS so every row gets its memory phase before deletion.
38
+ const PEER_FORGET_DEFAULT_MS = 30 * 24 * 60 * 60 * 1000;
39
+ const forgetRaw = Number(process.env.RELAY_PEER_FORGET_MS || PEER_FORGET_DEFAULT_MS);
40
+ const PEER_FORGET_MS = Math.max(Number.isFinite(forgetRaw) ? forgetRaw : PEER_FORGET_DEFAULT_MS, PEER_TTL_MS);
36
41
  const REAP_GRACE_MS = Number(process.env.RELAY_REAP_GRACE_MS || 15 * 60 * 1000);
37
42
  const SUPERSEDE_GRACE_MS = Number(process.env.RELAY_SUPERSEDE_GRACE_MS || REAP_GRACE_MS);
38
43
  const TODO_STALE_DEFAULT_MS = 14 * 24 * 60 * 60 * 1000;
@@ -73,8 +78,8 @@ if (process.argv.includes("--smoke")) {
73
78
  const reaper = createReaper({
74
79
  state: store.state, markDirty: store.markDirty, canon: authRuntime.canon,
75
80
  now: events.now, appendCardEvent: events.appendCardEvent, appendEvent: events.appendEvent,
76
- appendTaskLog: store.appendTaskLog, sweepPresence: events.sweepPresence, ONLINE_MS, PEER_TTL_MS, FOCUS_OFFLINE_MS,
77
- FOCUS_IDLE_MS, REAP_GRACE_MS, TODO_STALE_MS, REAP_INTERVAL_MS,
81
+ appendTaskLog: store.appendTaskLog, sweepPresence: events.sweepPresence, ONLINE_MS, PEER_TTL_MS, PEER_FORGET_MS,
82
+ FOCUS_OFFLINE_MS, FOCUS_IDLE_MS, REAP_GRACE_MS, TODO_STALE_MS, REAP_INTERVAL_MS,
78
83
  CONTRACT_ABANDON_MS, CONTRACT_WINDOW_MS,
79
84
  });
80
85
  const duty = createDuty({
@@ -114,7 +119,7 @@ let UI = "";
114
119
  try { UI = readFileSync(new URL("./ui.html", import.meta.url), "utf8"); } catch {}
115
120
  const context = {
116
121
  ...store, ...authRuntime, ...events, ...reaper, duty, overseer, derivePhases,
117
- hubSend: duty.hubSend, assertNoSecrets, UI, AUTH_MODE, ONLINE_MS,
122
+ hubSend: duty.hubSend, assertNoSecrets, UI, AUTH_MODE, ONLINE_MS, PEER_TTL_MS,
118
123
  REAP_GRACE_MS, CONTRACT_WINDOW_MS,
119
124
  };
120
125
  const routes = [routeAdmin, routeCards, routeInsights, routeMessages];
@@ -84,15 +84,27 @@ export function looksLikeAuthDeath(ownText, realWork = false) {
84
84
  * it did, phrased exactly like the runner's own "classified X because Y" log — so a pane that
85
85
  * scrolls away loses nothing that the telemetry row needs to say. */
86
86
  export function verdictFor(realExit, effExit, emptyOutput, ownText, emptyTurn = false, cut = false) {
87
- if (realExit === 0 && effExit === 0) return emptyTurn
87
+ if (realExit === 0 && effExit === 0) {
88
+ // #9724: the quota family's exit-0 trap — opencode printed the OpenRouter credit error and
89
+ // quit clean, so exit 0 + bytes read as success over a turn where nothing ran. A billing
90
+ // rejection on a clean exit is the provider refusing the turn; a shipped commit is still
91
+ // real work (checked by the runner via git), and it stands above the captured stream.
92
+ const bl = looksLikeBillingDeath(ownText);
93
+ if (bl) return `classified exhausted because ${BILLING_RE.exec(String(ownText || ""))[0]} in the CLI's own output on a clean exit`;
94
+ return emptyTurn
88
95
  ? "classified empty-turn because exit 0 with no worktree change, no substantive output, no bus activity"
89
96
  : "classified success because exit 0 with CLI output";
97
+ }
90
98
  if (realExit === 0 && effExit === 1) {
91
99
  if (emptyOutput) return "classified empty-output because exit 0 with no output on either stream";
92
100
  // #8778: the exit-0 escalation must not relabel a provider-side rejection as "auth" — codex
93
101
  // prints the chatgpt-backend 401 and exits 0, so the real specimen rode this exact path.
94
102
  const pr = providerRejected(String(ownText || ""));
95
103
  if (pr) return `classified ${pr.reason} because ${pr.matched}`;
104
+ // #9724: the runner's billing escalation rides this branch too — the OpenRouter specimen's
105
+ // credit error must read exhausted here, never fall through to the auth label.
106
+ const bl = String(ownText || "").match(BILLING_RE);
107
+ if (bl) return `classified exhausted because ${bl[0]} in the CLI's own output on a clean exit`;
96
108
  const m = AUTH_MARKER_RE.exec(String(ownText || ""));
97
109
  return `classified auth because ${m ? m[0] : "auth marker"} in the CLI's own short output`;
98
110
  }
@@ -120,6 +132,32 @@ export function providerRejected(errText) {
120
132
  };
121
133
  }
122
134
 
135
+ // ---- the provider billing rejection on a clean exit (#9724) ------------------------------------
136
+ // The OpenRouter specimen: opencode printed "This request requires more credits, or fewer
137
+ // max_tokens. You requested up to 32000 tokens, but can only afford 16." and exited 0 — the
138
+ // runner read exit 0 + bytes as success and posted "✅ done" over a turn where nothing ran.
139
+ export const BILLING_RE = /requires more credits|can only afford|http 402|insufficient balance|payment required/i;
140
+
141
+ /** #9724: a billing rejection on a clean exit is the provider refusing the turn. Same doctrine as
142
+ * looksLikeAuthDeath: a shipped commit (realWork, checked by the runner via git) is a live turn. */
143
+ export function looksLikeBillingDeath(ownText, realWork = false) {
144
+ if (realWork) return false;
145
+ return BILLING_RE.test(String(ownText || ""));
146
+ }
147
+
148
+ /** #9724/#8234: a usage record that RESOLVED (the #8234 lookup found the session's record) to all
149
+ * zeros means no model work happened. Null usage — the CLI prints none and keeps none — stays
150
+ * unknown and is never failure. */
151
+ export function usageSaysNoWork(usage) {
152
+ const vals = Object.values(usage || {});
153
+ return vals.length > 0 && vals.every(v => Number(v) === 0);
154
+ }
155
+
156
+ /** The verdict line for a resolved-zero-usage turn — named here so the drill pins the string. */
157
+ export function zeroUsageVerdict() {
158
+ return "classified zero-usage because the session usage record resolved to all zeros — no model work happened";
159
+ }
160
+
123
161
  /** Classify one failed turn. Returns { reason, matched } — matched is the evidence excerpt the
124
162
  * runner logs, so the next misclassification is diagnosable from the seat log alone. `cut` is the
125
163
  * runner's box/stall marker: 137 reads as the sweep's SIGKILL only under it — without the marker
@@ -151,7 +189,9 @@ export function classifyFailure(exit, errText, emptyOutput = false, emptyTurn =
151
189
  if (pr) return pr;
152
190
  // "reached your … limit" / "usage limit" catch the subscription CLIs (Claude's "You've reached
153
191
  // your Fable 5 limit"), which say nothing about quota or credits and would otherwise read as a crash.
154
- m = t.match(/quota|payment required|402|429|too many requests|rate.?limit|usage limit|exceeded your|reached your [^.\n]*limit|insufficient (?:credits?|funds)|out of (?:credits?|quota)/);
192
+ // #9724: the OpenRouter billing phrasing — "requires more credits … can only afford 16" — and
193
+ // "insufficient balance" name the same wall without any of the older tokens.
194
+ m = t.match(/quota|payment required|requires more credits|can only afford|insufficient balance|402|429|too many requests|rate.?limit|usage limit|exceeded your|reached your [^.\n]*limit|insufficient (?:credits?|funds)|out of (?:credits?|quota)/);
155
195
  if (m) return { reason: "exhausted", matched: m[0] };
156
196
  m = t.match(/unauthor|401|invalid[ _-]?api[ _-]?key|forbidden|403|token expired|expired/);
157
197
  if (m) return { reason: "auth", matched: m[0] };
package/mcp.mjs CHANGED
@@ -594,11 +594,12 @@ const projectDir = process.env.CLAUDE_PROJECT_DIR || process.cwd();
594
594
  const nonProjectReason = nonSeatReason(projectDir);
595
595
  const isHomeDirSession = !!nonProjectReason;
596
596
 
597
- // #6170: WHAT this session is, when it can know. sessionstart stamps kind "orch" on the
598
- // orchestrator pane, but that runs once — every MCP beat afterwards was kindless, and the hub's
599
- // crew exemption reads the peer row's kind, so the orchestrator kept being demoted by its own
600
- // heartbeat, then warned about as an intruder on its own project. Same test sessionstart uses.
601
- const KIND = process.env.TRANTOR_ORCH && process.env.TRANTOR_ORCH === PROJECT ? { kind: "orch" } : {};
597
+ // #6170: WHAT this session is, when it can know — sessionstart stamps kind "orch" once, but every
598
+ // MCP beat afterwards was kindless, so the hub's crew exemption read the demoted row and the
599
+ // orchestrator was warned as an intruder on its own project. Same test sessionstart uses.
600
+ // #8723 bounce-3: a crew seat stamps "agent" the same way (TRANTOR_SEAT, set by bin/crew-runner.mjs).
601
+ const KIND = process.env.TRANTOR_ORCH && process.env.TRANTOR_ORCH === PROJECT ? { kind: "orch" }
602
+ : process.env.TRANTOR_SEAT ? { kind: "agent" } : {};
602
603
 
603
604
  if (!isHomeDirSession) {
604
605
  await api("POST", "/register", { session: SESSION, project: PROJECT, status: `active in ${PROJECT}`, hookVersion: MCP_VERSION, ...KIND })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trantor",
3
- "version": "0.18.72",
3
+ "version": "0.18.73",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "trantor": "bin/cli.mjs"