trantor 0.18.100 → 0.18.102

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.100",
3
+ "version": "0.18.102",
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/crew/open.mjs CHANGED
@@ -60,16 +60,35 @@ function unconsumedHandoffs(ctx) {
60
60
  return out;
61
61
  }
62
62
 
63
- // The handoff written BY the recorded session `id` whose transcript has since grown past it, or null.
63
+ // A person's prompt (origin.kind "human") in a transcript, at or after `afterSecs`, or null. The app's
64
+ // own recap kickoff is not one: a resumed-by-mistake session would otherwise vouch for itself.
65
+ function humanPromptAfter(transcriptPath, afterSecs) {
66
+ let text;
67
+ try { text = readFileSync(transcriptPath, "utf8"); } catch { return null; }
68
+ for (const line of text.split("\n")) {
69
+ if (!line.includes('"human"')) continue;
70
+ let e; try { e = JSON.parse(line); } catch { continue; }
71
+ if (e?.type !== "user" || e?.origin?.kind !== "human") continue;
72
+ const at = Date.parse(e.timestamp || "") / 1000;
73
+ if (!(at >= afterSecs)) continue;
74
+ const c = e.message?.content;
75
+ const body = typeof c === "string" ? c : Array.isArray(c) ? c.map(x => x?.text || "").join("") : "";
76
+ if (body.startsWith("You have just taken over via handoff")) continue;
77
+ return at;
78
+ }
79
+ return null;
80
+ }
81
+
82
+ // The handoff written BY the recorded session `id` that a person kept talking past, or null. Machine
83
+ // traffic after the handoff (the dying turn, wake nudges, task notices) never supersedes it: 10-14 the
84
+ // mtime check resumed crebral-legal's 90%-full session over its own fresh handoff.
64
85
  export function supersededHandoff(ctx, id) {
65
86
  for (const { file, rec } of unconsumedHandoffs(ctx)) {
66
87
  if (!id || rec.session_id !== id || !(Number(rec.stamp) > 0)) continue;
67
88
  const slug = String(rec.project || ctx.dir || "").replace(/[/.]/g, "-");
68
89
  const transcript = rec.transcript_path || join(ctx.home, ".claude", "projects", slug, `${id}.jsonl`);
69
- try {
70
- const workedAt = statSync(transcript).mtimeMs / 1000;
71
- if (workedAt > Number(rec.stamp) + HANDOFF_SUPERSEDE_GRACE_SECS) return { file, handoffAt: Number(rec.stamp), workedAt };
72
- } catch {}
90
+ const workedAt = humanPromptAfter(transcript, Number(rec.stamp) + HANDOFF_SUPERSEDE_GRACE_SECS);
91
+ if (workedAt !== null) return { file, handoffAt: Number(rec.stamp), workedAt };
73
92
  }
74
93
  return null;
75
94
  }
@@ -28,7 +28,8 @@ import {
28
28
  CUT_CHAIN_PARK_MIN, cutChainEvidence, isBoundedPark, askDuringTurn, askedVerdict,
29
29
  } from "../lib/turn-policy.mjs";
30
30
  import { snapshotBeforeCut } from "./cut-snapshot.mjs";
31
- import { ocTurnUsage, dshTurnUsage, usageTotal } from "../lib/turn-usage.mjs";
31
+ import { ocTurnUsage, dshTurnUsage, usageTotal, processedInput, codexStepInput, kimiStepInput, claudeStepInput } from "../lib/turn-usage.mjs";
32
+ import { capConfig, capDecision, capHandoffPrompt, capPointerLine, capExhaustedNote } from "../lib/context-cap.mjs";
32
33
  import { ocCompactionContent, ocCompactionLogLine } from "../lib/seat-compaction.mjs";
33
34
  import { seatSignalPrefix } from "../lib/seat-signal-guard.mjs";
34
35
  import { listContainers, newContainers, recordContainers, statePathFor as dockerStatePathFor, sweep as dockerSweep } from "../lib/docker-janitor.mjs";
