@staix/agent-hub 0.20.0 → 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (183) hide show
  1. package/CHANGELOG.md +66 -3
  2. package/README.md +2 -2
  3. package/docs/agent-notes/README.md +11 -0
  4. package/docs/agent-notes/adapters.md +7 -3
  5. package/docs/agent-notes/benchmarks.md +27 -0
  6. package/docs/agent-notes/budget.md +2 -1
  7. package/docs/agent-notes/bus.md +3 -3
  8. package/docs/agent-notes/daemon.md +17 -8
  9. package/docs/agent-notes/local-worker.md +4 -0
  10. package/docs/agent-notes/models.md +2 -0
  11. package/docs/agent-notes/tasks.md +17 -9
  12. package/docs/agent-notes/tests.md +9 -2
  13. package/docs/bench.md +1 -1
  14. package/docs/data-policy.md +2 -0
  15. package/docs/dogfood.md +39 -4
  16. package/docs/events.md +2 -1
  17. package/docs/memory.md +9 -3
  18. package/docs/operations.md +610 -62
  19. package/docs/profiles.md +13 -1
  20. package/docs/quickstart.md +18 -9
  21. package/docs/research.md +7 -4
  22. package/docs/sdlc.md +54 -10
  23. package/docs/security.md +396 -34
  24. package/docs/smoke.md +133 -0
  25. package/docs/specs/2026-09-19-agent-hub-design.md +1101 -22
  26. package/docs/verification/2026-10-27-0.19.0.md +5 -5
  27. package/docs/verification/2026-10-28-claude-2.1.311-2.1.312-bash-permission.json +635 -0
  28. package/docs/verification/2026-10-28-claude-2.1.311-2.1.312-permission-transport.json +403 -0
  29. package/docs/verification/2026-10-28-claude-implementer-permission-triage.md +123 -0
  30. package/docs/verification/2026-10-28-jcode-0.99.1-usage.md +41 -0
  31. package/docs/verification/2026-10-28-realpath-name-audit.md +222 -0
  32. package/docs/verification/2026-10-28-routing-f0bb8ad0.md +1000 -0
  33. package/docs/verification/2026-10-29-0.21.0.md +61 -0
  34. package/docs/verification/2026-10-29-model-effort-capabilities.md +123 -0
  35. package/docs/verification/2026-10-29-modelrouting-b152ddb9.md +953 -0
  36. package/docs/verification/2026-10-30-0.22.0.md +49 -0
  37. package/docs/verification/2026-10-30-445-capability-probes.md +172 -0
  38. package/docs/verification/2026-10-30-bus-delivery-operational-settings.md +77 -0
  39. package/docs/verification/2026-10-30-jcode-ac4.md +24 -0
  40. package/docs/verification/2026-10-30-jcode-ac6.md +52 -0
  41. package/docs/verification/2026-10-30-recovery-wait-settings.md +141 -0
  42. package/docs/verification/claude-implementer-permission-triage.ts +160 -0
  43. package/docs/verification/claude-permission-transport-native.py +71 -14
  44. package/docs/verification/profiles/YYYY-MM-DD-research.md +1 -1
  45. package/docs/verified.json +15 -15
  46. package/package.json +2 -2
  47. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  48. package/plugins/agent-hub/server.js +1016 -280
  49. package/src/adapters/acp.ts +79 -3
  50. package/src/adapters/channel-peer.ts +4 -0
  51. package/src/adapters/claude-channel.ts +13 -6
  52. package/src/adapters/claude-implementer.ts +60 -7
  53. package/src/adapters/codex-appserver.ts +77 -4
  54. package/src/adapters/conformance/fakes/jcode-agent.ts +6 -2
  55. package/src/adapters/conformance/kit.ts +3 -9
  56. package/src/adapters/definitions/builtin-metadata.ts +3 -114
  57. package/src/adapters/definitions/native-config-defaults.ts +1 -1
  58. package/src/adapters/definitions/native-config-policy.ts +3 -2
  59. package/src/adapters/definitions/native-context.ts +12 -0
  60. package/src/adapters/external-metadata.ts +48 -0
  61. package/src/adapters/external-modules.ts +63 -3
  62. package/src/adapters/jcode.ts +6 -2
  63. package/src/adapters/local-worker.ts +40 -25
  64. package/src/adapters/native-proof-ports.ts +10 -0
  65. package/src/adapters/registry-types.ts +4 -0
  66. package/src/broker/protocol.ts +203 -0
  67. package/src/broker/server.ts +314 -0
  68. package/src/broker/store.ts +548 -0
  69. package/src/cli/bench.ts +3 -2
  70. package/src/cli/broker.ts +58 -0
  71. package/src/cli/conductor-recovery.ts +142 -0
  72. package/src/cli/configured-acp-entry.ts +26 -6
  73. package/src/cli/console-state.ts +59 -17
  74. package/src/cli/console.ts +17 -2
  75. package/src/cli/facts-hook.ts +3 -1
  76. package/src/cli/help.ts +29 -16
  77. package/src/cli/identity.ts +25 -10
  78. package/src/cli/init.ts +34 -7
  79. package/src/cli/intake-output.ts +33 -0
  80. package/src/cli/learn-output.ts +38 -0
  81. package/src/cli/main.ts +334 -73
  82. package/src/cli/output.ts +41 -1
  83. package/src/cli/recovery-package.ts +18 -1
  84. package/src/cli/recovery-waits.ts +26 -0
  85. package/src/cli/role-holders.ts +35 -0
  86. package/src/cli/room.ts +304 -0
  87. package/src/cli/say-input.ts +16 -0
  88. package/src/cli/schedule-output.ts +49 -0
  89. package/src/cli/sdlc-output.ts +44 -6
  90. package/src/cli/smoke-pty-bridge.ts +82 -0
  91. package/src/cli/smoke-pty.ts +153 -0
  92. package/src/cli/smoke-run.ts +139 -0
  93. package/src/cli/smoke-target.ts +130 -0
  94. package/src/cli/source-output.ts +29 -0
  95. package/src/cli/task-output.ts +3 -2
  96. package/src/cli/upgrade-runtime.ts +112 -28
  97. package/src/cli/upgrade.ts +68 -8
  98. package/src/hub/ask.ts +76 -12
  99. package/src/hub/board.ts +132 -22
  100. package/src/hub/bus.ts +416 -65
  101. package/src/hub/child-process.ts +2 -0
  102. package/src/hub/ci-watch.ts +42 -20
  103. package/src/hub/claude-failure-stage.ts +85 -0
  104. package/src/hub/claude-shell-wrapper.ts +11 -5
  105. package/src/hub/conductor.ts +17 -4
  106. package/src/hub/config-apply.ts +34 -6
  107. package/src/hub/config-reload.ts +7 -5
  108. package/src/hub/config-trust.ts +7 -1
  109. package/src/hub/context-window.ts +33 -3
  110. package/src/hub/control-client.ts +14 -2
  111. package/src/hub/corpus-config.ts +34 -0
  112. package/src/hub/corpus-evidence.ts +184 -0
  113. package/src/hub/corpus.ts +373 -0
  114. package/src/hub/cost-governance.ts +26 -0
  115. package/src/hub/daemon.ts +1209 -183
  116. package/src/hub/deliverable-files.ts +8 -2
  117. package/src/hub/delivery-journal.ts +200 -73
  118. package/src/hub/dogfood-profile.ts +32 -5
  119. package/src/hub/dogfood-read.ts +6 -2
  120. package/src/hub/envelope.ts +11 -0
  121. package/src/hub/eval-gate.ts +131 -0
  122. package/src/hub/events.ts +1 -1
  123. package/src/hub/external-instance.ts +69 -0
  124. package/src/hub/facts.ts +15 -5
  125. package/src/hub/folder-trigger.ts +223 -0
  126. package/src/hub/hub-tools.ts +2 -1
  127. package/src/hub/inference.ts +20 -5
  128. package/src/hub/instance-config.ts +69 -3
  129. package/src/hub/instance-execution.ts +6 -2
  130. package/src/hub/instances.ts +32 -4
  131. package/src/hub/intake.ts +125 -0
  132. package/src/hub/learning.ts +117 -0
  133. package/src/hub/lifecycle.ts +7 -3
  134. package/src/hub/limits.ts +12 -5
  135. package/src/hub/native-permission-request.ts +77 -0
  136. package/src/hub/operational-authority.ts +63 -5
  137. package/src/hub/operational-settings.ts +154 -0
  138. package/src/hub/operational-view.ts +1 -1
  139. package/src/hub/packs.ts +132 -0
  140. package/src/hub/path-policy.ts +7 -0
  141. package/src/hub/peer-doing.ts +130 -0
  142. package/src/hub/peers.ts +12 -0
  143. package/src/hub/queue-backpressure.ts +58 -0
  144. package/src/hub/read-tools.ts +2 -0
  145. package/src/hub/recovery-authority.ts +151 -0
  146. package/src/hub/recovery-policy.ts +220 -0
  147. package/src/hub/research.ts +24 -14
  148. package/src/hub/restart.ts +7 -0
  149. package/src/hub/routing.ts +54 -11
  150. package/src/hub/schedules.ts +266 -0
  151. package/src/hub/sdlc-merge.ts +38 -14
  152. package/src/hub/sdlc-metrics.ts +32 -10
  153. package/src/hub/sdlc-queue.ts +7 -1
  154. package/src/hub/sdlc-release.ts +1 -1
  155. package/src/hub/sdlc.ts +37 -7
  156. package/src/hub/security-baseline.ts +38 -0
  157. package/src/hub/settings.ts +27 -2
  158. package/src/hub/shipped-packs.ts +8 -0
  159. package/src/hub/snapshots.ts +72 -6
  160. package/src/hub/source-read.ts +160 -0
  161. package/src/hub/sources.ts +311 -0
  162. package/src/hub/tasks.ts +987 -110
  163. package/src/hub/workflow.ts +1 -1
  164. package/src/local/doc-read.ts +69 -0
  165. package/src/local/proxy.ts +4 -13
  166. package/src/local/tools.ts +37 -3
  167. package/src/local/web-fetch-config.ts +43 -0
  168. package/src/local/web-fetch.ts +183 -0
  169. package/src/memory/brief.ts +85 -13
  170. package/src/memory/capture.ts +1 -1
  171. package/src/rooms/application.ts +114 -0
  172. package/src/rooms/boundary.ts +103 -0
  173. package/src/rooms/commands.ts +294 -0
  174. package/src/rooms/crypto.ts +371 -0
  175. package/src/rooms/link.ts +584 -0
  176. package/src/rooms/policy.ts +82 -0
  177. package/src/rooms/remote-peer.ts +80 -0
  178. package/src/rooms/service.ts +426 -0
  179. package/src/rooms/status.ts +146 -0
  180. package/src/rooms/store.ts +1210 -0
  181. package/src/rooms/wire.ts +465 -0
  182. package/src/ui/index.html +91 -4
  183. package/templates/packs/example.pack.json +13 -0
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,23 +22,28 @@ 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";
29
+ import { renderLearnChange, renderLearnList } from "./learn-output.ts";
23
30
  import { terminalText } from "./console-state.ts";
