@platless/axom-cli 0.3.13 → 0.6.36

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (320) hide show
  1. package/README.md +18 -5
  2. package/dist/axom-patch/cli/README.md +119 -9
  3. package/dist/axom-patch/cli/account-limits/README.md +27 -0
  4. package/dist/axom-patch/cli/account-limits/antigravity-proxy.mjs +47 -0
  5. package/dist/axom-patch/cli/account-limits/antigravity-read.mjs +90 -0
  6. package/dist/axom-patch/cli/account-limits/antigravity.mjs +46 -0
  7. package/dist/axom-patch/cli/account-limits/generate.py +123 -0
  8. package/dist/axom-patch/cli/account-limits/kimi-proxy.mjs +81 -0
  9. package/dist/axom-patch/cli/account-limits/normalize.mjs +151 -0
  10. package/dist/axom-patch/cli/claude-account-limits.json +37 -0
  11. package/dist/axom-patch/cli/codex-account-limits.json +37 -0
  12. package/dist/axom-patch/cli/codex-terminal-output.json +16 -0
  13. package/dist/axom-patch/cli/commandcode-account-limits.json +30 -0
  14. package/dist/axom-patch/cli/kimi-quota.mjs +12 -0
  15. package/dist/axom-patch/cli/kimi-subagents.mjs +173 -0
  16. package/dist/axom-patch/cli/nightly-task.md +194 -0
  17. package/dist/axom-patch/cli/pi-subagent-subwork.json +23 -0
  18. package/dist/axom-patch/cli/probes/claude-late-async-terminal.mjs +5 -1
  19. package/dist/axom-patch/cli/probes/codex-terminal-output.mjs +33 -0
  20. package/dist/axom-patch/cli/probes/kimi-subagent-subwork.mjs +82 -0
  21. package/dist/axom-patch/cli/probes/pi-subagent-subwork.mjs +211 -0
  22. package/dist/axom-patch/cli/probes/zcode-subagent-subwork.mjs +257 -0
  23. package/dist/axom-patch/cli/qoder/dist/axom-acp-compat.mjs +1591 -0
  24. package/dist/axom-patch/cli/qoder/releases/1.1.75.json +360 -0
  25. package/dist/axom-patch/cli/qoder-background-terminal.md +13 -5
  26. package/dist/axom-patch/cli/qoder-foreground-output.md +14 -7
  27. package/dist/axom-patch/cli/qoder-native-acp.json +227 -0
  28. package/dist/axom-patch/cli/qoder-steering.md +20 -25
  29. package/dist/axom-patch/cli/qoder-subagent-subwork.md +17 -68
  30. package/dist/axom-patch/cli/runtime.json +39 -15
  31. package/dist/axom-patch/cli/zcode-account-limits.json +30 -0
  32. package/dist/axom-patch/cli/zcode-models.json +37 -0
  33. package/dist/axom-patch/cli/zcode-subagent-subwork.json +72 -0
  34. package/dist/cli.js +4 -9
  35. package/dist/commands/knowledge.js +47 -0
  36. package/dist/commands/update.js +65 -3
  37. package/dist/commands/workstation.js +73 -7
  38. package/dist/install/claude/commands.js +13 -13
  39. package/dist/keepers/acp/keeper.mjs +3083 -0
  40. package/dist/keepers/acp/manifest.json +9 -0
  41. package/dist/keepers/terminal/keeper.mjs +8469 -0
  42. package/dist/keepers/terminal/manifest.json +9 -0
  43. package/dist/keepers/terminal/node_modules/node-pty/lib/conpty_console_list_agent.js +25 -0
  44. package/dist/keepers/terminal/node_modules/node-pty/lib/eventEmitter2.js +47 -0
  45. package/dist/keepers/terminal/node_modules/node-pty/lib/index.js +52 -0
  46. package/dist/keepers/terminal/node_modules/node-pty/lib/interfaces.js +7 -0
  47. package/dist/keepers/terminal/node_modules/node-pty/lib/shared/conout.js +10 -0
  48. package/dist/keepers/terminal/node_modules/node-pty/lib/terminal.js +190 -0
  49. package/dist/keepers/terminal/node_modules/node-pty/lib/types.js +7 -0
  50. package/dist/keepers/terminal/node_modules/node-pty/lib/unixTerminal.js +351 -0
  51. package/dist/keepers/terminal/node_modules/node-pty/lib/utils.js +38 -0
  52. package/dist/keepers/terminal/node_modules/node-pty/lib/windowsConoutConnection.js +131 -0
  53. package/dist/keepers/terminal/node_modules/node-pty/lib/windowsPtyAgent.js +344 -0
  54. package/dist/keepers/terminal/node_modules/node-pty/lib/windowsTerminal.js +209 -0
  55. package/dist/keepers/terminal/node_modules/node-pty/lib/worker/conoutSocketWorker.js +22 -0
  56. package/dist/keepers/terminal/node_modules/node-pty/package.json +66 -0
  57. package/dist/keepers/terminal/node_modules/node-pty/prebuilds/darwin-arm64/pty.node +0 -0
  58. package/dist/keepers/terminal/node_modules/node-pty/prebuilds/darwin-arm64/spawn-helper +0 -0
  59. package/dist/keepers/terminal/node_modules/node-pty/prebuilds/darwin-x64/pty.node +0 -0
  60. package/dist/keepers/terminal/node_modules/node-pty/prebuilds/darwin-x64/spawn-helper +0 -0
  61. package/dist/keepers/terminal/node_modules/node-pty/prebuilds/linux-arm64/pty.node +0 -0
  62. package/dist/keepers/terminal/node_modules/node-pty/prebuilds/linux-x64/pty.node +0 -0
  63. package/dist/mcp.js +1 -1
  64. package/dist/native/darwin-arm64/axom-quic +0 -0
  65. package/dist/test/keeperKit.js +67 -0
  66. package/dist/workstation/acp/acpAgents.js +2192 -636
  67. package/dist/workstation/acp/acpClient.js +84 -495
  68. package/dist/workstation/acp/adoption.js +10 -396
  69. package/dist/workstation/acp/binaryInstall.js +100 -27
  70. package/dist/workstation/acp/builds/gc.js +245 -0
  71. package/dist/workstation/acp/builds/install/binary.js +28 -0
  72. package/dist/workstation/acp/builds/install/npm.js +324 -0
  73. package/dist/workstation/acp/builds/install/uv.js +254 -0
  74. package/dist/workstation/acp/builds/migrate.js +195 -0
  75. package/dist/workstation/acp/builds/model.js +128 -0
  76. package/dist/workstation/acp/builds/nodeGate.js +100 -0
  77. package/dist/workstation/acp/builds/store.js +352 -0
  78. package/dist/workstation/acp/commandTableProbe.js +68 -0
  79. package/dist/workstation/acp/configOptions.js +103 -1
  80. package/dist/workstation/acp/envSecrets.js +27 -0
  81. package/dist/workstation/acp/initializeResponse.js +290 -0
  82. package/dist/workstation/acp/installErrors.js +12 -2
  83. package/dist/workstation/acp/installProbe.js +1 -1
  84. package/dist/workstation/acp/managedPatches.js +239 -159
  85. package/dist/workstation/acp/managedRuntime.js +314 -0
  86. package/dist/workstation/acp/outbox.js +330 -0
  87. package/dist/workstation/acp/patchAssets.js +60 -3
  88. package/dist/workstation/acp/permission.js +42 -10
  89. package/dist/workstation/acp/processor/checkpoint.js +78 -0
  90. package/dist/workstation/acp/processor/commands.js +35 -0
  91. package/dist/workstation/acp/processor/control.js +282 -0
  92. package/dist/workstation/acp/processor/emit.js +26 -0
  93. package/dist/workstation/acp/processor/jsonrpc.js +172 -0
  94. package/dist/workstation/acp/processor/memoryLog.js +49 -0
  95. package/dist/workstation/acp/processor/origins.js +48 -0
  96. package/dist/workstation/acp/processor/output.js +67 -0
  97. package/dist/workstation/acp/processor/projection.js +42 -0
  98. package/dist/workstation/acp/processor/record.js +128 -0
  99. package/dist/workstation/acp/processor/runtime.js +105 -0
  100. package/dist/workstation/acp/processor/sdkSchemas.js +61 -0
  101. package/dist/workstation/acp/processor/session.js +496 -0
  102. package/dist/workstation/acp/processor/state.js +67 -0
  103. package/dist/workstation/acp/processor/step.js +442 -0
  104. package/dist/workstation/acp/processor/test/fixtureLog.js +58 -0
  105. package/dist/workstation/acp/processor/test/harness.js +120 -0
  106. package/dist/workstation/acp/processor/test/largeIdEcho.js +50 -0
  107. package/dist/workstation/acp/processor/test/rawAgent.js +70 -0
  108. package/dist/workstation/acp/processor/transport/generations.js +76 -0
  109. package/dist/workstation/acp/processor/transport/keeper.js +130 -0
  110. package/dist/workstation/acp/processor/transport/local.js +269 -0
  111. package/dist/workstation/acp/processor/transport/types.js +78 -0
  112. package/dist/workstation/acp/processor/updates.js +390 -0
  113. package/dist/workstation/acp/processorSession.js +1348 -0
  114. package/dist/workstation/acp/registry/catalog.js +189 -41
  115. package/dist/workstation/acp/registry/launch.js +76 -119
  116. package/dist/workstation/acp/sessionUpdateMapper.js +18 -7
  117. package/dist/workstation/acp/steering.js +58 -0
  118. package/dist/workstation/acp/switching.js +111 -0
  119. package/dist/workstation/acp/terminalLogin.js +16 -42
  120. package/dist/workstation/acp/terminalLoginSpec.js +41 -0
  121. package/dist/workstation/acp/test/adapterFixtures.js +256 -0
  122. package/dist/workstation/acp/test/daemonLife.js +106 -0
  123. package/dist/workstation/acp/test/fakeAgent.js +13 -0
  124. package/dist/workstation/acp/test/keeperWorld.js +295 -0
  125. package/dist/workstation/acp/test/memoryOutbox.js +55 -0
  126. package/dist/workstation/acp/test/raw/fixtures.js +196 -0
  127. package/dist/workstation/acp/test/raw/sanitize.js +344 -0
  128. package/dist/workstation/acp/test/raw/sources.js +135 -0
  129. package/dist/workstation/acp/test/realAdapter.js +331 -0
  130. package/dist/workstation/acp/test/scriptAgent.js +214 -0
  131. package/dist/workstation/acp/test/sessionFrames.js +42 -0
  132. package/dist/workstation/acp/timedCommand.js +4 -2
  133. package/dist/workstation/acp/uvxInstall.js +72 -122
  134. package/dist/workstation/agentFlow.js +103 -0
  135. package/dist/workstation/agentSessions.js +1289 -544
  136. package/dist/workstation/channels/agent.js +7 -5
  137. package/dist/workstation/channels/computer.js +69 -21
  138. package/dist/workstation/channels/computerExecs.js +200 -0
  139. package/dist/workstation/channels/management.js +172 -8
  140. package/dist/workstation/channels/url.js +1 -1
  141. package/dist/workstation/config.js +16 -4
  142. package/dist/workstation/connection/encryptedChannel.js +4 -1
  143. package/dist/workstation/control/bindingStore.js +200 -0
  144. package/dist/workstation/control/commands.js +124 -0
  145. package/dist/workstation/control/daemonIdentity.js +53 -0
  146. package/dist/workstation/control/enrollment.js +106 -0
  147. package/dist/workstation/control/inspect.js +71 -0
  148. package/dist/workstation/control/locks.js +63 -0
  149. package/dist/workstation/control/protocol.js +40 -0
  150. package/dist/workstation/control/restartGuard.js +46 -0
  151. package/dist/workstation/control/runtimeClient.js +97 -0
  152. package/dist/workstation/control/runtimeHost.js +148 -0
  153. package/dist/workstation/control/serverIdentity.js +35 -0
  154. package/dist/workstation/daemon.js +393 -12
  155. package/dist/workstation/daemonctl.js +103 -14
  156. package/dist/workstation/desktopControl/audio.js +99 -0
  157. package/dist/workstation/desktopControl/capabilities.js +92 -0
  158. package/dist/workstation/desktopControl/captureModes.js +9 -0
  159. package/dist/workstation/desktopControl/captureTargetLabel.js +9 -0
  160. package/dist/workstation/desktopControl/deviceSockets.js +246 -0
  161. package/dist/workstation/desktopControl/helperProcess.js +353 -0
  162. package/dist/workstation/desktopControl/lobby.js +129 -0
  163. package/dist/workstation/desktopControl/macProvider.js +155 -0
  164. package/dist/workstation/desktopControl/media.js +331 -0
  165. package/dist/workstation/desktopControl/nativeProvider.js +1018 -0
  166. package/dist/workstation/desktopControl/networkEvidence.js +43 -0
  167. package/dist/workstation/desktopControl/overview.js +58 -0
  168. package/dist/workstation/desktopControl/protocol.js +302 -0
  169. package/dist/workstation/desktopControl/quicMedia.js +240 -0
  170. package/dist/workstation/desktopControl/rateController.js +226 -0
  171. package/dist/workstation/desktopControl/supervisor.js +1681 -0
  172. package/dist/workstation/desktopControl/transport.js +27 -0
  173. package/dist/workstation/desktopControl/wallpaper.js +78 -0
  174. package/dist/workstation/exec/attach.js +126 -0
  175. package/dist/workstation/exec/command.js +40 -0
  176. package/dist/workstation/exec/durable.js +517 -0
  177. package/dist/workstation/exec/fallback.js +106 -0
  178. package/dist/workstation/exec/policy.js +837 -0
  179. package/dist/workstation/exec/shaping.js +211 -0
  180. package/dist/workstation/exec.js +3 -112
  181. package/dist/workstation/files/adapters/computerFileFrames.js +51 -0
  182. package/dist/workstation/files/adapters/toolRequests.js +121 -0
  183. package/dist/workstation/files/client.js +455 -0
  184. package/dist/workstation/files/credentials.js +142 -0
  185. package/dist/workstation/files/errors.js +28 -0
  186. package/dist/workstation/files/fence.js +350 -0
  187. package/dist/workstation/files/find/fileIndex.js +153 -0
  188. package/dist/workstation/files/find/fuzzy.js +155 -0
  189. package/dist/workstation/files/host.js +122 -0
  190. package/dist/workstation/files/hostProtocol.js +2 -0
  191. package/dist/workstation/files/media/cache.js +620 -0
  192. package/dist/workstation/files/media/encoding.js +308 -0
  193. package/dist/workstation/files/media/estimator.js +100 -0
  194. package/dist/workstation/files/media/media.js +325 -0
  195. package/dist/workstation/files/media/packedAudio.js +23 -0
  196. package/dist/workstation/files/media/scheduler.js +551 -0
  197. package/dist/workstation/files/media/types.js +5 -0
  198. package/dist/workstation/files/scope.js +45 -0
  199. package/dist/workstation/files/structured.js +284 -0
  200. package/dist/workstation/{fileOps.js → files/text.js} +152 -39
  201. package/dist/workstation/files/uploads.js +351 -0
  202. package/dist/workstation/journal.js +65 -236
  203. package/dist/workstation/keepers/acp/client.js +101 -0
  204. package/dist/workstation/keepers/acp/envelope.js +62 -0
  205. package/dist/workstation/keepers/acp/host.js +419 -0
  206. package/dist/workstation/keepers/acp/main.js +18 -0
  207. package/dist/workstation/keepers/acp/memoryStream.js +121 -0
  208. package/dist/workstation/keepers/acp/params.js +137 -0
  209. package/dist/workstation/keepers/acp/protocol.js +66 -0
  210. package/dist/workstation/keepers/acp/recover.js +120 -0
  211. package/dist/workstation/keepers/acp/session.js +584 -0
  212. package/dist/workstation/keepers/acp/stderr.js +154 -0
  213. package/dist/workstation/keepers/kit/activity.js +103 -0
  214. package/dist/workstation/keepers/kit/bootstrap.js +164 -0
  215. package/dist/workstation/keepers/kit/buildVersion.js +51 -0
  216. package/dist/workstation/keepers/kit/bundle.js +124 -0
  217. package/dist/workstation/keepers/kit/client.js +432 -0
  218. package/dist/workstation/keepers/kit/identity.js +21 -0
  219. package/dist/workstation/keepers/kit/ipc.js +112 -0
  220. package/dist/workstation/keepers/kit/launchConfig.js +8 -0
  221. package/dist/workstation/keepers/kit/launcher.js +289 -0
  222. package/dist/workstation/keepers/kit/lock.js +123 -0
  223. package/dist/workstation/keepers/kit/orphanSweep.js +86 -0
  224. package/dist/workstation/keepers/kit/paths.js +76 -0
  225. package/dist/workstation/keepers/kit/protocol.js +43 -0
  226. package/dist/workstation/keepers/kit/server.js +518 -0
  227. package/dist/workstation/keepers/kit/streamLog.js +697 -0
  228. package/dist/workstation/keepers/legacy.js +296 -0
  229. package/dist/workstation/keepers/policy.js +576 -0
  230. package/dist/workstation/keepers/stop.js +34 -0
  231. package/dist/workstation/keepers/terminal/client.js +213 -0
  232. package/dist/workstation/keepers/terminal/exec.js +981 -0
  233. package/dist/workstation/keepers/terminal/generations.js +129 -0
  234. package/dist/workstation/keepers/terminal/keeper.js +62 -0
  235. package/dist/workstation/keepers/terminal/main.js +43 -0
  236. package/dist/workstation/keepers/terminal/orphanSweep.js +3 -0
  237. package/dist/workstation/keepers/terminal/params.js +27 -0
  238. package/dist/workstation/keepers/terminal/protocol.js +56 -0
  239. package/dist/workstation/keepers/terminal/pty.js +330 -0
  240. package/dist/workstation/managedInstall.js +57 -0
  241. package/dist/workstation/protocol/accountLimits.js +47 -0
  242. package/dist/workstation/protocol/agent.js +50 -19
  243. package/dist/workstation/protocol/common.js +30 -0
  244. package/dist/workstation/protocol/computer.js +15 -4
  245. package/dist/workstation/protocol/management.js +141 -4
  246. package/dist/workstation/protocol/runtimeDiagnostics.js +15 -0
  247. package/dist/workstation/rd/artifact.js +119 -0
  248. package/dist/workstation/rd/control.js +119 -0
  249. package/dist/workstation/rd/display.js +29 -0
  250. package/dist/workstation/rd/provider.js +222 -0
  251. package/dist/workstation/rd/sessions.js +391 -0
  252. package/dist/workstation/rd/state.js +45 -0
  253. package/dist/workstation/rd/update.js +171 -0
  254. package/dist/workstation/rd/upkeep.js +38 -0
  255. package/dist/workstation/register.js +27 -37
  256. package/dist/workstation/service/installer.js +104 -13
  257. package/dist/workstation/service/launchd.js +1 -0
  258. package/dist/workstation/storageMetrics.js +118 -0
  259. package/dist/workstation/systemMetrics.js +154 -93
  260. package/dist/workstation/toolchain.js +94 -0
  261. package/dist/workstation/tools/browser.js +28 -4
  262. package/dist/workstation/tools/client.js +9 -1
  263. package/dist/workstation/tools/info.js +50 -0
  264. package/dist/workstation/tools/keeperProcess.js +15 -5
  265. package/dist/workstation/tools/router.js +55 -0
  266. package/dist/workstation/tools/terminalRouter.js +222 -0
  267. package/dist/workstation/tools/validation.js +37 -1
  268. package/dist/workstation/update/artifact.js +2 -1
  269. package/dist/workstation/update/coordinator.js +3 -0
  270. package/dist/workstation/update/globalInstall.js +116 -0
  271. package/dist/workstation/update/npmInstaller.js +49 -23
  272. package/dist/workstation/update/ptyPreflight.js +21 -0
  273. package/dist/workstation/update/rdRollback.js +145 -0
  274. package/dist/workstation/update/runUpdate.js +3 -2
  275. package/dist/workstation/update/taskRegistry.js +105 -11
  276. package/dist/workstation/update/watchdog.js +16790 -334
  277. package/package.json +11 -8
  278. package/skills/axom/SKILL.md +11 -11
  279. package/skills/axom-definition/SKILL.md +3 -3
  280. package/skills/axom-platform/SKILL.md +3 -3
  281. package/dist/axom-patch/cli/probes/qoder-steering.mjs +0 -89
  282. package/dist/axom-patch/cli/probes/qoder-subagent-origin.mjs +0 -49
  283. package/dist/axom-patch/cli/qoder-steering.json +0 -52
  284. package/dist/axom-patch/cli/qoder-subagent-origin.json +0 -73
  285. package/dist/commands/guide.js +0 -38
  286. package/dist/workstation/acp/acpSession.js +0 -1475
  287. package/dist/workstation/acp/contract.js +0 -499
  288. package/dist/workstation/acp/delegationMcp.js +0 -24
  289. package/dist/workstation/acp/draftInterceptor.js +0 -163
  290. package/dist/workstation/acp/qoderStartup.js +0 -45
  291. package/dist/workstation/acp/sessionScope.js +0 -19
  292. package/dist/workstation/filePaths.js +0 -174
  293. package/dist/workstation/fileWorker.js +0 -81
  294. package/dist/workstation/fileWorkerClient.js +0 -121
  295. package/dist/workstation/qoderAdapter/backgroundProcess.js +0 -106
  296. package/dist/workstation/qoderAdapter/backgroundTerminal.js +0 -262
  297. package/dist/workstation/qoderAdapter/completedContinuation.js +0 -139
  298. package/dist/workstation/qoderAdapter/contract.js +0 -78
  299. package/dist/workstation/qoderAdapter/foregroundOutput.js +0 -30
  300. package/dist/workstation/qoderAdapter/history.js +0 -87
  301. package/dist/workstation/qoderAdapter/identityIndex.js +0 -191
  302. package/dist/workstation/qoderAdapter/inputQueue.js +0 -42
  303. package/dist/workstation/qoderAdapter/main.js +0 -93
  304. package/dist/workstation/qoderAdapter/mapper.js +0 -117
  305. package/dist/workstation/qoderAdapter/observerPlugin.js +0 -245
  306. package/dist/workstation/qoderAdapter/observerReceiver.js +0 -187
  307. package/dist/workstation/qoderAdapter/permissions.js +0 -61
  308. package/dist/workstation/qoderAdapter/profile.js +0 -93
  309. package/dist/workstation/qoderAdapter/sdkDriver.js +0 -49
  310. package/dist/workstation/qoderAdapter/session.js +0 -319
  311. package/dist/workstation/qoderAdapter/subagentMeta.js +0 -28
  312. package/dist/workstation/qoderAdapter/subwork.js +0 -77
  313. package/dist/workstation/qoderAdapter/taskControl.js +0 -38
  314. package/dist/workstation/qoderAdapter/taskEvent.js +0 -26
  315. package/dist/workstation/qoderAdapter/taskLedger.js +0 -163
  316. package/dist/workstation/qoderAdapter/usage.js +0 -37
  317. package/dist/workstation/tools/core.js +0 -50
  318. package/dist/workstation/tools/files.js +0 -525
  319. package/dist/workstation/tools/terminal.js +0 -214
  320. /package/dist/workstation/{qoderAdapter/types.js → desktopControl/provider.js} +0 -0
