trantor 0.18.70 → 0.18.71

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.70",
3
+ "version": "0.18.71",
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": {
@@ -11,6 +11,7 @@ import { loadOrCreate } from "../lib/identity.mjs";
11
11
  import { signedHeaders } from "../lib/signed-fetch.mjs";
12
12
  import { ensureEnrolled } from "../lib/enroll.mjs";
13
13
  import { redactKeys } from "../lib/redact.mjs";
14
+ import { writeTurnState } from "../lib/turnstate.mjs";
14
15
  import { applyWorktreeDeclaration, ensureSeatWorktree, provisioningLines, readWorktreeDeclaration } from "../lib/seat-worktree.mjs";
15
16
  import {
16
17
  AUTH_MARKER_RE, classifyFailure, looksLikeAuthDeath,
@@ -23,6 +24,7 @@ import {
23
24
  senderProjectOf, isLinkedProject, stateSkipReason, isMessageCardTitle, OPEN_CARD_STATUSES,
24
25
  CUT_CHAIN_PARK_MIN, cutChainEvidence, isBoundedPark,
25
26
  } from "../lib/turn-policy.mjs";
27
+ import { ocTurnUsage, dshTurnUsage, usageTotal } from "../lib/turn-usage.mjs";
26
28
  import {
27
29
  auditDutyNudges, claimDutyNudges, claudeTranscriptDir, dutyEscalations, dutyNudgeDirective,
28
30
  observedDutyNudgeIds, requeueMissingWakeMessages, shedExpiredHubAlerts,
@@ -459,6 +461,10 @@ async function parkSeat(reason, undelivered, resetHint = 0, { evidence = "" } =
459
461
  // the operator can hear out of band, once per park.
460
462
  notifyOperator(`Trantor: ${SESSION} PARKED (${reason})`,
461
463
  `${undelivered} message(s) held${when ? ` — retrying after ${when}` : ` — needs \`trantor up ${AGENT}\``}`);
464
+ // #7749: parked is a first-class phase — the file and the peer row both say so, so an
465
+ // orchestrator stops confusing a quota-held seat with a working or a dead one.
466
+ writeTurnState(AGENT, PROJ, { phase: "parked", since: Date.now() });
467
+ await registerStatus(`parked (${reason})`, { phase: "parked", phaseSince: Date.now() });
462
468
  return until;
463
469
  }
464
470
 
@@ -515,9 +521,10 @@ async function balanceRows() {
515
521
  // ---- activity truth (#5965): the RUNNER is the source for this seat ----------------
516
522
  // herdr cannot see a runner-driven CLI mid-turn, so the runner reports turn boundaries to the hub:
517
523
  // `working · <trigger>` at start, `idle` on a clean landing. Bounded 5s, one call per transition.
518
- async function registerStatus(status) {
524
+ // #7749: the live phase + its start ride the same call, so /peers answers what the seat does NOW.
525
+ async function registerStatus(status, extra = {}) {
519
526
  const url = HUB + "/register";
520
- const body = JSON.stringify({ session: SESSION, project: PROJ, status, llm: AGENT, model: MODEL });
527
+ const body = JSON.stringify({ session: SESSION, project: PROJ, status, llm: AGENT, model: MODEL, ...extra });
521
528
  try {
522
529
  const opts = { method: "POST", headers: { "content-type": "application/json", connection: "close" }, body };
523
530
  await fetch(url, { ...opts, headers: { ...opts.headers, ...signedHeaders(identity, url, opts) }, signal: AbortSignal.timeout(5000) });
@@ -703,7 +710,10 @@ async function runTurn(prompt, isFirst, trigger = "kickoff", opts = {}) {
703
710
  // #5965 — TURN START. The hub peer row is where the app reads activity from, and the runner is
704
711
  // the only one who knows a turn is starting, so say so before the CLI spawn (awaited: the spawn
705
712
  // below blocks the loop, an unawaited fetch would not leave the machine until the turn ended).
706
- await registerStatus(`working · ${trigger}`);
713
+ await registerStatus(`working · ${trigger}`, { phase: "working", phaseSince: t0 });
714
+ // #7749: the same boundary on disk — the watchdog refreshes lastBytesAt/lastTranscriptAt while
715
+ // the turn runs, so a live reader sees the turn advance without waiting for its end.
716
+ writeTurnState(AGENT, PROJ, { turn: TURN, phase: "working", since: t0, card: sessionCard || 0, lastBytesAt: t0, lastTranscriptAt: 0 });
707
717
  const pf = join(homedir(), ".agent-bus", `turn-${AGENT}-${PROJ}.txt`);
708
718
  appendFileSync(pf, "", { flag: "w" }); // truncate
709
719
  appendFileSync(pf, prompt);
@@ -786,7 +796,9 @@ async function runTurn(prompt, isFirst, trigger = "kickoff", opts = {}) {
786
796
  const wd = spawn(process.execPath, [join(import.meta.dirname, "turn-watchdog.mjs"), STAMPF, ERRF, String(WD_MS), SESSION, PROJ, HUB, TRANSCRIPT_DIR, TURN_DIR,
787
797
  // #7752: the stall marker only exists when a box does — with no box there is no sweep to
788
798
  // end the turn, so the watchdog stays report-only (reporting is the whole job, boxless).
789
- TURN_MAX_MS ? STALLF : ""],
799
+ TURN_MAX_MS ? STALLF : "",
800
+ // #7749: the turn-state file the watchdog refreshes mid-turn (lastBytesAt/lastTranscriptAt).
801
+ join(homedir(), ".agent-bus", `turnstate-${AGENT}-${PROJ}.json`)],
790
802
  { detached: true, stdio: "ignore" });
791
803
  WD_CHILD = wd;
792
804
  wd.unref();
@@ -875,6 +887,12 @@ exit $turn_exit`;
875
887
  }
876
888
  lastTurnCut = cut;
877
889
  lastTurnStalled = cut && stallCut;
890
+ // #7749: the cut boundary lands on disk and on the peer row BEFORE the follow-up turn overwrites
891
+ // it — a reader sampling mid-chain sees what actually just happened, not a stale "working".
892
+ if (cut) {
893
+ writeTurnState(AGENT, PROJ, { phase: stallCut ? "stalled" : "cut", since: Date.now() });
894
+ await registerStatus(stallCut ? "stalled · no activity" : "cut at the time box", { phase: stallCut ? "stalled" : "cut", phaseSince: Date.now() });
895
+ }
878
896
  // #5869: scrub AT REST, synchronously, before anything reads the file back. The explicit shell
879
897
  // wait above drains the live stderr scrubber first; this pass is defense in depth for redaction.
880
898
  try { writeFileSync(ERRF, redactKeys(readFileSync(ERRF, "utf8"))); } catch {}
@@ -940,6 +958,13 @@ exit $turn_exit`;
940
958
  // actually carried counts.
941
959
  if (c) tokens = c.input + c.output + c.cache_read + c.cache_creation;
942
960
  }
961
+ // #8234: the CLIs that print no usage on stdout keep the counts in their own session records —
962
+ // opencode's db, the session resolved for this turn exactly like the `-s` resume id. Sum this
963
+ // turn's window there; null (unknown) leaves the stdout paths above standing — never 0 ("free").
964
+ const usage = cli.pinned && sid ? ocTurnUsage(OC_DB, sid, t0, Date.now())
965
+ : AGENT === "dsh" ? dshTurnUsage(join(homedir(), ".dsh", "sessions"), TURN_DIR, t0, Date.now())
966
+ : null;
967
+ if (usage) tokens = usageTotal(usage);
943
968
  // #6289: every ledger row names in ONE field what happened to the turn — cut, stalled (#7752),
944
969
  // api-error, completed — and what it cost (0 means "not reported", never "free"). #7762: `card`
945
970
  // binds the row to the card the turn worked (0 = kickoff/pulse) so the seat record attributes
@@ -952,6 +977,7 @@ exit $turn_exit`;
952
977
  try { finalOutcome = (await opts.judgeOutcome()) || outcome; } catch {}
953
978
  }
954
979
  const telemetryRow = { ts: Date.now(), agent: AGENT, project: PROJ, turn: TURN, trigger, card: sessionCard || 0, model: MODEL || "cli-default", duration_ms: Date.now() - t0, exit: realExit, effExit, authFailed: effExit !== realExit, emptyOutput: lastEmptyOutput, emptyTurn: lastEmptyTurn, verdict, outcome: finalOutcome, tokens };
980
+ if (usage) telemetryRow.usage = usage;
955
981
  if (cut) telemetryRow.cut = true;
956
982
  if (stallCut) telemetryRow.stalled = true;
957
983
  if (extensions) { telemetryRow.extensions = extensions; telemetryRow.boxMs = boxMs; }
@@ -967,7 +993,10 @@ exit $turn_exit`;
967
993
  if (realExit === 0 && effExit === 0) { cmuxStatus("idle", "#8a94a6", "robot"); herdrAgent("idle"); } // finished this turn, waiting for the next
968
994
  // #5965 — TURN END. A clean exit means the seat is idle again; say so right away so the app stops
969
995
  // pulsing it even before the next /poll heartbeat. Failure keeps reportFailure's down/errored.
970
- if (realExit === 0 && effExit === 0) await registerStatus("idle");
996
+ if (realExit === 0 && effExit === 0) {
997
+ writeTurnState(AGENT, PROJ, { phase: "idle", since: Date.now() });
998
+ await registerStatus("idle", { phase: "idle", phaseSince: Date.now() });
999
+ }
971
1000
  // The follow-up rides the SAME session, exactly once. A state step gets none: TIME_BOX_PROMPT
972
1001
  // would break the byte-identical prefix, and §4.4 says a cut turn never partially applied a patch.
973
1002
  if (cut && !inFollowUp && !opts.state) {
@@ -1055,24 +1084,10 @@ async function loadLessons() {
1055
1084
  // #5683: every prompt section is capped (bin/crew-payload.mjs) and the payload has ONE hard total
1056
1085
  // cap; below the caps the composition is byte-identical to the old concatenation.
1057
1086
  function composedTurn({ base = "", wakeText = "", ctxText = "", againText = "", tailText = "", rulesText = "", lessons = null }) {
1058
- // STABLE FIRST, VOLATILE LAST — this order is a COST decision, not a stylistic one (#8199).
1059
- // Every provider we send this to caches on an exact prefix match from token 0: DeepSeek's docs say
1060
- // "only requests with identical prefixes (starting from the 0th token) will be considered
1061
- // duplicates, and partial matches in the middle of the input will not trigger a cache hit", and a
1062
- // hit bills at roughly a tenth of a miss. This array used to open with `base` and then the
1063
- // per-turn WAKE, with the big unchanging blocks (RULES, lessons) buried at the end. On an ordinary
1064
- // turn `base` is empty — the sha rides inside `again` — so token 0 was the wake text itself, which
1065
- // is different every turn by definition. No cache could ever hit, and ~4,800 tokens of identical preamble were re-bought at full rate per turn,
1066
- // on every foreign-CLI seat: dsh, codex, kimi and every opencode/BYOM seat. The state path had
1067
- // known this for weeks — lib/state/assemble.mjs: "the bytes before STATE_DELIM are the caller's
1068
- // preamble and NOTHING else, or prefix caching never engages" — and it was never carried across.
1069
- //
1070
- // Reading order improves with it rather than suffering: rules and lessons are the standing brief,
1071
- // then context and history, and the ASK lands last, which is what #7063 found a work order wants.
1072
- //
1073
- // The `order` field is UNRELATED to this and deliberately unchanged: it ranks what gets trimmed
1074
- // when the payload busts its total cap, and wake is trimmed last there because losing the
1075
- // instruction is worse than losing the cache on that one turn.
1087
+ // STABLE FIRST, VOLATILE LAST — a COST decision, not style (#8199): providers cache on an exact
1088
+ // prefix from token 0, and the old order opened with the per-turn wake, so no cache could ever
1089
+ // hit and ~4,800 identical preamble tokens re-bought at full rate per turn on every seat. The
1090
+ // `order` field is the unrelated trim ranking under the total cap (#5683).
1076
1091
  const built = composePrompt([
1077
1092
  { name: "rules", text: rulesText, trim: "drop", order: 3 },
1078
1093
  { name: "lessons", text: lessons?.text || "", trim: "drop", order: 2 },
package/bin/seat-why.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  // trantor seat-why <agent> [--json] — explain why a crew seat is (not) working, straight from
3
3
  // local ~/.agent-bus evidence. No hub needed; works even when the whole fleet is down.
4
- import { seatWhy, fmtSpend } from "../lib/seat-why.mjs";
4
+ import { seatWhy, fmtSpend, dur } from "../lib/seat-why.mjs";
5
5
  import { resolveProject } from "../lib/project.mjs";
6
6
 
7
7
  const args = process.argv.slice(2);
@@ -22,6 +22,12 @@ if (asJson) {
22
22
  console.log(JSON.stringify({ agent, project, ...out }, null, 2));
23
23
  } else {
24
24
  console.log(`seat ${agent}:${project} -> ${out.state}`);
25
+ // #7749: the live phase line — what the seat is doing NOW and for how long, from the
26
+ // turn-state file, not inferred from the last ledger row.
27
+ if (out.turnState?.phase) {
28
+ const t = out.turnState;
29
+ console.log(`turn: ${t.phase} for ${dur(Date.now() - t.since)} (turn ${t.turn}${t.card ? `, card #${t.card}` : ""})`);
30
+ }
25
31
  console.log(`why: ${out.why}`);
26
32
  console.log(`today: ${fmtSpend(out.today)}`);
27
33
  console.log(`advice: ${out.advice}`);
package/bin/seats.mjs CHANGED
@@ -1,21 +1,14 @@
1
1
  #!/usr/bin/env node
2
2
  // trantor seats — declare which project lives in which directory, see which ones are missing, and
3
- // put them back. The answer to "a reboot reopened every window in $HOME and un-seated the crew".
4
- //
5
- // trantor seats status of every declared seat
6
- // trantor seats add <project> [dir] declare one (dir defaults to the cwd)
7
- // trantor seats remove <project> undeclare one
8
- // trantor seats adopt [workspace] declare every pinned project found under a workspace root
9
- // trantor seats up [project…] open a window for each MISSING seat, in its own directory
10
- // trantor seats login install bring missing seats back automatically after a reboot
11
- // trantor seats login uninstall|status
3
+ // put them back after a reboot that reopened every window in $HOME. Run with no arguments for the
4
+ // full command list.
12
5
  import { existsSync, writeFileSync, unlinkSync } from "node:fs";
13
6
  import { join, resolve } from "node:path";
14
7
  import { homedir } from "node:os";
15
8
  import { execFileSync } from "node:child_process";
16
9
  import {
17
- readSeats, declareSeat, undeclareSeat, seatStatus, missingSeats,
18
- launchSeat, suggestSeats, projectForDir,
10
+ readSeats, declareSeat, undeclareSeat, seatStatus,
11
+ launchSeat, suggestSeats, projectForDir, waitForHerdr,
19
12
  } from "../lib/seats.mjs";
20
13
 
21
14
  const args = process.argv.slice(2);
@@ -38,19 +31,24 @@ function printStatus() {
38
31
  const w = Math.max(...rows.map(r => r.project.length), 7);
39
32
  console.log(`${D}seat${" ".repeat(Math.max(0, w - 4))} status directory${R}`);
40
33
  for (const r of rows) {
41
- const status = r.live ? `${G}live${R} ${D}(${r.agent} ${r.pid})${R}`
34
+ const status = r.live ? `${G}live${R} ${D}(${r.agent} ${r.pid !== null ? r.pid : `pane ${r.pane}`})${R}`
35
+ : r.state === "unknown" ? `${O}UNKNOWN${R}`
42
36
  : r.exists ? `${O}MISSING${R}` : `${O}NO DIR${R}`;
43
37
  // (why is printed below for anything not live)
44
38
  const pad = " ".repeat(Math.max(0, w - r.project.length));
45
- console.log(`${r.project}${pad} ${status}${" ".repeat(Math.max(1, 20 - (r.live ? 4 + String(r.pid).length + r.agent.length + 4 : 7)))}${D}${r.dir}${R}`);
39
+ console.log(`${r.project}${pad} ${status}${" ".repeat(Math.max(1, 20 - (r.live ? 4 + String(r.pid !== null ? r.pid : `pane ${r.pane}`).length + r.agent.length + 4 : 7)))}${D}${r.dir}${R}`);
46
40
  if (!r.live && r.why) console.log(`${" ".repeat(w + 2)}${D}${r.why}${R}`);
47
41
  if (r.via !== "pin") console.log(`${" ".repeat(w + 2)}${O}⚠ hub not pinned${R} ${D}— resolves to ${r.hub} via ${r.via}; pin it: trantor hub set ${r.project} <url>${R}`);
48
42
  }
49
- const miss = rows.filter(r => !r.live && r.exists);
43
+ const miss = rows.filter(r => r.state === "missing" && r.exists);
44
+ const unk = rows.filter(r => r.state === "unknown");
50
45
  console.log("");
51
46
  if (miss.length) {
52
47
  console.log(`${O}${miss.length} seat(s) not running${R}: ${miss.map(m => m.project).join(", ")}`);
53
48
  console.log(`Bring them back: ${O}trantor seats up${R}`);
49
+ } else if (unk.length) {
50
+ console.log(`${O}${unk.length} seat(s) in an unknown state${R}: ${unk.map(u => u.project).join(", ")}`);
51
+ console.log(`${D}Unknown is not missing — nothing is launched for them until the state is provable.${R}`);
54
52
  } else {
55
53
  console.log(`${G}every declared seat is live${R}`);
56
54
  }
@@ -63,9 +61,8 @@ function trantorBin() {
63
61
  }
64
62
 
65
63
  // One-shot login job. Deliberately NOT KeepAlive: a KeepAlive job whose command fails relaunches
66
- // every ThrottleInterval forever, which is precisely how this machine ended up at load 490 on
67
- // 2026-08-21 (four portless services rebuilding every 10s). It runs once, after a delay that lets
68
- // the desktop settle, and exits.
64
+ // every ThrottleInterval forever (it once drove this machine to load 490, four portless services
65
+ // rebuilding every 10s). It runs once, after a delay that lets the desktop settle, and exits.
69
66
  function plistBody(delay) {
70
67
  const bin = trantorBin();
71
68
  return `<?xml version="1.0" encoding="UTF-8"?>
@@ -129,28 +126,37 @@ switch (sub) {
129
126
 
130
127
  case "up": {
131
128
  const only = rest;
132
- let miss = missingSeats();
133
- if (only.length) miss = miss.filter(m => only.includes(m.project));
134
129
  const login = flag("login");
130
+ // #8716: the login job races herdr's pane restore — a seat judged missing before its pane is
131
+ // back gets launched as a second Terminal window. Wait bounded, then never launch on UNKNOWN.
132
+ const herdr = login ? await waitForHerdr() : undefined;
133
+ if (herdr && !herdr.ok) {
134
+ console.log(`[${new Date().toISOString()}] herdr is installed but did not answer within 120s — its panes may still be restoring, so no seat can be judged missing; launching nothing`);
135
+ break;
136
+ }
137
+ const rows = seatStatus(undefined, herdr).filter(r => !only.length || only.includes(r.project));
138
+ const unknowns = rows.filter(r => r.state === "unknown");
139
+ const miss = rows.filter(r => r.state === "missing" && r.exists);
140
+ const when = login ? `[${new Date().toISOString()}] ` : "";
141
+ for (const u of unknowns) console.log(`${when}${O}unknown, not launching${R}: ${u.project} — ${u.why}`);
135
142
  if (!miss.length) {
136
- if (!login) console.log(`${G}nothing to do — every declared seat is live${R}`);
137
- else console.log(`[${new Date().toISOString()}] all declared seats live; nothing launched`);
143
+ if (!login) console.log(`${G}nothing to do — every declared seat is live or unknown${R}`);
144
+ else console.log(`${when}nothing launched${unknowns.length ? ` — ${unknowns.length} seat(s) unknown, see above` : "; every declared seat is live"}`);
138
145
  break;
139
146
  }
140
- // A guard rail, not a policy: launching a dozen agent windows at once is never what anyone
141
- // meant, and at login it would be actively hostile.
147
+ // A guard rail, not a policy: restoring a dozen seats at once is never what anyone meant, and
148
+ // at login it would be actively hostile.
142
149
  const CAP = 6;
143
150
  if (miss.length > CAP && !flag("force")) {
144
- console.error(`${O}${miss.length} seats are missing — refusing to open that many windows at once.${R}`);
151
+ console.error(`${O}${miss.length} seats are missing — refusing to restore that many at once.${R}`);
145
152
  console.error(`Name the ones you want (trantor seats up <project>…), or pass --force.`);
146
153
  process.exit(1);
147
154
  }
148
155
  for (const m of miss) {
149
156
  const r = launchSeat(m, { dryRun: flag("dry-run") });
150
- const when = login ? `[${new Date().toISOString()}] ` : "";
151
- if (r.launched) console.log(`${when}${G}opened${R} ${m.project} — ${m.dir}`);
157
+ if (r.launched) console.log(`${when}${G}restored${R} ${m.project} — ${m.dir}`);
152
158
  else if (flag("dry-run")) console.log(`${when}would run: ${r.command}`);
153
- else console.log(`${when}${O}could not open a window${R} for ${m.project}; run it yourself: ${r.command}${r.error ? ` (${r.error})` : ""}`);
159
+ else console.log(`${when}${O}not launched${R} for ${m.project} — run it yourself: ${r.command}${r.error ? ` (${r.error})` : ""}`);
154
160
  }
155
161
  break;
156
162
  }
@@ -162,11 +168,10 @@ switch (sub) {
162
168
  const delay = Number(rest[1] || 60);
163
169
  writeFileSync(PLIST, plistBody(Number.isFinite(delay) && delay >= 0 ? delay : 60));
164
170
  try { execFileSync("/bin/launchctl", ["bootout", `gui/${process.getuid()}/${LABEL}`], { stdio: "ignore" }); } catch {}
165
- // Deliberately NOT bootstrapped here. RunAtLoad fires on bootstrap, not only at login, so
166
- // `seats login install` would immediately open a window for every missing seat — which is
167
- // never what installing a RECOVERY job means, and on 2026-08-23 it very nearly reopened two
168
- // sessions the operator was deliberately holding closed. The plist on disk is the install;
169
- // launchd loads it at the next login. --now is the explicit opt-in for right this second.
171
+ // Deliberately NOT bootstrapped here: RunAtLoad fires on bootstrap, and installing a RECOVERY
172
+ // job must never immediately reopen the very sessions the operator is holding closed (it once
173
+ // very nearly did). The plist on disk is the install; launchd loads it at the next login.
174
+ // --now is the explicit opt-in for right this second.
170
175
  if (flag("now")) {
171
176
  try {
172
177
  execFileSync("/bin/launchctl", ["bootstrap", `gui/${process.getuid()}`, PLIST], { stdio: "ignore" });
@@ -204,12 +209,13 @@ switch (sub) {
204
209
  add <project> [dir] declare a seat (dir defaults to cwd; --agent=claude|codex|opencode)
205
210
  remove <project> undeclare a seat
206
211
  adopt [workspace] --yes declare every pinned project that has a directory there
207
- up [project…] open a window for each missing seat, in its own directory
212
+ up [project…] restore each missing seat as a herdr pane, in its own directory
208
213
  (--dry-run to see the commands, --force past the 6-seat cap)
209
214
  login install [delay] reopen missing seats automatically after a reboot (--now to start it immediately)
210
215
  login uninstall|status
211
216
 
212
217
  Seats live in ~/.agent-bus/config.json alongside the hub pins. A seat is "live" when a real agent
213
- process is standing in its directory — not when something on the hub claims that name.`);
218
+ process (or its herdr pane) is standing in its directory — not when something on the hub claims
219
+ that name. A seat whose holder cannot be proven absent is UNKNOWN: recovery never launches into it.`);
214
220
  process.exit(readSeats() ? 0 : 0);
215
221
  }
@@ -2,13 +2,14 @@
2
2
  // Turn watchdog (#5684, #6206, #7752, #7761): runTurn is spawnSync, so this DETACHED helper watches
3
3
  // the turn — liveness = transcript, worktree or stderr moving; a whole silent window earns ONE stall
4
4
  // report, and in kill mode (stall file given) also ends the turn via the shell box. Stamp-bound to one runner.
5
- // node bin/turn-watchdog.mjs <stampFile> <errFile> <windowMs> <session> <project> <hubUrl> <transcriptDir> <workDir> [stallFile]
5
+ // node bin/turn-watchdog.mjs <stampFile> <errFile> <windowMs> <session> <project> <hubUrl> <transcriptDir> <workDir> [stallFile] [turnStateFile]
6
6
  import { readFileSync, writeFileSync, appendFileSync, statSync, readdirSync } from "node:fs";
7
7
  import { join } from "node:path";
8
8
  import { hostId } from "../lib/project.mjs";
9
9
  import { signedPost } from "../hooks/lib/api.mjs";
10
+ import { refreshTurnLiveness } from "../lib/turnstate.mjs";
10
11
 
11
- const [stampFile, errFile, windowMsRaw, session, project, hub, transcriptDir = "", workDir = "", stallFile = ""] = process.argv.slice(2);
12
+ const [stampFile, errFile, windowMsRaw, session, project, hub, transcriptDir = "", workDir = "", stallFile = "", turnStateFile = ""] = process.argv.slice(2);
12
13
  // SAFETY: the 10-minute floor lives in crew-runner.mjs (the default when TRANTOR_TURN_WATCHDOG_MS
13
14
  // is unset); this fallback only covers a missing argument. Drills pass tiny windows on purpose.
14
15
  const windowMs = Number(windowMsRaw) || 10 * 60 * 1000;
@@ -61,6 +62,21 @@ let baseErr = errSize();
61
62
  const armedAt = armed.startedAt || Date.now();
62
63
  const SLACK = 2000; // timestamp granularity + scheduler drift under load
63
64
 
65
+ // #7749: mid-turn liveness lands in the seat's turn-state file — any new stderr byte moves
66
+ // lastBytesAt, a newer transcript file moves lastTranscriptAt. The refresh never writes phase
67
+ // (the runner owns transitions) and refuses a file whose turn or phase has moved on.
68
+ let prevSize = baseErr;
69
+ let prevTr = 0;
70
+ function reportLiveness(now, tr) {
71
+ if (!turnStateFile) return;
72
+ const size = errSize();
73
+ const grew = size > prevSize;
74
+ prevSize = size;
75
+ const trGrew = tr > prevTr;
76
+ if (trGrew) prevTr = tr;
77
+ if (grew || trGrew) refreshTurnLiveness(turnStateFile, { turn: armed.turn, bytesAt: grew ? now : 0, transcriptAt: trGrew ? tr : 0 });
78
+ }
79
+
64
80
  // #7752 kill mode: poll liveness; a whole window silent on EVERY channel writes the stall marker
65
81
  // (the shell box sweeps on it), reports once, exits; stderr is measured ROLLING (bytes this window).
66
82
  // #7761: `armed.box` (step, ceiling, assigners) exists only when a ceiling above the box does; a
@@ -95,6 +111,7 @@ if (stallFile) {
95
111
  const freshCut = Math.max(armedAt, now - windowMs - SLACK);
96
112
  const tr = transcriptDir ? newestMtime(transcriptDir) : 0;
97
113
  const wk = workDir ? newestMtime(workDir) : 0;
114
+ reportLiveness(now, tr);
98
115
  if (tr > freshCut || wk > freshCut || now - lastErrAt < windowMs) { // producing work: alive
99
116
  if (box && extensions < Number(box.extensionsMax) && now + poll + SLACK >= deadline) {
100
117
  extensions++;
@@ -129,6 +146,7 @@ for (;;) {
129
146
  const freshCut = Math.max(armedAt, Date.now() - windowMs - SLACK);
130
147
  const tr = transcriptDir ? newestMtime(transcriptDir) : 0;
131
148
  const wk = workDir ? newestMtime(workDir) : 0;
149
+ reportLiveness(Date.now(), tr);
132
150
  if (errSize() > baseErr + 200 || tr > freshCut || wk > freshCut) continue; // producing work: alive, re-arm
133
151
  const mins = Math.round((Date.now() - armedAt) / 60000);
134
152
  const orch = `${hostId()}:${project}`;
@@ -26,6 +26,9 @@ export async function routeAdmin({ req, res, q, P, auth, ctx }) {
26
26
  // #6148: WHAT a session is rides its peer row (kind "genesis" = the CLI's brief-poster,
27
27
  // "agent" = a crew seat) — /peers hands it to the app so the seat strip can tell them apart.
28
28
  if (pr) { if (b.model) pr.model = String(b.model).slice(0, 80); if (b.llm) pr.llm = String(b.llm).slice(0, 40); if (b.kind) pr.kind = String(b.kind).slice(0, 40); }
29
+ // #7749: the runner's live turn phase (working/idle/cut/stalled/parked) + its start ride the
30
+ // presence row, so /peers answers "what is the seat doing now" without inferring it from durations.
31
+ if (pr && b.phase) { pr.phase = String(b.phase).slice(0, 20); pr.phaseSince = Number(b.phaseSince) || now(); }
29
32
  return json(res, 200, { ok: true, session: b.session, peers: Object.keys(state.peers) });
30
33
  }
31
34
  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 }); }
@@ -316,7 +319,7 @@ export async function routeAdmin({ req, res, q, P, auth, ctx }) {
316
319
  prunePeers();
317
320
  const cutoff = now() - ONLINE_MS;
318
321
  const peerRows = filterDiscoverable(auth, Object.entries(state.peers), ([, v]) => v.project || "");
319
- 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 || "",
322
+ 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,
320
323
  pubkey: v.pubkey || "", identity: v.identity || null, authWarning: v.authWarning || "",
321
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) })) });
322
325
  }
