@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.
- package/dist/axom-patch/cli/README.md +30 -8
- package/dist/axom-patch/cli/account-limits/README.md +27 -0
- package/dist/axom-patch/cli/account-limits/antigravity-proxy.mjs +47 -0
- package/dist/axom-patch/cli/account-limits/antigravity-read.mjs +90 -0
- package/dist/axom-patch/cli/account-limits/antigravity.mjs +46 -0
- package/dist/axom-patch/cli/account-limits/generate.py +120 -0
- package/dist/axom-patch/cli/account-limits/kimi-proxy.mjs +64 -0
- package/dist/axom-patch/cli/account-limits/normalize.mjs +151 -0
- package/dist/axom-patch/cli/claude-account-limits.json +37 -0
- package/dist/axom-patch/cli/codex-account-limits.json +37 -0
- package/dist/axom-patch/cli/commandcode-account-limits.json +30 -0
- package/dist/axom-patch/cli/nightly-task.md +152 -0
- package/dist/axom-patch/cli/pi-subagent-subwork.json +23 -0
- package/dist/axom-patch/cli/probes/claude-late-async-terminal.mjs +5 -1
- package/dist/axom-patch/cli/probes/pi-subagent-subwork.mjs +211 -0
- package/dist/axom-patch/cli/probes/qoder-steering.mjs +1 -1
- package/dist/axom-patch/cli/probes/qoder-subagent-origin.mjs +20 -8
- package/dist/axom-patch/cli/probes/zcode-subagent-subwork.mjs +257 -0
- package/dist/axom-patch/cli/qoder-background-terminal.md +2 -1
- package/dist/axom-patch/cli/qoder-foreground-output.md +4 -3
- package/dist/axom-patch/cli/qoder-steering.json +15 -15
- package/dist/axom-patch/cli/qoder-subagent-origin.json +21 -21
- package/dist/axom-patch/cli/runtime.json +34 -8
- package/dist/axom-patch/cli/zcode-account-limits.json +31 -0
- package/dist/axom-patch/cli/zcode-models.json +38 -0
- package/dist/axom-patch/cli/zcode-subagent-subwork.json +73 -0
- package/dist/cli.js +33 -6
- package/dist/keepers/acp/keeper.mjs +3083 -0
- package/dist/keepers/acp/manifest.json +9 -0
- package/dist/keepers/terminal/keeper.mjs +8469 -0
- package/dist/keepers/terminal/manifest.json +9 -0
- package/dist/keepers/terminal/node_modules/node-pty/lib/conpty_console_list_agent.js +25 -0
- package/dist/keepers/terminal/node_modules/node-pty/lib/eventEmitter2.js +47 -0
- package/dist/keepers/terminal/node_modules/node-pty/lib/index.js +52 -0
- package/dist/keepers/terminal/node_modules/node-pty/lib/interfaces.js +7 -0
- package/dist/keepers/terminal/node_modules/node-pty/lib/shared/conout.js +10 -0
- package/dist/keepers/terminal/node_modules/node-pty/lib/terminal.js +190 -0
- package/dist/keepers/terminal/node_modules/node-pty/lib/types.js +7 -0
- package/dist/keepers/terminal/node_modules/node-pty/lib/unixTerminal.js +351 -0
- package/dist/keepers/terminal/node_modules/node-pty/lib/utils.js +38 -0
- package/dist/keepers/terminal/node_modules/node-pty/lib/windowsConoutConnection.js +131 -0
- package/dist/keepers/terminal/node_modules/node-pty/lib/windowsPtyAgent.js +344 -0
- package/dist/keepers/terminal/node_modules/node-pty/lib/windowsTerminal.js +209 -0
- package/dist/keepers/terminal/node_modules/node-pty/lib/worker/conoutSocketWorker.js +22 -0
- package/dist/keepers/terminal/node_modules/node-pty/package.json +66 -0
- package/dist/keepers/terminal/node_modules/node-pty/prebuilds/darwin-arm64/pty.node +0 -0
- package/dist/keepers/terminal/node_modules/node-pty/prebuilds/darwin-arm64/spawn-helper +0 -0
- package/dist/keepers/terminal/node_modules/node-pty/prebuilds/darwin-x64/pty.node +0 -0
- package/dist/keepers/terminal/node_modules/node-pty/prebuilds/darwin-x64/spawn-helper +0 -0
- package/dist/keepers/terminal/node_modules/node-pty/prebuilds/linux-arm64/pty.node +0 -0
- package/dist/keepers/terminal/node_modules/node-pty/prebuilds/linux-x64/pty.node +0 -0
- package/dist/runtime-deps/qoder-sdk/package-lock.json +1189 -0
- package/dist/runtime-deps/qoder-sdk/package.json +8 -0
- package/dist/test/keeperKit.js +67 -0
- package/dist/workstation/acp/acpAgents.js +2163 -611
- package/dist/workstation/acp/acpClient.js +60 -497
- package/dist/workstation/acp/adoption.js +10 -396
- package/dist/workstation/acp/binaryInstall.js +100 -27
- package/dist/workstation/acp/builds/gc.js +245 -0
- package/dist/workstation/acp/builds/install/binary.js +28 -0
- package/dist/workstation/acp/builds/install/npm.js +235 -0
- package/dist/workstation/acp/builds/install/uv.js +254 -0
- package/dist/workstation/acp/builds/migrate.js +195 -0
- package/dist/workstation/acp/builds/model.js +128 -0
- package/dist/workstation/acp/builds/nodeGate.js +100 -0
- package/dist/workstation/acp/builds/store.js +352 -0
- package/dist/workstation/acp/commandTableProbe.js +68 -0
- package/dist/workstation/acp/configOptions.js +103 -1
- package/dist/workstation/acp/envSecrets.js +27 -0
- package/dist/workstation/acp/initializeResponse.js +285 -0
- package/dist/workstation/acp/installErrors.js +12 -2
- package/dist/workstation/acp/installProbe.js +1 -1
- package/dist/workstation/acp/managedPatches.js +76 -160
- package/dist/workstation/acp/outbox.js +330 -0
- package/dist/workstation/acp/patchAssets.js +20 -3
- package/dist/workstation/acp/permission.js +42 -10
- package/dist/workstation/acp/processor/checkpoint.js +87 -0
- package/dist/workstation/acp/processor/commands.js +35 -0
- package/dist/workstation/acp/processor/control.js +355 -0
- package/dist/workstation/acp/processor/emit.js +26 -0
- package/dist/workstation/acp/processor/jsonrpc.js +172 -0
- package/dist/workstation/acp/processor/memoryLog.js +49 -0
- package/dist/workstation/acp/processor/origins.js +48 -0
- package/dist/workstation/acp/processor/output.js +67 -0
- package/dist/workstation/acp/processor/projection.js +42 -0
- package/dist/workstation/acp/processor/record.js +128 -0
- package/dist/workstation/acp/processor/runtime.js +181 -0
- package/dist/workstation/acp/processor/sdkSchemas.js +61 -0
- package/dist/workstation/acp/processor/session.js +525 -0
- package/dist/workstation/acp/processor/sideChannel.js +80 -0
- package/dist/workstation/acp/processor/state.js +74 -0
- package/dist/workstation/acp/processor/step.js +459 -0
- package/dist/workstation/acp/processor/test/fixtureLog.js +65 -0
- package/dist/workstation/acp/processor/test/harness.js +120 -0
- package/dist/workstation/acp/processor/test/largeIdEcho.js +50 -0
- package/dist/workstation/acp/processor/test/rawAgent.js +70 -0
- package/dist/workstation/acp/processor/transport/generations.js +76 -0
- package/dist/workstation/acp/processor/transport/keeper.js +130 -0
- package/dist/workstation/acp/processor/transport/local.js +269 -0
- package/dist/workstation/acp/processor/transport/types.js +78 -0
- package/dist/workstation/acp/processor/updates.js +364 -0
- package/dist/workstation/acp/processorSession.js +1512 -0
- package/dist/workstation/acp/qoderStartup.js +1 -1
- package/dist/workstation/acp/registry/catalog.js +154 -32
- package/dist/workstation/acp/registry/launch.js +61 -119
- package/dist/workstation/acp/sessionUpdateMapper.js +18 -7
- package/dist/workstation/acp/steering.js +58 -0
- package/dist/workstation/acp/switching.js +114 -0
- package/dist/workstation/acp/terminalLogin.js +16 -42
- package/dist/workstation/acp/terminalLoginSpec.js +41 -0
- package/dist/workstation/acp/test/adapterFixtures.js +256 -0
- package/dist/workstation/acp/test/daemonLife.js +106 -0
- package/dist/workstation/acp/test/keeperWorld.js +267 -0
- package/dist/workstation/acp/test/memoryOutbox.js +55 -0
- package/dist/workstation/acp/test/raw/fixtures.js +196 -0
- package/dist/workstation/acp/test/raw/qoderCaptures.js +125 -0
- package/dist/workstation/acp/test/raw/sanitize.js +344 -0
- package/dist/workstation/acp/test/raw/sources.js +249 -0
- package/dist/workstation/acp/test/realAdapter.js +377 -0
- package/dist/workstation/acp/test/scriptAgent.js +206 -0
- package/dist/workstation/acp/test/sessionFrames.js +42 -0
- package/dist/workstation/acp/timedCommand.js +4 -2
- package/dist/workstation/acp/uvxInstall.js +72 -122
- package/dist/workstation/agentFlow.js +103 -0
- package/dist/workstation/agentSessions.js +1324 -474
- package/dist/workstation/channels/agent.js +7 -5
- package/dist/workstation/channels/computer.js +69 -21
- package/dist/workstation/channels/computerExecs.js +200 -0
- package/dist/workstation/channels/management.js +84 -4
- package/dist/workstation/daemon.js +288 -7
- package/dist/workstation/daemonctl.js +28 -2
- package/dist/workstation/exec/attach.js +126 -0
- package/dist/workstation/exec/command.js +40 -0
- package/dist/workstation/exec/durable.js +517 -0
- package/dist/workstation/exec/fallback.js +106 -0
- package/dist/workstation/exec/policy.js +837 -0
- package/dist/workstation/exec/shaping.js +211 -0
- package/dist/workstation/exec.js +3 -112
- package/dist/workstation/files/adapters/computerFileFrames.js +51 -0
- package/dist/workstation/files/adapters/toolRequests.js +121 -0
- package/dist/workstation/files/client.js +455 -0
- package/dist/workstation/files/errors.js +28 -0
- package/dist/workstation/files/fence.js +350 -0
- package/dist/workstation/files/find/fileIndex.js +153 -0
- package/dist/workstation/files/find/fuzzy.js +155 -0
- package/dist/workstation/files/host.js +122 -0
- package/dist/workstation/files/hostProtocol.js +2 -0
- package/dist/workstation/files/media/cache.js +620 -0
- package/dist/workstation/files/media/encoding.js +308 -0
- package/dist/workstation/files/media/estimator.js +100 -0
- package/dist/workstation/files/media/media.js +325 -0
- package/dist/workstation/files/media/packedAudio.js +23 -0
- package/dist/workstation/files/media/scheduler.js +551 -0
- package/dist/workstation/files/media/types.js +5 -0
- package/dist/workstation/files/scope.js +45 -0
- package/dist/workstation/files/structured.js +284 -0
- package/dist/workstation/{fileOps.js → files/text.js} +117 -38
- package/dist/workstation/files/uploads.js +351 -0
- package/dist/workstation/journal.js +65 -236
- package/dist/workstation/keepers/acp/client.js +101 -0
- package/dist/workstation/keepers/acp/envelope.js +62 -0
- package/dist/workstation/keepers/acp/host.js +419 -0
- package/dist/workstation/keepers/acp/main.js +18 -0
- package/dist/workstation/keepers/acp/memoryStream.js +121 -0
- package/dist/workstation/keepers/acp/params.js +137 -0
- package/dist/workstation/keepers/acp/protocol.js +66 -0
- package/dist/workstation/keepers/acp/recover.js +120 -0
- package/dist/workstation/keepers/acp/session.js +584 -0
- package/dist/workstation/keepers/acp/stderr.js +154 -0
- package/dist/workstation/keepers/kit/activity.js +103 -0
- package/dist/workstation/keepers/kit/bootstrap.js +164 -0
- package/dist/workstation/keepers/kit/buildVersion.js +51 -0
- package/dist/workstation/keepers/kit/bundle.js +120 -0
- package/dist/workstation/keepers/kit/client.js +432 -0
- package/dist/workstation/keepers/kit/identity.js +21 -0
- package/dist/workstation/keepers/kit/ipc.js +112 -0
- package/dist/workstation/keepers/kit/launchConfig.js +8 -0
- package/dist/workstation/keepers/kit/launcher.js +249 -0
- package/dist/workstation/keepers/kit/lock.js +123 -0
- package/dist/workstation/keepers/kit/orphanSweep.js +86 -0
- package/dist/workstation/keepers/kit/paths.js +75 -0
- package/dist/workstation/keepers/kit/protocol.js +43 -0
- package/dist/workstation/keepers/kit/server.js +518 -0
- package/dist/workstation/keepers/kit/streamLog.js +697 -0
- package/dist/workstation/keepers/legacy.js +296 -0
- package/dist/workstation/keepers/policy.js +576 -0
- package/dist/workstation/keepers/stop.js +34 -0
- package/dist/workstation/keepers/terminal/client.js +213 -0
- package/dist/workstation/keepers/terminal/exec.js +981 -0
- package/dist/workstation/keepers/terminal/generations.js +129 -0
- package/dist/workstation/keepers/terminal/keeper.js +62 -0
- package/dist/workstation/keepers/terminal/main.js +43 -0
- package/dist/workstation/keepers/terminal/orphanSweep.js +3 -0
- package/dist/workstation/keepers/terminal/params.js +27 -0
- package/dist/workstation/keepers/terminal/protocol.js +56 -0
- package/dist/workstation/keepers/terminal/pty.js +330 -0
- package/dist/workstation/protocol/accountLimits.js +47 -0
- package/dist/workstation/protocol/agent.js +48 -4
- package/dist/workstation/protocol/common.js +30 -0
- package/dist/workstation/protocol/computer.js +15 -4
- package/dist/workstation/protocol/management.js +50 -2
- package/dist/workstation/qoderAdapter/backgroundProcess.js +18 -6
- package/dist/workstation/qoderAdapter/backgroundTerminal.js +78 -147
- package/dist/workstation/qoderAdapter/backgroundTerminalFold.js +372 -0
- package/dist/workstation/qoderAdapter/callbackContext.js +76 -0
- package/dist/workstation/qoderAdapter/foregroundOutput.js +15 -9
- package/dist/workstation/qoderAdapter/history.js +1 -3
- package/dist/workstation/qoderAdapter/main.js +4 -2
- package/dist/workstation/qoderAdapter/sdkDriver.js +1 -2
- package/dist/workstation/qoderAdapter/sdkRuntime.js +93 -0
- package/dist/workstation/qoderAdapter/sdkRuntimeInstall.js +145 -0
- package/dist/workstation/qoderAdapter/session.js +1 -1
- package/dist/workstation/qoderAdapter/subwork.js +15 -7
- package/dist/workstation/systemMetrics.js +110 -1
- package/dist/workstation/tools/client.js +9 -1
- package/dist/workstation/tools/info.js +50 -0
- package/dist/workstation/tools/keeperProcess.js +15 -5
- package/dist/workstation/tools/router.js +55 -0
- package/dist/workstation/tools/terminalRouter.js +222 -0
- package/dist/workstation/tools/validation.js +37 -1
- package/dist/workstation/update/coordinator.js +3 -0
- package/dist/workstation/update/ptyPreflight.js +21 -0
- package/dist/workstation/update/taskRegistry.js +105 -11
- package/package.json +10 -7
- package/dist/workstation/acp/acpSession.js +0 -1475
- package/dist/workstation/acp/contract.js +0 -499
- package/dist/workstation/acp/draftInterceptor.js +0 -163
- package/dist/workstation/acp/sessionScope.js +0 -19
- package/dist/workstation/filePaths.js +0 -174
- package/dist/workstation/fileWorker.js +0 -81
- package/dist/workstation/fileWorkerClient.js +0 -121
- package/dist/workstation/qoderAdapter/taskEvent.js +0 -26
- package/dist/workstation/tools/core.js +0 -50
- package/dist/workstation/tools/files.js +0 -525
- 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
|
-
}
|