trantor 0.18.17 → 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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trantor",
3
- "version": "0.18.17",
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
+ }
@@ -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");
@@ -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 { spawned, armed, windowId } = spawnBaton({ projectDir: project, handoffFile: found });
50
- if (spawned) console.log(`baton: fresh session opening (self-recapping)${armed ? ` — this window (${windowId}) closes once it takes over` : ""}`);
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
- const rec = { id: `${name}-${stamp}`, project, projectName: name, machine: hostname(), trigger: baton ? "manual-baton" : "manual-skill", stamp: Number(stamp) || 0, summary: summary.trim() || "(empty)", gitStatus: git, consumed: false };
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 { spawned, armed, windowId } = spawnBaton({ projectDir: project, handoffFile: file });
62
- if (spawned) console.log(`baton: fresh session opening (self-recapping)${armed ? ` — this window (${windowId}) closes once it takes over` : " original window left open (couldn't detect it)"}`);
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
  }
@@ -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
- readStdin().then(main).catch(() => {}).finally(() => process.exit(0));
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);
@@ -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
- const narrative = summary ?? buildSummary(transcript);
345
- const tail = verbatimRecentTail(transcript);
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
- // narrative + a verbatim recent-exchange block so exact in-flight state always survives
367
- summary: narrative + (tail ? `\n\n---\n## Verbatim recent exchange (exact in-flight state — continue from here)\n${tail}` : ""),
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 regression-tested headlessly.
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: "" };
@@ -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
- return `<system-reminder>You took over via handoff ${rec.handoffId}. If you have not yet recapped it, your reply MUST begin with the ≤3-sentence recap (task, state, next step) before anything else — including before answering this message.</system-reminder>`;
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() {
@@ -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
- 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`;
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.summary)}\n`;
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trantor",
3
- "version": "0.18.17",
3
+ "version": "0.18.18",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "trantor": "bin/cli.mjs"
@@ -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: