@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,18 +1,13 @@
1
- import fs from 'node:fs/promises';
2
1
  import path from 'node:path';
3
- import { createHash, randomUUID } from 'node:crypto';
4
- import { AGENT_WIRE_AUTH_URL_VERSION, AGENT_WIRE_HEARTBEAT_INVENTORY_VERSION, AGENT_WIRE_SCOPED_ORIGIN_VERSION, AGENT_WIRE_ORPHAN_OFFER_VERSION, AGENT_WIRE_SESSION_AUTH_METHODS_VERSION, AGENT_WIRE_SESSION_LOST_VERSION, AGENT_WIRE_SESSION_STATE_VERSION, AGENT_WIRE_VERSION, AGENT_WIRE_DELEGATION_MCP_VERSION, AGENT_WIRE_DELEGATION_RENEW_VERSION, AGENT_WIRE_PROVIDER_TASK_VERSION, AGENT_WIRE_RUNTIME_PROFILE_VERSION, outboundAgentFrameRejection, outboundFailureReply, } from './protocol/agent.js';
5
- import { DEFAULT_JOURNAL_MAX_BYTES, SessionJournal } from './journal.js';
6
- import { AGENT_PROBE_TIMEOUT_MS, AgentRefusedSessionError } from './acp/acpAgents.js';
7
- import { validateWireEvent } from './acp/validate.js';
2
+ import { AGENT_WIRE_AUTH_URL_VERSION, AGENT_WIRE_HEARTBEAT_INVENTORY_VERSION, AGENT_WIRE_SCOPED_ORIGIN_VERSION, AGENT_WIRE_ORPHAN_OFFER_VERSION, AGENT_WIRE_SESSION_AUTH_METHODS_VERSION, AGENT_WIRE_SESSION_LOST_VERSION, AGENT_WIRE_VERSION, AGENT_WIRE_PROVIDER_TASK_VERSION, AGENT_WIRE_REOPEN_REQUEST_VERSION, AGENT_WIRE_RELATION_ACK_VERSION, outboundAgentFrameRejection, outboundFailureReply, } from './protocol/agent.js';
3
+ import { listLegacyJournalRefs, readLegacyJournal, removeLegacyJournal } from './journal.js';
4
+ import { SESSION_WINDOW_BYTES, SESSION_WINDOW_EVENTS } from './acp/outbox.js';
5
+ import { SocketGate } from './agentFlow.js';
6
+ import { AGENT_PROBE_TIMEOUT_MS, AgentRefusedSessionError, LOCAL_SESSION_UNMOVABLE } from './acp/acpAgents.js';
7
+ import { projectFrame } from './acp/processor/projection.js';
8
8
  import { reportsActivity, supportsAsyncTaskStop } from './acp/activityReporting.js';
9
9
  import { activityIsBusy } from './acp/activityBusy.js';
10
- import { buildSessionMcpServers } from './acp/delegationMcp.js';
11
- function delegationLeaseId(lease) {
12
- return createHash('sha256').update(JSON.stringify([
13
- lease.url, lease.headers, lease.bindingId,
14
- ])).digest('hex');
15
- }
10
+ import { ADAPTER_LOST_REOPEN_ATTEMPTS, plainSwitchBlocker, REOPEN_REQUEST_RETRY_MS, switchBlockers, SWITCH_QUIET_MS } from './acp/switching.js';
16
11
  /**
17
12
  * 从线上收来的验证预算的绝对上限。
18
13
  *
@@ -109,6 +104,49 @@ function resumeFromSeqOf(frame) {
109
104
  const seq = Math.trunc(frame.resumeFromSeq ?? 0);
110
105
  return Number.isFinite(seq) ? Math.max(0, seq) : 0;
111
106
  }
107
+ /**
108
+ * What `agent_open` asks of {@link AcpAgents.open} (and of a switch, which needs the same fresh spawn environment — spec §6.3).
109
+ *
110
+ * `env` is an OVERLAY merged onto process.env when the agent is spawned (see AcpOpenSpec.env), so it is built from frame-supplied
111
+ * values only. The single exception is PATH: when pathPrepend is set the whole variable is resolved here — prepends first, then the
112
+ * frame's PATH or, failing that, the daemon's — so the launcher uses spec.env.PATH verbatim instead of re-deriving it.
113
+ */
114
+ function openSpecOf(frame) {
115
+ const env = { ...(frame.env ?? {}) };
116
+ const pathPrepend = frame.pathPrepend ?? [];
117
+ if (pathPrepend.length > 0) {
118
+ env.PATH = [...pathPrepend, env.PATH ?? process.env.PATH ?? ''].filter(Boolean).join(path.delimiter);
119
+ }
120
+ return {
121
+ openRequestId: frame.id,
122
+ sessionRef: frame.sessionRef,
123
+ agentKey: frame.agentKey,
124
+ // `frame.bridge` is the registry agent id now — the wire field keeps its name because Plan A
125
+ // renames nothing (Global Constraint 3); ruling R2 replaced what it *means*.
126
+ agentId: frame.bridge,
127
+ cwd: frame.cwd,
128
+ env,
129
+ agentEnv: frame.agentEnv ?? {},
130
+ ...(frame.command ? { command: frame.command } : {}),
131
+ args: frame.args ?? [],
132
+ ...(frame.nativeSessionId ? { nativeSessionId: frame.nativeSessionId } : {}),
133
+ ...(frame.requireSessionLoad ? { requireSessionLoad: true } : {}),
134
+ // Retaining a whole adopted history costs memory for the session's lifetime, so it is only
135
+ // kept when this open is the one that will stream it back.
136
+ backfill: frame.backfill === true,
137
+ };
138
+ }
139
+ /** A recorded frame as the processor makes it: the projection adds `sessionRef` (right after `t`) when it is sent. */
140
+ function withoutSessionRef(frame) {
141
+ const { sessionRef: _ref, ...rest } = frame;
142
+ return rest;
143
+ }
144
+ /** How an adapter exited, for the daemon log and the crash-limit refusal: code or signal, and the last stderr line. */
145
+ function exitWords(exit) {
146
+ const status = exit.signal ? `signal ${exit.signal}` : exit.code !== null ? `exit code ${exit.code}` : 'no exit status';
147
+ const lastLine = exit.stderrTail.trim().split('\n').at(-1)?.trim().slice(0, 200);
148
+ return lastLine ? `${status}; stderr: ${lastLine}` : status;
149
+ }
112
150
  function errorMessage(err) {
113
151
  return err instanceof Error ? err.message : String(err);
114
152
  }