24
31
  import { parseSettingsEditArgs, runSettingsEdit, SETTINGS_EDIT_USAGE } from "./settings-edit.ts";
25
32
  import { petView, readPet, renderPet } from "./pet.ts";
26
33
  import { startPetWindow, closePetWindow } from "../pet/window.ts";
27
34
  import { loadPetConfig, savePetPalette } from "./pet-config.ts";
28
- import { profileName, projectProfile, resolveProfile } from "../hub/profiles.ts";
35
+ import { projectProfile, resolveProfile } from "../hub/profiles.ts";
29
36
  import { catalogAssignments, loadRoleCatalog, selectedRoles } from "../hub/roles.ts";
30
37
  import { attachWatch } from "./watch.ts";
31
38
  import { verifiedJcodeCommand } from "../adapters/jcode.ts";
32
39
  import { configureJcodeMcp, jcodeExtraMcpServers } from "./jcode-mcp.ts";
33
40
  import { qwenSessionId } from "../hub/crash.ts";
34
41
  import { isPermissionMode } from "../hub/permission-mode.ts";
35
- import { parseSettingText, settingDef, settingText, type SettingRow } from "../hub/settings.ts";
42
+ import { parseSettingText, settingDef, settingText, undoableDef, type SettingRow } from "../hub/settings.ts";
36
43
  import { startModeOf } from "../hub/start-mode.ts";
37
- import { OVERLAY_FILE, parseOverlay, parseRouting, routingText, type Routing, type RoutingOverlay } from "../hub/routing.ts";
44
+ import { currentRouting, OVERLAY_FILE, parseOverlay, parseRouting, routingText, type Routing, type RoutingOverlay } from "../hub/routing.ts";
38
45
  import { spawn, spawnSync } from "node:child_process";
39
- import { chmodSync, existsSync, readFileSync, renameSync, writeFileSync } from "node:fs";
46
+ import { chmodSync, closeSync, constants, existsSync, fstatSync, openSync, readFileSync, readSync, renameSync, writeFileSync } from "node:fs";
40
47
  import { randomUUID } from "node:crypto";
41
48
  import { homedir } from "node:os";
42
49
  import { basename, dirname, join, relative, resolve } from "node:path";
@@ -59,8 +66,8 @@ import { VERSION } from "../version.ts";
59
66
  import { freeText } from "./free-text.ts";
60
67
  import { createInterface } from "node:readline/promises";
61
68
  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";
69
+ import { activeOperation, assertLifecycleAvailable, operationIdPattern, readOperation, writeOperation, recoveryLock, recoveryRunner, signedRunner, stopSignedRunner, supportsAction } from "../hub/recovery-store.ts";
70
+ import { childEnv, personEnv } from "../hub/child-process.ts";
64
71
  import { abortRecovery, createOperation, disposeRecovery, recordRecoveryDelegation, repairedFreshSession, reattachRecovery, liveProjects, nextChoices, nextActionsText, publicOperation, recoveryCommand, registeredProjects, runRecovery, type RecoveryOperation, type Inspection } from "./upgrade.ts";
65
72
  import { endPlannedPeer, makeRecoveryDriver, makeUpgradePlan, preserveSource } from "./upgrade-runtime.ts";
66
73
  import { exactVersion, latestRelease, newerVersion } from "./recovery-package.ts";
