@staix/agent-hub 0.20.0 → 0.22.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 (183) hide show
  1. package/CHANGELOG.md +66 -3
  2. package/README.md +2 -2
  3. package/docs/agent-notes/README.md +11 -0
  4. package/docs/agent-notes/adapters.md +7 -3
  5. package/docs/agent-notes/benchmarks.md +27 -0
  6. package/docs/agent-notes/budget.md +2 -1
  7. package/docs/agent-notes/bus.md +3 -3
  8. package/docs/agent-notes/daemon.md +17 -8
  9. package/docs/agent-notes/local-worker.md +4 -0
  10. package/docs/agent-notes/models.md +2 -0
  11. package/docs/agent-notes/tasks.md +17 -9
  12. package/docs/agent-notes/tests.md +9 -2
  13. package/docs/bench.md +1 -1
  14. package/docs/data-policy.md +2 -0
  15. package/docs/dogfood.md +39 -4
  16. package/docs/events.md +2 -1
  17. package/docs/memory.md +9 -3
  18. package/docs/operations.md +610 -62
  19. package/docs/profiles.md +13 -1
  20. package/docs/quickstart.md +18 -9
  21. package/docs/research.md +7 -4
  22. package/docs/sdlc.md +54 -10
  23. package/docs/security.md +396 -34
  24. package/docs/smoke.md +133 -0
  25. package/docs/specs/2026-09-19-agent-hub-design.md +1101 -22
  26. package/docs/verification/2026-10-27-0.19.0.md +5 -5
  27. package/docs/verification/2026-10-28-claude-2.1.311-2.1.312-bash-permission.json +635 -0
  28. package/docs/verification/2026-10-28-claude-2.1.311-2.1.312-permission-transport.json +403 -0
  29. package/docs/verification/2026-10-28-claude-implementer-permission-triage.md +123 -0
  30. package/docs/verification/2026-10-28-jcode-0.99.1-usage.md +41 -0
  31. package/docs/verification/2026-10-28-realpath-name-audit.md +222 -0
  32. package/docs/verification/2026-10-28-routing-f0bb8ad0.md +1000 -0
  33. package/docs/verification/2026-10-29-0.21.0.md +61 -0
  34. package/docs/verification/2026-10-29-model-effort-capabilities.md +123 -0
  35. package/docs/verification/2026-10-29-modelrouting-b152ddb9.md +953 -0
  36. package/docs/verification/2026-10-30-0.22.0.md +49 -0
  37. package/docs/verification/2026-10-30-445-capability-probes.md +172 -0
  38. package/docs/verification/2026-10-30-bus-delivery-operational-settings.md +77 -0
  39. package/docs/verification/2026-10-30-jcode-ac4.md +24 -0
  40. package/docs/verification/2026-10-30-jcode-ac6.md +52 -0
  41. package/docs/verification/2026-10-30-recovery-wait-settings.md +141 -0
  42. package/docs/verification/claude-implementer-permission-triage.ts +160 -0
  43. package/docs/verification/claude-permission-transport-native.py +71 -14
  44. package/docs/verification/profiles/YYYY-MM-DD-research.md +1 -1
  45. package/docs/verified.json +15 -15
  46. package/package.json +2 -2
  47. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  48. package/plugins/agent-hub/server.js +1016 -280
  49. package/src/adapters/acp.ts +79 -3
  50. package/src/adapters/channel-peer.ts +4 -0
  51. package/src/adapters/claude-channel.ts +13 -6
  52. package/src/adapters/claude-implementer.ts +60 -7
  53. package/src/adapters/codex-appserver.ts +77 -4
  54. package/src/adapters/conformance/fakes/jcode-agent.ts +6 -2
  55. package/src/adapters/conformance/kit.ts +3 -9
  56. package/src/adapters/definitions/builtin-metadata.ts +3 -114
  57. package/src/adapters/definitions/native-config-defaults.ts +1 -1
  58. package/src/adapters/definitions/native-config-policy.ts +3 -2
  59. package/src/adapters/definitions/native-context.ts +12 -0
  60. package/src/adapters/external-metadata.ts +48 -0
  61. package/src/adapters/external-modules.ts +63 -3
  62. package/src/adapters/jcode.ts +6 -2
  63. package/src/adapters/local-worker.ts +40 -25
  64. package/src/adapters/native-proof-ports.ts +10 -0
  65. package/src/adapters/registry-types.ts +4 -0
  66. package/src/broker/protocol.ts +203 -0
  67. package/src/broker/server.ts +314 -0
  68. package/src/broker/store.ts +548 -0
  69. package/src/cli/bench.ts +3 -2
  70. package/src/cli/broker.ts +58 -0
  71. package/src/cli/conductor-recovery.ts +142 -0
  72. package/src/cli/configured-acp-entry.ts +26 -6
  73. package/src/cli/console-state.ts +59 -17
  74. package/src/cli/console.ts +17 -2
  75. package/src/cli/facts-hook.ts +3 -1
  76. package/src/cli/help.ts +29 -16
  77. package/src/cli/identity.ts +25 -10
  78. package/src/cli/init.ts +34 -7
  79. package/src/cli/intake-output.ts +33 -0
  80. package/src/cli/learn-output.ts +38 -0
  81. package/src/cli/main.ts +334 -73
  82. package/src/cli/output.ts +41 -1
  83. package/src/cli/recovery-package.ts +18 -1
  84. package/src/cli/recovery-waits.ts +26 -0
  85. package/src/cli/role-holders.ts +35 -0
  86. package/src/cli/room.ts +304 -0
  87. package/src/cli/say-input.ts +16 -0
  88. package/src/cli/schedule-output.ts +49 -0
  89. package/src/cli/sdlc-output.ts +44 -6
  90. package/src/cli/smoke-pty-bridge.ts +82 -0
  91. package/src/cli/smoke-pty.ts +153 -0
  92. package/src/cli/smoke-run.ts +139 -0
  93. package/src/cli/smoke-target.ts +130 -0
  94. package/src/cli/source-output.ts +29 -0
  95. package/src/cli/task-output.ts +3 -2
  96. package/src/cli/upgrade-runtime.ts +112 -28
  97. package/src/cli/upgrade.ts +68 -8
  98. package/src/hub/ask.ts +76 -12
  99. package/src/hub/board.ts +132 -22
  100. package/src/hub/bus.ts +416 -65
  101. package/src/hub/child-process.ts +2 -0
  102. package/src/hub/ci-watch.ts +42 -20
  103. package/src/hub/claude-failure-stage.ts +85 -0
  104. package/src/hub/claude-shell-wrapper.ts +11 -5
  105. package/src/hub/conductor.ts +17 -4
  106. package/src/hub/config-apply.ts +34 -6
  107. package/src/hub/config-reload.ts +7 -5
  108. package/src/hub/config-trust.ts +7 -1
  109. package/src/hub/context-window.ts +33 -3
  110. package/src/hub/control-client.ts +14 -2
  111. package/src/hub/corpus-config.ts +34 -0
  112. package/src/hub/corpus-evidence.ts +184 -0
  113. package/src/hub/corpus.ts +373 -0
  114. package/src/hub/cost-governance.ts +26 -0
  115. package/src/hub/daemon.ts +1209 -183
  116. package/src/hub/deliverable-files.ts +8 -2
  117. package/src/hub/delivery-journal.ts +200 -73
  118. package/src/hub/dogfood-profile.ts +32 -5
  119. package/src/hub/dogfood-read.ts +6 -2
  120. package/src/hub/envelope.ts +11 -0
  121. package/src/hub/eval-gate.ts +131 -0
  122. package/src/hub/events.ts +1 -1
  123. package/src/hub/external-instance.ts +69 -0
  124. package/src/hub/facts.ts +15 -5
  125. package/src/hub/folder-trigger.ts +223 -0
  126. package/src/hub/hub-tools.ts +2 -1
  127. package/src/hub/inference.ts +20 -5
  128. package/src/hub/instance-config.ts +69 -3
  129. package/src/hub/instance-execution.ts +6 -2
  130. package/src/hub/instances.ts +32 -4
  131. package/src/hub/intake.ts +125 -0
  132. package/src/hub/learning.ts +117 -0
  133. package/src/hub/lifecycle.ts +7 -3
  134. package/src/hub/limits.ts +12 -5
  135. package/src/hub/native-permission-request.ts +77 -0
  136. package/src/hub/operational-authority.ts +63 -5
  137. package/src/hub/operational-settings.ts +154 -0
  138. package/src/hub/operational-view.ts +1 -1
  139. package/src/hub/packs.ts +132 -0
  140. package/src/hub/path-policy.ts +7 -0
  141. package/src/hub/peer-doing.ts +130 -0
  142. package/src/hub/peers.ts +12 -0
  143. package/src/hub/queue-backpressure.ts +58 -0
  144. package/src/hub/read-tools.ts +2 -0
  145. package/src/hub/recovery-authority.ts +151 -0
  146. package/src/hub/recovery-policy.ts +220 -0
  147. package/src/hub/research.ts +24 -14
  148. package/src/hub/restart.ts +7 -0
  149. package/src/hub/routing.ts +54 -11
  150. package/src/hub/schedules.ts +266 -0
  151. package/src/hub/sdlc-merge.ts +38 -14
  152. package/src/hub/sdlc-metrics.ts +32 -10
  153. package/src/hub/sdlc-queue.ts +7 -1
  154. package/src/hub/sdlc-release.ts +1 -1
  155. package/src/hub/sdlc.ts +37 -7
  156. package/src/hub/security-baseline.ts +38 -0
  157. package/src/hub/settings.ts +27 -2
  158. package/src/hub/shipped-packs.ts +8 -0
  159. package/src/hub/snapshots.ts +72 -6
  160. package/src/hub/source-read.ts +160 -0
  161. package/src/hub/sources.ts +311 -0
  162. package/src/hub/tasks.ts +987 -110
  163. package/src/hub/workflow.ts +1 -1
  164. package/src/local/doc-read.ts +69 -0
  165. package/src/local/proxy.ts +4 -13
  166. package/src/local/tools.ts +37 -3
  167. package/src/local/web-fetch-config.ts +43 -0
  168. package/src/local/web-fetch.ts +183 -0
  169. package/src/memory/brief.ts +85 -13
  170. package/src/memory/capture.ts +1 -1
  171. package/src/rooms/application.ts +114 -0
  172. package/src/rooms/boundary.ts +103 -0
  173. package/src/rooms/commands.ts +294 -0
  174. package/src/rooms/crypto.ts +371 -0
  175. package/src/rooms/link.ts +584 -0
  176. package/src/rooms/policy.ts +82 -0
  177. package/src/rooms/remote-peer.ts +80 -0
  178. package/src/rooms/service.ts +426 -0
  179. package/src/rooms/status.ts +146 -0
  180. package/src/rooms/store.ts +1210 -0
  181. package/src/rooms/wire.ts +465 -0
  182. package/src/ui/index.html +91 -4
  183. package/templates/packs/example.pack.json +13 -0