@@ -1,94 +1,16 @@
1
1
  /**
2
- * "Adoption" — a user picking up a native agent session that already exists on this machine and
3
- * having its history replayed into Axom's timeline. Two ACP methods, both **optional on the
4
- * agent** and gated by `AcpConnection.capabilities` (Task 3): {@link listNativeSessions} calls
5
- * `session/list`, gated on `capabilities.list`; {@link replayNativeSession} calls `session/load`,
6
- * gated on `capabilities.loadSession`. Both degrade to "nothing to show" rather than an error when
7
- * unsupported — see the doc comment on each for why, and the deleted `bridges/types.ts`'s
8
- * `AgentBridge.listNativeSessions`/`BridgeSession.backfill` KDoc for the contract this carries
9
- * forward verbatim ("the directory hides the grouping, not an error").
10
- *
11
- * ## `session/resume` is deliberately NOT used here — read this before "fixing" it back
12
- *
13
- * The plan text that spawned this task says "`session/resume(replayFrom)`". Both do not exist in
14
- * `@agentclientprotocol/sdk@1.4.0`'s stable v1 entry point: `ResumeSessionRequest` has no
15
- * `replayFrom` field (that name only appears under `@agentclientprotocol/sdk/experimental/v2`,
16
- * which Global Constraint 1 forbids importing), and — more fundamentally — `resumeSession`'s own
17
- * doc comment in `dist/acp.d.ts` says it "Resumes an existing session **without** replaying the
18
- * message history (**unlike** `session/load`)". `session/load` (`loadSession`, gated by the
19
- * top-level `AgentCapabilities.loadSession: boolean`, not `sessionCapabilities`) is the ACP v1
20
- * method whose own doc comment says the agent should "Stream the entire conversation history back
21
- * to the client via notifications" — i.e. it is the actual replay mechanism this file needs.
22
- * `session/resume` exists for a client that already has a session's history cached elsewhere and
23
- * wants to keep working without paying for a redundant replay; Axom has never seen an adopted
24
- * session's history before, so that case never applies here.
25
- *
26
- * Verified both target adapters actually support the method this file calls: `claude-agent-acp`
27
- * 0.70.0 and `codex-acp` 1.6.2 both declare `agentCapabilities.loadSession: true` and both
28
- * implement `session/load` (`.superpowers/research/acp-sdk-registry.md`, "VERIFIED" section).
29
- *
30
- * ## Observing the replayed `session/update` notifications
31
- *
32
- * Unlike `session/new` (Task 4's `AcpSession`, built on the SDK's `ClientContext.buildSession(...)`
33
- * convenience wrapper), the SDK has no `SessionBuilder`-equivalent helper for `session/load` — the
34
- * only public, supported way to see raw `session/update` notifications outside of that helper is a
35
- * `ClientApp`-level `onNotification(methods.client.session.update, ...)` handler (the pattern the
36
- * SDK's own `acp.test.js` uses throughout for exactly this). That has to be registered before
37
- * `ClientApp.connect()` copies its handler table (`acpClient.ts`'s `ConnectAgentOptions.configure`
38
- * doc comment; `permission.ts`'s file doc comment says the same for `session/request_permission`).
39
- * {@link createReplayCapture}/{@link ReplayCapture.register} exist for that: a caller passes
40
- * `capture.register` into `connectAgent`'s `configure` hook — composable with `permission.ts`'s
41
- * `registerPermissionHandlers` in the same hook, since `configure` is one function a caller can do
42
- * several registrations inside of — and later, once the connection exists, passes the *same*
43
- * `capture` into {@link replayNativeSession}. The registered handler sees *every* `session/update`
44
- * notification on the connection, not just the one being replayed (verified against
45
- * `SessionUpdateRouter.handleMessage`, which always returns `Handled.no(message)` — it never consumes
46
- * the message, so dispatch keeps walking the handler chain to whatever else is registered, including
47
- * the SDK's own internal `session/new`-scoped router for a live `AcpSession` on the *same* connection).
48
- * That is exactly the axis review found this file getting wrong in fix round 1: a stray comment here
49
- * used to say this "never interferes with a live `AcpSession` on some other connection" — true, but
50
- * irrelevant, since the real conflict is a live `AcpSession` on the *same* connection (the shape a
51
- * caller composing this with `registerPermissionHandlers` in one `configure` hook produces routinely).
52
- * Without gating, the handler above would buffer *every* session's updates forever — only
53
- * {@link ReplayCapture.take} ever releases a buffered entry, and it is only ever called for the one
54
- * `sessionId` being replayed, so any other session's notifications arriving on this connection (most
55
- * concretely: a live `AcpSession` opened on the same connection right after adoption, which Task 8
56
- * needs) would accumulate unbounded for the life of the connection. {@link ReplayCapture.arm} exists
57
- * to close that: the handler now only buffers a `sessionId` that has been armed, {@link
58
- * replayNativeSession} arms its target *before* sending `session/load`, and `take()` disarms on its
59
- * way out — so the buffer's upper bound is whatever is currently being replayed, never every session
60
- * this connection has ever seen an update for.
61
- *
62
- * ## Replay goes through the same mapping and folding the live path uses — reimplemented, not shared
63
- *
64
- * {@link replayNativeSession} maps every replayed `SessionUpdate` through the exact same
65
- * `mapSessionUpdate` (`sessionUpdateMapper.ts`) the live path (`AcpSession`) uses, and every mapped
66
- * event through the same `validateWireEvent` (`validate.ts`) outbound check — so it can never
67
- * emit anything the server can't decode, and (per `mapSessionUpdate`'s own contract) can never emit
68
- * `final_result`. What it does NOT reuse is `AcpSession`'s own `agent_message` synthesis /
69
- * synthesis: that lives in `AcpSession` as push-based, event-sink-driven instance state (see its
70
- * class doc comment), a different execution shape than this file's pull-based `AsyncGenerator` over
71
- * an already-complete update list. Task 4's report flagged this by name: "the replay path needs its
72
- * own equivalent folding and message-completion logic — do not assume the live path's state machine
73
- * is doing it for you." {@link foldReplayEvents} is that equivalent, reimplemented as a single pass
74
- * over a finished array instead of instance fields mutated across live callbacks.
75
- *
76
- * **The `user_message` half of that duplication is gone.** This file used to carry its own copy of
77
- * `AcpSession.foldUserMessageChunk`, and the two had already drifted: replay flushed a buffered user
78
- * message on *any* intervening event, the live path only on a different `messageId` or turn end, so
79
- * the same transcript produced a different number of cards depending on which path replayed it.
80
- * Phase 7 moved the fold to `CoworkEngine`, which already buffered `agent_message_chunk` by exactly
81
- * the ACP rule that covers all three chunk kinds (`ContentChunk`, `types.gen.d.ts:3456`).
82
- * `user_message_chunk` now passes through here untouched, and `ExternalAgentEventApplier`'s
83
- * `onBackfillDone` is what closes the last buffered message.
2
+ * "Adoption" — the native agent sessions that already exist on this machine, as an agent lists them, and deleting them.
3
+ * Short-lived connections only (`connectAgent`); both ACP methods are **optional on the agent** and gated by
4
+ * `AcpConnection.capabilities`: {@link listNativeSessions} calls `session/list` (gated on `capabilities.list`),
5
+ * {@link deleteNativeSessions} calls `session/delete` (gated on `capabilities.delete`). Both degrade to "nothing to show" /
6
+ * `unsupported` rather than an error when the method is not advertised — carried forward verbatim from the deleted
7
+ * `bridges/types.ts`'s `AgentBridge.listNativeSessions` contract ("the directory hides the grouping, not an error").
8
+ *
9
+ * Replaying an adopted session's history (`session/load`) is the processor's (`processor/updates.ts`, the history window
10
+ * the daemon opens with `agent_session_open{mode:'load'}`); the replay helpers this file used to carry were deleted with the
11
+ * old live path (Task 29).
84
12
  */
