trantor 0.18.16 → 0.18.18
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/README.md +8 -0
- package/bin/baton-pane.mjs +130 -0
- package/bin/drill-surface.mjs +80 -6
- package/bin/write-handoff.mjs +11 -6
- package/hooks/heartbeat.mjs +26 -1
- package/hooks/lib/handoff.mjs +101 -6
- package/hooks/prompt-focus.mjs +6 -1
- package/hooks/sessionstart.mjs +33 -4
- package/lib/balances.mjs +51 -9
- package/package.json +1 -1
- package/skills/handoff/SKILL.md +5 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "trantor",
|
|
3
|
-
"version": "0.18.
|
|
3
|
+
"version": "0.18.18",
|
|
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/README.md
CHANGED
|
@@ -348,6 +348,14 @@ same project **takes over with a brand-new full context window**. Works manually
|
|
|
348
348
|
agent via `relay_handoff`. Optional macOS auto-prompt (`autoHandoffPrompt` in
|
|
349
349
|
`~/.agent-bus/config.json`) offers to open the fresh session for you, with a timeout.
|
|
350
350
|
|
|
351
|
+
Since 0.18.18 the succession is a machine, not a ritual: at 90% context the running agent is
|
|
352
|
+
told to finish or checkpoint and author the boundary handoff itself; the app's banner counts
|
|
353
|
+
down ("handing off in 10s") when the dial allows; an automatic digest defers to a fresh
|
|
354
|
+
model-authored handoff instead of superseding it; the successor is injected a capped ≤4KB recap
|
|
355
|
+
(the verbatim tail stays on disk, one path away) and gets a kickoff prompt so it recaps without
|
|
356
|
+
being spoken to; and a session hosted in a Workspace pane is replaced in place by a detached
|
|
357
|
+
driver — the same chain the app's [Hand off now] button runs.
|
|
358
|
+
|
|
351
359
|
Why crews never exhaust the orchestrator: bus messages are **by reference** (~70 tokens),
|
|
352
360
|
work products stay in each agent's own context — the orchestrator burns at coordination
|
|
353
361
|
rate, not work rate.
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// The --baton pane leg (#5643). When the dying session lives in a hosted pane, the Terminal
|
|
3
|
+
// spawn is exactly wrong (the fresh window lands on the surface the operator is leaving), so
|
|
4
|
+
// spawnFresh refuses — and until now the chain dead-ended at "open a new session manually".
|
|
5
|
+
// A session cannot replace itself (SYSTEM-CONTRACT §5): this helper is the outside hand, run
|
|
6
|
+
// DETACHED from the dying session so it survives it. The chain mirrors the app's proven
|
|
7
|
+
// handoff_now (lib.rs): idle-gate → graceful end → reopen → kickoff prompt over the socket.
|
|
8
|
+
//
|
|
9
|
+
// node bin/baton-pane.mjs --project <dir> --handoff <file> [--pane <id>]
|
|
10
|
+
//
|
|
11
|
+
// Env seams (the drill's off switches, same doctrine as TRANTOR_NO_HANDOFF_SPAWN):
|
|
12
|
+
// TRANTOR_BATON_IDLE_DEADLINE_S give up waiting for idle after this many seconds (default 600)
|
|
13
|
+
// TRANTOR_BATON_REOPEN command to reopen the pane, instead of `trantor open`
|
|
14
|
+
// (the drill points this at its own herdr world)
|
|
15
|
+
// Detached means nobody reads stdout: everything lands in <bus>/logs/baton-pane-<project>.log.
|
|
16
|
+
import { readFileSync, existsSync, mkdirSync, appendFileSync } from "node:fs";
|
|
17
|
+
import { join, basename } from "node:path";
|
|
18
|
+
import { homedir } from "node:os";
|
|
19
|
+
import { execFileSync, execSync } from "node:child_process";
|
|
20
|
+
import { createConnection } from "node:net";
|
|
21
|
+
|
|
22
|
+
const arg = (name) => { const i = process.argv.indexOf(name); return i > 0 ? process.argv[i + 1] : ""; };
|
|
23
|
+
const projectDir = arg("--project") || process.cwd();
|
|
24
|
+
const handoffFile = arg("--handoff");
|
|
25
|
+
const projectName = basename(projectDir);
|
|
26
|
+
const busDir = process.env.AGENT_BUS_DIR || process.env.RELAY_DATA_DIR || join(homedir(), ".agent-bus");
|
|
27
|
+
|
|
28
|
+
const logDir = join(busDir, "logs");
|
|
29
|
+
try { mkdirSync(logDir, { recursive: true }); } catch {}
|
|
30
|
+
const logFile = join(logDir, `baton-pane-${projectName}.log`);
|
|
31
|
+
const log = (s) => { try { appendFileSync(logFile, `${new Date().toISOString()} ${s}\n`); } catch {} };
|
|
32
|
+
|
|
33
|
+
// Same text as the app's kickoff (lib.rs KICKOFF_PROMPT) — one boot prompt so the successor
|
|
34
|
+
// recaps unprompted instead of sitting idle until a human types (the 15-minute silence, #5649).
|
|
35
|
+
const KICKOFF_PROMPT = "You have just taken over via handoff. Recap now per your instructions.";
|
|
36
|
+
|
|
37
|
+
// The pane, resolved exactly like the app does (orch_pane_from_rows): last orch row wins.
|
|
38
|
+
export function orchPane(rows, project) {
|
|
39
|
+
let pane = null;
|
|
40
|
+
for (const l of String(rows).split("\n")) {
|
|
41
|
+
const f = l.split("\t");
|
|
42
|
+
if (f[0] === project && f[1] === "orch" && f[3] && f[3].trim()) pane = f[3].trim();
|
|
43
|
+
}
|
|
44
|
+
return pane;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function herdrJson(args) {
|
|
48
|
+
try { return JSON.parse(execFileSync("herdr", args, { encoding: "utf8", timeout: 15000 })); } catch { return null; }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** One request over herdr's socket — byte-identical to the app's transport (herdr.rs). */
|
|
52
|
+
function socketRequest(req, timeoutMs = 30_000) {
|
|
53
|
+
const sockPath = join(homedir(), ".config", "herdr", "herdr.sock");
|
|
54
|
+
return new Promise((resolve, reject) => {
|
|
55
|
+
const s = createConnection(sockPath);
|
|
56
|
+
const t = setTimeout(() => { s.destroy(); reject(new Error("socket timeout")); }, timeoutMs);
|
|
57
|
+
let buf = "";
|
|
58
|
+
s.on("connect", () => s.write(JSON.stringify(req) + "\n"));
|
|
59
|
+
s.on("data", (d) => {
|
|
60
|
+
buf += d.toString("utf8");
|
|
61
|
+
const nl = buf.indexOf("\n");
|
|
62
|
+
if (nl >= 0) { clearTimeout(t); s.destroy(); resolve(buf.slice(0, nl)); }
|
|
63
|
+
});
|
|
64
|
+
s.on("error", (e) => { clearTimeout(t); reject(e); });
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
|
|
69
|
+
const agentStatus = (pane) => herdrJson(["agent", "get", pane])?.result?.agent?.agent_status || null;
|
|
70
|
+
const alive = (pid) => { try { process.kill(pid, 0); return true; } catch { return false; } };
|
|
71
|
+
|
|
72
|
+
async function main() {
|
|
73
|
+
if (!handoffFile || !existsSync(handoffFile)) { log(`no handoff file (${handoffFile}) — abort`); process.exit(1); }
|
|
74
|
+
const pane = arg("--pane") || orchPane((() => { try { return readFileSync(join(busDir, "crew-windows.txt"), "utf8"); } catch { return ""; } })(), projectName);
|
|
75
|
+
if (!pane) { log(`no orch pane row for ${projectName} — abort (the window path should have run instead)`); process.exit(1); }
|
|
76
|
+
log(`armed: pane=${pane} handoff=${basename(handoffFile)}`);
|
|
77
|
+
|
|
78
|
+
// 1. Idle gate. The dying session invoked us MID-TURN (the skill's Bash call); ending it now
|
|
79
|
+
// would kill in-flight work — the exact failure #5645 exists to prevent. Wait for the turn
|
|
80
|
+
// boundary, with a deadline so a hung session doesn't pin this process forever.
|
|
81
|
+
const deadline = Date.now() + (Number(process.env.TRANTOR_BATON_IDLE_DEADLINE_S) || 600) * 1000;
|
|
82
|
+
let st;
|
|
83
|
+
while ((st = agentStatus(pane)) === "working") {
|
|
84
|
+
if (Date.now() > deadline) { log(`idle deadline passed (still ${st}) — giving up, handoff waits on disk`); process.exit(1); }
|
|
85
|
+
await sleep(3000);
|
|
86
|
+
}
|
|
87
|
+
log(`idle gate passed (status=${st ?? "no agent"})`);
|
|
88
|
+
|
|
89
|
+
// 2. Graceful end, mirroring end_process_gracefully: TERM, short wait, KILL.
|
|
90
|
+
const info = herdrJson(["pane", "process-info", "--pane", pane])?.result?.process_info;
|
|
91
|
+
const pid = Number(info?.foreground_process_group_id) || Number(info?.foreground_processes?.[0]?.pid) || 0;
|
|
92
|
+
if (pid > 0 && alive(pid)) {
|
|
93
|
+
try { process.kill(pid, "SIGTERM"); } catch {}
|
|
94
|
+
const killAt = Date.now() + 10_000;
|
|
95
|
+
while (alive(pid) && Date.now() < killAt) await sleep(200);
|
|
96
|
+
if (alive(pid)) { try { process.kill(pid, "SIGKILL"); } catch {} }
|
|
97
|
+
log(`ended pid ${pid}`);
|
|
98
|
+
} else {
|
|
99
|
+
log("no foreground process to end (already gone)");
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// 3. Reopen. `trantor open` rebinds orch-sessions.txt and restarts the pane's session — the
|
|
103
|
+
// bookkeeping the classic seam regression comes from skipping. The drill overrides this to
|
|
104
|
+
// stay inside its own herdr world.
|
|
105
|
+
const reopen = process.env.TRANTOR_BATON_REOPEN || "trantor open";
|
|
106
|
+
try {
|
|
107
|
+
execSync(reopen, { cwd: projectDir, encoding: "utf8", timeout: 120_000, stdio: "pipe" });
|
|
108
|
+
log(`reopened via: ${reopen}`);
|
|
109
|
+
} catch (e) {
|
|
110
|
+
log(`reopen FAILED (${String(e?.message).slice(0, 200)}) — handoff waits on disk`);
|
|
111
|
+
process.exit(1);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// 4. Kickoff — retry while the successor boots; a NotReady prompt would land on nobody.
|
|
115
|
+
for (let i = 0; i < 20; i++) {
|
|
116
|
+
if (agentStatus(pane) === "idle") break;
|
|
117
|
+
await sleep(3000);
|
|
118
|
+
}
|
|
119
|
+
try {
|
|
120
|
+
const raw = await socketRequest({ id: "trantor:agent.prompt", method: "agent.prompt", params: { target: pane, text: KICKOFF_PROMPT } });
|
|
121
|
+
log(`kickoff: ${String(JSON.parse(raw).result?.type)}`);
|
|
122
|
+
} catch (e) {
|
|
123
|
+
log(`kickoff FAILED (${String(e?.message).slice(0, 120)}) — successor may sit idle until spoken to`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Import-safe (the drill imports orchPane): only run as a script.
|
|
128
|
+
if (process.argv[1] && basename(process.argv[1]) === "baton-pane.mjs") {
|
|
129
|
+
main().catch(e => { log(`crash: ${String(e?.stack || e).slice(0, 400)}`); process.exit(1); });
|
|
130
|
+
}
|
package/bin/drill-surface.mjs
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
import { mkdirSync, rmSync, existsSync, readFileSync, writeFileSync, readdirSync, statSync } from "node:fs";
|
|
17
17
|
import { join, basename } from "node:path";
|
|
18
18
|
import { homedir, tmpdir } from "node:os";
|
|
19
|
-
import { execFileSync, execSync } from "node:child_process";
|
|
19
|
+
import { execFileSync, execSync, spawn } from "node:child_process";
|
|
20
20
|
import { createConnection } from "node:net";
|
|
21
21
|
|
|
22
22
|
const KEEP = process.argv.includes("--keep");
|
|
@@ -234,15 +234,23 @@ let predecessorSid = null;
|
|
|
234
234
|
step("S4 · handoff machine: warn → arm → fire → WRITTEN → successor claims → RECAPPED");
|
|
235
235
|
{
|
|
236
236
|
// A turn that uses a tool: the heartbeat is PostToolUse, so only tool use can arm the baton.
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
const
|
|
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
241
|
try {
|
|
242
242
|
const f = readdirSync(join(bus, "handoffs")).find(x => x.startsWith(`${projectName}-`) && x.endsWith(".json"));
|
|
243
243
|
return f ? join(bus, "handoffs", f) : null;
|
|
244
244
|
} catch { return null; }
|
|
245
|
-
}
|
|
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
|
+
}
|
|
246
254
|
if (!handoffFile) {
|
|
247
255
|
FAIL("the armed baton fired and WROTE a handoff at the turn boundary");
|
|
248
256
|
} else {
|
|
@@ -289,6 +297,72 @@ if (globalThis.__skew && fail > 0) {
|
|
|
289
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.`);
|
|
290
298
|
}
|
|
291
299
|
|
|
300
|
+
// ---------- S4b · the --baton pane leg (#5643) ----------
|
|
301
|
+
// The CLI-side replacement chain: a MANUAL handoff on disk, then bin/baton-pane.mjs (the detached
|
|
302
|
+
// driver spawnBaton arms for hosted panes) replaces the pane's session in place — idle gate,
|
|
303
|
+
// graceful end, reopen, kickoff — and the successor claims AND recaps with no human prompt.
|
|
304
|
+
// The reopen is overridden to stay inside this drill's herdr world; the chain is otherwise the
|
|
305
|
+
// production one. The routing itself (spawnBaton → driver, never a window) is drilled hermetically
|
|
306
|
+
// in test-handoff.mjs.
|
|
307
|
+
step("S4b · --baton pane leg: the driver replaces the session in place, kickoff recaps it");
|
|
308
|
+
{
|
|
309
|
+
let st0 = null;
|
|
310
|
+
try { st0 = herdr(["agent", "get", pane]).result?.agent?.agent_status || null; } catch {}
|
|
311
|
+
if (!st0) {
|
|
312
|
+
SKIP("--baton pane leg", "no live agent in the pane (S4 did not leave a successor)");
|
|
313
|
+
} else {
|
|
314
|
+
let hf = null;
|
|
315
|
+
try {
|
|
316
|
+
const wh = execFileSync(process.execPath, [join(import.meta.dirname, "write-handoff.mjs")], {
|
|
317
|
+
input: "# handoff\nS4b: the pane leg drill — recap me.", encoding: "utf8", timeout: 30_000,
|
|
318
|
+
env: { ...process.env, CLAUDE_PROJECT_DIR: proj, AGENT_BUS_DIR: bus, TRANTOR_NO_HANDOFF_SPAWN: "1", TRANTOR_NO_BATON_SPAWN: "1" },
|
|
319
|
+
});
|
|
320
|
+
hf = /handoff saved: (\S+\.json)/.exec(wh)?.[1] || null;
|
|
321
|
+
} catch (e) { FAIL("S4b manual handoff written", String(e.message || e).slice(0, 120)); }
|
|
322
|
+
if (hf) {
|
|
323
|
+
PASS("manual handoff written for the pane to carry", basename(hf));
|
|
324
|
+
const child = spawn(process.execPath, [join(import.meta.dirname, "baton-pane.mjs"),
|
|
325
|
+
"--project", proj, "--handoff", hf, "--pane", pane], {
|
|
326
|
+
env: { ...process.env, AGENT_BUS_DIR: bus, TRANTOR_BATON_IDLE_DEADLINE_S: "90",
|
|
327
|
+
TRANTOR_BATON_REOPEN: `herdr agent start drill3 --kind claude --pane ${pane}` },
|
|
328
|
+
stdio: "ignore",
|
|
329
|
+
});
|
|
330
|
+
const exited = new Promise(res => child.on("exit", c => res(c)));
|
|
331
|
+
// Environment noise, not the leg: a fresh session may block on the trust dialog; answer it
|
|
332
|
+
// the way startClaude does so the kickoff has someone to land on.
|
|
333
|
+
const watcher = (async () => {
|
|
334
|
+
for (let i = 0; i < 40; i++) {
|
|
335
|
+
await sleep(3000);
|
|
336
|
+
try {
|
|
337
|
+
const g = herdr(["agent", "get", "drill3"]);
|
|
338
|
+
const st = g.result?.agent?.agent_status;
|
|
339
|
+
if (st === "blocked") herdr(["agent", "send-keys", "drill3", "enter"]);
|
|
340
|
+
if (st === "idle") break;
|
|
341
|
+
} catch {}
|
|
342
|
+
}
|
|
343
|
+
})();
|
|
344
|
+
const code = await Promise.race([exited, sleep(180_000).then(() => "timeout")]);
|
|
345
|
+
await watcher;
|
|
346
|
+
if (code === 0) PASS("driver ran the whole chain (idle gate → graceful end → reopen → kickoff)");
|
|
347
|
+
else FAIL("driver ran the whole chain", `exit ${code} — see ${join(bus, "logs")}/baton-pane-*.log`);
|
|
348
|
+
const rec = await waitFor("S4b claimed+recapped", () => {
|
|
349
|
+
try {
|
|
350
|
+
const r = JSON.parse(readFileSync(hf, "utf8"));
|
|
351
|
+
const s = (r.states || []).map(x => x.state);
|
|
352
|
+
return s.includes("claimed") && s.includes("recapped") ? r : null;
|
|
353
|
+
} catch { return null; }
|
|
354
|
+
}, { timeoutMs: 120_000, everyMs: 2_000 });
|
|
355
|
+
if (rec) PASS("successor claimed AND recapped — kicked off by the driver, no human prompt",
|
|
356
|
+
rec.states.map(s => s.state).join("→"));
|
|
357
|
+
else {
|
|
358
|
+
let states = "unreadable";
|
|
359
|
+
try { states = JSON.parse(readFileSync(hf, "utf8")).states?.map(s => s.state).join(",") || "none"; } catch {}
|
|
360
|
+
FAIL("successor claimed AND recapped without a human prompt", states);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
292
366
|
// ---------- S5 · takeover ----------
|
|
293
367
|
step("S5 · takeover from a Terminal session");
|
|
294
368
|
SKIP("takeover chain", "needs an interactive Terminal-window session; proven live 2026-08-28 (0.18.13 drill) — automate in drill v2");
|
package/bin/write-handoff.mjs
CHANGED
|
@@ -6,7 +6,7 @@ import { writeFileSync, readFileSync, existsSync, mkdirSync, readdirSync } from
|
|
|
6
6
|
import { join, basename } from "node:path";
|
|
7
7
|
import { homedir, hostname } from "node:os";
|
|
8
8
|
import { execSync } from "node:child_process";
|
|
9
|
-
import { spawnBaton } from "../hooks/lib/handoff.mjs";
|
|
9
|
+
import { spawnBaton, handoffMode } from "../hooks/lib/handoff.mjs";
|
|
10
10
|
import { handoffDir } from "../lib/project.mjs";
|
|
11
11
|
|
|
12
12
|
const baton = process.argv.includes("--baton");
|
|
@@ -46,19 +46,24 @@ if (latest) {
|
|
|
46
46
|
.find(p => { try { return JSON.parse(readFileSync(p, "utf8")).consumed === false; } catch { return false; } });
|
|
47
47
|
if (!found) { console.error(`no unconsumed handoff for "${name}" in ${dir} — write one first (pipe it in), then baton it`); process.exit(1); }
|
|
48
48
|
console.log(`baton on the existing handoff: ${found}`);
|
|
49
|
-
const
|
|
50
|
-
if (spawned) console.log(
|
|
49
|
+
const r = spawnBaton({ projectDir: project, handoffFile: found });
|
|
50
|
+
if (r.pane && r.spawned) console.log("baton: pane replacement armed (#5643) — this session ends at the turn boundary and the pane reopens fresh, self-recapping");
|
|
51
|
+
else if (r.spawned) console.log(`baton: fresh session opening (self-recapping)${r.armed ? ` — this window (${r.windowId}) closes once it takes over` : ""}`);
|
|
51
52
|
else console.log("baton: could not spawn a fresh session (non-macOS or spawn disabled) — handoff is saved, open a new session manually");
|
|
52
53
|
process.exit(0);
|
|
53
54
|
}
|
|
54
55
|
|
|
55
|
-
|
|
56
|
+
// The manual skill path opens the §5 ledger with WRITTEN like every other writer (#5642), and
|
|
57
|
+
// carries the same interface the hooks-side records have: transcript_path ("" — the summary IS
|
|
58
|
+
// the model's own words), mode (attended|unattended, #5648).
|
|
59
|
+
const rec = { id: `${name}-${stamp}`, project, projectName: name, machine: hostname(), trigger: baton ? "manual-baton" : "manual-skill", stamp: Number(stamp) || 0, summary: summary.trim() || "(empty)", transcript_path: "", mode: handoffMode(name), gitStatus: git, consumed: false, states: [{ state: "written", ts: Number(stamp) || 0, by: baton ? "manual-baton" : "manual-skill" }] };
|
|
56
60
|
const file = join(dir, `${rec.id}.json`);
|
|
57
61
|
writeFileSync(file, JSON.stringify(rec, null, 2));
|
|
58
62
|
console.log(`handoff saved: ${file}`);
|
|
59
63
|
|
|
60
64
|
if (baton) {
|
|
61
|
-
const
|
|
62
|
-
if (spawned) console.log(
|
|
65
|
+
const r = spawnBaton({ projectDir: project, handoffFile: file });
|
|
66
|
+
if (r.pane && r.spawned) console.log("baton: pane replacement armed (#5643) — this session ends at the turn boundary and the pane reopens fresh, self-recapping");
|
|
67
|
+
else if (r.spawned) console.log(`baton: fresh session opening (self-recapping)${r.armed ? ` — this window (${r.windowId}) closes once it takes over` : " — original window left open (couldn't detect it)"}`);
|
|
63
68
|
else console.log(`baton: could not spawn a fresh session (non-macOS or spawn disabled) — handoff saved, open a new session manually`);
|
|
64
69
|
}
|
package/hooks/heartbeat.mjs
CHANGED
|
@@ -29,6 +29,16 @@ const ARM_MAX_MS = Number(process.env.TRANTOR_BATON_ARM_MAX_MS || 15 * 60 * 1000
|
|
|
29
29
|
const INFLIGHT_MS = 5 * 60 * 1000;
|
|
30
30
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
31
31
|
|
|
32
|
+
// #5645 agent-aware succession: the moment the baton ARMS (warn frac, auto dial), the running agent
|
|
33
|
+
// is TOLD — via this hook's PostToolUse additionalContext, the one sanctioned channel that reaches a
|
|
34
|
+
// session mid-flow without driving its terminal. The agent then participates in its own succession:
|
|
35
|
+
// it reaches a real task boundary and authors the model-written handoff from the FRESHEST state,
|
|
36
|
+
// instead of the digest describing work 36 seconds stale (2026-08-24) or missing everything done
|
|
37
|
+
// after an early handoff write. Injected ONCE per arming (the fresh-arm path only) — a per-tool-call
|
|
38
|
+
// reminder would be context spam. Guards stay upstream: no arm mid-sub-agent build, no arm in 'ask'
|
|
39
|
+
// dial mode, so neither produces the notice.
|
|
40
|
+
let ARM_CTX = "";
|
|
41
|
+
|
|
32
42
|
// The model this session is ACTUALLY running, read from the transcript tail — the harness does not
|
|
33
43
|
// hand hooks a model field, but every assistant entry records one. Tail-read only (transcripts grow
|
|
34
44
|
// to MBs); any failure returns "" and the peer simply keeps its last known model.
|
|
@@ -143,6 +153,11 @@ async function maybeEarlyWarn(stdinRaw, session) {
|
|
|
143
153
|
// forever. It is marked where the baton actually fires: here on the backstop path, and in the
|
|
144
154
|
// Stop hook on the normal path. Re-arming every tick is prevented by the age check above.
|
|
145
155
|
armBaton(sessionId, { projectDir, transcript, reason: "context-warn", windowId, tty, tokens: usage.tokens });
|
|
156
|
+
// Tell the RUNNING agent (once, at arm time): wrap up and author the boundary handoff yourself.
|
|
157
|
+
ARM_CTX = `<system-reminder>TRANTOR SUCCESSION ARMED — this session is at ${Math.round(usage.frac * 100)}% of its context window, and the baton is armed: your NEXT STOP fires the handoff. You are now responsible for your own succession:\n`
|
|
158
|
+
+ `1. Reach a real task boundary — finish, pause, or checkpoint your in-flight work NOW; do not start new work.\n`
|
|
159
|
+
+ `2. Author (or refresh) the rich handoff from your CURRENT state — via the handoff skill or the relay_handoff MCP tool — so the successor gets the freshest picture, not a digest of a session 36 seconds stale. A fresh model-authored handoff supersedes the auto-digest.\n`
|
|
160
|
+
+ `3. Then end your turn. The Stop hook fires the baton at that boundary.</system-reminder>`;
|
|
146
161
|
} catch {}
|
|
147
162
|
}
|
|
148
163
|
|
|
@@ -216,4 +231,14 @@ async function main(stdinRaw) {
|
|
|
216
231
|
}
|
|
217
232
|
|
|
218
233
|
// Never block or break the tool flow: swallow everything, always exit clean.
|
|
219
|
-
|
|
234
|
+
// stdout carries the arm-time succession notice (above) when — and only when — this tick armed
|
|
235
|
+
// the baton; every other tick emits "{}", exactly as inbox-deliver.mjs does on a quiet poll.
|
|
236
|
+
function emitAndExit() {
|
|
237
|
+
try {
|
|
238
|
+
process.stdout.write(ARM_CTX
|
|
239
|
+
? JSON.stringify({ hookSpecificOutput: { hookEventName: "PostToolUse", additionalContext: ARM_CTX } })
|
|
240
|
+
: "{}");
|
|
241
|
+
} catch {}
|
|
242
|
+
process.exit(0);
|
|
243
|
+
}
|
|
244
|
+
readStdin().then(main).catch(() => {}).finally(emitAndExit);
|
package/hooks/lib/handoff.mjs
CHANGED
|
@@ -17,6 +17,7 @@ import { execSync, spawn } from "node:child_process";
|
|
|
17
17
|
import { fileURLToPath } from "node:url";
|
|
18
18
|
import { deriveSubagentManifest } from "../../lib/subagent-manifest.mjs";
|
|
19
19
|
import { signedPost } from "./api.mjs";
|
|
20
|
+
import { loadAutonomy, resolveAutonomy } from "../../lib/autonomy.mjs";
|
|
20
21
|
|
|
21
22
|
// Writer and reader MUST resolve the same directory — see lib/project.mjs busDir(). This used to
|
|
22
23
|
// honour only RELAY_DATA_DIR while the reader honoured neither override.
|
|
@@ -307,6 +308,68 @@ export function verbatimRecentTail(transcript, chars = 7000) {
|
|
|
307
308
|
try { return collectTurns(transcript).join("\n\n").slice(-chars); } catch { return ""; }
|
|
308
309
|
}
|
|
309
310
|
|
|
311
|
+
// ---- #5648: handoff writer discipline --------------------------------------
|
|
312
|
+
// The inline summary is the RECAP, not the record: the successor reads it as a hook injection,
|
|
313
|
+
// and an oversized injection gets persisted to a file the successor re-reads — paying twice for
|
|
314
|
+
// the same context. Cap the composed digest at ~4KB. When it must cut, keep BOTH ends a successor
|
|
315
|
+
// needs — the opening (task & goal framing) and the tail (current state) — cutting each on a
|
|
316
|
+
// paragraph boundary, with an explicit elision marker so nobody mistakes the middle for missing.
|
|
317
|
+
export function capSummary(text, cap = 4096) {
|
|
318
|
+
const s = String(text || "");
|
|
319
|
+
if (s.length <= cap) return s;
|
|
320
|
+
const elide = "\n\n[…]\n\n";
|
|
321
|
+
const headRaw = s.slice(0, Math.max(0, cap - elide.length - 2048));
|
|
322
|
+
const hCut = headRaw.lastIndexOf("\n\n");
|
|
323
|
+
const head = hCut > 200 ? headRaw.slice(0, hCut) : headRaw;
|
|
324
|
+
let tail = s.slice(s.length - (cap - head.length - elide.length));
|
|
325
|
+
const tCut = tail.indexOf("\n\n");
|
|
326
|
+
if (tCut > 0 && tCut < 2000) tail = tail.slice(tCut + 2); // drop the partial opening line
|
|
327
|
+
return head + elide + tail;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// How fresh a model-authored handoff must be before an automatic digest DEFERS to it: 15 minutes.
|
|
331
|
+
// Older than that, the state it describes has likely moved on — compose fresh.
|
|
332
|
+
const FRESH_HANDOFF_SEC = 15 * 60;
|
|
333
|
+
const MANUAL_TRIGGERS = ["manual-skill", "manual-baton"];
|
|
334
|
+
|
|
335
|
+
// The newest unconsumed MODEL-authored handoff (the /trantor:handoff skill / manual baton path)
|
|
336
|
+
// for this project, if it is still fresh. Incident (#5648): minutes after the operator hand-wrote
|
|
337
|
+
// trantor-1788141357, an automatic digest recomposed and SUPERSEDED it — the successor then
|
|
338
|
+
// loaded the machine's lossy summary instead of the author's exact words. The digest is the
|
|
339
|
+
// fallback, never the replacement.
|
|
340
|
+
export function freshAuthoredHandoff(projectName, nowS = nowSec() || Math.floor(Date.now() / 1000)) {
|
|
341
|
+
try {
|
|
342
|
+
if (!existsSync(HANDOFF_DIR)) return null;
|
|
343
|
+
const re = new RegExp("^" + String(projectName).replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + "-(\\d+)\\.json$");
|
|
344
|
+
const cands = readdirSync(HANDOFF_DIR)
|
|
345
|
+
.map(f => { const m = re.exec(f); return m ? { f, stamp: Number(m[1]) } : null; })
|
|
346
|
+
.filter(Boolean)
|
|
347
|
+
.sort((a, b) => b.stamp - a.stamp)
|
|
348
|
+
.map(x => join(HANDOFF_DIR, x.f));
|
|
349
|
+
for (const p of cands) {
|
|
350
|
+
try {
|
|
351
|
+
const r = JSON.parse(readFileSync(p, "utf8"));
|
|
352
|
+
if (r.consumed !== false) continue;
|
|
353
|
+
if (!MANUAL_TRIGGERS.includes(r.trigger)) continue;
|
|
354
|
+
if (nowS - (Number(r.stamp) || 0) > FRESH_HANDOFF_SEC) continue;
|
|
355
|
+
return r;
|
|
356
|
+
} catch {}
|
|
357
|
+
}
|
|
358
|
+
} catch {}
|
|
359
|
+
return null;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// mode:"attended"|"unattended" on every handoff record (#5648) — WHO pulls the baton trigger.
|
|
363
|
+
// Read from the resolved autonomy dials (the same JSON `trantor autonomy json` prints):
|
|
364
|
+
// baton:"auto" means the arm-at-warn → fire-at-turn-boundary chain runs itself (unattended);
|
|
365
|
+
// the default "ask" keeps the operator in the loop (attended). Fail closed to "attended".
|
|
366
|
+
export function handoffMode(projectName) {
|
|
367
|
+
try {
|
|
368
|
+
const a = resolveAutonomy(projectName, loadAutonomy());
|
|
369
|
+
return a.baton === "auto" ? "unattended" : "attended";
|
|
370
|
+
} catch { return "attended"; }
|
|
371
|
+
}
|
|
372
|
+
|
|
310
373
|
// ---- write + announce + spawn ----------------------------------------------
|
|
311
374
|
/** Append one §5 state transition to a handoff's own file — the machine's ledger rides the
|
|
312
375
|
* record it describes (SYSTEM-CONTRACT §5): every owner of a transition already holds this
|
|
@@ -338,11 +401,18 @@ export function writeHandoff({ projectDir, sessionId, transcript, trigger, summa
|
|
|
338
401
|
} catch {}
|
|
339
402
|
}
|
|
340
403
|
if (!existsSync(HANDOFF_DIR)) mkdirSync(HANDOFF_DIR, { recursive: true });
|
|
404
|
+
// #5648: an automatic digest must never recompose+supersede a FRESH model-authored handoff.
|
|
405
|
+
// If one exists (<15min, unconsumed), point the baton at THAT and write nothing — the caller's
|
|
406
|
+
// spawn path proceeds on the authored handoff exactly as if it had just written it.
|
|
407
|
+
const fresh = freshAuthoredHandoff(projectName);
|
|
408
|
+
if (fresh) return { deferred: true, file: join(HANDOFF_DIR, `${fresh.id}.json`), record: fresh };
|
|
341
409
|
const stamp = nowSec() || Date.now();
|
|
342
410
|
let gitStatus = "";
|
|
343
411
|
try { gitStatus = execSync("git -C " + JSON.stringify(projectDir) + " status --short 2>/dev/null | head -30", { encoding: "utf8" }).trim(); } catch {}
|
|
344
|
-
|
|
345
|
-
|
|
412
|
+
// Cap the composed narrative to the injection budget (~4KB). The verbatim tail is deliberately
|
|
413
|
+
// NOT embedded anymore: the record's transcript_path points at the full exchange, and embedding
|
|
414
|
+
// it here doubled the successor's read for state that was already one path away (#5648).
|
|
415
|
+
const narrative = capSummary(summary ?? buildSummary(transcript));
|
|
346
416
|
// Sub-agent manifest SNAPSHOT (fallback). The successor should re-derive it LIVE via
|
|
347
417
|
// `trantor agents <sid>` (catches files an agent finished that were clobbered AFTER this
|
|
348
418
|
// snapshot — the kill that motivated this corrupted a completed 30KB lib post-handoff). This
|
|
@@ -363,8 +433,10 @@ export function writeHandoff({ projectDir, sessionId, transcript, trigger, summa
|
|
|
363
433
|
project: projectDir, projectName, machine: hostname(),
|
|
364
434
|
session_id: sessionId || "", trigger: trigger || "auto",
|
|
365
435
|
transcript_path: transcript || "", stamp: Number(stamp) || 0,
|
|
366
|
-
//
|
|
367
|
-
summary: narrative
|
|
436
|
+
// recap-sufficient inline summary, capped ~4KB — the full story lives at transcript_path
|
|
437
|
+
summary: narrative,
|
|
438
|
+
// attended|unattended — who pulls the baton trigger (resolved autonomy `baton` dial)
|
|
439
|
+
mode: handoffMode(projectName),
|
|
368
440
|
gitStatus, subagents, verifyGates, consumed: false,
|
|
369
441
|
// The §5 machine's ledger: every transition appends here via appendHandoffState.
|
|
370
442
|
states: [{ state: "written", ts: Number(stamp) || 0, by: sessionId || "" }],
|
|
@@ -543,14 +615,31 @@ export function resolveOriginalWindow() {
|
|
|
543
615
|
return { windowId, tty };
|
|
544
616
|
}
|
|
545
617
|
|
|
618
|
+
// The pane leg of the baton (#5643): hand the replacement to a DETACHED driver (bin/baton-pane.mjs)
|
|
619
|
+
// that survives this session's death — idle-gate → graceful end → trantor open → kickoff. The
|
|
620
|
+
// window machinery (resolve/arm-close) is deliberately absent here: there is no window to close,
|
|
621
|
+
// and the old path's answer ("spawn disabled — open manually") left the operator doing the
|
|
622
|
+
// machine's job by hand.
|
|
623
|
+
export function spawnPaneBaton(projectDir, handoffFile) {
|
|
624
|
+
try {
|
|
625
|
+
const script = join(HERE, "..", "..", "bin", "baton-pane.mjs");
|
|
626
|
+
if (!existsSync(script)) return false;
|
|
627
|
+
const child = spawn(process.execPath, [script, "--project", projectDir, "--handoff", handoffFile], { detached: true, stdio: "ignore" });
|
|
628
|
+
child.unref();
|
|
629
|
+
return true;
|
|
630
|
+
} catch { return false; }
|
|
631
|
+
}
|
|
632
|
+
|
|
546
633
|
// MANUAL one-command baton: spawn the fresh session (no dialog) + arm the close of THIS window once the
|
|
547
634
|
// fresh one consumes the handoff. Returns { spawned, armed, windowId }.
|
|
548
635
|
// ORDER IS LOAD-BEARING: resolve the original window BEFORE spawning. Reversing it is the
|
|
549
636
|
// "successor closes ITSELF" bug — the just-opened window is frontmost, the front-window fallback
|
|
550
637
|
// captures it, and baton-close then kills the FRESH session the moment it takes over. The seams
|
|
551
|
-
// (_resolveWindow/_spawnFresh/_armClose) exist so the ordering can be
|
|
638
|
+
// (_resolveWindow/_spawnFresh/_armClose/_hasPane/_spawnPane) exist so the ordering can be
|
|
639
|
+
// regression-tested headlessly.
|
|
552
640
|
export function spawnBaton({ projectDir, handoffFile, conf = readConfig(),
|
|
553
|
-
_resolveWindow = resolveOriginalWindow, _spawnFresh = spawnFresh, _armClose = armBatonClose
|
|
641
|
+
_resolveWindow = resolveOriginalWindow, _spawnFresh = spawnFresh, _armClose = armBatonClose,
|
|
642
|
+
_hasPane = hasOrchPane, _spawnPane = spawnPaneBaton }) {
|
|
554
643
|
// A DRILL MUST BE ABLE TO SAY NO. There was no such switch, so exercising the baton path in a
|
|
555
644
|
// test opened real Terminal windows running real `claude` sessions in temp directories the test
|
|
556
645
|
// then deleted, each parked on a "do you trust this folder?" prompt. Five of them were found by
|
|
@@ -559,6 +648,12 @@ export function spawnBaton({ projectDir, handoffFile, conf = readConfig(),
|
|
|
559
648
|
if (spawnSuppressed() || conf.batonSpawn === false) {
|
|
560
649
|
return { spawned: false, armed: false, windowId: "", suppressed: true };
|
|
561
650
|
}
|
|
651
|
+
// Hosted pane (#5643): the pane IS the successor surface — no window is resolved, spawned, or
|
|
652
|
+
// armed for closing. The detached driver replaces the session at the turn boundary.
|
|
653
|
+
if (_hasPane(basename(projectDir))) {
|
|
654
|
+
const spawned = _spawnPane(projectDir, handoffFile);
|
|
655
|
+
return { spawned, armed: false, windowId: "", pane: true };
|
|
656
|
+
}
|
|
562
657
|
const { windowId, tty } = _resolveWindow(); // original window FIRST, while it's still frontmost
|
|
563
658
|
const spawned = _spawnFresh(projectDir);
|
|
564
659
|
if (!spawned) return { spawned: false, armed: false, windowId: "" };
|
package/hooks/prompt-focus.mjs
CHANGED
|
@@ -43,7 +43,12 @@ function loadRecapCtx(sessionId) {
|
|
|
43
43
|
const p = join(handoffDir(), `recap-pending-${String(sessionId).replace(/[^A-Za-z0-9_.-]/g, "_")}.json`);
|
|
44
44
|
if (!_ex(p)) return "";
|
|
45
45
|
const rec = JSON.parse(_rf(p, "utf8"));
|
|
46
|
-
|
|
46
|
+
// #5645 mandate pinning: the stamp carries rec.mode — the reminder enforces the SAME succession
|
|
47
|
+
// mandate the sessionstart injection announced, right up to the first Stop that records RECAPPED.
|
|
48
|
+
const mandate = rec.mode === "unattended"
|
|
49
|
+
? " Then RESUME the handoff's OPEN THREADS immediately — they are your work order; this succession is unattended, do NOT wait for the user."
|
|
50
|
+
: " Then WAIT for the user.";
|
|
51
|
+
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.${mandate}</system-reminder>`;
|
|
47
52
|
} catch { return ""; }
|
|
48
53
|
}
|
|
49
54
|
function emitAndExit() {
|
package/hooks/sessionstart.mjs
CHANGED
|
@@ -67,8 +67,10 @@ function loadPendingHandoff(projectName, { claim = true, freshSession = null } =
|
|
|
67
67
|
writeFileSync(p, JSON.stringify(rec, null, 2));
|
|
68
68
|
if (freshSession?.session_id) {
|
|
69
69
|
try {
|
|
70
|
+
// #5645: the mandate rides the stamp too, so prompt-focus's recap reminder pins
|
|
71
|
+
// the SAME rec.mode the injection below announces (attended=WAIT / unattended=RESUME).
|
|
70
72
|
writeFileSync(join(dir, `recap-pending-${String(freshSession.session_id).replace(/[^A-Za-z0-9_.-]/g, "_")}.json`),
|
|
71
|
-
JSON.stringify({ handoffId: rec.id, ts: nowSec() }));
|
|
73
|
+
JSON.stringify({ handoffId: rec.id, ts: nowSec(), mode: rec.mode === "unattended" ? "unattended" : "attended" }));
|
|
72
74
|
} catch {}
|
|
73
75
|
}
|
|
74
76
|
}
|
|
@@ -109,6 +111,26 @@ function sanitize(s) {
|
|
|
109
111
|
return out;
|
|
110
112
|
}
|
|
111
113
|
|
|
114
|
+
// #5645 injection cap: the handoff injection is a POINTER, not a payload. The 2026-08-30 failure:
|
|
115
|
+
// a 22KB record (narrative + embedded verbatim tail) was injected and then re-read, costing ~9% of
|
|
116
|
+
// the fresh window at boot. The writer contract (#5648, hooks/lib) caps rec.summary at ~4KB and
|
|
117
|
+
// keeps the verbatim tail OUT of it; this reader enforces the same bound against older/oversized
|
|
118
|
+
// records — strip any embedded verbatim block, hard-cap on a line boundary, point at the record
|
|
119
|
+
// file + transcript for the rest.
|
|
120
|
+
const HANDOFF_INJECT_CAP = 4096;
|
|
121
|
+
function capHandoffSummary(handoff) {
|
|
122
|
+
let s = String(handoff?.summary || "");
|
|
123
|
+
const marker = s.indexOf("\n---\n## Verbatim recent exchange");
|
|
124
|
+
if (marker > 0) s = s.slice(0, marker);
|
|
125
|
+
if (s.length <= HANDOFF_INJECT_CAP) return s;
|
|
126
|
+
const cut = s.slice(0, HANDOFF_INJECT_CAP);
|
|
127
|
+
const nl = cut.lastIndexOf("\n");
|
|
128
|
+
s = (nl > HANDOFF_INJECT_CAP * 0.6 ? cut.slice(0, nl) : cut).trimEnd();
|
|
129
|
+
let ptr = `\n\n…(summary capped at ${HANDOFF_INJECT_CAP} chars — full record: ${join(handoffDir(), `${handoff.id}.json`)}`;
|
|
130
|
+
if (handoff.transcript_path) ptr += ` · full transcript: ${handoff.transcript_path}`;
|
|
131
|
+
return s + ptr + ")";
|
|
132
|
+
}
|
|
133
|
+
|
|
112
134
|
// Fail-silent wrapper for the optional #4214 resources detection lib (hooks/lib/resources.mjs).
|
|
113
135
|
// A throwing detector — or a half-landed lib whose export isn't a function yet — must never break
|
|
114
136
|
// session start; this returns dft on any error. The lib is itself fail-silent, this is defense-in-depth.
|
|
@@ -454,8 +476,15 @@ try {
|
|
|
454
476
|
if (claimed && stdinObj.session_id) {
|
|
455
477
|
await jpost(`${url}/instance/supersede`, { name: session, exceptInstanceId: String(stdinObj.session_id) }, session).catch(() => {});
|
|
456
478
|
}
|
|
457
|
-
additionalContext += `<trantor-handoff id="${sanitize(handoff.id)}" from="${sanitize(handoff.machine)}" trigger="${sanitize(handoff.trigger)}">\n`;
|
|
458
|
-
|
|
479
|
+
additionalContext += `<trantor-handoff id="${sanitize(handoff.id)}" from="${sanitize(handoff.machine)}" trigger="${sanitize(handoff.trigger)}" mode="${sanitize(handoff.mode === "unattended" ? "unattended" : "attended")}">\n`;
|
|
480
|
+
// #5645 mandate pinning: the successor's orders ride rec.mode (#5644's long-run switch flips it).
|
|
481
|
+
// attended (default) = recap-then-WAIT; unattended = recap-then-RESUME — the handoff's OPEN
|
|
482
|
+
// THREADS are the work order ("handoffs must never be a break").
|
|
483
|
+
if (handoff.mode === "unattended") {
|
|
484
|
+
additionalContext += `🔄 **You are taking over from a prior session that hit its context limit, in UNATTENDED (long-run) mode.** This is a fresh full window. Resume the work below — the prior session's summary, git state, and a pointer to its full transcript (searchable; Foundation/Gaia has it ingested) follow. Continue from "OPEN THREADS & NEXT STEPS"; do not restart from scratch. Recap the task, state, and next step in at most 3 sentences, then RESUME the open threads immediately — they are your work order. Do NOT wait for the user; keep building. Keep replies short: no status tables, no headers, no walls of text.\n\n`;
|
|
485
|
+
} else {
|
|
486
|
+
additionalContext += `🔄 **You are taking over from a prior session that hit its context limit.** This is a fresh full window. Resume the work below — the prior session's summary, git state, and a pointer to its full transcript (searchable; Foundation/Gaia has it ingested) follow. Continue from "OPEN THREADS & NEXT STEPS"; do not restart from scratch. Recap the task, state, and next step in at most 3 sentences, then wait. Keep replies short: no status tables, no headers, no walls of text unless the user explicitly asks for detail.\n\n`;
|
|
487
|
+
}
|
|
459
488
|
// Verification gates FIRST — these are structured "must verify before shipping" claims the prior
|
|
460
489
|
// session couldn't independently prove. They go above the summary on purpose: a safety-critical
|
|
461
490
|
// check must not be skimmed past (the lesson of the lost "verify Gail coefficients" intent).
|
|
@@ -469,7 +498,7 @@ try {
|
|
|
469
498
|
}
|
|
470
499
|
additionalContext += `\n`;
|
|
471
500
|
}
|
|
472
|
-
additionalContext += `## Handoff summary\n${sanitize(handoff
|
|
501
|
+
additionalContext += `## Handoff summary\n${sanitize(capHandoffSummary(handoff))}\n`;
|
|
473
502
|
if (handoff.gitStatus) additionalContext += `\n## Git working-tree at handoff\n\`\`\`\n${sanitize(handoff.gitStatus)}\n\`\`\`\n`;
|
|
474
503
|
// Sub-agent manifest: LIVE-primary, snapshot-as-fallback. The prior session may have had
|
|
475
504
|
// sub-agents (Agent/Task, Workflow) building things you can't see in its narrative — and a
|
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
|
-
|
|
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
|
|
172
|
-
//
|
|
173
|
-
//
|
|
174
|
-
|
|
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
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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/package.json
CHANGED
package/skills/handoff/SKILL.md
CHANGED
|
@@ -13,6 +13,11 @@ user-invocable: true
|
|
|
13
13
|
Write a complete handoff capturing everything a NEW session needs to continue this work without
|
|
14
14
|
re-deriving context, and save it so the next session in this project auto-loads it on start.
|
|
15
15
|
|
|
16
|
+
Hosted in a Workspace pane? Since 0.18.18 `--baton` detects the pane and drives the in-place
|
|
17
|
+
replacement itself (idle gate, graceful end, reopen, kickoff prompt) — no Terminal window opens,
|
|
18
|
+
and the successor recaps unprompted. Nothing extra to do; the notes below about windows apply
|
|
19
|
+
only to plain Terminal sessions.
|
|
20
|
+
|
|
16
21
|
## Instructions
|
|
17
22
|
|
|
18
23
|
0. **Already written one this session?** Then do NOT write it again — pass the baton on it:
|