package/lib/seat-why.mjs CHANGED
@@ -5,6 +5,7 @@ import { join } from "node:path";
5
5
  import { homedir } from "node:os";
6
6
  import { execSync } from "node:child_process";
7
7
  import { asNumber, asRecord } from "./decode.mjs";
8
+ import { readTurnState } from "./turnstate.mjs";
8
9
 
9
10
  const busDir = () => process.env.AGENT_BUS_DIR || join(homedir(), ".agent-bus");
10
11
 
@@ -73,6 +74,29 @@ const rel = (ts) => {
73
74
  if (s < 3600) return `${Math.round(s / 60)}m ago`;
74
75
  return `${Math.round(s / 3600)}h ago`;
75
76
  };
77
+ // A duration ("working for 4m"), the counterpart to rel's "4m ago".
78
+ const dur = (ms) => {
79
+ const s = Math.max(0, Math.round(ms / 1000));
80
+ if (s < 60) return `${s}s`;
81
+ if (s < 3600) return `${Math.floor(s / 60)}m`;
82
+ return `${Math.floor(s / 3600)}h${Math.floor((s % 3600) / 60)}m`;
83
+ };
84
+
85
+ // #7749: when the turn-state file exists the advice IS the phase — "watch it or swap" is what you
86
+ // say when a turn cannot be seen; now it can. Returns null without a phase so the caller falls
87
+ // back to the last-ledger-row reading.
88
+ function phaseAdvice(t) {
89
+ if (!t?.phase || !t.since) return null;
90
+ const on = t.card ? ` on #${t.card}` : "";
91
+ switch (t.phase) {
92
+ case "working": return `working for ${dur(Date.now() - t.since)}${on} — the turn is live; nothing to do.`;
93
+ case "idle": return `idle since ${fmt(t.since)} — waiting for the next message; nothing to do.`;
94
+ case "cut": return `cut at the time box at ${fmt(t.since)}${on} — the runner continues in a follow-up turn; swap only if the cuts repeat.`;
95
+ case "stalled": return `stalled since ${fmt(t.since)}${on} — the watchdog cut a silent turn; watch the pane if the next turn stalls too.`;
96
+ case "parked": return `parked since ${fmt(t.since)} — it holds its queue until the reset time or \`trantor up\`; the park reason is on the bus.`;
97
+ default: return null;
98
+ }
99
+ }
76
100
 
