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.
- package/.claude-plugin/plugin.json +1 -1
- package/bin/crew/open.mjs +24 -5
- package/bin/crew-runner.mjs +62 -8
- package/bin/drill-surface.mjs +2 -0
- package/hooks/sessionstart.mjs +13 -3
- package/lib/context-cap.mjs +23 -0
- package/lib/turn-usage.mjs +129 -1
- package/package.json +1 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "trantor",
|
|
3
|
-
"version": "0.18.
|
|
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
|
-
//
|
|
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
|
-
|
|
70
|
-
|
|
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
|
}
|
package/bin/crew-runner.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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 &&
|
|
1801
|
-
if (
|
|
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
|
|
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,
|
package/bin/drill-surface.mjs
CHANGED
|
@@ -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, () => {
|
package/hooks/sessionstart.mjs
CHANGED
|
@@ -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>"
|
|
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
|
-
|
|
306
|
-
|
|
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})`;
|
package/lib/turn-usage.mjs
CHANGED
|
@@ -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
|
+
}
|