85
- import { randomUUID } from 'node:crypto';
86
13
  import { methods } from '@agentclientprotocol/sdk';
87
- import { validateWireEvent } from './validate.js';
88
- import { SessionMapperScopes, scopeKey } from './sessionScope.js';
89
- import { mapSessionUpdate } from './sessionUpdateMapper.js';
90
- import { newSessionMetaFor } from './activityReporting.js';
91
- import { buildSessionMcpServers } from './delegationMcp.js';
92
14
  /** `NativeSessionInfo.title` is truncated to this many characters — mirrors the deleted `claudeCode/adoption.ts`'s display-safety guard (`TITLE_MAX_LENGTH`). */
93
15
  const TITLE_MAX_LENGTH = 80;
94
16
  /** `SessionInfo.updatedAt` is an ISO 8601 string or `null`/absent; missing or unparseable sorts as oldest (0) rather than throwing. */
@@ -144,311 +66,3 @@ export async function listNativeSessions(connection, query) {
144
66
  .sort((a, b) => b.updatedAtEpochMs - a.updatedAtEpochMs);
145
67
  return response.nextCursor != null ? { sessions, nextCursor: response.nextCursor } : { sessions };
146
68
  }
147
- export function createReplayCapture() {
148
- const armed = new Set();
149
- const buffered = new Map();
150
- const listeners = new Map();
151
- const takeScoped = (sessionId) => {
152
- armed.delete(sessionId);
153
- const list = buffered.get(sessionId) ?? [];
154
- buffered.delete(sessionId);
155
- return list;
156
- };
157
- const capture = (sessionId, notification) => {
158
- if (!armed.has(sessionId))
159
- return false;
160
- const list = buffered.get(sessionId) ?? [];
161
- list.push(notification);
162
- buffered.set(sessionId, list);
163
- return true;
164
- };
165
- return {
166
- capture,
167
- takeScoped,
168
- register(app) {
169
- app.onNotification(methods.client.session.update, (ctx) => {
170
- // Armed wins over listening: while a session/load replay is in flight, its history must land
171
- // in the buffer rather than in a live session's read loop — see `listen`'s doc comment.
172
- if (capture(ctx.params.sessionId, ctx.params))
173
- return;
174
- // Neither armed nor listened to: dropped, so this handler cannot accumulate every session
175
- // the connection has ever seen an update for (see the file doc comment).
176
- listeners.get(ctx.params.sessionId)?.(ctx.params.update);
177
- });
178
- },
179
- arm(sessionId) {
180
- armed.add(sessionId);
181
- },
182
- take(sessionId) {
183
- return takeScoped(sessionId).map(({ update }) => update);
184
- },
185
- count(sessionId) {
186
- return buffered.get(sessionId)?.length ?? 0;
187
- },
188
- listen(sessionId, sink) {
189
- listeners.set(sessionId, sink);
190
- return () => {
191
- // Only clear our own entry: a later listen() for the same sessionId may already have
192
- // replaced it, and unsubscribing then would silence the session that is actually running.
193
- if (listeners.get(sessionId) === sink)
194
- listeners.delete(sessionId);
195
- };
196
- },
197
- };
198
- }
199
- /** `payload[key]` narrowed to `string`, or `null` — same helper `acpSession.ts` uses for the identical reason (`payload` is `Record<string, unknown>` by construction). */
200
- function stringField(payload, key) {
201
- const value = payload[key];
202
- return typeof value === 'string' ? value : null;
203
- }
204
- /**
205
- * Map a completed list of replayed `SessionUpdate`s to wire events, applying the same
206
- * message-completion synthesis {@link AcpSession} applies to the live stream — see the file doc
207
- * comment for why this is a reimplementation, not a shared call, and `acpSession.ts`'s
208
- * `trackMessageBoundary`/`flushMessageCompleted` for the rule being mirrored:
209
- *
210
- * - `agent_message` is synthesized whenever the streaming `agent_message_chunk`/`agent_thought_chunk`
211
- * `messageId` changes, and once more at the very end of the list (there is no live "turn end"
212
- * signal to hang this off during replay — a whole adopted session's history has no `PromptResponse`
213
- * in it at all — so "end of the replayed list" is this function's equivalent of `AcpSession.finishTurn`'s
214
- * unconditional turn-end flush).
215
- * - A `user_message_chunk` still forces that synthesis: a user turn beginning means whatever the
216
- * assistant was streaming has necessarily already ended. The chunk itself is emitted verbatim —
217
- * folding it is the server's job now (see the file doc comment).
218
- *
219
- * Exported directly (not just through {@link replayNativeSession}) so its synthesis behavior can be
220
- * pinned by unit tests with no process spawning involved — the process-spawning tests are reserved
221
- * for what only a real subprocess can prove (capability gating, the actual RPC shape, ordering
222
- * against the real `session/load` response).
223
- */
224
- export function foldReplayEvents(updates) {
225
- return foldScopedReplayEvents(updates.map((update) => ({ sessionId: 'root', update })), 'root');
226
- }
227
- /** Fold each origin independently, without reordering interleaved native notifications. */
228
- export function foldScopedReplayEvents(updates, rootNativeSessionId) {
229
- const events = [];
230
- const scopes = new SessionMapperScopes();
231
- const rootRuntimeId = randomUUID();
232
- const messages = new Map();
233
- const emit = (event, nativeSessionId) => {
234
- events.push(nativeSessionId === rootNativeSessionId ? event : { ...event, acpSessionId: nativeSessionId });
235
- };
236
- const flush = (key) => {
237
- const message = messages.get(key);
238
- if (!message)
239
- return;
240
- emit({ eventName: 'agent_message', payload: { messageId: message.messageId ?? 'replay' } }, message.nativeSessionId);
241
- messages.delete(key);
242
- };
243
- for (const { sessionId: nativeSessionId, update } of updates) {
244
- const origin = { rootRuntimeId, nativeSessionId };
245
- const key = scopeKey(origin);
246
- for (const event of mapSessionUpdate(update, scopes.get(origin))) {
247
- if (event.eventName === 'user_message_chunk')
248
- flush(key);
249
- if (event.eventName === 'agent_message_chunk' || event.eventName === 'agent_thought_chunk') {
250
- const messageId = stringField(event.payload, 'messageId');
251
- const previous = messages.get(key);
252
- if (previous && messageId !== null && previous.messageId !== null && messageId !== previous.messageId)
253
- flush(key);
254
- messages.set(key, { nativeSessionId, messageId: messageId ?? messages.get(key)?.messageId ?? null });
255
- }
256
- emit(event, nativeSessionId);
257
- }
258
- }
259
- for (const key of messages.keys())
260
- flush(key);
261
- scopes.clear();
262
- return events;
263
- }
264
- /**
265
- * Wait until the `session/update` notifications an agent sent *before* answering a request have
266
- * actually been **dispatched**, which is **not** implied by that request having responded.
267
- *
268
- * The SDK handles the two message kinds on different schedules (`jsonrpc.js`'s
269
- * `receiveWireMessage`): a response is applied **synchronously** via `handleResponse` the moment the
270
- * reader pulls it off the stream, while a notification is handed to `processIncomingMessage`, which
271
- * is *not* awaited by the read loop and which awaits each registered handler in turn. So even though
272
- * the agent writes every `session/update` before its response — and the reader therefore reads them
273
- * first — the response's `await` can resume before a single one of our handlers has run. Measured
274
- * directly on both request paths that have this shape:
275
- *
276
- * - `session/load` ({@link replayNativeSession}): the order observed was `response`, then the
277
- * updates, so `take()` on the spot returned a partial history, or none at all.
278
- * - `session/prompt` (`acpSession.ts`'s `AcpSession.attach`): the turn's terminal `stop` message was
279
- * enqueued **ahead of** the tail of the same turn's deltas — `agent_message` and the
280
- * `TurnResult` landing mid-stream, with the remaining text arriving as orphans after the item the
281
- * server had already closed.
282
- *
283
- * The difference between "works" and "reorders" is nothing but how many handlers happen to be
284
- * registered on the connection — which is why the same code looked correct until the manager wired
285
- * permission handlers onto the same `ClientApp`.
286
- *
287
- * The one guarantee that survives is ordering *into* the dispatcher: by the time the response is
288
- * applied, every preceding notification's dispatch has been **started**. This waits for a macrotask
289
- * boundary, which drains a chain of synchronous handlers' microtask continuations, and keeps waiting
290
- * while `count()` is still growing.
291
- *
292
- * ## This is a heuristic. It is bounded **and** it truncates — three ways, all measured
293
- *
294
- * Nothing enforces the premise it rests on, so read these as live failure modes rather than as
295
- * theoretical corners:
296
- *
297
- * | Condition | Measured outcome |
298
- * |---|---|
299
- * | Any handler on the chain awaits a **macrotask** (even `setTimeout(…, 0)`) | 6 updates → **0** replayed |
300
- * | The agent answers **before** it has finished streaming | 6 updates → **4** replayed |
301
- * | The stream outlasts `maxRounds` | 3000 updates → **2086** replayed |
302
- *
303
- * The first is why "every handler on this chain is synchronous" is a **prerequisite**, and why it is
304
- * written down at the place that actually establishes it — `acpAgents.ts`'s `AcpAgents.open`
305
- * `configure` hook, the one site where handlers get registered — rather than only here. A function
306
- * cannot enforce a property of a registration it never sees.
307
- *
308
- * The second is outside this function's reach entirely: an agent that responds mid-stream has broken
309
- * the ordering guarantee this is built on, and no amount of waiting recovers it.
310
- *
311
- * The third is `maxRounds`, the backstop against an agent that keeps streaming forever — a wait with
312
- * no exit would hang the whole open. It is reachable in practice, so hitting it is **logged** rather
313
- * than returned quietly: that diagnostic is the whole line between "bounded degradation" and
314
- * "invisible data loss", and it is the only one of the three this function can see happening to
315
- * itself.
316
- *
317
- * `count` is supplied by the caller rather than read off a `ReplayCapture` because the two callers
318
- * count different things: the replay path counts buffered updates for one armed `sessionId`, the
319
- * live path counts updates delivered into an attached session's queue.
320
- */
321
- export async function settleUpdates(sessionId, count, maxRounds = 10) {
322
- let previous = -1;
323
- for (let round = 0; round < maxRounds; round += 1) {
324
- const before = count();
325
- // setImmediate, not setTimeout(0): it runs in the check phase, after the poll phase has drained
326
- // whatever the transport already had readable, so one turn covers both the microtasks and any
327
- // further messages sitting in the socket buffer.
328
- await new Promise((resolve) => setImmediate(resolve));
329
- const after = count();
330
- if (after === before && after === previous)
331
- return;
332
- previous = before;
333
- }
334
- // Falling out of the loop is not "settled" — it is "gave up while the agent was still streaming".
335
- // Whatever arrives from here on may be reordered against the turn/replay boundary this call was
336
- // holding, or dropped outright. Never silent: a session that quietly loses the tail of its history
337
- // is indistinguishable from an agent that had no history to give.
338
- console.error(`[acp] session/update stream for '${sessionId}' was still growing after ${maxRounds} settle rounds ` +
339
- `(${count()} updates so far) — continuing anyway; later updates may be reordered or lost.`);
340
- }
341
- /**
342
- * Replay one native session's history as wire events via `session/load` — see the file doc comment
343
- * for why `session/load`, not `session/resume`.
344
- *
345
- * When the agent doesn't advertise `agentCapabilities.loadSession` (`connection.capabilities.loadSession`),
346
- * this yields nothing **without ever calling `session/load`** — same "hidden grouping, not an error"
347
- * contract as {@link listNativeSessions}, applied to the capability that actually gates replay.
348
- *
349
- * Every yielded event has already gone through {@link foldReplayEvents} (same mapping the live path
350
- * uses, plus the same `agent_message` synthesis) and `validateWireEvent` (same outbound check the live path's
351
- * `permission_request` events go through) — an event that fails validation is dropped with a
352
- * diagnostic, never thrown, matching `validate.ts`'s own "drop + diagnostic, never an
353
- * exception" design. `mapSessionUpdate` never produces `final_result` (Task 4's contract, unchanged
354
- * here since this function adds no new event-producing path of its own) and `foldReplayEvents` adds
355
- * no new event names beyond what `mapSessionUpdate` and this file's own `agent_message` synthesis
356
- * already produce — both already appear in the server's understood vocabulary
357
- * (`sessionUpdateMapper.test.ts`'s `SERVER_UNDERSTOOD_EVENT_NAMES`), so nothing here needs to extend
358
- * it.
359
- *
360
- * ## `session/load` also activates the session, not just replays it — and Task 8 relies on that
361
- *
362
- * The `session/load` request below sends the current MCP list. That is not incidental — `session/load`
363
- * is not a read-only "give me the history" call: per its own spec, the agent restores this
364
- * `sessionId` as its **current** session, the same way `session/new` would, just with history
365
- * attached instead of starting empty.
366
- *
367
- * Task 8 resolved the fork this once handed forward: **Axom keeps prompting on
368
- * `params.nativeSessionId`, the session `session/load` just activated**, rather than opening a fresh
369
- * `session/new` and treating the replay as read-only history. `agentSessions.ts` receives a
370
- * `nativeSessionId` on reconnect as well as on adoption (the server persists it and hands it back),
371
- * so "open a new session and replay read-only" would silently drop the agent's own context on every
372
- * single reconnect — a regression against the deleted Claude Code bridge, which really did resume.
373
- * `AcpSession.attach` (`acpSession.ts`) is what that costs: the SDK's `ActiveSession` has a private
374
- * constructor reachable only through `SessionBuilder.start()`, i.e. only through `session/new`, so
375
- * driving an already-loaded `sessionId` means hand-building the same small `DrivenSession` surface
376
- * around raw `session/prompt` / `session/cancel` requests and {@link ReplayCapture.listen}.
377
- *
378
- * Both paths use `buildSessionMcpServers`, so an adopted or reconnected session receives the same
379
- * user servers and a freshly issued delegation lease when managed delegation is enabled.
380
- *
381
- * ## Also opts claude into activity reporting, the same as `session/new` — closing a real gap, not a cosmetic one
382
- *
383
- * `AcpSession.open`'s `session/new` request carries `_meta.claudeCode.emitRawSDKMessages`
384
- * (`newSessionMetaFor`, `activityReporting.ts`) so claude's collector pushes
385
- * `extNotification("_claude/sdkMessage", …)` back on the connection. `session/load` used to send no
386
- * `_meta` at all, so every session reached through this function — every adoption, and every
387
- * reconnect after a daemon restart, since a fresh child process's session table is always empty and
388
- * therefore always takes the `createSession` branch of `getOrCreateSession` — permanently lost
389
- * activity reporting the moment it was (re)loaded, while `agent_opened.reportsActivity` kept
390
- * claiming `true`. Verified directly against the installed `claude-agent-acp@0.79.0` adapter
391
- * (`dist/acp-agent.js`): `loadSession` → `getOrCreateSession` → (no matching in-process session, the
392
- * case that always applies to a freshly spawned agent) → `createSession({ …, _meta: params._meta },
393
- * { resume: params.sessionId })`, and `createSession` reads
394
- * `sessionMeta?.claudeCode?.emitRawSDKMessages` off that same `_meta` exactly as `session/new` does.
395
- * `newSessionMetaFor` is reused verbatim rather than re-implementing the same agent-name check here,
396
- * so there is exactly one place that decides which agents opt in.
397
- */
398
- export async function* replayNativeSession(connection, capture, params) {
399
- if (!connection.capabilities.loadSession)
400
- return;
401
- // Exclusive ownership must precede every mutation of the shared capture/known-session state.
402
- // A rejected concurrent load must not consume or disarm the active owner's buffer.
403
- const releaseCapture = connection.captureSessionUpdates((notification) => capture.capture(params.nativeSessionId, notification));
404
- // Arm before requesting — the agent may start sending session/update notifications for this
405
- // sessionId as soon as it receives the request, and the capture's handler drops anything for a
406
- // sessionId that isn't armed yet (see ReplayCapture.arm's doc comment).
407
- capture.arm(params.nativeSessionId);
408
- // The transport diverts updates for a session it does not know is handled here, because the SDK's
409
- // router would otherwise drop them on the floor. During a replay this capture *is* the handler,
410
- // so the claim has to be made explicitly — and withdrawn in the same `finally` that disarms.
411
- const unmarkSession = connection.markSessionKnown(params.nativeSessionId);
412
- // Same opt-in `session/new` sends, on the request that actually reaches the agent for every
413
- // adoption/reconnect — see the doc comment above. `undefined` for every non-claude agent, in which
414
- // case the spread below adds nothing, so a non-claude `session/load` is byte-for-byte the request
415
- // it always sent.
416
- const meta = newSessionMetaFor(connection.agentInfo?.name);
417
- let updates;
418
- try {
419
- const mcpServers = buildSessionMcpServers(params.mcpServers, params.delegationMcp);
420
- if (params.delegationMcp)
421
- connection.redactStderrSecret(Object.values(params.delegationMcp.headers)[0]);
422
- const response = await connection.connection.agent.request(methods.agent.session.load, {
423
- sessionId: params.nativeSessionId,
424
- cwd: params.cwd,
425
- mcpServers,
426
- ...(meta ? { _meta: meta } : {}),
427
- });
428
- params.onLoaded?.(response);
429
- // The response resolving is NOT the end of the replay — see {@link settleUpdates} for the SDK
430
- // scheduling that makes `take()` on the spot return a partial history.
431
- await settleUpdates(params.nativeSessionId, () => capture.count(params.nativeSessionId));
432
- }
433
- finally {
434
- releaseCapture();
435
- unmarkSession();
436
- // In a `finally` because `arm()` has no other exit. A rejected `session/load` — a stale id the
437
- // agent has since deleted, the case `AcpAgents.startSession` catches and falls back from — used
438
- // to throw straight past `take()`, leaving the id armed and whatever had already been buffered
439
- // for it held until the connection died. That contradicts this file's own claim that "the
440
- // buffer's upper bound is whatever is currently being replayed": the fallback opens a *different*
441
- // session id, so nothing would ever come back to take this one. `take()` is both halves at once
442
- // (release the buffer, disarm), which is why the failure path calls the same function the
443
- // success path does rather than a disarm of its own.
444
- updates = capture.takeScoped(params.nativeSessionId);
445
- }
446
- for (const event of foldScopedReplayEvents(updates, params.nativeSessionId)) {
447
- const rejection = validateWireEvent(event);
448
- if (rejection) {
449
- console.error(`[acp] dropping a replayed event that fails validateWireEvent: ${rejection.reason} (session ${params.nativeSessionId})`);
450
- continue;
451
- }
452
- yield event;
453
- }
454
- }
@@ -29,13 +29,14 @@
29
29
  * but the result says so via `checksumVerified: false`. Nothing here may report an unverified
