@staix/agent-hub 0.19.0 → 0.21.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 (184) hide show
  1. package/CHANGELOG.md +70 -3
  2. package/README.md +2 -2
  3. package/docs/agent-notes/README.md +11 -0
  4. package/docs/agent-notes/adapters.md +10 -5
  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 +20 -7
  9. package/docs/agent-notes/local-worker.md +5 -0
  10. package/docs/agent-notes/models.md +2 -0
  11. package/docs/agent-notes/tasks.md +20 -10
  12. package/docs/agent-notes/tests.md +13 -5
  13. package/docs/bench.md +1 -1
  14. package/docs/data-policy.md +2 -0
  15. package/docs/dogfood.md +41 -5
  16. package/docs/events.md +2 -1
  17. package/docs/memory.md +9 -3
  18. package/docs/operations.md +598 -63
  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 +66 -10
  23. package/docs/security.md +397 -35
  24. package/docs/smoke.md +127 -0
  25. package/docs/specs/2026-09-19-agent-hub-design.md +1149 -19
  26. package/docs/verification/2026-10-27-0.19.0.md +5 -5
  27. package/docs/verification/2026-10-27-0.20.0.md +75 -0
  28. package/docs/verification/2026-10-28-claude-2.1.311-2.1.312-bash-permission.json +635 -0
  29. package/docs/verification/2026-10-28-claude-2.1.311-2.1.312-permission-transport.json +403 -0
  30. package/docs/verification/2026-10-28-claude-implementer-permission-triage.md +123 -0
  31. package/docs/verification/2026-10-28-jcode-0.99.1-usage.md +41 -0
  32. package/docs/verification/2026-10-28-realpath-name-audit.md +222 -0
  33. package/docs/verification/2026-10-28-routing-f0bb8ad0.md +1000 -0
  34. package/docs/verification/2026-10-29-0.21.0.md +61 -0
  35. package/docs/verification/2026-10-29-model-effort-capabilities.md +123 -0
  36. package/docs/verification/2026-10-29-modelrouting-b152ddb9.md +953 -0
  37. package/docs/verification/claude-implementer-permission-triage.ts +160 -0
  38. package/docs/verification/claude-permission-transport-native.py +71 -14
  39. package/docs/verification/profiles/YYYY-MM-DD-research.md +1 -1
  40. package/docs/verified.json +26 -18
  41. package/package.json +2 -2
  42. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  43. package/plugins/agent-hub/server.js +1116 -284
  44. package/src/adapters/acp.ts +87 -3
  45. package/src/adapters/channel-peer.ts +11 -5
  46. package/src/adapters/claude-channel.ts +13 -6
  47. package/src/adapters/claude-implementer.ts +61 -7
  48. package/src/adapters/codex-appserver.ts +88 -5
  49. package/src/adapters/conformance/enrollment.ts +4 -4
  50. package/src/adapters/conformance/factories.ts +2 -1
  51. package/src/adapters/conformance/fakes/jcode-agent.ts +6 -2
  52. package/src/adapters/conformance/kit.ts +10 -6
  53. package/src/adapters/definitions/builtin-metadata.ts +3 -114
  54. package/src/adapters/definitions/native-config-defaults.ts +1 -1
  55. package/src/adapters/definitions/native-config-policy.ts +3 -2
  56. package/src/adapters/definitions/native-context.ts +12 -0
  57. package/src/adapters/external-metadata.ts +48 -0
  58. package/src/adapters/external-modules.ts +63 -3
  59. package/src/adapters/jcode.ts +6 -2
  60. package/src/adapters/local-worker.ts +42 -25
  61. package/src/adapters/native-proof-ports.ts +10 -0
  62. package/src/adapters/pi.ts +7 -0
  63. package/src/adapters/registry-types.ts +4 -0
  64. package/src/adapters/reviewer-print.ts +1 -0
  65. package/src/broker/protocol.ts +203 -0
  66. package/src/broker/server.ts +314 -0
  67. package/src/broker/store.ts +548 -0
  68. package/src/cli/bench.ts +3 -2
  69. package/src/cli/broker.ts +58 -0
  70. package/src/cli/conductor-recovery.ts +142 -0
  71. package/src/cli/configured-acp-entry.ts +26 -6
  72. package/src/cli/console-state.ts +59 -17
  73. package/src/cli/console.ts +17 -2
  74. package/src/cli/facts-hook.ts +3 -1
  75. package/src/cli/help.ts +28 -16
  76. package/src/cli/identity.ts +23 -10
  77. package/src/cli/init.ts +34 -7
  78. package/src/cli/intake-output.ts +33 -0
  79. package/src/cli/main.ts +314 -71
  80. package/src/cli/output.ts +41 -1
  81. package/src/cli/recovery-package.ts +18 -1
  82. package/src/cli/role-holders.ts +35 -0
  83. package/src/cli/room.ts +304 -0
  84. package/src/cli/say-input.ts +16 -0
  85. package/src/cli/schedule-output.ts +49 -0
  86. package/src/cli/sdlc-output.ts +44 -6
  87. package/src/cli/smoke-pty-bridge.ts +33 -0
  88. package/src/cli/smoke-pty.ts +145 -0
  89. package/src/cli/smoke-run.ts +139 -0
  90. package/src/cli/smoke-target.ts +130 -0
  91. package/src/cli/source-output.ts +29 -0
  92. package/src/cli/task-output.ts +3 -2
  93. package/src/cli/upgrade-runtime.ts +98 -16
  94. package/src/cli/upgrade.ts +66 -7
  95. package/src/hub/ask.ts +76 -12
  96. package/src/hub/board.ts +132 -22
  97. package/src/hub/bus.ts +399 -56
  98. package/src/hub/child-process.ts +2 -0
  99. package/src/hub/ci-watch.ts +162 -0
  100. package/src/hub/claude-failure-stage.ts +85 -0
  101. package/src/hub/claude-launch-proof.ts +67 -2
  102. package/src/hub/claude-shell-wrapper.ts +11 -5
  103. package/src/hub/conductor.ts +17 -4
  104. package/src/hub/config-apply.ts +32 -5
  105. package/src/hub/config-reload.ts +7 -5
  106. package/src/hub/config-trust.ts +8 -1
  107. package/src/hub/context-window.ts +33 -3
  108. package/src/hub/control-client.ts +14 -2
  109. package/src/hub/corpus-config.ts +34 -0
  110. package/src/hub/corpus-evidence.ts +184 -0
  111. package/src/hub/corpus.ts +373 -0
  112. package/src/hub/cost-governance.ts +26 -0
  113. package/src/hub/daemon.ts +1228 -242
  114. package/src/hub/deliverable-files.ts +8 -2
  115. package/src/hub/delivery-journal.ts +123 -17
  116. package/src/hub/dogfood-profile.ts +32 -5
  117. package/src/hub/dogfood-read.ts +6 -2
  118. package/src/hub/envelope.ts +11 -0
  119. package/src/hub/eval-gate.ts +131 -0
  120. package/src/hub/events.ts +1 -1
  121. package/src/hub/external-instance.ts +69 -0
  122. package/src/hub/facts.ts +15 -5
  123. package/src/hub/folder-trigger.ts +223 -0
  124. package/src/hub/hub-tools.ts +2 -1
  125. package/src/hub/inference.ts +20 -5
  126. package/src/hub/instance-config.ts +69 -3
  127. package/src/hub/instance-execution.ts +6 -2
  128. package/src/hub/instances.ts +32 -4
  129. package/src/hub/intake.ts +125 -0
  130. package/src/hub/learning.ts +85 -0
  131. package/src/hub/lifecycle.ts +7 -3
  132. package/src/hub/limits.ts +12 -5
  133. package/src/hub/native-permission-request.ts +77 -0
  134. package/src/hub/operational-authority.ts +63 -5
  135. package/src/hub/operational-settings.ts +76 -0
  136. package/src/hub/operational-view.ts +1 -1
  137. package/src/hub/packs.ts +132 -0
  138. package/src/hub/path-policy.ts +7 -0
  139. package/src/hub/peer-doing.ts +130 -0
  140. package/src/hub/peers.ts +46 -0
  141. package/src/hub/queue-backpressure.ts +58 -0
  142. package/src/hub/read-tools.ts +2 -0
  143. package/src/hub/recovery-authority.ts +151 -0
  144. package/src/hub/recovery-policy.ts +220 -0
  145. package/src/hub/research.ts +24 -14
  146. package/src/hub/restart.ts +7 -0
  147. package/src/hub/routing.ts +54 -11
  148. package/src/hub/schedules.ts +266 -0
  149. package/src/hub/sdlc-merge.ts +38 -14
  150. package/src/hub/sdlc-metrics.ts +32 -10
  151. package/src/hub/sdlc-queue.ts +7 -1
  152. package/src/hub/sdlc-release.ts +1 -1
  153. package/src/hub/sdlc.ts +37 -7
  154. package/src/hub/security-baseline.ts +38 -0
  155. package/src/hub/settings.ts +7 -1
  156. package/src/hub/shipped-packs.ts +8 -0
  157. package/src/hub/snapshots.ts +72 -6
  158. package/src/hub/source-read.ts +160 -0
  159. package/src/hub/sources.ts +311 -0
  160. package/src/hub/task-shell-default.ts +3 -2
  161. package/src/hub/tasks.ts +1047 -115
  162. package/src/hub/workflow.ts +1 -1
  163. package/src/local/deny.ts +21 -0
  164. package/src/local/doc-read.ts +69 -0
  165. package/src/local/proxy.ts +4 -13
  166. package/src/local/sandbox.ts +3 -1
  167. package/src/local/tools.ts +37 -3
  168. package/src/local/web-fetch-config.ts +43 -0
  169. package/src/local/web-fetch.ts +183 -0
  170. package/src/memory/brief.ts +85 -13
  171. package/src/memory/capture.ts +1 -1
  172. package/src/rooms/application.ts +114 -0
  173. package/src/rooms/boundary.ts +103 -0
  174. package/src/rooms/commands.ts +294 -0
  175. package/src/rooms/crypto.ts +371 -0
  176. package/src/rooms/link.ts +584 -0
  177. package/src/rooms/policy.ts +82 -0
  178. package/src/rooms/remote-peer.ts +80 -0
  179. package/src/rooms/service.ts +426 -0
  180. package/src/rooms/status.ts +146 -0
  181. package/src/rooms/store.ts +1210 -0
  182. package/src/rooms/wire.ts +465 -0
  183. package/src/ui/index.html +91 -4
  184. package/templates/packs/example.pack.json +13 -0