@@ -93,10 +100,10 @@ import { agentShellCause, classifyPeerCommand, cliCommandLabel, detectCliIdentit
93
100
  import { recordCliAudit, recordDelegatedCliAudit } from "./identity-audit.ts";
94
101
  import { runConsole } from "./console.ts";
95
102
  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";
103
+ 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
104
  import { renderHelp, configuredAcpHelp } from "./help.ts";
98
105
  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";
106
+ import { renderAuthorization, renderSdlcCosts, renderSdlcExplain, renderSdlcMerge, renderSdlcMetrics, renderSdlcQueue, renderSdlcRelease, renderSdlcStatus } from "./sdlc-output.ts";
100
107
  import { renderTailEvent } from "./tail-render.ts";
101
108
  import { archiveProblem, archiveState, damagedState, failureText, MANIFEST, planReset, resetLines, resetRuntime, startState, type ResetPlan } from "./reset.ts";
102
109
 
@@ -160,7 +167,7 @@ function localDeclarations(): { scope: Pick<InstanceRoleConfig, "instances" | "c
160
167
  catch (error) { return { scope: { catalog: createProjectAdapterCatalog() }, error: startupDiagnostic(error) }; }
161
168
  }
162
169
  const projectCatalog = peerConfig.catalog;
163
- const configuredCommand = !definitionOf(given) && projectCatalog.definitionOf(given)?.permission.genericAcp !== undefined;
170
+ const configuredCommand = !definitionOf(given) && (projectCatalog.definitionOf(given)?.permission.genericAcp !== undefined || peerConfig.declaredExternalInstances?.some(row => row.kind === given) === true);
164
171
  const unboundCaller = detectCliIdentity(process.env);
165
172
  const markerNeedsDeclaration = unboundCaller.role === "invalid" && unboundCaller.reason === "AGENTHUB_PEER_ID conflicts with native agent marker"
166
173
  && process.env.AGENTHUB_PEER_ID !== undefined && !definitionOf(process.env.AGENTHUB_PEER_ID);
@@ -170,7 +177,7 @@ let nativeSelection: ReturnType<typeof instanceLaunchArguments>;
170
177
  try {
171
178
  declaredInstances = needsInstances || configuredCommand ? peerConfig.instances : undefined;
172
179
  if (configuredCommand && !(rest.length === 1 && ["--help", "-h"].includes(rest[0]!))) {
173
- const selection = parseConfiguredAcpArguments(given, rest, projectCatalog, declaredInstances);
180
+ const selection = parseConfiguredAcpArguments(given, rest, projectCatalog, declaredInstances, peerConfig.declaredExternalInstances);
174
181
  nativeSelection = { ...(selection.peer !== given ? { instance: selection.peer } : {}), args: [...rest] };
175
182
  } else nativeSelection = instanceLaunchArguments(given, rest, declaredInstances);
176
183
  } catch (error) { fail(startupDiagnostic(error)); }
@@ -207,11 +214,30 @@ const stateDir = selected.stateDir;
207
214
  var operationalAuditReady=false, operationalRefusalRecorded=false;
208
215
  const caller = declaredInstances ? detectCliIdentity(process.env, declaredInstances) : unboundCaller;
209
216
  const commandLabel = cliCommandLabel(cmd, args, projectCatalog);
210
- const commandAccess = classifyPeerCommand(cmd, args, projectCatalog);
217
+ let commandAccess = classifyPeerCommand(cmd, args, projectCatalog);
218
+ const continuationPeer = process.env.AGENTHUB_RECOVERY_CONDUCTOR;
219
+ const continuationId = process.env.AGENTHUB_RECOVERY_OPERATION;
220
+ let recoveryLauncher = false, recoveryRunnerContinuation = false;
221
+ let recoveryLauncherCurrent: (() => void) | undefined;
222
+ if (continuationPeer !== undefined) {
223
+ 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");
224
+ let operation: RecoveryOperation;
225
+ try { operation = readOperation<RecoveryOperation>(continuationId); }
226
+ catch { fail("conductor recovery authority unavailable; the person runs it in ahub console or a terminal"); }
227
+ if (!operation || operation.id !== continuationId || typeof operation.sourceRoot !== "string") fail("conductor recovery authority unavailable; the person runs it in ahub console or a terminal");
228
+ if (cmd === "recovery-run" && args.length === 1 && args[0] === continuationId) {
229
+ await authorizeConductorOperation(operation, continuationPeer);
230
+ if (realPath(operation.sourceRoot) !== realPath(resolve(import.meta.dir, "../.."))) fail("conductor recovery authority unavailable; the person runs it in ahub console or a terminal");
231
+ recoveryRunnerContinuation = true; commandAccess = "conductor";
232
+ } else if (process.env.AGENTHUB_RECOVERY_CONTINUATION === continuationId) {
233
+ await authorizeConductorLaunch(operation, continuationPeer, cwd, resolve(import.meta.dir, "../.."), cmd, args); recoveryLauncher = true; recoveryLauncherCurrent = () => assertConductorOperationLocal(operation, continuationPeer);
234
+ } else fail("conductor recovery authority unavailable; the person runs it in ahub console or a terminal");
235
+ }
236
+ const conductorPeer = !liveTest && caller.role === "tools" && (recoveryRunnerContinuation || ["restart", "upgrade", "recovery"].includes(cmd) && commandAccess === "conductor") ? caller.peer : undefined;
211
237
  // Agent-authored content keeps its peer. Operational starts/holds may borrow console authority for live verification.
212
238
  const delegationRefused = liveTest && caller.role === "tools" && commandAccess !== "allowed" && liveTestRefusal(cmd, args);
213
239
  const delegatedPeer = liveTest && caller.role === "tools" && commandAccess !== "allowed" && !delegationRefused ? caller.peer : undefined;
214
- const identity = delegatedPeer ? { role: "console" as const } : caller;
240
+ const identity = delegatedPeer || recoveryLauncher ? { role: "console" as const } : caller;
215
241
  operationalAuditReady=identity.role==="tools" && commandAccess==="conductor" && conductorOperation(cmd,args)!==undefined;
216
242
  function audit(outcome: "run" | "refused" | "invalid"): void {
217
243
  if (caller.role === "console") return;
@@ -576,13 +602,13 @@ function printRecoveryStatus(op: RecoveryOperation, message: string, presentatio
576
602
  }
577
603
  }
578
604
  async function recoveryEndInspection(op: RecoveryOperation): Promise<Record<string, Inspection | undefined>> {
579
- const driver = makeRecoveryDriver(undefined, delegatedPeer);
605
+ const driver = makeRecoveryDriver(undefined, delegatedPeer, undefined, conductorPeer);
580
606
  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
607
  }
582
608
 
583
609
  function spawnRecovery(operation: RecoveryOperation, quiet = false, presentation?: RecoveryPresentationOptions): void {
584
610
  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 },
611
+ cwd, detached: true, stdio: "ignore", env: { ...process.env, AGENTHUB_RECOVERY_OPERATION: operation.id, ...(conductorPeer ? { AGENTHUB_PEER_ID: conductorPeer, AGENTHUB_RECOVERY_CONDUCTOR: conductorPeer } : {}) },
586
612
  });
587
613
  child.on("error", () => console.error(`runner launch failed; use ${recoveryCommand(operation, "resume")}`));
588
614
  child.unref();
@@ -657,7 +683,7 @@ function upgradeHost(kind: "restart" | "upgrade", version: string): UpgradeHost
657
683
  lock: recoveryLock,
658
684
  read: (id) => readOperation<RecoveryOperation>(id),
659
685
  runner: recoveryRunner,
660
- live: (op) => liveProjects(op, makeRecoveryDriver(undefined, delegatedPeer).inspect),
686
+ live: (op) => liveProjects(op, makeRecoveryDriver(undefined, delegatedPeer, undefined, conductorPeer).inspect),
661
687
  endInspection: recoveryEndInspection,
662
688
  endPeer: (planned, peer) => endPlannedPeer(planned, peer),
663
689
  runnerStoppable: (id) => signedRunner(id) !== undefined,