30
30
  * download as checked; that is precisely the distinction {@link InstallOutcome} exists to preserve.
31
31
  *
32
- * ## Layout: several versions can coexist
32
+ * ## Layout: several versions can coexist, and each appears whole or not at all
33
33
  *
34
- * Installs land at `<root>/<registryAgent>/<version>/`, one directory per version, the same
35
- * side-by-side layout npm's own `_npx` cache keeps for npx-distributed adapters. Picking one
36
- * installed version when several are present reuses `installProbe.ts`'s
37
- * {@link pickInstalledVersion} rather than reimplementing that rule a second time — see
38
- * {@link installedBinaryVersion}.
34
+ * Installs land at `<root>/bin/<registryAgent>/<version>/` (spec 2026-10-11 §6.2), one directory per
35
+ * version. The archive is unpacked into `<root>/.staging/<uuid>/` and only a complete tree is
36
+ * `rename(2)`d into place, so a directory under `bin/` always holds a whole install — an unpack that
37
+ * is killed half-way leaves only a staging leftover, never a half-written version that something
38
+ * would take for installed. Which version an agent *launches* is no longer chosen here: it is the
39
+ * agent's `current` build (`builds/store.ts`). {@link installedBinaryVersion} only enumerates.
39
40
  *
40
41
  * ## Unpacking without a new dependency
