@platless/axom-cli 0.1.0 → 0.5.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (281) hide show
  1. package/README.md +1 -1
  2. package/dist/authScopes.js +19 -0
  3. package/dist/axom-patch/cli/README.md +132 -0
  4. package/dist/axom-patch/cli/account-limits/README.md +27 -0
  5. package/dist/axom-patch/cli/account-limits/antigravity-proxy.mjs +47 -0
  6. package/dist/axom-patch/cli/account-limits/antigravity-read.mjs +90 -0
  7. package/dist/axom-patch/cli/account-limits/antigravity.mjs +46 -0
  8. package/dist/axom-patch/cli/account-limits/generate.py +120 -0
  9. package/dist/axom-patch/cli/account-limits/kimi-proxy.mjs +64 -0
  10. package/dist/axom-patch/cli/account-limits/normalize.mjs +151 -0
  11. package/dist/axom-patch/cli/claude-account-limits.json +37 -0
  12. package/dist/axom-patch/cli/codex-account-limits.json +37 -0
  13. package/dist/axom-patch/cli/commandcode-account-limits.json +30 -0
  14. package/dist/axom-patch/cli/nightly-task.md +152 -0
  15. package/dist/axom-patch/cli/pi-steering.json +37 -0
  16. package/dist/axom-patch/cli/pi-subagent-subwork.json +23 -0
  17. package/dist/axom-patch/cli/probes/claude-late-async-terminal.mjs +38 -0
  18. package/dist/axom-patch/cli/probes/pi-steering.mjs +80 -0
  19. package/dist/axom-patch/cli/probes/pi-subagent-subwork.mjs +211 -0
  20. package/dist/axom-patch/cli/probes/qoder-steering.mjs +89 -0
  21. package/dist/axom-patch/cli/probes/qoder-subagent-origin.mjs +61 -0
  22. package/dist/axom-patch/cli/probes/zcode-subagent-subwork.mjs +257 -0
  23. package/dist/axom-patch/cli/qoder-background-terminal.md +82 -0
  24. package/dist/axom-patch/cli/qoder-foreground-output.md +42 -0
  25. package/dist/axom-patch/cli/qoder-steering.json +52 -0
  26. package/dist/axom-patch/cli/qoder-steering.md +30 -0
  27. package/dist/axom-patch/cli/qoder-subagent-origin.json +73 -0
  28. package/dist/axom-patch/cli/qoder-subagent-subwork.md +73 -0
  29. package/dist/axom-patch/cli/runtime.json +63 -0
  30. package/dist/axom-patch/cli/steering/claude-steering-js.patch +89 -0
  31. package/dist/axom-patch/cli/steering/claude-steering-types.patch +28 -0
  32. package/dist/axom-patch/cli/steering/codex-steering.patch +57 -0
  33. package/dist/axom-patch/cli/steering/pi-steering.js +79 -0
  34. package/dist/axom-patch/cli/zcode-account-limits.json +31 -0
  35. package/dist/axom-patch/cli/zcode-models.json +38 -0
  36. package/dist/axom-patch/cli/zcode-subagent-subwork.json +73 -0
  37. package/dist/cli.js +36 -1
  38. package/dist/commands/auth.js +4 -13
  39. package/dist/commands/execute.js +4 -4
  40. package/dist/commands/record.js +6 -6
  41. package/dist/commands/struct.js +4 -4
  42. package/dist/commands/workstation.js +2 -2
  43. package/dist/credentials.js +2 -2
  44. package/dist/install/claude/commands.js +13 -13
  45. package/dist/keepers/acp/keeper.mjs +3083 -0
  46. package/dist/keepers/acp/manifest.json +9 -0
  47. package/dist/keepers/terminal/keeper.mjs +8469 -0
  48. package/dist/keepers/terminal/manifest.json +9 -0
  49. package/dist/keepers/terminal/node_modules/node-pty/lib/conpty_console_list_agent.js +25 -0
  50. package/dist/keepers/terminal/node_modules/node-pty/lib/eventEmitter2.js +47 -0
  51. package/dist/keepers/terminal/node_modules/node-pty/lib/index.js +52 -0
  52. package/dist/keepers/terminal/node_modules/node-pty/lib/interfaces.js +7 -0
  53. package/dist/keepers/terminal/node_modules/node-pty/lib/shared/conout.js +10 -0
  54. package/dist/keepers/terminal/node_modules/node-pty/lib/terminal.js +190 -0
  55. package/dist/keepers/terminal/node_modules/node-pty/lib/types.js +7 -0
  56. package/dist/keepers/terminal/node_modules/node-pty/lib/unixTerminal.js +351 -0
  57. package/dist/keepers/terminal/node_modules/node-pty/lib/utils.js +38 -0
  58. package/dist/keepers/terminal/node_modules/node-pty/lib/windowsConoutConnection.js +131 -0
  59. package/dist/keepers/terminal/node_modules/node-pty/lib/windowsPtyAgent.js +344 -0
  60. package/dist/keepers/terminal/node_modules/node-pty/lib/windowsTerminal.js +209 -0
  61. package/dist/keepers/terminal/node_modules/node-pty/lib/worker/conoutSocketWorker.js +22 -0
  62. package/dist/keepers/terminal/node_modules/node-pty/package.json +66 -0
  63. package/dist/keepers/terminal/node_modules/node-pty/prebuilds/darwin-arm64/pty.node +0 -0
  64. package/dist/keepers/terminal/node_modules/node-pty/prebuilds/darwin-arm64/spawn-helper +0 -0
  65. package/dist/keepers/terminal/node_modules/node-pty/prebuilds/darwin-x64/pty.node +0 -0
  66. package/dist/keepers/terminal/node_modules/node-pty/prebuilds/darwin-x64/spawn-helper +0 -0
  67. package/dist/keepers/terminal/node_modules/node-pty/prebuilds/linux-arm64/pty.node +0 -0
  68. package/dist/keepers/terminal/node_modules/node-pty/prebuilds/linux-x64/pty.node +0 -0
  69. package/dist/mcp.js +32 -14
  70. package/dist/runtime-deps/qoder-sdk/package-lock.json +1189 -0
  71. package/dist/runtime-deps/qoder-sdk/package.json +8 -0
  72. package/dist/test/keeperKit.js +67 -0
  73. package/dist/workstation/acp/acpAgents.js +2514 -681
  74. package/dist/workstation/acp/acpClient.js +95 -318
  75. package/dist/workstation/acp/activityBusy.js +43 -0
  76. package/dist/workstation/acp/activityReporting.js +54 -0
  77. package/dist/workstation/acp/adoption.js +28 -356
  78. package/dist/workstation/acp/authUrlSniffer.js +36 -0
  79. package/dist/workstation/acp/binaryInstall.js +100 -27
  80. package/dist/workstation/acp/builds/gc.js +245 -0
  81. package/dist/workstation/acp/builds/install/binary.js +28 -0
  82. package/dist/workstation/acp/builds/install/npm.js +235 -0
  83. package/dist/workstation/acp/builds/install/uv.js +254 -0
  84. package/dist/workstation/acp/builds/migrate.js +195 -0
  85. package/dist/workstation/acp/builds/model.js +128 -0
  86. package/dist/workstation/acp/builds/nodeGate.js +100 -0
  87. package/dist/workstation/acp/builds/store.js +352 -0
  88. package/dist/workstation/acp/commandTableProbe.js +68 -0
  89. package/dist/workstation/acp/configOptions.js +103 -1
  90. package/dist/workstation/acp/delegationMcp.js +24 -0
  91. package/dist/workstation/acp/envSecrets.js +27 -0
  92. package/dist/workstation/acp/initializeResponse.js +285 -0
  93. package/dist/workstation/acp/installErrors.js +12 -2
  94. package/dist/workstation/acp/installProbe.js +1 -1
  95. package/dist/workstation/acp/managedPatches.js +144 -0
  96. package/dist/workstation/acp/outbox.js +330 -0
  97. package/dist/workstation/acp/patchAssets.js +80 -0
  98. package/dist/workstation/acp/permission.js +335 -37
  99. package/dist/workstation/acp/processor/checkpoint.js +87 -0
  100. package/dist/workstation/acp/processor/commands.js +35 -0
  101. package/dist/workstation/acp/processor/control.js +355 -0
  102. package/dist/workstation/acp/processor/emit.js +26 -0
  103. package/dist/workstation/acp/processor/jsonrpc.js +172 -0
  104. package/dist/workstation/acp/processor/memoryLog.js +49 -0
  105. package/dist/workstation/acp/processor/origins.js +48 -0
  106. package/dist/workstation/acp/processor/output.js +67 -0
  107. package/dist/workstation/acp/processor/projection.js +42 -0
  108. package/dist/workstation/acp/processor/record.js +128 -0
  109. package/dist/workstation/acp/processor/runtime.js +181 -0
  110. package/dist/workstation/acp/processor/sdkSchemas.js +61 -0
  111. package/dist/workstation/acp/processor/session.js +525 -0
  112. package/dist/workstation/acp/processor/sideChannel.js +80 -0
  113. package/dist/workstation/acp/processor/state.js +74 -0
  114. package/dist/workstation/acp/processor/step.js +459 -0
  115. package/dist/workstation/acp/processor/test/fixtureLog.js +65 -0
  116. package/dist/workstation/acp/processor/test/harness.js +120 -0
  117. package/dist/workstation/acp/processor/test/largeIdEcho.js +50 -0
  118. package/dist/workstation/acp/processor/test/rawAgent.js +70 -0
  119. package/dist/workstation/acp/processor/transport/generations.js +76 -0
  120. package/dist/workstation/acp/processor/transport/keeper.js +130 -0
  121. package/dist/workstation/acp/processor/transport/local.js +269 -0
  122. package/dist/workstation/acp/processor/transport/types.js +78 -0
  123. package/dist/workstation/acp/processor/updates.js +364 -0
  124. package/dist/workstation/acp/processorSession.js +1512 -0
  125. package/dist/workstation/acp/qoderStartup.js +45 -0
  126. package/dist/workstation/acp/registry/catalog.js +385 -4
  127. package/dist/workstation/acp/registry/detect.js +55 -0
  128. package/dist/workstation/acp/registry/launch.js +61 -119
  129. package/dist/workstation/acp/sessionUpdateMapper.js +56 -27
  130. package/dist/workstation/acp/steering.js +58 -0
  131. package/dist/workstation/acp/switching.js +114 -0
  132. package/dist/workstation/acp/terminalLogin.js +171 -0
  133. package/dist/workstation/acp/terminalLoginSpec.js +41 -0
  134. package/dist/workstation/acp/test/adapterFixtures.js +256 -0
  135. package/dist/workstation/acp/test/daemonLife.js +106 -0
  136. package/dist/workstation/acp/test/fakeAgent.js +185 -9
  137. package/dist/workstation/acp/test/keeperWorld.js +267 -0
  138. package/dist/workstation/acp/test/memoryOutbox.js +55 -0
  139. package/dist/workstation/acp/test/raw/fixtures.js +196 -0
  140. package/dist/workstation/acp/test/raw/qoderCaptures.js +125 -0
  141. package/dist/workstation/acp/test/raw/sanitize.js +344 -0
  142. package/dist/workstation/acp/test/raw/sources.js +249 -0
  143. package/dist/workstation/acp/test/realAdapter.js +377 -0
  144. package/dist/workstation/acp/test/scriptAgent.js +206 -0
  145. package/dist/workstation/acp/test/sessionFrames.js +42 -0
  146. package/dist/workstation/acp/timedCommand.js +4 -2
  147. package/dist/workstation/acp/uvxInstall.js +72 -122
  148. package/dist/workstation/acp/validate.js +33 -5
  149. package/dist/workstation/agentFlow.js +103 -0
  150. package/dist/workstation/agentSessions.js +1686 -348
  151. package/dist/workstation/channels/agent.js +14 -6
  152. package/dist/workstation/channels/computer.js +203 -7
  153. package/dist/workstation/channels/computerExecs.js +200 -0
  154. package/dist/workstation/channels/management.js +86 -6
  155. package/dist/workstation/config.js +3 -1
  156. package/dist/workstation/connection/encryptedChannel.js +88 -21
  157. package/dist/workstation/daemon.js +309 -6
  158. package/dist/workstation/daemonctl.js +28 -2
  159. package/dist/workstation/desktop/binding.js +36 -0
  160. package/dist/workstation/desktop/bridge.js +212 -0
  161. package/dist/workstation/desktop/flow-control.js +52 -0
  162. package/dist/workstation/desktop/focused-text.js +130 -0
  163. package/dist/workstation/desktop/protocol.js +35 -0
  164. package/dist/workstation/exec/attach.js +126 -0
  165. package/dist/workstation/exec/command.js +40 -0
  166. package/dist/workstation/exec/durable.js +517 -0
  167. package/dist/workstation/exec/fallback.js +106 -0
  168. package/dist/workstation/exec/policy.js +837 -0
  169. package/dist/workstation/exec/shaping.js +211 -0
  170. package/dist/workstation/exec.js +3 -112
  171. package/dist/workstation/files/adapters/computerFileFrames.js +51 -0
  172. package/dist/workstation/files/adapters/toolRequests.js +121 -0
  173. package/dist/workstation/files/client.js +455 -0
  174. package/dist/workstation/files/errors.js +28 -0
  175. package/dist/workstation/files/fence.js +350 -0
  176. package/dist/workstation/files/find/fileIndex.js +153 -0
  177. package/dist/workstation/files/find/fuzzy.js +155 -0
  178. package/dist/workstation/files/host.js +122 -0
  179. package/dist/workstation/files/hostProtocol.js +2 -0
  180. package/dist/workstation/files/media/cache.js +620 -0
  181. package/dist/workstation/files/media/encoding.js +308 -0
  182. package/dist/workstation/files/media/estimator.js +100 -0
  183. package/dist/workstation/files/media/media.js +325 -0
  184. package/dist/workstation/files/media/packedAudio.js +23 -0
  185. package/dist/workstation/files/media/scheduler.js +551 -0
  186. package/dist/workstation/files/media/types.js +5 -0
  187. package/dist/workstation/files/scope.js +45 -0
  188. package/dist/workstation/files/structured.js +284 -0
  189. package/dist/workstation/files/text.js +356 -0
  190. package/dist/workstation/files/uploads.js +351 -0
  191. package/dist/workstation/journal.js +65 -236
  192. package/dist/workstation/keepers/acp/client.js +101 -0
  193. package/dist/workstation/keepers/acp/envelope.js +62 -0
  194. package/dist/workstation/keepers/acp/host.js +419 -0
  195. package/dist/workstation/keepers/acp/main.js +18 -0
  196. package/dist/workstation/keepers/acp/memoryStream.js +121 -0
  197. package/dist/workstation/keepers/acp/params.js +137 -0
  198. package/dist/workstation/keepers/acp/protocol.js +66 -0
  199. package/dist/workstation/keepers/acp/recover.js +120 -0
  200. package/dist/workstation/keepers/acp/session.js +584 -0
  201. package/dist/workstation/keepers/acp/stderr.js +154 -0
  202. package/dist/workstation/keepers/kit/activity.js +103 -0
  203. package/dist/workstation/keepers/kit/bootstrap.js +164 -0
  204. package/dist/workstation/keepers/kit/buildVersion.js +51 -0
  205. package/dist/workstation/keepers/kit/bundle.js +120 -0
  206. package/dist/workstation/keepers/kit/client.js +432 -0
  207. package/dist/workstation/keepers/kit/identity.js +21 -0
  208. package/dist/workstation/keepers/kit/ipc.js +112 -0
  209. package/dist/workstation/keepers/kit/launchConfig.js +8 -0
  210. package/dist/workstation/keepers/kit/launcher.js +249 -0
  211. package/dist/workstation/keepers/kit/lock.js +123 -0
  212. package/dist/workstation/keepers/kit/orphanSweep.js +86 -0
  213. package/dist/workstation/keepers/kit/paths.js +75 -0
  214. package/dist/workstation/keepers/kit/protocol.js +43 -0
  215. package/dist/workstation/keepers/kit/server.js +518 -0
  216. package/dist/workstation/keepers/kit/streamLog.js +697 -0
  217. package/dist/workstation/keepers/legacy.js +296 -0
  218. package/dist/workstation/keepers/policy.js +576 -0
  219. package/dist/workstation/keepers/stop.js +34 -0
  220. package/dist/workstation/keepers/terminal/client.js +213 -0
  221. package/dist/workstation/keepers/terminal/exec.js +981 -0
  222. package/dist/workstation/keepers/terminal/generations.js +129 -0
  223. package/dist/workstation/keepers/terminal/keeper.js +62 -0
  224. package/dist/workstation/keepers/terminal/main.js +43 -0
  225. package/dist/workstation/keepers/terminal/orphanSweep.js +3 -0
  226. package/dist/workstation/keepers/terminal/params.js +27 -0
  227. package/dist/workstation/keepers/terminal/protocol.js +56 -0
  228. package/dist/workstation/keepers/terminal/pty.js +330 -0
  229. package/dist/workstation/oauth/deviceAuthorization.js +21 -12
  230. package/dist/workstation/protocol/accountLimits.js +47 -0
  231. package/dist/workstation/protocol/agent.js +201 -8
  232. package/dist/workstation/protocol/common.js +36 -0
  233. package/dist/workstation/protocol/computer.js +107 -7
  234. package/dist/workstation/protocol/management.js +50 -2
  235. package/dist/workstation/qoderAdapter/backgroundProcess.js +118 -0
  236. package/dist/workstation/qoderAdapter/backgroundTerminal.js +193 -0
  237. package/dist/workstation/qoderAdapter/backgroundTerminalFold.js +372 -0
  238. package/dist/workstation/qoderAdapter/callbackContext.js +76 -0
  239. package/dist/workstation/qoderAdapter/completedContinuation.js +139 -0
  240. package/dist/workstation/qoderAdapter/contract.js +78 -0
  241. package/dist/workstation/qoderAdapter/foregroundOutput.js +36 -0
  242. package/dist/workstation/qoderAdapter/history.js +85 -0
  243. package/dist/workstation/qoderAdapter/identityIndex.js +191 -0
  244. package/dist/workstation/qoderAdapter/inputQueue.js +42 -0
  245. package/dist/workstation/qoderAdapter/main.js +95 -0
  246. package/dist/workstation/qoderAdapter/mapper.js +117 -0
  247. package/dist/workstation/qoderAdapter/observerPlugin.js +245 -0
  248. package/dist/workstation/qoderAdapter/observerReceiver.js +187 -0
  249. package/dist/workstation/qoderAdapter/permissions.js +61 -0
  250. package/dist/workstation/qoderAdapter/profile.js +93 -0
  251. package/dist/workstation/qoderAdapter/sdkDriver.js +48 -0
  252. package/dist/workstation/qoderAdapter/sdkRuntime.js +93 -0
  253. package/dist/workstation/qoderAdapter/sdkRuntimeInstall.js +145 -0
  254. package/dist/workstation/qoderAdapter/session.js +319 -0
  255. package/dist/workstation/qoderAdapter/subagentMeta.js +28 -0
  256. package/dist/workstation/qoderAdapter/subwork.js +85 -0
  257. package/dist/workstation/qoderAdapter/taskControl.js +38 -0
  258. package/dist/workstation/qoderAdapter/taskLedger.js +163 -0
  259. package/dist/workstation/qoderAdapter/types.js +1 -0
  260. package/dist/workstation/qoderAdapter/usage.js +37 -0
  261. package/dist/workstation/register.js +4 -0
  262. package/dist/workstation/systemMetrics.js +110 -1
  263. package/dist/workstation/tools/browser.js +394 -0
  264. package/dist/workstation/tools/client.js +277 -0
  265. package/dist/workstation/tools/info.js +50 -0
  266. package/dist/workstation/tools/keeperProcess.js +124 -0
  267. package/dist/workstation/tools/router.js +55 -0
  268. package/dist/workstation/tools/terminalRouter.js +222 -0
  269. package/dist/workstation/tools/validation.js +86 -0
  270. package/dist/workstation/update/coordinator.js +32 -0
  271. package/dist/workstation/update/npmInstaller.js +2 -0
  272. package/dist/workstation/update/ptyPreflight.js +45 -0
  273. package/dist/workstation/update/taskRegistry.js +141 -23
  274. package/dist/workstation/update/watchdog.js +66 -0
  275. package/package.json +20 -7
  276. package/skills/axom/SKILL.md +55 -17
  277. package/skills/axom-definition/SKILL.md +32 -32
  278. package/skills/axom-platform/SKILL.md +39 -39
  279. package/dist/workstation/acp/acpSession.js +0 -1047
  280. package/dist/workstation/acp/contract.js +0 -494
  281. package/dist/workstation/acp/draftInterceptor.js +0 -154
@@ -1,9 +1,21 @@
1
- import fs from 'node:fs/promises';
2
1
  import path from 'node:path';