@@ -0,0 +1,29 @@
1
+ // Human output for `ahub source add|list` (#508). Pure: data in, spans out, no I/O. `--json` is the caller's branch and prints the same data.
2
+ import { blank, grid, heading, pair, s } from "./sdlc-output.ts";
3
+ import { moment } from "./sdlc-output.ts";
4
+ import type { Span } from "./console-state.ts";
5
+
6
+ const list = (value: unknown): any[] => Array.isArray(value) ? value : [];
7
+ const size = (bytes: unknown) => typeof bytes === "number" && Number.isFinite(bytes) ? bytes < 1024 ? `${bytes} B` : bytes < 1024 * 1024 ? `${(bytes / 1024).toFixed(1)} KiB` : `${(bytes / 1024 / 1024).toFixed(1)} MiB` : "unknown";
8
+
9
+ /** `ahub source add`: what was registered, and whether it was new or an upgrade of an existing record. */
10
+ export function renderSourceAdd(result: unknown, columns?: number): Span[][] {
11
+ const r = result as any, out: Span[][] = [...heading(r?.created ? `Registered ${r?.id}` : r?.upgraded ? `Already registered ${r?.id}; now private` : `Already registered ${r?.id}`, columns)];
12
+ out.push(...pair("kind", r?.kind ?? "unknown", columns, 8), ...pair("origin", r?.origin ?? "unknown", columns, 8), ...pair("sha256", r?.sha256 ?? "unknown", columns, 8), ...pair("size", size(r?.bytes), columns, 8));
13
+ out.push(...pair("private", r?.private ? "yes" : "no", columns, 8, r?.private ? "attention" : undefined), ...pair("text", r?.text ? "projected" : "none (no converter handles these bytes)", columns, 8, r?.text ? undefined : "muted"));
14
+ if (list(r?.reasons).length) out.push(...pair("because", list(r.reasons).join("; "), columns, 8, "attention"));
15
+ return out;
16
+ }
17
+
18
+ /** `ahub source list`: one row per registered source, newest last. The person sees every origin of a private source. */
19
+ export function renderSourceList(rows: unknown, columns?: number, now = Date.now()): Span[][] {
20
+ const sources = list(rows);
21
+ if (!sources.length) return [[s("no sources registered; add one with ahub source add <path|url>", "muted")]];
22
+ const out: Span[][] = [...heading(`Sources (${sources.length})`, columns)];
23
+ out.push(...grid(["ID", "KIND", "SIZE", "PRIVATE", "ADDED", "ORIGIN"], sources.map((row: any) => [
24
+ s(row.id, "issueRef"), s(row.kind), s(size(row.bytes)), s(row.private ? "yes" : "no", row.private ? "attention" : "muted"), s(moment(row.added_at, now)), s(row.origin),
25
+ ]), columns));
26
+ const many = sources.filter((row: any) => list(row.origins).length > 1);
27
+ for (const row of many) out.push(blank, ...pair(row.id, `${list(row.origins).length} origins: ${list(row.origins).map((o: any) => `${o.origin}${o.private ? " (private)" : ""}`).join("; ")}`, columns, 20, undefined));
28
+ return out;
29
+ }
@@ -5,6 +5,7 @@
5
5
  // and detail, empty refs and plan): this module shows that stub and never asks for more.