41
42
  *
@@ -65,12 +66,14 @@
65
66
  * `<installDir>/<basename of the archive URL>` and marked executable — that filename is exactly
66
67
  * what `BinaryTarget.cmd` refers to. See {@link isArchiveFile}.
67
68
  */
68
- import { createHash } from 'node:crypto';
69
+ import { createHash, randomUUID } from 'node:crypto';
69
70
  import { createWriteStream, promises as fs, readdirSync } from 'node:fs';
70
71
  import { tmpdir } from 'node:os';
71
72
  import path from 'node:path';
72
73
  import { Readable } from 'node:stream';
73
74
  import { pipeline } from 'node:stream/promises';
75
+ import { binaryBuildDir, inside } from './builds/model.js';
76
+ import { publishStaged } from './builds/store.js';
74
77
  import { pickInstalledVersion } from './installProbe.js';
75
78
  import { runTimedCommand } from './timedCommand.js';
76
79
  const PLATFORM_SEGMENT = {
@@ -122,12 +125,13 @@ const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
122
125
  export function installedBinaryVersion(registryAgent, root, pinned) {
123
126
  let entries;
124
127
  try {
125
- entries = readdirSync(path.join(root, registryAgent), { withFileTypes: true });
128
+ entries = readdirSync(path.join(root, 'bin', registryAgent), { withFileTypes: true });
126
129
  }
127
130
  catch {
128
131
  return undefined;
129
132
  }
130
- const versions = entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name);
133
+ // Dot-directories (`.staging`, `.trash`, editor droppings) are never versions.
134
+ const versions = entries.filter((entry) => entry.isDirectory() && !entry.name.startsWith('.')).map((entry) => entry.name);
131
135
  return pickInstalledVersion(versions, pinned);
132
136
  }
