@platless/axom-cli 0.3.13 → 0.5.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (235) hide show
  1. package/dist/axom-patch/cli/README.md +30 -8
  2. package/dist/axom-patch/cli/account-limits/README.md +27 -0
  3. package/dist/axom-patch/cli/account-limits/antigravity-proxy.mjs +47 -0
  4. package/dist/axom-patch/cli/account-limits/antigravity-read.mjs +90 -0
  5. package/dist/axom-patch/cli/account-limits/antigravity.mjs +46 -0
  6. package/dist/axom-patch/cli/account-limits/generate.py +120 -0
  7. package/dist/axom-patch/cli/account-limits/kimi-proxy.mjs +64 -0
  8. package/dist/axom-patch/cli/account-limits/normalize.mjs +151 -0
  9. package/dist/axom-patch/cli/claude-account-limits.json +37 -0
  10. package/dist/axom-patch/cli/codex-account-limits.json +37 -0
  11. package/dist/axom-patch/cli/commandcode-account-limits.json +30 -0
  12. package/dist/axom-patch/cli/nightly-task.md +152 -0
  13. package/dist/axom-patch/cli/pi-subagent-subwork.json +23 -0
  14. package/dist/axom-patch/cli/probes/claude-late-async-terminal.mjs +5 -1
  15. package/dist/axom-patch/cli/probes/pi-subagent-subwork.mjs +211 -0
  16. package/dist/axom-patch/cli/probes/qoder-steering.mjs +1 -1
  17. package/dist/axom-patch/cli/probes/qoder-subagent-origin.mjs +20 -8
  18. package/dist/axom-patch/cli/probes/zcode-subagent-subwork.mjs +257 -0
  19. package/dist/axom-patch/cli/qoder-background-terminal.md +2 -1
  20. package/dist/axom-patch/cli/qoder-foreground-output.md +4 -3
  21. package/dist/axom-patch/cli/qoder-steering.json +15 -15
  22. package/dist/axom-patch/cli/qoder-subagent-origin.json +21 -21
  23. package/dist/axom-patch/cli/runtime.json +34 -8
  24. package/dist/axom-patch/cli/zcode-account-limits.json +31 -0
  25. package/dist/axom-patch/cli/zcode-models.json +38 -0
  26. package/dist/axom-patch/cli/zcode-subagent-subwork.json +73 -0
  27. package/dist/cli.js +33 -6
  28. package/dist/keepers/acp/keeper.mjs +3083 -0
  29. package/dist/keepers/acp/manifest.json +9 -0
  30. package/dist/keepers/terminal/keeper.mjs +8469 -0
  31. package/dist/keepers/terminal/manifest.json +9 -0
  32. package/dist/keepers/terminal/node_modules/node-pty/lib/conpty_console_list_agent.js +25 -0
  33. package/dist/keepers/terminal/node_modules/node-pty/lib/eventEmitter2.js +47 -0
  34. package/dist/keepers/terminal/node_modules/node-pty/lib/index.js +52 -0
  35. package/dist/keepers/terminal/node_modules/node-pty/lib/interfaces.js +7 -0
  36. package/dist/keepers/terminal/node_modules/node-pty/lib/shared/conout.js +10 -0
  37. package/dist/keepers/terminal/node_modules/node-pty/lib/terminal.js +190 -0
  38. package/dist/keepers/terminal/node_modules/node-pty/lib/types.js +7 -0
  39. package/dist/keepers/terminal/node_modules/node-pty/lib/unixTerminal.js +351 -0
  40. package/dist/keepers/terminal/node_modules/node-pty/lib/utils.js +38 -0
  41. package/dist/keepers/terminal/node_modules/node-pty/lib/windowsConoutConnection.js +131 -0
  42. package/dist/keepers/terminal/node_modules/node-pty/lib/windowsPtyAgent.js +344 -0
  43. package/dist/keepers/terminal/node_modules/node-pty/lib/windowsTerminal.js +209 -0
  44. package/dist/keepers/terminal/node_modules/node-pty/lib/worker/conoutSocketWorker.js +22 -0
  45. package/dist/keepers/terminal/node_modules/node-pty/package.json +66 -0
  46. package/dist/keepers/terminal/node_modules/node-pty/prebuilds/darwin-arm64/pty.node +0 -0
  47. package/dist/keepers/terminal/node_modules/node-pty/prebuilds/darwin-arm64/spawn-helper +0 -0
  48. package/dist/keepers/terminal/node_modules/node-pty/prebuilds/darwin-x64/pty.node +0 -0
  49. package/dist/keepers/terminal/node_modules/node-pty/prebuilds/darwin-x64/spawn-helper +0 -0
  50. package/dist/keepers/terminal/node_modules/node-pty/prebuilds/linux-arm64/pty.node +0 -0
  51. package/dist/keepers/terminal/node_modules/node-pty/prebuilds/linux-x64/pty.node +0 -0
  52. package/dist/runtime-deps/qoder-sdk/package-lock.json +1189 -0
  53. package/dist/runtime-deps/qoder-sdk/package.json +8 -0
  54. package/dist/test/keeperKit.js +67 -0
  55. package/dist/workstation/acp/acpAgents.js +2163 -611
  56. package/dist/workstation/acp/acpClient.js +60 -497
  57. package/dist/workstation/acp/adoption.js +10 -396
  58. package/dist/workstation/acp/binaryInstall.js +100 -27
  59. package/dist/workstation/acp/builds/gc.js +245 -0
  60. package/dist/workstation/acp/builds/install/binary.js +28 -0
  61. package/dist/workstation/acp/builds/install/npm.js +235 -0
  62. package/dist/workstation/acp/builds/install/uv.js +254 -0
  63. package/dist/workstation/acp/builds/migrate.js +195 -0
  64. package/dist/workstation/acp/builds/model.js +128 -0
  65. package/dist/workstation/acp/builds/nodeGate.js +100 -0
  66. package/dist/workstation/acp/builds/store.js +352 -0
  67. package/dist/workstation/acp/commandTableProbe.js +68 -0
  68. package/dist/workstation/acp/configOptions.js +103 -1
  69. package/dist/workstation/acp/envSecrets.js +27 -0
  70. package/dist/workstation/acp/initializeResponse.js +285 -0
  71. package/dist/workstation/acp/installErrors.js +12 -2
  72. package/dist/workstation/acp/installProbe.js +1 -1
  73. package/dist/workstation/acp/managedPatches.js +76 -160
  74. package/dist/workstation/acp/outbox.js +330 -0
  75. package/dist/workstation/acp/patchAssets.js +20 -3
  76. package/dist/workstation/acp/permission.js +42 -10
  77. package/dist/workstation/acp/processor/checkpoint.js +87 -0
  78. package/dist/workstation/acp/processor/commands.js +35 -0
  79. package/dist/workstation/acp/processor/control.js +355 -0
  80. package/dist/workstation/acp/processor/emit.js +26 -0
  81. package/dist/workstation/acp/processor/jsonrpc.js +172 -0
  82. package/dist/workstation/acp/processor/memoryLog.js +49 -0
  83. package/dist/workstation/acp/processor/origins.js +48 -0
  84. package/dist/workstation/acp/processor/output.js +67 -0
  85. package/dist/workstation/acp/processor/projection.js +42 -0
  86. package/dist/workstation/acp/processor/record.js +128 -0
  87. package/dist/workstation/acp/processor/runtime.js +181 -0
  88. package/dist/workstation/acp/processor/sdkSchemas.js +61 -0
  89. package/dist/workstation/acp/processor/session.js +525 -0
  90. package/dist/workstation/acp/processor/sideChannel.js +80 -0
  91. package/dist/workstation/acp/processor/state.js +74 -0
  92. package/dist/workstation/acp/processor/step.js +459 -0
  93. package/dist/workstation/acp/processor/test/fixtureLog.js +65 -0
  94. package/dist/workstation/acp/processor/test/harness.js +120 -0
  95. package/dist/workstation/acp/processor/test/largeIdEcho.js +50 -0
  96. package/dist/workstation/acp/processor/test/rawAgent.js +70 -0
  97. package/dist/workstation/acp/processor/transport/generations.js +76 -0
  98. package/dist/workstation/acp/processor/transport/keeper.js +130 -0
  99. package/dist/workstation/acp/processor/transport/local.js +269 -0
  100. package/dist/workstation/acp/processor/transport/types.js +78 -0
  101. package/dist/workstation/acp/processor/updates.js +364 -0
  102. package/dist/workstation/acp/processorSession.js +1512 -0
  103. package/dist/workstation/acp/qoderStartup.js +1 -1
  104. package/dist/workstation/acp/registry/catalog.js +154 -32
  105. package/dist/workstation/acp/registry/launch.js +61 -119
  106. package/dist/workstation/acp/sessionUpdateMapper.js +18 -7
  107. package/dist/workstation/acp/steering.js +58 -0
  108. package/dist/workstation/acp/switching.js +114 -0
  109. package/dist/workstation/acp/terminalLogin.js +16 -42
  110. package/dist/workstation/acp/terminalLoginSpec.js +41 -0
  111. package/dist/workstation/acp/test/adapterFixtures.js +256 -0
  112. package/dist/workstation/acp/test/daemonLife.js +106 -0
  113. package/dist/workstation/acp/test/keeperWorld.js +267 -0
  114. package/dist/workstation/acp/test/memoryOutbox.js +55 -0
  115. package/dist/workstation/acp/test/raw/fixtures.js +196 -0
  116. package/dist/workstation/acp/test/raw/qoderCaptures.js +125 -0
  117. package/dist/workstation/acp/test/raw/sanitize.js +344 -0
  118. package/dist/workstation/acp/test/raw/sources.js +249 -0
  119. package/dist/workstation/acp/test/realAdapter.js +377 -0
  120. package/dist/workstation/acp/test/scriptAgent.js +206 -0
  121. package/dist/workstation/acp/test/sessionFrames.js +42 -0
  122. package/dist/workstation/acp/timedCommand.js +4 -2
  123. package/dist/workstation/acp/uvxInstall.js +72 -122
  124. package/dist/workstation/agentFlow.js +103 -0
  125. package/dist/workstation/agentSessions.js +1324 -474
  126. package/dist/workstation/channels/agent.js +7 -5
  127. package/dist/workstation/channels/computer.js +69 -21
  128. package/dist/workstation/channels/computerExecs.js +200 -0
  129. package/dist/workstation/channels/management.js +84 -4
  130. package/dist/workstation/daemon.js +288 -7
  131. package/dist/workstation/daemonctl.js +28 -2
  132. package/dist/workstation/exec/attach.js +126 -0
  133. package/dist/workstation/exec/command.js +40 -0
  134. package/dist/workstation/exec/durable.js +517 -0
  135. package/dist/workstation/exec/fallback.js +106 -0
  136. package/dist/workstation/exec/policy.js +837 -0
  137. package/dist/workstation/exec/shaping.js +211 -0
  138. package/dist/workstation/exec.js +3 -112
  139. package/dist/workstation/files/adapters/computerFileFrames.js +51 -0
  140. package/dist/workstation/files/adapters/toolRequests.js +121 -0
  141. package/dist/workstation/files/client.js +455 -0
  142. package/dist/workstation/files/errors.js +28 -0
  143. package/dist/workstation/files/fence.js +350 -0
  144. package/dist/workstation/files/find/fileIndex.js +153 -0
  145. package/dist/workstation/files/find/fuzzy.js +155 -0
  146. package/dist/workstation/files/host.js +122 -0
  147. package/dist/workstation/files/hostProtocol.js +2 -0
  148. package/dist/workstation/files/media/cache.js +620 -0
  149. package/dist/workstation/files/media/encoding.js +308 -0
  150. package/dist/workstation/files/media/estimator.js +100 -0
  151. package/dist/workstation/files/media/media.js +325 -0
  152. package/dist/workstation/files/media/packedAudio.js +23 -0
  153. package/dist/workstation/files/media/scheduler.js +551 -0
  154. package/dist/workstation/files/media/types.js +5 -0
  155. package/dist/workstation/files/scope.js +45 -0
  156. package/dist/workstation/files/structured.js +284 -0
  157. package/dist/workstation/{fileOps.js → files/text.js} +117 -38
  158. package/dist/workstation/files/uploads.js +351 -0
  159. package/dist/workstation/journal.js +65 -236
  160. package/dist/workstation/keepers/acp/client.js +101 -0
  161. package/dist/workstation/keepers/acp/envelope.js +62 -0
  162. package/dist/workstation/keepers/acp/host.js +419 -0
  163. package/dist/workstation/keepers/acp/main.js +18 -0
  164. package/dist/workstation/keepers/acp/memoryStream.js +121 -0
  165. package/dist/workstation/keepers/acp/params.js +137 -0
  166. package/dist/workstation/keepers/acp/protocol.js +66 -0
  167. package/dist/workstation/keepers/acp/recover.js +120 -0
  168. package/dist/workstation/keepers/acp/session.js +584 -0
  169. package/dist/workstation/keepers/acp/stderr.js +154 -0
  170. package/dist/workstation/keepers/kit/activity.js +103 -0
  171. package/dist/workstation/keepers/kit/bootstrap.js +164 -0
  172. package/dist/workstation/keepers/kit/buildVersion.js +51 -0
  173. package/dist/workstation/keepers/kit/bundle.js +120 -0
  174. package/dist/workstation/keepers/kit/client.js +432 -0
  175. package/dist/workstation/keepers/kit/identity.js +21 -0
  176. package/dist/workstation/keepers/kit/ipc.js +112 -0
  177. package/dist/workstation/keepers/kit/launchConfig.js +8 -0
  178. package/dist/workstation/keepers/kit/launcher.js +249 -0
  179. package/dist/workstation/keepers/kit/lock.js +123 -0
  180. package/dist/workstation/keepers/kit/orphanSweep.js +86 -0
  181. package/dist/workstation/keepers/kit/paths.js +75 -0
  182. package/dist/workstation/keepers/kit/protocol.js +43 -0
  183. package/dist/workstation/keepers/kit/server.js +518 -0
  184. package/dist/workstation/keepers/kit/streamLog.js +697 -0
  185. package/dist/workstation/keepers/legacy.js +296 -0
  186. package/dist/workstation/keepers/policy.js +576 -0
  187. package/dist/workstation/keepers/stop.js +34 -0
  188. package/dist/workstation/keepers/terminal/client.js +213 -0
  189. package/dist/workstation/keepers/terminal/exec.js +981 -0
  190. package/dist/workstation/keepers/terminal/generations.js +129 -0
  191. package/dist/workstation/keepers/terminal/keeper.js +62 -0
  192. package/dist/workstation/keepers/terminal/main.js +43 -0
  193. package/dist/workstation/keepers/terminal/orphanSweep.js +3 -0
  194. package/dist/workstation/keepers/terminal/params.js +27 -0
  195. package/dist/workstation/keepers/terminal/protocol.js +56 -0
  196. package/dist/workstation/keepers/terminal/pty.js +330 -0
  197. package/dist/workstation/protocol/accountLimits.js +47 -0
  198. package/dist/workstation/protocol/agent.js +48 -4
  199. package/dist/workstation/protocol/common.js +30 -0
  200. package/dist/workstation/protocol/computer.js +15 -4
  201. package/dist/workstation/protocol/management.js +50 -2
  202. package/dist/workstation/qoderAdapter/backgroundProcess.js +18 -6
  203. package/dist/workstation/qoderAdapter/backgroundTerminal.js +78 -147
  204. package/dist/workstation/qoderAdapter/backgroundTerminalFold.js +372 -0
  205. package/dist/workstation/qoderAdapter/callbackContext.js +76 -0
  206. package/dist/workstation/qoderAdapter/foregroundOutput.js +15 -9
  207. package/dist/workstation/qoderAdapter/history.js +1 -3
  208. package/dist/workstation/qoderAdapter/main.js +4 -2
  209. package/dist/workstation/qoderAdapter/sdkDriver.js +1 -2
  210. package/dist/workstation/qoderAdapter/sdkRuntime.js +93 -0
  211. package/dist/workstation/qoderAdapter/sdkRuntimeInstall.js +145 -0
  212. package/dist/workstation/qoderAdapter/session.js +1 -1
  213. package/dist/workstation/qoderAdapter/subwork.js +15 -7
  214. package/dist/workstation/systemMetrics.js +110 -1
  215. package/dist/workstation/tools/client.js +9 -1
  216. package/dist/workstation/tools/info.js +50 -0
  217. package/dist/workstation/tools/keeperProcess.js +15 -5
  218. package/dist/workstation/tools/router.js +55 -0
  219. package/dist/workstation/tools/terminalRouter.js +222 -0
  220. package/dist/workstation/tools/validation.js +37 -1
  221. package/dist/workstation/update/coordinator.js +3 -0
  222. package/dist/workstation/update/ptyPreflight.js +21 -0
  223. package/dist/workstation/update/taskRegistry.js +105 -11
  224. package/package.json +10 -7
  225. package/dist/workstation/acp/acpSession.js +0 -1475
  226. package/dist/workstation/acp/contract.js +0 -499
  227. package/dist/workstation/acp/draftInterceptor.js +0 -163
  228. package/dist/workstation/acp/sessionScope.js +0 -19
  229. package/dist/workstation/filePaths.js +0 -174
  230. package/dist/workstation/fileWorker.js +0 -81
  231. package/dist/workstation/fileWorkerClient.js +0 -121
  232. package/dist/workstation/qoderAdapter/taskEvent.js +0 -26
  233. package/dist/workstation/tools/core.js +0 -50
  234. package/dist/workstation/tools/files.js +0 -525
  235. package/dist/workstation/tools/terminal.js +0 -214
