@staix/agent-hub 0.24.0 → 0.26.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 (114) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/README.md +35 -8
  3. package/docs/agent-notes/README.md +12 -0
  4. package/docs/agent-notes/adapters.md +2 -1
  5. package/docs/agent-notes/bus.md +1 -0
  6. package/docs/agent-notes/daemon.md +18 -3
  7. package/docs/agent-notes/tasks.md +12 -4
  8. package/docs/agent-notes/tests.md +3 -1
  9. package/docs/dogfood.md +16 -2
  10. package/docs/memory.md +1 -1
  11. package/docs/operations.md +264 -14
  12. package/docs/profiles.md +2 -0
  13. package/docs/quickstart.md +23 -6
  14. package/docs/security.md +198 -3
  15. package/docs/specs/2026-09-19-agent-hub-design.md +25 -1
  16. package/docs/specs/2026-09-19-multi-project-design.md +12 -1
  17. package/docs/specs/2026-11-01-research-campaigns-design.md +354 -0
  18. package/docs/verification/2026-11-01-0.25.0.md +91 -0
  19. package/docs/verification/2026-11-01-0.26.0.md +54 -0
  20. package/docs/verified.json +45 -17
  21. package/package.json +4 -3
  22. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  23. package/plugins/agent-hub/server.js +5294 -4797
  24. package/src/adapters/channel-peer.ts +4 -2
  25. package/src/adapters/claude-channel.ts +29 -6
  26. package/src/adapters/definitions/builtin-metadata.ts +6 -6
  27. package/src/adapters/definitions/native-cli-doctor.ts +30 -0
  28. package/src/adapters/definitions/qwen.ts +2 -0
  29. package/src/adapters/jcode.ts +3 -0
  30. package/src/adapters/registry-types.ts +2 -0
  31. package/src/adapters/registry.ts +2 -1
  32. package/src/cli/acon-register.ts +29 -0
  33. package/src/cli/conductor-recovery.ts +3 -2
  34. package/src/cli/handoff-args.ts +27 -0
  35. package/src/cli/handoff-output.ts +25 -0
  36. package/src/cli/help.ts +11 -8
  37. package/src/cli/identity.ts +7 -1
  38. package/src/cli/main.ts +138 -30
  39. package/src/cli/output.ts +8 -5
  40. package/src/cli/recovery-package.ts +94 -4
  41. package/src/cli/reset.ts +14 -4
  42. package/src/cli/runtime-version-warning.ts +28 -0
  43. package/src/cli/schedule-output.ts +8 -4
  44. package/src/cli/start-agents-run.ts +125 -0
  45. package/src/cli/study-output.ts +71 -0
  46. package/src/cli/study.ts +58 -0
  47. package/src/cli/task-output.ts +6 -0
  48. package/src/cli/upgrade-interactive.ts +4 -4
  49. package/src/cli/upgrade-runtime.ts +105 -57
  50. package/src/cli/upgrade.ts +90 -28
  51. package/src/hub/autonomy-defaults.ts +32 -7
  52. package/src/hub/board.ts +993 -8
  53. package/src/hub/budget.ts +3 -4
  54. package/src/hub/channel-project.ts +61 -0
  55. package/src/hub/child-process.ts +4 -3
  56. package/src/hub/conductor.ts +3 -4
  57. package/src/hub/config-apply.ts +13 -2
  58. package/src/hub/config-edit-host.ts +1 -0
  59. package/src/hub/config-tier.ts +3 -0
  60. package/src/hub/config-trust.ts +10 -2
  61. package/src/hub/control-client.ts +39 -10
  62. package/src/hub/daemon.ts +326 -25
  63. package/src/hub/db-schema.ts +143 -0
  64. package/src/hub/delivery-journal.ts +3 -4
  65. package/src/hub/envelope.ts +2 -0
  66. package/src/hub/events.ts +4 -1
  67. package/src/hub/execution-budget.ts +15 -4
  68. package/src/hub/folder-trigger.ts +3 -4
  69. package/src/hub/handoff.ts +126 -0
  70. package/src/hub/hub-tools.ts +12 -2
  71. package/src/hub/invocations.ts +3 -4
  72. package/src/hub/lifecycle.ts +87 -29
  73. package/src/hub/manager.ts +110 -87
  74. package/src/hub/no-writes.ts +1 -1
  75. package/src/hub/orca-env.ts +19 -0
  76. package/src/hub/path-policy.ts +12 -8
  77. package/src/hub/project-runtime-state.ts +139 -0
  78. package/src/hub/project-runtime.ts +62 -0
  79. package/src/hub/project.ts +14 -5
  80. package/src/hub/protocol-window.ts +19 -0
  81. package/src/hub/provenance.ts +30 -0
  82. package/src/hub/recovery-store.ts +102 -12
  83. package/src/hub/registry.ts +5 -0
  84. package/src/hub/research-campaign.ts +265 -0
  85. package/src/hub/research-experiments.ts +474 -0
  86. package/src/hub/research-runner.ts +172 -0
  87. package/src/hub/restart.ts +35 -0
  88. package/src/hub/runtime-capture-prune.ts +415 -0
  89. package/src/hub/runtime-prune-health.ts +112 -0
  90. package/src/hub/schedules.ts +213 -13
  91. package/src/hub/sdlc-merge.ts +7 -0
  92. package/src/hub/sdlc-queue.ts +13 -1
  93. package/src/hub/session-identity.ts +49 -0
  94. package/src/hub/settings.ts +4 -0
  95. package/src/hub/snapshots.ts +3 -4
  96. package/src/hub/start-agents.ts +44 -0
  97. package/src/hub/task-approval.ts +23 -0
  98. package/src/hub/tasks.ts +1005 -34
  99. package/src/hub/ui.ts +15 -6
  100. package/src/hub/usage-rollup.ts +3 -4
  101. package/src/memory/capture.ts +2 -2
  102. package/src/pi/tool-receipts.ts +3 -4
  103. package/src/ui/index.html +5 -3
  104. package/src/ui/office-space.ts +118 -0
  105. package/src/ui/plugin.ts +293 -0
  106. package/templates/AGENTS.block.md +1 -0
  107. package/templates/profiles/general.json +1 -1
  108. package/templates/profiles/research.json +4 -4
  109. package/templates/profiles/writing.json +1 -1
  110. package/templates/roles/research/analyst.md +1 -0
  111. package/templates/roles/research/fact-checker.md +1 -0
  112. package/templates/roles/research/research-lead.md +1 -0
  113. package/templates/roles/research/researcher.md +1 -0
  114. package/templates/roles/research/statistician.md +1 -0
