@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
package/src/cli/main.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  #!/usr/bin/env bun
2
- import { conductorOperation, OPERATIONAL_PERSON_GUIDANCE } from "../hub/operational-authority.ts";
2
+ import { assertConductorOperationLocal, authorizeConductorOperation, authorizeConductorLaunch, issueConductorOperation, preflightConductorRecovery, retireConductorOperation } from "./conductor-recovery.ts";
3
+ import { conductorLocalOperation, conductorOperation, OPERATIONAL_PERSON_GUIDANCE } from "../hub/operational-authority.ts";
4
+ import { sayRecipients } from "./say-input.ts";
3
5
  import type { DeliverableRow } from "../hub/tasks.ts";
4
6
  import { kimiSourceRow } from "../hub/budget-notes.ts";
5
7
  import { hubGitSync, hubGitEnv } from "../hub/git.ts";
@@ -20,12 +22,16 @@ import { supportsBoardReleaseFilter, supportsBoardColumnMetadata } from "./board
20
22
  import { matchesPolicy } from "../hub/path-policy.ts";
21
23
  import { projectedDiff } from "../hub/projected-diff.ts";
22
24
  import { TextProjections } from "../hub/text-projection.ts";
25
+ import { fetchSourceUrl, MAX_SOURCE_BYTES, publicSourceView, screenRegistration, SourceRegistry, validSourceId, type SourceMeta } from "../hub/sources.ts";
26
+ import { renderSourceAdd, renderSourceList } from "./source-output.ts";
27
+ import { renderScheduleChange, renderScheduleList, renderScheduleRun } from "./schedule-output.ts";
28
+ import { renderIntake, renderIntakeChange } from "./intake-output.ts";
23
29
  import { terminalText } from "./console-state.ts";
24
30
  import { parseSettingsEditArgs, runSettingsEdit, SETTINGS_EDIT_USAGE } from "./settings-edit.ts";
25
31
  import { petView, readPet, renderPet } from "./pet.ts";
26
32
  import { startPetWindow, closePetWindow } from "../pet/window.ts";
27
33
  import { loadPetConfig, savePetPalette } from "./pet-config.ts";
28
- import { profileName, projectProfile, resolveProfile } from "../hub/profiles.ts";
34
+ import { projectProfile, resolveProfile } from "../hub/profiles.ts";
29
35
  import { catalogAssignments, loadRoleCatalog, selectedRoles } from "../hub/roles.ts";
30
36
  import { attachWatch } from "./watch.ts";
31
37
  import { verifiedJcodeCommand } from "../adapters/jcode.ts";
@@ -34,9 +40,9 @@ import { qwenSessionId } from "../hub/crash.ts";
34
40
  import { isPermissionMode } from "../hub/permission-mode.ts";
35
41
  import { parseSettingText, settingDef, settingText, type SettingRow } from "../hub/settings.ts";
36
42
  import { startModeOf } from "../hub/start-mode.ts";
37
- import { OVERLAY_FILE, parseOverlay, parseRouting, routingText, type Routing, type RoutingOverlay } from "../hub/routing.ts";
43
+ import { currentRouting, OVERLAY_FILE, parseOverlay, parseRouting, routingText, type Routing, type RoutingOverlay } from "../hub/routing.ts";
38
44
  import { spawn, spawnSync } from "node:child_process";
39
- import { chmodSync, existsSync, readFileSync, renameSync, writeFileSync } from "node:fs";
45
+ import { chmodSync, closeSync, constants, existsSync, fstatSync, openSync, readFileSync, readSync, renameSync, writeFileSync } from "node:fs";
40
46
  import { randomUUID } from "node:crypto";
41
47
  import { homedir } from "node:os";
42
48
  import { basename, dirname, join, relative, resolve } from "node:path";
@@ -59,8 +65,8 @@ import { VERSION } from "../version.ts";
59
65
  import { freeText } from "./free-text.ts";
60
66
  import { createInterface } from "node:readline/promises";
61
67
  import { createInterface as lineReader } from "node:readline";
62
- import { activeOperation, assertLifecycleAvailable, operationIdPattern, readOperation, recoveryLock, recoveryRunner, signedRunner, stopSignedRunner, supportsAction } from "../hub/recovery-store.ts";
63
- import { childEnv } from "../hub/child-process.ts";
68
+ import { activeOperation, assertLifecycleAvailable, operationIdPattern, readOperation, writeOperation, recoveryLock, recoveryRunner, signedRunner, stopSignedRunner, supportsAction } from "../hub/recovery-store.ts";
69
+ import { childEnv, personEnv } from "../hub/child-process.ts";
64
70
  import { abortRecovery, createOperation, disposeRecovery, recordRecoveryDelegation, repairedFreshSession, reattachRecovery, liveProjects, nextChoices, nextActionsText, publicOperation, recoveryCommand, registeredProjects, runRecovery, type RecoveryOperation, type Inspection } from "./upgrade.ts";
65
71
  import { endPlannedPeer, makeRecoveryDriver, makeUpgradePlan, preserveSource } from "./upgrade-runtime.ts";
66
72
  import { exactVersion, latestRelease, newerVersion } from "./recovery-package.ts";