@@ -759,6 +760,24 @@ let cardStartedAt = releaseState?.cardStartedAt || 0;
759
760
  let budgetStop = releaseState?.budgetStop || null; // { card, tokens } once the card crossed
760
761
  let budgetRetryAt = releaseState?.budgetRetryAt || 0; // paces the paused card's OWN redeliveries
761
762
 
763
+ // #11855: card context cap (decision in lib/context-cap.mjs). codex/kimi/claude resume one session
764
+ // per card with no compaction knob; a clean turn whose last step passed the cap earns one handoff
765
+ // note, and the card's next wake starts a fresh session pointed at it. opencode and dsh are exempt.
766
+ const CAPCFG = capConfig();
767
+ let capHandoffs = releaseState?.capHandoffs || {}; // { cardId: handoffs so far }
768
+ let capLogged = releaseState?.capLogged || {}; // { cardId: true } the exhausted note was posted
769
+ let capArmed = releaseState?.capArmed || null; // { card, tokens }: that card's next wake goes fresh
770
+ let capFreshTurn = releaseState?.capFreshTurn || 0; // TURN of the last fresh session start
771
+
772
+ // Last step's processed input from the harness's own record; null = unreadable, never trips.
773
+ function capStepProcessed(sinceMs) {
774
+ const u = DRIVER === "codex" ? codexStepInput(join(homedir(), ".codex", "sessions"), sinceMs, TURN_DIR)
775
+ : DRIVER === "kimi" ? kimiStepInput(join(homedir(), ".kimi-code", "sessions"), sid, sinceMs)
776
+ : DRIVER === "claude" ? claudeStepInput(TRANSCRIPT_DIR, sinceMs)
777
+ : null;
778
+ return u ? processedInput(u) : null;
779
+ }
780
+
762
781
  // #11559: the compaction block injected to opencode-family seats via OPENCODE_CONFIG_CONTENT (""
763
782
  // injects nothing: TRANTOR_OC_COMPACTION=off or invalid JSON). opencode has no absolute-threshold
764
783
  // knob — auto-compact fires at window − reserved (opencode.ai/config.json; calculateCeiling in
@@ -785,7 +804,8 @@ async function runTurn(prompt, isFirst, trigger = "kickoff", opts = {}) {
785
804
  const t0 = Date.now();
786
805
  // A fresh session must not resume the old one's id: `first` is chosen by isFirst OR a missing
787
806
  // sid, so a stale sid would quietly resume the session this turn exists to leave behind.
788
- if (isFirst) sid = "";
807
+ // #11855: the min-steps guard counts from the last fresh start; a state step never resets it.
808
+ if (isFirst) { sid = ""; if (!opts.state) capFreshTurn = TURN; }
789
809
  // #5965 — TURN START. The hub peer row is where the app reads activity from, and the runner is
790
810
  // the only one who knows a turn is starting, so say so before the CLI spawn (awaited: the spawn
791
811
  // below blocks the loop, an unawaited fetch would not leave the machine until the turn ended).
@@ -1195,6 +1215,26 @@ exit $turn_exit`;
1195
1215
  try { return await runTurn(TIME_BOX_PROMPT, false, "time-box follow-up"); }
1196
1216
  finally { inFollowUp = false; }
1197
1217
  }
1218
+ // #11855: a clean card turn only; a cut turn belongs to the box follow-up chain, and the handoff
1219
+ // follow-up's own end must not arm another.
1220
+ if (CAPCFG.cap > 0 && !cut && !inFollowUp && !opts.state && effExit === 0 && sessionCard > 0) {
1221
+ const step = capStepProcessed(t0);
1222
+ const doneHandoffs = capHandoffs[sessionCard] || 0;
1223
+ const act = capDecision({ ...CAPCFG, step, turn: TURN, freshTurn: capFreshTurn, handoffs: doneHandoffs, logged: !!capLogged[sessionCard] });
1224
+ if (act === "log-exhausted") {
1225
+ capLogged[sessionCard] = true;
1226
+ const text = capExhaustedNote(sessionCard, CAPCFG.cap, step, doneHandoffs, CAPCFG.maxHandoffs);
1227
+ log(`\x1b[33m${text}\x1b[0m`);
1228
+ await api("/task/update", { id: sessionCard, note: text, by: SESSION }).catch(() => {});
1229
+ } else if (act === "handoff") {
1230
+ log(`\x1b[33mcontext cap: the last step processed ${step} > ${CAPCFG.cap}; #${sessionCard} hands off to a fresh session (${doneHandoffs + 1}/${CAPCFG.maxHandoffs})\x1b[0m`);
1231
+ capHandoffs[sessionCard] = doneHandoffs + 1;
1232
+ capArmed = { card: sessionCard, tokens: step };
1233
+ inFollowUp = true;
1234
+ try { await runTurn(capHandoffPrompt(sessionCard), false, "context-cap handoff"); }
1235
+ finally { inFollowUp = false; }
1236
+ }
1237
+ }
1198
1238
  return effExit;
1199
1239
  }
1200
1240
 
@@ -1492,7 +1532,8 @@ async function resolveWakeCard(messages, { session }) {
1492
1532
  wake: pendingWake, bcast: pendingBcast, seen: seenLedger, ask: awaitingAsk?.id || 0, askTo: awaitingAsk?.to || "" }));