@@ -8,16 +8,17 @@ import { readAdapterModuleDeclarations } from "../adapters/external-modules.ts";
8
8
  import { configRefusal } from "../hub/config-trust.ts";
9
9
  import { validExternalInstanceBinding } from "../hub/external-instance.ts";
10
10
  import { isDatabaseBusy, isDatabaseBusyReply, RecoveryDatabaseBusy } from "../hub/recovery-error.ts";
11
- import { cpSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync } from "node:fs";
11
+ import { existsSync, mkdirSync, readFileSync } from "node:fs";
12
12
  import { join, relative, resolve, sep } from "node:path";
13
- import { createHash, randomUUID } from "node:crypto";
14
- import { ControlClient, PROTOCOL, RECOVERY_SOURCE_PROTOCOLS, readControl } from "../hub/control-client.ts";
13
+ import { createHash } from "node:crypto";
14
+ import { ControlClient, PROTOCOL, PROTOCOL_MIN, controlProtocolWindow, RECOVERY_SOURCE_PROTOCOLS, readControl } from "../hub/control-client.ts";
15
+ import { negotiateProtocol, protocolWindow } from "../hub/protocol-window.ts";
15
16
  import { awaitStopped, inspectProject } from "../hub/lifecycle.ts";
16
17
  import type { Project } from "../hub/registry.ts";
17
- import { hubHome } from "../hub/project.ts";
18
- import { packageDigest, preserveRuntimeDependencies, registryRelease, runCommand, stageRelease, verifyPackage, type RunCommand } from "./recovery-package.ts";
18
+ import { canonicalPath, hubHome, realPath } from "../hub/project.ts";
19
+ import { packageDigest, preserveRuntimePackage, preserveRuntimeSource, stageCurrentRuntime, registryRelease, runCommand, stageRelease, verifyPackage, type RunCommand } from "./recovery-package.ts";
19
20
  import { inspectSavedTerminal, inspectTerminals, closeTerminal, createTerminal, launcherOf, recordPath, waitForIdle, shellQuote, type SessionRef, type TerminalBinding, type TerminalRecoveryOptions } from "./terminal-recovery.ts";
20
- import { ReattachmentRequired, MissingRecordedSession, FinalRefusal, planFingerprint, registeredProjects, targetReadsWaivers, type Inspection, type PlannedProject, type ProjectProgress, type RecoveryDriver, type RecoveryOperation, type RecoveryPeer, type UpgradePlan } from "./upgrade.ts";
21
+ import { ReattachmentRequired, MissingRecordedSession, FinalRefusal, planFingerprint, recoveryScope, registeredProjects, targetReadsWaivers, type Inspection, type PlannedProject, type ProjectProgress, type RecoveryDriver, type RecoveryOperation, type RecoveryPeer, type UpgradePlan } from "./upgrade.ts";
21
22
  import { SHARED_CHANNEL_VERSION_ARGV, nativeRecordedSessionState } from "../adapters/definitions/native-recovery.ts";
22
23
  import { refreshManager } from "../hub/manager.ts";
23
24
  import { abandonRestartSnapshot, readRestartSnapshot, waiveRecoveryPeers } from "../hub/restart.ts";
@@ -80,8 +81,19 @@ export function restoredTerminalArgv(entrypoint: string, projectRoot: string, bi
80
81
  return [process.execPath, entrypoint, "--project", projectRoot, ...tail];
81
82
  }