@@ -93,10 +99,10 @@ import { agentShellCause, classifyPeerCommand, cliCommandLabel, detectCliIdentit
93
99
  import { recordCliAudit, recordDelegatedCliAudit } from "./identity-audit.ts";
94
100
  import { runConsole } from "./console.ts";
95
101
  import { paint, permissionBoundary, resolveColor, wrap, type Span } from "./console-state.ts";
96
- import { outputWidth, renderRoster, renderStatus, renderBoard, renderBacklog, renderBoardColumns, boardColumnModel, renderBudget, renderDoctor, renderProjects, renderQueue, renderTurns, renderOrphans, renderQueueShow, renderModelsStatus, renderExecutionBudgetStatus, renderReport, renderUsage, renderLimits, renderTotal, type DoctorCheck } from "./output.ts";
102
+ import { outputWidth, renderRoster, renderStatus, renderBoard, renderBacklog, renderBoardColumns, boardColumnModel, renderBudget, renderDoctor, renderProjects, renderQueue, renderTurns, renderOrphans, renderOrphanCleanup, renderQueueShow, renderModelsStatus, renderExecutionBudgetStatus, renderReport, renderUsage, renderLimits, renderTotal, type DoctorCheck } from "./output.ts";
97
103
  import { renderHelp, configuredAcpHelp } from "./help.ts";
98
104
  import { renderBacklogResult, renderGroom, renderReleaseChange, renderReleaseList, renderReleaseNew, renderReleaseShow, renderTaskCancel, renderTaskShow } from "./task-output.ts";
99
- import { renderAuthorization, renderSdlcCosts, renderSdlcExplain, renderSdlcMetrics, renderSdlcQueue, renderSdlcRelease, renderSdlcStatus } from "./sdlc-output.ts";
105
+ import { renderAuthorization, renderSdlcCosts, renderSdlcExplain, renderSdlcMerge, renderSdlcMetrics, renderSdlcQueue, renderSdlcRelease, renderSdlcStatus } from "./sdlc-output.ts";
100
106
  import { renderTailEvent } from "./tail-render.ts";
101
107
  import { archiveProblem, archiveState, damagedState, failureText, MANIFEST, planReset, resetLines, resetRuntime, startState, type ResetPlan } from "./reset.ts";
102
108
 
@@ -160,7 +166,7 @@ function localDeclarations(): { scope: Pick<InstanceRoleConfig, "instances" | "c
160
166
  catch (error) { return { scope: { catalog: createProjectAdapterCatalog() }, error: startupDiagnostic(error) }; }
161
167
  }
162
168
  const projectCatalog = peerConfig.catalog;
163
- const configuredCommand = !definitionOf(given) && projectCatalog.definitionOf(given)?.permission.genericAcp !== undefined;
169
+ const configuredCommand = !definitionOf(given) && (projectCatalog.definitionOf(given)?.permission.genericAcp !== undefined || peerConfig.declaredExternalInstances?.some(row => row.kind === given) === true);
164
170
  const unboundCaller = detectCliIdentity(process.env);
165
171
  const markerNeedsDeclaration = unboundCaller.role === "invalid" && unboundCaller.reason === "AGENTHUB_PEER_ID conflicts with native agent marker"
166
172
  && process.env.AGENTHUB_PEER_ID !== undefined && !definitionOf(process.env.AGENTHUB_PEER_ID);
@@ -170,7 +176,7 @@ let nativeSelection: ReturnType<typeof instanceLaunchArguments>;
170
176
  try {
171
177
  declaredInstances = needsInstances || configuredCommand ? peerConfig.instances : undefined;
172
178
  if (configuredCommand && !(rest.length === 1 && ["--help", "-h"].includes(rest[0]!))) {
173
- const selection = parseConfiguredAcpArguments(given, rest, projectCatalog, declaredInstances);
179
+ const selection = parseConfiguredAcpArguments(given, rest, projectCatalog, declaredInstances, peerConfig.declaredExternalInstances);
174
180
  nativeSelection = { ...(selection.peer !== given ? { instance: selection.peer } : {}), args: [...rest] };
175
181
  } else nativeSelection = instanceLaunchArguments(given, rest, declaredInstances);
176
182
  } catch (error) { fail(startupDiagnostic(error)); }
@@ -207,11 +213,30 @@ const stateDir = selected.stateDir;
207
213
  var operationalAuditReady=false, operationalRefusalRecorded=false;
208
214
  const caller = declaredInstances ? detectCliIdentity(process.env, declaredInstances) : unboundCaller;
209
215
  const commandLabel = cliCommandLabel(cmd, args, projectCatalog);
210
- const commandAccess = classifyPeerCommand(cmd, args, projectCatalog);
216
+ let commandAccess = classifyPeerCommand(cmd, args, projectCatalog);
217
+ const continuationPeer = process.env.AGENTHUB_RECOVERY_CONDUCTOR;
218
+ const continuationId = process.env.AGENTHUB_RECOVERY_OPERATION;
219
+ let recoveryLauncher = false, recoveryRunnerContinuation = false;
220
+ let recoveryLauncherCurrent: (() => void) | undefined;
221
+ if (continuationPeer !== undefined) {
222
+ if (caller.role !== "tools" || caller.peer !== continuationPeer || !continuationId || !operationIdPattern.test(continuationId) || liveTest) fail("conductor recovery authority unavailable; the person runs it in ahub console or a terminal");
223
+ let operation: RecoveryOperation;
224
+ try { operation = readOperation<RecoveryOperation>(continuationId); }
225
+ catch { fail("conductor recovery authority unavailable; the person runs it in ahub console or a terminal"); }
226
+ if (!operation || operation.id !== continuationId || typeof operation.sourceRoot !== "string") fail("conductor recovery authority unavailable; the person runs it in ahub console or a terminal");
227
+ if (cmd === "recovery-run" && args.length === 1 && args[0] === continuationId) {
228
+ await authorizeConductorOperation(operation, continuationPeer);
229
+ if (realPath(operation.sourceRoot) !== realPath(resolve(import.meta.dir, "../.."))) fail("conductor recovery authority unavailable; the person runs it in ahub console or a terminal");
230
+ recoveryRunnerContinuation = true; commandAccess = "conductor";
231
+ } else if (process.env.AGENTHUB_RECOVERY_CONTINUATION === continuationId) {
232
+ await authorizeConductorLaunch(operation, continuationPeer, cwd, resolve(import.meta.dir, "../.."), cmd, args); recoveryLauncher = true; recoveryLauncherCurrent = () => assertConductorOperationLocal(operation, continuationPeer);
233
+ } else fail("conductor recovery authority unavailable; the person runs it in ahub console or a terminal");
234
+ }
235
+ const conductorPeer = !liveTest && caller.role === "tools" && (recoveryRunnerContinuation || ["restart", "upgrade", "recovery"].includes(cmd) && commandAccess === "conductor") ? caller.peer : undefined;
211
236
  // Agent-authored content keeps its peer. Operational starts/holds may borrow console authority for live verification.
212
237
  const delegationRefused = liveTest && caller.role === "tools" && commandAccess !== "allowed" && liveTestRefusal(cmd, args);
213
238
  const delegatedPeer = liveTest && caller.role === "tools" && commandAccess !== "allowed" && !delegationRefused ? caller.peer : undefined;
214
- const identity = delegatedPeer ? { role: "console" as const } : caller;
239
+ const identity = delegatedPeer || recoveryLauncher ? { role: "console" as const } : caller;
215
240
  operationalAuditReady=identity.role==="tools" && commandAccess==="conductor" && conductorOperation(cmd,args)!==undefined;
216
241
  function audit(outcome: "run" | "refused" | "invalid"): void {
217
242
  if (caller.role === "console") return;
@@ -576,13 +601,13 @@ function printRecoveryStatus(op: RecoveryOperation, message: string, presentatio
576
601
  }
577
602
  }
578
603
  async function recoveryEndInspection(op: RecoveryOperation): Promise<Record<string, Inspection | undefined>> {
579
- const driver = makeRecoveryDriver(undefined, delegatedPeer);
604
+ const driver = makeRecoveryDriver(undefined, delegatedPeer, undefined, conductorPeer);
580
605
  return Object.fromEntries(await Promise.all(op.plan.projects.map(async planned => [planned.project.id, await driver.inspect(planned.project).catch(() => undefined)] as const)));
581
606
  }
582
607
 
583
608
  function spawnRecovery(operation: RecoveryOperation, quiet = false, presentation?: RecoveryPresentationOptions): void {
584
609
  const child = spawn(process.execPath, [join(operation.sourceRoot, "src/cli/main.js"), ...liveTestArgs, "recovery-run", operation.id], {
585
- cwd, detached: true, stdio: "ignore", env: { ...process.env, AGENTHUB_RECOVERY_OPERATION: operation.id },
610
+ cwd, detached: true, stdio: "ignore", env: { ...process.env, AGENTHUB_RECOVERY_OPERATION: operation.id, ...(conductorPeer ? { AGENTHUB_PEER_ID: conductorPeer, AGENTHUB_RECOVERY_CONDUCTOR: conductorPeer } : {}) },
586
611
  });
587
612
  child.on("error", () => console.error(`runner launch failed; use ${recoveryCommand(operation, "resume")}`));
588
613
  child.unref();
@@ -657,7 +682,7 @@ function upgradeHost(kind: "restart" | "upgrade", version: string): UpgradeHost
657
682
  lock: recoveryLock,
658
683
  read: (id) => readOperation<RecoveryOperation>(id),
659
684
  runner: recoveryRunner,
660
- live: (op) => liveProjects(op, makeRecoveryDriver(undefined, delegatedPeer).inspect),
685
+ live: (op) => liveProjects(op, makeRecoveryDriver(undefined, delegatedPeer, undefined, conductorPeer).inspect),
661
686
  endInspection: recoveryEndInspection,
662
687
  endPeer: (planned, peer) => endPlannedPeer(planned, peer),
663
688
  runnerStoppable: (id) => signedRunner(id) !== undefined,
@@ -684,6 +709,7 @@ async function upgrade(kind: "restart" | "upgrade"): Promise<void> {
684
709
  // Before the value reaches a package spec or the registry: a range such as 0.13 would resolve to whatever matches.
685
710
  if (to !== undefined) { try { exactVersion(to); } catch (error) { fail((error as Error).message); } }
686
711
  // An older CLI must not coordinate a newer target (docs/operations.md): the target's own coordinator takes over.
712
+ if (conductorPeer && kind === "upgrade" && newerVersion(to!, VERSION)) fail("a supporting target coordinator is required; the person runs it in ahub console or a terminal");
687
713
  if (kind === "upgrade" && newerVersion(to!, VERSION) && process.env.AGENTHUB_UPGRADE_HANDOVER !== "1") {
688
714
  const argv = ["x", "--package", `@staix/agent-hub@${to}`, "ahub", ...liveTestArgs, "upgrade", "--to", to!, ...rest, ...display.flags, ...(dry && !rest.includes("--dry-run") ? ["--dry-run"] : [])];
689
715
  console.error(`ahub: ${to} is newer than this CLI (${VERSION}), so its own coordinator runs the upgrade: bun ${argv.join(" ")}`);
@@ -703,7 +729,8 @@ async function upgrade(kind: "restart" | "upgrade"): Promise<void> {
703
729
  }
704
730
  if (kind === "upgrade") console.error("ahub: Kimi now defaults to its own TUI (pull-only) in projects without peers.kimi.start_mode. To keep ACP: ahub settings set peers.kimi.start_mode headless");
705
731
  if (interactive) return onTerminal((io) => planScreen(upgradeHost(kind, to ?? VERSION), io), display.options);
706
- const plan = await makeUpgradePlan(kind, to ?? VERSION, kind === "restart" ? cwd : undefined);
732
+ const plan = await makeUpgradePlan(kind, to ?? VERSION, kind === "restart" ? cwd : undefined, undefined, conductorPeer);
733
+ if (conductorPeer) await preflightConductorRecovery(plan, conductorPeer);
707
734
  const jsonApply = display.json && yes && !dry;
708
735
  // #814: a person reading a pipe or a dry run gets the plan screen's rows, with every blocker, reconnect-only session and
709
736
  // fresh start in them. `--json` keeps the document and the stderr lines (#206) exactly as they were.
@@ -715,7 +742,7 @@ async function upgrade(kind: "restart" | "upgrade"): Promise<void> {
715
742
  const blockers = [...plan.blockers, ...plan.projects.flatMap((p) => p.blockers.map((b) => `${p.project.id}: ${b}`))];
716
743
  if (display.json) for (const blocker of blockers) console.error(`ahub: blocker: ${blocker}`);
717
744
  if (dry) return;
718
- if (blockers.length) { if (jsonApply) console.log(JSON.stringify(plan, null, 2)); fail("plan has blockers; no runtime was changed"); }
745
+ if (blockers.length) { if (jsonApply) console.log(JSON.stringify(plan, null, 2)); fail(conductorPeer ? "conductor recovery cannot prove this plan; no runtime was changed; the person runs it in ahub console or a terminal" : "plan has blockers; no runtime was changed"); }
719
746
  assertLifecycleAvailable();
720
747
  if (!yes) {
721
748
  if (!process.stdin.isTTY) fail("review --dry-run and use --yes in non-interactive sessions");
@@ -724,10 +751,21 @@ async function upgrade(kind: "restart" | "upgrade"): Promise<void> {
724
751
  if (!/^(y|yes)$/i.test((await rl.question("Apply this project list and restore its sessions? [y/N] ")).trim())) return;
725
752
  } finally { rl.close(); }
726
753
  }
727
- const current = await makeUpgradePlan(kind, plan.version, kind === "restart" ? cwd : undefined);
754
+ const current = await makeUpgradePlan(kind, plan.version, kind === "restart" ? cwd : undefined, undefined, conductorPeer);
728
755
  if (current.fingerprint !== plan.fingerprint) fail("plan changed during review; run the command again");
756
+ const routingDefaults = conductorPeer ? await preflightConductorRecovery(plan, conductorPeer) : undefined;
729
757
  const sourceRoot = preserveSource(plan);
730
- const operation = createOperation(plan, sourceRoot, undefined, delegatedPeer ? { peer: delegatedPeer, command: kind } : undefined);
758
+ let operation: RecoveryOperation;
759
+ if (conductorPeer) {
760
+ const id = randomUUID();
761
+ const pending: RecoveryOperation = { schema: 1, id, plan, sourceRoot, createdAt: Date.now(), updatedAt: Date.now(), phase: "pending", step: "stage", ...(routingDefaults ? { conductorRoutingDefaults: routingDefaults } : {}), projects: plan.projects.map(row => ({ id: row.project.id, phase: "pending", terminals: {} })) };
762
+ const target = await makeRecoveryDriver().stage(pending);
763
+ pending.targetRoot = target.root; pending.targetDigest = target.digest; pending.targetFeatures = target.targetFeatures;
764
+ await issueConductorOperation(pending, conductorPeer);
765
+ operation = createOperation(plan, sourceRoot, undefined, { peer: conductorPeer, command: kind }, id);
766
+ operation.targetRoot = target.root; operation.targetDigest = target.digest; operation.targetFeatures = target.targetFeatures; operation.conductorRoutingDefaults = routingDefaults;
767
+ writeOperation(id, operation);
768
+ } else operation = createOperation(plan, sourceRoot, undefined, delegatedPeer ? { peer: delegatedPeer, command: kind } : undefined);
731
769
  if (display.json) { spawnRecovery(operation, true); console.log(JSON.stringify(publicOperation(operation), null, 2)); }
732
770
  else spawnRecovery(operation, false, humanRecovery(false) ? display.options : undefined);
733
771
  }
@@ -741,6 +779,40 @@ function parseRecoveryReattach(argv: readonly string[]): { id: string; peer: Ter
741
779
  return { id: argv[1]!, peer: argv[2] as TerminalPeer };
742
780
  }
743
781
 
782
+ /** #248: `ahub bench run` arguments, checked before anything runs (#933: also before the conductor's admission writes its audit row). */
783
+ function benchRunArgs(rest: string[]) {
784
+ const { one, many, rest: positional } = takeFlags(rest, ["--arm", "--repeat", "--tasks", "--memory"], ["--addon"]);
785
+ if (one["--memory"] !== undefined && !(MEMORY_MODES as readonly string[]).includes(one["--memory"])) fail(`--memory takes ${MEMORY_MODES.join(", ")}`);
786
+ // #248: `--addon ripwire=off` declares the arm's add-on state; the run refuses peers that do not match it.
787
+ const addons: Partial<Record<AddonName, "on" | "off">> = {};
788
+ for (const spec of many["--addon"] ?? []) {
789
+ const [name, state] = spec.split("=");
790
+ if (!name || !isAddon(name) || (state !== "on" && state !== "off") || addons[name]) fail(`--addon takes <name>=on|off once per add-on, name one of ${ADDONS.join(", ")}`);
791
+ addons[name] = state;
792
+ }
793
+ const suite = positional[0];
794
+ const repeat = one["--repeat"] === undefined ? 1 : Number(one["--repeat"]);
795
+ if (!suite || positional.length !== 1 || !one["--arm"]?.trim() || !Number.isSafeInteger(repeat) || repeat < 1 || repeat > 100) {
796
+ fail("usage: ahub bench run <suite.json> --arm <label> [--repeat 1..100] [--tasks a,b] [--addon <name>=on|off]... [--memory off|claude-mem|native|both]");
797
+ }
798
+ return { one, addons, suite, repeat };
799
+ }
800
+ /** Another project is `ahub --project <dir> research`; anything unknown is refused rather than ignored. */
801
+ function researchArgs(): void {
802
+ const sub = args[0], flags = new Set(["--all", "--json", "--since", "--format"]), valued = new Set(["--since", "--format"]);
803
+ for (let i = sub === "export" || sub === "backfill" || sub === "addons" ? 1 : 0; i < args.length; i++) {
804
+ if (!flags.has(args[i]!)) fail(`ahub research: unknown argument ${args[i]} (another project: ahub --project <dir> research)`);
805
+ if (valued.has(args[i]!)) i++;
806
+ }
807
+ }
808
+ const SOURCE_USAGE = "usage: ahub source add <path|url> [--json] [--color=auto|always|never] | list [--json] [--color=auto|always|never]";
809
+ /** #933: a local conductor form's argument check, run before the `admit` handshake so a usage error leaves no admitted row. */
810
+ const LOCAL_OPERATION_ARGS: Record<string, () => void> = {
811
+ "bench run": () => { benchRunArgs(args.filter(arg => arg !== "--json").slice(1)); },
812
+ "research backfill": researchArgs,
813
+ "source add": () => { const operands = args.filter(arg => arg !== "--json" && arg !== "--full" && !arg.startsWith("--color=")); if (operands[0] !== "add" || operands.length !== 2 || !operands[1]) fail(SOURCE_USAGE); },
814
+ };
815
+
744
816
  const commands: Record<string, () => Promise<void> | void> = {
745
817
  upgrade: () => upgrade("upgrade"),
746
818
  restart: () => upgrade("restart"),
@@ -778,7 +850,7 @@ const commands: Record<string, () => Promise<void> | void> = {
778
850
  const result = await reattachRecovery(selected.id, selected.peer, makeRecoveryDriver());
779
851
  if (display.json) { console.log(JSON.stringify(publicOperation(result), null, 2)); return; }
780
852
  printRecovery(renderOperation(result, undefined, display.options));
781
- const live = await liveProjects(result, makeRecoveryDriver(undefined, delegatedPeer).inspect);
853
+ const live = await liveProjects(result, makeRecoveryDriver(undefined, delegatedPeer, undefined, conductorPeer).inspect);
782
854
  printRecovery(renderBlocked(result, nextChoices(result, undefined, live), display.options));
783
855
  return;
784
856
  }
@@ -805,15 +877,20 @@ const commands: Record<string, () => Promise<void> | void> = {
805
877
  audit("refused"); fail(LIVE_TEST_PERSON_REFUSAL);
806
878
  }
807
879
  }
880
+ if (conductorPeer) {
881
+ if (operation.id !== id) fail("conductor recovery authority unavailable; the person runs it in ahub console or a terminal");
882
+ await authorizeConductorOperation(operation, conductorPeer);
883
+ }
808
884
  const runner = recoveryRunner(id);
809
885
  if (action === "status") {
810
886
  // #215: abort and resume are offered by what the open sources show, read as the runner's errors read them.
811
- const live = runner ? {} : await liveProjects(operation, makeRecoveryDriver(undefined, delegatedPeer).inspect);
887
+ const live = runner ? {} : await liveProjects(operation, makeRecoveryDriver(undefined, delegatedPeer, undefined, conductorPeer).inspect);
812
888
  if (display.json) console.log(JSON.stringify(publicOperation(operation, runner, live), null, 2));
813
889
  else printRecovery(renderRecoveryStatus(operation, runner, nextChoices(operation, runner, live), display.options));
814
890
  }
815
891
  else if (action === "abort") {
816
- await abortRecovery(id, makeRecoveryDriver(undefined, delegatedPeer));
892
+ await abortRecovery(id, makeRecoveryDriver(undefined, delegatedPeer, undefined, conductorPeer));
893
+ if (conductorPeer) retireConductorOperation(readOperation<RecoveryOperation>(id), conductorPeer);
817
894
  if (display.json) console.log(JSON.stringify(publicOperation(readOperation<RecoveryOperation>(id)), null, 2));
818
895
  else console.log("preflight cancelled; no committed transition was rolled back");
819
896
  }
@@ -823,7 +900,7 @@ const commands: Record<string, () => Promise<void> | void> = {
823
900
  const stop = flags.length === 1 && flags[0] === "--stop-and-archive";
824
901
  const reason = one["--reason"]?.trim() ?? "";
825
902
  if (!reason || reason.length > 500 || (flags.length && !stop) || stop === !!one["--fresh-session"]) fail(usage);
826
- const result = await disposeRecovery(id, stop ? { stop: true } : { fresh: one["--fresh-session"]! }, reason, makeRecoveryDriver(undefined, delegatedPeer));
903
+ const result = await disposeRecovery(id, stop ? { stop: true } : { fresh: one["--fresh-session"]! }, reason, makeRecoveryDriver(undefined, delegatedPeer, undefined, conductorPeer));
827
904
  if (!stop) {
828
905
  if (display.json) { console.log(JSON.stringify(publicOperation(result), null, 2)); return spawnRecovery(result, true); }
829
906
  console.log(`${one["--fresh-session"]}: a fresh session is accepted and the lost one is recorded; resuming`);
@@ -834,7 +911,7 @@ const commands: Record<string, () => Promise<void> | void> = {
834
911
  }
835
912
  else if (["completed", "cancelled"].includes(operation.phase)) console.log(display.json ? JSON.stringify(publicOperation(operation, runner), null, 2) : `recovery is already ${operation.phase}`);
836
913
  // #215: an operation being abandoned is never resumed, whichever coordinator started it (an older one would).
837
- else if (operation.disposition) fail(`a stop-and-archive of this operation is partway; nothing was resumed; ${nextActionsText(operation, await liveProjects(operation, makeRecoveryDriver(undefined, delegatedPeer).inspect))}`);
914
+ else if (operation.disposition) fail(`a stop-and-archive of this operation is partway; nothing was resumed; ${nextActionsText(operation, await liveProjects(operation, makeRecoveryDriver(undefined, delegatedPeer, undefined, conductorPeer).inspect))}`);
838
915
  else if (runner === "unknown") {
839
916
  if (display.json) console.log(JSON.stringify(publicOperation(operation, runner), null, 2));
840
917
  else if (humanRecovery(false)) printRecoveryStatus(operation, "Runner ownership is unknown; nothing was started.", display.options);
@@ -853,8 +930,9 @@ const commands: Record<string, () => Promise<void> | void> = {
853
930
  },
854
931
  "recovery-run": async () => {
855
932
  if (args.length !== 1 || process.env.AGENTHUB_RECOVERY_OPERATION !== args[0]) fail("recovery-run is an internal command");
856
- const result = await runRecovery(args[0]!, makeRecoveryDriver(undefined, delegatedPeer));
933
+ const result = await runRecovery(args[0]!, makeRecoveryDriver(undefined, delegatedPeer, undefined, conductorPeer));
857
934
  if (result.phase !== "completed") process.exitCode = 1;
935
+ else if (conductorPeer) retireConductorOperation(result, conductorPeer);
858
936
  },
859
937
  help: () => {
860
938
  const color = resolveColor(undefined, { isTTY: !!process.stdout.isTTY, TERM: process.env.TERM, NO_COLOR: process.env.NO_COLOR }) === true;
@@ -923,10 +1001,11 @@ const commands: Record<string, () => Promise<void> | void> = {
923
1001
  },
924
1002
 
925
1003
  setup: async () => {
1004
+ recoveryLauncherCurrent?.();
926
1005
  assertLifecycleAvailable();
927
1006
  const root = join(import.meta.dir, "..", "..");
928
1007
  const state = () => {
929
- const out = (argv: string[]) => spawnSync("claude", argv, { encoding: "utf8" }).stdout ?? "";
1008
+ const out = (argv: string[]) => { recoveryLauncherCurrent?.(); const result = spawnSync("claude", argv, { encoding: "utf8", ...(recoveryLauncherCurrent ? { timeout: 30_000 } : {}) }); recoveryLauncherCurrent?.(); return result.stdout ?? ""; };
930
1009
  return nextStep(parseList<InstalledPlugin>(out(["plugin", "list", "--json"])), parseList<Marketplace>(out(["plugin", "marketplace", "list", "--json"])), root);
931
1010
  };
932
1011
  let step = state();
@@ -943,17 +1022,21 @@ const commands: Record<string, () => Promise<void> | void> = {
943
1022
  }
944
1023
  for (let i = 0; step && i < 6; i++, step = state()) {
945
1024
  console.log(`> ${step.argv.join(" ")} (${step.why})`);
946
- if (spawnSync(step.argv[0]!, step.argv.slice(1), { stdio: "inherit" }).status !== 0) fail(`"${step.argv.join(" ")}" failed; nothing after it was run`);
1025
+ recoveryLauncherCurrent?.();
1026
+ if (spawnSync(step.argv[0]!, step.argv.slice(1), { stdio: "inherit", ...(recoveryLauncherCurrent ? { timeout: 30_000 } : {}) }).status !== 0) fail(`"${step.argv.join(" ")}" failed; nothing after it was run`);
1027
+ recoveryLauncherCurrent?.();
947
1028
  }
948
1029
  if (step) fail(`still not done after 6 steps; next would be: ${step.argv.join(" ")}`);
949
1030
  }
950
- await commands.doctor!();
1031
+ if (recoveryLauncherCurrent) recoveryLauncherCurrent();
1032
+ else await commands.doctor!();
951
1033
  },
952
1034
 
953
1035
  init: () => {
954
1036
  const flags = takeFlags(args.filter(arg => arg !== "--dry-run" && arg !== "--json" && arg !== "--shadow" && arg !== "--release-guard"), ["--profile"], []);
955
- if (flags.rest.length) fail("usage: ahub init [--profile dev|research|writing|general] [--shadow] [--release-guard] [--dry-run --json]");
956
- const requestedProfile = flags.one["--profile"] === undefined ? undefined : profileName(flags.one["--profile"]);
1037
+ if (flags.rest.length) fail("usage: ahub init [--profile dev|research|writing|general|<pack>] [--shadow] [--release-guard] [--dry-run --json]");
1038
+ // A pack is a project file in .agenthub/roles/<name>.pack.json (#513a); init resolves the name and refuses an unknown one with the profiles and packs it knows.
1039
+ const requestedProfile = flags.one["--profile"];
957
1040
  if (args.includes("--dry-run")) {
958
1041
  const plan = planInit(cwd, requestedProfile, args.includes("--shadow"), args.includes("--release-guard"));
959
1042
  return console.log(args.includes("--json") ? JSON.stringify(plan, null, 2) : plan.map(change => `${change.action} ${change.path}: ${change.reason}${change.managedBlock ? ` (managed block: ${change.managedBlock})` : ""}`).join("\n") || "already up to date");
@@ -983,8 +1066,9 @@ const commands: Record<string, () => Promise<void> | void> = {
983
1066
  up: async () => {
984
1067
  const unattended = unattendedEnv || args.includes("--unattended");
985
1068
  if (unattended) console.error(UNATTENDED_WARNING);
986
- const status = await startProject(registeredProject(), { unattended, liveTest });
1069
+ const status = await startProject(registeredProject(), { unattended, liveTest, ...(recoveryLauncherCurrent ? { current: recoveryLauncherCurrent, env: { ...personEnv(process.env), AGENTHUB_RECOVERY_OPERATION: continuationId, AGENTHUB_PEER_ID: continuationPeer, AGENTHUB_RECOVERY_CONDUCTOR: continuationPeer, AGENTHUB_RECOVERY_CONTINUATION: continuationId } } : {}) });
987
1070
  console.log(`ahub up (ws://127.0.0.1:${status.controlPort}), state in ${stateDir}`);
1071
+ if (recoveryLauncherCurrent) { recoveryLauncherCurrent(); return; }
988
1072
  try { if (loadPetConfig(cwd).window) console.log(await startPetWindow(stateDir, cwd)); }
989
1073
  catch { console.error("Pet window was not started; run ahub pet --window to inspect it."); }
990
1074
  if (process.stdin.isTTY && process.stdout.isTTY && !args.includes("--no-console")) await runConsole({ client: await connect(), cwd, stateDir, liveTest });
@@ -1048,9 +1132,10 @@ const commands: Record<string, () => Promise<void> | void> = {
1048
1132
  },
1049
1133
 
1050
1134
  say: async () => {
1051
- const lead = args.findIndex((a) => !/^@[a-z][a-z0-9-]*$/.test(a)); // only leading @tokens are recipients
1052
- const to = args.slice(0, lead === -1 ? args.length : lead).map((a) => a.slice(1));
1053
- const body = lead === -1 ? "" : freeText(args.slice(lead), "ahub say [@peer ...] <text>");
1135
+ let addressed: ReturnType<typeof sayRecipients>;
1136
+ try { addressed = sayRecipients(args); } catch (error) { return fail((error as Error).message); }
1137
+ const { to, text } = addressed;
1138
+ const body = text.length ? freeText(text, "ahub say [@peer ...] <text>") : "";
1054
1139
  const hub = await connect();
1055
1140
  const res = await hub.request({ t: "send", body, to });
1056
1141
  hub.close();
@@ -1249,8 +1334,144 @@ const commands: Record<string, () => Promise<void> | void> = {
1249
1334
  return shown(renderReleaseChange)(taskOp("release_change", { name, revision: Number(revision), fields }));
1250
1335
  },
1251
1336
 
1337
+ smoke: async () => {
1338
+ // Log every live-test invocation, including syntax/target refusals; native output never joins the audit.
1339
+ if (liveTest && caller.role === "console") {
1340
+ try { recordCliAudit(stateDir, "user", commandLabel, "run"); }
1341
+ catch { return fail("smoke audit unavailable; nothing was executed"); }
1342
+ }
1343
+ const options = outputOptions();
1344
+ const operands = args.filter(arg => arg !== "--json");
1345
+ const usage = "usage: ahub --live-test smoke run qwen-dual-output --project-id <id> --receipt <file.json> [--timeout-ms <100..120000>] [--json]";
1346
+ const values = new Map<string, string>();
1347
+ if (operands[0] !== "run" || operands[1] !== "qwen-dual-output") return fail(usage);
1348
+ for (let n = 2; n < operands.length; n += 2) {
1349
+ const key = operands[n], value = operands[n + 1];
1350
+ if (!key || !["--project-id", "--receipt", "--timeout-ms"].includes(key) || !value || value.startsWith("--") || values.has(key)) return fail(usage);
1351
+ values.set(key, value);
1352
+ }
1353
+ if (!values.has("--project-id") || !values.has("--receipt")) return fail(usage);
1354
+ const home = process.env.AGENTHUB_HOME;
1355
+ if (!liveTest || !home) return fail("smoke target refused");
1356
+ const { validateSmokeTarget } = await import("./smoke-target.ts");
1357
+ const { runSmoke } = await import("./smoke-run.ts");
1358
+ const project = registeredProjects(home).find(project => project.root === cwd && project.stateDir === stateDir);
1359
+ if (!project) return fail("smoke target refused");
1360
+ // Keep path spelling for the guard: project selection above canonicalizes symlinks for ordinary CLI commands.
1361
+ const smokeCwd = selector && selector !== project.id ? resolve(invokedFrom, selector) : cwd;
1362
+ const checked = validateSmokeTarget({ cwd: smokeCwd, hubHome: home, project, expectedProjectId: values.get("--project-id")!, liveTest });
1363
+ const target = { ...checked, check: () => { checked.check(); assertDelegatedAuditConsumer(); } };
1364
+ const abort = new AbortController();
1365
+ const interrupted = () => abort.abort();
1366
+ process.on("SIGINT", interrupted); process.on("SIGTERM", interrupted);
1367
+ try {
1368
+ const result = await runSmoke(target, { leg: "qwen-dual-output", receipt: values.get("--receipt")!, timeoutMs: Number(values.get("--timeout-ms") ?? "60000"), signal: abort.signal });
1369
+ process.exitCode = result.outcome === "passed" ? 0 : 1;
1370
+ if (options.json) return console.log(JSON.stringify(result));
1371
+ console.log(`smoke ${result.outcome} (${result.reason}): ${result.counts.submits} submits, ${result.counts.stops} stops; cleanup ${result.checks.cleaned ? "confirmed" : "unconfirmed"}`);
1372
+ } finally { process.removeListener("SIGINT", interrupted); process.removeListener("SIGTERM", interrupted); }
1373
+ },
1374
+
1375
+ room: async () => {
1376
+ if (liveTest && !["list", "status"].includes(args[0] ?? "")) fail(LIVE_TEST_PERSON_REFUSAL);
1377
+ const { runRoomCommand, roomCommandError } = await import("./room.ts");
1378
+ const hub = await connect();
1379
+ try { await runRoomCommand(args, message => hub.request(message)); }
1380
+ catch (error) { fail(roomCommandError(error)); }
1381
+ finally { hub.close(); }
1382
+ },
1383
+
1384
+ broker: async () => {
1385
+ const { runBrokerCommand, brokerCommandError } = await import("./broker.ts");
1386
+ try { await runBrokerCommand(args, stateDir); }
1387
+ catch (error) { fail(brokerCommandError(error)); }
1388
+ },
1389
+
1390
+ source: async () => {
1391
+ // #508: the source registry. `add` is the person's or the configured conductor's (#933: admitted and audited by the daemon before this runs) and local:
1392
+ // it reads and writes the hub state dir directly and needs no daemon. A URL is fetched here, once, by this process; no hub tool or agent ever fetches one.
1393
+ const options = outputOptions();
1394
+ const operands = args.filter(arg => arg !== "--json" && arg !== "--full" && !arg.startsWith("--color="));
1395
+ const [sub, ...rest] = operands;
1396
+ const registry = new SourceRegistry(stateDir);
1397
+ if (sub === "list" && rest.length === 0) {
1398
+ const rows = registry.list();
1399
+ if (options.json) return console.log(JSON.stringify(rows, null, 2));
1400
+ return printOutput(renderSourceList(rows, options.columns, Date.now()), options.color);
1401
+ }
1402
+ if (sub !== "add" || rest.length !== 1 || !rest[0]) return fail(SOURCE_USAGE);
1403
+ const target = rest[0], config = projectConfig();
1404
+ const isUrl = /^[a-z][a-z0-9+.-]*:\/\//i.test(target);
1405
+ let bytes: Buffer, origin: string, kind: "file" | "url", absolute: string | undefined;
1406
+ try {
1407
+ if (isUrl) {
1408
+ const fetched = await fetchSourceUrl(target);
1409
+ bytes = fetched.bytes; origin = fetched.finalUrl; kind = "url";
1410
+ } else {
1411
+ absolute = realPath(resolve(cwd, target));
1412
+ const fd = openSync(absolute, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
1413
+ try {
1414
+ const st = fstatSync(fd);
1415
+ if (!st.isFile()) throw new Error("not a regular file");
1416
+ if (st.size > MAX_SOURCE_BYTES) throw new Error(`file is ${st.size} bytes, over the ${MAX_SOURCE_BYTES} byte cap (MAX_PROJECTION_INPUT); nothing was stored`);
1417
+ bytes = Buffer.alloc(st.size); let got = 0;
1418
+ while (got < bytes.length) { const n = readSync(fd, bytes, got, bytes.length - got, got); if (!n) break; got += n; }
1419
+ if (got !== st.size) throw new Error("the file changed while it was read");
1420
+ } finally { closeSync(fd); }
1421
+ origin = absolute; kind = "file";
1422
+ }
1423
+ } catch (error) { return fail(`cannot register ${isUrl ? "that URL" : target}: ${(error as Error).message}`); }
1424
+ const projections = new TextProjections({ cwd, stateDir, config: config.text_projection ?? {}, deny: config.local.deny, readAllow: config.local.read_allow, log: line => console.error(line) });
1425
+ const extension = /\.[A-Za-z0-9]{1,16}$/.exec(isUrl ? new URL(origin).pathname : origin)?.[0] ?? "";
1426
+ const verdict = screenRegistration({ bytes, kind, ...(absolute ? { path: absolute, typedPath: resolve(cwd, target) } : {}) }, {
1427
+ piiPaths: config.pii?.paths ?? [], piiPatterns: currentRouting(cwd).signals.pii_patterns, cwd, matchesPolicy,
1428
+ project: data => extension ? projections.projectBytes(join(cwd, `source${extension.toLowerCase()}`), data)?.text : undefined,
1429
+ });
1430
+ let added;
1431
+ try { added = registry.add({ bytes, kind, origin, private: verdict.private, ...(verdict.text !== undefined ? { text: verdict.text } : {}) }); }
1432
+ catch (error) { return fail(`cannot register ${isUrl ? "that URL" : target}: ${(error as Error).message}`); }
1433
+ const meta = registry.get(added.id);
1434
+ if (!meta) return fail(`the record for ${added.id} cannot be read after it was registered; inspect ${join(stateDir, "sources")}`);
1435
+ const result = { id: added.id, created: added.created, upgraded: added.upgraded, kind: meta.kind, origin: meta.origin, sha256: meta.sha256, bytes: meta.bytes, private: meta.private, text: added.text, reasons: verdict.reasons };
1436
+ if (options.json) return console.log(JSON.stringify(result, null, 2));
1437
+ printOutput(renderSourceAdd(result, options.columns), options.color);
1438
+ },
1439
+
1440
+ schedule: async () => {
1441
+ // #335 (#512 AC3 and AC4): the person's schedule commands; #933: run, pause and resume are the configured conductor's too, admitted and audited by the daemon.
1442
+ // The hub keeps the schedules, their periods and the pause; this process only asks.
1443
+ const split = splitPresentation(args);
1444
+ const present = outputOptions(split.flags); // before any request: an invalid --color must refuse before a mutation is sent
1445
+ const [sub, id, ...extra] = split.operands;
1446
+ const usage = "usage: ahub schedule list | run <id> | pause <id> | resume <id> [--json] [--color=auto|always|never]";
1447
+ if (sub === "list" && id === undefined) return showToolText(data => renderScheduleList(data, present.columns, Date.now()), await taskOp("schedule_list", {}), present);
1448
+ if (!["run", "pause", "resume"].includes(sub ?? "") || !id || extra.length) return fail(usage);
1449
+ if (sub === "run") return showToolText(data => renderScheduleRun(data, present.columns), await taskOp("schedule_run", { id }, false, undefined, 90_000), present);
1450
+ return showToolText(data => renderScheduleChange(sub as "pause" | "resume", data, present.columns, Date.now()), await taskOp(sub === "pause" ? "schedule_pause" : "schedule_resume", { id }), present);
1451
+ },
1452
+
1453
+ intake: async () => {
1454
+ // #334 (#511a): natural-language intake. Person only, except approve (#933: the configured conductor too, admitted and audited by the daemon).
1455
+ // The request is a draft backlog card that nothing offers until `approve`; the hub, not this process, splits it.
1456
+ const split = splitPresentation(args, { valueFlags: ["--source"] });
1457
+ const present = outputOptions(split.flags); // before any request: an invalid --color must refuse before a mutation is sent
1458
+ const usage = "usage: ahub intake \"<text>\" [--source <id>...] | approve <id> | discard <id> [--json] [--color=auto|always|never]";
1459
+ const sources: string[] = [], words: string[] = [];
1460
+ for (let i = 0; i < split.operands.length; i++) {
1461
+ const arg = split.operands[i]!;
1462
+ if (arg === "--source") { const id = split.operands[++i]; if (!id) return fail("--source needs a source id"); sources.push(id); } else words.push(arg);
1463
+ }
1464
+ const [sub, id] = words;
1465
+ if ((sub === "approve" || sub === "discard") && words.length === 2 && !sources.length) {
1466
+ if (!/^[1-9]\d*$/.test(id!)) return fail(usage);
1467
+ return showToolText(data => renderIntakeChange(sub, data, present.columns), await taskOp(sub === "approve" ? "intake_approve" : "intake_discard", { id: Number(id) }), present);
1468
+ }
1469
+ if (words.length !== 1 || !words[0]!.trim()) return fail(usage);
1470
+ return showToolText(data => renderIntake(data, present.columns), await taskOp("intake", { text: words[0], ...(sources.length ? { sources } : {}) }, false, undefined, 90_000), present);
1471
+ },
1472
+
1252
1473
  deliverables: async () => {
1253
- // #317: what each task delivered and what a reviewer approved, by id, hash and size (a path only where the PII screen allows it). Person only: the daemon refuses an agent.
1474
+ // #317: what each task delivered and what a reviewer approved, by id, hash and size (a path only where the PII screen allows it). #933: an agent gets only the rows hub_task_show would show it.
1254
1475
  const options = outputOptions();
1255
1476
  const { one, rest } = takeFlags(args, ["--task"], []);
1256
1477
  const approved = rest.includes("--approved"), check = rest.includes("--check");
@@ -1415,14 +1636,19 @@ const commands: Record<string, () => Promise<void> | void> = {
1415
1636
  }
1416
1637
  if (sub === "merge") {
1417
1638
  // #786 T1: the guarded merge of one approved task; a person or the configured conductor asks, the daemon re-reads every gate.
1418
- const flags = takeFlags(args.slice(2), [], []);
1639
+ // #899: the decision prints for a person; `--json` is the same view as a document. The presentation flags are read before the
1640
+ // request, so an invalid --color refuses before a mutation is sent, and they never reach the hub.
1641
+ const split = splitPresentation(args.slice(1)), display = outputOptions(split.flags);
1642
+ const task = Number(split.operands[0]), flags = takeFlags(split.operands.slice(1), [], []);
1419
1643
  const dryRun = flags.rest.includes("--dry-run");
1420
- if (flags.rest.some(arg => arg !== "--dry-run") || !Number.isSafeInteger(id) || id! <= 0) fail("usage: ahub sdlc merge <task> [--dry-run]");
1644
+ if (flags.rest.some(arg => arg !== "--dry-run") || !Number.isSafeInteger(task) || task <= 0) fail("usage: ahub sdlc merge <task> [--dry-run] [--json]");
1421
1645
  const hub = await connect();
1422
1646
  try {
1423
- const reply = await hub.request({ t: "sdlc", op: "merge", id, ...(dryRun ? { dryRun: true } : {}) }, 60_000);
1647
+ const reply = await hub.request({ t: "sdlc", op: "merge", id: task, ...(dryRun ? { dryRun: true } : {}) }, 60_000);
1424
1648
  if (!reply.ok) fail(reply.error);
1425
- console.log(JSON.stringify({ task: reply.task, mode: reply.mode, ran: reply.ran, decision: reply.decision, ...(reply.argv ? { argv: reply.argv } : {}), ...(reply.refused ? { refused: reply.refused } : {}), ...(reply.result ? { result: reply.result } : {}) }, null, 2));
1649
+ const view = { task: reply.task, mode: reply.mode, ran: reply.ran, decision: reply.decision, ...(reply.argv ? { argv: reply.argv } : {}), ...(reply.refused ? { refused: reply.refused } : {}), ...(reply.result ? { result: reply.result } : {}) };
1650
+ if (display.json) console.log(JSON.stringify(view, null, 2));
1651
+ else printOutput(renderSdlcMerge(view, display.columns, Date.now()), display.color);
1426
1652
  if (reply.decision === "refused" || reply.ran && reply.result?.state !== "merged") process.exitCode = 1;
1427
1653
  } finally { hub.close(); }
1428
1654
  return;
@@ -1481,6 +1707,13 @@ const commands: Record<string, () => Promise<void> | void> = {
1481
1707
  if (rest.length !== 1) fail("usage: ahub task show <id> [--json] [--full] [--color=auto|always|never]");
1482
1708
  return shownTask(renderTaskShow)(taskOp("task_show", { id: rest[0] }));
1483
1709
  }
1710
+ if (sub === "claim") {
1711
+ // #901: a person's verdict on one declared plan claim. The claim is named by its 1-based slot and its text is never typed, stored or printed here.
1712
+ const { one, rest: positional } = takeFlags(rest, ["--model", "--model-said"], []);
1713
+ if (positional.length !== 3 || !/^[1-9]\d*$/.test(positional[0]!) || !/^[1-9]\d*$/.test(positional[1]!) || !["supported", "unsupported", "unclear"].includes(positional[2]!)) fail("usage: ahub task claim <id> <slot> <supported|unsupported|unclear> [--model <label>] [--model-said <supported|unsupported|unclear>]");
1714
+ const reply = JSON.parse(await taskOp("claim_verdict", { id: Number(positional[0]), slot: Number(positional[1]), verdict: positional[2], ...(one["--model"] === undefined ? {} : { model: one["--model"] }), ...(one["--model-said"] === undefined ? {} : { modelVerdict: one["--model-said"] }) })) as { task: number; recorded?: { slot: number; verdict: string; model?: string; modelVerdict?: string } };
1715
+ return console.log(`task #${reply.task}: claim ${reply.recorded?.slot} recorded as ${reply.recorded?.verdict}${reply.recorded?.model ? ` (model ${reply.recorded.model}` : ""}${reply.recorded?.modelVerdict ? `${reply.recorded?.model ? ", " : " ("}model said ${reply.recorded.modelVerdict}` : ""}${reply.recorded?.model || reply.recorded?.modelVerdict ? ")" : ""}`);
1716
+ }
1484
1717
  if (sub === "escalate") return console.log(await taskOp("task_escalate", { id: rest[0] }));
1485
1718
  if (sub === "assign") {
1486
1719
  const { one, rest: positional } = takeFlags(rest, ["--role", "--revision"], []);
@@ -1495,14 +1728,18 @@ const commands: Record<string, () => Promise<void> | void> = {
1495
1728
  return console.log(await taskOp("task_unassign", { id: positional[0], role, ...(revision === undefined ? {} : { revision }) }));
1496
1729
  }
1497
1730
  if (sub === "label") {
1498
- // #247: a person's later verdict on an approved task (the identity gate keeps it human-only), for research records.
1731
+ // #247: a later verdict on an approved task, for research records (#933: an agent's own task only, attributed to it).
1499
1732
  const { one, rest: positional } = takeFlags(rest, ["--created"], []);
1500
1733
  const id = Number(positional[0]), label = positional[1] as Label;
1501
1734
  if (positional.length !== 2 || !/^[1-9]\d*$/.test(positional[0]!) || !Number.isSafeInteger(id) || !(LABELS as readonly string[]).includes(label)) fail(`usage: ahub task label <id> ${LABELS.join("|")} [--created <time from ahub research export>]`);
1735
+ // #933: the daemon checks the current board row only, so an agent never names an earlier task with this id from before a reset.
1736
+ if (identity.role === "tools" && one["--created"] !== undefined) fail("--created names an earlier task with this id; only the person labels it (ahub console or a terminal)");
1502
1737
  const project = researchProject();
1738
+ // #933: an agent labels only its own task, which the daemon checks by role; its record names it, so it never reads as the person's verdict.
1739
+ if (identity.role === "tools") await taskOp("task_label_check", { id });
1503
1740
  let record: TaskRecord;
1504
1741
  try { record = labelTarget(readStores(project.id), readEvents(join(stateDir, "events.jsonl")), id, one["--created"]); } catch (error) { fail((error as Error).message); }
1505
- appendRecords(project.id, [{ schema: RESEARCH_SCHEMA, kind: "label", project: projectKey(project.id), task: id, createdAt: record!.createdAt!, label, at: new Date().toISOString() }]);
1742
+ appendRecords(project.id, [{ schema: RESEARCH_SCHEMA, kind: "label", project: projectKey(project.id), task: id, createdAt: record!.createdAt!, label, at: new Date().toISOString(), ...(identity.role === "tools" ? { by: identity.peer } : {}) }]);
1506
1743
  return console.log(`task #${id} (approved ${record!.approvedAt}) labelled ${label} in the research records`);
1507
1744
  }
1508
1745
  if (sub !== "propose" || rest.length < 1) fail("usage: ahub task propose [<class>] <title...> | show <id> | assign <id> <peer> | unassign <id> | cancel <id> --reason <text> | escalate <id>");
@@ -1831,26 +2068,13 @@ const commands: Record<string, () => Promise<void> | void> = {
1831
2068
  const json = args.includes("--json");
1832
2069
  const [sub, ...rest] = args.filter((a) => a !== "--json");
1833
2070
  if (sub === "run") {
1834
- const { one, many, rest: positional } = takeFlags(rest, ["--arm", "--repeat", "--tasks", "--memory"], ["--addon"]);
1835
- if (one["--memory"] !== undefined && !(MEMORY_MODES as readonly string[]).includes(one["--memory"])) fail(`--memory takes ${MEMORY_MODES.join(", ")}`);
1836
- // #248: `--addon ripwire=off` declares the arm's add-on state; the run refuses peers that do not match it.
1837
- const addons: Partial<Record<AddonName, "on" | "off">> = {};
1838
- for (const spec of many["--addon"] ?? []) {
1839
- const [name, state] = spec.split("=");
1840
- if (!name || !isAddon(name) || (state !== "on" && state !== "off") || addons[name]) fail(`--addon takes <name>=on|off once per add-on, name one of ${ADDONS.join(", ")}`);
1841
- addons[name] = state;
1842
- }
1843
- const suite = positional[0];
1844
- const repeat = one["--repeat"] === undefined ? 1 : Number(one["--repeat"]);
1845
- if (!suite || positional.length !== 1 || !one["--arm"]?.trim() || !Number.isSafeInteger(repeat) || repeat < 1 || repeat > 100) {
1846
- fail("usage: ahub bench run <suite.json> --arm <label> [--repeat 1..100] [--tasks a,b] [--addon <name>=on|off]... [--memory off|claude-mem|native|both]");
1847
- }
2071
+ const { one, addons, suite, repeat } = benchRunArgs(rest);
1848
2072
  await benchPreflight(cwd, loadConfig(cwd).bench.enabled); // the suite's refs are checked once it is read
1849
2073
  let interrupted = false;
1850
2074
  const stop = () => { interrupted = true; console.log("stopping at the next check: the attempt in progress is recorded as interrupted (a task still open is named)"); };
1851
2075
  process.on("SIGINT", stop);
1852
2076
  try {
1853
- const run = await runBench({ cwd, stateDir, suitePath: resolve(invokedFrom, suite!), arm: one["--arm"]!.trim(), repeat, addons, ...(one["--memory"] ? { memory: one["--memory"] as MemoryMode } : {}), only: one["--tasks"]?.split(",").map((t) => t.trim()).filter(Boolean), stopped: () => interrupted }, defaultIo(stateDir, cwd));
2077
+ const run = await runBench({ cwd, stateDir, suitePath: resolve(invokedFrom, suite!), arm: one["--arm"]!.trim(), repeat, addons, ...(one["--memory"] ? { memory: one["--memory"] as MemoryMode } : {}), only: one["--tasks"]?.split(",").map((t) => t.trim()).filter(Boolean), stopped: () => interrupted }, defaultIo(stateDir, cwd, identity.role === "tools" ? identity.peer : undefined));
1854
2078
  return console.log(formatBenchReport(benchReport(readRuns().filter((r) => r.header.run === run))).join("\n"));
1855
2079
  } finally { process.off("SIGINT", stop); }
1856
2080
  }
@@ -1915,12 +2139,7 @@ const commands: Record<string, () => Promise<void> | void> = {
1915
2139
  research: () => {
1916
2140
  // #247: measures from the opt-in research records; `export` writes them out, `backfill` builds them from events.jsonl.
1917
2141
  const [sub] = args;
1918
- // Another project is `ahub --project <dir> research`; anything unknown is refused rather than ignored.
1919
- const flags = new Set(["--all", "--json", "--since", "--format"]), valued = new Set(["--since", "--format"]);
1920
- for (let i = sub === "export" || sub === "backfill" || sub === "addons" ? 1 : 0; i < args.length; i++) {
1921
- if (!flags.has(args[i]!)) fail(`ahub research: unknown argument ${args[i]} (another project: ahub --project <dir> research)`);
1922
- if (valued.has(args[i]!)) i++;
1923
- }
2142
+ researchArgs();
1924
2143
  if (sub === "backfill") {
1925
2144
  const project = researchProject();
1926
2145
  const written = appendRecords(project.id, taskRecords(readEvents(join(stateDir, "events.jsonl")), project.id, { version: VERSION, source: "backfill" }));
@@ -2084,9 +2303,14 @@ const commands: Record<string, () => Promise<void> | void> = {
2084
2303
  if (why.length) fail(`refusing to undo ${id}: ${why.join("\n")}${reverted ? "\nCodex's conversation was already reverted; restore the files later with ahub undo without --context" : ""}`);
2085
2304
  return p;
2086
2305
  };
2306
+ // #798 D3: the hub saw these submodules dirty when the turn ended and cannot see inside them: they are never restored, and never counted as "no change".
2307
+ const unobserved = turn.unobserved ?? [];
2308
+ const unobservedNote = unobserved.length ? `\nthese submodules were changed during the turn and their contents cannot be restored (the hub does not observe inside a submodule); undo them in the submodule itself:\n ${unobserved.join("\n ")}` : "";
2087
2309
  const first = plan();
2088
- if (first.undone) return console.log(`turn ${id} is already undone: its files are as they were before it started`);
2089
- console.log(first.restore.length ? `turn ${id} changed:\n ${first.restore.join("\n ")}` : `turn ${id} changed no files`);
2310
+ if (first.undone && !unobserved.length) return console.log(`turn ${id} is already undone: its files are as they were before it started`);
2311
+ if (first.undone) return console.log(`turn ${id}: its files are as they were before it started${unobservedNote}`);
2312
+ // "changed no files" is true of the files the hub observes and false of the turn: with a submodule dirty it says what it can and cannot see instead.
2313
+ console.log((first.restore.length ? `turn ${id} changed:\n ${first.restore.join("\n ")}` : unobserved.length ? `turn ${id} changed no files the hub can restore` : `turn ${id} changed no files`) + unobservedNote);
2090
2314
  console.log("(every change made in the project during the turn counts as its own, including any by Claude or by you)");
2091
2315
  const projections = new TextProjections({ cwd: undoCwd, stateDir, config: undoConfig.text_projection ?? {}, deny: undoConfig.local.deny, readAllow: undoConfig.local.read_allow, log: line => console.error(line) });
2092
2316
  const preview = projectedDiff({ repo, from: endTree, to: startTree, paths: first.restore, projections, deny: undoConfig.local.deny, privatePath: path => matchesPolicy(undoCwd, join(repo.top, path), undoConfig.pii?.paths ?? []) });
@@ -2115,6 +2339,8 @@ const commands: Record<string, () => Promise<void> | void> = {
2115
2339
  await stopProject(project);
2116
2340
  console.log("hub stopped");
2117
2341
  },
2342
+ // The counterpart of `ahub up`: the same verified graceful stop as `ahub kill`, never a second shutdown path.
2343
+ down: async () => { await commands.kill!(); },
2118
2344
 
2119
2345
  reset: async () => {
2120
2346
  if (args.some((a) => !["--all", "--yes"].includes(a))) fail("usage: ahub reset [--all] [--yes]");
@@ -2235,7 +2461,15 @@ const commands: Record<string, () => Promise<void> | void> = {
2235
2461
  }
2236
2462
  if (args.includes("--orphans")) {
2237
2463
  if(identity.role==="tools" && args.includes("--kill")) {
2238
- const hub=await connect();try {const result=await hub.request({t:"conductor_operational",op:"orphans"},10_000);if(result.ok!==true)fail(String(result.error??"orphan inspection unavailable"),result.refusalAudited===true,result.authorityRefused);console.log(JSON.stringify(result,null,2));}finally{hub.close();}return;
2464
+ // #812: the conductor's shell asks the hub to stop its own orphan; #899: the answer prints for a person unless --json.
2465
+ const hub=await connect();
2466
+ try {
2467
+ const result=await hub.request({t:"conductor_operational",op:"orphans"},10_000);
2468
+ if(result.ok!==true)fail(String(result.error??"orphan inspection unavailable"),result.refusalAudited===true,result.authorityRefused);
2469
+ if (options.json) console.log(JSON.stringify(result,null,2));
2470
+ else printOutput(renderOrphanCleanup(result, options.columns), options.color);
2471
+ } finally{hub.close();}
2472
+ return;
2239
2473
  }
2240
2474
  return orphanDoctor(args.includes("--kill"), options);
2241
2475
  }
@@ -2395,20 +2629,26 @@ function inspectDoctorRouting(cwd: string): { routing?: Routing; errors: string[
2395
2629
  }
2396
2630
 
2397
2631
  async function runConductorCommand(): Promise<void> {
2632
+ if (recoveryRunnerContinuation) return commands[cmd]!();
2398
2633
  const operation=conductorOperation(cmd,args);
2399
2634
  if(operation) {
2400
2635
  const unavailable=`conductor authorization unavailable; nothing was executed; ${OPERATIONAL_PERSON_GUIDANCE}`;
2401
2636
  const descriptor=readControl(stateDir);
2637
+ // Offline resume proves the source-issued local continuation inside the command; no console connection is borrowed.
2638
+ if (conductorPeer && cmd === "recovery" && (!descriptor?.pid || !descriptor.pidSignature || processLiveness(descriptor.pid, descriptor.pidSignature) !== "live")) return commands.recovery!();
2402
2639
  if(!descriptor?.instanceId || !descriptor.projectId || !descriptor.pid || !descriptor.pidSignature || processLiveness(descriptor.pid,descriptor.pidSignature)!=="live")fail(unavailable);
2403
2640
  let hub:ControlClient;
2404
2641
  try {hub=await connect(3000);} catch {fail(unavailable);}
2405
2642
  let release:(()=>void)|undefined;
2406
2643
  try {
2407
2644
  let result:any;
2408
- try {result=await hub.request({t:"conductor_operational",op:"authorize",command:operation},3000);} catch {fail(unavailable);}
2645
+ // #933: a form that runs in this process is admitted and audited once here (`admit`), after its arguments pass; any other form's own request admits it.
2646
+ if(conductorLocalOperation(operation))LOCAL_OPERATION_ARGS[operation]?.();
2647
+ try {result=await hub.request({t:"conductor_operational",op:conductorLocalOperation(operation)?"admit":"authorize",command:operation},3000);} catch {fail(unavailable);}
2409
2648
  if(result.ok!==true)fail(result.authorityRefused===true || result.authorityRefused===false?String(result.error??unavailable):unavailable,result.refusalAudited===true,result.authorityRefused);
2410
2649
  if(!hub.instanceId)fail(unavailable);
2411
- release=ControlClient.pinInstance(stateDir,hub.instanceId);
2650
+ // Recovery replaces its source instance. Its own per-project proofs fence every later connection.
2651
+ if (!conductorPeer || !["restart", "upgrade", "recovery"].includes(cmd)) release=ControlClient.pinInstance(stateDir,hub.instanceId);
2412
2652
  await commands[cmd]!();
2413
2653
  } finally {release?.();hub.close();}
2414
2654
  return;
@@ -2430,11 +2670,14 @@ async function runConductorCommand(): Promise<void> {
2430
2670
  } else {
2431
2671
  // Native TUIs return a human launch command through the daemon; an agent shell never executes it.
2432
2672
  if (configuredCommand) {
2433
- const selection = parseConfiguredAcpArguments(cmd, args, projectCatalog, declaredInstances);
2434
- if (selection.preview) return runConfiguredAcpEntry({ kind: cmd, raw: args, catalog: projectCatalog, instances: declaredInstances, connect, print: text => console.log(text) });
2673
+ const selection = parseConfiguredAcpArguments(cmd, args, projectCatalog, declaredInstances, peerConfig.declaredExternalInstances);
2674
+ if (selection.preview) return runConfiguredAcpEntry({ kind: cmd, raw: args, catalog: projectCatalog, instances: declaredInstances, declaredExternalInstances: peerConfig.declaredExternalInstances, projectRoot: cwd, connect, print: text => console.log(text) });
2435
2675
  if (selection.sessionId) fail("conductor starts accept no session override; a person starts a configured ACP session");
2436
2676
  const hub = await connect();
2437
2677
  try {
2678
+ const external = selection.peer !== cmd && (peerConfig.declaredExternalInstances?.some(row => row.id === selection.peer && row.kind === cmd)
2679
+ || projectCatalog.externalDefinitions.some(definition => definition.kind === cmd));
2680
+ if (external) fail("external module instances require an explicit person start");
2438
2681
  await requireActiveConfiguredAcp(hub, cmd, selection.peer, text => console.error(text));
2439
2682
  console.log(await taskOp("hub_peer_start", { peer: selection.peer, mode: "headless" }, false, hub, 70_000));
2440
2683
  } finally { hub.close(); }
@@ -2451,9 +2694,9 @@ async function runConductorCommand(): Promise<void> {
2451
2694
  console.log(await taskOp(op, input));
2452
2695
  }
2453
2696
  const runConfigured = configuredCommand ? async () => {
2454
- const selection = parseConfiguredAcpArguments(cmd, args, projectCatalog, declaredInstances);
2697
+ const selection = parseConfiguredAcpArguments(cmd, args, projectCatalog, declaredInstances, peerConfig.declaredExternalInstances);
2455
2698
  if (!selection.preview) assertLifecycleAvailable();
2456
- await runConfiguredAcpEntry({ kind: cmd, raw: args, catalog: projectCatalog, instances: declaredInstances, connect: connectLaunch, print: text => console.log(text), operationId: process.env.AGENTHUB_RECOVERY_OPERATION });
2699
+ await runConfiguredAcpEntry({ kind: cmd, raw: args, catalog: projectCatalog, instances: declaredInstances, declaredExternalInstances: peerConfig.declaredExternalInstances, projectRoot: cwd, connect: connectLaunch, print: text => console.log(text), operationId: process.env.AGENTHUB_RECOVERY_OPERATION });
2457
2700
  } : undefined;
2458
2701
  const run = identity.role === "tools" && commandAccess === "conductor" ? runConductorCommand : (Object.hasOwn(commands, cmd) ? commands[cmd] : undefined) ?? runConfigured ?? (() => fail(`unknown command "${cmd}"; run ahub help`));
2459
2702
  try {