@@ -1,10 +1,14 @@
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 { readAdapterModuleDeclarations } from "../adapters/external-modules.ts";
7
+ import { configRefusal } from "../hub/config-trust.ts";
8
+ import { validExternalInstanceBinding } from "../hub/external-instance.ts";
5
9
  import { isDatabaseBusy, isDatabaseBusyReply, RecoveryDatabaseBusy } from "../hub/recovery-error.ts";
6
10
  import { cpSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync } from "node:fs";
7
- import { join, resolve } from "node:path";
11
+ import { join, relative, resolve, sep } from "node:path";
8
12
  import { createHash, randomUUID } from "node:crypto";
9
13
  import { ControlClient, PROTOCOL, RECOVERY_SOURCE_PROTOCOLS, readControl } from "../hub/control-client.ts";
10
14
  import { awaitStopped, inspectProject } from "../hub/lifecycle.ts";
@@ -78,8 +82,8 @@ export function restoredTerminalArgv(entrypoint: string, projectRoot: string, bi
78
82
 
79
83
  const RECOVERY_CONTROL_TIMEOUT_MS = 30_000;
80
84
 
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);
85
+ 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);
83
87
  try {
84
88
  const reply = await client.request(message, RECOVERY_CONTROL_TIMEOUT_MS);
85
89
  if (reply.ok === false) {
@@ -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 }, RECOVERY_CONTROL_TIMEOUT_MS, 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"> = {}) =>
@@ -510,6 +570,7 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
510
570
  const idle = await waitForIdle(retired, 120_000, 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();
@@ -566,6 +627,7 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
566
627
  const decision = restoreTransition(waiting, { target: "running", launcher: launchState,
567
628
  session: session && peer && ["idle", "busy", "paused"].includes(peer.state) ? changed ? "other" : "planned" : "none", allowOther: zeroTurn || expectedSession === null, binding });
568
629
  if (decision.action === "settle" && session) {
630
+ await recoveryCurrent();
569
631
  if (zeroTurn) waiveRecoveryPeers(planned.project.stateDir, op.id, { [binding.peer]: "zero-turn" });
570
632
  return session;
571
633
  }
@@ -609,6 +671,7 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
609
671
  };
610
672
  return {
611
673
  ...(liveTestPeer === undefined ? {} : { liveTestPeer }),
674
+ ...(conductorPeer ? { conductorPeer, authorize: async (op: RecoveryOperation) => { await authorizeConductorOperation(op, conductorPeer); authorizedOperation = op; } } : {}),
612
675
  now, sleep, inspect: inspectRecovery,
613
676
  recordedSessionState: async (binding, kind) => nativeRecordedSessionState(binding, kind),
614
677
  stage: async (op) => {
@@ -627,6 +690,10 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
627
690
  return { ...target, ...(op.targetFeatures === undefined ? {} : { targetFeatures: [...op.targetFeatures] }) };
628
691
  }
629
692
  if (cached && op.targetFeatures !== undefined) {
693
+ for (const planned of op.plan.projects) {
694
+ const refusal = typeof planned.project?.root === "string" ? instanceRecoveryTargetRefusal(planned.project.root, op.targetFeatures, planned.source.peers) : undefined;
695
+ if (refusal) throw new FinalRefusal(refusal);
696
+ }
630
697
  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
698
  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
699
  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 +707,10 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
640
707
  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
708
  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
709
  const targetFeatures: readonly string[] = [...metadata.features];
710
+ for (const planned of op.plan.projects) {
711
+ const refusal = typeof planned.project?.root === "string" ? instanceRecoveryTargetRefusal(planned.project.root, targetFeatures, planned.source.peers) : undefined;
712
+ if (refusal) throw new FinalRefusal(refusal);
713
+ }
643
714
  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
715
  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
716
  if (metadata.protocol !== PROTOCOL) throw new FinalRefusal("target protocol requires a newer coordinator; staged package retained, runtimes unchanged");
@@ -705,6 +776,7 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
705
776
  // the original peer roster and verified target features, including after a resume.
706
777
  await control(planned.project, "prepare", op.id, planned.source.instanceId!, op.targetFeatures);
707
778
  progress.terminals[key] = "pending"; save();
779
+ await recoveryCurrent();
708
780
  const result = await closeTerminal(binding, 0, terminalOptions(run));
709
781
  if (result.manualRequired) throw new Error(`${binding.peer}: terminal close not verified; manual-required`);
710
782
  progress.terminals[key] = true; save();
@@ -716,6 +788,7 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
716
788
  throw new Error("committed restart snapshot is missing or belongs to another operation");
717
789
  }
718
790
  const reconnect = op.plan.projects.find((planned) => planned.project.id === p.id)?.reconnectOnly ?? [];
791
+ await recoveryCurrent();
719
792
  if (reconnect.length) waiveRecoveryPeers(p.stateDir, op.id, Object.fromEntries(reconnect.map((peer) => [peer, "reconnect-only"])));
720
793
  await command(op, ["up"], p);
721
794
  },
@@ -727,6 +800,10 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
727
800
  const current = live.peers.find((p) => p.id === peer.id);
728
801
  if (current && current.state !== "offline") continue;
729
802
  const adapter = projectDefinition(peer.id, planned.project.root)!;
803
+ if (conductorPeer) {
804
+ await rpc(planned.project, { t: "recovery_restore", peer: peer.id, authority: conductorRecoveryBinding(op, planned.project, conductorPeer) }, PROTOCOL, conductorPeer);
805
+ continue;
806
+ }
730
807
  const args = [adapter.cli.command, ...(peer.id !== adapter.kind ? ["--as", peer.id] : [])];
731
808
  const refusal = adapter.session.recoveryRefusal?.(peer, "restore");
732
809
  if (refusal) throw new Error(refusal);
@@ -803,6 +880,7 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
803
880
  // #21: a Claude session that never got a first turn has no transcript, so `claude --resume S` can never
804
881
  // succeed and accepting its fresh attach loses nothing. Otherwise only the planned id, or a new session
805
882
  // the operator or the plan accepted, counts as restored.
883
+ await recoveryCurrent();
806
884
  if (attached !== original.sessionId && !fresh() && !(!recordedOnly && nativeZeroTurn(planned.project.stateDir, original, "attach"))) {
807
885
  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
886
  }
@@ -817,6 +895,7 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
817
895
  // Any other accepted session needs the target to waive the saved id, or release would wait for it forever;
818
896
  // a launch writes the waiver first, but this must not rely on that.
819
897
  // The planned session needs no waiver: the target's snapshot already expects it.
898
+ await recoveryCurrent();
820
899
  if (attached !== original.sessionId && attached !== plannedBinding.sessionId) waiveRecoveryPeers(planned.project.stateDir, op.id, { [original.peer]: waiver() });
821
900
  if (repair) repair.state = "attempted"; // a verified spontaneous reattachment also closes this repair allowance
822
901
  // The person accepted one loss, not a permanent exemption for later repairs of the replacement.
@@ -881,6 +960,7 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
881
960
  if (fresh()) {
882
961
  if (!targetReadsWaivers(op)) throw new Error(`${original.peer}: target ${op.plan.version} predates recovery waivers, so it cannot accept a new session`);
883
962
  // The restored daemon must accept the new session instead of the recorded one.
963
+ await recoveryCurrent();
884
964
  waiveRecoveryPeers(planned.project.stateDir, op.id, { [original.peer]: waiver() });
885
965
  }
886
966
  if (repair) {
@@ -894,6 +974,7 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
894
974
  command: ["env", ...Object.entries(assignments).map(([k, v]) => `${k}=${v}`), ...argv].map(shellQuote).join(" ") };
895
975
  const binding = { ...original, launch, launchMetadata: launch };
896
976
  progress.terminals[key] = { state: "pending" } satisfies RestoreReceipt; save();
977
+ await recoveryCurrent();
897
978
  const restored = await createTerminal(binding, { ...terminalOptions(run), stateDir: planned.project.stateDir, instanceId: progress.instanceId }, undefined, fresh());
898
979
  const exited = restored.blockers.find((b) => b.code === "launcher-exited");
899
980
  if (exited) {
@@ -970,6 +1051,7 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
970
1051
  if (!configured.size) await command(op, ["setup", "--yes"]);
971
1052
  },
972
1053
  installGlobal: async (op) => {
1054
+ await recoveryCurrent();
973
1055
  const result = await run([process.execPath, "add", "-g", `@staix/agent-hub@${op.plan.version}`, "--ignore-scripts"], { env: env(op), timeoutMs: 180_000 });
974
1056
  if (result.code !== 0) throw new Error("global installation failed; project runtimes use the retained staged package");
975
1057
  const readback = await run(["ahub", "--version"], { env: env(op) });
@@ -1005,7 +1087,7 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
1005
1087
  } catch { return { continuity: "unavailable" }; } // optional metadata must not wedge stop-and-archive
1006
1088
  }