@@ -1,1475 +0,0 @@
1
- /**
2
- * One ACP session: `session/new`, `session/prompt`, `session/cancel`, `session/update` routing, and
3
- * the buffered event/turn sinks the deleted `BridgeSession` SPI required.
4
- *
5
- * Built on the SDK's `ClientContext.buildSession(cwd)` / `ActiveSession` convenience wrapper rather
6
- * than raw `session/new`/`session/prompt` requests: `ActiveSession.prompt(...)` both sends
7
- * `session/prompt` *and* enqueues the eventual response as a `{kind: 'stop', ...}` message onto the
8
- * exact same internal queue `nextUpdate()` reads `{kind: 'session_update', ...}` messages from — so
9
- * one continuous `nextUpdate()` loop (see {@link runReadLoop}) sees every `session/update` in order
10
- * interleaved with the turn's own completion, with no separate `.onNotification('session/update', …)`
11
- * registration needed. That registration would not even be possible after the fact in the way this
12
- * class is constructed (`AcpConnection` is already past `ClientApp.connect()` by the time
13
- * `AcpSession.open` runs, and `acpClient.ts` documents that `connect()` copies its handler
14
- * table at connect time) — `buildSession`'s routing is installed unconditionally by the SDK itself,
15
- * independent of anything a caller registers via `connectAgent`'s `configure` hook.
16
- *
17
- * One responsibility lives here rather than in `sessionUpdateMapper.ts`, because this is the layer
18
- * with a turn's whole lifetime in view, not just one `session/update` at a time:
19
- *
20
- * - **`agent_message` synthesis** (see {@link flushMessageCompleted}). The server's
21
- * `CoworkEngine` buffers `agent_message_chunk`/`agent_thought_chunk` text and only flushes it on a ~400ms gap
22
- * between deltas or on an item-backed event — ending the turn does not flush it. Both deleted
23
- * bridges closed every assistant message with `agent_message` (item-backed, so it *is* the
24
- * flush); nothing in ACP's own vocabulary plays that role, so this class synthesizes it — once
25
- * when the streaming `messageId` changes mid-turn, and once more at turn end. The same event is
26
- * also what makes the server attach a turn's `usage_update` figures
27
- * (`CoworkEngine.appendCoworkTimelineEvent`'s `MessageCompleted` branch reads and clears
28
- * `latestUsageByRun`) — without it those figures are captured and then silently discarded.
29
- *
30
- * **`user_message_chunk` folding used to be the second one, and is gone.** It lived here as a
31
- * per-session accumulator, and `adoption.ts` carried a second copy of the same rule for replay —
32
- * two implementations that had already drifted (replay split a message on any interleaved event;
33
- * this one did not). Phase 7 moved the fold to `CoworkEngine`, which was already buffering
34
- * `agent_message_chunk` by exactly the ACP rule that governs all three chunk kinds (`ContentChunk`,
35
- * `types.gen.d.ts:3456`: same `messageId` = same message). Chunks now pass straight through, and
36
- * the server's turn-end / replay-end flush is what closes a message.
37
- */
38
- import { randomUUID } from 'node:crypto';
39
- import { certifiedRuntime, ForegroundOutputBoundary } from '../qoderAdapter/foregroundOutput.js';
40
- import { BackgroundTerminalRepair, nativeTerminalHistoryPath } from '../qoderAdapter/backgroundTerminal.js';
41
- import { SubworkBoundary, supportsSubworkMeta } from '../qoderAdapter/subwork.js';
42
- import { methods } from '@agentclientprotocol/sdk';
43
- import { jsonRpcErrorCode } from './acpClient.js';
44
- import { settleUpdates } from './adoption.js';
45
- import { mapSessionUpdate, SessionMapperState } from './sessionUpdateMapper.js';
46
- import { SessionMapperScopes } from './sessionScope.js';
47
- import { newSessionMetaFor } from './activityReporting.js';
48
- import { buildSessionMcpServers } from './delegationMcp.js';
49
- import { flatChoicesOf, modesAlreadyRepresented, normalizeConfigOptions, SYNTHESIZED_MODES_CONFIG_ID } from './configOptions.js';
50
- /**
51
- * Points the connection's diverted-update stream at the session that owns it.
52
- *
53
- * **Belongs here, not in `AcpAgents`.** Both ways a session comes into existence run through this
54
- * file, and only one of them goes through the agent registry — the first attempt bound from there
55
- * and every test that opens a session directly then saw *every* update diverted, because an unbound
56
- * connection cannot tell its own session from a child's and must assume the worse case. Binding
57
- * where the session is constructed makes "a session exists" and "the transport knows about it" the
58
- * same moment.
59
- *
60
- * An empty id means the agent named no session, so nothing is bound and every update keeps being
61
- * reported rather than silently handed to an SDK that would drop it.
62
- */
63
- function bindDivertedUpdates(connection, ownSessionId, session) {
64
- if (ownSessionId.length === 0)
65
- return;
66
- connection.bindDraftUpdates(ownSessionId, (update) => session.applyDivertedUpdate(update));
67
- }
68
- /**
69
- * The steering extension's JSON-RPC method. **Not an ACP method** — `grep steer` over the pinned
70
- * SDK's `dist/schema/types.gen.d.ts` returns nothing. It is implemented by `claude-agent-acp`
71
- * (`acp-agent.js@0.76.0:164`), whose own comment calls it a *"Custom (extension) request method ...
72
- * Named `_session/steering` per the agreed ACP steering wire protocol"*; the leading underscore is
73
- * ACP's extension namespace. It is therefore reached through the untyped `request<Response, Params>
74
- * (method: string, …)` overload rather than `methods.agent.*`, and every caller must check
75
- * {@link AcpConnection.supportsSteering} first.
76
- */
77
- const STEER_METHOD = '_session/steering';
78
- /**
79
- * The adapter's *stop one background task* extension method. **Not an ACP method** either — it is
80
- * `claude-agent-acp`'s own `ASYNC_TASK_STOP_METHOD` (`acp-agent.js@0.79.0:165`), whose own comment
81
- * reads *"Stops one Claude background task without cancelling the parent prompt turn."*
82
- * `codex-acp@1.12.0` registers the same literal name with the same params
83
- * (`dist/index.js:31608`, `:35126-35129`, `:32395-32406`). Reached through the untyped `request`
84
- * overload, for the reason {@link STEER_METHOD} documents.
85
- */
86
- const ASYNC_TASK_STOP_METHOD = '_session/async_task/stop';
87
- /**
88
- * Narrow a `_session/steering` response onto {@link SteerOutcome}, or throw.
89
- *
90
- * The extension answers with one of three outcomes (`acp-agent.js@0.76.0:1737`, `:1751`, `:1779`):
91
- *
92
- * - `injected` — a turn was running and the message went into it.
93
- * - `promptRequired` (with `reason: "noRunningTurn"`) — no turn was running and we opted in, so the
94
- * agent did nothing at all and the content stays host-owned.
95
- * - `startedNewTurn` — no turn was running and the client did *not* opt in, so the agent detached a
96
- * `prompt()` of its own.
97
- *
98
- * **`startedNewTurn` throws rather than mapping to anything** (decision D13). We always send
99
- * `idleBehavior: "promptRequired"` ({@link AcpSession.steer}), so receiving it means the agent
100
- * ignored the opt-in — and what it handed back is a turn nobody awaits: it never enters our queue,
101
- * never lands an item, and never produces a `turn_done`, in a system that accounts by turn.
102
- * Absorbing it as "injected" would mark a message delivered to a turn we are not tracking;
103
- * absorbing it as "promptRequired" would submit the same message a second time. Neither is a
104
- * recovery, so this fails where the contract broke, loudly enough to name.
105
- *
106
- * An outcome nobody recognises throws for the same reason: silently reading it as `injected` would
107
- * record a delivery that may never have happened.
108
- */
109
- export function mapSteerOutcome(result) {
110
- const outcome = result?.outcome;
111
- if (outcome === 'injected')
112
- return 'injected';
113
- if (outcome === 'promptRequired')
114
- return 'promptRequired';
115
- if (outcome === 'startedNewTurn') {
116
- throw new Error(`[acp] the agent answered ${STEER_METHOD} with 'startedNewTurn', a turn this client never ` +
117
- "opted into and does not track: we always send _meta.steering.idleBehavior = 'promptRequired'.");
118
- }
119
- throw new Error(`[acp] unrecognised ${STEER_METHOD} outcome: ${JSON.stringify(outcome)}`);
120
- }
121
- /**
122
- * The value `registerPendingApproval`'s resolver receives when nobody is ever going to answer —
123
- * the session closed, the connection died, or the agent itself cancelled the request — rather
124
- * than a real user choice. `PermissionOptionId` is an unconstrained `string`, so a fixed literal
125
- * like `'deny'` risks colliding with an agent's own option id and turning "nobody answered" into
126
- * a fabricated selection (review finding: task-5, item 6). Randomized per process start and never
127
- * sent over the wire to any agent, so no real option id can ever equal it in practice.
128
- */
129
- const ABANDONED_ANSWER = `__acp_session_abandoned_${randomUUID()}__`;
130
- /**
131
- * The value `registerPendingElicitation`'s resolver receives when nobody is ever going to answer.
132
- * Unlike {@link ABANDONED_ANSWER}, this needs no randomized sentinel: `CreateElicitationResponse`'s
133
- * `action` union already has `'cancel'` as a real ACP outcome meaning exactly this ("the client
134
- * will not provide a response"), so there is no risk of it colliding with a real user answer the
135
- * way a fixed permission-option-id literal would.
136
- */
137
- const ABANDONED_ELICITATION_ANSWER = { action: 'cancel' };
138
- /** ACP's `StopReason` (`end_turn | max_tokens | max_turn_requests | refusal | cancelled`) → the wire's `TurnStopReason`. `'cancelled'` is handled by the caller (it forces `status: 'interrupted'` instead, which carries no `stopReason` — see {@link AcpSession.finishTurn}), so it never actually reaches this function; the arm is kept so the switch stays exhaustive over every literal `StopReason` can hold. */
139
- function mapStopReason(reason) {
140
- switch (reason) {
141
- case 'end_turn':
142
- return 'end_turn';
143
- case 'max_tokens':
144
- return 'max_tokens';
145
- case 'max_turn_requests':
146
- return 'max_turns';
147
- case 'refusal':
148
- return 'refusal';
149
- }
150
- }
151
- function errorMessage(err) {
152
- return err instanceof Error ? err.message : String(err);
153
- }
154
- /**
155
- * One `_session/steering` round trip, shared verbatim by {@link AcpSession.open} and
156
- * {@link AcpSession.attach} — the two differ only in where the session id comes from.
157
- *
158
- * The request shape is the extension's `parseSteerRequest` (`acp-agent.js@0.76.0:192-215`): a non-empty
159
- * `sessionId`, a non-empty `prompt` array of ACP `ContentBlock`s, and an optional
160
- * `_meta.steering.idleBehavior` whose **only** accepted value is `promptRequired` — anything else is
161
- * `invalidParams`. We always send it; see {@link mapSteerOutcome} for why.
162
- *
163
- * `blocks` is ACP's own `ContentBlock[]`, exactly like {@link AcpSession.prompt} — **this boundary
164
- * used to narrow it to a `string`, and that narrowing was ours, not the protocol's**. The extension
165
- * takes a `prompt` array and hands it to the same `promptToClaude(params)` a `session/prompt` goes
166
- * through, so an image steers as well as it prompts. While this parameter was a `string`, an image
167
- * attached to a message the reader chose to interject stopped dead here — split off into a queue row
168
- * of its own, leaving *"this is what I mean"* next to nothing.
169
- *
170
- * `priority` is the extension's `_meta.steering.priority`. **`undefined` means the caller is
171
- * deliberately naming none**, and that is not the same as picking the default: the adapter silently
172
- * ignores a value it does not recognise and only `next` is lossless, so a timing is only sent when
173
- * the adapter declared it accepts it. (The adapter's own comment about its internal
174
- * `STEER_PRIORITY_NOW` / `STEER_PRIORITY_LATER` constants — *"an internal Claude implementation
175
- * detail, not part of the wire contract"*, `acp-agent.js@0.76.0:181-188` — is about those
176
- * constants, not about `_meta`, which `parseSteerRequest` passes through whole. 0.76.0 picks the
177
- * priority itself (`:1762-1763`) and ignores `_meta.steering.priority`. This sentence used to
178
- * name a symbol `STEER_PRIORITY`; there is no such symbol in 0.76.0.)
179
- */
180
- async function steerRequest(connection, sessionId, blocks, priority) {
181
- const result = await connection.connection.agent.request(STEER_METHOD, {
182
- sessionId,
183
- prompt: blocks,
184
- _meta: { steering: { idleBehavior: 'promptRequired', ...(priority === undefined ? {} : { priority }) } },
185
- });
186
- return mapSteerOutcome(result);
187
- }
188
- /**
189
- * One `_session/async_task/stop` round trip, shared by {@link AcpSession.open} and
190
- * {@link AcpSession.attach} exactly as {@link steerRequest} is — the two differ only in where the
191
- * session id comes from.
192
- *
193
- * The request shape is the adapter's own `parseAsyncTaskStopRequest`
194
- * (`claude-agent-acp@0.79.0` `dist/acp-agent.js:166-173`): a non-empty `sessionId` and a non-empty
195
- * `asyncTaskId`, both trimmed strings, and nothing else. `codex-acp@1.12.0` parses the identical
196
- * pair (`dist/index.js:35126-35129`, a zod object with `.trim().min(1)` on both), so there is one
197
- * shape here, not a per-vendor branch. The response is `{ stopped: boolean }` on both
198
- * (`acp-agent.js:1784-1798`, `index.js:32395-32406`), which this layer deliberately does **not**
199
- * turn into a verdict: `stopped: false` only means the adapter had no live task under that id
200
- * right now, and the platform's real answer
201
- * — did the background task actually end — arrives as the agent's own next `async_task_state_update`
202
- * for this `(sessionId, asyncTaskId)`. Reporting `false` as a failure would make the server show an
203
- * error for a task that is, by then, already settled.
204
- *
205
- * Reached through the untyped `request` overload for the same reason {@link steerRequest} is: this
206
- * is an adapter extension in ACP's `_`-prefixed namespace, not an ACP method — `grep async_task`
207
- * over the pinned SDK's `types.gen.d.ts` returns nothing.
208
- */
209
- async function asyncTaskStopRequest(connection, sessionId, asyncTaskId) {
210
- const result = await connection.connection.agent.request(ASYNC_TASK_STOP_METHOD, {
211
- sessionId,
212
- asyncTaskId,
213
- });
214
- if (result?.stopped !== true) {
215
- // Logged, never thrown: see this function's own doc for why `stopped: false` is not a failure.
216
- console.warn(`[acp] ${ASYNC_TASK_STOP_METHOD} reported no live task '${asyncTaskId}' on session ${sessionId}`);
217
- }
218
- }
219
- async function providerTaskStopRequest(connection, sessionId, taskId, requestId) {
220
- const result = await connection.connection.agent.request('_axom/task/stop', {
221
- sessionId, taskId, requestId,
222
- });
223
- if (result?.state === 'accepted')
224
- return 'accepted';
225
- if (result?.state === 'unknown')
226
- return 'unknown';
227
- throw new Error('Provider task stop was rejected');
228
- }
229
- /**
230
- * One `session/set_config_option` (`SetSessionConfigOptionRequest`, `types.gen.d.ts:5102-5132`) or
231
- * — for the daemon's synthesized {@link SYNTHESIZED_MODES_CONFIG_ID} — `session/set_mode`
232
- * (`SetSessionModeRequest`, `:5079`), shared by {@link AcpSession.open} and {@link AcpSession.attach}
233
- * the same way {@link steerRequest} is.
234
- *
235
- * Returns the normalized, full snapshot either way. `session/set_config_option`'s response carries
236
- * the agent's own full `configOptions` list (`SetSessionConfigOptionResponse`, `:2949-2964`; the
237
- * `configOptions` field itself at `:2953`) — which never carries the synthesized `acp:modes` entry,
238
- * so a synthesized entry from the last snapshot is carried across by hand. `session/set_mode`'s
239
- * response carries no list at all (`SetSessionModeResponse`, `:2934-2945`), so that snapshot is
240
- * built locally by applying `value` to the synthesized entry already in `current()`.
241
- *
242
- * `current` and `remember` are how the two construction paths ({@link AcpSession.open}'s closure
243
- * over its own `let snapshot`, {@link AcpSession.attach}'s over its own) each keep one mutable
244
- * "latest snapshot" cell without this function needing to know which kind of session it is serving.
245
- */
246
- function setConfigOptionRequest(connection, sessionId, current, remember) {
247
- return async (configId, value) => {
248
- const agent = connection.connection.agent;
249
- if (configId === SYNTHESIZED_MODES_CONFIG_ID) {
250
- if (typeof value !== 'string') {
251
- throw new Error(`[acp] ${SYNTHESIZED_MODES_CONFIG_ID} takes a mode id (a string), got ${typeof value}`);
252
- }
253
- await agent.request(methods.agent.session.setMode, { sessionId, modeId: value });
254
- const next = current().map((o) => (o.id === configId && o.type === 'select' ? { ...o, currentValue: value } : o));
255
- remember(next);
256
- return next;
257
- }
258
- const params = typeof value === 'boolean' ? { sessionId, configId, type: 'boolean', value } : { sessionId, configId, value };
259
- const response = await agent.request(methods.agent.session.setConfigOption, params);
260
- // The agent's reply has no `modes` field to normalize against — a synthesized entry, if there
261
- // was one, survives only by being carried across from the last snapshot.
262
- //
263
- // Carried across under the *same* condition `normalizeConfigOptions` synthesizes under
264
- // ({@link modesAlreadyRepresented}), not unconditionally: an agent that only starts declaring
265
- // its own equivalent control *after* we synthesized one — it grew a `category: "mode"` option
266
- // between the open and this set, or (pi's shape) it already offers the identical choice list
267
- // under an id of its own — would otherwise get two pickers for one thing on the same session,
268
- // its real one and our stale stand-in for it. The agent's own always wins.
269
- const reNormalized = normalizeConfigOptions(response.configOptions, null);
270
- const carried = current().find((o) => o.id === SYNTHESIZED_MODES_CONFIG_ID);
271
- const carriedChoices = (carried ? flatChoicesOf(carried) : null) ?? [];
272
- const synthesized = carried && !modesAlreadyRepresented(reNormalized, carriedChoices) ? carried : undefined;
273
- const next = reNormalized.concat(synthesized ? [synthesized] : []);
274
- remember(next);
275
- return next;
276
- };
277
- }
278
- /** `payload[key]` narrowed to `string`, or `null` — every messageId/text field this file reads off a mapped `WireEvent` goes through this rather than an `as` cast, since the payload is `Record<string, unknown>` by construction. */
279
- function stringField(payload, key) {
280
- const value = payload[key];
281
- return typeof value === 'string' ? value : null;
282
- }
283
- /**
284
- * The queue behind an attached session's `nextUpdate()`, standing in for the one the SDK's
285
- * `ActiveSession` keeps internally.
286
- *
287
- * Three producers feed it and they must interleave in arrival order: `session/update` notifications
288
- * (via {@link LiveUpdateSource}), the `session/prompt` response that ends a turn, and that same
289
- * request's rejection. `ActiveSession` routes all three onto one queue — which is what lets
290
- * {@link AcpSession.runReadLoop} be a single loop — so this reproduces that exactly rather than
291
- * giving the prompt result its own channel.
292
- *
293
- * {@link fail} is the `dispose()` half: it is permanent and drops anything still buffered, because
294
- * its only caller is a session that is closing. Leaving buffered updates readable there would emit
295
- * events for a session the manager has already retired.
296
- */
297
- class AttachedQueue {
298
- items = [];
299
- waiters = [];
300
- failure = null;
301
- push(value) {
302
- this.settle({ ok: true, value });
303
- }
304
- /**
305
- * A one-shot rejection — a failed `session/prompt`, not a dead queue. `runReadLoop` turns exactly
306
- * this into a `failed` TurnResult and then keeps reading for the next prompt, mirroring how
307
- * `ActiveSession.prompt()` routes its own rejection onto the same queue.
308
- */
309
- pushError(error) {
310
- this.settle({ ok: false, error });
311
- }
312
- fail(error) {
313
- if (this.failure)
314
- return;
315
- this.failure = { error };
316
- this.items.length = 0;
317
- for (const waiter of this.waiters.splice(0))
318
- waiter.reject(error);
319
- }
320
- next() {
321
- const head = this.items.shift();
322
- if (head)
323
- return head.ok ? Promise.resolve(head.value) : Promise.reject(head.error);
324
- if (this.failure)
325
- return Promise.reject(this.failure.error);
326
- return new Promise((resolve, reject) => {
327
- this.waiters.push({ resolve, reject });
328
- });
329
- }
330
- settle(item) {
331
- if (this.failure)
332
- return;
333
- const waiter = this.waiters.shift();
334
- if (!waiter) {
335
- this.items.push(item);
336
- return;
337
- }
338
- if (item.ok)
339
- waiter.resolve(item.value);
340
- else
341
- waiter.reject(item.error);
342
- }
343
- }
344
- export class AcpSession {
345
- session;
346
- connection;
347
- outbox = [];
348
- /**
349
- * This session's `toolCallId → name` memory, for the `tool_call_update`s ACP lets arrive with
350
- * no `name` on them ("unchanged", not "unknown" — see {@link SessionMapperState}).
351
- *
352
- * Per instance, like every other cross-update field here, and for the same reason `inFlightTurns`
353
- * is: one `AcpSession` is one ACP session, and a registry shared between two of them would let
354
- * one session's tool call name a different session's update.
355
- */
356
- rootRuntimeId = randomUUID();
357
- scopes = new SessionMapperScopes((scope) => new SessionMapperState((raw) => { if (scope.nativeSessionId === this.session.sessionId)
358
- this.reportActivity(raw); }));
359
- mapperFor(nativeSessionId) {
360
- return this.scopes.get({ rootRuntimeId: this.rootRuntimeId, nativeSessionId });
361
- }
362
- eventSink = null;
363
- turnSink = null;
364
- backgroundWriterUnconfirmed = false;
365
- exactExecutionUnconfirmed = false;
366
- /**
367
- * This session's latest agent-reported activity-state consumer — `agentSessions.ts`, via
368
- * `AcpAgentSession.onActivity`. Kept apart from {@link eventSink} on purpose: an activity push is
369
- * a level, not an edge (see the `agent_session_state` frame's own doc comment), so it must never
370
- * pass through the `WireEvent`/journal/seq machinery {@link eventSink} feeds.
371
- */
372
- activitySink = null;
373
- draining = false;
374
- /**
375
- * Every turn this session has started and not yet reported a result for, oldest first.
376
- *
377
- * A list rather than a single `currentTurn`, because `session/prompt` is a request per turn and
378
- * ACP places no bar on a second one going out while the first is still running — the SDK's
379
- * `ActiveSession.prompt` enqueues a `{kind:'stop'}` message per call, so two prompts really do
380
- * produce two completions on the read queue, in the order the agent answers them. Folding a
381
- * second prompt into the first turn (`currentTurn ??=`) meant the second `stop` found no turn to
382
- * finish and its result was dropped outright: the second turn's deltas arrived, but no
383
- * `agent_message` ever flushed the server's delta buffer for them and no `turn_done` ever
384
- * reached the run, which stayed parked on work that had already finished. Pinned by clause 7 of
385
- * `contract.ts`.
386
- *
387
- * Strictly FIFO, which is an assumption worth naming: a stop message is matched to the *oldest*
388
- * unfinished turn rather than to the prompt that produced it, because JSON-RPC ids live inside
389
- * the SDK and never reach this class. Two overlapping turns that the agent answers out of order
390
- * therefore swap results. Both results still arrive, which is what everything downstream needs;
391
- * per-prompt attribution would need an id ACP does not put on `session/update` either.
392
- */
393
- inFlightTurns = [];
394
- /**
395
- * Whether a `session/prompt` round trip is outstanding on the agent **right now**.
396
- *
397
- * This is the turn's ground truth: {@link inFlightTurns} is pushed when the prompt goes out and
398
- * shifted when the agent answers, so it says what the agent is doing rather than what anything
399
- * else recorded about it. `TaskRegistry.snapshot()` reads this getter directly, live, on every
400
- * call -- there is no bracket of started/finished events kept elsewhere on the daemon side that
401
- * could go stale or outlive it (that bookkeeping set is gone; see the design doc's §4.1/§4.2).
402
- * The signal that *can* now outlive the events it describes is the agent's own self-reported
403
- * activity (`lastActivityRaw`): a vendor that says `running` and then falls silent stays
404
- * "running" until it says otherwise, by deliberate policy (no invented timeout; §4.4). This
405
- * getter has no equivalent failure mode -- it cannot outlive the round trip it is asking about,
406
- * because it *is* that round trip, sampled live rather than recorded.
407
- */
408
- get turnRunning() {
409
- return this.inFlightTurns.length > 0;
410
- }
411
- /** Whether this session's stdio is still readable -- false once the read loop died. */
412
- get readable() {
413
- return this.readLoopDead === null;
414
- }
415
- nativeTerminalRepair;
416
- foregroundOutput;
417
- subworkBoundary;
418
- closed = false;
419
- connectionAborted = false;
420
- /**
421
- * Why {@link runReadLoop} stopped for good, or `null` while it is still reading. Set at all three
422
- * of the loop's permanent exits, so the flag means exactly "no `stop` message can ever arrive
423
- * again"; {@link prompt} is the reader.
424
- *
425
- * Without it, a prompt sent after the loop died was `session/prompt`-ed into a queue nobody reads:
426
- * no `TurnResult`, no `turn_done`, no error card and no timeout, and every retry the user makes
427
- * joins the same silence. Two of the consequences live outside this file, which is why no
428
- * per-file review caught them:
429
- *
430
- * - `TaskRegistry.canUpdate()` samples {@link turnRunning} live off {@link inFlightTurns} rather
431
- * than accumulating a bracketed ref (2026-09-21 rewrite — the old `agentTasks` Set could go
432
- * stale on its own, holding a ref no real condition still justified; live sampling means a mark
433
- * can no longer outlive the condition that set it). That does not make this concern go away,
434
- * because the condition here would be real, not stale bookkeeping: {@link inFlightTurns} is
435
- * still pushed unconditionally at the bottom of this method, and only {@link finishTurn} pops
436
- * it. A prompt sent into a connection nobody is reading gets no `session/update`, so nothing
437
- * would ever call {@link finishTurn} for that entry — the turn is genuinely, permanently
438
- * unfinished, so live sampling faithfully reports it and pins `turnRunning` to `true`, and with
439
- * it `canUpdate()` to `false`, for the rest of this process's life. Same failure mode as the old
440
- * leak, one call frame earlier: a busy mark nothing will ever clear. This flag's early return is
441
- * what pairs that push with an immediate {@link finishTurn} instead of leaving it stuck.
442
- * - Each retry `touch()`es the session, so retrying keeps `lastActivityAt` fresh and `sweepIdle`
443
- * never reclaims it either.
444
- *
445
- * Deliberately a flag of its own rather than reusing {@link closed}: `close()` short-circuits its
446
- * whole body on `closed`, so setting that here would skip `session/cancel` and `dispose()` on a
447
- * session that was never actually closed.
448
- */
449
- readLoopDead = null;
450
- /** Tracks the streaming assistant message for `agent_message` synthesis — see {@link flushMessageCompleted}. */
451
- sawMessageContent = false;
452
- currentMessageId = null;
453
- /**
454
- * `requestId` → the resolver for one outstanding `session/request_permission` round trip.
455
- * `AcpSession` owns this map (rather than `permission.ts` keeping its own) purely so `close()`
456
- * can settle every outstanding card the same way the deleted Claude Code bridge's
457
- * `denyAllPending()` did — a request with no user left to answer it must not hang the agent
458
- * process forever. `permission.ts` is the only thing that populates it, via
459
- * {@link registerPendingApproval}; nothing in this file does.
460
- */
461
- connectionGeneration = randomUUID();
462
- permissionsInterrupted = false;
463
- permissionOrigins = new Set();
464
- // One parent per exact native ID, including edges whose parent has not arrived yet.
465
- permissionParents = new Map();
466
- unresolvedPermissionOrigins = new Set();
467
- pendingApprovals = new Map();
468
- settledApprovals = new Map();
469
- callbacksComplete = true;
470
- publishCallbacks() {
471
- this.connection.publishCallbacks?.({ rootSessionId: this.session.sessionId,
472
- connectionGeneration: this.connectionGeneration, complete: this.callbacksComplete,
473
- pending: this.callbacksComplete ? [...this.pendingApprovals].map(([requestId, entry]) => ({ requestId,
474
- originNativeSessionId: entry.originNativeSessionId, rpcRequestId: entry.rpcRequestId })) : [],
475
- settled: this.callbacksComplete ? [...this.settledApprovals].map(([requestId, reason]) => ({ requestId, reason })) : [] });
476
- }
477
- /**
478
- * `requestId` → the resolver for one outstanding `elicitation/create` round trip. Parallel to
479
- * {@link pendingApprovals}, and drained the same way for the same reason — `close()` and the
480
- * connection-abort branch of `runReadLoop` must not leave the agent process blocked inside
481
- * `elicitation/create` forever. A separate map rather than reusing `pendingApprovals`, because the
482
- * resolved value differs in kind: a `CreateElicitationResponse` object, not a bare option-id
483
- * string — see `AgentFrame.AgentElicitationAnswer`'s KDoc for why the two frames (and so the two
484
- * maps) do not collapse into one.
485
- */
486
- pendingElicitations = new Map();
487
- readLoopDone;
488
- constructor(session, connectionSignal, connection) {
489
- this.session = session;
490
- this.connection = connection;
491
- if (connectionSignal.aborted)
492
- this.connectionAborted = true;
493
- else
494
- connectionSignal.addEventListener('abort', () => {
495
- this.connectionAborted = true;
496
- this.nativeTerminalRepair?.close();
497
- }, { once: true });
498
- if (certifiedRuntime(connection.agentInfo))
499
- this.foregroundOutput = new ForegroundOutputBoundary(this.session.sessionId);
500
- if (supportsSubworkMeta(connection.agentInfo))
501
- this.subworkBoundary = new SubworkBoundary(this.session.sessionId);
502
- this.readLoopDone = this.runReadLoop();
503
- }
504
- /** Only actual hosted sessions enable the certified Qoder boundary; probes never read history. */
505
- enableBackgroundTerminalRepair(cwd, historyHome, restoreCalls = false) {
506
- if (this.closed || this.connectionAborted || this.readLoopDead || this.nativeTerminalRepair)
507
- return;
508
- const path = nativeTerminalHistoryPath(this.connection.agentInfo, this.session.sessionId, cwd, historyHome);
509
- if (!path)
510
- return;
511
- this.nativeTerminalRepair = new BackgroundTerminalRepair({
512
- includeProcess: this.subworkBoundary !== undefined,
513
- path, cwd, restoreCalls, nativeSessionId: this.session.sessionId, emit: event => this.emitEvent(event),
514
- });
515
- this.nativeTerminalRepair.start();
516
- }
517
- /**
518
- * Open a new ACP session via `session/new` and start driving it.
519
- *
520
- * `nativeSessionId` mirrors the deleted `AgentBridge.open()` contract verbatim: `null` when no
521
- * usable id came back, **never** `''` — the server persists any non-null value it is given as an
522
- * identity and later resumes from it, so an agent that technically returns an empty string must be
523
- * degraded to `null` here rather than trusted at face value.
524
- */
525
- static async open(connection, options) {
526
- // Opts into claude-agent-acp's raw-SDK-message channel (design §5.1/§2.1) for exactly the
527
- // session that needs it — `undefined` for every other agent, in which case `buildSession` keeps
528
- // taking the plain `cwd` string it always has when no MCP server is configured. The explicit
529
- // request path carries the same MCP list as session/load, including any scoped delegation lease.
530
- const meta = newSessionMetaFor(connection.agentInfo?.name);
531
- const mcpServers = buildSessionMcpServers(options.mcpServers, options.delegationMcp);
532
- if (options.delegationMcp)
533
- connection.redactStderrSecret(Object.values(options.delegationMcp.headers)[0]);
534
- const builder = meta || mcpServers.length > 0
535
- ? connection.connection.agent.buildSession({ cwd: options.cwd, mcpServers, ...(meta ? { _meta: meta } : {}) })
536
- : connection.connection.agent.buildSession(options.cwd);
537
- const activeSession = await builder.start();
538
- // `NewSessionResponse` (`acp.d.ts:329`'s `newSessionResponse` getter) carries both halves
539
- // `normalizeConfigOptions` needs: `configOptions` (`types.gen.d.ts:2546`) and `modes` (`:2542`).
540
- const response = activeSession.newSessionResponse;
541
- let snapshot = normalizeConfigOptions(response.configOptions, response.modes);
542
- const drivenSession = {
543
- sessionId: activeSession.sessionId,
544
- // `ActiveSession.prompt`'s own parameter is already `string | ContentBlock | ContentBlock[]`
545
- // (`acp.d.ts:338`) — it is this class's `DrivenSession` boundary that was narrow, not the SDK's.
546
- prompt: (blocks) => activeSession.prompt(blocks),
547
- nextUpdate: () => activeSession.nextUpdate(),
548
- dispose: () => activeSession.dispose(),
549
- // A notification (`ClientContext.notify`, not `.request`) — `session/cancel` cancels the whole
550
- // prompt turn and expects no response, unlike `$/cancel_request`'s per-request
551
- // `SendRequestOptions.cancellationSignal` mechanism.
552
- cancel: () => connection.connection.agent.notify(methods.agent.session.cancel, { sessionId: activeSession.sessionId }),
553
- steer: (blocks, priority) => steerRequest(connection, activeSession.sessionId, blocks, priority),
554
- setConfigOption: setConfigOptionRequest(connection, activeSession.sessionId, () => snapshot, (next) => (snapshot = next)),
555
- stopAsyncTask: (asyncTaskId) => asyncTaskStopRequest(connection, activeSession.sessionId, asyncTaskId),
556
- providerTaskStop: (taskId, requestId) => providerTaskStopRequest(connection, activeSession.sessionId, taskId, requestId),
557
- };
558
- const session = new AcpSession(drivenSession, connection.connection.signal, connection);
559
- // Claude's activity-state notification arrives on the connection, not on this session's own
560
- // `session/update` stream (see `activityReporting.ts` and `AcpConnection.bindActivity`'s doc
561
- // comment for why `extNotification` cannot be routed the way `session/update` is). Bound
562
- // unconditionally: a connection whose agent never sends `_claude/sdkMessage` simply never
563
- // invokes this sink.
564
- connection.bindActivity((raw) => session.reportActivity(raw));
565
- const nativeSessionId = activeSession.sessionId.length > 0 ? activeSession.sessionId : null;
566
- bindDivertedUpdates(connection, activeSession.sessionId, session);
567
- return { session, nativeSessionId, configOptions: snapshot };
568
- }
569
- /**
570
- * Drive a session the agent has **already loaded** (`session/load`, see `adoption.ts`) instead of
571
- * opening a new one — the path a reconnect and an adoption both take.
572
- *
573
- * ### Why this exists at all
574
- *
575
- * `agentSessions.ts` is handed a `nativeSessionId` on every reconnect, not only on adoption: the
576
- * server persists the id and sends it back with `agent_open`. Answering that by opening a fresh
577
- * `session/new` and replaying the old session's history as read-only display would throw away the
578
- * agent's own context on every reconnect — the deleted Claude Code bridge really did resume, so
579
- * that would be a regression, not an equivalent swap.
580
- *
581
- * ### Why it is hand-built rather than an SDK call
582
- *
583
- * The SDK offers no way to attach its `ActiveSession` to an already-loaded session: the class has a
584
- * private constructor and the only thing that produces one is `SessionBuilder.start()`, which
585
- * *is* `session/new` (`@agentclientprotocol/sdk/dist/acp.d.ts`). So the second construction path is
586
- * this: the same {@link DrivenSession} surface, assembled from raw requests —
587
- * `prompt` → `session/prompt`, `cancel` → the `session/cancel` notification, `nextUpdate` →
588
- * {@link AttachedQueue} fed by `updates.listen(...)` plus the prompt's own completion, `dispose` →
589
- * unsubscribe and fail the queue. Everything above this line — the read loop, the outbox, the
590
- * `agent_message` synthesis, the pending-approval map — is shared verbatim with
591
- * {@link AcpSession.open}; only how the four calls reach the agent differs.
592
- *
593
- * The caller is responsible for having already issued `session/load` for `sessionId` (and, when it
594
- * wants that history, for having captured the replay it produced). Call `updates.listen` here
595
- * rather than earlier so nothing is dropped in between: `ReplayCapture` withholds updates from a
596
- * listener only while the id is armed, and it is disarmed by the `take()` that ends the replay.
597
- *
598
- * ### `initial.configOptions` is read lazily, not copied at construction
599
- *
600
- * `AcpAgents.startSession` (Task 3) must construct this session *before* issuing `session/load` —
601
- * the session has to be subscribed to the live update stream before the replay begins — so the
602
- * snapshot (which only exists once `session/load`'s response arrives, via `adoption.ts`'s
603
- * `ReplaySessionParams.onLoaded`) is not there yet at the moment this runs. An eager
604
- * `let snapshot = initial.configOptions` at construction time would therefore capture an empty
605
- * array forever. `current()` instead re-reads `initial.configOptions` every time, until a real
606
- * `setConfigOption` call produces a snapshot of its own — so a caller that populates `initial`
607
- * (a mutable object) after construction is still seen.
608
- *
609
- * ### Opts claude into activity reporting exactly as {@link AcpSession.open} does
610
- *
611
- * `AcpSession.open` sends `_meta.claudeCode.emitRawSDKMessages` on `session/new` and calls
612
- * `connection.bindActivity(...)` right after constructing its session (`:596`, `:631`). This method
613
- * used to do neither, permanently losing activity reporting for every reconnect and every
614
- * adoption of a claude session, even though `agentInfo.name` still made it report
615
- * `reportsActivity: true` on `agent_opened` — see `protocol/agent.ts`'s `reportsActivity` field doc
616
- * for how that gap was measured and closed. The `_meta` half is now sent by the caller: this
617
- * method does not issue `session/load` itself (see this method's own opening paragraph — the
618
- * caller already did, before calling this), but `adoption.ts`'s `replayNativeSession` — the
619
- * function that does issue it — sends the exact same opt-in `AcpSession.open` sends, via the same
620
- * `newSessionMetaFor` filter, so there is exactly one place that decides which agents opt in. This
621
- * method's own half is `bindActivity` below, called unconditionally right after construction, the
622
- * same as `open` — a connection whose agent never sends `_claude/sdkMessage` simply never invokes
623
- * the sink.
624
- */
625
- static attach(connection, sessionId, updates, initial) {
626
- const agent = connection.connection.agent;
627
- const queue = new AttachedQueue();
628
- // Counted, not just forwarded: `settleUpdates` below needs a growth signal for this session's
629
- // live stream, the way the replay path uses `ReplayCapture.count`.
630
- let delivered = 0;
631
- const unsubscribe = updates.listen(sessionId, (update) => {
632
- delivered += 1;
633
- queue.push({ kind: 'session_update', update });
634
- });
635
- let snapshot = null;
636
- const current = () => snapshot ?? initial.configOptions;
637
- const driven = {
638
- sessionId,
639
- async prompt(blocks) {
640
- try {
641
- const response = await agent.request(methods.agent.session.prompt, {
642
- sessionId,
643
- prompt: blocks,
644
- });
645
- // The response resolving does NOT mean this turn's own `session/update` notifications have
646
- // been dispatched — the SDK applies a response synchronously off the reader but hands a
647
- // notification to an un-awaited handler chain (see `settleUpdates`, which documents the
648
- // measurement). Without this drain, the `stop` below jumps the queue ahead of deltas the
649
- // reader had already pulled off the wire, and `agent_message`/`TurnResult` land
650
- // mid-message: the server's CoworkEngine closes the item on `agent_message`, so the
651
- // remaining text arrives orphaned, and an `agent_close` racing the early `turn_done` can
652
- // drop it outright (`AttachedQueue.fail` discards what is still buffered).
653
- //
654
- // Not a `settleUpdates` gap the SDK-routed path shares: `ActiveSession` is routed by a
655
- // *static* handler that runs first in the chain, which is why `AcpSession.open` never
656
- // needed this and `attach` does.
657
- await settleUpdates(sessionId, () => delivered);
658
- // Enqueued, not just returned: the read loop — not this caller — is what turns a completed
659
- // turn into a TurnResult, exactly as `ActiveSession.prompt()` does it.
660
- queue.push({ kind: 'stop', stopReason: response.stopReason });
661
- return { stopReason: response.stopReason };
662
- }
663
- catch (err) {
664
- // Same routing for the failure: `runReadLoop` reads it off the queue and reports a `failed`
665
- // turn. Rethrown as well so the shape matches `ActiveSession.prompt()` — `AcpSession.prompt`
666
- // swallows it with a `.catch(() => {})` precisely because the queue is the real channel.
667
- // Drained first for the same reason the success path is: an agent that streamed some of a
668
- // turn and *then* errored has the identical reordering hazard, and the failed TurnResult
669
- // must not overtake the text that preceded it.
670
- await settleUpdates(sessionId, () => delivered);
671
- queue.pushError(err);
672
- throw err;
673
- }
674
- },
675
- nextUpdate: () => queue.next(),
676
- cancel: () => agent.notify(methods.agent.session.cancel, { sessionId }),
677
- steer: (blocks, priority) => steerRequest(connection, sessionId, blocks, priority),
678
- setConfigOption: setConfigOptionRequest(connection, sessionId, current, (next) => (snapshot = next)),
679
- stopAsyncTask: (asyncTaskId) => asyncTaskStopRequest(connection, sessionId, asyncTaskId),
680
- dispose: () => {
681
- unsubscribe();
682
- queue.fail(new Error(`[acp] session ${sessionId} was disposed`));
683
- },
684
- };
685
- const attached = new AcpSession(driven, connection.connection.signal, connection);
686
- // Same wiring `AcpSession.open` does right after constructing its session (`:631`) — see this
687
- // method's own doc comment ("Opts claude into activity reporting exactly as `AcpSession.open`
688
- // does") for why this half was missing and what closes it.
689
- connection.bindActivity((raw) => attached.reportActivity(raw));
690
- bindDivertedUpdates(connection, sessionId, attached);
691
- return attached;
692
- }
693
- // -------------------------------------------------------------------------------------------
694
- // The surface `agentSessions.ts` drives (carried over from the deleted BridgeSession SPI)
695
- // -------------------------------------------------------------------------------------------
696
- /**
697
- * Send one `session/prompt` turn. `blocks` is ACP's own `ContentBlock[]` (see {@link DrivenSession}
698
- * for why this class no longer narrows it to a `string`) — a lone text block is what every caller
699
- * sends today; an image block is only ever valid here for a session whose agent advertised
700
- * `PromptCapabilities.image` (`AcpConnection.capabilities.promptImage`, surfaced to callers as
701
- * `AcpAgentSession.supportsImageInput`), and enforcing that is the caller's job, not this method's.
702
- */
703
- prompt(blocks, platformTurnId) {
704
- // Checked ahead of `closed`, and the reason this branch exists at all: a session can outlive its
705
- // read loop without anyone having closed it (a dead connection, an unmappable update), and then
706
- // nothing but this call is left to end the turn. Registered as a turn and finished on the spot,
707
- // rather than reported some other way, so that "one prompt, one terminal result" holds on this
708
- // path exactly as it does on the live one — `agentSessions.ts` unblocks `TaskRegistry` and the
709
- // server closes the round off `onTurnDone` and nothing else. See {@link readLoopDead}.
710
- if (this.readLoopDead !== null) {
711
- this.inFlightTurns.push({ interrupted: false, platformTurnId });
712
- this.finishTurn('failed', undefined, this.readLoopDead);
713
- return;
714
- }
715
- if (this.closed) {
716
- console.warn('[acp] dropping prompt for a session that is no longer running');
717
- return;
718
- }
719
- if (this.exactExecutionUnconfirmed || (this.inFlightTurns.length > 0 && (platformTurnId !== undefined ||
720
- this.inFlightTurns.some((turn) => turn.platformTurnId !== undefined)))) {
721
- throw new Error('An exact platform turn is still running; overlapping prompts are unavailable');
722
- }
723
- this.permissionsInterrupted = false;
724
- // One turn per prompt, always — see {@link inFlightTurns} for why an overlapping prompt must not
725
- // fold into the turn already running.
726
- this.inFlightTurns.push({ interrupted: false, platformTurnId });
727
- if (platformTurnId !== undefined)
728
- this.exactExecutionUnconfirmed = true;
729
- // The `.catch(() => {})` is not error handling — `ActiveSession.prompt()` already routes this
730
- // same rejection onto the `nextUpdate()` queue internally (see the file doc comment), and
731
- // `runReadLoop`'s catch branch is what turns it into a `TurnResult`. It exists only so the
732
- // promise this call itself produces is never flagged as an unhandled rejection.
733
- this.session.prompt(blocks).catch(() => { });
734
- }
735
- /**
736
- * Inject a message into the turn that is currently running, instead of queueing it behind that
737
- * turn as a second `session/prompt` would.
738
- *
739
- * **Injecting aborts.** The adapter's own comment (`acp-agent.js@0.76.0:1702-1704`): *"Pre-empting
740
- * means ABORTING: the interrupted cycle emits a `result` of its own and the steered message runs
741
- * as a second one, so the turn is marked (`Turn.steeredEchoes`) to settle at the SDK's `idle`
742
- * instead of that result."* From this class's side that is invisible and must stay so: no
743
- * {@link inFlightTurns} entry is pushed here, because steering does **not** create a turn — the
744
- * one `stop` message still on its way belongs to the prompt that is already registered. Pushing
745
- * one would leave a turn that nothing can ever finish.
746
- *
747
- * The steered message's own output arrives through `session/update` like any other, **not** in
748
- * this response (`:1115-1116`), so the only thing a caller learns here is the disposition.
749
- *
750
- * Callers must have checked {@link AcpConnection.supportsSteering} — this is an extension, and an
751
- * agent that never advertised it answers `Method not found`.
752
- *
753
- * `blocks` carries the **whole** message, images included, for the reason set out on
754
- * {@link steerRequest}; `priority` is the delivery timing, `undefined` meaning the caller is
755
- * naming none rather than picking one.
756
- */
757
- async steer(blocks, priority) {
758
- if (this.closed)
759
- throw new Error('[acp] cannot steer a session that is no longer running');
760
- return this.session.steer(blocks, priority);
761
- }
762
- /**
763
- * `session/set_config_option` — or, for the daemon's synthesized {@link SYNTHESIZED_MODES_CONFIG_ID},
764
- * `session/set_mode` — and the resulting normalized snapshot; see {@link setConfigOptionRequest}.
765
- */
766
- async setConfigOption(configId, value) {
767
- return this.session.setConfigOption(configId, value);
768
- }
769
- /**
770
- * Stop **one** background task on this session, leaving the running turn alone — the
771
- * `_session/async_task/stop` extension; see {@link asyncTaskStopRequest}.
772
- *
773
- * No {@link inFlightTurns} bookkeeping, for the same reason {@link steer} does none: this does not
774
- * create a turn and, unlike {@link interrupt}, it does not end one either. The adapter's own
775
- * comment is explicit that the parent prompt turn keeps running.
776
- *
777
- * Throws on a closed session rather than warning-and-returning the way {@link interrupt} does: a
778
- * dropped interrupt still leaves the turn marked interrupted locally, so the caller's picture
779
- * stays true, whereas a dropped stop leaves a background task running with the server told
780
- * nothing — the exact silence this whole path exists to remove.
781
- */
782
- async stopAsyncTask(asyncTaskId) {
783
- if (this.closed)
784
- throw new Error('[acp] cannot stop a background task on a session that is no longer running');
785
- return this.session.stopAsyncTask(asyncTaskId);
786
- }
787
- async providerTaskStop(taskId, requestId) {
788
- if (this.closed)
789
- throw new Error('[acp] cannot stop a provider task on a closed session');
790
- return this.session.providerTaskStop?.(taskId, requestId) ?? Promise.reject(new Error('Provider task stop unavailable'));
791
- }
792
- /**
793
- * Resolve one pending `session/request_permission` round trip. Unknown/already-answered
794
- * `requestId`s are silently ignored, matching the deleted Claude Code bridge's `answer()` — a
795
- * platform relaying a stale answer must not throw.
796
- */
797
- answer(requestId, outcome) {
798
- this.settleApproval(requestId, outcome, 'answered');
799
- }
800
- cancelPendingApproval(requestId, outcome) {
801
- this.settleApproval(requestId, outcome, 'canceled');
802
- }
803
- settleApproval(requestId, outcome, reason) {
804
- const entry = this.pendingApprovals.get(requestId);
805
- if (!entry || entry.connectionGeneration !== this.connectionGeneration)
806
- return;
807
- this.pendingApprovals.delete(requestId);
808
- if (this.settledApprovals.size >= 64)
809
- this.callbacksComplete = false;
810
- else
811
- this.settledApprovals.set(requestId, reason);
812
- this.publishCallbacks();
813
- entry.resolve(outcome);
814
- }
815
- /**
816
- * Register a pending `session/request_permission` round trip. `permission.ts` calls this from its
817
- * `session/request_permission` handler and resolves the registered callback once the user (or
818
- * `close()`) answers it; nothing in this file calls it.
819
- */
820
- registerPendingApproval(requestId, resolve, address) {
821
- const origin = address?.originNativeSessionId ?? this.session.sessionId;
822
- if (!origin.trim() || (origin !== this.session.sessionId && !this.permissionOrigins.has(origin))) {
823
- console.error(JSON.stringify({ component: 'acp', error: 'invalid_permission_origin', origin, rootRuntimeId: this.rootRuntimeId }));
824
- resolve(ABANDONED_ANSWER);
825
- return;
826
- }
827
- if (this.closed || this.readLoopDead !== null || this.connectionAborted || this.permissionsInterrupted) {
828
- // A permission request that reaches us after our own close() has nobody left to answer it —
829
- // review finding (task-5, item 2): registering it anyway would park a card `close()`'s own
830
- // drain already ran past, and the agent would wait forever for a response nothing will send.
831
- // Resolve immediately with the same abandonment sentinel `close()`'s drain uses, instead of
832
- // ever storing the entry.
833
- resolve(ABANDONED_ANSWER);
834
- return;
835
- }
836
- if ([requestId, origin, address?.rpcRequestId ?? requestId].some((value) => !value.trim() || Buffer.byteLength(value, 'utf8') > 256 || /[\x00-\x1f\x7f]/.test(value)) ||
837
- this.pendingApprovals.size >= 64 || this.pendingApprovals.has(requestId) || this.settledApprovals.has(requestId)) {
838
- this.callbacksComplete = false;
839
- this.publishCallbacks();
840
- resolve(ABANDONED_ANSWER);
841
- return;
842
- }
843
- this.pendingApprovals.set(requestId, {
844
- connectionGeneration: this.connectionGeneration, rootRuntimeId: this.rootRuntimeId,
845
- originNativeSessionId: origin, rpcRequestId: address?.rpcRequestId ?? requestId, resolve,
846
- });
847
- this.publishCallbacks();
848
- }
849
- /**
850
- * Resolve one pending `elicitation/create` round trip. Same contract as {@link answer}: an
851
- * unknown or already-answered `requestId` is silently ignored, because a stale elicitation answer
852
- * for a request that already settled must not throw.
853
- */
854
- answerElicitation(requestId, response) {
855
- const resolve = this.pendingElicitations.get(requestId);
856
- if (!resolve)
857
- return;
858
- this.pendingElicitations.delete(requestId);
859
- resolve(response);
860
- }
861
- /**
862
- * Register a pending `elicitation/create` round trip. Same contract as
863
- * {@link registerPendingApproval}: once this session is closed, there is nobody left to answer,
864
- * so the resolver settles immediately (`{action: 'cancel'}`, ACP's own real cancellation outcome
865
- * — unlike a permission option id, there is no collision to guard against here) instead of ever
866
- * being stored.
867
- */
868
- registerPendingElicitation(requestId, resolve) {
869
- if (this.closed) {
870
- resolve(ABANDONED_ELICITATION_ANSWER);
871
- return;
872
- }
873
- this.pendingElicitations.set(requestId, resolve);
874
- }
875
- /**
876
- * Emit a wire event that did not come from `session/update` — `permission.ts` calls this from its
877
- * `session/request_permission` handler to raise the `permission_request` card. Routed
878
- * through the same buffered {@link outbox}/{@link drain} pair every mapped `session/update` event
879
- * uses, rather than a separate channel, so a permission card lands in the exact same delivery
880
- * order relative to other events (and is subject to the same registration-buffering guarantee: an
881
- * event emitted before `onEvent` is registered is queued, not lost — see {@link drain}'s doc
882
- * comment) instead of racing a second, independent path a consumer would have to merge itself.
883
- */
884
- emitEvent(event) {
885
- this.nativeTerminalRepair?.observe(event);
886
- if (this.closed) {
887
- // Same reasoning as `registerPendingApproval` above: once closed, nothing is left reading the
888
- // outbox on the other end that could still make sense of a fresh card.
889
- return;
890
- }
891
- const payload = { ...event.payload };
892
- if (event.eventName === 'permission_request') {
893
- delete payload.callbackContext;
894
- const callbackContext = this.callbackContextFor(event);
895
- if (callbackContext)
896
- payload.callbackContext = callbackContext;
897
- const call = payload.toolCall;
898
- const meta = call && typeof call === 'object' && !Array.isArray(call)
899
- ? call._meta : undefined;
900
- const sdkCallback = meta && typeof meta === 'object' && !Array.isArray(meta) &&
901
- Object.prototype.hasOwnProperty.call(meta, 'qoderSdk');
902
- if (sdkCallback && !callbackContext) {
903
- if (typeof payload.requestId === 'string')
904
- this.cancelPendingApproval(payload.requestId, ABANDONED_ANSWER);
905
- return;
906
- }
907
- }
908
- const normalized = { ...event, payload,
909
- ...(event.acpSessionId === this.session.sessionId ? { acpSessionId: undefined } : {}) };
910
- for (const mapped of this.subworkBoundary?.map(normalized) ?? [normalized]) {
911
- this.observePermissionOrigin(mapped);
912
- this.enqueue({ kind: 'event', event: mapped });
913
- }
914
- }
915
- callbackContextFor(event) {
916
- if (event.eventName !== 'permission_request' || event.acpSessionId !== this.session.sessionId)
917
- return;
918
- const diagnostics = this.connection.runtimeDiagnostics;
919
- const profile = diagnostics.profile;
920
- const callbacks = diagnostics.callbacks;
921
- const requestId = event.payload.requestId;
922
- const toolCall = event.payload.toolCall;
923
- if (!diagnostics.available || profile?.mode !== 'sdk-adapter' || profile.initialized !== true ||
924
- profile.rootConfirmed !== true || profile.rootSessionId !== this.session.sessionId ||
925
- !profile.runtimeGeneration || callbacks?.complete !== true ||
926
- callbacks.rootSessionId !== this.session.sessionId ||
927
- typeof requestId !== 'string' || !this.pendingApprovals.has(requestId) ||
928
- !toolCall || typeof toolCall !== 'object' || Array.isArray(toolCall))
929
- return;
930
- const call = toolCall;
931
- const sdkMeta = call._meta && typeof call._meta === 'object' && !Array.isArray(call._meta)
932
- ? call._meta.qoderSdk : undefined;
933
- if (!sdkMeta || typeof sdkMeta !== 'object' || Array.isArray(sdkMeta))
934
- return;
935
- const sdk = sdkMeta;
936
- const origin = sdk.origin;
937
- if (sdk.version !== 1 || sdk.blockingScope !== 'root' || sdk.callbackOnly !== true ||
938
- typeof sdk.requestId !== 'string' || !/^[0-9a-f-]{36}$/i.test(sdk.requestId) ||
939
- !origin || typeof origin !== 'object' || Array.isArray(origin))
940
- return;
941
- const source = origin;
942
- const toolUseId = call.toolCallId;
943
- const agentId = source.agentId;
944
- if (source.rootSessionId !== this.session.sessionId || source.runtimeGeneration !== profile.runtimeGeneration ||
945
- typeof toolUseId !== 'string' || source.toolUseId !== toolUseId ||
946
- !toolUseId || Buffer.byteLength(toolUseId, 'utf8') > 256 ||
947
- (agentId !== undefined && (typeof agentId !== 'string' || !agentId || Buffer.byteLength(agentId, 'utf8') > 256)))
948
- return;
949
- const pending = callbacks.pending.find((entry) => entry.requestId === requestId &&
950
- entry.originNativeSessionId === this.session.sessionId);
951
- if (!pending || callbacks.pending.length > 64 || this.pendingApprovals.get(requestId)?.rpcRequestId !== pending.rpcRequestId)
952
- return;
953
- return { rootSessionId: this.session.sessionId, hostGeneration: diagnostics.generation,
954
- connectionGeneration: callbacks.connectionGeneration, requestId,
955
- runtimeGeneration: profile.runtimeGeneration, toolUseId,
956
- ...(agentId ? { agentId } : {}), independent: false };
957
- }
958
- /**
959
- * Applies one `session/update` the transport diverted before the SDK could destroy it.
960
- *
961
- * Two things arrive here and they differ only in addressing (see `draftInterceptor.ts` for the
962
- * measurements): a draft variant on **this** session, and any update at all on a **child**
963
- * session this client never opened. Both are mapped by the same mapper as everything else — this
964
- * is not a second translation path, it is the same one reached by a different door.
965
- *
966
- * A child's events are tagged with {@link WireEvent.acpSessionId} so the server can route them to
967
- * the projected child session; the frame itself still goes out on this session's ref and seq.
968
- * They deliberately bypass {@link routeMappedEvent}'s message-boundary tracking: that accumulator
969
- * synthesises `agent_message` for **this** session's turn, and feeding a child's chunks into it
970
- * would end the parent's message on the child's boundaries.
971
- *
972
- * Never throws. Its caller is the stream, and an exception there would stall every later message
973
- * on the connection, not just this one.
974
- */
975
- applyDivertedUpdate(diverted) {
976
- const isChild = diverted.sessionId !== this.session.sessionId;
977
- let events;
978
- try {
979
- events = mapSessionUpdate(diverted.update, this.mapperFor(diverted.sessionId));
980
- }
981
- catch (err) {
982
- // An unmapped variant is a contract gap worth naming, not worth killing a session over. The
983
- // agent, the session and the variant are what make it actionable.
984
- console.warn(`[acp] dropped a diverted '${String(diverted.update.sessionUpdate)}' ` +
985
- `for session ${diverted.sessionId}: ${err instanceof Error ? err.message : String(err)}`);
986
- return;
987
- }
988
- for (const event of events) {
989
- this.emitEvent(isChild ? { ...event, acpSessionId: diverted.sessionId } : event);
990
- }
991
- }
992
- /**
993
- * Cancel the current turn via `session/cancel` — a **notification**, not `$/cancel_request`
994
- * (that method cancels one in-flight *request*, via `SendRequestOptions.cancellationSignal`, and is
995
- * a different mechanism entirely from ending a whole prompt turn). The current turn is marked
996
- * interrupted synchronously so the eventual `TurnResult` reports `'interrupted'` no matter what
997
- * `stopReason` the agent's `session/prompt` response ends up carrying — including a `stopReason`
998
- * that (incorrectly) claims the turn ended normally.
999
- */
1000
- async interrupt() {
1001
- if (this.closed) {
1002
- console.warn('[acp] ignoring interrupt for a session that is no longer running');
1003
- return;
1004
- }
1005
- // Every in-flight turn, not just the newest: `session/cancel` cancels the session's prompt
1006
- // turn(s) wholesale, so a second overlapping turn must not come back reporting 'completed'
1007
- // after the user pressed Stop.
1008
- for (const turn of this.inFlightTurns)
1009
- turn.interrupted = true;
1010
- this.permissionsInterrupted = true;
1011
- this.drainPendingApprovals();
1012
- this.drainPendingElicitations();
1013
- try {
1014
- await this.session.cancel();
1015
- }
1016
- catch (err) {
1017
- // Not rethrown: the turn is already marked interrupted above, so it will report
1018
- // 'interrupted' regardless of whether the agent ever heard the cancel — a failed notify means
1019
- // the agent may keep working, which is what the warning is for.
1020
- console.warn(`[acp] session/cancel notification failed: ${errorMessage(err)}`);
1021
- }
1022
- }
1023
- /**
1024
- * Idempotent: the first call sends `session/cancel`, denies every still-pending permission card,
1025
- * then disposes the underlying `ActiveSession` (which fails its update queue); the second call just
1026
- * awaits the same read-loop exit the first one triggered.
1027
- *
1028
- * `session/cancel` is sent **before** `dispose()`, deliberately: `ActiveSession.dispose()`'s own
1029
- * docstring says it does not close the session on the agent side, only local update routing. With
1030
- * `dispose()` alone, an in-flight prompt keeps the agent running tools and burning tokens until
1031
- * whatever holds this session's `AcpConnection` eventually kills the child process — harmless only
1032
- * while there is exactly one connection per session, which ACP does not guarantee in general. The
1033
- * cancel is wrapped in its own `try`/`catch` so a failed notify cannot skip or mask the
1034
- * deny-pending/dispose/read-loop shutdown that must happen regardless.
1035
- */
1036
- async close() {
1037
- if (!this.closed) {
1038
- this.closed = true;
1039
- this.nativeTerminalRepair?.close();
1040
- this.drainPendingApprovals();
1041
- this.drainPendingElicitations();
1042
- try {
1043
- await this.session.cancel();
1044
- }
1045
- catch (err) {
1046
- console.warn(`[acp] session/cancel notification failed during close: ${errorMessage(err)}`);
1047
- }
1048
- this.session.dispose();
1049
- }
1050
- await this.readLoopDone;
1051
- this.scopes.clear();
1052
- this.permissionOrigins.clear();
1053
- this.permissionParents.clear();
1054
- this.unresolvedPermissionOrigins.clear();
1055
- }
1056
- /**
1057
- * Resolve every still-pending permission request with {@link ABANDONED_ANSWER} and clear the map.
1058
- * Shared by `close()` (the request-time answer path `permission.ts` reads) and
1059
- * `runReadLoop`'s connection-abort branch — review finding (task-5, item 5): the child dying or the
1060
- * connection otherwise aborting out from under a pending permission left it undrained before, since
1061
- * only `close()` itself drained it and neither the read loop's own abort handling nor a dead
1062
- * connection ever calls `close()`.
1063
- */
1064
- drainPendingApprovals() {
1065
- const pending = [...this.pendingApprovals.values()];
1066
- this.pendingApprovals.clear();
1067
- this.callbacksComplete = false;
1068
- this.publishCallbacks();
1069
- for (const entry of pending)
1070
- entry.resolve(ABANDONED_ANSWER);
1071
- }
1072
- /** {@link pendingElicitations}' half of {@link drainPendingApprovals} — same callers, same reason. */
1073
- drainPendingElicitations() {
1074
- for (const resolve of this.pendingElicitations.values())
1075
- resolve(ABANDONED_ELICITATION_ANSWER);
1076
- this.pendingElicitations.clear();
1077
- }
1078
- onEvent(cb) {
1079
- this.eventSink = cb;
1080
- this.drain();
1081
- }
1082
- onTurnDone(cb) {
1083
- this.turnSink = cb;
1084
- this.drain();
1085
- }
1086
- /** Registers this session's activity-state consumer. See {@link activitySink}. */
1087
- onActivity(cb) {
1088
- this.activitySink = cb;
1089
- }
1090
- /**
1091
- * Feeds one agent-reported activity-state push into this session's sink — the join point for both
1092
- * collection paths: codex-acp's `session_info_update._meta.codex.threadStatus`, via
1093
- * {@link scopes}' injected callback, and claude-agent-acp's `_claude/sdkMessage`
1094
- * extNotification, wired directly onto the connection in `AcpSession.open` (see there for why
1095
- * that registration cannot live inside `mapSessionUpdate`'s ordinary flow: the notification is not
1096
- * a `session/update` at all).
1097
- */
1098
- reportActivity(raw) {
1099
- this.activitySink?.(raw);
1100
- }
1101
- // `backfill?()` is deliberately not implemented here: replaying an existing native session's
1102
- // history is `session/load`, which is `adoption.ts`'s job ("领养"), not this file's. Not
1103
- // `session/resume(replayFrom)`, as the plan text and an earlier version of this comment said —
1104
- // ACP v1's `session/resume` explicitly does *not* replay history, and `replayFrom` exists only
1105
- // under the experimental v2 entry point Global Constraint 1 forbids importing. `adoption.ts`'s
1106
- // "`session/resume` is deliberately NOT used here" section carries the full reading. A session
1107
- // with no adopted history simply has no `backfill` method, exactly like an unsupported bridge in
1108
- // the deleted SPI.
1109
- // -------------------------------------------------------------------------------------------
1110
- // session/update routing + turn completion
1111
- // -------------------------------------------------------------------------------------------
1112
- /**
1113
- * One continuous consumer of `ActiveSession.nextUpdate()`, for the session's whole lifetime (not
1114
- * just one turn): `session_update` messages become buffered wire events, `stop` messages finish the
1115
- * current turn. See the class doc comment for why a single loop is what makes a second
1116
- * `.onNotification('session/update', …)` registration unnecessary.
1117
- */
1118
- async runReadLoop() {
1119
- for (;;) {
1120
- let message;
1121
- try {
1122
- message = await this.session.nextUpdate();
1123
- }
1124
- catch (err) {
1125
- if (this.closed) {
1126
- // Our own close() disposed the ActiveSession, which fails its queue — expected, not an
1127
- // error. Every turn still in flight at that moment must still get a terminal result, or
1128
- // the manager's onTurnDone would never fire for it. All of them, not just the oldest: the
1129
- // queue is permanently failed, so no further stop message is ever coming for the rest.
1130
- this.readLoopDead = errorMessage(err);
1131
- this.nativeTerminalRepair?.close();
1132
- this.finishAllTurns('interrupted', errorMessage(err));
1133
- return;
1134
- }
1135
- // `ActiveSession.prompt()` routes its own rejection onto this same queue (see the class doc
1136
- // comment) — this is that one-shot rejection, not a permanently failed queue (only
1137
- // `dispose()`/connection-close does that), so the loop keeps running for the next prompt.
1138
- //
1139
- // **The one exit on this class where the agent itself answered**, and so the only one that
1140
- // may claim {@link TurnResult.agentRefused} — unless the connection is what died, in which
1141
- // case this same throw is a dead queue rather than a reply and the branch just below takes
1142
- // over. `connectionAborted` is read here rather than after `finishTurn` for no reason but
1143
- // ordering: both reads happen in the same synchronous run, so they see the same value (the
1144
- // branch below has always depended on that).
1145
- // `err` itself, not its message: this is the only frame in which the agent's JSON-RPC code
1146
- // still exists. `errorMessage(err)` on the same line is what destroyed it before.
1147
- const peerErrorCode = jsonRpcErrorCode(err);
1148
- this.finishTurn('failed', undefined, errorMessage(err), !this.connectionAborted, peerErrorCode, !this.connectionAborted && peerErrorCode !== undefined);
1149
- if (this.connectionAborted) {
1150
- // The connection died out from under us without our own close() ever running. Same
1151
- // reasoning as the `this.closed` branch above: the queue is now permanently failed, so no
1152
- // further stop message is ever coming for any turn still behind the one `finishTurn` above
1153
- // just popped — review finding (task-11 fix round 1, finding 1): before the FIFO change a
1154
- // single `finishTurn` above always cleared the *only* turn, so this branch never had a
1155
- // second one left behind to orphan. And every *later* prompt needs the same treatment,
1156
- // which is what {@link readLoopDead} gives it — finishing the turns open right now says
1157
- // nothing about the retry the user sends ten seconds from now.
1158
- this.readLoopDead = errorMessage(err);
1159
- this.nativeTerminalRepair?.close();
1160
- this.finishAllTurns('failed', errorMessage(err));
1161
- // Nothing will drain pendingApprovals otherwise (review finding, task-5 item 5).
1162
- this.drainPendingApprovals();
1163
- this.drainPendingElicitations();
1164
- return; // the connection itself is gone; further reads would just spin
1165
- }
1166
- continue;
1167
- }
1168
- if (message.kind === 'session_update') {
1169
- try {
1170
- for (const event of mapSessionUpdate(message.update, this.mapperFor(this.session.sessionId)))
1171
- this.routeMappedEvent(event);
1172
- }
1173
- catch (err) {
1174
- // The third permanent-failure exit, and the reason this mapping call is inside a `try` at
1175
- // all: nothing thrown under `mapSessionUpdate` may escape `runReadLoop`. An uncaught throw
1176
- // here rejects `readLoopDone`, strands every in-flight turn in `inFlightTurns` on a read
1177
- // loop that no longer exists, and skips `drainPendingApprovals`, leaving the agent process
1178
- // blocked forever on a permission card nobody will answer.
1179
- //
1180
- // What this branch is *not*: the handler for an unknown `sessionUpdate` variant. An
1181
- // earlier version of this comment claimed it was, and a probe disproved it. Both paths
1182
- // that feed this loop parse the notification through the SDK's `zSessionNotification`
1183
- // first — `SessionUpdateRouter.handleMessage` for `open()`, `registerAppNotification` for
1184
- // the `attach()`/replay handler — and `zSessionUpdate` is a closed union of literals. An
1185
- // update naming a variant our pinned SDK does not know fails that parse inside the SDK's
1186
- // own dispatch, which prints one `Error handling notification` line and carries on;
1187
- // `mapSessionUpdate` is never called for it. Measured against a bare ndjson agent emitting
1188
- // `{"sessionUpdate":"brand_new_v2_variant"}`: the surrounding deltas and the turn's own
1189
- // completion all arrived normally and this branch never ran. (The tests that do run it
1190
- // push into `attach`'s `LiveUpdateSource` directly, which is downstream of that parse.)
1191
- //
1192
- // So the exhaustiveness guard is unreachable in production today, and what actually
1193
- // happens to a future agent's new update variant is that **it disappears silently** —
1194
- // one SDK console line, and nothing in Axom at all: no wire event, no card, no diagnostic,
1195
- // no record that the agent said anything. Nobody on this branch adjudicated that; it is
1196
- // written down here because this is where a reader will come looking for it, and the place
1197
- // to change it would be the SDK parse boundary, not this catch.
1198
- //
1199
- // The branch stays regardless: a throw from anywhere under `mapSessionUpdate` still has to
1200
- // be contained, and it is treated as permanent rather than skipped because whatever made
1201
- // one update unrepresentable is a property of a *kind* of update, so the next one of that
1202
- // kind throws again. Same shutdown as the other two permanent-failure exits — finish every
1203
- // turn with the shared cause, drain the approvals, stop reading.
1204
- console.error(`[acp] unmappable session/update; ending this session's turns: ${errorMessage(err)}`);
1205
- this.readLoopDead = errorMessage(err);
1206
- this.nativeTerminalRepair?.close();
1207
- this.finishAllTurns('failed', errorMessage(err));
1208
- this.drainPendingApprovals();
1209
- return;
1210
- }
1211
- }
1212
- else {
1213
- // ACP's own 'cancelled' stopReason additionally forces 'interrupted' even when this session's
1214
- // own interrupt() was never called (a defensive backstop, not the primary path — see
1215
- // `finishTurn`'s interrupted-flag handling for that).
1216
- const status = message.stopReason === 'cancelled' ? 'interrupted' : 'completed';
1217
- this.finishTurn(status, message.stopReason, undefined, false, undefined, true);
1218
- }
1219
- }
1220
- }
1221
- /** Host-owned load history restores addresses, never actionable interactions or live events. */
1222
- observeReplayEvent(event) {
1223
- this.nativeTerminalRepair?.observe(event);
1224
- this.observePermissionOrigin(event);
1225
- this.observeBackgroundWriter(event);
1226
- }
1227
- observeBackgroundWriter(event) {
1228
- const input = event.payload.rawInput;
1229
- if (input && typeof input === 'object' && !Array.isArray(input) &&
1230
- input.run_in_background === true) {
1231
- // An explicitly requested detached writer has no authenticated termination signal here.
1232
- this.backgroundWriterUnconfirmed = true;
1233
- }
1234
- const output = event.payload.rawOutput;
1235
- if (output && typeof output === 'object' && !Array.isArray(output)) {
1236
- const evidence = output;
1237
- if (evidence.backgrounded === true && (typeof evidence.taskId === 'string' || typeof evidence.pid === 'number')) {
1238
- this.backgroundWriterUnconfirmed = true;
1239
- }
1240
- }
1241
- }
1242
- /** Connection-local relations grant approval eligibility only after a complete path to our root. */
1243
- observePermissionOrigin(event) {
1244
- if (event.eventName !== 'subagent_spawned')
1245
- return;
1246
- const parent = event.acpSessionId ?? this.session.sessionId;
1247
- const child = event.payload.subagentSessionId;
1248
- const reject = (reason) => console.warn(JSON.stringify({
1249
- component: 'acp', error: 'invalid_permission_relation', reason,
1250
- parent, child, rootRuntimeId: this.rootRuntimeId,
1251
- }));
1252
- if (typeof parent !== 'string' || !parent.trim() || typeof child !== 'string' || !child.trim()) {
1253
- reject('invalid_native_id');
1254
- return;
1255
- }
1256
- if (child === parent || child === this.session.sessionId) {
1257
- reject(child === parent ? 'self_parent' : 'root_reparent');
1258
- return;
1259
- }
1260
- const existing = this.permissionParents.get(child);
1261
- if (existing !== undefined) {
1262
- if (existing !== parent)
1263
- reject('conflicting_parent');
1264
- return;
1265
- }
1266
- // Include unresolved edges: otherwise two untrusted parents could first form a cycle and
1267
- // become eligible later when one is named by the root. Rejected edges never enter the graph.
1268
- for (let ancestor = parent; ancestor !== undefined; ancestor = this.permissionParents.get(ancestor)) {
1269
- if (ancestor === child) {
1270
- reject('cycle');
1271
- return;
1272
- }
1273
- }
1274
- const trusted = parent === this.session.sessionId || this.permissionOrigins.has(parent);
1275
- if (!trusted && this.unresolvedPermissionOrigins.size >= 1024) {
1276
- reject('unresolved_capacity');
1277
- return;
1278
- }
1279
- this.permissionParents.set(child, parent);
1280
- if (!trusted) {
1281
- this.unresolvedPermissionOrigins.add(child);
1282
- return;
1283
- }
1284
- this.permissionOrigins.add(child);
1285
- // A bounded fixed-point walk handles arbitrarily reversed arrival order, including replay.
1286
- // Only relation events run it; ordinary event/approval traffic never scans these edges.
1287
- let promoted;
1288
- do {
1289
- promoted = false;
1290
- for (const candidate of this.unresolvedPermissionOrigins) {
1291
- if (this.permissionOrigins.has(this.permissionParents.get(candidate))) {
1292
- this.unresolvedPermissionOrigins.delete(candidate);
1293
- this.permissionOrigins.add(candidate);
1294
- promoted = true;
1295
- }
1296
- }
1297
- } while (promoted);
1298
- }
1299
- /**
1300
- * Routes one mapped `WireEvent` to the outbox, tracking `agent_message_chunk`/`agent_thought_chunk`
1301
- * on the way through so `agent_message` can be synthesized at the right moments — the one per-turn
1302
- * accumulator this class still owns instead of `sessionUpdateMapper.ts` (see the class doc comment).
1303
- *
1304
- * `user_message_chunk` is deliberately *not* special-cased: it goes to the outbox like any other
1305
- * event, and the server folds it.
1306
- */
1307
- routeMappedEvent(event) {
1308
- if (this.foregroundOutput && !this.foregroundOutput.accept(event))
1309
- return;
1310
- this.nativeTerminalRepair?.observe(event);
1311
- for (const mapped of this.subworkBoundary?.map(event) ?? [event]) {
1312
- this.observePermissionOrigin(mapped);
1313
- this.observeBackgroundWriter(mapped);
1314
- if ((!this.subworkBoundary || !mapped.acpSessionId || mapped.acpSessionId === this.session.sessionId) &&
1315
- (mapped.eventName === 'agent_message_chunk' || mapped.eventName === 'agent_thought_chunk')) {
1316
- this.trackMessageBoundary(mapped);
1317
- }
1318
- this.enqueue({ kind: 'event', event: mapped });
1319
- }
1320
- }
1321
- /**
1322
- * Notes that assistant content/thought is streaming, and — if a *different* `messageId` from the
1323
- * one currently tracked shows up mid-turn — flushes `agent_message` for the message that just
1324
- * ended before this chunk's own event reaches the outbox. Without this, two assistant messages in
1325
- * one turn (e.g. around a tool call) would flush together into the server's single delta buffer
1326
- * the moment they landed within its ~400ms gap window, reading as one run-on message.
1327
- */
1328
- trackMessageBoundary(event) {
1329
- const messageId = stringField(event.payload, 'messageId');
1330
- if (this.sawMessageContent && messageId !== null && this.currentMessageId !== null && messageId !== this.currentMessageId) {
1331
- this.flushMessageCompleted();
1332
- }
1333
- this.sawMessageContent = true;
1334
- if (messageId !== null)
1335
- this.currentMessageId = messageId;
1336
- }
1337
- /**
1338
- * Synthesizes the existing `agent_message` event — see the class doc comment for why this
1339
- * class, not `sessionUpdateMapper.ts`, is what emits it. A no-op when no content/thought has
1340
- * streamed since the last flush (a turn with only tool calls, or an already-flushed
1341
- * mid-turn message boundary), so this is always safe to call unconditionally.
1342
- */
1343
- flushMessageCompleted() {
1344
- if (!this.sawMessageContent)
1345
- return;
1346
- this.enqueue({ kind: 'event', event: { eventName: 'agent_message', payload: { messageId: this.currentMessageId ?? 'turn' } } });
1347
- this.sawMessageContent = false;
1348
- this.currentMessageId = null;
1349
- }
1350
- /**
1351
- * Terminate every turn still in flight — for the one path (a permanently failed read queue) where
1352
- * no further stop message can arrive for any of them.
1353
- *
1354
- * `error` is the *shared* cause, handed to every turn rather than only to whichever one happened
1355
- * to be popped first: one dead queue fails N turns for exactly one reason, and `error` is the only
1356
- * field that carries it (`agentSessions.ts` records `error: r.error ?? null`; the engine's
1357
- * `ExternalAgentEventApplier.onTurnDone` substitutes a generic "external agent turn failed" when
1358
- * it is absent). Passing it on the `'interrupted'` call as well keeps every permanent-failure exit
1359
- * shaped the same; {@link finishTurn} is the single place that decides a result carries it, and it
1360
- * still attaches `error` to `failed` results only.
1361
- */
1362
- finishAllTurns(status, error) {
1363
- while (this.inFlightTurns.length > 0)
1364
- this.finishTurn(status, undefined, error);
1365
- }
1366
- /**
1367
- * @param agentRefused whether this failure is the agent's own answer rather than something this
1368
- * class synthesised for it — see {@link TurnResult.agentRefused}. Defaulted to `false` so that
1369
- * every existing exit keeps saying "cannot attribute this", and a new one has to opt in.
1370
- * @param errorCode the JSON-RPC code of that answer, when there was one. Recorded only under
1371
- * `agentRefused` — the single `if` below is what keeps the code and the attribution from ever
1372
- * disagreeing, rather than two conditions someone can later edit apart.
1373
- */
1374
- finishTurn(status, stopReason, error, agentRefused = false, errorCode, executionEnded = false) {
1375
- // The oldest unfinished turn, popped: one stop message (or one prompt rejection) ends exactly
1376
- // one turn, so a second overlapping turn keeps its own result instead of being swallowed.
1377
- const turn = this.inFlightTurns.shift();
1378
- if (!turn)
1379
- return;
1380
- if (this.inFlightTurns.length === 0)
1381
- this.foregroundOutput?.endTurn();
1382
- // Must land before the TurnResult below. Unconditionally safe to call — a no-op when nothing
1383
- // was accumulated — so every path that can end a turn (a normal stop, an interrupt, a failed
1384
- // request, or close() mid-turn) goes through exactly this one place rather than each needing to
1385
- // remember to flush for itself. The user half of this pair is gone: a buffered user message is
1386
- // now the server's to close, at `CoworkEngine.endBridgeTurn`, which sees the same turn boundary.
1387
- this.flushMessageCompleted();
1388
- // A turn this session itself interrupted always reports 'interrupted', regardless of what the
1389
- // agent's own response claims — mirrors the deleted Claude Code bridge's finishTurn.
1390
- const resolved = turn.interrupted ? 'interrupted' : status;
1391
- if (resolved === 'interrupted') {
1392
- this.permissionsInterrupted = true;
1393
- this.drainPendingApprovals();
1394
- this.drainPendingElicitations();
1395
- }
1396
- const result = { status: resolved };
1397
- if (turn.platformTurnId !== undefined)
1398
- result.platformTurnId = turn.platformTurnId;
1399
- if (executionEnded && this.inFlightTurns.length === 0 && !this.backgroundWriterUnconfirmed) {
1400
- result.executionEnded = true;
1401
- this.exactExecutionUnconfirmed = false;
1402
- }
1403
- if (resolved === 'completed' && stopReason && stopReason !== 'cancelled') {
1404
- result.stopReason = mapStopReason(stopReason);
1405
- }
1406
- // Echoed on every finished turn, matching the deleted Claude Code bridge's TurnResult shape —
1407
- // ACP's session id is known synchronously from session/new and never renames mid-session the way
1408
- // Claude Code's occasionally does, but a manager built against the old contract may still expect
1409
- // this field to be populated whenever it is known, not only the first time.
1410
- if (this.session.sessionId.length > 0)
1411
- result.nativeSessionId = this.session.sessionId;
1412
- if (resolved === 'failed' && error)
1413
- result.error = error;
1414
- // Gated on `resolved`, not on the requested `status`: a turn this session interrupted reports
1415
- // 'interrupted' whatever the agent said, and attributing that to the agent would be a claim
1416
- // about a result it no longer owns.
1417
- if (resolved === 'failed' && agentRefused) {
1418
- result.agentRefused = true;
1419
- if (errorCode !== undefined)
1420
- result.errorCode = errorCode;
1421
- }
1422
- this.enqueue({ kind: 'turn', result });
1423
- }
1424
- enqueue(item) {
1425
- this.outbox.push(item);
1426
- this.drain();
1427
- }
1428
- /**
1429
- * Deliver from the head of the outbox for as long as the head's sink exists. Buffering
1430
- * `session/update` output that arrives before `onEvent`/`onTurnDone` are registered — and flushing
1431
- * it in order once they are — is a contract the deleted `BridgeSession` SPI's KDoc called out
1432
- * explicitly: events emitted with no sink registered are otherwise silently lost.
1433
- *
1434
- * Stopping at the first undeliverable entry (rather than skipping it) preserves relative order
1435
- * across the two-step registration a manager does (`onEvent` first, `onTurnDone` second, or vice
1436
- * versa): a turn result queued ahead of some events must still reach its sink before those events
1437
- * reach theirs, and a single shared queue drained strictly FIFO is what guarantees that.
1438
- */
1439
- drain() {
1440
- if (this.draining)
1441
- return;
1442
- this.draining = true;
1443
- try {
1444
- while (this.outbox.length > 0) {
1445
- const head = this.outbox[0];
1446
- if (head.kind === 'event') {
1447
- const sink = this.eventSink;
1448
- if (!sink)
1449
- return;
1450
- this.outbox.shift();
1451
- this.deliver(() => sink(head.event));
1452
- }
1453
- else {
1454
- const sink = this.turnSink;
1455
- if (!sink)
1456
- return;
1457
- this.outbox.shift();
1458
- this.deliver(() => sink(head.result));
1459
- }
1460
- }
1461
- }
1462
- finally {
1463
- this.draining = false;
1464
- }
1465
- }
1466
- /** A throwing sink is the consumer's bug; it must not abort the drain. */
1467
- deliver(call) {
1468
- try {
1469
- call();
1470
- }
1471
- catch (err) {
1472
- console.error(`[acp] sink threw: ${errorMessage(err)}`);
1473
- }
1474
- }
1475
- }