@platless/axom-cli 0.1.0 → 0.5.9

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 (281) hide show
  1. package/README.md +1 -1
  2. package/dist/authScopes.js +19 -0
  3. package/dist/axom-patch/cli/README.md +132 -0
  4. package/dist/axom-patch/cli/account-limits/README.md +27 -0
  5. package/dist/axom-patch/cli/account-limits/antigravity-proxy.mjs +47 -0
  6. package/dist/axom-patch/cli/account-limits/antigravity-read.mjs +90 -0
  7. package/dist/axom-patch/cli/account-limits/antigravity.mjs +46 -0
  8. package/dist/axom-patch/cli/account-limits/generate.py +120 -0
  9. package/dist/axom-patch/cli/account-limits/kimi-proxy.mjs +64 -0
  10. package/dist/axom-patch/cli/account-limits/normalize.mjs +151 -0
  11. package/dist/axom-patch/cli/claude-account-limits.json +37 -0
  12. package/dist/axom-patch/cli/codex-account-limits.json +37 -0
  13. package/dist/axom-patch/cli/commandcode-account-limits.json +30 -0
  14. package/dist/axom-patch/cli/nightly-task.md +152 -0
  15. package/dist/axom-patch/cli/pi-steering.json +37 -0
  16. package/dist/axom-patch/cli/pi-subagent-subwork.json +23 -0
  17. package/dist/axom-patch/cli/probes/claude-late-async-terminal.mjs +38 -0
  18. package/dist/axom-patch/cli/probes/pi-steering.mjs +80 -0
  19. package/dist/axom-patch/cli/probes/pi-subagent-subwork.mjs +211 -0
  20. package/dist/axom-patch/cli/probes/qoder-steering.mjs +89 -0
  21. package/dist/axom-patch/cli/probes/qoder-subagent-origin.mjs +61 -0
  22. package/dist/axom-patch/cli/probes/zcode-subagent-subwork.mjs +257 -0
  23. package/dist/axom-patch/cli/qoder-background-terminal.md +82 -0
  24. package/dist/axom-patch/cli/qoder-foreground-output.md +42 -0
  25. package/dist/axom-patch/cli/qoder-steering.json +52 -0
  26. package/dist/axom-patch/cli/qoder-steering.md +30 -0
  27. package/dist/axom-patch/cli/qoder-subagent-origin.json +73 -0
  28. package/dist/axom-patch/cli/qoder-subagent-subwork.md +73 -0
  29. package/dist/axom-patch/cli/runtime.json +63 -0
  30. package/dist/axom-patch/cli/steering/claude-steering-js.patch +89 -0
  31. package/dist/axom-patch/cli/steering/claude-steering-types.patch +28 -0
  32. package/dist/axom-patch/cli/steering/codex-steering.patch +57 -0
  33. package/dist/axom-patch/cli/steering/pi-steering.js +79 -0
  34. package/dist/axom-patch/cli/zcode-account-limits.json +31 -0
  35. package/dist/axom-patch/cli/zcode-models.json +38 -0
  36. package/dist/axom-patch/cli/zcode-subagent-subwork.json +73 -0
  37. package/dist/cli.js +36 -1
  38. package/dist/commands/auth.js +4 -13
  39. package/dist/commands/execute.js +4 -4
  40. package/dist/commands/record.js +6 -6
  41. package/dist/commands/struct.js +4 -4
  42. package/dist/commands/workstation.js +2 -2
  43. package/dist/credentials.js +2 -2
  44. package/dist/install/claude/commands.js +13 -13
  45. package/dist/keepers/acp/keeper.mjs +3083 -0
  46. package/dist/keepers/acp/manifest.json +9 -0
  47. package/dist/keepers/terminal/keeper.mjs +8469 -0
  48. package/dist/keepers/terminal/manifest.json +9 -0
  49. package/dist/keepers/terminal/node_modules/node-pty/lib/conpty_console_list_agent.js +25 -0
  50. package/dist/keepers/terminal/node_modules/node-pty/lib/eventEmitter2.js +47 -0
  51. package/dist/keepers/terminal/node_modules/node-pty/lib/index.js +52 -0
  52. package/dist/keepers/terminal/node_modules/node-pty/lib/interfaces.js +7 -0
  53. package/dist/keepers/terminal/node_modules/node-pty/lib/shared/conout.js +10 -0
  54. package/dist/keepers/terminal/node_modules/node-pty/lib/terminal.js +190 -0
  55. package/dist/keepers/terminal/node_modules/node-pty/lib/types.js +7 -0
  56. package/dist/keepers/terminal/node_modules/node-pty/lib/unixTerminal.js +351 -0
  57. package/dist/keepers/terminal/node_modules/node-pty/lib/utils.js +38 -0
  58. package/dist/keepers/terminal/node_modules/node-pty/lib/windowsConoutConnection.js +131 -0
  59. package/dist/keepers/terminal/node_modules/node-pty/lib/windowsPtyAgent.js +344 -0
  60. package/dist/keepers/terminal/node_modules/node-pty/lib/windowsTerminal.js +209 -0
  61. package/dist/keepers/terminal/node_modules/node-pty/lib/worker/conoutSocketWorker.js +22 -0
  62. package/dist/keepers/terminal/node_modules/node-pty/package.json +66 -0
  63. package/dist/keepers/terminal/node_modules/node-pty/prebuilds/darwin-arm64/pty.node +0 -0
  64. package/dist/keepers/terminal/node_modules/node-pty/prebuilds/darwin-arm64/spawn-helper +0 -0
  65. package/dist/keepers/terminal/node_modules/node-pty/prebuilds/darwin-x64/pty.node +0 -0
  66. package/dist/keepers/terminal/node_modules/node-pty/prebuilds/darwin-x64/spawn-helper +0 -0
  67. package/dist/keepers/terminal/node_modules/node-pty/prebuilds/linux-arm64/pty.node +0 -0
  68. package/dist/keepers/terminal/node_modules/node-pty/prebuilds/linux-x64/pty.node +0 -0
  69. package/dist/mcp.js +32 -14
  70. package/dist/runtime-deps/qoder-sdk/package-lock.json +1189 -0
  71. package/dist/runtime-deps/qoder-sdk/package.json +8 -0
  72. package/dist/test/keeperKit.js +67 -0
  73. package/dist/workstation/acp/acpAgents.js +2514 -681
  74. package/dist/workstation/acp/acpClient.js +95 -318
  75. package/dist/workstation/acp/activityBusy.js +43 -0
  76. package/dist/workstation/acp/activityReporting.js +54 -0
  77. package/dist/workstation/acp/adoption.js +28 -356
  78. package/dist/workstation/acp/authUrlSniffer.js +36 -0
  79. package/dist/workstation/acp/binaryInstall.js +100 -27
  80. package/dist/workstation/acp/builds/gc.js +245 -0
  81. package/dist/workstation/acp/builds/install/binary.js +28 -0
  82. package/dist/workstation/acp/builds/install/npm.js +235 -0
  83. package/dist/workstation/acp/builds/install/uv.js +254 -0
  84. package/dist/workstation/acp/builds/migrate.js +195 -0
  85. package/dist/workstation/acp/builds/model.js +128 -0
  86. package/dist/workstation/acp/builds/nodeGate.js +100 -0
  87. package/dist/workstation/acp/builds/store.js +352 -0
  88. package/dist/workstation/acp/commandTableProbe.js +68 -0
  89. package/dist/workstation/acp/configOptions.js +103 -1
  90. package/dist/workstation/acp/delegationMcp.js +24 -0
  91. package/dist/workstation/acp/envSecrets.js +27 -0
  92. package/dist/workstation/acp/initializeResponse.js +285 -0
  93. package/dist/workstation/acp/installErrors.js +12 -2
  94. package/dist/workstation/acp/installProbe.js +1 -1
  95. package/dist/workstation/acp/managedPatches.js +144 -0
  96. package/dist/workstation/acp/outbox.js +330 -0
  97. package/dist/workstation/acp/patchAssets.js +80 -0
  98. package/dist/workstation/acp/permission.js +335 -37
  99. package/dist/workstation/acp/processor/checkpoint.js +87 -0
  100. package/dist/workstation/acp/processor/commands.js +35 -0
  101. package/dist/workstation/acp/processor/control.js +355 -0
  102. package/dist/workstation/acp/processor/emit.js +26 -0
  103. package/dist/workstation/acp/processor/jsonrpc.js +172 -0
  104. package/dist/workstation/acp/processor/memoryLog.js +49 -0
  105. package/dist/workstation/acp/processor/origins.js +48 -0
  106. package/dist/workstation/acp/processor/output.js +67 -0
  107. package/dist/workstation/acp/processor/projection.js +42 -0
  108. package/dist/workstation/acp/processor/record.js +128 -0
  109. package/dist/workstation/acp/processor/runtime.js +181 -0
  110. package/dist/workstation/acp/processor/sdkSchemas.js +61 -0
  111. package/dist/workstation/acp/processor/session.js +525 -0
  112. package/dist/workstation/acp/processor/sideChannel.js +80 -0
  113. package/dist/workstation/acp/processor/state.js +74 -0
  114. package/dist/workstation/acp/processor/step.js +459 -0
  115. package/dist/workstation/acp/processor/test/fixtureLog.js +65 -0
  116. package/dist/workstation/acp/processor/test/harness.js +120 -0
  117. package/dist/workstation/acp/processor/test/largeIdEcho.js +50 -0
  118. package/dist/workstation/acp/processor/test/rawAgent.js +70 -0
  119. package/dist/workstation/acp/processor/transport/generations.js +76 -0
  120. package/dist/workstation/acp/processor/transport/keeper.js +130 -0
  121. package/dist/workstation/acp/processor/transport/local.js +269 -0
  122. package/dist/workstation/acp/processor/transport/types.js +78 -0
  123. package/dist/workstation/acp/processor/updates.js +364 -0
  124. package/dist/workstation/acp/processorSession.js +1512 -0
  125. package/dist/workstation/acp/qoderStartup.js +45 -0
  126. package/dist/workstation/acp/registry/catalog.js +385 -4
  127. package/dist/workstation/acp/registry/detect.js +55 -0
  128. package/dist/workstation/acp/registry/launch.js +61 -119
  129. package/dist/workstation/acp/sessionUpdateMapper.js +56 -27
  130. package/dist/workstation/acp/steering.js +58 -0
  131. package/dist/workstation/acp/switching.js +114 -0
  132. package/dist/workstation/acp/terminalLogin.js +171 -0
  133. package/dist/workstation/acp/terminalLoginSpec.js +41 -0
  134. package/dist/workstation/acp/test/adapterFixtures.js +256 -0
  135. package/dist/workstation/acp/test/daemonLife.js +106 -0
  136. package/dist/workstation/acp/test/fakeAgent.js +185 -9
  137. package/dist/workstation/acp/test/keeperWorld.js +267 -0
  138. package/dist/workstation/acp/test/memoryOutbox.js +55 -0
  139. package/dist/workstation/acp/test/raw/fixtures.js +196 -0
  140. package/dist/workstation/acp/test/raw/qoderCaptures.js +125 -0
  141. package/dist/workstation/acp/test/raw/sanitize.js +344 -0
  142. package/dist/workstation/acp/test/raw/sources.js +249 -0
  143. package/dist/workstation/acp/test/realAdapter.js +377 -0
  144. package/dist/workstation/acp/test/scriptAgent.js +206 -0
  145. package/dist/workstation/acp/test/sessionFrames.js +42 -0
  146. package/dist/workstation/acp/timedCommand.js +4 -2
  147. package/dist/workstation/acp/uvxInstall.js +72 -122
  148. package/dist/workstation/acp/validate.js +33 -5
  149. package/dist/workstation/agentFlow.js +103 -0
  150. package/dist/workstation/agentSessions.js +1686 -348
  151. package/dist/workstation/channels/agent.js +14 -6
  152. package/dist/workstation/channels/computer.js +203 -7
  153. package/dist/workstation/channels/computerExecs.js +200 -0
  154. package/dist/workstation/channels/management.js +86 -6
  155. package/dist/workstation/config.js +3 -1
  156. package/dist/workstation/connection/encryptedChannel.js +88 -21
  157. package/dist/workstation/daemon.js +309 -6
  158. package/dist/workstation/daemonctl.js +28 -2
  159. package/dist/workstation/desktop/binding.js +36 -0
  160. package/dist/workstation/desktop/bridge.js +212 -0
  161. package/dist/workstation/desktop/flow-control.js +52 -0
  162. package/dist/workstation/desktop/focused-text.js +130 -0
  163. package/dist/workstation/desktop/protocol.js +35 -0
  164. package/dist/workstation/exec/attach.js +126 -0
  165. package/dist/workstation/exec/command.js +40 -0
  166. package/dist/workstation/exec/durable.js +517 -0
  167. package/dist/workstation/exec/fallback.js +106 -0
  168. package/dist/workstation/exec/policy.js +837 -0
  169. package/dist/workstation/exec/shaping.js +211 -0
  170. package/dist/workstation/exec.js +3 -112
  171. package/dist/workstation/files/adapters/computerFileFrames.js +51 -0
  172. package/dist/workstation/files/adapters/toolRequests.js +121 -0
  173. package/dist/workstation/files/client.js +455 -0
  174. package/dist/workstation/files/errors.js +28 -0
  175. package/dist/workstation/files/fence.js +350 -0
  176. package/dist/workstation/files/find/fileIndex.js +153 -0
  177. package/dist/workstation/files/find/fuzzy.js +155 -0
  178. package/dist/workstation/files/host.js +122 -0
  179. package/dist/workstation/files/hostProtocol.js +2 -0
  180. package/dist/workstation/files/media/cache.js +620 -0
  181. package/dist/workstation/files/media/encoding.js +308 -0
  182. package/dist/workstation/files/media/estimator.js +100 -0
  183. package/dist/workstation/files/media/media.js +325 -0
  184. package/dist/workstation/files/media/packedAudio.js +23 -0
  185. package/dist/workstation/files/media/scheduler.js +551 -0
  186. package/dist/workstation/files/media/types.js +5 -0
  187. package/dist/workstation/files/scope.js +45 -0
  188. package/dist/workstation/files/structured.js +284 -0
  189. package/dist/workstation/files/text.js +356 -0
  190. package/dist/workstation/files/uploads.js +351 -0
  191. package/dist/workstation/journal.js +65 -236
  192. package/dist/workstation/keepers/acp/client.js +101 -0
  193. package/dist/workstation/keepers/acp/envelope.js +62 -0
  194. package/dist/workstation/keepers/acp/host.js +419 -0
  195. package/dist/workstation/keepers/acp/main.js +18 -0
  196. package/dist/workstation/keepers/acp/memoryStream.js +121 -0
  197. package/dist/workstation/keepers/acp/params.js +137 -0
  198. package/dist/workstation/keepers/acp/protocol.js +66 -0
  199. package/dist/workstation/keepers/acp/recover.js +120 -0
  200. package/dist/workstation/keepers/acp/session.js +584 -0
  201. package/dist/workstation/keepers/acp/stderr.js +154 -0
  202. package/dist/workstation/keepers/kit/activity.js +103 -0
  203. package/dist/workstation/keepers/kit/bootstrap.js +164 -0
  204. package/dist/workstation/keepers/kit/buildVersion.js +51 -0
  205. package/dist/workstation/keepers/kit/bundle.js +120 -0
  206. package/dist/workstation/keepers/kit/client.js +432 -0
  207. package/dist/workstation/keepers/kit/identity.js +21 -0
  208. package/dist/workstation/keepers/kit/ipc.js +112 -0
  209. package/dist/workstation/keepers/kit/launchConfig.js +8 -0
  210. package/dist/workstation/keepers/kit/launcher.js +249 -0
  211. package/dist/workstation/keepers/kit/lock.js +123 -0
  212. package/dist/workstation/keepers/kit/orphanSweep.js +86 -0
  213. package/dist/workstation/keepers/kit/paths.js +75 -0
  214. package/dist/workstation/keepers/kit/protocol.js +43 -0
  215. package/dist/workstation/keepers/kit/server.js +518 -0
  216. package/dist/workstation/keepers/kit/streamLog.js +697 -0
  217. package/dist/workstation/keepers/legacy.js +296 -0
  218. package/dist/workstation/keepers/policy.js +576 -0
  219. package/dist/workstation/keepers/stop.js +34 -0
  220. package/dist/workstation/keepers/terminal/client.js +213 -0
  221. package/dist/workstation/keepers/terminal/exec.js +981 -0
  222. package/dist/workstation/keepers/terminal/generations.js +129 -0
  223. package/dist/workstation/keepers/terminal/keeper.js +62 -0
  224. package/dist/workstation/keepers/terminal/main.js +43 -0
  225. package/dist/workstation/keepers/terminal/orphanSweep.js +3 -0
  226. package/dist/workstation/keepers/terminal/params.js +27 -0
  227. package/dist/workstation/keepers/terminal/protocol.js +56 -0
  228. package/dist/workstation/keepers/terminal/pty.js +330 -0
  229. package/dist/workstation/oauth/deviceAuthorization.js +21 -12
  230. package/dist/workstation/protocol/accountLimits.js +47 -0
  231. package/dist/workstation/protocol/agent.js +201 -8
  232. package/dist/workstation/protocol/common.js +36 -0
  233. package/dist/workstation/protocol/computer.js +107 -7
  234. package/dist/workstation/protocol/management.js +50 -2
  235. package/dist/workstation/qoderAdapter/backgroundProcess.js +118 -0
  236. package/dist/workstation/qoderAdapter/backgroundTerminal.js +193 -0
  237. package/dist/workstation/qoderAdapter/backgroundTerminalFold.js +372 -0
  238. package/dist/workstation/qoderAdapter/callbackContext.js +76 -0
  239. package/dist/workstation/qoderAdapter/completedContinuation.js +139 -0
  240. package/dist/workstation/qoderAdapter/contract.js +78 -0
  241. package/dist/workstation/qoderAdapter/foregroundOutput.js +36 -0
  242. package/dist/workstation/qoderAdapter/history.js +85 -0
  243. package/dist/workstation/qoderAdapter/identityIndex.js +191 -0
  244. package/dist/workstation/qoderAdapter/inputQueue.js +42 -0
  245. package/dist/workstation/qoderAdapter/main.js +95 -0
  246. package/dist/workstation/qoderAdapter/mapper.js +117 -0
  247. package/dist/workstation/qoderAdapter/observerPlugin.js +245 -0
  248. package/dist/workstation/qoderAdapter/observerReceiver.js +187 -0
  249. package/dist/workstation/qoderAdapter/permissions.js +61 -0
  250. package/dist/workstation/qoderAdapter/profile.js +93 -0
  251. package/dist/workstation/qoderAdapter/sdkDriver.js +48 -0
  252. package/dist/workstation/qoderAdapter/sdkRuntime.js +93 -0
  253. package/dist/workstation/qoderAdapter/sdkRuntimeInstall.js +145 -0
  254. package/dist/workstation/qoderAdapter/session.js +319 -0
  255. package/dist/workstation/qoderAdapter/subagentMeta.js +28 -0
  256. package/dist/workstation/qoderAdapter/subwork.js +85 -0
  257. package/dist/workstation/qoderAdapter/taskControl.js +38 -0
  258. package/dist/workstation/qoderAdapter/taskLedger.js +163 -0
  259. package/dist/workstation/qoderAdapter/types.js +1 -0
  260. package/dist/workstation/qoderAdapter/usage.js +37 -0
  261. package/dist/workstation/register.js +4 -0
  262. package/dist/workstation/systemMetrics.js +110 -1
  263. package/dist/workstation/tools/browser.js +394 -0
  264. package/dist/workstation/tools/client.js +277 -0
  265. package/dist/workstation/tools/info.js +50 -0
  266. package/dist/workstation/tools/keeperProcess.js +124 -0
  267. package/dist/workstation/tools/router.js +55 -0
  268. package/dist/workstation/tools/terminalRouter.js +222 -0
  269. package/dist/workstation/tools/validation.js +86 -0
  270. package/dist/workstation/update/coordinator.js +32 -0
  271. package/dist/workstation/update/npmInstaller.js +2 -0
  272. package/dist/workstation/update/ptyPreflight.js +45 -0
  273. package/dist/workstation/update/taskRegistry.js +141 -23
  274. package/dist/workstation/update/watchdog.js +66 -0
  275. package/package.json +20 -7
  276. package/skills/axom/SKILL.md +55 -17
  277. package/skills/axom-definition/SKILL.md +32 -32
  278. package/skills/axom-platform/SKILL.md +39 -39
  279. package/dist/workstation/acp/acpSession.js +0 -1047
  280. package/dist/workstation/acp/contract.js +0 -494
  281. package/dist/workstation/acp/draftInterceptor.js +0 -154
@@ -1,18 +1,78 @@
1
+ import { homedir } from 'node:os';
2
+ /**
3
+ * The daemon's single entry point into ACP — what `agentSessions.ts` holds where it used to hold a
4
+ * `Map<string, AgentBridge>`.
5
+ *
6
+ * ## What replaced what
7
+ *
8
+ * The deleted bridge SPI dispatched on a *bridge type* (`claude-code` | `codex` | `acp`) to one of
9
+ * several implementations. There is only one implementation now, so there is nothing to dispatch to:
10
+ * ruling R1 deleted the interface rather than leaving an extension point with a single member. What
11
+ * varies per agent is no longer code but **data** — the launch spec — so the key is a **registry
12
+ * agent id** (`claude-acp`, `codex-acp`, …) and this class turns that id into a running subprocess
13
+ * by way of the ACP agent registry (`registry/`) and a processor-driven session (`processorSession.ts`, over keeper-acp or the
14
+ * local transport); `connectAgent` (`acpClient.ts`) serves the short-lived connections (list, delete, verify, sign-in).
15
+ *
16
+ * ## One connection per session, deliberately
17
+ *
18
+ * Every {@link AcpAgents.open} spawns its own agent subprocess and its own `AcpConnection`, and
19
+ * {@link AcpAgentSession.close} tears both down together. Pooling one connection across sessions
20
+ * would be cheaper and is exactly what `permission.ts`'s `PermissionSink` doc comment warns against:
21
+ * `registerPermissionHandlers` binds one sink per connection with no per-session routing, so two
22
+ * sessions sharing a connection would cross-deliver permission cards, and `createDeferredPermissionSink`
23
+ * would throw on the second `bind()`. The one-subprocess-per-session shape is also what the deleted
24
+ * bridges had, so nothing downstream had to change.
25
+ *
26
+ * ## Config overrides beat the registry (ruling R3)
27
+ *
28
+ * A workstation's agent config block may carry `command`/`args`. When it does, that is the whole
29
+ * launch spec — the registry is not consulted at all, and no registry-supplied `env` is merged in.
30
+ * This is the escape hatch for an agent that is not in the registry, a locally built adapter, or a
31
+ * wrapper script; it is also what lets this module's own tests drive a real ACP subprocess without
32
+ * reaching the network or running `npx`.
33
+ */
34
+ import { RequestError } from '@agentclientprotocol/sdk';
35
+ import { certifiedRuntime } from '../qoderAdapter/foregroundOutput.js';
36
+ import { existsSync } from 'node:fs';
37
+ import { readdir } from 'node:fs/promises';
1
38
  import path from 'node:path';
39
+ import { createHash, randomUUID } from 'node:crypto';
2
40
  import semver from 'semver';
3
- import { connectAgent, isMissingPackageVersion, jsonRpcErrorCode, stderrShowsMissingPackageVersion } from './acpClient.js';
4
- import { AcpSession } from './acpSession.js';
5
- import { createReplayCapture, listNativeSessions, replayNativeSession } from './adoption.js';
41
+ import { connectAgent, jsonRpcErrorCode, stderrShowsMissingPackageVersion } from './acpClient.js';
42
+ import { launchForTerminalLogin, runTerminalLogin, terminalInvocationFor } from './terminalLogin.js';
43
+ import { createCommandTableProbe } from './commandTableProbe.js';
44
+ import { deleteNativeSessions, listNativeSessions } from './adoption.js';
6
45
  import { AcpAuth } from './auth.js';
7
- import { binaryTargetFor, installBinary, installedBinaryVersion, platformTriple } from './binaryInstall.js';
8
- import { normalizeConfigOptions } from './configOptions.js';
9
- import { friendlyOrdinaryInstallError, friendlyPinnedVersionUnavailableAndFallbackFailed, logInstallFailureDetail, } from './installErrors.js';
10
- import { ADAPTER_INSTALL_TIMEOUT_MS, findOnPath, installNpxPackage, npmPackageName, npxCacheVersion, probeEnvironment, readNpxCache, runDependencyInstall, } from './installProbe.js';
46
+ import { binaryTargetFor, installBinary, platformTriple } from './binaryInstall.js';
47
+ import { binaryBuild } from './builds/install/binary.js';
48
+ import { defaultNpmCommand, ensurePristine, npmBuild } from './builds/install/npm.js';
49
+ import { installUvBuild } from './builds/install/uv.js';
50
+ import { adoptNpxCopy, findNpxCopy, legacyBinaryVersions, moveLegacyBinary, RESERVED_ROOT_NAMES } from './builds/migrate.js';
51
+ import { AdapterNotInstalledError, AdapterStillInstallingError, binaryBuildDir, npmPristineDir, inside } from './builds/model.js';
52
+ import { AdapterNodeVersionError } from './builds/nodeGate.js';
53
+ import { buildKey as buildKeyOf, collectBuilds, collectLegacy, processCommandLines } from './builds/gc.js';
54
+ import { keyGenOf, refOfSession, resolveSuperseded, restoreConfigOptions, sessionKeyFor, SWITCH_ABANDONED_NOTE, SWITCH_SUSPEND_TIMEOUT_MS, } from './switching.js';
55
+ import { decodeCheckpoint } from './processor/checkpoint.js';
56
+ import { BuildStore } from './builds/store.js';
57
+ import { selectPackagePatches, patchDigest } from './managedPatches.js';
58
+ import { nativeExtensionFor, nativeAccountLimitsVersionFor } from './patchAssets.js';
59
+ import { ACCOUNT_LIMITS_READ_TIMEOUT_MS } from '../protocol/accountLimits.js';
60
+ import { buildSessionMcpServers } from './delegationMcp.js';
61
+ import { overlayEnvSecrets } from './envSecrets.js';
62
+ import { AdoptionRefusedError, ProcessorSession } from './processorSession.js';
63
+ import { hostsRunningAdapter } from './processor/transport/generations.js';
64
+ import { isOutboxTail } from './outbox.js';
65
+ import { LocalTransport } from './processor/transport/local.js';
66
+ import { AcpKeeperUnavailableError } from '../keepers/acp/client.js';
67
+ import { prepareQoderStartup, QODER_MANAGED_VERSION } from './qoderStartup.js';
68
+ import { friendlyOrdinaryInstallError, friendlyPinnedVersionUnavailableAndFallbackFailed, friendlyUvMissingError, logInstallFailureDetail, } from './installErrors.js';
69
+ import { ADAPTER_INSTALL_TIMEOUT_MS, npmCacheDir, npmPackageName, npmPackageVersion, pickInstalledVersion, findOnPath, probeEnvironment, runDependencyInstall, } from './installProbe.js';
11
70
  import { createDeferredPermissionSink, registerPermissionHandlers } from './permission.js';
12
- import { catalogEntryFor } from './registry/catalog.js';
13
- import { resolveLaunch, unpinnedFallbackFor } from './registry/launch.js';
71
+ import { catalogEntryFor, catalogLaunchEnv, effectiveEntries, RECOMMENDED_RANK, withCatalogOverrides } from './registry/catalog.js';
72
+ import { detected } from './registry/detect.js';
73
+ import { launchForBuild, withPathPrefix } from './registry/launch.js';
14
74
  import { RegistryClient } from './registry/registryClient.js';
15
- import { installUvTool, listUvTools, uvPackageName, uvToolVersion } from './uvxInstall.js';
75
+ import { defaultRunUv, parseUvxSpec, UV_TOOL_INSTALL_TIMEOUT_MS } from './uvxInstall.js';
16
76
  function errorMessage(error) {
17
77
  return error instanceof Error ? error.message : String(error);
18
78
  }