@@ -684,6 +710,7 @@ async function upgrade(kind: "restart" | "upgrade"): Promise<void> {
684
710
  // Before the value reaches a package spec or the registry: a range such as 0.13 would resolve to whatever matches.
685
711
  if (to !== undefined) { try { exactVersion(to); } catch (error) { fail((error as Error).message); } }
686
712
  // An older CLI must not coordinate a newer target (docs/operations.md): the target's own coordinator takes over.
713
+ 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
714
  if (kind === "upgrade" && newerVersion(to!, VERSION) && process.env.AGENTHUB_UPGRADE_HANDOVER !== "1") {
688
715
  const argv = ["x", "--package", `@staix/agent-hub@${to}`, "ahub", ...liveTestArgs, "upgrade", "--to", to!, ...rest, ...display.flags, ...(dry && !rest.includes("--dry-run") ? ["--dry-run"] : [])];
689
716
  console.error(`ahub: ${to} is newer than this CLI (${VERSION}), so its own coordinator runs the upgrade: bun ${argv.join(" ")}`);
@@ -703,7 +730,8 @@ async function upgrade(kind: "restart" | "upgrade"): Promise<void> {
703
730
  }
704
731
  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
732
  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);
733
+ const plan = await makeUpgradePlan(kind, to ?? VERSION, kind === "restart" ? cwd : undefined, undefined, conductorPeer);
734
+ if (conductorPeer) await preflightConductorRecovery(plan, conductorPeer);
707
735
  const jsonApply = display.json && yes && !dry;
708
736
  // #814: a person reading a pipe or a dry run gets the plan screen's rows, with every blocker, reconnect-only session and
709
737
  // fresh start in them. `--json` keeps the document and the stderr lines (#206) exactly as they were.
@@ -715,7 +743,7 @@ async function upgrade(kind: "restart" | "upgrade"): Promise<void> {
715
743
  const blockers = [...plan.blockers, ...plan.projects.flatMap((p) => p.blockers.map((b) => `${p.project.id}: ${b}`))];
716
744
  if (display.json) for (const blocker of blockers) console.error(`ahub: blocker: ${blocker}`);
717
745
  if (dry) return;
718
- if (blockers.length) { if (jsonApply) console.log(JSON.stringify(plan, null, 2)); fail("plan has blockers; no runtime was changed"); }
746
+ 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
747
  assertLifecycleAvailable();
720
748
  if (!yes) {
721
749
  if (!process.stdin.isTTY) fail("review --dry-run and use --yes in non-interactive sessions");
@@ -724,10 +752,21 @@ async function upgrade(kind: "restart" | "upgrade"): Promise<void> {
724
752
  if (!/^(y|yes)$/i.test((await rl.question("Apply this project list and restore its sessions? [y/N] ")).trim())) return;
725
753
  } finally { rl.close(); }
726
754
  }
727
- const current = await makeUpgradePlan(kind, plan.version, kind === "restart" ? cwd : undefined);
755
+ const current = await makeUpgradePlan(kind, plan.version, kind === "restart" ? cwd : undefined, undefined, conductorPeer);
728
756
  if (current.fingerprint !== plan.fingerprint) fail("plan changed during review; run the command again");
757
+ const routingDefaults = conductorPeer ? await preflightConductorRecovery(plan, conductorPeer) : undefined;
729
758
  const sourceRoot = preserveSource(plan);
730
- const operation = createOperation(plan, sourceRoot, undefined, delegatedPeer ? { peer: delegatedPeer, command: kind } : undefined);
759
+ let operation: RecoveryOperation;
760
+ if (conductorPeer) {
761
+ const id = randomUUID();
762
+ 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: {} })) };
763
+ const target = await makeRecoveryDriver().stage(pending);
764
+ pending.targetRoot = target.root; pending.targetDigest = target.digest; pending.targetFeatures = target.targetFeatures;
765
+ await issueConductorOperation(pending, conductorPeer);
766
+ operation = createOperation(plan, sourceRoot, undefined, { peer: conductorPeer, command: kind }, id);
767
+ operation.targetRoot = target.root; operation.targetDigest = target.digest; operation.targetFeatures = target.targetFeatures; operation.conductorRoutingDefaults = routingDefaults;
768
+ writeOperation(id, operation);
769
+ } else operation = createOperation(plan, sourceRoot, undefined, delegatedPeer ? { peer: delegatedPeer, command: kind } : undefined);
731
770
  if (display.json) { spawnRecovery(operation, true); console.log(JSON.stringify(publicOperation(operation), null, 2)); }
732
771
  else spawnRecovery(operation, false, humanRecovery(false) ? display.options : undefined);
733
772
  }
@@ -741,6 +780,40 @@ function parseRecoveryReattach(argv: readonly string[]): { id: string; peer: Ter
741
780
  return { id: argv[1]!, peer: argv[2] as TerminalPeer };
742
781
  }
743
782
 
783
+ /** #248: `ahub bench run` arguments, checked before anything runs (#933: also before the conductor's admission writes its audit row). */
784
+ function benchRunArgs(rest: string[]) {
785
+ const { one, many, rest: positional } = takeFlags(rest, ["--arm", "--repeat", "--tasks", "--memory"], ["--addon"]);
786
+ if (one["--memory"] !== undefined && !(MEMORY_MODES as readonly string[]).includes(one["--memory"])) fail(`--memory takes ${MEMORY_MODES.join(", ")}`);
787
+ // #248: `--addon ripwire=off` declares the arm's add-on state; the run refuses peers that do not match it.
788
+ const addons: Partial<Record<AddonName, "on" | "off">> = {};
789
+ for (const spec of many["--addon"] ?? []) {
790
+ const [name, state] = spec.split("=");
791
+ 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(", ")}`);
792
+ addons[name] = state;
793
+ }
794
+ const suite = positional[0];
795
+ const repeat = one["--repeat"] === undefined ? 1 : Number(one["--repeat"]);
796
+ if (!suite || positional.length !== 1 || !one["--arm"]?.trim() || !Number.isSafeInteger(repeat) || repeat < 1 || repeat > 100) {
797
+ 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]");
798
+ }
799
+ return { one, addons, suite, repeat };
800
+ }
801
+ /** Another project is `ahub --project <dir> research`; anything unknown is refused rather than ignored. */
802
+ function researchArgs(): void {
803
+ const sub = args[0], flags = new Set(["--all", "--json", "--since", "--format"]), valued = new Set(["--since", "--format"]);
804
+ for (let i = sub === "export" || sub === "backfill" || sub === "addons" ? 1 : 0; i < args.length; i++) {
805
+ if (!flags.has(args[i]!)) fail(`ahub research: unknown argument ${args[i]} (another project: ahub --project <dir> research)`);
806
+ if (valued.has(args[i]!)) i++;
807
+ }
808
+ }
809
+ const SOURCE_USAGE = "usage: ahub source add <path|url> [--json] [--color=auto|always|never] | list [--json] [--color=auto|always|never]";
810
+ /** #933: a local conductor form's argument check, run before the `admit` handshake so a usage error leaves no admitted row. */
811
+ const LOCAL_OPERATION_ARGS: Record<string, () => void> = {
812
+ "bench run": () => { benchRunArgs(args.filter(arg => arg !== "--json").slice(1)); },
813
+ "research backfill": researchArgs,
814
+ "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); },
815
+ };
816
+
744
817
  const commands: Record<string, () => Promise<void> | void> = {
745
818
  upgrade: () => upgrade("upgrade"),
746
819
  restart: () => upgrade("restart"),
@@ -778,7 +851,7 @@ const commands: Record<string, () => Promise<void> | void> = {
778
851
  const result = await reattachRecovery(selected.id, selected.peer, makeRecoveryDriver());
779
852
  if (display.json) { console.log(JSON.stringify(publicOperation(result), null, 2)); return; }
780
853
  printRecovery(renderOperation(result, undefined, display.options));
781
- const live = await liveProjects(result, makeRecoveryDriver(undefined, delegatedPeer).inspect);
854
+ const live = await liveProjects(result, makeRecoveryDriver(undefined, delegatedPeer, undefined, conductorPeer).inspect);
782
855
  printRecovery(renderBlocked(result, nextChoices(result, undefined, live), display.options));
783
856
  return;
784
857
  }
@@ -805,15 +878,20 @@ const commands: Record<string, () => Promise<void> | void> = {
805
878
  audit("refused"); fail(LIVE_TEST_PERSON_REFUSAL);
806
879
  }
807
880
  }
881
+ if (conductorPeer) {
882
+ if (operation.id !== id) fail("conductor recovery authority unavailable; the person runs it in ahub console or a terminal");
883
+ await authorizeConductorOperation(operation, conductorPeer);
884
+ }
808
885
  const runner = recoveryRunner(id);
809
886
  if (action === "status") {
810
887
  // #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);
888
+ const live = runner ? {} : await liveProjects(operation, makeRecoveryDriver(undefined, delegatedPeer, undefined, conductorPeer).inspect);
812
889
  if (display.json) console.log(JSON.stringify(publicOperation(operation, runner, live), null, 2));
813
890
  else printRecovery(renderRecoveryStatus(operation, runner, nextChoices(operation, runner, live), display.options));
814
891
  }
815
892
  else if (action === "abort") {
816
- await abortRecovery(id, makeRecoveryDriver(undefined, delegatedPeer));
893
+ await abortRecovery(id, makeRecoveryDriver(undefined, delegatedPeer, undefined, conductorPeer));
894
+ if (conductorPeer) retireConductorOperation(readOperation<RecoveryOperation>(id), conductorPeer);
817
895
  if (display.json) console.log(JSON.stringify(publicOperation(readOperation<RecoveryOperation>(id)), null, 2));
818
896
  else console.log("preflight cancelled; no committed transition was rolled back");
819
897
  }
@@ -823,7 +901,7 @@ const commands: Record<string, () => Promise<void> | void> = {
823
901
  const stop = flags.length === 1 && flags[0] === "--stop-and-archive";
824
902
  const reason = one["--reason"]?.trim() ?? "";
825
903
  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));
904
+ const result = await disposeRecovery(id, stop ? { stop: true } : { fresh: one["--fresh-session"]! }, reason, makeRecoveryDriver(undefined, delegatedPeer, undefined, conductorPeer));
827
905
  if (!stop) {
828
906
  if (display.json) { console.log(JSON.stringify(publicOperation(result), null, 2)); return spawnRecovery(result, true); }
829
907
  console.log(`${one["--fresh-session"]}: a fresh session is accepted and the lost one is recorded; resuming`);
@@ -834,7 +912,7 @@ const commands: Record<string, () => Promise<void> | void> = {
834
912
  }
835
913
  else if (["completed", "cancelled"].includes(operation.phase)) console.log(display.json ? JSON.stringify(publicOperation(operation, runner), null, 2) : `recovery is already ${operation.phase}`);
836
914
  // #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))}`);