3
- import { AGENT_WIRE_AUTH_URL_VERSION, AGENT_WIRE_HEARTBEAT_INVENTORY_VERSION, AGENT_WIRE_ORPHAN_OFFER_VERSION, AGENT_WIRE_SESSION_AUTH_METHODS_VERSION, AGENT_WIRE_SESSION_LOST_VERSION, AGENT_WIRE_VERSION, outboundAgentFrameRejection, outboundFailureReply, } from './protocol/agent.js';
4
- import { DEFAULT_JOURNAL_MAX_BYTES, SessionJournal } from './journal.js';
5
- import { AGENT_PROBE_TIMEOUT_MS, AgentRefusedSessionError } from './acp/acpAgents.js';
6
- import { validateWireEvent } from './acp/validate.js';
2
+ import { createHash, randomUUID } from 'node:crypto';
3
+ 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_DELEGATION_MCP_VERSION, AGENT_WIRE_DELEGATION_RENEW_VERSION, AGENT_WIRE_PROVIDER_TASK_VERSION, AGENT_WIRE_REOPEN_REQUEST_VERSION, AGENT_WIRE_RELATION_ACK_VERSION, outboundAgentFrameRejection, outboundFailureReply, } from './protocol/agent.js';
4
+ import { listLegacyJournalRefs, readLegacyJournal, removeLegacyJournal } from './journal.js';
5
+ import { SESSION_WINDOW_BYTES, SESSION_WINDOW_EVENTS } from './acp/outbox.js';
6
+ import { SocketGate } from './agentFlow.js';
7
+ import { AGENT_PROBE_TIMEOUT_MS, AgentRefusedSessionError, LOCAL_SESSION_UNMOVABLE } from './acp/acpAgents.js';
8
+ import { AdapterStillInstallingError } from './acp/builds/model.js';
9
+ import { projectFrame } from './acp/processor/projection.js';
10
+ import { reportsActivity, supportsAsyncTaskStop } from './acp/activityReporting.js';
11
+ import { activityIsBusy } from './acp/activityBusy.js';
12
+ import { buildSessionMcpServers } from './acp/delegationMcp.js';
13
+ import { ADAPTER_LOST_REOPEN_ATTEMPTS, plainSwitchBlocker, REOPEN_REQUEST_RETRY_MS, switchBlockers, SWITCH_QUIET_MS } from './acp/switching.js';
14
+ function delegationLeaseId(lease) {
15
+ return createHash('sha256').update(JSON.stringify([
16
+ lease.url, lease.headers, lease.bindingId,
17
+ ])).digest('hex');
18
+ }
7
19
  /**
8
20
  * 从线上收来的验证预算的绝对上限。
9
21
  *
@@ -71,6 +83,20 @@ function promptBlocks(frame) {
71
83
  : [];
72
84
  return [...text, ...images];
73
85
  }
86
+ /**
87
+ * Narrows `agent_elicitation_answer.action` to the three literals this daemon itself ever sends.
88
+ *
89
+ * `AGENT_FRAME_SHAPES` only validates `action` as *a* string — ACP's own `CreateElicitationResponse`
90
+ * has a fourth, open `action: string` arm for an agent's own custom/future actions, so the wire
91
+ * layer cannot reject an arbitrary string as malformed. But this daemon is the only sender of this
92
+ * frame, and the server only ever offers the user accept/decline/cancel, so anything else reaching
93
+ * `handleFrame` is a platform bug, not a legitimate custom action — and a bare `as
94
+ * CreateElicitationResponse['action']` cast at the call site would launder it through unchecked,
95
+ * reaching the agent as a value ACP itself still accepts as protocol-valid.
96
+ */
97
+ function isKnownElicitationAction(action) {
98
+ return action === 'accept' || action === 'decline' || action === 'cancel';
99
+ }
74
100
  /**
75
101
  * The server's applied-through watermark on an open frame, sanitized. Absent means "nothing yet".
76
102
  *
@@ -86,14 +112,96 @@ function resumeFromSeqOf(frame) {
86
112
  const seq = Math.trunc(frame.resumeFromSeq ?? 0);
87
113
  return Number.isFinite(seq) ? Math.max(0, seq) : 0;
88
114
  }
115
+ /**
116
+ * What `agent_open` asks of {@link AcpAgents.open} (and of a switch, which needs the same fresh spawn environment — spec §6.3).
117
+ *
118
+ * `env` is an OVERLAY merged onto process.env when the agent is spawned (see AcpOpenSpec.env), so it is built from frame-supplied
119
+ * values only. The single exception is PATH: when pathPrepend is set the whole variable is resolved here — prepends first, then the
120
+ * frame's PATH or, failing that, the daemon's — so the launcher uses spec.env.PATH verbatim instead of re-deriving it.
121
+ */
122
+ function openSpecOf(frame) {
123
+ const env = { ...(frame.env ?? {}) };
124
+ const pathPrepend = frame.pathPrepend ?? [];
125
+ if (pathPrepend.length > 0) {
126
+ env.PATH = [...pathPrepend, env.PATH ?? process.env.PATH ?? ''].filter(Boolean).join(path.delimiter);
127
+ }
128
+ return {
129
+ openRequestId: frame.id,
130
+ sessionRef: frame.sessionRef,
131
+ agentKey: frame.agentKey,
132
+ ...(frame.delegationMcp ? { delegation: {
133
+ leaseId: delegationLeaseId(frame.delegationMcp),
134
+ ...(frame.delegationMcp.bindingId ? { bindingId: frame.delegationMcp.bindingId, instanceId: randomUUID() } : {}),
135
+ } } : {}),
136
+ // `frame.bridge` is the registry agent id now — the wire field keeps its name because Plan A
137
+ // renames nothing (Global Constraint 3); ruling R2 replaced what it *means*.
138
+ agentId: frame.bridge,
139
+ cwd: frame.cwd,
140
+ env,
141
+ agentEnv: frame.agentEnv ?? {},
142
+ ...(frame.command ? { command: frame.command } : {}),
143
+ args: frame.args ?? [],
144
+ ...(frame.nativeSessionId ? { nativeSessionId: frame.nativeSessionId } : {}),
145
+ ...(frame.requireSessionLoad ? { requireSessionLoad: true } : {}),
146
+ ...(frame.delegationMcp ? { delegationMcp: frame.delegationMcp } : {}),
147
+ // Retaining a whole adopted history costs memory for the session's lifetime, so it is only
148
+ // kept when this open is the one that will stream it back.
149
+ backfill: frame.backfill === true,
150
+ };
151
+ }
152
+ /** A recorded frame as the processor makes it: the projection adds `sessionRef` (right after `t`) when it is sent. */
153
+ function withoutSessionRef(frame) {
154
+ const { sessionRef: _ref, ...rest } = frame;
155
+ return rest;
156
+ }
157
+ /** How an adapter exited, for the daemon log and the crash-limit refusal: code or signal, and the last stderr line. */
158
+ function exitWords(exit) {
159
+ const status = exit.signal ? `signal ${exit.signal}` : exit.code !== null ? `exit code ${exit.code}` : 'no exit status';
160
+ const lastLine = exit.stderrTail.trim().split('\n').at(-1)?.trim().slice(0, 200);
161
+ return lastLine ? `${status}; stderr: ${lastLine}` : status;
162
+ }
89
163
  function errorMessage(err) {
90
164
  return err instanceof Error ? err.message : String(err);
91
165
  }
166
+ /**
167
+ * The reply text of an open that failed. A managed (delegation) open hides the cause behind one fixed sentence — except when
168
+ * the adapter is still being installed: that one is actionable ("try again shortly") and carries nothing about the lease (15c).
169
+ */
170
+ function openFailureText(frame, err) {
171
+ return frame.delegationMcp && !(err instanceof AdapterStillInstallingError) ? 'Managed delegation session could not open' : errorMessage(err);
172
+ }
92
173
  /**
93
174
  * Batch size for `agent_backfill_events`. Kept modest so a session with a long history does not
94
175
  * arrive as one giant frame; the last batch of a stream is whatever remains below this size.
95
176
  */
96
177
  export const BACKFILL_BATCH_SIZE = 64;
178
+ /**
179
+ * How long the reconnect offer waits for the start-time adoption (Task 21, spec §5.5.2: rebuild before the agent channel is
180
+ * answered). Sessions still being rebuilt after it are offered without `turnRunning` — never as missing, which would end their turns.
181
+ */
182
+ export const ADOPTION_OFFER_WAIT_MS = 10_000;
183
+ /**
184
+ * 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
185
+ * between, past which a respawn is no longer made at once (spec §11 "single adapter crash": `session/load` recovers it — but an
186
+ * adapter that dies on every respawn must not be respawned for ever). From the limit on, an `agent_open` that would respawn it is
187
+ * refused with the reason until {@link ADAPTER_CRASH_BACKOFF_MS} has passed since the last exit, doubling per exit past the limit up
188
+ * to {@link ADAPTER_CRASH_BACKOFF_MAX_MS}. A completed turn, or {@link ADAPTER_CRASH_FORGET_MS} without an exit, starts the count over.
189
+ */
190
+ export const ADAPTER_CRASH_LIMIT = 3;
191
+ export const ADAPTER_CRASH_BACKOFF_MS = 30_000;
192
+ export const ADAPTER_CRASH_BACKOFF_MAX_MS = 10 * 60_000;
193
+ export const ADAPTER_CRASH_FORGET_MS = 30 * 60_000;
194
+ /** At most one daemon-log line per ref per this period for refused respawns. */
195
+ const ADAPTER_CRASH_WARN_EVERY_MS = 60_000;
196
+ /**
197
+ * 一条会话自报 `running` 之后多久没有任何新推送,就值得在日志里点名。
198
+ *
199
+ * **这不是超时,不会清任何状态**——裁决 A 说 vendor 报 running 就是 running,发明一个
200
+ * 到点就翻成 idle 的定时器正是它禁止的猜测。这条只是把「我们完全信任了一个已经沉默 30 分钟
201
+ * 的信源」这件事摆到排查者面前。取 30 分钟是因为比任何正常回合都长,又短到一次排查会话里
202
+ * 看得见。
203
+ */
204
+ export const STALE_ACTIVITY_WARN_MS = 30 * 60 * 1000;
97
205
  /**
98
206
  * Defensively turns the wire query of `agent_list_native` / `agent_verify` / `agent_authenticate`
99
207
  * — one shape, because all three ask the same thing of an agent that has no session yet: where to
@@ -122,19 +230,6 @@ function sanitizeNativeSessionQuery(agentId, query) {
122
230
  args: query.args ?? [],
123
231
  };
124
232
  }
125
- /**
126
- * Name an event for a diagnostic without trusting it: on a payload hostile enough that even
127
- * `eventName` throws (the validator's 'event not inspectable' case), the log line itself must
128
- * still come out.
129
- */
130
- function wireEventName(e) {
131
- try {
132
- return String(e?.eventName);
133
- }
134
- catch {
135
- return '<uninspectable>';
136
- }
137
- }
138
233
  /**
139
234
  * Owns the workstation's live external-agent sessions and translates between the tunnel's
140
235
  * `agent_*` frames and the ACP client.
@@ -143,14 +238,17 @@ function wireEventName(e) {
143
238
  * (and the subprocesses behind them) must survive a dropped websocket. The send binding is swapped
144
239
  * per connection via {@link bindSend} / {@link closeAll}.
145
240
  *
146
- * Delivery is at-least-once, not best-effort: every outbound event is journaled before it is sent
147
- * ({@link SessionJournal}), so a send that goes nowhere — socket down, or the daemon restarted
148
- * entirely — is replayed when the server re-opens the ref with its `resumeFromSeq` watermark.
241
+ * Delivery is at-least-once, not best-effort: every outbound event is recorded in the session's outbox (the transport's outbox
242
+ * stream, Task 20 — it replaced the daemon journal) before it is sent, so a send that goes nowhere — socket down, or the daemon
243
+ * restarted entirely — is resent when the server re-opens the ref with its `resumeFromSeq` watermark.
244
+ *
245
+ * Sending is flow-controlled (spec §5.6): a session's numbered frames go out from its outbox while fewer than
246
+ * {@link SESSION_WINDOW_EVENTS} events / {@link SESSION_WINDOW_BYTES} bytes are unacknowledged on the connection and the socket
247
+ * gate is open ({@link SocketGate}); sessions take turns. Control frames are written at once ({@link emit}).
149
248
  */
