trantor 0.18.49 → 0.18.51
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 +98 -12
- package/bin/doctor.mjs +61 -1
- 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/bin/duty.mjs +68 -1
- package/bin/write-handoff.mjs +7 -1
- package/deploy/setup.sh +38 -0
- package/hooks/lib/handoff.mjs +158 -2
- package/hooks/sessionstart.mjs +10 -0
- package/lib/duty-nudges.mjs +48 -10
- package/lib/state/apply.mjs +170 -0
- package/lib/state/derive.mjs +229 -0
- package/lib/state/gate.mjs +355 -0
- package/lib/state/migrate.mjs +126 -0
- package/lib/state/promote.mjs +122 -0
- package/lib/state/schema.mjs +216 -0
- package/lib/state/store.mjs +442 -0
- package/lib/state/validate.mjs +231 -0
- package/package.json +1 -1
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/bin/duty.mjs
CHANGED
|
@@ -245,6 +245,14 @@ if (cmd === "up") {
|
|
|
245
245
|
const env = (() => {
|
|
246
246
|
const e = { RELAY_URL: hub, RUNNER_RULES: RULES, CREW_KICKOFF: KICKOFF,
|
|
247
247
|
RUNNER_DUTY_NUDGES: "1",
|
|
248
|
+
// A parked seat waits for `trantor up`. That is right for a CODE seat — burning a
|
|
249
|
+
// plan re-sending the same contract helps nobody (#6270). It is wrong for DUTY,
|
|
250
|
+
// which is the seat every other seat's liveness runs through: on 2026-09-09 it
|
|
251
|
+
// parked on a quota read and sat holding 48 messages for 21.9 hours while the
|
|
252
|
+
// crew finished a night's work nobody gated. Duty is the one seat under a launchd
|
|
253
|
+
// keepalive, so it does not need to sit there — it can exit and let the supervisor
|
|
254
|
+
// bring it back clean, which also re-reads auth. 15 minutes, then hand over.
|
|
255
|
+
RUNNER_PARK_MAX_MS: "900000",
|
|
248
256
|
RUNNER_TITLE: "Trantor Duty Agent", RUNNER_ABOUT: ABOUT,
|
|
249
257
|
// launchd starts jobs with a MINIMAL Path — the resurrected seat could not find
|
|
250
258
|
// `claude` and every turn died exit 127 "missing-cli" (found live 2026-08-31,
|
|
@@ -360,6 +368,31 @@ if (cmd === "down") {
|
|
|
360
368
|
process.exit(0);
|
|
361
369
|
}
|
|
362
370
|
|
|
371
|
+
/**
|
|
372
|
+
* Is duty actually moving mail? Read from the runner's own on-disk queue rather than trusting the
|
|
373
|
+
* process table. `pending-<agent>-<project>.json` is written on every failed delivery and deleted
|
|
374
|
+
* when the queue drains, so its existence means messages are held and its oldest entry says for how
|
|
375
|
+
* long. STALL_HOURS is deliberately well past the runner's own 15-minute retry ceiling: anything
|
|
376
|
+
* older than that is not a retry in progress, it is a parked seat nobody restarted.
|
|
377
|
+
*/
|
|
378
|
+
const STALL_HOURS = 1;
|
|
379
|
+
function dutyHealth() {
|
|
380
|
+
const f = join(BUS, `pending-${AGENT}-trantor-duty.json`);
|
|
381
|
+
try {
|
|
382
|
+
const j = JSON.parse(readFileSync(f, "utf8"));
|
|
383
|
+
const wake = Array.isArray(j.wake) ? j.wake : [];
|
|
384
|
+
const bcast = Array.isArray(j.bcast) ? j.bcast : [];
|
|
385
|
+
const held = wake.length + bcast.length;
|
|
386
|
+
if (!held) return { held: 0, stalled: false, oldestHours: "0.0" };
|
|
387
|
+
const stamps = [...wake, ...bcast].map(m => m?.ts).filter(Number.isFinite);
|
|
388
|
+
const oldest = stamps.length ? Math.min(...stamps) : j.ts;
|
|
389
|
+
const hours = (Date.now() - oldest) / 3.6e6;
|
|
390
|
+
return { held, stalled: hours >= STALL_HOURS, oldestHours: hours.toFixed(1) };
|
|
391
|
+
} catch {
|
|
392
|
+
return { held: 0, stalled: false, oldestHours: "0.0" }; // no queue file = nothing held
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
363
396
|
// status
|
|
364
397
|
{
|
|
365
398
|
const pid = alivePid();
|
|
@@ -370,7 +403,41 @@ if (cmd === "down") {
|
|
|
370
403
|
console.log(" whether a seat is still alive, and clears away dead runners. It never writes code");
|
|
371
404
|
console.log(" and never edits your project files. Stop it with: trantor duty down");
|
|
372
405
|
console.log("");
|
|
373
|
-
|
|
406
|
+
// A pid is not health. This line used to read `pid ? "RUNNING" : "NOT running"`, and on 2026-09-09
|
|
407
|
+
// it said RUNNING for a seat whose last successful turn was 21.8 hours earlier and which was
|
|
408
|
+
// holding 48 undelivered messages, the oldest 21.9 hours old — the runner had parked itself
|
|
409
|
+
// waiting on a quota reset and only `trantor up` un-parks it. Nothing noticed, because the one
|
|
410
|
+
// command whose job is to answer "is duty working" was answering "does a process exist".
|
|
411
|
+
//
|
|
412
|
+
// The irony worth keeping: ten lines below, this same function already refuses to assume the hub
|
|
413
|
+
// feed is wired, on the grounds that "a running seat the hub isn't feeding looks identical to a
|
|
414
|
+
// working one from the outside". That rigour was applied to the hub and not to the seat itself.
|
|
415
|
+
//
|
|
416
|
+
// The queue is the honest signal because it survives the process: the runner persists it to
|
|
417
|
+
// pending-<agent>-<project>.json on every failed delivery, so a backlog with an old head means
|
|
418
|
+
// messages are not moving no matter what the process table says.
|
|
419
|
+
const health = dutyHealth();
|
|
420
|
+
if (!pid) {
|
|
421
|
+
console.log("NOT running");
|
|
422
|
+
// A backlog matters MORE when the seat is down, not less: those messages have nobody to deliver
|
|
423
|
+
// them and no retry ladder running. Reporting it only in the pid branch was the first version
|
|
424
|
+
// of this fix, and it hid exactly the case that needs saying out loud.
|
|
425
|
+
if (health.held) {
|
|
426
|
+
console.log(` \x1b[31mand ${health.held} undelivered message(s) are held on disk, oldest ${health.oldestHours}h old\x1b[0m`);
|
|
427
|
+
console.log(` → nothing will deliver them until the seat is back: trantor duty up`);
|
|
428
|
+
} else {
|
|
429
|
+
console.log(" queue empty — nothing is waiting");
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
else if (health.stalled) {
|
|
433
|
+
console.log(`\x1b[31mSTALLED\x1b[0m (pid ${pid}) as ${SESSION} — the process is up but mail is not moving`);
|
|
434
|
+
console.log(` holding ${health.held} undelivered message(s), oldest ${health.oldestHours}h old`);
|
|
435
|
+
console.log(` → the runner parks on quota/api failure and only a restart un-parks it: trantor duty up`);
|
|
436
|
+
} else if (health.held) {
|
|
437
|
+
console.log(`RUNNING (pid ${pid}) as ${SESSION} — draining ${health.held} queued message(s), oldest ${health.oldestHours}h old`);
|
|
438
|
+
} else {
|
|
439
|
+
console.log(`RUNNING (pid ${pid}) as ${SESSION} — queue empty`);
|
|
440
|
+
}
|
|
374
441
|
console.log(existsSync(DUTY_PLIST)
|
|
375
442
|
? `keepalive: installed (${DUTY_LABEL}${process.platform === "darwin" ? (dutyLoaded() ? ", loaded" : ", not loaded in this session") : ""}) — launchd relaunches the seat after a crash or reboot`
|
|
376
443
|
: "keepalive: NOT installed — a crash or reboot leaves the seat down (trantor duty up installs it)");
|
package/bin/write-handoff.mjs
CHANGED
|
@@ -6,7 +6,7 @@ import { writeFileSync, readFileSync, existsSync, mkdirSync, readdirSync } from
|
|
|
6
6
|
import { join, basename } from "node:path";
|
|
7
7
|
import { homedir, hostname } from "node:os";
|
|
8
8
|
import { execSync } from "node:child_process";
|
|
9
|
-
import { spawnBaton, handoffMode, resolveHandoffSurface } from "../hooks/lib/handoff.mjs";
|
|
9
|
+
import { spawnBaton, handoffMode, resolveHandoffSurface, attachState, resolveSeat, resolveHandoffCard } from "../hooks/lib/handoff.mjs";
|
|
10
10
|
import { handoffDir } from "../lib/project.mjs";
|
|
11
11
|
|
|
12
12
|
const baton = process.argv.includes("--baton");
|
|
@@ -63,6 +63,12 @@ if (latest) {
|
|
|
63
63
|
// carries the same interface the hooks-side records have: transcript_path ("" — the summary IS
|
|
64
64
|
// the model's own words), mode (attended|unattended, #5648).
|
|
65
65
|
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" }] };
|
|
66
|
+
// The structured working state rides beside the prose (TDD §4.5), dark behind
|
|
67
|
+
// TRANTOR_STATE_HANDOFF. The manual path gets it for the same reason the hook path does: this
|
|
68
|
+
// record is what the successor loads, and `summary` here IS the model's own handoff — the richest
|
|
69
|
+
// STATE block there is.
|
|
70
|
+
const seat = resolveSeat(name);
|
|
71
|
+
attachState(rec, { project: name, seat, card: resolveHandoffCard({ projectName: name, seat }), worktree: project });
|
|
66
72
|
const file = join(dir, `${rec.id}.json`);
|
|
67
73
|
writeFileSync(file, JSON.stringify(rec, null, 2));
|
|
68
74
|
console.log(`handoff saved: ${file}`);
|
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
|