915
+ 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
916
  else if (runner === "unknown") {
839
917
  if (display.json) console.log(JSON.stringify(publicOperation(operation, runner), null, 2));
840
918
  else if (humanRecovery(false)) printRecoveryStatus(operation, "Runner ownership is unknown; nothing was started.", display.options);
@@ -853,8 +931,9 @@ const commands: Record<string, () => Promise<void> | void> = {
853
931
  },
854
932
  "recovery-run": async () => {
855
933
  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));
934
+ const result = await runRecovery(args[0]!, makeRecoveryDriver(undefined, delegatedPeer, undefined, conductorPeer));
857
935
  if (result.phase !== "completed") process.exitCode = 1;
936
+ else if (conductorPeer) retireConductorOperation(result, conductorPeer);
858
937
  },
859
938
  help: () => {
860
939
  const color = resolveColor(undefined, { isTTY: !!process.stdout.isTTY, TERM: process.env.TERM, NO_COLOR: process.env.NO_COLOR }) === true;
@@ -923,10 +1002,11 @@ const commands: Record<string, () => Promise<void> | void> = {
923
1002
  },
924
1003
 
925
1004
  setup: async () => {
1005
+ recoveryLauncherCurrent?.();
926
1006
  assertLifecycleAvailable();
927
1007
  const root = join(import.meta.dir, "..", "..");
928
1008
  const state = () => {
929
- const out = (argv: string[]) => spawnSync("claude", argv, { encoding: "utf8" }).stdout ?? "";
1009
+ const out = (argv: string[]) => { recoveryLauncherCurrent?.(); const result = spawnSync("claude", argv, { encoding: "utf8", ...(recoveryLauncherCurrent ? { timeout: 30_000 } : {}) }); recoveryLauncherCurrent?.(); return result.stdout ?? ""; };
930
1010
  return nextStep(parseList<InstalledPlugin>(out(["plugin", "list", "--json"])), parseList<Marketplace>(out(["plugin", "marketplace", "list", "--json"])), root);
931
1011
  };
932
1012
  let step = state();
@@ -943,17 +1023,21 @@ const commands: Record<string, () => Promise<void> | void> = {
943
1023
  }
944
1024
  for (let i = 0; step && i < 6; i++, step = state()) {
945
1025
  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`);
1026
+ recoveryLauncherCurrent?.();
1027
+ 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`);
1028
+ recoveryLauncherCurrent?.();
947
1029
  }
948
1030
  if (step) fail(`still not done after 6 steps; next would be: ${step.argv.join(" ")}`);
949
1031
  }
950
- await commands.doctor!();
1032
+ if (recoveryLauncherCurrent) recoveryLauncherCurrent();
1033
+ else await commands.doctor!();
951
1034
  },
952
1035
 