@@ -44,8 +104,8 @@ function errorMessage(error) {
44
104
  */
45
105
  export class AgentRefusedSessionError extends Error {
46
106
  errorCode;
47
- constructor(cause) {
48
- super(errorMessage(cause), { cause });
107
+ constructor(cause, publicMessage) {
108
+ super(publicMessage ?? errorMessage(cause), publicMessage === undefined ? { cause } : undefined);
49
109
  this.name = 'AgentRefusedSessionError';
50
110
  this.errorCode = jsonRpcErrorCode(cause);
51
111
  }
@@ -184,9 +244,11 @@ export function createLoginUrlSink(login) {
184
244
  /** A verification or authentication ran past {@link AGENT_PROBE_TIMEOUT_MS}. */
185
245
  export class AgentProbeTimeoutError extends Error {
186
246
  }
247
+ /** A real session stopped answering after initialize; closing its transport reaps the child. */
248
+ export class AgentSessionStartTimeoutError extends Error {
249
+ }
187
250
  /**
188
- * Reject with {@link AgentProbeTimeoutError} once `timeoutMs` has passed, whatever `work` is still
189
- * doing.
251
+ * Reject with the specified timeout error once `timeoutMs` has passed, whatever `work` is still doing.
190
252
  *
191
253
  * `work` is deliberately *not* cancelled here — nothing in ACP can cancel an `initialize` or a
192
254
  * `session/new` in flight. What actually stops it is the caller's `connection.close()`, which kills
@@ -194,10 +256,10 @@ export class AgentProbeTimeoutError extends Error {
194
256
  * it will usually reject (the child died under it) long after nobody is listening, and an unhandled
195
257
  * rejection would take the daemon down.
196
258
  */
197
- async function withDeadline(work, timeoutMs, what) {
259
+ async function withDeadline(work, timeoutMs, what, timeoutError = AgentProbeTimeoutError) {
198
260
  let timer;
199
261
  const deadline = new Promise((_, reject) => {
200
- timer = setTimeout(() => reject(new AgentProbeTimeoutError(`${what} did not finish within ${timeoutMs}ms.`)), timeoutMs);
262
+ timer = setTimeout(() => reject(new timeoutError(`${what} did not finish within ${timeoutMs}ms.`)), timeoutMs);
201
263
  });
202
264
  deadline.catch(() => { });
203
265
  try {
@@ -212,7 +274,7 @@ async function withDeadline(work, timeoutMs, what) {
212
274
  * 等**第一条** `available_commands_update` 的窗口。一条都没等到就收工,`observed` 保持 undefined
213
275
  * ——按「缺席 ≠ 空表」的约定,服务端据此不写命令表那一半(见 {@link verify} 的 KDoc)。
214
276
  *
215
- * 数值沿用原来那个固定 settle 窗口(`docs/superpowers/notes/2026-08-31-codex-command-table-race.md`)。
277
+ * 数值沿用原来那个固定 settle 窗口(`archived/docs/superpowers/notes/2026-08-31-codex-command-table-race.md`)。
216
278
  * **没有证据支持把它调大**:真机上量到同一个 agent 在一台机器上回 72 条、在另一台回 0 条,但那两次
217
279
  * 的耗时差(8.4s vs 2.1s)完全可能全部落在 spawn/connect 上,而不是广播来晚了。要区分这两件事,
218
280
  * 得先有 {@link verify} 里那条「没等到表」的日志,不是先改这个数字。
@@ -232,6 +294,13 @@ const COMMAND_QUIET_MS = 750;
232
294
  * 必须显著小于 {@link AGENT_PROBE_TIMEOUT_MS} —— 连接和 `session/new` 还要在这之前各花一段。
233
295
  */
234
296
  const COMMAND_SETTLE_CEILING_MS = 5_000;
297
+ /**
298
+ * p3-fix7 N9: a verification whose first opening saw no command table opens one more session and waits this long for
299
+ * the first broadcast. "Nothing seen" is not an observation: the dispatch planner reads "offers
300
+ * superpowers:brainstorming" off this table, and a missed table can flip a project to native. Kept well inside
301
+ * {@link AGENT_PROBE_TIMEOUT_MS} with the first attempt's window and two session openings.
302
+ */
303
+ const COMMAND_RETRY_FIRST_BROADCAST_MS = 4_000;
235
304
  /** ACP 的 `AvailableCommand` → 隧道形状:`input.hint` 拍平成 `inputHint`。 */
236
305
  function toAvailableCommandWire(raw) {
237
306
  const command = raw;
@@ -250,42 +319,49 @@ function toAvailableCommandWire(raw) {
250
319
  * others — `verify` and `listNativeSessions` are exactly the paths a settings page depends on, and
251
320
  * they are the ones nobody remembers.
252
321
  */
322
+ /** The same observation, off the processor's `initialize` reading (a live session's). */
323
+ function observationOfFacts(facts) {
324
+ return {
325
+ authMethods: facts.authMethods,
326
+ supportsAccountLimits: facts.supportsAccountLimits,
327
+ supportsSteering: facts.supportsSteering,
328
+ terminalLogins: new Map(facts.terminalLogins),
329
+ };
330
+ }
331
+ /** keeper-acp's bound on a spawn spec's lists (`params.ts` MAX_LIST). */
332
+ const ACP_STDERR_SECRETS_MAX = 256;
333
+ /** A spawn spec's environment is complete (keeper-acp never reads its own): `process.env`'s unset entries dropped. */
334
+ function completeEnvironment(env) {
335
+ const out = {};
336
+ for (const [key, value] of Object.entries(env))
337
+ if (typeof value === 'string')
338
+ out[key] = value;
339
+ return out;
340
+ }
341
+ /** `initialize` on a session open (`connectAgent`'s default, which the old path used). */
342
+ const DEFAULT_SESSION_INITIALIZE_TIMEOUT_MS = 30_000;
253
343
  function observationOf(connection) {
254
- return { authMethods: connection.authMethods, supportsSteering: connection.supportsSteering };
344
+ return {
345
+ authMethods: connection.authMethods,
346
+ supportsAccountLimits: connection.supportsAccountLimits,
347
+ supportsSteering: connection.supportsSteering,
348
+ terminalLogins: connection.terminalLogins,
349
+ };
255
350
  }
256
351
  /**
257
- * The version of `agent`'s ACP adapter actually found on this machine, dispatched by distribution
258
- * channel — the npx cache (`npxCacheVersion`), a hoisted `uv tool list` snapshot (`uvVersions`), or
259
- * the binary install root (`installedBinaryVersion`). Unlike `adapterInstalled`, which only ever
260
- * answered for npx, this covers all three, and `undefined` here is a genuine "not installed" rather
261
- * than "did not look" — every branch is a cheap local read this daemon always performs.
262
- *
263
- * An agent with more than one distribution (uncommon on the live registry, but not ruled out by the
264
- * types) is checked npx-first, then uvx, then binary — the same preference order `resolveLaunch`
265
- * uses to pick which one actually launches.
266
- *
267
- * `uvVersions` is a pre-read `uv tool list` snapshot ({@link listUvTools}), not a live call: `uv` is
268
- * a real subprocess, and this function runs once per registry entry inside {@link AcpAgents.roster}'s
269
- * map — spawning it per uvx agent there is exactly the per-agent-subprocess defect the roster's own
270
- * doc comment rules out (39 entries, on every scan). The caller reads `uv tool list` exactly once and
271
- * hands the parsed result in here for a plain map lookup.
352
+ * The version a new session of an agent would run on this machine: its **current build**
353
+ * (`builds.json`, spec §6.4 — `adapterVersion` reports current), for every distribution, uv included
354
+ * (Task 15b). `undefined` is a genuine "not installed".
272
355
  */
273
- function adapterVersionFor(agent, npxCache, adapterRoot, uvVersions) {
274
- const { npx, uvx, binary } = agent.distribution;
275
- if (npx)
276
- return npxCacheVersion(npx.package, npxCache);
277
- if (uvx)
278
- return uvVersions.get(uvPackageName(uvx.package));
279
- if (binary)
280
- return installedBinaryVersion(agent.id, adapterRoot, agent.version);
281
- return undefined;
356
+ function adapterVersionFor(current) {
357
+ return current?.version;
282
358
  }
283
359
  /**
284
360
  * Whether `agent` cannot be launched on this machine **at all** — not "its `binary` archive is
285
361
  * missing for this platform", which is what this used to check on its own, independent of whatever
286
362
  * else the agent offers.
287
363
  *
288
- * `resolveLaunch` (`registry/launch.ts`) tries npx first, then uvx, then binary, and an agent with
364
+ * Install dispatches npx first, then uvx, then binary, and an agent with
289
365
  * either of the first two is launchable through it regardless of what its `binary` block does or
290
366
  * does not cover — `kilo` and `sigit` on the live registry both carry `npx` *and* `binary`, and a
291
367
  * platform missing their binary archive is still fully served by npx. So this derives its answer
@@ -304,34 +380,215 @@ function isUnsupportedPlatform(agent, platform, arch) {
304
380
  const triple = platformTriple(platform, arch);
305
381
  return !triple || !binaryTargetFor(agent, triple);
306
382
  }
383
+ /** The version an entry asks npm for: its npx specifier's pin, else the entry's version. */
384
+ function requestedVersionOf(entry) {
385
+ const npx = entry.agent.distribution.npx;
386
+ return (npx && npmPackageVersion(npx.package)) || entry.agent.version;
387
+ }
388
+ /**
389
+ * How long an open waits for an install it had to start (spec §6.2) before failing with
390
+ * {@link AdapterStillInstallingError}. The server gives a whole open `AGENT_CHANNEL_OPEN_TIMEOUT_MS`
391
+ * = 120 s (`AgentChannelSession.kt`); this leaves ~30 s for the spawn, `initialize` and
392
+ * `session/new|load` that follow, so the reader gets this actionable error instead of a generic
393
+ * "open timed out". The install itself is not bounded by this — it keeps running under the agent lock
394
+ * with its own install budget, and a retried open waits on that lock.
395
+ */
396
+ export const OPEN_INSTALL_BUDGET_MS = 90_000;
397
+ /** How long the resume offer waits for keeper-acp's session list (the offer goes out regardless). */
398
+ const LEFTOVER_LIST_TIMEOUT_MS = 5_000;
399
+ /** keeper-acp sessions rebuilt at once by the start-time adoption. */
400
+ const ADOPT_CONCURRENCY = 8;
401
+ /** The version installing `entry` asks for — what an open that has to install shows the user. */
402
+ function installingVersionOf(entry) {
403
+ const { npx, uvx } = entry.agent.distribution;
404
+ if (npx)
405
+ return requestedVersionOf(entry);
406
+ return (uvx && parseUvxSpec(uvx.package).version) || entry.agent.version;
407
+ }
408
+ /** The registry could not be read for a migration: tried again by the next launch rather than given up on. */
409
+ class RegistryUnavailableForMigration extends Error {
410
+ }
411
+ /** Pre-build binary agent directories directly under the `acp-adapters` root (`builds/migrate.ts`). */
412
+ async function legacyAgentDirs(root) {
413
+ let entries;
414
+ try {
415
+ entries = await readdir(root, { withFileTypes: true });
416
+ }
417
+ catch {
418
+ return [];
419
+ }
420
+ return entries.filter((entry) => entry.isDirectory() && !entry.name.startsWith('.') && !RESERVED_ROOT_NAMES.has(entry.name)).map((entry) => entry.name);
421
+ }
422
+ /**
423
+ * {@link BridgeInfo.currentBuild}/`builds`/`switchPending` for one agent (spec §6.4): the current build (with its sessions, possibly
424
+ * none) and every build a live session runs. `listed` false (keeper-acp could not be listed): only `currentBuild`.
425
+ */
426
+ export function rosterBuildFields(current, sessions, listed) {
427
+ const patchedBuild = (build) => build.kind === 'npm-patched' || build.patchDigest !== undefined;
428
+ const fields = {};
429
+ if (current)
430
+ fields.currentBuild = { id: current.buildId, version: current.version, ...(patchedBuild(current) ? { patched: true } : {}) };
431
+ if (!listed)
432
+ return fields;
433
+ const tallies = new Map();
434
+ const tally = (id, version, patched) => {
435
+ let entry = tallies.get(id);
436
+ if (!entry) {
437
+ entry = { id, version, patched, sessions: 0, idle: 0, busy: 0, unmovable: 0, busyReasons: new Set(), unmovableReasons: new Set() };
438
+ tallies.set(id, entry);
439
+ }
440
+ return entry;
441
+ };
442
+ if (current)
443
+ tally(current.buildId, current.version, patchedBuild(current));
444
+ const pendingTargets = [];
445
+ for (const session of sessions ?? []) {
446
+ const entry = tally(session.buildId, session.version, session.patched);
447
+ entry.sessions += 1;
448
+ entry[session.state] += 1;
449
+ const reasons = session.state === 'busy' ? entry.busyReasons : session.state === 'unmovable' ? entry.unmovableReasons : undefined;
450
+ for (const reason of session.reasons ?? [])
451
+ if (reasons && reasons.size < ROSTER_REASONS_MAX)
452
+ reasons.add(reason);
453
+ if (session.pendingTo !== undefined)
454
+ pendingTargets.push(session.pendingTo);
455
+ }
456
+ if (tallies.size === 0)
457
+ return fields;
458
+ // The current build first, then the rest by version (newest first), so a card reads the same on every scan.
459
+ const ordered = [...tallies.values()].sort((a, b) => (a.id === current?.buildId ? -1 : b.id === current?.buildId ? 1 : 0) || compareVersionsDesc(a.version, b.version) || a.id.localeCompare(b.id));
460
+ fields.builds = ordered.map((entry) => ({
461
+ id: entry.id, version: entry.version, ...(entry.patched ? { patched: true } : {}),
462
+ ...(entry.id === current?.buildId ? { current: true } : {}),
463
+ sessions: entry.sessions, idle: entry.idle, busy: entry.busy, unmovable: entry.unmovable,
464
+ ...(entry.busyReasons.size > 0 ? { busyReasons: [...entry.busyReasons] } : {}),
465
+ ...(entry.unmovableReasons.size > 0 ? { unmovableReasons: [...entry.unmovableReasons] } : {}),
466
+ }));
467
+ if (pendingTargets.length > 0) {
468
+ const first = pendingTargets[0];
469
+ fields.switchPending = { remaining: pendingTargets.length, ...(pendingTargets.every((target) => target === first) ? { toBuild: first } : {}) };
470
+ }
471
+ return fields;
472
+ }
473
+ function compareVersionsDesc(a, b) {
474
+ const va = semver.parse(a, { loose: true });
475
+ const vb = semver.parse(b, { loose: true });
476
+ return va && vb ? semver.rcompare(va, vb) : 0;
477
+ }
478
+ /** Why a local-fallback session is never moved (spec §6.3 "当前限制"): it does not outlive the daemon, so its next reopen uses current. */
479
+ export const LOCAL_SESSION_UNMOVABLE = 'it is running without the session keeper; it will use the current build when reopened';
480
+ /** At most this many distinct reasons per build entry (the card explains, it does not list every session). */
481
+ const ROSTER_REASONS_MAX = 4;
307
482
  export class AcpAgents {
483
+ sessionStartTimeoutMs;
308
484
  registry;
309
485
  platform;
310
486
  arch;
311
487
  probe;
312
488
  runCommand;
313
489
  /**
314
- * Where `binaryInstall.ts` unpacks binary-distributed adapters —
490
+ * The adapter builds root (`builds/model.ts` layout) —
315
491
  * `<workstation config dir>/acp-adapters`, derived from the same `cacheDir` the registry cache
316
- * itself lives in directly (ruling F2 in the plan's pre-flight scan: `resolveLaunch`'s binary
317
- * branch needs this root and nothing else in this class's options carries it).
492
+ * itself lives in directly.
318
493
  */
319
494
  adapterRoot;
495
+ commandRetryFirstBroadcastMs;
320
496
  /** Last observation per registry agent id; in-process only, by design — see {@link AuthObservation}. */
321
497
  observed = new Map();
322
- /** Injectable `uv` runner for {@link roster}'s single hoisted `uv tool list` read; see {@link AcpAgentsOptions.runUv}. */
498
+ quotaReaders = new Map();
499
+ /** The `uv` uv builds are installed with; see {@link AcpAgentsOptions.runUv}. */
323
500
  runUv;
324
- /** Injectable `npx` binary for {@link installAdapter}'s npx branch; see {@link AcpAgentsOptions.npxCommand}. */
325
- npxCommand;
501
+ /** Injectable `npm` for adapter builds; see {@link AcpAgentsOptions.npmCommand}. */
502
+ npmCommand;
503
+ nodePath;
504
+ binaryFetch;
505
+ patchRecipes;
506
+ /** `builds.json` and the per-agent install/switch/GC lock (spec §6.1). */
507
+ builds;
508
+ /** The registry with catalog supplements but without overrides applied — {@link effectiveEntries} applies them, keeping launchEnv apart. */
509
+ baseRegistry;
510
+ /** {@link OPEN_INSTALL_BUDGET_MS}, overridable for tests. */
511
+ openInstallBudgetMs;
512
+ cacheDir;
513
+ transports;
514
+ generations;
515
+ /** The once-per-daemon adoption's discovery ({@link adoptKeeperSessions}). */
516
+ adoption;
517
+ /**
518
+ * keeper-acp generations whose listing failed (timed out, not answering) at the adoption or a retry since (final review finding 3):
519
+ * their sessions are neither adopted nor orphans yet — {@link retryKeeperAdoption} lists them again, no open spawns while one
520
+ * remains (a spawn into a session of it would stop its running adapter), and they are never retired meanwhile.
521
+ */
522
+ unlistedGens = new Set();
523
+ /** The retry in flight ({@link retryKeeperAdoption}). */
524
+ retrying;
525
+ leftoverListTimeoutMs;
526
+ /** Which keeper-acp generation holds a session key (discovery and leftover listings): its IPC goes there. */
527
+ keeperOwner = new Map();
528
+ /** The highest seq a keeper-acp session's outbox held when it was listed (the local fallback numbers above it, Task 21). */
529
+ keeperHeads = new Map();
530
+ /** Sessions this daemon holds (or is spawning) on an older generation, by key: that generation is never stopped meanwhile. */
531
+ heldOnOlder = new Map();
532
+ /** Refs whose session the manager is retiring to respawn it ({@link holdForReopen}): their older generation stays held. */
533
+ reopenHolds = new Set();
534
+ /** Keys this daemon spawned on keeper-acp: never adopted (they are its own). */
535
+ ownKeeperKeys = new Set();
536
+ initializeTimeoutMs;
537
+ /** One legacy migration per agent per process ({@link migrateAgent}); the daemon's start and any open share it. */
538
+ migrations = new Map();
539
+ /**
540
+ * Each ref's current keeper-acp key generation (Task 24): key `<ref>` (0) until a switch moves it to `<ref>#s<n>`. Learned from
541
+ * every listing (the spawn label's `keyGen` is the persisted record) and set by every switch; a ref absent here uses itself.
542
+ */
543
+ refKeys = new Map();
544
+ /**
545
+ * keeper-acp sessions whose adapter runs but which cannot be continued (spec §5.5.6: no readable checkpoint and raw truncated, or
546
+ * its open never completed), by key: left running, marked degraded, and switched at the ref's next `agent_open` — the process is
547
+ * closed and a new one loads the native session (never `session/new`).
548
+ */
549
+ unrecoverable = new Map();
550
+ /** Daemon leases on builds (opens resolving → spawning, switches, short-lived connections), `agentId\0buildId` → count (GC). */
551
+ leases = new Map();
552
+ /** When each build was last seen referenced (GC's 10-minute grace); unknown builds count from this daemon's start. */
553
+ buildSeen = new Map();
554
+ startedAt = Date.now();
555
+ /** Refs whose switch cannot happen (spec §6.4 `unmovable`), with why — until the session goes or its target changes. */
556
+ unmovableRefs = new Map();
557
+ /** The persistent intents (`builds.json` `switchPending`), read once and kept in step with every write. */
558
+ intents;
559
+ /** One GC at a time (a later one runs after it). */
560
+ collecting;
561
+ /** One {@link retireIdleGenerations} pass at a time, and whether another was asked for meanwhile. */
562
+ retiringGenerations;
563
+ retireAgain = false;
326
564
  constructor(options) {
327
- this.registry = options.registry ?? new RegistryClient({ cacheDir: options.cacheDir });
565
+ this.sessionStartTimeoutMs = options.sessionStartTimeoutMs ?? 60_000;
566
+ if (!(this.sessionStartTimeoutMs > 0) || !Number.isFinite(this.sessionStartTimeoutMs)) {
567
+ throw new RangeError('sessionStartTimeoutMs must be finite and positive');
568
+ }
569
+ // Through the catalog, always: an injected registry too, so tests exercise the same overlay.
570
+ this.baseRegistry = options.registry ?? new RegistryClient({ cacheDir: options.cacheDir });
571
+ this.registry = withCatalogOverrides(this.baseRegistry);
328
572
  this.platform = options.platform ?? process.platform;
329
573
  this.arch = options.arch ?? process.arch;
330
574
  this.probe = options.probe ?? probeEnvironment();
331
575
  this.runCommand = options.runCommand ?? runDependencyInstall;
332
- this.runUv = options.runUv;
333
- this.npxCommand = options.npxCommand;
576
+ this.runUv = options.runUv ?? defaultRunUv;
577
+ this.nodePath = options.nodePath ?? process.execPath;
578
+ this.npmCommand = options.npmCommand ?? defaultNpmCommand(this.nodePath, this.platform);
579
+ this.binaryFetch = options.binaryFetch;
580
+ this.patchRecipes = options.patchRecipes;
581
+ this.commandRetryFirstBroadcastMs = options.commandRetryFirstBroadcastMs ?? COMMAND_RETRY_FIRST_BROADCAST_MS;
582
+ this.openInstallBudgetMs = options.openInstallBudgetMs ?? OPEN_INSTALL_BUDGET_MS;
334
583
  this.adapterRoot = path.join(options.cacheDir, 'acp-adapters');
584
+ this.builds = new BuildStore(this.adapterRoot);
585
+ this.cacheDir = options.cacheDir;
586
+ this.transports = { ...options.transports };
587
+ this.generations = options.keeperGenerations;
588
+ this.initializeTimeoutMs = options.initializeTimeoutMs ?? DEFAULT_SESSION_INITIALIZE_TIMEOUT_MS;
589
+ this.leftoverListTimeoutMs = options.leftoverListTimeoutMs ?? LEFTOVER_LIST_TIMEOUT_MS;
590
+ // Read once, up front: an `agent_open` asks for a ref's intent on its way to the reply (Task 24).
591
+ void this.loadIntents().catch(() => undefined);
335
592
  }
336
593
  /**
337
594
  * The roster reported in the `hello` frame and answered to an external-agents scan, one entry per
@@ -349,9 +606,15 @@ export class AcpAgents {
349
606
  * is the one field that does describe this machine (has the ACP adapter already been downloaded
350
607
  * here), spread in only when it was genuinely established.
351
608
  *
352
- * `recommended` / `defaultName` / `defaultLabel` come from `registry/catalog.ts` — the curated list
609
+ * `curated` / `defaultName` / `defaultLabel` come from `registry/catalog.ts` — the curated list
353
610
  * of agents Axom has actually verified, which replaced the old "official CLI on PATH" table. They
354
611
  * are spread in only for a catalog hit; an agent outside the catalog is not claimed either way.
612
+ * `recommended` is the catalog's own static flag (claude, codex, pi) and nothing else — it used to
613
+ * be spread for every catalog hit, which is what `curated` now says. `detected` is the catalog's
614
+ * local detection (`registry/detect.ts`): the agent itself, or its prerequisite, is on this machine
615
+ * the way the user installed it — a reason to recommend, never a claim that Axom installed it.
616
+ * `missingPrerequisite` names a prerequisite that is absent *and* that `install` cannot fetch, so
617
+ * the card can say what to do instead of offering an `Install` that can only refuse.
355
618
  *
356
619
  * Never throws, for the same reason the deleted `rosterFor` swallowed a failing probe: a registry
357
620
  * that is unreachable on a cold-start machine must not be able to stop the daemon from connecting.
@@ -362,7 +625,7 @@ export class AcpAgents {
362
625
  * started daemon reports neither field for anything — that absence is the truthful answer and the
363
626
  * server contract spells out that it means "unknown", not "not authenticated".
364
627
  */
365
- async roster() {
628
+ async roster(options = {}) {
366
629
  let agents;
367
630
  try {
368
631
  agents = await this.registry.load();
@@ -371,37 +634,39 @@ export class AcpAgents {
371
634
  console.warn(`[acp] agent registry unavailable, reporting an empty roster: ${errorMessage(error)}`);
372
635
  return [];
373
636
  }
374
- // Read once for the whole roster, not once per agent: this is a directory scan, and the
375
- // registry has 39 entries.
376
- const npxCache = readNpxCache(this.probe);
377
- // Same reasoning, for `uv`: a directory scan has no subprocess to spawn, but `uv tool list` does,
378
- // and building the roster must not spawn anything per agent (see this method's own doc comment).
379
- // Read it exactly once here and let every uvx-distributed agent below look itself up in the
380
- // result, instead of `adapterVersionFor` shelling out to `uv` again for each one. `uv` is now a
381
- // non-blocking call (see `uvxInstall.ts`'s `RunUv`), hence the `await` — it still happens only
382
- // this once per roster build, not once per agent.
383
- const uvTools = await listUvTools(this.runUv);
384
- const uvVersions = new Map(uvTools.map((entry) => [entry.name, entry.version]));
385
- // Hoisted for the same reason as `npxCache`/`uvTools`: it is the same value for every entry in
637
+ // Read once for the whole roster, not once per agent: every npm/binary agent's installed version
638
+ // is its current build (spec §6.4).
639
+ const buildsDocument = await this.builds.read().catch((error) => {
640
+ console.warn(`[acp] adapter builds unreadable, reporting none installed: ${errorMessage(error)}`);
641
+ return { schema: 1, agents: {} };
642
+ });
643
+ // Since Task 15b uv agents are builds too: building the roster spawns no `uv` (and nothing else).
644
+ // Hoisted for the same reason as `buildsDocument`: it is the same value for every entry in
386
645
  // this roster (a fact about this machine, not this agent), so it is computed once rather than
387
646
  // once per agent inside the `.map` below.
388
647
  const triple = platformTriple(this.platform, this.arch);
389
- // `Promise.all` over an async map, not a plain `.map`, only because `resolvable` below now
390
- // returns a `Promise` (it calls the now-async `resolveLaunch`) — every entry still resolves
391
- // synchronously off the already-hoisted `uvTools` snapshot, so this adds no real waiting and no
392
- // extra `uv` spawns, just Promise bookkeeping around work that was already computed once.
393
- return Promise.all(agents.map(async (agent) => {
648
+ // Spec §6.4: the build data is joined only for a scan (`options.sessions`) — never the hello — and only when every keeper-acp
649
+ // generation could be listed (`undefined`: absent, "not looked at").
650
+ const running = options.sessions ? await this.classifiedRunningSessions(options.sessions) : undefined;
651
+ return agents.map((agent) => {
394
652
  const observation = this.observed.get(agent.id);
395
653
  const catalog = catalogEntryFor(agent.id);
396
654
  // Computed once and used by both `adapterInstalled` and `adapterVersion` below: they are two
397
655
  // halves of one disk read, and deriving them separately is exactly how they drifted apart.
398
- const adapterVersion = adapterVersionFor(agent, npxCache, this.adapterRoot, uvVersions);
656
+ const current = this.builds.currentIn(buildsDocument, agent.id);
657
+ const adapterVersion = adapterVersionFor(current);
658
+ // One detection pass per agent, shared by `adapterInstalled`, `detected` and
659
+ // `missingPrerequisite`: three readings of the same look at the disk must not disagree.
660
+ const prerequisite = catalog?.prerequisite;
661
+ const prerequisiteMet = !prerequisite || detected(prerequisite.detect, this.probe, triple);
662
+ const inUse = detected(catalog?.detect, this.probe, triple) || (prerequisite !== undefined && prerequisiteMet);
663
+ // A `pathLaunch` agent has no installable artifact: its `available` is exactly "the body is on
664
+ // this machine" (see `registry/catalog.ts`). No build will ever exist for it, so the build
665
+ // halves below stay honestly absent.
666
+ const pathLaunchAvailable = catalog?.pathLaunch !== undefined && inUse;
399
667
  return {
400
668
  type: agent.id,
401
- // `uvTools` is the same hoisted `uv tool list` snapshot `adapterVersion` below looks up in
402
- // (`uvVersions`) — handed through so this doesn't make `resolveLaunch` spawn `uv` again for
403
- // every uvx-distributed agent (see `resolvable`'s own doc comment).
404
- available: await this.resolvable(agent, uvTools),
669
+ available: this.resolvable(agent, current) || pathLaunchAvailable,
405
670
  version: agent.version,
406
671
  // Always present: the registry document itself always carries a version for every entry,
407
672
  // unlike `adapterVersion` below, which depends on what this particular machine has done.
@@ -415,19 +680,18 @@ export class AcpAgents {
415
680
  // which meant the one reader who most needed an `Update` button was the one reader who
416
681
  // never got offered one (design §11). Which version is on disk is `adapterVersion`'s job
417
682
  // below; whether it is behind the pin is the card's `updatable` comparison. Neither belongs
418
- // here. Range handling survives only where a genuine range specifier can appear — the
419
- // *launch* decision in `registry/launch.ts` — not in this presence check.
683
+ // here.
420
684
  //
421
- // Covers all three distribution channels, so `false` is now a real answer rather than
422
- // "did not look": `adapterVersionFor` reads the npx cache, the hoisted `uv tool list`
423
- // snapshot and our own binary install root, and every one of them is a local read.
685
+ // Since spec 2026-10-11 §6.4 "installed" means the agent has a **current build** (npm, binary
686
+ // and — since Task 15b — uv), and `adapterVersion`
687
+ // is that build's version — the version a new session would actually run.
424
688
  //
425
- // `dependencySatisfied` folds in the catalog's extra prerequisite (design §4.4): `pi-acp`'s
426
- // adapter sitting in the npx cache with no `pi` on `PATH` is not an agent this machine can
427
- // add — the card would offer `Add`, the try-run would fail, and the reader would be told
428
- // nothing about the missing CLI. Reporting it not-installed is what makes `Install` fetch
429
- // the dependency too.
430
- adapterInstalled: adapterVersion !== undefined && this.dependencySatisfied(agent.id),
689
+ // `prerequisiteMet` folds in the catalog's prerequisite (design §4.4): `pi-acp`'s adapter
690
+ // installed with no `pi` on `PATH` is not an agent this machine can add —
691
+ // the card would offer `Add`, the try-run would fail, and the reader would be told nothing
692
+ // about the missing CLI. Reporting it not-installed is what makes `Install` fetch the
693
+ // prerequisite too (or, with nothing to fetch, `missingPrerequisite` below explain it).
694
+ adapterInstalled: adapterVersion !== undefined && prerequisiteMet,
431
695
  // The version half of the same answer — see `adapterVersionFor`'s doc comment. Absent
432
696
  // rather than null when nothing is installed, on this file's usual "never write down what
433
697
  // nobody looked at" rule.
@@ -442,7 +706,11 @@ export class AcpAgents {
442
706
  // machine can launch has no use for it. Absent when the machine's own platform/arch falls
443
707
  // outside the registry's six triples (see `binaryInstall.ts`'s `platformTriple`) — `true`
444
708
  // with no triple to quote is still the correct, honest signal in that case.
445
- ...(isUnsupportedPlatform(agent, this.platform, this.arch)
709
+ // A `pathLaunch` agent has no distribution to be "unsupported" about: its launch path is the
710
+ // machine's own installation (detect-driven, above), not a downloadable artifact's platform
711
+ // coverage — the empty-distribution supplement would otherwise read as "nothing left to
712
+ // launch through".
713
+ ...(!pathLaunchAvailable && isUnsupportedPlatform(agent, this.platform, this.arch)
446
714
  ? { unsupportedPlatform: true, ...(triple ? { platformTriple: triple } : {}) }
447
715
  : {}),
448
716
  // The registry publishes a human-facing name for every agent; carrying it is what stops the
@@ -452,10 +720,30 @@ export class AcpAgents {
452
720
  // its id-derived fallback would have said something.
453
721
  ...(agent.name ? { displayName: agent.name } : {}),
454
722
  ...(agent.description ? { description: agent.description } : {}),
455
- // Spread from `registry/catalog.ts` only for a catalog hit — `recommended` has no `false`,
456
- // the same "absent means unknown/no" rule as `supportsSteering` below.
457
- ...(catalog ? { recommended: true, defaultName: catalog.defaultName } : {}),
723
+ // Spread from `registry/catalog.ts` only for a catalog hit — `curated`, `recommended` and
724
+ // `detected` have no `false`, the same "absent means unknown/no" rule as `supportsSteering`.
725
+ // An `unverified` entry keeps its defaults but is not one Axom vouches for (see `catalog.ts`).
726
+ ...(catalog ? { ...(catalog.unverified ? {} : { curated: true }), defaultName: catalog.defaultName } : {}),
458
727
  ...(catalog?.defaultLabel ? { defaultLabel: catalog.defaultLabel } : {}),
728
+ ...(catalog?.recommended ? { recommended: true } : {}),
729
+ // 静态推荐条目的展示序(目录数组序):推荐层按它排,使目录能表达 "pi 第一、hermes 第二"
730
+ // 这类顺序意图 —— 名册序做不到(supplement 追加在后),字母序与本意相反。
731
+ ...(RECOMMENDED_RANK.has(agent.id) ? { recommendedRank: RECOMMENDED_RANK.get(agent.id) } : {}),
732
+ ...(catalog?.pathLaunch
733
+ ? {
734
+ pathLaunch: {
735
+ command: catalog.pathLaunch.command,
736
+ ...(catalog.pathLaunch.args ? { args: [...catalog.pathLaunch.args] } : {}),
737
+ ...(catalog.pathLaunch.hint ? { hint: catalog.pathLaunch.hint } : {}),
738
+ },
739
+ }
740
+ : {}),
741
+ ...(inUse ? { detected: true } : {}),
742
+ // Only when nothing here can fix it: a prerequisite with an `install` argv (Pi) is fetched by
743
+ // `Install`, which the card already offers because `adapterInstalled` is false.
744
+ ...(prerequisite && !prerequisiteMet && !prerequisite.install
745
+ ? { missingPrerequisite: { label: prerequisite.label, ...(prerequisite.hint ? { hint: prerequisite.hint } : {}) } }
746
+ : {}),
459
747
  // Spread rather than assign: an explicit `authMethods: undefined` would still be an own
460
748
  // property, and "the key is here but empty" is exactly the ambiguity these fields exist to
461
749
  // avoid on the roster's other end.
@@ -465,8 +753,121 @@ export class AcpAgents {
465
753
  // has connected to and found *not* steering-capable publishes nothing, because absent
466
754
  // already means "do not offer it". See `BridgeInfo.supportsSteering`.
467
755
  ...(observation?.supportsSteering === true ? { supportsSteering: true } : {}),
756
+ // The installed native extension is known without spawning a process or reading quota.
757
+ // A pre-upgrade live session's initialize observation must not hide its refresh entry.
758
+ ...(observation?.supportsAccountLimits || (current && current.launch.runtime !== 'node' && nativeExtensionFor(current.agentId, current.version)) ? { supportsAccountLimits: true } : {}),
759
+ ...(options.sessions ? rosterBuildFields(current, running?.get(agent.id), running !== undefined) : {}),
468
760
  };
469
- }));
761
+ });
762
+ }
763
+ /**
764
+ * The live sessions' builds joined with the manager's view of each (Task 25), grouped by agent; `undefined` when a keeper-acp
765
+ * generation could not be listed (the counts would be wrong, so none are reported).
766
+ */
767
+ async classifiedRunningSessions(classify) {
768
+ const running = await this.runningSessions();
769
+ if (!running)
770
+ return undefined;
771
+ const byAgent = new Map();
772
+ for (const session of running) {
773
+ const state = session.local
774
+ ? { state: 'unmovable', reasons: [LOCAL_SESSION_UNMOVABLE] }
775
+ : await classify(session).catch((error) => {
776
+ console.warn(`[acp] roster: the state of ${session.sessionRef} could not be read: ${errorMessage(error)}`);
777
+ return { state: 'busy', reasons: ['its state could not be read right now'] };
778
+ });
779
+ const list = byAgent.get(session.agentId) ?? [];
780
+ list.push({ ...session, ...state });
781
+ byAgent.set(session.agentId, list);
782
+ }
783
+ return byAgent;
784
+ }
785
+ /**
786
+ * Every live session's adapter process with its build (spec §6.4, Task 25): keeper-acp's process labels across every live
787
+ * generation, plus the local fallback's. One entry per ref (a ref a crash left with two keys is counted once, by its newest key).
788
+ * `undefined` when keeper-acp — any generation — cannot be listed.
789
+ */
790
+ async runningSessions() {
791
+ const listed = await this.listRunningAdapters();
792
+ if (!listed)
793
+ return undefined;
794
+ const byRef = new Map();
795
+ for (const { summary, local } of listed) {
796
+ const ref = refOfSession(summary);
797
+ const { agentId, buildId, version, kind } = summary.label;
798
+ if (ref === undefined || typeof agentId !== 'string' || typeof buildId !== 'string')
799
+ continue;
800
+ const keyGen = keyGenOf(summary.label);
801
+ const previous = byRef.get(ref);
802
+ if (previous && previous.keyGen >= keyGen)
803
+ continue;
804
+ byRef.set(ref, {
805
+ keyGen,
806
+ session: { sessionRef: ref, agentId, buildId, version: typeof version === 'string' ? version : buildId, patched: kind === 'npm-patched', local },
807
+ });
808
+ }
809
+ return [...byRef.values()].map(({ session }) => session);
810
+ }
811
+ /** The adapter processes running now, in every live keeper-acp generation and the local fallback; `undefined` if any cannot be listed. */
812
+ async listRunningAdapters() {
813
+ const found = [];
814
+ if (this.transports.keeper) {
815
+ let transports;
816
+ if (this.generations) {
817
+ try {
818
+ const live = await this.generations.live();
819
+ transports = await Promise.all(live.map((gen) => this.generations.transportFor(gen)));
820
+ }
821
+ catch {
822
+ return undefined;
823
+ }
824
+ }
825
+ else {
826
+ transports = [this.transports.keeper];
827
+ }
828
+ for (const transport of transports) {
829
+ try {
830
+ for (const summary of await withDeadline(transport.list(), this.leftoverListTimeoutMs, 'keeper-acp list', Error)) {
831
+ if (hostsRunningAdapter(summary))
832
+ found.push({ summary, local: false });
833
+ }
834
+ }
835
+ catch {
836
+ return undefined;
837
+ }
838
+ }
839
+ }
840
+ if (this.transports.local) {
841
+ try {
842
+ for (const summary of await this.transports.local.list())
843
+ if (hostsRunningAdapter(summary))
844
+ found.push({ summary, local: true });
845
+ }
846
+ catch {
847
+ return undefined;
848
+ }
849
+ }
850
+ return found;
851
+ }
852
+ /** The agent's current build id and every installed build id (`builds.json`), for `agent_switch_idle` (Task 25). */
853
+ async buildIdsOf(agentId) {
854
+ const entry = await this.builds.agent(agentId);
855
+ return { ...(entry?.current ? { current: entry.current } : {}), installed: Object.keys(entry?.builds ?? {}) };
856
+ }
857
+ /** Roster-visible change listeners (see {@link onRosterChanged}). */
858
+ rosterChangedListeners = [];
859
+ /**
860
+ * Subscribe to roster-visible observation changes.
861
+ *
862
+ * Fired when an agent's quota capability (`supportsAccountLimits`) is first observed by this
863
+ * process — the one roster field that can change mid-flight without a build changing, hence
864
+ * without the build-upkeep push that carries every other roster update. The daemon answers by
865
+ * re-scanning and pushing the roster; offline, the next hello carries it. Observations are
866
+ * in-memory, so a restart forgets the capability and this firing together — the next spawn
867
+ * re-learns both.
868
+ */
869
+ onRosterChanged(listener) {
870
+ this.rosterChangedListeners.push(listener);
470
871
  }
471
872
  /**
472
873
  * Record what a just-established connection revealed about one agent. Called on every connection,
@@ -478,7 +879,21 @@ export class AcpAgents {
478
879
  */
479
880
  observe(agentId, observation) {
480
881
  const previous = this.observed.get(agentId);
481
- this.observed.set(agentId, { ...previous, ...observation });
882
+ const merged = { ...previous, ...observation };
883
+ this.observed.set(agentId, merged);
884
+ if (merged.supportsAccountLimits === true && previous?.supportsAccountLimits !== true) {
885
+ // The quota capability was just learned mid-flight. Without a push here the server's roster
886
+ // copy stays at the hello/build-change snapshot, and the account UI it gates never renders
887
+ // (observed 2026-10-13: this daemon's first zcode spawn advertised the capability, the
888
+ // server's roster remained at that morning's build-change push, no scan happened, and no
889
+ // account UI ever appeared for the agent).
890
+ for (const listener of this.rosterChangedListeners) {
891
+ try {
892
+ listener();
893
+ }
894
+ catch { /* a listener must not break the observe path */ }
895
+ }
896
+ }
482
897
  }
483
898
  /**
484
899
  * What one agent was last seen to advertise as ways of signing in, or `undefined` when this
@@ -504,267 +919,1527 @@ export class AcpAgents {
504
919
  return this.observed.get(agentId)?.authMethods;
505
920
  }
506
921
  /**
507
- * Start one session: spawn the agent, negotiate `initialize`, and either continue in
508
- * {@link AcpOpenSpec.nativeSessionId} or open a fresh one.
922
+ * Start one session (Task 19, spec 2026-10-11 §5.3/§5.4): build the spawn spec, spawn the adapter in keeper-acp (the local
923
+ * fallback when keeper-acp is unavailable), let the processor negotiate `initialize`, then either continue in
924
+ * {@link AcpOpenSpec.nativeSessionId} or open a fresh one — every step a note or a write in the session's raw log.
509
925
  *
510
- * The subprocess is killed on every failure path after it was spawned — an agent left running for
511
- * a session that never registered is exactly the orphan the manager's own tombstone machinery
512
- * exists to prevent one layer up.
926
+ * The adapter is stopped on every failure path after it was spawned — an agent left running for a session that never
927
+ * registered is exactly the orphan the manager's own tombstone machinery exists to prevent one layer up.
513
928
  */
514
929
  async open(spec) {
515
- const resolved = await this.resolveLaunchFor(spec.agentId, spec.command, spec.args);
516
- const capture = createReplayCapture();
517
- const permissions = createDeferredPermissionSink();
518
- const connection = await this.connectResolved(spec.agentId, resolved, spec.env, spec.agentEnv, {
519
- // Both registrations go in one hook: `ClientApp.connect()` copies its handler table once, so
520
- // this is the only moment either of them can be installed (see `acpClient.ts`'s
521
- // `ConnectAgentOptions.configure`).
522
- //
523
- // The capture goes FIRST, and the order is load-bearing rather than stylistic. The SDK awaits
524
- // every handler in registration order (`jsonrpc.js`'s `processIncomingMessage`), so each one
525
- // ahead of the capture costs it a microtask hop before it ever sees a `session/update` — while
526
- // the response that ends the same `session/load` / `session/prompt` is applied synchronously
527
- // off the reader. Registering the capture behind the three permission handlers is what made
528
- // that gap wide enough to observe (measured: a turn's trailing deltas arriving after its own
529
- // `stop`). `settleUpdates` is the actual fix — this is defence in depth, and it is not a
530
- // substitute: nothing here forces the capture to stay first.
531
- //
532
- // ## Prerequisite for anything added to this hook: **every handler registered here must be
533
- // ## synchronous with respect to `session/update`**
534
- //
535
- // This is where that property is established, so this is where it is written down —
536
- // `settleUpdates` (`adoption.ts`) depends on it and cannot check it. It waits one macrotask
537
- // boundary, which drains a chain of *synchronous* handlers' microtask continuations and
538
- // nothing more; a handler that awaits a real macrotask (a timer, a file read, a subprocess,
539
- // an HTTP call) before returning `Handled.no` moves the rest of the chain past that boundary
540
- // and the wait ends with **zero** updates delivered — measured, and silent apart from
541
- // `settleUpdates`' own maxRounds diagnostic, which this failure does not even trip.
542
- //
543
- // Both current registrations satisfy it. `capture.register` is a plain synchronous
544
- // `onNotification` body. The permission handlers do block on a human answer, but only for
545
- // `session/request_permission` and `elicitation/*`; for a `session/update` they fall through
546
- // without awaiting anything real. Anything added here later has to clear the same bar — if it
547
- // genuinely needs to await, it must do that work *after* handing the message on, not before.
548
- configure: (app) => {
549
- capture.register(app);
550
- // **No `present` here, and that is a decision rather than an omission.** A session
551
- // connection lives for hours; a `url` elicitation raised on one is not a sign-in the reader
552
- // asked for, and there is no login for a card on screen to belong to. So
553
- // `handleCreateElicitation` declines it rather than accepting into a void, which is an
554
- // answer the agent can act on. The one connection that *does* wire both callbacks is the
555
- // throwaway one {@link authenticate} opens — see {@link connectFor}. The stderr half of the
556
- // same rule is enforced by the compiler: `ConnectAgentOptions.present` is typed `never`, so
557
- // this call's sibling option cannot arm the sniffer on a session either.
558
- //
559
- // `dismiss` is a no-op for the same reason: nothing was presented, so nothing can be
560
- // retracted, and an agent that sends `elicitation/complete` anyway must not crash the
561
- // connection over it.
562
- registerPermissionHandlers(app, permissions.sink, { dismiss: () => { } });
563
- },
564
- });
565
- this.observe(spec.agentId, observationOf(connection));
930
+ const requestId = spec.openRequestId ?? randomUUID();
931
+ const openStarted = performance.now();
932
+ // Log identifiers and timing only: commands, environment, cwd and lease headers can hold secrets.
933
+ const phase = async (name, work) => {
934
+ const started = performance.now();
935
+ const log = (status) => console.info(`${new Date().toISOString()} [acp-open] request=${JSON.stringify(requestId)} agent=${JSON.stringify(spec.agentId)}` +
936
+ ` phase=${name} status=${status} durationMs=${Math.round(performance.now() - started)}` +
937
+ ` totalMs=${Math.round(performance.now() - openStarted)}`);
938
+ log('started');
939
+ try {
940
+ const result = await work();
941
+ log('completed');
942
+ return result;
943
+ }
944
+ catch (error) {
945
+ log('failed');
946
+ throw error;
947
+ }
948
+ };
949
+ // A successful open-time install becomes the session's own `agent_install_outcome` note (Task 20: numbered by the processor,
950
+ // recorded in the outbox); the caller still hears every outcome (a failure has no session to go on).
951
+ let installed;
952
+ if (!spec.command) {
953
+ await phase('ensure-installed', () => this.installForOpen(spec.agentId, (outcome) => {
954
+ if (outcome.ok)
955
+ installed = outcome;
956
+ spec.onInstallOutcome?.(outcome);
957
+ }));
958
+ }
959
+ let resolved = await phase('prepare-launch', () => this.resolveLaunchFor(spec.agentId, spec.command, spec.args));
960
+ // Spec §5.5.6 (Task 24): a session the adoption could not continue is switched by this open — its process is closed and a new
961
+ // one loads the native session, never falling back to `session/new`.
962
+ if (this.transports.keeper && spec.sessionRef !== undefined && spec.nativeSessionId) {
963
+ await this.adoptKeeperSessions();
964
+ if (this.unrecoverable.has(this.keyFor(spec.sessionRef)))
965
+ spec = { ...spec, requireSessionLoad: true };
966
+ }
967
+ // GC never takes the build between resolving it and the spawn (whose keeper-acp label references it from then on).
968
+ const releaseLease = this.leaseBuild(spec.agentId, resolved.launch.label?.buildId);
969
+ const startup = spec.agentId === 'qoder' && spec.delegationMcp
970
+ ? await phase('prepare-qoder', () => prepareQoderStartup(resolved.launch, spec.delegationMcp, spec.cwd, spec.env, spec.agentEnv))
971
+ : undefined;
972
+ if (startup)
973
+ resolved = { launch: startup.launch, source: resolved.source };
974
+ let session;
975
+ try {
976
+ session = await phase('initialize', () => this.startProcessorSession(resolved, spec, startup, installed));
977
+ }
978
+ catch (error) {
979
+ await startup?.dispose();
980
+ if (startup)
981
+ throw new Error('Managed Qoder startup could not initialize');
982
+ throw error;
983
+ }
984
+ finally {
985
+ releaseLease();
986
+ }
987
+ const facts = session.initializeFacts;
988
+ this.observe(spec.agentId, observationOfFacts(facts));
566
989
  try {
567
- const started = await this.startSession(connection, capture, spec);
990
+ if (startup && (session.agentInfo?.name !== 'qoder-cli' || session.agentInfo.version !== QODER_MANAGED_VERSION)) {
991
+ throw new Error('Managed Qoder runtime identity is not certified');
992
+ }
993
+ // Qoder 1.1.61 accepts HTTP MCP at startup, but rejects that entry in ACP new/load.
994
+ const sessionSpec = { ...spec, managedHistoryResumeRequired: !!spec.delegationMcp || spec.managedHistoryResumeRequired,
995
+ ...(startup ? { delegationMcp: undefined, requireSessionLoad: spec.requireSessionLoad || !!spec.nativeSessionId } : {}) };
996
+ const operation = spec.nativeSessionId && facts.capabilities.loadSession ? 'session/load' : 'session/new';
997
+ const started = await phase(operation, () => withDeadline(this.startSession(session, facts, sessionSpec), this.sessionStartTimeoutMs, `Agent '${spec.agentId}' ${operation}`, AgentSessionStartTimeoutError));
568
998
  // Only here, never before: the agent accepted a session, which is the one piece of evidence
569
999
  // ACP offers that it is not waiting on credentials. Recording it up at `initialize` time
570
1000
  // would claim authentication for an agent about to reject `session/new` with auth_required.
571
- this.observe(spec.agentId, { ...observationOf(connection), authenticated: true });
572
- permissions.bind(started.session);
573
- return {
574
- session: hostedSession(started.session, connection, started.history, started.configOptions),
575
- nativeSessionId: started.nativeSessionId,
576
- };
1001
+ this.observe(spec.agentId, { ...observationOfFacts(facts), authenticated: true });
1002
+ return { session, nativeSessionId: started.nativeSessionId };
577
1003
  }
578
1004
  catch (error) {
579
- await connection.close().catch(() => undefined);
1005
+ await session.close('auto').catch(() => undefined);
1006
+ await startup?.dispose();
580
1007
  // Marked, not merely rethrown: everything above this `try` failed before the agent said
581
1008
  // anything, and only a failure from here on can be an `auth_required`. See
582
1009
  // {@link AgentRefusedSessionError} — `agentSessions.ts` is the reader.
583
- throw new AgentRefusedSessionError(error);
1010
+ throw new AgentRefusedSessionError(error, startup ? 'Managed Qoder session could not open' : undefined);
584
1011
  }
585
1012
  }
586
1013
  /**
587
- * List one agent's native sessions for adoption, via a throwaway connection — listing happens
588
- * before any session exists, so there is nothing running to ask.
589
- *
590
- * Degrades to an empty list rather than an error when the agent does not advertise
591
- * `sessionCapabilities.list` (that gate lives in `adoption.ts`); a *connection* failure still
592
- * throws, because "this agent is not installed" and "this agent has no sessions" must not look
593
- * alike to the user.
1014
+ * Spawn the adapter behind a {@link ProcessorSession} and wait for its `initialize` (spec §5.3: keeper-acp, or the local
1015
+ * fallback when keeper-acp cannot be reached — §5.7). The spawn spec carries the **complete** environment (keeper-acp never
1016
+ * reads its own), the daemon's working directory (where `connectAgent` started adapters), a managed startup's secrets for
1017
+ * stderr redaction and its temporary files to remove on exit; the label is opaque to the keeper.
594
1018
  */
595
- async listNativeSessions(spec) {
596
- const resolved = await this.resolveLaunchFor(spec.agentId, spec.command, spec.args);
597
- const connection = await this.connectResolved(spec.agentId, resolved, spec.env, spec.agentEnv);
598
- this.observe(spec.agentId, observationOf(connection));
599
- try {
600
- const result = await listNativeSessions(connection, { cwd: spec.filterCwd });
601
- return result.sessions;
1019
+ async startProcessorSession(resolved, spec, startup, installOutcome) {
1020
+ const { spawn, label: baseLabel, launch } = this.spawnSpecOf(resolved, spec, startup);
1021
+ const ref = spec.sessionRef;
1022
+ const sessionKey = ref ?? `open-${randomUUID()}`;
1023
+ // A ref whose earlier life ran in the daemon (the local fallback, or a migrated legacy journal) and left frames the server has
1024
+ // not applied continues there for this life, so they are resent from where they are; a drained one is released, its watermark
1025
+ // kept as the floor. Either way the processor numbers after everything that ref ever held (spec §5.5).
1026
+ const resumeFrom = spec.seqBase?.() ?? 0;
1027
+ const local = await this.localLeftover(sessionKey);
1028
+ let seqBase = Math.max(resumeFrom, local?.lastSeq ?? 0);
1029
+ const stayLocal = local !== undefined && local.lastSeq > resumeFrom && local.unacknowledged;
1030
+ if (local && !stayLocal)
1031
+ await this.localTransport().release(sessionKey).catch(() => undefined);
1032
+ // Sessions an earlier daemon left in keeper-acp are discovered (and adopted) before keeper-acp spawns anything for this one.
1033
+ if (this.transports.keeper)
1034
+ await this.adoptKeeperSessions();
1035
+ // keeper-acp's key for the ref: the ref itself, or the key its last switch moved it to (Task 24).
1036
+ const keyGen = ref !== undefined ? this.refKeys.get(ref) ?? 0 : 0;
1037
+ const keeperKey = ref !== undefined ? sessionKeyFor(ref, keyGen) : sessionKey;
1038
+ // A generation that could not be listed may run THIS ref's adapter — only possible when the ref already has a keeper-acp session
1039
+ // directory (keys are deterministic, the data dir is shared): reopening it into a new spawn would stop that adapter, so such an
1040
+ // open is refused until the generation is listed (the manager retried right before this frame). A brand-new ref spawns as usual:
1041
+ // keeper-acp itself refuses (`foreign`) a directory another live keeper holds. (final review finding 3, correction)
1042
+ if (this.transports.keeper && !stayLocal && ref !== undefined && this.keeperAdoptionPending()) {
1043
+ const candidates = [...new Set([ref, keeperKey])];
1044
+ let existing = true;
1045
+ try {
1046
+ existing = (await Promise.all(candidates.map((key) => this.generations?.hasSessionDir?.(key) ?? Promise.resolve(true)))).some(Boolean);
1047
+ }
1048
+ catch {
1049
+ existing = true;
1050
+ }
1051
+ if (existing) {
1052
+ throw new Error(`keeper-acp (generation ${[...this.unlistedGens].join(', ')}) is not answering; this session may still be running there. Try again shortly.`);
1053
+ }
602
1054
  }
603
- finally {
604
- await connection.close().catch(() => undefined);
1055
+ const options = {
1056
+ spawn, seqBase, backfill: spec.backfill === true,
1057
+ ...(installOutcome ? { installOutcome } : {}),
1058
+ launch,
1059
+ ...(startup ? { onClosed: () => startup.dispose() } : {}),
1060
+ };
1061
+ let session;
1062
+ if (this.transports.keeper && !stayLocal) {
1063
+ try {
1064
+ // A kept session of this ref in an older generation that still runs (it hosts other adapters): the new spawn goes into it,
1065
+ // there — its raw log and outbox continue as any reused session's; nothing is moved or released. Moving it to the current
1066
+ // generation is §6.3 switching (Task 24). Every other open goes to the current generation (fix round 1, I-2/I-3).
1067
+ const target = await this.spawnTargetFor(keeperKey);
1068
+ // Spec §5.5.6 (Task 24): a session the adoption could not continue still runs its adapter — this open is its switch: the
1069
+ // process is closed first (keeper-acp refuses a second spawn into a running session), then the new one loads.
1070
+ const unrecoverable = this.unrecoverable.get(keeperKey);
1071
+ if (unrecoverable) {
1072
+ await target.transport.terminate(keeperKey).catch((error) => {
1073
+ console.warn(`[acp] stopping the unrecoverable session ${keeperKey} before its reopen failed: ${errorMessage(error)}`);
1074
+ });
1075
+ }
1076
+ this.ownKeeperKeys.add(keeperKey);
1077
+ if (target.gen !== undefined)
1078
+ this.heldOnOlder.set(keeperKey, target.gen);
1079
+ else
1080
+ this.heldOnOlder.delete(keeperKey); // a reopen hold on an older generation that is gone now
1081
+ try {
1082
+ session = await ProcessorSession.start({ ...options, sessionKey: keeperKey, transport: target.transport,
1083
+ label: { ...baseLabel, ...(keyGen > 0 ? { keyGen } : {}) },
1084
+ onDegrade: this.recordDegraded(keeperKey, target.gen ?? 'current'),
1085
+ ...(target.gen !== undefined ? { onClosed: () => this.olderSessionClosed(keeperKey, options.onClosed) } : {}) });
1086
+ }
1087
+ catch (error) {
1088
+ this.heldOnOlder.delete(keeperKey);
1089
+ throw error;
1090
+ }
1091
+ this.unrecoverable.delete(keeperKey);
1092
+ if (target.gen === undefined)
1093
+ this.keeperOwner.delete(keeperKey);
1094
+ this.keeperHeads.delete(keeperKey);
1095
+ }
1096
+ catch (error) {
1097
+ if (!(error instanceof AcpKeeperUnavailableError))
1098
+ throw error;
1099
+ console.warn(`[acp] keeper-acp is unavailable (${errorMessage(error)}); running agent '${spec.agentId}' in the daemon`);
1100
+ }
1101
+ }
1102
+ if (!session) {
1103
+ // keeper-acp holds an earlier life of this ref this daemon cannot read now: its unacknowledged frames may never reach the
1104
+ // server. Number above everything it held (as far as the last listing knew), and say that output may be missing (Task 21).
1105
+ const keeperHead = stayLocal ? undefined : this.keeperHeads.get(keeperKey);
1106
+ const gap = keeperHead !== undefined && this.keeperOwner.has(keeperKey);
1107
+ if (gap)
1108
+ seqBase = Math.max(seqBase, keeperHead);
1109
+ session = await ProcessorSession.start({ ...options, sessionKey, label: baseLabel, seqBase, transport: this.localTransport(),
1110
+ ...(gap ? { outputGap: `keeper-acp, which holds this session's earlier output (through seq ${keeperHead}), cannot be reached` } : {}) });
605
1111
  }
606
- }
607
- /**
608
- * Verify one agent on demand: spawn it, negotiate `initialize`, try to open a session, then throw
609
- * the whole thing away. Nothing is kept running — a verification is a question, not a resource.
610
- *
611
- * This is the only way the roster's auth fields can ever be populated for an agent nobody has
612
- * started a session with, which is the case a settings page is looking at: {@link roster} reads
613
- * {@link AuthObservation} and building the roster spawns nothing, so before the first verification
614
- * (or the first real session) every agent reports "unknown" and stays that way. What is learned
615
- * here goes through {@link observe}, so the next `hello`/scan carries it.
616
- *
617
- * ### Two outcomes, and neither of them is "not authenticated"
618
- *
619
- * A session that opened proves the agent is not waiting on credentials — that is `authenticated:
620
- * true`. A session that did not open proves nothing at all: the agent may need signing in, or its
621
- * cwd may not exist, or it may have crashed. That case comes back as {@link
622
- * AcpVerification.sessionError} — the observed facts, with no verdict attached — and the
623
- * observation is *not* updated, so an earlier proof is not erased by a later inconclusive attempt.
624
- *
625
- * Failing to connect at all throws, because "this agent is not installed" and "this agent is
626
- * installed and would not start a session" are different answers to the user.
627
- */
628
- async verify(spec, timeoutMs = AGENT_PROBE_TIMEOUT_MS) {
629
- const connection = await this.connectFor(spec, timeoutMs);
630
- const authMethods = connection.authMethods;
631
- this.observe(spec.agentId, observationOf(connection));
632
1112
  try {
633
- let observed;
634
- let observedConfig;
635
- await withDeadline((async () => {
636
- const opened = await AcpSession.open(connection, { cwd: spec.cwd });
637
- // Captured off `session/new`'s own response, not off the settle wait below: the snapshot
638
- // exists the moment `AcpSession.open` resolves, unlike the command table, which the agent
639
- // only broadcasts afterwards.
640
- observedConfig = opened.configOptions;
641
- // The table is broadcast *after* `session/new` is answered (`acp-agent.js:752-757`), so a
642
- // verification that closed here — as this one used to — could never see it. The sink is
643
- // attached after `open` on purpose and is still safe: `AcpSession` buffers events until
644
- // one is attached (`onEvent`'s `drain()`), so nothing that arrived in between is lost.
645
- // Settle rather than "first push wins": Claude broadcasts twice — once on `session/new`
646
- // and again when the init frame lets it filter out terminal-bound commands. Taking the
647
- // first would leave commands in the menu that do nothing over ACP.
648
- //
649
- // 但**不能靠一个固定的等待**。这里原本是 `setTimeout(1500)` 死等,于是这段代码在和
650
- // agent 的广播赛跑,而且**机器越快越容易输**:`session/new` 秒回时,1.5 秒窗口内广播还
651
- // 没到,`observed` 保持 undefined,整张命令表就此丢失。真机上量到过同一个 agent 在热机器
652
- // 上 2.1 秒回来 0 条、在慢机器上 8.4 秒回来 72 条 —— 差别只是后者慢到"顺便"等到了。
653
- //
654
- // 原来是无条件 `setTimeout(1500)` 睡满。改成两段:一条都没来 → 到
655
- // [COMMAND_FIRST_BROADCAST_MS] 就收工(与原实现逐字同样的时间与结果);来了第一条 →
656
- // 改用安静期收尾,并受 [COMMAND_SETTLE_CEILING_MS] 兜底。**严格不劣于原实现**:
657
- // 没有广播的路径一模一样,有广播的路径要么更快,要么多收到一条原本会被丢掉的。
658
- let finish;
659
- const settled = new Promise((resolve) => {
660
- finish = resolve;
661
- });
662
- let quietTimer;
663
- let ceilingTimer;
664
- let firstTimer = setTimeout(() => finish?.(), COMMAND_FIRST_BROADCAST_MS);
665
- opened.session.onEvent((event) => {
666
- if (event.eventName !== 'available_commands_update')
667
- return;
668
- const commands = event.payload.availableCommands;
669
- if (Array.isArray(commands))
670
- observed = commands.map(toAvailableCommandWire);
671
- // 第一条到手:等第一条的窗口作废,换成"安静就收工"+ 绝对上限。
672
- if (firstTimer !== undefined) {
673
- clearTimeout(firstTimer);
674
- firstTimer = undefined;
675
- ceilingTimer = setTimeout(() => finish?.(), COMMAND_SETTLE_CEILING_MS);
676
- }
677
- // 每再来一条就把安静计时重新起头 —— Claude 的第二条才是能用的那份。
678
- if (quietTimer !== undefined)
679
- clearTimeout(quietTimer);
680
- quietTimer = setTimeout(() => finish?.(), COMMAND_QUIET_MS);
681
- });
682
- await settled;
683
- if (firstTimer !== undefined)
684
- clearTimeout(firstTimer);
685
- if (quietTimer !== undefined)
686
- clearTimeout(quietTimer);
687
- if (ceilingTimer !== undefined)
688
- clearTimeout(ceilingTimer);
689
- if (observed === undefined) {
690
- // A 那条日志的 daemon 侧对应物。没有它,"这个 agent 在这个目录下没有命令" 与
691
- // "表来晚了、窗口没接住" 在任何一层都分不开 —— 而这两件事的修法完全不同。
692
- console.warn(`[acp] agent '${spec.agentId}' broadcast no command table within ` +
693
- `${COMMAND_FIRST_BROADCAST_MS}ms of opening a session in ${spec.cwd}`);
694
- }
695
- await opened.session.close().catch(() => undefined);
696
- })(), timeoutMs, `Opening a session on agent '${spec.agentId}'`);
697
- this.observe(spec.agentId, { ...observationOf(connection), authenticated: true });
698
- return {
699
- authMethods,
700
- authenticated: true,
701
- ...(observed !== undefined ? { availableCommands: observed } : {}),
702
- ...(observedConfig !== undefined ? { configOptions: observedConfig } : {}),
703
- };
1113
+ await session.initialized(this.initializeTimeoutMs);
704
1114
  }
705
1115
  catch (error) {
706
- // Prefixed rather than relayed raw: the SDK masks an agent-thrown error as "Internal error",
707
- // and a settings page showing that alone tells the user nothing about which agent it came from
708
- // or what was being attempted.
709
- return { authMethods, sessionError: `Agent '${spec.agentId}' did not open a session: ${errorMessage(error)}` };
1116
+ await session.close('auto').catch(() => undefined);
1117
+ throw error;
710
1118
  }
711
- finally {
712
- await connection.close().catch(() => undefined);
1119
+ return session;
1120
+ }
1121
+ /**
1122
+ * The spawn spec, label and launch diagnostics of a launch (spec §5.3): the **complete** environment (keeper-acp never reads its
1123
+ * own), the daemon's working directory (where `connectAgent` started adapters), a managed startup's secrets for stderr redaction and
1124
+ * its temporary files to remove on exit; the label is opaque to the keeper.
1125
+ */
1126
+ spawnSpecOf(resolved, spec, startup) {
1127
+ const { launch } = resolved;
1128
+ const env = completeEnvironment(withPathPrefix({ ...process.env, ...launch.env, ...spec.env, ...spec.agentEnv }, launch.pathPrefix));
1129
+ const cwd = process.cwd();
1130
+ // keeper-acp takes at most ACP_STDERR_SECRETS_MAX; a managed startup's own secrets come first.
1131
+ const allSecrets = [...(startup?.secrets ?? []), ...overlayEnvSecrets(spec)];
1132
+ if (allSecrets.length > ACP_STDERR_SECRETS_MAX) {
1133
+ console.warn(`[acp] agent '${spec.agentId}': ${allSecrets.length - ACP_STDERR_SECRETS_MAX} of ${allSecrets.length} env values ` +
1134
+ `are not redacted from its stderr (keeper-acp takes at most ${ACP_STDERR_SECRETS_MAX})`);
713
1135
  }
1136
+ const stderrSecrets = allSecrets.slice(0, ACP_STDERR_SECRETS_MAX);
1137
+ const spawn = {
1138
+ command: launch.command, args: [...launch.args], env, cwd,
1139
+ ...(stderrSecrets.length > 0 ? { stderrSecrets } : {}),
1140
+ ...(startup ? { removeOnExit: [startup.dir] } : {}),
1141
+ };
1142
+ const label = {
1143
+ sessionRef: spec.sessionRef ?? null, agentId: spec.agentId, agentKey: spec.agentKey ?? null,
1144
+ buildId: launch.label?.buildId ?? null, version: launch.label?.version ?? null, kind: launch.label?.kind ?? null,
1145
+ specDigest: createHash('sha256').update(JSON.stringify([spawn.command, spawn.args, spawn.cwd, Object.keys(env).sort()])).digest('hex').slice(0, 16),
1146
+ };
1147
+ return {
1148
+ spawn, label,
1149
+ launch: {
1150
+ source: resolved.source, platform: process.platform, architecture: process.arch, transport: 'stdio',
1151
+ executable: (findOnPath(launch.command, probeEnvironment(env)) ?? launch.command).slice(0, 4096),
1152
+ },
1153
+ };
714
1154
  }
715
1155
  /**
716
- * Run one agent's `authenticate` for a `methodId` **it advertised**, over a throwaway connection.
717
- *
718
- * The refusal for an unadvertised (or terminal) `methodId` is {@link AcpAuth}'s and happens before
719
- * anything is written to the agent — which is why this hands `AcpAuth` the connection rather than
720
- * re-deriving the check: there is exactly one place that rule lives.
721
- *
722
- * Deliberately does not report an auth state. `authenticate` resolving means the agent accepted
723
- * the call, which is not the same as a session it will now open; the caller asks {@link verify}
724
- * for that, and gets an observation made the only way ACP allows one to be made. What is recorded
725
- * here is `authMethods`, because `initialize` happened.
1156
+ * The daemon's once-per-process adoption (Task 21, spec §5.5): every keeper-acp generation that runs (and the current one when
1157
+ * session directories wait on disk for it) is listed; each session whose adapter still runs is adopted where it is
1158
+ * ({@link ProcessorSession.adopt}) — its {@link KeeperAdoption.session} resolves with the rebuilt session, or `undefined` when it
1159
+ * could not be continued (its adapter is stopped then, its outbox kept). Sessions whose adapter is gone stay leftovers (offered as
1160
+ * orphans, resent on a reopen). Resolves once the listing is done; rebuilding goes on behind it. Never rejects.
726
1161
  */
727
- async authenticate(spec, methodId, login = {}) {
728
- const urls = createLoginUrlSink(login);
729
- // **The retraction has to cover the connect, not just the login.** The sniffer is armed inside
730
- // `connectAgent` at `spawn()`, before `initialize` — so a URL can be presented while this is
731
- // still awaiting, and every way connecting fails (initialize timeout, spawn error, an
732
- // unsupported protocol version, the unpinned retry failing too) would otherwise leave the card
733
- // up with nothing left running that could ever take it down. Measured, not reasoned: a probe of
734
- // the first version reported `PRESENTED [ 'stderr-auth-url-…' ] RETRACTED []`. And the trigger
735
- // is ordinary — the scan covers the `npx` install, whose own output can carry a "visit this
736
- // URL" line, and a login whose agent then fails to install is exactly when that appears.
737
- try {
738
- const connection = await this.connectFor(spec, AGENT_PROBE_TIMEOUT_MS, {
739
- ...(urls.present ? { present: urls.present } : {}),
740
- dismiss: urls.dismiss,
741
- });
742
- this.observe(spec.agentId, observationOf(connection));
1162
+ adoptKeeperSessions() {
1163
+ this.adoption ??= this.discoverKeeperSessions().catch((error) => {
1164
+ console.warn(`[acp] adopting keeper-acp sessions failed: ${errorMessage(error)}`);
1165
+ return [];
1166
+ });
1167
+ return this.adoption;
1168
+ }
1169
+ async discoverKeeperSessions() {
1170
+ const generations = this.generations;
1171
+ if (!generations || !this.transports.keeper)
1172
+ return [];
1173
+ const current = await generations.current();
1174
+ const live = await generations.live();
1175
+ const gens = live.includes(current) || !await generations.hasSessionData() ? live : [...live, current];
1176
+ return this.adoptFrom(gens, current);
1177
+ }
1178
+ /**
1179
+ * Whether a keeper-acp generation could not be listed yet (see {@link unlistedGens}): the manager then retries the adoption
1180
+ * ({@link retryKeeperAdoption}) before it handles a frame, offers its inventory or polls. Synchronous.
1181
+ */
1182
+ keeperAdoptionPending() { return this.unlistedGens.size > 0; }
1183
+ /**
1184
+ * Final review finding 3: lists again the keeper-acp generations whose listing failed (at the daemon's start or a retry since) and
1185
+ * adopts their sessions exactly as the start-time adoption does; each adoption is returned once, here. A generation that stopped
1186
+ * meanwhile is dropped (the current generation recovers its directories). One retry at a time; never rejects.
1187
+ */
1188
+ retryKeeperAdoption() {
1189
+ if (!this.adoption || this.unlistedGens.size === 0)
1190
+ return Promise.resolve([]);
1191
+ this.retrying ??= (async () => {
1192
+ await this.adoption;
1193
+ const generations = this.generations;
1194
+ if (!generations || this.unlistedGens.size === 0)
1195
+ return [];
1196
+ const current = await generations.current();
1197
+ const live = new Set(await generations.live());
1198
+ for (const gen of [...this.unlistedGens])
1199
+ if (gen !== current && !live.has(gen))
1200
+ this.unlistedGens.delete(gen);
1201
+ return this.adoptFrom([...this.unlistedGens], current);
1202
+ })().catch((error) => {
1203
+ console.warn(`[acp] retrying the adoption of keeper-acp sessions failed: ${errorMessage(error)}`);
1204
+ return [];
1205
+ }).finally(() => { this.retrying = undefined; });
1206
+ return this.retrying;
1207
+ }
1208
+ /** Lists `gens` and adopts what they hold (see {@link adoptKeeperSessions}); a generation that cannot be listed is remembered. */
1209
+ async adoptFrom(gens, current) {
1210
+ const generations = this.generations;
1211
+ const listed = [];
1212
+ for (const gen of gens) {
1213
+ let transport;
1214
+ let sessions;
743
1215
  try {
744
- const what = `Signing in to agent '${spec.agentId}' with method '${methodId}'`;
745
- await withDeadline(cancellable(new AcpAuth(connection).authenticate(methodId), login.signal, what), loginDeadlineMs(login.ceilingMs), what);
1216
+ transport = await generations.transportFor(gen);
1217
+ sessions = await withDeadline(transport.list(), this.leftoverListTimeoutMs, `keeper-acp ${gen} list`, Error);
746
1218
  }
747
- finally {
748
- // The connection dies here and not a moment earlier — this `finally` runs when the login
749
- // *settles*, never on a probe-sized deadline part-way through one.
750
- await connection.close().catch(() => undefined);
1219
+ catch (error) {
1220
+ // keeper-acp of the current build could not be launched or reached: the local fallback serves this daemon (spec §5.7) — it is
1221
+ // not a generation whose running sessions wait to be adopted (final review finding 3, correction).
1222
+ if (gen === current && error instanceof AcpKeeperUnavailableError) {
1223
+ console.warn(`[acp] keeper-acp (generation ${gen}) is unavailable; its kept sessions are not adopted: ${errorMessage(error)}`);
1224
+ continue;
1225
+ }
1226
+ this.unlistedGens.add(gen);
1227
+ console.warn(`[acp] keeper-acp generation ${gen} could not be listed; its sessions are adopted once it answers: ${errorMessage(error)}`);
1228
+ continue;
751
1229
  }
1230
+ this.unlistedGens.delete(gen);
1231
+ for (const summary of sessions)
1232
+ if (!this.ownKeeperKeys.has(summary.sessionKey))
1233
+ listed.push({ gen, transport, summary });
752
1234
  }
753
- finally {
754
- // The retraction trigger a stderr-sourced URL can never get from the protocol — see
755
- // {@link createLoginUrlSink}. This runs on every exit, including every way connecting failed.
756
- urls.finish();
1235
+ const found = [];
1236
+ const queue = [];
1237
+ for (const { gen, transport, summary } of await this.withoutSuperseded(listed)) {
1238
+ const key = summary.sessionKey;
1239
+ this.noteKeeperSession(gen, summary);
1240
+ const running = summary.process.state === 'running' || summary.process.state === 'starting';
1241
+ // An exited adapter's session is looked at too (Task 23, spec §5.3): its raw tail may hold a running turn's real end, which is
1242
+ // folded and delivered before the session is reported dead — when there is nothing to finish, `adoptOne` leaves it alone.
1243
+ const exitedWithRaw = summary.process.state === 'exited' && summary.raw !== null;
1244
+ if (!running && !exitedWithRaw)
1245
+ continue;
1246
+ const ref = refOfSession(summary);
1247
+ if (!running && ref === undefined)
1248
+ continue;
1249
+ if (ref === undefined) {
1250
+ // No ref the server could reopen it under: nobody will ever drive it.
1251
+ console.warn(`[acp] keeper-acp session ${key} names no session ref; stopping its adapter (its outbox is kept)`);
1252
+ void transport.terminate(key).catch(() => null);
1253
+ continue;
1254
+ }
1255
+ let settle;
1256
+ const session = new Promise((resolve) => { settle = resolve; });
1257
+ queue.push(async () => { settle(await this.adoptOne(transport, gen, gen === current, summary, !running)); });
1258
+ found.push({
1259
+ sessionRef: ref, agentId: String(summary.label.agentId ?? ''), agentKey: String(summary.label.agentKey ?? ''),
1260
+ journalMaxSeq: this.keeperHeads.get(key) ?? 0, ...(running ? {} : { agentAlive: false }), session,
1261
+ });
757
1262
  }
1263
+ // A few at a time: each rebuild reads a checkpoint and up to one cadence of raw.
1264
+ const worker = async () => { for (let next = queue.shift(); next; next = queue.shift())
1265
+ await next(); };
1266
+ void Promise.all(Array.from({ length: ADOPT_CONCURRENCY }, worker)).then(() => this.retireIdleGenerations());
1267
+ return found;
758
1268
  }
759
1269
  /**
760
- * The spawn half shared by {@link verify} and {@link authenticate}. No session either way — but
761
- * **not "no handlers" any more**, and the difference is the whole of Task 4b: `authenticate` is a
762
- * round trip through a human, so its connection carries the elicitation/permission handlers and
763
- * the stderr sniffer that a login URL reaches the reader through. `verify` passes no `login` and
764
- * so gets exactly what it always got: a bare connection nobody can raise a card on.
765
- *
766
- * **This is the only method in this class that builds a `present`**, and that is the mechanism, not
767
- * a convention: {@link connectResolved} — the route every other connection takes, including
1270
+ * Task 24: one keeper-acp session per ref. Two exist only when a switch was interrupted by a crash (the successor `<ref>#s<n>` is
1271
+ * spawned, and loaded, while the predecessor still exists; the predecessor is released last): `resolveSuperseded` keeps the newest
1272
+ * key whose open completed — its import of the predecessor's outbox had landed — or, when none did, the oldest. The others are
1273
+ * stopped and released. Every listed ref's current key generation is recorded.
1274
+ */
1275
+ async withoutSuperseded(listed) {
1276
+ const byRef = new Map();
1277
+ const kept = [];
1278
+ for (const entry of listed) {
1279
+ const ref = refOfSession(entry.summary);
1280
+ if (ref === undefined) {
1281
+ kept.push(entry);
1282
+ continue;
1283
+ }
1284
+ byRef.set(ref, [...(byRef.get(ref) ?? []), entry]);
1285
+ }
1286
+ for (const [ref, entries] of byRef) {
1287
+ let winner = entries[0];
1288
+ if (entries.length > 1) {
1289
+ const resolved = await resolveSuperseded(entries.map((entry) => ({ ...entry, sessionKey: entry.summary.sessionKey, label: entry.summary.label })), (entry) => this.openCompleted(entry.transport, entry.summary.sessionKey));
1290
+ winner = entries.find((entry) => entry.summary.sessionKey === resolved.winner.sessionKey);
1291
+ for (const gone of resolved.superseded) {
1292
+ console.warn(`[acp] ${ref}: keeper-acp session ${gone.sessionKey} (generation ${gone.gen}) was superseded by ${winner.summary.sessionKey} ` +
1293
+ '(a switch a restart interrupted); stopping and releasing it');
1294
+ await gone.transport.terminate(gone.sessionKey).catch(() => null);
1295
+ await gone.transport.release(gone.sessionKey).catch((error) => {
1296
+ console.warn(`[acp] releasing the superseded session ${gone.sessionKey} failed: ${errorMessage(error)}`);
1297
+ });
1298
+ }
1299
+ }
1300
+ this.refKeys.set(ref, keyGenOf(winner.summary.label));
1301
+ kept.push(winner);
1302
+ }
1303
+ return kept;
1304
+ }
1305
+ /** Whether a keeper-acp session's head checkpoint says its open had completed (no load history open). */
1306
+ async openCompleted(transport, sessionKey) {
1307
+ try {
1308
+ const attached = await transport.attach(sessionKey);
1309
+ if (!attached.checkpoint)
1310
+ return false;
1311
+ // A successor the switch gave up on (its rollback took the session back) never wins, whatever its checkpoint says.
1312
+ if (await this.switchAbandoned(transport, sessionKey, attached))
1313
+ return false;
1314
+ const checkpoint = decodeCheckpoint(Buffer.from(attached.checkpoint.blob, 'base64').toString('utf8'));
1315
+ return checkpoint.state.open.status === 'open' && checkpoint.state.history === null;
1316
+ }
1317
+ catch {
1318
+ return false;
1319
+ }
1320
+ }
1321
+ noteKeeperSession(gen, summary) {
1322
+ this.keeperOwner.set(summary.sessionKey, gen);
1323
+ const tail = isOutboxTail(summary.outbox.committedTail) ? summary.outbox.committedTail.seq : 0;
1324
+ this.keeperHeads.set(summary.sessionKey, Math.max(summary.outbox.lastSeq ?? 0, tail));
1325
+ const ref = refOfSession(summary);
1326
+ if (ref !== undefined && keyGenOf(summary.label) >= (this.refKeys.get(ref) ?? 0))
1327
+ this.refKeys.set(ref, keyGenOf(summary.label));
1328
+ }
1329
+ async adoptOne(transport, gen, current, summary, exited = false) {
1330
+ const key = summary.sessionKey;
1331
+ try {
1332
+ if (!current)
1333
+ this.heldOnOlder.set(key, gen);
1334
+ const options = { transport, sessionKey: key, label: summary.label, onDegrade: this.recordDegraded(key, gen),
1335
+ // An older generation that no longer hosts a session of this daemon (or a running adapter) is stopped once this one is gone.
1336
+ ...(current ? {} : { onClosed: () => this.olderSessionClosed(key) }) };
1337
+ const session = exited ? await ProcessorSession.adopt({ ...options, allowExited: true }) : await ProcessorSession.adopt(options);
1338
+ if (!session) {
1339
+ // An exited adapter with no turn left to finish: nothing to hold; it stays a leftover for its ref's next open.
1340
+ this.heldOnOlder.delete(key);
1341
+ return undefined;
1342
+ }
1343
+ // The owner of an older generation's session is kept: a reopen after its adapter exits spawns there (fix round 1, I-2).
1344
+ if (current)
1345
+ this.keeperOwner.delete(key);
1346
+ return session;
1347
+ }
1348
+ catch (error) {
1349
+ this.heldOnOlder.delete(key);
1350
+ if (exited) {
1351
+ // Its adapter had already exited: there is nothing to stop. Its outbox stays for its ref's next open (which loads it).
1352
+ console.warn(`[acp] keeper-acp session ${key} (generation ${gen}, adapter exited) could not be adopted` +
1353
+ `${error instanceof AdoptionRefusedError ? '' : ' (keeper-acp failed)'}: ${errorMessage(error)}; keeping its outbox`);
1354
+ return undefined;
1355
+ }
1356
+ // Spec §5.5.6 (Task 24 replaces Task 21's interim stop): a session this daemon cannot continue — its open never completed, or
1357
+ // its checkpoint is unreadable and raw truncated — is marked degraded and left running; the ref's next `agent_open` switches it
1358
+ // (closes the process, then a new one loads the native session). Until then it is offered as an orphan; its outbox is kept.
1359
+ const reason = errorMessage(error);
1360
+ console.warn(`[acp] keeper-acp session ${key} (generation ${gen}) could not be adopted` +
1361
+ `${error instanceof AdoptionRefusedError ? '' : ' (keeper-acp failed)'}: ${reason}; marked degraded — its next open switches it`);
1362
+ this.unrecoverable.set(key, { gen, reason });
1363
+ this.keeperOwner.set(key, gen);
1364
+ return undefined;
1365
+ }
1366
+ }
1367
+ /** Where a keeper spawn of `sessionKey` goes: the older generation that still holds a kept session of it, else the current one. */
1368
+ async spawnTargetFor(sessionKey) {
1369
+ const generations = this.generations;
1370
+ const owner = this.keeperOwner.get(sessionKey);
1371
+ const current = this.transports.keeper;
1372
+ if (!generations || owner === undefined || owner === await generations.current())
1373
+ return { transport: current };
1374
+ if (!(await generations.live()).includes(owner))
1375
+ return { transport: current }; // gone: the current one adopts the directory
1376
+ return { transport: await generations.transportFor(owner), gen: owner };
1377
+ }
1378
+ /**
1379
+ * The manager retires a session to respawn it at once (an `agent_open` of a keeper session whose adapter is gone, Task 21 fix
1380
+ * round 2): until the returned release, the older generation that holds it is not stopped — the retire's close no longer releases
1381
+ * the hold, the respawn's spawn takes it over (or drops it when it goes elsewhere or fails). Release after the attempt, whatever
1382
+ * it did.
1383
+ */
1384
+ holdForReopen(sessionRef) {
1385
+ return this.holdKey(this.keyFor(sessionRef));
1386
+ }
1387
+ /** {@link holdForReopen} by key (a switch holds its predecessor's generation until it is released or rolled back to). */
1388
+ holdKey(sessionKey) {
1389
+ this.reopenHolds.add(sessionKey);
1390
+ let released = false;
1391
+ return () => {
1392
+ if (released)
1393
+ return;
1394
+ released = true;
1395
+ this.reopenHolds.delete(sessionKey);
1396
+ void this.retireIdleGenerations();
1397
+ };
1398
+ }
1399
+ // ---- switching (Task 24, spec §6.3) ---------------------------------------------------------------------------------------
1400
+ /** A ref's current keeper-acp key: `<ref>`, or `<ref>#s<n>` once a switch moved it (`switching.ts`). */
1401
+ keyFor(sessionRef) {
1402
+ return sessionKeyFor(sessionRef, this.refKeys.get(sessionRef) ?? 0);
1403
+ }
1404
+ /**
1405
+ * Where a held session runs (Task 24/25): its transport key, the keeper-acp generation hosting it (`undefined`: the current one, or
1406
+ * the local fallback), its build (`undefined`: a command override) and whether keeper-acp hosts it at all.
1407
+ */
1408
+ placementOf(session) {
1409
+ if (!(session instanceof ProcessorSession))
1410
+ return undefined;
1411
+ const label = session.label;
1412
+ const gen = this.heldOnOlder.get(session.sessionKey);
1413
+ return {
1414
+ sessionKey: session.sessionKey, keeper: session.transportMode === 'keeper',
1415
+ ...(gen !== undefined ? { gen } : {}),
1416
+ ...(typeof label.agentId === 'string' ? { agentId: label.agentId } : {}),
1417
+ ...(typeof label.buildId === 'string' ? { buildId: label.buildId } : {}),
1418
+ };
1419
+ }
1420
+ /**
1421
+ * Move a live keeper-hosted session onto a new adapter process (spec §6.3): the target build (`toBuild`, else the agent's current
1422
+ * one — a command override keeps its command) on the **current** keeper-acp generation, under a new key, build before teardown.
1423
+ * Called only at an `agent_open` (its `spec` carries the session's fresh spawn environment) and only for a strictly idle session
1424
+ * (the caller checks; this only holds the loop at a record boundary). Steps:
1425
+ *
1426
+ * 1. lease both builds; hold the predecessor's generation; hold the predecessor's loop ({@link ProcessorSession.suspend}) — its
1427
+ * checkpoint is the successor's initial state;
1428
+ * 2. spawn the successor `<ref>#s<n>` with `baseOffset` past the predecessor's raw log, the predecessor's unacknowledged outbox
1429
+ * entries imported first, and `initialize` it — it must advertise `loadSession`, or it is stopped and released and the
1430
+ * predecessor goes on untouched (`unmovable`);
1431
+ * 3. only now stop the predecessor's adapter (its transport session — raw, outbox — stays), then `session/load` on the successor
1432
+ * with `requireSessionLoad` **forced** whatever the `agent_open` said (never `session/new`), and set back every config option the
1433
+ * load did not restore;
1434
+ * 4. success: release the predecessor (the import landed in step 2) — `switched`; failure: stop and release the successor, spawn
1435
+ * the predecessor's build again in the predecessor's key and load there (`rolledBack`), or, when that fails too, `lost`.
1436
+ *
1437
+ * Runs under the agent's build lock (install / switch / GC are serialised per agent). Never rejects.
1438
+ */
1439
+ async switchSession(from, spec, request) {
1440
+ const ref = spec.sessionRef;
1441
+ const nativeSessionId = (from instanceof ProcessorSession ? from.nativeSessionId : null) ?? spec.nativeSessionId;
1442
+ const refuse = (reason, permanent = true) => ({ kind: 'unmovable', reason, permanent });
1443
+ let outcome;
1444
+ if (!(from instanceof ProcessorSession) || from.transportMode !== 'keeper' || !this.transports.keeper) {
1445
+ outcome = refuse('only a keeper-hosted session can be moved');
1446
+ }
1447
+ else if (ref === undefined) {
1448
+ outcome = refuse('the session has no ref');
1449
+ }
1450
+ else if (!nativeSessionId) {
1451
+ outcome = refuse('the session has no native session to load');
1452
+ }
1453
+ else if (spec.delegationMcp || from.delegation) {
1454
+ outcome = refuse('a managed delegation session is not moved');
1455
+ }
1456
+ else {
1457
+ outcome = await this.builds.withAgentLock(spec.agentId, () => this.switchLocked(from, { ...spec, sessionRef: ref, nativeSessionId }, request))
1458
+ .catch((error) => refuse(`the switch failed before anything was stopped: ${errorMessage(error)}`, false));
1459
+ }
1460
+ if (ref !== undefined) {
1461
+ // The build the refusal was about, resolved (Task 25): a later "move" to a different build may try again.
1462
+ const refusedFor = async () => request.toBuild ?? (await this.builds.agent(spec.agentId).catch(() => undefined))?.current;
1463
+ if (outcome.kind === 'unmovable' && outcome.permanent) {
1464
+ const toBuild = await refusedFor();
1465
+ this.unmovableRefs.set(ref, { reason: outcome.reason, ...(toBuild ? { toBuild } : {}) });
1466
+ }
1467
+ else if (outcome.kind === 'rolledBack' && !outcome.retry) {
1468
+ const toBuild = await refusedFor();
1469
+ this.unmovableRefs.set(ref, { reason: outcome.error, ...(toBuild ? { toBuild } : {}) });
1470
+ }
1471
+ else if (outcome.kind === 'switched')
1472
+ this.unmovableRefs.delete(ref);
1473
+ // Fulfilled, or never possible: the intent goes. A transient refusal keeps it for the next idle moment.
1474
+ if (!(outcome.kind === 'unmovable' && !outcome.permanent) && !(outcome.kind === 'rolledBack' && outcome.retry)) {
1475
+ await this.cancelSwitch({ sessionRef: ref }).catch(() => false);
1476
+ }
1477
+ }
1478
+ const what = outcome.kind === 'switched' ? `moved to ${outcome.toKey}` : outcome.kind === 'unmovable' ? `not moved: ${outcome.reason}`
1479
+ : outcome.kind === 'rolledBack' ? `rolled back: ${outcome.error}` : `lost: ${outcome.error}`;
1480
+ console.info(`[acp] switch of ${ref ?? '?'} (agent '${spec.agentId}', ${request.reason}): ${what}`);
1481
+ if (outcome.kind === 'switched')
1482
+ void this.collectBuilds().catch(() => undefined);
1483
+ return outcome;
1484
+ }
1485
+ async switchLocked(from, spec, request) {
1486
+ const ref = spec.sessionRef;
1487
+ const agentId = spec.agentId;
1488
+ const oldKey = from.sessionKey;
1489
+ const oldGen = this.heldOnOlder.get(oldKey);
1490
+ const oldTransport = oldGen === undefined ? this.transports.keeper : await this.generations.transportFor(oldGen);
1491
+ const current = this.transports.keeper;
1492
+ const fromBuildId = typeof from.label.buildId === 'string' ? from.label.buildId : undefined;
1493
+ let resolved;
1494
+ let toBuildId;
1495
+ try {
1496
+ if (spec.command) {
1497
+ resolved = { launch: { command: spec.command, args: spec.args, env: {} }, source: 'command-override' };
1498
+ }
1499
+ else {
1500
+ toBuildId = request.toBuild ?? (await this.builds.agent(agentId))?.current;
1501
+ if (toBuildId === undefined)
1502
+ return { kind: 'unmovable', reason: `agent '${agentId}' has no current build`, permanent: true };
1503
+ resolved = await this.launchOfBuild(agentId, toBuildId);
1504
+ }
1505
+ }
1506
+ catch (error) {
1507
+ return { kind: 'unmovable', reason: errorMessage(error), permanent: true };
1508
+ }
1509
+ const releases = [this.leaseBuild(agentId, fromBuildId), this.leaseBuild(agentId, toBuildId), this.holdKey(oldKey)];
1510
+ try {
1511
+ const configBefore = from.configOptions.map((option) => ({ ...option }));
1512
+ let suspendTimer;
1513
+ const checkpoint = await Promise.race([
1514
+ from.suspend(),
1515
+ new Promise((resolve) => { suspendTimer = setTimeout(() => resolve(undefined), SWITCH_SUSPEND_TIMEOUT_MS); }),
1516
+ ]).finally(() => clearTimeout(suspendTimer));
1517
+ if (!checkpoint) {
1518
+ from.resume();
1519
+ return { kind: 'unmovable', reason: 'its record loop could not be held at a boundary', permanent: false };
1520
+ }
1521
+ // 2. The successor, under a fresh key on the current generation.
1522
+ const taken = new Set((await current.list()).map((summary) => summary.sessionKey));
1523
+ let keyGen = Math.max(keyGenOf(from.label), this.refKeys.get(ref) ?? 0) + 1;
1524
+ while (taken.has(sessionKeyFor(ref, keyGen)))
1525
+ keyGen += 1;
1526
+ const newKey = sessionKeyFor(ref, keyGen);
1527
+ const oldRaw = (await oldTransport.list()).find((summary) => summary.sessionKey === oldKey)?.raw;
1528
+ const baseOffset = Math.max(checkpoint.offset + 1, oldRaw?.next ?? 0);
1529
+ const imports = from.outbox.pending.map((item) => ({ seq: item.seq, frame: item.frame }));
1530
+ let next;
1531
+ try {
1532
+ next = await this.startPlaced(resolved, spec, { transport: current, sessionKey: newKey, keyGen, seqBase: from.headSeq, switchedFrom: oldKey,
1533
+ seed: { checkpoint, baseOffset, imports } });
1534
+ }
1535
+ catch (error) {
1536
+ await current.terminate(newKey).catch(() => null);
1537
+ await current.release(newKey).catch(() => undefined);
1538
+ from.resume();
1539
+ return { kind: 'unmovable', reason: `the new adapter process did not start: ${errorMessage(error)}`, permanent: false };
1540
+ }
1541
+ const facts = next.initializeFacts;
1542
+ if (!facts.capabilities.loadSession) {
1543
+ // Build before teardown: the predecessor was never touched.
1544
+ await next.close('release').catch(() => undefined);
1545
+ from.resume();
1546
+ return { kind: 'unmovable', reason: `${facts.agentInfo?.name ?? agentId} ${facts.agentInfo?.version ?? ''} does not support session/load`.replace(/ +/g, ' '), permanent: true };
1547
+ }
1548
+ if (request.proceed && !request.proceed()) {
1549
+ await next.close('release').catch(() => undefined);
1550
+ from.resume();
1551
+ return { kind: 'unmovable', reason: 'the workstation is stopping', permanent: false };
1552
+ }
1553
+ // The predecessor's adapter wrote something while its loop was held (fix round 1): only the predecessor can fold it, and its
1554
+ // log goes with it. Not now — the predecessor folds it, the intent stays, the next idle moment tries again. Also looked up in
1555
+ // keeper-acp itself: an output record already appended but not yet delivered counts the same. Only adapter output (`up`)
1556
+ // counts (fix round 2); stderr does not hold a switch back.
1557
+ if (from.arrivedWhileSuspended > 0 || await this.outputPast(oldTransport, oldKey, checkpoint.offset)) {
1558
+ await next.close('release').catch(() => undefined);
1559
+ from.resume();
1560
+ return { kind: 'unmovable', reason: 'its adapter produced output while the switch was being prepared', permanent: false };
1561
+ }
1562
+ // 3. Only now: the predecessor's adapter stops (its raw and outbox stay, for a rollback) — with its loop still held.
1563
+ await from.stopAdapter();
1564
+ // Fix round 2: keeper-acp records what the adapter still wrote on its way out (after the check above, or on SIGTERM) into the
1565
+ // predecessor's raw. Only the predecessor can fold it — under its own seqs, which the successor has not used yet (it numbered
1566
+ // nothing it recorded: it is not registered) — so the switch is given up here: the successor is abandoned, the predecessor
1567
+ // folds its whole tail (output, then its exit) on its loop, and is restarted and loaded by the rollback. Nothing is lost and the
1568
+ // intent stays for a later idle moment.
1569
+ if (await this.outputPast(oldTransport, oldKey, checkpoint.offset)) {
1570
+ await this.abandonSuccessor(current, newKey, next, 'the previous adapter wrote output while it was being stopped');
1571
+ const tailEnd = ((await oldTransport.list()).find((summary) => summary.sessionKey === oldKey)?.raw?.next ?? checkpoint.offset + 1) - 1;
1572
+ from.resume();
1573
+ await from.foldedThrough(tailEnd).catch((error) => {
1574
+ console.warn(`[acp] ${oldKey}: folding the stopped adapter's last output failed: ${errorMessage(error)}`);
1575
+ });
1576
+ await from.close('keep');
1577
+ const back = await this.rollBack(from, spec, { oldKey, oldGen, oldTransport, fromBuildId, configBefore,
1578
+ error: 'the previous adapter wrote output while it was being stopped' });
1579
+ return back.kind === 'rolledBack' ? { ...back, retry: true } : back;
1580
+ }
1581
+ await from.close('keep');
1582
+ try {
1583
+ await this.loadInto(next, spec, configBefore);
1584
+ }
1585
+ catch (error) {
1586
+ await this.abandonSuccessor(current, newKey, next, errorMessage(error));
1587
+ return this.rollBack(from, spec, { oldKey, oldGen, oldTransport, fromBuildId, configBefore, error: errorMessage(error) });
1588
+ }
1589
+ // 4. The successor holds the session and the predecessor's unacknowledged frames (imported before the load): release it.
1590
+ this.refKeys.set(ref, keyGen);
1591
+ this.heldOnOlder.delete(oldKey);
1592
+ this.keeperOwner.delete(oldKey);
1593
+ this.keeperHeads.delete(oldKey);
1594
+ this.unrecoverable.delete(oldKey);
1595
+ await oldTransport.release(oldKey).catch((error) => {
1596
+ console.warn(`[acp] releasing ${oldKey} after its switch to ${newKey} failed (the next daemon drops it as superseded): ${errorMessage(error)}`);
1597
+ });
1598
+ return { kind: 'switched', session: next, nativeSessionId: spec.nativeSessionId, fromKey: oldKey, toKey: newKey };
1599
+ }
1600
+ finally {
1601
+ for (const release of releases)
1602
+ release();
1603
+ }
1604
+ }
1605
+ /**
1606
+ * A successor that will not hold the session (its load failed, or it failed after loading — fix round 1): marked abandoned in its
1607
+ * own log FIRST (`switch_abandoned` note — a crash before the release must not let the next daemon's discovery pick it over the
1608
+ * rolled-back predecessor, which then holds every newer frame), then stopped and released, retried, and loudly reported when it
1609
+ * stays.
1610
+ */
1611
+ async abandonSuccessor(transport, key, session, reason) {
1612
+ await transport.note(key, { type: SWITCH_ABANDONED_NOTE, reason: reason.slice(0, 1024) }).catch((error) => {
1613
+ console.error(`[acp] marking the abandoned switch successor ${key} failed: ${errorMessage(error)}`);
1614
+ });
1615
+ await session.close('release').catch(() => undefined);
1616
+ let last;
1617
+ for (let attempt = 0; attempt < 3; attempt += 1) {
1618
+ let still;
1619
+ try {
1620
+ still = (await transport.list()).some((summary) => summary.sessionKey === key);
1621
+ }
1622
+ catch (error) {
1623
+ last = error;
1624
+ still = true;
1625
+ }
1626
+ if (!still)
1627
+ return;
1628
+ if (attempt > 0)
1629
+ await new Promise((resolve) => setTimeout(resolve, 250 * attempt));
1630
+ await transport.terminate(key).catch(() => null);
1631
+ await transport.release(key).catch((error) => { last = error; });
1632
+ }
1633
+ console.error(`[acp] the abandoned switch successor ${key} could not be released (${last instanceof Error ? last.message : String(last)}); ` +
1634
+ 'it is marked abandoned, so the next daemon drops it in favour of its predecessor');
1635
+ }
1636
+ /** Whether raw holds adapter output (`up`) past `offset` (the predecessor's checkpoint): output only it can fold (fix round 2). */
1637
+ async outputPast(transport, sessionKey, offset) {
1638
+ for (let at = offset + 1;;) {
1639
+ let page;
1640
+ try {
1641
+ page = await transport.readRaw(sessionKey, at);
1642
+ }
1643
+ catch {
1644
+ return true;
1645
+ } // cannot tell: do not risk it
1646
+ if (page.length === 0)
1647
+ return false;
1648
+ if (page.some((record) => record.kind === 'up'))
1649
+ return true;
1650
+ at = page[page.length - 1].o + 1;
1651
+ }
1652
+ }
1653
+ /** Whether a switch successor was marked abandoned (fix round 1): its log holds a `switch_abandoned` note. */
1654
+ async switchAbandoned(transport, sessionKey, attached) {
1655
+ for (let at = attached.raw?.first ?? 0; at <= attached.head;) {
1656
+ let page;
1657
+ try {
1658
+ page = await transport.readRaw(sessionKey, at);
1659
+ }
1660
+ catch {
1661
+ return false;
1662
+ }
1663
+ if (page.length === 0)
1664
+ return false;
1665
+ if (page.some((record) => record.kind === 'note' && record.note.type === SWITCH_ABANDONED_NOTE))
1666
+ return true;
1667
+ at = page[page.length - 1].o + 1;
1668
+ }
1669
+ return false;
1670
+ }
1671
+ /** Step 6: the predecessor's build in the predecessor's key, loaded again; `lost` when that fails too (never `session/new`). */
1672
+ async rollBack(from, spec, old) {
1673
+ let back;
1674
+ try {
1675
+ const resolved = spec.command
1676
+ ? { launch: { command: spec.command, args: spec.args, env: {} }, source: 'command-override' }
1677
+ : old.fromBuildId !== undefined ? await this.launchOfBuild(spec.agentId, old.fromBuildId)
1678
+ : (() => { throw new Error('the session\'s build is unknown'); })();
1679
+ back = await this.startPlaced(resolved, spec, { transport: old.oldTransport, sessionKey: old.oldKey, keyGen: keyGenOf(from.label),
1680
+ seqBase: from.headSeq, ...(old.oldGen !== undefined ? { gen: old.oldGen } : {}) });
1681
+ if (!back.initializeFacts.capabilities.loadSession)
1682
+ throw new Error('the previous build no longer advertises session/load');
1683
+ await this.loadInto(back, spec, old.configBefore);
1684
+ return { kind: 'rolledBack', session: back, nativeSessionId: spec.nativeSessionId, error: old.error };
1685
+ }
1686
+ catch (error) {
1687
+ await back?.close('keep').catch(() => undefined);
1688
+ if (old.oldGen !== undefined)
1689
+ this.heldOnOlder.delete(old.oldKey);
1690
+ return { kind: 'lost', error: `the new adapter could not load the session (${old.error}), and neither could the previous one (${errorMessage(error)})` };
1691
+ }
1692
+ }
1693
+ /** `session/load` with `requireSessionLoad` forced (spec §5.5.4/§6.3: never `session/new`), then the config options set back. */
1694
+ async loadInto(session, spec, configBefore) {
1695
+ const facts = session.initializeFacts;
1696
+ await withDeadline(this.startSession(session, facts, { ...spec, requireSessionLoad: true, backfill: false }), this.sessionStartTimeoutMs, `Agent '${spec.agentId}' session/load`, AgentSessionStartTimeoutError);
1697
+ if (session.nativeSessionId !== spec.nativeSessionId)
1698
+ throw new Error(`session/load bound '${session.nativeSessionId}', not '${spec.nativeSessionId}'`);
1699
+ await restoreConfigOptions(session, configBefore, () => `switch-${randomUUID()}`, (line) => console.warn(line));
1700
+ }
1701
+ /** A session spawned where the caller says (a switch's successor, or its rollback), `initialize`d. */
1702
+ async startPlaced(resolved, spec, placement) {
1703
+ const { spawn, label, launch } = this.spawnSpecOf(resolved, spec);
1704
+ const key = placement.sessionKey;
1705
+ this.ownKeeperKeys.add(key);
1706
+ if (placement.gen !== undefined)
1707
+ this.heldOnOlder.set(key, placement.gen);
1708
+ const session = await ProcessorSession.start({
1709
+ spawn, launch, seqBase: placement.seqBase, backfill: false, transport: placement.transport, sessionKey: key,
1710
+ label: { ...label, ...(placement.keyGen > 0 ? { keyGen: placement.keyGen } : {}), ...(placement.switchedFrom ? { switchedFrom: placement.switchedFrom } : {}) },
1711
+ ...(placement.seed ? { seed: placement.seed } : {}),
1712
+ onDegrade: this.recordDegraded(key, placement.gen ?? 'current'),
1713
+ ...(placement.gen !== undefined ? { onClosed: () => this.olderSessionClosed(key) } : {}),
1714
+ });
1715
+ try {
1716
+ await session.initialized(this.initializeTimeoutMs);
1717
+ }
1718
+ catch (error) {
1719
+ await session.close(placement.seed ? 'release' : 'auto').catch(() => undefined);
1720
+ throw error;
1721
+ }
1722
+ return session;
1723
+ }
1724
+ /** The launch of one recorded build (not necessarily the current one), through the same Node gate and launch env as any open. */
1725
+ async launchOfBuild(agentId, buildId) {
1726
+ const build = (await this.builds.agent(agentId))?.builds[buildId];
1727
+ if (!build || !existsSync(inside(this.adapterRoot, build.dir)))
1728
+ throw new AdapterNotInstalledError(agentId, `build ${buildId} is not installed`);
1729
+ this.touchBuild(agentId, buildId);
1730
+ return {
1731
+ source: 'registry',
1732
+ launch: await launchForBuild(build, this.adapterRoot, {
1733
+ nodePath: this.nodePath, launchEnv: catalogLaunchEnv(agentId, build.registryVersion), basePath: this.probe.path,
1734
+ }),
1735
+ };
1736
+ }
1737
+ // ---- switch intents (spec §6.3 step 7) ------------------------------------------------------------------------------------
1738
+ /** The intents, read from `builds.json` once; every write below updates this copy too. */
1739
+ loadIntents() {
1740
+ this.intents ??= this.builds.switchIntents().then((all) => new Map(all.map(({ agentId, sessionRef, intent }) => [sessionRef, { agentId, intent }])), (error) => { this.intents = undefined; throw error; });
1741
+ return this.intents;
1742
+ }
1743
+ /** Every persistent switch intent (`builds.json` `switchPending`). */
1744
+ async switchIntents() {
1745
+ return [...(await this.loadIntents())].map(([sessionRef, { agentId, intent }]) => ({ agentId, sessionRef, intent }));
1746
+ }
1747
+ /**
1748
+ * Record that `sessionRef` (a session of `agentId`) should move — to `toBuild`, else the agent's current build at the time, and to
1749
+ * the current keeper-acp generation (`keeperGen: 'current'` alone: only off an older generation). Persistent: survives a restart.
1750
+ */
1751
+ async requestSwitch(agentId, sessionRef, intent) {
1752
+ const record = {
1753
+ ...(intent.toBuild !== undefined ? { toBuild: intent.toBuild } : {}), ...(intent.keeperGen ? { keeperGen: intent.keeperGen } : {}),
1754
+ reason: intent.reason, requestedBy: intent.requestedBy ?? 'user', requestedAt: new Date().toISOString(),
1755
+ };
1756
+ const intents = await this.loadIntents();
1757
+ if (!await this.builds.setSwitchIntent(agentId, sessionRef, record))
1758
+ throw new Error(`${this.builds.file} could not be written`);
1759
+ intents.set(sessionRef, { agentId, intent: record });
1760
+ this.unmovableRefs.delete(sessionRef);
1761
+ }
1762
+ /** Cancel the intent of one ref, or every intent of one agent. `true` when one existed. */
1763
+ async cancelSwitch(target) {
1764
+ const intents = await this.loadIntents();
1765
+ const refs = 'sessionRef' in target ? [target.sessionRef]
1766
+ : [...intents].filter(([, entry]) => entry.agentId === target.agentId).map(([ref]) => ref);
1767
+ if (!refs.some((ref) => intents.has(ref)))
1768
+ return false;
1769
+ const removed = await this.builds.cancelSwitchIntents(target);
1770
+ for (const ref of refs)
1771
+ intents.delete(ref);
1772
+ return removed;
1773
+ }
1774
+ /** Why `sessionRef` cannot be moved (spec §6.4 `unmovable`), if a switch of it found so. */
1775
+ unmovableReason(sessionRef) {
1776
+ return this.unmovableRefs.get(sessionRef)?.reason;
1777
+ }
1778
+ /** {@link unmovableReason} with the build the refusal was about (`toBuild`; absent when it could not be resolved). */
1779
+ unmovableFor(sessionRef) {
1780
+ return this.unmovableRefs.get(sessionRef);
1781
+ }
1782
+ /**
1783
+ * The switch a held session still owes its intent, or `undefined` (no intent, or it is fulfilled — then the intent is dropped).
1784
+ * A build intent is fulfilled once the session runs the target build; a keeper-generation intent once it runs on the current
1785
+ * generation. A command-override session only ever moves generations.
1786
+ */
1787
+ async pendingSwitchFor(sessionRef, session) {
1788
+ const found = (await this.loadIntents()).get(sessionRef);
1789
+ if (!found)
1790
+ return undefined;
1791
+ const placement = this.placementOf(session);
1792
+ if (!placement?.keeper)
1793
+ return undefined;
1794
+ const { intent, agentId } = found;
1795
+ const onOlder = placement.gen !== undefined;
1796
+ const target = intent.keeperGen === 'current' && intent.toBuild === undefined ? undefined
1797
+ : intent.toBuild ?? (await this.builds.agent(agentId))?.current;
1798
+ const needsBuild = target !== undefined && placement.buildId !== undefined && placement.buildId !== target;
1799
+ const needsGen = intent.keeperGen === 'current' && onOlder;
1800
+ if (!needsBuild && !needsGen) {
1801
+ await this.cancelSwitch({ sessionRef }).catch(() => false);
1802
+ return undefined;
1803
+ }
1804
+ return { ...(needsBuild ? { toBuild: target } : {}), reason: intent.reason };
1805
+ }
1806
+ // ---- build leases and GC (spec §6.3 "GC") ----------------------------------------------------------------------------------
1807
+ /** A daemon lease on a build (GC never takes it while held); the release also marks it seen now. */
1808
+ leaseBuild(agentId, buildId) {
1809
+ if (buildId === undefined)
1810
+ return () => { };
1811
+ const key = buildKeyOf(agentId, buildId);
1812
+ this.leases.set(key, (this.leases.get(key) ?? 0) + 1);
1813
+ this.touchBuild(agentId, buildId);
1814
+ let released = false;
1815
+ return () => {
1816
+ if (released)
1817
+ return;
1818
+ released = true;
1819
+ const left = (this.leases.get(key) ?? 1) - 1;
1820
+ if (left > 0)
1821
+ this.leases.set(key, left);
1822
+ else
1823
+ this.leases.delete(key);
1824
+ this.touchBuild(agentId, buildId, Date.now());
1825
+ };
1826
+ }
1827
+ /**
1828
+ * A build was just resolved for a launch: protected until any connection started from it has necessarily ended (a login's ceiling)
1829
+ * — the lease of the short-lived connections (verify, list, delete, login), which end on their own.
1830
+ */
1831
+ touchBuild(agentId, buildId, until = Date.now() + AGENT_LOGIN_CEILING_MS) {
1832
+ const key = buildKeyOf(agentId, buildId);
1833
+ this.buildSeen.set(key, Math.max(this.buildSeen.get(key) ?? 0, until));
1834
+ }
1835
+ /**
1836
+ * The builds adapter processes run right now: every keeper-acp generation that runs, listed (a generation that cannot be listed →
1837
+ * `undefined`: nothing may be collected), plus the local fallback's processes.
1838
+ */
1839
+ async runningBuildReferences() {
1840
+ const listed = await this.listRunningAdapters();
1841
+ if (!listed)
1842
+ return undefined;
1843
+ const refs = [];
1844
+ for (const { summary } of listed) {
1845
+ const { agentId, buildId } = summary.label;
1846
+ if (typeof agentId === 'string' && typeof buildId === 'string')
1847
+ refs.push({ agentId, buildId });
1848
+ }
1849
+ return refs;
1850
+ }
1851
+ /**
1852
+ * Collect unused adapter builds (spec §6.3; `builds/gc.ts` has the rules) and what an older CLI left. Never while keeper-acp cannot
1853
+ * be listed. One at a time; never rejects (failures are logged).
1854
+ */
1855
+ collectBuilds(options = {}) {
1856
+ const previous = this.collecting;
1857
+ let run;
1858
+ run = (async () => {
1859
+ await previous?.catch(() => undefined);
1860
+ try {
1861
+ const running = await this.runningBuildReferences();
1862
+ const leased = [...this.leases.keys()].map((key) => { const [agentId, buildId] = key.split('\u0000'); return { agentId: agentId, buildId: buildId }; });
1863
+ // A pending switch's target build is leased until the switch ran (fix round 1): GC must not take it first.
1864
+ for (const { agentId, intent } of await this.switchIntents().catch(() => [])) {
1865
+ if (intent.toBuild !== undefined)
1866
+ leased.push({ agentId, buildId: intent.toBuild });
1867
+ }
1868
+ const report = await collectBuilds({ store: this.builds, running, leased, lastSeen: this.buildSeen, seenByDefault: this.startedAt,
1869
+ now: options.now ?? Date.now(), ...(options.graceMs !== undefined ? { graceMs: options.graceMs } : {}) });
1870
+ if (report.skipped) {
1871
+ console.info(`[acp-builds] GC skipped: ${report.skipped}`);
1872
+ return report;
1873
+ }
1874
+ const legacyRemoved = await collectLegacy({ store: this.builds, commandLines: await processCommandLines() });
1875
+ return { ...report, legacyRemoved };
1876
+ }
1877
+ catch (error) {
1878
+ console.warn(`[acp-builds] GC failed: ${errorMessage(error)}`);
1879
+ return undefined;
1880
+ }
1881
+ finally {
1882
+ if (this.collecting === run)
1883
+ this.collecting = undefined;
1884
+ }
1885
+ })();
1886
+ this.collecting = run;
1887
+ return run;
1888
+ }
1889
+ /** Whether this daemon holds (or is about to respawn) a session on older generation `gen`. */
1890
+ holds(gen) {
1891
+ // A generation that could not be listed may host running adapters nobody adopted yet (final review finding 3).
1892
+ return this.unlistedGens.has(gen) || [...this.heldOnOlder.values()].includes(gen);
1893
+ }
1894
+ /** A session of this daemon on an older generation ended: that generation may be idle now. */
1895
+ olderSessionClosed(sessionKey, also) {
1896
+ // A reopen's retire keeps the hold: the respawn goes into the same generation right behind it (fix round 2).
1897
+ if (!this.reopenHolds.has(sessionKey))
1898
+ this.heldOnOlder.delete(sessionKey);
1899
+ void this.retireIdleGenerations();
1900
+ return also?.();
1901
+ }
1902
+ /**
1903
+ * A keeper-hosted session degraded (keeper-acp gone, or poisoned): its outbox head is remembered, so a later open of the ref that
1904
+ * has to run in the daemon numbers above it and reports the gap (fix round 1, minor 4).
1905
+ */
1906
+ recordDegraded(sessionKey, gen) {
1907
+ return (headSeq) => {
1908
+ this.keeperHeads.set(sessionKey, Math.max(this.keeperHeads.get(sessionKey) ?? 0, headSeq));
1909
+ if (!this.keeperOwner.has(sessionKey))
1910
+ this.keeperOwner.set(sessionKey, gen);
1911
+ };
1912
+ }
1913
+ /**
1914
+ * Older keeper-acp generations that host no running adapter any more are stopped (the current one adopts what they kept). One pass
1915
+ * at a time (Task 24): two concurrent passes would both stop the same generation, and the second one's listing reconnects to it
1916
+ * while the first one asks it to stop — a pass asked for meanwhile runs once more afterwards.
1917
+ */
1918
+ retireIdleGenerations() {
1919
+ if (this.retiringGenerations) {
1920
+ this.retireAgain = true;
1921
+ return this.retiringGenerations;
1922
+ }
1923
+ this.retiringGenerations = (async () => {
1924
+ do {
1925
+ this.retireAgain = false;
1926
+ await this.retireIdleGenerationsOnce();
1927
+ } while (this.retireAgain);
1928
+ })().finally(() => { this.retiringGenerations = undefined; });
1929
+ return this.retiringGenerations;
1930
+ }
1931
+ async retireIdleGenerationsOnce() {
1932
+ const generations = this.generations;
1933
+ if (!generations)
1934
+ return;
1935
+ try {
1936
+ const current = await generations.current();
1937
+ let stopped = false;
1938
+ for (const gen of await generations.live()) {
1939
+ if (gen === current)
1940
+ continue;
1941
+ // Never while a session this daemon holds (or is spawning) lives there, its adapter exited or not (fix round 1, minor 7).
1942
+ if (this.holds(gen))
1943
+ continue;
1944
+ // Asked again after keeper-acp's own listing: a reopen may have taken a hold meanwhile (fix round 2).
1945
+ if (await generations.stopIfIdle(gen, () => this.holds(gen))) {
1946
+ stopped = true;
1947
+ for (const [key, owner] of this.keeperOwner)
1948
+ if (owner === gen)
1949
+ this.keeperOwner.delete(key);
1950
+ }
1951
+ }
1952
+ // The current generation adopts the kept sessions now (keeper-acp's `acp.list` adopts directories whose keeper is gone), so the
1953
+ // next offer lists them without waiting for its periodic adoption (fix round 1, minor 5).
1954
+ if (stopped && this.transports.keeper) {
1955
+ for (const summary of await withDeadline(this.transports.keeper.list(), this.leftoverListTimeoutMs, 'keeper-acp list', Error)) {
1956
+ if (!hostsRunningAdapter(summary))
1957
+ this.noteKeeperSession(current, summary);
1958
+ }
1959
+ }
1960
+ }
1961
+ catch (error) {
1962
+ console.warn(`[acp] retiring idle keeper-acp generations failed: ${errorMessage(error)}`);
1963
+ }
1964
+ }
1965
+ /**
1966
+ * What the local transport holds under `sessionKey` from an earlier life (a fallback session's outbox, a migrated legacy journal):
1967
+ * the highest seq it ever held and whether frames past its watermark remain. `undefined`: nothing.
1968
+ */
1969
+ async localLeftover(sessionKey) {
1970
+ let summary;
1971
+ try {
1972
+ summary = (await this.localTransport().list()).find((s) => s.sessionKey === sessionKey);
1973
+ }
1974
+ catch {
1975
+ return undefined;
1976
+ }
1977
+ if (!summary)
1978
+ return undefined;
1979
+ const tail = summary.outbox.committedTail;
1980
+ const tailSeq = typeof tail?.seq === 'number' ? tail.seq : 0;
1981
+ return {
1982
+ lastSeq: Math.max(summary.outbox.lastSeq ?? 0, tailSeq),
1983
+ unacknowledged: summary.outbox.next > Math.max(summary.outbox.first, summary.outbox.committed + 1),
1984
+ };
1985
+ }
1986
+ /**
1987
+ * Sessions an earlier life left in either transport that no live session holds (spec §5.5.2: the resume offer lists them as
1988
+ * orphans): local outboxes, and keeper-acp's sessions once this daemon may use keeper-acp (its sweep succeeded). The caller
1989
+ * removes the refs it holds. keeper-acp that does not answer in time contributes nothing (the offer must not wait on it).
1990
+ */
1991
+ async leftoverSessions() {
1992
+ const keys = new Set();
1993
+ try {
1994
+ for (const summary of await this.localTransport().list())
1995
+ keys.add(summary.sessionKey);
1996
+ }
1997
+ catch (error) {
1998
+ console.warn(`[acp] listing local agent outboxes failed: ${errorMessage(error)}`);
1999
+ }
2000
+ if (this.transports.keeper && (this.adoption || this.ownKeeperKeys.size > 0)) {
2001
+ await this.adoptKeeperSessions();
2002
+ for (const [gen, transport] of await this.keeperTransports()) {
2003
+ try {
2004
+ for (const summary of await withDeadline(transport.list(), this.leftoverListTimeoutMs, 'keeper-acp list', Error)) {
2005
+ // A running adapter in a generation the adoption has not listed yet is not an orphan: it is adopted on the next retry
2006
+ // (final review finding 3) — offered as an orphan, the server would end its turn and reopen it into a new spawn.
2007
+ if (gen !== undefined && this.unlistedGens.has(gen) && hostsRunningAdapter(summary))
2008
+ continue;
2009
+ // The ref, not the key: a switched session lives under `<ref>#s<n>` (Task 24).
2010
+ keys.add(refOfSession(summary) ?? summary.sessionKey);
2011
+ if (gen !== undefined && !hostsRunningAdapter(summary))
2012
+ this.noteKeeperSession(gen, summary);
2013
+ }
2014
+ }
2015
+ catch (error) {
2016
+ console.warn(`[acp] listing keeper-acp sessions${gen ? ` (generation ${gen})` : ''} for the resume offer failed: ${errorMessage(error)}`);
2017
+ }
2018
+ }
2019
+ }
2020
+ return [...keys].sort();
2021
+ }
2022
+ /**
2023
+ * keeper-acp's transports by generation: every running one when the generations are known (the current one also when this daemon
2024
+ * used it, or session directories wait for it — nothing is launched just to be listed), else the current one alone.
2025
+ */
2026
+ async keeperTransports() {
2027
+ if (!this.transports.keeper)
2028
+ return [];
2029
+ const generations = this.generations;
2030
+ if (!generations)
2031
+ return [[undefined, this.transports.keeper]];
2032
+ try {
2033
+ const current = await generations.current();
2034
+ const gens = new Set(await generations.live());
2035
+ if (!gens.has(current) && (this.ownKeeperKeys.size > 0 || await generations.hasSessionData()))
2036
+ gens.add(current);
2037
+ return Promise.all([...gens].map(async (gen) => [gen, await generations.transportFor(gen)]));
2038
+ }
2039
+ catch {
2040
+ return [[undefined, this.transports.keeper]];
2041
+ }
2042
+ }
2043
+ /** `agent_close` for a ref no live session holds: whatever an earlier life left under it goes (its adapter stopped first). */
2044
+ async releaseLeftover(sessionRef) {
2045
+ const keeper = this.transports.keeper && (this.adoption || this.ownKeeperKeys.size > 0) ? (await this.keeperTransports()).map(([, t]) => t) : [];
2046
+ for (const transport of [this.localTransport(), ...keeper]) {
2047
+ try {
2048
+ // Every key of the ref (`<ref>`, and `<ref>#s<n>` after a switch — Task 24).
2049
+ for (const summary of (await transport.list()).filter((s) => s.sessionKey === sessionRef || refOfSession(s) === sessionRef)) {
2050
+ const key = summary.sessionKey;
2051
+ this.keeperOwner.delete(key);
2052
+ this.keeperHeads.delete(key);
2053
+ this.unrecoverable.delete(key);
2054
+ if (summary.process.state !== 'none' && summary.process.state !== 'exited')
2055
+ await transport.terminate(key);
2056
+ await transport.release(key);
2057
+ }
2058
+ }
2059
+ catch (error) {
2060
+ console.warn(`[acp] releasing what an earlier life left for ${sessionRef} failed: ${errorMessage(error)}`);
2061
+ }
2062
+ }
2063
+ this.refKeys.delete(sessionRef);
2064
+ }
2065
+ /**
2066
+ * A legacy `agents/sessions/<ref>.jsonl` (Task 20 removed the daemon journal): its entries become a local outbox under the ref,
2067
+ * which the resume offer lists and a reopen resends, exactly as the journal was. Done once per file; idempotent. `false`: a local
2068
+ * outbox for the ref already existed and was left as it is.
2069
+ */
2070
+ async importLegacyOutbox(sessionKey, records) {
2071
+ const local = this.localTransport();
2072
+ if (!(local instanceof LocalTransport))
2073
+ throw new Error('The local transport cannot import an outbox');
2074
+ return local.importOutbox(sessionKey, records);
2075
+ }
2076
+ /** The fallback transport (spec §5.7), made on first use. */
2077
+ localTransport() {
2078
+ this.transports.local ??= new LocalTransport({ configDir: this.cacheDir });
2079
+ return this.transports.local;
2080
+ }
2081
+ /** Daemon shutdown: keeper-acp's connection is dropped (its adapters keep running); local adapters are stopped. */
2082
+ closeTransports() {
2083
+ this.clearQuotaReaders();
2084
+ this.transports.keeper?.close();
2085
+ this.generations?.close();
2086
+ this.transports.local?.close();
2087
+ }
2088
+ clearQuotaReaders(agentId) {
2089
+ for (const [key, reader] of this.quotaReaders)
2090
+ if (!agentId || reader.agentId === agentId) {
2091
+ this.quotaReaders.delete(key);
2092
+ void reader.pending.then(connection => connection.close()).catch(() => undefined);
2093
+ }
2094
+ }
2095
+ /**
2096
+ * Continue in an already-existing native session when we have an id for one and the agent can load
2097
+ * it; otherwise open a new session and report the new id truthfully.
2098
+ *
2099
+ * Two things are deliberately *not* failures here. An agent that does not advertise
2100
+ * `loadSession` still gets a session — the manager already tolerates the id it asked for coming
2101
+ * back different (it warns and re-points), so refusing to open would be strictly worse than
2102
+ * continuing in a fresh one. A `session/load` that is advertised but rejected (a stale id the
2103
+ * agent has since deleted, most likely) is treated the same way, for the same reason.
2104
+ * Manual configuration refresh opts out of both fallbacks with `requireSessionLoad`: a failed
2105
+ * load must not silently replace the conversation whose settings the reader wanted to refresh.
2106
+ *
2107
+ * Every decision is the daemon's, made on the processor's `initialize` reading; each attempt is one `agent_session_open` note
2108
+ * and the processor's result. A load's history window is closed (`historyEnd`) as soon as its result is in — before the
2109
+ * manager can admit a prompt (interface-contracts: `historyEnd` before any admitted prompt). The Qoder repair goes on where
2110
+ * `enableBackgroundTerminalRepair` was called: after the `agent_session_open` note of a load (before its history is folded),
2111
+ * after the result of a new or resumed session — a resume with `restoreCalls`.
2112
+ */
2113
+ async startSession(session, facts, spec) {
2114
+ const nativeSessionId = spec.nativeSessionId;
2115
+ const capabilities = facts.capabilities;
2116
+ if (nativeSessionId && spec.managedHistoryResumeRequired) {
2117
+ throw new Error('Managed native history replay has no certified boundary');
2118
+ }
2119
+ if (spec.delegationMcp && !capabilities.mcpHttp) {
2120
+ throw new Error(`Agent '${spec.agentId}' does not advertise HTTP MCP support`);
2121
+ }
2122
+ if (spec.requireSessionLoad && !nativeSessionId) {
2123
+ throw new Error('Refreshing settings requires an existing native session ID');
2124
+ }
2125
+ const historyHome = spec.agentEnv.HOME ?? spec.env.HOME ?? homedir();
2126
+ // The finished MCP list (the lease checked here, against the clock, before any note is written); the bearer is a secretRef
2127
+ // in the note and the down record, its value only in this daemon's memory and the adapter's stdin (spec §13).
2128
+ const servers = buildSessionMcpServers(spec.mcpServers, spec.delegationMcp);
2129
+ const redact = [];
2130
+ const mcpServers = servers.map((server) => {
2131
+ if (!('headers' in server) || !spec.delegationMcp || server.name !== 'axom-delegation')
2132
+ return server;
2133
+ return { ...server, headers: server.headers.map((header) => {
2134
+ const ref = session.secret(header.value);
2135
+ redact.push(ref);
2136
+ return { ...header, value: ref };
2137
+ }) };
2138
+ });
2139
+ const delegation = spec.delegationMcp && spec.delegation ? {
2140
+ expiresAtEpochMs: spec.delegationMcp.expiresAtEpochMs, leaseId: spec.delegation.leaseId,
2141
+ ...(spec.delegation.bindingId !== undefined && spec.delegation.instanceId !== undefined
2142
+ ? { bindingId: spec.delegation.bindingId, instanceId: spec.delegation.instanceId } : {}),
2143
+ } : undefined;
2144
+ const request = { cwd: spec.cwd, mcpServers, redact, ...(delegation ? { delegation } : {}) };
2145
+ const refused = (result) => (result.code !== undefined ? new RequestError(result.code, result.error) : new Error(result.error));
2146
+ // Qoder's load may emit old chunks after its response. Cached reconnects need context, not
2147
+ // another history import. Actual adoption/backfill and explicit settings loads still use load.
2148
+ if (nativeSessionId && certifiedRuntime(facts.agentInfo) && capabilities.resume &&
2149
+ !spec.backfill && !spec.requireSessionLoad && !spec.delegationMcp) {
2150
+ const result = await session.openSession({ ...request, mode: 'resume', sessionId: nativeSessionId });
2151
+ if (!result.ok)
2152
+ throw refused(result); // Never silently replace this cached conversation with a fresh native root.
2153
+ await session.enableRepair(nativeSessionId, spec.cwd, historyHome, true);
2154
+ return { nativeSessionId };
2155
+ }
2156
+ if (nativeSessionId) {
2157
+ if (!capabilities.loadSession) {
2158
+ if (spec.requireSessionLoad || spec.delegationMcp)
2159
+ throw new Error(`Agent '${spec.agentId}' does not support session/load`);
2160
+ console.warn(`[acp] agent '${spec.agentId}' does not support session/load; starting a new session ` +
2161
+ `instead of continuing '${nativeSessionId}'`);
2162
+ }
2163
+ else {
2164
+ const result = await session.openSession({ ...request, mode: 'load', sessionId: nativeSessionId, repair: { historyHome } });
2165
+ if (result.ok) {
2166
+ await session.endHistory();
2167
+ return { nativeSessionId };
2168
+ }
2169
+ if (spec.requireSessionLoad || spec.delegationMcp)
2170
+ throw refused(result);
2171
+ console.warn(`[acp] session/load for '${nativeSessionId}' on agent '${spec.agentId}' failed ` +
2172
+ `(${result.error}); starting a new session instead`);
2173
+ }
2174
+ }
2175
+ const result = await session.openSession({ ...request, mode: 'new' });
2176
+ if (!result.ok)
2177
+ throw refused(result);
2178
+ if (result.nativeSessionId)
2179
+ await session.enableRepair(result.nativeSessionId, spec.cwd, historyHome);
2180
+ return { nativeSessionId: result.nativeSessionId };
2181
+ }
2182
+ /**
2183
+ * List one agent's native sessions for adoption, via a throwaway connection — listing happens
2184
+ * before any session exists, so there is nothing running to ask.
2185
+ *
2186
+ * Degrades to an empty list rather than an error when the agent does not advertise
2187
+ * `sessionCapabilities.list` (that gate lives in `adoption.ts`); a *connection* failure still
2188
+ * throws, because "this agent is not installed" and "this agent has no sessions" must not look
2189
+ * alike to the user.
2190
+ */
2191
+ async listNativeSessions(spec) {
2192
+ const resolved = await this.resolveLaunchFor(spec.agentId, spec.command, spec.args);
2193
+ const connection = await this.connectResolved(resolved, spec.env, spec.agentEnv);
2194
+ this.observe(spec.agentId, observationOf(connection));
2195
+ try {
2196
+ const result = await listNativeSessions(connection, { cwd: spec.filterCwd });
2197
+ return result.sessions;
2198
+ }
2199
+ finally {
2200
+ await connection.close().catch(() => undefined);
2201
+ }
2202
+ }
2203
+ /**
2204
+ * Delete native sessions over `session/delete`, via a throwaway connection. Per-id outcomes; an
2205
+ * agent that does not advertise `sessionCapabilities.delete` yields `unsupported` for every id
2206
+ * without the method being called. A *connection* failure still throws.
2207
+ */
2208
+ async deleteNativeSessions(spec, ids) {
2209
+ const resolved = await this.resolveLaunchFor(spec.agentId, spec.command, spec.args);
2210
+ const connection = await this.connectResolved(resolved, spec.env, spec.agentEnv);
2211
+ this.observe(spec.agentId, observationOf(connection));
2212
+ try {
2213
+ return await deleteNativeSessions(connection, ids);
2214
+ }
2215
+ finally {
2216
+ await connection.close().catch(() => undefined);
2217
+ }
2218
+ }
2219
+ /** Explicit quota read, called only from an admitted footer refresh. */
2220
+ async minimumAccountLimitsVersion(spec) {
2221
+ if (spec.command)
2222
+ return 2;
2223
+ const current = await this.builds.current(spec.agentId);
2224
+ return current ? nativeAccountLimitsVersionFor(current.agentId, current.version) : 2;
2225
+ }
2226
+ async readAccountLimits(spec) {
2227
+ // Readers that need interactive credential authorization retain their process-local cache.
2228
+ // Keeping this control connection alive performs no model calls or periodic quota queries.
2229
+ const cached = (await this.minimumAccountLimitsVersion(spec)) >= 3;
2230
+ const key = createHash('sha256').update(JSON.stringify(spec)).digest('hex');
2231
+ let pending = this.quotaReaders.get(key)?.pending;
2232
+ if (!cached || !pending) {
2233
+ pending = this.connectFor(spec, 30_000);
2234
+ if (cached) {
2235
+ this.quotaReaders.set(key, { agentId: spec.agentId, pending });
2236
+ void pending.catch(() => { if (this.quotaReaders.get(key)?.pending === pending)
2237
+ this.quotaReaders.delete(key); });
2238
+ }
2239
+ }
2240
+ const connection = await pending;
2241
+ try {
2242
+ const raw = await withDeadline(connection.connection.agent.request("_axom/account/rateLimits/read", { cwd: spec.cwd }), ACCOUNT_LIMITS_READ_TIMEOUT_MS, 'Refreshing account usage');
2243
+ const { accountLimitsSchema } = await import('../protocol/accountLimits.js');
2244
+ return accountLimitsSchema.parse(raw);
2245
+ }
2246
+ catch (error) {
2247
+ // A provider's RPC error can be retried with the same credential cache. A dead transport cannot.
2248
+ if (cached && typeof error?.code !== 'number') {
2249
+ this.quotaReaders.delete(key);
2250
+ await connection.close().catch(() => undefined);
2251
+ }
2252
+ throw error;
2253
+ }
2254
+ finally {
2255
+ if (!cached)
2256
+ await connection.close().catch(() => undefined);
2257
+ }
2258
+ }
2259
+ /**
2260
+ * Verify one agent on demand: spawn it, negotiate `initialize`, try to open a session, then throw
2261
+ * the whole thing away. Nothing is kept running — a verification is a question, not a resource.
2262
+ *
2263
+ * This is the only way the roster's auth fields can ever be populated for an agent nobody has
2264
+ * started a session with, which is the case a settings page is looking at: {@link roster} reads
2265
+ * {@link AuthObservation} and building the roster spawns nothing, so before the first verification
2266
+ * (or the first real session) every agent reports "unknown" and stays that way. What is learned
2267
+ * here goes through {@link observe}, so the next `hello`/scan carries it.
2268
+ *
2269
+ * ### Two outcomes, and neither of them is "not authenticated"
2270
+ *
2271
+ * A session that opened proves the agent is not waiting on credentials — that is `authenticated:
2272
+ * true`. A session that did not open proves nothing at all: the agent may need signing in, or its
2273
+ * cwd may not exist, or it may have crashed. That case comes back as {@link
2274
+ * AcpVerification.sessionError} — the observed facts, with no verdict attached — and the
2275
+ * observation is *not* updated, so an earlier proof is not erased by a later inconclusive attempt.
2276
+ *
2277
+ * Failing to connect at all throws, because "this agent is not installed" and "this agent is
2278
+ * installed and would not start a session" are different answers to the user.
2279
+ */
2280
+ async verify(spec, timeoutMs = AGENT_PROBE_TIMEOUT_MS) {
2281
+ const probe = createCommandTableProbe();
2282
+ const connection = await this.connectFor(spec, timeoutMs, undefined, probe);
2283
+ const authMethods = connection.authMethods;
2284
+ this.observe(spec.agentId, observationOf(connection));
2285
+ try {
2286
+ let observed;
2287
+ let observedConfig;
2288
+ const started = Date.now();
2289
+ await withDeadline((async () => {
2290
+ const first = await observeCommands(probe, connection, spec.cwd, COMMAND_FIRST_BROADCAST_MS);
2291
+ observedConfig = first.configOptions;
2292
+ observed = first.commands;
2293
+ })(), timeoutMs, `Opening a session on agent '${spec.agentId}'`);
2294
+ if (observed === undefined) {
2295
+ // p3-fix7 N9: "no table within the window" is unknown, not "this agent has no commands": look once more, with a
2296
+ // longer window. p3-fix7b M-4: on a fresh connection (this one is bound to the first session, so a table pushed
2297
+ // before the retry's session/new answer would read as a stranger's). p3-fix7c 3: the retry is only a second
2298
+ // look for the table — its failure, or the deadline running out during it, leaves the table unknown and never
2299
+ // turns a session that opened into a sessionError.
2300
+ observed = await this.retryCommandTable(spec, timeoutMs - (Date.now() - started));
2301
+ }
2302
+ if (observed === undefined) {
2303
+ // A 那条日志的 daemon 侧对应物。没有它,"这个 agent 在这个目录下没有命令" 与
2304
+ // "表来晚了、窗口没接住" 在任何一层都分不开 —— 而这两件事的修法完全不同。
2305
+ console.warn(`[acp] agent '${spec.agentId}' broadcast no command table within ` +
2306
+ `${COMMAND_FIRST_BROADCAST_MS}ms (and ${this.commandRetryFirstBroadcastMs}ms on a second session) ` +
2307
+ `of opening a session in ${spec.cwd}`);
2308
+ }
2309
+ this.observe(spec.agentId, { ...observationOf(connection), authenticated: true });
2310
+ return {
2311
+ authMethods,
2312
+ authenticated: true,
2313
+ ...(observed !== undefined ? { availableCommands: observed } : {}),
2314
+ ...(observedConfig !== undefined ? { configOptions: observedConfig } : {}),
2315
+ };
2316
+ }
2317
+ catch (error) {
2318
+ // Prefixed rather than relayed raw: the SDK masks an agent-thrown error as "Internal error",
2319
+ // and a settings page showing that alone tells the user nothing about which agent it came from
2320
+ // or what was being attempted.
2321
+ return { authMethods, sessionError: `Agent '${spec.agentId}' did not open a session: ${errorMessage(error)}` };
2322
+ }
2323
+ finally {
2324
+ await connection.close().catch(() => undefined);
2325
+ }
2326
+ }
2327
+ /** p3-fix7c 3: one more look for the command table on a fresh connection; undefined on any failure or timeout. */
2328
+ async retryCommandTable(spec, remainingMs) {
2329
+ if (remainingMs <= 0)
2330
+ return undefined;
2331
+ let retryConnection;
2332
+ let abandoned = false;
2333
+ try {
2334
+ return await withDeadline((async () => {
2335
+ const probe = createCommandTableProbe();
2336
+ const connection = await this.connectFor(spec, remainingMs, undefined, probe);
2337
+ retryConnection = connection;
2338
+ // The deadline may already have fired while connecting: nobody will close it after this point but us.
2339
+ if (abandoned) {
2340
+ await connection.close().catch(() => undefined);
2341
+ return undefined;
2342
+ }
2343
+ return (await observeCommands(probe, connection, spec.cwd, this.commandRetryFirstBroadcastMs)).commands;
2344
+ })(), remainingMs, `Looking again for agent '${spec.agentId}''s command table`);
2345
+ }
2346
+ catch (error) {
2347
+ abandoned = true;
2348
+ console.warn(`[acp] retry for agent '${spec.agentId}''s command table failed: ${errorMessage(error)}`);
2349
+ return undefined;
2350
+ }
2351
+ finally {
2352
+ await retryConnection?.close().catch(() => undefined);
2353
+ }
2354
+ }
2355
+ /**
2356
+ * Run one agent's `authenticate` for a `methodId` **it advertised**, over a throwaway connection —
2357
+ * or, for a terminal-type method, run the sign-in itself ({@link hostTerminalLogin}): ACP forbids
2358
+ * passing a terminal method to `authenticate`, and the workstation is the terminal's host (spec
2359
+ * 2026-09-24 D6/D12).
2360
+ *
2361
+ * The refusal for an unadvertised non-terminal `methodId` is {@link AcpAuth}'s and happens before
2362
+ * anything is written to the agent — which is why this hands `AcpAuth` the connection rather than
2363
+ * re-deriving the check: there is exactly one place that rule lives.
2364
+ *
2365
+ * Deliberately does not report an auth state. `authenticate` resolving means the agent accepted
2366
+ * the call, which is not the same as a session it will now open; the caller asks {@link verify}
2367
+ * for that, and gets an observation made the only way ACP allows one to be made. What is recorded
2368
+ * here is `authMethods`, because `initialize` happened.
2369
+ */
2370
+ async authenticate(spec, methodId, login = {}) {
2371
+ this.clearQuotaReaders(spec.agentId);
2372
+ const urls = createLoginUrlSink(login);
2373
+ // **The retraction has to cover the connect, not just the login.** The sniffer is armed inside
2374
+ // `connectAgent` at `spawn()`, before `initialize` — so a URL can be presented while this is
2375
+ // still awaiting, and every way connecting fails (initialize timeout, spawn error, an
2376
+ // unsupported protocol version, the unpinned retry failing too) would otherwise leave the card
2377
+ // up with nothing left running that could ever take it down. Measured, not reasoned: a probe of
2378
+ // the first version reported `PRESENTED [ 'stderr-auth-url-…' ] RETRACTED []`. And the trigger
2379
+ // is ordinary — the scan covers the `npx` install, whose own output can carry a "visit this
2380
+ // URL" line, and a login whose agent then fails to install is exactly when that appears.
2381
+ try {
2382
+ // Spec 2026-09-24 D10: a sign-in almost always follows the connection that hit
2383
+ // `auth_required`, which already read this method. Reuse it rather than cold-start the agent.
2384
+ const remembered = this.observed.get(spec.agentId)?.terminalLogins?.get(methodId);
2385
+ if (remembered) {
2386
+ await this.hostTerminalLogin(spec, remembered, urls.present, login);
2387
+ return;
2388
+ }
2389
+ const connection = await this.connectFor(spec, AGENT_PROBE_TIMEOUT_MS, {
2390
+ ...(urls.present ? { present: urls.present } : {}),
2391
+ dismiss: urls.dismiss,
2392
+ });
2393
+ this.observe(spec.agentId, observationOf(connection));
2394
+ const terminal = connection.terminalLogins.get(methodId);
2395
+ if (terminal) {
2396
+ // ACP: a terminal method is never passed to `authenticate`. This connection has nothing
2397
+ // left to do, and a login can take minutes — do not hold an agent process open for it.
2398
+ await connection.close().catch(() => undefined);
2399
+ await this.hostTerminalLogin(spec, terminal, urls.present, login);
2400
+ return;
2401
+ }
2402
+ try {
2403
+ const what = `Signing in to agent '${spec.agentId}' with method '${methodId}'`;
2404
+ await withDeadline(cancellable(new AcpAuth(connection).authenticate(methodId), login.signal, what), loginDeadlineMs(login.ceilingMs), what);
2405
+ }
2406
+ finally {
2407
+ // The connection dies here and not a moment earlier — this `finally` runs when the login
2408
+ // *settles*, never on a probe-sized deadline part-way through one.
2409
+ await connection.close().catch(() => undefined);
2410
+ }
2411
+ }
2412
+ finally {
2413
+ // The retraction trigger a stderr-sourced URL can never get from the protocol — see
2414
+ // {@link createLoginUrlSink}. This runs on every exit, including every way connecting failed.
2415
+ urls.finish();
2416
+ }
2417
+ }
2418
+ /**
2419
+ * Run a terminal sign-in on this machine (spec 2026-09-24 D7, D9). The launch is resolved the same
2420
+ * way a session's is, because the standard shape appends to it; the `_meta` shape ignores it.
2421
+ */
2422
+ async hostTerminalLogin(spec, terminal, present, login) {
2423
+ // The launch is resolved for both shapes, and for the same reason a session's is: its env is
2424
+ // the login child's environment. The `_meta` shape keeps the invocation's own command and args
2425
+ // (see `launchForTerminalLogin`) but rides the resolved launch's env — a login that seeded a
2426
+ // different credential slot than the sessions it was logging in for would be a no-op for them.
2427
+ const launch = launchForTerminalLogin(terminal, (await this.resolveLaunchFor(spec.agentId, spec.command, spec.args)).launch);
2428
+ await runTerminalLogin(terminalInvocationFor(terminal, launch, { ...spec.env, ...spec.agentEnv }), {
2429
+ ...(present ? { present } : {}),
2430
+ ...(login.signal ? { signal: login.signal } : {}),
2431
+ deadlineMs: loginDeadlineMs(login.ceilingMs),
2432
+ });
2433
+ }
2434
+ /**
2435
+ * The spawn half shared by {@link verify} and {@link authenticate}. No session either way — but
2436
+ * **not "no handlers" any more**, and the difference is the whole of Task 4b: `authenticate` is a
2437
+ * round trip through a human, so its connection carries the elicitation/permission handlers and
2438
+ * the stderr sniffer that a login URL reaches the reader through. `verify` passes no `login` and
2439
+ * so gets exactly what it always got: a bare connection nobody can raise a card on.
2440
+ *
2441
+ * **This is the only method in this class that builds a `present`**, and that is the mechanism, not
2442
+ * a convention: {@link connectResolved} — the route every other connection takes, including
768
2443
  * {@link open}'s long-lived session — accepts only {@link ConnectAgentOptions}, whose `present` is
769
2444
  * typed `never`. Arming the stderr sniffer anywhere else would mean writing a fresh `connectAgent`
770
2445
  * call by hand, which is a visibly deliberate act rather than one more argument. What that buys is
@@ -782,21 +2457,24 @@ export class AcpAgents {
782
2457
  * `connectAgent`'s `initialize` deadline, whose expiry kills the child. A login's own budget is
783
2458
  * {@link AGENT_LOGIN_CEILING_MS} and is applied around `authenticate` alone.
784
2459
  */
785
- async connectFor(spec, timeoutMs, login) {
2460
+ async connectFor(spec, timeoutMs, login, probe) {
786
2461
  const resolved = await this.resolveLaunchFor(spec.agentId, spec.command, spec.args);
787
2462
  // The same budget as the caller's, not the 30s default: `connectAgent` kills the child when it
788
2463
  // fires, which is the only thing that stops an agent that opened stdio and never spoke.
789
- if (!login)
790
- return this.connectResolved(spec.agentId, resolved, spec.env, spec.agentEnv, { initializeTimeoutMs: timeoutMs });
2464
+ if (!login) {
2465
+ return this.connectResolved(resolved, spec.env, spec.agentEnv, {
2466
+ initializeTimeoutMs: timeoutMs, ...(probe ? { configure: (app) => probe.configure(app) } : {}),
2467
+ });
2468
+ }
791
2469
  const options = {
792
2470
  initializeTimeoutMs: timeoutMs,
793
2471
  ...(login.present ? { present: login.present } : {}),
794
- configure: (app) => {
2472
+ configure: (app, adapter) => {
795
2473
  // The same registration the session connection makes (see `open`), with the two
796
2474
  // elicitation callbacks actually wired — this is the only connection they can be reached
797
2475
  // on, because `authenticate` does not run on a session's connection.
798
2476
  //
799
- // The sink is deferred and never bound, deliberately: there is no `AcpSession` here, so an
2477
+ // The sink is deferred and never bound, deliberately: there is no session here, so an
800
2478
  // agent that sent `session/request_permission` on a login connection would be referring to
801
2479
  // a session that cannot exist. `createDeferredPermissionSink` answers that by throwing a
802
2480
  // named error, which the SDK turns into a JSON-RPC error for the agent — loud and
@@ -804,117 +2482,53 @@ export class AcpAgents {
804
2482
  registerPermissionHandlers(app, createDeferredPermissionSink().sink, {
805
2483
  present: login.present,
806
2484
  dismiss: login.dismiss ?? (() => { }),
807
- });
2485
+ }, adapter);
808
2486
  },
809
2487
  };
810
- return withUnpinnedFallback(spec.agentId, resolved, (launch) => connectAgent(launch, spec.env, spec.agentEnv, options));
811
- }
812
- /**
813
- * Continue in an already-existing native session when we have an id for one and the agent can load
814
- * it; otherwise open a new session and report the new id truthfully.
815
- *
816
- * Two things are deliberately *not* failures here. An agent that does not advertise
817
- * `loadSession` still gets a session — the manager already tolerates the id it asked for coming
818
- * back different (it warns and re-points), so refusing to open would be strictly worse than
819
- * continuing in a fresh one. A `session/load` that is advertised but rejected (a stale id the
820
- * agent has since deleted, most likely) is treated the same way, for the same reason.
821
- *
822
- * `AcpSession.attach` runs **before** the replay, not after: it subscribes to the connection's live
823
- * `session/update` stream, and `ReplayCapture` withholds updates from that subscription for exactly
824
- * as long as the id is armed — i.e. for the whole `session/load`. Subscribing afterwards would
825
- * leave a gap between the replay's `take()` and the subscription in which a live update is dropped.
826
- */
827
- async startSession(connection, capture, spec) {
828
- const nativeSessionId = spec.nativeSessionId;
829
- if (nativeSessionId) {
830
- if (!connection.capabilities.loadSession) {
831
- console.warn(`[acp] agent '${spec.agentId}' does not support session/load; starting a new session ` +
832
- `instead of continuing '${nativeSessionId}'`);
833
- }
834
- else {
835
- // `AcpSession.attach` reads `initial.configOptions` lazily (see its own doc comment) —
836
- // exactly so this can hand it a mutable object *before* the snapshot exists, and fill it in
837
- // below once `session/load`'s response arrives. Snapshotting eagerly here (e.g. passing
838
- // `{ configOptions: [] }` outright) would leave the attached session's `configOptions`
839
- // empty forever, because nothing would ever revisit it.
840
- const initial = { configOptions: [] };
841
- const session = AcpSession.attach(connection, nativeSessionId, capture, initial);
842
- try {
843
- const history = [];
844
- for await (const event of replayNativeSession(connection, capture, {
845
- cwd: spec.cwd,
846
- nativeSessionId,
847
- onLoaded: (response) => {
848
- initial.configOptions = normalizeConfigOptions(response.configOptions, response.modes);
849
- },
850
- })) {
851
- // Folded and validated either way (one code path, no second copy of the replay rules);
852
- // retained only when someone is going to ask for it, so a long history is not held for
853
- // the life of a session that will never replay it.
854
- if (spec.backfill)
855
- history.push(event);
856
- }
857
- return { session, nativeSessionId, history, configOptions: initial.configOptions };
858
- }
859
- catch (error) {
860
- console.warn(`[acp] session/load for '${nativeSessionId}' on agent '${spec.agentId}' failed ` +
861
- `(${errorMessage(error)}); starting a new session instead`);
862
- await session.close().catch(() => undefined);
863
- }
864
- }
865
- }
866
- const opened = await AcpSession.open(connection, { cwd: spec.cwd });
867
- return { session: opened.session, nativeSessionId: opened.nativeSessionId, history: [], configOptions: opened.configOptions };
2488
+ return connectAgent(resolved.launch, spec.env, spec.agentEnv, options);
868
2489
  }
869
2490
  /**
870
- * Fetch one agent's ACP adapter onto this machine, ahead of the first launch that would otherwise
871
- * have to. What the settings page's **Config** button does before it writes anything.
2491
+ * Install (or Update) one agent's ACP adapter and make it the agent's **current build** — the only
2492
+ * thing, besides a CLI-certified change ({@link reconcileCertifiedBuilds}), that moves the pointer
2493
+ * new sessions launch from (spec §6.1). What the settings page's Install / Update / Config does.
872
2494
  *
873
- * ## Why an explicit step exists at all
2495
+ * ## Builds, not caches
874
2496
  *
875
- * `resolveLaunch` produces `npx --yes <adapter>@<version>`, and npx downloads the adapter the
876
- * first time it runs one — which meant the very first `open` on a fresh machine paid for a
877
- * multi-megabyte download inside a session the user had already started, with nothing on screen
878
- * saying so. Doing it here moves that cost to a button press, and — because it is the same cache
879
- * npx reads — makes the roster's `adapterInstalled` true afterwards. The uvx and binary paths
880
- * install into `uv tool dir` and `<workstation config dir>/acp-adapters` respectively, which are
881
- * likewise exactly where the roster and the launch path look.
2497
+ * npm and binary adapters are installed into daemon-owned, immutable, versioned directories under
2498
+ * `<configDir>/acp-adapters/` (`builds/model.ts`), staged and renamed into place whole, then
2499
+ * recorded in `builds.json`. A new install never touches a directory a running adapter is loading
2500
+ * from, and `open` never downloads anything: it launches the current build or fails with
2501
+ * {@link AdapterNotInstalledError}. A registry version bump only makes the card offer Update.
2502
+ * Install, switch and GC of one agent are serialised ({@link BuildStore.withAgentLock}), so two
2503
+ * concurrent Updates download once. The uvx path still installs into `uv tool dir` until Task 15b.
882
2504
  *
883
2505
  * ## Three answers, and the third one is not a failure
884
2506
  *
885
- * `ok` with `alreadyInstalled` means there was nothing to do. `ok` without it means this call
2507
+ * `ok` with `alreadyInstalled` means there was nothing to download. `ok` without it means this call
886
2508
  * downloaded it. `unsupported` means there is nothing for *this machine* to fetch — the entry
887
2509
  * declares no launchable distribution, or it is binary-only with no archive for this platform
888
2510
  * triple. Reported as its own outcome rather than as an error so the page can grey the card out
889
2511
  * instead of offering a retry that cannot succeed.
890
2512
  *
891
- * All three distributions are fetched, not just npx — see {@link installAdapter}, which dispatches
892
- * npx → uvx → binary in `resolveLaunch`'s own preference order.
893
- *
894
- * ## The catalog's `dependency` runs first, and can refuse to run anything at all
2513
+ * ## The catalog's `prerequisite` runs first, and can refuse to run anything at all
895
2514
  *
896
- * `registry/catalog.ts` may curate one extra prerequisite for an agent (`pi-acp` needs the `pi`
897
- * CLI) that the adapter fetch above does nothing to provide. That block is handled *before* the
898
- * adapter step, in two gates:
2515
+ * `registry/catalog.ts` may curate one prerequisite for an agent (`pi-acp` needs the `pi` CLI,
2516
+ * `zcode-acp` the ZCode desktop app) that the adapter fetch above does nothing to provide. That
2517
+ * block is handled *before* the adapter step, in up to three gates:
899
2518
  *
900
2519
  * 1. `requiresNode`, checked against this daemon's own `process.version` (or the injected
901
2520
  * `nodeVersion`, for tests) — because npm only *warns*, never refuses, on an `engines`
902
- * mismatch. On a Node-20 machine `npm install -g` for `pi` "succeeds", `findOnPath` then finds
903
- * the binary, and a check that stopped at presence-on-`PATH` would report the dependency
904
- * satisfied right before the agent fails at run time with an engines error nobody could trace
905
- * back to install. Failing this gate returns `nodeTooOld` and runs no command at all — not
906
- * even the `findOnPath` probe below, since there would be nothing useful to install anyway.
907
- * 2. `check` on `PATH` (via `findOnPath`) — only when that comes back empty does this call the
2521
+ * mismatch. Failing this gate returns `nodeTooOld` and runs no command at all.
2522
+ * 2. its `detect` (`registry/detect.ts`) — only when that finds nothing does this call the
908
2523
  * catalog's literal `install` argv through {@link AcpAgents.runCommand}, which is
909
2524
  * `execFile`-based and never a shell (see `catalog.ts`'s security note on that array).
2525
+ * 3. a prerequisite with no `install` argv cannot be fetched by anyone but the reader, so a missing
2526
+ * one refuses with the catalog's `hint` as the error and fetches no adapter either.
910
2527
  *
911
- * An agent outside the catalog, or one with no `dependency` block, skips straight to the adapter
912
- * step below — nothing here changes for `claude-acp`, `codex-acp`, or any other uncurated agent.
913
- *
914
- * Never starts the agent: see {@link installNpxPackage}.
2528
+ * Never starts the agent.
915
2529
  */
916
2530
  async install(agentId, options = {}) {
917
- const dependency = catalogEntryFor(agentId)?.dependency;
2531
+ const dependency = catalogEntryFor(agentId)?.prerequisite;
918
2532
  if (dependency) {
919
2533
  const nodeVersion = options.nodeVersion ?? process.version;
920
2534
  if (dependency.requiresNode && !semver.satisfies(nodeVersion, dependency.requiresNode)) {
@@ -927,7 +2541,13 @@ export class AcpAgents {
927
2541
  }
928
2542
  }
929
2543
  let dependencyInstalled;
930
- if (dependency && findOnPath(dependency.check, this.probe) === undefined) {
2544
+ if (dependency && !this.prerequisiteSatisfied(agentId)) {
2545
+ if (!dependency.install) {
2546
+ return {
2547
+ ok: false,
2548
+ error: dependency.hint ?? `Agent '${agentId}' needs ${dependency.label}, which is not installed on this machine.`,
2549
+ };
2550
+ }
931
2551
  const [command, ...args] = dependency.install;
932
2552
  const outcome = await this.runCommand(command, args, { timeoutMs: options.timeoutMs });
933
2553
  if (!outcome.ok) {
@@ -935,12 +2555,11 @@ export class AcpAgents {
935
2555
  }
936
2556
  dependencyInstalled = true;
937
2557
  }
938
- const agents = await this.registry.load();
939
- const agent = agents.find((candidate) => candidate.id === agentId);
940
- if (!agent) {
2558
+ const entry = (await this.effective()).find((candidate) => candidate.agent.id === agentId);
2559
+ if (!entry) {
941
2560
  return { ok: false, error: `Agent '${agentId}' is not in the ACP agent registry.` };
942
2561
  }
943
- const outcome = await this.installAdapter(agent, options.timeoutMs);
2562
+ const outcome = await this.builds.withAgentLock(agentId, () => this.installAdapter(entry, 'user', options.timeoutMs));
944
2563
  if (!outcome.ok)
945
2564
  return outcome;
946
2565
  return {
@@ -951,60 +2570,86 @@ export class AcpAgents {
951
2570
  ...(outcome.pinnedVersionUnavailable ? { pinnedVersionUnavailable: outcome.pinnedVersionUnavailable } : {}),
952
2571
  };
953
2572
  }
2573
+ /** The registry as Axom installs it (catalog supplements, pins, launchEnv kept apart) — see `registry/catalog.ts`. */
2574
+ async effective() {
2575
+ return effectiveEntries(await this.baseRegistry.load());
2576
+ }
954
2577
  /**
955
- * Fetch one agent's ACP adapter through **whichever distribution it actually ships**, dispatching
956
- * npx → uvx → binary: the same preference order `resolveLaunch` uses to decide which one will be
957
- * launched, so `Install` always fetches the thing that will later be started.
958
- *
959
- * ## Why this exists
960
- *
961
- * {@link install} used to refuse anything without an `npx` block outright, which made
962
- * `installBinary` (`binaryInstall.ts`) and `installUvTool` (`uvxInstall.ts`) unreachable from
963
- * production: they were written, tested, and called by nothing. On the live registry snapshot that
964
- * is 18 of 39 agents — 16 binary-only and 2 uvx-only — every one of them rendering an `Install`
965
- * button whose task could only ever fail. Design §4.1's "three distributions, one model" is this
966
- * function.
967
- *
968
- * ## `unsupported` still exists, and now means something narrower
2578
+ * Install one agent's adapter through **whichever distribution it actually ships**, dispatching
2579
+ * npx → uvx → binary, then commit the build and make it current. The caller holds the agent's lock.
969
2580
  *
970
- * Two cases, and neither of them is "this build has not got round to it yet":
971
- *
972
- * - the registry entry declares no launchable distribution at all, so there is nothing to fetch;
973
- * - the entry is binary-only and publishes no archive for **this machine's** platform triple —
974
- * `windows-aarch64` covers 9 of 18 binary entries (see `binaryInstall.ts`'s module doc). Checked
975
- * here rather than left to `installBinary`'s own identical check so the answer carries
976
- * `unsupported`, which is what a card renders as `Unsupported` instead of a retryable error.
977
- *
978
- * Every other failure is an ordinary `error` the reader can retry.
979
- *
980
- * `checksumVerified` from the binary path is deliberately not propagated into
981
- * {@link AcpInstallResult}: a registry entry with no `sha256` (`antigravity-acp`, `cursor`)
982
- * installs successfully and this result must not imply either that it was verified or that
983
- * anything went wrong. `binaryInstall.ts` logs the fact where it is established.
2581
+ * `unsupported` means one of two things: the entry declares no launchable distribution at all, or
2582
+ * it is binary-only and publishes no archive for **this machine's** platform triple. Every other
2583
+ * failure is an ordinary `error` the reader can retry, and leaves the current build untouched.
2584
+ */
2585
+ async installAdapter(entry, setBy, timeoutMs,
2586
+ /**
2587
+ * The certified reconcile's current build: if the install lands on that same build, its record is
2588
+ * refreshed but the pointer (and `currentSetBy`) is left alone — that is not a move.
984
2589
  */
985
- async installAdapter(agent, timeoutMs) {
2590
+ currentBuildId) {
2591
+ const agent = entry.agent;
986
2592
  const { npx, uvx, binary } = agent.distribution;
2593
+ const source = entry.pinnedBy ?? 'registry';
2594
+ const commit = (build) => this.builds.commit(build, { makeCurrent: build.buildId !== currentBuildId, setBy });
987
2595
  if (npx) {
988
- return this.installNpxWithFallback(agent, npx.package, timeoutMs);
2596
+ const pristine = await this.installPristineWithFallback(agent, npx.package, timeoutMs);
2597
+ if (!pristine.ok)
2598
+ return pristine;
2599
+ try {
2600
+ const build = await npmBuild({
2601
+ store: this.builds, agentId: agent.id, name: npmPackageName(npx.package), version: pristine.version,
2602
+ args: npx.args ?? [], env: npx.env ?? {}, registryVersion: entry.registryVersion,
2603
+ source: pristine.pinnedVersionUnavailable ? 'unpinned-fallback' : source, installedBy: setBy,
2604
+ nodePath: this.nodePath,
2605
+ ...(this.patchRecipes ? { recipes: this.patchRecipes } : {}),
2606
+ });
2607
+ const recorded = pristine.pinnedVersionUnavailable
2608
+ ? { ...build, requestedVersion: requestedVersionOf(entry) }
2609
+ : build;
2610
+ await commit(recorded);
2611
+ return {
2612
+ ok: true, alreadyInstalled: !pristine.downloaded, build: recorded,
2613
+ ...(pristine.pinnedVersionUnavailable ? { pinnedVersionUnavailable: pristine.pinnedVersionUnavailable } : {}),
2614
+ };
2615
+ }
2616
+ catch (error) {
2617
+ logInstallFailureDetail(agent.id, 'npx', npx.package, String(error));
2618
+ // The Node gate: the package's own engines.node refuses the Node it would run on. Not current.
2619
+ if (error instanceof AdapterNodeVersionError) {
2620
+ return { ok: false, error: error.message, nodeTooOld: { required: error.required, found: error.found } };
2621
+ }
2622
+ return { ok: false, error: `Adapter build preparation failed: ${errorMessage(error)}` };
2623
+ }
989
2624
  }
990
2625
  if (uvx) {
991
- // `uv tool install <pkg>==<ver>`, not `uvx <pkg>`: the launch path reads the entry point out
992
- // of `uv tool dir`, so this has to populate that same place (design §4.2).
993
- const outcome = await (this.runUv ? installUvTool(uvx.package, this.runUv) : installUvTool(uvx.package));
994
- if (outcome.ok)
995
- return { ok: true, alreadyInstalled: outcome.alreadyInstalled };
996
- // What's already on disk, read fresh at the moment of failure -- never the version this call
997
- // just tried to fetch, and never assumed: `installUvTool` resolves before touching the
998
- // environment (measured: a bad pin's resolution failure leaves whatever was already `uv tool
999
- // install`ed running, untouched), so whatever `uv tool list` reports right now is exactly what
1000
- // survived this attempt.
1001
- const existingVersion = await uvToolVersion(uvPackageName(uvx.package), this.runUv);
1002
- logInstallFailureDetail(agent.id, 'uvx', uvx.package, outcome.error);
1003
- return { ok: false, error: friendlyOrdinaryInstallError(agent.id, outcome.error, existingVersion) };
2626
+ // A per-version build under `uv/<pkg>/<version>/` (`builds/install/uv.ts`): its own UV_TOOL_DIR
2627
+ // and UV_TOOL_BIN_DIR, never the user's uv tools or `~/.local/bin`; launch never runs `uv`.
2628
+ const outcome = await installUvBuild({
2629
+ store: this.builds, agentId: agent.id, spec: uvx.package, args: uvx.args ?? [], env: uvx.env ?? {},
2630
+ registryVersion: entry.registryVersion, source, installedBy: setBy, runUv: this.runUv,
2631
+ timeoutMs: timeoutMs ?? UV_TOOL_INSTALL_TIMEOUT_MS,
2632
+ }).catch((error) => ({ ok: false, error: errorMessage(error), uvMissing: undefined }));
2633
+ if (!outcome.ok) {
2634
+ const existingVersion = (await this.builds.current(agent.id))?.version;
2635
+ logInstallFailureDetail(agent.id, 'uvx', uvx.package, outcome.error);
2636
+ // `uv` itself is absent: retrying will not help, installing uv will.
2637
+ if (outcome.uvMissing)
2638
+ return { ok: false, error: friendlyUvMissingError(agent.id, existingVersion) };
2639
+ return { ok: false, error: friendlyOrdinaryInstallError(agent.id, outcome.error, existingVersion) };
2640
+ }
2641
+ try {
2642
+ await commit(outcome.build);
2643
+ return { ok: true, alreadyInstalled: !outcome.downloaded, build: outcome.build };
2644
+ }
2645
+ catch (error) {
2646
+ return { ok: false, error: errorMessage(error) };
2647
+ }
1004
2648
  }
1005
2649
  if (binary) {
1006
2650
  const triple = platformTriple(this.platform, this.arch);
1007
- if (!triple || !binaryTargetFor(agent, triple)) {
2651
+ const target = triple ? binaryTargetFor(agent, triple) : undefined;
2652
+ if (!triple || !target) {
1008
2653
  return {
1009
2654
  ok: false,
1010
2655
  unsupported: true,
@@ -1015,16 +2660,26 @@ export class AcpAgents {
1015
2660
  root: this.adapterRoot,
1016
2661
  platform: this.platform,
1017
2662
  arch: this.arch,
2663
+ ...(this.binaryFetch ? { fetch: this.binaryFetch } : {}),
1018
2664
  });
1019
- if (outcome.ok)
1020
- return { ok: true, alreadyInstalled: outcome.alreadyInstalled };
1021
- // Same "read what's actually there, right now" rule as the uvx branch above: `installBinary`
1022
- // never touches a sibling version directory on failure (it only ever removes the *new*
1023
- // version's own, half-written one -- see that function's own doc), so this reads whichever
1024
- // version, if any, survived the attempt untouched.
1025
- const existingVersion = installedBinaryVersion(agent.id, this.adapterRoot);
1026
- logInstallFailureDetail(agent.id, 'binary', agent.version, outcome.error);
1027
- return { ok: false, error: friendlyOrdinaryInstallError(agent.id, outcome.error, existingVersion) };
2665
+ if (!outcome.ok) {
2666
+ // `installBinary` never touches another version's tree (it stages, then renames), so what
2667
+ // survives the attempt is exactly the current build.
2668
+ const existingVersion = (await this.builds.current(agent.id))?.version;
2669
+ logInstallFailureDetail(agent.id, 'binary', agent.version, outcome.error);
2670
+ return { ok: false, error: friendlyOrdinaryInstallError(agent.id, outcome.error, existingVersion) };
2671
+ }
2672
+ try {
2673
+ const build = binaryBuild({
2674
+ root: this.adapterRoot, agentId: agent.id, version: outcome.version, target, env: target.env ?? {},
2675
+ registryVersion: entry.registryVersion, source, installedBy: setBy,
2676
+ });
2677
+ await commit(build);
2678
+ return { ok: true, alreadyInstalled: outcome.alreadyInstalled, build };
2679
+ }
2680
+ catch (error) {
2681
+ return { ok: false, error: errorMessage(error) };
2682
+ }
1028
2683
  }
1029
2684
  return {
1030
2685
  ok: false,
@@ -1033,255 +2688,433 @@ export class AcpAgents {
1033
2688
  };
1034
2689
  }
1035
2690
  /**
1036
- * `installNpxPackage` for the registry-pinned specifier and, when — and only when — that failure
1037
- * is npm's own `ETARGET`, one retry with the pin stripped. The install-time sibling of
1038
- * {@link connectResolved}, which does the same thing for a launch that is already running; see its
1039
- * doc comment for why the condition is ETARGET specifically and why the retry happens at most once.
1040
- *
1041
- * ## Why `Install`/`Update` needed this and `connectResolved` alone was not enough
1042
- *
1043
- * The launch-time fallback only ever fires *after* an `Install` has already put something usable
1044
- * on disk — it exists to recover a session that is about to open, not to get the adapter onto the
1045
- * machine in the first place. Before this method, a registry entry pinned to an npm version that
1046
- * does not exist failed here, at `Install`/`Update`, every single time, and never got anywhere
1047
- * near `connectResolved`: the card offered a button whose task could only ever fail, worse than
1048
- * the launch-time defect it was fixed for.
2691
+ * The npm half of {@link installAdapter}: the pinned version into `npm/<sha16(name)>/<version>/pristine`
2692
+ * and, when — and only when — npm answers `ETARGET` (the registry pinned a version npm does not
2693
+ * have), one retry unpinned, recording the version that actually landed. This is the **only** place
2694
+ * that fallback exists now: a launch never resolves a version (spec §6.2 "ETARGET 回退移到安装期").
1049
2695
  */
1050
- async installNpxWithFallback(agent, pinnedSpec, timeoutMs) {
1051
- const outcome = await installNpxPackage(pinnedSpec, {
1052
- env: this.probe,
1053
- timeoutMs: timeoutMs ?? ADAPTER_INSTALL_TIMEOUT_MS,
1054
- npxCommand: this.npxCommand,
1055
- });
2696
+ async installPristineWithFallback(agent, pinnedSpec, timeoutMs) {
2697
+ const name = npmPackageName(pinnedSpec);
2698
+ const version = npmPackageVersion(pinnedSpec);
2699
+ const budget = timeoutMs ?? ADAPTER_INSTALL_TIMEOUT_MS;
2700
+ const install = (pinned) => ensurePristine({
2701
+ store: this.builds, name, ...(pinned !== undefined ? { version: pinned } : {}), npmCommand: this.npmCommand, timeoutMs: budget,
2702
+ }).catch((error) => ({ ok: false, error: errorMessage(error), stderr: '' }));
2703
+ const outcome = await install(version);
1056
2704
  if (outcome.ok)
1057
- return { ok: true, alreadyInstalled: outcome.alreadyInstalled };
1058
- // What (if anything) of this package is already on disk, read *before* any fallback runs --
1059
- // `installNpxPackage`'s own failure never touches an existing npx cache entry (measured: `npm
1060
- // exec --package=` only ever adds to the cache), so this is a fact about what survives this
1061
- // attempt no matter which branch below is taken.
1062
- const existingVersion = npxCacheVersion(npmPackageName(pinnedSpec), readNpxCache(this.probe));
1063
- const fallback = unpinnedFallbackFor(agent, this.platform);
1064
- if (!fallback || !stderrShowsMissingPackageVersion(outcome.stderr)) {
2705
+ return outcome;
2706
+ const existingVersion = (await this.builds.current(agent.id))?.version;
2707
+ if (version === undefined || !stderrShowsMissingPackageVersion(outcome.stderr)) {
1065
2708
  logInstallFailureDetail(agent.id, 'npx', pinnedSpec, outcome.stderr || outcome.error);
1066
- return {
1067
- ok: false,
1068
- error: friendlyOrdinaryInstallError(agent.id, outcome.stderr || outcome.error, existingVersion),
1069
- };
2709
+ return { ok: false, error: friendlyOrdinaryInstallError(agent.id, outcome.stderr || outcome.error, existingVersion) };
1070
2710
  }
1071
2711
  // 两条都是 warn,而且都要能被一个月后读日志的人看懂——他不知道今晚发生了什么,只看得见这两行。
1072
- // 措辞跟 `connectResolved` 里那两条故意保持一致,只把"启动"换成"安装"。
1073
2712
  console.warn(`[acp] agent '${agent.id}' could not be installed at the version the ACP registry pins ` +
1074
- `('${fallback.pinned}'): npm has no published version matching it (npm error code ` +
2713
+ `('${pinnedSpec}'): npm has no published version matching it (npm error code ` +
1075
2714
  'ETARGET). This is a bad entry published upstream, not a broken install on this machine.');
1076
- console.warn(`[acp] retrying the install for agent '${agent.id}' once as '${fallback.unpinned}', with the ` +
2715
+ console.warn(`[acp] retrying the install for agent '${agent.id}' once as '${name}', with the ` +
1077
2716
  "version pin removed, so npm resolves whatever it currently calls 'latest'. That is " +
1078
2717
  'deliberately NOT the version the registry asked for: if this agent then misbehaves, ' +
1079
2718
  'suspect the version before suspecting the agent.');
1080
- const retryOutcome = await installNpxPackage(fallback.unpinned, {
1081
- env: this.probe,
1082
- timeoutMs: timeoutMs ?? ADAPTER_INSTALL_TIMEOUT_MS,
1083
- npxCommand: this.npxCommand,
1084
- });
1085
- if (!retryOutcome.ok) {
1086
- // 不钉版本那次再失败,抛出去的是把两件事都说清楚的一句话:registry 钉的版本不存在,而且
1087
- // 绕过去的那次尝试自己也没成 —— 而不是原来那条已经绕过去的 ETARGET。
1088
- logInstallFailureDetail(agent.id, 'npx', fallback.unpinned, retryOutcome.stderr || retryOutcome.error);
2719
+ const retry = await install(undefined);
2720
+ if (!retry.ok) {
2721
+ logInstallFailureDetail(agent.id, 'npx', name, retry.stderr || retry.error);
1089
2722
  return {
1090
2723
  ok: false,
1091
- error: friendlyPinnedVersionUnavailableAndFallbackFailed(agent.id, fallback.pinned, retryOutcome.stderr || retryOutcome.error, existingVersion),
2724
+ error: friendlyPinnedVersionUnavailableAndFallbackFailed(agent.id, pinnedSpec, retry.stderr || retry.error, existingVersion),
1092
2725
  };
1093
2726
  }
1094
- // The fallback succeeded: this machine now has *some* version of the adapter, but never the one
1095
- // the registry actually pins -- npm does not have it, so `Update` could offer to fetch it again
1096
- // forever and never converge. Reading the version back off the cache (rather than trusting
1097
- // `fallback.unpinned`'s own unversioned specifier) is what lets the caller persist a concrete
1098
- // fact ("Y is installed") instead of a vague "not the pinned one".
1099
- const installedInstead = npxCacheVersion(fallback.unpinned, readNpxCache(this.probe));
1100
- return {
1101
- ok: true,
1102
- alreadyInstalled: retryOutcome.alreadyInstalled,
1103
- ...(installedInstead ? { pinnedVersionUnavailable: { pinned: fallback.pinned, installedInstead } } : {}),
2727
+ return { ...retry, pinnedVersionUnavailable: { pinned: pinnedSpec, installedInstead: retry.version } };
2728
+ }
2729
+ /**
2730
+ * Move installed agents forward to what **this CLI** certifies, without anyone pressing Update
2731
+ * (spec §6.1 "由 Axom 更新"): an agent whose effective version is pinned by this CLI (runtime.json
2732
+ * override, catalog pin or supplement) and differs from its current build, or whose current
2733
+ * version would now be patched differently (a recipe added, changed or retired). The new build is
2734
+ * installed and made current with `currentSetBy: 'axom'`; running sessions keep the build they
2735
+ * were spawned from. Agents with no current build are left alone (nothing is installed to move),
2736
+ * as is every registry-only version change — that is the user's Update to make.
2737
+ *
2738
+ * Never throws: failures are logged per agent and the old current stays. Reads no registry at all
2739
+ * when no agent is installed, so a fresh machine starts without touching the network for this.
2740
+ */
2741
+ async reconcileCertifiedBuilds(options = {}) {
2742
+ const moves = [];
2743
+ let document;
2744
+ try {
2745
+ document = await this.builds.read();
2746
+ }
2747
+ catch (error) {
2748
+ console.warn(`[acp-builds] cannot read builds: ${errorMessage(error)}`);
2749
+ return moves;
2750
+ }
2751
+ const installed = Object.keys(document.agents).filter((agentId) => this.builds.currentIn(document, agentId));
2752
+ if (!installed.length)
2753
+ return moves;
2754
+ let entries;
2755
+ try {
2756
+ entries = await this.effective();
2757
+ }
2758
+ catch (error) {
2759
+ console.warn(`[acp-builds] registry unavailable, skipping certified build reconcile: ${errorMessage(error)}`);
2760
+ return moves;
2761
+ }
2762
+ for (const agentId of installed) {
2763
+ const entry = entries.find((candidate) => candidate.agent.id === agentId);
2764
+ if (!entry)
2765
+ continue;
2766
+ try {
2767
+ const move = await this.builds.withAgentLock(agentId, () => this.reconcileOne(entry, options.timeoutMs));
2768
+ if (move)
2769
+ moves.push(move);
2770
+ }
2771
+ catch (error) {
2772
+ console.warn(`[acp-builds] agent '${agentId}': certified build reconcile failed: ${errorMessage(error)}`);
2773
+ }
2774
+ }
2775
+ return moves;
2776
+ }
2777
+ async reconcileOne(entry, timeoutMs) {
2778
+ const agentId = entry.agent.id;
2779
+ const current = await this.builds.current(agentId);
2780
+ if (!current)
2781
+ return undefined;
2782
+ // The pin this CLI certifies was already tried and npm did not have it: the current build is the
2783
+ // install-time fallback for exactly this pin. Trying again on every daemon start would only repeat
2784
+ // the same network round trip and land on the same build.
2785
+ const sameUnavailablePin = current.source === 'unpinned-fallback' && current.requestedVersion === requestedVersionOf(entry);
2786
+ if (entry.pinnedBy && entry.agent.version !== current.version && !sameUnavailablePin) {
2787
+ const outcome = await this.installAdapter(entry, 'axom', timeoutMs, current.buildId);
2788
+ if (!outcome.ok)
2789
+ throw new Error(outcome.error);
2790
+ if (!outcome.build || outcome.build.buildId === current.buildId)
2791
+ return undefined;
2792
+ console.info(`[acp-builds] agent '${agentId}': moved ${current.buildId} -> ${outcome.build.buildId} (pinned by ${entry.pinnedBy})`);
2793
+ return { agentId, from: current.buildId, to: outcome.build.buildId, reason: 'certified-pin' };
2794
+ }
2795
+ // Same version: does this CLI patch it differently from the build that is current?
2796
+ if ((current.kind !== 'npm' && current.kind !== 'npm-patched') || !current.package)
2797
+ return undefined;
2798
+ const pristine = inside(this.adapterRoot, npmPristineDir(current.package, current.version));
2799
+ const recipes = await selectPackagePatches(current.package, current.version, inside(path.join(pristine, 'node_modules'), current.package), this.patchRecipes);
2800
+ const wanted = recipes.length ? patchDigest(recipes) : undefined;
2801
+ if (wanted === current.patchDigest)
2802
+ return undefined;
2803
+ const build = await npmBuild({
2804
+ store: this.builds, agentId, name: current.package, version: current.version,
2805
+ args: current.launch.args, env: current.launch.env, registryVersion: current.registryVersion,
2806
+ source: current.source, installedBy: 'axom', nodePath: this.nodePath,
2807
+ ...(this.patchRecipes ? { recipes: this.patchRecipes } : {}),
2808
+ });
2809
+ await this.builds.commit(build, { makeCurrent: true, setBy: 'axom' });
2810
+ console.info(`[acp-builds] agent '${agentId}': moved ${current.buildId} -> ${build.buildId} (patch set changed)`);
2811
+ return { agentId, from: current.buildId, to: build.buildId, reason: 'patch-set' };
2812
+ }
2813
+ /**
2814
+ * Take over what an older CLI left on this machine (spec §6.2 "迁移", `builds/migrate.ts`), for every
2815
+ * agent that has no current build: an `_npx` copy of exactly the effective version, or pre-build
2816
+ * binary directories. Run by the daemon at start, in the background; an open (or any launch) of an
2817
+ * agent whose share has not run yet waits on that agent's lock instead of reporting it not installed.
2818
+ *
2819
+ * Idempotent (an agent with a current build is never touched) and once per agent per process. Reads
2820
+ * no registry when there is nothing to migrate from (no `_npx`, no legacy directories). Never throws.
2821
+ */
2822
+ async migrateLegacyAdapters() {
2823
+ const migrated = [];
2824
+ let document;
2825
+ try {
2826
+ document = await this.builds.read();
2827
+ }
2828
+ catch (error) {
2829
+ console.warn(`[acp-builds] cannot read builds, skipping migration: ${errorMessage(error)}`);
2830
+ return migrated;
2831
+ }
2832
+ const hasNpx = existsSync(path.join(npmCacheDir(this.probe), '_npx'));
2833
+ const legacy = new Set(await legacyAgentDirs(this.adapterRoot));
2834
+ if (!hasNpx && !legacy.size)
2835
+ return migrated;
2836
+ let entries;
2837
+ try {
2838
+ entries = await this.effective();
2839
+ }
2840
+ catch (error) {
2841
+ console.warn(`[acp-builds] registry unavailable, migration deferred to first open: ${errorMessage(error)}`);
2842
+ return migrated;
2843
+ }
2844
+ for (const entry of entries) {
2845
+ const agentId = entry.agent.id;
2846
+ if (this.builds.currentIn(document, agentId))
2847
+ continue;
2848
+ const { npx, uvx, binary } = entry.agent.distribution;
2849
+ if (npx ? !hasNpx : uvx || !binary || !legacy.has(agentId))
2850
+ continue;
2851
+ const result = await this.migrateAgent(agentId, entries);
2852
+ if (result)
2853
+ migrated.push(result);
2854
+ }
2855
+ return migrated;
2856
+ }
2857
+ /** {@link migrateLegacyAdapters} for one agent, at most once per process (shared by the daemon's sweep and every launch). */
2858
+ migrateAgent(agentId, entries) {
2859
+ let running = this.migrations.get(agentId);
2860
+ if (!running) {
2861
+ running = this.builds.withAgentLock(agentId, () => this.migrateOne(agentId, entries)).catch((error) => {
2862
+ console.warn(`[acp-builds] agent '${agentId}': migration of the previous install failed: ${errorMessage(error)}`);
2863
+ // A registry that could not be read is worth another try; anything else is not repeated.
2864
+ if (error instanceof RegistryUnavailableForMigration)
2865
+ this.migrations.delete(agentId);
2866
+ return undefined;
2867
+ });
2868
+ this.migrations.set(agentId, running);
2869
+ }
2870
+ return running;
2871
+ }
2872
+ async migrateOne(agentId, known) {
2873
+ if (await this.builds.current(agentId))
2874
+ return undefined;
2875
+ const npmCache = npmCacheDir(this.probe);
2876
+ const legacyVersions = await legacyBinaryVersions(this.adapterRoot, agentId);
2877
+ const unrecorded = await legacyBinaryVersions(path.join(this.adapterRoot, 'bin'), agentId);
2878
+ if (!legacyVersions.length && !unrecorded.length && !existsSync(path.join(npmCache, '_npx')))
2879
+ return undefined;
2880
+ let entries = known;
2881
+ if (!entries) {
2882
+ try {
2883
+ entries = await this.effective();
2884
+ }
2885
+ catch (error) {
2886
+ throw new RegistryUnavailableForMigration(errorMessage(error));
2887
+ }
2888
+ }
2889
+ const entry = entries.find((candidate) => candidate.agent.id === agentId);
2890
+ if (!entry)
2891
+ return undefined;
2892
+ const { npx, uvx, binary } = entry.agent.distribution;
2893
+ if (npx)
2894
+ return this.migrateFromNpx(entry, npx.package, npmCache);
2895
+ // uv: the user's own uv tool dir is not ours to take over; the first open installs a build.
2896
+ if (uvx || !binary)
2897
+ return undefined;
2898
+ return this.migrateLegacyBinaries(entry, legacyVersions, unrecorded);
2899
+ }
2900
+ /** npm: the effective version's `_npx` copy, as this agent's current build (pristine or patched, Node gate applied). */
2901
+ async migrateFromNpx(entry, spec, npmCache) {
2902
+ const agentId = entry.agent.id;
2903
+ const name = npmPackageName(spec);
2904
+ // Exactly what the pre-build launch ran: `npx <name>@<effective version>`.
2905
+ const version = requestedVersionOf(entry);
2906
+ const copy = await findNpxCopy(npmCache, name, version);
2907
+ if (!copy)
2908
+ return undefined;
2909
+ const { copied } = await adoptNpxCopy({ store: this.builds, name, version, nodeModules: copy });
2910
+ const npx = entry.agent.distribution.npx;
2911
+ const build = {
2912
+ ...await npmBuild({
2913
+ store: this.builds, agentId, name, version, args: npx.args ?? [], env: npx.env ?? {},
2914
+ registryVersion: entry.registryVersion, source: entry.pinnedBy ?? 'registry', installedBy: 'axom',
2915
+ nodePath: this.nodePath, ...(this.patchRecipes ? { recipes: this.patchRecipes } : {}),
2916
+ }),
2917
+ migratedFrom: 'npx-cache',
2918
+ };
2919
+ await this.builds.commit(build, { makeCurrent: true, setBy: 'axom' });
2920
+ console.info(`[acp-builds] agent '${agentId}': ${name}@${version} ${copied ? 'copied from' : 'already copied from'} ${copy} as build ${build.buildId}`);
2921
+ return { agentId, from: 'npx-cache', current: build.buildId, builds: [build.buildId] };
2922
+ }
2923
+ /**
2924
+ * binary: every pre-build `acp-adapters/<agent>/<version>` renamed in place into `bin/<agent>/<version>`
2925
+ * and recorded (plus any `bin/<agent>/<version>` an interrupted migration renamed but never recorded);
2926
+ * the one the pre-build launch picked — the pinned version if present, else the highest — is current.
2927
+ */
2928
+ async migrateLegacyBinaries(entry, legacyVersions, unrecorded) {
2929
+ const agent = entry.agent;
2930
+ const triple = platformTriple(this.platform, this.arch);
2931
+ const target = triple ? binaryTargetFor(agent, triple) : undefined;
2932
+ if (!target)
2933
+ return undefined;
2934
+ const recorded = [];
2935
+ const record = (version) => {
2936
+ try {
2937
+ recorded.push({
2938
+ ...binaryBuild({
2939
+ root: this.adapterRoot, agentId: agent.id, version, target, env: target.env ?? {},
2940
+ registryVersion: entry.registryVersion, source: entry.pinnedBy ?? 'registry', installedBy: 'axom',
2941
+ }),
2942
+ migratedFrom: 'legacy-binary',
2943
+ });
2944
+ }
2945
+ catch (error) {
2946
+ console.warn(`[acp-builds] agent '${agent.id}' ${version}: not taken over: ${errorMessage(error)}`);
2947
+ }
1104
2948
  };
2949
+ for (const version of [...legacyVersions].sort()) {
2950
+ let destination;
2951
+ try {
2952
+ destination = inside(this.adapterRoot, binaryBuildDir(agent.id, version));
2953
+ }
2954
+ catch {
2955
+ continue;
2956
+ }
2957
+ // The old install unpacked in place: a directory without its executable is a killed unpack. Left alone.
2958
+ const command = path.posix.normalize(target.cmd.replace(/\\/g, '/'));
2959
+ if (!existsSync(path.join(this.adapterRoot, agent.id, version, ...command.split('/')))) {
2960
+ console.warn(`[acp-builds] agent '${agent.id}' ${version}: no '${target.cmd}' in the old install; left where it is.`);
2961
+ continue;
2962
+ }
2963
+ if (await moveLegacyBinary(this.adapterRoot, agent.id, version, destination))
2964
+ record(version);
2965
+ }
2966
+ for (const version of unrecorded)
2967
+ if (!recorded.some((build) => build.version === version))
2968
+ record(version);
2969
+ const pick = pickInstalledVersion(recorded.map((build) => build.version), agent.version);
2970
+ if (pick === undefined)
2971
+ return undefined;
2972
+ for (const build of recorded)
2973
+ await this.builds.commit(build, { makeCurrent: false, setBy: 'axom' });
2974
+ const current = recorded.find((build) => build.version === pick);
2975
+ await this.builds.commit(current, { makeCurrent: true, setBy: 'axom' });
2976
+ console.info(`[acp-builds] agent '${agent.id}': took over ${recorded.map((build) => build.buildId).join(', ')}; current ${current.buildId}`);
2977
+ return { agentId: agent.id, from: 'legacy-binary', current: current.buildId, builds: recorded.map((build) => build.buildId) };
2978
+ }
2979
+ /**
2980
+ * Spec §6.2: an agent with no current build — after its migration, if any — is installed **once**,
2981
+ * by the first open that needs it ("Installing <version>" on the session). That is what the pre-build
2982
+ * npx launch did on every open; a uv tool the user had in their own uv dir is reinstalled the same
2983
+ * way. Only npm and uv distributions: an open never fetched a binary agent, which still fails as not
2984
+ * installed. Every later open launches the build and downloads nothing.
2985
+ */
2986
+ async installForOpen(agentId, onOutcome) {
2987
+ if (await this.builds.current(agentId))
2988
+ return;
2989
+ await this.migrateAgent(agentId);
2990
+ if (await this.builds.current(agentId))
2991
+ return;
2992
+ let entry;
2993
+ try {
2994
+ entry = (await this.effective()).find((candidate) => candidate.agent.id === agentId);
2995
+ }
2996
+ catch {
2997
+ return;
2998
+ }
2999
+ // Not in the registry, or nothing an open may fetch: `launchSpecFor` says which.
3000
+ if (!entry || !(entry.agent.distribution.npx || entry.agent.distribution.uvx))
3001
+ return;
3002
+ const found = entry;
3003
+ const version = installingVersionOf(found);
3004
+ const agentName = found.agent.name || agentId;
3005
+ // Started once and never aborted: an open that gives up below leaves it running under the lock.
3006
+ const install = this.builds.withAgentLock(agentId, async () => {
3007
+ if (await this.builds.current(agentId))
3008
+ return undefined;
3009
+ const outcome = await this.installAdapter(found, 'user');
3010
+ return outcome.ok ? { ok: true, agentName, version } : { ok: false, agentName, version, error: outcome.error };
3011
+ }).catch((error) => ({ ok: false, agentName, version, error: errorMessage(error) }));
3012
+ let timer;
3013
+ const budget = new Promise((resolve) => { timer = setTimeout(() => resolve('budget'), this.openInstallBudgetMs); });
3014
+ const settled = await Promise.race([install, budget]).finally(() => clearTimeout(timer));
3015
+ if (settled === 'budget') {
3016
+ void install.then((late) => {
3017
+ if (late)
3018
+ console.info(`[acp-builds] agent '${agentId}': install of ${version} finished after its open gave up (${late.ok ? 'installed' : late.error})`);
3019
+ });
3020
+ throw new AdapterStillInstallingError(agentId, agentName, version);
3021
+ }
3022
+ if (!settled)
3023
+ return;
3024
+ onOutcome?.(settled);
3025
+ if (!settled.ok)
3026
+ throw new Error(settled.error);
1105
3027
  }
1106
3028
  /**
1107
- * Whether the catalog's extra prerequisite for this agent (`registry/catalog.ts`'s `dependency`)
1108
- * is already on `PATH` — never whether the adapter itself has been fetched, and never whether
1109
- * this daemon's Node satisfies `requiresNode` (that gate only matters at {@link install} time, not
1110
- * for reporting what is already on the machine). An agent outside the catalog, or one with no
1111
- * `dependency` block, needs nothing extra and reads as satisfied.
3029
+ * Whether the catalog's prerequisite for this agent (`registry/catalog.ts`'s `prerequisite`) is
3030
+ * detected on this machine (`registry/detect.ts`) — never whether the adapter itself has been
3031
+ * fetched, and never whether this daemon's Node satisfies `requiresNode` (that gate only matters
3032
+ * at {@link install} time, not for reporting what is already on the machine). An agent outside the
3033
+ * catalog, or one with no `prerequisite` block, needs nothing extra and reads as satisfied.
1112
3034
  *
1113
- * {@link roster} folds this into `adapterInstalled`: an agent whose adapter is on disk but whose
1114
- * curated prerequisite is not reports **not installed**, so its card offers `Install` (which
1115
- * fetches the dependency) instead of an `Add` whose try-run was always going to fail on
3035
+ * {@link roster} makes the same reading for `adapterInstalled`: an agent whose adapter is on disk
3036
+ * but whose curated prerequisite is not reports **not installed**, so its card offers `Install`
3037
+ * (which fetches the prerequisite) instead of an `Add` whose try-run was always going to fail on
1116
3038
  * `Could not start pi: executable not found`. Design §4.4, point 2.
1117
3039
  */
1118
- dependencySatisfied(agentId, probe = this.probe) {
1119
- const dependency = catalogEntryFor(agentId)?.dependency;
1120
- if (!dependency)
3040
+ prerequisiteSatisfied(agentId, probe = this.probe) {
3041
+ const prerequisite = catalogEntryFor(agentId)?.prerequisite;
3042
+ if (!prerequisite)
1121
3043
  return true;
1122
- return findOnPath(dependency.check, probe) !== undefined;
3044
+ return detected(prerequisite.detect, probe, platformTriple(this.platform, this.arch));
1123
3045
  }
1124
- /** A config-supplied `command` is the whole launch spec (ruling R3); otherwise the registry decides. */
1125
- async resolveLaunchFor(agentId, command, args) {
1126
- // 裁决 R3 的覆盖没有回退可言:用户自己写下的命令行就是**全部**,这里不该去猜他们的哪一段
1127
- // 参数是版本号。
1128
- if (command)
1129
- return { launch: { command, args, env: {} } };
3046
+ /**
3047
+ * The spawn spec a new session of `agentId` launches with: its **current build** and nothing else
3048
+ * — no npx, no download (spec §6.1/§6.2). The spec carries the build's `label`, and a JavaScript
3049
+ * build must pass the Node gate (`builds/nodeGate.ts`). An agent with no current build first waits
3050
+ * for its share of the legacy migration ({@link migrateLegacyAdapters}); still none → it fails with
3051
+ * {@link AdapterNotInstalledError} — whatever its distribution: a uv agent too launches only from its
3052
+ * build, never through `uv`. Only {@link open} goes on to install (once) — see `installForOpen`.
3053
+ */
3054
+ async launchSpecFor(agentId) {
3055
+ let current = await this.builds.current(agentId);
3056
+ if (!current) {
3057
+ // What an older CLI left may still be on its way into the store (daemon start runs this in the
3058
+ // background); wait for this agent's share of it rather than report it not installed.
3059
+ await this.migrateAgent(agentId);
3060
+ current = await this.builds.current(agentId);
3061
+ }
3062
+ if (current) {
3063
+ this.touchBuild(agentId, current.buildId);
3064
+ return launchForBuild(current, this.adapterRoot, {
3065
+ nodePath: this.nodePath,
3066
+ launchEnv: catalogLaunchEnv(agentId, current.registryVersion),
3067
+ basePath: this.probe.path,
3068
+ });
3069
+ }
1130
3070
  const agents = await this.registry.load();
1131
- const agent = agents.find((candidate) => candidate.id === agentId);
1132
- if (!agent) {
3071
+ if (!agents.some((candidate) => candidate.id === agentId)) {
1133
3072
  throw new Error(`Agent '${agentId}' is not in the ACP agent registry. Check the id, refresh the registry, ` +
1134
3073
  'or give this agent an explicit command in the workstation config.');
1135
3074
  }
1136
- // 每次启动都从刚读到的 registry 现算一遍,没有任何一处把回退记下来:上游把版本修好的那一刻,
1137
- // 钉版本的那条路自己就恢复了,不需要谁去清什么状态。
1138
- return {
1139
- launch: await resolveLaunch(agent, this.platform, this.arch, this.adapterRoot),
1140
- fallback: unpinnedFallbackFor(agent, this.platform),
1141
- };
3075
+ throw new AdapterNotInstalledError(agentId);
3076
+ }
3077
+ /** A config-supplied `command` is the whole launch spec (ruling R3); otherwise the current build decides. */
3078
+ async resolveLaunchFor(agentId, command, args) {
3079
+ if (command)
3080
+ return { launch: { command, args, env: {} }, source: 'command-override' };
3081
+ return { source: 'registry', launch: await this.launchSpecFor(agentId) };
1142
3082
  }
1143
3083
  /**
1144
3084
  * Connect a launch that is not a login — every connection this class makes except
1145
- * {@link connectFor}'s login one — with {@link withUnpinnedFallback}'s ETARGET retry around it.
3085
+ * {@link connectFor}'s login one.
1146
3086
  *
1147
3087
  * **`options` is {@link ConnectAgentOptions}, whose `present` is `never`**, and that is the whole
1148
3088
  * point of this method still existing as a separate one: the stderr sniffer cannot be armed
1149
- * through the route {@link open} and {@link listNativeSessions} take. It used to take a second
1150
- * `login` parameter for `connectFor`'s benefit, which meant the same door stood open one argument
1151
- * further along; `connectFor` builds its own options now.
3089
+ * through the route {@link open} and {@link listNativeSessions} take.
1152
3090
  */
1153
- async connectResolved(agentId, resolved, env, agentEnv, options = {}) {
1154
- return withUnpinnedFallback(agentId, resolved, (launch) => connectAgent(launch, env, agentEnv, options));
3091
+ async connectResolved(resolved, env, agentEnv, options = {}) {
3092
+ return connectAgent(resolved.launch, env, agentEnv, options);
1155
3093
  }
1156
3094
  /**
1157
- * Whether `resolveLaunch` can produce a spec for this agent on this machine (see {@link roster}).
1158
- *
1159
- * `uvTools` is {@link roster}'s single hoisted `uv tool list` snapshot, passed straight through to
1160
- * `resolveLaunch`'s uvx branch. Without it, `resolveLaunch` falls back to reading `uv tool list`
1161
- * itself via its default runner — a real `uv` subprocess per uvx-distributed agent, called from
1162
- * inside `roster()`'s per-agent map, which is exactly the per-agent-spawn defect `adapterVersionFor`
1163
- * was hoisted out of. This is the second call site that same invariant covers: building the roster
1164
- * must start nothing per agent, no matter how many code paths ask "is this agent launchable".
1165
- *
1166
- * `resolveLaunch` itself is `async` now (`uv` is a non-blocking call — see `uvxInstall.ts`'s
1167
- * `RunUv`), so this awaits it; with `uvTools` always supplied here, that branch never actually
1168
- * waits on anything; it resolves off the already-hoisted snapshot exactly as synchronously as
1169
- * before, just wrapped in a settled `Promise`.
3095
+ * Whether this agent can be launched on this machine (see {@link roster}): with a current build it
3096
+ * can; an npm or uv agent can be once installed (Install fetches it); a binary-only agent only with a
3097
+ * current build. Reads nothing and spawns nothing.
1170
3098
  */
1171
- async resolvable(agent, uvTools) {
1172
- try {
1173
- await resolveLaunch(agent, this.platform, this.arch, this.adapterRoot, undefined, uvTools);
3099
+ resolvable(agent, current) {
3100
+ if (current)
1174
3101
  return true;
1175
- }
1176
- catch {
1177
- return false;
1178
- }
1179
- }
1180
- }
1181
- /**
1182
- * Run `attempt` on the launch the registry pins, and — only when npm answers ETARGET — once more on
1183
- * the unpinned fallback.
1184
- *
1185
- * Extracted from `connectResolved` so that the login path can reuse the retry **without** reusing
1186
- * `connectResolved`'s options parameter, which is typed to forbid `present`. Both callers get the
1187
- * same retry and the same two log lines; only one of them can arm the stderr sniffer, and it says so
1188
- * in its own signature rather than by passing an extra argument the other caller could also pass.
1189
- *
1190
- * ## Only on ETARGET, and only once
1191
- *
1192
- * 只在 npm 自己说「这个版本不存在」时重试。一次冷启动要几十秒(npx 得先把适配器下下来),所以
1193
- * 「失败就重试」等于把每一次真实故障的代价翻倍。这里认的是 npm 打在子进程 stderr 上的错误码,见
1194
- * `acpClient.ts` 的 `isMissingPackageVersion` —— 那是证据;从一根断掉的管子倒推「大概是版本不对吧」
1195
- * 是猜测,两者在日志里长得一样,后果不一样。
1196
- *
1197
- * 不钉版本那次再失败,抛出去的就是**它自己**的错误。把原来的 ETARGET 报上去会让读日志的人接着去
1198
- * 追一个已经绕过去的问题。
1199
- */
1200
- async function withUnpinnedFallback(agentId, resolved, attempt) {
1201
- try {
1202
- return await attempt(resolved.launch);
1203
- }
1204
- catch (error) {
1205
- const fallback = resolved.fallback;
1206
- if (!fallback || !isMissingPackageVersion(error))
1207
- throw error;
1208
- // 两条都是 warn,而且都要能被一个月后读日志的人看懂 —— 他不知道今晚发生了什么,只看得见这两行。
1209
- console.warn(`[acp] agent '${agentId}' could not be launched at the version the ACP registry pins ` +
1210
- `('${fallback.pinned}'): npm has no published version matching it (npm error code ` +
1211
- 'ETARGET). This is a bad entry published upstream, not a broken install on this machine.');
1212
- console.warn(`[acp] retrying agent '${agentId}' once as '${fallback.unpinned}', with the version pin ` +
1213
- "removed, so npm resolves whatever it currently calls 'latest'. That is deliberately NOT " +
1214
- 'the version the registry asked for: if this agent now misbehaves, suspect the version ' +
1215
- 'before suspecting the agent.');
1216
- return await attempt(fallback.launch);
3102
+ const { npx, uvx } = agent.distribution;
3103
+ return Boolean(npx || uvx);
1217
3104
  }
1218
3105
  }
1219
3106
  /**
1220
- * Wrap one `AcpSession` plus the connection it runs on into the shape the manager holds.
3107
+ * Opens one session on [connection] (a verification's own, short-lived) and collects the command table the agent broadcasts
3108
+ * after `session/new` ({@link createCommandTableProbe}). `commands` undefined = no table arrived within [firstWindowMs]
3109
+ * (unknown, not empty).
1221
3110
  *
1222
- * Two behaviours are added on top of `AcpSession`. Lifetime: `close()` must end the subprocess as
1223
- * well as the session, or `agent_close` would leave a live agent behind holding its cwd and its
1224
- * quota. `backfill` is attached only when there is history to replay, because its *absence* is what
1225
- * the manager reads as "nothing to replay". And the config option snapshot: {@link
1226
- * AcpAgentSession.configOptions} must answer with the **latest** one, not the one this session
1227
- * opened with, so the cell below is what `setConfigOption` writes back into and what the getter
1228
- * reads — see that field's own doc for what a stale answer costs on every reconnect.
3111
+ * The table is broadcast *after* `session/new` is answered (`acp-agent.js:752-757`), so a verification that closed right after
3112
+ * opening could never see it; and it can also arrive before this client has read that answer (p3-fix7 N9), which the probe
3113
+ * keeps. Settle rather than "first push wins": Claude broadcasts twice — once on `session/new` and again when the init frame
3114
+ * lets it filter out terminal-bound commands. 不能靠一个固定的等待:一条都没来 → 到 [firstWindowMs] 就收工;来了第一条 →
3115
+ * 改用安静期([COMMAND_QUIET_MS])收尾,并受 [COMMAND_SETTLE_CEILING_MS] 兜底。
1229
3116
  */
1230
- function hostedSession(session, connection, history, configOptions) {
1231
- // `AcpSession` keeps a cell of its own for the same value, but does not expose it — and the two
1232
- // are written from the same place (the wrapper below is the only caller of its setter), so this
1233
- // one cannot drift from it.
1234
- let snapshot = configOptions;
1235
- const hosted = {
1236
- // Verbatim. This used to be the one place a string became a `ContentBlock[]`
1237
- // (`session.prompt([{ type: 'text', text }])`) — a stopgap for as long as `agent_prompt` could
1238
- // only carry text. That seam is gone: the frame now carries blocks, so wrapping here would be a
1239
- // second, narrower opinion about the shape of a user message.
1240
- prompt: (blocks) => session.prompt(blocks),
1241
- // Both halves come off the live agent: the process's own exit state, and the session's own
1242
- // outstanding-prompt list. Nothing here is remembered by this daemon.
1243
- get liveness() {
1244
- return {
1245
- agentAlive: connection.agentProcessAlive() && session.readable,
1246
- turnRunning: session.turnRunning,
1247
- };
1248
- },
1249
- answer: (requestId, outcome) => session.answer(requestId, outcome),
1250
- interrupt: () => session.interrupt(),
1251
- setConfigOption: async (configId, value) => {
1252
- snapshot = await session.setConfigOption(configId, value);
1253
- return snapshot;
1254
- },
1255
- get configOptions() {
1256
- return snapshot;
1257
- },
1258
- steer: (blocks, priority) => session.steer(blocks, priority),
1259
- // Straight off the connection that opened this session — never inferred, and never read back
1260
- // from the roster, whose entry for this agent may still be the pre-connection one.
1261
- supportsSteering: connection.supportsSteering,
1262
- // Same source, same rule, and likewise never defaulted: an empty list here is the agent saying
1263
- // nothing about *when*, which ruling N4 reads as "the server may not name a timing".
1264
- steeringTimings: connection.steeringTimings,
1265
- agentInfo: connection.agentInfo,
1266
- supportsImageInput: connection.capabilities.promptImage,
1267
- onEvent: (cb) => session.onEvent(cb),
1268
- onTurnDone: (cb) => session.onTurnDone(cb),
1269
- async close() {
1270
- try {
1271
- await session.close();
1272
- }
1273
- finally {
1274
- // Runs even if the session's own shutdown threw: the subprocess is the expensive thing to
1275
- // leak, and nothing else in the daemon will come back for it.
1276
- await connection.close().catch(() => undefined);
1277
- }
1278
- },
1279
- };
1280
- if (history.length > 0) {
1281
- hosted.backfill = async function* backfill() {
1282
- for (const event of history)
1283
- yield event;
1284
- };
1285
- }
1286
- return hosted;
3117
+ async function observeCommands(probe, connection, cwd, firstWindowMs) {
3118
+ const { raw, configOptions } = await probe.observe(connection, cwd, { firstWindowMs, quietMs: COMMAND_QUIET_MS, ceilingMs: COMMAND_SETTLE_CEILING_MS });
3119
+ return { commands: raw?.map(toAvailableCommandWire), configOptions };
1287
3120
  }