trantor 0.18.49 → 0.18.50

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.
@@ -0,0 +1,157 @@
1
+ import { execFileSync, spawn } from "node:child_process";
2
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, symlinkSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { dirname, join } from "node:path";
5
+ import { createServer } from "node:net";
6
+ import { workspacePane, splitPane } from "./crew/herdr.mjs";
7
+
8
+ const ROOT = dirname(import.meta.dirname);
9
+ const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
10
+ const herdr = args => JSON.parse(execFileSync("herdr", args, { encoding: "utf8", timeout: 30000 }));
11
+ const read = path => existsSync(path) ? readFileSync(path, "utf8") : "";
12
+
13
+ export function checkSocketHome(home) {
14
+ const socket = join(home, ".config", "herdr", "herdr.sock");
15
+ if (Buffer.byteLength(socket) >= (process.platform === "darwin" ? 104 : 108)) {
16
+ throw new Error(`drill HOME socket path is too long: ${socket}; use a shorter TRANTOR_DRILL_WORLD inside .agent-bus-out`);
17
+ }
18
+ return socket;
19
+ }
20
+
21
+ export function isolatedEnv(home, bus, overrides = {}) {
22
+ const env = { ...process.env };
23
+ for (const key of Object.keys(env)) {
24
+ if (/^(RELAY_|TRANTOR_|CLAUDE_CODE_|HERDR_|AGENT_BUS_)/.test(key) || key === "CLAUDECODE") delete env[key];
25
+ }
26
+ return { ...env, HOME: home, AGENT_BUS_DIR: bus, RELAY_DATA_DIR: bus, ...overrides };
27
+ }
28
+
29
+ export async function stopChild(child) {
30
+ if (child.exitCode !== null || child.signalCode !== null) return;
31
+ child.kill("SIGTERM");
32
+ await Promise.race([new Promise(resolve => child.once("close", resolve)), sleep(2000)]);
33
+ if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL");
34
+ }
35
+
36
+ export async function startDrillHub(home, bus, auth = "enforce") {
37
+ mkdirSync(bus, { recursive: true });
38
+ const probe = createServer();
39
+ await new Promise(resolve => probe.listen(0, "127.0.0.1", resolve));
40
+ const port = probe.address().port;
41
+ await new Promise(resolve => probe.close(resolve));
42
+ const url = `http://127.0.0.1:${port}`;
43
+ const env = isolatedEnv(home, bus, {
44
+ RELAY_PORT: String(port), RELAY_HOST: "127.0.0.1", RELAY_URL: url,
45
+ RELAY_AUTH: auth, RELAY_ENROLL: "tofu", RELAY_STORE: "json",
46
+ });
47
+ const child = spawn(process.execPath, [join(ROOT, "hub.mjs")], { env, stdio: "ignore" });
48
+ for (let attempt = 0; attempt < 100; attempt++) {
49
+ try {
50
+ if ((await fetch(`${url}/health`, { signal: AbortSignal.timeout(500) })).ok) return { child, env, url };
51
+ } catch { /* The private hub has not bound its port yet. */ }
52
+ if (child.exitCode !== null) break;
53
+ await sleep(100);
54
+ }
55
+ await stopChild(child);
56
+ throw new Error("private drill hub did not become ready");
57
+ }
58
+
59
+ export function appVerdict(kind, trace, panics = "") {
60
+ if (/\b(?:FAILED|FAIL|ERROR)\b/.test(trace)) return false;
61
+ if (kind === "ask") return /ask-drill open PASS/.test(trace) && /ask-drill cold PASS/.test(trace) && /ask-drill PASS:/.test(trace);
62
+ if (kind === "handoff") return /handoff-drill PASS:/.test(trace) && /handoff-drill verdict exit=0/.test(trace);
63
+ return /key-drill verdict exit=0/.test(trace) && !/skipped/.test(trace)
64
+ && [1, 2, 3].every(pass => trace.includes(`pass=${pass} posted keyDown+keyUp`))
65
+ && (kind !== "key-throw" || panics.includes("TaoObjcExceptionDrill"));
66
+ }
67
+
68
+ async function launchApp(kind, env, bus) {
69
+ const binary = process.env.TRANTOR_DRILL_APP || "/Applications/Trantor.app/Contents/MacOS/Trantor";
70
+ if (!existsSync(binary)) throw new Error(`installed app missing: ${binary}; no build/install attempted`);
71
+ const tracePath = join(bus, "app-trace.log");
72
+ const panicPath = join(bus, "app-panics.log");
73
+ const offset = read(tracePath).length;
74
+ const panicOffset = read(panicPath).length;
75
+ const child = spawn(binary, [], { env, stdio: "ignore", timeout: 180000, killSignal: "SIGKILL" });
76
+ let spawnError = null;
77
+ child.once("error", error => { spawnError = error; });
78
+ const deadline = Date.now() + 180000;
79
+ let trace = "";
80
+ try {
81
+ await sleep(1000);
82
+ execFileSync("osascript", ["-e", `tell application "System Events" to set frontmost of (first process whose unix id is ${child.pid}) to true`], { timeout: 10000, stdio: "ignore" });
83
+ while (Date.now() < deadline) {
84
+ trace = read(tracePath).slice(offset);
85
+ if (spawnError) throw spawnError;
86
+ if (appVerdict(kind, trace, read(panicPath).slice(panicOffset))) return `${tracePath}: ${trace.trim().split("\n").filter(line => /PASS|survived|verdict/.test(line)).join("; ")}`;
87
+ if (/\b(?:FAILED|FAIL|ERROR)\b/.test(trace) || child.exitCode !== null || child.signalCode !== null) break;
88
+ await sleep(500);
89
+ }
90
+ throw new Error(`${kind}: no complete app proof (exit=${child.exitCode} signal=${child.signalCode}); ${tracePath}: ${trace.trim().slice(-500)}`);
91
+ } finally {
92
+ await stopChild(child);
93
+ // The app normally closes these in its own finally. If it timed out first, only this
94
+ // launch's trace can authorize cleanup; never sweep the shared herdr workspace list.
95
+ for (const match of trace.matchAll(/ask-drill (?:open|cold) herdr workspace=(\S+)/g)) {
96
+ try { herdr(["workspace", "close", match[1]]); } catch { /* Already closed by the app. */ }
97
+ }
98
+ }
99
+ }
100
+
101
+ export async function runAppDrills({ world, proj, project, run }) {
102
+ // The HOME socket pathname must fit sockaddr_un even when it is a symlink.
103
+ const home = world;
104
+ const bus = join(home, "app-bus");
105
+ mkdirSync(join(home, ".config", "herdr"), { recursive: true });
106
+ const socket = checkSocketHome(home);
107
+ if (!existsSync(socket)) symlinkSync(join(homedir(), ".config", "herdr", "herdr.sock"), socket);
108
+ let hub;
109
+ try {
110
+ hub = await startDrillHub(home, bus, "off");
111
+ await fetch(`${hub.url}/project`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ project, brief: "Throwaway drill project" }) });
112
+ writeFileSync(join(bus, "config.json"), JSON.stringify({ url: hub.url, hubs: { [project]: hub.url }, contextWindow: 200000 }));
113
+ const env = { ...hub.env, TRANTOR_DEV_ROOT: dirname(proj), TRANTOR_ROOT: ROOT };
114
+ const created = herdr(["workspace", "create", "--cwd", proj, "--label", `tt-dead-drill-${process.pid}`, "--no-focus"]);
115
+ const workspace = created.result.workspace.workspace_id;
116
+ const pane = created.result.root_pane.pane_id;
117
+ try {
118
+ const sid = "00000000-0000-4000-8000-000000000092";
119
+ const transcripts = join(home, ".claude", "projects", proj.replace(/[/.]/g, "-"));
120
+ mkdirSync(transcripts, { recursive: true });
121
+ writeFileSync(join(transcripts, `${sid}.jsonl`), JSON.stringify({ type: "assistant", sessionId: sid, cwd: proj, timestamp: new Date().toISOString(), message: { model: "claude-sonnet-4-5", role: "assistant", usage: { input_tokens: 184000, output_tokens: 1, cache_read_input_tokens: 0, cache_creation_input_tokens: 0 }, content: [{ type: "text", text: "dead drill session" }] } }) + "\n");
122
+ writeFileSync(join(bus, "crew-windows.txt"), `${project}\therdrws\t__ws__\t${workspace}\n${project}\torch\torchestrator\t${pane}\n`);
123
+ writeFileSync(join(bus, "orch-sessions.txt"), `${project}\t${sid}\n`);
124
+ await run("S6-handoff · app dead-pane guard", async () => {
125
+ const before = herdr(["pane", "process-info", "--pane", pane]).result.process_info.shell_pid;
126
+ const evidence = await launchApp("handoff", { ...env, TRANTOR_HANDOFF_DRILL: project }, bus);
127
+ const after = herdr(["pane", "process-info", "--pane", pane]).result.process_info.shell_pid;
128
+ if (!before || before !== after) throw new Error("dead-pane shell did not survive");
129
+ return `${evidence}; shell ${after} survived`;
130
+ });
131
+ } finally { herdr(["workspace", "close", workspace]); }
132
+ } catch (error) {
133
+ await run("S6-handoff · app setup", () => { throw error; });
134
+ } finally { if (hub) await stopChild(hub.child); }
135
+ }
136
+
137
+ export async function crewWorkspaceDrill({ proj, workspace }) {
138
+ if (!workspace) throw new Error("S1 left no project workspace");
139
+ const ctx = { env: process.env, have: { herdr: true } };
140
+ const host = workspacePane(ctx, workspace, proj);
141
+ if (!host) throw new Error("no project-local host pane");
142
+ const foreign = herdr(["workspace", "create", "--cwd", proj, "--label", `tt-focus-drill-${process.pid}`, "--focus"]);
143
+ const panes = [];
144
+ try {
145
+ for (let i = 0; i < 4; i++) {
146
+ const pane = splitPane(ctx, panes.at(-1) || host, "right", proj);
147
+ if (!pane) throw new Error("crew split failed");
148
+ panes.push(pane);
149
+ }
150
+ const rows = herdr(["pane", "list"]).result.panes;
151
+ if (!panes.every(id => rows.some(row => row.pane_id === id && row.workspace_id === workspace && row.cwd === proj))) throw new Error("a crew split landed outside the project workspace/cwd");
152
+ return `focused=${foreign.result.workspace.workspace_id}; seats=${panes.join(",")}; workspace=${workspace}; cwd=${proj}`;
153
+ } finally {
154
+ for (const pane of panes) herdr(["pane", "close", pane]);
155
+ herdr(["workspace", "close", foreign.result.workspace.workspace_id]);
156
+ }
157
+ }
@@ -7,25 +7,69 @@
7
7
  // handoff machine — and asserts on evidence (transcript rows, herdr state, ledger files),