77
101
  // What this seat has SPENT today (#6134). The turn count is the number that mattered on 09-02:
78
102
  // 151 turns and ~16 agentic hours across the fleet, most of it redelivery and coordination noise.
@@ -104,6 +128,8 @@ export function seatWhy(project, agent, opts = {}) {
104
128
  const dir = opts.dir || busDir();
105
129
  const errText = readF(join(dir, `err-${agent}-${project}.txt`));
106
130
  const telemetry = readTelemetry(join(dir, "logs", `${agent}-${project}.jsonl`));
131
+ // #7749: the runner's LIVE turn state — written at every transition, refreshed mid-turn.
132
+ const turnState = readTurnState(agent, project, dir);
107
133
  const pane = findPane(dir, project, agent);
108
134
  const pids = opts.pidCheck ? opts.pidCheck(project, agent) : scanPids(project, agent);
109
135
  const last = [...telemetry].reverse().find(r => asNumber(r.turn) !== null) || null;
@@ -132,16 +158,24 @@ export function seatWhy(project, agent, opts = {}) {
132
158
  why = `crew-runner pid ${pidList} alive for ${agent}:${project}, but crew-windows.txt has no pane row — the seat is headless.`;
133
159
  advice = "give the seat a window it can be read in: `trantor up <agent>` (or attach via herdr/cmux).";
134
160
  } else {
135
- const health = last
136
- ? (last.effExit || last.exit)
137
- ? `last turn ${rel(last.ts)} (${last.trigger}) FAILED (exit ${last.effExit ?? last.exit}${last.authFailed ? ", auth" : ""})`
138
- : `last turn ${rel(last.ts)} (${last.trigger}) clean (exit 0)`
139
- : `no completed turn yet (${boots} boot record${boots === 1 ? "" : "s"})`;
161
+ // #7749: the live phase beats the ledger row — a seat mid-turn reads "working for 4m" with
162
+ // its last-output age, not "last turn ended N minutes ago" (which a dead seat also says).
163
+ const health = turnState?.phase
164
+ ? `turn ${turnState.turn} ${turnState.phase} for ${dur(Date.now() - turnState.since)}${turnState.card ? ` (card #${turnState.card})` : ""}${turnState.phase === "working" && turnState.lastBytesAt ? `, last output ${rel(turnState.lastBytesAt)}` : ""}`
165
+ : last
166
+ ? (last.effExit || last.exit)
167
+ ? `last turn ${rel(last.ts)} (${last.trigger}) FAILED (exit ${last.effExit ?? last.exit}${last.authFailed ? ", auth" : ""})`
168
+ : `last turn ${rel(last.ts)} (${last.trigger}) clean (exit 0)`
169
+ : `no completed turn yet (${boots} boot record${boots === 1 ? "" : "s"})`;
140
170
  state = "live";
141
171
  why = `crew-runner pid ${pidList} alive, pane ${pane.source}/${pane.pane}; ${health}.`;
142
- advice = last && (last.effExit || last.exit)
143
- ? `seat is up but its last turn failed${last.authFailed ? " — auth: check the provider key" : ""}. Watch it or swap.`
144
- : "seat is healthy — nothing to do.";
172
+ // #7749: a live phase replaces the generic advice — a seat mid-turn is not "watch it or
173
+ // swap", it is "working for 4m on #7749". The ledger fallback covers a seat whose runner
174
+ // predates the turn-state file.
175
+ advice = phaseAdvice(turnState)
176
+ || (last && (last.effExit || last.exit)
177
+ ? `seat is up but its last turn failed${last.authFailed ? " — auth: check the provider key" : ""}. Watch it or swap.`
178
+ : "seat is healthy — nothing to do.");
145
179
  }
146
180
  } else {
147
181
  const bits = [];
@@ -169,7 +203,7 @@ export function seatWhy(project, agent, opts = {}) {
169
203
  }
170
204
  }
171
205
 
172
- return { state, why, advice, today: todaySpend(telemetry, opts.now) };
206
+ return { state, why, advice, today: todaySpend(telemetry, opts.now), turnState: turnState || null };
173
207
  }
