@heretyc/subagent-mcp 2.6.0

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/dist/setup.js ADDED
@@ -0,0 +1,507 @@
1
+ #!/usr/bin/env node
2
+ // Setup CLI for the globally-installed subagent-mcp addon.
3
+ // Wires Claude Code CLI and Codex CLI with the MCP server + orchestration-mode hook.
4
+ // Run after: npm install -g @heretyc/subagent-mcp
5
+ //
6
+ // Usage:
7
+ // subagent-mcp setup -- auto-detect vendors, wire all present
8
+ // subagent-mcp setup --dry-run -- print config, make no changes
9
+ //
10
+ // Design: maximally automatic and self-repairing.
11
+ // - Wiring that exists but points at a WRONG/STALE path (moved npm prefix,
12
+ // scope rename, dev-tree leftovers) is REPAIRED in place, not "left as-is".
13
+ // - Claude server registration falls back from the official CLI to a direct
14
+ // ~/.claude.json edit (same schema) if the CLI fails.
15
+ // - A missing ~/.codex/config.toml is created, not punted to the user.
16
+ // - Every config file is backed up before its first edit.
17
+ // - Failures never abort the run: they are collected and reported at the end
18
+ // with a copy-paste repair prompt the user can hand to Claude/Codex.
19
+ import { existsSync, readFileSync, writeFileSync, copyFileSync, } from "node:fs";
20
+ import { homedir } from "node:os";
21
+ import { join, dirname, resolve } from "node:path";
22
+ import { fileURLToPath } from "node:url";
23
+ import { execFileSync, execSync } from "node:child_process";
24
+ const cliArgs = process.argv.slice(3); // argv[2]='setup', flags start at [3]
25
+ const DRY_RUN = cliArgs.includes("--dry-run");
26
+ // Install root: dist/setup.js -> dist/ -> <install-root>
27
+ const INSTALL_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
28
+ export function fwd(p) {
29
+ return p.split("\\").join("/");
30
+ }
31
+ export function serverPaths(root = INSTALL_ROOT) {
32
+ const f = fwd(root);
33
+ return {
34
+ server: `${f}/dist/index.js`,
35
+ claudeHook: `${f}/dist/hooks/orchestration-claude.js`,
36
+ codexHook: `${f}/dist/hooks/orchestration-codex.js`,
37
+ };
38
+ }
39
+ /**
40
+ * Pure-node PATH lookup. `where`/`which` are not guaranteed to exist (minimal
41
+ * containers, stripped distros), so scan PATH ourselves. On win32, PATHEXT
42
+ * extensions are tried; existence (not exec bit) is the test on POSIX, which is
43
+ * the right tolerance level for "is this CLI installed".
44
+ */
45
+ export function findOnPath(cmd, env = process.env, platform = process.platform) {
46
+ const sep = platform === "win32" ? ";" : ":";
47
+ const dirs = (env.PATH ?? env.Path ?? "").split(sep).filter(Boolean);
48
+ const exts = platform === "win32"
49
+ ? (env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean)
50
+ : [""];
51
+ for (const d of dirs) {
52
+ for (const e of exts) {
53
+ const p = join(d, cmd + e.toLowerCase());
54
+ if (existsSync(p))
55
+ return p;
56
+ }
57
+ if (platform === "win32" && existsSync(join(d, cmd)))
58
+ return join(d, cmd);
59
+ }
60
+ return null;
61
+ }
62
+ /**
63
+ * Reconcile the UserPromptSubmit hook in a parsed ~/.claude/settings.json.
64
+ * Mutates `s` in place. Exact wiring present -> ok. A hook referencing
65
+ * orchestration-claude.js at any OTHER path/shape -> repaired (rewritten to the
66
+ * canonical exec form). Absent -> added. Unrelated hooks are never touched.
67
+ */
68
+ export function reconcileClaudeSettings(s, hookPath) {
69
+ const hooksBlock = (s.hooks ?? {});
70
+ s.hooks = hooksBlock;
71
+ const upsList = (hooksBlock.UserPromptSubmit ?? []);
72
+ hooksBlock.UserPromptSubmit = upsList;
73
+ for (const grp of upsList) {
74
+ for (const hk of grp.hooks ?? []) {
75
+ if (!JSON.stringify(hk).includes("orchestration-claude.js"))
76
+ continue;
77
+ const args = hk.args;
78
+ const exact = hk.command === "node" &&
79
+ Array.isArray(args) &&
80
+ args.length === 1 &&
81
+ args[0] === hookPath;
82
+ if (exact)
83
+ return { changed: false, status: "ok" };
84
+ hk.type = "command";
85
+ hk.command = "node";
86
+ hk.args = [hookPath];
87
+ return { changed: true, status: "repaired" };
88
+ }
89
+ }
90
+ upsList.push({
91
+ hooks: [{ type: "command", command: "node", args: [hookPath] }],
92
+ });
93
+ return { changed: true, status: "added" };
94
+ }
95
+ /**
96
+ * Reconcile the user-scope MCP server entry in a parsed ~/.claude.json.
97
+ * Mutates `cj` in place. Same ok/repaired/added semantics; other servers are
98
+ * never touched. (Direct-edit fallback for when `claude mcp add` fails; the
99
+ * schema written matches what the official CLI writes.)
100
+ */
101
+ export function reconcileClaudeJson(cj, serverPath) {
102
+ const servers = (cj.mcpServers ?? {});
103
+ cj.mcpServers = servers;
104
+ const cur = servers["subagent-mcp"];
105
+ if (cur) {
106
+ const args = cur.args;
107
+ const exact = cur.command === "node" &&
108
+ Array.isArray(args) &&
109
+ args.length === 1 &&
110
+ args[0] === serverPath;
111
+ if (exact)
112
+ return { changed: false, status: "ok" };
113
+ }
114
+ servers["subagent-mcp"] = {
115
+ type: "stdio",
116
+ command: "node",
117
+ args: [serverPath],
118
+ env: {},
119
+ };
120
+ return { changed: true, status: cur ? "repaired" : "added" };
121
+ }
122
+ /**
123
+ * Reconcile the [mcp_servers.subagent-mcp] block in ~/.codex/config.toml text.
124
+ * Block absent -> appended. Block present with the exact server path -> ok.
125
+ * Block present pointing elsewhere -> the main block (NOT its .tools subtables)
126
+ * is rewritten to the canonical form. Returns the new TOML text.
127
+ */
128
+ export function reconcileCodexToml(toml, serverPath) {
129
+ const canonical = `[mcp_servers.subagent-mcp]\n` +
130
+ `command = "node"\n` +
131
+ `args = ["${serverPath}"]\n` +
132
+ `startup_timeout_sec = 10\n` +
133
+ `tool_timeout_sec = 60\n`;
134
+ // Main block runs from its header to the next table header — a '[' at the
135
+ // START of a line (its .tools.* subtables are separate tables and are left
136
+ // alone). A bare [^[]* would stop at the '[' inside `args = ["..."]`.
137
+ const blockRe = /\[mcp_servers\.subagent-mcp\][\s\S]*?(?=\n\[|$)/;
138
+ const m = toml.match(blockRe);
139
+ if (!m) {
140
+ const sepNl = toml.endsWith("\n") || toml === "" ? "" : "\n";
141
+ return {
142
+ toml: toml + `${sepNl}\n` + canonical,
143
+ changed: true,
144
+ status: "added",
145
+ };
146
+ }
147
+ if (m[0].includes(`args = ["${serverPath}"]`)) {
148
+ return { toml, changed: false, status: "ok" };
149
+ }
150
+ return {
151
+ toml: toml.replace(blockRe, canonical + "\n"),
152
+ changed: true,
153
+ status: "repaired",
154
+ };
155
+ }
156
+ /**
157
+ * Reconcile the SessionStart + UserPromptSubmit entries in a parsed
158
+ * ~/.codex/hooks.json. Mutates `h` in place. Per event: exact command -> ok,
159
+ * stale orchestration-codex.js reference -> repaired, absent -> added.
160
+ */
161
+ export function reconcileCodexHooks(h, hookCmd) {
162
+ const hooksBlock = (h.hooks ?? {});
163
+ h.hooks = hooksBlock;
164
+ const statuses = {};
165
+ let changed = false;
166
+ for (const ev of ["SessionStart", "UserPromptSubmit"]) {
167
+ const evList = (hooksBlock[ev] = hooksBlock[ev] ?? []);
168
+ let found = null;
169
+ for (const grp of evList) {
170
+ for (const hk of grp.hooks ?? []) {
171
+ if (JSON.stringify(hk).includes("orchestration-codex.js")) {
172
+ found = hk;
173
+ break;
174
+ }
175
+ }
176
+ if (found)
177
+ break;
178
+ }
179
+ if (found) {
180
+ if (found.command === hookCmd && found.commandWindows === hookCmd) {
181
+ statuses[ev] = "ok";
182
+ continue;
183
+ }
184
+ found.type = "command";
185
+ found.command = hookCmd;
186
+ found.commandWindows = hookCmd;
187
+ found.timeout = 10;
188
+ statuses[ev] = "repaired";
189
+ changed = true;
190
+ }
191
+ else {
192
+ evList.push({
193
+ hooks: [
194
+ {
195
+ type: "command",
196
+ command: hookCmd,
197
+ commandWindows: hookCmd,
198
+ timeout: 10,
199
+ },
200
+ ],
201
+ });
202
+ statuses[ev] = "added";
203
+ changed = true;
204
+ }
205
+ }
206
+ return { changed, statuses };
207
+ }
208
+ // ---------------------------------------------------------------------------
209
+ // IO helpers
210
+ // ---------------------------------------------------------------------------
211
+ export function verifyInstall(root = INSTALL_ROOT) {
212
+ const required = [
213
+ "dist/index.js",
214
+ "dist/advanced-ruleset.py",
215
+ "dist/hooks/orchestration-claude.js",
216
+ "dist/hooks/orchestration-codex.js",
217
+ "directives/orchestration-claude.md",
218
+ "directives/orchestration-codex.md",
219
+ "directives/off-turn-reminder.md",
220
+ ];
221
+ return required.filter((f) => !existsSync(join(root, ...f.split("/"))));
222
+ }
223
+ function readJson(file, fallback) {
224
+ try {
225
+ return JSON.parse(readFileSync(file, "utf8"));
226
+ }
227
+ catch {
228
+ return { ...fallback };
229
+ }
230
+ }
231
+ const backedUp = new Set();
232
+ function backup(file) {
233
+ if (backedUp.has(file) || !existsSync(file))
234
+ return;
235
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-");
236
+ try {
237
+ copyFileSync(file, `${file}.bak-setup-${stamp}`);
238
+ backedUp.add(file);
239
+ }
240
+ catch {
241
+ /* backup is best-effort */
242
+ }
243
+ }
244
+ function runCmd(cmd, cmdArgs) {
245
+ console.log(` $ ${cmd} ${cmdArgs.join(" ")}`);
246
+ if (DRY_RUN) {
247
+ console.log(" (dry-run: skipped)");
248
+ return true;
249
+ }
250
+ try {
251
+ if (process.platform === "win32") {
252
+ // npm-installed CLIs are .cmd shims execFileSync can't spawn directly.
253
+ const line = [cmd, ...cmdArgs.map((a) => (/\s/.test(a) ? `"${a}"` : a))].join(" ");
254
+ execSync(line, { stdio: "pipe" });
255
+ }
256
+ else {
257
+ execFileSync(cmd, cmdArgs, { stdio: "pipe" });
258
+ }
259
+ return true;
260
+ }
261
+ catch {
262
+ return false;
263
+ }
264
+ }
265
+ const issues = [];
266
+ function repairPromptFor(vendor, problem) {
267
+ const p = serverPaths();
268
+ if (vendor === "claude") {
269
+ return (`subagent-mcp setup hit a problem on my machine: ${problem}. ` +
270
+ `The install root is "${fwd(INSTALL_ROOT)}". Please repair my Claude Code wiring: ` +
271
+ `(1) register a user-scope MCP server named "subagent-mcp" running ` +
272
+ `[node "${p.server}"] (use 'claude mcp add --scope user' or edit the mcpServers ` +
273
+ `key in ~/.claude.json), and (2) ensure ~/.claude/settings.json has a ` +
274
+ `hooks.UserPromptSubmit entry {type:"command", command:"node", args:["${p.claudeHook}"]}. ` +
275
+ `Back up any file before editing it.`);
276
+ }
277
+ return (`subagent-mcp setup hit a problem on my machine: ${problem}. ` +
278
+ `The install root is "${fwd(INSTALL_ROOT)}". Please repair my Codex CLI wiring: ` +
279
+ `(1) ensure ~/.codex/config.toml has [mcp_servers.subagent-mcp] with command = "node", ` +
280
+ `args = ["${p.server}"], startup_timeout_sec = 10, tool_timeout_sec = 60, and ` +
281
+ `(2) ensure ~/.codex/hooks.json has SessionStart and UserPromptSubmit entries ` +
282
+ `{type:"command", command:'node "${p.codexHook}"', timeout:10}. ` +
283
+ `Back up any file before editing it, then remind me to run /hooks in Codex and trust the hook.`);
284
+ }
285
+ function fail(vendor, problem) {
286
+ console.log(` PROBLEM: ${problem}`);
287
+ issues.push({ vendor, problem, repairPrompt: repairPromptFor(vendor, problem) });
288
+ }
289
+ // ---------------------------------------------------------------------------
290
+ // Vendor wiring
291
+ // ---------------------------------------------------------------------------
292
+ function describe(status, what) {
293
+ if (status === "ok")
294
+ console.log(` ${what}: already correct.`);
295
+ else if (status === "added")
296
+ console.log(` ${what}: added.`);
297
+ else
298
+ console.log(` ${what}: pointed at a stale path — repaired.`);
299
+ }
300
+ function wireClaude() {
301
+ console.log("\n--- Claude Code CLI ---");
302
+ const p = serverPaths();
303
+ const cjFile = join(homedir(), ".claude.json");
304
+ // 1) MCP server (user scope). Reconcile against ~/.claude.json; prefer the
305
+ // official CLI for writes, fall back to a direct (schema-identical) edit.
306
+ try {
307
+ const cj = readJson(cjFile, {});
308
+ const probe = JSON.parse(JSON.stringify(cj));
309
+ const { status } = reconcileClaudeJson(probe, p.server);
310
+ if (status === "ok") {
311
+ describe("ok", "MCP server (user scope)");
312
+ }
313
+ else {
314
+ if (status === "repaired") {
315
+ console.log(" MCP server registration points at a stale path — re-registering.");
316
+ runCmd("claude", ["mcp", "remove", "-s", "user", "subagent-mcp"]);
317
+ }
318
+ const cliOk = runCmd("claude", [
319
+ "mcp", "add", "--scope", "user", "subagent-mcp", "--", "node", p.server,
320
+ ]);
321
+ // Read back; if the CLI failed or didn't take, write the entry directly.
322
+ const after = readJson(cjFile, {});
323
+ const verify = reconcileClaudeJson(after, p.server);
324
+ if (verify.status !== "ok" && !DRY_RUN) {
325
+ if (!cliOk)
326
+ console.log(" 'claude mcp add' failed — writing ~/.claude.json directly.");
327
+ backup(cjFile);
328
+ writeFileSync(cjFile, JSON.stringify(after, null, 2));
329
+ }
330
+ describe(status, "MCP server (user scope)");
331
+ }
332
+ }
333
+ catch (e) {
334
+ fail("claude", `could not register the MCP server: ${e.message}`);
335
+ }
336
+ // 2) UserPromptSubmit hook in ~/.claude/settings.json.
337
+ try {
338
+ const sfile = join(homedir(), ".claude", "settings.json");
339
+ const s = readJson(sfile, {});
340
+ const { changed, status } = reconcileClaudeSettings(s, p.claudeHook);
341
+ if (changed && !DRY_RUN) {
342
+ backup(sfile);
343
+ writeFileSync(sfile, JSON.stringify(s, null, 2));
344
+ }
345
+ describe(status, "UserPromptSubmit hook");
346
+ if (changed && DRY_RUN)
347
+ console.log(" (dry-run: not written)");
348
+ }
349
+ catch (e) {
350
+ fail("claude", `could not write the settings.json hook: ${e.message}`);
351
+ }
352
+ }
353
+ function wireCodex() {
354
+ console.log("\n--- Codex CLI ---");
355
+ const p = serverPaths();
356
+ const codexDir = join(homedir(), ".codex");
357
+ // 1) config.toml — MCP server block (created if the file is missing).
358
+ try {
359
+ const cfg = join(codexDir, "config.toml");
360
+ const toml = existsSync(cfg) ? readFileSync(cfg, "utf8") : "";
361
+ const r = reconcileCodexToml(toml, p.server);
362
+ if (r.changed && !DRY_RUN) {
363
+ backup(cfg);
364
+ writeFileSync(cfg, r.toml);
365
+ }
366
+ describe(r.status, toml === "" ? "config.toml (created) MCP server block" : "config.toml MCP server block");
367
+ if (r.changed && DRY_RUN)
368
+ console.log(" (dry-run: not written)");
369
+ }
370
+ catch (e) {
371
+ fail("codex", `could not write config.toml: ${e.message}`);
372
+ }
373
+ // 2) hooks.json — SessionStart + UserPromptSubmit hooks.
374
+ try {
375
+ const hfile = join(codexDir, "hooks.json");
376
+ const h = readJson(hfile, { hooks: {} });
377
+ const hookCmd = `node "${p.codexHook}"`;
378
+ const { changed, statuses } = reconcileCodexHooks(h, hookCmd);
379
+ if (changed && !DRY_RUN) {
380
+ backup(hfile);
381
+ writeFileSync(hfile, JSON.stringify(h, null, 2));
382
+ }
383
+ for (const [ev, st] of Object.entries(statuses))
384
+ describe(st, `${ev} hook`);
385
+ if (changed && DRY_RUN)
386
+ console.log(" (dry-run: not written)");
387
+ if (changed) {
388
+ console.log(" NOTE: hook content changed — run 'codex', then /hooks, and TRUST the hook.");
389
+ }
390
+ }
391
+ catch (e) {
392
+ fail("codex", `could not write hooks.json: ${e.message}`);
393
+ }
394
+ }
395
+ export function verifyWiring(root = INSTALL_ROOT) {
396
+ const p = serverPaths(root);
397
+ const results = [];
398
+ const home = homedir();
399
+ const missing = verifyInstall(root);
400
+ results.push({
401
+ label: "install files",
402
+ ok: missing.length === 0,
403
+ detail: missing.length === 0 ? `all present under ${fwd(root)}` : `missing: ${missing.join(", ")}`,
404
+ });
405
+ const hasClaude = findOnPath("claude") !== null;
406
+ if (hasClaude) {
407
+ const cj = readJson(join(home, ".claude.json"), {});
408
+ const sj = readJson(join(home, ".claude", "settings.json"), {});
409
+ const srv = reconcileClaudeJson(JSON.parse(JSON.stringify(cj)), p.server);
410
+ const hk = reconcileClaudeSettings(JSON.parse(JSON.stringify(sj)), p.claudeHook);
411
+ results.push({
412
+ label: "claude: MCP server (user scope)",
413
+ ok: srv.status === "ok",
414
+ detail: srv.status === "ok" ? "registered" : `${srv.status === "repaired" ? "stale path" : "not registered"} — run: subagent-mcp setup`,
415
+ });
416
+ results.push({
417
+ label: "claude: UserPromptSubmit hook",
418
+ ok: hk.status === "ok",
419
+ detail: hk.status === "ok" ? "wired" : `${hk.status === "repaired" ? "stale path" : "not wired"} — run: subagent-mcp setup`,
420
+ });
421
+ }
422
+ const hasCodex = findOnPath("codex") !== null || existsSync(join(home, ".codex"));
423
+ if (hasCodex) {
424
+ const cfg = join(home, ".codex", "config.toml");
425
+ const toml = existsSync(cfg) ? readFileSync(cfg, "utf8") : "";
426
+ const tomlR = reconcileCodexToml(toml, p.server);
427
+ const hj = readJson(join(home, ".codex", "hooks.json"), { hooks: {} });
428
+ const hkR = reconcileCodexHooks(hj, `node "${p.codexHook}"`);
429
+ results.push({
430
+ label: "codex: config.toml MCP server block",
431
+ ok: tomlR.status === "ok",
432
+ detail: tomlR.status === "ok" ? "registered" : `${tomlR.status === "repaired" ? "stale path" : "not registered"} — run: subagent-mcp setup`,
433
+ });
434
+ const allOk = Object.values(hkR.statuses).every((s) => s === "ok");
435
+ results.push({
436
+ label: "codex: SessionStart + UserPromptSubmit hooks",
437
+ ok: allOk,
438
+ detail: allOk ? "wired (trust via /hooks in Codex)" : "incomplete — run: subagent-mcp setup",
439
+ });
440
+ }
441
+ if (!hasClaude && !hasCodex) {
442
+ results.push({
443
+ label: "vendors",
444
+ ok: false,
445
+ detail: "neither 'claude' nor 'codex' detected on PATH (and no ~/.codex)",
446
+ });
447
+ }
448
+ return results;
449
+ }
450
+ // ---------------------------------------------------------------------------
451
+ // Entry
452
+ // ---------------------------------------------------------------------------
453
+ export async function runSetup() {
454
+ console.log(`subagent-mcp setup${DRY_RUN ? " (dry-run)" : ""}`);
455
+ console.log(`Install root: ${INSTALL_ROOT}\n`);
456
+ const major = Number(process.versions.node.split(".")[0]);
457
+ if (major < 18) {
458
+ console.error(`ERROR: Node ${process.versions.node} is too old — Node >= 18 required.`);
459
+ process.exit(1);
460
+ }
461
+ const missing = verifyInstall();
462
+ if (missing.length > 0) {
463
+ console.error(`ERROR: install is incomplete — missing:\n - ${missing.join("\n - ")}`);
464
+ console.error("Re-install: npm install -g @heretyc/subagent-mcp");
465
+ process.exit(1);
466
+ }
467
+ const hasClaude = findOnPath("claude") !== null;
468
+ const hasCodex = findOnPath("codex") !== null || existsSync(join(homedir(), ".codex"));
469
+ if (!hasClaude && !hasCodex) {
470
+ console.log("No supported vendors found (neither 'claude' nor 'codex' on PATH, " +
471
+ "and ~/.codex does not exist).\n" +
472
+ "Install Claude Code CLI or Codex CLI first, then re-run: subagent-mcp setup");
473
+ process.exit(1);
474
+ }
475
+ if (hasClaude)
476
+ wireClaude();
477
+ else
478
+ console.log("\nSkipping Claude Code (not on PATH).");
479
+ if (hasCodex)
480
+ wireCodex();
481
+ else
482
+ console.log("\nSkipping Codex CLI (not detected).");
483
+ // Read-back verification: report what is ACTUALLY on disk now.
484
+ if (!DRY_RUN) {
485
+ console.log("\n--- Verification (read-back) ---");
486
+ for (const r of verifyWiring()) {
487
+ console.log(` ${r.ok ? "PASS" : "FAIL"} ${r.label} — ${r.detail}`);
488
+ }
489
+ }
490
+ console.log("\n=== Setup complete ===");
491
+ if (hasClaude) {
492
+ console.log("Claude Code: restart your session, then run /mcp — 'subagent-mcp' should show Connected.");
493
+ }
494
+ if (hasCodex) {
495
+ console.log("Codex CLI: restart your session, then run /hooks and TRUST the subagent-mcp hook.");
496
+ }
497
+ console.log("Health check any time: subagent-mcp doctor");
498
+ if (issues.length > 0) {
499
+ console.log(`\n=== ${issues.length} issue(s) need attention ===`);
500
+ for (const i of issues) {
501
+ console.log(`\n[${i.vendor}] ${i.problem}`);
502
+ console.log(" Paste this prompt into Claude Code or Codex to repair it:");
503
+ console.log(` "${i.repairPrompt}"`);
504
+ }
505
+ process.exit(1);
506
+ }
507
+ }
@@ -0,0 +1,56 @@
1
+ // Pure, unit-testable status-transition logic for sub-agents.
2
+ //
3
+ // Public status enum (visible-stream activity model):
4
+ // processing - ALIVE and seen visible provider activity within the heartbeat
5
+ // window. Counts against provider concurrency caps.
6
+ // stalled - ALIVE but NO parsed visible provider stream item for the
7
+ // heartbeat window. NOT a failure and NOT terminal; does NOT
8
+ // count against provider concurrency caps.
9
+ // finished - process exited 0.
10
+ // errored - process exited non-zero.
11
+ // stopped - process was killed.
12
+ //
13
+ // Liveness is driven by a heartbeat: launch time is the initial heartbeat and
14
+ // every subsequent PARSED visible provider stream item refreshes it (raw
15
+ // raw stdout/stderr bytes do not). A live agent is
16
+ // `processing` until the heartbeat is older than the window, at which point it
17
+ // becomes `stalled`. Resumed visible activity returns it to `processing`.
18
+ // 10-minute visible-stream heartbeat window. A live agent with no parsed
19
+ // visible provider stream item for this long becomes `stalled`.
20
+ export const HEARTBEAT_TIMEOUT_MS = 600000;
21
+ // Exit reconciliation is FIRST and authoritative: a live agent whose process
22
+ // has exited becomes finished/errored regardless of heartbeat age. Otherwise a
23
+ // live agent toggles between `processing` (recent visible activity) and
24
+ // `stalled` (heartbeat older than the window).
25
+ export function computeStatusTransition(input) {
26
+ const { status, exitCode, lastActivity, now } = input;
27
+ const isLive = status === "processing" || status === "stalled";
28
+ if (isLive && exitCode !== null) {
29
+ return {
30
+ status: exitCode === 0 ? "finished" : "errored",
31
+ exitedAt: input.exitedAt ?? now,
32
+ };
33
+ }
34
+ if (status === "stalled" && now - lastActivity <= HEARTBEAT_TIMEOUT_MS) {
35
+ return { status: "processing", exitedAt: input.exitedAt };
36
+ }
37
+ if (status === "processing" && now - lastActivity > HEARTBEAT_TIMEOUT_MS) {
38
+ return { status: "stalled", exitedAt: input.exitedAt };
39
+ }
40
+ return { status, exitedAt: input.exitedAt };
41
+ }
42
+ // Pure formatter for the per-agent liveness fields shared by poll_agent and
43
+ // list_agents. `hint` is present ONLY when status === "stalled" AND the caller
44
+ // opts in (poll_agent does; list_agents omits it to stay token-efficient).
45
+ export function buildLivenessFields(status, exitCode, lastActivity, now, includeHint = true) {
46
+ const idle_seconds = Math.floor((now - lastActivity) / 1000);
47
+ const alive = exitCode === null && (status === "processing" || status === "stalled");
48
+ const fields = { alive, idle_seconds };
49
+ if (status === "stalled" && includeHint) {
50
+ fields.hint =
51
+ `alive but no visible provider activity for ${idle_seconds}s; the process ` +
52
+ `is still running and may be thinking or awaiting a temp-file handoff. ` +
53
+ `Prefer \`wait\` or re-poll (or check its temp output) before killing.`;
54
+ }
55
+ return fields;
56
+ }