82
83
 
84
+ /** Recovery alone retains explicit old source wires; ordinary clients use the current supported window. */
85
+ function recoveryProtocol(descriptor: { protocol?: unknown; protocolMin?: unknown; protocolMax?: unknown } | undefined): number | undefined {
86
+ if (!descriptor) return undefined;
87
+ if (descriptor.protocol === undefined && descriptor.protocolMin === undefined && descriptor.protocolMax === undefined) return PROTOCOL_MIN;
88
+ const advertised = controlProtocolWindow(descriptor);
89
+ if (!advertised) return undefined;
90
+ const current = negotiateProtocol(protocolWindow(PROTOCOL, PROTOCOL_MIN)!, advertised);
91
+ if (current !== undefined) return current;
92
+ return [...RECOVERY_SOURCE_PROTOCOLS].reverse().find(version => version < PROTOCOL_MIN && version >= advertised.vMin && version <= advertised.v);
93
+ }
94
+
83
95
 
84
- async function rpc(project: Project, message: Record<string, unknown>, protocol = PROTOCOL, conductorPeer?: string): Promise<any> {
96
+ async function rpc(project: Project, message: Record<string, unknown>, protocol?: number, conductorPeer?: string): Promise<any> {
85
97
  const controlTimeoutMs = readRecoveryWaits(project.root).controlTimeoutMs;
86
98
  const client = await ControlClient.connect(project.stateDir, { ...(conductorPeer ? { role: "tools" as const, peer: conductorPeer } : { role: "console" as const }), projectRoot: project.root, projectId: project.id }, controlTimeoutMs, protocol);
87
99
  try {
@@ -136,7 +148,7 @@ export async function endPlannedPeer(planned: PlannedProject, peer: RecoveryPeer
136
148
  return result.closed ? `${name}: terminal ${current.handle} closed${cut}` : `${name}: its terminal was not closed (${result.blockers[0]?.message ?? "unknown reason"}); end it in that terminal`;
137
149
  }
138
150
  try {
139
- await rpc(planned.project, { t: "peer_stop", peer: peer.id }, planned.source.protocol ?? PROTOCOL);
151
+ await rpc(planned.project, { t: "peer_stop", peer: peer.id }, planned.source.protocol);
140
152
  return `${name}: stopped${cut}`;
141
153
  } catch (error) {
142
154
  const message = (error as Error).message;
@@ -218,7 +230,10 @@ export async function inspectRecovery(project: Project, targetFeatures?: readonl
218
230
  const base = await inspectProject({ ...project, instanceId: claim?.instanceId ?? null, pid: claim?.pid ?? null, pidSignature: claim?.pidSignature ?? null }, readRecoveryWaits(project.root).controlTimeoutMs, conductorPeer);
219
231
  if (base.failure?.code === "database_busy") throw new RecoveryDatabaseBusy();
220
232
  const control = readControl(project.stateDir);
221
- const sourceProtocol = control?.protocol;
233
+ const sourceProtocol = recoveryProtocol(control);
234
+ const sourceWindow = control && controlProtocolWindow(control);
235
+ const wire = { ...(sourceProtocol === undefined ? {} : { protocol: sourceProtocol }),
236
+ ...(sourceWindow ? { protocolMin: sourceWindow.vMin, protocolMax: sourceWindow.v } : {}) };
222
237
  const legacySupported = sourceProtocol !== undefined && RECOVERY_SOURCE_PROTOCOLS.includes(sourceProtocol as (typeof RECOVERY_SOURCE_PROTOCOLS)[number]);
223
238
  // A stopped runtime reports whose unreleased restart snapshot it holds: only that one can start it again (#215).
224
239
  let snapshot: string | undefined, snapshotError: Inspection["snapshotError"];
@@ -240,15 +255,19 @@ export async function inspectRecovery(project: Project, targetFeatures?: readonl
240
255
  else blockers = [base.error ?? base.state];
241
256
  return {
242
257
  state: base.state, peers: [], blockers,
243
- ...(control?.instanceId ? { instanceId: control.instanceId } : {}), ...(control?.protocol ? { protocol: control.protocol } : {}), ...(snapshot ? { snapshot } : {}), ...(snapshotError ? { snapshotError } : {}),
258
+ ...(control?.instanceId ? { instanceId: control.instanceId } : {}),
259
+ ...(control?.protocol === undefined ? {} : { protocol: control.protocol }),
260
+ ...(control?.protocolMin === undefined ? {} : { protocolMin: control.protocolMin }),
261
+ ...(control?.protocolMax === undefined ? {} : { protocolMax: control.protocolMax }),
262
+ ...(snapshot ? { snapshot } : {}), ...(snapshotError ? { snapshotError } : {}),
244
263
  };
245
264
  }
246
265
  let status = base.status;
247
- if (!status && control && sourceProtocol === PROTOCOL) return {
266
+ if (!status && control && sourceProtocol !== undefined && sourceProtocol >= PROTOCOL_MIN) return {
248
267
  state: "unavailable", peers: [], blockers: ["runtime changed during recovery inspection"],
249
- ...(control.instanceId ? { instanceId: control.instanceId } : {}), protocol: control.protocol,
268
+ ...(control.instanceId ? { instanceId: control.instanceId } : {}), ...wire,
250
269
  };
251
- if (!status && legacySupported && sourceProtocol !== PROTOCOL) {
270
+ if (!status && legacySupported && sourceProtocol < PROTOCOL_MIN) {
252
271
  try {
253
272
  const readback = await rpc(project, { t: "status" }, sourceProtocol, conductorPeer);
254
273
  status = readback.status;
@@ -258,29 +277,39 @@ export async function inspectRecovery(project: Project, targetFeatures?: readonl
258
277
  // connections with its manifest still on disk. Never infer stopped from a connect
259
278
  // failure: callers poll again and see "stopped" once the manifest is gone.
260
279
  return { state: "unavailable", peers: [], blockers: ["runtime changed during recovery inspection"],
261
- ...(control?.instanceId ? { instanceId: control.instanceId } : {}), ...(control?.protocol ? { protocol: control.protocol } : {}) };
280
+ ...(control?.instanceId ? { instanceId: control.instanceId } : {}), ...wire };
262
281
  }
263
282
  }
264
283
  if (!status || !control) return { state: "unavailable", peers: [], blockers: ["authenticated source status unavailable"] };
265
284
  const instanceRefusal = purpose === "cleanup" ? undefined : instanceRecoverySourceRefusal(project.root, status.features, status.peers);
266
- if (instanceRefusal) return { state: "incompatible", instanceId: status.instanceId, version: status.version, protocol: status.protocol, peers: [], blockers: [instanceRefusal] };
285
+ if (instanceRefusal) return { state: "incompatible", instanceId: status.instanceId, version: status.version, ...wire, peers: [], blockers: [instanceRefusal] };
267
286
  let response: any;
268
- try { response = await rpc(project, { t: "recovery", op: "inspect", expectedInstanceId: status.instanceId, ...(targetFeatures === undefined ? {} : { targetFeatures }) }, sourceProtocol ?? PROTOCOL, conductorPeer); }
287
+ try { response = await rpc(project, { t: "recovery", op: "inspect", expectedInstanceId: status.instanceId, ...(targetFeatures === undefined ? {} : { targetFeatures }) }, sourceProtocol, conductorPeer); }
269
288
  catch (error) {
270
289
  if (isDatabaseBusy(error) || targetFeatures !== undefined) throw error;
271
290
  // A stop can complete between authenticated status and the second read. Never infer stopped
272
291
  // from a connection failure: the coordinator will inspect the ownership manifest again.
273
- return { state: "unavailable", instanceId: status.instanceId, version: status.version, protocol: status.protocol,
292
+ return { state: "unavailable", instanceId: status.instanceId, version: status.version, ...wire,
274
293
  peers: [], blockers: ["runtime changed during recovery inspection"] };
275
294
  }
276
- const peers = Object.values(response.recovery?.peers ?? {}) as any[];
295
+ const recovery = response?.recovery;
296
+ const unavailable = () => {
297
+ const message = "source recovery metadata is unavailable";
298
+ if (targetFeatures !== undefined) throw new Error(message);
299
+ return { state: "unavailable", instanceId: status.instanceId, version: status.version, ...wire, peers: [], blockers: [message] };
300
+ };
301
+ if (!recovery || typeof recovery !== "object" || Array.isArray(recovery)
302
+ || !recovery.peers || typeof recovery.peers !== "object" || Array.isArray(recovery.peers)) return unavailable();
303
+ const peers = Object.values(recovery.peers) as any[];
304
+ if (peers.some(peer => !peer || typeof peer !== "object" || Array.isArray(peer) || typeof peer.id !== "string" || typeof peer.state !== "string")) return unavailable();
277
305
  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"] };
306
+ return { state: "incompatible", instanceId: status.instanceId, version: status.version, ...wire, peers: [], blockers: ["source external module instance binding is invalid"] };
279
307
  }
280
308
  // #272: the hub's own words for what it waits on; shown to the person, never decided on.
281
309
  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));
282
- return { state: "running", instanceId: status.instanceId, version: status.version, protocol: status.protocol,
310
+ return { state: "running", instanceId: status.instanceId, version: status.version, ...wire,
283
311
  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,
312
+ ...(protocolWindow(peer.controlProtocol) ? { controlProtocol: peer.controlProtocol } : {}),
284
313
  ...(peer.observation === undefined ? {} : { observation: peer.observation }),
285
314
  ...(peer.externalInstance === undefined ? {} : { externalInstance: peer.externalInstance }),
286
315
  ...(peer.threadId ? { threadId: peer.threadId } : {}), ...(peer.sessionId ? { sessionId: peer.sessionId } : {}),
@@ -290,12 +319,13 @@ export async function inspectRecovery(project: Project, targetFeatures?: readonl
290
319
 
291
320
  export async function makeUpgradePlan(kind: "restart" | "upgrade", version: string, selectedRoot?: string, run: RunCommand = runCommand, conductorPeer?: string): Promise<UpgradePlan> {
292
321
  const projects = registeredProjects();
322
+ const selected = selectedRoot === undefined ? undefined : canonicalPath(selectedRoot);
293
323
  const sourceDigest = packageDigest(PACKAGE_ROOT);
294
324
  const currentVersion = JSON.parse(readFileSync(join(PACKAGE_ROOT, "package.json"), "utf8")).version;
295
325
  const release = kind === "upgrade" ? await registryRelease(version, run) : undefined;
296
326
  const body: Omit<UpgradePlan, "fingerprint"> = { schema: 1, kind, version: kind === "restart" ? currentVersion : version,
297
- sourceRoot: PACKAGE_ROOT, sourceDigest, ...(release ? { integrity: release.integrity } : {}), projects: [], blockers: [] };
298
- if (kind === "upgrade") {
327
+ sourceRoot: PACKAGE_ROOT, sourceDigest, ...(release ? { integrity: release.integrity } : {}), ...(selected === undefined ? {} : { scope: { projectRoot: selected } }), projects: [], blockers: [] };
328
+ if (kind === "upgrade" && selected === undefined) {
299
329
  try {
300
330
  if ((await run([...SHARED_CHANNEL_VERSION_ARGV], { timeoutMs: 10_000 })).code !== 0) body.blockers.push("shared Claude plugin installer is unavailable");
301
331
  } catch { body.blockers.push("shared Claude plugin installer is unavailable"); }
@@ -304,17 +334,17 @@ export async function makeUpgradePlan(kind: "restart" | "upgrade", version: stri
304
334
  if (existsSync(managerFile)) {
305
335
  try {
306
336
  const manager = JSON.parse(readFileSync(managerFile, "utf8"));
307
- if (!RECOVERY_SOURCE_PROTOCOLS.includes(manager.protocol as (typeof RECOVERY_SOURCE_PROTOCOLS)[number])) body.blockers.push("manager requires manual bootstrap with its matching CLI before current-protocol recovery");
337
+ if (recoveryProtocol(manager) === undefined) body.blockers.push("manager requires manual bootstrap with its matching CLI before current-protocol recovery");
308
338
  } catch { body.blockers.push("manager ownership manifest is unreadable"); }
309
339
  }
310
340
  for (const project of projects) {
311
- if (kind === "restart" && project.root !== selectedRoot) continue;
341
+ if (kind === "restart" && selected === undefined || selected !== undefined && project.root !== selected) continue;
312
342
  let source: Inspection;
313
343
  try { source = await inspectRecovery(project, undefined, undefined, conductorPeer); }
314
344
  catch { source = { state: "unavailable", peers: [], blockers: ["source recovery metadata could not be authenticated"] }; }
315
345
  if (source.state === "stopped" || source.state === "missing") continue;
316
346
  const blockers = [...source.blockers];
317
- if (!RECOVERY_SOURCE_PROTOCOLS.includes(source.protocol as (typeof RECOVERY_SOURCE_PROTOCOLS)[number]) || source.state !== "running") blockers.push("manual-bootstrap-required: an authenticated protocol-9, protocol-10, protocol-11, protocol-12, protocol-13, protocol-14, protocol-15 or protocol-16 source is required");
347
+ if (!RECOVERY_SOURCE_PROTOCOLS.includes(source.protocol as (typeof RECOVERY_SOURCE_PROTOCOLS)[number]) || source.state !== "running") blockers.push(`manual-bootstrap-required: an authenticated source overlapping protocols ${PROTOCOL_MIN}..${PROTOCOL}, or an explicit recovery protocol 9..15, is required`);
318
348
  if (source.recovery?.operationId && source.recovery.phase !== "released") blockers.push(`existing recovery operation ${source.recovery.operationId} must be resolved first`);
319
349
  const sessions: Partial<Record<TerminalBinding["peer"], string | SessionRef>> = {};
320
350
  const reconnectOnly: string[] = [];
@@ -341,8 +371,10 @@ export async function makeUpgradePlan(kind: "restart" | "upgrade", version: stri
341
371
  unrecordedTerminals.push(peer.id);
342
372
  delete peer.sessionId;
343
373
  delete peer.args; // An unmanaged channel reconnects itself; its observed identity is not restoration data.
344
- if (source.protocol === PROTOCOL) reconnectOnly.push(peer.id);
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`);
374
+ const sourceWindow = source.protocolMin === undefined || source.protocolMax === undefined ? undefined : controlProtocolWindow(source);
375
+ const pluginProtocol = peer.controlProtocol ?? (sourceWindow?.v === sourceWindow?.vMin ? sourceWindow?.v : undefined);
376
+ if (pluginProtocol !== undefined && negotiateProtocol(protocolWindow(PROTOCOL, PROTOCOL_MIN)!, protocolWindow(pluginProtocol)!) !== undefined) reconnectOnly.push(peer.id);
377
+ else blockers.push(`${peer.id}: unmanaged session (no live ahub claude launcher recorded) has ${pluginProtocol === undefined ? "an unknown attached plugin protocol" : `a protocol-${pluginProtocol} plugin`} and cannot be verified to reconnect to a protocol-${PROTOCOL_MIN}..${PROTOCOL} hub; next action: end that Claude session, or relaunch it with ahub claude in an Orca terminal, then make a new plan`);
346
378
  continue;
347
379
  }
348
380
  let record: { instanceId?: unknown; launchId?: unknown; sessionId?: unknown } | undefined;
@@ -396,25 +428,13 @@ export async function makeUpgradePlan(kind: "restart" | "upgrade", version: stri
396
428
 
397
429
  /** Freeze the coordinator/source package before its mutable global installation is replaced. */
398
430
  export function preserveSource(plan: UpgradePlan, home = hubHome()): string {
431
+ if (plan.projects?.length === 0) throw new Error("no running projects to recover");
399
432
  if (packageDigest(plan.sourceRoot) !== plan.sourceDigest) throw new Error("source package changed since planning");
400
433
  const base = join(home, "releases");
401
- const dest = join(base, `source-${plan.sourceDigest}`);
402
- if (existsSync(dest)) {
403
- if (packageDigest(dest, { ownDependencies: true }) !== plan.sourceDigest) throw new Error("preserved source package changed");
404
- return dest;
405
- }
406
434
  mkdirSync(base, { recursive: true, mode: 0o700 });
407
- const temporary = join(base, `.source-${randomUUID()}`);
408
- mkdirSync(temporary, { mode: 0o700 });
409
- try {
410
- for (const name of ["src", "plugins", "templates", ".claude-plugin", "package.json", "README.md", "LICENSE", "CHANGELOG.md"]) {
411
- cpSync(join(plan.sourceRoot, name), join(temporary, name), { recursive: true, dereference: false });
412
- }
413
- preserveRuntimeDependencies(plan.sourceRoot, temporary);
414
- if (packageDigest(temporary, { ownDependencies: true }) !== plan.sourceDigest) throw new Error("source changed while being preserved");
415
- renameSync(temporary, dest);
416
- } finally { rmSync(temporary, { recursive: true, force: true }); }
417
- return dest;
435
+ const destination = join(base, `source-${plan.sourceDigest}`);
436
+ return recoveryScope(plan) === undefined ? preserveRuntimeSource(plan.sourceRoot, destination, plan.sourceDigest)
437
+ : preserveRuntimePackage(plan.sourceRoot, destination, plan.sourceDigest).root;
418
438
  }
419
439
 
420
440
  const unboundRecoveryInspection = inspectRecovery;
@@ -455,8 +475,7 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
455
475
  };
456
476
  const control = async (project: Project, op: string, id: string, instance: string, targetFeatures?: readonly string[]) => {
457
477
  await recoveryCurrent();
458
- const sourceProtocol = readControl(project.stateDir)?.protocol;
459
- const protocol = sourceProtocol !== undefined && RECOVERY_SOURCE_PROTOCOLS.includes(sourceProtocol as (typeof RECOVERY_SOURCE_PROTOCOLS)[number]) ? sourceProtocol : PROTOCOL;
478
+ const protocol = recoveryProtocol(readControl(project.stateDir));
460
479
  return rpc(project, { t: "recovery", op, operationId: id, expectedInstanceId: instance, ...(targetFeatures === undefined ? {} : { targetFeatures }), ...(conductorPeer && authorizedOperation ? { authority: conductorRecoveryBinding(authorizedOperation, project, conductorPeer) } : {}) }, protocol, conductorPeer).then(() => {});
461
480
  };
462
481
  const nativeFailure = (planned: PlannedProject, peer: string, reason: RecoveryNativeCause["reason"], message: string,
@@ -677,8 +696,20 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
677
696
  recordedSessionState: async (binding, kind) => nativeRecordedSessionState(binding, kind),
678
697
  stage: async (op) => {
679
698
  if (packageDigest(op.sourceRoot, { ownDependencies: true }) !== op.plan.sourceDigest) throw new FinalRefusal("preserved source changed");
680
- const target = op.plan.kind === "restart" ? { root: op.sourceRoot, digest: verifyPackage(op.sourceRoot, op.plan.version, { ownDependencies: true }) }
681
- : await stageRelease(op.plan.version, op.plan.integrity!, run);
699
+ const scoped = recoveryScope(op.plan) !== undefined;
700
+ let target: { root: string; digest: string };
701
+ if (scoped && op.plan.kind === "upgrade" && (op.targetRoot !== undefined || op.targetDigest !== undefined)) {
702
+ if (!op.targetRoot || !op.targetDigest || !/^[a-f0-9]{64}$/.test(op.targetDigest)
703
+ || realPath(op.targetRoot) !== realPath(join(hubHome(), "releases", op.plan.version, `source-${op.targetDigest}`)))
704
+ throw new FinalRefusal("retained project target path changed; runtimes unchanged");
705
+ const digest = verifyPackage(op.targetRoot, op.plan.version, { ownDependencies: true });
706
+ if (digest !== op.targetDigest) throw new FinalRefusal("retained project target changed; runtimes unchanged");
707
+ target = { root: op.targetRoot, digest };
708
+ } else {
709
+ const staged = op.plan.kind === "restart" ? { root: op.sourceRoot, digest: verifyPackage(op.sourceRoot, op.plan.version, { ownDependencies: true }) }
710
+ : await stageRelease(op.plan.version, op.plan.integrity!, run, hubHome(), scoped);
711
+ target = scoped && op.plan.kind === "upgrade" ? stageCurrentRuntime(staged.root, op.plan.version) : staged;
712
+ }
682
713
  const identityPath = join(target.root, "src/cli/identity.ts");
683
714
  if (liveTestPeer && (!existsSync(identityPath) || !readFileSync(identityPath, "utf8").includes("export const LIVE_TEST_SUPPORTED = true;"))) {
684
715
  throw new FinalRefusal("target does not support live-test delegation; choose a supporting target before retrying; runtimes unchanged");
@@ -687,8 +718,12 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
687
718
  const cached = op.targetDigest === target.digest && op.targetRoot === target.root;
688
719
  // Once every source committed, compatibility cannot be re-applied using mutable project declarations.
689
720
  // Package identity/hash and live-test authority above still bind these retained bytes.
690
- if (cached && op.projects?.every(project => ["stopped", "started", "peers-restored", "verified"].includes(project.phase))) {
691
- return { ...target, ...(op.targetFeatures === undefined ? {} : { targetFeatures: [...op.targetFeatures] }) };
721
+ const committed = op.projects?.every(project => ["stopped", "started", "peers-restored", "verified"].includes(project.phase));
722
+ // A legacy coordinator already chose and started this target. Resume its retained evidence without retroactive staging gates.
723
+ if (cached && committed && op.schema !== 3) return { ...target, ...(op.targetFeatures === undefined ? {} : { targetFeatures: [...op.targetFeatures] }) };
724
+ if (cached && committed && op.targetFeatures !== undefined) {
725
+ if (!op.targetFeatures.includes("schema_guard")) throw new FinalRefusal("target lacks schema_guard; choose a supporting target before recovery effects");
726
+ return { ...target, targetFeatures: [...op.targetFeatures] };
692
727
  }
693
728
  if (cached && op.targetFeatures !== undefined) {
694
729
  for (const planned of op.plan.projects) {
@@ -698,25 +733,32 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
698
733
  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");
699
734
  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");
700
735
  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");
736
+ if (!op.targetFeatures.includes("schema_guard")) throw new FinalRefusal("target lacks schema_guard; choose a supporting target before recovery effects");
701
737
  return { ...target, targetFeatures: [...op.targetFeatures] };
702
738
  }
703
739
  // A prepared commitSent receipt remains uncertain and must still obtain proof before a possible commit.
704
740
  // Legacy uncommitted receipts acquire verified feature evidence before source compatibility checks.
705
- const probe = await run([process.execPath, "-e", `import * as target from ${JSON.stringify(join(target.root, "src/hub/control-client.ts"))}; console.log(JSON.stringify({protocol: target.PROTOCOL, features: target.DAEMON_FEATURES ?? []}))`]);
706
- let metadata: { protocol?: unknown; features?: unknown } | undefined;
741
+ const probe = await run([process.execPath, "-e", `import * as target from ${JSON.stringify(join(target.root, "src/hub/control-client.ts"))}; console.log(JSON.stringify({protocol: target.PROTOCOL, protocolMin: target.PROTOCOL_MIN, features: target.DAEMON_FEATURES ?? []}))`]);
742
+ let metadata: { protocol?: unknown; protocolMin?: unknown; features?: unknown } | undefined;
707
743
  try { if (probe.code === 0) metadata = JSON.parse(probe.stdout); } catch { /* Unknown metadata refuses before effects. */ }
708
744
  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`);
709
745
  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");
710
746
  const targetFeatures: readonly string[] = [...metadata.features];
747
+ const targetWindow = protocolWindow(metadata.protocol, metadata.protocolMin);
748
+ if (!targetWindow || negotiateProtocol(protocolWindow(PROTOCOL, PROTOCOL_MIN)!, targetWindow) === undefined) throw new FinalRefusal("target protocol requires a compatible coordinator; staged package retained, runtimes unchanged");
749
+ const verified = { ...target, targetFeatures };
750
+ if (cached && committed) {
751
+ if (!targetFeatures.includes("schema_guard")) throw new FinalRefusal("target lacks schema_guard; choose a supporting target before recovery effects");
752
+ return verified;
753
+ }
711
754
  for (const planned of op.plan.projects) {
712
755
  const refusal = typeof planned.project?.root === "string" ? instanceRecoveryTargetRefusal(planned.project.root, targetFeatures, planned.source.peers) : undefined;
713
756
  if (refusal) throw new FinalRefusal(refusal);
714
757
  }
715
758
  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");
716
759
  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");
717
- if (metadata.protocol !== PROTOCOL) throw new FinalRefusal("target protocol requires a newer coordinator; staged package retained, runtimes unchanged");
718
760
  if (!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");
719
- const verified = { ...target, targetFeatures };
761
+ if (!targetFeatures.includes("schema_guard")) throw new FinalRefusal("target lacks schema_guard; choose a supporting target before recovery effects");
720
762
  // Existing peer/waiver compatibility checks remain properties of the already verified bytes.
721
763
  if (op.targetDigest && op.targetDigest === target.digest && op.targetRoot === target.root) return verified;
722
764
  for (const adapter of ADAPTERS) {
@@ -728,6 +770,13 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
728
770
  if (op.plan.projects.some((p) => p.reconnectOnly?.length || p.freshStart?.length) && !targetReadsWaivers({ targetRoot: target.root })) {
729
771
  throw new FinalRefusal(`target ${op.plan.version} predates recovery waivers, so its hub could never accept a reconnect-only Claude or a fresh Codex start; staged package retained, runtimes unchanged; choose a newer target once this operation is cancelled or ended`);
730
772
  }
773
+ for (const planned of op.plan.projects) for (const id of planned.reconnectOnly ?? []) {
774
+ const peer = planned.source.peers.find(peer => peer.id === id);
775
+ const sourceWindow = planned.source.protocolMin === undefined || planned.source.protocolMax === undefined ? undefined : controlProtocolWindow(planned.source);
776
+ const pluginProtocol = peer?.controlProtocol ?? (sourceWindow?.v === sourceWindow?.vMin ? sourceWindow?.v : undefined);
777
+ const pluginWindow = protocolWindow(pluginProtocol);
778
+ if (!pluginWindow || negotiateProtocol(targetWindow, pluginWindow) === undefined) throw new FinalRefusal(`${id}: unmanaged plugin protocol cannot reconnect to the staged target; runtimes unchanged`);
779
+ }
731
780
  return verified;
732
781
  },
733
782
  prepare: (p, id, instance, features) => control(p, "prepare", id, instance, features),
@@ -802,7 +851,7 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
802
851
  if (current && current.state !== "offline") continue;
803
852
  const adapter = projectDefinition(peer.id, planned.project.root)!;
804
853
  if (conductorPeer) {
805
- await rpc(planned.project, { t: "recovery_restore", peer: peer.id, authority: conductorRecoveryBinding(op, planned.project, conductorPeer) }, PROTOCOL, conductorPeer);
854
+ await rpc(planned.project, { t: "recovery_restore", peer: peer.id, authority: conductorRecoveryBinding(op, planned.project, conductorPeer) }, live.protocol, conductorPeer);
806
855
  continue;
807
856
  }
808
857
  const args = [adapter.cli.command, ...(peer.id !== adapter.kind ? ["--as", peer.id] : [])];
@@ -1064,8 +1113,7 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
1064
1113
  // #215: a target an older coordinator started speaks its own protocol, which the lifecycle stop's inspection
1065
1114
  // refuses. Ask it to stop at that protocol, fenced by its instance (the daemon refuses another), then wait as
1066
1115
  // the lifecycle stop does until its manifest and registry claim are gone.
1067
- const sourceProtocol = readControl(project.stateDir)?.protocol;
1068
- const protocol = sourceProtocol !== undefined && RECOVERY_SOURCE_PROTOCOLS.includes(sourceProtocol as (typeof RECOVERY_SOURCE_PROTOCOLS)[number]) ? sourceProtocol : PROTOCOL;
1116
+ const protocol = recoveryProtocol(readControl(project.stateDir));
1069
1117
  await rpc(project, { t: "kill", instanceId: instance }, protocol);
1070
1118
  await awaitStopped(project, instance);
1071
1119
  }
@@ -1089,7 +1137,7 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
1089
1137
  } catch { return { continuity: "unavailable" }; } // optional metadata must not wedge stop-and-archive
1090
1138
  }
1091
1139
  },
1092
- refreshManager: async (op) => { if (conductorPeer && op.plan.kind === "restart") return; await refreshManager({ home: hubHome(), cli: join(op.targetRoot!, "src/cli/main.ts") }); },
1140
+ refreshManager: async (op) => { if (recoveryScope(op.plan) !== undefined || conductorPeer && op.plan.kind === "restart") return; await refreshManager({ home: hubHome(), cli: join(op.targetRoot!, "src/cli/main.ts") }); },
1093
1141
  verify: async (planned, progress, op, save) => {
1094
1142
  const live = await inspectRecovery(planned.project);
1095
1143
  if (live.instanceId !== progress.instanceId || live.version !== op.plan.version || live.recovery?.operationId !== op.id) throw new Error("target identity verification failed");