8
8
  // never on exit codes alone.
9
9
  //
10
- // It is the ship gate for desktop/chat/handoff/crew changes: run it before every such release;
11
- // a red drill does not ship. (There is no scripted release path to wire it into — the release
12
- // dance is manual — so the gate is this command plus the contract that mandates it.)
10
+ // This gate covers automated seams, including the installed app's dead-pane guard. Live
11
+ // key/ask/Accounts checks belong to in-app Drill Mode; duty needs a live probe. Those checks
12
+ // report SKIP with recipes and never auto-close. A failed seam or signed close exits nonzero.
13
13
  //
14
- // Flags: --keep leave the scratch world in place for inspection (prints paths).
14
+ // Flags: --keep leaves the scratch world AND workspace for inspection.
15
+ // Failed runs retain disk evidence after closing their workspace. No app build/install.
16
+ // TRANTOR_DRILL_RESULT overrides ~/.agent-bus/drill-result.json (crew seats use .agent-bus-out).
17
+ // TRANTOR_DRILL_APP selects an existing app executable; TRANTOR_DRILL_WORLD selects scratch cwd.
15
18
 
16
- import { mkdirSync, rmSync, existsSync, readFileSync, writeFileSync, readdirSync, statSync } from "node:fs";
17
- import { join, basename } from "node:path";
18
- import { homedir, tmpdir } from "node:os";
19
+ import { mkdirSync, mkdtempSync, rmSync, existsSync, readFileSync, writeFileSync, readdirSync, statSync, symlinkSync } from "node:fs";
20
+ import { join, basename, resolve } from "node:path";
21
+ import { homedir } from "node:os";
19
22
  import { execFileSync, execSync, spawn } from "node:child_process";