133
137
  /**
@@ -203,9 +207,40 @@ const ARCHIVE_DOWNLOAD_TIMEOUT_MS = 300_000;
203
207
  * longer than the download step right above it is already allowed to take.
204
208
  */
205
209
  const ARCHIVE_EXTRACT_TIMEOUT_MS = 300_000;
210
+ /**
211
+ * Attempts made on the *request* phase of one archive download. Registry archives are
212
+ * third-party-hosted, and the failure this retries — the initial `fetch` throwing — is the
213
+ * transient kind: an edge node that answers this minute and not the next, a resolver rotating
214
+ * between a real and a dead address. Deterministic failures (redirect policy, loops, HTTP codes)
215
+ * come back from {@link fetchArchive} as structured results and are never retried. Both attempts
216
+ * share one {@link ARCHIVE_DOWNLOAD_TIMEOUT_MS} signal, so the whole-request budget never doubles;
217
+ * undici's own ~10s connect bound keeps the added worst case small.
218
+ */
219
+ const ARCHIVE_FETCH_ATTEMPTS = 2;
220
+ /** Pause between the two fetch attempts — long enough for a flipped resolver answer, short next to a 300s budget. */
221
+ const ARCHIVE_FETCH_RETRY_PAUSE_MS = 2_000;
206
222
  function errorMessage(err) {
207
223
  return err instanceof Error ? err.message : String(err);
208
224
  }