174
208
 
175
- export { fmt, rel };
209
+ export { fmt, rel, dur };
package/lib/seats.mjs CHANGED
@@ -47,7 +47,8 @@ export function undeclareSeat(project) {
47
47
 
48
48
  // ── liveness ────────────────────────────────────────────────────────────────────
49
49
  // A seat is live when a real process is STANDING IN ITS DIRECTORY (ps + lsof, bounded, fail-soft),
50
- // never by the hub's presence list: a name is assertable, a cwd is not.
50
+ // never by the hub's presence list. Two blind spots read as UNKNOWN, never missing (#8716): a
51
+ // process whose cwd lsof could not read could be standing in any seat, and so could a dark herdr.
51
52
  export function liveAgentDirs(binaries = ["claude", "codex", "opencode"]) {
52
53
  const out = []; // [{ pid, comm, cwd }]
53
54
  let rows = "";
@@ -62,21 +63,58 @@ export function liveAgentDirs(binaries = ["claude", "codex", "opencode"]) {
62
63
  if (pid === self) continue;
63
64
  const base = comm.split("/").pop();
64
65
  if (!binaries.includes(base)) continue;
65
- let cwd = "";
66
+ let cwd = null;
66
67
  try {
67
68
  const fn = execFileSync("/usr/sbin/lsof", ["-a", "-p", pid, "-d", "cwd", "-Fn"],
68
69
  { encoding: "utf8", timeout: 4000, stdio: ["ignore", "pipe", "ignore"] });
69
70
  const hit = fn.split("\n").find(l => l.startsWith("n"));
70
71
  if (hit) cwd = hit.slice(1);
71
- } catch { /* process died, or lsof is unavailable — treat as unknown, never as live */ }
72
- if (cwd) out.push({ pid: Number(pid), comm: base, cwd });
72
+ } catch { /* the process died, or lsof starved: the cwd is UNKNOWN, never live, never absent */ }
73
+ out.push({ pid: Number(pid), comm: base, cwd });
73
74
  }
74
75
  return out;
75
76
  }