150
249
  export class AgentSessionManager {
151
250
  agents;
152
251
  journalDir;
153
- maxJournalBytes;
154
252
  tasks;
155
253
  sessions = new Map();
156
254
  /** In-flight opens, keyed by sessionRef, so two racing agent_open frames cannot spawn two agents. */
@@ -161,21 +259,19 @@ export class AgentSessionManager {
161
259
  * fails its pending open with ok:false and will never adopt the result). Without this, the native
162
260
  * agent that finishes spawning afterwards is orphaned: alive, registered, and unknown to the
163
261
  * server. {@link openSession} closes and discards any session whose ref is tombstoned, and uses
164
- * the recorded {@link AbandonReason} to decide whether the journal dies with it.
262
+ * the recorded {@link AbandonReason} to decide whether its outbox dies with it.
165
263
  */
166
264
  closedDuringOpen = new Map();
167
265
  /**
168
- * Refs whose journal is still being torn down, and the promise that says when it is gone.
266
+ * Refs whose session is still being torn down (adapter stopped, outbox flushed and kept or released), and the promise that says
267
+ * when it is done.
169
268
  *
170
269
  * A close and the reopen of the same ref are two independent inbound frames — the daemon does not
171
270
  * serialize `handleFrame` (see daemon.ts's `ws.on('message', async …)`) — and `onClose` returns the
172
- * ref to the "openable" state the moment it drops out of `sessions`, while the actual `destroy()`
173
- * is still queued behind that session's pending writes. Letting the reopen build a second
174
- * {@link SessionJournal} on the same path in that window is a data-loss race in both directions:
175
- * the old `destroy()` can land after the new journal's first append and delete it, or the new
176
- * `init()` can read a file the old `destroy()` then removes, leaving the new journal with a stale
177
- * size and a seq counter recovered from bytes that no longer exist. The two instances also share a
178
- * `.tmp` path, so a rewrite in one can be pulled out from under the other.
271
+ * ref to the "openable" state the moment it drops out of `sessions`, while the actual teardown is
272
+ * still running. The reopen spawns under the same transport key: letting it start in that window
273
+ * would race the old release against the new session's outbox (deleting it under it) or read the
274
+ * outbox back while the old session's last appends are still landing.
179
275
  *
180
276
  * {@link openSession} waits on this before constructing anything.
181
277
  */
@@ -218,33 +314,110 @@ export class AgentSessionManager {
218
314
  * running for a card nobody will ever see.
219
315
  */
220
316
  loginsInFlight = new Set();
317
+ /** The socket gate of the bound connection (spec §5.6). */
318
+ gate = new SocketGate();
319
+ /** Bumped per bound connection: a session's window counts what was sent on this one. */
320
+ epoch = 0;
321
+ /** Round-robin start for the sender. */
322
+ turn = 0;
323
+ pumping = false;
324
+ pumpAgain = false;
325
+ /** The one-time migration of the legacy daemon journal ({@link migrateLegacyJournals}). */
326
+ legacyMigration;
327
+ /** The start-time adoption of keeper-acp sessions ({@link adoptSessions}): resolves once they are all registered (or given up). */
328
+ adoption;
329
+ /** A retry of the adoption in flight ({@link adoptLate}): resolves once what it found is tracked in {@link adopting}. */
330
+ lateAdoption;
331
+ /** Refs found running in keeper-acp and still being rebuilt: what the offer reports for them, and when they are done. */
332
+ adopting = new Map();
333
+ /**
334
+ * Refs this daemon detached (spec §5.5.5): the session now belongs to the next daemon. A frame for one is dropped quietly — telling
335
+ * the server the session is lost would end the turn the next daemon continues.
336
+ */
337
+ detached = new Set();
338
+ /**
339
+ * Refs being switched (spec §6.3, Task 24) and the promise that says when the switch (and its `agent_opened`) is done: every other
340
+ * inbound frame of the ref is parked behind it and delivered in order afterwards — to the session that then holds the ref.
341
+ */
342
+ switching = new Map();
343
+ /** 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. */
344
+ reopenRequests = new Map();
345
+ /** The quiet period a session must keep before it is switched (spec §14; a test seam). */
346
+ switchQuietMs = SWITCH_QUIET_MS;
347
+ /** Own adapter exits per ref ({@link ADAPTER_CRASH_LIMIT}): how many in a row, the last one's time and words. */
348
+ adapterCrashes = new Map();
349
+ /** {@link ADAPTER_CRASH_BACKOFF_MS} (a test seam). */
350
+ adapterCrashBackoffMs = ADAPTER_CRASH_BACKOFF_MS;
351
+ /** `agent_open`s being handled per ref, from admission to their reply: a lost notice waits for them ({@link flushAdapterLost}). */
352
+ openHandling = new Map();
221
353
  constructor(
222
354
  /** The one ACP client every external agent runs through; keyed per call by registry agent id. */
223
355
  agents, send,
224
- /** Root for per-session journals; they live under `<journalDir>/sessions`. */
356
+ /** `<configDir>/agents`: the legacy journals (`sessions/<ref>.jsonl`) an older CLI left are migrated from here, once. */
225
357
  journalDir,
226
- /** Per-session journal cap. Beyond it the oldest half is dropped and the server told about the hole. */
227
- maxJournalBytes = DEFAULT_JOURNAL_MAX_BYTES,
228
358
  /** Shared update admission and force-cancellation authority, when this daemon supports updates. */
229
359
  tasks) {
230
360
  this.agents = agents;
231
361
  this.journalDir = journalDir;
232
- this.maxJournalBytes = maxJournalBytes;
233
362
  this.tasks = tasks;
234
363
  this.send = send;
235
364
  }
236
- /** Where journals live: `<journalDir>/sessions/<sessionRef>.jsonl`. */
365
+ /** Where an older CLI's journals live: `<journalDir>/sessions/<sessionRef>.jsonl` (read-only legacy, `journal.ts`). */
237
366
  get sessionsDir() {
238
367
  return path.join(this.journalDir, 'sessions');
239
368
  }
369
+ /**
370
+ * The daemon journal an older CLI kept (`agents/sessions/<ref>.jsonl`) becomes, once, a local outbox under the same ref
371
+ * (`AcpAgents.importLegacyOutbox`): from then on it is offered as an orphan and resent on a reopen exactly as the journal was,
372
+ * and released like any other outbox. Each file is deleted once its outbox exists; a file that fails stays for the next start.
373
+ * Runs once per process (the resume offer and every open wait for it).
374
+ */
375
+ migrateLegacyJournals() {
376
+ this.legacyMigration ??= (async () => {
377
+ const importOutbox = this.agents.importLegacyOutbox?.bind(this.agents);
378
+ if (!importOutbox)
379
+ return;
380
+ let refs;
381
+ try {
382
+ refs = await listLegacyJournalRefs(this.sessionsDir);
383
+ }
384
+ catch (error) {
385
+ console.warn(`[agent] listing legacy journals failed: ${errorMessage(error)}`);
386
+ return;
387
+ }
388
+ for (const sessionRef of refs) {
389
+ try {
390
+ const entries = await readLegacyJournal(this.sessionsDir, sessionRef);
391
+ const imported = await importOutbox(sessionRef, entries.map((entry) => ({
392
+ seq: entry.seq,
393
+ ...(entry.src ? { src: entry.src } : {}),
394
+ // The outbox keeps the processor's frame; the projection adds `sessionRef` when it is sent.
395
+ frame: withoutSessionRef(entry.frame),
396
+ })));
397
+ if (imported === false) {
398
+ // A local outbox already holds this ref (an import that already happened, or a fallback session's own): it wins, and
399
+ // the file's frames are not merged into it.
400
+ console.warn(`[agent] legacy journal of ${sessionRef} not imported: a local outbox for it already exists; deleting the journal`);
401
+ }
402
+ await removeLegacyJournal(this.sessionsDir, sessionRef);
403
+ if (imported !== false)
404
+ console.info(`[agent] migrated the legacy journal of ${sessionRef} (${entries.length} unacknowledged frame(s)) to its outbox`);
405
+ }
406
+ catch (error) {
407
+ console.warn(`[agent] migrating the legacy journal of ${sessionRef} failed (kept for the next start): ${errorMessage(error)}`);
408
+ }
409
+ }
410
+ })();
411
+ return this.legacyMigration;
412
+ }
240
413
  /** Number of daemon-owned external-agent sessions, for the management heartbeat projection. */
241
414
  activeSessionCount() {
242
415
  return [...this.sessions.values()].filter((session) => !session.closed).length;
243
416
  }
244
- /** Snapshot advertised once after an agent-channel reconnect; the server remains replay authority. */
245
417
  /**
246
- * The reconnect inventory: the sessions this daemon holds, plus the journals a previous daemon
247
- * life left on disk.
418
+ * The reconnect inventory: the sessions this daemon holds, plus the outboxes earlier lives left in the transports (Task 20: what
419
+ * `acp.list` and the local fallback list that no live session holds — a previous daemon's keeper-acp sessions, swept or degraded
420
+ * sessions, local outboxes, migrated legacy journals).
248
421
  *
249
422
  * **The orphan half is what makes recovery automatic.** A journal whose agent is gone -- the
250
423
  * daemon restarted, or the machine rebooted -- is not in {@link sessions}, so before this it
@@ -254,9 +427,7 @@ export class AgentSessionManager {
254
427
  *
255
428
  * **Orphans carry `journalMaxSeq: 0`, and that is not a placeholder for a number worth reading.**
256
429
  * The server drives an orphan off `agentAlive` and its own live-turn ledger, never off a watermark
257
- * comparison, so nothing reads the field. Filling it honestly would mean a `SessionJournal.init`
258
- * per file -- each one reads the whole file, up to 64 MB -- on every agent-channel reconnect, to
259
- * produce a number that is then discarded.
430
+ * comparison, so nothing reads the field.
260
431
  *
261
432
  * **Gated on {@link AGENT_WIRE_ORPHAN_OFFER_VERSION}, and that gate is load-bearing** in a way the
262
433
  * heartbeat-inventory floor is not: this widens the meaning of an existing list whose absence the
@@ -265,14 +436,22 @@ export class AgentSessionManager {
265
436
  * honest failure it produces today.
266
437
  */
267
438
  async resumeOfferSessions() {
268
- const live = this.liveInventory();
439
+ // `true`: this is the once-per-connection offer, the one place `activityRaw` is allowed to
440
+ // ride — see {@link liveInventory}'s parameter doc for why {@link heartbeatInventory} must pass
441
+ // `false` at its own call site instead.
442
+ // Spec §5.5.2: every session keeper-acp kept is in the offer (a missing one has its turn ended) — rebuilt ones with their
443
+ // liveness, ones still rebuilding without `turnRunning`.
444
+ await this.adoptionSettled(ADOPTION_OFFER_WAIT_MS);
445
+ await this.adoptLate();
446
+ const live = [...this.liveInventory(true), ...this.adoptingInventory()];
269
447
  if (this.serverAgentWire < AGENT_WIRE_ORPHAN_OFFER_VERSION)
270
448
  return live;
271
449
  // Every ref this daemon still has a handle on, not merely the ones it would report as live:
272
450
  // a ref that is mid-open, mid-retire, or closed-but-not-yet-forgotten is one we are still the
273
451
  // owner of, and announcing it as abandoned invites a reopen against a session we hold.
274
- const held = new Set([...this.sessions.keys(), ...this.opening.keys(), ...this.retiring.keys()]);
275
- const orphans = await this.orphanedJournalRefs(held);
452
+ await this.migrateLegacyJournals();
453
+ const held = new Set([...this.sessions.keys(), ...this.opening.keys(), ...this.retiring.keys(), ...this.adopting.keys()]);
454
+ const orphans = await this.orphanedRefs(held);
276
455
  // `turnRunning: false` is an observation here, not a default: an orphan has no agent process at
277
456
  // all, so there is nothing that could be running on it.
278
457
  return [
@@ -281,42 +460,20 @@ export class AgentSessionManager {
281
460
  ];
282
461
  }
283
462
  /**
284
- * Journal files on disk whose ref this daemon holds no handle for.
285
- *
286
- * Reads names only -- never contents. A directory that does not exist is the ordinary state of a
287
- * daemon that has never opened a session, so it is not a warning; anything else is, but it still
288
- * degrades to "offer what we hold" rather than failing the whole offer, because losing the live
289
- * half of the inventory would be a far bigger regression than missing the orphan half.
463
+ * Transport sessions no live session holds (`AcpAgents.leftoverSessions`). A failure degrades to "offer what we hold" rather
464
+ * than failing the whole offer: losing the live half of the inventory would be a far bigger regression than missing the orphan
465
+ * half.
290
466
  */
291
- async orphanedJournalRefs(held) {
292
- let names;
467
+ async orphanedRefs(held) {
468
+ let keys;
293
469
  try {
294
- names = await fs.readdir(this.sessionsDir);
470
+ keys = (await this.agents.leftoverSessions?.()) ?? [];
295
471
  }
296
472
  catch (error) {
297
- if (error?.code !== 'ENOENT') {
298
- console.warn(`[agent] listing journals for the resume offer failed: ${errorMessage(error)}`);
299
- }
473
+ console.warn(`[agent] listing leftover sessions for the resume offer failed: ${errorMessage(error)}`);
300
474
  return [];
301
475
  }
302
- const refs = [];
303
- for (const name of names) {
304
- if (!name.endsWith('.jsonl'))
305
- continue;
306
- let ref;
307
- try {
308
- ref = decodeURIComponent(name.slice(0, -'.jsonl'.length));
309
- }
310
- catch {
311
- // Not a name this daemon wrote (`encodeURIComponent` never emits a malformed escape), so
312
- // there is no ref behind it to offer.
313
- continue;
314
- }
315
- if (!held.has(ref))
316
- refs.push(ref);
317
- }
318
- // readdir order is filesystem-defined; the offer should not be.
319
- return refs.sort();
476
+ return keys.filter((ref) => !held.has(ref)).sort();
320
477
  }
321
478
  /**
322
479
  * The live half, shared with the heartbeat -- which must stay synchronous and touch no disk.
@@ -327,20 +484,59 @@ export class AgentSessionManager {
327
484
  * here, and lands in exactly the same server-side branch as a journal left by a previous daemon
328
485
  * life -- which is the point: "the agent is gone" is one fact with one handling, not a special
329
486
  * case per way of getting there.
487
+ *
488
+ * @param includeActivity Whether to carry each session's {@link AcpAgentSession.activityRaw}
489
+ * along as `activityRaw`. **Must be `false` from {@link heartbeatInventory} and `true` from
490
+ * {@link resumeOfferSessions}, never the other way round** — `activityRaw` is an unbounded vendor
491
+ * blob, and `AGENT_WIRE_HEARTBEAT_INVENTORY_VERSION`'s own doc comment already ruled that kind of
492
+ * cost off the heartbeat ("ten seconds of overhead forever is worth skipping"). Threading this as
493
+ * a required parameter at the one shared call site — rather than, say, a field this method reads
494
+ * off `this` — is what makes "never on a heartbeat" a fact about the call site instead of a fact
495
+ * someone has to remember to keep true.
330
496
  */
331
- liveInventory() {
497
+ liveInventory(includeActivity) {
332
498
  return [...this.sessions.values()]
333
499
  .filter((session) => !session.closed)
334
500
  .map((session) => {
335
501
  const liveness = session.session.liveness;
336
502
  return {
337
503
  sessionRef: session.sessionRef,
338
- journalMaxSeq: session.journal.lastSeq,
339
- agentAlive: liveness.agentAlive,
504
+ // The processor's head seq (spec §5.5.2): everything this session numbered, or its outbox held. A server below
505
+ // AGENT_WIRE_SCOPED_ORIGIN_VERSION never gets the scoped frames the processor numbered (they are withheld, a seq gap),
506
+ // so its watermark can never reach the head seq: it is told what the outbox recorded instead, or it would reopen the
507
+ // ref on every inventory.
508
+ journalMaxSeq: this.serverAgentWire < AGENT_WIRE_SCOPED_ORIGIN_VERSION ? session.session.outbox.lastSeq : session.session.headSeq,
509
+ // A ref being switched (Task 24) is idle by construction and alive on one process or the other.
510
+ agentAlive: liveness.agentAlive || this.switching.has(session.sessionRef),
340
511
  turnRunning: liveness.turnRunning,
512
+ ...(includeActivity && session.session.activityRaw !== undefined
513
+ ? { activityRaw: session.session.activityRaw }
514
+ : {}),
515
+ // Task 22 (spec §5.5.4): a durable session's agent blocked on cards — the server reopens the ref when it already
516
+ // answered one of them (the answer this daemon may have lost is resent off that open). Only to a server that reads it.
517
+ ...(this.serverAgentWire >= AGENT_WIRE_REOPEN_REQUEST_VERSION && session.session.durable &&
518
+ (session.session.pendingRequestIds?.length ?? 0) > 0
519
+ ? { pendingRequestIds: [...session.session.pendingRequestIds] }
520
+ : {}),
341
521
  };
342
522
  });
343
523
  }
524
+ /**
525
+ * Ask the server for `agent_open{requireSessionLoad:true}` on a live session (spec §6.3, `agent_reopen_request`): an idle
526
+ * session with a pending switch needs the spawn secrets only an open carries. Sent only to a server at
527
+ * {@link AGENT_WIRE_REOPEN_REQUEST_VERSION} — an older one cannot decode the frame, and eight undecodable frames in a row close
528
+ * the agent channel (spec §10: there the switch waits for the session's natural reopen). Returns whether it was sent: `false`
529
+ * for an older or unbound server and for a ref this daemon does not hold. The caller (Task 24) decides when.
530
+ */
531
+ requestReopen(sessionRef, reason) {
532
+ if (this.send === null || this.serverAgentWire < AGENT_WIRE_REOPEN_REQUEST_VERSION)
533
+ return false;
534
+ const live = this.sessions.get(sessionRef);
535
+ if (!live || live.closed)
536
+ return false;
537
+ this.emit({ t: 'agent_reopen_request', sessionRef, reason });
538
+ return true;
539
+ }
344
540
  /**
345
541
  * The same inventory, for the heartbeat that goes out every ten seconds.
346
542
  *
@@ -363,7 +559,8 @@ export class AgentSessionManager {
363
559
  heartbeatInventory() {
364
560
  if (this.serverAgentWire < AGENT_WIRE_HEARTBEAT_INVENTORY_VERSION)
365
561
  return undefined;
366
- const sessions = this.liveInventory();
562
+ // `false`: activityRaw must stay absent on every heartbeat — see `liveInventory`'s own doc.
563
+ const sessions = [...this.liveInventory(false), ...this.adoptingInventory()];
367
564
  return sessions.length > 0 ? sessions : undefined;
368
565
  }
369
566
  /**
@@ -371,7 +568,7 @@ export class AgentSessionManager {
371
568
  * the server advertised on this connection — the version is per-connection, so it is rebound with
372
569
  * the sender rather than set once.
373
570
  */
374
- bindSend(send, serverAgentWire = AGENT_WIRE_VERSION) {
571
+ bindSend(send, serverAgentWire = AGENT_WIRE_VERSION, bufferedAmount) {
375
572
  if (this.mutedDrops > 0) {
376
573
  const seconds = this.mutedSinceMs === null ? '?' : ((Date.now() - this.mutedSinceMs) / 1000).toFixed(1);
377
574
  console.warn(`[agent] sender rebound after ${seconds}s unbound; ${this.mutedDrops} frame(s) were dropped while muted`);
@@ -380,21 +577,37 @@ export class AgentSessionManager {
380
577
  this.mutedSinceMs = null;
381
578
  this.send = send;
382
579
  this.serverAgentWire = serverAgentWire;
580
+ // 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
581
+ // it got; what was never sent goes out now.
582
+ this.epoch += 1;
583
+ for (const live of this.sessions.values())
584
+ live.session.outbox.connectionChanged();
585
+ this.gate.rebind(bufferedAmount);
383
586
  // Before any inbound frame on this connection can be answered: a sweep notice that arrived here
384
587
  // after the server had already re-opened its ref would invalidate a healthy generation.
385
588
  this.flushSwept();
589
+ this.pump();
590
+ // Account observations are outside the session journal. Replay the cached state on reconnect
591
+ // so a server restart does not depend on the provider sending another quota notification.
592
+ if (this.serverAgentWire >= 27)
593
+ for (const live of this.sessions.values()) {
594
+ if (!live.closed && live.session.accountLimits)
595
+ this.emit({ t: 'agent_account_update', agentKey: live.agentKey, snapshot: live.session.accountLimits });
596
+ }
597
+ for (const ref of [...this.sessions.keys()])
598
+ this.flushAdapterLost(ref);
386
599
  }
387
600
  /**
388
601
  * Called when the websocket drops. Despite the name, this does **not** close agent sessions — the
389
602
  * agent subprocesses keep running so a reconnect can resume them. What it does is unbind the
390
603
  * sender, tombstone in-flight opens, and end in-flight *logins*.
391
604
  *
392
- * Events produced while unbound are journaled and their `send` is a no-op; the server gets them
393
- * back when it re-opens the ref with a `resumeFromSeq`.
605
+ * Events produced while unbound are recorded in their session's outbox and wait there; the server gets them when the sender is
606
+ * rebound, and again when it re-opens the ref with a `resumeFromSeq`.
394
607
  *
395
608
  * A login is the one piece of work here that cannot survive the drop, which is why it is the one
396
609
  * thing this really does end: its URL reaches the reader only as `agent_auth_url`, and that frame
397
- * is not journaled the way session events are, so one emitted while unbound is gone rather than
610
+ * is not recorded the way session events are, so one emitted while unbound is gone rather than
398
611
  * replayed — leaving an agent subprocess held open, for up to `AGENT_LOGIN_CEILING_MS`, for a card
399
612
  * nobody will ever see. See {@link loginsInFlight}, and the comment at the abort itself for why it
400
613
  * is scoped to the teardown that actually unbinds.
@@ -402,7 +615,7 @@ export class AgentSessionManager {
402
615
  * In-flight opens are the other exception: the server fails its pending open with ok:false on
403
616
  * disconnect, so a session that finishes spawning after this point would be orphaned. Those refs
404
617
  * are tombstoned and the resulting bridge session is closed on arrival — but as `disconnected`,
405
- * which keeps the journal: the server is coming back for it.
618
+ * which keeps the outbox: the server is coming back for it.
406
619
  */
407
620
  closeAll(boundSender) {
408
621
  // Unbinding is scoped to the connection that did the binding. The sender is one shared slot on
@@ -423,6 +636,8 @@ export class AgentSessionManager {
423
636
  // registered, and unknown to the server.
424
637
  if (boundSender === undefined || this.send === boundSender) {
425
638
  this.send = null;
639
+ this.epoch += 1;
640
+ this.gate.rebind(undefined);
426
641
  // Logins are the exception to "sessions keep running" — see {@link loginsInFlight}. Scoped to
427
642
  // the teardown that actually unbinds, unlike the tombstoning below: if a newer connection has
428
643
  // already rebound, this login's frames still have somewhere to go and killing it would be
@@ -433,13 +648,13 @@ export class AgentSessionManager {
433
648
  login.abort();
434
649
  }
435
650
  for (const sessionRef of this.opening.keys()) {
436
- // Never downgrade an explicit close into a disconnect: that would strand the journal.
651
+ // Never downgrade an explicit close into a disconnect: that would strand the outbox.
437
652
  if (!this.closedDuringOpen.has(sessionRef))
438
653
  this.closedDuringOpen.set(sessionRef, 'disconnected');
439
654
  }
440
655
  }
441
656
  /**
442
- * Awaits every queued journal write and the send behind it. Used by tests, and available to a
657
+ * Awaits every queued outbox write and the send behind it. Used by tests, and available to a
443
658
  * shutdown path that wants events on disk before the process exits.
444
659
  *
445
660
  * Bounded on purpose. A session that is actively streaming keeps appending to its own chain, so
@@ -452,27 +667,200 @@ export class AgentSessionManager {
452
667
  for (let round = 0; round < maxRounds; round += 1) {
453
668
  let progressed = false;
454
669
  for (const live of [...this.sessions.values()]) {
455
- const chain = live.writes;
456
- await chain;
457
- if (live.writes !== chain)
670
+ const before = live.session.outbox.pending.length + live.session.outbox.lastSeq;
671
+ await live.session.outbox.flush();
672
+ if (live.session.outbox.pending.length + live.session.outbox.lastSeq !== before)
458
673
  progressed = true;
459
674
  }
675
+ await new Promise((resolve) => setImmediate(resolve));
460
676
  if (!progressed)
461
677
  return;
462
678
  }
463
679
  }
680
+ /**
681
+ * The daemon's start (Task 21, spec §5.5): adopt the sessions keeper-acp still runs from a previous daemon. Each is registered as
682
+ * a live session once rebuilt (frames it produces from then on are recorded and sent as any session's); until then the offer and
683
+ * the heartbeat report it without `turnRunning`, and frames for its ref wait for it. A session that cannot be continued is dropped
684
+ * (its adapter stopped, its outbox offered as an orphan). Once per manager; never rejects.
685
+ */
686
+ adoptSessions() {
687
+ this.adoption ??= (async () => {
688
+ const adopt = this.agents.adoptKeeperSessions?.bind(this.agents);
689
+ if (!adopt)
690
+ return;
691
+ let found;
692
+ try {
693
+ found = await adopt();
694
+ }
695
+ catch (error) {
696
+ console.warn(`[agent] adopting keeper-acp sessions failed: ${errorMessage(error)}`);
697
+ return;
698
+ }
699
+ await Promise.all(this.trackAdoptions(found));
700
+ })();
701
+ return this.adoption;
702
+ }
703
+ /** Registers each found session once rebuilt; until then it is `adopting` (offered without `turnRunning`, its frames wait). */
704
+ trackAdoptions(found) {
705
+ const done = [];
706
+ for (const candidate of found) {
707
+ if (this.sessions.has(candidate.sessionRef) || this.adopting.has(candidate.sessionRef))
708
+ continue;
709
+ const settled = candidate.session.then((session) => {
710
+ if (session)
711
+ this.registerAdopted(candidate, session);
712
+ }, (error) => {
713
+ console.warn(`[agent] adopting ${candidate.sessionRef} failed: ${errorMessage(error)}`);
714
+ }).finally(() => { this.adopting.delete(candidate.sessionRef); });
715
+ this.adopting.set(candidate.sessionRef, { journalMaxSeq: candidate.journalMaxSeq, agentAlive: candidate.agentAlive !== false, done: settled });
716
+ done.push(settled);
717
+ }
718
+ return done;
719
+ }
720
+ /**
721
+ * Final review finding 3: a keeper-acp generation the start-time adoption could not list (it did not answer in time) is listed
722
+ * again — before a frame is handled, before the reconnect offer and on every switch poll — and what it holds is adopted like
723
+ * any start-time adoption, so its sessions are never offered as orphans nor reopened into a new spawn. Resolves once what the
724
+ * retry found is tracked (not rebuilt). Never rejects.
725
+ */
726
+ adoptLate() {
727
+ const retry = this.agents.retryKeeperAdoption?.bind(this.agents);
728
+ if (!retry || !this.adoption || !this.agents.keeperAdoptionPending?.())
729
+ return Promise.resolve();
730
+ this.lateAdoption ??= (async () => {
731
+ await this.adoption?.catch(() => undefined);
732
+ let found;
733
+ try {
734
+ found = await retry();
735
+ }
736
+ catch (error) {
737
+ console.warn(`[agent] retrying the keeper-acp adoption failed: ${errorMessage(error)}`);
738
+ return;
739
+ }
740
+ if (found.length > 0)
741
+ console.info(`[agent] adopted ${found.length} keeper-acp session(s) late: ${found.map((f) => f.sessionRef).join(', ')}`);
742
+ void Promise.all(this.trackAdoptions(found));
743
+ })().finally(() => { this.lateAdoption = undefined; });
744
+ return this.lateAdoption;
745
+ }
746
+ /** An adopted session joins like an opened one; the server reopens it (or not) from the offer. */
747
+ registerAdopted(candidate, session) {
748
+ if (this.sessions.has(candidate.sessionRef)) {
749
+ // Cannot happen (opens of an adopting ref wait for it); never hold two sessions under one ref.
750
+ void session.detach?.();
751
+ return;
752
+ }
753
+ let highestScopedJournalSeq = 0;
754
+ for (const item of session.outbox.pending) {
755
+ if (item.frame.t === 'agent_event' && item.frame.acpSessionId != null)
756
+ highestScopedJournalSeq = Math.max(highestScopedJournalSeq, item.seq);
757
+ }
758
+ const live = {
759
+ sessionRef: candidate.sessionRef, adopted: true, agentId: candidate.agentId, agentKey: candidate.agentKey, session,
760
+ nativeSessionId: session.nativeSessionId ?? null,
761
+ scopedReplayBlocked: false, highestScopedJournalSeq, unwatch: () => { }, replays: Promise.resolve(), closed: false,
762
+ lastActivityAt: Date.now(),
763
+ };
764
+ this.register(live);
765
+ console.info(`[acp:${live.agentId} agent:${live.agentKey}] adopted session ${live.sessionRef} from keeper-acp`);
766
+ }
767
+ /** Waits (bounded) for the start-time adoption; what is still rebuilding afterwards is reported as such. */
768
+ async adoptionSettled(timeoutMs) {
769
+ if (!this.adoption)
770
+ return;
771
+ let timer;
772
+ await Promise.race([this.adoption, new Promise((resolve) => { timer = setTimeout(resolve, timeoutMs); })]);
773
+ clearTimeout(timer);
774
+ }
775
+ /** Refs still being rebuilt, as inventory entries: alive, their outbox's head seq, and no `turnRunning` (not observed yet). */
776
+ adoptingInventory() {
777
+ return [...this.adopting.entries()].filter(([ref]) => !this.sessions.has(ref))
778
+ .map(([sessionRef, { journalMaxSeq, agentAlive }]) => ({ sessionRef, journalMaxSeq, agentAlive }));
779
+ }
780
+ /**
781
+ * The daemon is going away (SIGTERM, spec §5.5.5): every durable (keeper-hosted) session is detached — head checkpoint, adapter
782
+ * untouched, no failed turn — and the others cut a head checkpoint (their adapters end with the daemon, their outboxes are kept).
783
+ * Bounded by `timeoutMs`.
784
+ */
785
+ async detachAll(timeoutMs = 2_000) {
786
+ // Sessions still being adopted are not in `sessions` and are skipped. Harmless: an adoption writes no checkpoint of its own and
787
+ // holds nothing the next daemon cannot rebuild the same way (the adapter runs on in keeper-acp, the logs are durable); if one
788
+ // registers after this, the transports are closed right behind it and its loop stops quietly (`KeeperClosedError`).
789
+ const work = [...this.sessions.values()].map((live) => (live.session.durable && live.session.detach
790
+ ? this.retire(live, 'detach')
791
+ : live.session.checkpoint?.())?.catch((err) => {
792
+ console.warn(`[agent] leaving ${live.sessionRef} to the next daemon failed: ${errorMessage(err)}`);
793
+ }));
794
+ let timer;
795
+ await Promise.race([Promise.all(work), new Promise((resolve) => { timer = setTimeout(resolve, timeoutMs); })]);
796
+ clearTimeout(timer);
797
+ }
464
798
  async handleFrame(frame) {
799
+ // A frame for a ref keeper-acp still runs from a previous daemon waits until that session is rebuilt (Task 21): answering
800
+ // it "unknown session" would end a turn that is going on.
801
+ const frameRef = 'sessionRef' in frame && typeof frame.sessionRef === 'string' ? frame.sessionRef : undefined;
802
+ // A generation the adoption could not list yet may hold this ref (final review finding 3): listed again first (synchronous check).
803
+ if (frameRef !== undefined && !this.liveSession(frameRef) && this.agents.keeperAdoptionPending?.())
804
+ await this.adoptLate();
805
+ const adoptingRef = frameRef !== undefined ? this.adopting.get(frameRef) : undefined;
806
+ if (adoptingRef)
807
+ await adoptingRef.done;
808
+ // Parked while the ref is switched (spec §6.3 step 1): delivered, in arrival order, to whichever session holds it afterwards.
809
+ for (let parked = frameRef !== undefined ? this.switching.get(frameRef) : undefined; parked; parked = this.switching.get(frameRef))
810
+ await parked;
465
811
  switch (frame.t) {
812
+ case 'agent_delegation_renew': {
813
+ if (this.serverAgentWire < AGENT_WIRE_DELEGATION_RENEW_VERSION)
814
+ return;
815
+ const live = this.liveSession(frame.sessionRef);
816
+ const now = Date.now();
817
+ // Delegation's non-secret fields are processor state (Task 19): the checks read it, an accepted renewal is a note.
818
+ const delegation = live?.session.delegation ?? undefined;
819
+ if (!live || !delegation || live.closed || !live.session.liveness.agentAlive || live.session.liveness.turnRunning ||
820
+ activityIsBusy(live.session.activityRaw) || delegation.refreshPending ||
821
+ live.nativeSessionId !== frame.nativeSessionId || delegation.instanceId !== frame.instanceId ||
822
+ delegation.bindingId !== frame.bindingId || !frame.bindingId ||
823
+ !Number.isSafeInteger(frame.renewalRevision) || frame.renewalRevision < 1 ||
824
+ !Number.isSafeInteger(frame.expiresAtEpochMs) || frame.expiresAtEpochMs <= now ||
825
+ frame.expiresAtEpochMs > now + 330_000 || frame.renewalRevision < (delegation.renewalRevision ?? 0) ||
826
+ frame.expiresAtEpochMs < delegation.expiresAtEpochMs ||
827
+ (frame.renewalRevision === delegation.renewalRevision && frame.expiresAtEpochMs !== delegation.expiresAtEpochMs)) {
828
+ this.emit({ t: 'agent_delegation_renewed', id: frame.id, ok: false, error: 'The original managed instance cannot renew' });
829
+ return;
830
+ }
831
+ try {
832
+ await live.session.note({ type: 'agent_delegation_renewed', expiresAtEpochMs: frame.expiresAtEpochMs, renewalRevision: frame.renewalRevision });
833
+ }
834
+ catch (err) {
835
+ this.emit({ t: 'agent_delegation_renewed', id: frame.id, ok: false, error: errorMessage(err) });
836
+ return;
837
+ }
838
+ const renewed = live.session.delegation ?? delegation;
839
+ this.emit({ t: 'agent_delegation_renewed', id: frame.id, ok: true,
840
+ nativeSessionId: live.nativeSessionId, instanceId: renewed.instanceId, bindingId: renewed.bindingId,
841
+ renewalRevision: renewed.renewalRevision, expiresAtEpochMs: renewed.expiresAtEpochMs });
842
+ return;
843
+ }
466
844
  case 'agent_open': {
467
845
  const admission = this.reserveOpen(frame.id, frame.sessionRef);
468
846
  if (admission === 'rejected')
469
847
  return;
848
+ const ref = frame.sessionRef;
849
+ this.openHandling.set(ref, (this.openHandling.get(ref) ?? 0) + 1);
470
850
  try {
471
851
  await this.onOpen(frame);
472
852
  }
473
853
  finally {
474
854
  if (admission === 'reserved')
475
- this.tasks?.agentOpenFinished(frame.sessionRef);
855
+ this.tasks?.agentOpenFinished(ref);
856
+ const left = (this.openHandling.get(ref) ?? 1) - 1;
857
+ if (left > 0)
858
+ this.openHandling.set(ref, left);
859
+ else
860
+ this.openHandling.delete(ref);
861
+ // An adapter that exited while this open was handled (a respawn that died right after its load) is reported after the
862
+ // reply: reported before it, the server would void the generation it is establishing.
863
+ this.flushAdapterLost(ref);
476
864
  }
477
865
  return;
478
866
  }
@@ -480,19 +868,15 @@ export class AgentSessionManager {
480
868
  const live = this.routableSession(frame.sessionRef, 'prompt');
481
869
  if (!live)
482
870
  return;
483
- if (this.tasks && !this.tasks.agentTurnStarted(frame.sessionRef)) {
484
- // No `authMethods` here, deliberately, even though this is a failed turn on a session
485
- // whose agent we have certainly connected to: the prompt never reached that agent, so
486
- // nothing about credentials is being reported. This frame is built here rather than in
487
- // `onAgentTurnDone`, so it never passes the {@link TurnResult.agentRefused} gate — the
488
- // omission is structural, not a line someone has to remember.
489
- this.record(live, (seq) => ({
490
- t: 'agent_turn_done', sessionRef: frame.sessionRef, seq, status: 'failed',
491
- error: 'Workstation is not accepting new work',
492
- }));
871
+ if (this.tasks && !this.tasks.tryAdmitTurn(frame.sessionRef)) {
872
+ // The refusal is the daemon's decision, so it is a note (`agent_prompt{admitted:false}`) and the processor ends the
873
+ // turn `failed` with the same words (`ADMISSION_REFUSED`), numbered in the session's own sequence (spec §5.4). No
874
+ // `authMethods`: the processor's short frame carries no `agentRefused`, so the projection adds none — the prompt
875
+ // never reached the agent.
876
+ live.session.refusePrompt(frame.platformTurnId);
493
877
  return;
494
878
  }
495
- live.session.prompt(promptBlocks(frame));
879
+ live.session.prompt(promptBlocks(frame), frame.platformTurnId);
496
880
  return;
497
881
  }
498
882
  case 'agent_answer': {
@@ -500,6 +884,28 @@ export class AgentSessionManager {
500
884
  live?.session.answer(frame.requestId, frame.outcome);
501
885
  return;
502
886
  }
887
+ case 'agent_elicitation_answer': {
888
+ const live = this.routableSession(frame.sessionRef, 'elicitationAnswer');
889
+ if (!live)
890
+ return;
891
+ if (!isKnownElicitationAction(frame.action)) {
892
+ // `AGENT_FRAME_SHAPES` only validates `action` as *a* string (ACP's own action union has
893
+ // an open `string` arm for agent-authored custom actions), but this daemon is the only
894
+ // sender of this frame and the server only ever offers the user accept/decline/cancel —
895
+ // so anything else reaching here is a platform bug (a typo, a stale enum), not a
896
+ // legitimate custom action. Declining rather than forwarding it verbatim keeps the agent
897
+ // from being handed a value ACP itself would still accept as "protocol-valid" — the exact
898
+ // shape `isString`-only validation cannot catch on its own.
899
+ console.warn(`[agent] agent_elicitation_answer for '${frame.sessionRef}' carried an unrecognised action '${frame.action}'; declining instead of forwarding it`);
900
+ live.session.answerElicitation(frame.requestId, { action: 'decline' });
901
+ return;
902
+ }
903
+ live.session.answerElicitation(frame.requestId, {
904
+ action: frame.action,
905
+ ...(frame.content ? { content: frame.content } : {}),
906
+ });
907
+ return;
908
+ }
503
909
  case 'agent_interrupt': {
504
910
  const live = this.routableSession(frame.sessionRef, 'interrupt');
505
911
  if (!live)
@@ -513,16 +919,92 @@ export class AgentSessionManager {
513
919
  return;
514
920
  }
515
921
  /**
516
- * A steer rides a turn that is **already** running, so it is deliberately not bracketed by
517
- * `tasks.agentTurnStarted` / `agentTurnFinished` the way `agent_prompt` is: the agent still
518
- * answers exactly one `session/prompt` and still sends exactly one `agent_turn_done`, so
519
- * there is no second completion to close a second bracket with.
922
+ * Stop **one** background task, leaving the running turn alone — ACP's
923
+ * `_session/async_task/stop`. The server sends this instead of marking its JOB row cancelled
924
+ * behind the agent's back, which is what it used to do: the row went terminal, the task kept
925
+ * running, and the UI said "stopped" while the truth was "still running".
926
+ *
927
+ * Doesn't touch `busy()`'s inputs, for the reason `agent_steer` documents at length: this is
928
+ * not a turn, so it must not push {@link AcpSession.inFlightTurns} — `busy()` is sampled live
929
+ * off that array and the agent's reported activity (2026-09-21 rewrite, no accumulator to
930
+ * leak any more), and `stopAsyncTask()` deliberately does none of that bookkeeping, same as
931
+ * `steer()` (see `AcpSession.stopAsyncTask`'s own doc).
932
+ *
933
+ * **No reply frame, so a failure can only be logged** — and that is by design for the ordinary
934
+ * case, not an omission (see `agent_async_task_stop`'s own doc in `protocol/agent.ts`): the
935
+ * question the server is waiting on is not "did the request arrive" but "did the task actually
936
+ * stop", and only the agent's own next `async_task_state_update` for this
937
+ * `(sessionRef, asyncTaskId)` answers it. That update travels the ordinary event stream and
938
+ * settles the JOB there.
939
+ *
940
+ * ## Known gap: a refusal here is invisible to the server
941
+ *
942
+ * A `Method not found` from an agent that advertised a name we have a collector for but does
943
+ * not implement `_session/async_task/stop` lands in this catch and goes no further. The server
944
+ * recorded a "Stop requested" note on that JOB and will now wait for a terminal update that is
945
+ * never coming; nothing on that side says why.
946
+ *
947
+ * **Narrow today, and deliberately not papered over.** The server only sends this frame to a
948
+ * session whose `agent_opened` explicitly reported `supportsAsyncTaskStop`, from the
949
+ * certified `InitializeResponse.agentInfo.name` compatibility map — and both adapters
950
+ * implement the method (verified: `acp-agent.js@0.79.0:165`,
951
+ * `codex-acp@1.12.0 dist/index.js:31608`). So the gap opens only if one of them drops the
952
+ * method in a future version, or a third-party agent adopts one of those names.
953
+ *
954
+ * **Why it is not closed here**: the only honest thing to report is "the stop request was
955
+ * refused; the task is still running", and this daemon has no channel that can carry it. The
956
+ * frame has no reply; synthesizing an `async_task_state_update` would invent a terminal state
957
+ * the agent never reported, which is the exact lie this whole path was built to remove; and a
958
+ * new event name or a reply frame is a wire-contract change that belongs in a reviewed task,
959
+ * not in a fix applied to one. Tracked as a Task 8 acceptance item. Until then this log line
960
+ * is the only evidence, so it names the consequence rather than just the error.
961
+ */
962
+ case 'agent_async_task_stop': {
963
+ const live = this.routableSession(frame.sessionRef, 'async_task_stop');
964
+ if (!live)
965
+ return;
966
+ try {
967
+ if (!live.nativeSessionId || !supportsAsyncTaskStop(live.session.agentInfo?.name) || !frame.asyncTaskId.trim()) {
968
+ throw new Error('No certified task-stop route and exact target for this session');
969
+ }
970
+ await live.session.stopAsyncTask(frame.asyncTaskId);
971
+ }
972
+ catch (err) {
973
+ console.error(`[agent] stopping background task ${frame.asyncTaskId} failed for ${frame.sessionRef}: ` +
974
+ `${errorMessage(err)} -- the task is still running and the server will keep waiting ` +
975
+ 'for a terminal update it will never receive');
976
+ }
977
+ return;
978
+ }
979
+ case 'agent_provider_task_stop': {
980
+ if (this.serverAgentWire < AGENT_WIRE_PROVIDER_TASK_VERSION) {
981
+ this.emit({ t: 'agent_provider_task_stopped', id: frame.id, state: 'rejected', error: 'Provider task control requires a newer server' });
982
+ return;
983
+ }
984
+ // No session selects a provider-task adapter (none ever did: the old path's `providerTaskAdapterSelected` had no
985
+ // producer that set it, and it went with that path in Task 29), so no task identity is ever live and trusted. The
986
+ // wire answer is unchanged, and so is the routing (an unknown ref is still reported lost).
987
+ this.routableSession(frame.sessionRef, 'provider_task_stop');
988
+ this.emit({ t: 'agent_provider_task_stopped', id: frame.id, state: 'rejected', error: 'Provider task identity is not live and trusted' });
989
+ return;
990
+ }
991
+ /**
992
+ * A steer rides a turn that is **already** running, so it must not push
993
+ * {@link AcpSession.inFlightTurns} the way `agent_prompt` does: `steer()` sends exactly one
994
+ * extension request and gets exactly one reply, not a second `agent_turn_done`, so there is
995
+ * no second completion to pop a second entry with.
520
996
  *
521
- * The leak that would open is not hypothetical. The server steers whenever **it** believes a
522
- * turn is running, and the agent is free to disagree and answer `promptRequired` — a steer on
523
- * an idle session. Nothing will ever send a `turn_done` for that one, so the sessionRef would
524
- * sit in `TaskRegistry`'s agent-task set forever and `canUpdate()` would be false for the rest
525
- * of this daemon's life. Same shape as the hung-prompt leak `readLoopDead` exists for.
997
+ * That distinction used to matter for a leak that was not hypothetical, and still explains why
998
+ * `steer()` pushes nothing today. The server steers whenever **it** believes a turn is
999
+ * running, and the agent is free to disagree and answer `promptRequired` — a steer on an idle
1000
+ * session. Under the old bracketed `TaskRegistry.agentTasks` (removed 2026-09-21), nothing
1001
+ * would ever send a `turn_done` to close that bracket, so the sessionRef sat there forever and
1002
+ * `canUpdate()` stayed `false` for the rest of the daemon's life — the same shape as the
1003
+ * hung-prompt leak `readLoopDead` exists for. `busy()` is sampled live today, off
1004
+ * {@link AcpSession.turnRunning} and the agent's own reported activity, with no set to leave a
1005
+ * ref in — but that safety is a property of `busy()`'s inputs, not of this call site, so
1006
+ * `steer()` still deliberately pushes nothing to {@link AcpSession.inFlightTurns} (see its own
1007
+ * doc): an idle-session steer must not be mistaken for the start of a turn.
526
1008
  *
527
1009
  * Every exit answers. The server is holding the steered message in its own queue table
528
1010
  * (`session_message_queue`) while this request is in flight — the message is not on the
@@ -545,7 +1027,7 @@ export class AgentSessionManager {
545
1027
  // — see {@link promptBlocks}. `frame.priority` is passed through untouched, `undefined`
546
1028
  // included: absent means the server named no timing, and inventing one here would be
547
1029
  // this daemon deciding something the server deliberately left unsaid.
548
- outcome: await live.session.steer(promptBlocks(frame), frame.priority),
1030
+ outcome: await live.session.steer(promptBlocks(frame), frame.priority, frame.id),
549
1031
  });
550
1032
  }
551
1033
  catch (err) {
@@ -559,10 +1041,12 @@ export class AgentSessionManager {
559
1041
  /**
560
1042
  * Set one session config option, round-tripping ACP `session/set_config_option`. Same shape
561
1043
  * as `agent_steer` immediately above, and for the same reason: this is a real round trip the
562
- * server is blocked waiting on, not a turn, so it is not bracketed by
563
- * `tasks.agentTurnStarted` / `agentTurnFinished` — there is no second completion to close a
564
- * second bracket with, and a bracket opened here would never close, leaking the ref for the
565
- * life of the daemon.
1044
+ * server is blocked waiting on, not a turn, so it must not push
1045
+ * {@link AcpSession.inFlightTurns} — there is no second `agent_turn_done` to pop it with, and
1046
+ * `busy()` is sampled live off that array plus the agent's own reported activity (2026-09-21
1047
+ * rewrite: no accumulator here any more), so a push with nothing to pop it would pin a session
1048
+ * "busy" for the life of the daemon exactly the way the old bracketed `TaskRegistry.agentTasks`
1049
+ * used to leak.
566
1050
  *
567
1051
  * Every exit answers: a session that is not routable, an agent that throws (its own refusal,
568
1052
  * or `Method not found` from one that never advertised the method), all send
@@ -575,11 +1059,16 @@ export class AgentSessionManager {
575
1059
  this.emit({ t: 'agent_config_option_set', id: frame.id, ok: false, error: `no live session '${frame.sessionRef}'` });
576
1060
  return;
577
1061
  }
1062
+ const startedAt = performance.now();
1063
+ const operation = `request=${frame.id} session=${frame.sessionRef} config=${frame.configId}`;
1064
+ console.info(`${new Date().toISOString()} [agent/config] applying ${operation}`);
578
1065
  try {
579
- const configOptions = await live.session.setConfigOption(frame.configId, frame.value);
1066
+ const configOptions = await live.session.setConfigOption(frame.configId, frame.value, frame.id);
1067
+ console.info(`${new Date().toISOString()} [agent/config] confirmed ${operation} elapsedMs=${Math.round(performance.now() - startedAt)}`);
580
1068
  this.emit({ t: 'agent_config_option_set', id: frame.id, ok: true, configOptions });
581
1069
  }
582
1070
  catch (err) {
1071
+ console.warn(`${new Date().toISOString()} [agent/config] failed ${operation} elapsedMs=${Math.round(performance.now() - startedAt)}: ${errorMessage(err)}`);
583
1072
  // Includes the agent's own refusal ("Invalid value…", "Unknown config option…") and a
584
1073
  // `Method not found` from an agent without the method — both are the server's to show.
585
1074
  this.emit({ t: 'agent_config_option_set', id: frame.id, ok: false, error: errorMessage(err) });
@@ -596,7 +1085,7 @@ export class AgentSessionManager {
596
1085
  if (!live)
597
1086
  return;
598
1087
  this.touch(live);
599
- await this.enqueue(live, () => live.journal.truncateThrough(frame.throughSeq));
1088
+ await this.acknowledge(live, frame.throughSeq);
600
1089
  return;
601
1090
  }
602
1091
  case 'agent_list_native':
@@ -606,8 +1095,37 @@ export class AgentSessionManager {
606
1095
  }
607
1096
  await this.onListNative(frame);
608
1097
  return;
1098
+ case 'agent_delete_native':
1099
+ if (this.tasks && !this.tasks.accepting) {
1100
+ this.emit({ t: 'agent_native_deleted', id: frame.id, ok: false, error: 'Workstation is not accepting new work' });
1101
+ return;
1102
+ }
1103
+ await this.onDeleteNative(frame);
1104
+ return;
609
1105
  // Both of these spawn an agent subprocess, exactly as `agent_list_native` does, so they answer
610
1106
  // a draining workstation the same way rather than starting work it is trying to finish.
1107
+ case 'agent_account_read': {
1108
+ if (this.tasks && !this.tasks.accepting) {
1109
+ this.emit({ t: 'agent_account_read_result', id: frame.id, ok: false, error: 'Workstation is not accepting new work' });
1110
+ return;
1111
+ }
1112
+ try {
1113
+ // A keeper session may predate the extension even after its current adapter build was upgraded.
1114
+ const spec = sanitizeNativeSessionQuery(frame.bridge, frame.query);
1115
+ if (!spec)
1116
+ throw new Error('No workspace is available');
1117
+ const minimumVersion = await this.agents.minimumAccountLimitsVersion?.(spec) ?? 2;
1118
+ 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);
1119
+ if (!live?.session.readAccountLimits && !this.agents.readAccountLimits)
1120
+ throw new Error('Account usage is unsupported');
1121
+ const snapshot = live?.session.readAccountLimits ? await live.session.readAccountLimits(frame.id) : await this.agents.readAccountLimits(spec);
1122
+ this.emit({ t: 'agent_account_read_result', id: frame.id, ok: true, snapshot });
1123
+ }
1124
+ catch {
1125
+ this.emit({ t: 'agent_account_read_result', id: frame.id, ok: false, error: 'Failed to fetch usage' });
1126
+ }
1127
+ return;
1128
+ }
611
1129
  case 'agent_verify':
612
1130
  if (this.tasks && !this.tasks.accepting) {
613
1131
  this.emit({ t: 'agent_verified', id: frame.id, ok: false, error: 'Workstation is not accepting new work' });
@@ -631,32 +1149,150 @@ export class AgentSessionManager {
631
1149
  }
632
1150
  await this.onInstall(frame);
633
1151
  return;
1152
+ // "Move idle sessions" (Task 25): a move spawns adapters, so a draining workstation refuses it like an install.
1153
+ case 'agent_switch_idle':
1154
+ if (this.tasks && !this.tasks.accepting) {
1155
+ this.emit({ t: 'agent_switch_idle_result', id: frame.id, ok: false, error: 'Workstation is not accepting new work' });
1156
+ return;
1157
+ }
1158
+ await this.onSwitchIdle(frame);
1159
+ return;
634
1160
  default:
635
- // Not an agent frame (or an outbound-only one) — the daemon routes those elsewhere.
1161
+ this.warnUnhandledFrame(frame);
636
1162
  return;
637
1163
  }
638
1164
  }
1165
+ /** Frame types already reported as having no case on this daemon; see {@link warnUnhandledFrame}. */
1166
+ warnedUnhandledFrameTypes = new Set();
1167
+ /**
1168
+ * Say once, per frame type, that a frame reached this dispatcher and nothing acted on it.
1169
+ *
1170
+ * **This used to be a bare `return` with a comment.** That silence is the exact half of the wire
1171
+ * this side had no alarm for: `agent_async_task_stop` is the first server->client request frame
1172
+ * whose only consumer is the `switch` above, so a version of this daemon that shipped the sending
1173
+ * half without the receiving one would have dropped every stop request with no type error, no log
1174
+ * line and no timeout — "shipped inert", and invisible. The Kotlin twin
1175
+ * (`AgentChannelSession.warnUnhandledFrame`) has been loud here since the `ec341b8e4` incident,
1176
+ * where three reply frames were dropped before their handler and three features were inert on
1177
+ * every workstation from the day they shipped; this is the same alarm on the other end.
1178
+ *
1179
+ * Deduplicated by type rather than logged per frame, exactly as the Kotlin twin does it: the
1180
+ * diagnosis is "this type has no case", and repeating it per frame would bury it.
1181
+ *
1182
+ * One benign arrival is possible and is deliberately **not** exempted: a `heartbeat_ack` for a seq
1183
+ * the channel no longer has outstanding slips past `connectEncryptedChannel`'s ack filter and
1184
+ * lands here. That costs one line for the life of the process, and an exemption list would be a
1185
+ * second, parallel statement of which frames this daemon handles — precisely the hand-maintained
1186
+ * copy `AGENT_FRAME_SHAPES`'s own doc exists to avoid.
1187
+ */
1188
+ warnUnhandledFrame(frame) {
1189
+ if (this.warnedUnhandledFrameTypes.has(frame.t))
1190
+ return;
1191
+ this.warnedUnhandledFrameTypes.add(frame.t);
1192
+ console.warn(`[agent] no case for an inbound '${frame.t}'; it reached this daemon and nothing consumed it`);
1193
+ }
639
1194
  /**
640
1195
  * Classifies an open before its first await. A live or already-opening ref is continuation
641
1196
  * traffic, so graceful drain lets it replay/piggyback without acquiring (and later releasing)
642
1197
  * another reservation. Force-stop is different: it is destructive shutdown and rejects every
643
- * open, including reconnects. A ref absent from both maps is genuinely new work and still needs
1198
+ * open, including reconnects — except a reconnect of a live keeper-hosted session, which the force
1199
+ * stop leaves attached. A ref absent from both maps is genuinely new work and still needs
644
1200
  * the ordinary TaskRegistry admission reservation.
645
1201
  */
646
1202
  reserveOpen(openId, sessionRef) {
647
1203
  if (!this.tasks)
648
1204
  return 'continuation';
1205
+ const live = this.liveSession(sessionRef);
1206
+ // A keeper-hosted session whose adapter is gone is respawned by this open: new work, not a continuation — it takes a reservation,
1207
+ // and a draining workstation refuses it like any open (spec §8.2; fix round 1, minor 9).
1208
+ const respawn = live !== undefined && this.needsRespawn(live);
649
1209
  if (this.tasks.isForceStopping()) {
1210
+ // A keeper-hosted session a force update left attached (TaskRegistry.stopAll) is still reopened as a continuation — a reconnect
1211
+ // during the download must not cut it off; anything else is refused.
1212
+ if (live && !respawn && this.tasks.isDurableSession(sessionRef))
1213
+ return 'continuation';
650
1214
  this.emit({ t: 'agent_opened', id: openId, ok: false, error: 'Workstation is not accepting new work' });
651
1215
  return 'rejected';
652
1216
  }
653
- if (this.liveSession(sessionRef) || this.opening.has(sessionRef))
1217
+ if ((live && !respawn) || this.opening.has(sessionRef) || this.adopting.has(sessionRef))
654
1218
  return 'continuation';
655
1219
  if (this.tasks.tryReserveAgentOpen(sessionRef))
656
1220
  return 'reserved';
657
1221
  this.emit({ t: 'agent_opened', id: openId, ok: false, error: 'Workstation is not accepting new work' });
658
1222
  return 'rejected';
659
1223
  }
1224
+ /**
1225
+ * A session whose adapter is gone: an `agent_open` of it spawns and loads anew (Task 21) — keeper-hosted or adopted, and also a
1226
+ * local-fallback session whose adapter exited (it would otherwise be "reused" dead).
1227
+ */
1228
+ needsRespawn(live) {
1229
+ const exited = live.session.adapterExit !== undefined;
1230
+ return (live.adopted === true || live.session.durable === true || exited) && !live.session.liveness.agentAlive;
1231
+ }
1232
+ /**
1233
+ * The adapter of `live` exited under its loop, and its frames (a failed `turn_done` for an open turn) are recorded. Unless this
1234
+ * daemon stopped it (`terminated`: a close, a switch, a retire), the session cannot take another prompt: the server is told it is
1235
+ * lost (`agent_session_lost{sweep}` — the poison contract's recovery, spec §11 "single adapter crash"), so its next prompt opens
1236
+ * first, and that `agent_open` respawns the adapter and loads the native session ({@link needsRespawn}). The session stays held
1237
+ * (reported `agentAlive:false`) until then. An exit of its own also counts towards {@link ADAPTER_CRASH_LIMIT}.
1238
+ */
1239
+ onAdapterExited(live, exit) {
1240
+ const ref = live.sessionRef;
1241
+ if (exit.reason === 'terminated')
1242
+ return;
1243
+ if (live.closed || this.sessions.get(ref) !== live || this.detached.has(ref))
1244
+ return;
1245
+ const how = exitWords(exit);
1246
+ if (exit.reason === undefined) {
1247
+ const now = Date.now();
1248
+ const last = this.adapterCrashes.get(ref);
1249
+ const count = last && now - last.lastAt < ADAPTER_CRASH_FORGET_MS ? last.count + 1 : 1;
1250
+ this.adapterCrashes.set(ref, { count, lastAt: now, detail: how });
1251
+ }
1252
+ console.warn(`[acp:${live.agentId} agent:${live.agentKey}] the adapter of session ${ref} exited ` +
1253
+ `(${exit.reason ?? 'on its own'}; ${how})${exit.busy ? ' while a turn or card was open' : ''}; its next open respawns it`);
1254
+ // Everything the exit produced (the failed `turn_done`) is in the outbox by now: the notice waits until it has been sent.
1255
+ live.adapterLost = { spawnId: exit.spawnId, reason: exit.reason, busy: exit.busy, notified: false, throughSeq: live.session.outbox.lastSeq };
1256
+ this.flushAdapterLost(ref);
1257
+ }
1258
+ /**
1259
+ * Sends the lost notice of the session holding `ref` ({@link onAdapterExited}) once it cannot overtake anything: no open, switch
1260
+ * or retire of the ref under way (the open's own reply goes first), and every frame the exit produced (`throughSeq`: its failed
1261
+ * `turn_done`) already sent **on this connection** — the outbox pump holds frames behind the session window and the socket gate,
1262
+ * while this notice is sent directly. Overtaken, the server would end the turn itself, drop the `turn_done` unacknowledged, and
1263
+ * 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
1264
+ * rebind. Not queued while unbound ({@link sweptWhileUnsent}): a new connection leaves the server's delivery unwired anyway, and
1265
+ * entries sent on an earlier one are held until its reopen — which respawns the adapter and retires this session.
1266
+ */
1267
+ flushAdapterLost(ref) {
1268
+ const live = this.liveSession(ref);
1269
+ const lost = live?.adapterLost;
1270
+ if (!live || !lost || lost.notified || this.send === null)
1271
+ return;
1272
+ if (this.opening.has(ref) || this.openHandling.has(ref) || this.switching.has(ref) || this.retiring.has(ref))
1273
+ return;
1274
+ if (live.session.liveness.agentAlive)
1275
+ return;
1276
+ if (live.session.outbox.pending.some((item) => item.seq <= lost.throughSeq && item.sentOn !== this.epoch))
1277
+ return;
1278
+ lost.notified = true;
1279
+ this.reportSessionLost(ref, AgentSessionManager.SWEEP_LOST_ACTION);
1280
+ }
1281
+ /**
1282
+ * Why an open of `ref` may not respawn its adapter now ({@link ADAPTER_CRASH_LIMIT}), or `undefined`. The text is what the user's
1283
+ * failed message shows.
1284
+ */
1285
+ adapterCrashHold(ref, now) {
1286
+ const crashes = this.adapterCrashes.get(ref);
1287
+ if (!crashes || crashes.count < ADAPTER_CRASH_LIMIT || now - crashes.lastAt >= ADAPTER_CRASH_FORGET_MS)
1288
+ return undefined;
1289
+ const backoff = Math.min(this.adapterCrashBackoffMs * 2 ** (crashes.count - ADAPTER_CRASH_LIMIT), ADAPTER_CRASH_BACKOFF_MAX_MS);
1290
+ const wait = crashes.lastAt + backoff - now;
1291
+ if (wait <= 0)
1292
+ return undefined;
1293
+ return `The agent's adapter process exited on its own ${crashes.count} times in a row (last: ${crashes.detail}); ` +
1294
+ `it is not started again for another ${Math.ceil(wait / 1000)} s.`;
1295
+ }
660
1296
  /**
661
1297
  * Everything a failure frame says about a refusal **the agent itself** produced: the sign-in table
662
1298
  * it advertised, and the JSON-RPC code it answered with.
@@ -715,7 +1351,12 @@ export class AgentSessionManager {
715
1351
  */
716
1352
  routableSession(sessionRef, action) {
717
1353
  const live = this.liveSession(sessionRef);
718
- if (!live) {
1354
+ if (!live && this.detached.has(sessionRef)) {
1355
+ // 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
1356
+ // server off the next `agent_opened.pendingRequestIds`.
1357
+ console.warn(`[agent] dropped ${action} for '${sessionRef}': the session was handed to the next daemon`);
1358
+ }
1359
+ else if (!live) {
719
1360
  console.warn(`[agent] dropped ${action} for unknown session '${sessionRef}'`);
720
1361
  this.reportSessionLost(sessionRef, action);
721
1362
  }
@@ -805,24 +1446,38 @@ export class AgentSessionManager {
805
1446
  * `now` is a parameter rather than a clock read so the sweep is deterministic under test.
806
1447
  * The teardown is deliberately the same one `agent_close` performs ({@link retire}) — a swept
807
1448
  * session must leave nothing behind that a later frame could resurrect — with one deliberate
808
- * exception: **the journal survives**.
1449
+ * exception: **the outbox survives**.
809
1450
  *
810
1451
  * That exception is the difference between `agent_close` and this sweep. `agent_close` is the
811
- * server saying "I am done with this ref", so its journal can never be asked for again. A sweep is
1452
+ * server saying "I am done with this ref", so its outbox can never be asked for again. A sweep is
812
1453
  * unilateral: the server has not asked for anything and may well re-open the ref with a
813
- * `resumeFromSeq` the moment it next hears from the user. The sweep also fires precisely when a
814
- * journal is most likely to be holding undelivered output — the workstation was offline past the
815
- * TTL (a laptop shut overnight), so a turn that finished while the socket was down is journaled
816
- * and unsent. Destroying it there loses that turn outright, and `droppedOldest` is per-instance so
817
- * not even a transport-gap card would mark the hole. The same distinction already exists one layer
1454
+ * `resumeFromSeq` the moment it next hears from the user. The sweep also fires precisely when an
1455
+ * outbox is most likely to be holding undelivered output — the workstation was offline past the
1456
+ * TTL (a laptop shut overnight), so a turn that finished while the socket was down is recorded
1457
+ * and unsent. Destroying it there loses that turn outright. The same distinction already exists one layer
818
1458
  * up as {@link AbandonReason}; a sweep is `disconnected`-shaped, not `closed`-shaped. Kill the
819
- * process, keep the file.
1459
+ * process, keep the outbox (the transport session; its next open reuses it).
820
1460
  *
821
1461
  * Each sweep also reports itself to the server ({@link notifySwept}) so its stale session state is
822
1462
  * retired before the next user message runs into it, rather than after — the sweep heals the
823
1463
  * daemon's side of the divergence; the notice heals the server's.
824
1464
  */
825
1465
  async sweepIdle(ttlMs, now) {
1466
+ // Observation, not a decision: a session the agent still claims is `running` is never swept
1467
+ // for that alone (ruling A forbids inventing a timeout that overrides the vendor), but one
1468
+ // that has gone this long without another push is worth naming in the log so whoever is
1469
+ // debugging a stuck workstation can see it instead of just trusting a RUNNING that never moves.
1470
+ for (const live of this.sessions.values()) {
1471
+ if (!live.staleActivityWarned &&
1472
+ activityIsBusy(live.session.activityRaw) &&
1473
+ live.lastActivityAtMs !== undefined &&
1474
+ now - live.lastActivityAtMs > STALE_ACTIVITY_WARN_MS) {
1475
+ live.staleActivityWarned = true;
1476
+ console.warn(`[acp:${live.agentId} agent:${live.agentKey}] session ${live.sessionRef} has reported \`running\` ` +
1477
+ `for ${Math.round((now - live.lastActivityAtMs) / 60000)} minutes with no further push; ` +
1478
+ 'the platform is reporting it as RUNNING because that is what the agent said.');
1479
+ }
1480
+ }
826
1481
  const idle = [...this.sessions.values()].filter((live) => now - live.lastActivityAt >= ttlMs);
827
1482
  // Snapshot first: retire() mutates `sessions`, and it awaits.
828
1483
  for (const live of idle) {
@@ -835,28 +1490,115 @@ export class AgentSessionManager {
835
1490
  return idle.map((live) => live.sessionRef);
836
1491
  }
837
1492
  async onOpen(frame) {
838
- // Reconnect / duplicate open: reuse the live session as-is and replay what the server
839
- // still misses.
840
- const existing = this.liveSession(frame.sessionRef);
841
- if (existing) {
842
- // Reusing a live session: neither the process nor the native session moved.
843
- await this.resumeAndReply(existing, frame, false);
844
- // The session already exists, so any history it has was already streamed under whichever
845
- // open first established it (or never will be, if that open didn't ask for it) — replaying it
846
- // again here would duplicate what the platform already holds. But if THIS frame asked for
847
- // backfill, the server is waiting on exactly one `agent_backfill_done` for THIS open's id:
848
- // silence is not "no", it is a wait that never resolves. Answer it without replaying.
849
- this.replyNoReplayNeeded(frame);
1493
+ if (frame.delegationMcp && (this.serverAgentWire < AGENT_WIRE_DELEGATION_MCP_VERSION ||
1494
+ (frame.delegationMcp.bindingId && this.serverAgentWire < AGENT_WIRE_DELEGATION_RENEW_VERSION))) {
1495
+ this.emit({ t: 'agent_opened', id: frame.id, ok: false, error: 'Managed delegation requires a newer server' });
850
1496
  return;
851
1497
  }
1498
+ if (frame.delegationMcp) {
1499
+ try {
1500
+ buildSessionMcpServers([], frame.delegationMcp);
1501
+ }
1502
+ catch {
1503
+ this.emit({ t: 'agent_opened', id: frame.id, ok: false, error: 'Delegation MCP configuration is unavailable' });
1504
+ return;
1505
+ }
1506
+ }
1507
+ // Rotation is server-driven. A busy reconnect resends the original process's outbox.
1508
+ let existing = this.liveSession(frame.sessionRef);
1509
+ // An open that would spawn the ref's adapter anew after it kept exiting on its own waits out the backoff (no crash loop).
1510
+ if (existing && this.needsRespawn(existing) && !this.opening.has(frame.sessionRef)) {
1511
+ const held = this.adapterCrashHold(frame.sessionRef, Date.now());
1512
+ if (held) {
1513
+ // Rate-limited: the server's heartbeat may retry the open every ten seconds for the whole backoff.
1514
+ const now = Date.now();
1515
+ const crashes = this.adapterCrashes.get(frame.sessionRef);
1516
+ if (crashes && now - (crashes.warnedAt ?? -Infinity) >= ADAPTER_CRASH_WARN_EVERY_MS) {
1517
+ crashes.warnedAt = now;
1518
+ console.warn(`[agent] ${frame.sessionRef}: not respawning its adapter: ${held}`);
1519
+ }
1520
+ this.emit({ t: 'agent_opened', id: frame.id, ok: false, error: held });
1521
+ if (frame.backfill)
1522
+ this.emit({ t: 'agent_backfill_done', id: frame.id, ok: false, count: 0, error: held });
1523
+ return;
1524
+ }
1525
+ }
1526
+ let openFrame = frame;
1527
+ let retireForRefresh;
1528
+ if (existing && this.needsRespawn(existing)) {
1529
+ // A keeper-hosted session whose adapter is gone (it exited, or keeper-acp restarted and recovered it as `keeper_lost` — its
1530
+ // tail was folded and recorded): the reopen starts a new spawn in the same transport session and loads the native one, as
1531
+ // for any session left behind (Task 21).
1532
+ retireForRefresh = existing;
1533
+ existing = undefined;
1534
+ }
1535
+ if (existing) {
1536
+ const delegation = existing.session.delegation ?? undefined;
1537
+ if (delegation && !frame.delegationMcp) {
1538
+ this.emit({ t: 'agent_opened', id: frame.id, ok: false, error: 'Managed delegation lease is required' });
1539
+ return;
1540
+ }
1541
+ if (frame.delegationMcp && frame.nativeSessionId && frame.nativeSessionId !== existing.nativeSessionId) {
1542
+ this.emit({ t: 'agent_opened', id: frame.id, ok: false, error: 'Managed delegation cannot reload the original session' });
1543
+ return;
1544
+ }
1545
+ const refresh = !!frame.delegationMcp &&
1546
+ (delegation?.leaseId !== delegationLeaseId(frame.delegationMcp) || delegation?.refreshPending === true);
1547
+ if (refresh) {
1548
+ if (!existing.nativeSessionId || (frame.nativeSessionId && frame.nativeSessionId !== existing.nativeSessionId)) {
1549
+ this.emit({ t: 'agent_opened', id: frame.id, ok: false, error: 'Managed delegation cannot reload the original session' });
1550
+ return;
1551
+ }
1552
+ if (existing.session.liveness.turnRunning || activityIsBusy(existing.session.activityRaw)) {
1553
+ if (!delegation?.refreshPending) {
1554
+ try {
1555
+ await existing.session.note({ type: 'agent_delegation_refresh_pending' });
1556
+ }
1557
+ catch (err) {
1558
+ this.emit({ t: 'agent_opened', id: frame.id, ok: false, error: errorMessage(err) });
1559
+ return;
1560
+ }
1561
+ }
1562
+ if (!await this.resumeAndReply(existing, frame, false))
1563
+ return;
1564
+ this.replyNoReplayNeeded(frame);
1565
+ return;
1566
+ }
1567
+ openFrame = { ...frame, nativeSessionId: existing.nativeSessionId, requireSessionLoad: true };
1568
+ retireForRefresh = existing;
1569
+ }
1570
+ else {
1571
+ // Spec §6.3: a session with a switch pending (a persistent intent: another adapter build, or off an older keeper-acp
1572
+ // generation) moves at this open — the only moment its spawn secrets are at hand — when it is strictly idle.
1573
+ if (await this.switchAtOpen(existing, frame))
1574
+ return;
1575
+ // Reusing a live session: neither the process nor the native session moved.
1576
+ if (!await this.resumeAndReply(existing, frame, false))
1577
+ return;
1578
+ // The session already exists, so any history it has was already streamed under whichever
1579
+ // open first established it (or never will be, if that open didn't ask for it) — replaying it
1580
+ // again here would duplicate what the platform already holds. But if THIS frame asked for
1581
+ // backfill, the server is waiting on exactly one `agent_backfill_done` for THIS open's id:
1582
+ // silence is not "no", it is a wait that never resolves. Answer it without replaying.
1583
+ this.replyNoReplayNeeded(frame);
1584
+ return;
1585
+ }
1586
+ }
852
1587
  const inFlight = this.opening.get(frame.sessionRef);
853
1588
  if (inFlight) {
854
1589
  // Piggyback on the attempt already running, and report its real failure — not a generic one.
855
1590
  const { live, error, agentRefused } = await inFlight;
856
1591
  if (live) {
1592
+ const delegation = live.session.delegation ?? undefined;
1593
+ if (!!frame.delegationMcp !== !!delegation ||
1594
+ (frame.delegationMcp && delegation?.leaseId !== delegationLeaseId(frame.delegationMcp))) {
1595
+ this.emit({ t: 'agent_opened', id: frame.id, ok: false, error: 'Managed delegation lease does not match the live session' });
1596
+ return;
1597
+ }
857
1598
  // Piggybacking on the attempt already running: a non-null `live` means that attempt was a
858
1599
  // real open (`session/load` or `session/new`), which this frame is reporting secondhand.
859
- await this.resumeAndReply(live, frame, true);
1600
+ if (!await this.resumeAndReply(live, frame, true))
1601
+ return;
860
1602
  // Same reasoning as the reconnect branch above: whichever frame actually drove openSession
861
1603
  // already streamed this session's history (if it asked to); this frame is a second, racing
862
1604
  // open of the same ref and must not trigger a second replay — but still owes its own
@@ -876,8 +1618,15 @@ export class AgentSessionManager {
876
1618
  }
877
1619
  return;
878
1620
  }
879
- const attempt = this.openSession(frame)
880
- .catch((err) => ({ live: null, error: errorMessage(err) }));
1621
+ // A respawn of a dead keeper session keeps its (older) generation held across the retire and the spawn (Task 21 fix round 2).
1622
+ const releaseHold = retireForRefresh && this.needsRespawn(retireForRefresh) ? this.agents.holdForReopen?.(frame.sessionRef) : undefined;
1623
+ const attempt = (async () => {
1624
+ if (retireForRefresh)
1625
+ await this.retire(retireForRefresh, 'keep');
1626
+ return this.openSession(openFrame);
1627
+ })()
1628
+ .catch((err) => ({ live: null, error: openFailureText(openFrame, err) }))
1629
+ .finally(() => releaseHold?.());
881
1630
  this.opening.set(frame.sessionRef, attempt);
882
1631
  let outcome;
883
1632
  try {
@@ -900,7 +1649,8 @@ export class AgentSessionManager {
900
1649
  // resolving from the finally, and microtasks always drain before the next inbound frame.
901
1650
  if (outcome.live) {
902
1651
  // openSession returned successfully, having just done session/load or session/new.
903
- await this.resumeAndReply(outcome.live, frame, true);
1652
+ if (!await this.resumeAndReply(outcome.live, frame, true))
1653
+ return;
904
1654
  // Only the direct-attempt path: the existing-session and in-flight-piggyback branches above
905
1655
  // return before reaching here, so a racing duplicate `agent_open` (or a reconnect of an
906
1656
  // already-open ref) can never trigger a second replay of the same history.
@@ -920,6 +1670,332 @@ export class AgentSessionManager {
920
1670
  });
921
1671
  }
922
1672
  }
1673
+ // ---- switching (Task 24, spec §6.3) ---------------------------------------------------------------------------------------
1674
+ /** Why `live` may not be switched now (the strict idle criterion, `switching.ts`); empty = strictly idle. */
1675
+ switchBlockersOf(live, now) {
1676
+ const ref = live.sessionRef;
1677
+ return switchBlockers({
1678
+ session: live.session,
1679
+ lastActivityAt: live.lastActivityAt,
1680
+ busyRef: this.opening.has(ref) || this.retiring.has(ref) || this.adopting.has(ref) || this.switching.has(ref),
1681
+ now, quietMs: this.switchQuietMs,
1682
+ });
1683
+ }
1684
+ /**
1685
+ * 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
1686
+ * open carries the spawn environment only the server has — and this open is answered with the session that holds the ref
1687
+ * afterwards (`reloaded`; the server takes its runtime diagnostics whole). Every other frame of the ref is parked meanwhile.
1688
+ * `false`: nothing to switch now (no intent, not idle, draining) — the caller reuses the session as usual.
1689
+ */
1690
+ async switchAtOpen(live, frame) {
1691
+ const agents = this.agents;
1692
+ if (!agents.switchSession || !agents.pendingSwitchFor || frame.delegationMcp)
1693
+ return false;
1694
+ // A switch spawns an adapter: new work, refused while the workstation drains (spec §8.2).
1695
+ if (this.tasks && !this.tasks.accepting)
1696
+ return false;
1697
+ let target;
1698
+ try {
1699
+ target = await agents.pendingSwitchFor(live.sessionRef, live.session);
1700
+ }
1701
+ catch {
1702
+ return false;
1703
+ }
1704
+ if (!target || live.closed || this.sessions.get(live.sessionRef) !== live)
1705
+ return false;
1706
+ const blockers = this.switchBlockersOf(live, Date.now());
1707
+ if (blockers.length > 0) {
1708
+ console.info(`[agent] ${live.sessionRef}: switch (${target.reason}) waits: ${blockers.join('; ')}`);
1709
+ return false;
1710
+ }
1711
+ const ref = live.sessionRef;
1712
+ // 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
1713
+ // a workstation that stopped admitting refuses it.
1714
+ if (this.tasks && !this.tasks.tryReserveAgentOpen(ref))
1715
+ return false;
1716
+ let finished;
1717
+ this.switching.set(ref, new Promise((resolve) => { finished = resolve; }));
1718
+ try {
1719
+ const resumeFrom = resumeFromSeqOf(frame);
1720
+ const outcome = await agents.switchSession(live.session, {
1721
+ ...openSpecOf(frame), seqBase: () => resumeFrom,
1722
+ ...(live.nativeSessionId ? { nativeSessionId: live.nativeSessionId } : {}),
1723
+ // Forced whatever the frame says (spec §5.5.4): a concurrent open can win the server's reopen without the flag.
1724
+ requireSessionLoad: true,
1725
+ }, { ...target, proceed: () => !this.tasks?.isForceStopping() && !live.closed });
1726
+ await this.afterSwitch(live, frame, outcome);
1727
+ return true;
1728
+ }
1729
+ finally {
1730
+ this.tasks?.agentOpenFinished(ref);
1731
+ this.switching.delete(ref);
1732
+ finished();
1733
+ }
1734
+ }
1735
+ async afterSwitch(live, frame, outcome) {
1736
+ switch (outcome.kind) {
1737
+ case 'unmovable':
1738
+ // Nothing was torn down: the session goes on in its process, as for any reopen of a live session.
1739
+ if (!await this.resumeAndReply(live, frame, false))
1740
+ return;
1741
+ this.replyNoReplayNeeded(frame);
1742
+ return;
1743
+ case 'switched':
1744
+ case 'rolledBack': {
1745
+ const next = this.replaceSession(live, outcome.session, outcome.nativeSessionId);
1746
+ if (!await this.resumeAndReply(next, frame, true))
1747
+ return;
1748
+ this.replyNoReplayNeeded(frame);
1749
+ return;
1750
+ }
1751
+ case 'lost': {
1752
+ // Its adapter is gone and none could load it: retired like a degraded session (outbox kept), the server told. Its next
1753
+ // `agent_open` reopens it the ordinary way.
1754
+ this.unhold(live);
1755
+ this.notifySwept(live.sessionRef);
1756
+ const error = `The agent session could not be moved to another adapter process: ${outcome.error}`;
1757
+ this.emit({ t: 'agent_opened', id: frame.id, ok: false, error });
1758
+ if (frame.backfill)
1759
+ this.emit({ t: 'agent_backfill_done', id: frame.id, ok: false, count: 0, error });
1760
+ }
1761
+ }
1762
+ }
1763
+ /** Stop holding `live` (its session is already closed by whoever ended it). */
1764
+ unhold(live) {
1765
+ // Replaced by a switch's successor or rollback, or lost by a switch: a different process from now on.
1766
+ this.adapterCrashes.delete(live.sessionRef);
1767
+ live.closed = true;
1768
+ live.unwatch();
1769
+ if (this.sessions.get(live.sessionRef) === live)
1770
+ this.sessions.delete(live.sessionRef);
1771
+ this.tasks?.unregisterAgentSession(live.sessionRef);
1772
+ }
1773
+ /** The ref is now held by `session` (a switch's successor or its rollback): registered exactly like an opened session. */
1774
+ replaceSession(live, session, nativeSessionId) {
1775
+ this.unhold(live);
1776
+ let highestScopedJournalSeq = 0;
1777
+ for (const item of session.outbox.pending) {
1778
+ if (item.frame.t === 'agent_event' && item.frame.acpSessionId != null)
1779
+ highestScopedJournalSeq = Math.max(highestScopedJournalSeq, item.seq);
1780
+ }
1781
+ const next = {
1782
+ sessionRef: live.sessionRef, agentId: live.agentId, agentKey: live.agentKey, session, nativeSessionId,
1783
+ scopedReplayBlocked: false, highestScopedJournalSeq, unwatch: () => { }, replays: Promise.resolve(), closed: false,
1784
+ lastActivityAt: Date.now(),
1785
+ };
1786
+ this.register(next);
1787
+ return next;
1788
+ }
1789
+ /**
1790
+ * The daemon's switch scheduler (spec §6.3 step 7; the daemon calls it every few seconds): for each held session with a switch
1791
+ * pending that is strictly idle, ask the server for an `agent_open` (`agent_reopen_request`, wire ≥ 25 only) — the switch happens
1792
+ * at that open. Also (Task 23's carry): a keeper-hosted session whose adapter keeper-acp lost (`keeper_lost`/`keeper_stopped` —
1793
+ * 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
1794
+ * the user's next message — at most {@link ADAPTER_LOST_REOPEN_ATTEMPTS} times per lost spawn, backing off from
1795
+ * {@link REOPEN_REQUEST_RETRY_MS}, never while draining. A switch request the server did not answer is asked again after
1796
+ * {@link REOPEN_REQUEST_RETRY_MS}. Returns the refs a request went out for. Never rejects.
1797
+ */
1798
+ async pollSwitches(now = Date.now(), only) {
1799
+ await this.adoptLate(); // the periodic tick also retries a keeper-acp listing that failed (final review finding 3)
1800
+ const requested = [];
1801
+ for (const live of [...this.sessions.values()]) {
1802
+ const ref = live.sessionRef;
1803
+ if (live.closed || (only && !only.has(ref)))
1804
+ continue;
1805
+ const last = this.reopenRequests.get(ref);
1806
+ const recently = (why) => last !== undefined && last.why === why && now - last.at < REOPEN_REQUEST_RETRY_MS * 2 ** Math.max(0, last.attempts - 1);
1807
+ const busyRef = this.opening.has(ref) || this.retiring.has(ref) || this.adopting.has(ref) || this.switching.has(ref);
1808
+ const session = live.session;
1809
+ const exit = session.adapterExit;
1810
+ if (session.durable && exit && !session.liveness.agentAlive && !session.isDegraded) {
1811
+ // 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
1812
+ // was reported lost and waits for its next message — a keeper-acp restart must not respawn every idle session at once.
1813
+ const lostByKeeper = (exit.reason === 'keeper_lost' || exit.reason === 'keeper_stopped') &&
1814
+ live.adapterLost?.spawnId === exit.spawnId && live.adapterLost?.busy === true;
1815
+ const why = `adapter-lost:${exit.spawnId ?? '?'}`;
1816
+ // Bounded per lost spawn (fix round 1): 60 s, 120 s, 240 s apart, then the user's next message reopens it. Not while
1817
+ // draining: a respawn is new work (spec §8.2).
1818
+ const attempts = last?.why === why ? last.attempts : 0;
1819
+ if (lostByKeeper && live.nativeSessionId && !busyRef && !session.liveness.turnRunning && !recently(why) &&
1820
+ attempts < ADAPTER_LOST_REOPEN_ATTEMPTS && (!this.tasks || this.tasks.accepting) && this.requestReopen(ref, 'agent_exited')) {
1821
+ this.reopenRequests.set(ref, { at: now, why, attempts: attempts + 1 });
1822
+ requested.push(ref);
1823
+ }
1824
+ continue;
1825
+ }
1826
+ if (!this.agents.pendingSwitchFor)
1827
+ continue;
1828
+ let target;
1829
+ try {
1830
+ target = await this.agents.pendingSwitchFor(ref, live.session);
1831
+ }
1832
+ catch {
1833
+ continue;
1834
+ }
1835
+ if (!target || live.closed || (this.tasks && !this.tasks.accepting))
1836
+ continue;
1837
+ if (this.switchBlockersOf(live, now).length > 0 || recently('switch'))
1838
+ continue;
1839
+ if (this.requestReopen(ref, target.reason)) {
1840
+ this.reopenRequests.set(ref, { at: now, why: 'switch', attempts: 1 });
1841
+ requested.push(ref);
1842
+ }
1843
+ }
1844
+ return requested;
1845
+ }
1846
+ /**
1847
+ * "Move idle sessions" (Task 25's `agent_switch_idle`, spec §6.4): every held session of `agentId` that runs another build than
1848
+ * `toBuild` (else the agent's current build) gets a persistent intent to move there; those already strictly idle get their
1849
+ * `agent_reopen_request` at once, the others when they become idle. A session that can never be moved this way
1850
+ * ({@link unmovableWhy}: a local-fallback session, a managed delegation, no native session, a target that already refused it) gets
1851
+ * no intent — it would only be asked to reopen again and again — and is listed in `unmovable` with the reason. A `toBuild` that is
1852
+ * not installed for the agent is refused (throws). Returns the refs registered, the ones a request went out for, and the unmovable.
1853
+ */
1854
+ async switchIdle(agentId, options = {}) {
1855
+ const { requestSwitch, placementOf } = this.agents;
1856
+ if (!requestSwitch || !placementOf)
1857
+ return { registered: [], requested: [], unmovable: [] };
1858
+ let target = options.toBuild;
1859
+ if (this.agents.buildIdsOf) {
1860
+ const ids = await this.agents.buildIdsOf(agentId);
1861
+ if (target !== undefined && !ids.installed.includes(target))
1862
+ throw new Error(`Adapter build ${target} of '${agentId}' is not installed on this workstation`);
1863
+ target ??= ids.current;
1864
+ }
1865
+ const reason = options.reason ?? (options.toBuild ? `move to adapter build ${options.toBuild}` : 'move to the current adapter build');
1866
+ const registered = [];
1867
+ const unmovable = [];
1868
+ for (const live of [...this.sessions.values()]) {
1869
+ if (live.closed || live.agentId !== agentId)
1870
+ continue;
1871
+ const placement = placementOf.call(this.agents, live.session);
1872
+ // A command override (a custom agent) has no build to move — and the scan does not count it either.
1873
+ if (placement && placement.buildId === undefined)
1874
+ continue;
1875
+ // Already there: nothing to move, and nothing to report as unmovable either.
1876
+ if (target !== undefined && placement?.buildId === target)
1877
+ continue;
1878
+ const why = this.unmovableWhy(live, target);
1879
+ if (why !== undefined) {
1880
+ console.info(`[agent] ${live.sessionRef}: not moved to ${target ?? 'the current build'}: ${why.detail ?? why.plain}`);
1881
+ unmovable.push({ sessionRef: live.sessionRef, reason: why.plain });
1882
+ continue;
1883
+ }
1884
+ await requestSwitch.call(this.agents, agentId, live.sessionRef, { ...(options.toBuild ? { toBuild: options.toBuild } : {}), reason, requestedBy: 'user' });
1885
+ registered.push(live.sessionRef);
1886
+ }
1887
+ const requested = await this.pollSwitches(Date.now(), new Set(registered));
1888
+ return { registered, requested, unmovable };
1889
+ }
1890
+ /**
1891
+ * Why `live` can never be moved to `target` by a switch (spec §6.3 "当前限制"), or `undefined`: a session keeper-acp does not host
1892
+ * (the local fallback), a managed delegation, a session with no native session to load, or one a switch to the same build already
1893
+ * found unmovable. `plain` is what the scan card and the result frame say (Task 25); `detail` is the switch's own words, for the
1894
+ * log, when there are any.
1895
+ */
1896
+ unmovableWhy(live, target) {
1897
+ const placement = this.agents.placementOf?.call(this.agents, live.session);
1898
+ if (!placement?.keeper)
1899
+ return { plain: LOCAL_SESSION_UNMOVABLE };
1900
+ if (live.session.delegation)
1901
+ return { plain: 'it is a delegated session, which can\'t be moved to another build yet' };
1902
+ if (!live.nativeSessionId)
1903
+ return { plain: 'it has no saved conversation the new build could reload' };
1904
+ const refused = this.agents.unmovableFor?.call(this.agents, live.sessionRef);
1905
+ if (refused && (refused.toBuild === undefined || refused.toBuild === target)) {
1906
+ return { plain: `the ${refused.toBuild ?? 'new'} adapter could not take it over when this was last tried`, detail: refused.reason };
1907
+ }
1908
+ return undefined;
1909
+ }
1910
+ /**
1911
+ * How the external-agents scan counts one running session (spec §6.4, Task 25; `AcpAgents.roster({sessions})`): `unmovable` with
1912
+ * the reason ({@link unmovableWhy}; also a session this daemon does not hold), else `busy` with the strict idle criterion's blockers
1913
+ * ({@link switchBlockersOf}), else `idle`. `pendingTo`: the build a pending switch intent still owes it. Never rejects.
1914
+ */
1915
+ async rosterSessionState(running) {
1916
+ const live = this.sessions.get(running.sessionRef);
1917
+ if (!live || live.closed)
1918
+ return { state: 'unmovable', reasons: ['it is not connected to a conversation right now'] };
1919
+ const current = this.agents.buildIdsOf ? (await this.agents.buildIdsOf(running.agentId).catch(() => undefined))?.current : undefined;
1920
+ let pendingTo;
1921
+ try {
1922
+ pendingTo = (await this.agents.pendingSwitchFor?.(running.sessionRef, live.session))?.toBuild;
1923
+ }
1924
+ catch { /* an unreadable builds.json: nothing pending to report */ }
1925
+ const pending = pendingTo !== undefined ? { pendingTo } : {};
1926
+ const why = this.unmovableWhy(live, current);
1927
+ if (why !== undefined)
1928
+ return { state: 'unmovable', reasons: [why.plain], ...pending };
1929
+ // The card's words, not the log's (`plainSwitchBlocker`); `switchAtOpen` still logs the internal sentences.
1930
+ const blockers = this.switchBlockersOf(live, Date.now()).map(plainSwitchBlocker);
1931
+ return blockers.length > 0 ? { state: 'busy', reasons: blockers, ...pending } : { state: 'idle', ...pending };
1932
+ }
1933
+ /** Answers `agent_switch_idle` (Task 25): {@link switchIdle}, counted. */
1934
+ async onSwitchIdle(frame) {
1935
+ try {
1936
+ const result = await this.switchIdle(frame.bridge, frame.toBuild !== undefined ? { toBuild: frame.toBuild } : {});
1937
+ this.emit({
1938
+ t: 'agent_switch_idle_result', id: frame.id, ok: true,
1939
+ registered: result.registered.length, immediate: result.requested.length, unmovable: result.unmovable,
1940
+ });
1941
+ }
1942
+ catch (error) {
1943
+ this.emit({ t: 'agent_switch_idle_result', id: frame.id, ok: false, error: errorMessage(error) });
1944
+ }
1945
+ }
1946
+ /**
1947
+ * 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
1948
+ * now — a turn in flight or the agent reporting itself active (the same sample `activeAgentTasks` once used). `undefined` when this
1949
+ * daemon does not hold the ref.
1950
+ */
1951
+ sessionView(sessionRef) {
1952
+ const live = this.sessions.get(sessionRef);
1953
+ if (!live || live.closed)
1954
+ return undefined;
1955
+ let busy = false;
1956
+ try {
1957
+ busy = live.session.liveness.turnRunning || activityIsBusy(live.session.activityRaw);
1958
+ }
1959
+ catch {
1960
+ busy = false;
1961
+ }
1962
+ return { agentId: live.agentId, busy };
1963
+ }
1964
+ /**
1965
+ * keeper-acp replacement (spec §8.3, for Task 26): every held session on an older keeper-acp generation — only on `gens` when given
1966
+ * (the generations a replacement was decided for; another older one may merely be `available`) — gets a persistent intent to move
1967
+ * to the current generation when idle (same build). Returns the refs registered.
1968
+ */
1969
+ async moveOffOlderKeeperGenerations(reason = 'keeper-acp was upgraded', gens) {
1970
+ const { requestSwitch, placementOf } = this.agents;
1971
+ if (!requestSwitch || !placementOf)
1972
+ return [];
1973
+ const registered = [];
1974
+ for (const live of [...this.sessions.values()]) {
1975
+ const placement = placementOf.call(this.agents, live.session);
1976
+ if (live.closed || !placement?.keeper || placement.gen === undefined)
1977
+ continue;
1978
+ if (gens && !gens.includes(placement.gen))
1979
+ continue;
1980
+ await requestSwitch.call(this.agents, live.agentId, live.sessionRef, { keeperGen: 'current', reason, requestedBy: 'axom' });
1981
+ registered.push(live.sessionRef);
1982
+ }
1983
+ await this.pollSwitches(Date.now(), new Set(registered));
1984
+ return registered;
1985
+ }
1986
+ /** Cancel a pending switch: one ref's, or every one of an agent's. `true` when one existed. */
1987
+ async cancelSwitch(target) {
1988
+ if (!this.agents.cancelSwitch)
1989
+ return false;
1990
+ const cancelled = await this.agents.cancelSwitch(target);
1991
+ if ('sessionRef' in target)
1992
+ this.reopenRequests.delete(target.sessionRef);
1993
+ else
1994
+ for (const live of this.sessions.values())
1995
+ if (live.agentId === target.agentId)
1996
+ this.reopenRequests.delete(live.sessionRef);
1997
+ return cancelled;
1998
+ }
923
1999
  /**
924
2000
  * Answers a backfill-flagged open that landed on an already-live (or already-being-opened)
925
2001
  * session, without replaying anything.
@@ -946,8 +2022,8 @@ export class AgentSessionManager {
946
2022
  * and `agent_open` already has the whole open path (spec construction, ok/error reply) that a
947
2023
  * standalone frame would force the daemon to rebuild a second copy of.
948
2024
  *
949
- * Deliberately outside the live event/journal machinery — no `bridge_seq`, no
950
- * {@link SessionJournal} entry, no `agent_event_ack` round trip. `bridge_seq` means one specific
2025
+ * Deliberately outside the live event/outbox machinery — no `bridge_seq`, no
2026
+ * outbox entry, no `agent_event_ack` round trip. `bridge_seq` means one specific
951
2027
  * thing: live-consumption progress, the only basis for de-duplication across reconnects. Backfill
952
2028
  * is a one-shot replay correlated purely by this open's `id`; folding it into that watermark would
953
2029
  * give it two meanings, which is exactly what produced two watermark bugs in an earlier phase.
@@ -975,9 +2051,14 @@ export class AgentSessionManager {
975
2051
  // legitimately rely on `this`, and a bare local `const backfill = session.backfill` would call
976
2052
  // it unbound.
977
2053
  for await (const event of session.backfill()) {
2054
+ if (event.acpSessionId !== undefined && this.serverAgentWire < AGENT_WIRE_SCOPED_ORIGIN_VERSION) {
2055
+ throw new Error(`Scoped backfill requires agent wire ${AGENT_WIRE_SCOPED_ORIGIN_VERSION}; native origin cannot be discarded`);
2056
+ }
978
2057
  batch.push(event);
979
2058
  count += 1;
980
2059
  if (batch.length >= BACKFILL_BATCH_SIZE) {
2060
+ // Bulk and never acknowledged: no session window, but it waits for the socket gate like session frames (agentFlow.ts).
2061
+ await this.gate.opened();
981
2062
  this.emit({ t: 'agent_backfill_events', id, events: batch });
982
2063
  batch = [];
983
2064
  }
@@ -1013,27 +2094,42 @@ export class AgentSessionManager {
1013
2094
  `but the live one is '${live.nativeSessionId}'`);
1014
2095
  }
1015
2096
  const resumeFrom = resumeFromSeqOf(frame);
1016
- // On the session's own queue so an agent event produced mid-replay is sent after it, not into
1017
- // the middle of it.
1018
- await this.enqueue(live, async () => {
1019
- const entries = await live.journal.entriesAfter(resumeFrom);
1020
- if (this.hasGap(live, entries, resumeFrom)) {
1021
- this.emit({
1022
- t: 'agent_event',
1023
- sessionRef: live.sessionRef,
1024
- // The hole left by the overflow drop starts exactly here. Reusing that seq (rather than
1025
- // taking a fresh one) is what makes the notice survive the server's `seq > bridgeSeq`
1026
- // filter *without* pushing the watermark past the replayed entries behind it.
1027
- seq: resumeFrom + 1,
1028
- eventName: 'step',
1029
- payload: { message: 'transport gap: oldest streamed output dropped', tone: 'neutral' },
1030
- });
1031
- }
1032
- // Already-formed frames, carrying their original seq — resent verbatim so the server's
1033
- // de-duplication does the deciding.
1034
- for (const entry of entries)
1035
- this.emit(entry.frame);
2097
+ // The server applied through `resumeFrom` (its durable watermark): that is an ack. Everything after it is resent in order —
2098
+ // verbatim, carrying the original seq, so the server's de-duplication does the deciding. The outbox never drops its oldest
2099
+ // entries (the backlog stays in the raw log instead, §5.6), so there is no gap to announce any more.
2100
+ let incompatible = false;
2101
+ // One replay at a time per session: two racing reopens resend the tail once, the second only what the first did not cover.
2102
+ const replay = live.replays.then(async () => {
2103
+ if (live.closed)
2104
+ return;
2105
+ incompatible = this.blockIncompatibleJournal(live);
2106
+ if (incompatible)
2107
+ return;
2108
+ await this.acknowledge(live, resumeFrom);
2109
+ if (live.closed)
2110
+ return;
2111
+ live.scopedReplayBlocked = false;
2112
+ // Sends synchronously while the window and the socket allow, so on an idle link the tail precedes `agent_opened`; on a
2113
+ // busy one `agent_opened` may overtake it — the session's frames keep their own order (analysis B §4.3), and a large tail
2114
+ // cannot push the reply past the server's open timeout.
2115
+ live.session.outbox.resendAll();
2116
+ this.pump();
1036
2117
  });
2118
+ live.replays = replay.catch((err) => console.error(`[agent] replay failed for ${live.sessionRef}: ${errorMessage(err)}`));
2119
+ await live.replays;
2120
+ if (incompatible) {
2121
+ const error = `Scoped journal replay requires agent wire ${AGENT_WIRE_SCOPED_ORIGIN_VERSION}; retained history was not delivered or acknowledged`;
2122
+ this.emit({ t: 'agent_opened', id: frame.id, ok: false, error });
2123
+ if (frame.backfill)
2124
+ this.emit({ t: 'agent_backfill_done', id: frame.id, ok: false, count: 0, error });
2125
+ return false;
2126
+ }
2127
+ if (live.closed) {
2128
+ this.emit({ t: 'agent_opened', id: frame.id, ok: false, error: 'session was closed while opening' });
2129
+ return false;
2130
+ }
2131
+ const delegation = live.session.delegation ?? undefined;
2132
+ const activityRaw = live.session.activityRaw;
1037
2133
  this.emit({
1038
2134
  t: 'agent_opened',
1039
2135
  id: frame.id,
@@ -1044,6 +2140,13 @@ export class AgentSessionManager {
1044
2140
  // Emitted only when true, the same `supportsSteering` convention as below: absent is the
1045
2141
  // field's only "no", and it is also what an older peer sends.
1046
2142
  ...(reloaded ? { reloaded: true } : {}),
2143
+ // Delegation's non-secret fields, from processor state (Task 19: `agent_opened` is assembled from state).
2144
+ ...(delegation ? { delegationExpiresAtEpochMs: delegation.expiresAtEpochMs } : {}),
2145
+ ...(delegation?.refreshPending ? { delegationRefreshPending: true } : {}),
2146
+ ...(this.serverAgentWire >= AGENT_WIRE_DELEGATION_RENEW_VERSION && delegation?.instanceId ? {
2147
+ delegationInstanceId: delegation.instanceId, delegationBindingId: delegation.bindingId,
2148
+ delegationRenewalRevision: delegation.renewalRevision,
2149
+ } : {}),
1047
2150
  // The server learns this here and nowhere else. It used to be published only on the roster in
1048
2151
  // the `agent_hello` handshake, which is a snapshot taken *before* any agent was connected to —
1049
2152
  // and the capability can only be observed on a live `initialize`, whose record
@@ -1078,6 +2181,7 @@ export class AgentSessionManager {
1078
2181
  // ruling R1: `agent_opened` is assembled from `live.session.*`, not from the roster, so a
1079
2182
  // field added to `BridgeInfo` would have no reader here).
1080
2183
  ...(live.session.agentInfo ? { agentInfo: live.session.agentInfo } : {}),
2184
+ ...(live.session.runtimeDiagnostics ? { runtimeDiagnostics: live.session.runtimeDiagnostics } : {}),
1081
2185
  // ACP `PromptCapabilities.image`, learned at the same `initialize` and for the same
1082
2186
  // session-not-roster reason as `supportsSteering` above: `true`-or-absent is this field's
1083
2187
  // whole vocabulary, so an agent that said nothing (or a peer too old to answer) reads the
@@ -1094,85 +2198,97 @@ export class AgentSessionManager {
1094
2198
  // would report the pre-change values to the server after every config change. See
1095
2199
  // {@link AcpAgentSession.configOptions} for what that costs.
1096
2200
  configOptions: live.session.configOptions,
2201
+ // Whether this daemon wired up an activity-state collector for this session's agent (design
2202
+ // §5.3) — decided from the same `agentInfo.name` just read above, never from whether a push
2203
+ // has actually arrived: those are different facts (see the field's own doc in
2204
+ // `protocol/agent.ts`). No version floor: an additive key on a frame the peer already decodes
2205
+ // with `ignoreUnknownKeys`, the same `supportsSteering`/`reloaded` shape, not the
2206
+ // new-discriminator shape `agent_session_state` itself is.
2207
+ ...(reportsActivity(live.session.agentInfo?.name) ? { reportsActivity: true } : {}),
2208
+ ...(live.nativeSessionId && supportsAsyncTaskStop(live.session.agentInfo?.name)
2209
+ ? { supportsAsyncTaskStop: true } : {}),
2210
+ // A keeper-hosted session outlives this daemon (spec §5.5.4): the server resends answers it recorded for the cards still
2211
+ // pending (a daemon that died between receiving an answer and writing it would leave the adapter blocked), and decides an
2212
+ // unconfirmed steer by whether it was steered. Additive keys: a server that does not read them ignores them.
2213
+ ...(live.session.durable ? {
2214
+ durable: true,
2215
+ pendingRequestIds: [...(live.session.pendingRequestIds ?? [])],
2216
+ steeredIds: [...(live.session.steeredIds ?? [])],
2217
+ } : {}),
2218
+ // This session's activity-state snapshot as of right now (processor state), resent here because
2219
+ // `agent_session_state` is never journaled and a reopen would otherwise have nothing to
2220
+ // recover it from. Absent when nothing has ever arrived (including "no collector for this
2221
+ // agent"), never a synthesized default.
2222
+ ...(activityRaw !== undefined ? { activityRaw } : {}),
1097
2223
  });
1098
- }
1099
- /**
1100
- * Whether the overflow drop actually took something the server still needs. `droppedOldest` alone
1101
- * is not enough: by the time of this reconnect the server may have acked past the hole, and
1102
- * announcing a gap that no longer exists would both mislead the user and collide with the seq of
1103
- * the first replayed entry.
1104
- */
1105
- hasGap(live, entries, resumeFrom) {
1106
- if (!live.journal.droppedOldest)
1107
- return false;
1108
- const first = entries[0];
1109
- return first ? first.seq > resumeFrom + 1 : live.journal.lastSeq > resumeFrom;
2224
+ return true;
1110
2225
  }
1111
2226
  async openSession(frame) {
1112
- // A previous incarnation of this ref may still be deleting its journal; two SessionJournal
1113
- // instances must never share a path (see {@link retiring}). This is the first statement on
1114
- // purpose — before init, before the agent spawns, before anything touches the filesystem.
2227
+ // A previous incarnation of this ref may still be closing: its adapter stopped and its transport session (raw, outbox) kept or
2228
+ // released — the new spawn goes under the same key (see {@link retiring}). This is the first statement on purpose — before the
2229
+ // agent spawns, before anything touches the transport. The one-time legacy journal migration lands before any open too.
1115
2230
  await this.retiring.get(frame.sessionRef);
1116
- // spec.env is an OVERLAY merged onto process.env when the agent is spawned (see
1117
- // AcpOpenSpec.env), so it is built from frame-supplied values only. The single exception is
1118
- // PATH: when pathPrepend is set we resolve the whole variable here — prepends first, then the
1119
- // frame's PATH or, failing that, the daemon's — so the launcher uses spec.env.PATH verbatim
1120
- // instead of re-deriving it.
1121
- const env = { ...(frame.env ?? {}) };
1122
- const pathPrepend = frame.pathPrepend ?? [];
1123
- if (pathPrepend.length > 0) {
1124
- env.PATH = [...pathPrepend, env.PATH ?? process.env.PATH ?? ''].filter(Boolean).join(path.delimiter);
1125
- }
1126
- // Recovered before the agent spawns, because nextSeq() is synchronous and the first event may
1127
- // arrive the instant onEvent is registered: the high-water mark has to be known by then or a
1128
- // restarted daemon would restart seq at 1 and the server would swallow the next turn as
1129
- // already-applied. The frame's watermark is passed in as a floor under whatever the file
1130
- // yields, because "the file is empty" is the *normal* state between turns — everything in it
1131
- // was acked and truncated — so the file alone would say 0 while the server sits at N.
1132
- // init() only reads — the file appears on the first append — so an open that fails here leaves
1133
- // nothing behind.
1134
- const journal = new SessionJournal(this.sessionsDir, frame.sessionRef, this.maxJournalBytes);
1135
- try {
1136
- await journal.init(resumeFromSeqOf(frame));
1137
- }
1138
- catch (err) {
1139
- return { live: null, error: `journal init failed: ${errorMessage(err)}` };
1140
- }
1141
- // Both journal recovery and a retiring prior incarnation can await before this point. Only a
1142
- // force-stop may cancel an open admitted before that await; graceful drain owns its reservation
1143
- // and must let it settle without starting a second admission decision halfway through.
2231
+ await this.adopting.get(frame.sessionRef)?.done;
2232
+ await this.migrateLegacyJournals();
2233
+ // The server's watermark is the floor the processor numbers from (AcpOpenSpec.seqBase); the outbox an earlier life left under
2234
+ // the ref raises it (`max(outbox.maxSeq, committedTail.seq, resumeFromSeq)`, §5.5) — a restarted daemon never restarts seq at 1,
2235
+ // or the server would swallow the next turn as already applied.
2236
+ const resumeFrom = resumeFromSeqOf(frame);
2237
+ // Only a force-stop may cancel an open admitted before the awaits above; graceful drain owns its reservation and must let it
2238
+ // settle without starting a second admission decision halfway through.
1144
2239
  if (this.tasks?.isForceStopping()) {
1145
- await journal.destroy();
1146
2240
  return { live: null, error: 'Workstation is not accepting new work' };
1147
2241
  }
1148
2242
  let session;
1149
2243
  let nativeSessionId;
1150
- try {
1151
- const result = await this.agents.open({
1152
- // `frame.bridge` is the registry agent id now — the wire field keeps its name because Plan A
1153
- // renames nothing (Global Constraint 3); ruling R2 replaced what it *means*.
1154
- agentId: frame.bridge,
1155
- cwd: frame.cwd,
1156
- env,
1157
- agentEnv: frame.agentEnv ?? {},
1158
- ...(frame.command ? { command: frame.command } : {}),
1159
- args: frame.args ?? [],
1160
- ...(frame.nativeSessionId ? { nativeSessionId: frame.nativeSessionId } : {}),
1161
- // Retaining a whole adopted history costs memory for the session's lifetime, so it is only
1162
- // kept when this open is the one that will stream it back.
1163
- backfill: frame.backfill === true,
2244
+ // Spec 2026-10-11 §6.2: an agent with nothing installed yet is installed once, by this open. A success becomes the session's
2245
+ // own `agent_install_outcome` note (AcpAgents → the processor numbers one past-tense `step`, recorded in the outbox like any
2246
+ // event). A failure has no session to go on: its `step` is sent here, numbered right after the server's watermark — not
2247
+ // recorded anywhere (the open fails with the same reason, and a later open numbers from the server's watermark again).
2248
+ // Nothing is written while the install runs: a durable "Installing…" card would never be updated and read as stuck.
2249
+ let failedInstall;
2250
+ const onInstallOutcome = (outcome) => {
2251
+ if (!outcome.ok)
2252
+ failedInstall = outcome;
2253
+ };
2254
+ // Sent before the failed open's reply — unless an earlier life left frames under the ref: one of them may hold
2255
+ // `resumeFrom + 1`, and the server would keep the step and drop that frame as a duplicate. `agent_opened{ok:false}` carries
2256
+ // the same error then.
2257
+ const reportFailedInstall = async () => {
2258
+ if (!failedInstall)
2259
+ return;
2260
+ let leftover = false;
2261
+ try {
2262
+ leftover = ((await this.agents.leftoverSessions?.()) ?? []).includes(frame.sessionRef);
2263
+ }
2264
+ catch {
2265
+ leftover = true;
2266
+ }
2267
+ if (leftover)
2268
+ return;
2269
+ this.emit({
2270
+ t: 'agent_event', sessionRef: frame.sessionRef, seq: resumeFrom + 1, eventName: 'step',
2271
+ payload: { title: 'Agent adapter', message: `Could not install ${failedInstall.agentName} adapter ${failedInstall.version}`, detail: failedInstall.error, tone: 'error' },
1164
2272
  });
2273
+ };
2274
+ try {
2275
+ const result = await this.agents.open({ ...openSpecOf(frame), onInstallOutcome, seqBase: () => resumeFrom });
1165
2276
  session = result.session;
1166
2277
  nativeSessionId = result.nativeSessionId;
2278
+ if (frame.delegationMcp && !nativeSessionId) {
2279
+ await session.close();
2280
+ return { live: null, error: 'Managed delegation requires a native session ID' };
2281
+ }
1167
2282
  }
1168
2283
  catch (err) {
2284
+ await reportFailedInstall();
1169
2285
  // `open` throws on both sides of the agent's `initialize` — nothing to launch and a spawn
1170
2286
  // that failed look identical from here — so the marker is asked for rather than assumed. Only
1171
2287
  // an {@link AgentRefusedSessionError} is the agent's own no, which is what an `auth_required`
1172
2288
  // on `session/new` arrives as; see {@link OpenOutcome.agentRefused}.
1173
2289
  return {
1174
2290
  live: null,
1175
- error: errorMessage(err),
2291
+ error: openFailureText(frame, err),
1176
2292
  // The code comes off the marker, not off `errorMessage(err)` above: the wrapper captured it
1177
2293
  // from the original rejection at the moment that object still existed.
1178
2294
  ...(err instanceof AgentRefusedSessionError
@@ -1189,7 +2305,6 @@ export class AgentSessionManager {
1189
2305
  catch (err) {
1190
2306
  console.error(`[agent] close of force-stopped open failed for ${frame.sessionRef}: ${errorMessage(err)}`);
1191
2307
  }
1192
- await journal.destroy();
1193
2308
  return { live: null, error: 'Workstation is not accepting new work' };
1194
2309
  }
1195
2310
  // The session became pointless while it was spawning (agent_close, or the socket dropped and the
@@ -1199,35 +2314,59 @@ export class AgentSessionManager {
1199
2314
  if (abandonReason) {
1200
2315
  this.closedDuringOpen.delete(frame.sessionRef);
1201
2316
  try {
1202
- await session.close();
2317
+ // Only an explicit agent_close retires the ref, and only then does its outbox go. A socket
2318
+ // drop must leave it alone: it may hold frames an earlier life left, and the server will
2319
+ // re-open the ref and ask for exactly those.
2320
+ await session.close(abandonReason === 'closed' ? 'release' : 'keep');
1203
2321
  }
1204
2322
  catch (err) {
1205
2323
  console.error(`[agent] close of abandoned open failed for ${frame.sessionRef}: ${errorMessage(err)}`);
1206
2324
  }
1207
- // Only an explicit agent_close retires the ref, and only then does its journal go. A socket
1208
- // drop must leave the file alone: it may hold entries this daemon recovered from a previous
1209
- // life, and the server will re-open the ref and ask for exactly those.
1210
- if (abandonReason === 'closed')
1211
- await journal.destroy();
1212
2325
  return { live: null, error: 'session closed during open' };
1213
2326
  }
2327
+ // Frames an earlier life left (read back into the outbox at start) keep the scoped high-water mark they had.
2328
+ let highestScopedJournalSeq = 0;
2329
+ for (const item of session.outbox.pending) {
2330
+ if (item.frame.t === 'agent_event' && item.frame.acpSessionId != null)
2331
+ highestScopedJournalSeq = Math.max(highestScopedJournalSeq, item.seq);
2332
+ }
1214
2333
  const live = {
1215
2334
  sessionRef: frame.sessionRef,
1216
2335
  agentId: frame.bridge,
1217
2336
  agentKey: frame.agentKey,
1218
2337
  session,
1219
2338
  nativeSessionId,
1220
- journal,
1221
- writes: Promise.resolve(),
2339
+ scopedReplayBlocked: false,
2340
+ highestScopedJournalSeq,
2341
+ unwatch: () => { },
2342
+ replays: Promise.resolve(),
1222
2343
  closed: false,
1223
2344
  lastActivityAt: Date.now(),
1224
2345
  };
1225
- this.sessions.set(frame.sessionRef, live);
1226
- this.tasks?.registerAgentSession(frame.sessionRef, () => this.retire(live, 'destroy'));
1227
- session.onEvent((e) => this.onAgentEvent(live, e));
1228
- session.onTurnDone((r) => this.onAgentTurnDone(live, r));
2346
+ this.register(live);
1229
2347
  return { live };
1230
2348
  }
2349
+ /** A session joins: held, registered with the TaskRegistry, its frames and its outbox wired to the sender. */
2350
+ register(live) {
2351
+ const session = live.session;
2352
+ this.detached.delete(live.sessionRef);
2353
+ this.sessions.set(live.sessionRef, live);
2354
+ const durable = session.durable === true && session.detach !== undefined;
2355
+ this.tasks?.registerAgentSession(live.sessionRef,
2356
+ // A keeper-hosted session is detached, never stopped, by the update paths (spec §5.5.5): its turn goes on in keeper-acp.
2357
+ () => this.retire(live, durable ? 'detach' : 'destroy'),
2358
+ // 两个层级量取或:本进程发出的提示词是否在途(agent 自己也许在续跑一个我们没发起的回合,
2359
+ // 那一种由第二项回答),以及 agent 自报的状态。两者都不累积,所以没有可泄漏的状态。
2360
+ () => live.session.liveness.turnRunning || activityIsBusy(live.session.activityRaw), { durable });
2361
+ // Everything the session produces arrives as processor frames (Task 19): numbered by the processor, projected for the
2362
+ // connected server here, recorded in the outbox (the numbered ones) and sent from it (Task 20).
2363
+ live.unwatch = session.outbox.onChange(() => this.pump());
2364
+ session.onFrames((frames) => this.onSessionFrames(live, frames));
2365
+ if (session.accountLimits && this.serverAgentWire >= 27)
2366
+ this.emit({ t: 'agent_account_update', agentKey: live.agentKey, snapshot: session.accountLimits });
2367
+ session.onDegraded?.((reason) => { void this.onSessionDegraded(live, reason); });
2368
+ session.onAdapterExited?.((exit) => { this.onAdapterExited(live, exit); });
2369
+ }
1231
2370
  /**
1232
2371
  * Answers `agent_list_native` by asking the agent itself, over a throwaway connection this manager
1233
2372
  * never sees. Deliberately involves no `LiveSession`: listing is what a user does *before* deciding
@@ -1268,6 +2407,25 @@ export class AgentSessionManager {
1268
2407
  this.emit({ t: 'agent_native_sessions', id: frame.id, ok: false, error: errorMessage(err) });
1269
2408
  }
1270
2409
  }
2410
+ /**
2411
+ * Answers `agent_delete_native`: `session/delete` per id over a throwaway connection, like
2412
+ * {@link onListNative}. Per-id outcomes ride in `results` (`ok:true`); `ok:false` means the
2413
+ * deletion could not be attempted at all (no launchable agent, bad query). Never throws.
2414
+ */
2415
+ async onDeleteNative(frame) {
2416
+ const spec = sanitizeNativeSessionQuery(frame.bridge, frame.query);
2417
+ if (!spec) {
2418
+ this.emit({ t: 'agent_native_deleted', id: frame.id, ok: false, error: 'native session query is missing cwd' });
2419
+ return;
2420
+ }
2421
+ try {
2422
+ const results = await this.agents.deleteNativeSessions(spec, frame.nativeSessionIds);
2423
+ this.emit({ t: 'agent_native_deleted', id: frame.id, ok: true, results });
2424
+ }
2425
+ catch (err) {
2426
+ this.emit({ t: 'agent_native_deleted', id: frame.id, ok: false, error: errorMessage(err) });
2427
+ }
2428
+ }
1271
2429
  /**
1272
2430
  * Answers `agent_verify` by actually starting the agent and seeing what it says — the only way the
1273
2431
  * roster's auth fields can be populated for an agent no session has ever run (see
@@ -1440,102 +2598,251 @@ export class AgentSessionManager {
1440
2598
  this.emit({ t: 'agent_installed', id: frame.id, ok: false, error: errorMessage(err) });
1441
2599
  }
1442
2600
  }
1443
- onAgentEvent(live, e) {
1444
- if (live.closed)
1445
- return;
1446
- // An agent can resume an existing native turn after a client restart, so an inbound prompt is
1447
- // not the only reliable start marker. These are the two wire events that represent work which
1448
- // may still require user action or native execution; turn_done below releases the mark.
1449
- if (e.eventName === 'tool_call' || e.eventName === 'permission_request')
1450
- this.tasks?.agentTurnStarted(live.sessionRef);
1451
- // Before the contract check: an agent emitting even non-conforming events is a live process,
1452
- // and the idle sweep is about liveness, not correctness.
1453
- this.touch(live);
1454
- // Contract check BEFORE record(): record() takes the next seq synchronously, so a violating
1455
- // event that got numbered and then dropped would leave a hole in the sequence — the server's
1456
- // watermark reads a hole as lost data. Drop, don't throw: one bad event must not poison the
1457
- // session, and the diagnostic (agent, session, event) is what makes the violation debuggable.
1458
- const rejection = validateWireEvent(e);
1459
- if (rejection) {
1460
- console.warn(`[acp:${live.agentId} agent:${live.agentKey}] dropped event '${wireEventName(e)}' for ${live.sessionRef}: ` +
1461
- `${rejection.reason}${rejection.detail ? ` (${rejection.detail})` : ''}`);
1462
- return;
2601
+ /**
2602
+ * The session's processor frames (Task 19), in processor order: projected for the connected server
2603
+ * (`processor/projection.ts`), the numbered ones journaled under the processor's own `seq` and then sent, the levels and
2604
+ * RPC replies sent as they are.
2605
+ *
2606
+ * What used to be decided here moved to the processor, and is no longer re-decided:
2607
+ *
2608
+ * - **Numbering.** `seq` is processor state; this method never allocates one. The contract check that used to run before
2609
+ * `record()` (`validateWireEvent`) runs in the processor before it numbers (`emit.ts`), so a refused event still leaves no hole.
2610
+ * - **Scoped events for an old server** (spec §5.4, "与连接状态无关"): the processor numbers them whatever the server; the
2611
+ * projection withholds them below {@link AGENT_WIRE_SCOPED_ORIGIN_VERSION} — leaving a `seq` gap the server does not detect —
2612
+ * and a withheld permission card is answered by an `agent_answer_undeliverable` note the processor turns into a logged
2613
+ * `cancelled` answer (the auto-answer used to be written straight to the agent).
2614
+ * - **`refusalFields`**: the projection adds the agent's observed sign-in methods and code to an `agent_turn_done` the processor
2615
+ * marked `agentRefused` — exactly the turns {@link refusalFields} was gated on.
2616
+ *
2617
+ * A frame the projection withholds is not recorded either: the outbox (Task 20, the record-before-send buffer that replaced the
2618
+ * journal) only ever holds frames this server can be sent, which is what keeps {@link blockIncompatibleJournal} meaningful.
2619
+ *
2620
+ * Returns the outbox append of the numbered frames: the session's record loop awaits it before folding another record, so the
2621
+ * outbox holding a frame proves its record's commands are in raw (and a checkpoint never precedes a record's frames).
2622
+ */
2623
+ onSessionFrames(live, frames) {
2624
+ const numbered = [];
2625
+ for (const output of frames) {
2626
+ if (live.closed) {
2627
+ // Closed or detached while the session was finishing a record (Task 21 fix round 1, I-1): its numbered frames are still
2628
+ // recorded — a detached session's next daemon resends them; dropping them would skip their seqs behind the head checkpoint.
2629
+ // Nothing is sent or noted for it any more.
2630
+ if (output.journaled && projectFrame(output, { sessionRef: live.sessionRef, serverAgentWire: this.serverAgentWire }).frame) {
2631
+ numbered.push({ seq: output.frame.seq, src: output.src, frame: output.frame });
2632
+ }
2633
+ continue;
2634
+ }
2635
+ const frame = output.frame;
2636
+ if (frame.t === 'agent_account_update') {
2637
+ if (this.serverAgentWire >= 27)
2638
+ this.emit({ t: 'agent_account_update', agentKey: live.agentKey, snapshot: frame.snapshot });
2639
+ continue;
2640
+ }
2641
+ switch (frame.t) {
2642
+ case 'agent_event':
2643
+ // A live process, whatever the event: the idle sweep is about liveness.
2644
+ this.touch(live);
2645
+ break;
2646
+ case 'agent_turn_done':
2647
+ // Busy is sampled (`liveness.turnRunning`, already false by now); a session that was the registry's only busy one
2648
+ // may just have gone idle, and sampling has no event of its own to announce that.
2649
+ this.tasks?.sessionBusyChanged();
2650
+ this.touch(live);
2651
+ if (typeof frame.nativeSessionId === 'string' && frame.nativeSessionId)
2652
+ live.nativeSessionId = frame.nativeSessionId;
2653
+ // The adapter works: its earlier own exits no longer count towards the crash limit.
2654
+ if (frame.status !== 'failed')
2655
+ this.adapterCrashes.delete(live.sessionRef);
2656
+ break;
2657
+ case 'agent_session_state':
2658
+ // A level, never journaled; the session keeps the latest (`activityRaw`) for `agent_opened`/the resume offer, even for a
2659
+ // server too old to be sent the push itself. A push is when sampled busy may flip, so the registry looks again.
2660
+ live.lastActivityAtMs = Date.now();
2661
+ live.staleActivityWarned = false;
2662
+ this.tasks?.sessionBusyChanged();
2663
+ break;
2664
+ case 'agent_runtime_profile':
2665
+ if (this.sessions.get(live.sessionRef) !== live)
2666
+ continue;
2667
+ break;
2668
+ }
2669
+ const projection = projectFrame(output, {
2670
+ sessionRef: live.sessionRef,
2671
+ serverAgentWire: this.serverAgentWire,
2672
+ ...(frame.t === 'agent_turn_done' && frame.agentRefused === true
2673
+ ? { authMethods: this.agents.observedAuthMethods(live.agentId) } : {}),
2674
+ });
2675
+ if (projection.note) {
2676
+ void live.session.note(projection.note).catch((err) => {
2677
+ console.error(`[agent] auto-answer for an undeliverable card failed for ${live.sessionRef}: ${errorMessage(err)}`);
2678
+ });
2679
+ }
2680
+ if (!projection.frame) {
2681
+ if (frame.t === 'agent_event' && frame.acpSessionId !== undefined) {
2682
+ console.warn(`[acp:${live.agentId}] scoped live event '${String(frame.eventName)}' for '${String(frame.acpSessionId)}' disabled: ` +
2683
+ `requires agent wire ${AGENT_WIRE_SCOPED_ORIGIN_VERSION}`);
2684
+ }
2685
+ continue;
2686
+ }
2687
+ if (output.journaled)
2688
+ numbered.push({ seq: frame.seq, src: output.src, frame });
2689
+ else
2690
+ this.emit(projection.frame);
1463
2691
  }
1464
- this.record(live, (seq) => ({
1465
- t: 'agent_event',
1466
- sessionRef: live.sessionRef,
1467
- seq,
1468
- eventName: e.eventName,
1469
- payload: e.payload,
1470
- // Present only for a subagent's own output. The frame still rides this session's ref, journal
1471
- // and seq — one watermark per ref (see `WireEvent.acpSessionId`); this field only says which
1472
- // session on the far side the event belongs to.
1473
- ...(e.acpSessionId ? { acpSessionId: e.acpSessionId } : {}),
1474
- }));
2692
+ return numbered.length > 0 ? this.record(live, numbered) : undefined;
1475
2693
  }
1476
- onAgentTurnDone(live, r) {
1477
- if (live.closed)
2694
+ /**
2695
+ * The session degraded under its own loop (a poisoned processor, or a transport that failed): its open turns were already ended
2696
+ * `failed` through {@link onSessionFrames} and its adapter is being stopped. It is retired like a swept session — the journal
2697
+ * kept, the server told — so the next `agent_open` spawns anew and loads the native session (interface-contracts: the poison
2698
+ * recovery contract; never retried from a checkpoint).
2699
+ */
2700
+ async onSessionDegraded(live, reason) {
2701
+ if (live.closed || this.sessions.get(live.sessionRef) !== live)
1478
2702
  return;
1479
- this.tasks?.agentTurnFinished(live.sessionRef);
1480
- this.touch(live);
1481
- if (r.nativeSessionId)
1482
- live.nativeSessionId = r.nativeSessionId;
1483
- this.record(live, (seq) => ({
1484
- t: 'agent_turn_done',
1485
- sessionRef: live.sessionRef,
1486
- seq,
1487
- status: r.status,
1488
- stopReason: r.stopReason,
1489
- nativeSessionId: r.nativeSessionId ?? null,
1490
- error: r.error ?? null,
1491
- // Only on a failure the agent itself answered. This is the frame credentials that expired
1492
- // mid-conversation arrive on — the agent answers the next `session/prompt` with
1493
- // `auth_required` rather than ending the session — and it is also the busiest frame on this
1494
- // channel, one per turn for the life of every session, which is why a completed turn carries
1495
- // no table nothing reads.
1496
- //
1497
- // `status === 'failed'` on its own would be wrong, and wrong in the direction that hurts:
1498
- // `AcpSession` synthesises that status on three paths the agent never answered (a prompt
1499
- // after the read loop died, a connection that aborted mid-turn, an unmappable update), all of
1500
- // them on a *live* session whose method table this daemon certainly holds. An agent process
1501
- // crashing mid-turn is plausibly more common than a token expiring mid-turn, so pairing those
1502
- // with `authMethods` would put more false login cards on screen than real ones. See
1503
- // {@link TurnResult.agentRefused}.
1504
- //
1505
- // And `agentRefused` on its own is not enough either, which is what `errorCode` is here for:
1506
- // an exceeded context window is a refusal the agent genuinely answered, and it is common.
1507
- ...(r.status === 'failed' && r.agentRefused ? this.refusalFields(live.agentId, r.errorCode) : {}),
1508
- }));
2703
+ console.error(`[acp:${live.agentId} agent:${live.agentKey}] session ${live.sessionRef} degraded and was stopped: ${reason}`);
2704
+ const alreadyLost = live.adapterLost?.notified === true;
2705
+ await this.retire(live, 'keep');
2706
+ // Its adapter's exit already told the server: a second notice could arrive after the server reopened the ref, and void the new
2707
+ // generation.
2708
+ if (!alreadyLost)
2709
+ this.notifySwept(live.sessionRef);
1509
2710
  }
1510
2711
  /**
1511
- * Numbers a frame, writes it, then sends it. The seq is taken synchronously so callbacks are
1512
- * numbered in arrival order; the write and the send ride the session's queue so they stay in that
1513
- * order on the wire.
2712
+ * Records processor-numbered frames in the session's outbox (the processor's frame, without `sessionRef`). They are sent from
2713
+ * there by {@link pump} — an append announces itself — under the session window and the socket gate. A failed append rejects:
2714
+ * the session degrades (a frame that could not be recorded is not sent either).
1514
2715
  */
1515
- record(live, build) {
1516
- const seq = live.journal.nextSeq();
1517
- const frame = build(seq);
1518
- void this.enqueue(live, async () => {
1519
- try {
1520
- await live.journal.append({ seq, frame });
1521
- }
1522
- catch (err) {
1523
- // Losing durability is bad; losing the event outright is worse. Send it anyway — it just
1524
- // will not survive a reconnect.
1525
- console.error(`[agent] journal append failed for ${live.sessionRef}: ${errorMessage(err)}`);
2716
+ record(live, frames) {
2717
+ for (const { seq, frame } of frames) {
2718
+ if (frame.t === 'agent_event' && frame.acpSessionId != null) {
2719
+ live.highestScopedJournalSeq = Math.max(live.highestScopedJournalSeq, seq);
1526
2720
  }
1527
- this.emit(frame);
2721
+ }
2722
+ return live.session.outbox.append(frames);
2723
+ }
2724
+ /**
2725
+ * The server applied through `throughSeq` (`agent_event_ack`, or the `resumeFromSeq` of a reopen): the outbox forgets those
2726
+ * entries and the transport truncates its stream (with the `committedTail` watermark). Held back, like delivery, while an
2727
+ * incompatible server still owes this session a scoped frame it cannot interpret.
2728
+ */
2729
+ async acknowledge(live, throughSeq) {
2730
+ if (live.scopedReplayBlocked || this.blockIncompatibleJournal(live))
2731
+ return;
2732
+ await live.session.outbox.ack(throughSeq, (error) => {
2733
+ console.error(`[agent] outbox commit failed for ${live.sessionRef}: ${errorMessage(error)}`);
1528
2734
  });
2735
+ if (throughSeq >= live.highestScopedJournalSeq)
2736
+ live.highestScopedJournalSeq = 0;
2737
+ }
2738
+ /**
2739
+ * Must run before replay, delivery or ACK truncation: a server below {@link AGENT_WIRE_SCOPED_ORIGIN_VERSION} cannot deliver or
2740
+ * acknowledge a scoped frame the outbox still holds, so nothing of this session goes to it (sending a higher seq would let it
2741
+ * acknowledge past the child event it could not interpret).
2742
+ */
2743
+ blockIncompatibleJournal(live) {
2744
+ if (this.serverAgentWire >= AGENT_WIRE_SCOPED_ORIGIN_VERSION)
2745
+ return false;
2746
+ if (live.scopedReplayBlocked)
2747
+ return true;
2748
+ // Only a compatible ACK proves the peer has applied (or explicitly accepted a gap through) that scoped sequence.
2749
+ const incompatible = live.highestScopedJournalSeq > 0;
2750
+ if (incompatible)
2751
+ live.scopedReplayBlocked = true;
2752
+ return incompatible;
2753
+ }
2754
+ /**
2755
+ * The sender (spec §5.6): sessions take turns, one frame each per round, while the socket gate is open. A session sends its
2756
+ * next unsent outbox entry only while fewer than {@link SESSION_WINDOW_EVENTS} events and {@link SESSION_WINDOW_BYTES} bytes it
2757
+ * sent on this connection are unacknowledged (one frame always fits an empty window). Each frame is projected for the
2758
+ * connected server as it leaves. Re-entrant calls fold into the running one.
2759
+ */
2760
+ pump() {
2761
+ if (this.pumping) {
2762
+ this.pumpAgain = true;
2763
+ return;
2764
+ }
2765
+ this.pumping = true;
2766
+ try {
2767
+ do {
2768
+ this.pumpAgain = false;
2769
+ if (this.send === null)
2770
+ return;
2771
+ const lives = [...this.sessions.values()];
2772
+ if (lives.length === 0)
2773
+ return;
2774
+ let progressed = true;
2775
+ while (progressed) {
2776
+ progressed = false;
2777
+ const start = this.turn;
2778
+ this.turn = (start + 1) % lives.length;
2779
+ for (let k = 0; k < lives.length; k++) {
2780
+ const live = lives[(start + k) % lives.length];
2781
+ if (live.closed || this.sessions.get(live.sessionRef) !== live)
2782
+ continue;
2783
+ if (live.scopedReplayBlocked || this.blockIncompatibleJournal(live))
2784
+ continue;
2785
+ const outbox = live.session.outbox;
2786
+ const item = outbox.nextUnsent();
2787
+ if (!item)
2788
+ continue;
2789
+ // A resolving subagent relation and everything before it go out past the window, in seq order — the server cannot
2790
+ // acknowledge anything after a child frame without it (spec §5.6). Once sent they count again for a server that
2791
+ // acknowledges as soon as the relation releases its cap (wire ≥ 25); an older one wastes its acks while capped, so
2792
+ // there they keep not counting (`SessionOutbox.inflight`).
2793
+ const pinnedCountsWhenSent = this.serverAgentWire >= AGENT_WIRE_RELATION_ACK_VERSION;
2794
+ const inflight = outbox.inflight(this.epoch, pinnedCountsWhenSent);
2795
+ const bypass = item.seq <= outbox.pinnedThrough;
2796
+ if (!bypass && (inflight.events >= SESSION_WINDOW_EVENTS || (inflight.events > 0 && inflight.bytes + item.bytes > SESSION_WINDOW_BYTES)))
2797
+ continue;
2798
+ if (!this.gate.isOpen()) {
2799
+ this.gate.whenOpen(() => this.pump());
2800
+ return;
2801
+ }
2802
+ item.sentOn = this.epoch;
2803
+ this.sendRecorded(live, item.frame, item.src);
2804
+ progressed = true;
2805
+ if (this.send === null)
2806
+ return;
2807
+ if (live.adapterLost && !live.adapterLost.notified && item.seq >= live.adapterLost.throughSeq)
2808
+ this.flushAdapterLost(live.sessionRef);
2809
+ }
2810
+ }
2811
+ } while (this.pumpAgain);
2812
+ }
2813
+ finally {
2814
+ this.pumping = false;
2815
+ }
2816
+ }
2817
+ /** A retiring session's last unsent frames, in order (outside the rotation; bounded by its window). */
2818
+ sendUnsent(live) {
2819
+ if (this.send === null || live.scopedReplayBlocked || this.blockIncompatibleJournal(live))
2820
+ return;
2821
+ for (let item = live.session.outbox.nextUnsent(); item; item = live.session.outbox.nextUnsent()) {
2822
+ item.sentOn = this.epoch;
2823
+ this.sendRecorded(live, item.frame, item.src);
2824
+ }
1529
2825
  }
1530
- /** Appends `op` to the session's serialized queue. The returned promise never rejects. */
1531
- enqueue(live, op) {
1532
- const next = live.writes.then(op).catch((err) => {
1533
- console.error(`[agent] journal operation failed for ${live.sessionRef}: ${errorMessage(err)}`);
2826
+ /** One recorded frame, projected for the connected server as it leaves. */
2827
+ sendRecorded(live, frame, src) {
2828
+ const projection = projectFrame({ journaled: true, frame, src }, {
2829
+ sessionRef: live.sessionRef,
2830
+ serverAgentWire: this.serverAgentWire,
2831
+ ...(frame.t === 'agent_turn_done' && frame.agentRefused === true ? { authMethods: this.agents.observedAuthMethods(live.agentId) } : {}),
1534
2832
  });
1535
- live.writes = next;
1536
- return next;
2833
+ if (projection.note) {
2834
+ void live.session.note(projection.note).catch((err) => {
2835
+ console.error(`[agent] auto-answer for an undeliverable card failed for ${live.sessionRef}: ${errorMessage(err)}`);
2836
+ });
2837
+ }
2838
+ if (projection.frame)
2839
+ this.emit(projection.frame);
1537
2840
  }
1538
2841
  async onClose(sessionRef) {
2842
+ // 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.
2843
+ void this.agents.cancelSwitch?.({ sessionRef }).catch(() => false);
2844
+ this.reopenRequests.delete(sessionRef);
2845
+ this.adapterCrashes.delete(sessionRef);
1539
2846
  // Closing a ref that is still spawning: nothing to close yet, so tombstone it — openSession
1540
2847
  // closes the session (and destroys its journal) the moment the bridge hands it over.
1541
2848
  //
@@ -1548,47 +2855,78 @@ export class AgentSessionManager {
1548
2855
  return;
1549
2856
  }
1550
2857
  const live = this.sessions.get(sessionRef);
1551
- if (!live)
2858
+ if (live) {
2859
+ await this.retire(live, 'destroy');
1552
2860
  return;
1553
- await this.retire(live, 'destroy');
2861
+ }
2862
+ // Not held: whatever an earlier life left under the ref (a swept or degraded session's outbox, a previous daemon's, a migrated
2863
+ // legacy journal) can never be asked for again — it goes, as the journal did on `agent_close`.
2864
+ //
2865
+ // The cleanup publishes on `retiring` — synchronously, before this handler's first await — because the server's close→reopen
2866
+ // pair (a stale recycle, a config reload) can land its reopen while the cleanup is still listing the keeper. `openSession`
2867
+ // waits on this map as its very first statement, so without the publication that open spawns under the cleanup, and the
2868
+ // cleanup's list sees the fresh spawn as a running leftover and stops it mid-`initialize` ("exited before responding to
2869
+ // initialize (code=null, signal=SIGTERM)"). The recheck stays inside: an open that appeared while a previous teardown was
2870
+ // settling owns the ref — its reopen reads the leftover outbox back instead of dropping it. A cleanup that fails must not
2871
+ // fail whatever waits on it (the published promise never rejects), and a second close chains behind this one as today.
2872
+ const previous = this.retiring.get(sessionRef);
2873
+ const cleanup = (async () => {
2874
+ await previous;
2875
+ if (this.sessions.has(sessionRef) || this.opening.has(sessionRef))
2876
+ return;
2877
+ await this.agents.releaseLeftover?.(sessionRef);
2878
+ })().catch((error) => {
2879
+ console.warn(`[agent] leftover cleanup for ${sessionRef} failed: ${error instanceof Error ? error.message : String(error)}`);
2880
+ });
2881
+ this.retiring.set(sessionRef, cleanup);
2882
+ try {
2883
+ await cleanup;
2884
+ }
2885
+ finally {
2886
+ if (this.retiring.get(sessionRef) === cleanup)
2887
+ this.retiring.delete(sessionRef);
2888
+ }
1554
2889
  }
1555
2890
  /**
1556
2891
  * The teardown behind `agent_close`: stop emitting, unregister, kill the agent subprocess, and —
1557
- * when
1558
- * `journal` says so — delete the journal. Shared with {@link sweepIdle} so an orphan swept on the
1559
- * timer leaves exactly as little behind as one the server closed properly, except for the file
1560
- * itself (see {@link sweepIdle} for why that one differs).
2892
+ * when `outbox` says so — drop the session's transport session with its outbox. Shared with {@link sweepIdle} so an orphan swept
2893
+ * on the timer leaves exactly as little behind as one the server closed properly, except for the outbox itself (see
2894
+ * {@link sweepIdle} for why that one differs).
1561
2895
  *
1562
- * `journal: 'keep'` still rides the same queue and still publishes on {@link retiring}. The wait
1563
- * is not about the unlink: the point is that this session's pending appends must have landed
1564
- * before a reopen constructs a second {@link SessionJournal} on the same path, or the new
1565
- * instance recovers its seq counter from a file that is still being written behind it.
2896
+ * Published on {@link retiring} either way: a reopen spawns under the same transport key, so it must wait until the old adapter
2897
+ * is stopped and the session's pending appends (and its head checkpoint) have landed — or its outbox is gone.
1566
2898
  */
1567
- async retire(live, journal) {
2899
+ async retire(live, outbox) {
1568
2900
  const sessionRef = live.sessionRef;
2901
+ // 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
2902
+ // must survive it, or a crash-looping adapter would be respawned for ever.
2903
+ if (outbox === 'destroy')
2904
+ this.adapterCrashes.delete(sessionRef);
1569
2905
  live.closed = true;
2906
+ live.unwatch();
1570
2907
  this.sessions.delete(sessionRef);
1571
2908
  this.tasks?.unregisterAgentSession(sessionRef);
1572
- // Queued behind the pending writes: an append that landed after the file was removed would
1573
- // resurrect a journal for a session that no longer exists. Published on `retiring` for the whole
1574
- // teardown, so a reopen of this ref parks until the file is actually gone.
1575
- const journalDone = this.enqueue(live, async () => {
1576
- if (journal === 'destroy')
1577
- await live.journal.destroy();
2909
+ if (outbox === 'detach')
2910
+ this.detached.add(sessionRef);
2911
+ const closed = (async () => {
2912
+ // Frames produced before the close still go out (as the journal's queued sends did): appends in flight land, then whatever
2913
+ // is unsent is written — the session left the sender's rotation with the `sessions` entry.
2914
+ await live.session.outbox.flush();
2915
+ this.sendUnsent(live);
2916
+ if (outbox === 'detach' && live.session.detach)
2917
+ await live.session.detach();
2918
+ else
2919
+ await live.session.close(outbox === 'destroy' ? 'release' : 'keep');
2920
+ })().catch((err) => {
2921
+ console.error(`[agent] close failed for ${sessionRef}: ${errorMessage(err)}`);
1578
2922
  });
1579
- const retired = journalDone.finally(() => {
2923
+ const retired = closed.then(() => undefined).finally(() => {
1580
2924
  // Only clear our own entry: a later close of the same ref may already have replaced it.
1581
2925
  if (this.retiring.get(sessionRef) === retired)
1582
2926
  this.retiring.delete(sessionRef);
1583
2927
  });
1584
2928
  this.retiring.set(sessionRef, retired);
1585
- try {
1586
- await live.session.close();
1587
- }
1588
- catch (err) {
1589
- console.error(`[agent] close failed for ${sessionRef}: ${errorMessage(err)}`);
1590
- }
1591
- await journalDone;
2929
+ await retired;
1592
2930
  }
1593
2931
  /**
1594
2932
  * The one place every outbound agent frame passes, and so the one place the wire contract can be