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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trantor",
3
- "version": "0.18.17",
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
+ }
@@ -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.
@@ -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. There is therefore no safe universal default — the
99
- // window must be declared (env RELAY_CONTEXT_WINDOW or config.contextWindow) for
100
- // the proactive early-warning to activate. Returns 0 when unknown (→ no warning).
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
- const narrative = summary ?? buildSummary(transcript);
345
- const tail = verbatimRecentTail(transcript);
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
- // 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}` : ""),
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 regression-tested headlessly.
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: "" };
@@ -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/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.17",
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": [
@@ -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: