@staix/agent-hub 0.21.0 → 0.23.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.
Files changed (100) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +2 -2
  3. package/docs/agent-notes/adapters.md +2 -1
  4. package/docs/agent-notes/budget.md +1 -1
  5. package/docs/agent-notes/bus.md +5 -2
  6. package/docs/agent-notes/daemon.md +5 -4
  7. package/docs/agent-notes/local-worker.md +1 -1
  8. package/docs/agent-notes/models.md +2 -1
  9. package/docs/agent-notes/tasks.md +2 -1
  10. package/docs/agent-notes/tests.md +2 -2
  11. package/docs/dogfood.md +10 -2
  12. package/docs/operations.md +192 -6
  13. package/docs/profiles.md +29 -4
  14. package/docs/quickstart.md +9 -1
  15. package/docs/security.md +7 -1
  16. package/docs/smoke.md +6 -0
  17. package/docs/specs/2026-09-19-agent-hub-design.md +94 -1
  18. package/docs/verification/2026-10-30-0.22.0.md +49 -0
  19. package/docs/verification/2026-10-30-0.23.0.md +70 -0
  20. package/docs/verification/2026-10-30-445-capability-probes.md +172 -0
  21. package/docs/verification/2026-10-30-adapter-operational-settings.md +261 -0
  22. package/docs/verification/2026-10-30-bus-delivery-operational-settings.md +77 -0
  23. package/docs/verification/2026-10-30-jcode-ac4.md +24 -0
  24. package/docs/verification/2026-10-30-jcode-ac6.md +52 -0
  25. package/docs/verification/2026-10-30-model-operational-settings.md +211 -0
  26. package/docs/verification/2026-10-30-operational-settings-closure.md +1217 -0
  27. package/docs/verification/2026-10-30-recovery-wait-settings.md +141 -0
  28. package/docs/verification/2026-10-30-ui-console-operational-settings.md +1845 -0
  29. package/docs/verified.json +15 -15
  30. package/package.json +1 -1
  31. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  32. package/plugins/agent-hub/ROLE-NOTICE.md +1 -1
  33. package/plugins/agent-hub/server.js +559 -115
  34. package/src/adapters/acp.ts +48 -2
  35. package/src/adapters/claude-channel.ts +6 -4
  36. package/src/adapters/codex-appserver.ts +19 -0
  37. package/src/adapters/jcode.ts +1 -1
  38. package/src/adapters/local-worker.ts +3 -3
  39. package/src/adapters/native-capabilities.ts +50 -0
  40. package/src/adapters/registry-types.ts +3 -10
  41. package/src/cli/capabilities-output.ts +30 -0
  42. package/src/cli/console-state.ts +8 -5
  43. package/src/cli/console.ts +27 -4
  44. package/src/cli/help.ts +3 -1
  45. package/src/cli/identity.ts +4 -1
  46. package/src/cli/learn-output.ts +38 -0
  47. package/src/cli/main.ts +28 -2
  48. package/src/cli/recovery-waits.ts +26 -0
  49. package/src/cli/smoke-pty-bridge.ts +67 -18
  50. package/src/cli/smoke-pty.ts +10 -2
  51. package/src/cli/upgrade-runtime.ts +16 -14
  52. package/src/cli/upgrade.ts +2 -1
  53. package/src/hub/board.ts +8 -2
  54. package/src/hub/bus.ts +117 -17
  55. package/src/hub/capabilities.ts +215 -0
  56. package/src/hub/config-apply.ts +8 -4
  57. package/src/hub/config-trust.ts +1 -0
  58. package/src/hub/control-client.ts +41 -8
  59. package/src/hub/daemon.ts +436 -33
  60. package/src/hub/deferred-hub.ts +2 -2
  61. package/src/hub/delivery-hints.ts +2 -2
  62. package/src/hub/delivery-journal.ts +80 -61
  63. package/src/hub/envelope.ts +12 -2
  64. package/src/hub/events.ts +2 -0
  65. package/src/hub/hub-tools.ts +16 -1
  66. package/src/hub/inference.ts +10 -5
  67. package/src/hub/invocations.ts +316 -0
  68. package/src/hub/learning.ts +33 -1
  69. package/src/hub/operational-settings.ts +180 -1
  70. package/src/hub/peers.ts +3 -0
  71. package/src/hub/restart.ts +2 -2
  72. package/src/hub/room-invocations.ts +145 -0
  73. package/src/hub/settings.ts +22 -2
  74. package/src/hub/shipped-packs.ts +5 -2
  75. package/src/hub/shipped-roles.ts +13 -0
  76. package/src/hub/tasks.ts +92 -1
  77. package/src/memory/capture.ts +2 -2
  78. package/src/models/relay.ts +20 -9
  79. package/src/rooms/application.ts +67 -2
  80. package/src/rooms/boundary.ts +43 -11
  81. package/src/rooms/service.ts +40 -2
  82. package/templates/checks/analysis.mjs +59 -0
  83. package/templates/checks/fixtures/analysis-expected.json +1 -0
  84. package/templates/checks/fixtures/analysis-input.json +1 -0
  85. package/templates/checks/fixtures/analysis.mjs +7 -0
  86. package/templates/checks/fixtures/ops.json +4 -0
  87. package/templates/checks/fixtures/writing-rules.json +4 -0
  88. package/templates/checks/fixtures/writing.json +5 -0
  89. package/templates/checks/ops.mjs +40 -0
  90. package/templates/checks/writing.mjs +40 -0
  91. package/templates/packs/analysis.pack.json +14 -0
  92. package/templates/packs/ops.pack.json +14 -0
  93. package/templates/packs/writing-pack.pack.json +14 -0
  94. package/templates/roles/NOTICE.md +1 -1
  95. package/templates/roles/ops/meeting-actions-owner.md +26 -0
  96. package/templates/roles/ops/project-manager.md +26 -0
  97. package/templates/roles/writing/editor.md +26 -0
  98. package/templates/roles/writing/style-checker.md +26 -0
  99. package/templates/roles/writing/terminology-checker.md +26 -0
  100. package/templates/roles/writing/translator.md +26 -0
@@ -0,0 +1,26 @@
1
+ import { configRefusal } from "../hub/config-trust.ts";
2
+ import { readInstanceConfigLayers } from "../hub/instance-config.ts";
3
+ import { recoveryWaits, type RecoveryOperationalConfig, type RecoveryWaits } from "../hub/operational-settings.ts";
4
+
5
+ /**
6
+ * #489 H4. The upgrade coordinator is a CLI process, not the daemon, so it reads the five recovery waits itself, from the project it is acting on, at the START of each wait (a wait already running keeps
7
+ * its deadline, and the receipt's `waits` records it). Both layers count, the machine-local file last, as `loadConfig` merges them; a layer git tracks as local policy is refused like every other local
8
+ * policy (`configRefusal`) and a file that cannot be read or parsed is the default. A recovery never fails on its configuration: bounds were checked when the value was written, and `recoveryWaits` falls
9
+ * back to the default for a value outside them.
10
+ */
11
+ const LEAVES = new Set(["reconnect_wait_s", "unmanaged_reconnect_wait_s", "control_timeout_s", "shutdown_wait_s", "terminal_idle_wait_s"]);
12
+ export function readRecoveryWaits(projectRoot: string | undefined, read: (cwd: string) => ReturnType<typeof readInstanceConfigLayers> = readInstanceConfigLayers): RecoveryWaits {
13
+ if (!projectRoot) return recoveryWaits(() => undefined);
14
+ let merged: RecoveryOperationalConfig["recovery"];
15
+ try {
16
+ for (const layer of read(projectRoot)) {
17
+ const recovery = layer.raw.recovery;
18
+ if (!recovery || typeof recovery !== "object" || Array.isArray(recovery) || !Object.keys(recovery).some(key => LEAVES.has(key))) continue;
19
+ // The shared file is team policy and always read; the machine-local file only when git does not track it. Git is asked only when that file actually sets one of the five
20
+ // (a recovery polls and sends many control requests, and a file that sets none must cost no subprocess).
21
+ if (layer.name === "config.local.json" && configRefusal(projectRoot, layer.name)) continue;
22
+ merged = { ...merged, ...(recovery as Record<string, unknown>) };
23
+ }
24
+ } catch { return recoveryWaits(() => undefined); }
25
+ return recoveryWaits(() => ({ recovery: merged }));
26
+ }
@@ -1,33 +1,82 @@
1
1
  // macOS script(1) requires a terminal on its own stdin, including when its child gets a PTY.
2
2
  // Keep a real PTY in this owned bridge instead; the outer Node child remains tracked by stopOwnedProcess.
3
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+
7
+ /**
8
+ * #560: Bun up to 1.4.3 can hang for good inside `Bun.spawn({ terminal })` on macOS when the child writes and starts exiting before the call returns (oven-sh/bun#45300, fixed in 1.4.4): the
9
+ * event loop thread blocks in wait4 while the exiting session leader waits for that same thread to drain its pty. Measured here with a bare Bun script and no agent-hub code: 26 of 96 runs hung
10
+ * at 12 in parallel on 1.4.3 and 0 of 180 on 1.4.4. The child therefore starts behind a gate: a shell that waits for a file the bridge writes right after `Bun.spawn` has returned, then `exec`s the
11
+ * real command (same pid, same session, same terminal). The wait has no bound of its own: a bridge that dies before it opens the gate closes the pty, which hangs the shell up (the trap below), and a bridge
12
+ * that is alive but never opens it is a hung bridge, which `runPty` stops with its group.
13
+ * ponytail: ceiling and upgrade path. The gate exists only because of oven-sh/bun#45300 on Bun up to 1.4.3 (this repo pins 1.4.2 in `.bun-version`); the upgrade path is to delete GATE_SCRIPT, `gate` and `gateDir` once the pinned Bun is 1.4.4 or newer, which is its own task because it
14
+ * touches CI and every OS leg. The parent-death stop below stays whatever the Bun version: an orphaned bridge is this repository's bug, not Bun's. The gate costs about one shell start (a few ms).
15
+ */
16
+ // The shell removes its own gate (file, then directory) once released and when it is hung up or terminated (a bridge killed with SIGKILL runs no cleanup, and the pty closing
17
+ // sends the shell SIGHUP), so no path leaves a directory behind. `exec` resets the traps for the real command.
18
+ const GATE_SCRIPT = 'd=${0%/*}; trap "rm -f \\"$0\\"; rmdir \\"$d\\" 2>/dev/null; exit 125" HUP TERM INT; while [ ! -e "$0" ]; do sleep 0.01; done; rm -f "$0"; rmdir "$d" 2>/dev/null; exec "$@"';
19
+ /** A bridge whose caller died is stopped instead of running on: its child holds a terminal nobody reads and, under launchd, nothing else would ever stop it. */
20
+ const PARENT_POLL_MS = 250;
21
+ const PARENT_TERM_GRACE_MS = 1_000;
22
+ const PARENT_HARD_EXIT_MS = 3_000;
23
+
24
+ // The caller is whoever started this bridge, read before anything that can take time: a caller that dies while `Bun.spawn` runs (the window #45300 widens) has already reparented this process by the
25
+ // time the call returns, and a parent read after it would be launchd and never change.
26
+ const parent = process.ppid;
27
+ const callerGone = (): boolean => process.ppid !== parent;
3
28
  const argv = process.argv.slice(2);
4
29
  if (!argv.length) {
5
30
  process.exitCode = 125;
6
31
  } else {
7
32
  let streamClosed!: () => void;
8
33
  const eof = new Promise<void>(resolve => { streamClosed = resolve; });
9
- const child = Bun.spawn(argv, {
10
- cwd: process.cwd(), env: process.env,
11
- terminal: {
12
- cols: 120, rows: 40, name: "xterm-256color",
13
- data: (_terminal, data) => { process.stdout.write(data); },
14
- exit: () => streamClosed(),
15
- },
16
- });
17
- const stop = () => { try { child.kill("SIGTERM"); } catch { /* exit readback decides */ } };
18
- process.on("SIGINT", stop); process.on("SIGTERM", stop);
34
+ const gateDir = process.platform === "darwin" ? mkdtempSync(join(tmpdir(), "ahub-pty-gate-")) : undefined;
35
+ const gate = gateDir ? join(gateDir, "open") : undefined;
36
+ let watch: ReturnType<typeof setInterval> | undefined;
19
37
  try {
20
- const code = await child.exited;
21
- // Drain the PTY before closing it; a descendant holding a slave cannot make the bridge wait forever.
22
- let timer: ReturnType<typeof setTimeout> | undefined;
23
- await Promise.race([eof, new Promise<void>(resolve => { timer = setTimeout(resolve, 250); })]);
24
- if (timer) clearTimeout(timer);
25
- process.exitCode = code;
38
+ const child = Bun.spawn(gate ? ["/bin/sh", "-c", GATE_SCRIPT, gate, ...argv] : argv, {
39
+ cwd: process.cwd(), env: process.env,
40
+ terminal: {
41
+ cols: 120, rows: 40, name: "xterm-256color",
42
+ data: (_terminal, data) => { process.stdout.write(data); },
43
+ exit: () => streamClosed(),
44
+ },
45
+ });
46
+ const stop = () => { try { child.kill("SIGTERM"); } catch { /* exit readback decides */ } };
47
+ process.on("SIGINT", stop); process.on("SIGTERM", stop);
48
+ /** The caller is gone: stop the child now, kill it after a grace period, and exit even if its exit is never reported. */
49
+ const abandon = () => {
50
+ clearInterval(watch);
51
+ stop();
52
+ setTimeout(() => { try { child.kill("SIGKILL"); } catch { /* already gone */ } }, PARENT_TERM_GRACE_MS).unref();
53
+ setTimeout(() => process.exit(129), PARENT_HARD_EXIT_MS).unref();
54
+ };
55
+ // A caller that died while Bun.spawn ran never gets its command run: the gate stays shut and the gate shell takes the SIGTERM (its trap removes the gate and exits).
56
+ // Abandoning once is enough: the interval below exists only while the caller is still there, and clears itself the first time it is not.
57
+ if (callerGone()) abandon();
58
+ else {
59
+ if (gate) writeFileSync(gate, "");
60
+ watch = setInterval(() => { if (callerGone()) abandon(); }, PARENT_POLL_MS);
61
+ }
62
+ try {
63
+ const code = await child.exited;
64
+ // Drain the PTY before closing it; a descendant holding a slave cannot make the bridge wait forever.
65
+ let timer: ReturnType<typeof setTimeout> | undefined;
66
+ await Promise.race([eof, new Promise<void>(resolve => { timer = setTimeout(resolve, 250); })]);
67
+ if (timer) clearTimeout(timer);
68
+ process.exitCode = code;
69
+ } catch {
70
+ process.exitCode = 125;
71
+ } finally {
72
+ child.terminal?.close();
73
+ process.removeListener("SIGINT", stop); process.removeListener("SIGTERM", stop);
74
+ }
26
75
  } catch {
27
76
  process.exitCode = 125;
28
77
  } finally {
29
- child.terminal?.close();
30
- process.removeListener("SIGINT", stop); process.removeListener("SIGTERM", stop);
78
+ if (watch) clearInterval(watch);
79
+ if (gateDir) rmSync(gateDir, { recursive: true, force: true });
31
80
  }
32
81
  }