1493
1533
  const state = { cursor, turn: TURN, sid, sessionCard, retryAt, deliveryFails,
1494
1534
  noDeliveryRetry, askReleased, lastTurnAt, attempted: [...attemptedVersions],
1495
- cardTokens, cardStartedAt, budgetStop, budgetRetryAt };
1535
+ cardTokens, cardStartedAt, budgetStop, budgetRetryAt,
1536
+ capHandoffs, capLogged, capArmed, capFreshTurn };
1496
1537
  process.execve(process.execPath, [process.execPath, ...process.execArgv, ...process.argv.slice(1)],
1497
1538
  { ...process.env, CREW_RELEASE_STATE: JSON.stringify(state) });
1498
1539
  } catch (error) { log(`runner restart failed: ${error.message}`); }
@@ -1784,7 +1825,10 @@ async function resolveWakeCard(messages, { session }) {
1784
1825
  // #10824: the LABEL is the binding, 0 included — a wake that binds nothing labels its turn 0
1785
1826
  // and never inherits the previous contract's card (#10197's judge then reads the right card).
1786
1827
  const sessionCardForTurn = card || baseCard;
1787
- const fresh = !resumedAfterAsk && sessionCardForTurn > 0 && sessionCardForTurn !== sessionCard;
1828
+ const newCard = !resumedAfterAsk && sessionCardForTurn > 0 && sessionCardForTurn !== sessionCard;
1829
+ // #11855: the armed card's next wake starts a fresh session that reads the handoff note.
1830
+ const capFresh = !resumedAfterAsk && !!capArmed && capArmed.card === sessionCardForTurn;
1831
+ const fresh = newCard || capFresh;
1788
1832
  // #11559: a pause rides the card, not the seat — a contract for a DIFFERENT card lifts it
1789
1833
  // (and resets the counter with the fresh session below); the paused card itself never buys
1790
1834
  // another CLI session. The wake stays owed and retries on a long clock, so the orchestrator
@@ -1797,17 +1841,19 @@ async function resolveWakeCard(messages, { session }) {
1797
1841
  budgetRetryAt = Date.now() + BUDGET_RETRY_MS;
1798
1842
  return;
1799
1843
  }
1800
- if (budgetStop && fresh) { budgetStop = null; budgetRetryAt = 0; }
1801
- if (fresh) { cardTokens = 0; cardStartedAt = Date.now(); }
1844
+ if (budgetStop && newCard) { budgetStop = null; budgetRetryAt = 0; }
1845
+ if (newCard) { cardTokens = 0; cardStartedAt = Date.now(); }
1802
1846
  sessionCard = card;
1803
1847
  const cited = [...new Set(wakeForTurn.flatMap(m => cardRefs(m.text)))];
1804
- const freshText = fresh
1848
+ const capPointer = capFresh ? capPointerLine(sessionCardForTurn) : "";
1849
+ if (capFresh) capArmed = null;
1850
+ const freshText = capPointer + (newCard
1805
1851
  ? `\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`
1806
1852
  // A wake naming several cards used to leave the seat guessing which one the machine believed
1807
1853
  // — the prompt named two and committed to neither. Say it, even when the session continues.
1808
1854
  : (card && cited.length > 1
1809
1855
  ? `\n(This turn is card #${card} — the wake cites ${cited.length} cards; the rest are context.)\n`
1810
- : "");
1856
+ : ""));
1811
1857
  const prompt = composedTurn({
1812
1858
  wakeText, ctxText, againText: againText + freshText + baseText + dutyNudgeDirective(dutyPlan),
1813
1859
  tailText: "\nAct on what's addressed to you, then end your turn.\n\n",
@@ -1996,6 +2042,14 @@ async function resolveWakeCard(messages, { session }) {
1996
2042
  try {
1997
2043
  const { task } = await api(`/card?project=${encodeURIComponent(PROJ)}&id=${sessionCard}`);
1998
2044
  if (task && (task.status === "testing" || task.status === "done")) await janitorStop(`#${sessionCard} → ${task.status}`, sessionCard);
2045
+ // #11855: a handoff was written and no wake is queued, so the card would idle; wake it
2046
+ // ourselves into the fresh session unless the seat already moved it to testing/done.
2047
+ else if (task && capArmed?.card === sessionCard && !pendingWake.length) {
2048
+ pendingWake.push({ id: `cap-${sessionCard}-${capHandoffs[sessionCard] || 0}`, from: SESSION, to: SESSION, card: sessionCard, kind: "status", ts: Date.now(),
2049
+ text: `#${sessionCard}: the context cap handoff note is written; continue the card in this fresh session.` });
2050
+ savePending(pendingWake, pendingBcast);
2051
+ log(`context cap: #${sessionCard} re-queued itself into a fresh session`);
2052
+ }
1999
2053
  } catch {}
2000
2054
  }
2001
2055
  await notifyAssigners(assigners,
@@ -219,6 +219,8 @@ const cleanup = () => {
219
219
  try { if (ws) herdr(["workspace", "close", ws.workspace_id], { json: true }); } catch {}
220
220
  if (fail > 0) { console.log(`failed-run evidence retained at ${world}`); return; }
221
221
  try { rmSync(world, { recursive: true, force: true }); } catch {}
222
+ // the pane's real claude wrote its transcript under ~/.claude/projects; it is the drill's too
223
+ try { rmSync(tDir, { recursive: true, force: true }); } catch {}
222
224
  };
223
225
  process.on("exit", cleanup);
224
226
  for (const signal of ["SIGINT", "SIGTERM"]) process.once(signal, () => {
@@ -300,10 +300,20 @@ try {
300
300
  // SAME project where it stands instead of starting blind. Cheap + LLM-free.
301
301
  try {
302
302
  const cu = await jget(`${url}/catchup?project=${encodeURIComponent(project)}`, session).catch(() => null);
303
- sessionTitle = sessionTitleFrom(project, cu); // "<project> · <current work>" for the picker/--resume/mobile
303
+ sessionTitle = sessionTitleFrom(project, cu); // "<project> · <current work>" — computed on EVERY source; the picker/--resume/mobile read it
304
+ // #11853: only a FRESH start pays for the full board dump. A resumed session still carries the copy
305
+ // it got at startup and a compacted one carries the summary, so each `claude -c` was welding another
306
+ // 2-4K-token board snapshot into the transcript for good. Empty/unknown source fails open to the
307
+ // full block (a launcher that omits it is starting fresh); the pointer keeps the model staleness-honest.
308
+ const freshStart = !source || source === "startup" || source === "clear"; // /clear empties the context, so it needs the board too
304
309
  let gitlog = "";
305
- try { gitlog = execSync(`git -C ${JSON.stringify(projectDir)} log --oneline -5 2>/dev/null`, { encoding: "utf8", timeout: 2500 }).trim(); } catch {}
306
- if ((cu && cu.total > 0) || gitlog) {
310
+ if (freshStart) {
311
+ try { gitlog = execSync(`git -C ${JSON.stringify(projectDir)} log --oneline -5 2>/dev/null`, { encoding: "utf8", timeout: 2500 }).trim(); } catch {}
312
+ }
313
+ if (!freshStart && cu && cu.total > 0) {
314
+ additionalContext += `<trantor-board-pointer project="${sanitize(project)}" source="${sanitize(source)}">Board may have moved since this session last ran — one \`relay_board\` call gets the current board before you act on remembered cards.</trantor-board-pointer>\n`;
315
+ process.stderr.write(`[trantor] ${source}: board pointer only for ${project} — full catch-up skipped (#11853)\n`);
316
+ } else if (freshStart && ((cu && cu.total > 0) || gitlog)) {
307
317
  const line = (arr) => (arr || []).map(t => `#${t.id} ${String(t.title).slice(0, 72)}${t.assignee ? ` @${t.assignee}` : ""}`).join("\n ");
308
318
  additionalContext += `<trantor-project-state project="${sanitize(project)}">\n`;
309
319
  additionalContext += `📋 **Catching up on the continuous "${sanitize(project)}" board** (this project's living record across all sessions — read it before starting; don't duplicate done work).\n`;
@@ -0,0 +1,23 @@
1
+ // #11855: the card context cap. The runner reads the last step's processed input after a clean
2
+ // card turn; this decides what to do with it. Pure: no I/O, no clock, no env.
3
+
4
+ export const capConfig = (env = process.env) => ({
5
+ cap: Math.max(0, Number(env.TRANTOR_CARD_CONTEXT_CAP || 120_000)),
6
+ minSteps: Math.max(1, Number(env.TRANTOR_CARD_CAP_MIN_STEPS || 5)),
7
+ maxHandoffs: Math.max(1, Number(env.TRANTOR_CARD_CAP_MAX_HANDOFFS || 3)),
8
+ });
9
+
10
+ // -> "none" | "handoff" | "log-exhausted" (post the one card note) | "exhausted" (already posted)
11
+ export function capDecision({ cap, minSteps, maxHandoffs, step, turn, freshTurn, handoffs = 0, logged = false }) {
12
+ if (!(cap > 0) || !Number.isFinite(step) || step <= cap) return "none";
13
+ if (handoffs >= maxHandoffs) return logged ? "exhausted" : "log-exhausted";
14
+ if (turn - freshTurn < minSteps) return "none";
15
+ return "handoff";
16
+ }
17
+
18
+ export const capHandoffPrompt = (card) => `this session's context passed the card context cap and a fresh session takes over card #${card} next. Add ONE note to card #${card} (relay_task_move keeping its current status) prefixed "HANDOFF (context cap):" holding: the goal in one line, the current state with the sha you verified at, the key decisions, the next steps, and the key files (paths). Then end your turn: the fresh session continues from your note.`;
19
+
20
+ export const capPointerLine = (card) => `\n(FRESH SESSION for card #${card}: the previous session hit the context cap and left a card note prefixed "HANDOFF (context cap):". Read it with relay_board card:${card} and continue from it.)\n`;
21
+
22
+ export const capExhaustedNote = (card, cap, step, handoffs, maxHandoffs) =>
23
+ `context cap: #${card} is past ${cap} processed tokens/step again after ${handoffs} handoffs (max ${maxHandoffs}); carrying on, CARD_TOKEN_BUDGET stays the backstop (last step ${step})`;
@@ -3,7 +3,7 @@
3
3
  // in each provider's OWN session record; read them there, never from stdout.
4
4
  // Usage shape: { input, output, cacheRead, cacheWrite } — null means "could not read", never 0.
5
5
  import { spawnSync } from "node:child_process";
6
- import { readdirSync, statSync } from "node:fs";
6
+ import { closeSync, openSync, readdirSync, readSync, realpathSync, statSync } from "node:fs";
7
7
  import { join } from "node:path";
8
8
 
9
9
  export function usageTotal(u) {
@@ -114,3 +114,131 @@ export function dshTurnUsage(sessionsRoot, cwd, sinceMs, untilMs, run = spawnSyn
114
114
  }
115
115
  return found ? sum : null;
116
116
  }
117
+
118
+ // ---- #11855: the LAST step's processed input, read from each harness's own session record ----
119
+ // null = no readable record; the card context cap never trips blind.
120
+
121
+ // Processed input a step re-read: fresh input + cache read + cache write.
122
+ export const processedInput = (u) => (u ? (u.input || 0) + (u.cacheRead || 0) + (u.cacheWrite || 0) : 0);
123
+
124
+ const readdirMaybe = (d) => { try { return readdirSync(d); } catch { return []; } };
125
+ const inWindow = (t, sinceMs) => Number.isFinite(t) && t >= sinceMs;
126
+ const realOr = (p) => { try { return realpathSync(p); } catch { return String(p || ""); } };
127
+
128
+ // Last `maxBytes` of a file as lines (a cut first line dropped): usage rows sit at the end.
129
+ function tailLines(path, maxBytes = 262144) {
130
+ let fd;
131
+ try { fd = openSync(path, "r"); } catch { return []; }
132
+ try {
133
+ const size = statSync(path).size;
134
+ const off = Math.max(0, size - maxBytes);
135
+ const buf = Buffer.alloc(Math.min(size, maxBytes));
136
+ readSync(fd, buf, 0, buf.length, off);
137
+ const lines = buf.toString("utf8").split("\n");
138
+ if (off > 0) lines.shift();
139
+ return lines;
140
+ } catch { return []; }
141
+ finally { try { closeSync(fd); } catch {} }
142
+ }
143
+
144
+ // codex: ~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl, local-date dirs. A resumed session keeps
145
+ // appending to its ORIGINAL file, so the last `lookbackDays` of dirs are scanned, and the file is
146
+ // matched to the seat by the session_meta record's cwd. input_tokens already includes the cached
147
+ // part, so it is split to keep processedInput from counting the cache twice.
148
+ export function codexRolloutCwd(file) {
149
+ let fd;
150
+ try { fd = openSync(file, "r"); } catch { return null; }
151
+ try {
152
+ const buf = Buffer.alloc(8192);
153
+ const m = buf.toString("utf8", 0, readSync(fd, buf, 0, 8192, 0)).match(/^\{"timestamp":"[^"]*"[^\n]*?"type":"session_meta"[^\n]*?"cwd":("(?:[^"\\]|\\.)*")/);
154
+ return m ? JSON.parse(m[1]) : null;
155
+ } catch { return null; }
156
+ finally { try { closeSync(fd); } catch {} }
157
+ }
158
+ export function codexStepInput(sessionsRoot, sinceMs, cwd, { nowMs = Date.now(), lookbackDays = 14 } = {}) {
159
+ if (!sessionsRoot || !cwd || !Number.isFinite(sinceMs)) return null;
160
+ const want = realOr(cwd), pad = (n) => String(n).padStart(2, "0");
161
+ let newest = null;
162
+ for (let i = -1; i < lookbackDays; i++) {
163
+ const day = new Date(nowMs - i * 86400000);
164
+ const dir = join(sessionsRoot, String(day.getFullYear()), pad(day.getMonth() + 1), pad(day.getDate()));
165
+ for (const n of readdirMaybe(dir)) {
166
+ if (!n.startsWith("rollout-") || !n.endsWith(".jsonl")) continue;
167
+ const f = join(dir, n);
168
+ let mt; try { mt = statSync(f).mtimeMs; } catch { continue; }
169
+ if (mt < sinceMs || (newest && mt <= newest.mt)) continue;
170
+ const c = codexRolloutCwd(f);
171
+ if (c && realOr(c) === want) newest = { f, mt };
172
+ }
173
+ }
174
+ if (!newest) return null;
175
+ let last = null;
176
+ for (const line of tailLines(newest.f)) {
177
+ const s = line.trim(); if (!s) continue;
178
+ let ev; try { ev = JSON.parse(s); } catch { continue; }
179
+ if (ev?.payload?.type !== "token_count") continue;
180
+ if (!inWindow(Date.parse(ev.timestamp || ""), sinceMs)) continue;
181
+ const u = ev.payload?.info?.last_token_usage;
182
+ if (u) last = u;
183
+ }
184
+ if (!last) return null;
185
+ const inp = field(last.input_tokens), cached = field(last.cached_input_tokens), out = field(last.output_tokens);
186
+ if (inp === null || cached === null || out === null) return null;
187
+ return { input: Math.max(0, inp - cached), output: out, cacheRead: cached, cacheWrite: 0 };
188
+ }
189
+
190
+ // kimi: ~/.kimi-code/sessions/<wd>/<sid>/agents/main/wire.jsonl, found by the runner's sid (the
191
+ // printed `kimi -r <sid>` id) so a sibling seat's wire is never read. One usage.record per step.
192
+ export function kimiStepInput(sessionsRoot, sid, sinceMs) {
193
+ if (!sessionsRoot || !sid || !Number.isFinite(sinceMs)) return null;
194
+ let file = null;
195
+ for (const wd of readdirMaybe(sessionsRoot)) {
196
+ const f = join(sessionsRoot, wd, String(sid), "agents", "main", "wire.jsonl");
197
+ try { if (statSync(f).isFile()) { file = f; break; } } catch {}
198
+ }
199
+ if (!file) return null;
200
+ let last = null;
201
+ for (const line of tailLines(file)) {
202
+ const s = line.trim(); if (!s) continue;
203
+ let ev; try { ev = JSON.parse(s); } catch { continue; }
204
+ if (ev?.type !== "usage.record" || !ev.usage) continue;
205
+ if (!inWindow(Number(ev.time), sinceMs)) continue;
206
+ last = ev.usage;
207
+ }
208
+ if (!last) return null;
209
+ const inp = field(last.inputOther), cr = field(last.inputCacheRead),
210
+ cw = field(last.inputCacheCreation), out = field(last.output);
211
+ if ([inp, cr, cw, out].some(v => v === null)) return null;
212
+ return { input: inp, output: out, cacheRead: cr, cacheWrite: cw };
213
+ }
214
+
215
+ // claude: ~/.claude/projects/<cwd-slug>/*.jsonl (claudeTranscriptDir names the seat's dir), the
216
+ // newest transcript touched in the window; assistant rows carry message.usage. Subagent rows
217
+ // (isSidechain) are skipped. A 1MB tail, since an assistant line can carry long text.
218
+ export function claudeStepInput(transcriptDir, sinceMs) {
219
+ if (!transcriptDir || !Number.isFinite(sinceMs)) return null;
220
+ let newest = null;
221
+ for (const n of readdirMaybe(transcriptDir)) {
222
+ if (!n.endsWith(".jsonl")) continue;
223
+ const f = join(transcriptDir, n);
224
+ let mt; try { mt = statSync(f).mtimeMs; } catch { continue; }
225
+ if (mt < sinceMs) continue;
226
+ if (!newest || mt > newest.mt) newest = { f, mt };
227
+ }
228
+ if (!newest) return null;
229
+ let last = null;
230
+ for (const line of tailLines(newest.f, 1048576)) {
231
+ const s = line.trim(); if (!s) continue;
232
+ let ev; try { ev = JSON.parse(s); } catch { continue; }
233
+ if (ev?.type !== "assistant" || ev.isSidechain) continue;
234
+ const u = ev.message?.usage;
235
+ if (!u) continue;
236
+ if (!inWindow(Date.parse(ev.timestamp || ""), sinceMs)) continue;
237
+ last = u;
238
+ }
239
+ if (!last) return null;
240
+ const inp = field(last.input_tokens), cr = field(last.cache_read_input_tokens),
241
+ cw = field(last.cache_creation_input_tokens), out = field(last.output_tokens);
242
+ if ([inp, cr, cw, out].some(v => v === null)) return null;
243
+ return { input: inp, output: out, cacheRead: cr, cacheWrite: cw };
244
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trantor",
3
- "version": "0.18.100",
3
+ "version": "0.18.102",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "trantor": "bin/cli.mjs"