@platless/axom-cli 0.1.0 → 0.5.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/authScopes.js +19 -0
- package/dist/axom-patch/cli/README.md +132 -0
- 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-steering.json +37 -0
- package/dist/axom-patch/cli/pi-subagent-subwork.json +23 -0
- package/dist/axom-patch/cli/probes/claude-late-async-terminal.mjs +38 -0
- package/dist/axom-patch/cli/probes/pi-steering.mjs +80 -0
- package/dist/axom-patch/cli/probes/pi-subagent-subwork.mjs +211 -0
- package/dist/axom-patch/cli/probes/qoder-steering.mjs +89 -0
- package/dist/axom-patch/cli/probes/qoder-subagent-origin.mjs +61 -0
- package/dist/axom-patch/cli/probes/zcode-subagent-subwork.mjs +257 -0
- package/dist/axom-patch/cli/qoder-background-terminal.md +82 -0
- package/dist/axom-patch/cli/qoder-foreground-output.md +42 -0
- package/dist/axom-patch/cli/qoder-steering.json +52 -0
- package/dist/axom-patch/cli/qoder-steering.md +30 -0
- package/dist/axom-patch/cli/qoder-subagent-origin.json +73 -0
- package/dist/axom-patch/cli/qoder-subagent-subwork.md +73 -0
- package/dist/axom-patch/cli/runtime.json +63 -0
- package/dist/axom-patch/cli/steering/claude-steering-js.patch +89 -0
- package/dist/axom-patch/cli/steering/claude-steering-types.patch +28 -0
- package/dist/axom-patch/cli/steering/codex-steering.patch +57 -0
- package/dist/axom-patch/cli/steering/pi-steering.js +79 -0
- 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 +36 -1
- package/dist/commands/auth.js +4 -13
- package/dist/commands/execute.js +4 -4
- package/dist/commands/record.js +6 -6
- package/dist/commands/struct.js +4 -4
- package/dist/commands/workstation.js +2 -2
- package/dist/credentials.js +2 -2
- package/dist/install/claude/commands.js +13 -13
- 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/mcp.js +32 -14
- 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 +2514 -681
- package/dist/workstation/acp/acpClient.js +95 -318
- package/dist/workstation/acp/activityBusy.js +43 -0
- package/dist/workstation/acp/activityReporting.js +54 -0
- package/dist/workstation/acp/adoption.js +28 -356
- package/dist/workstation/acp/authUrlSniffer.js +36 -0
- 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/delegationMcp.js +24 -0
- 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 +144 -0
- package/dist/workstation/acp/outbox.js +330 -0
- package/dist/workstation/acp/patchAssets.js +80 -0
- package/dist/workstation/acp/permission.js +335 -37
- 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 +45 -0
- package/dist/workstation/acp/registry/catalog.js +385 -4
- package/dist/workstation/acp/registry/detect.js +55 -0
- package/dist/workstation/acp/registry/launch.js +61 -119
- package/dist/workstation/acp/sessionUpdateMapper.js +56 -27
- package/dist/workstation/acp/steering.js +58 -0
- package/dist/workstation/acp/switching.js +114 -0
- package/dist/workstation/acp/terminalLogin.js +171 -0
- 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/fakeAgent.js +185 -9
- 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/acp/validate.js +33 -5
- package/dist/workstation/agentFlow.js +103 -0
- package/dist/workstation/agentSessions.js +1686 -348
- package/dist/workstation/channels/agent.js +14 -6
- package/dist/workstation/channels/computer.js +203 -7
- package/dist/workstation/channels/computerExecs.js +200 -0
- package/dist/workstation/channels/management.js +86 -6
- package/dist/workstation/config.js +3 -1
- package/dist/workstation/connection/encryptedChannel.js +88 -21
- package/dist/workstation/daemon.js +309 -6
- package/dist/workstation/daemonctl.js +28 -2
- package/dist/workstation/desktop/binding.js +36 -0
- package/dist/workstation/desktop/bridge.js +212 -0
- package/dist/workstation/desktop/flow-control.js +52 -0
- package/dist/workstation/desktop/focused-text.js +130 -0
- package/dist/workstation/desktop/protocol.js +35 -0
- 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/files/text.js +356 -0
- 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/oauth/deviceAuthorization.js +21 -12
- package/dist/workstation/protocol/accountLimits.js +47 -0
- package/dist/workstation/protocol/agent.js +201 -8
- package/dist/workstation/protocol/common.js +36 -0
- package/dist/workstation/protocol/computer.js +107 -7
- package/dist/workstation/protocol/management.js +50 -2
- package/dist/workstation/qoderAdapter/backgroundProcess.js +118 -0
- package/dist/workstation/qoderAdapter/backgroundTerminal.js +193 -0
- package/dist/workstation/qoderAdapter/backgroundTerminalFold.js +372 -0
- package/dist/workstation/qoderAdapter/callbackContext.js +76 -0
- package/dist/workstation/qoderAdapter/completedContinuation.js +139 -0
- package/dist/workstation/qoderAdapter/contract.js +78 -0
- package/dist/workstation/qoderAdapter/foregroundOutput.js +36 -0
- package/dist/workstation/qoderAdapter/history.js +85 -0
- package/dist/workstation/qoderAdapter/identityIndex.js +191 -0
- package/dist/workstation/qoderAdapter/inputQueue.js +42 -0
- package/dist/workstation/qoderAdapter/main.js +95 -0
- package/dist/workstation/qoderAdapter/mapper.js +117 -0
- package/dist/workstation/qoderAdapter/observerPlugin.js +245 -0
- package/dist/workstation/qoderAdapter/observerReceiver.js +187 -0
- package/dist/workstation/qoderAdapter/permissions.js +61 -0
- package/dist/workstation/qoderAdapter/profile.js +93 -0
- package/dist/workstation/qoderAdapter/sdkDriver.js +48 -0
- package/dist/workstation/qoderAdapter/sdkRuntime.js +93 -0
- package/dist/workstation/qoderAdapter/sdkRuntimeInstall.js +145 -0
- package/dist/workstation/qoderAdapter/session.js +319 -0
- package/dist/workstation/qoderAdapter/subagentMeta.js +28 -0
- package/dist/workstation/qoderAdapter/subwork.js +85 -0
- package/dist/workstation/qoderAdapter/taskControl.js +38 -0
- package/dist/workstation/qoderAdapter/taskLedger.js +163 -0
- package/dist/workstation/qoderAdapter/types.js +1 -0
- package/dist/workstation/qoderAdapter/usage.js +37 -0
- package/dist/workstation/register.js +4 -0
- package/dist/workstation/systemMetrics.js +110 -1
- package/dist/workstation/tools/browser.js +394 -0
- package/dist/workstation/tools/client.js +277 -0
- package/dist/workstation/tools/info.js +50 -0
- package/dist/workstation/tools/keeperProcess.js +124 -0
- package/dist/workstation/tools/router.js +55 -0
- package/dist/workstation/tools/terminalRouter.js +222 -0
- package/dist/workstation/tools/validation.js +86 -0
- package/dist/workstation/update/coordinator.js +32 -0
- package/dist/workstation/update/npmInstaller.js +2 -0
- package/dist/workstation/update/ptyPreflight.js +45 -0
- package/dist/workstation/update/taskRegistry.js +141 -23
- package/dist/workstation/update/watchdog.js +66 -0
- package/package.json +20 -7
- package/skills/axom/SKILL.md +55 -17
- package/skills/axom-definition/SKILL.md +32 -32
- package/skills/axom-platform/SKILL.md +39 -39
- package/dist/workstation/acp/acpSession.js +0 -1047
- package/dist/workstation/acp/contract.js +0 -494
- package/dist/workstation/acp/draftInterceptor.js +0 -154
|
@@ -1,1047 +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 { methods } from '@agentclientprotocol/sdk';
|
|
40
|
-
import { jsonRpcErrorCode } from './acpClient.js';
|
|
41
|
-
import { settleUpdates } from './adoption.js';
|
|
42
|
-
import { mapSessionUpdate, SessionMapperState } from './sessionUpdateMapper.js';
|
|
43
|
-
import { flatChoicesOf, modesAlreadyRepresented, normalizeConfigOptions, SYNTHESIZED_MODES_CONFIG_ID } from './configOptions.js';
|
|
44
|
-
/**
|
|
45
|
-
* A few ACP adapters reply to `session/prompt` before their last `session/update` notifications
|
|
46
|
-
* have arrived. Those trailing chunks have no following turn boundary to synthesize
|
|
47
|
-
* `agent_message`, so the server would keep them buffered until the reader sent another message.
|
|
48
|
-
* This short quiet period lets a burst remain one message while ensuring it is rendered on its own.
|
|
49
|
-
*/
|
|
50
|
-
const LATE_MESSAGE_SETTLE_MS = 250;
|
|
51
|
-
/**
|
|
52
|
-
* Points the connection's diverted-update stream at the session that owns it.
|
|
53
|
-
*
|
|
54
|
-
* **Belongs here, not in `AcpAgents`.** Both ways a session comes into existence run through this
|
|
55
|
-
* file, and only one of them goes through the agent registry — the first attempt bound from there
|
|
56
|
-
* and every test that opens a session directly then saw *every* update diverted, because an unbound
|
|
57
|
-
* connection cannot tell its own session from a child's and must assume the worse case. Binding
|
|
58
|
-
* where the session is constructed makes "a session exists" and "the transport knows about it" the
|
|
59
|
-
* same moment.
|
|
60
|
-
*
|
|
61
|
-
* An empty id means the agent named no session, so nothing is bound and every update keeps being
|
|
62
|
-
* reported rather than silently handed to an SDK that would drop it.
|
|
63
|
-
*/
|
|
64
|
-
function bindDivertedUpdates(connection, ownSessionId, session) {
|
|
65
|
-
if (ownSessionId.length === 0)
|
|
66
|
-
return;
|
|
67
|
-
connection.bindDraftUpdates(ownSessionId, (update) => session.applyDivertedUpdate(update));
|
|
68
|
-
}
|
|
69
|
-
/**
|
|
70
|
-
* The steering extension's JSON-RPC method. **Not an ACP method** — `grep steer` over the pinned
|
|
71
|
-
* SDK's `dist/schema/types.gen.d.ts` returns nothing. It is implemented by `claude-agent-acp`
|
|
72
|
-
* (`acp-agent.js@0.76.0:164`), whose own comment calls it a *"Custom (extension) request method ...
|
|
73
|
-
* Named `_session/steering` per the agreed ACP steering wire protocol"*; the leading underscore is
|
|
74
|
-
* ACP's extension namespace. It is therefore reached through the untyped `request<Response, Params>
|
|
75
|
-
* (method: string, …)` overload rather than `methods.agent.*`, and every caller must check
|
|
76
|
-
* {@link AcpConnection.supportsSteering} first.
|
|
77
|
-
*/
|
|
78
|
-
const STEER_METHOD = '_session/steering';
|
|
79
|
-
/**
|
|
80
|
-
* Narrow a `_session/steering` response onto {@link SteerOutcome}, or throw.
|
|
81
|
-
*
|
|
82
|
-
* The extension answers with one of three outcomes (`acp-agent.js@0.76.0:1737`, `:1751`, `:1779`):
|
|
83
|
-
*
|
|
84
|
-
* - `injected` — a turn was running and the message went into it.
|
|
85
|
-
* - `promptRequired` (with `reason: "noRunningTurn"`) — no turn was running and we opted in, so the
|
|
86
|
-
* agent did nothing at all and the content stays host-owned.
|
|
87
|
-
* - `startedNewTurn` — no turn was running and the client did *not* opt in, so the agent detached a
|
|
88
|
-
* `prompt()` of its own.
|
|
89
|
-
*
|
|
90
|
-
* **`startedNewTurn` throws rather than mapping to anything** (decision D13). We always send
|
|
91
|
-
* `idleBehavior: "promptRequired"` ({@link AcpSession.steer}), so receiving it means the agent
|
|
92
|
-
* ignored the opt-in — and what it handed back is a turn nobody awaits: it never enters our queue,
|
|
93
|
-
* never lands an item, and never produces a `turn_done`, in a system that accounts by turn.
|
|
94
|
-
* Absorbing it as "injected" would mark a message delivered to a turn we are not tracking;
|
|
95
|
-
* absorbing it as "promptRequired" would submit the same message a second time. Neither is a
|
|
96
|
-
* recovery, so this fails where the contract broke, loudly enough to name.
|
|
97
|
-
*
|
|
98
|
-
* An outcome nobody recognises throws for the same reason: silently reading it as `injected` would
|
|
99
|
-
* record a delivery that may never have happened.
|
|
100
|
-
*/
|
|
101
|
-
export function mapSteerOutcome(result) {
|
|
102
|
-
const outcome = result?.outcome;
|
|
103
|
-
if (outcome === 'injected')
|
|
104
|
-
return 'injected';
|
|
105
|
-
if (outcome === 'promptRequired')
|
|
106
|
-
return 'promptRequired';
|
|
107
|
-
if (outcome === 'startedNewTurn') {
|
|
108
|
-
throw new Error(`[acp] the agent answered ${STEER_METHOD} with 'startedNewTurn', a turn this client never ` +
|
|
109
|
-
"opted into and does not track: we always send _meta.steering.idleBehavior = 'promptRequired'.");
|
|
110
|
-
}
|
|
111
|
-
throw new Error(`[acp] unrecognised ${STEER_METHOD} outcome: ${JSON.stringify(outcome)}`);
|
|
112
|
-
}
|
|
113
|
-
/**
|
|
114
|
-
* The value `registerPendingApproval`'s resolver receives when nobody is ever going to answer —
|
|
115
|
-
* the session closed, the connection died, or the agent itself cancelled the request — rather
|
|
116
|
-
* than a real user choice. `PermissionOptionId` is an unconstrained `string`, so a fixed literal
|
|
117
|
-
* like `'deny'` risks colliding with an agent's own option id and turning "nobody answered" into
|
|
118
|
-
* a fabricated selection (review finding: task-5, item 6). Randomized per process start and never
|
|
119
|
-
* sent over the wire to any agent, so no real option id can ever equal it in practice.
|
|
120
|
-
*/
|
|
121
|
-
const ABANDONED_ANSWER = `__acp_session_abandoned_${randomUUID()}__`;
|
|
122
|
-
/** 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. */
|
|
123
|
-
function mapStopReason(reason) {
|
|
124
|
-
switch (reason) {
|
|
125
|
-
case 'end_turn':
|
|
126
|
-
return 'end_turn';
|
|
127
|
-
case 'max_tokens':
|
|
128
|
-
return 'max_tokens';
|
|
129
|
-
case 'max_turn_requests':
|
|
130
|
-
return 'max_turns';
|
|
131
|
-
case 'refusal':
|
|
132
|
-
return 'refusal';
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
function errorMessage(err) {
|
|
136
|
-
return err instanceof Error ? err.message : String(err);
|
|
137
|
-
}
|
|
138
|
-
/**
|
|
139
|
-
* One `_session/steering` round trip, shared verbatim by {@link AcpSession.open} and
|
|
140
|
-
* {@link AcpSession.attach} — the two differ only in where the session id comes from.
|
|
141
|
-
*
|
|
142
|
-
* The request shape is the extension's `parseSteerRequest` (`acp-agent.js@0.76.0:192-215`): a non-empty
|
|
143
|
-
* `sessionId`, a non-empty `prompt` array of ACP `ContentBlock`s, and an optional
|
|
144
|
-
* `_meta.steering.idleBehavior` whose **only** accepted value is `promptRequired` — anything else is
|
|
145
|
-
* `invalidParams`. We always send it; see {@link mapSteerOutcome} for why.
|
|
146
|
-
*
|
|
147
|
-
* `blocks` is ACP's own `ContentBlock[]`, exactly like {@link AcpSession.prompt} — **this boundary
|
|
148
|
-
* used to narrow it to a `string`, and that narrowing was ours, not the protocol's**. The extension
|
|
149
|
-
* takes a `prompt` array and hands it to the same `promptToClaude(params)` a `session/prompt` goes
|
|
150
|
-
* through, so an image steers as well as it prompts. While this parameter was a `string`, an image
|
|
151
|
-
* attached to a message the reader chose to interject stopped dead here — split off into a queue row
|
|
152
|
-
* of its own, leaving *"this is what I mean"* next to nothing.
|
|
153
|
-
*
|
|
154
|
-
* `priority` is the extension's `_meta.steering.priority`. **`undefined` means the caller is
|
|
155
|
-
* deliberately naming none**, and that is not the same as picking the default: the adapter silently
|
|
156
|
-
* ignores a value it does not recognise and only `next` is lossless, so a timing is only sent when
|
|
157
|
-
* the adapter declared it accepts it. (The adapter's own comment about its internal
|
|
158
|
-
* `STEER_PRIORITY_NOW` / `STEER_PRIORITY_LATER` constants — *"an internal Claude implementation
|
|
159
|
-
* detail, not part of the wire contract"*, `acp-agent.js@0.76.0:181-188` — is about those
|
|
160
|
-
* constants, not about `_meta`, which `parseSteerRequest` passes through whole. 0.76.0 picks the
|
|
161
|
-
* priority itself (`:1762-1763`) and ignores `_meta.steering.priority`. This sentence used to
|
|
162
|
-
* name a symbol `STEER_PRIORITY`; there is no such symbol in 0.76.0.)
|
|
163
|
-
*/
|
|
164
|
-
async function steerRequest(connection, sessionId, blocks, priority) {
|
|
165
|
-
const result = await connection.connection.agent.request(STEER_METHOD, {
|
|
166
|
-
sessionId,
|
|
167
|
-
prompt: blocks,
|
|
168
|
-
_meta: { steering: { idleBehavior: 'promptRequired', ...(priority === undefined ? {} : { priority }) } },
|
|
169
|
-
});
|
|
170
|
-
return mapSteerOutcome(result);
|
|
171
|
-
}
|
|
172
|
-
/**
|
|
173
|
-
* One `session/set_config_option` (`SetSessionConfigOptionRequest`, `types.gen.d.ts:5102-5132`) or
|
|
174
|
-
* — for the daemon's synthesized {@link SYNTHESIZED_MODES_CONFIG_ID} — `session/set_mode`
|
|
175
|
-
* (`SetSessionModeRequest`, `:5079`), shared by {@link AcpSession.open} and {@link AcpSession.attach}
|
|
176
|
-
* the same way {@link steerRequest} is.
|
|
177
|
-
*
|
|
178
|
-
* Returns the normalized, full snapshot either way. `session/set_config_option`'s response carries
|
|
179
|
-
* the agent's own full `configOptions` list (`SetSessionConfigOptionResponse`, `:2949-2964`; the
|
|
180
|
-
* `configOptions` field itself at `:2953`) — which never carries the synthesized `acp:modes` entry,
|
|
181
|
-
* so a synthesized entry from the last snapshot is carried across by hand. `session/set_mode`'s
|
|
182
|
-
* response carries no list at all (`SetSessionModeResponse`, `:2934-2945`), so that snapshot is
|
|
183
|
-
* built locally by applying `value` to the synthesized entry already in `current()`.
|
|
184
|
-
*
|
|
185
|
-
* `current` and `remember` are how the two construction paths ({@link AcpSession.open}'s closure
|
|
186
|
-
* over its own `let snapshot`, {@link AcpSession.attach}'s over its own) each keep one mutable
|
|
187
|
-
* "latest snapshot" cell without this function needing to know which kind of session it is serving.
|
|
188
|
-
*/
|
|
189
|
-
function setConfigOptionRequest(connection, sessionId, current, remember) {
|
|
190
|
-
return async (configId, value) => {
|
|
191
|
-
const agent = connection.connection.agent;
|
|
192
|
-
if (configId === SYNTHESIZED_MODES_CONFIG_ID) {
|
|
193
|
-
if (typeof value !== 'string') {
|
|
194
|
-
throw new Error(`[acp] ${SYNTHESIZED_MODES_CONFIG_ID} takes a mode id (a string), got ${typeof value}`);
|
|
195
|
-
}
|
|
196
|
-
await agent.request(methods.agent.session.setMode, { sessionId, modeId: value });
|
|
197
|
-
const next = current().map((o) => (o.id === configId && o.type === 'select' ? { ...o, currentValue: value } : o));
|
|
198
|
-
remember(next);
|
|
199
|
-
return next;
|
|
200
|
-
}
|
|
201
|
-
const params = typeof value === 'boolean' ? { sessionId, configId, type: 'boolean', value } : { sessionId, configId, value };
|
|
202
|
-
const response = await agent.request(methods.agent.session.setConfigOption, params);
|
|
203
|
-
// The agent's reply has no `modes` field to normalize against — a synthesized entry, if there
|
|
204
|
-
// was one, survives only by being carried across from the last snapshot.
|
|
205
|
-
//
|
|
206
|
-
// Carried across under the *same* condition `normalizeConfigOptions` synthesizes under
|
|
207
|
-
// ({@link modesAlreadyRepresented}), not unconditionally: an agent that only starts declaring
|
|
208
|
-
// its own equivalent control *after* we synthesized one — it grew a `category: "mode"` option
|
|
209
|
-
// between the open and this set, or (pi's shape) it already offers the identical choice list
|
|
210
|
-
// under an id of its own — would otherwise get two pickers for one thing on the same session,
|
|
211
|
-
// its real one and our stale stand-in for it. The agent's own always wins.
|
|
212
|
-
const reNormalized = normalizeConfigOptions(response.configOptions, null);
|
|
213
|
-
const carried = current().find((o) => o.id === SYNTHESIZED_MODES_CONFIG_ID);
|
|
214
|
-
const carriedChoices = (carried ? flatChoicesOf(carried) : null) ?? [];
|
|
215
|
-
const synthesized = carried && !modesAlreadyRepresented(reNormalized, carriedChoices) ? carried : undefined;
|
|
216
|
-
const next = reNormalized.concat(synthesized ? [synthesized] : []);
|
|
217
|
-
remember(next);
|
|
218
|
-
return next;
|
|
219
|
-
};
|
|
220
|
-
}
|
|
221
|
-
/** `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. */
|
|
222
|
-
function stringField(payload, key) {
|
|
223
|
-
const value = payload[key];
|
|
224
|
-
return typeof value === 'string' ? value : null;
|
|
225
|
-
}
|
|
226
|
-
/**
|
|
227
|
-
* The queue behind an attached session's `nextUpdate()`, standing in for the one the SDK's
|
|
228
|
-
* `ActiveSession` keeps internally.
|
|
229
|
-
*
|
|
230
|
-
* Three producers feed it and they must interleave in arrival order: `session/update` notifications
|
|
231
|
-
* (via {@link LiveUpdateSource}), the `session/prompt` response that ends a turn, and that same
|
|
232
|
-
* request's rejection. `ActiveSession` routes all three onto one queue — which is what lets
|
|
233
|
-
* {@link AcpSession.runReadLoop} be a single loop — so this reproduces that exactly rather than
|
|
234
|
-
* giving the prompt result its own channel.
|
|
235
|
-
*
|
|
236
|
-
* {@link fail} is the `dispose()` half: it is permanent and drops anything still buffered, because
|
|
237
|
-
* its only caller is a session that is closing. Leaving buffered updates readable there would emit
|
|
238
|
-
* events for a session the manager has already retired.
|
|
239
|
-
*/
|
|
240
|
-
class AttachedQueue {
|
|
241
|
-
items = [];
|
|
242
|
-
waiters = [];
|
|
243
|
-
failure = null;
|
|
244
|
-
push(value) {
|
|
245
|
-
this.settle({ ok: true, value });
|
|
246
|
-
}
|
|
247
|
-
/**
|
|
248
|
-
* A one-shot rejection — a failed `session/prompt`, not a dead queue. `runReadLoop` turns exactly
|
|
249
|
-
* this into a `failed` TurnResult and then keeps reading for the next prompt, mirroring how
|
|
250
|
-
* `ActiveSession.prompt()` routes its own rejection onto the same queue.
|
|
251
|
-
*/
|
|
252
|
-
pushError(error) {
|
|
253
|
-
this.settle({ ok: false, error });
|
|
254
|
-
}
|
|
255
|
-
fail(error) {
|
|
256
|
-
if (this.failure)
|
|
257
|
-
return;
|
|
258
|
-
this.failure = { error };
|
|
259
|
-
this.items.length = 0;
|
|
260
|
-
for (const waiter of this.waiters.splice(0))
|
|
261
|
-
waiter.reject(error);
|
|
262
|
-
}
|
|
263
|
-
next() {
|
|
264
|
-
const head = this.items.shift();
|
|
265
|
-
if (head)
|
|
266
|
-
return head.ok ? Promise.resolve(head.value) : Promise.reject(head.error);
|
|
267
|
-
if (this.failure)
|
|
268
|
-
return Promise.reject(this.failure.error);
|
|
269
|
-
return new Promise((resolve, reject) => {
|
|
270
|
-
this.waiters.push({ resolve, reject });
|
|
271
|
-
});
|
|
272
|
-
}
|
|
273
|
-
settle(item) {
|
|
274
|
-
if (this.failure)
|
|
275
|
-
return;
|
|
276
|
-
const waiter = this.waiters.shift();
|
|
277
|
-
if (!waiter) {
|
|
278
|
-
this.items.push(item);
|
|
279
|
-
return;
|
|
280
|
-
}
|
|
281
|
-
if (item.ok)
|
|
282
|
-
waiter.resolve(item.value);
|
|
283
|
-
else
|
|
284
|
-
waiter.reject(item.error);
|
|
285
|
-
}
|
|
286
|
-
}
|
|
287
|
-
export class AcpSession {
|
|
288
|
-
session;
|
|
289
|
-
outbox = [];
|
|
290
|
-
/**
|
|
291
|
-
* This session's `toolCallId → name` memory, for the `tool_call_update`s ACP lets arrive with
|
|
292
|
-
* no `name` on them ("unchanged", not "unknown" — see {@link SessionMapperState}).
|
|
293
|
-
*
|
|
294
|
-
* Per instance, like every other cross-update field here, and for the same reason `inFlightTurns`
|
|
295
|
-
* is: one `AcpSession` is one ACP session, and a registry shared between two of them would let
|
|
296
|
-
* one session's tool call name a different session's update.
|
|
297
|
-
*/
|
|
298
|
-
mapperState = new SessionMapperState();
|
|
299
|
-
eventSink = null;
|
|
300
|
-
turnSink = null;
|
|
301
|
-
draining = false;
|
|
302
|
-
/**
|
|
303
|
-
* Every turn this session has started and not yet reported a result for, oldest first.
|
|
304
|
-
*
|
|
305
|
-
* A list rather than a single `currentTurn`, because `session/prompt` is a request per turn and
|
|
306
|
-
* ACP places no bar on a second one going out while the first is still running — the SDK's
|
|
307
|
-
* `ActiveSession.prompt` enqueues a `{kind:'stop'}` message per call, so two prompts really do
|
|
308
|
-
* produce two completions on the read queue, in the order the agent answers them. Folding a
|
|
309
|
-
* second prompt into the first turn (`currentTurn ??=`) meant the second `stop` found no turn to
|
|
310
|
-
* finish and its result was dropped outright: the second turn's deltas arrived, but no
|
|
311
|
-
* `agent_message` ever flushed the server's delta buffer for them and no `turn_done` ever
|
|
312
|
-
* reached the run, which stayed parked on work that had already finished. Pinned by clause 7 of
|
|
313
|
-
* `contract.ts`.
|
|
314
|
-
*
|
|
315
|
-
* Strictly FIFO, which is an assumption worth naming: a stop message is matched to the *oldest*
|
|
316
|
-
* unfinished turn rather than to the prompt that produced it, because JSON-RPC ids live inside
|
|
317
|
-
* the SDK and never reach this class. Two overlapping turns that the agent answers out of order
|
|
318
|
-
* therefore swap results. Both results still arrive, which is what everything downstream needs;
|
|
319
|
-
* per-prompt attribution would need an id ACP does not put on `session/update` either.
|
|
320
|
-
*/
|
|
321
|
-
inFlightTurns = [];
|
|
322
|
-
/** Timer which closes assistant text that an adapter sent after it had already ended the turn. */
|
|
323
|
-
lateMessageFlushTimer = null;
|
|
324
|
-
/**
|
|
325
|
-
* Whether a `session/prompt` round trip is outstanding on the agent **right now**.
|
|
326
|
-
*
|
|
327
|
-
* This is the turn's ground truth: {@link inFlightTurns} is pushed when the prompt goes out and
|
|
328
|
-
* shifted when the agent answers, so it says what the agent is doing rather than what anything
|
|
329
|
-
* else recorded about it. The daemon's own `TaskRegistry` bracket is bookkeeping over the same
|
|
330
|
-
* events and can outlive them (a steer on an idle session, a bracket never closed); this cannot.
|
|
331
|
-
*/
|
|
332
|
-
get turnRunning() {
|
|
333
|
-
return this.inFlightTurns.length > 0;
|
|
334
|
-
}
|
|
335
|
-
/** Whether this session's stdio is still readable -- false once the read loop died. */
|
|
336
|
-
get readable() {
|
|
337
|
-
return this.readLoopDead === null;
|
|
338
|
-
}
|
|
339
|
-
closed = false;
|
|
340
|
-
connectionAborted = false;
|
|
341
|
-
/**
|
|
342
|
-
* Why {@link runReadLoop} stopped for good, or `null` while it is still reading. Set at all three
|
|
343
|
-
* of the loop's permanent exits, so the flag means exactly "no `stop` message can ever arrive
|
|
344
|
-
* again"; {@link prompt} is the reader.
|
|
345
|
-
*
|
|
346
|
-
* Without it, a prompt sent after the loop died was `session/prompt`-ed into a queue nobody reads:
|
|
347
|
-
* no `TurnResult`, no `turn_done`, no error card and no timeout, and every retry the user makes
|
|
348
|
-
* joins the same silence. Two of the consequences live outside this file, which is why no
|
|
349
|
-
* per-file review caught them:
|
|
350
|
-
*
|
|
351
|
-
* - `agentSessions.ts` adds the ref to `TaskRegistry`'s `agentTasks` on every `agent_prompt` and
|
|
352
|
-
* only `onAgentTurnDone` takes it out again, so one hung prompt pins `canUpdate()` to `false`
|
|
353
|
-
* for the rest of this process's life — the daemon refuses **every** self-update until that
|
|
354
|
-
* session is explicitly closed or swept.
|
|
355
|
-
* - Each retry `touch()`es the session, so retrying keeps `lastActivityAt` fresh and `sweepIdle`
|
|
356
|
-
* never reclaims it either.
|
|
357
|
-
*
|
|
358
|
-
* Deliberately a flag of its own rather than reusing {@link closed}: `close()` short-circuits its
|
|
359
|
-
* whole body on `closed`, so setting that here would skip `session/cancel` and `dispose()` on a
|
|
360
|
-
* session that was never actually closed.
|
|
361
|
-
*/
|
|
362
|
-
readLoopDead = null;
|
|
363
|
-
/** Tracks the streaming assistant message for `agent_message` synthesis — see {@link flushMessageCompleted}. */
|
|
364
|
-
sawMessageContent = false;
|
|
365
|
-
currentMessageId = null;
|
|
366
|
-
/**
|
|
367
|
-
* `requestId` → the resolver for one outstanding `session/request_permission` round trip.
|
|
368
|
-
* `AcpSession` owns this map (rather than `permission.ts` keeping its own) purely so `close()`
|
|
369
|
-
* can settle every outstanding card the same way the deleted Claude Code bridge's
|
|
370
|
-
* `denyAllPending()` did — a request with no user left to answer it must not hang the agent
|
|
371
|
-
* process forever. `permission.ts` is the only thing that populates it, via
|
|
372
|
-
* {@link registerPendingApproval}; nothing in this file does.
|
|
373
|
-
*/
|
|
374
|
-
pendingApprovals = new Map();
|
|
375
|
-
readLoopDone;
|
|
376
|
-
constructor(session, connectionSignal) {
|
|
377
|
-
this.session = session;
|
|
378
|
-
if (connectionSignal.aborted)
|
|
379
|
-
this.connectionAborted = true;
|
|
380
|
-
else
|
|
381
|
-
connectionSignal.addEventListener('abort', () => (this.connectionAborted = true), { once: true });
|
|
382
|
-
this.readLoopDone = this.runReadLoop();
|
|
383
|
-
}
|
|
384
|
-
/**
|
|
385
|
-
* Open a new ACP session via `session/new` and start driving it.
|
|
386
|
-
*
|
|
387
|
-
* `nativeSessionId` mirrors the deleted `AgentBridge.open()` contract verbatim: `null` when no
|
|
388
|
-
* usable id came back, **never** `''` — the server persists any non-null value it is given as an
|
|
389
|
-
* identity and later resumes from it, so an agent that technically returns an empty string must be
|
|
390
|
-
* degraded to `null` here rather than trusted at face value.
|
|
391
|
-
*/
|
|
392
|
-
static async open(connection, options) {
|
|
393
|
-
const activeSession = await connection.connection.agent.buildSession(options.cwd).start();
|
|
394
|
-
// `NewSessionResponse` (`acp.d.ts:329`'s `newSessionResponse` getter) carries both halves
|
|
395
|
-
// `normalizeConfigOptions` needs: `configOptions` (`types.gen.d.ts:2546`) and `modes` (`:2542`).
|
|
396
|
-
const response = activeSession.newSessionResponse;
|
|
397
|
-
let snapshot = normalizeConfigOptions(response.configOptions, response.modes);
|
|
398
|
-
const drivenSession = {
|
|
399
|
-
sessionId: activeSession.sessionId,
|
|
400
|
-
// `ActiveSession.prompt`'s own parameter is already `string | ContentBlock | ContentBlock[]`
|
|
401
|
-
// (`acp.d.ts:338`) — it is this class's `DrivenSession` boundary that was narrow, not the SDK's.
|
|
402
|
-
prompt: (blocks) => activeSession.prompt(blocks),
|
|
403
|
-
nextUpdate: () => activeSession.nextUpdate(),
|
|
404
|
-
dispose: () => activeSession.dispose(),
|
|
405
|
-
// A notification (`ClientContext.notify`, not `.request`) — `session/cancel` cancels the whole
|
|
406
|
-
// prompt turn and expects no response, unlike `$/cancel_request`'s per-request
|
|
407
|
-
// `SendRequestOptions.cancellationSignal` mechanism.
|
|
408
|
-
cancel: () => connection.connection.agent.notify(methods.agent.session.cancel, { sessionId: activeSession.sessionId }),
|
|
409
|
-
steer: (blocks, priority) => steerRequest(connection, activeSession.sessionId, blocks, priority),
|
|
410
|
-
setConfigOption: setConfigOptionRequest(connection, activeSession.sessionId, () => snapshot, (next) => (snapshot = next)),
|
|
411
|
-
};
|
|
412
|
-
const session = new AcpSession(drivenSession, connection.connection.signal);
|
|
413
|
-
const nativeSessionId = activeSession.sessionId.length > 0 ? activeSession.sessionId : null;
|
|
414
|
-
bindDivertedUpdates(connection, activeSession.sessionId, session);
|
|
415
|
-
return { session, nativeSessionId, configOptions: snapshot };
|
|
416
|
-
}
|
|
417
|
-
/**
|
|
418
|
-
* Drive a session the agent has **already loaded** (`session/load`, see `adoption.ts`) instead of
|
|
419
|
-
* opening a new one — the path a reconnect and an adoption both take.
|
|
420
|
-
*
|
|
421
|
-
* ### Why this exists at all
|
|
422
|
-
*
|
|
423
|
-
* `agentSessions.ts` is handed a `nativeSessionId` on every reconnect, not only on adoption: the
|
|
424
|
-
* server persists the id and sends it back with `agent_open`. Answering that by opening a fresh
|
|
425
|
-
* `session/new` and replaying the old session's history as read-only display would throw away the
|
|
426
|
-
* agent's own context on every reconnect — the deleted Claude Code bridge really did resume, so
|
|
427
|
-
* that would be a regression, not an equivalent swap.
|
|
428
|
-
*
|
|
429
|
-
* ### Why it is hand-built rather than an SDK call
|
|
430
|
-
*
|
|
431
|
-
* The SDK offers no way to attach its `ActiveSession` to an already-loaded session: the class has a
|
|
432
|
-
* private constructor and the only thing that produces one is `SessionBuilder.start()`, which
|
|
433
|
-
* *is* `session/new` (`@agentclientprotocol/sdk/dist/acp.d.ts`). So the second construction path is
|
|
434
|
-
* this: the same {@link DrivenSession} surface, assembled from raw requests —
|
|
435
|
-
* `prompt` → `session/prompt`, `cancel` → the `session/cancel` notification, `nextUpdate` →
|
|
436
|
-
* {@link AttachedQueue} fed by `updates.listen(...)` plus the prompt's own completion, `dispose` →
|
|
437
|
-
* unsubscribe and fail the queue. Everything above this line — the read loop, the outbox, the
|
|
438
|
-
* `agent_message` synthesis, the pending-approval map — is shared verbatim with
|
|
439
|
-
* {@link AcpSession.open}; only how the four calls reach the agent differs.
|
|
440
|
-
*
|
|
441
|
-
* The caller is responsible for having already issued `session/load` for `sessionId` (and, when it
|
|
442
|
-
* wants that history, for having captured the replay it produced). Call `updates.listen` here
|
|
443
|
-
* rather than earlier so nothing is dropped in between: `ReplayCapture` withholds updates from a
|
|
444
|
-
* listener only while the id is armed, and it is disarmed by the `take()` that ends the replay.
|
|
445
|
-
*
|
|
446
|
-
* ### `initial.configOptions` is read lazily, not copied at construction
|
|
447
|
-
*
|
|
448
|
-
* `AcpAgents.startSession` (Task 3) must construct this session *before* issuing `session/load` —
|
|
449
|
-
* the session has to be subscribed to the live update stream before the replay begins — so the
|
|
450
|
-
* snapshot (which only exists once `session/load`'s response arrives, via `adoption.ts`'s
|
|
451
|
-
* `ReplaySessionParams.onLoaded`) is not there yet at the moment this runs. An eager
|
|
452
|
-
* `let snapshot = initial.configOptions` at construction time would therefore capture an empty
|
|
453
|
-
* array forever. `current()` instead re-reads `initial.configOptions` every time, until a real
|
|
454
|
-
* `setConfigOption` call produces a snapshot of its own — so a caller that populates `initial`
|
|
455
|
-
* (a mutable object) after construction is still seen.
|
|
456
|
-
*/
|
|
457
|
-
static attach(connection, sessionId, updates, initial) {
|
|
458
|
-
const agent = connection.connection.agent;
|
|
459
|
-
const queue = new AttachedQueue();
|
|
460
|
-
// Counted, not just forwarded: `settleUpdates` below needs a growth signal for this session's
|
|
461
|
-
// live stream, the way the replay path uses `ReplayCapture.count`.
|
|
462
|
-
let delivered = 0;
|
|
463
|
-
const unsubscribe = updates.listen(sessionId, (update) => {
|
|
464
|
-
delivered += 1;
|
|
465
|
-
queue.push({ kind: 'session_update', update });
|
|
466
|
-
});
|
|
467
|
-
let snapshot = null;
|
|
468
|
-
const current = () => snapshot ?? initial.configOptions;
|
|
469
|
-
const driven = {
|
|
470
|
-
sessionId,
|
|
471
|
-
async prompt(blocks) {
|
|
472
|
-
try {
|
|
473
|
-
const response = await agent.request(methods.agent.session.prompt, {
|
|
474
|
-
sessionId,
|
|
475
|
-
prompt: blocks,
|
|
476
|
-
});
|
|
477
|
-
// The response resolving does NOT mean this turn's own `session/update` notifications have
|
|
478
|
-
// been dispatched — the SDK applies a response synchronously off the reader but hands a
|
|
479
|
-
// notification to an un-awaited handler chain (see `settleUpdates`, which documents the
|
|
480
|
-
// measurement). Without this drain, the `stop` below jumps the queue ahead of deltas the
|
|
481
|
-
// reader had already pulled off the wire, and `agent_message`/`TurnResult` land
|
|
482
|
-
// mid-message: the server's CoworkEngine closes the item on `agent_message`, so the
|
|
483
|
-
// remaining text arrives orphaned, and an `agent_close` racing the early `turn_done` can
|
|
484
|
-
// drop it outright (`AttachedQueue.fail` discards what is still buffered).
|
|
485
|
-
//
|
|
486
|
-
// Not a `settleUpdates` gap the SDK-routed path shares: `ActiveSession` is routed by a
|
|
487
|
-
// *static* handler that runs first in the chain, which is why `AcpSession.open` never
|
|
488
|
-
// needed this and `attach` does.
|
|
489
|
-
await settleUpdates(sessionId, () => delivered);
|
|
490
|
-
// Enqueued, not just returned: the read loop — not this caller — is what turns a completed
|
|
491
|
-
// turn into a TurnResult, exactly as `ActiveSession.prompt()` does it.
|
|
492
|
-
queue.push({ kind: 'stop', stopReason: response.stopReason });
|
|
493
|
-
return { stopReason: response.stopReason };
|
|
494
|
-
}
|
|
495
|
-
catch (err) {
|
|
496
|
-
// Same routing for the failure: `runReadLoop` reads it off the queue and reports a `failed`
|
|
497
|
-
// turn. Rethrown as well so the shape matches `ActiveSession.prompt()` — `AcpSession.prompt`
|
|
498
|
-
// swallows it with a `.catch(() => {})` precisely because the queue is the real channel.
|
|
499
|
-
// Drained first for the same reason the success path is: an agent that streamed some of a
|
|
500
|
-
// turn and *then* errored has the identical reordering hazard, and the failed TurnResult
|
|
501
|
-
// must not overtake the text that preceded it.
|
|
502
|
-
await settleUpdates(sessionId, () => delivered);
|
|
503
|
-
queue.pushError(err);
|
|
504
|
-
throw err;
|
|
505
|
-
}
|
|
506
|
-
},
|
|
507
|
-
nextUpdate: () => queue.next(),
|
|
508
|
-
cancel: () => agent.notify(methods.agent.session.cancel, { sessionId }),
|
|
509
|
-
steer: (blocks, priority) => steerRequest(connection, sessionId, blocks, priority),
|
|
510
|
-
setConfigOption: setConfigOptionRequest(connection, sessionId, current, (next) => (snapshot = next)),
|
|
511
|
-
dispose: () => {
|
|
512
|
-
unsubscribe();
|
|
513
|
-
queue.fail(new Error(`[acp] session ${sessionId} was disposed`));
|
|
514
|
-
},
|
|
515
|
-
};
|
|
516
|
-
const attached = new AcpSession(driven, connection.connection.signal);
|
|
517
|
-
bindDivertedUpdates(connection, sessionId, attached);
|
|
518
|
-
return attached;
|
|
519
|
-
}
|
|
520
|
-
// -------------------------------------------------------------------------------------------
|
|
521
|
-
// The surface `agentSessions.ts` drives (carried over from the deleted BridgeSession SPI)
|
|
522
|
-
// -------------------------------------------------------------------------------------------
|
|
523
|
-
/**
|
|
524
|
-
* Send one `session/prompt` turn. `blocks` is ACP's own `ContentBlock[]` (see {@link DrivenSession}
|
|
525
|
-
* for why this class no longer narrows it to a `string`) — a lone text block is what every caller
|
|
526
|
-
* sends today; an image block is only ever valid here for a session whose agent advertised
|
|
527
|
-
* `PromptCapabilities.image` (`AcpConnection.capabilities.promptImage`, surfaced to callers as
|
|
528
|
-
* `AcpAgentSession.supportsImageInput`), and enforcing that is the caller's job, not this method's.
|
|
529
|
-
*/
|
|
530
|
-
prompt(blocks) {
|
|
531
|
-
// Checked ahead of `closed`, and the reason this branch exists at all: a session can outlive its
|
|
532
|
-
// read loop without anyone having closed it (a dead connection, an unmappable update), and then
|
|
533
|
-
// nothing but this call is left to end the turn. Registered as a turn and finished on the spot,
|
|
534
|
-
// rather than reported some other way, so that "one prompt, one terminal result" holds on this
|
|
535
|
-
// path exactly as it does on the live one — `agentSessions.ts` unblocks `TaskRegistry` and the
|
|
536
|
-
// server closes the round off `onTurnDone` and nothing else. See {@link readLoopDead}.
|
|
537
|
-
if (this.readLoopDead !== null) {
|
|
538
|
-
this.inFlightTurns.push({ interrupted: false });
|
|
539
|
-
this.finishTurn('failed', undefined, this.readLoopDead);
|
|
540
|
-
return;
|
|
541
|
-
}
|
|
542
|
-
if (this.closed) {
|
|
543
|
-
console.warn('[acp] dropping prompt for a session that is no longer running');
|
|
544
|
-
return;
|
|
545
|
-
}
|
|
546
|
-
// An adapter may have ended the preceding prompt before delivering its final chunks. Do not let
|
|
547
|
-
// those chunks borrow this prompt's completion marker: that makes the old answer appear only
|
|
548
|
-
// after the reader submits a new message.
|
|
549
|
-
this.flushLateMessageCompletion();
|
|
550
|
-
// One turn per prompt, always — see {@link inFlightTurns} for why an overlapping prompt must not
|
|
551
|
-
// fold into the turn already running.
|
|
552
|
-
this.inFlightTurns.push({ interrupted: false });
|
|
553
|
-
// The `.catch(() => {})` is not error handling — `ActiveSession.prompt()` already routes this
|
|
554
|
-
// same rejection onto the `nextUpdate()` queue internally (see the file doc comment), and
|
|
555
|
-
// `runReadLoop`'s catch branch is what turns it into a `TurnResult`. It exists only so the
|
|
556
|
-
// promise this call itself produces is never flagged as an unhandled rejection.
|
|
557
|
-
this.session.prompt(blocks).catch(() => { });
|
|
558
|
-
}
|
|
559
|
-
/**
|
|
560
|
-
* Inject a message into the turn that is currently running, instead of queueing it behind that
|
|
561
|
-
* turn as a second `session/prompt` would.
|
|
562
|
-
*
|
|
563
|
-
* **Injecting aborts.** The adapter's own comment (`acp-agent.js@0.76.0:1702-1704`): *"Pre-empting
|
|
564
|
-
* means ABORTING: the interrupted cycle emits a `result` of its own and the steered message runs
|
|
565
|
-
* as a second one, so the turn is marked (`Turn.steeredEchoes`) to settle at the SDK's `idle`
|
|
566
|
-
* instead of that result."* From this class's side that is invisible and must stay so: no
|
|
567
|
-
* {@link inFlightTurns} entry is pushed here, because steering does **not** create a turn — the
|
|
568
|
-
* one `stop` message still on its way belongs to the prompt that is already registered. Pushing
|
|
569
|
-
* one would leave a turn that nothing can ever finish.
|
|
570
|
-
*
|
|
571
|
-
* The steered message's own output arrives through `session/update` like any other, **not** in
|
|
572
|
-
* this response (`:1115-1116`), so the only thing a caller learns here is the disposition.
|
|
573
|
-
*
|
|
574
|
-
* Callers must have checked {@link AcpConnection.supportsSteering} — this is an extension, and an
|
|
575
|
-
* agent that never advertised it answers `Method not found`.
|
|
576
|
-
*
|
|
577
|
-
* `blocks` carries the **whole** message, images included, for the reason set out on
|
|
578
|
-
* {@link steerRequest}; `priority` is the delivery timing, `undefined` meaning the caller is
|
|
579
|
-
* naming none rather than picking one.
|
|
580
|
-
*/
|
|
581
|
-
async steer(blocks, priority) {
|
|
582
|
-
if (this.closed)
|
|
583
|
-
throw new Error('[acp] cannot steer a session that is no longer running');
|
|
584
|
-
return this.session.steer(blocks, priority);
|
|
585
|
-
}
|
|
586
|
-
/**
|
|
587
|
-
* `session/set_config_option` — or, for the daemon's synthesized {@link SYNTHESIZED_MODES_CONFIG_ID},
|
|
588
|
-
* `session/set_mode` — and the resulting normalized snapshot; see {@link setConfigOptionRequest}.
|
|
589
|
-
*/
|
|
590
|
-
async setConfigOption(configId, value) {
|
|
591
|
-
return this.session.setConfigOption(configId, value);
|
|
592
|
-
}
|
|
593
|
-
/**
|
|
594
|
-
* Resolve one pending `session/request_permission` round trip. Unknown/already-answered
|
|
595
|
-
* `requestId`s are silently ignored, matching the deleted Claude Code bridge's `answer()` — a
|
|
596
|
-
* platform relaying a stale answer must not throw.
|
|
597
|
-
*/
|
|
598
|
-
answer(requestId, outcome) {
|
|
599
|
-
const resolve = this.pendingApprovals.get(requestId);
|
|
600
|
-
if (!resolve)
|
|
601
|
-
return;
|
|
602
|
-
this.pendingApprovals.delete(requestId);
|
|
603
|
-
resolve(outcome);
|
|
604
|
-
}
|
|
605
|
-
/**
|
|
606
|
-
* Register a pending `session/request_permission` round trip. `permission.ts` calls this from its
|
|
607
|
-
* `session/request_permission` handler and resolves the registered callback once the user (or
|
|
608
|
-
* `close()`) answers it; nothing in this file calls it.
|
|
609
|
-
*/
|
|
610
|
-
registerPendingApproval(requestId, resolve) {
|
|
611
|
-
if (this.closed) {
|
|
612
|
-
// A permission request that reaches us after our own close() has nobody left to answer it —
|
|
613
|
-
// review finding (task-5, item 2): registering it anyway would park a card `close()`'s own
|
|
614
|
-
// drain already ran past, and the agent would wait forever for a response nothing will send.
|
|
615
|
-
// Resolve immediately with the same abandonment sentinel `close()`'s drain uses, instead of
|
|
616
|
-
// ever storing the entry.
|
|
617
|
-
resolve(ABANDONED_ANSWER);
|
|
618
|
-
return;
|
|
619
|
-
}
|
|
620
|
-
this.pendingApprovals.set(requestId, resolve);
|
|
621
|
-
}
|
|
622
|
-
/**
|
|
623
|
-
* Emit a wire event that did not come from `session/update` — `permission.ts` calls this from its
|
|
624
|
-
* `session/request_permission` handler to raise the `permission_request` card. Routed
|
|
625
|
-
* through the same buffered {@link outbox}/{@link drain} pair every mapped `session/update` event
|
|
626
|
-
* uses, rather than a separate channel, so a permission card lands in the exact same delivery
|
|
627
|
-
* order relative to other events (and is subject to the same registration-buffering guarantee: an
|
|
628
|
-
* event emitted before `onEvent` is registered is queued, not lost — see {@link drain}'s doc
|
|
629
|
-
* comment) instead of racing a second, independent path a consumer would have to merge itself.
|
|
630
|
-
*/
|
|
631
|
-
emitEvent(event) {
|
|
632
|
-
if (this.closed) {
|
|
633
|
-
// Same reasoning as `registerPendingApproval` above: once closed, nothing is left reading the
|
|
634
|
-
// outbox on the other end that could still make sense of a fresh card.
|
|
635
|
-
return;
|
|
636
|
-
}
|
|
637
|
-
this.enqueue({ kind: 'event', event });
|
|
638
|
-
}
|
|
639
|
-
/**
|
|
640
|
-
* Applies one `session/update` the transport diverted before the SDK could destroy it.
|
|
641
|
-
*
|
|
642
|
-
* Two things arrive here and they differ only in addressing (see `draftInterceptor.ts` for the
|
|
643
|
-
* measurements): a draft variant on **this** session, and any update at all on a **child**
|
|
644
|
-
* session this client never opened. Both are mapped by the same mapper as everything else — this
|
|
645
|
-
* is not a second translation path, it is the same one reached by a different door.
|
|
646
|
-
*
|
|
647
|
-
* A child's events are tagged with {@link WireEvent.acpSessionId} so the server can route them to
|
|
648
|
-
* the projected child session; the frame itself still goes out on this session's ref and seq.
|
|
649
|
-
* They deliberately bypass {@link routeMappedEvent}'s message-boundary tracking: that accumulator
|
|
650
|
-
* synthesises `agent_message` for **this** session's turn, and feeding a child's chunks into it
|
|
651
|
-
* would end the parent's message on the child's boundaries.
|
|
652
|
-
*
|
|
653
|
-
* Never throws. Its caller is the stream, and an exception there would stall every later message
|
|
654
|
-
* on the connection, not just this one.
|
|
655
|
-
*/
|
|
656
|
-
applyDivertedUpdate(diverted) {
|
|
657
|
-
const isChild = diverted.reason === 'foreign-session';
|
|
658
|
-
let events;
|
|
659
|
-
try {
|
|
660
|
-
events = mapSessionUpdate(diverted.update, this.mapperState);
|
|
661
|
-
}
|
|
662
|
-
catch (err) {
|
|
663
|
-
// An unmapped variant is a contract gap worth naming, not worth killing a session over. The
|
|
664
|
-
// agent, the session and the variant are what make it actionable.
|
|
665
|
-
console.warn(`[acp] dropped a diverted '${String(diverted.update.sessionUpdate)}' ` +
|
|
666
|
-
`for session ${diverted.sessionId}: ${err instanceof Error ? err.message : String(err)}`);
|
|
667
|
-
return;
|
|
668
|
-
}
|
|
669
|
-
for (const event of events) {
|
|
670
|
-
this.emitEvent(isChild ? { ...event, acpSessionId: diverted.sessionId } : event);
|
|
671
|
-
}
|
|
672
|
-
}
|
|
673
|
-
/**
|
|
674
|
-
* Cancel the current turn via `session/cancel` — a **notification**, not `$/cancel_request`
|
|
675
|
-
* (that method cancels one in-flight *request*, via `SendRequestOptions.cancellationSignal`, and is
|
|
676
|
-
* a different mechanism entirely from ending a whole prompt turn). The current turn is marked
|
|
677
|
-
* interrupted synchronously so the eventual `TurnResult` reports `'interrupted'` no matter what
|
|
678
|
-
* `stopReason` the agent's `session/prompt` response ends up carrying — including a `stopReason`
|
|
679
|
-
* that (incorrectly) claims the turn ended normally.
|
|
680
|
-
*/
|
|
681
|
-
async interrupt() {
|
|
682
|
-
if (this.closed) {
|
|
683
|
-
console.warn('[acp] ignoring interrupt for a session that is no longer running');
|
|
684
|
-
return;
|
|
685
|
-
}
|
|
686
|
-
// Every in-flight turn, not just the newest: `session/cancel` cancels the session's prompt
|
|
687
|
-
// turn(s) wholesale, so a second overlapping turn must not come back reporting 'completed'
|
|
688
|
-
// after the user pressed Stop.
|
|
689
|
-
for (const turn of this.inFlightTurns)
|
|
690
|
-
turn.interrupted = true;
|
|
691
|
-
try {
|
|
692
|
-
await this.session.cancel();
|
|
693
|
-
}
|
|
694
|
-
catch (err) {
|
|
695
|
-
// Not rethrown: the turn is already marked interrupted above, so it will report
|
|
696
|
-
// 'interrupted' regardless of whether the agent ever heard the cancel — a failed notify means
|
|
697
|
-
// the agent may keep working, which is what the warning is for.
|
|
698
|
-
console.warn(`[acp] session/cancel notification failed: ${errorMessage(err)}`);
|
|
699
|
-
}
|
|
700
|
-
}
|
|
701
|
-
/**
|
|
702
|
-
* Idempotent: the first call sends `session/cancel`, denies every still-pending permission card,
|
|
703
|
-
* then disposes the underlying `ActiveSession` (which fails its update queue); the second call just
|
|
704
|
-
* awaits the same read-loop exit the first one triggered.
|
|
705
|
-
*
|
|
706
|
-
* `session/cancel` is sent **before** `dispose()`, deliberately: `ActiveSession.dispose()`'s own
|
|
707
|
-
* docstring says it does not close the session on the agent side, only local update routing. With
|
|
708
|
-
* `dispose()` alone, an in-flight prompt keeps the agent running tools and burning tokens until
|
|
709
|
-
* whatever holds this session's `AcpConnection` eventually kills the child process — harmless only
|
|
710
|
-
* while there is exactly one connection per session, which ACP does not guarantee in general. The
|
|
711
|
-
* cancel is wrapped in its own `try`/`catch` so a failed notify cannot skip or mask the
|
|
712
|
-
* deny-pending/dispose/read-loop shutdown that must happen regardless.
|
|
713
|
-
*/
|
|
714
|
-
async close() {
|
|
715
|
-
if (!this.closed) {
|
|
716
|
-
this.closed = true;
|
|
717
|
-
this.cancelLateMessageCompletion();
|
|
718
|
-
this.drainPendingApprovals();
|
|
719
|
-
try {
|
|
720
|
-
await this.session.cancel();
|
|
721
|
-
}
|
|
722
|
-
catch (err) {
|
|
723
|
-
console.warn(`[acp] session/cancel notification failed during close: ${errorMessage(err)}`);
|
|
724
|
-
}
|
|
725
|
-
this.session.dispose();
|
|
726
|
-
}
|
|
727
|
-
await this.readLoopDone;
|
|
728
|
-
}
|
|
729
|
-
/**
|
|
730
|
-
* Resolve every still-pending permission request with {@link ABANDONED_ANSWER} and clear the map.
|
|
731
|
-
* Shared by `close()` (the request-time answer path `permission.ts` reads) and
|
|
732
|
-
* `runReadLoop`'s connection-abort branch — review finding (task-5, item 5): the child dying or the
|
|
733
|
-
* connection otherwise aborting out from under a pending permission left it undrained before, since
|
|
734
|
-
* only `close()` itself drained it and neither the read loop's own abort handling nor a dead
|
|
735
|
-
* connection ever calls `close()`.
|
|
736
|
-
*/
|
|
737
|
-
drainPendingApprovals() {
|
|
738
|
-
for (const resolve of this.pendingApprovals.values())
|
|
739
|
-
resolve(ABANDONED_ANSWER);
|
|
740
|
-
this.pendingApprovals.clear();
|
|
741
|
-
}
|
|
742
|
-
onEvent(cb) {
|
|
743
|
-
this.eventSink = cb;
|
|
744
|
-
this.drain();
|
|
745
|
-
}
|
|
746
|
-
onTurnDone(cb) {
|
|
747
|
-
this.turnSink = cb;
|
|
748
|
-
this.drain();
|
|
749
|
-
}
|
|
750
|
-
// `backfill?()` is deliberately not implemented here: replaying an existing native session's
|
|
751
|
-
// history is `session/load`, which is `adoption.ts`'s job ("领养"), not this file's. Not
|
|
752
|
-
// `session/resume(replayFrom)`, as the plan text and an earlier version of this comment said —
|
|
753
|
-
// ACP v1's `session/resume` explicitly does *not* replay history, and `replayFrom` exists only
|
|
754
|
-
// under the experimental v2 entry point Global Constraint 1 forbids importing. `adoption.ts`'s
|
|
755
|
-
// "`session/resume` is deliberately NOT used here" section carries the full reading. A session
|
|
756
|
-
// with no adopted history simply has no `backfill` method, exactly like an unsupported bridge in
|
|
757
|
-
// the deleted SPI.
|
|
758
|
-
// -------------------------------------------------------------------------------------------
|
|
759
|
-
// session/update routing + turn completion
|
|
760
|
-
// -------------------------------------------------------------------------------------------
|
|
761
|
-
/**
|
|
762
|
-
* One continuous consumer of `ActiveSession.nextUpdate()`, for the session's whole lifetime (not
|
|
763
|
-
* just one turn): `session_update` messages become buffered wire events, `stop` messages finish the
|
|
764
|
-
* current turn. See the class doc comment for why a single loop is what makes a second
|
|
765
|
-
* `.onNotification('session/update', …)` registration unnecessary.
|
|
766
|
-
*/
|
|
767
|
-
async runReadLoop() {
|
|
768
|
-
for (;;) {
|
|
769
|
-
let message;
|
|
770
|
-
try {
|
|
771
|
-
message = await this.session.nextUpdate();
|
|
772
|
-
}
|
|
773
|
-
catch (err) {
|
|
774
|
-
if (this.closed) {
|
|
775
|
-
// Our own close() disposed the ActiveSession, which fails its queue — expected, not an
|
|
776
|
-
// error. Every turn still in flight at that moment must still get a terminal result, or
|
|
777
|
-
// the manager's onTurnDone would never fire for it. All of them, not just the oldest: the
|
|
778
|
-
// queue is permanently failed, so no further stop message is ever coming for the rest.
|
|
779
|
-
this.readLoopDead = errorMessage(err);
|
|
780
|
-
this.finishAllTurns('interrupted', errorMessage(err));
|
|
781
|
-
return;
|
|
782
|
-
}
|
|
783
|
-
// `ActiveSession.prompt()` routes its own rejection onto this same queue (see the class doc
|
|
784
|
-
// comment) — this is that one-shot rejection, not a permanently failed queue (only
|
|
785
|
-
// `dispose()`/connection-close does that), so the loop keeps running for the next prompt.
|
|
786
|
-
//
|
|
787
|
-
// **The one exit on this class where the agent itself answered**, and so the only one that
|
|
788
|
-
// may claim {@link TurnResult.agentRefused} — unless the connection is what died, in which
|
|
789
|
-
// case this same throw is a dead queue rather than a reply and the branch just below takes
|
|
790
|
-
// over. `connectionAborted` is read here rather than after `finishTurn` for no reason but
|
|
791
|
-
// ordering: both reads happen in the same synchronous run, so they see the same value (the
|
|
792
|
-
// branch below has always depended on that).
|
|
793
|
-
// `err` itself, not its message: this is the only frame in which the agent's JSON-RPC code
|
|
794
|
-
// still exists. `errorMessage(err)` on the same line is what destroyed it before.
|
|
795
|
-
this.finishTurn('failed', undefined, errorMessage(err), !this.connectionAborted, jsonRpcErrorCode(err));
|
|
796
|
-
if (this.connectionAborted) {
|
|
797
|
-
// The connection died out from under us without our own close() ever running. Same
|
|
798
|
-
// reasoning as the `this.closed` branch above: the queue is now permanently failed, so no
|
|
799
|
-
// further stop message is ever coming for any turn still behind the one `finishTurn` above
|
|
800
|
-
// just popped — review finding (task-11 fix round 1, finding 1): before the FIFO change a
|
|
801
|
-
// single `finishTurn` above always cleared the *only* turn, so this branch never had a
|
|
802
|
-
// second one left behind to orphan. And every *later* prompt needs the same treatment,
|
|
803
|
-
// which is what {@link readLoopDead} gives it — finishing the turns open right now says
|
|
804
|
-
// nothing about the retry the user sends ten seconds from now.
|
|
805
|
-
this.readLoopDead = errorMessage(err);
|
|
806
|
-
this.finishAllTurns('failed', errorMessage(err));
|
|
807
|
-
// Nothing will drain pendingApprovals otherwise (review finding, task-5 item 5).
|
|
808
|
-
this.drainPendingApprovals();
|
|
809
|
-
return; // the connection itself is gone; further reads would just spin
|
|
810
|
-
}
|
|
811
|
-
continue;
|
|
812
|
-
}
|
|
813
|
-
if (message.kind === 'session_update') {
|
|
814
|
-
try {
|
|
815
|
-
for (const event of mapSessionUpdate(message.update, this.mapperState))
|
|
816
|
-
this.routeMappedEvent(event);
|
|
817
|
-
}
|
|
818
|
-
catch (err) {
|
|
819
|
-
// The third permanent-failure exit, and the reason this mapping call is inside a `try` at
|
|
820
|
-
// all: nothing thrown under `mapSessionUpdate` may escape `runReadLoop`. An uncaught throw
|
|
821
|
-
// here rejects `readLoopDone`, strands every in-flight turn in `inFlightTurns` on a read
|
|
822
|
-
// loop that no longer exists, and skips `drainPendingApprovals`, leaving the agent process
|
|
823
|
-
// blocked forever on a permission card nobody will answer.
|
|
824
|
-
//
|
|
825
|
-
// What this branch is *not*: the handler for an unknown `sessionUpdate` variant. An
|
|
826
|
-
// earlier version of this comment claimed it was, and a probe disproved it. Both paths
|
|
827
|
-
// that feed this loop parse the notification through the SDK's `zSessionNotification`
|
|
828
|
-
// first — `SessionUpdateRouter.handleMessage` for `open()`, `registerAppNotification` for
|
|
829
|
-
// the `attach()`/replay handler — and `zSessionUpdate` is a closed union of literals. An
|
|
830
|
-
// update naming a variant our pinned SDK does not know fails that parse inside the SDK's
|
|
831
|
-
// own dispatch, which prints one `Error handling notification` line and carries on;
|
|
832
|
-
// `mapSessionUpdate` is never called for it. Measured against a bare ndjson agent emitting
|
|
833
|
-
// `{"sessionUpdate":"brand_new_v2_variant"}`: the surrounding deltas and the turn's own
|
|
834
|
-
// completion all arrived normally and this branch never ran. (The tests that do run it
|
|
835
|
-
// push into `attach`'s `LiveUpdateSource` directly, which is downstream of that parse.)
|
|
836
|
-
//
|
|
837
|
-
// So the exhaustiveness guard is unreachable in production today, and what actually
|
|
838
|
-
// happens to a future agent's new update variant is that **it disappears silently** —
|
|
839
|
-
// one SDK console line, and nothing in Axom at all: no wire event, no card, no diagnostic,
|
|
840
|
-
// no record that the agent said anything. Nobody on this branch adjudicated that; it is
|
|
841
|
-
// written down here because this is where a reader will come looking for it, and the place
|
|
842
|
-
// to change it would be the SDK parse boundary, not this catch.
|
|
843
|
-
//
|
|
844
|
-
// The branch stays regardless: a throw from anywhere under `mapSessionUpdate` still has to
|
|
845
|
-
// be contained, and it is treated as permanent rather than skipped because whatever made
|
|
846
|
-
// one update unrepresentable is a property of a *kind* of update, so the next one of that
|
|
847
|
-
// kind throws again. Same shutdown as the other two permanent-failure exits — finish every
|
|
848
|
-
// turn with the shared cause, drain the approvals, stop reading.
|
|
849
|
-
console.error(`[acp] unmappable session/update; ending this session's turns: ${errorMessage(err)}`);
|
|
850
|
-
this.readLoopDead = errorMessage(err);
|
|
851
|
-
this.finishAllTurns('failed', errorMessage(err));
|
|
852
|
-
this.drainPendingApprovals();
|
|
853
|
-
return;
|
|
854
|
-
}
|
|
855
|
-
}
|
|
856
|
-
else {
|
|
857
|
-
// ACP's own 'cancelled' stopReason additionally forces 'interrupted' even when this session's
|
|
858
|
-
// own interrupt() was never called (a defensive backstop, not the primary path — see
|
|
859
|
-
// `finishTurn`'s interrupted-flag handling for that).
|
|
860
|
-
const status = message.stopReason === 'cancelled' ? 'interrupted' : 'completed';
|
|
861
|
-
this.finishTurn(status, message.stopReason);
|
|
862
|
-
}
|
|
863
|
-
}
|
|
864
|
-
}
|
|
865
|
-
/**
|
|
866
|
-
* Routes one mapped `WireEvent` to the outbox, tracking `agent_message_chunk`/`agent_thought_chunk`
|
|
867
|
-
* on the way through so `agent_message` can be synthesized at the right moments — the one per-turn
|
|
868
|
-
* accumulator this class still owns instead of `sessionUpdateMapper.ts` (see the class doc comment).
|
|
869
|
-
*
|
|
870
|
-
* `user_message_chunk` is deliberately *not* special-cased: it goes to the outbox like any other
|
|
871
|
-
* event, and the server folds it.
|
|
872
|
-
*/
|
|
873
|
-
routeMappedEvent(event) {
|
|
874
|
-
const isAssistantChunk = event.eventName === 'agent_message_chunk' || event.eventName === 'agent_thought_chunk';
|
|
875
|
-
if (isAssistantChunk) {
|
|
876
|
-
this.trackMessageBoundary(event);
|
|
877
|
-
}
|
|
878
|
-
this.enqueue({ kind: 'event', event });
|
|
879
|
-
// `finishTurn` has already consumed the normal boundary. If an adapter emits content after it,
|
|
880
|
-
// give the rest of that burst a short chance to arrive, then synthesize the missing boundary
|
|
881
|
-
// ourselves. Without this, CoworkEngine correctly buffers the delta but can only flush it when
|
|
882
|
-
// an unrelated later user message happens to append an item.
|
|
883
|
-
if (isAssistantChunk && !this.turnRunning)
|
|
884
|
-
this.scheduleLateMessageCompletion();
|
|
885
|
-
}
|
|
886
|
-
/**
|
|
887
|
-
* Notes that assistant content/thought is streaming, and — if a *different* `messageId` from the
|
|
888
|
-
* one currently tracked shows up mid-turn — flushes `agent_message` for the message that just
|
|
889
|
-
* ended before this chunk's own event reaches the outbox. Without this, two assistant messages in
|
|
890
|
-
* one turn (e.g. around a tool call) would flush together into the server's single delta buffer
|
|
891
|
-
* the moment they landed within its ~400ms gap window, reading as one run-on message.
|
|
892
|
-
*/
|
|
893
|
-
trackMessageBoundary(event) {
|
|
894
|
-
const messageId = stringField(event.payload, 'messageId');
|
|
895
|
-
if (this.sawMessageContent && messageId !== null && this.currentMessageId !== null && messageId !== this.currentMessageId) {
|
|
896
|
-
this.flushMessageCompleted();
|
|
897
|
-
}
|
|
898
|
-
this.sawMessageContent = true;
|
|
899
|
-
if (messageId !== null)
|
|
900
|
-
this.currentMessageId = messageId;
|
|
901
|
-
}
|
|
902
|
-
/**
|
|
903
|
-
* Synthesizes the existing `agent_message` event — see the class doc comment for why this
|
|
904
|
-
* class, not `sessionUpdateMapper.ts`, is what emits it. A no-op when no content/thought has
|
|
905
|
-
* streamed since the last flush (a turn with only tool calls, or an already-flushed
|
|
906
|
-
* mid-turn message boundary), so this is always safe to call unconditionally.
|
|
907
|
-
*/
|
|
908
|
-
flushMessageCompleted() {
|
|
909
|
-
if (!this.sawMessageContent)
|
|
910
|
-
return;
|
|
911
|
-
this.enqueue({ kind: 'event', event: { eventName: 'agent_message', payload: { messageId: this.currentMessageId ?? 'turn' } } });
|
|
912
|
-
this.sawMessageContent = false;
|
|
913
|
-
this.currentMessageId = null;
|
|
914
|
-
}
|
|
915
|
-
/** Debounce a late chunk burst into one synthetic `agent_message` marker. */
|
|
916
|
-
scheduleLateMessageCompletion() {
|
|
917
|
-
this.cancelLateMessageCompletion();
|
|
918
|
-
this.lateMessageFlushTimer = setTimeout(() => {
|
|
919
|
-
this.lateMessageFlushTimer = null;
|
|
920
|
-
if (!this.closed && !this.turnRunning)
|
|
921
|
-
this.flushMessageCompleted();
|
|
922
|
-
}, LATE_MESSAGE_SETTLE_MS);
|
|
923
|
-
}
|
|
924
|
-
/** Flush a late tail before its contents can be mistaken for a later prompt's answer. */
|
|
925
|
-
flushLateMessageCompletion() {
|
|
926
|
-
this.cancelLateMessageCompletion();
|
|
927
|
-
this.flushMessageCompleted();
|
|
928
|
-
}
|
|
929
|
-
cancelLateMessageCompletion() {
|
|
930
|
-
if (this.lateMessageFlushTimer === null)
|
|
931
|
-
return;
|
|
932
|
-
clearTimeout(this.lateMessageFlushTimer);
|
|
933
|
-
this.lateMessageFlushTimer = null;
|
|
934
|
-
}
|
|
935
|
-
/**
|
|
936
|
-
* Terminate every turn still in flight — for the one path (a permanently failed read queue) where
|
|
937
|
-
* no further stop message can arrive for any of them.
|
|
938
|
-
*
|
|
939
|
-
* `error` is the *shared* cause, handed to every turn rather than only to whichever one happened
|
|
940
|
-
* to be popped first: one dead queue fails N turns for exactly one reason, and `error` is the only
|
|
941
|
-
* field that carries it (`agentSessions.ts` records `error: r.error ?? null`; the engine's
|
|
942
|
-
* `ExternalAgentEventApplier.onTurnDone` substitutes a generic "external agent turn failed" when
|
|
943
|
-
* it is absent). Passing it on the `'interrupted'` call as well keeps every permanent-failure exit
|
|
944
|
-
* shaped the same; {@link finishTurn} is the single place that decides a result carries it, and it
|
|
945
|
-
* still attaches `error` to `failed` results only.
|
|
946
|
-
*/
|
|
947
|
-
finishAllTurns(status, error) {
|
|
948
|
-
while (this.inFlightTurns.length > 0)
|
|
949
|
-
this.finishTurn(status, undefined, error);
|
|
950
|
-
}
|
|
951
|
-
/**
|
|
952
|
-
* @param agentRefused whether this failure is the agent's own answer rather than something this
|
|
953
|
-
* class synthesised for it — see {@link TurnResult.agentRefused}. Defaulted to `false` so that
|
|
954
|
-
* every existing exit keeps saying "cannot attribute this", and a new one has to opt in.
|
|
955
|
-
* @param errorCode the JSON-RPC code of that answer, when there was one. Recorded only under
|
|
956
|
-
* `agentRefused` — the single `if` below is what keeps the code and the attribution from ever
|
|
957
|
-
* disagreeing, rather than two conditions someone can later edit apart.
|
|
958
|
-
*/
|
|
959
|
-
finishTurn(status, stopReason, error, agentRefused = false, errorCode) {
|
|
960
|
-
// The oldest unfinished turn, popped: one stop message (or one prompt rejection) ends exactly
|
|
961
|
-
// one turn, so a second overlapping turn keeps its own result instead of being swallowed.
|
|
962
|
-
const turn = this.inFlightTurns.shift();
|
|
963
|
-
if (!turn)
|
|
964
|
-
return;
|
|
965
|
-
// Must land before the TurnResult below. Unconditionally safe to call — a no-op when nothing
|
|
966
|
-
// was accumulated — so every path that can end a turn (a normal stop, an interrupt, a failed
|
|
967
|
-
// request, or close() mid-turn) goes through exactly this one place rather than each needing to
|
|
968
|
-
// remember to flush for itself. The user half of this pair is gone: a buffered user message is
|
|
969
|
-
// now the server's to close, at `CoworkEngine.endBridgeTurn`, which sees the same turn boundary.
|
|
970
|
-
this.flushMessageCompleted();
|
|
971
|
-
// A turn this session itself interrupted always reports 'interrupted', regardless of what the
|
|
972
|
-
// agent's own response claims — mirrors the deleted Claude Code bridge's finishTurn.
|
|
973
|
-
const resolved = turn.interrupted ? 'interrupted' : status;
|
|
974
|
-
const result = { status: resolved };
|
|
975
|
-
if (resolved === 'completed' && stopReason && stopReason !== 'cancelled') {
|
|
976
|
-
result.stopReason = mapStopReason(stopReason);
|
|
977
|
-
}
|
|
978
|
-
// Echoed on every finished turn, matching the deleted Claude Code bridge's TurnResult shape —
|
|
979
|
-
// ACP's session id is known synchronously from session/new and never renames mid-session the way
|
|
980
|
-
// Claude Code's occasionally does, but a manager built against the old contract may still expect
|
|
981
|
-
// this field to be populated whenever it is known, not only the first time.
|
|
982
|
-
if (this.session.sessionId.length > 0)
|
|
983
|
-
result.nativeSessionId = this.session.sessionId;
|
|
984
|
-
if (resolved === 'failed' && error)
|
|
985
|
-
result.error = error;
|
|
986
|
-
// Gated on `resolved`, not on the requested `status`: a turn this session interrupted reports
|
|
987
|
-
// 'interrupted' whatever the agent said, and attributing that to the agent would be a claim
|
|
988
|
-
// about a result it no longer owns.
|
|
989
|
-
if (resolved === 'failed' && agentRefused) {
|
|
990
|
-
result.agentRefused = true;
|
|
991
|
-
if (errorCode !== undefined)
|
|
992
|
-
result.errorCode = errorCode;
|
|
993
|
-
}
|
|
994
|
-
this.enqueue({ kind: 'turn', result });
|
|
995
|
-
}
|
|
996
|
-
enqueue(item) {
|
|
997
|
-
this.outbox.push(item);
|
|
998
|
-
this.drain();
|
|
999
|
-
}
|
|
1000
|
-
/**
|
|
1001
|
-
* Deliver from the head of the outbox for as long as the head's sink exists. Buffering
|
|
1002
|
-
* `session/update` output that arrives before `onEvent`/`onTurnDone` are registered — and flushing
|
|
1003
|
-
* it in order once they are — is a contract the deleted `BridgeSession` SPI's KDoc called out
|
|
1004
|
-
* explicitly: events emitted with no sink registered are otherwise silently lost.
|
|
1005
|
-
*
|
|
1006
|
-
* Stopping at the first undeliverable entry (rather than skipping it) preserves relative order
|
|
1007
|
-
* across the two-step registration a manager does (`onEvent` first, `onTurnDone` second, or vice
|
|
1008
|
-
* versa): a turn result queued ahead of some events must still reach its sink before those events
|
|
1009
|
-
* reach theirs, and a single shared queue drained strictly FIFO is what guarantees that.
|
|
1010
|
-
*/
|
|
1011
|
-
drain() {
|
|
1012
|
-
if (this.draining)
|
|
1013
|
-
return;
|
|
1014
|
-
this.draining = true;
|
|
1015
|
-
try {
|
|
1016
|
-
while (this.outbox.length > 0) {
|
|
1017
|
-
const head = this.outbox[0];
|
|
1018
|
-
if (head.kind === 'event') {
|
|
1019
|
-
const sink = this.eventSink;
|
|
1020
|
-
if (!sink)
|
|
1021
|
-
return;
|
|
1022
|
-
this.outbox.shift();
|
|
1023
|
-
this.deliver(() => sink(head.event));
|
|
1024
|
-
}
|
|
1025
|
-
else {
|
|
1026
|
-
const sink = this.turnSink;
|
|
1027
|
-
if (!sink)
|
|
1028
|
-
return;
|
|
1029
|
-
this.outbox.shift();
|
|
1030
|
-
this.deliver(() => sink(head.result));
|
|
1031
|
-
}
|
|
1032
|
-
}
|
|
1033
|
-
}
|
|
1034
|
-
finally {
|
|
1035
|
-
this.draining = false;
|
|
1036
|
-
}
|
|
1037
|
-
}
|
|
1038
|
-
/** A throwing sink is the consumer's bug; it must not abort the drain. */
|
|
1039
|
-
deliver(call) {
|
|
1040
|
-
try {
|
|
1041
|
-
call();
|
|
1042
|
-
}
|
|
1043
|
-
catch (err) {
|
|
1044
|
-
console.error(`[acp] sink threw: ${errorMessage(err)}`);
|
|
1045
|
-
}
|
|
1046
|
-
}
|
|
1047
|
-
}
|