20
23
  import { createConnection } from "node:net";
21
24
 
25
+ import { pathToFileURL } from "node:url";
26
+ import { DrillReport } from "./drill-report.mjs";
27
+ import { sessionContext } from "../hooks/lib/api.mjs";
28
+ import { runAppDrills, crewWorkspaceDrill, checkSocketHome } from "./drill-seams.mjs";
29
+ import { shellQuote } from "./crew/core.mjs";
30
+
31
+ // One ownership map. Shared steps must finish before ANY card they cover can close.
32
+ export const CARD_STEPS = {
33
+ 6799: { steps: ["S0", "S1", "S2", "S3", "S4", "S5"], autoClose: false },
34
+ 6533: { steps: ["S6-ask"], autoClose: false, recipe: "covered by in-app Drill Mode: open a live orchestrator mid-ask, answer from Chat, and confirm the terminal advances and the card closes" },
35
+ 6668: { steps: ["S6-handoff"], autoClose: true },
36
+ 6317: { steps: ["S6-key-post", "S6-key-throw"], autoClose: false, recipe: "covered by in-app Drill Mode: open a real Workspace with a live terminal pane and exercise key dispatch in the terminal and composer" },
37
+ 6667: { steps: ["S4b", "S7"], autoClose: true },
38
+ 6587: { steps: ["S8"], autoClose: false, recipe: "needs live duty probe: operator removes the trantor/trantor-duty link, DMs the idle orchestrator, and checks for a socket nudge within 3 minutes" },
39
+ 6481: { steps: ["S9"], autoClose: true },
40
+ 6483: { steps: ["S10"], autoClose: false, recipe: "covered by in-app Drill Mode (#6800): operator checks Accounts with the CLI below the app minimum, then restores the CLI" },
41
+ };
42
+
43
+ export function findHandoff(dir, project) {
44
+ if (!existsSync(dir)) return null;
45
+ for (const name of readdirSync(dir)) {
46
+ if (!name.startsWith(`${project}-`) || !name.endsWith(".json")) continue;
47
+ const file = join(dir, name);
48
+ try {
49
+ const record = JSON.parse(readFileSync(file, "utf8"));
50
+ // relay_handoff can arrive before the hook ledger. Keep polling until states exist.
51
+ if (Array.isArray(record?.states) && record.states.length) return file;
52
+ } catch { /* A hook may still be writing this record; try it on the next poll. */ }
53
+ }
54
+ return null;
55
+ }
56
+
57
+ export async function main() {
22
58
  const KEEP = process.argv.includes("--keep");
23
59
  const G = "\x1b[32m", Rd = "\x1b[31m", Y = "\x1b[33m", D = "\x1b[2m", R = "\x1b[0m";
24
60
  let pass = 0, fail = 0, skip = 0;
25
- const PASS = (s, ev = "") => { pass++; console.log(` ${G}PASS${R} ${s}${ev ? ` ${D}${ev}${R}` : ""}`); };
26
- const FAIL = (s, ev = "") => { fail++; console.log(` ${Rd}FAIL${R} ${s}${ev ? ` ${D}${ev}${R}` : ""}`); };
27
- const SKIP = (s, why) => { skip++; console.log(` ${Y}SKIP${R} ${s} ${D}${why}${R}`); };
28
- const step = (n) => console.log(`\n${n}`);
61
+ const context = sessionContext();
62
+ const output = resolve(process.env.TRANTOR_DRILL_RESULT || join(process.env.AGENT_BUS_DIR || join(homedir(), ".agent-bus"), "drill-result.json"));
63
+ const report = new DrillReport(CARD_STEPS, output, context);
64
+ let currentStep = null;
65
+ const PASS = (s, ev = "") => { pass++; report.record(currentStep, "pass", s, ev); console.log(` ${G}PASS${R} ${s} [${report.cardsFor(currentStep).map(id => `#${id}`).join(", ")}]${ev ? ` ${D}${ev}${R}` : ""}`); };
66
+ const FAIL = (s, ev = "") => { fail++; report.record(currentStep, "fail", s, ev); console.log(` ${Rd}FAIL${R} ${s} [${report.cardsFor(currentStep).map(id => `#${id}`).join(", ")}]${ev ? ` ${D}${ev}${R}` : ""}`); };
67
+ const SKIP = (s, why) => { skip++; report.record(currentStep, "skip", s, why); console.log(` ${Y}SKIP${R} ${s} ${D}${why}${R}`); };
68
+ const step = (n) => {
69
+ if (currentStep) report.complete(currentStep);
70
+ currentStep = n.split(" ")[0];
71
+ console.log(`\n${n}`);
72
+ };
29
73
  const sleep = (ms) => new Promise(r => setTimeout(r, ms));
30
74
 
31
75
  function herdr(args, { json = true } = {}) {
@@ -77,7 +121,9 @@ function userTurnsContaining(file, needle) {
77
121
  let r; try { r = JSON.parse(line); } catch { continue; }
78
122
  if (r?.type !== "user") continue;
79
123
  const c = r.message?.content;
124
+ // oxlint-disable-next-line anti-slop/no-runtime-typeof -- SAFETY: JSONL content is a wire union of text or content blocks; this is its decode boundary.
80
125
  const t = typeof c === "string" ? c
126
+ // oxlint-disable-next-line anti-slop/no-runtime-typeof -- SAFETY: Untrusted JSONL blocks must be objects with type=text before reading their text field.
81
127
  : Array.isArray(c) ? c.map(b => (b && typeof b === "object" && b.type === "text") ? b.text : "").join(" ") : "";
82
128
  if (t.includes(needle)) hits.push(t);
83
129
  }
@@ -99,25 +145,23 @@ function assistantSaid(file, needle) {
99
145
  /** Start a Claude agent in a pane, answering the folder-trust dialog if it blocks startup
100
146
  * (the P0b recovery: agent_not_ready keeps the name live; one enter accepts the fresh dir). */
101
147
  async function startClaude(name, paneId) {
102
- let blocked = false;
103
- try {
104
- const r = herdr(["agent", "start", name, "--kind", "claude", "--pane", paneId]);
105
- if (r.result?.agent?.agent_status === "idle") return "idle";
106
- blocked = true;
107
- } catch { blocked = true; }
108
- if (blocked) {
109
- await sleep(1500);
110
- try { herdr(["agent", "send-keys", name, "enter"]); } catch {}
111
- const settled = await waitFor("startup dialog answered", () => {
112
- try {
113
- const g = herdr(["agent", "get", name]);
114
- const st = g.result?.agent?.agent_status;
115
- return st === "idle" ? st : null;
116
- } catch { return null; }
117
- }, { timeoutMs: 45_000, everyMs: 2_000 });
118
- return settled || "not-ready";
119
- }
120
- return "not-ready";
148
+ // Run in the pane shell: herdr agent start restores the server's child-session flag.
149
+ herdr(["pane", "run", paneId, "env -u CLAUDECODE -u CLAUDE_CODE_CHILD_SESSION claude"], { json: false });
150
+ let trusted = false;
151
+ const settled = await waitFor("startup", () => {
152
+ const screen = herdr(["pane", "read", paneId], { json: false });
153
+ if (!trusted && screen.includes("Yes, I trust this")) {
154
+ // New Claude versions default to No. Answer ONLY this drill-owned folder dialog.
155
+ const keys = /❯\s*No, exit/.test(screen) ? ["down", "enter"] : ["enter"];
156
+ herdr(["pane", "send-keys", paneId, ...keys], { json: false });
157
+ trusted = true;
158
+ }
159
+ try {
160
+ const status = herdr(["agent", "get", paneId]).result?.agent?.agent_status;
161
+ return status === "idle" ? status : null;
162
+ } catch { return null; }
163
+ }, { timeoutMs: 45000, everyMs: 1500 });
164
+ return settled || "not-ready";
121
165
  }
122
166
 
123
167
  // ---------- S0 · version skew ----------
@@ -136,9 +180,9 @@ step("S0 · version skew (hooks vs CLI vs app)");
136
180
  try { app = execSync('plutil -extract CFBundleShortVersionString raw "/Applications/Trantor.app/Contents/Info.plist"', { encoding: "utf8" }).trim(); } catch {}
137
181
  console.log(` ${D}cli ${cli} · plugin ${plugin} · app ${app}${R}`);
138
182
  if (plugin === "?") {
139
- console.log(` ${Y}WARN${R} plugin hook version unreadable — cannot rule out skew`);
183
+ SKIP("hook/CLI version skew", "plugin hook version unreadable");
140
184
  } else if (plugin !== cli) {
141
- console.log(` ${Y}WARN${R} installed plugin hooks (${plugin}) differ from this tree (${cli}) — running sessions use the PLUGIN's hooks`);
185
+ FAIL("hook/CLI version skew", `plugin ${plugin} vs CLI ${cli}`);
142
186
  } else {
143
187
  PASS("no hook/CLI version skew", `${cli}`);
144
188
  }
@@ -148,11 +192,17 @@ step("S0 · version skew (hooks vs CLI vs app)");
148
192
  // ---------- world ----------
149
193
  // NOT tmpdir(): macOS tmp is a /var symlink and Claude records the /private/var realpath,
150
194
  // so the transcript-slug lookup would miss. A dot-dir under $HOME has no such alias.
151
- const world = join(homedir(), `.tt-drill-${process.pid}`);
152
- const proj = join(world, "drill-proj");
153
- const bus = join(world, "bus");
195
+ const scratch = join(process.cwd(), ".agent-bus-out");
196
+ mkdirSync(scratch, { recursive: true });
197
+ const world = process.env.TRANTOR_DRILL_WORLD
198
+ ? resolve(process.env.TRANTOR_DRILL_WORLD) : mkdtempSync(`${scratch}/`);
199
+ checkSocketHome(world);
200
+ const proj = join(world, context.project);
201
+ const bus = join(world, ".agent-bus");
154
202
  mkdirSync(proj, { recursive: true });
155
203
  mkdirSync(join(bus, "handoffs"), { recursive: true });
204
+ mkdirSync(join(world, ".config", "herdr"), { recursive: true });
205
+ symlinkSync(join(homedir(), ".config", "herdr", "herdr.sock"), join(world, ".config", "herdr", "herdr.sock"));
156
206
  execFileSync("git", ["init", "-q"], { cwd: proj });
157
207
  // The drill's handoff phase exercises the AUTO chain deliberately; the shipped default is ask.
158
208
  writeFileSync(join(bus, "autonomy.json"), JSON.stringify({ version: 1, defaults: { baton: "auto" }, projects: {} }));
@@ -160,25 +210,37 @@ const projectName = basename(proj);
160
210
  const tDir = transcriptDirFor(proj);
161
211
 
162
212
  let ws = null, pane = null;
213
+ let cleaned = false;
163
214
  const cleanup = () => {
215
+ if (cleaned) return;
216
+ cleaned = true;
164
217
  if (KEEP) { console.log(`\n${D}--keep: world at ${world} · workspace ${ws?.workspace_id || "?"} left open${R}`); return; }
165
218
  try { if (pane) herdr(["agent", "prompt", pane, "/exit"], { json: true }); } catch {}
166
219
  try { if (ws) herdr(["workspace", "close", ws.workspace_id], { json: true }); } catch {}
220
+ if (fail > 0) { console.log(`failed-run evidence retained at ${world}`); return; }
167
221
  try { rmSync(world, { recursive: true, force: true }); } catch {}
168
222
  };
169
223
  process.on("exit", cleanup);
224
+ for (const signal of ["SIGINT", "SIGTERM"]) process.once(signal, () => {
225
+ if (currentStep) FAIL("drill interrupted", signal);
226
+ cleanup();
227
+ process.exit(signal === "SIGINT" ? 130 : 143);
228
+ });
170
229
 
230
+ try {
171
231
  // ---------- S1 · cold start ----------
172
232
  step("S1 · cold start: workspace, clean env, agent, transcript EXISTS");
173
233
  try {
174
234
  const created = herdr(["workspace", "create", "--cwd", proj, "--label", "tt-drill"]);
175
235
  ws = { workspace_id: created.result.workspace.workspace_id };
176
236
  pane = created.result.root_pane.pane_id;
237
+ writeFileSync(join(bus, "crew-windows.txt"), `${projectName}\therdrws\t__ws__\t${ws.workspace_id}\n${projectName}\torch\torchestrator\t${pane}\n`);
177
238
  PASS("throwaway herdr workspace", `${ws.workspace_id} pane ${pane}`);
178
239
  // The P0b trap, prevented at the source: a pane inheriting CLAUDE_CODE_CHILD_SESSION runs
179
240
  // Claude with transcript saving OFF — an invisible session. Every spawn path must clear it.
180
241
  herdr(["pane", "run", pane,
181
- `unset CLAUDE_CODE_CHILD_SESSION; export AGENT_BUS_DIR=${bus} RELAY_URL=http://127.0.0.1:1 ` +
242
+ // Heartbeat throttling is keyed by relay identity; never share the live orchestrator's stamp.
243
+ `unset CLAUDECODE CLAUDE_CODE_CHILD_SESSION RELAY_AGENT TRANTOR_ORCH TRANTOR_SEAT; export RELAY_SESSION=drill-${process.pid}:${projectName} RELAY_PROJECT=${projectName} AGENT_BUS_DIR=${bus} RELAY_URL=http://127.0.0.1:1 ` +
182
244
  `TRANTOR_NO_SCROOGE=1 TRANTOR_NO_HANDOFF_SPAWN=1 TRANTOR_NO_BALANCE_CHECK=1 ` +
183
245
  `RELAY_CONTEXT_WARN_FRAC=0.000001 RELAY_STOP_TIMEOUT_MS=300 RELAY_CONTEXT_WINDOW=1000000; echo ENV-READY`], { json: false });
184
246
  await sleep(1500);
@@ -188,7 +250,7 @@ try {
188
250
  } catch (e) {
189
251
  FAIL("S1 world setup", String(e.message || e).slice(0, 160));
190
252
  console.log(`\n${Rd}cannot continue without S1${R}`);
191
- process.exit(1);
253
+ throw e;
192
254
  }
193
255
 
194
256
  // ---------- S2 · transport ----------
@@ -220,7 +282,7 @@ const MARK = `drill-${Date.now() % 100000}`;
220
282
  step("S3 · identity: the pane itself names the session (Phase 2)");
221
283
  let predecessorSid = null;
222
284
  {
223
- const got = herdr(["agent", "get", "drill"]);
285
+ const got = herdr(["agent", "get", pane]);
224
286
  const as = got.result?.agent?.agent_session;
225
287
  const tfile = newestJsonl(tDir);
226
288
  predecessorSid = as?.kind === "id" ? as.value : null;
@@ -237,18 +299,12 @@ step("S4 · handoff machine: warn → arm → fire → WRITTEN → successor cla
237
299
  // The heartbeat is PostToolUse: only a REAL tool call can arm the baton. Models sometimes
238
300
  // answer without the tool (observed: run 2 of 3 on 2026-08-30 — 1-in-3 prompt fragility,
239
301
  // not a seam), so ask, verify tool use in the transcript, and re-ask up to twice.
240
- const findHandoff = () => {
241
- try {
242
- const f = readdirSync(join(bus, "handoffs")).find(x => x.startsWith(`${projectName}-`) && x.endsWith(".json"));
243
- return f ? join(bus, "handoffs", f) : null;
244
- } catch { return null; }
245
- };
246
302
  let handoffFile = null;
247
303
  for (let attempt = 1; attempt <= 3 && !handoffFile; attempt++) {
248
304
  const raw = await socketRequest({ id: "trantor:agent.prompt", method: "agent.prompt", params: {
249
305
  target: pane, text: `You MUST call the Bash tool now and run exactly: pwd — do not answer without calling it. Then reply with just DONE-S4-${attempt}.` } });
250
306
  if (JSON.parse(raw).result?.type !== "agent_prompted") { FAIL("S4 prompt accepted", raw.slice(0, 100)); break; }
251
- handoffFile = await waitFor("handoff written", findHandoff, { timeoutMs: 120_000, everyMs: 2_000 });
307
+ handoffFile = await waitFor("handoff written", () => findHandoff(join(bus, "handoffs"), projectName), { timeoutMs: 120_000, everyMs: 2_000 });
252
308
  if (!handoffFile) console.log(` ${D}attempt ${attempt}: no handoff yet — re-asking with the tool requirement${R}`);
253
309
  }
254
310
  if (!handoffFile) {
@@ -260,7 +316,7 @@ step("S4 · handoff machine: warn → arm → fire → WRITTEN → successor cla
260
316
  else FAIL("§5 ledger opens with WRITTEN", states.join(","));
261
317
 
262
318
  // Successor: end the predecessor, start fresh in the SAME pane — the claim is sessionstart's.
263
- try { herdr(["agent", "prompt", "drill", "/exit"]); } catch {}
319
+ try { herdr(["agent", "prompt", pane, "/exit"]); } catch {}
264
320
  await sleep(4000);
265
321
  const st2 = await startClaude("drill2", pane);
266
322
  if (st2 !== "idle") FAIL("successor claude starts", String(st2));
@@ -315,7 +371,7 @@ step("S4b · --baton pane leg: the driver replaces the session in place, kickoff
315
371
  try {
316
372
  const wh = execFileSync(process.execPath, [join(import.meta.dirname, "write-handoff.mjs")], {
317
373
  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" },
374
+ env: { ...process.env, RELAY_PROJECT: projectName, RELAY_SESSION: "", RELAY_AGENT: "", TRANTOR_ORCH: "", RELAY_URL: "http://127.0.0.1:1", CLAUDE_PROJECT_DIR: proj, AGENT_BUS_DIR: bus, TRANTOR_NO_HANDOFF_SPAWN: "1", TRANTOR_NO_BATON_SPAWN: "1" },
319
375
  });
320
376
  hf = /handoff saved: (\S+\.json)/.exec(wh)?.[1] || null;
321
377
  } catch (e) { FAIL("S4b manual handoff written", String(e.message || e).slice(0, 120)); }
@@ -323,8 +379,8 @@ step("S4b · --baton pane leg: the driver replaces the session in place, kickoff
323
379
  PASS("manual handoff written for the pane to carry", basename(hf));
324
380
  const child = spawn(process.execPath, [join(import.meta.dirname, "baton-pane.mjs"),
325
381
  "--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}` },
382
+ env: { ...process.env, HOME: world, TRANTOR_DEV_ROOT: world, RELAY_PROJECT: projectName, RELAY_SESSION: "", RELAY_AGENT: "", TRANTOR_ORCH: "", RELAY_URL: "http://127.0.0.1:1", AGENT_BUS_DIR: bus, TRANTOR_BATON_IDLE_DEADLINE_S: "90",
383
+ TRANTOR_BATON_REOPEN: `${shellQuote(process.execPath)} ${shellQuote(join(import.meta.dirname, "cli.mjs"))} open ${shellQuote(projectName)}` },
328
384
  stdio: "ignore",
329
385
  });
330
386
  const exited = new Promise(res => child.on("exit", c => res(c)));
@@ -334,14 +390,17 @@ step("S4b · --baton pane leg: the driver replaces the session in place, kickoff
334
390
  for (let i = 0; i < 40; i++) {
335
391
  await sleep(3000);
336
392
  try {
337
- const g = herdr(["agent", "get", "drill3"]);
393
+ const g = herdr(["agent", "get", pane]);
338
394
  const st = g.result?.agent?.agent_status;
339
- if (st === "blocked") herdr(["agent", "send-keys", "drill3", "enter"]);
395
+ if (st === "blocked") herdr(["agent", "send-keys", pane, "enter"]);
340
396
  if (st === "idle") break;
341
397
  } catch {}
342
398
  }
343
399
  })();
344
- const code = await Promise.race([exited, sleep(180_000).then(() => "timeout")]);
400
+ let timer;
401
+ const code = await Promise.race([exited, new Promise(resolve => { timer = setTimeout(() => resolve("timeout"), 180_000); })]);
402
+ clearTimeout(timer);
403
+ if (code === "timeout") child.kill("SIGTERM");
345
404
  await watcher;
346
405
  if (code === 0) PASS("driver ran the whole chain (idle gate → graceful end → reopen → kickoff)");
347
406
  else FAIL("driver ran the whole chain", `exit ${code} — see ${join(bus, "logs")}/baton-pane-*.log`);
@@ -367,6 +426,50 @@ step("S4b · --baton pane leg: the driver replaces the session in place, kickoff
367
426
  step("S5 · takeover from a Terminal session");
368
427
  SKIP("takeover chain", "needs an interactive Terminal-window session; proven live 2026-08-28 (0.18.13 drill) — automate in drill v2");
369
428
 
429
+ } catch (error) {
430
+ FAIL("seam interrupted", error.message);
431
+ }
432
+
433
+ const run = async (name, fn) => {
434
+ step(name);
435
+ try { PASS(name, await fn()); } catch (error) { FAIL(name, error.message); }
436
+ };
437
+ step("S6-ask · app AskUserQuestion");
438
+ SKIP("app AskUserQuestion", CARD_STEPS[6533].recipe);
439
+ await runAppDrills({ world, proj, project: projectName, run });
440
+ for (const mode of ["post", "throw"]) {
441
+ step(`S6-key-${mode} · app key dispatch`);
442
+ SKIP("app key dispatch", CARD_STEPS[6317].recipe);
443
+ }
444
+ await run("S7 · reopen-race", async () => {
445
+ // S4b records the production driver log plus successor claim. A rerun cannot reuse an old log.
446
+ const logs = readdirSync(join(bus, "logs")).filter(name => name.startsWith("baton-pane-"));
447
+ const trace = logs.map(name => readFileSync(join(bus, "logs", name), "utf8")).join("\n");
448
+ const handoff = /armed: .*handoff=(\S+)/.exec(trace)?.[1];
449
+ const record = handoff && JSON.parse(readFileSync(join(bus, "handoffs", handoff), "utf8"));
450
+ const ended = /^(\S+) ended pid /m.exec(trace)?.[1];
451
+ const reopened = /^(\S+) reopen starting via:/m.exec(trace)?.[1];
452
+ const elapsed = Date.parse(reopened) - Date.parse(ended);
453
+ if (!record?.states?.some(row => row.state === "claimed") || !trace.includes("reopen result: status=0")
454
+ || !trace.includes("agent drop dropped") || !Number.isFinite(elapsed) || elapsed < 0 || elapsed > 1000)
455
+ throw new Error(`no complete sub-second end/drop/reopen/claim proof; elapsed=${elapsed}; ${trace.trim().slice(-700)}`);
456
+ return `${handoff} claimed; reopen after ${elapsed}ms; ${trace.trim().split("\n").join("; ")}`;
457
+ });
458
+ step("S8 · duty wake-chain");
459
+ SKIP("duty wake-chain", CARD_STEPS[6587].recipe);
460
+ await run("S9 · crew workspace ownership", () => crewWorkspaceDrill({ proj, workspace: ws?.workspace_id }));
461
+ step("S10 · provider CLI source");
462
+ SKIP("provider CLI source", CARD_STEPS[6483].recipe);
463
+ if (currentStep) report.complete(currentStep);
464
+ if (!await report.closePassed()) fail++;
465
+ console.log(`\nresult: ${output}`);
466
+ console.log(JSON.stringify(report.results(), null, 2));
467
+ cleanup();
370
468
  // ---------- verdict ----------
371
- console.log(`\n${fail === 0 ? G + "DRILL GREEN" : Rd + "DRILL RED"}${R} — ${pass} passed, ${fail} failed, ${skip} skipped`);
372
- process.exit(fail === 0 ? 0 : 1);
469
+ process.exitCode = fail === 0 ? report.exitCode() : 1;
470
+ console.log(`\n${process.exitCode === 0 ? G + "DRILL GREEN" : Rd + "DRILL RED"}${R} — ${pass} passed, ${fail} failed, ${skip} skipped`);
471
+ }
472
+
473
+ if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
474
+ await main();
475
+ }
package/deploy/setup.sh CHANGED
@@ -24,6 +24,44 @@ if [ -f "$REPO/engine/install.sh" ]; then
24
24
  || echo " (engine install failed — Trantor still works; the Advisor runs without live pricing. Retry: trantor setup)"
25
25
  case ":$PATH:" in *":$HOME/.local/bin:"*) ;; *) echo " note: add ~/.local/bin to your PATH";; esac
26
26
  fi
27
+ # Graft — local code-graph MCP tools every seat can call (token-saving retrieval; NanoNets, MIT).
28
+ # Installed here so connect (next) wires it into each CLI; the graph self-refreshes per query and is
29
+ # a no-op where a project has no index (built per project at crew launch). Non-fatal if it fails.
30
+ #
31
+ # We probe by BUILDING, not by `command -v graft`. A bin link proves nothing about whether graft can
32
+ # parse: it depends on ten tree-sitter native bindings, all wired as `"install": "node-gyp-build"`.
33
+ # npm v12 turns lifecycle scripts and implicit node-gyp builds OFF by default, and the prebuilds that
34
+ # save us do not cover every platform — tree-sitter core and tree-sitter-python ship no linux-arm64.
35
+ # There, the gyp fallback that used to compile them no longer runs, `graft` is still on PATH, and a
36
+ # presence check prints a green tick over a tool that throws on first use. So the probe indexes a
37
+ # one-file fixture (~0.2s) and requires the symbol back: exit 0 alone can mean "parsed nothing".
38
+ # Every failure path is explicitly `|| rc=1` rather than leaning on set -e being suspended inside an
39
+ # `if` condition: this must return a verdict, never abort setup.
40
+ graft_works() {
41
+ rc=0
42
+ command -v graft >/dev/null 2>&1 || return 1
43
+ d="$(mktemp -d 2>/dev/null)" || return 1
44
+ printf 'export function graftProbe(n) {\n return n;\n}\n' > "$d/probe.mjs" || rc=1
45
+ if [ "$rc" -eq 0 ]; then
46
+ ( cd "$d" && graft build . >/dev/null 2>&1 && graft skeleton probe.mjs . 2>/dev/null | grep -q graftProbe ) || rc=1
47
+ fi
48
+ rm -rf "$d"
49
+ return "$rc"
50
+ }
51
+ if ! graft_works; then
52
+ echo "▸ installing Graft (code-graph MCP tools for the seats)…"
53
+ npm install -g @nanonets/graft >/dev/null 2>&1 || true
54
+ fi
55
+ if graft_works; then
56
+ echo "✓ Graft installed"
57
+ elif command -v graft >/dev/null 2>&1; then
58
+ echo " (Graft is on PATH but cannot parse — almost certainly its native parsers were never built."
59
+ echo " npm v12 skips them by default. Fix: npm install -g @nanonets/graft --allow-scripts"
60
+ echo " Seats keep working without graft_* tools until then.)"
61
+ else
62
+ echo " (Graft install failed — seats keep working without graft_* tools; retry: npm install -g @nanonets/graft)"
63
+ fi
64
+
27
65
  node "$REPO/bin/connect.mjs"
28
66
  echo
29
67
  node "$REPO/bin/doctor.mjs" || true
@@ -247,7 +247,13 @@ export function lastRowMidTurn(transcriptPath) {
247
247
  const c = r?.message?.content;
248
248
  if (r.type === "assistant") {
249
249
  const blocks = Array.isArray(c) ? c : [];
250
- if (blocks.some(b => b?.type === "tool_use")) return true; // a result is still owed
250
+ const calls = blocks.filter(b => b?.type === "tool_use");
251
+ // #6668: a session parked in a LONE relay_wait is at its boundary. The wait is not work
252
+ // in flight — everything the turn did is already on disk, and the tool returns only when
253
+ // the bus speaks. Reading it as mid-turn armed the baton for the 17-minute boundary wait
254
+ // on a turn that never ends on its own; the pre-kill idle gate's deadline is what ends it.
255
+ if (calls.length && calls.every(isRelayWaitCall)) return false;
256
+ if (calls.length) return true; // a result is still owed
251
257
  return false; // text-only → turn said its piece
252
258
  }
253
259
  // user row: #6528 follow-up — a trailing user row of ANY kind means in flight. A
@@ -263,6 +269,42 @@ export function lastRowMidTurn(transcriptPath) {
263
269
  export function turnInFlight(transcriptPath) {
264
270
  return subagentsActive(transcriptPath) || lastRowMidTurn(transcriptPath);
265
271
  }
272
+ // The relay MCP's wait tool, by any server prefix (mcp__plugin_trantor_relay__relay_wait,
273
+ // mcp__trantor__relay_wait, a bare relay_wait in a fixture).
274
+ function isRelayWaitCall(block) {
275
+ return /(^|__)relay_wait$/.test(String(block?.name || ""));
276
+ }
277
+
278
+ // ---- does the transcript's session still have a process? (#6668) ------------------------------
279
+ // Claude Code registers every live session in ~/.claude/sessions/<pid>.json ({pid, sessionId,
280
+ // cwd, ...}) and removes the file at exit. The 09-07 12:35 chain armed on a transcript whose
281
+ // session had exited at 12:16: the transcript's last row was a tool_result ("Connection closed"),
282
+ // so the boundary gate read it as mid-turn and waited on a turn that no process would ever end.
283
+ // A session with no live process IS at its boundary — its record can be written now.
284
+ // "live" an entry names this session and its pid answers kill -0
285
+ // "dead" the registry is in use (some other session is live) and none of its live entries
286
+ // name this session
287
+ // "unknown" no registry, or nothing in it is alive — say nothing, the boundary gate decides
288
+ // The "dead" verdict needs another LIVE entry on purpose: a Claude Code too old to keep the
289
+ // registry must not turn every mid-turn handoff into an immediate write.
290
+ export function sessionProcessState(sessionId, { home = homedir() } = {}) {
291
+ if (!sessionId) return "unknown";
292
+ let files;
293
+ try { files = readdirSync(join(home, ".claude", "sessions")).filter(f => f.endsWith(".json")); } catch { return "unknown"; }
294
+ let anyLive = false;
295
+ for (const f of files) {
296
+ let entry;
297
+ try { entry = JSON.parse(readFileSync(join(home, ".claude", "sessions", f), "utf8")); } catch { continue; }
298
+ const pid = Number(entry?.pid) || Number(basename(f, ".json")) || 0;
299
+ if (!(pid > 0) || !pidAlive(pid)) continue;
300
+ anyLive = true;
301
+ if (String(entry?.sessionId || "") === sessionId) return "live";
302
+ }
303
+ return anyLive ? "dead" : "unknown";
304
+ }
305
+ function pidAlive(pid) {
306
+ try { process.kill(pid, 0); return true; } catch (e) { return e?.code === "EPERM"; }
307
+ }
266
308
 
267
309
  // ---- whole-session summary --------------------------------------------------
268
310
  function collectTurns(transcriptPath) {