trantor 0.18.15 → 0.18.17

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.15",
3
+ "version": "0.18.17",
4
4
  "description": "Trantor \u2014 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/adopt.mjs CHANGED
@@ -15,7 +15,7 @@ import { readdirSync, statSync, existsSync, readFileSync, writeFileSync, mkdirSy
15
15
  import { execFileSync } from "node:child_process";
16
16
  import { join, dirname } from "node:path";
17
17
  import { homedir } from "node:os";
18
- import { resolveProject } from "../lib/project.mjs";
18
+ import { resolveProject, writeOrchSession } from "../lib/project.mjs";
19
19
 
20
20
  const D = "\x1b[2m", B = "\x1b[1m", Y = "\x1b[33m", G = "\x1b[32m", R = "\x1b[0m";
21
21
  const args = process.argv.slice(2);
@@ -74,14 +74,10 @@ if (candidates.length > 1) {
74
74
  console.log(`${D}the newest is assumed to be yours; pick another with --session <id>${R}`);
75
75
  }
76
76
 
77
- // Record it where `trantor open` looks. Same file, same format the orchestrator pane already uses,
78
- // so adopting and opening fresh converge on one mechanism rather than two.
79
- const busDir = process.env.AGENT_BUS_DIR || join(homedir(), ".agent-bus");
80
- const store = join(busDir, "orch-sessions.txt");
81
- mkdirSync(dirname(store), { recursive: true });
82
- const rows = existsSync(store) ? readFileSync(store, "utf8").split("\n").filter(Boolean) : [];
83
- const kept = rows.filter(r => r.split("\t")[0] !== project);
84
- writeFileSync(store, [...kept, `${project}\t${chosen}`].join("\n") + "\n");
77
+ // Record it where `trantor open` looks. One choke point for the map (writeOrchSession) so every
78
+ // rewrite is attributable in orch-sessions.log adopt is one of the map's three writers
79
+ // (SYSTEM-CONTRACT §4), and until 2026-08-30 it wrote the file by hand, invisibly.
80
+ writeOrchSession(project, chosen, "adopt");
85
81
  console.log(`\n${G}recorded${R} ${chosen} as ${project}'s orchestrator session`);
86
82
 
87
83
  // Two live claudes on one transcript is the one thing that must not happen: they would interleave
package/bin/autonomy.mjs CHANGED
@@ -18,7 +18,7 @@ function projectFlag() {
18
18
  }
19
19
 
20
20
  const BOOLS = ["commit", "push", "deploy", "swapDeadSeat", "retryFailedTurn"];
21
- const ENUMS = { harness: ["prompt", "bypass"] };
21
+ const ENUMS = { harness: ["prompt", "bypass"], baton: ["ask", "auto"] };
22
22
 
23
23
  if (cmd === "get") {
24
24
  // Machine-readable, one value, no decoration — crew.sh reads this.
package/bin/cli.mjs CHANGED
@@ -74,6 +74,7 @@ switch (cmd) {
74
74
  case "handoff": run("bin/baton.mjs"); break;
75
75
  case "adopt": run("bin/adopt.mjs"); break;
76
76
  case "takeover": run("bin/takeover.mjs"); break;
77
+ case "drill": run("bin/drill-surface.mjs"); break;
77
78
  case "summarize": run("bin/summarize.mjs"); break;
78
79
  case "policy": run("bin/policy.mjs"); break;
79
80
  case "proposals": case "proposal": run("bin/proposals.mjs"); break;
@@ -0,0 +1,306 @@
1
+ #!/usr/bin/env node
2
+ // `trantor drill` — the §7 end-to-end SEAM drill (SYSTEM-CONTRACT.md; Phase 5 of the reassembly).
3
+ //
4
+ // Unit suites passing while the seams fail is this project's most-repeated lesson. This drill
5
+ // runs the REAL components against each other on a throwaway project: a real herdr workspace,
6
+ // a real Claude session, the real socket transport the app uses, the real hooks, the real
7
+ // handoff machine — and asserts on evidence (transcript rows, herdr state, ledger files),
8
+ // never on exit codes alone.
9
+ //
10
+ // It is the ship gate for desktop/chat/handoff/crew changes: run it before every such release;
11
+ // a red drill does not ship. (There is no scripted release path to wire it into — the release
12
+ // dance is manual — so the gate is this command plus the contract that mandates it.)
13
+ //
14
+ // Flags: --keep leave the scratch world in place for inspection (prints paths).
15
+
16
+ import { mkdirSync, rmSync, existsSync, readFileSync, writeFileSync, readdirSync, statSync } from "node:fs";
17
+ import { join, basename } from "node:path";
18
+ import { homedir, tmpdir } from "node:os";
19
+ import { execFileSync, execSync } from "node:child_process";
20
+ import { createConnection } from "node:net";
21
+
22
+ const KEEP = process.argv.includes("--keep");
23
+ const G = "\x1b[32m", Rd = "\x1b[31m", Y = "\x1b[33m", D = "\x1b[2m", R = "\x1b[0m";
24
+ let pass = 0, fail = 0, skip = 0;
25
+ const PASS = (s, ev = "") => { pass++; console.log(` ${G}PASS${R} ${s}${ev ? ` ${D}${ev}${R}` : ""}`); };
26
+ const FAIL = (s, ev = "") => { fail++; console.log(` ${Rd}FAIL${R} ${s}${ev ? ` ${D}${ev}${R}` : ""}`); };
27
+ const SKIP = (s, why) => { skip++; console.log(` ${Y}SKIP${R} ${s} ${D}${why}${R}`); };
28
+ const step = (n) => console.log(`\n${n}`);
29
+ const sleep = (ms) => new Promise(r => setTimeout(r, ms));
30
+
31
+ function herdr(args, { json = true } = {}) {
32
+ const out = execFileSync("herdr", args, { encoding: "utf8", timeout: 90_000 });
33
+ return json ? JSON.parse(out) : out;
34
+ }
35
+
36
+ /** One request over herdr's socket — byte-identical to the app's transport (herdr.rs). */
37
+ function socketRequest(req, timeoutMs = 90_000) {
38
+ const sockPath = join(homedir(), ".config", "herdr", "herdr.sock");
39
+ return new Promise((resolve, reject) => {
40
+ const s = createConnection(sockPath);
41
+ const t = setTimeout(() => { s.destroy(); reject(new Error("socket timeout")); }, timeoutMs);
42
+ let buf = "";
43
+ s.on("connect", () => s.write(JSON.stringify(req) + "\n"));
44
+ s.on("data", (d) => {
45
+ buf += d.toString("utf8");
46
+ const nl = buf.indexOf("\n");
47
+ if (nl >= 0) { clearTimeout(t); s.destroy(); resolve(buf.slice(0, nl)); }
48
+ });
49
+ s.on("error", (e) => { clearTimeout(t); reject(e); });
50
+ });
51
+ }
52
+
53
+ async function waitFor(desc, fn, { timeoutMs = 60_000, everyMs = 1_000 } = {}) {
54
+ const end = Date.now() + timeoutMs;
55
+ while (Date.now() < end) {
56
+ const v = await fn();
57
+ if (v) return v;
58
+ await sleep(everyMs);
59
+ }
60
+ return null;
61
+ }
62
+
63
+ function transcriptDirFor(projDir) {
64
+ return join(homedir(), ".claude", "projects", String(projDir).replace(/[/.]/g, "-"));
65
+ }
66
+ function newestJsonl(dir) {
67
+ try {
68
+ return readdirSync(dir).filter(f => f.endsWith(".jsonl"))
69
+ .map(f => ({ f: join(dir, f), m: statSync(join(dir, f)).mtimeMs }))
70
+ .sort((a, b) => b.m - a.m)[0]?.f || null;
71
+ } catch { return null; }
72
+ }
73
+ function userTurnsContaining(file, needle) {
74
+ const hits = [];
75
+ for (const line of readFileSync(file, "utf8").split("\n")) {
76
+ if (!line) continue;
77
+ let r; try { r = JSON.parse(line); } catch { continue; }
78
+ if (r?.type !== "user") continue;
79
+ const c = r.message?.content;
80
+ const t = typeof c === "string" ? c
81
+ : Array.isArray(c) ? c.map(b => (b && typeof b === "object" && b.type === "text") ? b.text : "").join(" ") : "";
82
+ if (t.includes(needle)) hits.push(t);
83
+ }
84
+ return hits;
85
+ }
86
+ function assistantSaid(file, needle) {
87
+ for (const line of readFileSync(file, "utf8").split("\n")) {
88
+ if (!line) continue;
89
+ let r; try { r = JSON.parse(line); } catch { continue; }
90
+ if (r?.type !== "assistant") continue;
91
+ for (const b of r.message?.content || []) {
92
+ if (b && b.type === "text" && String(b.text).includes(needle)) return true;
93
+ }
94
+ }
95
+ return false;
96
+ }
97
+
98
+
99
+ /** Start a Claude agent in a pane, answering the folder-trust dialog if it blocks startup
100
+ * (the P0b recovery: agent_not_ready keeps the name live; one enter accepts the fresh dir). */
101
+ async function startClaude(name, paneId) {
102
+ let blocked = false;
103
+ try {
104
+ const r = herdr(["agent", "start", name, "--kind", "claude", "--pane", paneId]);
105
+ if (r.result?.agent?.agent_status === "idle") return "idle";
106
+ blocked = true;
107
+ } catch { blocked = true; }
108
+ if (blocked) {
109
+ await sleep(1500);
110
+ try { herdr(["agent", "send-keys", name, "enter"]); } catch {}
111
+ const settled = await waitFor("startup dialog answered", () => {
112
+ try {
113
+ const g = herdr(["agent", "get", name]);
114
+ const st = g.result?.agent?.agent_status;
115
+ return st === "idle" ? st : null;
116
+ } catch { return null; }
117
+ }, { timeoutMs: 45_000, everyMs: 2_000 });
118
+ return settled || "not-ready";
119
+ }
120
+ return "not-ready";
121
+ }
122
+
123
+ // ---------- S0 · version skew ----------
124
+ step("S0 · version skew (hooks vs CLI vs app)");
125
+ {
126
+ const cli = JSON.parse(readFileSync(join(import.meta.dirname, "..", "package.json"), "utf8")).version;
127
+ let plugin = "?";
128
+ try {
129
+ // The plugin cache keeps one directory per version; the newest is what sessions load.
130
+ const cache = join(homedir(), ".claude", "plugins", "cache", "trantor", "trantor");
131
+ plugin = readdirSync(cache).filter(v => /^\d+\.\d+\.\d+$/.test(v))
132
+ .sort((a, b) => { const A = a.split(".").map(Number), B = b.split(".").map(Number); return (A[0]-B[0]) || (A[1]-B[1]) || (A[2]-B[2]); })
133
+ .pop() || "?";
134
+ } catch {}
135
+ let app = "?";
136
+ try { app = execSync('plutil -extract CFBundleShortVersionString raw "/Applications/Trantor.app/Contents/Info.plist"', { encoding: "utf8" }).trim(); } catch {}
137
+ console.log(` ${D}cli ${cli} · plugin ${plugin} · app ${app}${R}`);
138
+ if (plugin === "?") {
139
+ console.log(` ${Y}WARN${R} plugin hook version unreadable — cannot rule out skew`);
140
+ } else if (plugin !== cli) {
141
+ console.log(` ${Y}WARN${R} installed plugin hooks (${plugin}) differ from this tree (${cli}) — running sessions use the PLUGIN's hooks`);
142
+ } else {
143
+ PASS("no hook/CLI version skew", `${cli}`);
144
+ }
145
+ globalThis.__skew = plugin !== "?" && plugin !== cli;
146
+ }
147
+
148
+ // ---------- world ----------
149
+ // NOT tmpdir(): macOS tmp is a /var symlink and Claude records the /private/var realpath,
150
+ // so the transcript-slug lookup would miss. A dot-dir under $HOME has no such alias.
151
+ const world = join(homedir(), `.tt-drill-${process.pid}`);
152
+ const proj = join(world, "drill-proj");
153
+ const bus = join(world, "bus");
154
+ mkdirSync(proj, { recursive: true });
155
+ mkdirSync(join(bus, "handoffs"), { recursive: true });
156
+ execFileSync("git", ["init", "-q"], { cwd: proj });
157
+ // The drill's handoff phase exercises the AUTO chain deliberately; the shipped default is ask.
158
+ writeFileSync(join(bus, "autonomy.json"), JSON.stringify({ version: 1, defaults: { baton: "auto" }, projects: {} }));
159
+ const projectName = basename(proj);
160
+ const tDir = transcriptDirFor(proj);
161
+
162
+ let ws = null, pane = null;
163
+ const cleanup = () => {
164
+ if (KEEP) { console.log(`\n${D}--keep: world at ${world} · workspace ${ws?.workspace_id || "?"} left open${R}`); return; }
165
+ try { if (pane) herdr(["agent", "prompt", pane, "/exit"], { json: true }); } catch {}
166
+ try { if (ws) herdr(["workspace", "close", ws.workspace_id], { json: true }); } catch {}
167
+ try { rmSync(world, { recursive: true, force: true }); } catch {}
168
+ };
169
+ process.on("exit", cleanup);
170
+
171
+ // ---------- S1 · cold start ----------
172
+ step("S1 · cold start: workspace, clean env, agent, transcript EXISTS");
173
+ try {
174
+ const created = herdr(["workspace", "create", "--cwd", proj, "--label", "tt-drill"]);
175
+ ws = { workspace_id: created.result.workspace.workspace_id };
176
+ pane = created.result.root_pane.pane_id;
177
+ PASS("throwaway herdr workspace", `${ws.workspace_id} pane ${pane}`);
178
+ // The P0b trap, prevented at the source: a pane inheriting CLAUDE_CODE_CHILD_SESSION runs
179
+ // Claude with transcript saving OFF — an invisible session. Every spawn path must clear it.
180
+ herdr(["pane", "run", pane,
181
+ `unset CLAUDE_CODE_CHILD_SESSION; export AGENT_BUS_DIR=${bus} RELAY_URL=http://127.0.0.1:1 ` +
182
+ `TRANTOR_NO_SCROOGE=1 TRANTOR_NO_HANDOFF_SPAWN=1 TRANTOR_NO_BALANCE_CHECK=1 ` +
183
+ `RELAY_CONTEXT_WARN_FRAC=0.000001 RELAY_STOP_TIMEOUT_MS=300 RELAY_CONTEXT_WINDOW=1000000; echo ENV-READY`], { json: false });
184
+ await sleep(1500);
185
+ const st = await startClaude("drill", pane);
186
+ if (st === "idle") PASS("claude starts and settles idle (trust dialog auto-answered if shown)");
187
+ else FAIL("claude starts and settles idle", String(st));
188
+ } catch (e) {
189
+ FAIL("S1 world setup", String(e.message || e).slice(0, 160));
190
+ console.log(`\n${Rd}cannot continue without S1${R}`);
191
+ process.exit(1);
192
+ }
193
+
194
+ // ---------- S2 · transport ----------
195
+ step("S2 · transport: whole multiline message through the app's exact socket call");
196
+ const MARK = `drill-${Date.now() % 100000}`;
197
+ {
198
+ const text = `-leading dash line for ${MARK}.\nSecond line with /tmp/fake.png mid-sentence.\nReply with exactly ${MARK}-OK and nothing else.`;
199
+ const raw = await socketRequest({ id: "trantor:agent.prompt", method: "agent.prompt", params: { target: pane, text } });
200
+ const resp = JSON.parse(raw);
201
+ if (resp.result?.type === "agent_prompted") PASS("agent.prompt accepted (no keystrokes involved)");
202
+ else FAIL("agent.prompt accepted", raw.slice(0, 120));
203
+
204
+ const tfile = await waitFor("transcript exists", () => newestJsonl(tDir), { timeoutMs: 10_000, everyMs: 500 });
205
+ if (tfile) PASS("transcript EXISTS within seconds (CLAUDE_CODE_CHILD_SESSION trap absent)", basename(tfile));
206
+ else FAIL("transcript EXISTS within seconds — invisible-session trap?");
207
+
208
+ if (tfile) {
209
+ const replied = await waitFor("reply", () => assistantSaid(tfile, `${MARK}-OK`) || null, { timeoutMs: 120_000, everyMs: 2_000 });
210
+ const turns = userTurnsContaining(tfile, MARK);
211
+ if (turns.length === 1 && turns[0].includes("\n") && turns[0].startsWith("-leading"))
212
+ PASS("ONE user turn, newlines intact, dash-leading text unmangled");
213
+ else FAIL("ONE whole user turn", `turns=${turns.length}`);
214
+ if (replied) PASS("the reply came back");
215
+ else FAIL("the reply came back");
216
+ }
217
+ }
218
+
219
+ // ---------- S3 · identity ----------
220
+ step("S3 · identity: the pane itself names the session (Phase 2)");
221
+ let predecessorSid = null;
222
+ {
223
+ const got = herdr(["agent", "get", "drill"]);
224
+ const as = got.result?.agent?.agent_session;
225
+ const tfile = newestJsonl(tDir);
226
+ predecessorSid = as?.kind === "id" ? as.value : null;
227
+ if (predecessorSid && tfile && basename(tfile) === `${predecessorSid}.jsonl`)
228
+ PASS("pane report matches the live transcript", predecessorSid.slice(0, 8));
229
+ else if (!as) FAIL("pane reports its session — is the herdr claude integration installed?");
230
+ else FAIL("pane report matches the live transcript", `${as?.value?.slice(0, 8)} vs ${tfile && basename(tfile).slice(0, 8)}`);
231
+ }
232
+
233
+ // ---------- S4 · the handoff machine ----------
234
+ step("S4 · handoff machine: warn → arm → fire → WRITTEN → successor claims → RECAPPED");
235
+ {
236
+ // A turn that uses a tool: the heartbeat is PostToolUse, so only tool use can arm the baton.
237
+ // The heartbeat is PostToolUse: only a REAL tool call can arm the baton. Models sometimes
238
+ // answer without the tool (observed: run 2 of 3 on 2026-08-30 — 1-in-3 prompt fragility,
239
+ // not a seam), so ask, verify tool use in the transcript, and re-ask up to twice.
240
+ const findHandoff = () => {
241
+ try {
242
+ const f = readdirSync(join(bus, "handoffs")).find(x => x.startsWith(`${projectName}-`) && x.endsWith(".json"));
243
+ return f ? join(bus, "handoffs", f) : null;
244
+ } catch { return null; }
245
+ };
246
+ let handoffFile = null;
247
+ for (let attempt = 1; attempt <= 3 && !handoffFile; attempt++) {
248
+ const raw = await socketRequest({ id: "trantor:agent.prompt", method: "agent.prompt", params: {
249
+ target: pane, text: `You MUST call the Bash tool now and run exactly: pwd — do not answer without calling it. Then reply with just DONE-S4-${attempt}.` } });
250
+ if (JSON.parse(raw).result?.type !== "agent_prompted") { FAIL("S4 prompt accepted", raw.slice(0, 100)); break; }
251
+ handoffFile = await waitFor("handoff written", findHandoff, { timeoutMs: 120_000, everyMs: 2_000 });
252
+ if (!handoffFile) console.log(` ${D}attempt ${attempt}: no handoff yet — re-asking with the tool requirement${R}`);
253
+ }
254
+ if (!handoffFile) {
255
+ FAIL("the armed baton fired and WROTE a handoff at the turn boundary");
256
+ } else {
257
+ const rec = JSON.parse(readFileSync(handoffFile, "utf8"));
258
+ const states = (rec.states || []).map(s => s.state);
259
+ if (states[0] === "written") PASS("§5 ledger opens with WRITTEN", `${basename(handoffFile)}`);
260
+ else FAIL("§5 ledger opens with WRITTEN", states.join(","));
261
+
262
+ // Successor: end the predecessor, start fresh in the SAME pane — the claim is sessionstart's.
263
+ try { herdr(["agent", "prompt", "drill", "/exit"]); } catch {}
264
+ await sleep(4000);
265
+ const st2 = await startClaude("drill2", pane);
266
+ if (st2 !== "idle") FAIL("successor claude starts", String(st2));
267
+ const claimed = await waitFor("claimed on ledger", () => {
268
+ try {
269
+ const r = JSON.parse(readFileSync(handoffFile, "utf8"));
270
+ return (r.states || []).some(s => s.state === "claimed") ? r : null;
271
+ } catch { return null; }
272
+ }, { timeoutMs: 60_000, everyMs: 2_000 });
273
+ if (claimed) PASS("successor CLAIMED it (sessionstart, on the ledger)", `by ${claimed.states.find(s => s.state === "claimed")?.by?.slice(0, 8)}`);
274
+ else FAIL("successor CLAIMED it");
275
+
276
+ const stamp = () => {
277
+ try { return readdirSync(join(bus, "handoffs")).find(f => f.startsWith("recap-pending-")); } catch { return null; }
278
+ };
279
+ if (stamp()) PASS("recap net armed (pending stamp exists)");
280
+ else FAIL("recap net armed (pending stamp exists)");
281
+
282
+ const raw2 = await socketRequest({ id: "trantor:agent.prompt", method: "agent.prompt", params: {
283
+ target: pane, text: "Say only: ACK-S4" } });
284
+ if (JSON.parse(raw2).result?.type !== "agent_prompted") FAIL("successor prompt accepted", raw2.slice(0, 100));
285
+ const recapped = await waitFor("recapped", () => {
286
+ try {
287
+ const r = JSON.parse(readFileSync(handoffFile, "utf8"));
288
+ return (r.states || []).some(s => s.state === "recapped") && !stamp() ? r : null;
289
+ } catch { return null; }
290
+ }, { timeoutMs: 120_000, everyMs: 2_000 });
291
+ if (recapped) PASS("first Stop recorded RECAPPED and cleared the net", recapped.states.map(s => s.state).join("→"));
292
+ else FAIL("first Stop recorded RECAPPED and cleared the net", stamp() ? "stamp still present" : "no recapped state");
293
+ }
294
+ }
295
+
296
+ if (globalThis.__skew && fail > 0) {
297
+ console.log(` ${Y}NOTE${R} S4 runs the INSTALLED plugin's hooks — with the skew above, ledger/recap failures are expected until the newer CLI is published and \`claude plugin update trantor@trantor\` runs.`);
298
+ }
299
+
300
+ // ---------- S5 · takeover ----------
301
+ step("S5 · takeover from a Terminal session");
302
+ SKIP("takeover chain", "needs an interactive Terminal-window session; proven live 2026-08-28 (0.18.13 drill) — automate in drill v2");
303
+
304
+ // ---------- verdict ----------
305
+ console.log(`\n${fail === 0 ? G + "DRILL GREEN" : Rd + "DRILL RED"}${R} — ${pass} passed, ${fail} failed, ${skip} skipped`);
306
+ process.exit(fail === 0 ? 0 : 1);
@@ -96,6 +96,15 @@ async function maybeEarlyWarn(stdinRaw, session) {
96
96
  };
97
97
 
98
98
  const projectDir = process.env.CLAUDE_PROJECT_DIR || process.cwd();
99
+ // The baton dial (#5509 W2, SYSTEM-CONTRACT §5): "ask" (default) means the OPERATOR fires
100
+ // handoffs — the app's banner asks at this same threshold, and this hook neither arms nor
101
+ // auto-fires. "auto" restores the arm-here → fire-at-Stop chain below. PreCompact remains
102
+ // the at-the-wall backstop in both modes.
103
+ const { resolveAutonomy } = await import("../lib/autonomy.mjs");
104
+ if (resolveAutonomy(resolveProject(projectDir)).baton !== "auto") {
105
+ process.stderr.write(`[trantor] context ${Math.round(usage.frac * 100)}% — baton dial is 'ask': the banner offers, nothing auto-fires\n`);
106
+ return;
107
+ }
99
108
  // Detect THIS session's Terminal window NOW (the hook has the controlling tty; the detached worker
100
109
  // won't) so the baton-close can replace this exact window once the fresh session takes over.
101
110
  const tty = controllingTty();
@@ -53,18 +53,45 @@ export function contextUsage(transcriptPath, conf = readConfig()) {
53
53
  } catch { return null; }
54
54
 
55
55
  const lines = buf.split("\n").filter(Boolean);
56
- for (let i = lines.length - 1; i >= 0; i--) {
57
- let r; try { r = JSON.parse(lines[i]); } catch { continue; }
56
+ const rows = [];
57
+ let model = "";
58
+ for (const line of lines) {
59
+ let r; try { r = JSON.parse(line); } catch { continue; }
58
60
  const u = r?.message?.usage;
59
- if (r?.type === "assistant" && u) {
60
- const tokens = (u.input_tokens || 0) + (u.cache_read_input_tokens || 0) + (u.cache_creation_input_tokens || 0);
61
- if (tokens <= 0) continue;
62
- const model = r.message.model || "";
63
- const window = resolveWindow(model, conf);
64
- return { tokens, window, frac: window ? tokens / window : null, model };
65
- }
61
+ if (r?.type !== "assistant" || !u) continue;
62
+ const tokens = (u.input_tokens || 0) + (u.cache_read_input_tokens || 0) + (u.cache_creation_input_tokens || 0);
63
+ if (tokens <= 0) continue;
64
+ rows.push(tokens);
65
+ model = r.message.model || model;
66
66
  }
67
- return null;
67
+ const tokens = guardContextTokens(rows);
68
+ if (tokens == null) return null;
69
+ const window = resolveWindow(model, conf);
70
+ return { tokens, window, frac: window ? tokens / window : null, model };
71
+ }
72
+
73
+ // The #5572 poison guard — the SAME rule, from the SAME fixture manifest
74
+ // (test/fixtures/context/manifest.json), as the desktop gauge's ContextGuard: the baton must
75
+ // read what the gauge reads, or the banner and the heartbeat disagree. Report the last row
76
+ // unless it falls below 40% of the session max; then report the recent maximum (a lone
77
+ // collapsed row is an artifact — across 1,839 usage rows in the two incident-era transcripts,
78
+ // zero real drops below that floor were observed) unless the last FIVE rows all sit below the
79
+ // floor: a sustained new level is reality, accept it.
80
+ export function guardContextTokens(rows) {
81
+ let max = 0;
82
+ const recent = [];
83
+ for (const t of rows) {
84
+ if (!t || t <= 0) continue;
85
+ recent.push(t);
86
+ if (recent.length > 5) recent.shift();
87
+ if (t > max) max = t;
88
+ }
89
+ if (!recent.length) return null;
90
+ const last = recent[recent.length - 1];
91
+ const floor = max * 0.4;
92
+ if (last >= floor) return last;
93
+ if (recent.length === 5 && recent.every(r => r < floor)) return last;
94
+ return Math.max(...recent);
68
95
  }
69
96
 
70
97
  // The transcript logs the model WITHOUT the [1m] marker, so we cannot tell a
@@ -281,6 +308,20 @@ export function verbatimRecentTail(transcript, chars = 7000) {
281
308
  }
282
309
 
283
310
  // ---- write + announce + spawn ----------------------------------------------
311
+ /** Append one §5 state transition to a handoff's own file — the machine's ledger rides the
312
+ * record it describes (SYSTEM-CONTRACT §5): every owner of a transition already holds this
313
+ * file, it survives both sessions it connects, and no network is involved. Best-effort. */
314
+ export function appendHandoffState(id, state, by = "") {
315
+ try {
316
+ const p = join(HANDOFF_DIR, `${id}.json`);
317
+ const rec = JSON.parse(readFileSync(p, "utf8"));
318
+ if (!Array.isArray(rec.states)) rec.states = [];
319
+ rec.states.push({ state, ts: nowSec() || Math.floor(Date.now() / 1000), by });
320
+ writeFileSync(p, JSON.stringify(rec, null, 2));
321
+ return true;
322
+ } catch { return false; }
323
+ }
324
+
284
325
  export function writeHandoff({ projectDir, sessionId, transcript, trigger, summary, force = false }) {
285
326
  const projectName = basename(projectDir);
286
327
  // Server-side storm guard: a session running OLD hooks (before the local markHandedOff guard) re-fires
@@ -325,6 +366,8 @@ export function writeHandoff({ projectDir, sessionId, transcript, trigger, summa
325
366
  // narrative + a verbatim recent-exchange block so exact in-flight state always survives
326
367
  summary: narrative + (tail ? `\n\n---\n## Verbatim recent exchange (exact in-flight state — continue from here)\n${tail}` : ""),
327
368
  gitStatus, subagents, verifyGates, consumed: false,
369
+ // The §5 machine's ledger: every transition appends here via appendHandoffState.
370
+ states: [{ state: "written", ts: Number(stamp) || 0, by: sessionId || "" }],
328
371
  };
329
372
  const file = join(HANDOFF_DIR, `${record.id}.json`);
330
373
  writeFileSync(file, JSON.stringify(record, null, 2));
@@ -29,21 +29,46 @@ function titleFrom(prompt) {
29
29
  return s.slice(0, 120);
30
30
  }
31
31
 
32
+
33
+ // §5 recap net (SYSTEM-CONTRACT): while this session carries a claimed-but-unrecapped handoff
34
+ // (the recap-pending stamp sessionstart wrote), EVERY prompt before its first Stop carries the
35
+ // reminder — including the stale queued message that ate the 2026-08-30 takeover. The stamp is
36
+ // cleared (and RECAPPED recorded) by stop-inbox at the first turn boundary.
37
+ import { handoffDir } from "../lib/project.mjs";
38
+ import { existsSync as _ex, readFileSync as _rf } from "node:fs";
39
+ let RECAP_CTX = "";
40
+ function loadRecapCtx(sessionId) {
41
+ try {
42
+ if (!sessionId) return "";
43
+ const p = join(handoffDir(), `recap-pending-${String(sessionId).replace(/[^A-Za-z0-9_.-]/g, "_")}.json`);
44
+ if (!_ex(p)) return "";
45
+ const rec = JSON.parse(_rf(p, "utf8"));
46
+ return `<system-reminder>You took over via handoff ${rec.handoffId}. If you have not yet recapped it, your reply MUST begin with the ≤3-sentence recap (task, state, next step) before anything else — including before answering this message.</system-reminder>`;
47
+ } catch { return ""; }
48
+ }
49
+ function emitAndExit() {
50
+ process.stdout.write(RECAP_CTX
51
+ ? JSON.stringify({ hookSpecificOutput: { hookEventName: "UserPromptSubmit", additionalContext: RECAP_CTX } })
52
+ : "{}");
53
+ process.exit(0);
54
+ }
55
+
32
56
  try {
33
- if (process.env.TRANTOR_NO_FOCUS === "1") { process.stdout.write("{}"); process.exit(0); } // opt-out
57
+ if (process.env.TRANTOR_NO_FOCUS === "1") { emitAndExit(); } // opt-out
34
58
  const input = JSON.parse((await readStdin()) || "{}");
59
+ RECAP_CTX = loadRecapCtx(String(input.session_id || ""));
35
60
  const prompt = String(input.prompt || "");
36
61
  const cwd = input.cwd || process.env.CLAUDE_PROJECT_DIR || process.cwd();
37
62
  // don't card home-dir sessions (matches sessionstart's phantom-project guard)
38
- if (!process.env.RELAY_SESSION && !process.env.RELAY_PROJECT && cwd === homedir()) { process.stdout.write("{}"); process.exit(0); }
63
+ if (!process.env.RELAY_SESSION && !process.env.RELAY_PROJECT && cwd === homedir()) { emitAndExit(); }
39
64
  const trimmed = prompt.replace(/\s+/g, " ").trim();
40
65
  // skip empties, tiny continuations, and pure acks — they're not a new focus
41
- if (!trimmed || trimmed.length < 12 || ACK.test(trimmed)) { process.stdout.write("{}"); process.exit(0); }
66
+ if (!trimmed || trimmed.length < 12 || ACK.test(trimmed)) { emitAndExit(); }
42
67
  // HARNESS-INJECTED prompts are not a human's focus. Task notifications, hook system-reminders and
43
68
  // protocol frames arrive through the same UserPromptSubmit channel, and carding one titled a board
44
69
  // card "<task-notification> <task-id>bavlqfmzq</task-id>…" — pure noise a human cannot read.
45
70
  if (/^\s*[<{[]/.test(trimmed) || /<task-notification>|<system-reminder>|<teammate-message/i.test(trimmed)) {
46
- process.stdout.write("{}"); process.exit(0);
71
+ emitAndExit();
47
72
  }
48
73
  const project = resolveProject(cwd);
49
74
  const session = process.env.RELAY_SESSION
@@ -74,5 +99,4 @@ try {
74
99
  } catch (e) {
75
100
  process.stderr.write(`[trantor] prompt-focus error: ${e?.message || e}\n`);
76
101
  }
77
- process.stdout.write("{}");
78
- process.exit(0);
102
+ emitAndExit();
@@ -57,7 +57,20 @@ function loadPendingHandoff(projectName, { claim = true, freshSession = null } =
57
57
  transcript_path: freshSession.transcript_path || "",
58
58
  };
59
59
  }
60
+ // §5 CLAIMED, on the machine's ledger (SYSTEM-CONTRACT), and the recap net armed:
61
+ // a recap-pending stamp names this successor. Every prompt before its first Stop
62
+ // carries a recap reminder (prompt-focus), and the first Stop marks RECAPPED —
63
+ // the 2026-08-30 failure (successor answered a stale queued message, never
64
+ // recapped) becomes mechanically impossible instead of hopefully avoided.
65
+ if (!Array.isArray(rec.states)) rec.states = [];
66
+ rec.states.push({ state: "claimed", ts: nowSec(), by: freshSession?.session_id || "" });
60
67
  writeFileSync(p, JSON.stringify(rec, null, 2));
68
+ if (freshSession?.session_id) {
69
+ try {
70
+ writeFileSync(join(dir, `recap-pending-${String(freshSession.session_id).replace(/[^A-Za-z0-9_.-]/g, "_")}.json`),
71
+ JSON.stringify({ handoffId: rec.id, ts: nowSec() }));
72
+ } catch {}
73
+ }
61
74
  }
62
75
  return rec;
63
76
  }
@@ -425,8 +438,8 @@ try {
425
438
  // thread, so the map `trantor open` resumes and the app's chat reads moves with it. The pane
426
439
  // also records itself on every fresh start — that keeps the map honest even if a future
427
440
  // claude forks the session id on resume.
428
- if (claimed && orchOrigin && stdinObj.session_id) writeOrchSession(project, String(stdinObj.session_id));
429
- if (isOrchPane && !isCompact && stdinObj.session_id) writeOrchSession(project, String(stdinObj.session_id));
441
+ if (claimed && orchOrigin && stdinObj.session_id) writeOrchSession(project, String(stdinObj.session_id), "sessionstart-claim");
442
+ if (isOrchPane && !isCompact && stdinObj.session_id) writeOrchSession(project, String(stdinObj.session_id), "orch-pane-start");
430
443
  if (handoff && held) {
431
444
  process.stderr.write(`[trantor] pending handoff ${handoff.id} HELD for the orch pane (${Math.round(ageMs / 60000)}m old)\n`);
432
445
  const mins = Math.max(1, Math.round((holdMs - ageMs) / 60000));
@@ -21,15 +21,15 @@
21
21
  // * Only claim delivery once we have actually decided to surface it (peek first). Marking a message
22
22
  // delivered and then letting the stop through would hide it from the waker too — a silent hole.
23
23
  // * Any error, or a hub that is down -> allow the stop. Never trap a session because of us.
24
- import { readFileSync, writeFileSync, existsSync } from "node:fs";
24
+ import { readFileSync, writeFileSync, existsSync, unlinkSync } from "node:fs";
25
25
  import { join, dirname } from "node:path";
26
26
  import { spawn } from "node:child_process";
27
27
  import { fileURLToPath } from "node:url";
28
28
  import { homedir } from "node:os";
29
- import { resolveProject, hostId } from "../lib/project.mjs";
29
+ import { resolveProject, hostId, handoffDir } from "../lib/project.mjs";
30
30
  import { signedGet } from "./lib/api.mjs"; // signed: enforce hubs 401 unsigned reads — unsigned, T2 delivery is silently dead
31
31
  import { ledgerPaths, ensureStart, anchorCursor, writeCursor } from "./lib/inbox-ledger.mjs";
32
- import { readArm, clearArm, markHandedOff } from "./lib/handoff.mjs";
32
+ import { readArm, clearArm, markHandedOff, appendHandoffState } from "./lib/handoff.mjs";
33
33
 
34
34
  const HERE = dirname(fileURLToPath(import.meta.url));
35
35
 
@@ -147,6 +147,23 @@ async function main() {
147
147
  process.stderr.write("[trantor] turn boundary reached — firing the armed baton\n");
148
148
  }
149
149
  } catch {}
150
+ // §5 RECAPPED (SYSTEM-CONTRACT): this session's FIRST turn boundary after claiming a handoff.
151
+ // By Stop time an assistant reply exists, and every prompt of that first turn carried the
152
+ // recap reminder (prompt-focus) — so the reply had the instruction in front of it. Record the
153
+ // transition on the handoff's own ledger and disarm the net.
154
+ try {
155
+ const sid = String(input.session_id || "");
156
+ if (sid) {
157
+ const stampPath = join(handoffDir(), `recap-pending-${sid.replace(/[^A-Za-z0-9_.-]/g, "_")}.json`);
158
+ if (existsSync(stampPath)) {
159
+ try {
160
+ const stamp = JSON.parse(readFileSync(stampPath, "utf8"));
161
+ appendHandoffState(stamp.handoffId, "recapped", sid);
162
+ } catch {}
163
+ try { unlinkSync(stampPath); } catch {}
164
+ }
165
+ }
166
+ } catch {}
150
167
  // Mirror the other hooks: a home-directory session isn't project work and isn't on the bus.
151
168
  if (!process.env.RELAY_SESSION && !process.env.RELAY_PROJECT && projectDir === homedir()) return allow();
152
169
 
package/lib/autonomy.mjs CHANGED
@@ -32,9 +32,16 @@ export const DEFAULTS = Object.freeze({
32
32
  deploy: false,
33
33
  swapDeadSeat: true, // replacing an exhausted seat costs nothing and loses nothing
34
34
  retryFailedTurn: true,
35
+ // Who pulls the handoff trigger at the context warn line (#5509 W2, SYSTEM-CONTRACT §5).
36
+ // "ask" (default): the app's banner asks; the heartbeat neither arms nor auto-fires — the
37
+ // 2026-08-28 silent handoff is the incident this default answers. "auto": the proven
38
+ // arm-at-warn → fire-at-turn-boundary chain. PreCompact remains the at-the-wall backstop
39
+ // in BOTH modes: dying mid-compaction is strictly worse than an unasked handoff.
40
+ baton: "ask", // ask | auto
35
41
  });
36
42
 
37
43
  const HARNESS = ["prompt", "bypass"];
44
+ const BATON = ["ask", "auto"];
38
45
 
39
46
  export function loadAutonomy() {
40
47
  const p = AUTONOMY_PATH();
@@ -68,6 +75,7 @@ export function resolveAutonomy(project, cfg = loadAutonomy()) {
68
75
  deploy: !!merged.deploy,
69
76
  swapDeadSeat: merged.swapDeadSeat !== false,
70
77
  retryFailedTurn: merged.retryFailedTurn !== false,
78
+ baton: BATON.includes(merged.baton) ? merged.baton : DEFAULTS.baton,
71
79
  };
72
80
  if (!out.commit) out.push = false;
73
81
  if (!out.push) out.deploy = false;
package/lib/balances.mjs CHANGED
@@ -51,7 +51,15 @@ export const ADAPTERS = [
51
51
  const win = (w, name) => (w && w.utilization != null)
52
52
  ? { name, usedPct: Math.round(w.utilization), resetsAt: w.resets_at || null, locked: w.locked_reason || null }
53
53
  : null;
54
- return { windows: [win(d.five_hour, "5h"), win(d.seven_day, "7d")].filter(Boolean) };
54
+ // Model-scoped weekly limits ride limits[] (kind "weekly_scoped", scope.model.display_name
55
+ // e.g. "Fable") — Orca shows these as their own segment ("36% used Fable") and so do we.
56
+ // Captured live 2026-08-30: session/weekly_all in limits[] duplicate five_hour/seven_day,
57
+ // so only the scoped entries add information.
58
+ const scoped = (Array.isArray(d.limits) ? d.limits : [])
59
+ .filter((l) => l && l.kind === "weekly_scoped" && l.percent != null && l.scope?.model?.display_name)
60
+ .map((l) => ({ name: String(l.scope.model.display_name), usedPct: Math.round(l.percent),
61
+ resetsAt: l.resets_at || null, locked: null, scoped: true }));
62
+ return { windows: [win(d.five_hour, "5h"), win(d.seven_day, "7d"), ...scoped].filter(Boolean) };
55
63
  },
56
64
  },
57
65
 
@@ -168,17 +176,51 @@ export async function fetchBalances(env = process.env, opts = {}) {
168
176
  catch (e) { return { ...base, ok: false, error: String(e?.message || e) }; }
169
177
  });
170
178
  const rows = (await Promise.all(jobs)).filter(Boolean);
171
- // Codex has no balance API to query it authenticates by `codex login` and bills a
172
- // subscription. The fleet list must still show it, honestly, or the header reads as if the
173
- // seat does not exist. Evidence of configuration is the login artifact, not an env key.
174
- if (!only || only.has("codex") || only.has("openai")) {
179
+ // Codex: OpenAI publishes no public balance API, but the Codex CLI's OWN token can read the
180
+ // ChatGPT backend's usage windows the same source Orca's footer reads (#5570; verified live
181
+ // 2026-08-30: plan_type + primary/secondary_window {used_percent, reset_at}). Evidence of
182
+ // configuration is the login artifact (~/.codex/auth.json), not an env key. The token never
183
+ // leaves the process; only percentages are reported. If the endpoint is unreachable, fall
184
+ // back to the honest subscription row this used to be — never an error row for a seat that
185
+ // bills flat.
186
+ // Same profile gate as every adapter (latent bug from the v6 static row, surfaced 2026-08-30:
187
+ // `!only ||` made a codex row appear with NO profile at all — "better empty than wrong").
188
+ if (only && (only.has("codex") || only.has("openai"))) {
175
189
  try {
176
- const { existsSync } = await import("node:fs");
190
+ const { existsSync, readFileSync } = await import("node:fs");
177
191
  const { join } = await import("node:path");
178
192
  const { homedir } = await import("node:os");
179
- if (existsSync(join(homedir(), ".codex", "auth.json"))) {
180
- rows.push({ provider: "codex", label: "Codex", kind: "subscription", via: "codex login",
181
- ok: true, plan: "OpenAI subscription", note: "no balance API flat subscription" });
193
+ const authPath = join(homedir(), ".codex", "auth.json");
194
+ if (existsSync(authPath)) {
195
+ const base = { provider: "codex", label: "Codex", via: "codex login", ok: true };
196
+ try {
197
+ const tok = JSON.parse(readFileSync(authPath, "utf8"))?.tokens || {};
198
+ if (!tok.access_token) throw new Error("no codex token");
199
+ const r = await fetch("https://chatgpt.com/backend-api/wham/usage", {
200
+ headers: {
201
+ authorization: `Bearer ${tok.access_token}`,
202
+ "ChatGPT-Account-Id": tok.account_id || "",
203
+ "User-Agent": "codex-cli", "OpenAI-Beta": "codex-1", originator: "Codex Desktop",
204
+ },
205
+ signal: AbortSignal.timeout(8000),
206
+ });
207
+ if (!r.ok) throw new Error(`wham/usage ${r.status}`);
208
+ const d = await r.json();
209
+ const win = (w, fallbackName) => {
210
+ if (!w || w.used_percent == null) return null;
211
+ const secs = Number(w.limit_window_seconds) || 0;
212
+ const name = secs === 604800 ? "7d" : secs > 0 && secs <= 21600 ? "5h"
213
+ : secs > 0 ? `${Math.round(secs / 3600)}h` : fallbackName;
214
+ return { name, usedPct: Math.round(w.used_percent),
215
+ resetsAt: w.reset_at ? Number(w.reset_at) * 1000 : null, locked: null };
216
+ };
217
+ const windows = [win(d.rate_limit?.primary_window, "5h"), win(d.rate_limit?.secondary_window, "7d")].filter(Boolean);
218
+ if (!windows.length) throw new Error("no usage windows in response");
219
+ rows.push({ ...base, kind: "windows", plan: d.plan_type ? `ChatGPT ${d.plan_type}` : "OpenAI subscription", windows });
220
+ } catch {
221
+ rows.push({ ...base, kind: "subscription",
222
+ plan: "OpenAI subscription", note: "usage endpoint unreachable — flat subscription" });
223
+ }
182
224
  }
183
225
  } catch { /* no fs access → no row, never an error */ }
184
226
  }
package/lib/project.mjs CHANGED
@@ -6,7 +6,7 @@
6
6
  // RELAY_PROJECT always wins (deliberate override / crew inheritance). The hub
7
7
  // applies an alias map on top of this to fold any historical divergence.
8
8
  import { execSync } from "node:child_process";
9
- import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, statSync } from "node:fs";
9
+ import { readFileSync, writeFileSync, appendFileSync, existsSync, mkdirSync, readdirSync, statSync } from "node:fs";
10
10
  import { basename, join, dirname } from "node:path";
11
11
  import { homedir, hostname } from "node:os";
12
12
 
@@ -93,14 +93,25 @@ export function orchWriterSid(projectDir, project, { withinMs = 90_000, env = pr
93
93
  } catch {}
94
94
  return "";
95
95
  }
96
- export function writeOrchSession(project, sid) {
96
+ export function writeOrchSession(project, sid, by = "unknown") {
97
97
  try {
98
98
  if (!project || !sid) return false;
99
99
  const p = orchSessionsPath();
100
100
  mkdirSync(dirname(p), { recursive: true });
101
101
  const rows = existsSync(p) ? readFileSync(p, "utf8").split("\n").filter(Boolean) : [];
102
+ const prev = rows.find(r => r.split("\t")[0] === project)?.split("\t")[1] || "";
102
103
  const kept = rows.filter(r => r.split("\t")[0] !== project);
103
104
  writeFileSync(p, [...kept, `${project}\t${sid}`].join("\n") + "\n");
105
+ // Every rewrite leaves a row in the sibling log (SYSTEM-CONTRACT §4: the map has exactly
106
+ // three writers, and a rewrite must be attributable after the fact). Local and append-only
107
+ // on purpose: this runs inside hooks with no time for network, and the interesting rewrite
108
+ // — a handoff claim — additionally becomes a bus event when the Phase 4 state machine lands.
109
+ if (prev !== sid) {
110
+ try {
111
+ appendFileSync(join(busDir(), "orch-sessions.log"),
112
+ `${new Date().toISOString()}\t${project}\t${prev || "-"}\t${sid}\t${by}\n`);
113
+ } catch {}
114
+ }
104
115
  return true;
105
116
  } catch { return false; }
106
117
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trantor",
3
- "version": "0.18.15",
3
+ "version": "0.18.17",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "trantor": "bin/cli.mjs"