trantor 0.18.17 → 0.18.19
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 +9 -0
- package/bin/baton-pane.mjs +130 -0
- package/bin/drill-surface.mjs +67 -1
- package/bin/write-handoff.mjs +11 -6
- package/hooks/heartbeat.mjs +26 -1
- package/hooks/lib/handoff.mjs +108 -9
- package/hooks/prompt-focus.mjs +6 -1
- package/hooks/sessionstart.mjs +33 -4
- package/hub.mjs +29 -0
- package/mcp.mjs +12 -4
- package/package.json +2 -2
- 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.19",
|
|
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.
|
|
@@ -362,6 +370,7 @@ rate, not work rate.
|
|
|
362
370
|
| `relay_project_brief(text)` | The project's what/why on the dashboard |
|
|
363
371
|
| `relay_task_add(title, …, difficulty, model, deps, note?, project?)` | Cards with difficulty/model badges + DAG edges; `note` seeds the card's **permanent log**; `project` targets another board when you orchestrate from elsewhere |
|
|
364
372
|
| `relay_task_move(id, status, note?)` | `todo → doing → testing → done` (the gate), `failed`, `blocked` — moves to testing/done should carry a `note`: what you did + the evidence, stored on the card forever |
|
|
373
|
+
| `relay_task_check(id, index, done?)` | Tick one acceptance item on a card's checklist (seeded via `relay_task_add`'s `checklist`) — checked/total is the card's one honest progress denominator |
|
|
365
374
|
| `relay_board` | The project's full board, as text |
|
|
366
375
|
| `relay_scrooge(prompt, task?, difficulty?)` | Fractal cheap-model delegation, with the ledger receipt |
|
|
367
376
|
| `relay_lesson(text, scope?)` | Record a failure lesson — auto-injected into all future crews |
|
|
@@ -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");
|
|
@@ -297,6 +297,72 @@ if (globalThis.__skew && fail > 0) {
|
|
|
297
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
298
|
}
|
|
299
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
|
+
|
|
300
366
|
// ---------- S5 · takeover ----------
|
|
301
367
|
step("S5 · takeover from a Terminal session");
|
|
302
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.
|
|
@@ -95,13 +96,17 @@ export function guardContextTokens(rows) {
|
|
|
95
96
|
}
|
|
96
97
|
|
|
97
98
|
// The transcript logs the model WITHOUT the [1m] marker, so we cannot tell a
|
|
98
|
-
// 200k window from a 1M one.
|
|
99
|
-
// window
|
|
100
|
-
// the
|
|
99
|
+
// 200k window from a 1M one in general. Fable is the known exception (#5503):
|
|
100
|
+
// its window is 1M and the name is all the transcript ever gives us — the
|
|
101
|
+
// undeclared window kept the early-warning off and the session hit the wall
|
|
102
|
+
// silently. An explicit declaration (env RELAY_CONTEXT_WINDOW or
|
|
103
|
+
// config.contextWindow) always wins over any name-based inference. Returns 0
|
|
104
|
+
// when unknown (→ no warning).
|
|
101
105
|
export function resolveWindow(model = "", conf = readConfig()) {
|
|
102
106
|
const explicit = Number(process.env.RELAY_CONTEXT_WINDOW || conf.contextWindow || 0);
|
|
103
107
|
if (explicit > 0) return explicit;
|
|
104
108
|
if (/\[1m\]|-1m\b|:1m\b/i.test(model)) return 1_000_000; // honored if ever present
|
|
109
|
+
if (/fable/i.test(model)) return 1_000_000; // #5503: fable is 1M by name
|
|
105
110
|
return 0;
|
|
106
111
|
}
|
|
107
112
|
|
|
@@ -307,6 +312,68 @@ export function verbatimRecentTail(transcript, chars = 7000) {
|
|
|
307
312
|
try { return collectTurns(transcript).join("\n\n").slice(-chars); } catch { return ""; }
|
|
308
313
|
}
|
|
309
314
|
|
|
315
|
+
// ---- #5648: handoff writer discipline --------------------------------------
|
|
316
|
+
// The inline summary is the RECAP, not the record: the successor reads it as a hook injection,
|
|
317
|
+
// and an oversized injection gets persisted to a file the successor re-reads — paying twice for
|
|
318
|
+
// the same context. Cap the composed digest at ~4KB. When it must cut, keep BOTH ends a successor
|
|
319
|
+
// needs — the opening (task & goal framing) and the tail (current state) — cutting each on a
|
|
320
|
+
// paragraph boundary, with an explicit elision marker so nobody mistakes the middle for missing.
|
|
321
|
+
export function capSummary(text, cap = 4096) {
|
|
322
|
+
const s = String(text || "");
|
|
323
|
+
if (s.length <= cap) return s;
|
|
324
|
+
const elide = "\n\n[…]\n\n";
|
|
325
|
+
const headRaw = s.slice(0, Math.max(0, cap - elide.length - 2048));
|
|
326
|
+
const hCut = headRaw.lastIndexOf("\n\n");
|
|
327
|
+
const head = hCut > 200 ? headRaw.slice(0, hCut) : headRaw;
|
|
328
|
+
let tail = s.slice(s.length - (cap - head.length - elide.length));
|
|
329
|
+
const tCut = tail.indexOf("\n\n");
|
|
330
|
+
if (tCut > 0 && tCut < 2000) tail = tail.slice(tCut + 2); // drop the partial opening line
|
|
331
|
+
return head + elide + tail;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// How fresh a model-authored handoff must be before an automatic digest DEFERS to it: 15 minutes.
|
|
335
|
+
// Older than that, the state it describes has likely moved on — compose fresh.
|
|
336
|
+
const FRESH_HANDOFF_SEC = 15 * 60;
|
|
337
|
+
const MANUAL_TRIGGERS = ["manual-skill", "manual-baton"];
|
|
338
|
+
|
|
339
|
+
// The newest unconsumed MODEL-authored handoff (the /trantor:handoff skill / manual baton path)
|
|
340
|
+
// for this project, if it is still fresh. Incident (#5648): minutes after the operator hand-wrote
|
|
341
|
+
// trantor-1788141357, an automatic digest recomposed and SUPERSEDED it — the successor then
|
|
342
|
+
// loaded the machine's lossy summary instead of the author's exact words. The digest is the
|
|
343
|
+
// fallback, never the replacement.
|
|
344
|
+
export function freshAuthoredHandoff(projectName, nowS = nowSec() || Math.floor(Date.now() / 1000)) {
|
|
345
|
+
try {
|
|
346
|
+
if (!existsSync(HANDOFF_DIR)) return null;
|
|
347
|
+
const re = new RegExp("^" + String(projectName).replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + "-(\\d+)\\.json$");
|
|
348
|
+
const cands = readdirSync(HANDOFF_DIR)
|
|
349
|
+
.map(f => { const m = re.exec(f); return m ? { f, stamp: Number(m[1]) } : null; })
|
|
350
|
+
.filter(Boolean)
|
|
351
|
+
.sort((a, b) => b.stamp - a.stamp)
|
|
352
|
+
.map(x => join(HANDOFF_DIR, x.f));
|
|
353
|
+
for (const p of cands) {
|
|
354
|
+
try {
|
|
355
|
+
const r = JSON.parse(readFileSync(p, "utf8"));
|
|
356
|
+
if (r.consumed !== false) continue;
|
|
357
|
+
if (!MANUAL_TRIGGERS.includes(r.trigger)) continue;
|
|
358
|
+
if (nowS - (Number(r.stamp) || 0) > FRESH_HANDOFF_SEC) continue;
|
|
359
|
+
return r;
|
|
360
|
+
} catch {}
|
|
361
|
+
}
|
|
362
|
+
} catch {}
|
|
363
|
+
return null;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// mode:"attended"|"unattended" on every handoff record (#5648) — WHO pulls the baton trigger.
|
|
367
|
+
// Read from the resolved autonomy dials (the same JSON `trantor autonomy json` prints):
|
|
368
|
+
// baton:"auto" means the arm-at-warn → fire-at-turn-boundary chain runs itself (unattended);
|
|
369
|
+
// the default "ask" keeps the operator in the loop (attended). Fail closed to "attended".
|
|
370
|
+
export function handoffMode(projectName) {
|
|
371
|
+
try {
|
|
372
|
+
const a = resolveAutonomy(projectName, loadAutonomy());
|
|
373
|
+
return a.baton === "auto" ? "unattended" : "attended";
|
|
374
|
+
} catch { return "attended"; }
|
|
375
|
+
}
|
|
376
|
+
|
|
310
377
|
// ---- write + announce + spawn ----------------------------------------------
|
|
311
378
|
/** Append one §5 state transition to a handoff's own file — the machine's ledger rides the
|
|
312
379
|
* record it describes (SYSTEM-CONTRACT §5): every owner of a transition already holds this
|
|
@@ -338,11 +405,18 @@ export function writeHandoff({ projectDir, sessionId, transcript, trigger, summa
|
|
|
338
405
|
} catch {}
|
|
339
406
|
}
|
|
340
407
|
if (!existsSync(HANDOFF_DIR)) mkdirSync(HANDOFF_DIR, { recursive: true });
|
|
408
|
+
// #5648: an automatic digest must never recompose+supersede a FRESH model-authored handoff.
|
|
409
|
+
// If one exists (<15min, unconsumed), point the baton at THAT and write nothing — the caller's
|
|
410
|
+
// spawn path proceeds on the authored handoff exactly as if it had just written it.
|
|
411
|
+
const fresh = freshAuthoredHandoff(projectName);
|
|
412
|
+
if (fresh) return { deferred: true, file: join(HANDOFF_DIR, `${fresh.id}.json`), record: fresh };
|
|
341
413
|
const stamp = nowSec() || Date.now();
|
|
342
414
|
let gitStatus = "";
|
|
343
415
|
try { gitStatus = execSync("git -C " + JSON.stringify(projectDir) + " status --short 2>/dev/null | head -30", { encoding: "utf8" }).trim(); } catch {}
|
|
344
|
-
|
|
345
|
-
|
|
416
|
+
// Cap the composed narrative to the injection budget (~4KB). The verbatim tail is deliberately
|
|
417
|
+
// NOT embedded anymore: the record's transcript_path points at the full exchange, and embedding
|
|
418
|
+
// it here doubled the successor's read for state that was already one path away (#5648).
|
|
419
|
+
const narrative = capSummary(summary ?? buildSummary(transcript));
|
|
346
420
|
// Sub-agent manifest SNAPSHOT (fallback). The successor should re-derive it LIVE via
|
|
347
421
|
// `trantor agents <sid>` (catches files an agent finished that were clobbered AFTER this
|
|
348
422
|
// snapshot — the kill that motivated this corrupted a completed 30KB lib post-handoff). This
|
|
@@ -363,8 +437,10 @@ export function writeHandoff({ projectDir, sessionId, transcript, trigger, summa
|
|
|
363
437
|
project: projectDir, projectName, machine: hostname(),
|
|
364
438
|
session_id: sessionId || "", trigger: trigger || "auto",
|
|
365
439
|
transcript_path: transcript || "", stamp: Number(stamp) || 0,
|
|
366
|
-
//
|
|
367
|
-
summary: narrative
|
|
440
|
+
// recap-sufficient inline summary, capped ~4KB — the full story lives at transcript_path
|
|
441
|
+
summary: narrative,
|
|
442
|
+
// attended|unattended — who pulls the baton trigger (resolved autonomy `baton` dial)
|
|
443
|
+
mode: handoffMode(projectName),
|
|
368
444
|
gitStatus, subagents, verifyGates, consumed: false,
|
|
369
445
|
// The §5 machine's ledger: every transition appends here via appendHandoffState.
|
|
370
446
|
states: [{ state: "written", ts: Number(stamp) || 0, by: sessionId || "" }],
|
|
@@ -543,14 +619,31 @@ export function resolveOriginalWindow() {
|
|
|
543
619
|
return { windowId, tty };
|
|
544
620
|
}
|
|
545
621
|
|
|
622
|
+
// The pane leg of the baton (#5643): hand the replacement to a DETACHED driver (bin/baton-pane.mjs)
|
|
623
|
+
// that survives this session's death — idle-gate → graceful end → trantor open → kickoff. The
|
|
624
|
+
// window machinery (resolve/arm-close) is deliberately absent here: there is no window to close,
|
|
625
|
+
// and the old path's answer ("spawn disabled — open manually") left the operator doing the
|
|
626
|
+
// machine's job by hand.
|
|
627
|
+
export function spawnPaneBaton(projectDir, handoffFile) {
|
|
628
|
+
try {
|
|
629
|
+
const script = join(HERE, "..", "..", "bin", "baton-pane.mjs");
|
|
630
|
+
if (!existsSync(script)) return false;
|
|
631
|
+
const child = spawn(process.execPath, [script, "--project", projectDir, "--handoff", handoffFile], { detached: true, stdio: "ignore" });
|
|
632
|
+
child.unref();
|
|
633
|
+
return true;
|
|
634
|
+
} catch { return false; }
|
|
635
|
+
}
|
|
636
|
+
|
|
546
637
|
// MANUAL one-command baton: spawn the fresh session (no dialog) + arm the close of THIS window once the
|
|
547
638
|
// fresh one consumes the handoff. Returns { spawned, armed, windowId }.
|
|
548
639
|
// ORDER IS LOAD-BEARING: resolve the original window BEFORE spawning. Reversing it is the
|
|
549
640
|
// "successor closes ITSELF" bug — the just-opened window is frontmost, the front-window fallback
|
|
550
641
|
// 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
|
|
642
|
+
// (_resolveWindow/_spawnFresh/_armClose/_hasPane/_spawnPane) exist so the ordering can be
|
|
643
|
+
// regression-tested headlessly.
|
|
552
644
|
export function spawnBaton({ projectDir, handoffFile, conf = readConfig(),
|
|
553
|
-
_resolveWindow = resolveOriginalWindow, _spawnFresh = spawnFresh, _armClose = armBatonClose
|
|
645
|
+
_resolveWindow = resolveOriginalWindow, _spawnFresh = spawnFresh, _armClose = armBatonClose,
|
|
646
|
+
_hasPane = hasOrchPane, _spawnPane = spawnPaneBaton }) {
|
|
554
647
|
// A DRILL MUST BE ABLE TO SAY NO. There was no such switch, so exercising the baton path in a
|
|
555
648
|
// test opened real Terminal windows running real `claude` sessions in temp directories the test
|
|
556
649
|
// then deleted, each parked on a "do you trust this folder?" prompt. Five of them were found by
|
|
@@ -559,6 +652,12 @@ export function spawnBaton({ projectDir, handoffFile, conf = readConfig(),
|
|
|
559
652
|
if (spawnSuppressed() || conf.batonSpawn === false) {
|
|
560
653
|
return { spawned: false, armed: false, windowId: "", suppressed: true };
|
|
561
654
|
}
|
|
655
|
+
// Hosted pane (#5643): the pane IS the successor surface — no window is resolved, spawned, or
|
|
656
|
+
// armed for closing. The detached driver replaces the session at the turn boundary.
|
|
657
|
+
if (_hasPane(basename(projectDir))) {
|
|
658
|
+
const spawned = _spawnPane(projectDir, handoffFile);
|
|
659
|
+
return { spawned, armed: false, windowId: "", pane: true };
|
|
660
|
+
}
|
|
562
661
|
const { windowId, tty } = _resolveWindow(); // original window FIRST, while it's still frontmost
|
|
563
662
|
const spawned = _spawnFresh(projectDir);
|
|
564
663
|
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/hub.mjs
CHANGED
|
@@ -156,6 +156,16 @@ function appendTaskNote(t, b, ts = Date.now()) {
|
|
|
156
156
|
if (!b || typeof b.note !== "string") return false;
|
|
157
157
|
return appendTaskLog(t, b.by || "", b.note, ts);
|
|
158
158
|
}
|
|
159
|
+
// Card checklists (#5624): acceptance items are the one honest denominator for a progress bar.
|
|
160
|
+
// Accepts plain strings (fresh items) or {text,done} (round-trips); caps 20 items x 200 chars.
|
|
161
|
+
// Returns null for a non-array so callers can distinguish "not sent" from "sent empty".
|
|
162
|
+
function cleanChecklist(v) {
|
|
163
|
+
if (!Array.isArray(v)) return null;
|
|
164
|
+
return v.slice(0, 20)
|
|
165
|
+
.map(it => typeof it === "string" ? { text: it.slice(0, 200), done: false }
|
|
166
|
+
: { text: String(it?.text ?? "").slice(0, 200), done: !!it?.done })
|
|
167
|
+
.filter(it => it.text);
|
|
168
|
+
}
|
|
159
169
|
function runTaskBootMigrations() {
|
|
160
170
|
let changed = false;
|
|
161
171
|
const bootNow = Date.now();
|
|
@@ -1826,6 +1836,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
1826
1836
|
deps: Array.isArray(b.deps) ? [...new Set(b.deps.map(Number).filter(n => Number.isInteger(n) && n > 0))].slice(0, 20) : [],
|
|
1827
1837
|
by: b.by || "", ts: ts0, updated: ts0,
|
|
1828
1838
|
history: [{ to: st0, by: b.by || "", ts: ts0 }] };
|
|
1839
|
+
{ const cl = cleanChecklist(b.checklist); if (cl?.length) t.checklist = cl; } // #5624 — rides `extra`, survives restarts
|
|
1829
1840
|
if (b.source === "cc-subagent") { t._fp = subFp(b.title); if (b.agentType) t._atype = String(b.agentType).slice(0, 40); if (b.agentId) t._aid = String(b.agentId).slice(0, 80); if (b.parent) t.parent = String(b.parent).slice(0, 120); t.count = 1; if (t.status === "doing") { t._everStarted = true; t._inflight = 1; } }
|
|
1830
1841
|
appendTaskNote(t, b, ts0);
|
|
1831
1842
|
state.tasks.push(t); if (state.tasks.length > 2000) state.tasks.splice(0, 500);
|
|
@@ -1862,11 +1873,29 @@ const server = http.createServer(async (req, res) => {
|
|
|
1862
1873
|
// the narrative line a human reads on the board ("assigned — did"), written by the cheap
|
|
1863
1874
|
// summarizer; rides the tasks.extra column, so it survives restarts everywhere
|
|
1864
1875
|
if (b.summary !== undefined) t.summary = String(b.summary).slice(0, 220);
|
|
1876
|
+
// #5624: full checklist replace (null clears). Item-level toggles ride /task/checklist-toggle.
|
|
1877
|
+
if (b.checklist !== undefined) {
|
|
1878
|
+
const cl = cleanChecklist(b.checklist);
|
|
1879
|
+
if (cl) { if (cl.length) t.checklist = cl; else delete t.checklist; }
|
|
1880
|
+
else if (b.checklist === null) delete t.checklist;
|
|
1881
|
+
}
|
|
1865
1882
|
appendTaskNote(t, b);
|
|
1866
1883
|
if (b.delete) { eventType = "deleted"; eventFrom = null; eventTo = null; state.tasks = state.tasks.filter(x => x.id !== t.id); }
|
|
1867
1884
|
appendCardEvent(eventType, t, b.by, eventFrom, eventTo);
|
|
1868
1885
|
t.updated = now(); dirty = true; return json(res, 200, { ok: true, task: t });
|
|
1869
1886
|
}
|
|
1887
|
+
// #5624: toggle ONE acceptance item. Index-addressed against the card's current checklist —
|
|
1888
|
+
// a stale index 400s instead of silently toggling the wrong item.
|
|
1889
|
+
if (req.method === "POST" && P === "/task/checklist-toggle") {
|
|
1890
|
+
const b = await body(req); const t = state.tasks.find(x => x.id === Number(b.id));
|
|
1891
|
+
if (!t) return json(res, 404, { error: "no such task" });
|
|
1892
|
+
const i = Number(b.index);
|
|
1893
|
+
if (!Array.isArray(t.checklist) || !Number.isInteger(i) || i < 0 || i >= t.checklist.length) {
|
|
1894
|
+
return json(res, 400, { error: "no such checklist item" });
|
|
1895
|
+
}
|
|
1896
|
+
t.checklist[i].done = !!b.done;
|
|
1897
|
+
t.updated = now(); dirty = true; return json(res, 200, { ok: true, task: t });
|
|
1898
|
+
}
|
|
1870
1899
|
// Manual board sweep — the aggressive companion to the automatic reaper. The reaper only touches
|
|
1871
1900
|
// OFFLINE-owner cards (no false positives on live work); /sweep is the explicit "this live seat forgot
|
|
1872
1901
|
// its card" path: it stales EVERY doing/testing card untouched past `olderMs`, regardless of owner
|
package/mcp.mjs
CHANGED
|
@@ -182,11 +182,19 @@ server.tool("relay_contracts", "What you dispatched and are still owed. Lists ev
|
|
|
182
182
|
});
|
|
183
183
|
|
|
184
184
|
server.tool("relay_task_add", "Add a Kanban card to a project's board on the dashboard (what you're about to work on). Defaults: THIS project, assigned to you, status 'todo'. Pass `project` to target another board — e.g. when you orchestrate a crew that runs in a different directory than the one you launched Claude from. Keep the team's progress visible. Attach a `note` whenever context isn't obvious from the title — it lands on the card's permanent log ({ts,by,text}, kept: last 40).",
|
|
185
|
-
{ title: z.string().describe("short task title"), status: z.enum(["todo","doing","testing","failed","done","blocked"]).optional(), assignee: z.string().optional().describe("session id to assign (default: you)"), difficulty: z.enum(["easy","medium","hard"]).optional().describe("difficulty tag — drives model/agent routing (relay_advise) and shows on the board"), model: z.string().optional().describe("the model this card is routed to (from relay_advise routing, or the CLI default) — shown on the card"), deps: z.array(z.number()).optional().describe("card ids this card depends on — drawn as branch edges in the Flow view (e.g. integration depends on every crew card)"), phase: z.string().optional().describe("phase/milestone this card belongs to (e.g. 'P5', 'Auth', 'Launch') — groups it in the Flow view's phase flowchart. Optional; otherwise inferred from the title prefix + time."), note: z.string().max(2000).optional().describe("optional card-log entry (<=2000 chars): context, the plan, or a link — stored on the card as {ts,by,text}"), project: z.string().optional().describe("board to add to (default: this session's project). Set to the crew's project when you orchestrate from a different directory") },
|
|
186
|
-
async ({ title, status, assignee, difficulty, model, deps, phase, note, project }) => {
|
|
185
|
+
{ title: z.string().describe("short task title"), status: z.enum(["todo","doing","testing","failed","done","blocked"]).optional(), assignee: z.string().optional().describe("session id to assign (default: you)"), difficulty: z.enum(["easy","medium","hard"]).optional().describe("difficulty tag — drives model/agent routing (relay_advise) and shows on the board"), model: z.string().optional().describe("the model this card is routed to (from relay_advise routing, or the CLI default) — shown on the card"), deps: z.array(z.number()).optional().describe("card ids this card depends on — drawn as branch edges in the Flow view (e.g. integration depends on every crew card)"), phase: z.string().optional().describe("phase/milestone this card belongs to (e.g. 'P5', 'Auth', 'Launch') — groups it in the Flow view's phase flowchart. Optional; otherwise inferred from the title prefix + time."), note: z.string().max(2000).optional().describe("optional card-log entry (<=2000 chars): context, the plan, or a link — stored on the card as {ts,by,text}"), project: z.string().optional().describe("board to add to (default: this session's project). Set to the crew's project when you orchestrate from a different directory"), checklist: z.array(z.string().max(200)).max(20).optional().describe("acceptance items for the card — the honest denominator for its progress bar. Tick them off with relay_task_check as each is truly met") },
|
|
186
|
+
async ({ title, status, assignee, difficulty, model, deps, phase, note, project, checklist }) => {
|
|
187
187
|
const proj = project || PROJECT;
|
|
188
|
-
const { task } = await api("POST", "/task", { project: proj, title, status: status || "todo", assignee: assignee || SESSION, difficulty, model, deps, phase, note, by: SESSION });
|
|
189
|
-
return { content: [{ type: "text", text: `card #${task.id} added to ${proj}: "${title}" [${task.status}]${phase?` · phase ${phase}`:""}` }] };
|
|
188
|
+
const { task } = await api("POST", "/task", { project: proj, title, status: status || "todo", assignee: assignee || SESSION, difficulty, model, deps, phase, note, checklist, by: SESSION });
|
|
189
|
+
return { content: [{ type: "text", text: `card #${task.id} added to ${proj}: "${title}" [${task.status}]${phase?` · phase ${phase}`:""}${task.checklist?.length?` · ${task.checklist.length} acceptance item(s)`:""}` }] };
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
server.tool("relay_task_check", "Tick (or untick) ONE acceptance item on a card's checklist — the card's progress bar reads checked/total, so tick an item only when it is genuinely met (tests run, behavior observed), never to make the bar move. Items are 0-indexed in the order relay_task_add listed them.",
|
|
193
|
+
{ id: z.number().describe("card id"), index: z.number().int().min(0).describe("0-based checklist item index"), done: z.boolean().optional().describe("default true; pass false to untick") },
|
|
194
|
+
async ({ id, index, done }) => {
|
|
195
|
+
const { task } = await api("POST", "/task/checklist-toggle", { id, index, done: done !== false, by: SESSION });
|
|
196
|
+
const n = task.checklist.filter(c => c.done).length;
|
|
197
|
+
return { content: [{ type: "text", text: `card #${id} checklist: [${done !== false ? "x" : " "}] "${task.checklist[index].text}" — ${n}/${task.checklist.length} done` }] };
|
|
190
198
|
});
|
|
191
199
|
|
|
192
200
|
server.tool("relay_phase_goal", "Set what a PHASE is for — its goal — shown as the phase header in the Flow view (overrides the theme auto-derived from card titles). Capture this when you plan a phase so the board says what each milestone needs to do, not just 'P5'. Phase keys match relay_task_add's `phase` (or the inferred title-prefix family like 'P5').",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "trantor",
|
|
3
|
-
"version": "0.18.
|
|
3
|
+
"version": "0.18.19",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"bin": {
|
|
6
6
|
"trantor": "bin/cli.mjs"
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
"zod": "^4.4.3"
|
|
12
12
|
},
|
|
13
13
|
"scripts": {
|
|
14
|
-
"test": "node bin/slop-gate.mjs --surface desktop/src && node test.mjs && node test-scenarios.mjs && node test-failure.mjs && node test-redelivery.mjs && node test-crew-completion.mjs && node test-contracts.mjs && node test-autonomy.mjs && node test-integrate.mjs && node test-handoff.mjs && node test-handoff-summarizer.mjs && node test-baton-turn-boundary.mjs && node test-agents.mjs && node test-update.mjs && node test-handoff-guard.mjs && node test-baton-latest.mjs && node test-balances.mjs && node test-subagent-cost.mjs && node test-inbox.mjs && node test-inflight.mjs && node test-notify.mjs && node test-focus.mjs && node test-cardlog.mjs && node test-relay-note.mjs && node test-reaper.mjs && node test-events.mjs && node test-proposals.mjs && node test-scrub.mjs && node test-store-delta.mjs && node test-message-re.mjs && node test-claims.mjs && node test-adopt.mjs && node test-summarize.mjs && node test-identity-core.mjs && node test-identity.mjs && node test-identity-instances.mjs && node test-duty.mjs && node test-duty-seat.mjs && node test-discovery.mjs && node test-doctor.mjs && node test-crew-env.mjs && node test-desktop-transport.mjs && node test-crew-worktree.mjs && bash test-crew-herdr.sh && npm --prefix desktop run test --silent && node test-splitbrain.mjs && node test-overseer.mjs && node test-overseer-lib.mjs && node test-overseer-warn.mjs && node test-provider-keys.mjs && node test-inbox-delivery.mjs && node test-identity-drift.mjs && node test-inbox-staleness.mjs && node test-seats.mjs && node test-seat-identity.mjs && node test-hub-routing.mjs && node test-hook-routing.mjs && node test-relay-wait.mjs && node test-dsh-seat.mjs && node test-kimi-bridge.mjs && node test-kimi-events.mjs && python3 engine/test-routing.py && node test-reconcile.mjs && node test-resources.mjs && node test-patrol.mjs && node test-bridge.mjs && bash test-crew.sh"
|
|
14
|
+
"test": "node bin/slop-gate.mjs --surface desktop/src && node test.mjs && node test-scenarios.mjs && node test-failure.mjs && node test-redelivery.mjs && node test-crew-completion.mjs && node test-contracts.mjs && node test-autonomy.mjs && node test-integrate.mjs && node test-handoff.mjs && node test-handoff-summarizer.mjs && node test-baton-turn-boundary.mjs && node test-agents.mjs && node test-update.mjs && node test-handoff-guard.mjs && node test-baton-latest.mjs && node test-balances.mjs && node test-subagent-cost.mjs && node test-inbox.mjs && node test-inflight.mjs && node test-notify.mjs && node test-focus.mjs && node test-cardlog.mjs && node test-checklist.mjs && node test-relay-note.mjs && node test-reaper.mjs && node test-events.mjs && node test-proposals.mjs && node test-scrub.mjs && node test-store-delta.mjs && node test-message-re.mjs && node test-claims.mjs && node test-adopt.mjs && node test-summarize.mjs && node test-identity-core.mjs && node test-identity.mjs && node test-identity-instances.mjs && node test-duty.mjs && node test-duty-seat.mjs && node test-discovery.mjs && node test-doctor.mjs && node test-crew-env.mjs && node test-desktop-transport.mjs && node test-crew-worktree.mjs && bash test-crew-herdr.sh && npm --prefix desktop run test --silent && node test-splitbrain.mjs && node test-overseer.mjs && node test-overseer-lib.mjs && node test-overseer-warn.mjs && node test-provider-keys.mjs && node test-inbox-delivery.mjs && node test-identity-drift.mjs && node test-inbox-staleness.mjs && node test-seats.mjs && node test-seat-identity.mjs && node test-hub-routing.mjs && node test-hook-routing.mjs && node test-relay-wait.mjs && node test-dsh-seat.mjs && node test-kimi-bridge.mjs && node test-kimi-events.mjs && python3 engine/test-routing.py && node test-reconcile.mjs && node test-resources.mjs && node test-patrol.mjs && node test-bridge.mjs && bash test-crew.sh"
|
|
15
15
|
},
|
|
16
16
|
"description": "The hub-world for AI agent crews \u2014 orchestrate Claude Code, Codex, GLM, Kimi, DeepSeek & any OpenRouter model as live crews with a plan-aware Advisor, a Kanban/flow command center, a testing gate, and an economics brain (Scrooge).",
|
|
17
17
|
"files": [
|
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:
|