@@ -117,6 +155,24 @@ function errorMessage(err) {
117
155
  * arrive as one giant frame; the last batch of a stream is whatever remains below this size.
118
156
  */
119
157
  export const BACKFILL_BATCH_SIZE = 64;
158
+ /**
159
+ * How long the reconnect offer waits for the start-time adoption (Task 21, spec §5.5.2: rebuild before the agent channel is
160
+ * answered). Sessions still being rebuilt after it are offered without `turnRunning` — never as missing, which would end their turns.
161
+ */
162
+ export const ADOPTION_OFFER_WAIT_MS = 10_000;
163
+ /**
164
+ * Own exits of a ref's adapter (no keeper-acp reason: it crashed, or was killed outside Axom) in a row, with no turn completing in
165
+ * between, past which a respawn is no longer made at once (spec §11 "single adapter crash": `session/load` recovers it — but an
166
+ * adapter that dies on every respawn must not be respawned for ever). From the limit on, an `agent_open` that would respawn it is
167
+ * refused with the reason until {@link ADAPTER_CRASH_BACKOFF_MS} has passed since the last exit, doubling per exit past the limit up
168
+ * to {@link ADAPTER_CRASH_BACKOFF_MAX_MS}. A completed turn, or {@link ADAPTER_CRASH_FORGET_MS} without an exit, starts the count over.
169
+ */
170
+ export const ADAPTER_CRASH_LIMIT = 3;
171
+ export const ADAPTER_CRASH_BACKOFF_MS = 30_000;
172
+ export const ADAPTER_CRASH_BACKOFF_MAX_MS = 10 * 60_000;
173
+ export const ADAPTER_CRASH_FORGET_MS = 30 * 60_000;
174
+ /** At most one daemon-log line per ref per this period for refused respawns. */
175
+ const ADAPTER_CRASH_WARN_EVERY_MS = 60_000;
120
176
  /**
121
177
  * 一条会话自报 `running` 之后多久没有任何新推送,就值得在日志里点名。
122
178
  *
@@ -154,19 +210,6 @@ function sanitizeNativeSessionQuery(agentId, query) {
154
210
  args: query.args ?? [],
155
211
  };
156
212
  }
157
- /**
158
- * Name an event for a diagnostic without trusting it: on a payload hostile enough that even
159
- * `eventName` throws (the validator's 'event not inspectable' case), the log line itself must
160
- * still come out.
161
- */
162
- function wireEventName(e) {
163
- try {
164
- return String(e?.eventName);
165
- }
166
- catch {
167
- return '<uninspectable>';
168
- }
169
- }
170
213
  /**
171
214
  * Owns the workstation's live external-agent sessions and translates between the tunnel's
172
215
  * `agent_*` frames and the ACP client.
@@ -175,16 +218,21 @@ function wireEventName(e) {
175
218
  * (and the subprocesses behind them) must survive a dropped websocket. The send binding is swapped
176
219
  * per connection via {@link bindSend} / {@link closeAll}.
177
220
  *
178
- * Delivery is at-least-once, not best-effort: every outbound event is journaled before it is sent
179
- * ({@link SessionJournal}), so a send that goes nowhere — socket down, or the daemon restarted
180
- * entirely — is replayed when the server re-opens the ref with its `resumeFromSeq` watermark.
221
+ * Delivery is at-least-once, not best-effort: every outbound event is recorded in the session's outbox (the transport's outbox
222
+ * stream, Task 20 — it replaced the daemon journal) before it is sent, so a send that goes nowhere — socket down, or the daemon
223
+ * restarted entirely — is resent when the server re-opens the ref with its `resumeFromSeq` watermark.
224
+ *
225
+ * Sending is flow-controlled (spec §5.6): a session's numbered frames go out from its outbox while fewer than
226
+ * {@link SESSION_WINDOW_EVENTS} events / {@link SESSION_WINDOW_BYTES} bytes are unacknowledged on the connection and the socket
227
+ * gate is open ({@link SocketGate}); sessions take turns. Control frames are written at once ({@link emit}).
181
228
  */
182
229
  export class AgentSessionManager {
183
230
  agents;
184
231
  journalDir;
185
- maxJournalBytes;
186
232
  tasks;
187
233
  sessions = new Map();
234
+ /** A failed recorded-build respawn keeps this binding even after no LiveSession remains. */
235
+ retainedRespawnBuilds = new Map();
188
236
  /** In-flight opens, keyed by sessionRef, so two racing agent_open frames cannot spawn two agents. */
189
237
  opening = new Map();
190
238
  /**
@@ -193,21 +241,19 @@ export class AgentSessionManager {
193
241
  * fails its pending open with ok:false and will never adopt the result). Without this, the native
194
242
  * agent that finishes spawning afterwards is orphaned: alive, registered, and unknown to the
195
243
  * server. {@link openSession} closes and discards any session whose ref is tombstoned, and uses
196
- * the recorded {@link AbandonReason} to decide whether the journal dies with it.
244
+ * the recorded {@link AbandonReason} to decide whether its outbox dies with it.
197
245
  */
198
246
  closedDuringOpen = new Map();
199
247
  /**
200
- * Refs whose journal is still being torn down, and the promise that says when it is gone.
248
+ * Refs whose session is still being torn down (adapter stopped, outbox flushed and kept or released), and the promise that says
249
+ * when it is done.
201
250
  *
202
251
  * A close and the reopen of the same ref are two independent inbound frames — the daemon does not
203
252
  * serialize `handleFrame` (see daemon.ts's `ws.on('message', async …)`) — and `onClose` returns the
204
- * ref to the "openable" state the moment it drops out of `sessions`, while the actual `destroy()`
205
- * is still queued behind that session's pending writes. Letting the reopen build a second
206
- * {@link SessionJournal} on the same path in that window is a data-loss race in both directions:
207
- * the old `destroy()` can land after the new journal's first append and delete it, or the new
208
- * `init()` can read a file the old `destroy()` then removes, leaving the new journal with a stale
209
- * size and a seq counter recovered from bytes that no longer exist. The two instances also share a
210
- * `.tmp` path, so a rewrite in one can be pulled out from under the other.
253
+ * ref to the "openable" state the moment it drops out of `sessions`, while the actual teardown is
254
+ * still running. The reopen spawns under the same transport key: letting it start in that window
255
+ * would race the old release against the new session's outbox (deleting it under it) or read the
256
+ * outbox back while the old session's last appends are still landing.
211
257
  *
212
258
  * {@link openSession} waits on this before constructing anything.
213
259
  */
@@ -250,33 +296,110 @@ export class AgentSessionManager {
250
296
  * running for a card nobody will ever see.
251
297
  */
252
298
  loginsInFlight = new Set();
299
+ /** The socket gate of the bound connection (spec §5.6). */
300
+ gate = new SocketGate();
301
+ /** Bumped per bound connection: a session's window counts what was sent on this one. */
302
+ epoch = 0;
303
+ /** Round-robin start for the sender. */
304
+ turn = 0;
305
+ pumping = false;
306
+ pumpAgain = false;
307
+ /** The one-time migration of the legacy daemon journal ({@link migrateLegacyJournals}). */
308
+ legacyMigration;
309
+ /** The start-time adoption of keeper-acp sessions ({@link adoptSessions}): resolves once they are all registered (or given up). */
310
+ adoption;
311
+ /** A retry of the adoption in flight ({@link adoptLate}): resolves once what it found is tracked in {@link adopting}. */
312
+ lateAdoption;
313
+ /** Refs found running in keeper-acp and still being rebuilt: what the offer reports for them, and when they are done. */
314
+ adopting = new Map();
315
+ /**
316
+ * Refs this daemon detached (spec §5.5.5): the session now belongs to the next daemon. A frame for one is dropped quietly — telling
317
+ * the server the session is lost would end the turn the next daemon continues.
318
+ */
319
+ detached = new Set();
320
+ /**
321
+ * Refs being switched (spec §6.3, Task 24) and the promise that says when the switch (and its `agent_opened`) is done: every other
322
+ * inbound frame of the ref is parked behind it and delivered in order afterwards — to the session that then holds the ref.
323
+ */
324
+ switching = new Map();
325
+ /** When a reopen was last requested for a ref, and for what (a switch, or an adapter keeper-acp lost): not asked again too soon. */
326
+ reopenRequests = new Map();
327
+ /** The quiet period a session must keep before it is switched (spec §14; a test seam). */
328
+ switchQuietMs = SWITCH_QUIET_MS;
329
+ /** Own adapter exits per ref ({@link ADAPTER_CRASH_LIMIT}): how many in a row, the last one's time and words. */
330
+ adapterCrashes = new Map();
331
+ /** {@link ADAPTER_CRASH_BACKOFF_MS} (a test seam). */
332
+ adapterCrashBackoffMs = ADAPTER_CRASH_BACKOFF_MS;
333
+ /** `agent_open`s being handled per ref, from admission to their reply: a lost notice waits for them ({@link flushAdapterLost}). */
334
+ openHandling = new Map();
253
335
  constructor(
254
336
  /** The one ACP client every external agent runs through; keyed per call by registry agent id. */
255
337
  agents, send,
256
- /** Root for per-session journals; they live under `<journalDir>/sessions`. */
338
+ /** `<configDir>/agents`: the legacy journals (`sessions/<ref>.jsonl`) an older CLI left are migrated from here, once. */
257
339
  journalDir,
258
- /** Per-session journal cap. Beyond it the oldest half is dropped and the server told about the hole. */
259
- maxJournalBytes = DEFAULT_JOURNAL_MAX_BYTES,
260
340
  /** Shared update admission and force-cancellation authority, when this daemon supports updates. */
261
341
  tasks) {
262
342
  this.agents = agents;
263
343
  this.journalDir = journalDir;
264
- this.maxJournalBytes = maxJournalBytes;
265
344
  this.tasks = tasks;
266
345
  this.send = send;
267
346
  }
268
- /** Where journals live: `<journalDir>/sessions/<sessionRef>.jsonl`. */
347
+ /** Where an older CLI's journals live: `<journalDir>/sessions/<sessionRef>.jsonl` (read-only legacy, `journal.ts`). */
269
348
  get sessionsDir() {
270
349
  return path.join(this.journalDir, 'sessions');
271
350
  }
351
+ /**
352
+ * The daemon journal an older CLI kept (`agents/sessions/<ref>.jsonl`) becomes, once, a local outbox under the same ref
353
+ * (`AcpAgents.importLegacyOutbox`): from then on it is offered as an orphan and resent on a reopen exactly as the journal was,
354
+ * and released like any other outbox. Each file is deleted once its outbox exists; a file that fails stays for the next start.
355
+ * Runs once per process (the resume offer and every open wait for it).
356
+ */
357
+ migrateLegacyJournals() {
358
+ this.legacyMigration ??= (async () => {
359
+ const importOutbox = this.agents.importLegacyOutbox?.bind(this.agents);
360
+ if (!importOutbox)
361
+ return;
362
+ let refs;
363
+ try {
364
+ refs = await listLegacyJournalRefs(this.sessionsDir);
365
+ }
366
+ catch (error) {
367
+ console.warn(`[agent] listing legacy journals failed: ${errorMessage(error)}`);
368
+ return;
369
+ }
370
+ for (const sessionRef of refs) {
371
+ try {
372
+ const entries = await readLegacyJournal(this.sessionsDir, sessionRef);
373
+ const imported = await importOutbox(sessionRef, entries.map((entry) => ({
374
+ seq: entry.seq,
375
+ ...(entry.src ? { src: entry.src } : {}),
376
+ // The outbox keeps the processor's frame; the projection adds `sessionRef` when it is sent.
377
+ frame: withoutSessionRef(entry.frame),
378
+ })));
379
+ if (imported === false) {
380
+ // A local outbox already holds this ref (an import that already happened, or a fallback session's own): it wins, and
381
+ // the file's frames are not merged into it.
382
+ console.warn(`[agent] legacy journal of ${sessionRef} not imported: a local outbox for it already exists; deleting the journal`);
383
+ }
384
+ await removeLegacyJournal(this.sessionsDir, sessionRef);
385
+ if (imported !== false)
386
+ console.info(`[agent] migrated the legacy journal of ${sessionRef} (${entries.length} unacknowledged frame(s)) to its outbox`);
387
+ }
388
+ catch (error) {
389
+ console.warn(`[agent] migrating the legacy journal of ${sessionRef} failed (kept for the next start): ${errorMessage(error)}`);
390
+ }
391
+ }
392
+ })();
393
+ return this.legacyMigration;
394
+ }
272
395
  /** Number of daemon-owned external-agent sessions, for the management heartbeat projection. */
273
396
  activeSessionCount() {
274
397
  return [...this.sessions.values()].filter((session) => !session.closed).length;
275
398
  }
276
- /** Snapshot advertised once after an agent-channel reconnect; the server remains replay authority. */
277
399
  /**
278
- * The reconnect inventory: the sessions this daemon holds, plus the journals a previous daemon
279
- * life left on disk.
400
+ * The reconnect inventory: the sessions this daemon holds, plus the outboxes earlier lives left in the transports (Task 20: what
401
+ * `acp.list` and the local fallback list that no live session holds — a previous daemon's keeper-acp sessions, swept or degraded
402
+ * sessions, local outboxes, migrated legacy journals).
280
403
  *
281
404
  * **The orphan half is what makes recovery automatic.** A journal whose agent is gone -- the
282
405
  * daemon restarted, or the machine rebooted -- is not in {@link sessions}, so before this it
@@ -286,9 +409,7 @@ export class AgentSessionManager {
286
409
  *
287
410
  * **Orphans carry `journalMaxSeq: 0`, and that is not a placeholder for a number worth reading.**
288
411
  * The server drives an orphan off `agentAlive` and its own live-turn ledger, never off a watermark
289
- * comparison, so nothing reads the field. Filling it honestly would mean a `SessionJournal.init`
290
- * per file -- each one reads the whole file, up to 64 MB -- on every agent-channel reconnect, to
291
- * produce a number that is then discarded.
412
+ * comparison, so nothing reads the field.
292
413
  *
293
414
  * **Gated on {@link AGENT_WIRE_ORPHAN_OFFER_VERSION}, and that gate is load-bearing** in a way the
294
415
  * heartbeat-inventory floor is not: this widens the meaning of an existing list whose absence the
@@ -300,14 +421,19 @@ export class AgentSessionManager {
300
421
  // `true`: this is the once-per-connection offer, the one place `activityRaw` is allowed to
301
422
  // ride — see {@link liveInventory}'s parameter doc for why {@link heartbeatInventory} must pass
302
423
  // `false` at its own call site instead.
303
- const live = this.liveInventory(true);
424
+ // Spec §5.5.2: every session keeper-acp kept is in the offer (a missing one has its turn ended) — rebuilt ones with their
425
+ // liveness, ones still rebuilding without `turnRunning`.
426
+ await this.adoptionSettled(ADOPTION_OFFER_WAIT_MS);
427
+ await this.adoptLate();
428
+ const live = [...this.liveInventory(true), ...this.adoptingInventory()];
304
429
  if (this.serverAgentWire < AGENT_WIRE_ORPHAN_OFFER_VERSION)
305
430
  return live;
306
431
  // Every ref this daemon still has a handle on, not merely the ones it would report as live:
307
432
  // a ref that is mid-open, mid-retire, or closed-but-not-yet-forgotten is one we are still the
308
433
  // owner of, and announcing it as abandoned invites a reopen against a session we hold.
309
- const held = new Set([...this.sessions.keys(), ...this.opening.keys(), ...this.retiring.keys()]);
310
- const orphans = await this.orphanedJournalRefs(held);
434
+ await this.migrateLegacyJournals();
435
+ const held = new Set([...this.sessions.keys(), ...this.opening.keys(), ...this.retiring.keys(), ...this.adopting.keys()]);
436
+ const orphans = await this.orphanedRefs(held);
311
437
  // `turnRunning: false` is an observation here, not a default: an orphan has no agent process at
312
438
  // all, so there is nothing that could be running on it.
313
439
  return [
@@ -316,42 +442,20 @@ export class AgentSessionManager {
316
442
  ];
317
443
  }
318
444
  /**
319
- * Journal files on disk whose ref this daemon holds no handle for.
320
- *
321
- * Reads names only -- never contents. A directory that does not exist is the ordinary state of a
322
- * daemon that has never opened a session, so it is not a warning; anything else is, but it still
323
- * degrades to "offer what we hold" rather than failing the whole offer, because losing the live
324
- * half of the inventory would be a far bigger regression than missing the orphan half.
445
+ * Transport sessions no live session holds (`AcpAgents.leftoverSessions`). A failure degrades to "offer what we hold" rather
446
+ * than failing the whole offer: losing the live half of the inventory would be a far bigger regression than missing the orphan
447
+ * half.
325
448
  */
326
- async orphanedJournalRefs(held) {
327
- let names;
449
+ async orphanedRefs(held) {
450
+ let keys;
328
451
  try {
329
- names = await fs.readdir(this.sessionsDir);
452
+ keys = (await this.agents.leftoverSessions?.()) ?? [];
330
453
  }
331
454
  catch (error) {
332
- if (error?.code !== 'ENOENT') {
333
- console.warn(`[agent] listing journals for the resume offer failed: ${errorMessage(error)}`);
334
- }
455
+ console.warn(`[agent] listing leftover sessions for the resume offer failed: ${errorMessage(error)}`);
335
456
  return [];
336
457
  }
337
- const refs = [];
338
- for (const name of names) {
339
- if (!name.endsWith('.jsonl'))
340
- continue;
341
- let ref;
342
- try {
343
- ref = decodeURIComponent(name.slice(0, -'.jsonl'.length));
344
- }
345
- catch {
346
- // Not a name this daemon wrote (`encodeURIComponent` never emits a malformed escape), so
347
- // there is no ref behind it to offer.
348
- continue;
349
- }
350
- if (!held.has(ref))
351
- refs.push(ref);
352
- }
353
- // readdir order is filesystem-defined; the offer should not be.
354
- return refs.sort();
458
+ return keys.filter((ref) => !held.has(ref)).sort();
355
459
  }
356
460
  /**
357
461
  * The live half, shared with the heartbeat -- which must stay synchronous and touch no disk.
@@ -363,7 +467,7 @@ export class AgentSessionManager {
363
467
  * life -- which is the point: "the agent is gone" is one fact with one handling, not a special
364
468
  * case per way of getting there.
365
469
  *
366
- * @param includeActivity Whether to carry each session's {@link LiveSession.lastActivityRaw}
470
+ * @param includeActivity Whether to carry each session's {@link AcpAgentSession.activityRaw}
367
471
  * along as `activityRaw`. **Must be `false` from {@link heartbeatInventory} and `true` from
368
472
  * {@link resumeOfferSessions}, never the other way round** — `activityRaw` is an unbounded vendor
369
473
  * blob, and `AGENT_WIRE_HEARTBEAT_INVENTORY_VERSION`'s own doc comment already ruled that kind of
@@ -379,15 +483,42 @@ export class AgentSessionManager {
379
483
  const liveness = session.session.liveness;
380
484
  return {
381
485
  sessionRef: session.sessionRef,
382
- journalMaxSeq: session.journal.lastSeq,
383
- agentAlive: liveness.agentAlive,
486
+ // The processor's head seq (spec §5.5.2): everything this session numbered, or its outbox held. A server below
487
+ // AGENT_WIRE_SCOPED_ORIGIN_VERSION never gets the scoped frames the processor numbered (they are withheld, a seq gap),
488
+ // so its watermark can never reach the head seq: it is told what the outbox recorded instead, or it would reopen the
489
+ // ref on every inventory.
490
+ journalMaxSeq: this.serverAgentWire < AGENT_WIRE_SCOPED_ORIGIN_VERSION ? session.session.outbox.lastSeq : session.session.headSeq,
491
+ // A ref being switched (Task 24) is idle by construction and alive on one process or the other.
492
+ agentAlive: liveness.agentAlive || this.switching.has(session.sessionRef),
384
493
  turnRunning: liveness.turnRunning,
385
- ...(includeActivity && session.lastActivityRaw !== undefined
386
- ? { activityRaw: session.lastActivityRaw }
494
+ ...(includeActivity && session.session.activityRaw !== undefined
495
+ ? { activityRaw: session.session.activityRaw }
496
+ : {}),
497
+ // Task 22 (spec §5.5.4): a durable session's agent blocked on cards — the server reopens the ref when it already
498
+ // answered one of them (the answer this daemon may have lost is resent off that open). Only to a server that reads it.
499
+ ...(this.serverAgentWire >= AGENT_WIRE_REOPEN_REQUEST_VERSION && session.session.durable &&
500
+ (session.session.pendingRequestIds?.length ?? 0) > 0
501
+ ? { pendingRequestIds: [...session.session.pendingRequestIds] }
387
502
  : {}),
388
503
  };
389
504
  });
390
505
  }
506
+ /**
507
+ * Ask the server for `agent_open{requireSessionLoad:true}` on a live session (spec §6.3, `agent_reopen_request`): an idle
508
+ * session with a pending switch needs the spawn secrets only an open carries. Sent only to a server at
509
+ * {@link AGENT_WIRE_REOPEN_REQUEST_VERSION} — an older one cannot decode the frame, and eight undecodable frames in a row close
510
+ * the agent channel (spec §10: there the switch waits for the session's natural reopen). Returns whether it was sent: `false`
511
+ * for an older or unbound server and for a ref this daemon does not hold. The caller (Task 24) decides when.
512
+ */
513
+ requestReopen(sessionRef, reason) {
514
+ if (this.send === null || this.serverAgentWire < AGENT_WIRE_REOPEN_REQUEST_VERSION)
515
+ return false;
516
+ const live = this.sessions.get(sessionRef);
517
+ if (!live || live.closed)
518
+ return false;
519
+ this.emit({ t: 'agent_reopen_request', sessionRef, reason });
520
+ return true;
521
+ }
391
522
  /**
392
523
  * The same inventory, for the heartbeat that goes out every ten seconds.
393
524
  *
@@ -411,7 +542,7 @@ export class AgentSessionManager {
411
542
  if (this.serverAgentWire < AGENT_WIRE_HEARTBEAT_INVENTORY_VERSION)
412
543
  return undefined;
413
544
  // `false`: activityRaw must stay absent on every heartbeat — see `liveInventory`'s own doc.
414
- const sessions = this.liveInventory(false);
545
+ const sessions = [...this.liveInventory(false), ...this.adoptingInventory()];
415
546
  return sessions.length > 0 ? sessions : undefined;
416
547
  }
417
548
  /**
@@ -419,7 +550,7 @@ export class AgentSessionManager {
419
550
  * the server advertised on this connection — the version is per-connection, so it is rebound with
420
551
  * the sender rather than set once.
421
552
  */
422
- bindSend(send, serverAgentWire = AGENT_WIRE_VERSION) {
553
+ bindSend(send, serverAgentWire = AGENT_WIRE_VERSION, bufferedAmount) {
423
554
  if (this.mutedDrops > 0) {
424
555
  const seconds = this.mutedSinceMs === null ? '?' : ((Date.now() - this.mutedSinceMs) / 1000).toFixed(1);
425
556
  console.warn(`[agent] sender rebound after ${seconds}s unbound; ${this.mutedDrops} frame(s) were dropped while muted`);
@@ -428,21 +559,37 @@ export class AgentSessionManager {
428
559
  this.mutedSinceMs = null;
429
560
  this.send = send;
430
561
  this.serverAgentWire = serverAgentWire;
562
+ // A new connection: its own window and socket. What was sent on the previous one waits for the server's reopen to say how far
563
+ // it got; what was never sent goes out now.
564
+ this.epoch += 1;
565
+ for (const live of this.sessions.values())
566
+ live.session.outbox.connectionChanged();
567
+ this.gate.rebind(bufferedAmount);
431
568
  // Before any inbound frame on this connection can be answered: a sweep notice that arrived here
432
569
  // after the server had already re-opened its ref would invalidate a healthy generation.
433
570
  this.flushSwept();
571
+ this.pump();
572
+ // Account observations are outside the session journal. Replay the cached state on reconnect
573
+ // so a server restart does not depend on the provider sending another quota notification.
574
+ if (this.serverAgentWire >= 27)
575
+ for (const live of this.sessions.values()) {
576
+ if (!live.closed && live.session.accountLimits)
577
+ this.emit({ t: 'agent_account_update', agentKey: live.agentKey, snapshot: live.session.accountLimits });
578
+ }
579
+ for (const ref of [...this.sessions.keys()])
580
+ this.flushAdapterLost(ref);
434
581
  }
435
582
  /**
436
583
  * Called when the websocket drops. Despite the name, this does **not** close agent sessions — the
437
584
  * agent subprocesses keep running so a reconnect can resume them. What it does is unbind the
438
585
  * sender, tombstone in-flight opens, and end in-flight *logins*.
439
586
  *
440
- * Events produced while unbound are journaled and their `send` is a no-op; the server gets them
441
- * back when it re-opens the ref with a `resumeFromSeq`.
587
+ * Events produced while unbound are recorded in their session's outbox and wait there; the server gets them when the sender is
588
+ * rebound, and again when it re-opens the ref with a `resumeFromSeq`.
442
589
  *
443
590
  * A login is the one piece of work here that cannot survive the drop, which is why it is the one
444
591
  * thing this really does end: its URL reaches the reader only as `agent_auth_url`, and that frame
445
- * is not journaled the way session events are, so one emitted while unbound is gone rather than
592
+ * is not recorded the way session events are, so one emitted while unbound is gone rather than
446
593
  * replayed — leaving an agent subprocess held open, for up to `AGENT_LOGIN_CEILING_MS`, for a card
447
594
  * nobody will ever see. See {@link loginsInFlight}, and the comment at the abort itself for why it
448
595
  * is scoped to the teardown that actually unbinds.
@@ -450,7 +597,7 @@ export class AgentSessionManager {
450
597
  * In-flight opens are the other exception: the server fails its pending open with ok:false on
451
598
  * disconnect, so a session that finishes spawning after this point would be orphaned. Those refs
452
599
  * are tombstoned and the resulting bridge session is closed on arrival — but as `disconnected`,
453
- * which keeps the journal: the server is coming back for it.
600
+ * which keeps the outbox: the server is coming back for it.
454
601
  */
455
602
  closeAll(boundSender) {
456
603
  // Unbinding is scoped to the connection that did the binding. The sender is one shared slot on
@@ -471,6 +618,8 @@ export class AgentSessionManager {
471
618
  // registered, and unknown to the server.
472
619
  if (boundSender === undefined || this.send === boundSender) {
473
620
  this.send = null;
621
+ this.epoch += 1;
622
+ this.gate.rebind(undefined);
474
623
  // Logins are the exception to "sessions keep running" — see {@link loginsInFlight}. Scoped to
475
624
  // the teardown that actually unbinds, unlike the tombstoning below: if a newer connection has
476
625
  // already rebound, this login's frames still have somewhere to go and killing it would be
@@ -481,13 +630,13 @@ export class AgentSessionManager {
481
630
  login.abort();
482
631
  }
483
632
  for (const sessionRef of this.opening.keys()) {
484
- // Never downgrade an explicit close into a disconnect: that would strand the journal.
633
+ // Never downgrade an explicit close into a disconnect: that would strand the outbox.
485
634
  if (!this.closedDuringOpen.has(sessionRef))
486
635
  this.closedDuringOpen.set(sessionRef, 'disconnected');
487
636
  }
488
637
  }
489
638
  /**
490
- * Awaits every queued journal write and the send behind it. Used by tests, and available to a
639
+ * Awaits every queued outbox write and the send behind it. Used by tests, and available to a
491
640
  * shutdown path that wants events on disk before the process exits.
492
641
  *
493
642
  * Bounded on purpose. A session that is actively streaming keeps appending to its own chain, so
@@ -500,51 +649,168 @@ export class AgentSessionManager {
500
649
  for (let round = 0; round < maxRounds; round += 1) {
501
650
  let progressed = false;
502
651
  for (const live of [...this.sessions.values()]) {
503
- const chain = live.writes;
504
- await chain;
505
- if (live.writes !== chain)
652
+ const before = live.session.outbox.pending.length + live.session.outbox.lastSeq;
653
+ await live.session.outbox.flush();
654
+ if (live.session.outbox.pending.length + live.session.outbox.lastSeq !== before)
506
655
  progressed = true;
507
656
  }
657
+ await new Promise((resolve) => setImmediate(resolve));
508
658
  if (!progressed)
509
659
  return;
510
660
  }
511
661
  }
512
- async handleFrame(frame) {
513
- switch (frame.t) {
514
- case 'agent_delegation_renew': {
515
- if (this.serverAgentWire < AGENT_WIRE_DELEGATION_RENEW_VERSION)
516
- return;
517
- const live = this.liveSession(frame.sessionRef);
518
- const now = Date.now();
519
- if (!live || live.closed || !live.session.liveness.agentAlive || live.session.liveness.turnRunning ||
520
- activityIsBusy(live.lastActivityRaw) || live.delegationRefreshPending ||
521
- live.nativeSessionId !== frame.nativeSessionId || live.delegationInstanceId !== frame.instanceId ||
522
- live.delegationBindingId !== frame.bindingId || !frame.bindingId ||
523
- !Number.isSafeInteger(frame.renewalRevision) || frame.renewalRevision < 1 ||
524
- !Number.isSafeInteger(frame.expiresAtEpochMs) || frame.expiresAtEpochMs <= now ||
525
- frame.expiresAtEpochMs > now + 330_000 || frame.renewalRevision < (live.delegationRenewalRevision ?? 0) ||
526
- frame.expiresAtEpochMs < (live.delegationExpiresAtEpochMs ?? 0) ||
527
- (frame.renewalRevision === live.delegationRenewalRevision && frame.expiresAtEpochMs !== live.delegationExpiresAtEpochMs)) {
528
- this.emit({ t: 'agent_delegation_renewed', id: frame.id, ok: false, error: 'The original managed instance cannot renew' });
529
- return;
530
- }
531
- live.delegationExpiresAtEpochMs = frame.expiresAtEpochMs;
532
- live.delegationRenewalRevision = frame.renewalRevision;
533
- this.emit({ t: 'agent_delegation_renewed', id: frame.id, ok: true,
534
- nativeSessionId: live.nativeSessionId, instanceId: live.delegationInstanceId, bindingId: live.delegationBindingId,
535
- renewalRevision: live.delegationRenewalRevision, expiresAtEpochMs: live.delegationExpiresAtEpochMs });
662
+ /**
663
+ * The daemon's start (Task 21, spec §5.5): adopt the sessions keeper-acp still runs from a previous daemon. Each is registered as
664
+ * a live session once rebuilt (frames it produces from then on are recorded and sent as any session's); until then the offer and
665
+ * the heartbeat report it without `turnRunning`, and frames for its ref wait for it. A session that cannot be continued is dropped
666
+ * (its adapter stopped, its outbox offered as an orphan). Once per manager; never rejects.
667
+ */
668
+ adoptSessions() {
669
+ this.adoption ??= (async () => {
670
+ const adopt = this.agents.adoptKeeperSessions?.bind(this.agents);
671
+ if (!adopt)
672
+ return;
673
+ let found;
674
+ try {
675
+ found = await adopt();
676
+ }
677
+ catch (error) {
678
+ console.warn(`[agent] adopting keeper-acp sessions failed: ${errorMessage(error)}`);
536
679
  return;
537
680
  }
681
+ await Promise.all(this.trackAdoptions(found));
682
+ })();
683
+ return this.adoption;
684
+ }
685
+ /** Registers each found session once rebuilt; until then it is `adopting` (offered without `turnRunning`, its frames wait). */
686
+ trackAdoptions(found) {
687
+ const done = [];
688
+ for (const candidate of found) {
689
+ if (this.sessions.has(candidate.sessionRef) || this.adopting.has(candidate.sessionRef))
690
+ continue;
691
+ const settled = candidate.session.then((session) => {
692
+ if (session)
693
+ this.registerAdopted(candidate, session);
694
+ }, (error) => {
695
+ console.warn(`[agent] adopting ${candidate.sessionRef} failed: ${errorMessage(error)}`);
696
+ }).finally(() => { this.adopting.delete(candidate.sessionRef); });
697
+ this.adopting.set(candidate.sessionRef, { journalMaxSeq: candidate.journalMaxSeq, agentAlive: candidate.agentAlive !== false, done: settled });
698
+ done.push(settled);
699
+ }
700
+ return done;
701
+ }
702
+ /**
703
+ * Final review finding 3: a keeper-acp generation the start-time adoption could not list (it did not answer in time) is listed
704
+ * again — before a frame is handled, before the reconnect offer and on every switch poll — and what it holds is adopted like
705
+ * any start-time adoption, so its sessions are never offered as orphans nor reopened into a new spawn. Resolves once what the
706
+ * retry found is tracked (not rebuilt). Never rejects.
707
+ */
708
+ adoptLate() {
709
+ const retry = this.agents.retryKeeperAdoption?.bind(this.agents);
710
+ if (!retry || !this.adoption || !this.agents.keeperAdoptionPending?.())
711
+ return Promise.resolve();
712
+ this.lateAdoption ??= (async () => {
713
+ await this.adoption?.catch(() => undefined);
714
+ let found;
715
+ try {
716
+ found = await retry();
717
+ }
718
+ catch (error) {
719
+ console.warn(`[agent] retrying the keeper-acp adoption failed: ${errorMessage(error)}`);
720
+ return;
721
+ }
722
+ if (found.length > 0)
723
+ console.info(`[agent] adopted ${found.length} keeper-acp session(s) late: ${found.map((f) => f.sessionRef).join(', ')}`);
724
+ void Promise.all(this.trackAdoptions(found));
725
+ })().finally(() => { this.lateAdoption = undefined; });
726
+ return this.lateAdoption;
727
+ }
728
+ /** An adopted session joins like an opened one; the server reopens it (or not) from the offer. */
729
+ registerAdopted(candidate, session) {
730
+ if (this.sessions.has(candidate.sessionRef)) {
731
+ // Cannot happen (opens of an adopting ref wait for it); never hold two sessions under one ref.
732
+ void session.detach?.();
733
+ return;
734
+ }
735
+ let highestScopedJournalSeq = 0;
736
+ for (const item of session.outbox.pending) {
737
+ if (item.frame.t === 'agent_event' && item.frame.acpSessionId != null)
738
+ highestScopedJournalSeq = Math.max(highestScopedJournalSeq, item.seq);
739
+ }
740
+ const live = {
741
+ sessionRef: candidate.sessionRef, adopted: true, agentId: candidate.agentId, agentKey: candidate.agentKey, session,
742
+ nativeSessionId: session.nativeSessionId ?? null,
743
+ scopedReplayBlocked: false, highestScopedJournalSeq, unwatch: () => { }, replays: Promise.resolve(), closed: false,
744
+ lastActivityAt: Date.now(),
745
+ };
746
+ this.register(live);
747
+ console.info(`[acp:${live.agentId} agent:${live.agentKey}] adopted session ${live.sessionRef} from keeper-acp`);
748
+ }
749
+ /** Waits (bounded) for the start-time adoption; what is still rebuilding afterwards is reported as such. */
750
+ async adoptionSettled(timeoutMs) {
751
+ if (!this.adoption)
752
+ return;
753
+ let timer;
754
+ await Promise.race([this.adoption, new Promise((resolve) => { timer = setTimeout(resolve, timeoutMs); })]);
755
+ clearTimeout(timer);
756
+ }
757
+ /** Refs still being rebuilt, as inventory entries: alive, their outbox's head seq, and no `turnRunning` (not observed yet). */
758
+ adoptingInventory() {
759
+ return [...this.adopting.entries()].filter(([ref]) => !this.sessions.has(ref))
760
+ .map(([sessionRef, { journalMaxSeq, agentAlive }]) => ({ sessionRef, journalMaxSeq, agentAlive }));
761
+ }
762
+ /**
763
+ * The daemon is going away (SIGTERM, spec §5.5.5): every durable (keeper-hosted) session is detached — head checkpoint, adapter
764
+ * untouched, no failed turn — and the others cut a head checkpoint (their adapters end with the daemon, their outboxes are kept).
765
+ * Bounded by `timeoutMs`.
766
+ */
767
+ async detachAll(timeoutMs = 2_000) {
768
+ // Sessions still being adopted are not in `sessions` and are skipped. Harmless: an adoption writes no checkpoint of its own and
769
+ // holds nothing the next daemon cannot rebuild the same way (the adapter runs on in keeper-acp, the logs are durable); if one
770
+ // registers after this, the transports are closed right behind it and its loop stops quietly (`KeeperClosedError`).
771
+ const work = [...this.sessions.values()].map((live) => (live.session.durable && live.session.detach
772
+ ? this.retire(live, 'detach')
773
+ : live.session.checkpoint?.())?.catch((err) => {
774
+ console.warn(`[agent] leaving ${live.sessionRef} to the next daemon failed: ${errorMessage(err)}`);
775
+ }));
776
+ let timer;
777
+ await Promise.race([Promise.all(work), new Promise((resolve) => { timer = setTimeout(resolve, timeoutMs); })]);
778
+ clearTimeout(timer);
779
+ }
780
+ async handleFrame(frame) {
781
+ // A frame for a ref keeper-acp still runs from a previous daemon waits until that session is rebuilt (Task 21): answering
782
+ // it "unknown session" would end a turn that is going on.
783
+ const frameRef = 'sessionRef' in frame && typeof frame.sessionRef === 'string' ? frame.sessionRef : undefined;
784
+ // A generation the adoption could not list yet may hold this ref (final review finding 3): listed again first (synchronous check).
785
+ if (frameRef !== undefined && !this.liveSession(frameRef) && this.agents.keeperAdoptionPending?.())
786
+ await this.adoptLate();
787
+ const adoptingRef = frameRef !== undefined ? this.adopting.get(frameRef) : undefined;
788
+ if (adoptingRef)
789
+ await adoptingRef.done;
790
+ // Parked while the ref is switched (spec §6.3 step 1): delivered, in arrival order, to whichever session holds it afterwards.
791
+ for (let parked = frameRef !== undefined ? this.switching.get(frameRef) : undefined; parked; parked = this.switching.get(frameRef))
792
+ await parked;
793
+ switch (frame.t) {
538
794
  case 'agent_open': {
539
795
  const admission = this.reserveOpen(frame.id, frame.sessionRef);
540
796
  if (admission === 'rejected')
541
797
  return;
798
+ const ref = frame.sessionRef;
799
+ this.openHandling.set(ref, (this.openHandling.get(ref) ?? 0) + 1);
542
800
  try {
543
801
  await this.onOpen(frame);
544
802
  }
545
803
  finally {
546
804
  if (admission === 'reserved')
547
- this.tasks?.agentOpenFinished(frame.sessionRef);
805
+ this.tasks?.agentOpenFinished(ref);
806
+ const left = (this.openHandling.get(ref) ?? 1) - 1;
807
+ if (left > 0)
808
+ this.openHandling.set(ref, left);
809
+ else
810
+ this.openHandling.delete(ref);
811
+ // An adapter that exited while this open was handled (a respawn that died right after its load) is reported after the
812
+ // reply: reported before it, the server would void the generation it is establishing.
813
+ this.flushAdapterLost(ref);
548
814
  }
549
815
  return;
550
816
  }
@@ -553,27 +819,14 @@ export class AgentSessionManager {
553
819
  if (!live)
554
820
  return;
555
821
  if (this.tasks && !this.tasks.tryAdmitTurn(frame.sessionRef)) {
556
- // No `authMethods` here, deliberately, even though this is a failed turn on a session
557
- // whose agent we have certainly connected to: the prompt never reached that agent, so
558
- // nothing about credentials is being reported. This frame is built here rather than in
559
- // `onAgentTurnDone`, so it never passes the {@link TurnResult.agentRefused} gate — the
560
- // omission is structural, not a line someone has to remember.
561
- this.record(live, (seq) => ({
562
- t: 'agent_turn_done', sessionRef: frame.sessionRef, seq, status: 'failed',
563
- error: 'Workstation is not accepting new work',
564
- platformTurnId: frame.platformTurnId,
565
- }));
822
+ // The refusal is the daemon's decision, so it is a note (`agent_prompt{admitted:false}`) and the processor ends the
823
+ // turn `failed` with the same words (`ADMISSION_REFUSED`), numbered in the session's own sequence (spec §5.4). No
824
+ // `authMethods`: the processor's short frame carries no `agentRefused`, so the projection adds none — the prompt
825
+ // never reached the agent.
826
+ live.session.refusePrompt(frame.platformTurnId);
566
827
  return;
567
828
  }
568
- try {
569
- live.session.prompt(promptBlocks(frame), frame.platformTurnId);
570
- }
571
- catch (error) {
572
- this.record(live, (seq) => ({
573
- t: 'agent_turn_done', sessionRef: frame.sessionRef, seq, status: 'failed',
574
- error: error instanceof Error ? error.message : String(error), platformTurnId: frame.platformTurnId,
575
- }));
576
- }
829
+ live.session.prompt(promptBlocks(frame), frame.platformTurnId);
577
830
  return;
578
831
  }
579
832
  case 'agent_answer': {
@@ -678,20 +931,11 @@ export class AgentSessionManager {
678
931
  this.emit({ t: 'agent_provider_task_stopped', id: frame.id, state: 'rejected', error: 'Provider task control requires a newer server' });
679
932
  return;
680
933
  }
681
- const live = this.routableSession(frame.sessionRef, 'provider_task_stop');
682
- if (!live || !live.session.providerTaskAdapterSelected || !live.session.providerTaskStop ||
683
- live.nativeSessionId !== frame.rootSessionId ||
684
- live.session.runtimeDiagnostics?.profile?.runtimeGeneration !== frame.runtimeGeneration) {
685
- this.emit({ t: 'agent_provider_task_stopped', id: frame.id, state: 'rejected', error: 'Provider task identity is not live and trusted' });
686
- return;
687
- }
688
- try {
689
- const state = await live.session.providerTaskStop(frame.taskId, frame.requestId);
690
- this.emit({ t: 'agent_provider_task_stopped', id: frame.id, state });
691
- }
692
- catch (err) {
693
- this.emit({ t: 'agent_provider_task_stopped', id: frame.id, state: 'unknown', error: errorMessage(err) });
694
- }
934
+ // No session selects a provider-task adapter (none ever did: the old path's `providerTaskAdapterSelected` had no
935
+ // producer that set it, and it went with that path in Task 29), so no task identity is ever live and trusted. The
936
+ // wire answer is unchanged, and so is the routing (an unknown ref is still reported lost).
937
+ this.routableSession(frame.sessionRef, 'provider_task_stop');
938
+ this.emit({ t: 'agent_provider_task_stopped', id: frame.id, state: 'rejected', error: 'Provider task identity is not live and trusted' });
695
939
  return;
696
940
  }
697
941
  /**
@@ -733,7 +977,7 @@ export class AgentSessionManager {
733
977
  // — see {@link promptBlocks}. `frame.priority` is passed through untouched, `undefined`
734
978
  // included: absent means the server named no timing, and inventing one here would be
735
979
  // this daemon deciding something the server deliberately left unsaid.
736
- outcome: await live.session.steer(promptBlocks(frame), frame.priority),
980
+ outcome: await live.session.steer(promptBlocks(frame), frame.priority, frame.id),
737
981
  });
738
982
  }
739
983
  catch (err) {
@@ -769,7 +1013,7 @@ export class AgentSessionManager {
769
1013
  const operation = `request=${frame.id} session=${frame.sessionRef} config=${frame.configId}`;
770
1014
  console.info(`${new Date().toISOString()} [agent/config] applying ${operation}`);
771
1015
  try {
772
- const configOptions = await live.session.setConfigOption(frame.configId, frame.value);
1016
+ const configOptions = await live.session.setConfigOption(frame.configId, frame.value, frame.id);
773
1017
  console.info(`${new Date().toISOString()} [agent/config] confirmed ${operation} elapsedMs=${Math.round(performance.now() - startedAt)}`);
774
1018
  this.emit({ t: 'agent_config_option_set', id: frame.id, ok: true, configOptions });
775
1019
  }
@@ -791,13 +1035,7 @@ export class AgentSessionManager {
791
1035
  if (!live)
792
1036
  return;
793
1037
  this.touch(live);
794
- await this.enqueue(live, async () => {
795
- if (live.scopedReplayBlocked || this.blockIncompatibleJournal(live))
796
- return;
797
- await live.journal.truncateThrough(frame.throughSeq);
798
- if (frame.throughSeq >= live.highestScopedJournalSeq)
799
- live.highestScopedJournalSeq = 0;
800
- });
1038
+ await this.acknowledge(live, frame.throughSeq);
801
1039
  return;
802
1040
  }
803
1041
  case 'agent_list_native':
@@ -816,6 +1054,28 @@ export class AgentSessionManager {
816
1054
  return;
817
1055
  // Both of these spawn an agent subprocess, exactly as `agent_list_native` does, so they answer
818
1056
  // a draining workstation the same way rather than starting work it is trying to finish.
1057
+ case 'agent_account_read': {
1058
+ if (this.tasks && !this.tasks.accepting) {
1059
+ this.emit({ t: 'agent_account_read_result', id: frame.id, ok: false, error: 'Workstation is not accepting new work' });
1060
+ return;
1061
+ }
1062
+ try {
1063
+ // A keeper session may predate the extension even after its current adapter build was upgraded.
1064
+ const spec = sanitizeNativeSessionQuery(frame.bridge, frame.query);
1065
+ if (!spec)
1066
+ throw new Error('No workspace is available');
1067
+ const minimumVersion = await this.agents.minimumAccountLimitsVersion?.(spec) ?? 2;
1068
+ const live = [...this.sessions.values()].find(live => live.agentKey === frame.agentKey && !live.closed && live.session.liveness.agentAlive && live.session.supportsAccountLimits === true && (live.session.accountLimitsVersion ?? 2) >= minimumVersion);
1069
+ if (!live?.session.readAccountLimits && !this.agents.readAccountLimits)
1070
+ throw new Error('Account usage is unsupported');
1071
+ const snapshot = live?.session.readAccountLimits ? await live.session.readAccountLimits(frame.id) : await this.agents.readAccountLimits(spec);
1072
+ this.emit({ t: 'agent_account_read_result', id: frame.id, ok: true, snapshot });
1073
+ }
1074
+ catch {
1075
+ this.emit({ t: 'agent_account_read_result', id: frame.id, ok: false, error: 'Failed to fetch usage' });
1076
+ }
1077
+ return;
1078
+ }
819
1079
  case 'agent_verify':
820
1080
  if (this.tasks && !this.tasks.accepting) {
821
1081
  this.emit({ t: 'agent_verified', id: frame.id, ok: false, error: 'Workstation is not accepting new work' });
@@ -839,6 +1099,14 @@ export class AgentSessionManager {
839
1099
  }
840
1100
  await this.onInstall(frame);
841
1101
  return;
1102
+ // "Move idle sessions" (Task 25): a move spawns adapters, so a draining workstation refuses it like an install.
1103
+ case 'agent_switch_idle':
1104
+ if (this.tasks && !this.tasks.accepting) {
1105
+ this.emit({ t: 'agent_switch_idle_result', id: frame.id, ok: false, error: 'Workstation is not accepting new work' });
1106
+ return;
1107
+ }
1108
+ await this.onSwitchIdle(frame);
1109
+ return;
842
1110
  default:
843
1111
  this.warnUnhandledFrame(frame);
844
1112
  return;
@@ -877,23 +1145,104 @@ export class AgentSessionManager {
877
1145
  * Classifies an open before its first await. A live or already-opening ref is continuation
878
1146
  * traffic, so graceful drain lets it replay/piggyback without acquiring (and later releasing)
879
1147
  * another reservation. Force-stop is different: it is destructive shutdown and rejects every
880
- * open, including reconnects. A ref absent from both maps is genuinely new work and still needs
1148
+ * open, including reconnects — except a reconnect of a live keeper-hosted session, which the force
1149
+ * stop leaves attached. A ref absent from both maps is genuinely new work and still needs
881
1150
  * the ordinary TaskRegistry admission reservation.
882
1151
  */
883
1152
  reserveOpen(openId, sessionRef) {
884
1153
  if (!this.tasks)
885
1154
  return 'continuation';
1155
+ const live = this.liveSession(sessionRef);
1156
+ // A keeper-hosted session whose adapter is gone is respawned by this open: new work, not a continuation — it takes a reservation,
1157
+ // and a draining workstation refuses it like any open (spec §8.2; fix round 1, minor 9).
1158
+ const respawn = live !== undefined && this.needsRespawn(live);
886
1159
  if (this.tasks.isForceStopping()) {
1160
+ // A keeper-hosted session a force update left attached (TaskRegistry.stopAll) is still reopened as a continuation — a reconnect
1161
+ // during the download must not cut it off; anything else is refused.
1162
+ if (live && !respawn && this.tasks.isDurableSession(sessionRef))
1163
+ return 'continuation';
887
1164
  this.emit({ t: 'agent_opened', id: openId, ok: false, error: 'Workstation is not accepting new work' });
888
1165
  return 'rejected';
889
1166
  }
890
- if (this.liveSession(sessionRef) || this.opening.has(sessionRef))
1167
+ if ((live && !respawn) || this.opening.has(sessionRef) || this.adopting.has(sessionRef))
891
1168
  return 'continuation';
892
1169
  if (this.tasks.tryReserveAgentOpen(sessionRef))
893
1170
  return 'reserved';
894
1171
  this.emit({ t: 'agent_opened', id: openId, ok: false, error: 'Workstation is not accepting new work' });
895
1172
  return 'rejected';
896
1173
  }
1174
+ /**
1175
+ * A session whose adapter is gone: an `agent_open` of it spawns and loads anew (Task 21) — keeper-hosted or adopted, and also a
1176
+ * local-fallback session whose adapter exited (it would otherwise be "reused" dead).
1177
+ */
1178
+ needsRespawn(live) {
1179
+ const exited = live.session.adapterExit !== undefined;
1180
+ return (live.adopted === true || live.session.durable === true || exited) && !live.session.liveness.agentAlive;
1181
+ }
1182
+ /**
1183
+ * The adapter of `live` exited under its loop, and its frames (a failed `turn_done` for an open turn) are recorded. Unless this
1184
+ * daemon stopped it (`terminated`: a close, a switch, a retire), the session cannot take another prompt: the server is told it is
1185
+ * lost (`agent_session_lost{sweep}` — the poison contract's recovery, spec §11 "single adapter crash"), so its next prompt opens
1186
+ * first, and that `agent_open` respawns the adapter and loads the native session ({@link needsRespawn}). The session stays held
1187
+ * (reported `agentAlive:false`) until then. An exit of its own also counts towards {@link ADAPTER_CRASH_LIMIT}.
1188
+ */
1189
+ onAdapterExited(live, exit) {
1190
+ const ref = live.sessionRef;
1191
+ if (exit.reason === 'terminated')
1192
+ return;
1193
+ if (live.closed || this.sessions.get(ref) !== live || this.detached.has(ref))
1194
+ return;
1195
+ const how = exitWords(exit);
1196
+ if (exit.reason === undefined) {
1197
+ const now = Date.now();
1198
+ const last = this.adapterCrashes.get(ref);
1199
+ const count = last && now - last.lastAt < ADAPTER_CRASH_FORGET_MS ? last.count + 1 : 1;
1200
+ this.adapterCrashes.set(ref, { count, lastAt: now, detail: how });
1201
+ }
1202
+ console.warn(`[acp:${live.agentId} agent:${live.agentKey}] the adapter of session ${ref} exited ` +
1203
+ `(${exit.reason ?? 'on its own'}; ${how})${exit.busy ? ' while a turn or card was open' : ''}; its next open respawns it`);
1204
+ // Everything the exit produced (the failed `turn_done`) is in the outbox by now: the notice waits until it has been sent.
1205
+ live.adapterLost = { spawnId: exit.spawnId, reason: exit.reason, busy: exit.busy, notified: false, throughSeq: live.session.outbox.lastSeq };
1206
+ this.flushAdapterLost(ref);
1207
+ }
1208
+ /**
1209
+ * Sends the lost notice of the session holding `ref` ({@link onAdapterExited}) once it cannot overtake anything: no open, switch
1210
+ * or retire of the ref under way (the open's own reply goes first), and every frame the exit produced (`throughSeq`: its failed
1211
+ * `turn_done`) already sent **on this connection** — the outbox pump holds frames behind the session window and the socket gate,
1212
+ * while this notice is sent directly. Overtaken, the server would end the turn itself, drop the `turn_done` unacknowledged, and
1213
+ * the reopen's replay of it would end the NEXT turn. Re-checked whenever the pump sends for the ref, after an open, and after a
1214
+ * rebind. Not queued while unbound ({@link sweptWhileUnsent}): a new connection leaves the server's delivery unwired anyway, and
1215
+ * entries sent on an earlier one are held until its reopen — which respawns the adapter and retires this session.
1216
+ */
1217
+ flushAdapterLost(ref) {
1218
+ const live = this.liveSession(ref);
1219
+ const lost = live?.adapterLost;
1220
+ if (!live || !lost || lost.notified || this.send === null)
1221
+ return;
1222
+ if (this.opening.has(ref) || this.openHandling.has(ref) || this.switching.has(ref) || this.retiring.has(ref))
1223
+ return;
1224
+ if (live.session.liveness.agentAlive)
1225
+ return;
1226
+ if (live.session.outbox.pending.some((item) => item.seq <= lost.throughSeq && item.sentOn !== this.epoch))
1227
+ return;
1228
+ lost.notified = true;
1229
+ this.reportSessionLost(ref, AgentSessionManager.SWEEP_LOST_ACTION);
1230
+ }
1231
+ /**
1232
+ * Why an open of `ref` may not respawn its adapter now ({@link ADAPTER_CRASH_LIMIT}), or `undefined`. The text is what the user's
1233
+ * failed message shows.
1234
+ */
1235
+ adapterCrashHold(ref, now) {
1236
+ const crashes = this.adapterCrashes.get(ref);
1237
+ if (!crashes || crashes.count < ADAPTER_CRASH_LIMIT || now - crashes.lastAt >= ADAPTER_CRASH_FORGET_MS)
1238
+ return undefined;
1239
+ const backoff = Math.min(this.adapterCrashBackoffMs * 2 ** (crashes.count - ADAPTER_CRASH_LIMIT), ADAPTER_CRASH_BACKOFF_MAX_MS);
1240
+ const wait = crashes.lastAt + backoff - now;
1241
+ if (wait <= 0)
1242
+ return undefined;
1243
+ return `The agent's adapter process exited on its own ${crashes.count} times in a row (last: ${crashes.detail}); ` +
1244
+ `it is not started again for another ${Math.ceil(wait / 1000)} s.`;
1245
+ }
897
1246
  /**
898
1247
  * Everything a failure frame says about a refusal **the agent itself** produced: the sign-in table
899
1248
  * it advertised, and the JSON-RPC code it answered with.
@@ -952,7 +1301,12 @@ export class AgentSessionManager {
952
1301
  */
953
1302
  routableSession(sessionRef, action) {
954
1303
  const live = this.liveSession(sessionRef);
955
- if (!live) {
1304
+ if (!live && this.detached.has(sessionRef)) {
1305
+ // Left to the next daemon (spec §5.5.5): it continues the session, so it is not lost. An answer dropped here is resent by the
1306
+ // server off the next `agent_opened.pendingRequestIds`.
1307
+ console.warn(`[agent] dropped ${action} for '${sessionRef}': the session was handed to the next daemon`);
1308
+ }
1309
+ else if (!live) {
956
1310
  console.warn(`[agent] dropped ${action} for unknown session '${sessionRef}'`);
957
1311
  this.reportSessionLost(sessionRef, action);
958
1312
  }
@@ -1042,18 +1396,17 @@ export class AgentSessionManager {
1042
1396
  * `now` is a parameter rather than a clock read so the sweep is deterministic under test.
1043
1397
  * The teardown is deliberately the same one `agent_close` performs ({@link retire}) — a swept
1044
1398
  * session must leave nothing behind that a later frame could resurrect — with one deliberate
1045
- * exception: **the journal survives**.
1399
+ * exception: **the outbox survives**.
1046
1400
  *
1047
1401
  * That exception is the difference between `agent_close` and this sweep. `agent_close` is the
1048
- * server saying "I am done with this ref", so its journal can never be asked for again. A sweep is
1402
+ * server saying "I am done with this ref", so its outbox can never be asked for again. A sweep is
1049
1403
  * unilateral: the server has not asked for anything and may well re-open the ref with a
1050
- * `resumeFromSeq` the moment it next hears from the user. The sweep also fires precisely when a
1051
- * journal is most likely to be holding undelivered output — the workstation was offline past the
1052
- * TTL (a laptop shut overnight), so a turn that finished while the socket was down is journaled
1053
- * and unsent. Destroying it there loses that turn outright, and `droppedOldest` is per-instance so
1054
- * not even a transport-gap card would mark the hole. The same distinction already exists one layer
1404
+ * `resumeFromSeq` the moment it next hears from the user. The sweep also fires precisely when an
1405
+ * outbox is most likely to be holding undelivered output — the workstation was offline past the
1406
+ * TTL (a laptop shut overnight), so a turn that finished while the socket was down is recorded
1407
+ * and unsent. Destroying it there loses that turn outright. The same distinction already exists one layer
1055
1408
  * up as {@link AbandonReason}; a sweep is `disconnected`-shaped, not `closed`-shaped. Kill the
1056
- * process, keep the file.
1409
+ * process, keep the outbox (the transport session; its next open reuses it).
1057
1410
  *
1058
1411
  * Each sweep also reports itself to the server ({@link notifySwept}) so its stale session state is
1059
1412
  * retired before the next user message runs into it, rather than after — the sweep heals the
@@ -1066,7 +1419,7 @@ export class AgentSessionManager {
1066
1419
  // debugging a stuck workstation can see it instead of just trusting a RUNNING that never moves.
1067
1420
  for (const live of this.sessions.values()) {
1068
1421
  if (!live.staleActivityWarned &&
1069
- activityIsBusy(live.lastActivityRaw) &&
1422
+ activityIsBusy(live.session.activityRaw) &&
1070
1423
  live.lastActivityAtMs !== undefined &&
1071
1424
  now - live.lastActivityAtMs > STALE_ACTIVITY_WARN_MS) {
1072
1425
  live.staleActivityWarned = true;
@@ -1087,73 +1440,61 @@ export class AgentSessionManager {
1087
1440
  return idle.map((live) => live.sessionRef);
1088
1441
  }
1089
1442
  async onOpen(frame) {
1090
- if (frame.delegationMcp && (this.serverAgentWire < AGENT_WIRE_DELEGATION_MCP_VERSION ||
1091
- (frame.delegationMcp.bindingId && this.serverAgentWire < AGENT_WIRE_DELEGATION_RENEW_VERSION))) {
1092
- this.emit({ t: 'agent_opened', id: frame.id, ok: false, error: 'Managed delegation requires a newer server' });
1093
- return;
1094
- }
1095
- if (frame.delegationMcp) {
1096
- try {
1097
- buildSessionMcpServers([], frame.delegationMcp);
1098
- }
1099
- catch {
1100
- this.emit({ t: 'agent_opened', id: frame.id, ok: false, error: 'Delegation MCP configuration is unavailable' });
1443
+ // Rotation is server-driven. A busy reconnect resends the original process's outbox.
1444
+ let existing = this.liveSession(frame.sessionRef);
1445
+ // An open that would spawn the ref's adapter anew after it kept exiting on its own waits out the backoff (no crash loop).
1446
+ if (existing && this.needsRespawn(existing) && !this.opening.has(frame.sessionRef)) {
1447
+ const held = this.adapterCrashHold(frame.sessionRef, Date.now());
1448
+ if (held) {
1449
+ // Rate-limited: the server's heartbeat may retry the open every ten seconds for the whole backoff.
1450
+ const now = Date.now();
1451
+ const crashes = this.adapterCrashes.get(frame.sessionRef);
1452
+ if (crashes && now - (crashes.warnedAt ?? -Infinity) >= ADAPTER_CRASH_WARN_EVERY_MS) {
1453
+ crashes.warnedAt = now;
1454
+ console.warn(`[agent] ${frame.sessionRef}: not respawning its adapter: ${held}`);
1455
+ }
1456
+ this.emit({ t: 'agent_opened', id: frame.id, ok: false, error: held });
1457
+ if (frame.backfill)
1458
+ this.emit({ t: 'agent_backfill_done', id: frame.id, ok: false, count: 0, error: held });
1101
1459
  return;
1102
1460
  }
1103
1461
  }
1104
- // Rotation is server-driven. A busy reconnect replays the original process's journal.
1105
- const existing = this.liveSession(frame.sessionRef);
1106
1462
  let openFrame = frame;
1107
1463
  let retireForRefresh;
1464
+ if (existing && this.needsRespawn(existing)) {
1465
+ // A keeper-hosted session whose adapter is gone (it exited, or keeper-acp restarted and recovered it as `keeper_lost` — its
1466
+ // tail was folded and recorded): the reopen starts a new spawn in the same transport session and loads the native one, as
1467
+ // for any session left behind (Task 21).
1468
+ retireForRefresh = existing;
1469
+ existing = undefined;
1470
+ }
1471
+ // A keeper session's label records the build that created its native session. Preserve that
1472
+ // binding across an adapter exit: the agent's current pointer may have advanced meanwhile,
1473
+ // but a cached reconnect must retry the recorded package before any explicit switch.
1474
+ const recordedBuildId = retireForRefresh
1475
+ ? this.agents.placementOf?.(retireForRefresh.session)?.buildId
1476
+ : this.retainedRespawnBuilds.get(frame.sessionRef);
1108
1477
  if (existing) {
1109
- if (existing.delegationExpiresAtEpochMs && !frame.delegationMcp) {
1110
- this.emit({ t: 'agent_opened', id: frame.id, ok: false, error: 'Managed delegation lease is required' });
1478
+ // Spec §6.3: a session with a switch pending (a persistent intent: another adapter build, or off an older keeper-acp
1479
+ // generation) moves at this open — the only moment its spawn secrets are at hand — when it is strictly idle.
1480
+ if (await this.switchAtOpen(existing, frame))
1111
1481
  return;
1112
- }
1113
- if (frame.delegationMcp && frame.nativeSessionId && frame.nativeSessionId !== existing.nativeSessionId) {
1114
- this.emit({ t: 'agent_opened', id: frame.id, ok: false, error: 'Managed delegation cannot reload the original session' });
1115
- return;
1116
- }
1117
- const refresh = !!frame.delegationMcp &&
1118
- (existing.delegationLeaseId !== delegationLeaseId(frame.delegationMcp) || existing.delegationRefreshPending === true);
1119
- if (refresh) {
1120
- if (!existing.nativeSessionId || (frame.nativeSessionId && frame.nativeSessionId !== existing.nativeSessionId)) {
1121
- this.emit({ t: 'agent_opened', id: frame.id, ok: false, error: 'Managed delegation cannot reload the original session' });
1122
- return;
1123
- }
1124
- if (existing.session.liveness.turnRunning || activityIsBusy(existing.lastActivityRaw)) {
1125
- existing.delegationRefreshPending = true;
1126
- if (!await this.resumeAndReply(existing, frame, false))
1127
- return;
1128
- this.replyNoReplayNeeded(frame);
1129
- return;
1130
- }
1131
- openFrame = { ...frame, nativeSessionId: existing.nativeSessionId, requireSessionLoad: true };
1132
- retireForRefresh = existing;
1133
- }
1134
- else {
1135
- // Reusing a live session: neither the process nor the native session moved.
1136
- if (!await this.resumeAndReply(existing, frame, false))
1137
- return;
1138
- // The session already exists, so any history it has was already streamed under whichever
1139
- // open first established it (or never will be, if that open didn't ask for it) — replaying it
1140
- // again here would duplicate what the platform already holds. But if THIS frame asked for
1141
- // backfill, the server is waiting on exactly one `agent_backfill_done` for THIS open's id:
1142
- // silence is not "no", it is a wait that never resolves. Answer it without replaying.
1143
- this.replyNoReplayNeeded(frame);
1482
+ // Reusing a live session: neither the process nor the native session moved.
1483
+ if (!await this.resumeAndReply(existing, frame, false))
1144
1484
  return;
1145
- }
1485
+ // The session already exists, so any history it has was already streamed under whichever
1486
+ // open first established it (or never will be, if that open didn't ask for it) — replaying it
1487
+ // again here would duplicate what the platform already holds. But if THIS frame asked for
1488
+ // backfill, the server is waiting on exactly one `agent_backfill_done` for THIS open's id:
1489
+ // silence is not "no", it is a wait that never resolves. Answer it without replaying.
1490
+ this.replyNoReplayNeeded(frame);
1491
+ return;
1146
1492
  }
1147
1493
  const inFlight = this.opening.get(frame.sessionRef);
1148
1494
  if (inFlight) {
1149
1495
  // Piggyback on the attempt already running, and report its real failure — not a generic one.
1150
1496
  const { live, error, agentRefused } = await inFlight;
1151
1497
  if (live) {
1152
- if (!!frame.delegationMcp !== !!live.delegationExpiresAtEpochMs ||
1153
- (frame.delegationMcp && live.delegationLeaseId !== delegationLeaseId(frame.delegationMcp))) {
1154
- this.emit({ t: 'agent_opened', id: frame.id, ok: false, error: 'Managed delegation lease does not match the live session' });
1155
- return;
1156
- }
1157
1498
  // Piggybacking on the attempt already running: a non-null `live` means that attempt was a
1158
1499
  // real open (`session/load` or `session/new`), which this frame is reporting secondhand.
1159
1500
  if (!await this.resumeAndReply(live, frame, true))
@@ -1177,14 +1518,15 @@ export class AgentSessionManager {
1177
1518
  }
1178
1519
  return;
1179
1520
  }
1521
+ // A respawn of a dead keeper session keeps its (older) generation held across the retire and the spawn (Task 21 fix round 2).
1522
+ const releaseHold = retireForRefresh && this.needsRespawn(retireForRefresh) ? this.agents.holdForReopen?.(frame.sessionRef) : undefined;
1180
1523
  const attempt = (async () => {
1181
1524
  if (retireForRefresh)
1182
1525
  await this.retire(retireForRefresh, 'keep');
1183
- return this.openSession(openFrame);
1526
+ return this.openSession(openFrame, recordedBuildId);
1184
1527
  })()
1185
- .catch((err) => ({
1186
- live: null, error: openFrame.delegationMcp ? 'Managed delegation session could not open' : errorMessage(err),
1187
- }));
1528
+ .catch((err) => ({ live: null, error: errorMessage(err) }))
1529
+ .finally(() => releaseHold?.());
1188
1530
  this.opening.set(frame.sessionRef, attempt);
1189
1531
  let outcome;
1190
1532
  try {
@@ -1206,6 +1548,7 @@ export class AgentSessionManager {
1206
1548
  // tombstone without anything ever closing it. Nothing but microtasks separates `attempt`
1207
1549
  // resolving from the finally, and microtasks always drain before the next inbound frame.
1208
1550
  if (outcome.live) {
1551
+ this.retainedRespawnBuilds.delete(frame.sessionRef);
1209
1552
  // openSession returned successfully, having just done session/load or session/new.
1210
1553
  if (!await this.resumeAndReply(outcome.live, frame, true))
1211
1554
  return;
@@ -1216,6 +1559,8 @@ export class AgentSessionManager {
1216
1559
  await this.streamBackfill(frame.id, outcome.live.session);
1217
1560
  }
1218
1561
  else {
1562
+ if (recordedBuildId !== undefined)
1563
+ this.retainedRespawnBuilds.set(frame.sessionRef, recordedBuildId);
1219
1564
  this.emit({
1220
1565
  t: 'agent_opened', id: frame.id, ok: false, error: outcome.error,
1221
1566
  // Only when the agent is the one that said no, and then it is the one refusal that can be
@@ -1228,6 +1573,330 @@ export class AgentSessionManager {
1228
1573
  });
1229
1574
  }
1230
1575
  }
1576
+ // ---- switching (Task 24, spec §6.3) ---------------------------------------------------------------------------------------
1577
+ /** Why `live` may not be switched now (the strict idle criterion, `switching.ts`); empty = strictly idle. */
1578
+ switchBlockersOf(live, now) {
1579
+ const ref = live.sessionRef;
1580
+ return switchBlockers({
1581
+ session: live.session,
1582
+ lastActivityAt: live.lastActivityAt,
1583
+ busyRef: this.opening.has(ref) || this.retiring.has(ref) || this.adopting.has(ref) || this.switching.has(ref),
1584
+ now, quietMs: this.switchQuietMs,
1585
+ });
1586
+ }
1587
+ /**
1588
+ * An `agent_open` of a held session with a switch pending (spec §6.3): when the session is strictly idle, it is moved here — the
1589
+ * open carries the spawn environment only the server has — and this open is answered with the session that holds the ref
1590
+ * afterwards (`reloaded`; the server takes its runtime diagnostics whole). Every other frame of the ref is parked meanwhile.
1591
+ * `false`: nothing to switch now (no intent, not idle, draining) — the caller reuses the session as usual.
1592
+ */
1593
+ async switchAtOpen(live, frame) {
1594
+ const agents = this.agents;
1595
+ if (!agents.switchSession || !agents.pendingSwitchFor)
1596
+ return false;
1597
+ // A switch spawns an adapter: new work, refused while the workstation drains (spec §8.2).
1598
+ if (this.tasks && !this.tasks.accepting)
1599
+ return false;
1600
+ let target;
1601
+ try {
1602
+ target = await agents.pendingSwitchFor(live.sessionRef, live.session);
1603
+ }
1604
+ catch {
1605
+ return false;
1606
+ }
1607
+ if (!target || live.closed || this.sessions.get(live.sessionRef) !== live)
1608
+ return false;
1609
+ const blockers = this.switchBlockersOf(live, Date.now());
1610
+ if (blockers.length > 0) {
1611
+ console.info(`[agent] ${live.sessionRef}: switch (${target.reason}) waits: ${blockers.join('; ')}`);
1612
+ return false;
1613
+ }
1614
+ const ref = live.sessionRef;
1615
+ // Like an open, the switch is counted while it runs (spec §8.2: a half-made keeper session would be stopped by a restart) — and
1616
+ // a workstation that stopped admitting refuses it.
1617
+ if (this.tasks && !this.tasks.tryReserveAgentOpen(ref))
1618
+ return false;
1619
+ let finished;
1620
+ this.switching.set(ref, new Promise((resolve) => { finished = resolve; }));
1621
+ try {
1622
+ const resumeFrom = resumeFromSeqOf(frame);
1623
+ const outcome = await agents.switchSession(live.session, {
1624
+ ...openSpecOf(frame), seqBase: () => resumeFrom,
1625
+ ...(live.nativeSessionId ? { nativeSessionId: live.nativeSessionId } : {}),
1626
+ // Forced whatever the frame says (spec §5.5.4): a concurrent open can win the server's reopen without the flag.
1627
+ requireSessionLoad: true,
1628
+ }, { ...target, proceed: () => !this.tasks?.isForceStopping() && !live.closed });
1629
+ await this.afterSwitch(live, frame, outcome);
1630
+ return true;
1631
+ }
1632
+ finally {
1633
+ this.tasks?.agentOpenFinished(ref);
1634
+ this.switching.delete(ref);
1635
+ finished();
1636
+ }
1637
+ }
1638
+ async afterSwitch(live, frame, outcome) {
1639
+ switch (outcome.kind) {
1640
+ case 'unmovable':
1641
+ // Nothing was torn down: the session goes on in its process, as for any reopen of a live session.
1642
+ if (!await this.resumeAndReply(live, frame, false))
1643
+ return;
1644
+ this.replyNoReplayNeeded(frame);
1645
+ return;
1646
+ case 'switched':
1647
+ case 'rolledBack': {
1648
+ const next = this.replaceSession(live, outcome.session, outcome.nativeSessionId);
1649
+ if (!await this.resumeAndReply(next, frame, true))
1650
+ return;
1651
+ this.replyNoReplayNeeded(frame);
1652
+ return;
1653
+ }
1654
+ case 'lost': {
1655
+ // Its adapter is gone and none could load it: retired like a degraded session (outbox kept), the server told. Its next
1656
+ // `agent_open` reopens it the ordinary way.
1657
+ this.unhold(live);
1658
+ this.notifySwept(live.sessionRef);
1659
+ const error = `The agent session could not be moved to another adapter process: ${outcome.error}`;
1660
+ this.emit({ t: 'agent_opened', id: frame.id, ok: false, error });
1661
+ if (frame.backfill)
1662
+ this.emit({ t: 'agent_backfill_done', id: frame.id, ok: false, count: 0, error });
1663
+ }
1664
+ }
1665
+ }
1666
+ /** Stop holding `live` (its session is already closed by whoever ended it). */
1667
+ unhold(live) {
1668
+ // Replaced by a switch's successor or rollback, or lost by a switch: a different process from now on.
1669
+ this.adapterCrashes.delete(live.sessionRef);
1670
+ live.closed = true;
1671
+ live.unwatch();
1672
+ if (this.sessions.get(live.sessionRef) === live)
1673
+ this.sessions.delete(live.sessionRef);
1674
+ this.tasks?.unregisterAgentSession(live.sessionRef);
1675
+ }
1676
+ /** The ref is now held by `session` (a switch's successor or its rollback): registered exactly like an opened session. */
1677
+ replaceSession(live, session, nativeSessionId) {
1678
+ this.unhold(live);
1679
+ let highestScopedJournalSeq = 0;
1680
+ for (const item of session.outbox.pending) {
1681
+ if (item.frame.t === 'agent_event' && item.frame.acpSessionId != null)
1682
+ highestScopedJournalSeq = Math.max(highestScopedJournalSeq, item.seq);
1683
+ }
1684
+ const next = {
1685
+ sessionRef: live.sessionRef, agentId: live.agentId, agentKey: live.agentKey, session, nativeSessionId,
1686
+ scopedReplayBlocked: false, highestScopedJournalSeq, unwatch: () => { }, replays: Promise.resolve(), closed: false,
1687
+ lastActivityAt: Date.now(),
1688
+ };
1689
+ this.register(next);
1690
+ return next;
1691
+ }
1692
+ /**
1693
+ * The daemon's switch scheduler (spec §6.3 step 7; the daemon calls it every few seconds): for each held session with a switch
1694
+ * pending that is strictly idle, ask the server for an `agent_open` (`agent_reopen_request`, wire ≥ 25 only) — the switch happens
1695
+ * at that open. Also (Task 23's carry): a keeper-hosted session whose adapter keeper-acp lost (`keeper_lost`/`keeper_stopped` —
1696
+ * not an exit of the adapter's own, which would loop) gets its reopen asked for, so it is respawned and loaded without waiting for
1697
+ * the user's next message — at most {@link ADAPTER_LOST_REOPEN_ATTEMPTS} times per lost spawn, backing off from
1698
+ * {@link REOPEN_REQUEST_RETRY_MS}, never while draining. A switch request the server did not answer is asked again after
1699
+ * {@link REOPEN_REQUEST_RETRY_MS}. Returns the refs a request went out for. Never rejects.
1700
+ */
1701
+ async pollSwitches(now = Date.now(), only) {
1702
+ await this.adoptLate(); // the periodic tick also retries a keeper-acp listing that failed (final review finding 3)
1703
+ const requested = [];
1704
+ for (const live of [...this.sessions.values()]) {
1705
+ const ref = live.sessionRef;
1706
+ if (live.closed || (only && !only.has(ref)))
1707
+ continue;
1708
+ const last = this.reopenRequests.get(ref);
1709
+ const recently = (why) => last !== undefined && last.why === why && now - last.at < REOPEN_REQUEST_RETRY_MS * 2 ** Math.max(0, last.attempts - 1);
1710
+ const busyRef = this.opening.has(ref) || this.retiring.has(ref) || this.adopting.has(ref) || this.switching.has(ref);
1711
+ const session = live.session;
1712
+ const exit = session.adapterExit;
1713
+ if (session.durable && exit && !session.liveness.agentAlive && !session.isDegraded) {
1714
+ // Only a session that had a turn or a card open when keeper-acp lost its adapter (what this daemon saw fold): an idle one
1715
+ // was reported lost and waits for its next message — a keeper-acp restart must not respawn every idle session at once.
1716
+ const lostByKeeper = (exit.reason === 'keeper_lost' || exit.reason === 'keeper_stopped') &&
1717
+ live.adapterLost?.spawnId === exit.spawnId && live.adapterLost?.busy === true;
1718
+ const why = `adapter-lost:${exit.spawnId ?? '?'}`;
1719
+ // Bounded per lost spawn (fix round 1): 60 s, 120 s, 240 s apart, then the user's next message reopens it. Not while
1720
+ // draining: a respawn is new work (spec §8.2).
1721
+ const attempts = last?.why === why ? last.attempts : 0;
1722
+ if (lostByKeeper && live.nativeSessionId && !busyRef && !session.liveness.turnRunning && !recently(why) &&
1723
+ attempts < ADAPTER_LOST_REOPEN_ATTEMPTS && (!this.tasks || this.tasks.accepting) && this.requestReopen(ref, 'agent_exited')) {
1724
+ this.reopenRequests.set(ref, { at: now, why, attempts: attempts + 1 });
1725
+ requested.push(ref);
1726
+ }
1727
+ continue;
1728
+ }
1729
+ if (!this.agents.pendingSwitchFor)
1730
+ continue;
1731
+ let target;
1732
+ try {
1733
+ target = await this.agents.pendingSwitchFor(ref, live.session);
1734
+ }
1735
+ catch {
1736
+ continue;
1737
+ }
1738
+ if (!target || live.closed || (this.tasks && !this.tasks.accepting))
1739
+ continue;
1740
+ if (this.switchBlockersOf(live, now).length > 0 || recently('switch'))
1741
+ continue;
1742
+ if (this.requestReopen(ref, target.reason)) {
1743
+ this.reopenRequests.set(ref, { at: now, why: 'switch', attempts: 1 });
1744
+ requested.push(ref);
1745
+ }
1746
+ }
1747
+ return requested;
1748
+ }
1749
+ /**
1750
+ * "Move idle sessions" (Task 25's `agent_switch_idle`, spec §6.4): every held session of `agentId` that runs another build than
1751
+ * `toBuild` (else the agent's current build) gets a persistent intent to move there; those already strictly idle get their
1752
+ * `agent_reopen_request` at once, the others when they become idle. A session that can never be moved this way
1753
+ * ({@link unmovableWhy}: a local-fallback session, no native session, a target that already refused it) gets
1754
+ * no intent — it would only be asked to reopen again and again — and is listed in `unmovable` with the reason. A `toBuild` that is
1755
+ * not installed for the agent is refused (throws). Returns the refs registered, the ones a request went out for, and the unmovable.
1756
+ */
1757
+ async switchIdle(agentId, options = {}) {
1758
+ const { requestSwitch, placementOf } = this.agents;
1759
+ if (!requestSwitch || !placementOf)
1760
+ return { registered: [], requested: [], unmovable: [] };
1761
+ let target = options.toBuild;
1762
+ if (this.agents.buildIdsOf) {
1763
+ const ids = await this.agents.buildIdsOf(agentId);
1764
+ if (target !== undefined && !ids.installed.includes(target))
1765
+ throw new Error(`Adapter build ${target} of '${agentId}' is not installed on this workstation`);
1766
+ target ??= ids.current;
1767
+ }
1768
+ const reason = options.reason ?? (options.toBuild ? `move to adapter build ${options.toBuild}` : 'move to the current adapter build');
1769
+ const registered = [];
1770
+ const unmovable = [];
1771
+ for (const live of [...this.sessions.values()]) {
1772
+ if (live.closed || live.agentId !== agentId)
1773
+ continue;
1774
+ const placement = placementOf.call(this.agents, live.session);
1775
+ // A command override (a custom agent) has no build to move — and the scan does not count it either.
1776
+ if (placement && placement.buildId === undefined)
1777
+ continue;
1778
+ // Already there: nothing to move, and nothing to report as unmovable either.
1779
+ if (target !== undefined && placement?.buildId === target)
1780
+ continue;
1781
+ const why = this.unmovableWhy(live, target);
1782
+ if (why !== undefined) {
1783
+ console.info(`[agent] ${live.sessionRef}: not moved to ${target ?? 'the current build'}: ${why.detail ?? why.plain}`);
1784
+ unmovable.push({ sessionRef: live.sessionRef, reason: why.plain });
1785
+ continue;
1786
+ }
1787
+ await requestSwitch.call(this.agents, agentId, live.sessionRef, { ...(options.toBuild ? { toBuild: options.toBuild } : {}), reason, requestedBy: 'user' });
1788
+ registered.push(live.sessionRef);
1789
+ }
1790
+ const requested = await this.pollSwitches(Date.now(), new Set(registered));
1791
+ return { registered, requested, unmovable };
1792
+ }
1793
+ /**
1794
+ * Why `live` can never be moved to `target` by a switch (spec §6.3 "当前限制"), or `undefined`: a session keeper-acp does not host
1795
+ * (the local fallback), a session with no native session to load, or one a switch to the same build already
1796
+ * found unmovable. `plain` is what the scan card and the result frame say (Task 25); `detail` is the switch's own words, for the
1797
+ * log, when there are any.
1798
+ */
1799
+ unmovableWhy(live, target) {
1800
+ const placement = this.agents.placementOf?.call(this.agents, live.session);
1801
+ if (!placement?.keeper)
1802
+ return { plain: LOCAL_SESSION_UNMOVABLE };
1803
+ if (!live.nativeSessionId)
1804
+ return { plain: 'it has no saved conversation the new build could reload' };
1805
+ const refused = this.agents.unmovableFor?.call(this.agents, live.sessionRef);
1806
+ if (refused && (refused.toBuild === undefined || refused.toBuild === target)) {
1807
+ return { plain: `the ${refused.toBuild ?? 'new'} adapter could not take it over when this was last tried`, detail: refused.reason };
1808
+ }
1809
+ return undefined;
1810
+ }
1811
+ /**
1812
+ * How the external-agents scan counts one running session (spec §6.4, Task 25; `AcpAgents.roster({sessions})`): `unmovable` with
1813
+ * the reason ({@link unmovableWhy}; also a session this daemon does not hold), else `busy` with the strict idle criterion's blockers
1814
+ * ({@link switchBlockersOf}), else `idle`. `pendingTo`: the build a pending switch intent still owes it. Never rejects.
1815
+ */
1816
+ async rosterSessionState(running) {
1817
+ const live = this.sessions.get(running.sessionRef);
1818
+ if (!live || live.closed)
1819
+ return { state: 'unmovable', reasons: ['it is not connected to a conversation right now'] };
1820
+ const current = this.agents.buildIdsOf ? (await this.agents.buildIdsOf(running.agentId).catch(() => undefined))?.current : undefined;
1821
+ let pendingTo;
1822
+ try {
1823
+ pendingTo = (await this.agents.pendingSwitchFor?.(running.sessionRef, live.session))?.toBuild;
1824
+ }
1825
+ catch { /* an unreadable builds.json: nothing pending to report */ }
1826
+ const pending = pendingTo !== undefined ? { pendingTo } : {};
1827
+ const why = this.unmovableWhy(live, current);
1828
+ if (why !== undefined)
1829
+ return { state: 'unmovable', reasons: [why.plain], ...pending };
1830
+ // The card's words, not the log's (`plainSwitchBlocker`); `switchAtOpen` still logs the internal sentences.
1831
+ const blockers = this.switchBlockersOf(live, Date.now()).map(plainSwitchBlocker);
1832
+ return blockers.length > 0 ? { state: 'busy', reasons: blockers, ...pending } : { state: 'idle', ...pending };
1833
+ }
1834
+ /** Answers `agent_switch_idle` (Task 25): {@link switchIdle}, counted. */
1835
+ async onSwitchIdle(frame) {
1836
+ try {
1837
+ const result = await this.switchIdle(frame.bridge, frame.toBuild !== undefined ? { toBuild: frame.toBuild } : {});
1838
+ this.emit({
1839
+ t: 'agent_switch_idle_result', id: frame.id, ok: true,
1840
+ registered: result.registered.length, immediate: result.requested.length, unmovable: result.unmovable,
1841
+ });
1842
+ }
1843
+ catch (error) {
1844
+ this.emit({ t: 'agent_switch_idle_result', id: frame.id, ok: false, error: errorMessage(error) });
1845
+ }
1846
+ }
1847
+ /**
1848
+ * The Workstations page's view of a held session (Task 26, spec §8.1): which registry agent it runs and whether it is busy right
1849
+ * now — a turn in flight or the agent reporting itself active (the same sample `activeAgentTasks` once used). `undefined` when this
1850
+ * daemon does not hold the ref.
1851
+ */
1852
+ sessionView(sessionRef) {
1853
+ const live = this.sessions.get(sessionRef);
1854
+ if (!live || live.closed)
1855
+ return undefined;
1856
+ let busy = false;
1857
+ try {
1858
+ busy = live.session.liveness.turnRunning || activityIsBusy(live.session.activityRaw);
1859
+ }
1860
+ catch {
1861
+ busy = false;
1862
+ }
1863
+ return { agentId: live.agentId, busy };
1864
+ }
1865
+ /**
1866
+ * keeper-acp replacement (spec §8.3, for Task 26): every held session on an older keeper-acp generation — only on `gens` when given
1867
+ * (the generations a replacement was decided for; another older one may merely be `available`) — gets a persistent intent to move
1868
+ * to the current generation when idle (same build). Returns the refs registered.
1869
+ */
1870
+ async moveOffOlderKeeperGenerations(reason = 'keeper-acp was upgraded', gens) {
1871
+ const { requestSwitch, placementOf } = this.agents;
1872
+ if (!requestSwitch || !placementOf)
1873
+ return [];
1874
+ const registered = [];
1875
+ for (const live of [...this.sessions.values()]) {
1876
+ const placement = placementOf.call(this.agents, live.session);
1877
+ if (live.closed || !placement?.keeper || placement.gen === undefined)
1878
+ continue;
1879
+ if (gens && !gens.includes(placement.gen))
1880
+ continue;
1881
+ await requestSwitch.call(this.agents, live.agentId, live.sessionRef, { keeperGen: 'current', reason, requestedBy: 'axom' });
1882
+ registered.push(live.sessionRef);
1883
+ }
1884
+ await this.pollSwitches(Date.now(), new Set(registered));
1885
+ return registered;
1886
+ }
1887
+ /** Cancel a pending switch: one ref's, or every one of an agent's. `true` when one existed. */
1888
+ async cancelSwitch(target) {
1889
+ if (!this.agents.cancelSwitch)
1890
+ return false;
1891
+ const cancelled = await this.agents.cancelSwitch(target);
1892
+ if ('sessionRef' in target)
1893
+ this.reopenRequests.delete(target.sessionRef);
1894
+ else
1895
+ for (const live of this.sessions.values())
1896
+ if (live.agentId === target.agentId)
1897
+ this.reopenRequests.delete(live.sessionRef);
1898
+ return cancelled;
1899
+ }
1231
1900
  /**
1232
1901
  * Answers a backfill-flagged open that landed on an already-live (or already-being-opened)
1233
1902
  * session, without replaying anything.
@@ -1254,8 +1923,8 @@ export class AgentSessionManager {
1254
1923
  * and `agent_open` already has the whole open path (spec construction, ok/error reply) that a
1255
1924
  * standalone frame would force the daemon to rebuild a second copy of.
1256
1925
  *
1257
- * Deliberately outside the live event/journal machinery — no `bridge_seq`, no
1258
- * {@link SessionJournal} entry, no `agent_event_ack` round trip. `bridge_seq` means one specific
1926
+ * Deliberately outside the live event/outbox machinery — no `bridge_seq`, no
1927
+ * outbox entry, no `agent_event_ack` round trip. `bridge_seq` means one specific
1259
1928
  * thing: live-consumption progress, the only basis for de-duplication across reconnects. Backfill
1260
1929
  * is a one-shot replay correlated purely by this open's `id`; folding it into that watermark would
1261
1930
  * give it two meanings, which is exactly what produced two watermark bugs in an earlier phase.
@@ -1289,6 +1958,8 @@ export class AgentSessionManager {
1289
1958
  batch.push(event);
1290
1959
  count += 1;
1291
1960
  if (batch.length >= BACKFILL_BATCH_SIZE) {
1961
+ // Bulk and never acknowledged: no session window, but it waits for the socket gate like session frames (agentFlow.ts).
1962
+ await this.gate.opened();
1292
1963
  this.emit({ t: 'agent_backfill_events', id, events: batch });
1293
1964
  batch = [];
1294
1965
  }
@@ -1324,36 +1995,29 @@ export class AgentSessionManager {
1324
1995
  `but the live one is '${live.nativeSessionId}'`);
1325
1996
  }
1326
1997
  const resumeFrom = resumeFromSeqOf(frame);
1327
- // On the session's own queue so an agent event produced mid-replay is sent after it, not into
1328
- // the middle of it.
1998
+ // The server applied through `resumeFrom` (its durable watermark): that is an ack. Everything after it is resent in order —
1999
+ // verbatim, carrying the original seq, so the server's de-duplication does the deciding. The outbox never drops its oldest
2000
+ // entries (the backlog stays in the raw log instead, §5.6), so there is no gap to announce any more.
1329
2001
  let incompatible = false;
1330
- await this.enqueue(live, async () => {
2002
+ // One replay at a time per session: two racing reopens resend the tail once, the second only what the first did not cover.
2003
+ const replay = live.replays.then(async () => {
1331
2004
  if (live.closed)
1332
2005
  return;
1333
2006
  incompatible = this.blockIncompatibleJournal(live);
1334
2007
  if (incompatible)
1335
2008
  return;
1336
- const entries = await live.journal.entriesAfter(resumeFrom);
2009
+ await this.acknowledge(live, resumeFrom);
1337
2010
  if (live.closed)
1338
2011
  return;
1339
- if (this.hasGap(live, entries, resumeFrom)) {
1340
- this.emit({
1341
- t: 'agent_event',
1342
- sessionRef: live.sessionRef,
1343
- // The hole left by the overflow drop starts exactly here. Reusing that seq (rather than
1344
- // taking a fresh one) is what makes the notice survive the server's `seq > bridgeSeq`
1345
- // filter *without* pushing the watermark past the replayed entries behind it.
1346
- seq: resumeFrom + 1,
1347
- eventName: 'step',
1348
- payload: { message: 'transport gap: oldest streamed output dropped', tone: 'neutral' },
1349
- });
1350
- }
1351
- // Already-formed frames, carrying their original seq — resent verbatim so the server's
1352
- // de-duplication does the deciding.
1353
- for (const entry of entries)
1354
- this.emit(entry.frame);
1355
2012
  live.scopedReplayBlocked = false;
2013
+ // Sends synchronously while the window and the socket allow, so on an idle link the tail precedes `agent_opened`; on a
2014
+ // busy one `agent_opened` may overtake it — the session's frames keep their own order (analysis B §4.3), and a large tail
2015
+ // cannot push the reply past the server's open timeout.
2016
+ live.session.outbox.resendAll();
2017
+ this.pump();
1356
2018
  });
2019
+ live.replays = replay.catch((err) => console.error(`[agent] replay failed for ${live.sessionRef}: ${errorMessage(err)}`));
2020
+ await live.replays;
1357
2021
  if (incompatible) {
1358
2022
  const error = `Scoped journal replay requires agent wire ${AGENT_WIRE_SCOPED_ORIGIN_VERSION}; retained history was not delivered or acknowledged`;
1359
2023
  this.emit({ t: 'agent_opened', id: frame.id, ok: false, error });
@@ -1365,6 +2029,7 @@ export class AgentSessionManager {
1365
2029
  this.emit({ t: 'agent_opened', id: frame.id, ok: false, error: 'session was closed while opening' });
1366
2030
  return false;
1367
2031
  }
2032
+ const activityRaw = live.session.activityRaw;
1368
2033
  this.emit({
1369
2034
  t: 'agent_opened',
1370
2035
  id: frame.id,
@@ -1375,12 +2040,6 @@ export class AgentSessionManager {
1375
2040
  // Emitted only when true, the same `supportsSteering` convention as below: absent is the
1376
2041
  // field's only "no", and it is also what an older peer sends.
1377
2042
  ...(reloaded ? { reloaded: true } : {}),
1378
- ...(live.delegationExpiresAtEpochMs ? { delegationExpiresAtEpochMs: live.delegationExpiresAtEpochMs } : {}),
1379
- ...(live.delegationRefreshPending ? { delegationRefreshPending: true } : {}),
1380
- ...(this.serverAgentWire >= AGENT_WIRE_DELEGATION_RENEW_VERSION && live.delegationInstanceId ? {
1381
- delegationInstanceId: live.delegationInstanceId, delegationBindingId: live.delegationBindingId,
1382
- delegationRenewalRevision: live.delegationRenewalRevision,
1383
- } : {}),
1384
2043
  // The server learns this here and nowhere else. It used to be published only on the roster in
1385
2044
  // the `agent_hello` handshake, which is a snapshot taken *before* any agent was connected to —
1386
2045
  // and the capability can only be observed on a live `initialize`, whose record
@@ -1441,106 +2100,89 @@ export class AgentSessionManager {
1441
2100
  ...(reportsActivity(live.session.agentInfo?.name) ? { reportsActivity: true } : {}),
1442
2101
  ...(live.nativeSessionId && supportsAsyncTaskStop(live.session.agentInfo?.name)
1443
2102
  ? { supportsAsyncTaskStop: true } : {}),
1444
- // This session's activity-state snapshot as of right now, resent here because
2103
+ // A keeper-hosted session outlives this daemon (spec §5.5.4): the server resends answers it recorded for the cards still
2104
+ // pending (a daemon that died between receiving an answer and writing it would leave the adapter blocked), and decides an
2105
+ // unconfirmed steer by whether it was steered. Additive keys: a server that does not read them ignores them.
2106
+ ...(live.session.durable ? {
2107
+ durable: true,
2108
+ pendingRequestIds: [...(live.session.pendingRequestIds ?? [])],
2109
+ steeredIds: [...(live.session.steeredIds ?? [])],
2110
+ } : {}),
2111
+ // This session's activity-state snapshot as of right now (processor state), resent here because
1445
2112
  // `agent_session_state` is never journaled and a reopen would otherwise have nothing to
1446
- // recover it from — see `LiveSession.lastActivityRaw`'s own doc. Absent when nothing has ever
1447
- // arrived (including "no collector for this agent"), never a synthesized default.
1448
- ...(live.lastActivityRaw !== undefined ? { activityRaw: live.lastActivityRaw } : {}),
2113
+ // recover it from. Absent when nothing has ever arrived (including "no collector for this
2114
+ // agent"), never a synthesized default.
2115
+ ...(activityRaw !== undefined ? { activityRaw } : {}),
1449
2116
  });
1450
2117
  return true;
1451
2118
  }
1452
- /**
1453
- * Whether the overflow drop actually took something the server still needs. `droppedOldest` alone
1454
- * is not enough: by the time of this reconnect the server may have acked past the hole, and
1455
- * announcing a gap that no longer exists would both mislead the user and collide with the seq of
1456
- * the first replayed entry.
1457
- */
1458
- hasGap(live, entries, resumeFrom) {
1459
- if (!live.journal.droppedOldest)
1460
- return false;
1461
- const first = entries[0];
1462
- return first ? first.seq > resumeFrom + 1 : live.journal.lastSeq > resumeFrom;
1463
- }
1464
- async openSession(frame) {
1465
- // A previous incarnation of this ref may still be deleting its journal; two SessionJournal
1466
- // instances must never share a path (see {@link retiring}). This is the first statement on
1467
- // purpose — before init, before the agent spawns, before anything touches the filesystem.
2119
+ async openSession(frame, recordedBuildId) {
2120
+ // A previous incarnation of this ref may still be closing: its adapter stopped and its transport session (raw, outbox) kept or
2121
+ // released — the new spawn goes under the same key (see {@link retiring}). This is the first statement on purpose — before the
2122
+ // agent spawns, before anything touches the transport. The one-time legacy journal migration lands before any open too.
1468
2123
  await this.retiring.get(frame.sessionRef);
1469
- // spec.env is an OVERLAY merged onto process.env when the agent is spawned (see
1470
- // AcpOpenSpec.env), so it is built from frame-supplied values only. The single exception is
1471
- // PATH: when pathPrepend is set we resolve the whole variable here — prepends first, then the
1472
- // frame's PATH or, failing that, the daemon's — so the launcher uses spec.env.PATH verbatim
1473
- // instead of re-deriving it.
1474
- const env = { ...(frame.env ?? {}) };
1475
- const pathPrepend = frame.pathPrepend ?? [];
1476
- if (pathPrepend.length > 0) {
1477
- env.PATH = [...pathPrepend, env.PATH ?? process.env.PATH ?? ''].filter(Boolean).join(path.delimiter);
1478
- }
1479
- // Recovered before the agent spawns, because nextSeq() is synchronous and the first event may
1480
- // arrive the instant onEvent is registered: the high-water mark has to be known by then or a
1481
- // restarted daemon would restart seq at 1 and the server would swallow the next turn as
1482
- // already-applied. The frame's watermark is passed in as a floor under whatever the file
1483
- // yields, because "the file is empty" is the *normal* state between turns — everything in it
1484
- // was acked and truncated — so the file alone would say 0 while the server sits at N.
1485
- // init() only reads — the file appears on the first append — so an open that fails here leaves
1486
- // nothing behind.
1487
- const journal = new SessionJournal(this.sessionsDir, frame.sessionRef, this.maxJournalBytes);
1488
- let highestScopedJournalSeq = 0;
1489
- try {
1490
- await journal.init(resumeFromSeqOf(frame));
1491
- // Recovery is the only full scan needed for compatibility. Appends and ACKs below maintain
1492
- // the high watermark incrementally, including while a v17 connection is offline.
1493
- for (const entry of await journal.entriesAfter(0)) {
1494
- if (entry.frame.t === 'agent_event' && entry.frame.acpSessionId != null) {
1495
- highestScopedJournalSeq = Math.max(highestScopedJournalSeq, entry.seq);
1496
- }
1497
- }
1498
- }
1499
- catch (err) {
1500
- return { live: null, error: `journal init failed: ${errorMessage(err)}` };
1501
- }
1502
- // Both journal recovery and a retiring prior incarnation can await before this point. Only a
1503
- // force-stop may cancel an open admitted before that await; graceful drain owns its reservation
1504
- // and must let it settle without starting a second admission decision halfway through.
2124
+ await this.adopting.get(frame.sessionRef)?.done;
2125
+ await this.migrateLegacyJournals();
2126
+ // The server's watermark is the floor the processor numbers from (AcpOpenSpec.seqBase); the outbox an earlier life left under
2127
+ // the ref raises it (`max(outbox.maxSeq, committedTail.seq, resumeFromSeq)`, §5.5) — a restarted daemon never restarts seq at 1,
2128
+ // or the server would swallow the next turn as already applied.
2129
+ const resumeFrom = resumeFromSeqOf(frame);
2130
+ // Only a force-stop may cancel an open admitted before the awaits above; graceful drain owns its reservation and must let it
2131
+ // settle without starting a second admission decision halfway through.
1505
2132
  if (this.tasks?.isForceStopping()) {
1506
- await journal.destroy();
1507
2133
  return { live: null, error: 'Workstation is not accepting new work' };
1508
2134
  }
1509
2135
  let session;
1510
2136
  let nativeSessionId;
2137
+ // Spec 2026-10-11 §6.2: an agent with nothing installed yet is installed once, by this open. A success becomes the session's
2138
+ // own `agent_install_outcome` note (AcpAgents → the processor numbers one past-tense `step`, recorded in the outbox like any
2139
+ // event). A failure has no session to go on: its `step` is sent here, numbered right after the server's watermark — not
2140
+ // recorded anywhere (the open fails with the same reason, and a later open numbers from the server's watermark again).
2141
+ // Nothing is written while the install runs: a durable "Installing…" card would never be updated and read as stuck.
2142
+ let failedInstall;
2143
+ const onInstallOutcome = (outcome) => {
2144
+ if (!outcome.ok)
2145
+ failedInstall = outcome;
2146
+ };
2147
+ // Sent before the failed open's reply — unless an earlier life left frames under the ref: one of them may hold
2148
+ // `resumeFrom + 1`, and the server would keep the step and drop that frame as a duplicate. `agent_opened{ok:false}` carries
2149
+ // the same error then.
2150
+ const reportFailedInstall = async () => {
2151
+ if (!failedInstall)
2152
+ return;
2153
+ let leftover = false;
2154
+ try {
2155
+ leftover = ((await this.agents.leftoverSessions?.()) ?? []).includes(frame.sessionRef);
2156
+ }
2157
+ catch {
2158
+ leftover = true;
2159
+ }
2160
+ if (leftover)
2161
+ return;
2162
+ this.emit({
2163
+ t: 'agent_event', sessionRef: frame.sessionRef, seq: resumeFrom + 1, eventName: 'step',
2164
+ payload: { title: 'Agent adapter', message: `Could not install ${failedInstall.agentName} adapter ${failedInstall.version}`, detail: failedInstall.error, tone: 'error' },
2165
+ });
2166
+ };
1511
2167
  try {
1512
2168
  const result = await this.agents.open({
1513
- openRequestId: frame.id,
1514
- // `frame.bridge` is the registry agent id now — the wire field keeps its name because Plan A
1515
- // renames nothing (Global Constraint 3); ruling R2 replaced what it *means*.
1516
- agentId: frame.bridge,
1517
- cwd: frame.cwd,
1518
- env,
1519
- agentEnv: frame.agentEnv ?? {},
1520
- ...(frame.command ? { command: frame.command } : {}),
1521
- args: frame.args ?? [],
1522
- ...(frame.nativeSessionId ? { nativeSessionId: frame.nativeSessionId } : {}),
1523
- ...(frame.requireSessionLoad ? { requireSessionLoad: true } : {}),
1524
- ...(frame.delegationMcp ? { delegationMcp: frame.delegationMcp } : {}),
1525
- // Retaining a whole adopted history costs memory for the session's lifetime, so it is only
1526
- // kept when this open is the one that will stream it back.
1527
- backfill: frame.backfill === true,
2169
+ ...openSpecOf(frame),
2170
+ ...(recordedBuildId !== undefined ? { recordedBuildId } : {}),
2171
+ onInstallOutcome,
2172
+ seqBase: () => resumeFrom,
1528
2173
  });
1529
2174
  session = result.session;
1530
2175
  nativeSessionId = result.nativeSessionId;
1531
- if (frame.delegationMcp && !nativeSessionId) {
1532
- await session.close();
1533
- return { live: null, error: 'Managed delegation requires a native session ID' };
1534
- }
1535
2176
  }
1536
2177
  catch (err) {
2178
+ await reportFailedInstall();
1537
2179
  // `open` throws on both sides of the agent's `initialize` — nothing to launch and a spawn
1538
2180
  // that failed look identical from here — so the marker is asked for rather than assumed. Only
1539
2181
  // an {@link AgentRefusedSessionError} is the agent's own no, which is what an `auth_required`
1540
2182
  // on `session/new` arrives as; see {@link OpenOutcome.agentRefused}.
1541
2183
  return {
1542
2184
  live: null,
1543
- error: frame.delegationMcp ? 'Managed delegation session could not open' : errorMessage(err),
2185
+ error: errorMessage(err),
1544
2186
  // The code comes off the marker, not off `errorMessage(err)` above: the wrapper captured it
1545
2187
  // from the original rejection at the moment that object still existed.
1546
2188
  ...(err instanceof AgentRefusedSessionError
@@ -1557,7 +2199,6 @@ export class AgentSessionManager {
1557
2199
  catch (err) {
1558
2200
  console.error(`[agent] close of force-stopped open failed for ${frame.sessionRef}: ${errorMessage(err)}`);
1559
2201
  }
1560
- await journal.destroy();
1561
2202
  return { live: null, error: 'Workstation is not accepting new work' };
1562
2203
  }
1563
2204
  // The session became pointless while it was spawning (agent_close, or the socket dropped and the
@@ -1567,57 +2208,58 @@ export class AgentSessionManager {
1567
2208
  if (abandonReason) {
1568
2209
  this.closedDuringOpen.delete(frame.sessionRef);
1569
2210
  try {
1570
- await session.close();
2211
+ // Only an explicit agent_close retires the ref, and only then does its outbox go. A socket
2212
+ // drop must leave it alone: it may hold frames an earlier life left, and the server will
2213
+ // re-open the ref and ask for exactly those.
2214
+ await session.close(abandonReason === 'closed' ? 'release' : 'keep');
1571
2215
  }
1572
2216
  catch (err) {
1573
2217
  console.error(`[agent] close of abandoned open failed for ${frame.sessionRef}: ${errorMessage(err)}`);
1574
2218
  }
1575
- // Only an explicit agent_close retires the ref, and only then does its journal go. A socket
1576
- // drop must leave the file alone: it may hold entries this daemon recovered from a previous
1577
- // life, and the server will re-open the ref and ask for exactly those.
1578
- if (abandonReason === 'closed')
1579
- await journal.destroy();
1580
2219
  return { live: null, error: 'session closed during open' };
1581
2220
  }
2221
+ // Frames an earlier life left (read back into the outbox at start) keep the scoped high-water mark they had.
2222
+ let highestScopedJournalSeq = 0;
2223
+ for (const item of session.outbox.pending) {
2224
+ if (item.frame.t === 'agent_event' && item.frame.acpSessionId != null)
2225
+ highestScopedJournalSeq = Math.max(highestScopedJournalSeq, item.seq);
2226
+ }
1582
2227
  const live = {
1583
2228
  sessionRef: frame.sessionRef,
1584
2229
  agentId: frame.bridge,
1585
2230
  agentKey: frame.agentKey,
1586
2231
  session,
1587
2232
  nativeSessionId,
1588
- ...(frame.delegationMcp ? { delegationExpiresAtEpochMs: frame.delegationMcp.expiresAtEpochMs } : {}),
1589
- ...(frame.delegationMcp ? { delegationLeaseId: delegationLeaseId(frame.delegationMcp) } : {}),
1590
- ...(frame.delegationMcp?.bindingId ? { delegationBindingId: frame.delegationMcp.bindingId,
1591
- delegationInstanceId: randomUUID(), delegationRenewalRevision: 0 } : {}),
1592
- journal,
1593
- writes: Promise.resolve(),
1594
2233
  scopedReplayBlocked: false,
1595
2234
  highestScopedJournalSeq,
2235
+ unwatch: () => { },
2236
+ replays: Promise.resolve(),
1596
2237
  closed: false,
1597
2238
  lastActivityAt: Date.now(),
1598
2239
  };
1599
- this.sessions.set(frame.sessionRef, live);
1600
- this.tasks?.registerAgentSession(frame.sessionRef, () => this.retire(live, 'destroy'),
2240
+ this.register(live);
2241
+ return { live };
2242
+ }
2243
+ /** A session joins: held, registered with the TaskRegistry, its frames and its outbox wired to the sender. */
2244
+ register(live) {
2245
+ const session = live.session;
2246
+ this.detached.delete(live.sessionRef);
2247
+ this.sessions.set(live.sessionRef, live);
2248
+ const durable = session.durable === true && session.detach !== undefined;
2249
+ this.tasks?.registerAgentSession(live.sessionRef,
2250
+ // A keeper-hosted session is detached, never stopped, by the update paths (spec §5.5.5): its turn goes on in keeper-acp.
2251
+ () => this.retire(live, durable ? 'detach' : 'destroy'),
1601
2252
  // 两个层级量取或:本进程发出的提示词是否在途(agent 自己也许在续跑一个我们没发起的回合,
1602
2253
  // 那一种由第二项回答),以及 agent 自报的状态。两者都不累积,所以没有可泄漏的状态。
1603
- () => live.session.liveness.turnRunning || activityIsBusy(live.lastActivityRaw));
1604
- session.onEvent((e) => this.onAgentEvent(live, e));
1605
- session.onTurnDone((r) => this.onAgentTurnDone(live, r));
1606
- session.onActivity((raw) => this.onAgentActivity(live, raw));
1607
- session.onRuntimeDiagnostics?.((diagnostics) => {
1608
- if (live.closed || this.sessions.get(live.sessionRef) !== live ||
1609
- this.serverAgentWire < AGENT_WIRE_RUNTIME_PROFILE_VERSION)
1610
- return;
1611
- this.emit({ t: 'agent_runtime_profile', sessionRef: live.sessionRef, diagnostics });
1612
- });
1613
- session.onProviderTaskEvent?.((event) => {
1614
- if (live.closed || this.sessions.get(live.sessionRef) !== live || this.serverAgentWire < AGENT_WIRE_PROVIDER_TASK_VERSION ||
1615
- !live.session.providerTaskAdapterSelected || event.rootSessionId !== live.nativeSessionId ||
1616
- event.runtimeGeneration !== live.session.runtimeDiagnostics?.profile?.runtimeGeneration)
1617
- return;
1618
- this.emit({ t: 'agent_provider_task_event', sessionRef: live.sessionRef, ...event });
1619
- });
1620
- return { live };
2254
+ () => live.session.liveness.turnRunning || activityIsBusy(live.session.activityRaw), { durable });
2255
+ // Everything the session produces arrives as processor frames (Task 19): numbered by the processor, projected for the
2256
+ // connected server here, recorded in the outbox (the numbered ones) and sent from it (Task 20).
2257
+ live.unwatch = session.outbox.onChange(() => this.pump());
2258
+ session.onFrames((frames) => this.onSessionFrames(live, frames));
2259
+ if (session.accountLimits && this.serverAgentWire >= 27)
2260
+ this.emit({ t: 'agent_account_update', agentKey: live.agentKey, snapshot: session.accountLimits });
2261
+ session.onDegraded?.((reason) => { void this.onSessionDegraded(live, reason); });
2262
+ session.onAdapterExited?.((exit) => { this.onAdapterExited(live, exit); });
1621
2263
  }
1622
2264
  /**
1623
2265
  * Answers `agent_list_native` by asking the agent itself, over a throwaway connection this manager
@@ -1850,180 +2492,252 @@ export class AgentSessionManager {
1850
2492
  this.emit({ t: 'agent_installed', id: frame.id, ok: false, error: errorMessage(err) });
1851
2493
  }
1852
2494
  }
1853
- onAgentEvent(live, e) {
1854
- if (live.closed)
1855
- return;
1856
- // 这里曾经有一行:`tool_call` / `permission_request` 就登记一次「这条会话在忙」。它是在
1857
- // 没有 vendor 状态信号的年代用来猜「agent 是不是在续跑一个本进程没发起的回合」的启发式,
1858
- // 而它开的口子只有 `turn_done` 能关——任何一条没有配套 `turn_done` 的事件都会把这个 ref
1859
- // 永久钉成忙碌。那个猜测现在有了真信号(`lastActivityRaw`,见本类的注册点),所以这一行
1860
- // 不是被删掉了事,是被它当初代理的那个事实取代了。
1861
- // Before the contract check: an agent emitting even non-conforming events is a live process,
1862
- // and the idle sweep is about liveness, not correctness.
1863
- this.touch(live);
1864
- // Contract check BEFORE record(): record() takes the next seq synchronously, so a violating
1865
- // event that got numbered and then dropped would leave a hole in the sequence — the server's
1866
- // watermark reads a hole as lost data. Drop, don't throw: one bad event must not poison the
1867
- // session, and the diagnostic (agent, session, event) is what makes the violation debuggable.
1868
- const rejection = validateWireEvent(e);
1869
- if (rejection) {
1870
- console.warn(`[acp:${live.agentId} agent:${live.agentKey}] dropped event '${wireEventName(e)}' for ${live.sessionRef}: ` +
1871
- `${rejection.reason}${rejection.detail ? ` (${rejection.detail})` : ''}`);
1872
- return;
1873
- }
1874
- if (e.acpSessionId !== undefined && this.serverAgentWire < AGENT_WIRE_SCOPED_ORIGIN_VERSION) {
1875
- if (e.eventName === 'permission_request' && typeof e.payload.requestId === 'string') {
1876
- const options = e.payload.options;
1877
- let cancelled = '__axom_undeliverable_permission__';
1878
- while (options.some((option) => option.optionId === cancelled))
1879
- cancelled += '_';
1880
- live.session.answer(e.payload.requestId, cancelled);
1881
- }
1882
- console.warn(`[acp:${live.agentId}] scoped live event '${e.eventName}' for '${e.acpSessionId}' disabled: requires agent wire ${AGENT_WIRE_SCOPED_ORIGIN_VERSION}`);
1883
- return;
1884
- }
1885
- this.record(live, (seq) => ({
1886
- t: 'agent_event',
1887
- sessionRef: live.sessionRef,
1888
- seq,
1889
- eventName: e.eventName,
1890
- payload: e.payload,
1891
- // Present only for a subagent's own output. The frame still rides this session's ref, journal
1892
- // and seq — one watermark per ref (see `WireEvent.acpSessionId`); this field only says which
1893
- // session on the far side the event belongs to.
1894
- ...(e.acpSessionId ? { acpSessionId: e.acpSessionId } : {}),
1895
- }));
1896
- }
1897
2495
  /**
1898
- * `session.onActivity`'s handler: one agent-reported activity-state push. Deliberately not
1899
- * routed through {@link record} — an activity push is a level, not an edge (the
1900
- * `agent_session_state` frame's own doc comment in `protocol/agent.ts`), so it must never be
1901
- * journaled and must never advance {@link SessionJournal}'s seq the way `onAgentEvent`'s
1902
- * `agent_event`s do.
2496
+ * The session's processor frames (Task 19), in processor order: projected for the connected server
2497
+ * (`processor/projection.ts`), the numbered ones journaled under the processor's own `seq` and then sent, the levels and
2498
+ * RPC replies sent as they are.
1903
2499
  *
1904
- * `live.lastActivityRaw` is updated **before** the version check, unconditionally: it feeds
1905
- * `agent_opened.activityRaw` and `AgentResumeSessionWire.activityRaw`, neither of which is gated
1906
- * on {@link AGENT_WIRE_SESSION_STATE_VERSION} (they are additive keys on frames the peer already
1907
- * decodes, the `supportsSteering`/`reloaded` shape — see those fields' own doc comments), so the
1908
- * cache has to be current even for a peer too old to receive the push itself.
2500
+ * What used to be decided here moved to the processor, and is no longer re-decided:
1909
2501
  *
1910
- * The push itself **is** gated, and the check has to live here — the client side of the
1911
- * compatibility matrix `AGENT_WIRE_SESSION_STATE_VERSION`'s own doc comment describes as the
1912
- * dangerous direction: a v15 client talking to a v14 server. `agent_session_state` is a new
1913
- * discriminator, not an additive key, so an unrecognised one throws inside `AgentCodec.decode` on
1914
- * the server and gets charged to `FailSession` — and this frame exists to be sent over and over,
1915
- * so a run of them on an unupgraded server would close the whole agent channel. Below the floor
1916
- * the push is silently dropped rather than queued or converted to anything else: a level nobody
1917
- * downstream can read is no worse than one that was never collected.
2502
+ * - **Numbering.** `seq` is processor state; this method never allocates one. The contract check that used to run before
2503
+ * `record()` (`validateWireEvent`) runs in the processor before it numbers (`emit.ts`), so a refused event still leaves no hole.
2504
+ * - **Scoped events for an old server** (spec §5.4, "与连接状态无关"): the processor numbers them whatever the server; the
2505
+ * projection withholds them below {@link AGENT_WIRE_SCOPED_ORIGIN_VERSION} — leaving a `seq` gap the server does not detect —
2506
+ * and a withheld permission card is answered by an `agent_answer_undeliverable` note the processor turns into a logged
2507
+ * `cancelled` answer (the auto-answer used to be written straight to the agent).
2508
+ * - **`refusalFields`**: the projection adds the agent's observed sign-in methods and code to an `agent_turn_done` the processor
2509
+ * marked `agentRefused` — exactly the turns {@link refusalFields} was gated on.
2510
+ *
2511
+ * A frame the projection withholds is not recorded either: the outbox (Task 20, the record-before-send buffer that replaced the
2512
+ * journal) only ever holds frames this server can be sent, which is what keeps {@link blockIncompatibleJournal} meaningful.
2513
+ *
2514
+ * Returns the outbox append of the numbered frames: the session's record loop awaits it before folding another record, so the
2515
+ * outbox holding a frame proves its record's commands are in raw (and a checkpoint never precedes a record's frames).
1918
2516
  */
1919
- onAgentActivity(live, raw) {
1920
- if (live.closed)
1921
- return;
1922
- live.lastActivityRaw = raw;
1923
- live.lastActivityAtMs = Date.now();
1924
- live.staleActivityWarned = false;
1925
- // 采样式判据没有「变空闲」事件;一次状态推送正是它可能翻转的时刻,喊一声让
1926
- // waitForIdle 重新判一次。漏喊只会推迟到下次判定,多喊无害。
1927
- this.tasks?.sessionBusyChanged();
1928
- if (this.serverAgentWire < AGENT_WIRE_SESSION_STATE_VERSION)
1929
- return;
1930
- this.emit({
1931
- t: 'agent_session_state',
1932
- sessionRef: live.sessionRef,
1933
- vendor: live.session.agentInfo?.name ?? '',
1934
- raw,
1935
- });
2517
+ onSessionFrames(live, frames) {
2518
+ const numbered = [];
2519
+ for (const output of frames) {
2520
+ if (live.closed) {
2521
+ // Closed or detached while the session was finishing a record (Task 21 fix round 1, I-1): its numbered frames are still
2522
+ // recorded — a detached session's next daemon resends them; dropping them would skip their seqs behind the head checkpoint.
2523
+ // Nothing is sent or noted for it any more.
2524
+ if (output.journaled && projectFrame(output, { sessionRef: live.sessionRef, serverAgentWire: this.serverAgentWire }).frame) {
2525
+ numbered.push({ seq: output.frame.seq, src: output.src, frame: output.frame });
2526
+ }
2527
+ continue;
2528
+ }
2529
+ const frame = output.frame;
2530
+ if (frame.t === 'agent_account_update') {
2531
+ if (this.serverAgentWire >= 27)
2532
+ this.emit({ t: 'agent_account_update', agentKey: live.agentKey, snapshot: frame.snapshot });
2533
+ continue;
2534
+ }
2535
+ switch (frame.t) {
2536
+ case 'agent_event':
2537
+ // A live process, whatever the event: the idle sweep is about liveness.
2538
+ this.touch(live);
2539
+ break;
2540
+ case 'agent_turn_done':
2541
+ // Busy is sampled (`liveness.turnRunning`, already false by now); a session that was the registry's only busy one
2542
+ // may just have gone idle, and sampling has no event of its own to announce that.
2543
+ this.tasks?.sessionBusyChanged();
2544
+ this.touch(live);
2545
+ if (typeof frame.nativeSessionId === 'string' && frame.nativeSessionId)
2546
+ live.nativeSessionId = frame.nativeSessionId;
2547
+ // The adapter works: its earlier own exits no longer count towards the crash limit.
2548
+ if (frame.status !== 'failed')
2549
+ this.adapterCrashes.delete(live.sessionRef);
2550
+ break;
2551
+ case 'agent_session_state':
2552
+ // A level, never journaled; the session keeps the latest (`activityRaw`) for `agent_opened`/the resume offer, even for a
2553
+ // server too old to be sent the push itself. A push is when sampled busy may flip, so the registry looks again.
2554
+ live.lastActivityAtMs = Date.now();
2555
+ live.staleActivityWarned = false;
2556
+ this.tasks?.sessionBusyChanged();
2557
+ break;
2558
+ case 'agent_runtime_profile':
2559
+ if (this.sessions.get(live.sessionRef) !== live)
2560
+ continue;
2561
+ break;
2562
+ }
2563
+ const projection = projectFrame(output, {
2564
+ sessionRef: live.sessionRef,
2565
+ serverAgentWire: this.serverAgentWire,
2566
+ ...(frame.t === 'agent_turn_done' && frame.agentRefused === true
2567
+ ? { authMethods: this.agents.observedAuthMethods(live.agentId) } : {}),
2568
+ });
2569
+ if (projection.note) {
2570
+ void live.session.note(projection.note).catch((err) => {
2571
+ console.error(`[agent] auto-answer for an undeliverable card failed for ${live.sessionRef}: ${errorMessage(err)}`);
2572
+ });
2573
+ }
2574
+ if (!projection.frame) {
2575
+ if (frame.t === 'agent_event' && frame.acpSessionId !== undefined) {
2576
+ console.warn(`[acp:${live.agentId}] scoped live event '${String(frame.eventName)}' for '${String(frame.acpSessionId)}' disabled: ` +
2577
+ `requires agent wire ${AGENT_WIRE_SCOPED_ORIGIN_VERSION}`);
2578
+ }
2579
+ continue;
2580
+ }
2581
+ if (output.journaled)
2582
+ numbered.push({ seq: frame.seq, src: output.src, frame });
2583
+ else
2584
+ this.emit(projection.frame);
2585
+ }
2586
+ return numbered.length > 0 ? this.record(live, numbered) : undefined;
1936
2587
  }
1937
- onAgentTurnDone(live, r) {
1938
- if (live.closed)
2588
+ /**
2589
+ * The session degraded under its own loop (a poisoned processor, or a transport that failed): its open turns were already ended
2590
+ * `failed` through {@link onSessionFrames} and its adapter is being stopped. It is retired like a swept session — the journal
2591
+ * kept, the server told — so the next `agent_open` spawns anew and loads the native session (interface-contracts: the poison
2592
+ * recovery contract; never retried from a checkpoint).
2593
+ */
2594
+ async onSessionDegraded(live, reason) {
2595
+ if (live.closed || this.sessions.get(live.sessionRef) !== live)
1939
2596
  return;
1940
- // `agentTurnFinished` used to clear this ref's bracket entry here. There is no entry to clear
1941
- // any more — busy is sampled straight off `live.session.liveness.turnRunning` (already false
1942
- // by the time this fires; see `AcpSession.turnRunning`'s own doc) — but a session that was the
1943
- // registry's *only* busy one may have just gone idle, and sampling has no event of its own to
1944
- // announce that, so this is where the registry is told to look again.
1945
- this.tasks?.sessionBusyChanged();
1946
- this.touch(live);
1947
- if (r.nativeSessionId)
1948
- live.nativeSessionId = r.nativeSessionId;
1949
- this.record(live, (seq) => ({
1950
- t: 'agent_turn_done',
1951
- sessionRef: live.sessionRef,
1952
- seq,
1953
- status: r.status,
1954
- platformTurnId: r.platformTurnId,
1955
- executionEnded: r.executionEnded,
1956
- stopReason: r.stopReason,
1957
- nativeSessionId: r.nativeSessionId ?? null,
1958
- error: r.error ?? null,
1959
- // Only on a failure the agent itself answered. This is the frame credentials that expired
1960
- // mid-conversation arrive on — the agent answers the next `session/prompt` with
1961
- // `auth_required` rather than ending the session — and it is also the busiest frame on this
1962
- // channel, one per turn for the life of every session, which is why a completed turn carries
1963
- // no table nothing reads.
1964
- //
1965
- // `status === 'failed'` on its own would be wrong, and wrong in the direction that hurts:
1966
- // `AcpSession` synthesises that status on three paths the agent never answered (a prompt
1967
- // after the read loop died, a connection that aborted mid-turn, an unmappable update), all of
1968
- // them on a *live* session whose method table this daemon certainly holds. An agent process
1969
- // crashing mid-turn is plausibly more common than a token expiring mid-turn, so pairing those
1970
- // with `authMethods` would put more false login cards on screen than real ones. See
1971
- // {@link TurnResult.agentRefused}.
1972
- //
1973
- // And `agentRefused` on its own is not enough either, which is what `errorCode` is here for:
1974
- // an exceeded context window is a refusal the agent genuinely answered, and it is common.
1975
- ...(r.status === 'failed' && r.agentRefused ? this.refusalFields(live.agentId, r.errorCode) : {}),
1976
- }));
2597
+ console.error(`[acp:${live.agentId} agent:${live.agentKey}] session ${live.sessionRef} degraded and was stopped: ${reason}`);
2598
+ const alreadyLost = live.adapterLost?.notified === true;
2599
+ await this.retire(live, 'keep');
2600
+ // Its adapter's exit already told the server: a second notice could arrive after the server reopened the ref, and void the new
2601
+ // generation.
2602
+ if (!alreadyLost)
2603
+ this.notifySwept(live.sessionRef);
1977
2604
  }
1978
2605
  /**
1979
- * Numbers a frame, writes it, then sends it. The seq is taken synchronously so callbacks are
1980
- * numbered in arrival order; the write and the send ride the session's queue so they stay in that
1981
- * order on the wire.
2606
+ * Records processor-numbered frames in the session's outbox (the processor's frame, without `sessionRef`). They are sent from
2607
+ * there by {@link pump} — an append announces itself — under the session window and the socket gate. A failed append rejects:
2608
+ * the session degrades (a frame that could not be recorded is not sent either).
1982
2609
  */
1983
- record(live, build) {
1984
- const seq = live.journal.nextSeq();
1985
- const frame = build(seq);
1986
- void this.enqueue(live, async () => {
2610
+ record(live, frames) {
2611
+ for (const { seq, frame } of frames) {
1987
2612
  if (frame.t === 'agent_event' && frame.acpSessionId != null) {
1988
2613
  live.highestScopedJournalSeq = Math.max(live.highestScopedJournalSeq, seq);
1989
2614
  }
1990
- try {
1991
- await live.journal.append({ seq, frame });
1992
- }
1993
- catch (err) {
1994
- // Losing durability is bad; losing the event outright is worse. Send it anyway — it just
1995
- // will not survive a reconnect.
1996
- console.error(`[agent] journal append failed for ${live.sessionRef}: ${errorMessage(err)}`);
1997
- }
1998
- // Keep subsequent root/turn frames durable but withheld too: sending a higher sequence
1999
- // would let the old peer ACK past the child event it could not interpret.
2000
- if (live.scopedReplayBlocked || this.blockIncompatibleJournal(live))
2001
- return;
2002
- this.emit(frame);
2615
+ }
2616
+ return live.session.outbox.append(frames);
2617
+ }
2618
+ /**
2619
+ * The server applied through `throughSeq` (`agent_event_ack`, or the `resumeFromSeq` of a reopen): the outbox forgets those
2620
+ * entries and the transport truncates its stream (with the `committedTail` watermark). Held back, like delivery, while an
2621
+ * incompatible server still owes this session a scoped frame it cannot interpret.
2622
+ */
2623
+ async acknowledge(live, throughSeq) {
2624
+ if (live.scopedReplayBlocked || this.blockIncompatibleJournal(live))
2625
+ return;
2626
+ await live.session.outbox.ack(throughSeq, (error) => {
2627
+ console.error(`[agent] outbox commit failed for ${live.sessionRef}: ${errorMessage(error)}`);
2003
2628
  });
2629
+ if (throughSeq >= live.highestScopedJournalSeq)
2630
+ live.highestScopedJournalSeq = 0;
2004
2631
  }
2005
- /** Must run on the session queue, before replay, delivery or ACK truncation. */
2632
+ /**
2633
+ * Must run before replay, delivery or ACK truncation: a server below {@link AGENT_WIRE_SCOPED_ORIGIN_VERSION} cannot deliver or
2634
+ * acknowledge a scoped frame the outbox still holds, so nothing of this session goes to it (sending a higher seq would let it
2635
+ * acknowledge past the child event it could not interpret).
2636
+ */
2006
2637
  blockIncompatibleJournal(live) {
2007
2638
  if (this.serverAgentWire >= AGENT_WIRE_SCOPED_ORIGIN_VERSION)
2008
2639
  return false;
2009
2640
  if (live.scopedReplayBlocked)
2010
2641
  return true;
2011
- // Conservatively retain this requirement through journal overflow: only a compatible ACK
2012
- // proves the peer has applied (or explicitly accepted a gap through) that scoped sequence.
2642
+ // Only a compatible ACK proves the peer has applied (or explicitly accepted a gap through) that scoped sequence.
2013
2643
  const incompatible = live.highestScopedJournalSeq > 0;
2014
2644
  if (incompatible)
2015
2645
  live.scopedReplayBlocked = true;
2016
2646
  return incompatible;
2017
2647
  }
2018
- /** Appends `op` to the session's serialized queue. The returned promise never rejects. */
2019
- enqueue(live, op) {
2020
- const next = live.writes.then(op).catch((err) => {
2021
- console.error(`[agent] journal operation failed for ${live.sessionRef}: ${errorMessage(err)}`);
2648
+ /**
2649
+ * The sender (spec §5.6): sessions take turns, one frame each per round, while the socket gate is open. A session sends its
2650
+ * next unsent outbox entry only while fewer than {@link SESSION_WINDOW_EVENTS} events and {@link SESSION_WINDOW_BYTES} bytes it
2651
+ * sent on this connection are unacknowledged (one frame always fits an empty window). Each frame is projected for the
2652
+ * connected server as it leaves. Re-entrant calls fold into the running one.
2653
+ */
2654
+ pump() {
2655
+ if (this.pumping) {
2656
+ this.pumpAgain = true;
2657
+ return;
2658
+ }
2659
+ this.pumping = true;
2660
+ try {
2661
+ do {
2662
+ this.pumpAgain = false;
2663
+ if (this.send === null)
2664
+ return;
2665
+ const lives = [...this.sessions.values()];
2666
+ if (lives.length === 0)
2667
+ return;
2668
+ let progressed = true;
2669
+ while (progressed) {
2670
+ progressed = false;
2671
+ const start = this.turn;
2672
+ this.turn = (start + 1) % lives.length;
2673
+ for (let k = 0; k < lives.length; k++) {
2674
+ const live = lives[(start + k) % lives.length];
2675
+ if (live.closed || this.sessions.get(live.sessionRef) !== live)
2676
+ continue;
2677
+ if (live.scopedReplayBlocked || this.blockIncompatibleJournal(live))
2678
+ continue;
2679
+ const outbox = live.session.outbox;
2680
+ const item = outbox.nextUnsent();
2681
+ if (!item)
2682
+ continue;
2683
+ // A resolving subagent relation and everything before it go out past the window, in seq order — the server cannot
2684
+ // acknowledge anything after a child frame without it (spec §5.6). Once sent they count again for a server that
2685
+ // acknowledges as soon as the relation releases its cap (wire ≥ 25); an older one wastes its acks while capped, so
2686
+ // there they keep not counting (`SessionOutbox.inflight`).
2687
+ const pinnedCountsWhenSent = this.serverAgentWire >= AGENT_WIRE_RELATION_ACK_VERSION;
2688
+ const inflight = outbox.inflight(this.epoch, pinnedCountsWhenSent);
2689
+ const bypass = item.seq <= outbox.pinnedThrough;
2690
+ if (!bypass && (inflight.events >= SESSION_WINDOW_EVENTS || (inflight.events > 0 && inflight.bytes + item.bytes > SESSION_WINDOW_BYTES)))
2691
+ continue;
2692
+ if (!this.gate.isOpen()) {
2693
+ this.gate.whenOpen(() => this.pump());
2694
+ return;
2695
+ }
2696
+ item.sentOn = this.epoch;
2697
+ this.sendRecorded(live, item.frame, item.src);
2698
+ progressed = true;
2699
+ if (this.send === null)
2700
+ return;
2701
+ if (live.adapterLost && !live.adapterLost.notified && item.seq >= live.adapterLost.throughSeq)
2702
+ this.flushAdapterLost(live.sessionRef);
2703
+ }
2704
+ }
2705
+ } while (this.pumpAgain);
2706
+ }
2707
+ finally {
2708
+ this.pumping = false;
2709
+ }
2710
+ }
2711
+ /** A retiring session's last unsent frames, in order (outside the rotation; bounded by its window). */
2712
+ sendUnsent(live) {
2713
+ if (this.send === null || live.scopedReplayBlocked || this.blockIncompatibleJournal(live))
2714
+ return;
2715
+ for (let item = live.session.outbox.nextUnsent(); item; item = live.session.outbox.nextUnsent()) {
2716
+ item.sentOn = this.epoch;
2717
+ this.sendRecorded(live, item.frame, item.src);
2718
+ }
2719
+ }
2720
+ /** One recorded frame, projected for the connected server as it leaves. */
2721
+ sendRecorded(live, frame, src) {
2722
+ const projection = projectFrame({ journaled: true, frame, src }, {
2723
+ sessionRef: live.sessionRef,
2724
+ serverAgentWire: this.serverAgentWire,
2725
+ ...(frame.t === 'agent_turn_done' && frame.agentRefused === true ? { authMethods: this.agents.observedAuthMethods(live.agentId) } : {}),
2022
2726
  });
2023
- live.writes = next;
2024
- return next;
2727
+ if (projection.note) {
2728
+ void live.session.note(projection.note).catch((err) => {
2729
+ console.error(`[agent] auto-answer for an undeliverable card failed for ${live.sessionRef}: ${errorMessage(err)}`);
2730
+ });
2731
+ }
2732
+ if (projection.frame)
2733
+ this.emit(projection.frame);
2025
2734
  }
2026
2735
  async onClose(sessionRef) {
2736
+ // The server is done with the ref: a switch it was waiting for is moot (Task 24), and so is its adapter's crash count.
2737
+ void this.agents.cancelSwitch?.({ sessionRef }).catch(() => false);
2738
+ this.reopenRequests.delete(sessionRef);
2739
+ this.adapterCrashes.delete(sessionRef);
2740
+ this.retainedRespawnBuilds.delete(sessionRef);
2027
2741
  // Closing a ref that is still spawning: nothing to close yet, so tombstone it — openSession
2028
2742
  // closes the session (and destroys its journal) the moment the bridge hands it over.
2029
2743
  //
@@ -2036,47 +2750,78 @@ export class AgentSessionManager {
2036
2750
  return;
2037
2751
  }
2038
2752
  const live = this.sessions.get(sessionRef);
2039
- if (!live)
2753
+ if (live) {
2754
+ await this.retire(live, 'destroy');
2040
2755
  return;
2041
- await this.retire(live, 'destroy');
2756
+ }
2757
+ // Not held: whatever an earlier life left under the ref (a swept or degraded session's outbox, a previous daemon's, a migrated
2758
+ // legacy journal) can never be asked for again — it goes, as the journal did on `agent_close`.
2759
+ //
2760
+ // The cleanup publishes on `retiring` — synchronously, before this handler's first await — because the server's close→reopen
2761
+ // pair (a stale recycle, a config reload) can land its reopen while the cleanup is still listing the keeper. `openSession`
2762
+ // waits on this map as its very first statement, so without the publication that open spawns under the cleanup, and the
2763
+ // cleanup's list sees the fresh spawn as a running leftover and stops it mid-`initialize` ("exited before responding to
2764
+ // initialize (code=null, signal=SIGTERM)"). The recheck stays inside: an open that appeared while a previous teardown was
2765
+ // settling owns the ref — its reopen reads the leftover outbox back instead of dropping it. A cleanup that fails must not
2766
+ // fail whatever waits on it (the published promise never rejects), and a second close chains behind this one as today.
2767
+ const previous = this.retiring.get(sessionRef);
2768
+ const cleanup = (async () => {
2769
+ await previous;
2770
+ if (this.sessions.has(sessionRef) || this.opening.has(sessionRef))
2771
+ return;
2772
+ await this.agents.releaseLeftover?.(sessionRef);
2773
+ })().catch((error) => {
2774
+ console.warn(`[agent] leftover cleanup for ${sessionRef} failed: ${error instanceof Error ? error.message : String(error)}`);
2775
+ });
2776
+ this.retiring.set(sessionRef, cleanup);
2777
+ try {
2778
+ await cleanup;
2779
+ }
2780
+ finally {
2781
+ if (this.retiring.get(sessionRef) === cleanup)
2782
+ this.retiring.delete(sessionRef);
2783
+ }
2042
2784
  }
2043
2785
  /**
2044
2786
  * The teardown behind `agent_close`: stop emitting, unregister, kill the agent subprocess, and —
2045
- * when
2046
- * `journal` says so — delete the journal. Shared with {@link sweepIdle} so an orphan swept on the
2047
- * timer leaves exactly as little behind as one the server closed properly, except for the file
2048
- * itself (see {@link sweepIdle} for why that one differs).
2787
+ * when `outbox` says so — drop the session's transport session with its outbox. Shared with {@link sweepIdle} so an orphan swept
2788
+ * on the timer leaves exactly as little behind as one the server closed properly, except for the outbox itself (see
2789
+ * {@link sweepIdle} for why that one differs).
2049
2790
  *
2050
- * `journal: 'keep'` still rides the same queue and still publishes on {@link retiring}. The wait
2051
- * is not about the unlink: the point is that this session's pending appends must have landed
2052
- * before a reopen constructs a second {@link SessionJournal} on the same path, or the new
2053
- * instance recovers its seq counter from a file that is still being written behind it.
2791
+ * Published on {@link retiring} either way: a reopen spawns under the same transport key, so it must wait until the old adapter
2792
+ * is stopped and the session's pending appends (and its head checkpoint) have landed — or its outbox is gone.
2054
2793
  */
2055
- async retire(live, journal) {
2794
+ async retire(live, outbox) {
2056
2795
  const sessionRef = live.sessionRef;
2796
+ // Closed for good (an `agent_close`): its crash count goes with it. A `keep` retire is a respawn, a sweep or a degrade — the count
2797
+ // must survive it, or a crash-looping adapter would be respawned for ever.
2798
+ if (outbox === 'destroy')
2799
+ this.adapterCrashes.delete(sessionRef);
2057
2800
  live.closed = true;
2801
+ live.unwatch();
2058
2802
  this.sessions.delete(sessionRef);
2059
2803
  this.tasks?.unregisterAgentSession(sessionRef);
2060
- // Queued behind the pending writes: an append that landed after the file was removed would
2061
- // resurrect a journal for a session that no longer exists. Published on `retiring` for the whole
2062
- // teardown, so a reopen of this ref parks until the file is actually gone.
2063
- const journalDone = this.enqueue(live, async () => {
2064
- if (journal === 'destroy')
2065
- await live.journal.destroy();
2804
+ if (outbox === 'detach')
2805
+ this.detached.add(sessionRef);
2806
+ const closed = (async () => {
2807
+ // Frames produced before the close still go out (as the journal's queued sends did): appends in flight land, then whatever
2808
+ // is unsent is written — the session left the sender's rotation with the `sessions` entry.
2809
+ await live.session.outbox.flush();
2810
+ this.sendUnsent(live);
2811
+ if (outbox === 'detach' && live.session.detach)
2812
+ await live.session.detach();
2813
+ else
2814
+ await live.session.close(outbox === 'destroy' ? 'release' : 'keep');
2815
+ })().catch((err) => {
2816
+ console.error(`[agent] close failed for ${sessionRef}: ${errorMessage(err)}`);
2066
2817
  });
2067
- const retired = journalDone.finally(() => {
2818
+ const retired = closed.then(() => undefined).finally(() => {
2068
2819
  // Only clear our own entry: a later close of the same ref may already have replaced it.
2069
2820
  if (this.retiring.get(sessionRef) === retired)
2070
2821
  this.retiring.delete(sessionRef);
2071
2822
  });
2072
2823
  this.retiring.set(sessionRef, retired);
2073
- try {
2074
- await live.session.close();
2075
- }
2076
- catch (err) {
2077
- console.error(`[agent] close failed for ${sessionRef}: ${errorMessage(err)}`);
2078
- }
2079
- await journalDone;
2824
+ await retired;
2080
2825
  }
2081
2826
  /**
2082
2827
  * The one place every outbound agent frame passes, and so the one place the wire contract can be