6
6
  import { type Span, type Tone, plural, table, terminalText, wrap } from "./console-state.ts";
7
7
  import { moment } from "./sdlc-output.ts";
8
+ import { taskHolderText } from "./role-holders.ts";
8
9
 
9
10
  type Line = Span[];
10
11
  // Local copies of the layout primitives of sdlc-output.ts (#814 T1), the source of these helpers: that file stays untouched by this task
@@ -100,8 +101,8 @@ function taskBlock(task: any, columns: number | undefined, now: number, detail:
100
101
  const out: Line[] = [...para(`${id} ${dash(task?.title)}`, columns, undefined, "", 6).map((line, i): Line => i === 0 ? [s(id, "taskRef"), s(line[0]!.text.slice(id.length), "strong")] : line)];
101
102
  const w = 10;
102
103
  out.push(...pair("state", `${dash(task?.state)}${task?.cancelled ? " (cancelled)" : ""}`, columns, w, stateTone(task?.state), " "),
103
- ...pair("class", dash(task?.class), columns, w, undefined, " "), ...pair("owner", dash(task?.owner), columns, w, undefined, " "), ...pair("reviewer", dash(task?.reviewer), columns, w, undefined, " "));
104
- if (task?.planner) out.push(...pair("planner", task.planner, columns, w, undefined, " "));
104
+ ...pair("class", dash(task?.class), columns, w, undefined, " "), ...pair("owner", taskHolderText(task, "owner"), columns, w, undefined, " "), ...pair("reviewer", taskHolderText(task, "reviewer"), columns, w, undefined, " "));
105
+ if (task?.planner !== undefined) out.push(...pair("planner", taskHolderText(task, "planner"), columns, w, undefined, " "));
105
106
  if (task?.reserved) out.push(...pair("reserved", task.reserved, columns, w, undefined, " "));
106
107
  out.push(...pair("after", ids(task?.deps), columns, w, undefined, " "));
107
108
  if (typeof task?.release === "number") out.push(...pair("release", `#${task.release}`, columns, w, undefined, " "));
@@ -1,10 +1,15 @@
1
+ import { authorizeConductorOperation, conductorRecoveryBinding } from "./conductor-recovery.ts";
1
2
  import { resolveAcceptedBinding } from "./recovery-guidance.ts";
2
3
  import { claudeStateFile } from "../hub/claude-state.ts";
3
4
  import { ADAPTERS, definitionOf } from "../adapters/registry.ts";
4
- import { readInstanceConfig } from "../hub/instance-config.ts";
5
+ import { readInstanceConfig, readInstanceConfigLayers } from "../hub/instance-config.ts";
6
+ import { readRecoveryWaits } from "./recovery-waits.ts";
7
+ import { readAdapterModuleDeclarations } from "../adapters/external-modules.ts";
8
+ import { configRefusal } from "../hub/config-trust.ts";
9
+ import { validExternalInstanceBinding } from "../hub/external-instance.ts";
5
10
  import { isDatabaseBusy, isDatabaseBusyReply, RecoveryDatabaseBusy } from "../hub/recovery-error.ts";
6
11
  import { cpSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync } from "node:fs";
7
- import { join, resolve } from "node:path";
12
+ import { join, relative, resolve, sep } from "node:path";
8
13
  import { createHash, randomUUID } from "node:crypto";
9
14
  import { ControlClient, PROTOCOL, RECOVERY_SOURCE_PROTOCOLS, readControl } from "../hub/control-client.ts";
10
15
  import { awaitStopped, inspectProject } from "../hub/lifecycle.ts";
@@ -22,10 +27,9 @@ import { decodeRestoreReceipt, restoreTransition, type RestoreReceipt, type Rest
22
27
  import { RecoveryDeferredHubError } from "../hub/deferred-hub.ts";
23
28
  import { RecoveryPreservationError, RecoveryPresentationError, type RecoveryNativeCause } from "./upgrade-presentation-data.ts";
24
29
 
25
- /** An unmanaged Claude's plugin retries with a backoff of at most 30 s; three of those bound the reconnect wait (#206). */
26
- const UNMANAGED_RECONNECT_WAIT_MS = 90_000;
27
- /** Managed TUI startup can include resume and MCP handshakes; match createTerminal's ten-minute readiness ceiling. */
28
- 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).
29
33
  class AttachmentLauncherGone extends Error {
30
34
  constructor(readonly transition: RestoreTransition) { super("chosen attachment launcher exited"); }
31
35
  }
@@ -76,12 +80,12 @@ export function restoredTerminalArgv(entrypoint: string, projectRoot: string, bi
76
80
  return [process.execPath, entrypoint, "--project", projectRoot, ...tail];
77
81
  }
78
82
 
79
- const RECOVERY_CONTROL_TIMEOUT_MS = 30_000;
80
83
 
