@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
@@ -0,0 +1,1512 @@
1
+ /**
2
+ * One live agent session driven by the deterministic ACP processor over a transport (Task 19, spec 2026-10-11 §5.3/§5.4/§5.7):
3
+ * the daemon side of keeper-acp — or of the local fallback, which is the same session mechanism in this process.
4
+ *
5
+ * ## The record loop
6
+ *
7
+ * The transport's raw stream (`subscribeRaw`) is the only input. For each record, in order:
8
+ * `step(record)` → every command it produced is executed through the transport (`executeDownCommandsVia`: idempotent by cause,
9
+ * refused unless its spawn runs now, prompt blocks filled from this daemon's memory, `secretRef`s substituted from this daemon's
10
+ * memory) → a prompt whose blocks are gone is failed with an `agent_prompt_failed` note → the record's frames go out.
11
+ * Everything the server or this daemon decides reaches the processor as a note or a write first (`agent_prompt`, `agent_answer`,
12
+ * `agent_interrupt`, …); nothing here changes processor state directly, and nothing here numbers a frame — the processor does —
13
+ * with the one exception the poison contract names (below).
14
+ *
15
+ * ## What leaves
16
+ *
17
+ * Frames the manager sends (`onFrames`), unchanged, with `src`. The ones meant for the daemon itself stay here:
18
+ * `processor.open_result` settles {@link openSession}; `processor.history_event`/`history_end` become the load's backfill
19
+ * (never journaled: a daemon that dies inside the window loads again); an `agent_steered` / `agent_config_option_set` whose
20
+ * request this object made settles that call (one with nobody waiting goes out as a frame).
21
+ *
22
+ * ## Secrets and blocks
23
+ *
24
+ * Prompt and steer blocks are held in memory under a `blocksRef` until their `down` is written; secrets (a delegation bearer)
25
+ * under a `secretRef`, for the life of the session. Notes carry only the refs (spec §13).
26
+ *
27
+ * ## Poison (interface-contracts, "中毒恢复契约")
28
+ *
29
+ * A step that throws poisons the processor. The session is degraded: every turn still open ends with a failed `agent_turn_done`
30
+ * this object numbers itself, after the processor's last seq; the adapter is terminated; `onDegraded` tells the manager, which
31
+ * retires the session so the next `agent_open` starts a new spawn (`session/load`). Never retried from a checkpoint: replay is
32
+ * deterministic and would poison again on the same record. A transport that fails under the loop (keeper gone, raw truncated) is
33
+ * handled the same way: Task 19 has no re-attach (Task 21).
34
+ *
35
+ * ## Qoder side files (17c)
36
+ *
37
+ * When the repair is on, a 1 s timer asks the processor's **current** `sideDemand` — only once the processor has folded every
38
+ * record this object appended — runs `BackgroundSideNotes.poll(demand)` and appends the notes it returns, in order. The reader
39
+ * stops when the demand is `undefined` (no repair any more).
40
+ *
41
+ * ## Outbox, backpressure, checkpoints (Task 20, spec §5.5/§5.6)
42
+ *
43
+ * The manager records every journaled frame in {@link outbox} (the transport's outbox stream) before it sends it; the listener
44
+ * {@link onFrames} registers returns that append, and the loop awaits it before folding the next record — so the outbox holding a
45
+ * frame with `src.o = o` proves record `o`'s commands are in raw, and a checkpoint at `o` never precedes `o`'s frames. Before each
46
+ * record the loop waits for {@link SessionOutbox.room}: a server that does not acknowledge stops the processor, and the backlog
47
+ * stays in the raw log.
48
+ *
49
+ * Checkpoints (`acp.commit{checkpoint, rawThrough}`, raw truncated to the checkpoint): at the end of every turn; within a turn
50
+ * every {@link CHECKPOINT_RAW_BYTES} of folded raw or {@link CHECKPOINT_INTERVAL_MS}; and on a `close('keep')` (graceful exit,
51
+ * detach). Never while `boundaries.sideChunk` holds a half-read Qoder fact or a load's history window is open — deferred to the
52
+ * next record where both are clear. Each carries `processorVersion` (`checkpointOf`).
53
+ *
54
+ * ## A session left under the same key
55
+ *
56
+ * An earlier life of this ref (a swept or degraded session, a previous daemon's, a local outbox) is reused, not released: the new
57
+ * spawn continues the same raw log and outbox. Its unacknowledged frames are read back into {@link outbox} (resent when the server
58
+ * reopens the ref) and the processor numbers after the highest seq the outbox ever held — `max(outbox.maxSeq, committedTail.seq,
59
+ * resumeFromSeq)` (§5.5). An earlier life whose adapter still runs is never stopped here: the daemon adopts it at start
60
+ * ({@link ProcessorSession.adopt}), and keeper-acp refuses a second spawn into a running session.
61
+ *
62
+ * ## Adoption (Task 21, spec §5.5)
63
+ *
64
+ * A keeper-acp session whose adapter outlived the daemon (restart, upgrade, crash) is adopted, not stopped:
65
+ * `acp.attach` → the head checkpoint is restored → raw `(checkpoint.offset, replayEnd]` is replayed **updating state only** —
66
+ * `replayEnd = max(outbox last.src.o, committedTail.src.o)`, everything whose frames were recorded — and the frames the replay
67
+ * produces are compared byte for byte with the outbox entries that survive (daemon-numbered ones, `src.i ≥ DAEMON_FRAME_INDEX`,
68
+ * skipped; differences are only counted when the checkpoint came from this very processor version) → the seq continues after
69
+ * `max(replayed seq, outbox.maxSeq, committedTail.seq)` → the unacknowledged outbox entries are resent on the server's reopen →
70
+ * the records after `replayEnd` are folded live (their commands are idempotent by cause). A session whose open never completed, or
71
+ * that cannot be rebuilt (no readable checkpoint and no spawn record left), is refused ({@link AdoptionRefusedError}); the caller
72
+ * stops its adapter and keeps its outbox. A checkpoint is cut right after the spawn and when the open completes, so every session
73
+ * has one that says whether it is open.
74
+ *
75
+ * ## Detach (Task 21, spec §5.5.5)
76
+ *
77
+ * {@link detach} ends this daemon's hold on a keeper-hosted session without touching the adapter: the loop stops after the
78
+ * record in hand, a head checkpoint is cut when the state is safe (never mid Qoder chunk or load history — then the next daemon
79
+ * replays), the subscription is dropped. No `session/cancel`, no answer to a pending card, no `turn_done(failed)`: a detaching
80
+ * session never degrades. The next daemon adopts it.
81
+ *
82
+ * ## Keeper connection loss (Task 21)
83
+ *
84
+ * keeper-acp unreachable under the loop (a request lost on a dropped connection, a stream that ended) is not a degradation: the
85
+ * session re-attaches — `acp.attach` (which relaunches the current generation's keeper-acp if it died; an older generation is
86
+ * only reconnected) and the same rebuild as an adoption, in place — with bounded retries. A note whose append failed is looked
87
+ * for in raw before it is appended again. Only a keeper that is gone with the session (not found, or unreachable after the
88
+ * retries) degrades it. When keeper-acp itself restarted, the session it recovered ends with `proc exit{reason:'keeper_lost'}`:
89
+ * that tail is folded (a real `turn_done` in it is delivered) and the session reports its adapter dead.
90
+ */
91
+ import { PROTOCOL_VERSION, RequestError } from '@agentclientprotocol/sdk';
92
+ import { ACCOUNT_LIMITS_READ_TIMEOUT_MS } from '../protocol/accountLimits.js';
93
+ import { BackgroundSideNotes, nativeTerminalHistoryPath } from '../qoderAdapter/backgroundTerminal.js';
94
+ import { AgentInitializeTimeoutError, AgentSpawnError, UnsupportedProtocolVersionError } from './acpClient.js';
95
+ import { executeDownCommandsVia } from './processor/transport/types.js';
96
+ import { createProcessor, ProcessorPoisonedError, restoreProcessor } from './processor/step.js';
97
+ import { checkpointOf, decodeCheckpoint, encodeCheckpoint, PROCESSOR_VERSION } from './processor/checkpoint.js';
98
+ import { DAEMON_FRAME_INDEX, isOutboxTail, SessionOutbox, transportOutboxStore } from './outbox.js';
99
+ import { initialState } from './processor/state.js';
100
+ import { activityRaw, runtimeDiagnostics } from './processor/runtime.js';
101
+ import { sideDemand } from './processor/sideChannel.js';
102
+ import { unfinishedAsyncTasks } from './processor/updates.js';
103
+ import { pendingRequestIds } from './processor/session.js';
104
+ import { steeredIds } from './processor/control.js';
105
+ import { RawTruncatedError } from './processor/transport/types.js';
106
+ import { REPAIR_NOTE } from '../qoderAdapter/backgroundTerminalFold.js';
107
+ import { AcpKeeperUnavailableError } from '../keepers/acp/client.js';
108
+ import { AcpErrorCode } from '../keepers/acp/protocol.js';
109
+ import { KeeperClosedError, KeeperConnectionLostError, KeeperNotConnectedError, KeeperRequestError, KeeperRequestTimeoutError, } from '../keepers/kit/client.js';
110
+ /** What the executor reports when a prompt's blocks are no longer in this daemon's memory (interface-contracts). */
111
+ export const PROMPT_LOST = 'The prompt was lost before it reached the agent';
112
+ /** The error a turn ends with when the session degrades under it (poison, or a transport that failed under the loop). */
113
+ export const SESSION_DEGRADED = 'The agent session failed and was stopped; reopen it to continue';
114
+ /** How often the Qoder side-file reader polls (the live repair's timer). */
115
+ const SIDE_POLL_MS = 1_000;
116
+ /** stderr kept for a startup failure's message (the `err` records the processor ignores). */
117
+ const STDERR_TAIL_BYTES = 8_192;
118
+ /** Within a turn, a checkpoint every this many bytes of folded raw (spec §5.5, §14). */
119
+ export const CHECKPOINT_RAW_BYTES = 8 * 1024 * 1024;
120
+ /** …or every this long (spec §5.5, §14). */
121
+ export const CHECKPOINT_INTERVAL_MS = 60_000;
122
+ /** Re-attach attempts after keeper-acp's connection was lost, before the session is given up (Task 21). */
123
+ export const REATTACH_ATTEMPTS = 6;
124
+ /** The first retry's delay; it doubles up to {@link REATTACH_BACKOFF_MAX_MS}. */
125
+ export const REATTACH_BACKOFF_MS = 250;
126
+ export const REATTACH_BACKOFF_MAX_MS = 8_000;
127
+ /** More re-attaches than this within {@link REATTACH_WINDOW_MS} is a connection that keeps failing: the session degrades. */
128
+ export const REATTACH_LIMIT = 5;
129
+ export const REATTACH_WINDOW_MS = 60_000;
130
+ /** Steer/config replies this object already settled: one the record loop produces again (after a re-attach) is not sent twice. */
131
+ const SETTLED_REPLIES_KEPT = 256;
132
+ /** A keeper-acp session this daemon will not continue (its open never completed, or it cannot be rebuilt): the caller stops it. */
133
+ export class AdoptionRefusedError extends Error {
134
+ constructor(message) { super(message); this.name = 'AdoptionRefusedError'; }
135
+ }
136
+ const delay = (ms) => new Promise((resolve) => { setTimeout(resolve, ms).unref?.(); });
137
+ /** keeper-acp could not be reached for this request (the connection dropped, or it is relaunching): a re-attach may fix it. */
138
+ export function isConnectionLoss(error) {
139
+ return error instanceof KeeperConnectionLostError || error instanceof KeeperNotConnectedError ||
140
+ error instanceof KeeperRequestTimeoutError || error instanceof AcpKeeperUnavailableError;
141
+ }
142
+ const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
143
+ function lastLines(text, count) {
144
+ return text.split('\n').filter((line) => line.trim()).slice(-count).join(' | ');
145
+ }
146
+ export class ProcessorSession {
147
+ options;
148
+ spawnId;
149
+ transport;
150
+ key;
151
+ /** Replaced (in place) when the session re-attaches after keeper-acp's connection was lost. */
152
+ processor;
153
+ /** Bumped by every re-attach: a fold of an older subscription stops. */
154
+ generation = 0;
155
+ reattaching;
156
+ reattaches = [];
157
+ settledReplies = [];
158
+ /** The session left this daemon without stopping its adapter ({@link detach}). */
159
+ detached = false;
160
+ /**
161
+ * Every journaled frame of the records through this offset is in the outbox (fix round 1): a checkpoint is never cut past it — the
162
+ * next daemon would restore past frames it never recorded, and their seqs would be skipped. Frames produced before the manager
163
+ * registered ({@link onFrames}) are only buffered: from the first such record ({@link unrecordedFrom}) it stops advancing until
164
+ * their append has landed.
165
+ */
166
+ recordedThrough = -1;
167
+ routedThrough = -1;
168
+ unrecordedFrom;
169
+ /** The open was complete (and no load history open) after the last record: its completion is checkpointed (Task 21). */
170
+ wasOpen = false;
171
+ /** The last rebuild (adoption, or a re-attach), for diagnostics. */
172
+ recovery;
173
+ /** Stops listening for keeper-acp connection drops. */
174
+ unlisten = () => { };
175
+ /** This session was adopted from a previous daemon (Task 21), not spawned by this one. */
176
+ adopted;
177
+ blocks = new Map();
178
+ secrets = new Map();
179
+ pendingReplies = new Map();
180
+ waiters = new Set();
181
+ log;
182
+ subscription;
183
+ frameListener;
184
+ /** The transport's outbox for this session (Task 20): the manager records frames here before sending them. */
185
+ outbox;
186
+ /** Highest seq this object numbered itself (degraded turns). */
187
+ daemonSeq = 0;
188
+ /** Checkpoint cadence: raw folded and time since the last checkpoint, and a checkpoint waiting for a safe record. */
189
+ sinceCheckpoint = { bytes: 0, records: 0, at: Date.now() };
190
+ checkpointDue = false;
191
+ /**
192
+ * The checkpoint due is only a marker (the spawn, the open completed — Task 21): cut without truncating raw, which only the spec's
193
+ * own cadence (turn end, 8 MiB / 60 s, exit/detach) does.
194
+ */
195
+ checkpointKeepsRaw = false;
196
+ /** A record is being folded (from `step` until its frames are recorded): not a boundary a checkpoint may be cut at. */
197
+ inRecord = false;
198
+ boundaryWaiters = [];
199
+ buffered = [];
200
+ degradedListener;
201
+ adapterExitListener;
202
+ /** An adapter exit folded before anyone listened ({@link onAdapterExited} delivers it on registration). */
203
+ undeliveredExit;
204
+ degraded;
205
+ closing;
206
+ refs = 0;
207
+ /** The highest offset this object appended (notes it wrote): the processor has caught up once it folded it. */
208
+ appended = -1;
209
+ stderr = '';
210
+ openResult;
211
+ historyEnd;
212
+ history = [];
213
+ side;
214
+ /** The record loop's tail: notes it appends are serialised behind it. */
215
+ chain = Promise.resolve();
216
+ /** The loop is held at a record boundary for a switch ({@link suspend}); `release` lets it go on. */
217
+ suspension;
218
+ /** Records delivered while the loop was held ({@link suspend}); reset by every suspend. */
219
+ suspendedArrivals = 0;
220
+ /** Wall-clock time the last record was folded (the switch's quiet period, spec §6.3 step 2). */
221
+ lastFoldAtMs = Date.now();
222
+ constructor(options, spawnId, outbox, processor, adopted) {
223
+ this.options = options;
224
+ this.spawnId = spawnId;
225
+ this.transport = options.transport;
226
+ this.key = options.sessionKey;
227
+ this.outbox = outbox;
228
+ this.processor = processor;
229
+ this.adopted = adopted;
230
+ // An adoption's rebuilt state: every record it holds had its frames recorded (through replayEnd), or none to record.
231
+ this.recordedThrough = adopted ? processor.state.offset : -1;
232
+ this.routedThrough = this.recordedThrough;
233
+ this.wasOpen = processor.state.open.status === 'open' && processor.state.history === null;
234
+ // A spawned session cuts its first checkpoint at the first safe record (its spawn): the seq floor it was given is then on
235
+ // record, so an adoption never has to guess it (Task 21).
236
+ this.checkpointDue = !adopted;
237
+ this.checkpointKeepsRaw = !adopted;
238
+ this.log = options.log ?? ((line) => console.warn(line));
239
+ this.unlisten = this.transport.onConnectionLost?.((error) => {
240
+ this.transportFailure(`keeper-acp connection lost: ${error.message}`, new KeeperConnectionLostError(error.message));
241
+ }) ?? (() => { });
242
+ }
243
+ /**
244
+ * Spawn the adapter in `sessionKey` and start the record loop. A session an earlier life left under the same key is reused (see
245
+ * the file comment): its adapter, if it still runs, is stopped (Task 21 adopts it instead), and its outbox is read back.
246
+ */
247
+ static async start(options) {
248
+ const { transport, sessionKey, seed } = options;
249
+ let spawned;
250
+ try {
251
+ spawned = await transport.spawn(sessionKey, options.spawn, { label: options.label, ...(seed ? { baseOffset: seed.baseOffset } : {}) });
252
+ }
253
+ catch (error) {
254
+ // keeper-acp unreachable is not a launch failure: the caller falls back to the local transport.
255
+ if (error instanceof AcpKeeperUnavailableError)
256
+ throw error;
257
+ throw spawnFailure(options.spawn.command, error);
258
+ }
259
+ let session;
260
+ try {
261
+ // The down-cause index (an earlier life's downs are in raw) and the outbox an earlier life left: unacknowledged frames are
262
+ // resent when the server reopens the ref, and numbering continues after the highest seq the outbox ever held.
263
+ const attached = await transport.attach(sessionKey);
264
+ let leftovers = await readOutboxTail(transport, sessionKey, attached.outbox);
265
+ if (seed && seed.imports.length > 0) {
266
+ // A switch's successor (Task 24): the predecessor's unacknowledged frames land here before anything this session numbers —
267
+ // the predecessor is released only after this append returned (never the other way round: the frames would be nowhere).
268
+ const at = Math.max(spawned.o - 1, 0);
269
+ const entries = seed.imports.map((entry, k) => ({ seq: entry.seq, src: { o: at, i: DAEMON_FRAME_INDEX + k }, frame: { ...entry.frame } }));
270
+ const { first } = await transport.appendOutbox(sessionKey, entries);
271
+ leftovers = [...leftovers, ...entries.map((entry, k) => ({ o: first + k, t: Date.now(), ...entry }))];
272
+ }
273
+ // A child whose relation the processor has folded (this life's, or a load's replayed history) is not an orphan for the
274
+ // outbox's relation rule (outbox.ts): the processor's relation graph says so.
275
+ let relatedBy;
276
+ const outbox = new SessionOutbox(transportOutboxStore(transport, sessionKey), {
277
+ leftovers, committedTail: attached.committedTail, isRelated: (child) => relatedBy?.(child) ?? false,
278
+ });
279
+ let processor;
280
+ if (seed) {
281
+ // The predecessor's checkpoint (spec §6.3 step 5), its offset moved to just before this log's first record (the spawn).
282
+ processor = restoreProcessor(seed.checkpoint);
283
+ const seeded = processor.state;
284
+ seeded.offset = spawned.o - 1;
285
+ seeded.seq = Math.max(seeded.seq, options.seqBase, outbox.lastSeq);
286
+ }
287
+ else {
288
+ const state = initialState();
289
+ state.seq = Math.max(options.seqBase, outbox.lastSeq);
290
+ processor = createProcessor({ state });
291
+ }
292
+ session = new ProcessorSession({ transport, sessionKey, label: options.label, backfill: options.backfill, command: options.spawn.command,
293
+ ...(options.onClosed ? { onClosed: options.onClosed } : {}), ...(options.onDegrade ? { onDegrade: options.onDegrade } : {}),
294
+ ...(options.log ? { log: options.log } : {}) }, spawned.spawnId, outbox, processor, false);
295
+ const started = session;
296
+ relatedBy = (child) => Object.hasOwn(started.processor.state.permissionParents, child);
297
+ // `agent_launch` is the record right after the spawn, before the loop folds the spawn (and writes `initialize`): one order.
298
+ await started.holdingLoop(async () => {
299
+ started.subscription = await started.subscribe(spawned.o);
300
+ await started.appendNote({ type: 'agent_launch', launch: options.launch });
301
+ if (options.installOutcome) {
302
+ await started.appendNote({ type: 'agent_install_outcome', ...options.installOutcome });
303
+ }
304
+ if (options.outputGap)
305
+ await started.appendNote({ type: 'agent_output_gap', detail: options.outputGap });
306
+ });
307
+ }
308
+ catch (error) {
309
+ if (!session) {
310
+ await transport.terminate(sessionKey).catch(() => null);
311
+ throw error;
312
+ }
313
+ // The adapter is running and nobody will drive it: stop it before reporting the failure (an outbox an earlier life left
314
+ // stays, for the next open).
315
+ await session.close('auto').catch(() => undefined);
316
+ throw error;
317
+ }
318
+ return session;
319
+ }
320
+ static async adopt(options) {
321
+ const { transport, sessionKey } = options;
322
+ const attached = await transport.attach(sessionKey);
323
+ const process = attached.process;
324
+ const running = process.state === 'running' || process.state === 'starting';
325
+ const exited = process.state === 'exited' && options.allowExited === true;
326
+ if (!running && !exited)
327
+ throw new AdoptionRefusedError(`its adapter is ${process.state}`);
328
+ // An exited adapter's session is rebuilt only when it may have a turn to finish (fix round 2): a cheap look first, so an idle
329
+ // one is not replayed on every boot for nothing.
330
+ if (!running && !await mayHaveTurnToFinish(transport, sessionKey, attached))
331
+ return undefined;
332
+ const leftovers = await readOutboxTail(transport, sessionKey, attached.outbox);
333
+ const { processor, report } = await rebuildProcessor(transport, sessionKey, attached, leftovers);
334
+ const state = processor.state;
335
+ let spawnId;
336
+ if (running) {
337
+ if (state.process.status !== 'running' || state.process.spawn.spawnId !== process.spawnId) {
338
+ throw new AdoptionRefusedError('its checkpoint and raw log do not reach the running adapter');
339
+ }
340
+ if (state.open.status !== 'open' || state.history !== null)
341
+ throw new AdoptionRefusedError('its open had not completed');
342
+ spawnId = state.process.spawn.spawnId;
343
+ }
344
+ else {
345
+ // Nothing to finish: no turn was running where the replay stopped.
346
+ if (state.turns.length === 0)
347
+ return undefined;
348
+ const exitedSpawn = process.spawnId;
349
+ const lastSpawn = state.process.status === 'running' ? state.process.spawn.spawnId
350
+ : state.process.status === 'exited' ? state.process.spawnId : undefined;
351
+ if (exitedSpawn === null || lastSpawn !== exitedSpawn) {
352
+ throw new AdoptionRefusedError('its checkpoint and raw log do not reach its last adapter');
353
+ }
354
+ spawnId = exitedSpawn;
355
+ }
356
+ let session;
357
+ const outbox = new SessionOutbox(transportOutboxStore(transport, sessionKey), {
358
+ leftovers, committedTail: attached.committedTail,
359
+ isRelated: (child) => session !== undefined && Object.hasOwn(session.processor.state.permissionParents, child),
360
+ });
361
+ // The spawn spec stayed with the daemon that made it; the command only names the agent in startup errors, which an adopted
362
+ // session (long past `initialize`) never reports.
363
+ const command = String(options.label.agentId ?? 'agent');
364
+ session = new ProcessorSession({ transport, sessionKey, label: options.label, backfill: false, command,
365
+ ...(options.onClosed ? { onClosed: options.onClosed } : {}), ...(options.onDegrade ? { onDegrade: options.onDegrade } : {}),
366
+ ...(options.log ? { log: options.log } : {}) }, spawnId, outbox, processor, true);
367
+ session.recovery = report;
368
+ session.reportRecovery(running ? 'adopted' : 'adopted (its adapter has exited: folding the tail)', report);
369
+ session.subscription = await session.subscribe(state.offset + 1);
370
+ return session;
371
+ }
372
+ reportRecovery(how, report) {
373
+ const divergence = report.counted ? `${report.diverged} of ${report.compared} recorded frame(s) differed`
374
+ : `${report.compared} recorded frame(s) not compared (checkpoint from another processor version, or none)`;
375
+ const line = `[acp] session ${this.key} ${how}: replayed ${report.replayed} record(s) from ${report.from} through ${report.replayEnd}; ${divergence}`;
376
+ if (report.counted && report.diverged > 0)
377
+ this.log(line);
378
+ else
379
+ console.info(line);
380
+ }
381
+ // ---- the record loop ----------------------------------------------------------------------------------------------------
382
+ /** The raw subscription from `from`, its records folded by the loop of the current generation. */
383
+ subscribe(from) {
384
+ const generation = this.generation;
385
+ return this.transport.subscribeRaw(this.key, from, (records) => this.onRecords(records, generation), {
386
+ onClosed: (error) => { if (generation === this.generation)
387
+ this.transportFailure(`raw stream closed: ${error.message}`, error, true); },
388
+ });
389
+ }
390
+ onRecords(records, generation) {
391
+ // Held for a switch (Task 24 fix round 1): whatever the adapter wrote meanwhile would never be folded if the switch went ahead.
392
+ // Only adapter output (`up`) counts (fix round 2): stderr lines (`err`) and the like must not hold a switch back.
393
+ if (this.suspension)
394
+ this.suspendedArrivals += records.filter((record) => record.kind === 'up').length;
395
+ const run = this.chain.then(() => this.fold(records, generation));
396
+ this.chain = run.catch(() => undefined);
397
+ return run;
398
+ }
399
+ /**
400
+ * Mid-record: a re-attach (a new generation) or a degrade abandons the record — a degrade has numbered and routed its own frames
401
+ * already, and this record's would follow them out of seq order (fix round 2). A close or detach lets it finish (fix round 1, I-1).
402
+ */
403
+ abandoned(generation) {
404
+ return generation !== this.generation || this.degraded !== undefined;
405
+ }
406
+ /** The loop of this generation must stop: degraded, closing or detached, or replaced by a re-attach. */
407
+ stopped(generation) {
408
+ return this.degraded !== undefined || this.closing !== undefined || generation !== this.generation;
409
+ }
410
+ async fold(records, generation) {
411
+ for (const record of records) {
412
+ if (this.stopped(generation))
413
+ return;
414
+ // Backpressure (§5.6): no more frames while this life's unacknowledged backlog fills the window.
415
+ const room = this.outbox.room();
416
+ if (room)
417
+ await room;
418
+ if (this.stopped(generation))
419
+ return;
420
+ if (record.kind === 'err')
421
+ this.stderr = `${this.stderr}${record.line}\n`.slice(-STDERR_TAIL_BYTES);
422
+ this.inRecord = true;
423
+ try {
424
+ if (!await this.foldOne(record, generation))
425
+ return;
426
+ }
427
+ finally {
428
+ this.inRecord = false;
429
+ for (const resolve of this.boundaryWaiters.splice(0))
430
+ resolve();
431
+ }
432
+ }
433
+ }
434
+ /** One record: step, its commands, its frames recorded, then the checkpoint cadence. `false`: the loop must stop. */
435
+ async foldOne(record, generation) {
436
+ const turnsBefore = this.processor.state.turns.length;
437
+ const invalidBefore = this.processor.state.diagnostics.invalidEvents;
438
+ // An exit of a running adapter: whether anything was open is read before the fold closes it all.
439
+ const exiting = record.kind === 'proc' && record.proc.type === 'exit' && this.processor.state.process.status === 'running';
440
+ const busyBefore = exiting && (this.liveness.turnRunning || pendingRequestIds(this.processor.state).length > 0);
441
+ let output;
442
+ try {
443
+ output = this.processor.step(record);
444
+ }
445
+ catch (error) {
446
+ this.degrade(error instanceof ProcessorPoisonedError || this.processor.poisoned !== undefined
447
+ ? `processor poisoned at record ${record.o}: ${this.processor.poisoned ?? error.message}`
448
+ : `record ${record.o} could not be folded: ${error instanceof Error ? error.message : String(error)}`);
449
+ return false;
450
+ }
451
+ let report;
452
+ try {
453
+ report = await executeDownCommandsVia(this.transport, this.key, output.commands, (ref) => this.blocks.get(ref), (ref) => this.secrets.get(ref));
454
+ }
455
+ catch (error) {
456
+ if (this.reattachable(error)) {
457
+ // keeper-acp went away mid-record: the re-attach rebuilds from what keeper-acp holds and folds this record again (its
458
+ // commands are idempotent by cause), so nothing of it goes out from here.
459
+ this.transportFailure(`a command could not be written: ${error.message}`, error);
460
+ return false;
461
+ }
462
+ // The record was folded: its frames (a turn that ended on it, numbered) go out before the session degrades, so no turn
463
+ // loses its `turn_done` and no seq is skipped.
464
+ void this.route(output.frames)?.catch(() => undefined);
465
+ this.degrade(`a command could not be written: ${error instanceof Error ? error.message : String(error)}`);
466
+ return false;
467
+ }
468
+ // Mid-record, only a re-attach (a new generation) abandons the record — it is folded again from what keeper-acp holds. A close
469
+ // or detach that arrived meanwhile lets this record finish: its frames are recorded, or they would be lost behind the head
470
+ // checkpoint and their seqs skipped (fix round 1, I-1).
471
+ if (this.abandoned(generation))
472
+ return false;
473
+ for (const command of [...report.written, ...report.dropped, ...report.refused]) {
474
+ if (command.fill)
475
+ this.blocks.delete(command.fill.blocksRef);
476
+ }
477
+ for (const command of report.unresolved) {
478
+ if (command.fill)
479
+ this.blocks.delete(command.fill.blocksRef);
480
+ const requestId = JSON.parse(command.line).id;
481
+ if (typeof requestId === 'number') {
482
+ await this.appendNote({ type: 'agent_prompt_failed', requestId, error: PROMPT_LOST }).catch((error) => {
483
+ this.transportFailure(`a note could not be written: ${error instanceof Error ? error.message : String(error)}`, error);
484
+ });
485
+ }
486
+ }
487
+ const invalid = this.processor.state.diagnostics.invalidEvents - invalidBefore;
488
+ if (invalid > 0) {
489
+ // The contract check runs before numbering (`processor/emit.ts`): nothing was numbered or sent for these. Named here, as
490
+ // the manager's `onAgentEvent` used to name each one, so an adapter's contract violation is visible in the daemon log.
491
+ this.log(`[acp:${String(this.options.label.agentId)} agent:${String(this.options.label.agentKey)}] dropped ${invalid} event(s) ` +
492
+ `for ${this.key} that fail the wire contract (record ${record.o})`);
493
+ }
494
+ if (this.abandoned(generation))
495
+ return false;
496
+ try {
497
+ await this.route(output.frames);
498
+ }
499
+ catch (error) {
500
+ // The outbox refused the frames: they were neither recorded nor sent. The session cannot keep its delivery promise — unless
501
+ // keeper-acp only went away for a while: the re-attach reads back what landed and folds this record again.
502
+ this.transportFailure(`frames could not be recorded: ${error instanceof Error ? error.message : String(error)}`, error);
503
+ return false;
504
+ }
505
+ if (this.abandoned(generation))
506
+ return false;
507
+ this.routedThrough = record.o;
508
+ this.lastFoldAtMs = Date.now();
509
+ if (this.unrecordedFrom === undefined)
510
+ this.recordedThrough = record.o;
511
+ this.settleWaiters();
512
+ this.afterRecord(record, turnsBefore);
513
+ if (exiting && record.kind === 'proc' && record.proc.type === 'exit' && this.processor.state.process.status === 'exited') {
514
+ const exit = record.proc;
515
+ this.adapterExited({ spawnId: this.processor.state.process.spawnId, reason: exit.reason, busy: busyBefore,
516
+ code: exit.code, signal: exit.signal, stderrTail: exit.stderrTail });
517
+ }
518
+ return true;
519
+ }
520
+ /** Tells the manager (once its frames are recorded: a failed `turn_done` precedes whatever the manager says about the exit). */
521
+ adapterExited(exit) {
522
+ if (this.closing || this.degraded !== undefined)
523
+ return;
524
+ if (!this.adapterExitListener) {
525
+ this.undeliveredExit = exit;
526
+ return;
527
+ }
528
+ try {
529
+ this.adapterExitListener(exit);
530
+ }
531
+ catch { /* a listener never stops the loop */ }
532
+ }
533
+ // ---- checkpoints (Task 20) ------------------------------------------------------------------------------------------------
534
+ /** The cadence (spec §5.5): a turn ended, or 8 MiB of raw / 60 s since the last checkpoint. Cut on the first safe record. */
535
+ afterRecord(record, turnsBefore) {
536
+ const since = this.sinceCheckpoint;
537
+ since.bytes += recordBytes(record);
538
+ since.records += 1;
539
+ const state = this.processor.state;
540
+ // The spec's cadence (§5.5): these checkpoints truncate raw to themselves.
541
+ if ((turnsBefore > 0 && state.turns.length === 0) || since.bytes >= CHECKPOINT_RAW_BYTES || Date.now() - since.at >= CHECKPOINT_INTERVAL_MS) {
542
+ this.checkpointDue = true;
543
+ this.checkpointKeepsRaw = false;
544
+ }
545
+ // The open completed (a load's history closed too): a marker checkpoint, so an adoption knows the session is open (Task 21).
546
+ const open = state.open.status === 'open' && state.history === null;
547
+ if (open && !this.wasOpen && !this.checkpointDue) {
548
+ this.checkpointDue = true;
549
+ this.checkpointKeepsRaw = true;
550
+ }
551
+ this.wasOpen = open;
552
+ if (this.checkpointDue && this.checkpointSafe())
553
+ this.cutCheckpoint();
554
+ }
555
+ /**
556
+ * A checkpoint must let a replay continue from it alone: not while a chunked Qoder fact is half-read (`sideChunk`, its first
557
+ * chunks would be truncated away) nor inside a load's history window (history is never journaled: a daemon that dies in the
558
+ * window loads again — interface-contracts, T19).
559
+ */
560
+ checkpointSafe() {
561
+ const state = this.processor.state;
562
+ return state.offset >= 0 && state.offset <= this.recordedThrough && state.boundaries.sideChunk === null && state.history === null &&
563
+ this.processor.poisoned === undefined;
564
+ }
565
+ /** Snapshot now (a record boundary: the loop is between records) and commit it with raw truncated through it (not a marker's). */
566
+ cutCheckpoint(keepRaw = this.checkpointKeepsRaw) {
567
+ const checkpoint = checkpointOf(this.processor.state);
568
+ this.checkpointDue = false;
569
+ this.checkpointKeepsRaw = false;
570
+ this.sinceCheckpoint = { bytes: 0, records: 0, at: Date.now() };
571
+ return this.outbox.commitCheckpoint(checkpoint.offset, encodeCheckpoint(checkpoint), !keepRaw).catch((error) => {
572
+ this.log(`[acp] session ${this.key}: checkpoint at record ${checkpoint.offset} failed: ${error instanceof Error ? error.message : String(error)}`);
573
+ });
574
+ }
575
+ /**
576
+ * A head checkpoint on the loop (graceful exit, detach — spec §5.5): after the record being folded, if anything was folded
577
+ * since the last one and the state is safe to cut. Resolves once it is written (or skipped).
578
+ */
579
+ async checkpoint() {
580
+ // Between records — also while the loop waits for room: everything folded so far has its frames recorded.
581
+ while (this.inRecord)
582
+ await new Promise((resolve) => { this.boundaryWaiters.push(resolve); });
583
+ if (this.sinceCheckpoint.records === 0 && !this.checkpointDue)
584
+ return;
585
+ if (!this.checkpointSafe())
586
+ return;
587
+ await this.cutCheckpoint(false);
588
+ }
589
+ // ---- switching (Task 24, spec §6.3) ---------------------------------------------------------------------------------------
590
+ /**
591
+ * Hold the record loop at a record boundary for a switch: every record delivered so far is folded and its frames are recorded,
592
+ * nothing more is folded until {@link resume} (or a close). Answers the processor's checkpoint there — the successor's initial
593
+ * state (spec §6.3 step 5) — or `undefined` when the session cannot be held (degraded, closing, already held). The adapter is not
594
+ * touched: a switch that does not go ahead resumes it as if nothing happened.
595
+ */
596
+ async suspend() {
597
+ if (this.degraded !== undefined || this.closing || this.suspension)
598
+ return undefined;
599
+ let release;
600
+ const gate = new Promise((resolve) => { release = resolve; });
601
+ let reached;
602
+ const atGate = new Promise((resolve) => { reached = resolve; });
603
+ this.suspension = { release };
604
+ this.suspendedArrivals = 0;
605
+ this.chain = this.chain.then(() => { reached(); return gate; });
606
+ await atGate;
607
+ await this.outbox.flush();
608
+ if (this.degraded !== undefined || this.closing || this.processor.poisoned !== undefined) {
609
+ this.resume();
610
+ return undefined;
611
+ }
612
+ return checkpointOf(this.processor.state);
613
+ }
614
+ /**
615
+ * Records the transport delivered since {@link suspend} held the loop — output of the predecessor's adapter that only this session
616
+ * can fold. A switch must not stop the adapter while any is waiting (fix round 1): it would be dropped with the predecessor's log.
617
+ */
618
+ get arrivedWhileSuspended() { return this.suspension ? this.suspendedArrivals : 0; }
619
+ /**
620
+ * A switch stops the adapter while the loop stays held (fix round 2): keeper-acp records whatever the adapter still writes on its
621
+ * way out into raw, where only this session can fold it. Answers the exit.
622
+ */
623
+ async stopAdapter() {
624
+ await this.transport.terminate(this.key);
625
+ }
626
+ /** Resolves once the loop has folded every record through `offset` (after a {@link resume}). */
627
+ foldedThrough(offset, timeoutMs = 10_000) {
628
+ return this.waitFor(() => (this.processor.state.offset >= offset ? true : undefined), timeoutMs);
629
+ }
630
+ /** The switch did not go ahead: the loop folds on from where {@link suspend} held it. */
631
+ resume() {
632
+ const suspension = this.suspension;
633
+ this.suspension = undefined;
634
+ suspension?.release();
635
+ }
636
+ /** The transport session key (Task 24: a switch moves a ref to a new key). */
637
+ get sessionKey() { return this.key; }
638
+ /** Which transport hosts the session (only a keeper-hosted one can be switched). */
639
+ get transportMode() { return this.transport.mode; }
640
+ /** The spawn label (`{sessionRef, agentId, agentKey, buildId, version, kind, specDigest, keyGen?}`). */
641
+ get label() { return this.options.label; }
642
+ /** Wall-clock time of the last folded record. */
643
+ get lastFoldAt() { return this.lastFoldAtMs; }
644
+ /** Async tasks / background work the adapter still runs for this session (processor state): a switch would kill them. */
645
+ get liveBackgroundWork() {
646
+ const state = this.processor.state;
647
+ return unfinishedAsyncTasks(state).length + (sideDemand(state)?.active() ? 1 : 0);
648
+ }
649
+ /** The adapter's exit, once it has one: the spawn it ended and why (keeper-acp's `reason`, absent for an exit of its own). */
650
+ get adapterExit() {
651
+ const process = this.processor.state.process;
652
+ if (process.status !== 'exited')
653
+ return undefined;
654
+ const reason = process.exit.reason;
655
+ return { spawnId: process.spawnId, reason: typeof reason === 'string' ? reason : undefined };
656
+ }
657
+ /** The session degraded (its adapter is being stopped; the manager retires it). */
658
+ get isDegraded() { return this.degraded !== undefined; }
659
+ route(frames) {
660
+ const out = [];
661
+ for (const output of frames) {
662
+ const frame = output.frame;
663
+ switch (frame.t) {
664
+ case 'processor.open_result':
665
+ this.openResult = frame.ok === true
666
+ ? { ok: true, mode: frame.mode, nativeSessionId: frame.nativeSessionId ?? null,
667
+ configOptions: frame.configOptions ?? [] }
668
+ : { ok: false, mode: frame.mode, error: String(frame.error),
669
+ ...(typeof frame.code === 'number' ? { code: frame.code } : {}) };
670
+ continue;
671
+ case 'processor.history_event':
672
+ if (this.options.backfill) {
673
+ const { t: _t, ...event } = frame;
674
+ this.history.push(event);
675
+ }
676
+ continue;
677
+ case 'processor.history_end': {
678
+ const droppedCount = typeof frame.droppedCount === 'number' ? frame.droppedCount : 0;
679
+ this.historyEnd = { ok: frame.ok === true, droppedCount };
680
+ if (droppedCount > 0) {
681
+ this.log(`[acp] session ${this.key}: ${droppedCount} history update(s) could not be mapped and were left out of the replayed history`);
682
+ }
683
+ continue;
684
+ }
685
+ case 'agent_steered':
686
+ case 'agent_account_read_result':
687
+ case 'agent_config_option_set': {
688
+ const key = typeof frame.id === 'string' ? `${frame.t}:${frame.id}` : undefined;
689
+ const reply = key !== undefined ? this.pendingReplies.get(key) : undefined;
690
+ if (reply) {
691
+ this.pendingReplies.delete(key);
692
+ this.settledReplies.push(key);
693
+ if (this.settledReplies.length > SETTLED_REPLIES_KEPT)
694
+ this.settledReplies.shift();
695
+ reply(frame);
696
+ continue;
697
+ }
698
+ // A reply this object already handed over, produced again by a re-attach's live fold: not a second answer.
699
+ if (key !== undefined && this.settledReplies.includes(key))
700
+ continue;
701
+ out.push(output);
702
+ continue;
703
+ }
704
+ default:
705
+ if (typeof frame.t === 'string' && frame.t.startsWith('processor.'))
706
+ continue;
707
+ out.push(output);
708
+ }
709
+ }
710
+ if (out.length === 0)
711
+ return undefined;
712
+ // Before the manager registers: journaled frames wait for it; levels do not (`agent_opened` carries them from state).
713
+ if (!this.frameListener) {
714
+ const journaled = out.filter((frame) => frame.journaled);
715
+ if (journaled.length > 0)
716
+ this.unrecordedFrom ??= journaled[0].src.o;
717
+ this.buffered.push(...journaled);
718
+ return undefined;
719
+ }
720
+ return this.frameListener(out) ?? undefined;
721
+ }
722
+ settleWaiters() {
723
+ for (const waiter of [...this.waiters]) {
724
+ if (waiter.check())
725
+ this.waiters.delete(waiter);
726
+ }
727
+ }
728
+ /** Resolves once `ready()` returns a value; rejects on `deadline` or when the session degrades/closes. */
729
+ waitFor(ready, timeoutMs, timeoutError) {
730
+ return new Promise((resolve, reject) => {
731
+ let timer;
732
+ const waiter = {
733
+ check: () => {
734
+ const value = ready();
735
+ if (value === undefined)
736
+ return false;
737
+ if (timer)
738
+ clearTimeout(timer);
739
+ resolve(value);
740
+ return true;
741
+ },
742
+ reject: (error) => {
743
+ if (timer)
744
+ clearTimeout(timer);
745
+ this.waiters.delete(waiter);
746
+ reject(error);
747
+ },
748
+ };
749
+ if (waiter.check())
750
+ return;
751
+ if (this.degraded !== undefined) {
752
+ reject(new Error(this.degraded));
753
+ return;
754
+ }
755
+ if (this.closing) {
756
+ reject(new Error('The agent session is closed'));
757
+ return;
758
+ }
759
+ this.waiters.add(waiter);
760
+ if (timeoutMs !== undefined) {
761
+ timer = setTimeout(() => waiter.reject(timeoutError ? timeoutError() : new Error(`timed out after ${timeoutMs}ms`)), timeoutMs);
762
+ }
763
+ });
764
+ }
765
+ async appendNote(note) {
766
+ // The earliest offset this note can land at: everything the processor folded is older.
767
+ const floor = this.processor.state.offset + 1;
768
+ let o;
769
+ try {
770
+ ({ o } = await this.transport.note(this.key, note));
771
+ }
772
+ catch (error) {
773
+ if (!this.reattachable(error))
774
+ throw error;
775
+ // keeper-acp went away: the note may have landed before the reply was lost. Once re-attached, look for it before appending.
776
+ await this.reattach(error);
777
+ if (this.degraded !== undefined || this.closing)
778
+ throw error;
779
+ const landed = await this.findNote(note, floor);
780
+ o = landed ?? (await this.transport.note(this.key, note)).o;
781
+ }
782
+ if (o > this.appended)
783
+ this.appended = o;
784
+ return o;
785
+ }
786
+ /** The offset of a note identical to `note` at or after `from` in raw, if one landed there. */
787
+ async findNote(note, from) {
788
+ const wanted = JSON.stringify(note);
789
+ for (let at = from;;) {
790
+ let page;
791
+ try {
792
+ page = await this.transport.readRaw(this.key, at);
793
+ }
794
+ catch (error) {
795
+ if (error instanceof RawTruncatedError && error.first > at) {
796
+ at = error.first;
797
+ continue;
798
+ }
799
+ throw error;
800
+ }
801
+ if (page.length === 0)
802
+ return undefined;
803
+ for (const record of page)
804
+ if (record.kind === 'note' && JSON.stringify(record.note) === wanted)
805
+ return record.o;
806
+ at = page[page.length - 1].o + 1;
807
+ }
808
+ }
809
+ /** Append a note and resolve once the processor has folded it. */
810
+ async noteFolded(note) {
811
+ const o = await this.appendNote(note);
812
+ await this.waitFor(() => (this.processor.state.offset >= o ? true : undefined));
813
+ }
814
+ /** Fire-and-forget note: a transport failure degrades the session (nothing reached the log). */
815
+ send(note) {
816
+ return this.appendNote(note).then(() => undefined, (error) => {
817
+ this.transportFailure(`a note could not be written: ${error instanceof Error ? error.message : String(error)}`, error);
818
+ throw error;
819
+ });
820
+ }
821
+ // ---- keeper connection loss (Task 21) ------------------------------------------------------------------------------------
822
+ /** A transport failure keeper-acp may recover from: this session re-attaches instead of degrading. */
823
+ reattachable(error) {
824
+ return this.transport.mode === 'keeper' && !this.detached && isConnectionLoss(error);
825
+ }
826
+ /**
827
+ * The transport failed under the session. keeper-acp that only went away (or a stream that ended: `stream` — the re-attach finds
828
+ * out whether the session is still there) → re-attach; the daemon closing its own client → nothing (it is going away); anything
829
+ * else → degrade.
830
+ */
831
+ transportFailure(reason, error, stream = false) {
832
+ if (this.closing || this.degraded !== undefined)
833
+ return;
834
+ if (error instanceof KeeperClosedError)
835
+ return;
836
+ if (this.reattachable(error) || (stream && this.transport.mode === 'keeper' && !this.detached)) {
837
+ void this.reattach(error instanceof Error ? error : new Error(String(error)));
838
+ return;
839
+ }
840
+ this.degrade(reason);
841
+ }
842
+ /**
843
+ * Re-attach after keeper-acp's connection was lost (see the file comment): drop the subscription, attach again, rebuild the
844
+ * processor and the outbox from what keeper-acp holds, subscribe after the rebuilt offset. Bounded: a session keeper-acp no longer
845
+ * has, or one it cannot be reached for after {@link REATTACH_ATTEMPTS}, degrades. Shared by concurrent failures.
846
+ */
847
+ reattach(cause) {
848
+ if (this.reattaching)
849
+ return this.reattaching;
850
+ if (this.closing || this.degraded !== undefined)
851
+ return Promise.resolve();
852
+ const now = Date.now();
853
+ while (this.reattaches.length > 0 && now - this.reattaches[0] > REATTACH_WINDOW_MS)
854
+ this.reattaches.shift();
855
+ if (this.reattaches.length >= REATTACH_LIMIT) {
856
+ this.degrade(`keeper-acp connection keeps failing (${REATTACH_LIMIT} re-attaches within ${REATTACH_WINDOW_MS / 1000} s): ${cause.message}`);
857
+ return Promise.resolve();
858
+ }
859
+ if (this.processor.state.history !== null) {
860
+ // Inside a load's history window: what was folded of it went to this object's backfill, and a rebuild would fold part of it
861
+ // again (or skip what the replay discards). The load is redone instead — the open fails, the next one spawns and loads anew
862
+ // (interface-contracts: a daemon that dies inside the window loads again).
863
+ this.degrade(`keeper-acp connection lost during a load's history: ${cause.message}`);
864
+ return Promise.resolve();
865
+ }
866
+ this.reattaches.push(now);
867
+ this.generation += 1;
868
+ this.log(`[acp] session ${this.key}: keeper-acp connection lost (${cause.message}); re-attaching`);
869
+ this.reattaching = (async () => {
870
+ const old = this.subscription;
871
+ this.subscription = undefined;
872
+ void old?.unsubscribe().catch(() => undefined);
873
+ // No stream operation may land after the outbox is read back.
874
+ await this.outbox.flush();
875
+ let last = cause;
876
+ for (let attempt = 0; attempt < REATTACH_ATTEMPTS; attempt += 1) {
877
+ if (this.closing || this.degraded !== undefined)
878
+ return;
879
+ if (attempt > 0)
880
+ await delay(Math.min(REATTACH_BACKOFF_MS * 2 ** (attempt - 1), REATTACH_BACKOFF_MAX_MS));
881
+ try {
882
+ const attached = await this.transport.attach(this.key);
883
+ const leftovers = await readOutboxTail(this.transport, this.key, attached.outbox);
884
+ // No seq floor of its own: a frame this session numbered but never recorded was never sent either, and folding its record
885
+ // again numbers it the same.
886
+ const { processor, report } = await rebuildProcessor(this.transport, this.key, attached, leftovers);
887
+ if (this.closing || this.degraded !== undefined)
888
+ return;
889
+ this.processor = processor;
890
+ this.recordedThrough = this.unrecordedFrom === undefined ? processor.state.offset : Math.min(this.recordedThrough, processor.state.offset);
891
+ this.routedThrough = processor.state.offset;
892
+ this.outbox.rebase({ leftovers, committedTail: attached.committedTail });
893
+ this.recovery = report;
894
+ this.reportRecovery('re-attached', report);
895
+ this.subscription = await this.subscribe(processor.state.offset + 1);
896
+ this.settleWaiters();
897
+ return;
898
+ }
899
+ catch (error) {
900
+ last = error;
901
+ // The session is gone (keeper-acp no longer has it), or what it holds cannot be continued: no retry helps.
902
+ if (error instanceof AdoptionRefusedError || (error instanceof KeeperRequestError && error.code === AcpErrorCode.notFound))
903
+ break;
904
+ if (error instanceof KeeperClosedError)
905
+ return;
906
+ }
907
+ }
908
+ this.degrade(`keeper-acp lost this session: ${last instanceof Error ? last.message : String(last)}`);
909
+ })().finally(() => { this.reattaching = undefined; });
910
+ return this.reattaching;
911
+ }
912
+ // ---- degradation --------------------------------------------------------------------------------------------------------
913
+ degrade(reason) {
914
+ if (this.degraded !== undefined || this.closing)
915
+ return;
916
+ this.degraded = reason;
917
+ this.unlisten();
918
+ this.log(`[acp] session ${this.key} degraded: ${reason}`);
919
+ // The poison contract: open turns end failed, numbered after the processor's last seq by this daemon.
920
+ const state = this.processor.state;
921
+ let seq = state.seq;
922
+ // The processor's open turns, and the admitted prompts whose note it never folded (they would otherwise never end).
923
+ const unfolded = [...this.promptsInFlight].filter((pending) => pending.o === undefined || pending.o > state.offset);
924
+ this.promptsInFlight.clear();
925
+ const frames = [...state.turns, ...unfolded].map((turn, index) => ({
926
+ journaled: true,
927
+ src: { o: Math.max(state.offset, 0), i: DAEMON_FRAME_INDEX + index },
928
+ frame: { t: 'agent_turn_done', seq: ++seq, status: 'failed', ...(turn.platformTurnId !== undefined ? { platformTurnId: turn.platformTurnId } : {}),
929
+ nativeSessionId: state.rootSessionId, error: SESSION_DEGRADED },
930
+ }));
931
+ this.daemonSeq = seq;
932
+ // These frames usually fail to record too — a degrade is mostly the transport failing (or a failed append itself). Then
933
+ // nothing carries their turns' end: the manager's `agent_session_lost{sweep}` (`notifySwept`) does — the server ends the
934
+ // turn when the session is lost.
935
+ // The loop may be waiting for room: it must see the degradation and stop, not wait for an ack.
936
+ this.outbox.stopWaiting();
937
+ if (frames.length > 0) {
938
+ if (this.frameListener)
939
+ void Promise.resolve(this.frameListener(frames)).catch(() => undefined);
940
+ else
941
+ this.buffered.push(...frames);
942
+ }
943
+ for (const waiter of [...this.waiters])
944
+ waiter.reject(new Error(reason));
945
+ for (const [, reply] of this.pendingReplies)
946
+ reply({ ok: false, error: reason });
947
+ this.pendingReplies.clear();
948
+ this.stopSide();
949
+ void this.transport.terminate(this.key).catch(() => null);
950
+ try {
951
+ this.options.onDegrade?.(this.headSeq);
952
+ }
953
+ catch { /* a bookkeeping hook never stops the degrade */ }
954
+ this.degradedListener?.(reason);
955
+ }
956
+ // ---- open ---------------------------------------------------------------------------------------------------------------
957
+ /** The `initialize` reading, or the failure today's `connectAgent` reports for it. */
958
+ async initialized(timeoutMs) {
959
+ const command = this.options.command;
960
+ const result = await this.waitFor(() => {
961
+ const state = this.processor.state;
962
+ if (state.initialize.status === 'ready' || state.initialize.status === 'failed')
963
+ return state.initialize;
964
+ return undefined;
965
+ }, timeoutMs, () => {
966
+ const detail = this.stderr.trim();
967
+ return new AgentInitializeTimeoutError(`Agent '${command}' did not respond to initialize within ${timeoutMs}ms.` +
968
+ (detail ? ` Last stderr: ${lastLines(detail, 6)}` : ''), detail);
969
+ });
970
+ if (result.status === 'ready') {
971
+ if (result.facts.droppedAuthMethods > 0) {
972
+ this.log(`[acp] agent '${result.facts.agentInfo?.name ?? command}' advertised ${result.facts.droppedAuthMethods} auth method(s) ` +
973
+ 'without the required id/name; ignoring them');
974
+ }
975
+ return result.facts;
976
+ }
977
+ const detail = this.stderr.trim();
978
+ switch (result.reason) {
979
+ case 'unsupported_protocol':
980
+ throw new UnsupportedProtocolVersionError(`Agent '${command}' negotiated protocol version ${String(result.protocolVersion)}, ` +
981
+ `but this client only supports version ${PROTOCOL_VERSION}.`);
982
+ case 'rpc_error':
983
+ throw new RequestError(result.code ?? -32603, result.detail ?? 'initialize failed');
984
+ case 'malformed':
985
+ throw new Error(`Agent '${command}' answered initialize with a malformed result${result.detail ? `: ${result.detail}` : ''}`);
986
+ case 'closed': {
987
+ const exit = this.processor.state.process.status === 'exited' ? this.processor.state.process.exit : undefined;
988
+ const tail = exit?.stderrTail?.trim() || detail;
989
+ const summary = `Agent process for '${command}' exited before responding to initialize ` +
990
+ `(code=${exit?.code ?? 'null'}, signal=${exit?.signal ?? 'null'}).`;
991
+ throw new AgentSpawnError(tail ? `${summary} stderr: ${lastLines(tail, 6)}` : summary, tail);
992
+ }
993
+ }
994
+ }
995
+ /** Register a secret for `secretRef` substitution in this session's down lines; returns its ref. */
996
+ secret(value) {
997
+ this.refs += 1;
998
+ const ref = `axom-secret-ref:${this.spawnId}:${this.refs}`;
999
+ this.secrets.set(ref, value);
1000
+ return ref;
1001
+ }
1002
+ /**
1003
+ * `session/new|load|resume` through an `agent_session_open` note; resolves with the processor's open result. A load's history
1004
+ * window stays open until {@link endHistory}.
1005
+ */
1006
+ async openSession(request) {
1007
+ this.openResult = undefined;
1008
+ const note = {
1009
+ type: 'agent_session_open', mode: request.mode, cwd: request.cwd,
1010
+ ...(request.sessionId !== undefined ? { sessionId: request.sessionId } : {}),
1011
+ mcpServers: request.mcpServers,
1012
+ ...(request.redact && request.redact.length > 0 ? { redact: [...request.redact] } : {}),
1013
+ ...(request.delegation ? { delegation: request.delegation } : {}),
1014
+ };
1015
+ const repair = request.mode === 'load' ? request.repair : undefined;
1016
+ if (repair) {
1017
+ // The repair note must follow the open note before the loop folds the open note (and so writes the `session/load` line
1018
+ // the history answers): the loop is held while both are appended.
1019
+ await this.holdingLoop(async () => {
1020
+ await this.send(note);
1021
+ await this.enableRepair(request.sessionId, request.cwd, repair.historyHome);
1022
+ });
1023
+ }
1024
+ else {
1025
+ await this.send(note);
1026
+ }
1027
+ return this.waitFor(() => this.openResult);
1028
+ }
1029
+ /** Runs `work` (appends only — never a wait on the loop) with the record loop held, so no record is folded meanwhile. */
1030
+ async holdingLoop(work) {
1031
+ let release;
1032
+ const gate = new Promise((resolve) => { release = resolve; });
1033
+ this.chain = this.chain.then(() => gate);
1034
+ try {
1035
+ return await work();
1036
+ }
1037
+ finally {
1038
+ release();
1039
+ }
1040
+ }
1041
+ /** A load's history is over (the daemon's decision, analysis B A4): write `historyEnd` and wait for the window to close. */
1042
+ async endHistory() {
1043
+ if (!this.historyEnd)
1044
+ await this.send({ type: 'historyEnd' });
1045
+ return this.waitFor(() => this.historyEnd);
1046
+ }
1047
+ /**
1048
+ * Turn the Qoder background terminal repair on (17c): the `qoder_terminal_repair` note, then the side-file reader. Nothing for a
1049
+ * runtime the repair does not certify (no history path).
1050
+ */
1051
+ async enableRepair(nativeSessionId, cwd, historyHome, restoreCalls = false) {
1052
+ const path = nativeTerminalHistoryPath(this.agentInfo, nativeSessionId, cwd, historyHome);
1053
+ if (!path || this.side?.nativeSessionId === nativeSessionId)
1054
+ return;
1055
+ // A load that failed and fell back to `session/new`: the processor closed the load's repair (`settleOpen`); its reader goes
1056
+ // too, and the new session gets its own (the old path closed the attached session and opened a fresh one).
1057
+ this.stopSide();
1058
+ const from = await this.appendNote({ type: REPAIR_NOTE, nativeSessionId, ...(restoreCalls ? { restoreCalls: true } : {}) });
1059
+ const reader = new BackgroundSideNotes({ path, nativeSessionId, cwd, restoreCalls });
1060
+ const timer = setInterval(() => { void this.pollSide(); }, SIDE_POLL_MS);
1061
+ timer.unref();
1062
+ this.side = { reader, timer, from, polling: false, nativeSessionId };
1063
+ }
1064
+ async pollSide() {
1065
+ const side = this.side;
1066
+ if (!side || side.polling || this.degraded !== undefined || this.closing)
1067
+ return;
1068
+ // The demand is the processor's *current* state, so only once it has folded everything this object appended.
1069
+ if (this.processor.state.offset < Math.max(side.from, this.appended))
1070
+ return;
1071
+ const demand = sideDemand(this.processor.state);
1072
+ if (!demand) {
1073
+ this.stopSide();
1074
+ return;
1075
+ }
1076
+ side.polling = true;
1077
+ try {
1078
+ const notes = await side.reader.poll(demand);
1079
+ for (const note of notes)
1080
+ await this.appendNote(note);
1081
+ }
1082
+ catch (error) {
1083
+ this.log(`[acp] session ${this.key}: background terminal read failed: ${error instanceof Error ? error.message : String(error)}`);
1084
+ }
1085
+ finally {
1086
+ side.polling = false;
1087
+ }
1088
+ }
1089
+ stopSide() {
1090
+ if (!this.side)
1091
+ return;
1092
+ clearInterval(this.side.timer);
1093
+ this.side.reader.close();
1094
+ this.side = undefined;
1095
+ }
1096
+ // ---- AcpAgentSession ----------------------------------------------------------------------------------------------------
1097
+ get facts() {
1098
+ const initialize = this.processor.state.initialize;
1099
+ return initialize.status === 'ready' ? initialize.facts : undefined;
1100
+ }
1101
+ /** The processor's `initialize` reading, once {@link initialized} resolved. */
1102
+ get initializeFacts() { return this.facts; }
1103
+ get capabilities() { return this.facts?.capabilities; }
1104
+ get agentInfo() { return this.facts?.agentInfo; }
1105
+ get supportsSteering() { return this.facts?.supportsSteering ?? false; }
1106
+ get steeringTimings() { return this.facts?.steeringTimings ?? []; }
1107
+ get supportsImageInput() { return this.facts?.capabilities.promptImage ?? false; }
1108
+ get configOptions() { return this.processor.state.configOptions ?? []; }
1109
+ get runtimeDiagnostics() { return runtimeDiagnostics(this.processor.state); }
1110
+ get activityRaw() { return activityRaw(this.processor.state); }
1111
+ get delegation() { return this.processor.state.delegation ?? null; }
1112
+ get nativeSessionId() {
1113
+ const root = this.processor.state.rootSessionId;
1114
+ return root !== null && root.length > 0 ? root : null;
1115
+ }
1116
+ /** Hosted by keeper-acp: the session outlives this daemon (`agent_opened.durable`, spec §5.5.4). */
1117
+ get durable() { return this.transport.mode === 'keeper'; }
1118
+ /** Every unanswered permission/elicitation card (`agent_opened.pendingRequestIds`, spec §5.5.4). */
1119
+ get pendingRequestIds() { return pendingRequestIds(this.processor.state); }
1120
+ /** Steers injected or written and not yet answered (`agent_opened.steeredIds`, spec §5.5.4). */
1121
+ get steeredIds() { return steeredIds(this.processor.state); }
1122
+ get liveness() {
1123
+ const state = this.processor.state;
1124
+ for (const pending of [...this.promptsInFlight]) {
1125
+ if (pending.o !== undefined && pending.o <= state.offset)
1126
+ this.promptsInFlight.delete(pending);
1127
+ }
1128
+ return {
1129
+ agentAlive: this.degraded === undefined && !this.closing && state.process.status === 'running' && state.closed === undefined,
1130
+ // An admitted prompt is a turn from the moment the manager hands it over, as it was when `AcpSession.prompt` pushed its
1131
+ // turn synchronously: its note is counted until the processor has folded it (then `state.turns` has it, or it ended).
1132
+ turnRunning: state.turns.length > 0 || this.promptsInFlight.size > 0,
1133
+ };
1134
+ }
1135
+ /** Admitted prompts whose note the processor has not folded yet (`o` once appended). */
1136
+ promptsInFlight = new Set();
1137
+ holdBlocks(blocks) {
1138
+ this.refs += 1;
1139
+ const ref = `blocks-${this.refs}`;
1140
+ this.blocks.set(ref, blocks);
1141
+ return ref;
1142
+ }
1143
+ prompt(blocks, platformTurnId) {
1144
+ const blocksRef = this.holdBlocks(blocks);
1145
+ const pending = platformTurnId !== undefined ? { platformTurnId } : {};
1146
+ this.promptsInFlight.add(pending);
1147
+ this.appendNote({ type: 'agent_prompt', blocksRef, ...(platformTurnId !== undefined ? { platformTurnId } : {}) }).then((o) => { pending.o = o; }, (error) => {
1148
+ this.promptsInFlight.delete(pending);
1149
+ this.transportFailure(`a note could not be written: ${error instanceof Error ? error.message : String(error)}`, error);
1150
+ });
1151
+ }
1152
+ refusePrompt(platformTurnId) {
1153
+ // No blocks are held: the processor ends the turn on the note itself.
1154
+ void this.send({ type: 'agent_prompt', blocksRef: 'refused', admitted: false, ...(platformTurnId !== undefined ? { platformTurnId } : {}) })
1155
+ .catch(() => undefined);
1156
+ }
1157
+ answer(requestId, outcome) {
1158
+ void this.send({ type: 'agent_answer', requestId, outcome }).catch(() => undefined);
1159
+ }
1160
+ answerElicitation(requestId, response) {
1161
+ const content = 'content' in response && isRecord(response.content) ? response.content : undefined;
1162
+ void this.send({ type: 'agent_elicitation_answer', requestId, action: response.action, ...(content ? { content } : {}) }).catch(() => undefined);
1163
+ }
1164
+ async interrupt() {
1165
+ await this.send({ type: 'agent_interrupt' });
1166
+ }
1167
+ /**
1168
+ * The processor's reply to the request this object is about to note, by the server's frame id. The callers refuse a second
1169
+ * request under an id still in flight: the processor would drop its note (one steer per id) and nothing would answer it.
1170
+ */
1171
+ reply(kind, id) {
1172
+ const key = `${kind}:${id}`;
1173
+ if (this.degraded !== undefined || this.closing)
1174
+ return Promise.resolve({ ok: false, error: this.degraded ?? 'The agent session is closed' });
1175
+ return new Promise((resolve) => this.pendingReplies.set(key, resolve));
1176
+ }
1177
+ async steer(blocks, priority, id) {
1178
+ if (this.pendingReplies.has(`agent_steered:${id}`))
1179
+ throw new Error(`A steer with id '${id}' is already in flight`);
1180
+ const answered = this.reply('agent_steered', id);
1181
+ await this.send({ type: 'agent_steer', id, blocksRef: this.holdBlocks(blocks), ...(priority !== undefined ? { priority } : {}) })
1182
+ .catch((error) => { this.pendingReplies.delete(`agent_steered:${id}`); throw error; });
1183
+ const frame = await answered;
1184
+ if (frame.ok !== true)
1185
+ throw new Error(String(frame.error));
1186
+ return frame.outcome;
1187
+ }
1188
+ async setConfigOption(configId, value, id) {
1189
+ if (this.pendingReplies.has(`agent_config_option_set:${id}`))
1190
+ throw new Error(`A config change with id '${id}' is already in flight`);
1191
+ const answered = this.reply('agent_config_option_set', id);
1192
+ await this.send({ type: 'agent_set_config_option', id, configId, value })
1193
+ .catch((error) => { this.pendingReplies.delete(`agent_config_option_set:${id}`); throw error; });
1194
+ const frame = await answered;
1195
+ if (frame.ok !== true)
1196
+ throw new Error(String(frame.error));
1197
+ return frame.configOptions;
1198
+ }
1199
+ get accountLimitsVersion() { const init = this.processor.state.initialize; return init.status === 'ready' ? init.facts.accountLimitsVersion ?? 1 : 0; }
1200
+ get supportsAccountLimits() { const init = this.processor.state.initialize; return init.status === 'ready' && init.facts.supportsAccountLimits === true && this.accountLimitsVersion >= 2; }
1201
+ get accountLimits() { return this.processor.state.accountLimits; }
1202
+ async readAccountLimits(id) {
1203
+ const answered = this.reply('agent_account_read_result', id);
1204
+ await this.send({ type: 'agent_account_read', id }).catch(error => { this.pendingReplies.delete(`agent_account_read_result:${id}`); throw error; });
1205
+ let timer;
1206
+ let frame;
1207
+ try {
1208
+ frame = await Promise.race([answered, new Promise((_, reject) => { timer = setTimeout(() => reject(new Error('Failed to fetch usage')), ACCOUNT_LIMITS_READ_TIMEOUT_MS); })]);
1209
+ }
1210
+ finally {
1211
+ clearTimeout(timer);
1212
+ this.pendingReplies.delete(`agent_account_read_result:${id}`);
1213
+ }
1214
+ if (frame.ok !== true)
1215
+ throw new Error(String(frame.error));
1216
+ return frame.snapshot;
1217
+ }
1218
+ async stopAsyncTask(asyncTaskId) {
1219
+ await this.send({ type: 'agent_async_task_stop', asyncTaskId });
1220
+ }
1221
+ async note(note) {
1222
+ await this.noteFolded(note);
1223
+ }
1224
+ /**
1225
+ * The manager's frame pipeline. What it returns for a batch (the outbox append) is awaited by the record loop before the next
1226
+ * record; frames buffered before registration are handed over now (their append runs ahead of anything the loop produces next,
1227
+ * on the manager's per-session queue).
1228
+ */
1229
+ onFrames(cb) {
1230
+ this.frameListener = cb;
1231
+ const pending = this.buffered;
1232
+ this.buffered = [];
1233
+ if (pending.length > 0) {
1234
+ void Promise.resolve(cb(pending)).then(() => {
1235
+ // The buffered frames landed; every record routed since landed before them or after (one outbox chain).
1236
+ this.unrecordedFrom = undefined;
1237
+ this.recordedThrough = this.routedThrough;
1238
+ }, (error) => {
1239
+ this.transportFailure(`frames could not be recorded: ${error instanceof Error ? error.message : String(error)}`, error);
1240
+ });
1241
+ }
1242
+ else if (this.unrecordedFrom !== undefined) {
1243
+ this.unrecordedFrom = undefined;
1244
+ this.recordedThrough = this.routedThrough;
1245
+ }
1246
+ }
1247
+ /** The processor's head seq (spec §5.5.2 `journalMaxSeq`): every seq this session numbered, or its outbox held. */
1248
+ get headSeq() {
1249
+ return Math.max(this.processor.state.seq, this.daemonSeq, this.outbox.lastSeq);
1250
+ }
1251
+ /** The adapter exited under the live loop ({@link AdapterExitNotice}); one folded before this call is delivered at once. */
1252
+ onAdapterExited(cb) {
1253
+ this.adapterExitListener = cb;
1254
+ const pending = this.undeliveredExit;
1255
+ this.undeliveredExit = undefined;
1256
+ if (pending && !this.closing && this.degraded === undefined)
1257
+ cb(pending);
1258
+ }
1259
+ onDegraded(cb) {
1260
+ this.degradedListener = cb;
1261
+ if (this.degraded !== undefined)
1262
+ cb(this.degraded);
1263
+ }
1264
+ /** The load's history (only when asked for, and only once the window closed with it). */
1265
+ get backfill() {
1266
+ if (!this.options.backfill || this.history.length === 0)
1267
+ return undefined;
1268
+ const history = this.history;
1269
+ return async function* backfill() { for (const event of history)
1270
+ yield event; };
1271
+ }
1272
+ /**
1273
+ * Leave the session to the next daemon (spec §5.5.5, Task 21): the loop stops after the record in hand, a head checkpoint is cut
1274
+ * when the state is safe to cut (otherwise the next daemon replays from the last one), the subscription is dropped. The adapter
1275
+ * keeps running; nothing is written to it (no `session/cancel`, no answer to a pending card) and no turn ends — a detaching session
1276
+ * never degrades. Pending steer/config replies stay unanswered (`agent_opened.steeredIds` lets the server decide). Only a
1277
+ * keeper-hosted session can be detached (a local adapter dies with the daemon): for one, this is `close('keep')`.
1278
+ */
1279
+ detach() {
1280
+ if (this.transport.mode !== 'keeper')
1281
+ return this.close('keep');
1282
+ this.closing ??= (async () => {
1283
+ this.resume();
1284
+ this.detached = true;
1285
+ this.unlisten();
1286
+ this.stopSide();
1287
+ for (const waiter of [...this.waiters])
1288
+ waiter.reject(new Error('The agent session was handed to the next daemon'));
1289
+ // A loop waiting for room stops (it sees `closing`); the head checkpoint is cut at the record boundary after it.
1290
+ this.outbox.stopWaiting();
1291
+ await this.checkpoint().catch(() => undefined);
1292
+ await this.subscription?.unsubscribe().catch(() => undefined);
1293
+ await this.outbox.flush();
1294
+ this.blocks.clear();
1295
+ this.secrets.clear();
1296
+ })();
1297
+ return this.closing;
1298
+ }
1299
+ /**
1300
+ * Ends the session and its adapter: the subscription stops and the adapter is terminated. `release` (an `agent_close`) also drops
1301
+ * the transport session — raw, outbox and checkpoints. `keep` (a sweep, a degraded session, a dropped open) keeps them, after a
1302
+ * head checkpoint: the unacknowledged frames are resent when the ref is reopened, and its numbering continues there. `auto` (an
1303
+ * open that failed): keep while the outbox still holds unacknowledged frames (an earlier life's), release otherwise.
1304
+ */
1305
+ close(requested = 'release') {
1306
+ const mode = requested === 'auto' ? (this.outbox.pending.length > 0 ? 'keep' : 'release') : requested;
1307
+ this.closing ??= (async () => {
1308
+ this.resume(); // a held loop drains (every fold sees `closing`)
1309
+ this.unlisten();
1310
+ this.stopSide();
1311
+ for (const waiter of [...this.waiters])
1312
+ waiter.reject(new Error('The agent session is closed'));
1313
+ for (const [, reply] of this.pendingReplies)
1314
+ reply({ ok: false, error: 'The agent session is closed' });
1315
+ this.pendingReplies.clear();
1316
+ // A loop waiting for room stops; the chain then drains (every fold sees `closing`) and the head checkpoint runs after it.
1317
+ this.outbox.stopWaiting();
1318
+ if (mode === 'keep' && this.degraded === undefined)
1319
+ await this.checkpoint().catch(() => undefined);
1320
+ await this.subscription?.unsubscribe().catch(() => undefined);
1321
+ await this.transport.terminate(this.key).catch(() => null);
1322
+ await this.outbox.flush();
1323
+ if (mode === 'release')
1324
+ await this.transport.release(this.key).catch(() => undefined);
1325
+ this.blocks.clear();
1326
+ this.secrets.clear();
1327
+ await this.options.onClosed?.();
1328
+ })();
1329
+ return this.closing;
1330
+ }
1331
+ }
1332
+ /** A spawn the transport could not make, in the words `connectAgent` used for the same failure. */
1333
+ function spawnFailure(command, error) {
1334
+ const message = error instanceof Error ? error.message : String(error);
1335
+ if (/\bENOENT\b/.test(message)) {
1336
+ return new AgentSpawnError(`Could not launch '${command}': no such command on PATH. ` +
1337
+ 'If this is npx or uvx, make sure it is installed and reachable from this process.');
1338
+ }
1339
+ return new AgentSpawnError(`Failed to launch '${command}': ${message}`);
1340
+ }
1341
+ /** Unacknowledged outbox records an earlier life left (past the stream's watermark), read in pages. */
1342
+ export async function readOutboxTail(transport, sessionKey, state) {
1343
+ if (!state)
1344
+ return [];
1345
+ const out = [];
1346
+ for (let from = Math.max(state.first, state.committed + 1); from < state.next;) {
1347
+ const page = await transport.readOutbox(sessionKey, from);
1348
+ if (page.length === 0)
1349
+ break;
1350
+ out.push(...page);
1351
+ from = page[page.length - 1].o + 1;
1352
+ }
1353
+ return out;
1354
+ }
1355
+ /**
1356
+ * Rebuild a session's processor from what keeper-acp holds (spec §5.5.1; Task 21): the checkpoint restored — or, without a readable
1357
+ * one, a fresh processor from this life's spawn record (the last `proc spawn` raw still holds) — then raw replayed through
1358
+ * `replayEnd = max(outbox last.src.o, committedTail.src.o)` **updating state only**: the frames it produces are compared with the
1359
+ * outbox entries that survive (daemon-numbered ones skipped) and dropped; no command is executed (every record up to `replayEnd`
1360
+ * had its commands written before its frames were recorded). The seq then continues after the highest of the replayed seq, the
1361
+ * outbox's and the watermark's (and `seqFloor`). Throws {@link AdoptionRefusedError} when that is not
1362
+ * possible (no checkpoint and no spawn record left, a gap in raw, a record the processor cannot fold).
1363
+ */
1364
+ /**
1365
+ * Whether an exited adapter's session may still have a turn running (Task 23 fix round 2), without replaying anything: its head
1366
+ * checkpoint (cut at every turn end) says no turn is running, and no prompt was admitted after it (no `agent_prompt` note, no
1367
+ * `session/prompt` down in the raw records the checkpoint does not cover — for an idle session only its exit and a few notes, since
1368
+ * raw is truncated to the checkpoint). A checkpoint this processor cannot read, or none, answers `true`: the rebuild decides.
1369
+ */
1370
+ export async function mayHaveTurnToFinish(transport, sessionKey, attached) {
1371
+ if (!attached.checkpoint)
1372
+ return true;
1373
+ let checkpoint;
1374
+ try {
1375
+ checkpoint = decodeCheckpoint(Buffer.from(attached.checkpoint.blob, 'base64').toString('utf8'));
1376
+ }
1377
+ catch {
1378
+ return true;
1379
+ }
1380
+ if (checkpoint.processorVersion !== PROCESSOR_VERSION || !Array.isArray(checkpoint.state.turns) || checkpoint.state.turns.length > 0) {
1381
+ return true;
1382
+ }
1383
+ for (let at = checkpoint.offset + 1;;) {
1384
+ let page;
1385
+ try {
1386
+ page = await transport.readRaw(sessionKey, at);
1387
+ }
1388
+ catch {
1389
+ return true;
1390
+ }
1391
+ if (page.length === 0)
1392
+ return false;
1393
+ for (const record of page) {
1394
+ if (record.kind === 'note' && record.note.type === 'agent_prompt')
1395
+ return true;
1396
+ if (record.kind === 'down' && record.line.includes('"session/prompt"'))
1397
+ return true;
1398
+ }
1399
+ at = page[page.length - 1].o + 1;
1400
+ }
1401
+ }
1402
+ export async function rebuildProcessor(transport, sessionKey, attached, leftovers, seqFloor = 0) {
1403
+ let checkpoint;
1404
+ let unreadable;
1405
+ if (attached.checkpoint) {
1406
+ try {
1407
+ checkpoint = decodeCheckpoint(Buffer.from(attached.checkpoint.blob, 'base64').toString('utf8'));
1408
+ }
1409
+ catch (error) {
1410
+ unreadable = error instanceof Error ? error.message : String(error);
1411
+ }
1412
+ }
1413
+ const tail = isOutboxTail(attached.committedTail) ? attached.committedTail : undefined;
1414
+ const replayEnd = Math.max(leftovers.length > 0 ? leftovers[leftovers.length - 1].src.o : -1, tail?.src.o ?? -1);
1415
+ let processor;
1416
+ let from;
1417
+ if (checkpoint) {
1418
+ processor = restoreProcessor(checkpoint);
1419
+ from = checkpoint.offset + 1;
1420
+ }
1421
+ else {
1422
+ const spawn = await lastSpawnRecord(transport, sessionKey, attached);
1423
+ if (spawn === undefined) {
1424
+ throw new AdoptionRefusedError(`${unreadable ? `its checkpoint is unreadable (${unreadable})` : 'it has no checkpoint'} and raw no longer holds its spawn`);
1425
+ }
1426
+ processor = createProcessor();
1427
+ from = spawn;
1428
+ }
1429
+ const produced = new Map();
1430
+ let replayed = 0;
1431
+ for (let at = from; at <= replayEnd;) {
1432
+ let page;
1433
+ try {
1434
+ page = await transport.readRaw(sessionKey, at);
1435
+ }
1436
+ catch (error) {
1437
+ if (error instanceof RawTruncatedError)
1438
+ throw new AdoptionRefusedError(`raw no longer holds records ${at}..${error.first - 1}`);
1439
+ throw error;
1440
+ }
1441
+ if (page.length === 0)
1442
+ throw new AdoptionRefusedError(`raw ends before record ${at} (the outbox reaches ${replayEnd})`);
1443
+ for (const record of page) {
1444
+ if (record.o > replayEnd)
1445
+ break;
1446
+ let output;
1447
+ try {
1448
+ output = processor.step(record);
1449
+ }
1450
+ catch (error) {
1451
+ throw new AdoptionRefusedError(`record ${record.o} could not be replayed: ${error instanceof Error ? error.message : String(error)}`);
1452
+ }
1453
+ replayed += 1;
1454
+ for (const frame of output.frames)
1455
+ if (frame.journaled)
1456
+ produced.set(`${frame.src.o}:${frame.src.i}`, JSON.stringify(frame.frame));
1457
+ at = record.o + 1;
1458
+ }
1459
+ }
1460
+ let compared = 0;
1461
+ let diverged = 0;
1462
+ for (const entry of leftovers) {
1463
+ if (entry.src.i >= DAEMON_FRAME_INDEX || entry.src.o < from || entry.src.o > replayEnd)
1464
+ continue;
1465
+ compared += 1;
1466
+ if (produced.get(`${entry.src.o}:${entry.src.i}`) !== JSON.stringify(entry.frame))
1467
+ diverged += 1;
1468
+ }
1469
+ // nextSeq = max(replayed seq, outbox.maxSeq, committedTail.seq, the floor) + 1 (§5.5): never a seq the server may have applied, or
1470
+ // one an earlier life numbered (a frame the projection withheld from an old server is numbered but never recorded). Set here, as
1471
+ // the start seeds it (the processor's own steps never lower it).
1472
+ const state = processor.state;
1473
+ state.seq = Math.max(state.seq, seqFloor, tail?.seq ?? 0, ...leftovers.map((entry) => entry.seq));
1474
+ return {
1475
+ processor,
1476
+ report: { checkpoint: checkpoint?.offset ?? null, from, replayEnd, replayed, compared, diverged,
1477
+ counted: checkpoint !== undefined && checkpoint.processorVersion === PROCESSOR_VERSION },
1478
+ };
1479
+ }
1480
+ /** The offset of the last `proc spawn` record raw still holds (this life's start), if any. */
1481
+ async function lastSpawnRecord(transport, sessionKey, attached) {
1482
+ let found;
1483
+ for (let at = attached.raw?.first ?? 0; at <= attached.head;) {
1484
+ let page;
1485
+ try {
1486
+ page = await transport.readRaw(sessionKey, at);
1487
+ }
1488
+ catch {
1489
+ return undefined;
1490
+ }
1491
+ if (page.length === 0)
1492
+ break;
1493
+ for (const record of page)
1494
+ if (record.kind === 'proc' && record.proc.type === 'spawn')
1495
+ found = record.o;
1496
+ at = page[page.length - 1].o + 1;
1497
+ }
1498
+ return found;
1499
+ }
1500
+ /** The checkpoint cadence's measure of a record: its line (or note) length. */
1501
+ function recordBytes(record) {
1502
+ switch (record.kind) {
1503
+ case 'up':
1504
+ case 'down':
1505
+ case 'err':
1506
+ return record.line.length;
1507
+ case 'note':
1508
+ return JSON.stringify(record.note).length;
1509
+ default:
1510
+ return 256;
1511
+ }
1512
+ }