225
+ /**
226
+ * `fetch`'s failures arrive as a bare `fetch failed` with the real reason (a connect timeout, a
227
+ * reset mid-handshake, a TLS refusal — classes that lead the diagnosis, not decorate it) one or
228
+ * two `cause` links down, and undici names them with stable machine codes (`UND_ERR_CONNECT_TIMEOUT`,
229
+ * `ECONNRESET`). This walks the chain and joins it onto the message, so a daemon log and an error
230
+ * card say what actually happened instead of leaving a one-word verdict. Observed 2026-10-14: a
231
+ * machine whose route to `dl.google.com` had died logged only "fetch failed" and cost a DNS/CDN
232
+ * investigation that `UND_ERR_CONNECT_TIMEOUT` would have named on the spot.
233
+ */
234
+ function describeFetchFailure(err) {
235
+ const parts = [errorMessage(err)];
236
+ let cause = err?.cause;
237
+ for (let depth = 0; depth < 5 && cause !== undefined && cause !== null; depth += 1) {
238
+ const named = cause;
239
+ parts.push(typeof named.code === 'string' ? named.code : typeof named.message === 'string' ? named.message : String(cause));
240
+ cause = cause.cause;
241
+ }
242
+ return [...new Set(parts)].join(' — ');
243
+ }
209
244
  function hasEntries(dir) {
210
245
  try {
211
246
  return readdirSync(dir).length > 0;
@@ -308,7 +343,7 @@ async function extractArchive(archivePath, destDir, exec, timeoutMs) {
308
343
  }
309
344
  /**
310
345
  * Fetch, verify and unpack `agent`'s binary adapter for this machine's platform triple. Never
311
- * starts the agent — the counterpart to `installNpxPackage`/`installUvTool`'s "download, don't run"
346
+ * starts the agent — the counterpart to `ensurePristine`/`installUvBuild`'s "download, don't run"
312
347
  * contract.
313
348
  *
314
349
  * ## The three outcomes the brief for this task requires, and why each looks the way it does
@@ -340,7 +375,13 @@ export async function installBinary(agent, opts) {
340
375
  return { ok: false, error: `Agent '${agent.id}' publishes no archive for ${triple ?? `${platform}-${arch}`}.` };
341
376
  }
342
377
  const version = agent.version;
343
- const installDir = path.join(opts.root, agent.id, version);
378
+ let installDir;
379
+ try {
380
+ installDir = inside(opts.root, binaryBuildDir(agent.id, version));
381
+ }
382
+ catch (err) {
383
+ return { ok: false, error: errorMessage(err) };
384
+ }
344
385
  if (hasEntries(installDir)) {
345
386
  return { ok: true, checksumVerified: false, alreadyInstalled: true, version, installDir };
346
387
  }
@@ -358,8 +399,24 @@ export async function installBinary(agent, opts) {
358
399
  // whereas a `BinaryTarget.archive` is an arbitrary third-party URL verified by an optional
359
400
  // SHA-256 the registry supplies. Different trust model, so the policy is stated here: one
360
401
  // whole-chain budget, a capped number of hops, and HTTPS at every one. See `fetchArchive` for
361
- // why following redirects is required rather than merely permitted.
362
- const fetched = await fetchArchive(target.archive, fetchImpl, AbortSignal.timeout(ARCHIVE_DOWNLOAD_TIMEOUT_MS));
402
+ // why following redirects is required rather than merely permitted. The one retry here covers
403
+ // only the transient request-phase throw ({@link ARCHIVE_FETCH_ATTEMPTS}).
404
+ const signal = AbortSignal.timeout(ARCHIVE_DOWNLOAD_TIMEOUT_MS);
405
+ let fetched;
406
+ for (let attempt = 1;; attempt += 1) {
407
+ try {
408
+ fetched = await fetchArchive(target.archive, fetchImpl, signal);
409
+ break;
410
+ }
411
+ catch (err) {
412
+ if (attempt >= ARCHIVE_FETCH_ATTEMPTS || signal.aborted) {
413
+ throw new Error(describeFetchFailure(err));
414
+ }
415
+ console.warn(`[acp] archive fetch attempt ${attempt} for '${agent.id}' failed ` +
416
+ `(${describeFetchFailure(err)}); retrying once.`);
417
+ await new Promise((resolve) => setTimeout(resolve, ARCHIVE_FETCH_RETRY_PAUSE_MS));
418
+ }
419
+ }
363
420
  if (!fetched.ok) {
364
421
  return { ok: false, error: `Failed to download adapter archive for '${agent.id}': ${fetched.error}` };
365
422
  }
@@ -374,7 +431,14 @@ export async function installBinary(agent, opts) {
374
431
  const hash = createHash('sha256');
375
432
  const source = Readable.fromWeb(response.body);
376
433
  source.on('data', (chunk) => hash.update(chunk));
377
- await pipeline(source, createWriteStream(archivePath, { mode: 0o600 }));
434
+ // A body that dies mid-stream (a reset connection, undici's "terminated") is a download
435
+ // failure like any other — reported, not thrown past the caller that owns the retry UX.
436
+ try {
437
+ await pipeline(source, createWriteStream(archivePath, { mode: 0o600 }));
438
+ }
439
+ catch (err) {
440
+ return { ok: false, error: `Failed to download adapter archive for '${agent.id}': ${describeFetchFailure(err)}` };
441
+ }
378
442
  if (target.sha256) {
379
443
  const digest = hash.digest('hex');
380
444
  if (digest.toLowerCase() !== target.sha256.toLowerCase()) {
@@ -385,23 +449,32 @@ export async function installBinary(agent, opts) {
385
449
  };
386
450
  }
387
451
  }
388
- await fs.mkdir(installDir, { recursive: true });
452
+ // Unpacked into a staging tree on the same filesystem, then renamed into place whole: nothing
453
+ // under `bin/` is ever half-written (see the module doc's layout section).
454
+ const staged = path.join(opts.root, '.staging', randomUUID());
389
455
  try {
390
- if (await isArchiveFile(archivePath, target.archive)) {
391
- await extractArchive(archivePath, installDir, opts.exec ?? runCommand, ARCHIVE_EXTRACT_TIMEOUT_MS);
456
+ await fs.mkdir(staged, { recursive: true });
457
+ try {
458
+ if (await isArchiveFile(archivePath, target.archive)) {
459
+ await extractArchive(archivePath, staged, opts.exec ?? runCommand, ARCHIVE_EXTRACT_TIMEOUT_MS);
460
+ }
461
+ else {
462
+ // Not an archive at all — the download *is* the program (e.g. `sigit`'s Linux/Windows
463
+ // targets, see the module doc). Place it under the same name `BinaryTarget.cmd` refers to
464
+ // and mark it executable; `chmod` is a no-op on Windows but harmless there.
465
+ const destPath = path.join(staged, archiveFileName(target.archive));
466
+ await fs.copyFile(archivePath, destPath);
467
+ await fs.chmod(destPath, 0o755);
468
+ }
392
469
  }
393
- else {
394
- // Not an archive at all — the download *is* the program (e.g. `sigit`'s Linux/Windows
395
- // targets, see the module doc). Place it under the same name `BinaryTarget.cmd` refers to
396
- // and mark it executable; `chmod` is a no-op on Windows but harmless there.
397
- const destPath = path.join(installDir, archiveFileName(target.archive));
398
- await fs.copyFile(archivePath, destPath);
399
- await fs.chmod(destPath, 0o755);
470
+ catch (err) {
471
+ return { ok: false, error: `Failed to unpack adapter archive for '${agent.id}': ${errorMessage(err)}` };
400
472
  }
473
+ // A concurrent install of the same version may have published first; its tree is equally whole.
474
+ await publishStaged(staged, installDir);
401
475
  }
402
- catch (err) {
403
- await fs.rm(installDir, { recursive: true, force: true }).catch(() => undefined);
404
- return { ok: false, error: `Failed to unpack adapter archive for '${agent.id}': ${errorMessage(err)}` };
476
+ finally {
477
+ await fs.rm(staged, { recursive: true, force: true }).catch(() => undefined);
405
478
  }
406
479
  return { ok: true, checksumVerified: Boolean(target.sha256), alreadyInstalled: false, version, installDir };
407
480
  }