1007
1089
  },
1008
- refreshManager: async (op) => { await refreshManager({ home: hubHome(), cli: join(op.targetRoot!, "src/cli/main.ts") }); },
1090
+ refreshManager: async (op) => { if (conductorPeer && op.plan.kind === "restart") return; await refreshManager({ home: hubHome(), cli: join(op.targetRoot!, "src/cli/main.ts") }); },
1009
1091
  verify: async (planned, progress, op, save) => {
1010
1092
  const live = await inspectRecovery(planned.project);
1011
1093
  if (live.instanceId !== progress.instanceId || live.version !== op.plan.version || live.recovery?.operationId !== op.id) throw new Error("target identity verification failed");
@@ -1,6 +1,7 @@
1
1
  import { nativeRestoreGroup } from "../adapters/definitions/native-identity-defaults.ts";
2
2
  import { definitionOf } from "../adapters/registry.ts";
3
3
  import { readInstanceConfig } from "../hub/instance-config.ts";
4
+ import { validExternalInstanceBinding, type ExternalInstanceBinding } from "../hub/external-instance.ts";
4
5
  import { LIVE_TEST_PERSON_REFUSAL } from "./identity.ts";
5
6
  import { checkedSessionPath } from "./recovery-session-file.ts";
6
7
  import { canChooseFresh, decodeRestoreReceipt, restoreReceiptStatus } from "./upgrade-restore-state.ts";
@@ -11,7 +12,7 @@ import { existsSync, readFileSync } from "node:fs";
11
12
  import { join } from "node:path";
12
13
  import { REGISTRY_DB_BUSY_TIMEOUT_MS, PROJECT_COLUMNS, projectFromRow, type Project } from "../hub/registry.ts";
13
14
  import { hubHome } from "../hub/project.ts";
14
- import { acquireRecoveryLock, claimRunner, coordinatorCurrent, readOperation, recoveryCommand, recoveryLock, releaseRecoveryLock, writeOperation } from "../hub/recovery-store.ts";
15
+ import { acquireRecoveryLock, claimRunner, coordinatorCurrent, operationIdPattern, readOperation, recoveryCommand, recoveryLock, releaseRecoveryLock, writeOperation } from "../hub/recovery-store.ts";
15
16
  import { resolveAcceptedBinding, piArchiveExit, recordedPeerGuidance, type RecoveryNativePeer } from "./recovery-guidance.ts";
16
17
  import type { TerminalBinding } from "./terminal-recovery.ts";
17
18
  import { RecoveryPresentationError, type RecoveryPresentationCause, type RecoveryWaitTiming } from "./upgrade-presentation-data.ts";
@@ -25,6 +26,10 @@ const projectDefinition = (peer: string, root?: string) => {
25
26
 
26
27
  export interface RecoveryPeer {
27
28
  id: string;
29
+ /** Read-only source observation; never consumed as restoration arguments. */
30
+ observation?: { kind: string; attached: boolean; sessionDigest?: string };
31
+ /** Captured stable module/instance identity; never a portable current-owner proof. */
32
+ externalInstance?: ExternalInstanceBinding;
28
33
  state: string;
29
34
  threadId?: string;
30
35
  sessionId?: string;
@@ -126,6 +131,8 @@ export interface RecoveryOperation {
126
131
  sourceRoot: string;
127
132
  /** Ids-only initiating and subsequent delegated invocations; retained across coordinator replacement. */
128
133
  delegatedAudits?: RecoveryDelegationAudit[];
134
+ /** Source template bytes only when the project has no routing.toml; integrity is sealed in the private policy digest. */
135
+ conductorRoutingDefaults?: Record<string, string>;
129
136
  targetRoot?: string;
130
137
  targetDigest?: string;
131
138
  /** Verified capability evidence bound to targetRoot/targetDigest; absence is legacy unknown, not an empty list. */
@@ -409,12 +416,13 @@ export function planFingerprint(plan: Omit<UpgradePlan, "fingerprint">): string
409
416
  return createHash("sha256").update(JSON.stringify(stable)).digest("hex");
410
417
  }
411
418
 
412
- export function createOperation(plan: UpgradePlan, sourceRoot: string, home = hubHome(), delegation?: RecoveryDelegation): RecoveryOperation {
419
+ export function createOperation(plan: UpgradePlan, sourceRoot: string, home = hubHome(), delegation?: RecoveryDelegation, operationId: string = randomUUID()): RecoveryOperation {
420
+ if (!operationIdPattern.test(operationId)) throw new Error("invalid recovery operation ID");
413
421
  if (plan.blockers.length || plan.projects.some((p) => p.blockers.length)) throw new Error("upgrade plan has blockers; no runtimes were changed");
414
422
  if (!plan.projects.length) throw new Error("no running projects to recover");
415
423
  const { fingerprint, ...body } = plan;
416
424
  if (planFingerprint(body) !== fingerprint) throw new Error("upgrade plan fingerprint is invalid");
417
- const id = randomUUID();
425
+ const id = operationId;
418
426
  const op: RecoveryOperation = {
419
427
  schema: 1, id, plan, sourceRoot, createdAt: Date.now(), updatedAt: Date.now(), phase: "pending", step: "stage",
420
428
  projects: plan.projects.map((p) => ({ id: p.project.id, phase: "pending", terminals: {} })),
@@ -487,6 +495,10 @@ function validateReceipt(id: string, op: RecoveryOperation, historical = false):
487
495
  op.projects.some((p, i) => p.id !== op.plan.projects[i]?.project.id)) throw new Error("reviewed operation plan or project scope changed");
488
496
  const object = (value: unknown): value is Record<string, unknown> => !!value && typeof value === "object" && !Array.isArray(value);
489
497
  for (const [index, progress] of op.projects.entries()) {
498
+ const planned = op.plan.projects[index]!;
499
+ for (const peer of planned.source.peers) {
500
+ if (Object.hasOwn(peer, "externalInstance") && !validExternalInstanceBinding(peer.externalInstance, planned.project.root, peer.id)) throw new Error("invalid retained external module instance binding");
501
+ }
490
502
  if (progress.repairedSessions !== undefined) {
491
503
  if (!object(progress.repairedSessions)) throw new Error("invalid repaired session provenance");
492
504
  for (const [peer, protectedSession] of Object.entries(progress.repairedSessions)) {
@@ -559,6 +571,45 @@ export interface RecoveryDriver {
559
571
  now(): number;
560
572
  /** Trusted per-invocation delegation, supplied only by the CLI's identity preflight. */
561
573
  readonly liveTestPeer?: string;
574
+ /** Trusted actor from the live conductor preflight; receipt audit rows confer no authority. */
575
+ readonly conductorPeer?: string;
576
+ /** Host-only current authority proof for this exact operation and every affected project. */
577
+ authorize?(op: RecoveryOperation, phase: "run" | "abort" | "effect"): Promise<void>;
578
+ }
579
+
580
+ async function authorizeRecoveryDriver(driver: RecoveryDriver, op: RecoveryOperation, phase: "run" | "abort" | "effect"): Promise<void> {
581
+ if (driver.conductorPeer === undefined) return;
582
+ if (!validRecoveryDelegation({ peer: driver.conductorPeer, command: "recovery-run" }) || typeof driver.authorize !== "function") throw new Error("conductor recovery authorization unavailable");
583
+ await driver.authorize(op, phase);
584
+ }
585
+
586
+ /** Closed effect ports only. Inspection stays read-only; person drivers keep their existing behavior. */
587
+ export function guardedRecoveryDriver(driver: RecoveryDriver, operation: () => RecoveryOperation): RecoveryDriver {
588
+ if (driver.conductorPeer === undefined) return driver;
589
+ const guard = <A extends unknown[], R>(action: (...args: A) => Promise<R>) => async (...args: A): Promise<R> => {
590
+ await authorizeRecoveryDriver(driver, operation(), "effect");
591
+ try { return await action(...args); }
592
+ finally { await authorizeRecoveryDriver(driver, operation(), "effect"); }
593
+ };
594
+ return {
595
+ ...driver,
596
+ stage: guard((...args: Parameters<RecoveryDriver["stage"]>) => driver.stage(...args)),
597
+ prepare: guard((...args: Parameters<RecoveryDriver["prepare"]>) => driver.prepare(...args)),
598
+ abort: guard((...args: Parameters<RecoveryDriver["abort"]>) => driver.abort(...args)),
599
+ commit: guard((...args: Parameters<RecoveryDriver["commit"]>) => driver.commit(...args)),
600
+ closeTerminals: guard((...args: Parameters<RecoveryDriver["closeTerminals"]>) => driver.closeTerminals(...args)),
601
+ start: guard((...args: Parameters<RecoveryDriver["start"]>) => driver.start(...args)),
602
+ restore: guard((...args: Parameters<RecoveryDriver["restore"]>) => driver.restore(...args)),
603
+ installPlugin: guard((...args: Parameters<RecoveryDriver["installPlugin"]>) => driver.installPlugin(...args)),
604
+ installGlobal: guard((...args: Parameters<RecoveryDriver["installGlobal"]>) => driver.installGlobal(...args)),
605
+ ...(driver.refreshManager ? { refreshManager: guard((op: RecoveryOperation) => driver.refreshManager!(op)) } : {}),
606
+ release: guard((...args: Parameters<RecoveryDriver["release"]>) => driver.release(...args)),
607
+ verify: guard((...args: Parameters<RecoveryDriver["verify"]>) => driver.verify(...args)),
608
+ stopAndArchive: guard((...args: Parameters<RecoveryDriver["stopAndArchive"]>) => driver.stopAndArchive(...args)),
609
+ inspect: (...args) => driver.inspect(...args),
610
+ ...(driver.recordedSessionState ? { recordedSessionState: (binding: TerminalBinding, kind: "rollout" | "transcript") => driver.recordedSessionState!(binding, kind) } : {}),
611
+ sleep: ms => driver.sleep(ms), now: () => driver.now(),
612
+ };
562
613
  }
563
614
 
564
615
  /** Resumable state machine. Driver actions are preceded by receipts and followed by live readback. */
@@ -567,6 +618,7 @@ export async function runRecovery(id: string, driver: RecoveryDriver, home = hub
567
618
  // mutable phase. A concurrent runner may have completed the operation meanwhile.
568
619
  let op = readOperation<RecoveryOperation>(id, home);
569
620
  validateReceipt(id, op);
621
+ if (driver.conductorPeer !== undefined) await authorizeRecoveryDriver(driver, op, "run");
570
622
  acquireRecoveryLock(id, home);
571
623
  const releaseRunner = claimRunner(id, home);
572
624
  try {
@@ -577,11 +629,13 @@ export async function runRecovery(id: string, driver: RecoveryDriver, home = hub
577
629
  releaseRunner();
578
630
  return op;
579
631
  }
580
- if (driver.liveTestPeer) {
581
- appendRecoveryDelegation(op, { peer: driver.liveTestPeer, command: "recovery-run" }, driver.now());
632
+ if (driver.conductorPeer !== undefined) await authorizeRecoveryDriver(driver, op, "run");
633
+ if (driver.conductorPeer ?? driver.liveTestPeer) {
634
+ appendRecoveryDelegation(op, { peer: (driver.conductorPeer ?? driver.liveTestPeer)!, command: "recovery-run" }, driver.now());
582
635
  writeOperation(id, op, home);
583
636
  }
584
637
  } catch (error) { releaseRunner(); throw error; }
638
+ driver = guardedRecoveryDriver(driver, () => op);
585
639
  let targetFeatures: readonly string[] = [];
586
640
  const save = () => { op.updatedAt = driver.now(); writeOperation(id, op, home); };
587
641
  const step = (value: string) => { op.step = value; save(); };
@@ -867,6 +921,7 @@ export function publicOperation(op: RecoveryOperation, runnerPid?: number | "unk
867
921
  * release, installation or completion. A later normal resume owns those steps.
868
922
  */
869
923
  export async function reattachRecovery(id: string, peer: string, driver: RecoveryDriver, home = hubHome()): Promise<RecoveryOperation> {
924
+ if (driver.conductorPeer !== undefined) throw new Error("recovery reattachment is person-only");
870
925
  let op = readOperation<RecoveryOperation>(id, home);
871
926
  validateReceipt(id, op);
872
927
  const selection = () => {
@@ -929,6 +984,7 @@ export async function reattachRecovery(id: string, peer: string, driver: Recover
929
984
  export async function abortRecovery(id: string, driver: RecoveryDriver, home = hubHome()): Promise<void> {
930
985
  let op = readOperation<RecoveryOperation>(id, home);
931
986
  validateReceipt(id, op, true);
987
+ if (driver.conductorPeer !== undefined) await authorizeRecoveryDriver(driver, op, "abort");
932
988
  acquireRecoveryLock(id, home);
933
989
  const releaseRunner = claimRunner(id, home);
934
990
  try {
@@ -940,10 +996,12 @@ export async function abortRecovery(id: string, driver: RecoveryDriver, home = h
940
996
  if (recoveryLock(home) === id) releaseRecoveryLock(id, home);
941
997
  return;
942
998
  }
943
- if (driver.liveTestPeer) {
944
- appendRecoveryDelegation(op, { peer: driver.liveTestPeer, command: "recovery abort" }, driver.now());
999
+ if (driver.conductorPeer !== undefined) await authorizeRecoveryDriver(driver, op, "abort");
1000
+ if (driver.conductorPeer ?? driver.liveTestPeer) {
1001
+ appendRecoveryDelegation(op, { peer: (driver.conductorPeer ?? driver.liveTestPeer)!, command: "recovery abort" }, driver.now());
945
1002
  writeOperation(id, op, home);
946
1003
  }
1004
+ driver = guardedRecoveryDriver(driver, () => op);
947
1005
  if (abandoned(op)) writeOperation(id, op, home);
948
1006
  const { fingerprint, ...body } = op.plan;
949
1007
  if (fingerprint !== planFingerprint(body)) throw new Error("operation plan changed");
@@ -971,6 +1029,7 @@ export async function abortRecovery(id: string, driver: RecoveryDriver, home = h
971
1029
  * every runtime this operation held or started is verified released or stopped.
972
1030
  */
973
1031
  export async function disposeRecovery(id: string, choice: { fresh: string } | { stop: true }, reason: string, driver: RecoveryDriver, home = hubHome()): Promise<RecoveryOperation> {
1032
+ if (driver.conductorPeer !== undefined) throw new Error("recovery disposition is person-only");
974
1033
  let op: RecoveryOperation;
975
1034
  try { op = readOperation<RecoveryOperation>(id, home); validateReceipt(id, op, "stop" in choice); }
976
1035
  catch (error) { if (driver.liveTestPeer && "fresh" in choice) throw new Error(LIVE_TEST_PERSON_REFUSAL); throw error; }