76
77
 
77
- // Status of every declared seat: live (with the pid holding it) or missing (with why).
78
- // `live` is injectable so a drill can pin the mapping without spawning processes named "claude".
79
- export function seatStatus(live = liveAgentDirs()) {
78
+ // herdr restores its panes at boot, before any Terminal window could exist, so a herdr agent
79
+ // standing in the seat's directory holds the seat exactly as a bare process would (#8716).
80
+ // Installed but unreachable reads { installed: true, ok: false } — uncertainty, not absence; not
81
+ // installed is SKIP: the probe holds nothing and blinds nothing.
82
+ export function herdrAgents(exec = execFileSync) {
83
+ let bin = "";
84
+ try { bin = exec("/usr/bin/which", ["herdr"], { encoding: "utf8", timeout: 4000 }).trim(); } catch { return { installed: false, ok: true, agents: [] }; }
85
+ if (!bin) return { installed: false, ok: true, agents: [] };
86
+ try {
87
+ const parsed = JSON.parse(exec(bin, ["agent", "list"], { encoding: "utf8", timeout: 4000, stdio: ["ignore", "pipe", "ignore"] }));
88
+ const rows = asRecord(parsed?.result)?.agents;
89
+ if (!Array.isArray(rows)) return { installed: true, ok: false, agents: [] };
90
+ const agents = rows.map(a => ({
91
+ agent: asString(asRecord(a)?.agent) ?? "",
92
+ cwd: asString(asRecord(a)?.cwd) ?? "",
93
+ pane: asString(asRecord(a)?.pane_id) ?? "",
94
+ status: asString(asRecord(a)?.agent_status) ?? "",
95
+ })).filter(a => a.agent && a.cwd);
96
+ return { installed: true, ok: true, agents };
97
+ } catch { return { installed: true, ok: false, agents: [] }; }
98
+ }
99
+
100
+ // The login job races herdr's restore: panes land over the first seconds after boot, and a seat
101
+ // judged missing before its pane returns is launched twice. Bounded: herdr that never answers
102
+ // inside the window reads { installed: true, ok: false }, and the caller launches nothing (#8716).
103
+ export async function waitForHerdr({ timeoutMs = 120000, everyMs = 5000, probe = herdrAgents } = {}) {
104
+ const deadline = Date.now() + timeoutMs;
105
+ let h = probe();
106
+ while (h.installed && !h.ok && Date.now() < deadline) {
107
+ await new Promise(r => setTimeout(r, everyMs));
108
+ h = probe();
109
+ }
110
+ return h;
111
+ }
112
+
113
+ // Status of every declared seat: live (held by a process or a herdr pane standing in its
114
+ // directory), unknown (a blind spot could be hiding the holder — launched over by nothing), or
115
+ // missing (with why). `live` and `herdr` are injectable so a drill pins the mapping without
116
+ // spawning processes named "claude" or a real herdr.
117
+ export function seatStatus(live = liveAgentDirs(), herdr = herdrAgents()) {
80
118
  const seats = readSeats();
81
119
  return Object.entries(seats).map(([project, { dir, agent }]) => {
82
120
  // The seat is held only by the agent it was DECLARED for. A crew seat (opencode, codex) working
@@ -87,34 +125,67 @@ export function seatStatus(live = liveAgentDirs()) {
87
125
  const holder = inDir.find(p => p.comm === agent);
88
126
  const others = inDir.filter(p => p.comm !== agent);
89
127
  const exists = existsSync(dir);
128
+ let pane = null;
129
+ if (herdr.ok) {
130
+ for (const a of herdr.agents) {
131
+ if (a.cwd !== dir) continue;
132
+ if (a.agent === agent) { if (!holder) pane = a.pane || "herdr pane"; }
133
+ else others.push({ pid: null, comm: a.agent, cwd: dir, pane: a.pane });
134
+ }
135
+ }
136
+ // UNKNOWN (#8716): an agent process whose cwd lsof could not read could be standing in this
137
+ // directory, and a herdr that is installed but would not answer could be holding this seat.
138
+ // Either way the holder is unprovable in both directions — the seat is not live, and it is
139
+ // never missing, because launching into an occupied seat is the harm this file prevents.
140
+ const unreadable = live.filter(p => p.cwd === null && p.comm === agent);
141
+ const herdrDark = herdr.installed && !herdr.ok;
142
+ const unknown = exists && !holder && !pane && (unreadable.length > 0 || herdrDark);
143
+ const why = holder || pane ? ""
144
+ : !exists ? "directory does not exist"
145
+ : unknown ? (unreadable.length
146
+ ? `lsof could not read the cwd of ${unreadable.map(p => `${agent} ${p.pid}`).join(", ")} — it could be this seat`
147
+ : "herdr is installed but would not answer — a restored pane could be standing here")
148
+ : others.length ? `no ${agent} here (${others.map(o => o.pane ? `${o.comm} pane ${o.pane}` : `${o.comm} ${o.pid}`).join(", ")} is, but that is not this seat)`
149
+ : `no ${agent} process in this directory`;
90
150
  return {
91
151
  project, dir, exists, agent,
92
- live: !!holder,
152
+ live: !!holder || !!pane,
153
+ state: holder || pane ? "live" : unknown ? "unknown" : "missing",
93
154
  pid: holder?.pid || null,
155
+ pane,
94
156
  others,
95
157
  hub: resolveHubInfo(project).url,
96
158
  via: resolveHubInfo(project).via,
97
- why: holder ? ""
98
- : !exists ? "directory does not exist"
99
- : others.length ? `no ${agent} here (${others.map(o => `${o.comm} ${o.pid}`).join(", ")} is, but that is not this seat)`
100
- : `no ${agent} process in this directory`,
159
+ why,
101
160
  };
102
161
  }).sort((a, b) => a.project.localeCompare(b.project));
103
162
  }
104
163
 
105
- // Seats that are declared, whose directory exists, and that nothing is standing in.
106
- export function missingSeats(live) { return seatStatus(live).filter(s => !s.live && s.exists); }
164
+ // Seats a recovery may LAUNCH: declared, directory present, and provably held by nothing. An
165
+ // unknown seat is never here (#8716) — the one failure this file exists to prevent is opening a
166
+ // second window into a seat that is already held.
167
+ export function missingSeats(live, herdr) {
168
+ return seatStatus(live, herdr).filter(s => s.state === "missing" && s.exists);
169
+ }
107
170
 
108
171
  // ── recovery ────────────────────────────────────────────────────────────────────
109
- // Open a NEW terminal window in the seat's directory running the agent. macOS-only by design (the
110
- // same osascript bin/open-session.sh has always used); elsewhere we print the command instead of
111
- // pretending. Returns { launched, command }.
112
- export function launchSeat(seat, { command, dryRun = false } = {}) {
113
- const cmd = `cd ${JSON.stringify(seat.dir)} && ${command || seat.agent || "claude"}`;
114
- if (dryRun || process.platform !== "darwin") return { launched: false, command: cmd };
115
- const osa = `tell application "Terminal"\n do script ${JSON.stringify(cmd)}\nend tell\n`;
172
+ // Restore a missing seat as a herdr pane: `trantor open <project>` run in the seat's own
173
+ // directory — never a Terminal.app window, whose twin once ran for hours beside the seat it
174
+ // duplicated (#8716). Herdr absent: return the command for the caller to print, spawn nothing.
175
+ const SESSION_IDENTITY_ENV = ["TRANTOR_ORCH", "HERDR_PANE_ID", "HERDR_TAB_ID", "HERDR_WORKSPACE_ID", "CLAUDE_CODE_CHILD_SESSION", "CLAUDECODE"];
176
+ export function launchSeat(seat, { dryRun = false, spawnFn = spawn, env = process.env, which = execFileSync } = {}) {
177
+ let trantor = "trantor";
178
+ try { trantor = which("/usr/bin/which", ["trantor"], { encoding: "utf8", timeout: 4000 }).trim() || "trantor"; } catch {}
179
+ const cmd = `cd ${JSON.stringify(seat.dir)} && ${trantor} open ${seat.project}`;
180
+ let herdr = false;
181
+ try { herdr = !!which("/usr/bin/which", ["herdr"], { encoding: "utf8", timeout: 4000 }).trim(); } catch {}
182
+ if (dryRun || !herdr) return { launched: false, command: cmd };
183
+ // The recovery job may itself run inside a pane or a Claude session; its identity env must not
184
+ // leak into the restored one (#7414), and trantor open's own badge check would refuse it anyway.
185
+ const clean = { ...env };
186
+ for (const k of SESSION_IDENTITY_ENV) delete clean[k];
116
187
  try {
117
- const kid = spawn("/usr/bin/osascript", ["-e", osa], { detached: true, stdio: "ignore" });
188
+ const kid = spawnFn(trantor, ["open", seat.project], { cwd: seat.dir, env: clean, detached: true, stdio: "ignore" });
118
189
  kid.unref();
119
190
  return { launched: true, command: cmd };
120
191
  } catch (e) {
@@ -0,0 +1,116 @@
1
+ // #8234: seat turn rows carried tokens 0 for every CLI that prints no usage line (the opencode
2
+ // family, dsh) — a ledger that records that a turn happened, never what it cost. The counts live
3
+ // in each provider's OWN session record; read them there, never from stdout.
4
+ // Usage shape: { input, output, cacheRead, cacheWrite } — null means "could not read", never 0.
5
+ import { spawnSync } from "node:child_process";
6
+ import { readdirSync, statSync } from "node:fs";
7
+ import { join } from "node:path";
8
+
9
+ export function usageTotal(u) {
10
+ if (!u) return 0;
11
+ return (u.input || 0) + (u.output || 0) + (u.cacheRead || 0) + (u.cacheWrite || 0);
12
+ }
13
+
14
+ // Absent = the provider did not report this field (0); present but not a non-negative integer =
15
+ // a corrupt read, which poisons the whole sum to null.
16
+ const field = (v) => {
17
+ if (v === "" || v === null || v === undefined) return 0;
18
+ const n = Number(v);
19
+ return Number.isInteger(n) && n >= 0 ? n : null;
20
+ };
21
+ const blank = () => ({ input: 0, output: 0, cacheRead: 0, cacheWrite: 0 });
22
+
23
+ // ---- opencode: per-message usage in opencode.db, message.data JSON on assistant rows ----------
24
+ // The reader emits one `input|output|cacheRead|cacheWrite` row per in-window assistant message;
25
+ // this parser sums them. No rows at all = unknown, not zero.
26
+ export function sumOcRows(text) {
27
+ const rows = String(text || "").split("\n").filter((l) => l.trim() !== "");
28
+ if (!rows.length) return null;
29
+ const sum = blank();
30
+ for (const line of rows) {
31
+ const cols = line.split("|");
32
+ if (cols.length !== 4) return null;
33
+ const vals = cols.map(field);
34
+ if (vals.some((v) => v === null)) return null;
35
+ sum.input += vals[0]; sum.output += vals[1]; sum.cacheRead += vals[2]; sum.cacheWrite += vals[3];
36
+ }
37
+ return sum;
38
+ }
39
+
40
+ // The one sqlite read, ocSid's mechanics exactly: sqlite3 CLI, -readonly, fail-open. Window is
41
+ // the turn (a resumed session's older messages carry older time_created values and drop out).
42
+ export function ocTurnUsage(dbPath, sessionId, sinceMs, untilMs, run = spawnSync) {
43
+ if (!dbPath || !/^ses_[A-Za-z0-9]+$/.test(String(sessionId || ""))) return null;
44
+ if (![sinceMs, untilMs].every(Number.isFinite) || untilMs < sinceMs) return null;
45
+ const q = `SELECT json_extract(data,'$.tokens.input'), json_extract(data,'$.tokens.output'),`
46
+ + ` json_extract(data,'$.tokens.cache.read'), json_extract(data,'$.tokens.cache.write')`
47
+ + ` FROM message WHERE session_id='${String(sessionId).replaceAll("'", "''")}'`
48
+ + ` AND json_extract(data,'$.role')='assistant'`
49
+ + ` AND time_created>=${Math.floor(sinceMs)} AND time_created<=${Math.ceil(untilMs)};`;
50
+ let r;
51
+ try { r = run("sqlite3", ["-readonly", dbPath, q], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 5000 }); }
52
+ catch { return null; }
53
+ if (!r || r.error || r.status !== 0) return null;
54
+ return sumOcRows(r.stdout);
55
+ }
56
+
57
+ // ---- dsh: usage rides assistant/message events in session.jsonl.zstd ---------------------------
58
+ const USAGE_KEYS = ["inputTokens", "outputTokens", "cacheReadTokens", "cacheWriteTokens"];
59
+ // dsh logs every step's usage TWICE (assistant/chunk and assistant/message carry the same
60
+ // numbers), so only the assistant/message copy counts — the chunk copy would bill double. The
61
+ // session's own turn counter is unrelated to the runner's, so the time window is the only filter.
62
+ export function dshUsageFromEvent(ev) {
63
+ const u = ev && ev.type === "assistant/message" && ev.data ? ev.data.usage : null;
64
+ // A usage RECORD carries at least one of the four counts; anything else (null, an array, a
65
+ // bare number from a mangled log) is not one and drops out.
66
+ if (!u || !USAGE_KEYS.some((k) => Object.prototype.hasOwnProperty.call(u, k))) return null;
67
+ const g = (k) => { const n = Number(u[k]); return Number.isFinite(n) && n >= 0 ? n : 0; };
68
+ return { input: g("inputTokens"), output: g("outputTokens"), cacheRead: g("cacheReadTokens"), cacheWrite: g("cacheWriteTokens") };
69
+ }
70
+
71
+ export function sumDshLog(text, sinceMs, untilMs) {
72
+ let sum = null;
73
+ for (const line of String(text || "").split("\n")) {
74
+ const s = line.trim();
75
+ if (!s) continue;
76
+ let ev; try { ev = JSON.parse(s); } catch { continue; }
77
+ const t = Number(ev.time);
78
+ if (!Number.isFinite(t) || t < sinceMs || t > untilMs) continue;
79
+ const u = dshUsageFromEvent(ev);
80
+ if (!u) continue;
81
+ if (!sum) sum = blank();
82
+ sum.input += u.input; sum.output += u.output; sum.cacheRead += u.cacheRead; sum.cacheWrite += u.cacheWrite;
83
+ }
84
+ return sum;
85
+ }
86
+
87
+ // dsh names a session dir after the cwd it ran in, "/" spelled "-" and a trailing "/" added
88
+ // before the wrap: /a/b/dsh -> --a-b-dsh-- (observed on live ~/.dsh/sessions dirs).
89
+ export const dshSessionDirName = (cwd) => `-${String(cwd + "/").replaceAll("/", "-")}-`;
90
+
91
+ // Whether dsh resumed or started fresh is its own business, so scan every session log for this
92
+ // cwd and keep only in-window events. A log last written before the window opened can hold none.
93
+ export function dshTurnUsage(sessionsRoot, cwd, sinceMs, untilMs, run = spawnSync) {
94
+ if (!sessionsRoot || !cwd || ![sinceMs, untilMs].every(Number.isFinite) || untilMs < sinceMs) return null;
95
+ const dir = join(sessionsRoot, dshSessionDirName(cwd));
96
+ let names;
97
+ try { names = readdirSync(dir); } catch { return null; }
98
+ const sum = blank();
99
+ let found = false;
100
+ for (const name of names) {
101
+ if (!name.startsWith("session-")) continue;
102
+ const f = join(dir, name, "session.jsonl.zstd");
103
+ let mtime;
104
+ try { mtime = statSync(f).mtimeMs; } catch { continue; }
105
+ if (mtime < sinceMs) continue;
106
+ let r;
107
+ try { r = run("zstd", ["-dc", f], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 15000, maxBuffer: 64 * 1024 * 1024 }); }
108
+ catch { continue; }
109
+ if (!r || r.status !== 0) continue;
110
+ const s = sumDshLog(r.stdout, sinceMs, untilMs);
111
+ if (!s) continue;
112
+ found = true;
113
+ sum.input += s.input; sum.output += s.output; sum.cacheRead += s.cacheRead; sum.cacheWrite += s.cacheWrite;
114
+ }
115
+ return found ? sum : null;
116
+ }
@@ -0,0 +1,49 @@
1
+ // #7749: a seat's LIVE turn state, one JSON file per seat — turnstate-<agent>-<project>.json in
2
+ // the bus dir. The runner writes it at every phase transition, its watchdog refreshes liveness
3
+ // mid-turn, so seat-why and the hub presence row read a fact, not an inference from ledger rows.
4
+ // Shape: { turn, phase: idle|working|cut|stalled|parked, since, card, lastBytesAt, lastTranscriptAt }.
5
+ import { readFileSync, writeFileSync, renameSync, mkdirSync } from "node:fs";
6
+ import { dirname, join } from "node:path";
7
+ import { homedir } from "node:os";
8
+
9
+ const busDir = () => process.env.AGENT_BUS_DIR || join(homedir(), ".agent-bus");
10
+ export const turnStatePath = (agent, project, dir = busDir()) => join(dir, `turnstate-${agent}-${project}.json`);
11
+
12
+ export function readTurnStateFile(path) {
13
+ try {
14
+ const j = JSON.parse(readFileSync(path, "utf8"));
15
+ return j && j.constructor === Object ? j : null;
16
+ } catch { return null; }
17
+ }
18
+ export function readTurnState(agent, project, dir = busDir()) {
19
+ return readTurnStateFile(turnStatePath(agent, project, dir));
20
+ }
21
+
22
+ // tmp + rename: a reader mid-write sees the last whole file, never a torn one.
23
+ export function writeTurnStateFile(path, state) {
24
+ try {
25
+ mkdirSync(dirname(path), { recursive: true });
26
+ const tmp = `${path}.tmp-${process.pid}`;
27
+ writeFileSync(tmp, JSON.stringify(state));
28
+ renameSync(tmp, path);
29
+ return true;
30
+ } catch { return false; }
31
+ }
32
+
33
+ // Merge onto the last write: the runner's transition sets phase/since, everything else rides along.
34
+ export function writeTurnState(agent, project, patch, dir = busDir()) {
35
+ const path = turnStatePath(agent, project, dir);
36
+ return writeTurnStateFile(path, { ...(readTurnStateFile(path) || {}), ...patch });
37
+ }
38
+
39
+ // The watchdog's mid-turn refresh. It NEVER writes phase: transitions belong to the runner, and
40
+ // a poll landing after the turn ended must not resurrect "working" over the runner's idle —
41
+ // the turn-number + phase guard is what keeps a slow poll from lying.
42
+ export function refreshTurnLiveness(path, { turn, bytesAt = 0, transcriptAt = 0 }) {
43
+ const cur = readTurnStateFile(path);
44
+ if (!cur || cur.turn !== turn || cur.phase !== "working") return false;
45
+ const patch = {};
46
+ if (bytesAt) patch.lastBytesAt = bytesAt;
47
+ if (transcriptAt) patch.lastTranscriptAt = transcriptAt;
48
+ return Object.keys(patch).length ? writeTurnStateFile(path, { ...cur, ...patch }) : false;
49
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trantor",
3
- "version": "0.18.70",
3
+ "version": "0.18.71",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "trantor": "bin/cli.mjs"