@platless/axom-cli 0.3.13 → 0.6.36

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