33
82
  export {};
@@ -54,14 +54,22 @@ function waitForExit(proc: ChildProcess): Promise<{ code: number | null }> {
54
54
  }
55
55
 
56
56
  /** Run an argv vector inside a fixed-size PTY, always stopping its owned process group before returning. */
57
- export async function runPty(argv: readonly string[], options: PtyOptions): Promise<PtyResult> {
57
+ export function runPty(argv: readonly string[], options: PtyOptions): Promise<PtyResult> {
58
+ return runPtyWith(commandFor, argv, options);
59
+ }
60
+
61
+ /**
62
+ * `runPty` with the launcher named by the caller. Only code can reach it (a test names a bridge that ignores SIGTERM or never exits); no config key, request or environment variable does, so the
63
+ * bridge a hub runs is always `commandFor`'s.
64
+ */
65
+ export async function runPtyWith(launcher: (argv: readonly string[]) => string[], argv: readonly string[], options: PtyOptions): Promise<PtyResult> {
58
66
  validate(argv, options);
59
67
  const started = Date.now();
60
68
  if (options.signal?.aborted) return { outcome: "aborted", exitCode: null, outputBytes: 0, elapsedMs: Date.now() - started, cleaned: true };
61
69
 
62
70
  let proc: ChildProcess;
63
71
  try {
64
- const command = commandFor(argv);
72
+ const command = launcher(argv);
65
73
  proc = spawn(command[0]!, command.slice(1), {
66
74
  cwd: options.cwd,
67
75
  env: { ...options.env, TERM: "xterm-256color" },
@@ -3,6 +3,7 @@ import { resolveAcceptedBinding } from "./recovery-guidance.ts";
3
3
  import { claudeStateFile } from "../hub/claude-state.ts";
4
4
  import { ADAPTERS, definitionOf } from "../adapters/registry.ts";
5
5
  import { readInstanceConfig, readInstanceConfigLayers } from "../hub/instance-config.ts";
6
+ import { readRecoveryWaits } from "./recovery-waits.ts";
6
7
  import { readAdapterModuleDeclarations } from "../adapters/external-modules.ts";
7
8
  import { configRefusal } from "../hub/config-trust.ts";
8
9
  import { validExternalInstanceBinding } from "../hub/external-instance.ts";
@@ -26,10 +27,9 @@ import { decodeRestoreReceipt, restoreTransition, type RestoreReceipt, type Rest
26
27
  import { RecoveryDeferredHubError } from "../hub/deferred-hub.ts";
27
28
  import { RecoveryPreservationError, RecoveryPresentationError, type RecoveryNativeCause } from "./upgrade-presentation-data.ts";
28
29
 
29
- /** An unmanaged Claude's plugin retries with a backoff of at most 30 s; three of those bound the reconnect wait (#206). */
30
- const UNMANAGED_RECONNECT_WAIT_MS = 90_000;
31
- /** Managed TUI startup can include resume and MCP handshakes; match createTerminal's ten-minute readiness ceiling. */
32
- const RECONNECT_WAIT_MS = 600_000;
30
+ // #489 H4: the reconnect, unmanaged reconnect, control request, terminal idle and shutdown waits are settings now (`recovery.*`, src/hub/operational-settings.ts), read by `readRecoveryWaits` at the start of
31
+ // each wait. Their defaults are the constants that stood here: an unmanaged Claude's plugin retries with a backoff of at most 30 s and three of those bound its reconnect wait (#206, 90 s); managed TUI startup
32
+ // can include resume and MCP handshakes, so its wait matches createTerminal's ten-minute readiness ceiling (600 s, which is also the setting's floor).
33
33
  class AttachmentLauncherGone extends Error {
34
34
  constructor(readonly transition: RestoreTransition) { super("chosen attachment launcher exited"); }
35
35
  }
@@ -80,12 +80,12 @@ export function restoredTerminalArgv(entrypoint: string, projectRoot: string, bi
80
80
  return [process.execPath, entrypoint, "--project", projectRoot, ...tail];
81
81
  }
82
82
 
83
- const RECOVERY_CONTROL_TIMEOUT_MS = 30_000;
84
83
 
85
84
  async function rpc(project: Project, message: Record<string, unknown>, protocol = PROTOCOL, conductorPeer?: string): Promise<any> {
86
- const client = await ControlClient.connect(project.stateDir, { ...(conductorPeer ? { role: "tools" as const, peer: conductorPeer } : { role: "console" as const }), projectRoot: project.root, projectId: project.id }, RECOVERY_CONTROL_TIMEOUT_MS, protocol);
85
+ const controlTimeoutMs = readRecoveryWaits(project.root).controlTimeoutMs;
86
+ const client = await ControlClient.connect(project.stateDir, { ...(conductorPeer ? { role: "tools" as const, peer: conductorPeer } : { role: "console" as const }), projectRoot: project.root, projectId: project.id }, controlTimeoutMs, protocol);
87
87
  try {
88
- const reply = await client.request(message, RECOVERY_CONTROL_TIMEOUT_MS);
88
+ const reply = await client.request(message, controlTimeoutMs);
89
89
  if (reply.ok === false) {
90
90
  // An older hub has only its exact SQLite message; preserve compatibility without trusting arbitrary text.
91
91
  if (isDatabaseBusyReply(reply)) throw new RecoveryDatabaseBusy();
@@ -215,7 +215,7 @@ export async function inspectRecovery(project: Project, targetFeatures?: readonl
215
215
  // pid from planning time (after a reboot another process may hold it). A registry that cannot be read throws: the
216
216
  // caller reads that as not inspected, never as stopped.
217
217
  const claim = registeredProjects().find((p) => p.id === project.id);
218
- const base = await inspectProject({ ...project, instanceId: claim?.instanceId ?? null, pid: claim?.pid ?? null, pidSignature: claim?.pidSignature ?? null }, RECOVERY_CONTROL_TIMEOUT_MS, conductorPeer);
218
+ const base = await inspectProject({ ...project, instanceId: claim?.instanceId ?? null, pid: claim?.pid ?? null, pidSignature: claim?.pidSignature ?? null }, readRecoveryWaits(project.root).controlTimeoutMs, conductorPeer);
219
219
  if (base.failure?.code === "database_busy") throw new RecoveryDatabaseBusy();
220
220
  const control = readControl(project.stateDir);
221
221
  const sourceProtocol = control?.protocol;
@@ -469,7 +469,7 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
469
469
  if (found.manualRequired || !current || (exact && (current.handle !== saved.handle || current.incarnationId !== saved.incarnationId || current.worktreeId !== saved.worktreeId || current.projectRoot !== saved.projectRoot))) {
470
470
  throw nativeFailure(planned, saved.peer, "terminal_unverified", `${saved.peer}: saved terminal identity changed or is not verified; manual-required`, { handle: saved.handle });
471
471
  }
472
- const idle = await waitForIdle(exact ? saved : current, 120_000, options);
472
+ const idle = await waitForIdle(exact ? saved : current, readRecoveryWaits(planned.project.root).terminalIdleWaitMs, options);
473
473
  if (!idle.satisfied) throw nativeFailure(planned, saved.peer, "terminal_unverified", `${saved.peer}: terminal is not idle; manual-required`, { handle: saved.handle });
474
474
  const observed = await inspectRecovery(planned.project);
475
475
  if (observed.instanceId !== progress.instanceId) throw nativeFailure(planned, saved.peer, "target_unavailable", `${saved.peer}: daemon instance changed during terminal revalidation`, { handle: saved.handle, targetState: observed.state });
@@ -567,7 +567,7 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
567
567
  if (seen !== "absent") throw new Error(closeUnsettled(retired, seen));
568
568
  progress.terminals[key] = true; save(); return;
569
569
  }
570
- const idle = await waitForIdle(retired, 120_000, terminalOptions(run));
570
+ const idle = await waitForIdle(retired, readRecoveryWaits(retired.projectRoot).terminalIdleWaitMs, terminalOptions(run));
571
571
  if (!idle.satisfied) throw new Error(`${peer}: terminal ${retired.handle} of the stopped target's session is not verified idle; finish or cancel its turn, or close that terminal by hand, first`);
572
572
  progress.terminals[key] = "pending"; save();
573
573
  await recoveryCurrent();
@@ -583,7 +583,8 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
583
583
  };
584
584
  const binding = waiting.binding;
585
585
  const matchesTerminal = (terminal: Pick<TerminalBinding, "handle" | "incarnationId" | "worktreeId">) => terminal.handle === binding.handle && terminal.incarnationId === binding.incarnationId && terminal.worktreeId === binding.worktreeId;
586
- const startedAt = now(), deadline = startedAt + RECONNECT_WAIT_MS;
586
+ const reconnectWaitMs = readRecoveryWaits(planned.project.root).reconnectWaitMs; // read once, at the start: a wait already running keeps the deadline the receipt records
587
+ const startedAt = now(), deadline = startedAt + reconnectWaitMs;
587
588
  const waitStep = op.step;
588
589
  if (waitStep) {
589
590
  (op.waits ??= {})[waitStep] = { waitStartedAt: new Date(startedAt).toISOString(), deadlineAt: new Date(deadline).toISOString() };
@@ -656,7 +657,7 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
656
657
  hub: { state: live.state, ...(live.instanceId ? { instanceId: live.instanceId } : {}), ...(peer ? { peerState: peer.state } : {}), ...(session ? { sessionId: session } : {}) },
657
658
  lastHubConnectAttempt: "unknown" };
658
659
  save();
659
- throw nativeFailure(planned, binding.peer, "attachment_timeout", `${binding.peer}: attachment not verified within ${RECONNECT_WAIT_MS / 1000} s; terminal ${binding.handle} retained; resume after it attaches (native-start diagnostic recorded; last hub connect attempt unknown)`, { handle: binding.handle, timeoutMs: RECONNECT_WAIT_MS });
660
+ throw nativeFailure(planned, binding.peer, "attachment_timeout", `${binding.peer}: attachment not verified within ${reconnectWaitMs / 1000} s; terminal ${binding.handle} retained; resume after it attaches (native-start diagnostic recorded; last hub connect attempt unknown)`, { handle: binding.handle, timeoutMs: reconnectWaitMs });
660
661
  } catch (error) { waitFailed = true; throw error; }
661
662
  finally {
662
663
  const finish = () => {
@@ -1030,9 +1031,10 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
1030
1031
  }
1031
1032
  if (group !== "native" && !onlyPeer) for (const id of planned.reconnectOnly ?? []) {
1032
1033
  // #206: nothing is launched for an unmanaged session; its plugin reconnects by itself, within a bound.
1033
- const deadline = now() + UNMANAGED_RECONNECT_WAIT_MS;
1034
+ const unmanagedWaitMs = readRecoveryWaits(planned.project.root).unmanagedReconnectWaitMs;
1035
+ const deadline = now() + unmanagedWaitMs;
1034
1036
  while (!(await inspectRecovery(planned.project)).peers.some((p) => p.id === id && p.state !== "offline")) {
1035
- if (now() >= deadline) throw nativeFailure(planned, id, "attachment_timeout", `${id}: the unmanaged session did not reconnect within ${UNMANAGED_RECONNECT_WAIT_MS / 1000} s; if that Claude session was closed, start Claude in the project again (its plugin reconnects) first`, { timeoutMs: UNMANAGED_RECONNECT_WAIT_MS });
1037
+ if (now() >= deadline) throw nativeFailure(planned, id, "attachment_timeout", `${id}: the unmanaged session did not reconnect within ${unmanagedWaitMs / 1000} s; if that Claude session was closed, start Claude in the project again (its plugin reconnects) first`, { timeoutMs: unmanagedWaitMs });
1036
1038
  await sleep(1000);
1037
1039
  }
1038
1040
  }
@@ -12,6 +12,7 @@ import { existsSync, readFileSync } from "node:fs";
12
12
  import { join } from "node:path";
13
13
  import { REGISTRY_DB_BUSY_TIMEOUT_MS, PROJECT_COLUMNS, projectFromRow, type Project } from "../hub/registry.ts";
14
14
  import { hubHome } from "../hub/project.ts";
15
+ import { readRecoveryWaits } from "./recovery-waits.ts";
15
16
  import { acquireRecoveryLock, claimRunner, coordinatorCurrent, operationIdPattern, readOperation, recoveryCommand, recoveryLock, releaseRecoveryLock, writeOperation } from "../hub/recovery-store.ts";
16
17
  import { resolveAcceptedBinding, piArchiveExit, recordedPeerGuidance, type RecoveryNativePeer } from "./recovery-guidance.ts";
17
18
  import type { TerminalBinding } from "./terminal-recovery.ts";
@@ -802,7 +803,7 @@ export async function runRecovery(id: string, driver: RecoveryDriver, home = hub
802
803
  throw new Error(`${project.id}: the source stopped while prepared and no commit was requested, so there is nothing to restore`);
803
804
  }
804
805
  if (awaitShutdown) {
805
- const deadline = driver.now() + 30_000;
806
+ const deadline = driver.now() + readRecoveryWaits(project.root).shutdownWaitMs; // #489 H4: recovery.shutdown_wait_s, 30 s unset
806
807
  // An already-sent commit can be between its final reply and the source closing its control server.
807
808
  if (resumedShutdown) {
808
809
  if (live.instanceId && live.instanceId !== planned.source.instanceId) throw new Error(`${project.id}: another daemon started during shutdown`);
package/src/hub/board.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { InvocationRow } from "./invocations.ts";
1
2
  import { HOST_CODE_SYMBOLS, parseTaskCodeSymbols, type TaskCodeSymbols } from "./task-code-symbols.ts";
2
3
  import { createHash } from "node:crypto";
3
4
  import { planFingerprint } from "./plan-conformance.ts";
@@ -57,7 +58,11 @@ export const OUTCOMES_KEPT_MS = 7 * 24 * 3_600_000;
57
58
 
58
59
  export const TASK_MOVE_REASONS = ["manual", "budget", "offline", "idle", "rejections", "declined", "inference_failed", "delivery_failed"] as const;
59
60
  export type TaskMoveReason = (typeof TASK_MOVE_REASONS)[number];
61
+ export type InvocationHistory = Pick<InvocationRow, "id" | "caller" | "target" | "capability" | "digest" | "class" | "approval" | "argsSha256" | "outcome">;
62
+
60
63
  export interface HistoryEntry {
64
+ /** Host-owned invocation metadata only, never arguments or results. */
65
+ invocation?: InvocationHistory;
61
66
  at: number;
62
67
  by: PeerId;
63
68
  event: string; // proposed | assigned | accepted | declined | done | approved | changes_requested | escalated | reassigned
@@ -483,7 +488,7 @@ export class Board {
483
488
 
484
489
  /** The only way a task changes. Validates the move, records who did what, returns the new row. */
485
490
  /** A `plan` in the patch replaces the old one whole: a new plan is the owner's current intent, not an addition. */
486
- update(id: number, by: PeerId, event: string, patch: Partial<Pick<Task, "state" | "owner" | "planner" | "reviewer" | "reserved" | "refs" | "plan" | "rejections" | "signals" | "sdlc" | "role" | "cancelled" | "codeSymbols">>, note?: string, extra: Pick<HistoryEntry, "plannerReason" | "planAuthor" | "selfReview" | "profile" | "sweep" | "reason" | "from" | "capacity" | "conductorManual" | "withheld" | "noWrites" | "reviewCost" | "reviewContext" | "reviewScope" | "evidenceOnly" | "reviewFindings" | "unmetCriteria" | "deliverables" | "checkKey" | "missingKeys" | "claim" | "planObservation" | "planDeparture" | "planDepartureInvalidated" | keyof ReviewerHistory> = {}, expectedFlowRevision?: number, codeSymbolsAuthority?: typeof HOST_CODE_SYMBOLS): Task {
491
+ update(id: number, by: PeerId, event: string, patch: Partial<Pick<Task, "state" | "owner" | "planner" | "reviewer" | "reserved" | "refs" | "plan" | "rejections" | "signals" | "sdlc" | "role" | "cancelled" | "codeSymbols">>, note?: string, extra: Pick<HistoryEntry, "invocation" | "plannerReason" | "planAuthor" | "selfReview" | "profile" | "sweep" | "reason" | "from" | "capacity" | "conductorManual" | "withheld" | "noWrites" | "reviewCost" | "reviewContext" | "reviewScope" | "evidenceOnly" | "reviewFindings" | "unmetCriteria" | "deliverables" | "checkKey" | "missingKeys" | "claim" | "planObservation" | "planDeparture" | "planDepartureInvalidated" | keyof ReviewerHistory> = {}, expectedFlowRevision?: number, codeSymbolsAuthority?: typeof HOST_CODE_SYMBOLS): Task {
487
492
  const task = this.get(id);
488
493
  if (!task) throw new Error(`no task #${id}`);
489
494
  if (expectedFlowRevision !== undefined && (!Number.isSafeInteger(expectedFlowRevision) || expectedFlowRevision < 0 || task.history.length !== expectedFlowRevision)) throw new Error("task flow revision changed");
@@ -499,6 +504,7 @@ export class Board {
499
504
  if (extra.selfReview !== undefined && extra.selfReview !== true) throw new Error("invalid self-review metadata");
500
505
  if (extra.conductorManual !== undefined && (extra.conductorManual !== true || by === "user" || by === "hub" || extra.reason !== "manual"
501
506
  || !["assigned", "reassigned", "reserved", "unassigned", "reviewer changed"].includes(event))) throw new Error("invalid conductor manual metadata");
507
+ if (extra.invocation && (by !== HUB || event !== "invocation")) throw new Error("invocation audit is host-only");
502
508
  const next = { ...task, ...patch, planner, refs: { ...task.refs, ...patch.refs } };
503
509
  const headOf = (row: Task) => row.refs.commit ?? row.sdlc?.head ?? row.sdlc?.localHead;
504
510
  const privateSymbols = next.signals.includes("pii") || next.signals.includes("pii_path");
@@ -542,7 +548,7 @@ export class Board {
542
548
  // Disabling reporting must not let plan or repository refs edits/reverts resurrect prior consent.
543
549
  const repositoryRefsChanged = patch.refs !== undefined && JSON.stringify([task.refs.repo, task.refs.paths]) !== JSON.stringify([next.refs.repo, next.refs.paths]);
544
550
  const departureInvalidated = extra.planDepartureInvalidated === true || hasDeparture && (planChanged || repositoryRefsChanged);
545
- const history = [...task.history, { at: Date.now(), by, event, ...plannerHistory, ...authorHistory, ...(extra.selfReview === true ? { selfReview: true as const } : {}), ...(planRevision ? { planRevision } : {}), ...(extra.planObservation ? { planObservation: extra.planObservation } : {}), ...(extra.planDeparture ? { planDeparture: extra.planDeparture } : {}), ...(departureInvalidated ? { planDepartureInvalidated: true as const } : {}), ...reviewMetadata, ...(note ? { note } : {}), ...(patch.cancelled?.supersededBy ? { supersededBy: patch.cancelled.supersededBy } : {}), ...("owner" in patch ? { owner: next.owner } : {}), ...(extra.profile ? { profile: extra.profile } : {}), ...(extra.sweep ? { sweep: extra.sweep } : {}), ...(extra.reason && TASK_MOVE_REASONS.includes(extra.reason) ? { reason: extra.reason } : {}), ...(extra.from ? { from: extra.from } : {}), ...(extra.capacity === true ? { capacity: true as const } : {}), ...(extra.conductorManual === true ? { conductorManual: true as const } : {}), ...(extra.withheld && (note || reviewFindings) ? { withheld: true as const } : {}), ...(reviewFindings ? { reviewFindings } : {}), ...(unmetCriteria?.length ? { unmetCriteria } : {}), ...(deliverables ? { deliverables } : {}), ...(extra.reviewContext === "fresh" || extra.reviewContext === "continuing" ? { reviewContext: extra.reviewContext } : {}), ...(extra.reviewScope === "code" ? { reviewScope: "code" as const } : {}), ...(extra.evidenceOnly === true && event === "changes_requested" ? { evidenceOnly: true as const } : {}), ...(extra.reviewCost ? { reviewCost: extra.reviewCost } : {}), ...(event === "done" && extra.noWrites === true ? { noWrites: true as const } : {}), ...(["check passed", "check failed"].includes(event) && typeof extra.checkKey === "string" && /^[a-z][a-z0-9_]{0,47}$/.test(extra.checkKey) ? { checkKey: extra.checkKey } : {}), ...(["check passed", "check failed"].includes(event) && Number.isSafeInteger(extra.missingKeys) && extra.missingKeys! >= 0 && extra.missingKeys! <= 100_000 ? { missingKeys: extra.missingKeys } : {}), ...(event === "claim verdict" && extra.claim ? { claim: extra.claim } : {}) }];
551
+ const history = [...task.history, { at: Date.now(), by, event, ...(extra.invocation ? { invocation: extra.invocation } : {}), ...plannerHistory, ...authorHistory, ...(extra.selfReview === true ? { selfReview: true as const } : {}), ...(planRevision ? { planRevision } : {}), ...(extra.planObservation ? { planObservation: extra.planObservation } : {}), ...(extra.planDeparture ? { planDeparture: extra.planDeparture } : {}), ...(departureInvalidated ? { planDepartureInvalidated: true as const } : {}), ...reviewMetadata, ...(note ? { note } : {}), ...(patch.cancelled?.supersededBy ? { supersededBy: patch.cancelled.supersededBy } : {}), ...("owner" in patch ? { owner: next.owner } : {}), ...(extra.profile ? { profile: extra.profile } : {}), ...(extra.sweep ? { sweep: extra.sweep } : {}), ...(extra.reason && TASK_MOVE_REASONS.includes(extra.reason) ? { reason: extra.reason } : {}), ...(extra.from ? { from: extra.from } : {}), ...(extra.capacity === true ? { capacity: true as const } : {}), ...(extra.conductorManual === true ? { conductorManual: true as const } : {}), ...(extra.withheld && (note || reviewFindings) ? { withheld: true as const } : {}), ...(reviewFindings ? { reviewFindings } : {}), ...(unmetCriteria?.length ? { unmetCriteria } : {}), ...(deliverables ? { deliverables } : {}), ...(extra.reviewContext === "fresh" || extra.reviewContext === "continuing" ? { reviewContext: extra.reviewContext } : {}), ...(extra.reviewScope === "code" ? { reviewScope: "code" as const } : {}), ...(extra.evidenceOnly === true && event === "changes_requested" ? { evidenceOnly: true as const } : {}), ...(extra.reviewCost ? { reviewCost: extra.reviewCost } : {}), ...(event === "done" && extra.noWrites === true ? { noWrites: true as const } : {}), ...(["check passed", "check failed"].includes(event) && typeof extra.checkKey === "string" && /^[a-z][a-z0-9_]{0,47}$/.test(extra.checkKey) ? { checkKey: extra.checkKey } : {}), ...(["check passed", "check failed"].includes(event) && Number.isSafeInteger(extra.missingKeys) && extra.missingKeys! >= 0 && extra.missingKeys! <= 100_000 ? { missingKeys: extra.missingKeys } : {}), ...(event === "claim verdict" && extra.claim ? { claim: extra.claim } : {}) }];
546
552
  this.taskWrite(() => {
547
553
  const result = this.db.query("UPDATE tasks SET state = ?, owner = ?, reviewer = ?, reserved = ?, refs = ?, plan = ?, rejections = ?, signals = ?, history = ?, sdlc = ?, task_role = ?, cancelled = ?, updated = ?, planner = ? WHERE id = ?")
548
554
  .run(next.state, next.owner, next.reviewer, next.reserved ?? null, JSON.stringify(next.refs), JSON.stringify(next.plan ?? {}), next.rejections, JSON.stringify(next.signals), JSON.stringify(history), next.sdlc ? JSON.stringify(next.sdlc) : null, next.role ?? null, next.cancelled ? JSON.stringify(next.cancelled) : null, Date.now(), planner, id);