@platless/axom-cli 0.3.13 → 0.5.9

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