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.
- package/.claude-plugin/plugin.json +1 -1
- package/bin/baton-pane.mjs +62 -9
- package/bin/baton.mjs +8 -2
- package/bin/connect.mjs +44 -4
- package/bin/crew/open.mjs +54 -5
- package/bin/crew-runner.mjs +29 -11
- package/bin/drill-report.mjs +80 -0
- package/bin/drill-report.test.mjs +146 -0
- package/bin/drill-seams.mjs +157 -0
- package/bin/drill-surface.mjs +157 -54
- package/deploy/setup.sh +38 -0
- package/hooks/lib/handoff.mjs +43 -1
- package/lib/duty-nudges.mjs +48 -10
- package/lib/state/apply.mjs +170 -0
- package/lib/state/migrate.mjs +126 -0
- package/lib/state/schema.mjs +216 -0
- package/lib/state/validate.mjs +231 -0
- package/package.json +1 -1
|
@@ -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
|
+
}
|
package/bin/drill-surface.mjs
CHANGED
|
@@ -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
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
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
|
|
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
|
|
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
|
|
26
|
-
const
|
|
27
|
-
const
|
|
28
|
-
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
|
|
183
|
+
SKIP("hook/CLI version skew", "plugin hook version unreadable");
|
|
140
184
|
} else if (plugin !== cli) {
|
|
141
|
-
|
|
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
|
|
152
|
-
|
|
153
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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",
|
|
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",
|
|
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:
|
|
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",
|
|
393
|
+
const g = herdr(["agent", "get", pane]);
|
|
338
394
|
const st = g.result?.agent?.agent_status;
|
|
339
|
-
if (st === "blocked") herdr(["agent", "send-keys",
|
|
395
|
+
if (st === "blocked") herdr(["agent", "send-keys", pane, "enter"]);
|
|
340
396
|
if (st === "idle") break;
|
|
341
397
|
} catch {}
|
|
342
398
|
}
|
|
343
399
|
})();
|
|
344
|
-
|
|
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
|
-
|
|
372
|
-
process.
|
|
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
|
package/hooks/lib/handoff.mjs
CHANGED
|
@@ -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
|
-
|
|
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) {
|