953
1036
  init: () => {
954
1037
  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"]);
1038
+ if (flags.rest.length) fail("usage: ahub init [--profile dev|research|writing|general|<pack>] [--shadow] [--release-guard] [--dry-run --json]");
1039
+ // 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.
1040
+ const requestedProfile = flags.one["--profile"];
957
1041
  if (args.includes("--dry-run")) {
958
1042
  const plan = planInit(cwd, requestedProfile, args.includes("--shadow"), args.includes("--release-guard"));
959
1043
  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 +1067,9 @@ const commands: Record<string, () => Promise<void> | void> = {
983
1067
  up: async () => {
984
1068
  const unattended = unattendedEnv || args.includes("--unattended");
985
1069
  if (unattended) console.error(UNATTENDED_WARNING);
986
- const status = await startProject(registeredProject(), { unattended, liveTest });
1070
+ 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
1071
  console.log(`ahub up (ws://127.0.0.1:${status.controlPort}), state in ${stateDir}`);
1072
+ if (recoveryLauncherCurrent) { recoveryLauncherCurrent(); return; }
988
1073
  try { if (loadPetConfig(cwd).window) console.log(await startPetWindow(stateDir, cwd)); }
989
1074
  catch { console.error("Pet window was not started; run ahub pet --window to inspect it."); }
990
1075
  if (process.stdin.isTTY && process.stdout.isTTY && !args.includes("--no-console")) await runConsole({ client: await connect(), cwd, stateDir, liveTest });
@@ -1048,9 +1133,10 @@ const commands: Record<string, () => Promise<void> | void> = {
1048
1133
  },
1049
1134
 
1050
1135
  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>");
1136
+ let addressed: ReturnType<typeof sayRecipients>;
1137
+ try { addressed = sayRecipients(args); } catch (error) { return fail((error as Error).message); }
1138
+ const { to, text } = addressed;
1139
+ const body = text.length ? freeText(text, "ahub say [@peer ...] <text>") : "";
1054
1140
  const hub = await connect();
1055
1141
  const res = await hub.request({ t: "send", body, to });
1056
1142
  hub.close();
@@ -1249,8 +1335,161 @@ const commands: Record<string, () => Promise<void> | void> = {
1249
1335
  return shown(renderReleaseChange)(taskOp("release_change", { name, revision: Number(revision), fields }));
1250
1336
  },
1251
1337
 
1338
+ smoke: async () => {
1339
+ // Log every live-test invocation, including syntax/target refusals; native output never joins the audit.
1340
+ if (liveTest && caller.role === "console") {
1341
+ try { recordCliAudit(stateDir, "user", commandLabel, "run"); }
1342
+ catch { return fail("smoke audit unavailable; nothing was executed"); }
1343
+ }
1344
+ const options = outputOptions();
1345
+ const operands = args.filter(arg => arg !== "--json");
1346
+ const usage = "usage: ahub --live-test smoke run qwen-dual-output --project-id <id> --receipt <file.json> [--timeout-ms <100..120000>] [--json]";
1347
+ const values = new Map<string, string>();
1348
+ if (operands[0] !== "run" || operands[1] !== "qwen-dual-output") return fail(usage);
1349
+ for (let n = 2; n < operands.length; n += 2) {
1350
+ const key = operands[n], value = operands[n + 1];
1351
+ if (!key || !["--project-id", "--receipt", "--timeout-ms"].includes(key) || !value || value.startsWith("--") || values.has(key)) return fail(usage);
1352
+ values.set(key, value);
1353
+ }
1354
+ if (!values.has("--project-id") || !values.has("--receipt")) return fail(usage);
1355
+ const home = process.env.AGENTHUB_HOME;
1356
+ if (!liveTest || !home) return fail("smoke target refused");
1357
+ const { validateSmokeTarget } = await import("./smoke-target.ts");
1358
+ const { runSmoke } = await import("./smoke-run.ts");
1359
+ const project = registeredProjects(home).find(project => project.root === cwd && project.stateDir === stateDir);
1360
+ if (!project) return fail("smoke target refused");
1361
+ // Keep path spelling for the guard: project selection above canonicalizes symlinks for ordinary CLI commands.
1362
+ const smokeCwd = selector && selector !== project.id ? resolve(invokedFrom, selector) : cwd;
1363
+ const checked = validateSmokeTarget({ cwd: smokeCwd, hubHome: home, project, expectedProjectId: values.get("--project-id")!, liveTest });
1364
+ const target = { ...checked, check: () => { checked.check(); assertDelegatedAuditConsumer(); } };
1365
+ const abort = new AbortController();
1366
+ const interrupted = () => abort.abort();
1367
+ process.on("SIGINT", interrupted); process.on("SIGTERM", interrupted);
1368
+ try {
1369
+ const result = await runSmoke(target, { leg: "qwen-dual-output", receipt: values.get("--receipt")!, timeoutMs: Number(values.get("--timeout-ms") ?? "60000"), signal: abort.signal });
1370
+ process.exitCode = result.outcome === "passed" ? 0 : 1;
1371
+ if (options.json) return console.log(JSON.stringify(result));
1372
+ console.log(`smoke ${result.outcome} (${result.reason}): ${result.counts.submits} submits, ${result.counts.stops} stops; cleanup ${result.checks.cleaned ? "confirmed" : "unconfirmed"}`);
1373
+ } finally { process.removeListener("SIGINT", interrupted); process.removeListener("SIGTERM", interrupted); }
1374
+ },
1375
+
1376
+ room: async () => {
1377
+ if (liveTest && !["list", "status"].includes(args[0] ?? "")) fail(LIVE_TEST_PERSON_REFUSAL);
1378
+ const { runRoomCommand, roomCommandError } = await import("./room.ts");
1379
+ const hub = await connect();
1380
+ try { await runRoomCommand(args, message => hub.request(message)); }
1381
+ catch (error) { fail(roomCommandError(error)); }
1382
+ finally { hub.close(); }
1383
+ },
1384
+
1385
+ broker: async () => {
1386
+ const { runBrokerCommand, brokerCommandError } = await import("./broker.ts");
1387
+ try { await runBrokerCommand(args, stateDir); }
1388
+ catch (error) { fail(brokerCommandError(error)); }
1389
+ },
1390
+
1391
+ source: async () => {
1392
+ // #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:
1393
+ // 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.
1394
+ const options = outputOptions();
1395
+ const operands = args.filter(arg => arg !== "--json" && arg !== "--full" && !arg.startsWith("--color="));
1396
+ const [sub, ...rest] = operands;
1397
+ const registry = new SourceRegistry(stateDir);
1398
+ if (sub === "list" && rest.length === 0) {
1399
+ const rows = registry.list();
1400
+ if (options.json) return console.log(JSON.stringify(rows, null, 2));
1401
+ return printOutput(renderSourceList(rows, options.columns, Date.now()), options.color);
1402
+ }
1403
+ if (sub !== "add" || rest.length !== 1 || !rest[0]) return fail(SOURCE_USAGE);
1404
+ const target = rest[0], config = projectConfig();
1405
+ const isUrl = /^[a-z][a-z0-9+.-]*:\/\//i.test(target);
1406
+ let bytes: Buffer, origin: string, kind: "file" | "url", absolute: string | undefined;
1407
+ try {
1408
+ if (isUrl) {
1409
+ const fetched = await fetchSourceUrl(target);
1410
+ bytes = fetched.bytes; origin = fetched.finalUrl; kind = "url";
1411
+ } else {
1412
+ absolute = realPath(resolve(cwd, target));
1413
+ const fd = openSync(absolute, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
1414
+ try {
1415
+ const st = fstatSync(fd);
1416
+ if (!st.isFile()) throw new Error("not a regular file");
1417
+ 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`);
1418
+ bytes = Buffer.alloc(st.size); let got = 0;
1419
+ while (got < bytes.length) { const n = readSync(fd, bytes, got, bytes.length - got, got); if (!n) break; got += n; }
1420
+ if (got !== st.size) throw new Error("the file changed while it was read");
1421
+ } finally { closeSync(fd); }
1422
+ origin = absolute; kind = "file";
1423
+ }
1424
+ } catch (error) { return fail(`cannot register ${isUrl ? "that URL" : target}: ${(error as Error).message}`); }
1425
+ const projections = new TextProjections({ cwd, stateDir, config: config.text_projection ?? {}, deny: config.local.deny, readAllow: config.local.read_allow, log: line => console.error(line) });
1426
+ const extension = /\.[A-Za-z0-9]{1,16}$/.exec(isUrl ? new URL(origin).pathname : origin)?.[0] ?? "";
1427
+ const verdict = screenRegistration({ bytes, kind, ...(absolute ? { path: absolute, typedPath: resolve(cwd, target) } : {}) }, {
1428
+ piiPaths: config.pii?.paths ?? [], piiPatterns: currentRouting(cwd).signals.pii_patterns, cwd, matchesPolicy,
1429
+ project: data => extension ? projections.projectBytes(join(cwd, `source${extension.toLowerCase()}`), data)?.text : undefined,
1430
+ });
1431
+ let added;
1432
+ try { added = registry.add({ bytes, kind, origin, private: verdict.private, ...(verdict.text !== undefined ? { text: verdict.text } : {}) }); }
1433
+ catch (error) { return fail(`cannot register ${isUrl ? "that URL" : target}: ${(error as Error).message}`); }
1434
+ const meta = registry.get(added.id);
1435
+ if (!meta) return fail(`the record for ${added.id} cannot be read after it was registered; inspect ${join(stateDir, "sources")}`);
1436
+ 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 };
1437
+ if (options.json) return console.log(JSON.stringify(result, null, 2));
1438
+ printOutput(renderSourceAdd(result, options.columns), options.color);
1439
+ },
1440
+
1441
+ schedule: async () => {
1442
+ // #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.
1443
+ // The hub keeps the schedules, their periods and the pause; this process only asks.
1444
+ const split = splitPresentation(args);
1445
+ const present = outputOptions(split.flags); // before any request: an invalid --color must refuse before a mutation is sent
1446
+ const [sub, id, ...extra] = split.operands;
1447
+ const usage = "usage: ahub schedule list | run <id> | pause <id> | resume <id> [--json] [--color=auto|always|never]";
1448
+ if (sub === "list" && id === undefined) return showToolText(data => renderScheduleList(data, present.columns, Date.now()), await taskOp("schedule_list", {}), present);
1449
+ if (!["run", "pause", "resume"].includes(sub ?? "") || !id || extra.length) return fail(usage);
1450
+ if (sub === "run") return showToolText(data => renderScheduleRun(data, present.columns), await taskOp("schedule_run", { id }, false, undefined, 90_000), present);
1451
+ return showToolText(data => renderScheduleChange(sub as "pause" | "resume", data, present.columns, Date.now()), await taskOp(sub === "pause" ? "schedule_pause" : "schedule_resume", { id }), present);
1452
+ },
1453
+
1454
+ learn: async () => {
1455
+ // #514b: the person's verdict on a recurring-criterion candidate (#514a). Person only, end to end: `learn` is a `console` command for an agent shell (classifyPeerCommand has no entry for it, so the
1456
+ // identity layer refuses it before this runs), and the daemon refuses any caller but the person again. `accept` writes ONE key (profile_overrides.review_checklist) to the machine-local
1457
+ // config through the settings writer; nothing in a card's text is written.
1458
+ const split = splitPresentation(args);
1459
+ const present = outputOptions(split.flags); // before any request: an invalid --color must refuse before a mutation is sent
1460
+ const usage = "usage: ahub learn list [--all] | accept <id> | reject <id> [--json] [--color=auto|always|never]";
1461
+ const [sub, ...rest] = split.operands;
1462
+ if (sub === "list" && rest.every(arg => arg === "--all")) {
1463
+ const all = rest.includes("--all");
1464
+ return showToolText(data => renderLearnList(data, present.columns, all), await taskOp("learn_list", { ...(all ? { all: true } : {}) }), present);
1465
+ }
1466
+ const [id, ...extra] = rest;
1467
+ if ((sub !== "accept" && sub !== "reject") || !id || extra.length || !/^[1-9]\d*$/.test(id)) return fail(usage);
1468
+ return showToolText(data => renderLearnChange(sub, data, present.columns), await taskOp(sub === "accept" ? "learn_accept" : "learn_reject", { id: Number(id) }), present);
1469
+ },
1470
+
1471
+ intake: async () => {
1472
+ // #334 (#511a): natural-language intake. Person only, except approve (#933: the configured conductor too, admitted and audited by the daemon).
1473
+ // The request is a draft backlog card that nothing offers until `approve`; the hub, not this process, splits it.
1474
+ const split = splitPresentation(args, { valueFlags: ["--source"] });
1475
+ const present = outputOptions(split.flags); // before any request: an invalid --color must refuse before a mutation is sent
1476
+ const usage = "usage: ahub intake \"<text>\" [--source <id>...] | approve <id> | discard <id> [--json] [--color=auto|always|never]";
1477
+ const sources: string[] = [], words: string[] = [];
1478
+ for (let i = 0; i < split.operands.length; i++) {
1479
+ const arg = split.operands[i]!;
1480
+ if (arg === "--source") { const id = split.operands[++i]; if (!id) return fail("--source needs a source id"); sources.push(id); } else words.push(arg);
1481
+ }
1482
+ const [sub, id] = words;
1483
+ if ((sub === "approve" || sub === "discard") && words.length === 2 && !sources.length) {
1484
+ if (!/^[1-9]\d*$/.test(id!)) return fail(usage);
1485
+ return showToolText(data => renderIntakeChange(sub, data, present.columns), await taskOp(sub === "approve" ? "intake_approve" : "intake_discard", { id: Number(id) }), present);
1486
+ }
1487
+ if (words.length !== 1 || !words[0]!.trim()) return fail(usage);
1488
+ return showToolText(data => renderIntake(data, present.columns), await taskOp("intake", { text: words[0], ...(sources.length ? { sources } : {}) }, false, undefined, 90_000), present);
1489
+ },
1490
+
1252
1491
  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.
1492
+ // #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
1493
  const options = outputOptions();
1255
1494
  const { one, rest } = takeFlags(args, ["--task"], []);
1256
1495
  const approved = rest.includes("--approved"), check = rest.includes("--check");
@@ -1415,14 +1654,19 @@ const commands: Record<string, () => Promise<void> | void> = {
1415
1654
  }
1416
1655
  if (sub === "merge") {
1417
1656
  // #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), [], []);
1657
+ // #899: the decision prints for a person; `--json` is the same view as a document. The presentation flags are read before the
1658
+ // request, so an invalid --color refuses before a mutation is sent, and they never reach the hub.
1659
+ const split = splitPresentation(args.slice(1)), display = outputOptions(split.flags);
1660
+ const task = Number(split.operands[0]), flags = takeFlags(split.operands.slice(1), [], []);
1419
1661
  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]");
1662
+ if (flags.rest.some(arg => arg !== "--dry-run") || !Number.isSafeInteger(task) || task <= 0) fail("usage: ahub sdlc merge <task> [--dry-run] [--json]");
1421
1663
  const hub = await connect();
1422
1664
  try {
1423
- const reply = await hub.request({ t: "sdlc", op: "merge", id, ...(dryRun ? { dryRun: true } : {}) }, 60_000);
1665
+ const reply = await hub.request({ t: "sdlc", op: "merge", id: task, ...(dryRun ? { dryRun: true } : {}) }, 60_000);
1424
1666
  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));
1667
+ 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 } : {}) };
1668
+ if (display.json) console.log(JSON.stringify(view, null, 2));
1669
+ else printOutput(renderSdlcMerge(view, display.columns, Date.now()), display.color);
1426
1670
  if (reply.decision === "refused" || reply.ran && reply.result?.state !== "merged") process.exitCode = 1;
1427
1671
  } finally { hub.close(); }
1428
1672
  return;
@@ -1481,6 +1725,13 @@ const commands: Record<string, () => Promise<void> | void> = {
1481
1725
  if (rest.length !== 1) fail("usage: ahub task show <id> [--json] [--full] [--color=auto|always|never]");
1482
1726
  return shownTask(renderTaskShow)(taskOp("task_show", { id: rest[0] }));
1483
1727
  }
1728
+ if (sub === "claim") {
1729
+ // #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.
1730
+ const { one, rest: positional } = takeFlags(rest, ["--model", "--model-said"], []);
1731
+ 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>]");
1732
+ 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 } };
1733
+ 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 ? ")" : ""}`);
1734
+ }
1484
1735
  if (sub === "escalate") return console.log(await taskOp("task_escalate", { id: rest[0] }));
1485
1736
  if (sub === "assign") {
1486
1737
  const { one, rest: positional } = takeFlags(rest, ["--role", "--revision"], []);
@@ -1495,14 +1746,18 @@ const commands: Record<string, () => Promise<void> | void> = {
1495
1746
  return console.log(await taskOp("task_unassign", { id: positional[0], role, ...(revision === undefined ? {} : { revision }) }));
1496
1747
  }
1497
1748
  if (sub === "label") {
1498
- // #247: a person's later verdict on an approved task (the identity gate keeps it human-only), for research records.
1749
+ // #247: a later verdict on an approved task, for research records (#933: an agent's own task only, attributed to it).
1499
1750
  const { one, rest: positional } = takeFlags(rest, ["--created"], []);
1500
1751
  const id = Number(positional[0]), label = positional[1] as Label;
1501
1752
  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>]`);
1753
+ // #933: the daemon checks the current board row only, so an agent never names an earlier task with this id from before a reset.
1754
+ 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
1755
  const project = researchProject();
1756
+ // #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.
1757
+ if (identity.role === "tools") await taskOp("task_label_check", { id });
1503
1758
  let record: TaskRecord;
1504
1759
  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() }]);
1760
+ 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
1761
  return console.log(`task #${id} (approved ${record!.approvedAt}) labelled ${label} in the research records`);
1507
1762
  }
1508
1763
  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>");
@@ -1715,7 +1970,7 @@ const commands: Record<string, () => Promise<void> | void> = {
1715
1970
  return console.log(paint([{ text: result.text }], false));
1716
1971
  }
1717
1972
  if (sub === "undo") {
1718
- const view = await request({ t: "settings_get" }), undoDef = settingDef(view.undo, settingScope.instances, settingScope.catalog);
1973
+ const view = await request({ t: "settings_get" }), undoDef = undoableDef(view.undo, settingScope.instances, settingScope.catalog);
1719
1974
  // A roster undo of a declared peer needs its confirmation prompt; without readable declarations, refuse rather than skip it.
1720
1975
  if (typeof undoDef === "string" && settingScopeError) fail(`pending undo ${String(view.undo)}: ${undoDef}; the project config could not be read: ${settingScopeError}`);
1721
1976
  const rosterPeer = typeof undoDef !== "string" && undoDef.group === "Roster" ? undoDef.path[1] : undefined;
@@ -1831,26 +2086,13 @@ const commands: Record<string, () => Promise<void> | void> = {
1831
2086
  const json = args.includes("--json");
1832
2087
  const [sub, ...rest] = args.filter((a) => a !== "--json");
1833
2088
  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
- }
2089
+ const { one, addons, suite, repeat } = benchRunArgs(rest);
1848
2090
  await benchPreflight(cwd, loadConfig(cwd).bench.enabled); // the suite's refs are checked once it is read
1849
2091
  let interrupted = false;
1850
2092
  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
2093
  process.on("SIGINT", stop);
1852
2094
  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));
2095
+ 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
2096
  return console.log(formatBenchReport(benchReport(readRuns().filter((r) => r.header.run === run))).join("\n"));
1855
2097
  } finally { process.off("SIGINT", stop); }
1856
2098
  }
@@ -1915,12 +2157,7 @@ const commands: Record<string, () => Promise<void> | void> = {
1915
2157
  research: () => {
1916
2158
  // #247: measures from the opt-in research records; `export` writes them out, `backfill` builds them from events.jsonl.
1917
2159
  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
- }
2160
+ researchArgs();
1924
2161
  if (sub === "backfill") {
1925
2162
  const project = researchProject();
1926
2163
  const written = appendRecords(project.id, taskRecords(readEvents(join(stateDir, "events.jsonl")), project.id, { version: VERSION, source: "backfill" }));
@@ -2084,9 +2321,14 @@ const commands: Record<string, () => Promise<void> | void> = {
2084
2321
  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
2322
  return p;
2086
2323
  };
2324
+ // #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".
2325
+ const unobserved = turn.unobserved ?? [];
2326
+ 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
2327
  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`);
2328
+ if (first.undone && !unobserved.length) return console.log(`turn ${id} is already undone: its files are as they were before it started`);
2329
+ if (first.undone) return console.log(`turn ${id}: its files are as they were before it started${unobservedNote}`);
2330
+ // "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.
2331
+ 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
2332
  console.log("(every change made in the project during the turn counts as its own, including any by Claude or by you)");
2091
2333
  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
2334
  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 +2357,8 @@ const commands: Record<string, () => Promise<void> | void> = {
2115
2357
  await stopProject(project);
2116
2358
  console.log("hub stopped");
2117
2359
  },
2360
+ // The counterpart of `ahub up`: the same verified graceful stop as `ahub kill`, never a second shutdown path.
2361
+ down: async () => { await commands.kill!(); },
2118
2362
 
2119
2363
  reset: async () => {
2120
2364
  if (args.some((a) => !["--all", "--yes"].includes(a))) fail("usage: ahub reset [--all] [--yes]");
@@ -2235,7 +2479,15 @@ const commands: Record<string, () => Promise<void> | void> = {
2235
2479
  }
2236
2480
  if (args.includes("--orphans")) {
2237
2481
  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;
2482
+ // #812: the conductor's shell asks the hub to stop its own orphan; #899: the answer prints for a person unless --json.
2483
+ const hub=await connect();
2484
+ try {
2485
+ const result=await hub.request({t:"conductor_operational",op:"orphans"},10_000);
2486
+ if(result.ok!==true)fail(String(result.error??"orphan inspection unavailable"),result.refusalAudited===true,result.authorityRefused);
2487
+ if (options.json) console.log(JSON.stringify(result,null,2));
2488
+ else printOutput(renderOrphanCleanup(result, options.columns), options.color);
2489
+ } finally{hub.close();}
2490
+ return;
2239
2491
  }
2240
2492
  return orphanDoctor(args.includes("--kill"), options);
2241
2493
  }
@@ -2395,20 +2647,26 @@ function inspectDoctorRouting(cwd: string): { routing?: Routing; errors: string[
2395
2647
  }
2396
2648
 
2397
2649
  async function runConductorCommand(): Promise<void> {
2650
+ if (recoveryRunnerContinuation) return commands[cmd]!();
2398
2651
  const operation=conductorOperation(cmd,args);
2399
2652
  if(operation) {
2400
2653
  const unavailable=`conductor authorization unavailable; nothing was executed; ${OPERATIONAL_PERSON_GUIDANCE}`;
2401
2654
  const descriptor=readControl(stateDir);
2655
+ // Offline resume proves the source-issued local continuation inside the command; no console connection is borrowed.
2656
+ if (conductorPeer && cmd === "recovery" && (!descriptor?.pid || !descriptor.pidSignature || processLiveness(descriptor.pid, descriptor.pidSignature) !== "live")) return commands.recovery!();
2402
2657
  if(!descriptor?.instanceId || !descriptor.projectId || !descriptor.pid || !descriptor.pidSignature || processLiveness(descriptor.pid,descriptor.pidSignature)!=="live")fail(unavailable);
2403
2658
  let hub:ControlClient;
2404
2659
  try {hub=await connect(3000);} catch {fail(unavailable);}
2405
2660
  let release:(()=>void)|undefined;
2406
2661
  try {
2407
2662
  let result:any;
2408
- try {result=await hub.request({t:"conductor_operational",op:"authorize",command:operation},3000);} catch {fail(unavailable);}
2663
+ // #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.
2664
+ if(conductorLocalOperation(operation))LOCAL_OPERATION_ARGS[operation]?.();
2665
+ try {result=await hub.request({t:"conductor_operational",op:conductorLocalOperation(operation)?"admit":"authorize",command:operation},3000);} catch {fail(unavailable);}
2409
2666
  if(result.ok!==true)fail(result.authorityRefused===true || result.authorityRefused===false?String(result.error??unavailable):unavailable,result.refusalAudited===true,result.authorityRefused);
2410
2667
  if(!hub.instanceId)fail(unavailable);
2411
- release=ControlClient.pinInstance(stateDir,hub.instanceId);
2668
+ // Recovery replaces its source instance. Its own per-project proofs fence every later connection.
2669
+ if (!conductorPeer || !["restart", "upgrade", "recovery"].includes(cmd)) release=ControlClient.pinInstance(stateDir,hub.instanceId);
2412
2670
  await commands[cmd]!();
2413
2671
  } finally {release?.();hub.close();}
2414
2672
  return;
@@ -2430,11 +2688,14 @@ async function runConductorCommand(): Promise<void> {
2430
2688
  } else {
2431
2689
  // Native TUIs return a human launch command through the daemon; an agent shell never executes it.
2432
2690
  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) });
2691
+ const selection = parseConfiguredAcpArguments(cmd, args, projectCatalog, declaredInstances, peerConfig.declaredExternalInstances);
2692
+ 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
2693
  if (selection.sessionId) fail("conductor starts accept no session override; a person starts a configured ACP session");
2436
2694
  const hub = await connect();
2437
2695
  try {
2696
+ const external = selection.peer !== cmd && (peerConfig.declaredExternalInstances?.some(row => row.id === selection.peer && row.kind === cmd)
2697
+ || projectCatalog.externalDefinitions.some(definition => definition.kind === cmd));
2698
+ if (external) fail("external module instances require an explicit person start");
2438
2699
  await requireActiveConfiguredAcp(hub, cmd, selection.peer, text => console.error(text));
2439
2700
  console.log(await taskOp("hub_peer_start", { peer: selection.peer, mode: "headless" }, false, hub, 70_000));
2440
2701
  } finally { hub.close(); }
@@ -2451,9 +2712,9 @@ async function runConductorCommand(): Promise<void> {
2451
2712
  console.log(await taskOp(op, input));
2452
2713
  }
2453
2714
  const runConfigured = configuredCommand ? async () => {
2454
- const selection = parseConfiguredAcpArguments(cmd, args, projectCatalog, declaredInstances);
2715
+ const selection = parseConfiguredAcpArguments(cmd, args, projectCatalog, declaredInstances, peerConfig.declaredExternalInstances);
2455
2716
  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 });
2717
+ 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
2718
  } : undefined;
2458
2719
  const run = identity.role === "tools" && commandAccess === "conductor" ? runConductorCommand : (Object.hasOwn(commands, cmd) ? commands[cmd] : undefined) ?? runConfigured ?? (() => fail(`unknown command "${cmd}"; run ahub help`));
2459
2720
  try {