81
- async function rpc(project: Project, message: Record<string, unknown>, protocol = PROTOCOL): Promise<any> {
82
- const client = await ControlClient.connect(project.stateDir, { role: "console", projectRoot: project.root, projectId: project.id }, RECOVERY_CONTROL_TIMEOUT_MS, protocol);
84
+ async function rpc(project: Project, message: Record<string, unknown>, protocol = PROTOCOL, conductorPeer?: string): Promise<any> {
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);
83
87
  try {
84
- const reply = await client.request(message, RECOVERY_CONTROL_TIMEOUT_MS);
88
+ const reply = await client.request(message, controlTimeoutMs);
85
89
  if (reply.ok === false) {
86
90
  // An older hub has only its exact SQLite message; preserve compatibility without trusting arbitrary text.
87
91
  if (isDatabaseBusyReply(reply)) throw new RecoveryDatabaseBusy();
@@ -142,10 +146,53 @@ export async function endPlannedPeer(planned: PlannedProject, peer: RecoveryPeer
142
146
  }
143
147
  }
144
148
 
149
+ /** No imports or executable catalog reconstruction: unknown kinds are only possible module instances. */
150
+ function recoveryDeclarations(projectRoot: string) {
151
+ const layers = readInstanceConfigLayers(projectRoot);
152
+ const modules = readAdapterModuleDeclarations(projectRoot, layers).modules;
153
+ const local = layers.find(layer => layer.name === "config.local.json");
154
+ const raw = local && !configRefusal(projectRoot, local.name) ? local.raw : {};
155
+ const object = (value: unknown): value is Record<string, unknown> => !!value && typeof value === "object" && !Array.isArray(value);
156
+ const instances = raw.instances === undefined ? {} : raw.instances;
157
+ if (!object(instances) || Object.keys(instances).length > 64) throw new Error("recovery instance declaration inventory unavailable");
158
+ const configured = object(raw.adapters) ? Object.keys(raw.adapters) : [];
159
+ const moduleInstances = Object.entries(instances).filter(([, entry]) => !object(entry) || typeof entry.kind !== "string" || !definitionOf(entry.kind) && !configured.includes(entry.kind));
160
+ return { modules, instances, configured, moduleInstances };
161
+ }
162
+ const hasRecoveryFeatures = (features: unknown, required: readonly string[]): features is string[] => Array.isArray(features) && features.every(feature => typeof feature === "string") && required.every(feature => features.includes(feature));
163
+ const EXTERNAL_INSTANCE_FEATURES = ["external_adapter_modules", "peer_instances", "external_adapter_instances"] as const;
164
+
165
+ /** Includes offline declarations and retained identities, so a reused target cannot downgrade their meaning. */
166
+ export function instanceRecoveryTargetRefusal(projectRoot: string, features: unknown, retainedPeers: readonly RecoveryPeer[] = []): string | undefined {
167
+ const declarations = recoveryDeclarations(projectRoot);
168
+ if ((declarations.moduleInstances.length || retainedPeers.some(peer => Object.hasOwn(peer, "externalInstance"))) && !hasRecoveryFeatures(features, EXTERNAL_INSTANCE_FEATURES)) {
169
+ return "target lacks external_adapter_instances or its external_adapter_modules/peer_instances prerequisites; module instances require a supporting target before recovery effects";
170
+ }
171
+ }
172
+
145
173
  /** Declared identities cannot be reinterpreted against an older running source's unbound peer rows. */
146
174
  export function instanceRecoverySourceRefusal(projectRoot: string, features: unknown, observedPeers?: unknown): string | undefined {
175
+ const inventory = recoveryDeclarations(projectRoot);
176
+ if (inventory.moduleInstances.length) {
177
+ if (!hasRecoveryFeatures(features, EXTERNAL_INSTANCE_FEATURES)) return "source lacks external_adapter_instances or its external_adapter_modules/peer_instances prerequisites; restart with a supporting source before recovering module instances, including offline declarations";
178
+ const rows = observedPeers && typeof observedPeers === "object" && !Array.isArray(observedPeers) ? observedPeers as Record<string, unknown> : undefined;
179
+ for (const [id, declaration] of inventory.moduleInstances) {
180
+ const peer = rows?.[id] as RecoveryPeer | undefined;
181
+ const binding = peer?.externalInstance;
182
+ if (!declaration || typeof declaration !== "object" || !peer || !["idle", "busy", "paused", "offline"].includes(peer.state) || !validExternalInstanceBinding(binding, projectRoot, id) || binding.kind !== (declaration as { kind?: unknown }).kind ||
183
+ !inventory.modules.some(module => module.sha256 === binding.sha256 && relative(projectRoot, resolve(projectRoot, module.path)).split(sep).join("/") === binding.modulePath)) {
184
+ return "source external module instance binding unavailable or changed; a matching authenticated live startup catalog is required before recovery";
185
+ }
186
+ }
187
+ const configuredIds = [...inventory.configured, ...Object.entries(inventory.instances).filter(([, entry]) => entry && typeof entry === "object" && inventory.configured.includes((entry as { kind: string }).kind)).map(([id]) => id)];
188
+ if (configuredIds.length && !hasRecoveryFeatures(features, ["configured_acp"])) {
189
+ if (!rows || !Object.values(rows).every(peer => peer && typeof peer === "object" && typeof (peer as { state?: unknown }).state === "string") || Object.entries(rows).some(([id, peer]) => configuredIds.includes(id) && (peer as { state: string }).state !== "offline")) return "source lacks configured_acp; configured native identities require a supporting source before recovery";
190
+ }
191
+ // The authenticated source owns semantic admission; raw config and cached display metadata cannot reconstruct it.
192
+ return undefined;
193
+ }
147
194
  const bound = readInstanceConfig(projectRoot);
148
- if (bound.adapter_modules?.length && !(Array.isArray(features) && features.includes("external_adapter_modules"))) return "source lacks external_adapter_modules; restart with a supporting source before recovering module peers";
195
+ if (bound.adapter_modules?.length && !hasRecoveryFeatures(features, ["external_adapter_modules"])) return "source lacks external_adapter_modules; restart with a supporting source before recovering module peers";
149
196
  const configured = Object.keys(bound.adapters ?? {});
150
197
  const configuredIds = [...configured, ...Object.entries(bound.instances ?? {}).filter(([, entry]) => configured.includes(entry.kind)).map(([id]) => id)];
151
198
  if (configuredIds.length && !(Array.isArray(features) && features.every(feature => typeof feature === "string") && features.includes("configured_acp"))) {
@@ -163,12 +210,12 @@ export function instanceRecoverySourceRefusal(projectRoot: string, features: unk
163
210
  return "source lacks peer_instances support and a declared instance of this project may be live; restart the source with a supporting agent-hub version before retrying recovery; for a first upgrade, end the extra native sessions first (a source whose declared instances are all offline proceeds without further steps); only if one cannot be ended, temporarily remove its declaration, upgrade the source, then declare it again; legacy peer identities were not reinterpreted";
164
211
  }
165
212
 
166
- export async function inspectRecovery(project: Project, targetFeatures?: readonly string[], purpose?: "cleanup"): Promise<Inspection> {
213
+ export async function inspectRecovery(project: Project, targetFeatures?: readonly string[], purpose?: "cleanup", conductorPeer?: string): Promise<Inspection> {
167
214
  // #226: the claim is judged by the registry as it is now, never by the plan's frozen copy, which holds the source's
168
215
  // pid from planning time (after a reboot another process may hold it). A registry that cannot be read throws: the
169
216
  // caller reads that as not inspected, never as stopped.
170
217
  const claim = registeredProjects().find((p) => p.id === project.id);
171
- const base = await inspectProject({ ...project, instanceId: claim?.instanceId ?? null, pid: claim?.pid ?? null, pidSignature: claim?.pidSignature ?? null }, RECOVERY_CONTROL_TIMEOUT_MS);
218
+ const base = await inspectProject({ ...project, instanceId: claim?.instanceId ?? null, pid: claim?.pid ?? null, pidSignature: claim?.pidSignature ?? null }, readRecoveryWaits(project.root).controlTimeoutMs, conductorPeer);
172
219
  if (base.failure?.code === "database_busy") throw new RecoveryDatabaseBusy();
173
220
  const control = readControl(project.stateDir);
174
221
  const sourceProtocol = control?.protocol;
@@ -203,7 +250,7 @@ export async function inspectRecovery(project: Project, targetFeatures?: readonl
203
250
  };
204
251
  if (!status && legacySupported && sourceProtocol !== PROTOCOL) {
205
252
  try {
206
- const readback = await rpc(project, { t: "status" }, sourceProtocol);
253
+ const readback = await rpc(project, { t: "status" }, sourceProtocol, conductorPeer);
207
254
  status = readback.status;
208
255
  } catch (error) {
209
256
  if (isDatabaseBusy(error)) throw error;
@@ -218,7 +265,7 @@ export async function inspectRecovery(project: Project, targetFeatures?: readonl
218
265
  const instanceRefusal = purpose === "cleanup" ? undefined : instanceRecoverySourceRefusal(project.root, status.features, status.peers);
219
266
  if (instanceRefusal) return { state: "incompatible", instanceId: status.instanceId, version: status.version, protocol: status.protocol, peers: [], blockers: [instanceRefusal] };
220
267
  let response: any;
221
- try { response = await rpc(project, { t: "recovery", op: "inspect", expectedInstanceId: status.instanceId, ...(targetFeatures === undefined ? {} : { targetFeatures }) }, sourceProtocol ?? PROTOCOL); }
268
+ try { response = await rpc(project, { t: "recovery", op: "inspect", expectedInstanceId: status.instanceId, ...(targetFeatures === undefined ? {} : { targetFeatures }) }, sourceProtocol ?? PROTOCOL, conductorPeer); }
222
269
  catch (error) {
223
270
  if (isDatabaseBusy(error) || targetFeatures !== undefined) throw error;
224
271
  // A stop can complete between authenticated status and the second read. Never infer stopped
@@ -227,16 +274,21 @@ export async function inspectRecovery(project: Project, targetFeatures?: readonl
227
274
  peers: [], blockers: ["runtime changed during recovery inspection"] };
228
275
  }
229
276
  const peers = Object.values(response.recovery?.peers ?? {}) as any[];
277
+ if (peers.some(peer => peer && Object.hasOwn(peer, "externalInstance") && !validExternalInstanceBinding(peer.externalInstance, project.root, peer.id))) {
278
+ return { state: "incompatible", instanceId: status.instanceId, version: status.version, protocol: status.protocol, peers: [], blockers: ["source external module instance binding is invalid"] };
279
+ }
230
280
  // #272: the hub's own words for what it waits on; shown to the person, never decided on.
231
281
  const waiting = (Array.isArray(response.recovery?.blockers) ? response.recovery.blockers : []).filter((b: unknown): b is string => typeof b === "string").slice(0, 20).map((b: string) => b.slice(0, 200));
232
282
  return { state: "running", instanceId: status.instanceId, version: status.version, protocol: status.protocol,
233
283
  recovery: { operationId: response.recovery?.operationId, phase: response.recovery?.phase, ready: response.recovery?.ready, ...(waiting.length ? { waiting } : {}) }, peers: peers.map((peer) => ({ id: peer.id, state: peer.state,
284
+ ...(peer.observation === undefined ? {} : { observation: peer.observation }),
285
+ ...(peer.externalInstance === undefined ? {} : { externalInstance: peer.externalInstance }),
234
286
  ...(peer.threadId ? { threadId: peer.threadId } : {}), ...(peer.sessionId ? { sessionId: peer.sessionId } : {}),
235
287
  ...(typeof (peer.sessionFile ?? peer.launch?.sessionFile) === "string" ? { sessionFile: peer.sessionFile ?? peer.launch.sessionFile } : {}),
236
288
  ...(peer.launch ? { args: Object.fromEntries(Object.entries(peer.launch).filter(([k, v]) => ["model", "route", "sessionFile", "mode", "backend"].includes(k) && typeof v === "string")) as Record<string, string> } : {}) })), blockers: [] };
237
289
  }
238
290
 
239
- export async function makeUpgradePlan(kind: "restart" | "upgrade", version: string, selectedRoot?: string, run: RunCommand = runCommand): Promise<UpgradePlan> {
291
+ export async function makeUpgradePlan(kind: "restart" | "upgrade", version: string, selectedRoot?: string, run: RunCommand = runCommand, conductorPeer?: string): Promise<UpgradePlan> {
240
292
  const projects = registeredProjects();
241
293
  const sourceDigest = packageDigest(PACKAGE_ROOT);
242
294
  const currentVersion = JSON.parse(readFileSync(join(PACKAGE_ROOT, "package.json"), "utf8")).version;
@@ -258,7 +310,7 @@ export async function makeUpgradePlan(kind: "restart" | "upgrade", version: stri
258
310
  for (const project of projects) {
259
311
  if (kind === "restart" && project.root !== selectedRoot) continue;
260
312
  let source: Inspection;
261
- try { source = await inspectRecovery(project); }
313
+ try { source = await inspectRecovery(project, undefined, undefined, conductorPeer); }
262
314
  catch { source = { state: "unavailable", peers: [], blockers: ["source recovery metadata could not be authenticated"] }; }
263
315
  if (source.state === "stopped" || source.state === "missing") continue;
264
316
  const blockers = [...source.blockers];
@@ -288,6 +340,7 @@ export async function makeUpgradePlan(kind: "restart" | "upgrade", version: stri
288
340
  if (!launcher) {
289
341
  unrecordedTerminals.push(peer.id);
290
342
  delete peer.sessionId;
343
+ delete peer.args; // An unmanaged channel reconnects itself; its observed identity is not restoration data.
291
344
  if (source.protocol === PROTOCOL) reconnectOnly.push(peer.id);
292
345
  else blockers.push(`${peer.id}: unmanaged session (no live ahub claude launcher recorded) runs a protocol-${source.protocol} plugin and cannot reconnect to a protocol-${PROTOCOL} hub; next action: end that Claude session, or relaunch it with ahub claude in an Orca terminal, then make a new plan`);
293
346
  continue;
@@ -364,7 +417,12 @@ export function preserveSource(plan: UpgradePlan, home = hubHome()): string {
364
417
  return dest;
365
418
  }
366
419
 
367
- export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?: string, clock?: Pick<RecoveryDriver, "now" | "sleep">): RecoveryDriver {
420
+ const unboundRecoveryInspection = inspectRecovery;
421
+
422
+ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?: string, clock?: Pick<RecoveryDriver, "now" | "sleep">, conductorPeer?: string): RecoveryDriver {
423
+ let authorizedOperation: RecoveryOperation | undefined;
424
+ const recoveryCurrent = async () => { if (conductorPeer) { if (!authorizedOperation) throw new FinalRefusal("conductor recovery authority unavailable"); await authorizeConductorOperation(authorizedOperation, conductorPeer); } };
425
+ const inspectRecovery = (project: Project, features?: readonly string[], purpose?: "cleanup") => unboundRecoveryInspection(project, features, purpose, conductorPeer);
368
426
  const now = clock?.now ?? (() => Date.now());
369
427
  const sleep = clock?.sleep ?? ((ms: number) => Bun.sleep(ms));
370
428
  /** `only`: one binding's native profile, for a command run once per profile (#450 I3: Claude instances may differ). */
@@ -376,6 +434,7 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
376
434
  value.AGENTHUB_HOME = hubHome(); value.AGENTHUB_RECOVERY_OPERATION = op.id;
377
435
  value.AGENTHUB_UNATTENDED = "0";
378
436
  if (liveTestPeer) value.AGENTHUB_PEER_ID = liveTestPeer;
437
+ if (conductorPeer) value.AGENTHUB_PEER_ID = conductorPeer;
379
438
  if (project) {
380
439
  value.AGENTHUB_PROJECT_DIR = project.root; value.AGENTHUB_STATE_DIR = project.stateDir;
381
440
  delete value.CODEX_HOME; delete value.CLAUDE_CONFIG_DIR;
@@ -390,14 +449,15 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
390
449
  };
391
450
  const command = async (op: RecoveryOperation, args: string[], project?: Project, only?: TerminalBinding) => {
392
451
  const result = await run([process.execPath, join(op.targetRoot!, "src/cli/main.js"), ...(project ? ["--project", project.root] : []), ...(liveTestPeer ? ["--live-test"] : []), ...args],
393
- { env: env(op, project, only), cwd: project?.root ?? op.plan.projects[0]!.project.root, timeoutMs: 180_000 });
452
+ { env: { ...env(op, project, only), ...(conductorPeer ? { AGENTHUB_RECOVERY_CONDUCTOR: conductorPeer, AGENTHUB_RECOVERY_CONTINUATION: op.id } : {}) }, cwd: project?.root ?? op.plan.projects[0]!.project.root, timeoutMs: 180_000 });
394
453
  if (result.code !== 0) throw new Error(`target command ${args[0]} failed; inspect recovery state before retrying`);
395
454
  return result;
396
455
  };
397
- const control = (project: Project, op: string, id: string, instance: string, targetFeatures?: readonly string[]) => {
456
+ const control = async (project: Project, op: string, id: string, instance: string, targetFeatures?: readonly string[]) => {
457
+ await recoveryCurrent();
398
458
  const sourceProtocol = readControl(project.stateDir)?.protocol;
399
459
  const protocol = sourceProtocol !== undefined && RECOVERY_SOURCE_PROTOCOLS.includes(sourceProtocol as (typeof RECOVERY_SOURCE_PROTOCOLS)[number]) ? sourceProtocol : PROTOCOL;
400
- return rpc(project, { t: "recovery", op, operationId: id, expectedInstanceId: instance, ...(targetFeatures === undefined ? {} : { targetFeatures }) }, protocol).then(() => {});
460
+ return rpc(project, { t: "recovery", op, operationId: id, expectedInstanceId: instance, ...(targetFeatures === undefined ? {} : { targetFeatures }), ...(conductorPeer && authorizedOperation ? { authority: conductorRecoveryBinding(authorizedOperation, project, conductorPeer) } : {}) }, protocol, conductorPeer).then(() => {});
401
461
  };
402
462
  const nativeFailure = (planned: PlannedProject, peer: string, reason: RecoveryNativeCause["reason"], message: string,
403
463
  details: Omit<RecoveryNativeCause, "kind" | "reason" | "projectId" | "peer"> = {}) =>
@@ -409,7 +469,7 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
409
469
  if (found.manualRequired || !current || (exact && (current.handle !== saved.handle || current.incarnationId !== saved.incarnationId || current.worktreeId !== saved.worktreeId || current.projectRoot !== saved.projectRoot))) {
410
470
  throw nativeFailure(planned, saved.peer, "terminal_unverified", `${saved.peer}: saved terminal identity changed or is not verified; manual-required`, { handle: saved.handle });
411
471
  }
412
- const idle = await waitForIdle(exact ? saved : current, 120_000, options);
472
+ const idle = await waitForIdle(exact ? saved : current, readRecoveryWaits(planned.project.root).terminalIdleWaitMs, options);
413
473
  if (!idle.satisfied) throw nativeFailure(planned, saved.peer, "terminal_unverified", `${saved.peer}: terminal is not idle; manual-required`, { handle: saved.handle });
414
474
  const observed = await inspectRecovery(planned.project);
415
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 });
@@ -507,9 +567,10 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
507
567
  if (seen !== "absent") throw new Error(closeUnsettled(retired, seen));
508
568
  progress.terminals[key] = true; save(); return;
509
569
  }
510
- const idle = await waitForIdle(retired, 120_000, terminalOptions(run));
570
+ const idle = await waitForIdle(retired, readRecoveryWaits(retired.projectRoot).terminalIdleWaitMs, terminalOptions(run));
511
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`);
512
572
  progress.terminals[key] = "pending"; save();
573
+ await recoveryCurrent();
513
574
  const result = await closeTerminal(retired, 0, terminalOptions(run));
514
575
  if (result.manualRequired) throw new Error(`${peer}: closing terminal ${retired.handle} of the stopped target's session was not verified; manual-required`);
515
576
  progress.terminals[key] = true; save();
@@ -522,7 +583,8 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
522
583
  };
523
584
  const binding = waiting.binding;
524
585
  const matchesTerminal = (terminal: Pick<TerminalBinding, "handle" | "incarnationId" | "worktreeId">) => terminal.handle === binding.handle && terminal.incarnationId === binding.incarnationId && terminal.worktreeId === binding.worktreeId;
525
- 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;
526
588
  const waitStep = op.step;
527
589
  if (waitStep) {
528
590
  (op.waits ??= {})[waitStep] = { waitStartedAt: new Date(startedAt).toISOString(), deadlineAt: new Date(deadline).toISOString() };
@@ -566,6 +628,7 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
566
628
  const decision = restoreTransition(waiting, { target: "running", launcher: launchState,
567
629
  session: session && peer && ["idle", "busy", "paused"].includes(peer.state) ? changed ? "other" : "planned" : "none", allowOther: zeroTurn || expectedSession === null, binding });
568
630
  if (decision.action === "settle" && session) {
631
+ await recoveryCurrent();
569
632
  if (zeroTurn) waiveRecoveryPeers(planned.project.stateDir, op.id, { [binding.peer]: "zero-turn" });
570
633
  return session;
571
634
  }
@@ -594,7 +657,7 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
594
657
  hub: { state: live.state, ...(live.instanceId ? { instanceId: live.instanceId } : {}), ...(peer ? { peerState: peer.state } : {}), ...(session ? { sessionId: session } : {}) },
595
658
  lastHubConnectAttempt: "unknown" };
596
659
  save();
597
- 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 });
598
661
  } catch (error) { waitFailed = true; throw error; }
599
662
  finally {
600
663
  const finish = () => {
@@ -609,6 +672,7 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
609
672
  };
610
673
  return {
611
674
  ...(liveTestPeer === undefined ? {} : { liveTestPeer }),
675
+ ...(conductorPeer ? { conductorPeer, authorize: async (op: RecoveryOperation) => { await authorizeConductorOperation(op, conductorPeer); authorizedOperation = op; } } : {}),
612
676
  now, sleep, inspect: inspectRecovery,
613
677
  recordedSessionState: async (binding, kind) => nativeRecordedSessionState(binding, kind),
614
678
  stage: async (op) => {
@@ -627,6 +691,10 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
627
691
  return { ...target, ...(op.targetFeatures === undefined ? {} : { targetFeatures: [...op.targetFeatures] }) };
628
692
  }
629
693
  if (cached && op.targetFeatures !== undefined) {
694
+ for (const planned of op.plan.projects) {
695
+ const refusal = typeof planned.project?.root === "string" ? instanceRecoveryTargetRefusal(planned.project.root, op.targetFeatures, planned.source.peers) : undefined;
696
+ if (refusal) throw new FinalRefusal(refusal);
697
+ }
630
698
  if (!op.targetFeatures.includes("external_adapter_modules") && op.plan.projects.some(planned => typeof planned.project?.root === "string" && (readInstanceConfig(planned.project.root).adapter_modules?.length ?? 0) > 0)) throw new FinalRefusal("target lacks external_adapter_modules; module peers require a supporting target before recovery effects");
631
699
  if (!op.targetFeatures.includes("configured_acp") && op.plan.projects.some(planned => typeof planned.project?.root === "string" && Object.keys(readInstanceConfig(planned.project.root).adapters ?? {}).length > 0)) throw new FinalRefusal("target lacks configured_acp; configured adapters require a supporting target before recovery effects");
632
700
  if (!op.targetFeatures.includes("peer_instances") && op.plan.projects.some(planned => typeof planned.project?.root === "string" && Object.keys(readInstanceConfig(planned.project.root).instances ?? {}).length > 0)) throw new FinalRefusal("target lacks peer_instances; declared project instances require a supporting target before recovery effects");
@@ -640,6 +708,10 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
640
708
  if (!metadata || typeof metadata !== "object" || !Number.isInteger(metadata.protocol)) throw new Error(`the staged target's control protocol could not be read (probe exit ${probe.code}); staged package retained, runtimes unchanged; resume again once it can run`);
641
709
  if (!Array.isArray(metadata.features) || !metadata.features.every((feature: unknown) => typeof feature === "string" && feature.length > 0)) throw new Error("the staged target features could not be read; staged package retained, runtimes unchanged");
642
710
  const targetFeatures: readonly string[] = [...metadata.features];
711
+ for (const planned of op.plan.projects) {
712
+ const refusal = typeof planned.project?.root === "string" ? instanceRecoveryTargetRefusal(planned.project.root, targetFeatures, planned.source.peers) : undefined;
713
+ if (refusal) throw new FinalRefusal(refusal);
714
+ }
643
715
  if (!targetFeatures.includes("external_adapter_modules") && op.plan.projects.some(planned => typeof planned.project?.root === "string" && (readInstanceConfig(planned.project.root).adapter_modules?.length ?? 0) > 0)) throw new FinalRefusal("target lacks external_adapter_modules; module peers require a supporting target before recovery effects");
644
716
  if (!targetFeatures.includes("configured_acp") && op.plan.projects.some(planned => typeof planned.project?.root === "string" && Object.keys(readInstanceConfig(planned.project.root).adapters ?? {}).length > 0)) throw new FinalRefusal("target lacks configured_acp; configured adapters require a supporting target before recovery effects");
645
717
  if (metadata.protocol !== PROTOCOL) throw new FinalRefusal("target protocol requires a newer coordinator; staged package retained, runtimes unchanged");
@@ -705,6 +777,7 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
705
777
  // the original peer roster and verified target features, including after a resume.
706
778
  await control(planned.project, "prepare", op.id, planned.source.instanceId!, op.targetFeatures);
707
779
  progress.terminals[key] = "pending"; save();
780
+ await recoveryCurrent();
708
781
  const result = await closeTerminal(binding, 0, terminalOptions(run));
709
782
  if (result.manualRequired) throw new Error(`${binding.peer}: terminal close not verified; manual-required`);
710
783
  progress.terminals[key] = true; save();
@@ -716,6 +789,7 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
716
789
  throw new Error("committed restart snapshot is missing or belongs to another operation");
717
790
  }
718
791
  const reconnect = op.plan.projects.find((planned) => planned.project.id === p.id)?.reconnectOnly ?? [];
792
+ await recoveryCurrent();
719
793
  if (reconnect.length) waiveRecoveryPeers(p.stateDir, op.id, Object.fromEntries(reconnect.map((peer) => [peer, "reconnect-only"])));
720
794
  await command(op, ["up"], p);
721
795
  },
@@ -727,6 +801,10 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
727
801
  const current = live.peers.find((p) => p.id === peer.id);
728
802
  if (current && current.state !== "offline") continue;
729
803
  const adapter = projectDefinition(peer.id, planned.project.root)!;
804
+ if (conductorPeer) {
805
+ await rpc(planned.project, { t: "recovery_restore", peer: peer.id, authority: conductorRecoveryBinding(op, planned.project, conductorPeer) }, PROTOCOL, conductorPeer);
806
+ continue;
807
+ }
730
808
  const args = [adapter.cli.command, ...(peer.id !== adapter.kind ? ["--as", peer.id] : [])];
731
809
  const refusal = adapter.session.recoveryRefusal?.(peer, "restore");
732
810
  if (refusal) throw new Error(refusal);
@@ -803,6 +881,7 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
803
881
  // #21: a Claude session that never got a first turn has no transcript, so `claude --resume S` can never
804
882
  // succeed and accepting its fresh attach loses nothing. Otherwise only the planned id, or a new session
805
883
  // the operator or the plan accepted, counts as restored.
884
+ await recoveryCurrent();
806
885
  if (attached !== original.sessionId && !fresh() && !(!recordedOnly && nativeZeroTurn(planned.project.stateDir, original, "attach"))) {
807
886
  throw new Error(`${original.peer}: terminal creation outcome is uncertain: session ${attached} is attached instead of ${original.sessionId}; end that ${original.peer} session and close its terminal first`);
808
887
  }
@@ -817,6 +896,7 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
817
896
  // Any other accepted session needs the target to waive the saved id, or release would wait for it forever;
818
897
  // a launch writes the waiver first, but this must not rely on that.
819
898
  // The planned session needs no waiver: the target's snapshot already expects it.
899
+ await recoveryCurrent();
820
900
  if (attached !== original.sessionId && attached !== plannedBinding.sessionId) waiveRecoveryPeers(planned.project.stateDir, op.id, { [original.peer]: waiver() });
821
901
  if (repair) repair.state = "attempted"; // a verified spontaneous reattachment also closes this repair allowance
822
902
  // The person accepted one loss, not a permanent exemption for later repairs of the replacement.
@@ -881,6 +961,7 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
881
961
  if (fresh()) {
882
962
  if (!targetReadsWaivers(op)) throw new Error(`${original.peer}: target ${op.plan.version} predates recovery waivers, so it cannot accept a new session`);
883
963
  // The restored daemon must accept the new session instead of the recorded one.
964
+ await recoveryCurrent();
884
965
  waiveRecoveryPeers(planned.project.stateDir, op.id, { [original.peer]: waiver() });
885
966
  }
886
967
  if (repair) {
@@ -894,6 +975,7 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
894
975
  command: ["env", ...Object.entries(assignments).map(([k, v]) => `${k}=${v}`), ...argv].map(shellQuote).join(" ") };
895
976
  const binding = { ...original, launch, launchMetadata: launch };
896
977
  progress.terminals[key] = { state: "pending" } satisfies RestoreReceipt; save();
978
+ await recoveryCurrent();
897
979
  const restored = await createTerminal(binding, { ...terminalOptions(run), stateDir: planned.project.stateDir, instanceId: progress.instanceId }, undefined, fresh());
898
980
  const exited = restored.blockers.find((b) => b.code === "launcher-exited");
899
981
  if (exited) {
@@ -949,9 +1031,10 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
949
1031
  }
950
1032
  if (group !== "native" && !onlyPeer) for (const id of planned.reconnectOnly ?? []) {
951
1033
  // #206: nothing is launched for an unmanaged session; its plugin reconnects by itself, within a bound.
952
- const deadline = now() + UNMANAGED_RECONNECT_WAIT_MS;
1034
+ const unmanagedWaitMs = readRecoveryWaits(planned.project.root).unmanagedReconnectWaitMs;
1035
+ const deadline = now() + unmanagedWaitMs;
953
1036
  while (!(await inspectRecovery(planned.project)).peers.some((p) => p.id === id && p.state !== "offline")) {
954
- 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 });
955
1038
  await sleep(1000);
956
1039
  }
957
1040
  }
@@ -970,6 +1053,7 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
970
1053
  if (!configured.size) await command(op, ["setup", "--yes"]);
971
1054
  },
972
1055
  installGlobal: async (op) => {
1056
+ await recoveryCurrent();
973
1057
  const result = await run([process.execPath, "add", "-g", `@staix/agent-hub@${op.plan.version}`, "--ignore-scripts"], { env: env(op), timeoutMs: 180_000 });
974
1058
  if (result.code !== 0) throw new Error("global installation failed; project runtimes use the retained staged package");
975
1059
  const readback = await run(["ahub", "--version"], { env: env(op) });
@@ -1005,7 +1089,7 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
1005
1089
  } catch { return { continuity: "unavailable" }; } // optional metadata must not wedge stop-and-archive
1006
1090
  }
1007
1091
  },
1008
- refreshManager: async (op) => { await refreshManager({ home: hubHome(), cli: join(op.targetRoot!, "src/cli/main.ts") }); },
1092
+ refreshManager: async (op) => { if (conductorPeer && op.plan.kind === "restart") return; await refreshManager({ home: hubHome(), cli: join(op.targetRoot!, "src/cli/main.ts") }); },
1009
1093
  verify: async (planned, progress, op, save) => {
1010
1094
  const live = await inspectRecovery(planned.project);
1011
1095
  if (live.instanceId !== progress.instanceId || live.version !== op.plan.version || live.recovery?.operationId !== op.id) throw new Error("target identity verification failed");