mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-10-27 04:30:59 +00:00
bd06804b67d7b4efa8180db2f36203a5d34c3ac4
810 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
bd06804b67 |
feat: keep a subagent's session across SendMessage resumes for subagents RFD clients (#1321)
The second of three stacked PRs that replace #1257. It's based on #1320, so its diff shows only the resume part. Merge #1320 first; this PR then retargets to `main`. 1. #1320: the RFD form for a v1 client that declares `subagents` and isn't AIR. 2. **This PR:** a child keeps its session across SendMessage resumes. 3. #1322: `requires_action` while a child's permission or question request is open. **AIR is unchanged:** it keeps a new session per generation, as in its draft. ## What changes The [subagents RFD](https://github.com/agentclientprotocol/agent-client-protocol/blob/main/docs/rfds/subagents.mdx) gives a child one session id across every delegation to it. With #1320 alone, a resumed child stays `idle`. | RFD | With #1320 | Now | | --- | --- | --- | | One session id per child, across delegations | a resumed child stays `idle`, and its resumed work isn't shown | the child runs again in the same session, under the SDK agent id. The CLI runs a resumed agent under the SendMessage call's tool id, so that call becomes the child's current delegation: the resumed run's output and end reach the child | | The parent's message to the child is an incoming `session_message` | only the launch prompt | each SendMessage that resumes the child is a `session_message` with the SendMessage tool use's id. Its sender is the session that made that call, or left out when unknown. A repeated resume signal doesn't repeat it | Out-of-order events, from the reviews of #1257: - **Late end:** the end of an earlier delegation ends nothing (`isStaleEnd`). It also no longer settles the resumed run's live task at the agent level. - **Late result:** a SendMessage result that arrives after its run ended resumes nothing (`isEndedDelegation`). - **Early output:** a resume's output that arrives before its `task_started` is buffered, then delivered to the child. - **Reused id:** a run under an id the child used before ends the run, since the SDK falls back to it. - **Running patch:** a `task_updated` running patch reports `running` without its guessed prompt or call. ## Live run Against real Claude Code (Sonnet), a SendMessage to a finished child ran it again in the same session. Its message arrived with the SendMessage's id and the root as sender, and the turn waited for the resumed run. No schema violations. ## Tests - **End to end:** - a SendMessage resume under the SendMessage's tool id: one session, two prompts, the resumed output in the child, a stale end ignored - the resumed run's live task kept when an earlier delegation's end arrives late - a late SendMessage result - **Runtime:** - reuse and resume - a resume's output buffered before its `task_started` - the sender of a resume's prompt: the root, a child, or unknown - a run under a reused id - a running patch - a result without a text - **Mutation checks during #1257's reviews:** removing any of these rules fails tests. - **Checks:** `npm run check`, build and all 2873 tests pass on this branch. CI runs only on PRs to `main`, so I ran `ci.yml` on this branch by hand. |
||
|
|
5758ac84a4 |
feat: report subagents as the subagents RFD defines, for clients that are not AIR (#1320)
The first of three stacked PRs that replace #1257, split for review. Together they bring v1 clients the [subagents RFD](https://github.com/agentclientprotocol/agent-client-protocol/blob/main/docs/rfds/subagents.mdx), as revised by agentclientprotocol/agent-client-protocol#2281 and #2308. **AIR is unchanged**: its golden files don't change. 1. **This PR:** the RFD form for a v1 client that declares `subagents` and isn't AIR. 2. #1321: a child keeps its session across SendMessage resumes. 3. #1322: a child reports `requires_action` while its permission or question request is open. Merge them in that order. Each later PR is based on the previous branch, so its diff shows only its own part. ## Who gets what - **AIR** (any client that declares `_meta.jetbrains.air`): the earlier draft it implements, exactly as before. That's `subagent_spawned`, `subagent_state_update`, a session per generation, the hidden Agent/Task tool call, and draft replay. AIR keeps it whichever signal it declares. - **A v1 client with `clientCapabilities.subagents` that isn't AIR:** the RFD form below. SDK 1.8.0 types it (`subagent_update`, `session_message`, the v1 `StateUpdate`). - **Without either signal:** nothing changes. `subagentForm` (`src/acp-subagents.ts`) picks the form. `NativeSubagentRuntime` keeps the routing, buffering and nested lineage, and renders either form. ## The RFD form | RFD | Before | Now | | --- | --- | --- | | Announce the child before any of its traffic | `subagent_spawned { name, task, prompt }` | `subagent_update { sessionId, title, description, state: { state: "running" } }` | | `description` is the role or purpose, "not a transcript of instructions" | `task` was the prompt | `title`: the agent's `name`, with its role (`subagent_type`) after it, else the role. `description`: the Agent input's short `description` | | The parent's message to the child is an incoming `session_message` | `prompt` on the announcement | a `session_message` in the child's transcript, with the Agent/Task tool use's id and the session that made the call as sender | | Delegation tools stay tool calls | the Agent/Task tool call was hidden | it stays a tool call of the session that made it, with the report its model got. Its heartbeats and result stay there too | | Work-state snapshots, not a terminal lifecycle | `completed`, `failed`, `cancelled`, `disconnected` | `running`; `idle` with `end_turn`, `cancelled`, or `error` with the SDK's error text as its JSON-RPC `error`; `idle` with no stop reason for work that ended without a known reason (a worker restart, or still running when the stream ends) | | `idle` isn't terminal | later updates were dropped | an idle child still gets its updates, such as its background work's | | A cancel stops the work it waits on; other descendants are up to the Agent (#2308) | `session/cancel` ended every child as cancelled | the adapter ends no child itself. Claude Code's interrupt stops the foreground children, and currently background ones too; each reports `idle`/`cancelled` from the SDK's events, and the foreground ones' requests are withdrawn | | Never guess a parent | a child that ended before its spawning frame was announced under the root | it stays unexposed. A nested child waits for its parent's announcement | | Never move a tool call | | a tool call that a permission request emitted on the root before its child was announced stays on the root after a cancel. AIR keeps the cancel's cleanup, since its draft ends every child there | | No agent-side capability | `sessionCapabilities.subagents` for every client | for AIR only | ## Not in this PR - **SendMessage resumes** (#1321). Until then a resumed child stays `idle`. Requests find a child by its agent id, so only the permission and question requests of the resumed run appear in its session. A resume never opens a second session for the child, which the RFD forbids, and a test pins that. - **`requires_action`** (#1322). - **Replay** doesn't expose children yet. Today's replay invents `<session>:replay-subagent:<id>` ids, and the RFD requires the original ones. The RFD makes child replay a SHOULD, so an RFD client's replay leaves children out until the replay PR. ## Settled during the review of #1257 - **Cancel cascade:** the RFD leaves background children to the Agent (#2308). An agent-level test pins #2308's validation scenario. - **Session ids across forks:** the raw SDK agent id stays the session id. The SDK's fork copies no subagent history, so a fork can't repeat an id under another root. The reasoning is on `nextChildSessionId`. - **Held turns:** the adapter holds a v1 prompt open while a background subagent the turn started runs. A cancel still settles the prompt. ## Live run Against real Claude Code (Sonnet), with a v1 client that declares `subagents` and checks every update and permission request against the SDK's v1 schema, there were no schema violations: - **Launch:** `running` with the title (`scout (general-purpose)`) and description, the prompt as a `session_message` from the root, and the child's tool calls in its session. Then `idle`/`end_turn` before the Agent call completes on the root. - **A 40-second child:** the Agent call's heartbeat and result stay on the root. Before the fix, they moved to the child, because the heartbeat named the Agent call as its own parent. - **Cancel:** a foreground child is `idle`/`cancelled` 15 ms after `session/cancel`, and the prompt returns `cancelled`. Claude Code stopped a background child too (its metadata says `stoppedByUser`). - **Found on `main`, for every client, and fixed separately:** after a cancel, a tool call that was running never gets a final status. ## Tests - **AIR:** the end-to-end tests of the earlier draft run with an AIR client that declares `nativeSubagentSessions` (`AIR_NATIVE_SUBAGENTS`). The AIR golden files are unchanged. - **RFD, end to end:** - announcement and prompt - a failure with the SDK's error, also when the error arrives after the failure - a stop, and a worker-restart orphan - no draft updates - the cancel per #2308 - the raced tool call after a cancel, kept on the root, with AIR's cleanup kept - the Agent call's heartbeat Every update is checked against the SDK's v1 schema. - **RFD, runtime:** - the visible control tool call, also nested - labels, also from a late frame - an unparented child staying unexposed, and a nested child announced only after its parent - nothing reported at the parent's cancel - the end of the stream, and `unknown` - a resume opening no second session - **Plain/Zed comparison with origin/main:** it now allows the missing agent capability, which `compare.ts` documents. - **Mutation checks during #1257's reviews:** removing any of these rules fails tests. - **Checks:** `npm run check`, build and all 2863 tests pass. ## Release Titled `feat:`, because it changes what a v1 client that declares `subagents` receives. |
||
|
|
d6c371704f |
fix: display active Central helpers as JetBrains Air Gateway (#1266)
### Summary Addresses [IJAI-2956](https://youtrack.jetbrains.com/issue/IJAI-2956). - Recognize Central only when the active key source is `apiKeyHelper` and its effective helper command contains the whole word `central` and the word sequence `proxy start`, case-insensitively, allowing multiple whitespace characters. - Read the effective command through the existing `SettingsManager`/SDK settings resolution, including project/managed overrides, rather than reimplementing configuration merging or executing helpers. - Return `kind: gateway` and the exact label `JetBrains Air Gateway`, without `detail` or invented account identity. URL, flags, routes, and endpoint ownership are intentionally not checked: this is an agreed weak display heuristic, not proof of provenance or entitlement. - Render ordinary helpers as `API key helper` instead of raw `apiKeyHelper`; do not expose unknown key-source identifiers. Preserve external backend and client-owned configuration priority, CLI/SDK merging, stale-probe protection, and duplicate suppression. - Add mapper and ACP-agent regression coverage for positive/negative commands, overrides, CLI/SDK results, and source changes; update `docs/air-extensions.md`. Examples matching the heuristic include `central proxy start` and `/opt/bin/CeNtRaL PrOxY StArT --arbitrary-flag`. `decentralized proxy start`, `central start`, and an inactive helper do not match. ### Sanitized auth status ```json { "kind": "gateway", "label": "JetBrains Air Gateway" } ``` No helper command, key, header, private route, or invented account information is sent. ### Verification Based on fresh upstream `main` at `0724b17`, rechecked immediately before publication. Changes and rebuilt `dist` are available in the main local clone; the user confirmed the local adapters work. ```bash npm run build npm run lint npm run test:run ``` - Build and lint passed. - Full test suite: **2484 passed, 30 existing skipped** across 44 passed test files and 4 skipped files. - No new skip flags or disabled tests were used. ### Scope No AIR UI, provisioning, personal configuration migration, effort/mode changes, or new ACP protocol. Plan files and personal configurations are not committed. --------- Co-authored-by: Junie <junie@jetbrains.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Andrey Bragin <andrey.bragin@jetbrains.com>v0.89.2-preview.15 |
||
|
|
30ad5f7372 |
refactor: send each tool call's _meta as the state a v2 client stores on the experimental ACP v2 surface (#1318)
## Problem A v1 report's `_meta` belongs to that report, and the agent gives each report its own `_meta.claudeCode` keys. A v2 client stores a tool call's `_meta`, and any report that carries `_meta` replaces it (`tool-calls.mdx`; the migration guide since agentclientprotocol/agent-client-protocol#2355). So a v2 client kept only the last report's keys. For example, a result's `{ toolName }` replaced the denial reason that came before it. On top of that, a PostToolUse hook stored the whole `tool_response` in `_meta`, which for a Write is the whole file text. ## Change All of this is v2 only. No v1 or AIR output changes, and no v1 golden changes. - **Stored `_meta` (`src/v2/tool-call-meta.ts`).** The v2 bridge keeps each tool call's `_meta` the way the client stores it. `claudeCode` keys merge one by one, and every other key replaces whole, the same model `ToolCallFieldTracker` uses. - A report that changes the stored `_meta` carries all of it. - A report that changes nothing carries no `_meta`, which leaves the stored value unchanged. - A permission request's subject always carries the stored `_meta`, since a client can show the request on its own. - The tool call's first report starts from nothing. - **`toolName` becomes `name`.** `claudeCode.toolName` repeats the tool's name, which v2 has a field for. It becomes the `name` of a tool call that has none yet, and is left out otherwise. A memory recall gets a `name` this way. Subagent routing still reads `toolName` on the agent's v1 reports, before the bridge. - **The `toolResponse` AIR gets.** A v2 client also keeps `_meta`, so it gets AIR's trimmed `toolResponse`: - a hook carries only the `status` and `isAsync` markers, not output the content already shows; - a denial carries the SDK message only when it differs from the reason. ## Evidence - **v2 goldens:** 28 change. Most only lose `toolName`. Hook reports lose the repeated stdout or file text. Memory recall gains `name`. The denial keeps its reason type and message. Subagent tool calls and their permission subjects keep `parentToolUseId`. - **Live, CLI 2.1.293 on Haiku, v2:** a Write and a Bash command arrived with `name` on their first report and no `_meta` at all. - **Tests:** 7 unit tests for the stored `_meta`. - **Mutation checks:** removing any of these 9 rules fails at least one test: - naming once; - leaving `toolName` out; - merging with earlier `claudeCode` keys, which also fails the `subagent-late-child-update` golden; - keeping other keys; - omitting unchanged `_meta`; - starting fresh; - the subject's stored `_meta`; - the bridge wiring; - the trimmed `toolResponse`. Forgetting a closed session's tool calls is memory cleanup that no test observes, like `V2Terminals.forget`. A limit of 2048 tool calls also bounds the memory. - `npm run check`, build and all 2820 tests pass.v0.89.2-preview.14 |
||
|
|
383bc7dcf0 |
fix: start a forked session so the client can prompt it at once (#1317)
## Problem The v1 schema says a forked session is active once the agent responds, and can be prompted without `session/load` or `session/resume` (agentclientprotocol/agent-client-protocol#2359). `session/fork` only copied the transcript, so prompting the new id failed with "Session not found" until the client loaded or resumed it. ## Change - **The fork starts the new session the way a resume does.** It starts from the copied transcript, with the model and the permission mode that transcript records, and with the request's `cwd`, MCP servers, additional directories and `_meta`. The response carries the session's `modes` and `configOptions`, and the available commands follow it, as for `session/new`. - **A client that loads or resumes after a fork keeps working.** A later `session/load` replays the history into the running session, and a later `session/resume` returns it. Neither starts a second Claude Code process. A load or resume with other settings restarts the session, as for any loaded session. The fingerprint that decides this ignores the AIR fork point in `_meta`. - **Cost:** each fork now starts a Claude Code process. That added about 0.65 s to the fork response in the live run. - **v2:** the v2 fork used to resume the forked session itself. It now uses the session that the v1 fork starts, so the v2 bridge drops its own resume. - **AIR docs:** `docs/air-extensions.md` now says, under the session fork point, what a load or resume after a fork does. ## Evidence **Live, CLI 2.1.293 on Haiku, over v1:** 1. A new session answered "Remember the codeword PELICAN" with `OK`. 2. `session/fork` returned the fork with mode `auto` and the `mode`, `model` and `effort` config options. 3. Prompting the fork right away returned `PELICAN`, with no load or resume in between. 4. A `session/load` of the fork then replayed its history and was ready in 4 ms. The timing log shows one session start for the fork and none for the load, and a further prompt still answered. **Tests:** - New: the fork can be prompted at once, and its response carries modes and config options; a later resume and load reuse the session; and the fork fails when its session can't start. - The existing fork tests now also check that the session starts from the fork, with the request's additional directories. - The v2 fork tests pass unchanged. - **Mutation checks:** removing the start, the response's `modes`, or the additional directories each fails a test. - `npm run check`, build and all 2813 tests pass, with no golden changes.v0.89.2-preview.13 |
||
|
|
9db8a28b64 |
refactor: show an approved ExitPlanMode plan as a markdown plan on the experimental ACP v2 surface (#1314)
## Summary
When the user approves a plan through ExitPlanMode, a v2 client now gets
it as the session's markdown plan. The update uses the draft's unstable
plan operations:
```json
{ "sessionUpdate": "plan_update", "plan": { "type": "markdown", "planId": "plan-mode", "content": "# Plan…" } }
```
Until now a v2 client saw the plan only in the "Approve Plan" tool call
and its permission request.
## Behavior
- **Its own plan id.** The approved plan has the id `plan-mode`, and the
TodoWrite checklist keeps `plan`, so the client shows both. Claude Code
keeps one plan file per session and rewrites it on a later plan mode, so
each approval replaces the plan before it. Nothing removes it.
- **A normal approval** is a successful ExitPlanMode result. The plan
follows the tool call's result, live and on replay.
- **Live:** the plan comes from the tool's structured output, which
includes edits the user made to the plan.
- **Replay:** the SDK's `getSessionMessages` drops the structured
output, but the transcript keeps the `plan` that Claude Code adds to the
tool's input.
- **An approval that clears the context** ends the tool with an error
result. The restart into the fresh context publishes that plan, after
the new mode and config options and before the fresh context runs. It
does so only once that context exists, so a cancel or a failed restart
leaves no plan behind that was never carried out.
- **Not published:** a rejected plan, a subagent's plan
(`parent_tool_use_id` or `isAgent`), a plan still waiting for its team
lead, and an empty plan. Plans are trimmed.
- **Who gets it.** The agent sends plan operations only to a client that
advertises v1's draft `plan` capability, as the v1 draft requires. The
v2 bridge advertises it for every v2 client and passes `plan_update` and
`plan_removed` through unchanged, since the shapes match. No v1 client
advertises `plan` today, so v1 behavior doesn't change.
## Replay limits
- **After a "clear context" approval, replay doesn't show that plan.**
It reads the transcript from before the clear, where the approval is the
deny, so it shows the plan approved before that, or none. Replay never
reaches the conversation after a clear, so this is part of an existing
gap.
- **A plan approved before a compaction boundary isn't replayed**, like
the rest of the history before it.
- **Replay can't apply the subagent and team-lead checks**, since they
come from the structured output that `getSessionMessages` drops. A
subagent's transcript still carries its `parent_tool_use_id`, so it's
skipped.
## Evidence
**Live, CLI 2.1.293 on Haiku, through the adapter on v2 in plan mode:**
- **Normal approval:** the plan arrived right after "Exited Plan Mode",
and resuming with replay republished it.
- **"Clear context":** the plan arrived after the switch to auto mode,
before the fresh context's first tool call.
- **Raw SDK probe:** the structured result carries `plan`, `isAgent` and
`filePath`, and the transcript's tool use keeps `plan` and
`planFilePath`.
**Tests and checks**
- **Result path:** unit tests for each rule and for the order. They
include structured output versus input, input-only as on replay, and
structured output with no plan.
- **Restart path:** coordinator tests check that the plan comes after
the session state and before the continuation, that a client without the
capability gets none, and that a failed restart publishes none.
- **Agent:** a client that advertises `plan` gets the restart's plan,
and one that doesn't gets nothing.
- **v2:** a replay test and translation tests. The v2
`exit-plan-approve` golden gains the plan update. `exit-plan-reject` and
every v1 golden are unchanged.
- **Mutation checks:** removing any rule on either path fails at least
one test. That covers both capability checks, the agent's capability
wiring, success only, structured output first, the order, trimming, and
the `plan_removed` pass-through.
- `npm run check`, build and all 2810 tests pass.
v0.89.2-preview.12
|
||
|
|
1137df33aa |
fix: drop streamed output of prompts a cancel abandoned (#1313)
## Problem After a cancel, the consumer drops the main conversation's consolidated `assistant`/`user` messages until Claude Code starts a model request that serves none of the cancelled prompts (#1309). `stream_event` frames skipped that check. Claude Code always streams, so when it had already taken an abandoned prompt off its queue, the prompt's answer still streamed to the client as deltas. Only the consolidated copy was dropped. That output arrives after the cancel has been reported: on v1 after the turn's `cancelled` response, and on v2 after `idle cancelled`, outside any cycle. Both protocol versions require an agent's updates to come before that report. Streamed tool calls leaked the same way. #1309's tests missed this because they scripted only consolidated messages. ## Change - **After the client has been told the stopped work ended**, while the window is still open, streamed text, thinking and tool calls are dropped like the consolidated messages they belong to. On v1 that report is the turn's prompt response (no active turn). On v2 it's the idle state (no open foreground work). The v2 signal also covers cycles that Claude Code starts on its own and turns that v1 holds for their subagents. - **Before that report**, what the interrupt flushes still streams, as it does today. Both drafts allow updates between the cancel and the cancelled report, and the text is in Claude Code's transcript, so a reload shows it anyway. Existing tests pin this: v2's queued-prompt cancel, a held turn cancelled mid-follow-up, and a cancel that arrives during a buffered backlog. - **A prompt that is merely queued no longer keeps the window open.** #1309 held the window while any queued prompt was unsettled. So a follow-up that Claude Code ran ahead of a prompt sent after the cancel lost its consolidated copy, and with this change it would have lost its streamed copy too. Now a request holds the window only when: - the stopped turn is still unsettled; - an abandoned prompt's result is still expected; - the request names a cancelled prompt; or - the request names an unsettled prompt. That covers one that arrived while the cancel was still awaiting, before the cancel records it. - **Per-block drop latch.** Where the consolidated message delivers the text, a stream that loses a delta keeps dropping until its streamed-text record resets: at the next message, or when a consolidated message consumes the record. If a window closes mid-block (an older CLI's late prompt echo), the record holds nothing, so the consolidated message delivers the whole block once. Without the latch the record would hold only the tail, which isn't a prefix of the text, and the client would get the tail and then the whole text again. A client that gets subagent text only streamed has no consolidated copy to fall back on, so its subagent streams don't latch. - Bookkeeping for dropped events (message id, usage, compaction heartbeat) is unchanged. ## Evidence **Tests** - The "output after a cancel" suite now streams every answer the way Claude Code does, with Claude Code's follow-up in two chunks. Without the fix, 9 of its tests leak the streamed copy. - New v1 tests: - a follow-up that runs ahead of a prompt sent after the cancel; - a prompt sent during the cancel whose request arrives before the cancel abandons it; - an echo in the middle of a two-block answer; - a subagent stream for a client that gets subagent text only streamed. - New v2 tests: - an abandoned prompt's streamed answer; - what the interrupt flushes of a cycle that Claude Code started; - a held turn's follow-up that the cancel stopped before it showed anything. - **Mutation checks:** each of these fails at least one test: - removing any of the window's four rules, or restoring #1309's hold for any queued prompt; - removing the drop; - dropping the flushed tail before the report too; - using the v1 signal on v2; - removing the latch, either of its record resets, or the subagent condition. The exception is clearing the latch when a subagent finishes. That's memory cleanup that no test can observe, like the `streamedBlocksByParent.delete` beside it. - `npm run check`, build and all 2796 tests pass, with no golden changes. **Live, CLI 2.1.293 on Haiku, v1 and v2** - A plain cancel mid-answer, and cancels with a background command finishing during or after the cancelled turn. - Nothing of the cancelled turn arrived after its report. - Claude Code's follow-up after the cancel streamed in full. On v2 it came in its own `running` … `idle end_turn` cycle. - An abandoned prompt that Claude Code had already dequeued is a narrow race through the adapter, so I didn't reproduce it live. The tests follow the order a direct CLI probe recorded for #1309. ## Not changed - A dropped prompt that runs a tool can still surface it after the cancel, through `canUseTool` (tool call plus permission request) and the PreToolUse preview hook. Neither checks the cancel. A check there would read the consumer's state, which lags the CLI's control channel, so this needs its own design. - On CLIs without `request_marker_lists_v1`, the window still lasts until the next prompt's echo. Claude Code's own follow-ups in that window now lose their streamed copy as well as the consolidated one. The bundled CLI has the marker.v0.89.2-preview.11 |
||
|
|
d074558e4a |
fix: keep the terminal of a command in the background running until its task ends (#1312)
A Bash call that sends its command to the background returns as soon as
the command detaches, and its tool result carried `terminal_exit`. So a
client that renders terminals, whether Zed's terminal extension or a v2
display terminal, showed the command's terminal as exited, with no code,
at launch, while the command ran on.
## What changes
For a client that renders terminals and isn't AIR:
- **At launch:** the tool result of a command in the background leaves
the exit out (`withRunningTerminal`). The tool call still completes; the
v2 draft keeps "Terminal exit and tool call status are independent".
- **When the command's task ends:** the consumer sends `terminal_exit`.
It does so at the task's `task_notification`, or at its terminal
`task_updated` if that comes first. The update is routed like the tool
call, so a subagent's command ends in the subagent's session.
- **No exit code.** Claude Code names the code only in the
notification's summary text ("…failed with exit code 3"), which the
adapter doesn't parse. `completed` doesn't mean 0 either: Claude Code
accepts some non-zero codes as a success, such as grep's 1.
The terminal still exits at the tool result, as before, in four cases:
- **A fast command** whose task ended before its tool result.
- **A task that started after the tool result.** Claude Code sends
`task_started` first, but the existing `bash-background` scenario
scripts the reverse, so its goldens don't change.
- **AIR,** which shows the command through its async tasks.
- **Replay,** where the command from before the reload no longer runs,
and the structured tool result isn't available anyway.
## Evidence
- **Live, CLI 2.1.293, through the built adapter over ACP:**
- **v2:** the terminal stays running when the Bash call completes and
the turn goes idle. `terminal_update` with `exitStatus: {}` arrives when
`sleep 5` finishes, before Claude Code's follow-up.
- **v1 with `terminal_output`:** no exit at the Bash result;
`terminal_exit` with code `null` arrives when the command finishes.
- **Recorded, CLI 2.1.293:**
- the order is `task_started` (`local_bash`, `is_backgrounded: true`),
then the Bash result with `tool_use_result.backgroundTaskId`, then
`task_updated` and `task_notification` when the command ends;
- neither event carries a structured exit code.
- **Tests:**
- v2: the terminal runs until the task ends, with one exit after the
turn's `idle`; and a task that ended before the result exits at the
result.
- v1 with Zed's terminal extension: the exit comes after the prompt's
answer. AIR keeps its exit at the result.
- Unit tests for `withRunningTerminal`, including dropping a `_meta`
that would be left empty, since an empty `_meta` would clear what a v2
client stores.
- **Mutations:** removing the strip, the end-of-task exit, the liveness
guard, or the AIR guard each fails a test.
- `npm run check`, build and the full suite pass, with no golden file
changes.
v0.89.2-preview.10
|
||
|
|
14b2233192 |
refactor: leave requests of agents without a record out of requires_action on the experimental ACP v2 surface (#1311)
A follow-up to #1310, from its review, which found no regressions but two wrong answers. Both come from one choice in #1310: a request from an agent without a `liveBackgroundTasks` record put v2's foreground work in `requires_action`. **When an agent has no record:** - **A teammate:** it never gets one. - **A settled subagent:** settling deletes its record, and settling didn't re-check open requests. - **A foreground subagent nested in a background one:** Claude Code leaves it out of `background_tasks_changed`, so the level reconciliation prunes it after two activations. The main conversation waits on none of them. A background subagent's request that the consumer read before its `task_started` also flickered from `requires_action` to `running`. ## What changes - **An agent without a record no longer blocks the foreground work.** A subagent the main conversation waits on has a record by the time it asks: its `task_started` comes first on the wire, and nothing prunes it while the main turn waits. When the consumer reads the request first, the `task_started` checks again, so `requires_action` comes slightly later instead of a flicker for background subagents. - **Settling a task re-checks** the open requests. - **The comment on `nativeSubagentParentByToolUseId`** no longer claims `parent_task_id` is unread. ## Tests - **Replaced:** "stops waiting for a request once its subagent turns out to run in the background". The new test checks that a request read before its `task_started` doesn't block, then blocks once the `task_started` says foreground. - **New:** a foreground subagent's open request stops blocking once Claude Code stops that subagent. - **Mutations:** restoring "no record blocks" fails both tests; dropping the re-check at settle fails the second. - `npm run check` and the full suite pass, with no golden file changes.v0.89.2-preview.9 |
||
|
|
c22ec1d189 |
refactor: report requires_action only for requests that block the foreground on the experimental ACP v2 surface (#1310)
v2's foreground work reported `requires_action` whenever any permission request or question in the session was open, including a background subagent's. The draft keeps `requires_action` for foreground work that is blocked on user action, and a background subagent asks without blocking the main conversation. So a v2 client saw a running turn as waiting on the user while it kept working. ## What changes A request blocks the foreground work when it is: - the main conversation's own request, or - the request of a subagent that runs in the foreground of the main conversation, so the tool call that spawned it waits on it. This applies up a chain of such subagents. A request from a subagent in the background, or from one nested inside it, no longer counts. - **Where each task runs.** The adapter records this per task: - from `task_started.is_backgrounded` and `parent_task_id`; - from a later `task_updated` patch that moves the task to the background; - Claude Code always resumes a subagent in the background. - **Unknown subagents.** A subagent without a record still counts, because its request can arrive before its `task_started`. Registering a task, or moving one to the background, checks the open requests again. - **Requests that name no agent** still count: MCP elicitations and dialogs. - **Steering is unchanged.** It still treats any open request as blocking. `Session.pendingUserInputCount` becomes `Session.openUserInputs`: the open requests, each with the subagent that asked. - **v1 is unchanged.** ## Evidence - **Live, CLI 2.1.293 via the SDK with a logging `canUseTool`:** - `task_started` carries `is_backgrounded: true` for a background subagent and `false` for a foreground one. - A subagent's `canUseTool` call carries `agentID` equal to its `task_started.task_id`. - `task_started` arrived before the subagent's request in both cases. - `parent_task_id` for nested subagents was not checked live; it's documented in the SDK types and covered by a test. - **6 new v2 tests:** - a background subagent's permission request leaves the turn `running`, and so does its question; - a foreground subagent's request reports `requires_action`; - a foreground subagent launched by a background one doesn't block; - a request that arrives before its subagent's `task_started` stops blocking once the subagent turns out to run in the background; - a request stops blocking once Claude Code moves its subagent to the background. - **Mutation checks.** Removing each rule in turn fails at least one test. That includes passing the subagent through for permission requests and for questions, the recheck at registration and at `task_updated`, and the walk up the chain. - `npm run check`, build and the full suite pass, with no golden file changes. Two resume tests now expect the resumed task to be recorded as backgrounded.v0.89.2-preview.8 |
||
|
|
b007ac7c45 |
fix: end a cancelled turn at its interrupted result and deliver Claude Code's follow-up after a cancel (#1309)
After a cancel, Claude Code ends the interrupted turn with an aborted result, then keeps working through its queue, and reports `idle` once at the end. The queue can hold a prompt it had already taken off it, then its own follow-ups, such as the summary of a background command that finished. The adapter settled a cancelled turn only at that final `idle`, and dropped everything in between until the next prompt activated. As a result: - **v1:** a cancelled `session/prompt` waited for any follow-up queued behind it to finish. - **v2:** a follow-up after a cancel streamed as text with no `running` or `idle`. This PR ends the cancel where Claude Code's own messages say the stopped work ends. ## What changes - **Where a cancelled turn settles.** It now settles at the result of the work the interrupt stopped: its own result, or, for a turn held open for its subagents, its follow-up's aborted result. - The `idle`, the next echo's hand-off and the force-cancel backstop still settle a turn whose result never comes. - That result owes its trailing `idle` like any settling result, a steered turn's included. - **When the cancel ends.** The cancel records the prompts it stopped: the unsettled turns and their steers when it arrives, and whatever it abandons when it sweeps the queue. - With `request_marker_lists_v1`, every `status: requesting` frame lists the prompts its model request serves. A cycle Claude Code starts on its own lists none. - The cancel ends at the first request that serves none of the recorded prompts, once no turn is unsettled and no orphan result is outstanding. Compaction updates resume there too. - A prompt Claude Code had already dequeued names itself, so its answer stays dropped even when the interrupt receipt leaves it out. - Without the capability, the cancel ends at the next activation, as before. - **Abandoned prompts' results.** A result that names a cancelled prompt never promotes the next queued prompt, so a prompt sent after the cancel can't take an abandoned prompt's result. - **Held turns.** Since CLI 2.1.270, Claude Code reports `running` for as long as background agents live. So with request markers, a held turn's follow-up counts as live only when a model request has started since the turn's result. A cancel while only the subagent runs settles the held turn at once. - **v2: aborted cycles.** A cycle whose result is aborted (`terminal_reason` `aborted_streaming` or `aborted_tools`) ends `cancelled`. ## Evidence **What CLI 2.1.293 sends.** I recorded the stream live with a probe that drives the SDK the way the adapter does. After an interrupt: 1. The interrupted turn's aborted result comes first. 2. Next, any prompt Claude Code had already taken off its queue runs. Its request names it. 3. Then its own follow-ups run. Their requests name no prompt. 4. Finally, one `idle`. The interrupt stops background agents, but not background commands. **An audit of the bundled CLI** found exactly one source of `status: requesting` frames: one per main query-loop iteration. Subagents, compaction, side queries, hooks and retries emit none, or only a named one. **The adapter, end to end over ACP against the live CLI**, this branch against `main`: | Scenario | `main` | This PR | | --- | --- | --- | | v1: a background command finishes while the turn the user cancels streams | `session/prompt` answered 14.1 s after the cancel | answered 4 ms after the cancel; the follow-up's text arrives after | | v2: same | follow-up text with no `running`; `idle cancelled` 14.8 s after the cancel | `idle cancelled` 10 ms after the cancel, then `running`, the follow-up, `idle end_turn` | | v2: a background command finishes after the cancelled turn | `idle cancelled`, then text with no `running` or `idle` | `idle cancelled`, then `running`, the follow-up, `idle end_turn` | | v2: cancel while idle, then a background command finishes | text with no `running` or `idle` | `running`, the follow-up, `idle end_turn` | | v1: cancel while a background subagent holds the prompt open | (not compared) | answered 2 ms after the cancel | ## Tests - **20 new tests, scripted on the recorded orders.** - v1 suite "output after a cancel" (12): a cancelled turn and a cancel while idle; a dequeued prompt the receipt leaves out; a steer; a prompt that Claude Code folds into its own follow-up; a prompt sent while the cancel is still in progress; a repeated cancel; trailing idles after a cancelled turn and after a steered one; older CLIs without request markers. - Held turns (3): a cancelled held turn settles at its follow-up's aborted result, not at a placeholder result, and at once when only its subagent runs. - v2 (5): the follow-up after a cancelled turn as its own cycle; a cancel while idle; a later cycle after a cancelled one; a cycle Claude Code started before the cancel arrived; a follow-up's compaction. - **Mutation checks.** Removing each rule in turn fails at least one test. Two exceptions: - Clearing the record at activation can't be observed. - Holding for any unsettled turn, not only a recorded one, can't be told apart from activation. - `npm run check`, build and the full suite pass, with no golden file changes. ## Review The design went through two rounds of independent review. - **The first attempt, rejected.** It judged "the stopped work is over" from the adapter's own bookkeeping. Review found that the interrupt stops what Claude Code is running, which the adapter may not have seen yet. That attempt is not in this PR. - **This design.** A second round audited the CLI's signals and the state machine. This PR fixes what it found: - a prompt sent after a cancel could take an abandoned prompt's result; - a held turn could settle at a placeholder result; - the window could close while an abandoned prompt that Claude Code folds into its own follow-up was still due; - a prompt sent during the cancel's awaits wasn't recorded; - a held turn's follow-up was misread as live on CLI 2.1.270+. ## Known limitations - **The cancelled turn's last streamed text still arrives after the cancel**, as on `main`: the cancel never held streamed text back. - **If the cancel lands just after Claude Code started its own follow-up**, the interrupt stops that follow-up, and its partial output gets through. On v2 it ends `cancelled`. - **Trailing-idle debt can be over-counted by one in a drain run.** The next `running` transition sweeps it, as before.v0.89.2-preview.7 |
||
|
|
412da2789a |
refactor: report idle at a turn's result and Claude Code's own model cycles on the experimental ACP v2 surface (#1305)
Stacked on #1304, which this targets; it retargets to `main` once #1304 merges. Together they replace #1303. ## What a v2 client gets now **`idle` at a turn's result,** also when v1 holds the turn's answer for the background subagents it spawned (`Turn.deferredSettle`). - The subagents' output and permission requests arrive while the session is idle. The draft allows that: background activity may send updates while idle, and they don't change the state. - A background request while idle no longer shows `requires_action`, which the draft reserves for foreground work. - The hold itself is unchanged. It now only delays v1's answer, with no v2 branch in the hold logic. **Model cycles that Claude Code starts on its own** are foreground work: the follow-up after a background subagent or command finished, or its answer to a message from another session. - A cycle reports `running` before its first output, `requires_action` while it waits, then `idle` with its own stop reason, usage and `_meta.quota`. - An error result gives `idle` with the `error` stop reason, and a cancel gives `idle cancelled`. - Before this, such a cycle streamed while v2 said `idle`. **Take-overs:** a prompt that Claude Code takes in during a cycle takes it over, with no new `running` and no `idle` in between. So do a steer that resumes a held turn, and the continuation of an accepted clear-context plan. In both, the final `idle` carries the turn's usage, not just the last cycle's. v1 and AIR clients are unchanged. ## How All of this builds on #1304's `ForegroundEvents`: - **A turn's work ends at its result:** `endForeground` in the defer branch of `settleOrDefer`, and where the steer idle lane re-applies a hold. - **A cycle starts** (`startAutonomousCycle`) at model output of the main conversation while no work runs. - Subagent output doesn't count. - Without command lifecycles, output while a prompt is queued stays that prompt's, since its output can come before its echo. With lifecycles (`msg_lifecycle_v1`), a prompt Claude Code hasn't started has no output yet, so a cycle starts. This narrows the gap that #1303 left. - **A cycle ends:** - at a result no turn took, except a placeholder result (`num_turns: 0`); - with `cancelled` at the diagnostic of an ExitPlanMode answer, as a turn does, unless a held turn continues the accepted plan; - at an SDK `idle` that no earlier result owes; - when the stream ends or fails; - before `session/close` answers. - **Starting work while a request is open** reports `requires_action` at once. - **The cycle's stop reason** comes from its result, as for a turn (`autonomousCycleEnd`). Its `_meta.quota` uses the same builder as a turn's (`quotaMeta`, extracted from `turnQuotaMeta`). ## Checks - `npm run check`, `npm run build` and all 2756 tests pass. No golden file changes against #1304: no recorded scenario has a held turn or output while no turn is active. - New tests: - **14 v2 tests:** #1303's eleven, plus a held turn reporting `idle` at its result with its follow-up as a cycle and a background request while idle, output before a queued prompt that Claude Code reports it hasn't started, and `session/close` during a cycle; - **agent:** a steer resuming a held turn's work; the follow-up that ExitPlanMode's "keep planning" interrupts ending `cancelled`; the clear-context continuation taking the cycle over, with the turn's usage; - **turn events:** work that starts while a request is open waits at once. - I made 20 mutations, one per rule above, and each failed a test. The take-overs for a steer and for the clear-context restart first survived, since a cycle stood in for the turn with the same states. The tests now check the usage of the final `idle` too. ## Not covered - No recorded scenario has such a cycle. The start signal (the first main-thread output) is chosen from the agent's side, not confirmed against a live CLI. - After a cancel, the agent drops main-thread output until the next prompt (`session.cancelled` stays set). So a follow-up that Claude Code runs after a cancel is still lost, for v1 and v2. That predates this PR, and I'll fix it separately.v0.89.2-preview.6 |
||
|
|
2a8417ab89 |
refactor: report a session's foreground work apart from the prompt's answer on the experimental ACP v2 surface (#1304)
The first of two PRs that replace #1303, after a design review. This one restructures how the agent reports v2's state, with two small changes in behavior (below). The next PR makes v2 report `idle` at a turn's result and adds model cycles that Claude Code starts on its own. ## Why The agent reported each turn's v2 states (`running`, `requires_action`, `idle`) through the prompt's `TurnEvents`, the same events that answer the prompt. That ties v2's state to when **v1** gets its answer. The clearest case is the hold (`Turn.deferredSettle`): v1 holds a turn open while the background subagents it spawned run, because a v1 client stops listening at the prompt response. So v2 stays `running` too. Making v2 report `idle` at the turn's result in that design would need a v2 branch inside the hold logic. That's the most intricate code in the adapter: steering, cancel, the echo hand-off, ExitPlanMode, idle debts. And a cycle that Claude Code starts on its own would need a second source of v2 state beside the turn's (#1303). ## What changes - **`TurnEvents` only answer the prompt:** `inserted`, `ended`, `failed`. v1 answers at `ended` or `failed`. v2 answers at `inserted`, and uses `ended`/`failed` only to reject a prompt before insertion. - **New `ForegroundEvents`** (`src/turn-events.ts`): the session's foreground work, meaning what a client would wait for before sending the next prompt. Events: `running`, `awaitingUser`, `ended`, `failed`. - Only the v2 surface supplies them, through the new `foreground` option. They map to `state_update` in `v2Foreground`. - A prompt's turn is the foreground work from its insertion until it ends, exactly as before. So `idle` still waits for a held turn's subagents; the next PR changes that. - **The work is keyed by the session id the client knows,** not stored on a `Session`. A clear-context restart (an accepted ExitPlanMode with a fresh context) replaces the Claude Code session behind that id and moves the running turn to it. Work stored on the old `Session` would stay behind. - **Requests report through the work** (`syncAwaitingUser`), not through the active turn. ## Observable changes on v2 1. **The `session/prompt` response now comes before `running`, not after it.** - The 38 v2 golden files change by exactly that, and nothing else; I checked every file with a script. - The old order was not guaranteed: the SDK's `notify` writes after `waitUntilInitialized(...).then(...)`, and the response is written once the handler's promise resolves. The old code won that race by sending `running` before resolving. - The draft lets clients receive the updates before or after the response. 2. **A request that Claude Code opens for a prompt it started, before the prompt's echo, now reports `requires_action` when it opens,** rather than at the echo. The prompt's work is already running then. v1 and AIR clients are unchanged. ## Checks - `npm run check`, `npm run build` and all 2739 tests pass. - Every v2 state test in `acp-v2.test.ts` passes unchanged. - `turn-events.test.ts` now reads the waiting events from the foreground. - New tests: - a request opened before the echo; - the work staying with the session id across a clear-context restart, with a permission request in the continuation. - I made six mutations and each failed a test: no `running` at insertion, no end on `resolve`, no end on `reject`, waiting never reported, waiting only for the active turn, and the work tied to the `Session` object.v0.89.2-preview.5 |
||
|
|
fe8b69edd4 |
feat: accept IDE session hooks without dropping model settings (#1265)
IDEs can pass their own Claude hooks through `_meta.claudeCode.options.settings.hooks` on `session/new` and `session/load`. Before this change, any session settings replaced `CLAUDE_MODEL_CONFIG` entirely, so adding a single hook dropped the deployment's `modelOverrides` / `availableModels`. The two are now merged, and the session settings win per key. The wrapper does not merge hooks itself: Claude Code already runs the hooks of every settings tier, so user and project hooks keep running next to the session ones. Hooks stay scoped to their session, and a `session/load` with different settings recreates the query, which replaces or removes them. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>v0.89.2-preview.4 |
||
|
|
b3bed2f763 |
fix: report a command whose result says interrupted as cancelled on the experimental ACP v2 surface (#1301)
#1289 made a v2 client get `cancelled` for a run that Claude Code aborted, when Claude Code reports it as an error result with `non_execution_kind`. Claude Code has a second way to report an aborted command: a **successful** Bash result whose structured output says `interrupted: true`. A v2 client got `completed` for that. With this PR it gets `cancelled` too, so the rule is the same however Claude Code reports the abort. ## When Claude Code sends that result I read the CLI that SDK 0.3.293 bundles. I didn't reproduce these cases live. | What stops the command | What the adapter receives | v2 before | v2 after | | --- | --- | --- | --- | | The client cancels (`session/cancel`) | an error result, `non_execution_kind: "interrupted"` | `cancelled` (#1289) | same | | A timeout | an error, "Command timed out after …", **not** `interrupted` (or the command is backgrounded) | `failed` | same | | The output limit or a replaced output file | an error with `Exit code 137`, not `interrupted` | `failed` | same | | A `now`-priority message such as `_session/steering`, landing just before the command starts, or while the output limit kills it | a successful result, `interrupted: true` | `completed` | **`cancelled`** | A successful `interrupted` result needs the abort reason `interrupt`, which a `now`-priority message causes. That abort doesn't kill a running command, so this result only comes from the races in the last row. Clients will rarely see it. The point of the change is that v2's status follows what Claude Code reports, however it reports it. v1 and AIR clients are unchanged: `completed` with no exit code, and AIR still gets exit code 1. ## How - `AcpToolCallRenderer.resultStatus` takes the command's `interrupted` fact alongside `nonExecution`. - `result()` used to call `resultFields`, which drops the command facts. It now computes the facts once (`resultFacts`) and builds the fields from them (`fieldsOfResult`). That lets it pass `interrupted` without adding a field to `RenderedResult`, which the public `toolUpdateFromToolResult` returns. - The agent's checks for a finished tool call already count `cancelled` (`isFinalToolCallStatus`, #1289). ## Checks - `npm run check`, `npm run build` and all 2730 tests pass. No golden file changes: no recorded scenario has an interrupted command. - New tests: - **renderer:** an interrupted command on v2, v1 and AIR, and one that isn't interrupted on v2; - **v2 surface:** a v2 client receives `cancelled`, and the turn still ends `end_turn`. - I made four mutations and each failed a test: the rule ignores `interrupted`, every client gets `cancelled`, `result()` doesn't pass the fact, and `result()` inverts it.v0.89.2-preview.3 |
||
|
|
77910dea74 |
refactor: report a tool call that Claude dropped before it ran as cancelled on the experimental ACP v2 surface (#1300)
v2 has a `cancelled` tool call status ("the tool call was cancelled
before it completed"), which v1 doesn't. #1289 started using it for a
run that Claude Code aborted. This PR uses it for the other tool call
that never ran: one that Claude dropped before running it.
## What changes
When a turn ends with `end_turn` and a streamed `tool_use` never reached
a complete assistant message, Claude dropped that tool call before it
ran, for example when it switched to a steering message. The agent
closes it at the end of the turn with "Claude stopped this tool call
before it ran." The turn itself still succeeds.
| Case | v2 client | v1 and AIR clients |
| --- | --- | --- |
| Dropped before it ran | `cancelled` (was `failed`) | `failed`, as
before |
| Ran, but Claude Code returned no result | `failed`, as before, and the
turn fails with `incomplete_tool_call` | same |
The content text is unchanged in both cases.
## How
- **The renderer builds both closing reports**
(`AcpToolCallRenderer.unfinished`), and picks the status from
`ClientCapabilities.v2`, as `resultStatus` does since #1289. Before, the
agent built the report inline with a hardcoded `failed`.
- **The agent** passes whether the tool ran. Its closing step
(`failToolCall`, now `closeUnfinished`) already removes the call from
all of its tracking itself, so no "is this status final" check needed
changing.
## Checks
- `npm run check`, `npm run build` and all 2728 tests pass. No golden
file changes: no recorded scenario drops a tool call.
- New tests:
- **renderer:** dropped and ran-without-result, on v2, v1 and AIR;
- **v2 surface:** a v2 client receives `pending` then `cancelled` for a
dropped tool call, and the turn ends `end_turn`.
- The existing v1 tests in `incomplete-tools.test.ts` are unchanged and
still pass.
- I made five mutations and each failed a test: the renderer never
cancels, the renderer cancels a tool that ran, the agent marks a dropped
call as having run, the agent marks a no-result call as dropped, and the
agent's renderer ignores the client's capabilities.
v0.89.2-preview.2
|
||
|
|
231bbd7dd6 |
refactor: show an Edit's or Write's exact patch before it runs on the experimental ACP v2 surface (#1291)
A v2 diff can't hold the snippet of an Edit's input (it needs line numbers and the operation), so on v2 an auto-approved Edit or Write showed its change only once the PostToolUse hook reported it, after the tool had already run. Only an asked one showed its approval patch first. ## What changes, v2 only The tool call shows the exact patch before the tool runs, auto-approved or not. ## How - **A PreToolUse hook for Edit and Write** (`fileChangePreviewHook`), registered only on v2 connections. Claude Code runs the tool only after the hook returns, so the file is still unchanged when the hook reads it. That's the same guarantee the approval patch in `canUseTool` relies on. The hook sends the same patch (`previewPatchContent`): - **Stream already showed the tool call:** a `tool_call_update` through the session's stream router, pinned like an approval patch. - **Hook came first:** the SDK dispatches hook callbacks as soon as it reads them, ahead of the message queue the agent consumes. The hook then emits the tool call with the patch via `ensureToolCallEmitted`, the permission flow's fix for the same race (#851). - **Subagent tools** are attributed exactly as in `canUseTool`. That rule moves into `toolUseOwner`, which both use, with no behavior change. - **Safe failure:** the hook makes no permission decision, and any failure only leaves the preview out. - **v1 and AIR** register no PreToolUse hook, so Claude Code waits on nothing new there and their output is unchanged. I first tried hanging the preview on the per-tool-use hook registry that PostToolUse uses. That fails when the hook comes before the stream, so the hook lives on the agent instead. ## Golden files The scenario harness now runs PreToolUse before each tool, as Claude Code does, and applies each hook's `matcher`. The v2 golden files of the four file-change scenarios change: - **`write-existing`** (auto-approved): the patch now arrives before the tool completes, and the final hook report no longer repeats it, because the tracker sees no change. - **`write-new`, `edit-with-permission`, `edit-rejected`:** the tool call gets the patch just before the permission request, which carries the same patch. No v1, AIR or plain golden file changes. ## Checks - `npm run check`, `npm run build` and all 2497 tests pass. - A new v2 test runs an auto-approved Edit with the hook both after and before the streamed tool use. It checks that the patch is computed from the file before the edit, arrives before the result, and isn't replaced. - A new test checks that only a v2 session registers the hook, with the `Edit|Write` matcher. - Mutations that fail tests: no hook on v2, the hook on v1 too, the hook-first path doing nothing, the already-shown path sending nothing, and Edit only. - Not pinned by any test: the pin itself. On v2 nothing later in the stream carries content for an Edit or Write, so it's only there for consistency with the approval patch. ## Follow-up AIR has the same gap for an auto-approved Write: it shows no diff until the hook. That's out of scope here, since it changes AIR's output.v0.89.2-preview.1 |
||
|
|
4e1fc1e8df |
chore(main): release 0.89.1 (#1294)
🤖 I have created a release *beep* *boop* --- ## [0.89.1](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.89.0...v0.89.1) (2026-10-10) ### Bug Fixes * push renamed sessions to list subscribers and reuse one listing across pages ([#1293](https://github.com/agentclientprotocol/claude-agent-acp/issues/1293)) ([v0.89.1 |
||
|
|
92abb8fd4d |
fix: push renamed sessions to list subscribers and reuse one listing across pages (#1293)
Two findings of the 2026-10-10 night perf run on a 5,000-session home (findings 8 and 9). ## Rename push (finding 9) The run measured "no push within 10 s" after `_session/rename`. The cause was the harness, not the adapter. The bench renames the newest session to `"Night renamed"`, and an earlier bench run on the same home had already given it that title. The transcript shows that pair of title records twice. A rename to the current title appends nothing, so there was nothing to push. On a fresh home, the same bench gets the push. The hypotheses checked: - The metadata cache is not updated by a rename. The appended records change the transcript's size, and the watcher re-reads it. - The sidecar lies outside the non-recursive watch, but so does every rename. Each one also appends to the transcript, which is watched. Still, the push depended on `fs.watch` plus its 150 ms quiet period. Now a rename, archive, unarchive or delete through this connection publishes at once. `ListSubscriptions.sessionWritten` stats the session's transcripts and reads its row, the way a transcript event would, without waiting. The event that follows finds the row unchanged and sends nothing. Renames by other processes still arrive through the watcher. The 1 s per-session interval of a subscription still applies. ## Listing every page (finding 8) Profiling `SessionIndex.list` showed where a page's time goes. The full enumeration and stat of 5,000 transcripts takes about 20 ms per page. Most of the remaining ~380 ms was the SDK `getSessionInfo`, called once per row read: its lookup runs a `readdir` of the whole project directory (5,000 entries) on every call. - A metadata read no longer calls the SDK. Titles come from the head and tail the index already reads, by the same rule (`sdkTitles`). The tail is searched as the SDK searches it, cut first line included. - The pages after a cursor reuse the list's enumeration. It is keyed by the cursor and kept for at most 30 s, at most 8 lists. It is dropped as soon as a directory of the scope adds or removes an entry, through the `DirListings` identity check. Such a page stats only the transcripts it reads (each with its session's other copies) right before reading them. A transcript that changed since either keeps its order key (no new prompt) or moves up before the cursor, so the page is as fresh as one from a new enumeration. The first page always enumerates. ## Measurements Real adapter (`dist/index.js`), driven as AIR drives it by `acp-perf-harness/air-ui/night/acp-adapter-bench.mjs`. Fixture: 5,000 sessions from `gen-night-data.mjs`. | | before | after | |---|---|---| | Listing every page (25 × 200) | 10.1–16.3 s | 0.92–1.16 s | | First page (51) | 156–472 ms | 46–62 ms | | Show more (50) | 117–283 ms | 13–23 ms | | Push after archive | ~170 ms | 3–4 ms | | Push after a rename | ~170 ms | 3–5 ms | ## Tests - A rename, archive, unarchive and delete each push within 500 ms while the watcher's quiet period is 60 s (fails without the fix). A rename by another process is pushed through the watcher. - Paging: - one enumeration for all pages; - between pages, a transcript emptied, one with a new prompt, and one with only a new answer give the same pages as a new enumeration; - an older copy of a session that got a prompt hides it on later pages; - adding or removing a transcript enumerates again. - A custom title whose record starts before the tail window matches the SDK. - Read-count assertions use the new `SessionIndex.transcriptsRead` counter instead of SDK call counts. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>v0.89.1-preview.1 |
||
|
|
96b6b4f478 |
chore(main): release 0.89.0 (#1279)
🤖 I have created a release *beep* *boop* --- ## [0.89.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.88.0...v0.89.0) (2026-10-09) ### Features * add the AIR sessionIndex extension for fast session lists ([#1268](https://github.com/agentclientprotocol/claude-agent-acp/issues/1268)) ([v0.89.0 |
||
|
|
03d84f6663 |
feat: add the AIR sessionIndex extension for fast session lists (#1268)
## Summary
Adds the opt-in AIR `sessionIndex` extension. With it, an AIR client can
show the latest sessions of a project quickly, with their state, and can
rename, archive and unarchive them over ACP. The full contract is in
`docs/air-extensions.md` ("Session index").
The extension follows ACP RFDs. The transport differs: it uses
`_meta.jetbrains.air` request options and row fields, `_session/*`
methods, and AIR capabilities that are not advertised under ACP v2. The
`archived` list filter also differs (see below).
- **Session list extensions**
(agentclientprotocol/agent-client-protocol#2323):
- `limit`, ordered by last user activity (`lastPromptAt ?? updatedAt`),
and `includeWorktrees`.
- Row fields `lastPromptAt`, `model` and `forkedFrom`.
- `state`: `running`, `idle`, `requires_action`, and `error`, which the
RFD lacks.
- `lastTurnEndedAt`.
- `cost`: the SDK's `total_cost_usd` for a session that runs on this
connection only. No cost is read from transcripts.
- Live updates come from an AIR list subscription (below).
- **Session archive** (agentclientprotocol/agent-client-protocol#2161):
- `archived` is on every row, `session_info_update` is sent on a change,
and an unknown session is `-32002`.
- The `archived` list filter has three values: `"unarchived"` (the
default), `"archived"` (archived sessions only) and `"all"` (both, in
one order). #2161 has a boolean here. Its `false` is `"unarchived"`, its
`true` is `"all"`, and `"archived"` has no counterpart.
- **Capabilities**: an AIR client that declares `sessionIndex` gets
these in `_meta.jetbrains.air.capabilities`. The client declares only
`sessionIndex`.
- `sessionIndex`.
- `sessionArchive`: `_session/archive` and `_session/unarchive`. The RFD
capability is `archive` (#2161).
- `sessionRename`: `_session/rename`. The RFD has capability `setTitle`
and method `session/set_title`
(agentclientprotocol/agent-client-protocol#1987).
- `sessionListSubscribe`: `_session/list/subscribe`,
`_session/list/unsubscribe` and `_session/list/changes`. No RFD
counterpart.
### Clients without `sessionIndex`
Clients that don't declare `sessionIndex`, which means non-AIR clients
and released AIR builds, keep the current behaviour. They get the same
SDK `listSessions` path, page size, cursor and initialize capabilities,
with no watchers and no notifications. There are two deliberate
deviations, both strictly safer:
1. **`session/delete` from an AIR client without `sessionIndex`.**
Released AIR builds use delete as "Done". Instead of deleting the
transcript (data loss), the adapter archives the session in AIR's own
Claude format (below). Archived sessions are hidden from that client's
list, so "Done" looks the same and nothing is lost. An unknown session
keeps the SDK's error. Other clients get the SDK delete, as before.
2. **`session/close`** of a session that is not loaded here returns `{}`
instead of throwing.
### Sessions open in another process
`session/load`, `session/resume` and `session/prompt` work on a session
that another live Claude Code process has open, for every client, as the
Claude CLI does. Rename, archive, unarchive and delete also go ahead,
writing records or deleting transcripts as the CLI's `/rename` and
delete do. The other process keeps running.
### Archive format and rule
The archive state uses AIR's native Claude archive format
(`ClaudeStoreThreadBackend.appendTitleRecords`, ADR 0140), so AIR's
native path and the adapter read and write the same state.
- **Archive** appends `{"type":"custom-title","customTitle":"[archived]
<title>",…}` and `{"type":"agent-name","agentName":"[archived]
<title>",…}` to every transcript copy. **Unarchive** appends the same
pair without the prefix. Titles are cut to the CLI's 200 characters.
- **Archived is decided by the agent name first**, as in AIR's native
Claude provider. A session's name is its last top-level `agentName`,
else the custom title the SDK reports. The session is archived exactly
when that name starts with `[archived] `. The listed title comes from
the same source, shown without the prefix.
- Title records are metadata: `updatedAt` and the list order do not
move.
### Rename, archive and delete
- **Rename** writes the same title pair. An archived session keeps its
prefix. A session running here is renamed through its CLI's
`rename_session` control request, serialised with auto-titling. Other
sessions get the records plus the `custom-title.json` sidecar the CLI
writes. No generated title replaces a client title, but a title stored
later by someone else (a `/rename`) is still adopted.
- **Archiving stops the running session.** `_session/archive` of a
session loaded on this connection does four things:
1. It interrupts the running turn.
2. It retitles the session through its CLI. The adapter also appends the
matching `agent-name` record to the CLI's transcript, because the CLI
writes that record only later.
3. It closes the session as `session/close` does.
4. It archives the transcripts once more if the closed CLI left them
unarchived.
The client then gets `session_info_update` with `archived: true`.
Unarchive does not reopen the session. A session that is not loaded here
is archived by writing the title records.
- **Delete** for a `sessionIndex` client deletes every copy of the
session and its `<sessionId>/` directories. A session running here is
closed first.
### Implementation (`src/session-index/`)
- **Index**:
- Reads the project dirs with `readdir` + `stat`, and keeps a 2000-entry
metadata LRU keyed by (path, mtime, size).
- Cache misses are read in parallel through `getSessionInfo`, plus a
growing tail read (64 KB → 4 MB) for the last prompt, last message, turn
end and agent name.
- SDK semantics are kept: sidecar titles, `continued-in`, long-path
hashes, darwin case, and first-prompt extraction (ported, and
fuzz-compared with the SDK).
- The `cwd` scope follows the SDK `listSessions`. Worktrees expand to
the same relative subdirectory, and a session copied to several project
directories is listed from its newest copy.
- The keyset cursor is bound to `cwd`, `archived` and
`includeWorktrees`. Session ids match exactly.
- **State**:
- For sessions of this process, the SDK state.
- For other sessions, the Claude Code live registry
(`~/.claude/sessions/<pid>.json`, with pid + procStart liveness and
Claude Desktop's rules) or the transcript tail. The registry is read
only for the row state.
- `error` means the session is not running and its last turn ended with
an error, not a cancel.
Documented in `docs/air-extensions.md`, with an extension → RFD mapping
table and the remaining differences.
### Measured on a real `~/.claude` (233 transcripts, read-only)
| Case | New path | Old SDK path |
|---|---|---|
| cwd, limit 50, cold process | ~80 ms | 78–156 ms (all rows) |
| cwd, limit 50, warm | 1.3–4 ms | — |
## Session list subscription
Replaces polling the first page. It is only for a client that declared
`sessionIndex`; the agent advertises `sessionListSubscribe` with it,
never under ACP v2.
- **Subscribe**: `_session/list/subscribe { cwd }` returns `{
subscriptionId }`. `cwd` must be absolute, else `-32602`; other params
are ignored.
- The scope is `cwd` and the same subdirectory in every linked worktree,
in any archive state, without sidechains.
- **Changes**: `_session/list/changes { subscriptionId, sessions,
removed }`.
- `sessions` holds the full `session/list` row of each session that
appeared, or whose title, `lastPromptAt`, `state`, `lastTurnEndedAt`,
`cost`, `model`, `forkedFrom` or `archived` changed since the last row
sent.
- The first change of a session after subscribe is sent in full. After
that, a change of `updatedAt` alone sends nothing.
- `removed` lists sessions that were deleted or moved out of scope.
- Archive and unarchive are row changes.
- A session is sent at most once a second, with one notification per
subscription per pass.
- **Unsubscribe**: `_session/list/unsubscribe { subscriptionId }`
returns `{}` and is idempotent.
- A subscription lives until unsubscribe or until the connection closes.
- A connection has at most 128 subscriptions. The 129th is `-32602` with
`data.reason: "too_many_subscriptions"`.
- Subscriptions of the same `cwd` share one watch and are notified
independently.
- **Delivery** is best effort, with no resync and no sequence numbers. A
rescan every 10 s (and 1 s after a project directory watch opens) sends
whatever differs.
- **Implementation** (`list-subscriptions.ts`, `session-info.ts`):
- A non-recursive `fs.watch` on the scope's project dirs, the registry
and the projects root.
- A transcript event re-reads only that transcript, and a registry event
reads only that `<pid>.json`. Sessions running here push their SDK state
at once.
- Subscribe reads no session row. The rescan reads only transcripts
whose stat changed or whose read failed.
### Measured: one subscription on the 5,200-session project (Docker
`node:22`, 2 CPU, 2 GB, shared host)
300 s of churn: 20 sessions appending 10/s each (updatedAt-only),
registry status flips every 200 ms, and a session created every 2 s and
deleted 10 s later.
| | Old `list_changed` watcher | Subscription |
|---|---|---|
| Agent CPU p50 / max | 19% / — | 12.9% / 49% |
| Event loop p99 (p50 / max of 1 s windows) | — | 16 / 31 ms; worst
single delay 87 ms |
| Change latency p50 / p95 | 950 ms (hint) | create 291 / 570 ms, delete
279 / 506 ms; registry state 902 / 990 ms* |
| Notifications / min | — | 90 (896 rows, 0 rows for updatedAt-only
appends) |
| RSS | flat ~160 MB | 159 → 198 MB (heap flat ~50 MB) |
\* The registry flips the same 5 sessions every 200 ms, so each is held
to the once-a-second limit. Latency is measured from the first unsent
change.
These numbers were measured with an earlier subscribe that read every
row up front. Subscribe now reads no rows, so that one-off cost no
longer occurs. The lazy subscription has not been re-measured yet.
## Load test: 10,000 sessions (Docker, 2 CPU, 2 GB)
Calibrated data: 10k transcripts, 15 GB, 36k subagent files, and one
5,200-session project. Sizes match a real profile (p50 311K / p90 2.67M
/ p99 20.5M).
| Scenario (limit 50) | Before | After |
|---|---|---|
| Big project, first page cold / warm p50 | out of memory | 225 / 19 ms
|
| No cwd, cold / warm p50 | 1,722 / 178 ms | 209 / 45 ms |
| Page through 4,637 rows | 457 s | 11.4 s cold, 2.9 s warm |
| Delete p50 / 100 concurrent | 23.8 ms / 4.4 s | 7.8 ms / 0.8 s |
| 300 s of churn (60k changes) | out of memory after 3 s | list p50 54
ms, 0 stalls >50 ms, RSS flat ~160 MB |
## Streaming: keep the event loop responsive under a backlog
Under a backlog of buffered SDK messages, the adapter could freeze its
event loop for seconds. Every `query.next()` on a non-empty SDK queue
resolves in a microtask, so `runConsumer` forwarded the whole backlog
without reaching a macrotask boundary. Incoming JSON-RPC, including
`session/cancel`, waited until the backlog drained.
1. **Consumer yields to the event loop.** A shared `EventLoopYielder`
(`src/utils.ts`) gives the event loop a turn via `setImmediate` once the
current iteration has run for 8 ms. Order, content and cancel semantics
are unchanged.
2. **No per-message copy in `nodeToWebWritable`** for byte streams.
Object-mode streams keep the copy.
3. **Diff memo.** The approval preview and the PostToolUse patch no
longer diff the same texts twice. Timed-out diffs are not remembered.
4. **Prefix guards** in `parseMcpCommand` and `sanitizeTitle`. Both give
identical output.
5. **Replay yields between history messages**, with the same budget.
With 10 sessions × 1 MB, the max event-loop delay went from 4205 ms to
1626 ms, and ping p95 from 2793 ms to 262 ms (interleaved A/B, Docker
`node:22`, 2 CPU). Single-stream throughput is unchanged. Remaining
stalls are inside the Claude SDK and GC.
## Test plan
- [x] Rebased on `origin/main`. `npm run check`, `npm run build` and
`npm run test:run` pass.
- [x] Regression tests for the unchanged non-`sessionIndex` paths.
- [x] Regression tests for every review finding.
- [x] Reviews: Codex (gpt-6-astra) and a Claude review subagent, run as
independent passes against `origin/main` until a round found nothing of
normal or higher severity.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
v0.88.1-preview.7
|
||
|
|
15034659f7 |
refactor: list a session's commands in its setup responses on the experimental ACP v2 surface (#1290)
The v2 draft lets the `session/new`, `session/resume`, and `session/fork` responses carry `availableCommands` ([agent-client-protocol#2259](https://github.com/agentclientprotocol/agent-client-protocol/pull/2259)), so a client can show a session's commands without waiting for an update. The v2 surface now fills it. ## Before and after, for a v2 client - **Before:** the setup response, then an `available_commands_update` with the list, from a `setTimeout` after the response. - **Now:** the list is in the response itself, for `session/new`, `session/resume` with and without replay, and `session/fork`. The field is left out when the list is empty, as the draft asks. - **No duplicate:** on a v2 connection the agent no longer sends that post-setup update (`afterSetupResponse`). Later changes still arrive as `available_commands_update`, for example the terminal-bound filtering after Claude Code's first init frame, or `commands_changed`. - v1 and AIR are unchanged. ## Cost None. The commands come from `Query.supportedCommands()`, which in the SDK is `this.latestCommands ?? (await this.initialization).commands`: the cached initialization result that setup already awaited. So the response doesn't wait any longer. ## Code - `ClaudeAcpAgent.availableCommands(sessionId)` returns the session's command list, factored out of `sendAvailableCommandsUpdate`. - The three post-setup `setTimeout` blocks become one `afterSetupResponse`, which skips the command update on v2. - `setupCommands` in `src/v2/agent.ts` adds the list in v2 form. A failure to read the commands is logged and leaves them out, since they're optional and the session already exists. ## Title `refactor:`, like the earlier v2 surface additions (#1253, #1255, #1256), so no release note announces v2 yet. Retitle it if you'd rather. ## Checks - `npm run check`, `npm run build` and all 2494 tests pass. - The 40 v2 golden files each move the list from the post-setup notification into the setup response, and nothing else changes; I checked every diff with a script. No v1 or AIR golden file changes. - New v2 tests cover each setup response (new, resume, resume with replay, fork) and check that no post-setup update follows. - I removed each part in turn, and each removal fails tests: the agent still sending the update on v2, and the commands missing from new, from resume, or from fork. - Not covered: the empty-list case, because the adapter always adds its `/mcp` command.v0.88.1-preview.6 |
||
|
|
55c3106c60 |
fix: report an interrupted tool call as cancelled on the experimental ACP v2 surface (#1289)
v2 has a `cancelled` tool call status ("the tool call was cancelled
before it completed"), which v1 doesn't. Until now a v2 client got
`failed` for a tool call the user interrupted, the same as a real
failure.
## What changes
Claude Code stamps a tool result that carries no tool output with
`tool_result_meta.non_execution_kind`. For a **v2 client**:
| Kind | Meaning (set in the bundled CLI) | Status |
| --- | --- | --- |
| `interrupted` | the turn was interrupted while the tool ran |
`cancelled` |
| `cancelled` | the call's own run was stopped (aborted, or moved to the
background) | `cancelled` |
| `user-rejected`, `permission-rule`, `automode-*` | the tool was
refused | `failed`, as before |
v1 and AIR clients are unchanged: `failed`, with the kind in
`_meta.claudeCode.nonExecutionKind`. v2 clients still get that `_meta`
too.
## How
- **The renderer decides the status**
(`AcpToolCallRenderer.resultStatus`), from `ClientCapabilities.v2`.
That's the same pattern the agent uses for v2 diffs: the value travels
as a v1 status (`V2_CANCELLED`, with a cast, since v1's `ToolCallStatus`
is closed), and the v2 surface passes it through. The v2 translation
still doesn't read `_meta`. The rule covers the regular result and the
two fallback reports in `toAcpNotifications`: an untracked tool call,
and a TodoWrite/Task tool call that only a permission request surfaced.
- **The agent's own "finished" checks** now use `isFinalToolCallStatus`,
which counts `cancelled`. Two places compared against
`completed`/`failed`: one removes a finished call from the turn's
foreground work, the other drops its eager-session and dispatched-call
entries. Without this, a cancelled call would have leaked those entries.
## Not covered
Replay doesn't have `tool_result_meta`, so a replayed interrupted call
stays `failed`, just as v1 replay has no `nonExecutionKind`.
## Checks
- `npm run check`, `npm run build` and all 2493 tests pass. No golden
file changes: no recorded scenario has an interrupted tool.
- New tests:
- **renderer:** each kind, for v2, v1 and AIR clients, plus the
permission-surfaced fallback;
- **agent:** a cancelled call is released from the dispatched-call
bookkeeping;
- **v2 surface:** a v2 client receives `cancelled`.
- I removed each part of the change in turn, and each removal fails a
test: never cancelled, cancelled for v1 too, refusals cancelled too, the
bookkeeping ignoring `cancelled`, and the fallback branches keeping
`failed`.
v0.88.1-preview.5
|
||
|
|
060bdb212a |
fix: report a failed turn with the error stop reason on the experimental ACP v2 surface (#1288)
Follow-up to #1287 (SDK 1.8.0). A v2 turn that fails after its prompt was taken in now ends with the standard `error` stop reason from [agent-client-protocol#2281](https://github.com/agentclientprotocol/agent-client-protocol/pull/2281), in place of the stand-ins we used while the SDK didn't have it. ## Before and after Before, on the v2 surface only: 1. an error `notice`, titled with the failure; 2. `idle` with the custom stop reason `_error`, and the JSON-RPC error in `_meta.claudeCode.error`. Now, one update: ```json { "sessionUpdate": "state_update", "state": "idle", "stopReason": "error", "error": { "code": -32603, "message": "Internal error: API Error: 529 Overloaded" } } ``` - `error` is the JSON-RPC error that v1 rejects the prompt with: same code, message and data. A client can show `message` and handle `code` as for an error response, so `-32000` (auth required) can start its sign-in again, which `_error` couldn't. - The notice is gone. The v2 draft's [Failures](https://agentclientprotocol.com/protocol/v2/draft/prompt-lifecycle) section reports a failure that ends the work through the stop reason, and keeps notices for failures the agent recovers from. - v1 and AIR are unchanged. ## Also - `TurnEvents.failed` loses its `title` parameter. Only the v2 notice read it, and v1's prompt already ignored it. Its pass-through in `Turn.reject` and `failActive` goes too. AIR's session failures still get the title, through their own path. - The v2 scenario harness treats an `error` stop reason as a failed turn, and reports its message. ## One visible difference The old notice showed the failure title, for example `API Error: 529 Overloaded`. The error's message is v1's, `Internal error: API Error: 529 Overloaded`, which v1 clients already show. If we'd rather v2 clients see the shorter text, the alternative is to use the title as the error's `message` and keep v1's code and data. That would make v2's message differ from v1's. ## Checks - `npm run check`, `npm run build` and all 2489 tests pass. No golden file changes: no recorded scenario fails a turn after insertion. - The two v2 failure tests pin the new update and the auth code. Putting `_error` back fails both.v0.88.1-preview.4 |
||
|
|
446a7217fa |
feat(deps): Bump @agentclientprotocol/sdk to 1.8.0 (#1287)
Bumps `@agentclientprotocol/sdk` from 1.7.0 to 1.8.0. The release is one change: the protocol schema moves to v1.25.0 and v2.0.0-alpha.8. ## What changed in the SDK Comparing the generated types of 1.7.0 and 1.8.0, the only type change in v1 and v2 is the `error` stop reason from [agent-client-protocol#2281](https://github.com/agentclientprotocol/agent-client-protocol/pull/2281): `IdleStateUpdate` gains `ErrorStopReason & { stopReason: "error" }`, and `ErrorStopReason` (an optional JSON-RPC `error`) is new. The v2 index no longer exports `StopReason`; the adapter doesn't import it. ## The one code change `idle()` in `src/v2/prompt.ts` built the v2 `state_update` for an ended turn by spreading the turn's stop reason into a `SessionUpdate`. With the larger `IdleStateUpdate` union, TypeScript can no longer match a union of stop reasons against it (it falls through to the custom and unknown members). The state is now typed as the members for the agent's own stop reasons (`Extract<IdleStateUpdate, { stopReason: StopReason }>`). A stop reason that v2 doesn't know is still a compile error, which I checked with a throwaway probe. The output is unchanged. ## Not in this PR The adapter doesn't use the new `error` stop reason yet. Two stand-ins stay until a follow-up switches them, which changes behavior and golden files: - a failed v2 turn's `_error` stop reason with `_meta.claudeCode.error`, and its error `notice` (`src/v2/prompt.ts`); - a failed subagent's `_meta.claudeCode.error` in the RFD form (#1257, not merged yet). ## Checks - `npm run check`, `npm run build`, and all 2489 tests pass. - The scenario suites validate every message against the schemas that 1.8.0 ships (`schema/schema.json`, `schema/v2/schema.unstable.json`), loaded from the installed SDK. No golden file changes.v0.88.1-preview.3 |
||
|
|
908c994901 | feat: Support custom instructions for agent sessions via meta (#1177) v0.88.1-preview.2 | ||
|
|
966be7abe6 |
fix: recreate live sessions on logout when it clears a provider or gateway override (#1278)
## What ACP `logout` cleared `providerConfig` and the gateway credentials but left live queries running. Provider and gateway routing is baked into each query's env, so open sessions kept using the old route after logout. `logout` now recreates every live session through the same path as `providers/disable` (`enqueueProviderUpdate`), but only when a provider or gateway override was active. It waits for active turns, then resumes each session under its id with native routing. This also runs when `claude auth logout` fails, because the override is already cleared by then. A plain CLI-login logout does not touch live sessions: the running CLI picks up the logout by itself. ## Behavior change With an override active, `logout` now waits for running turns before it returns, like `providers/disable`. Under `--hide-claude-auth`, a session that cannot be recreated signed out is dropped, as with `providers/disable`.v0.88.1-preview.1 |
||
|
|
d43fec3fa3 |
chore(main): release 0.88.0 (#1277)
🤖 I have created a release *beep* *boop* --- ## [0.88.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.87.0...v0.88.0) (2026-10-08) ### Features * **deps:** update Claude SDK ([#1276](https://github.com/agentclientprotocol/claude-agent-acp/issues/1276)) ([v0.88.0 |
||
|
|
0304f743f8 | feat(deps): update Claude SDK (#1276) v0.87.1-preview.2 | ||
|
|
6a162baf8b |
feat(deps): Bump @anthropic-ai/claude-agent-sdk to 0.3.293 (#1271)
Bumps the minor group with 12 updates in the / directory: | Package | From | To | | --- | --- | --- | | [@anthropic-ai/claude-agent-sdk](https://github.com/anthropics/claude-agent-sdk-typescript) | `0.3.287` | `0.3.289` | | [eslint](https://github.com/eslint/eslint) | `10.11.0` | `10.12.0` | | @anthropic-ai/claude-agent-sdk-darwin-arm64 | `0.3.287` | `0.3.289` | | @anthropic-ai/claude-agent-sdk-darwin-x64 | `0.3.287` | `0.3.289` | | @anthropic-ai/claude-agent-sdk-linux-arm64 | `0.3.287` | `0.3.289` | | @anthropic-ai/claude-agent-sdk-linux-arm64-musl | `0.3.287` | `0.3.289` | | @anthropic-ai/claude-agent-sdk-linux-x64 | `0.3.287` | `0.3.289` | | @anthropic-ai/claude-agent-sdk-linux-x64-musl | `0.3.287` | `0.3.289` | | @anthropic-ai/claude-agent-sdk-win32-arm64 | `0.3.287` | `0.3.289` | | @anthropic-ai/claude-agent-sdk-win32-x64 | `0.3.287` | `0.3.289` | | [@modelcontextprotocol/sdk](https://github.com/modelcontextprotocol/typescript-sdk) | `1.31.0` | `1.32.1` | | [hono](https://github.com/honojs/hono) | `4.13.12` | `4.13.13` | Updates `@anthropic-ai/claude-agent-sdk` from 0.3.287 to 0.3.289 <details> <summary>Release notes</summary> <p><em>Sourced from <a href="https://github.com/anthropics/claude-agent-sdk-typescript/releases">@anthropic-ai/claude-agent-sdk's releases</a>.</em></p> <blockquote> <h2>v0.3.289</h2> <h2>What's changed</h2> <ul> <li>Updated to parity with Claude Code v2.1.289</li> </ul> <h2>Update</h2> <pre lang="sh"><code>npm install @anthropic-ai/claude-agent-sdk@0.3.289 # or yarn add @anthropic-ai/claude-agent-sdk@0.3.289 # or pnpm add @anthropic-ai/claude-agent-sdk@0.3.289 # or bun add @anthropic-ai/claude-agent-sdk@0.3.289 </code></pre> <h2>v0.3.288</h2> <h2>What's changed</h2> <ul> <li>Updated to parity with Claude Code v2.1.288</li> </ul> <h2>Update</h2> <pre lang="sh"><code>npm install @anthropic-ai/claude-agent-sdk@0.3.288 # or yarn add @anthropic-ai/claude-agent-sdk@0.3.288 # or pnpm add @anthropic-ai/claude-agent-sdk@0.3.288 # or bun add @anthropic-ai/claude-agent-sdk@0.3.288 </code></pre> </blockquote> </details> <details> <summary>Changelog</summary> <p><em>Sourced from <a href="https://github.com/anthropics/claude-agent-sdk-typescript/blob/main/CHANGELOG.md">@anthropic-ai/claude-agent-sdk's changelog</a>.</em></p> <blockquote> <h2>0.3.289</h2> <ul> <li>Updated to parity with Claude Code v2.1.289</li> </ul> <h2>0.3.288</h2> <ul> <li>Updated to parity with Claude Code v2.1.288</li> </ul> </blockquote> </details> <details> <summary>Commits</summary> <ul> <li><a href="v0.87.1-preview.1 |
||
|
|
b2dbc8f5a1 |
chore(main): release 0.87.0 (#1261)
🤖 I have created a release *beep* *boop* --- ## [0.87.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.86.0...v0.87.0) (2026-10-07) ### Features * **deps:** Bump the minor group with 2 updates ([#1260](https://github.com/agentclientprotocol/claude-agent-acp/issues/1260)) ([v0.87.0 |
||
|
|
390b0b9f8b |
fix: settle prompts folded into a task-notification cycle (#1233)
## Motivation A prompt sent while Claude Code runs a turn it started on its own, for example after a background task finished, is folded into that turn. The turn's result keeps `origin: task-notification` but lists the prompt in `user_message_uuids`. The adapter routed results by origin only, so `session/prompt` was never answered and the client stayed in a running state. A JetBrains AIR session stayed "running" for hours this way. ## Change A task-notification result that names a pending prompt now answers it. A turn held for its subagents still waits for them. Results without the uuids behave as before. ## Tests Unit tests in `acp-agent.test.ts` and a replay of the session in `folded-prompt-scenario.test.ts`. Both hang without the fix.v0.86.1-preview.3 |
||
|
|
0724b17c58 |
feat(deps): Bump the minor group with 2 updates (#1260)
Bumps the minor group with 2 updates: [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) and [ip-address](https://github.com/beaugunderson/ip-address). Updates `@types/node` from 26.6.3 to 26.6.4 <details> <summary>Commits</summary> <ul> <li>See full diff in <a href="https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node">compare view</a></li> </ul> </details> <br /> Updates `ip-address` from 10.7.2 to 10.7.3 <details> <summary>Release notes</summary> <p><em>Sourced from <a href="https://github.com/beaugunderson/ip-address/releases">ip-address's releases</a>.</em></p> <blockquote> <h2>v10.7.3</h2> <h2>What's Changed</h2> <ul> <li>Reject an in-addr.arpa name longer than 32 characters before splitting it by <a href="https://github.com/beaugunderson"><code>@beaugunderson</code></a> in <a href="https://redirect.github.com/beaugunderson/ip-address/pull/228">beaugunderson/ip-address#228</a></li> </ul> <p><strong>Full Changelog</strong>: <a href="https://github.com/beaugunderson/ip-address/compare/v10.7.2...v10.7.3">https://github.com/beaugunderson/ip-address/compare/v10.7.2...v10.7.3</a></p> </blockquote> </details> <details> <summary>Commits</summary> <ul> <li><a href="v0.86.1-preview.2 |
||
|
|
0e83a06c4a |
fix: async task routing, Monitor tasks, structured task ids, and abandoned tool calls (#1249)
It affects AIR clients with the `asyncTasks` capability.
## Problem
- A subagent can start a background Bash or Monitor and then end its
turn. The task ends after the subagent finished. The route of the task
dropped every update of a finished child generation, so the client never
got the terminal `async_task_state_update`. The task stayed running in
the client forever. One AIR log held 440 such dropped updates.
- The `Monitor` tool streams the stdout of a background command to the
model only. The SDK reports its task as `local_bash`, so the client
showed it as a background shell. In one real session, 18 of 18 Monitor
tasks had no output file, no progress and no result. The Stop button
only broke the agent's wait.
- The adapter read a task id from the text of a Bash tool result. A
foreground `sed`, `strings` or `python3` that printed adapter source,
test fixtures or CLI strings became a background task, for example with
the id `";\n const start = text` or `${e}`. The client got a spawn and a
`backgrounded` marker on a foreground call, and the task never ended.
- Claude Code 2.1.287 reports a background command in four texts, and
only one gave an output path. A subagent tool result almost never has
`tool_use_result` (0 of 48 in one session). So a subagent command that
hit its timeout got no output path (9 of 10).
## Fix
- `NativeSubagentRuntime.routeOfToolCall` still sends
`async_task_spawned`, `async_task_progress` and
`async_task_state_update` to the child generation that owned the tool
call after that child finished. It never sends them to a later
generation. Every other late update is dropped as before. The spawn is
in the list because a held task can get its tool call id after the
subagent finished.
- `AsyncTaskRuntime` takes a `toolNameOf` lookup. A task of a `Monitor`
tool call is ignored, like a subagent task: no spawn, no progress, no
state, and no stop. A held task that gets its tool call id later goes
through the same check. A `local_monitor` task is ignored too. The
`Monitor` tool call stays in the transcript.
- A task comes only from structured data:
`tool_use_result.backgroundTaskId` or the SDK task events. The text id
parser is gone.
- The output path of a known task can come from the result of its own
tool call. The path ends with `tasks/<id>.output` and starts after the
nearest `": "` on the same line, so a path with spaces works, for
example under `C:\Users\John Doe\AppData\Local\Temp`. It must be
absolute (POSIX, a Windows drive or UNC), and it never holds the end of
another path. The parse is linear in the text length. All four texts
give the path in both event orders: the tool result before or after the
SDK names the task. The memory is bounded. A structured path still wins,
and the terminal `output_file` reaches the client also after a level
close.
- The `backgrounded` marker comes only from structured data: the
structured task, a known background task of the tool call, or a
`run_in_background` input. The "both event orders" above holds for the
path, not for this marker. The marker rides the tool result update, so a
command that the SDK names only after its tool result gets the marker
only from a `run_in_background` input. A command that the user moved to
the background by hand gets no marker in that order.
- The replay behaves the same in each case.
## Tests
- Native subagent and agent tests: a task update and a late spawn after
the child finished go to `worker-1`; a held task of a subagent gets its
spawn and state in the child session after the subagent finished; a late
message chunk is still dropped; a subagent's background Bash ends after
the subagent and the client gets the terminal state in the child
session.
- Monitor: runtime, live and replay tests, also a task that names its
tool call after the tool result. A Monitor task cannot be stopped.
- Structured ids: foreground output with each marker text creates no
task; each of the four texts gives the path in both orders, also a
Windows and a POSIX path with spaces and a UNC path; no path of another
task, another tool call, `.outputs`, a relative path, a path without a
`": "` boundary, or a path across a line; two paths on one line are not
glued; a 2 MB text without `": "` parses in milliseconds; a structured
path wins. An agent test covers a subagent without `tool_use_result`,
and a replay test takes the path from the tool result.
- The new tests fail without the change. Build, lint, format and all
tests pass.
## Abandoned streamed tool calls
This part affects every client, Zed included.
**Problem.** The adapter opens a tool call at the `content_block_start`
of a streamed `tool_use`. Claude can abandon that block before it is
complete, for example for a steering message. The tool then never runs.
At end_turn the settle check reported the session failure "Claude ended
the turn without returning results for tool calls"
(`incomplete_tool_call`). A real AIR session showed a red service error
after 27 minutes of normal work, for a call that never had input.
**Fix.**
- The session records the tool calls that Claude Code sent to the tool
runner: the `tool_use` blocks of a complete assistant message, and the
tool calls of a permission request. The record is bounded and is dropped
when the call ends.
- At end_turn, an open foreground call without that record is abandoned.
The adapter closes it as failed with "Claude stopped this tool call
before it ran." and reports no session failure. The turn ends as
`end_turn`.
- An open call with that record keeps the old behaviour: the old failure
text and the `incomplete_tool_call` session failure.
- The cleanup and the cancellation checks are the same for both groups.
- The replay does not change, because the history holds no partial
`tool_use`. No scenario golden file changes, for AIR, plain or Zed.
**Tests.** A partial `tool_use` and then text ends as `end_turn` with
the neutral close. A mixed turn still fails for the unfinished call. A
steering message during a partial `tool_use` gives no failure. The
existing incomplete-tool tests now send the complete `tool_use` and keep
their failure. The new tests fail without the change.
v0.86.1-preview.1
|
||
|
|
7b5c61a4ed |
chore(main): release 0.86.0 (#1225)
🤖 I have created a release *beep* *boop* --- ## [0.86.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.85.1...v0.86.0) (2026-10-05) ### Features * answer /mcp in chat with the MCP server status and a reconnect ([#1218](https://github.com/agentclientprotocol/claude-agent-acp/issues/1218)) ([v0.86.0 |
||
|
|
5598efbc8a | fix: excessive message/token_count API calls when probing context window (#1258) v0.85.2-preview.26 | ||
|
|
07c7ed3752 |
refactor: fork sessions on the experimental ACP v2 surface (#1256)
Step 6 of opt-in ACP v2 support, option (b) as decided in review. Stacked on #1255: both change the `initialize` line of every v2 golden file. Once #1255 merges, I'll merge `main` into this branch so the diff shows fork alone. Nothing changes on ACP v1. ## Why fork, then resume The agent's v1 fork copies the transcript into a new session and returns only its id. Until a client resumes or loads the fork, a prompt to it fails with "Session not found", and the request's `mcpServers` go unused. AIR, the only v1 client that forks, loads the fork right away. The fork RFD describes the response as "the same as `session/new`", with config options, and the request as taking `session/load`'s options. So on v2 the client gets a session it can prompt at once: - `v1ForkSessionRequests` (`src/v2/session.ts`) builds the v1 fork, then a v1 resume of the fork without replay, with the request's `cwd`, `additionalDirectories` and `mcpServers`. The request's `_meta` goes to the fork alone. - The response is the new `sessionId` plus the resume's `configOptions`. The available commands follow as an update, as for `session/new` and `session/resume`. - `capabilities.session.fork: {}` is advertised. Each fork starts its own Claude Code process, as `session/new` does. The draft doesn't say whether a fork must be live, so that's recorded as feedback for it. ## Verification - An end-to-end test forks a session with an HTTP MCP server, checks the forked Claude Code session resumes the copied transcript with that server, and prompts the fork without a `session/resume`. - Removing the resume, the MCP servers or the capability fails tests. - Every v2 golden file changes on its `initialize` line only, which gains `session.fork`. - Build, lint and all 2411 tests pass.v0.85.2-preview.25 |
||
|
|
3a61172e19 |
refactor: serve providers on the experimental ACP v2 surface (#1255)
Step 6 of opt-in ACP v2 support. Stacked on #1254: both change the `initialize` line of every v2 golden file. GitHub retargets this PR to `main` once #1254 merges. Nothing changes on ACP v1. ## Change - The v2 surface serves `providers/list`, `providers/set` and `providers/disable` through the agent's unstable v1 methods. Every provider type is identical in v1 and v2, so requests and responses pass through unchanged. - The v2 `initialize` response advertises `capabilities.providers: {}`, a constant like `capabilities.session`, since the agent serves the methods for every client. ## Verification - An end-to-end test lists the provider, sets a gateway and lists it, gets `invalid_params` for an unknown provider, and disables the provider back to the native routing. - The handshake test checks the capability. Removing the capability or a handler fails tests. - Every v2 golden file changes on its `initialize` line only, which gains `providers: {}`. - Build, lint and all 2410 tests pass.v0.85.2-preview.24 |
||
|
|
4ddda8f37e |
refactor: stop advertising steering on the experimental ACP v2 surface (#1254)
Found while reviewing what's left of opt-in ACP v2 support. Nothing
changes on ACP v1.
## Problem
The agent advertises `_session/steering` in the top-level `_meta` of its
v1 `initialize` response (`steering: { supported: true }`), and the v2
handshake passed that `_meta` through. The v2 surface doesn't register
`_session/steering`, so a v2 client that steered got "method not found".
## Change
The v2 `initialize` response leaves out the top-level `_meta` keys that
advertise an extension the v2 surface doesn't serve
(`UNSERVED_EXTENSION_KEYS` in `src/v2/initialize.ts`), which is
`steering` alone today. That sits next to the existing rule that keeps
the unstable `providers` capability unadvertised.
Whether v2 serves steering is left to the plan for the unstable and
extension surfaces. The protocol has no steering RFD yet, and steering
may need a different design on v2, which answers a prompt as soon as
it's accepted.
## Verification
- The handshake test checks that the v2 response has no
`_meta.steering`. The v1 handshake tests still check that v1 advertises
it.
- Every v2 golden file changes on its `initialize` line only. The line
loses its `_meta` and is otherwise identical.
- Build, lint and all 2409 tests pass.
v0.85.2-preview.23
|
||
|
|
96acfeccf9 |
refactor: report compaction and cover notices on the experimental ACP v2 surface (#1253)
Part of step 6 of opt-in ACP v2 support. Compaction and session notices are both Preview features of the v2 draft that are expected to stabilize soon, and v2 requires no client capability for either. Nothing changes on ACP v1. ## Compaction The agent already implements the compaction RFD for v1 clients that declare `clientCapabilities.session.compaction`, live and on replay. A v2 client got the legacy "Compact conversation" tool call instead, because the v2 surface didn't declare it. - `v1InitializeRequest` now tells the agent that every v2 client takes compaction updates, as it already did for notices. - `v2SessionUpdate` passes `compaction_update` and `compaction_summary_chunk` through: their v1 and v2 shapes are identical. So a v2 client now gets: - **live:** `compaction_update` `in_progress`, the summary as `compaction_summary_chunk`, then `completed` (or `failed` with the standard `error`); - **on replay:** one completed `compaction_update` with the summary, at the summary's position in the transcript. That's the materialized form the v2 draft asks for, without the start/chunk/finish sequence. The token counts and duration that the tool call's `rawOutput` carried have no v2 field. Only AIR gets them, in `_meta`. ## Notices These were already sent to every v2 client, but no test covered one on v2. A new end-to-end test sends Claude Code's `informational` warning as a live `notice`. The agent sends notices only live, never during replay, which matches the draft's "SHOULD NOT be replayed". ## Not changed **A replayed compaction's id differs from its live id.** Live, the id is the uuid of Claude Code's `compacting` status, which the transcript doesn't keep. Replay uses the summary message's uuid. The draft and the RFD ask for the original, stable ids. As with replayed message ids, a client that kept its view would see the compaction twice. Matching them needs Claude Code to persist the live id. ## Verification - The 3 compaction scenarios change their v2 golden files: the tool call becomes the compaction entity. The scenario names still say `legacy` because they're named for the v1 capability. - Unit tests cover the pass-through and the capability. End-to-end tests cover the live notice, and the replayed compaction between the messages around it. - Removing the capability or the pass-through fails tests. - Build, lint and all 2409 tests pass.v0.85.2-preview.22 |
||
|
|
52a92be959 |
fix: report a command's exit code only when the tool result says it (#1252)
Found while mapping commands to ACP v2 display terminals (#1240). It affects Zed and plain ACP v1 terminal clients and the v2 surface. **AIR is unchanged.** ## Problem The agent reported every command's exit with a number, even when it didn't know the code: - **any failure as 1.** A command that exited with 2, or a denied command that never ran, showed "Exited with code 1". - **a backgrounded command as 0 when it launched**, though it's still running. - **an interrupted command as 1**, and a non-zero code that Claude Code accepted as a success (grep with no matches) as 0. ## Change `CommandOutput.exitCode` is now optional. `BashReporter` sets it only when the tool result says it. I checked each source against the Claude Code 2.1.287 binary: - **a failure:** the `Exit code N` line that starts Claude Code's text of a command that exited with a failing code (`rE`/`JPr` for a `ShellError`). A failure that names none, such as a denial or a spawn error, has no code. - **a success:** 0, unless the structured `BashOutput` says otherwise. Claude Code accepts a non-zero code as a success only with a `returnCodeInterpretation`, such as grep's "No matches found" or diff's "Files differ". An interrupted command (an Esc interrupt returns a success with `interrupted: true`) and a backgrounded one have no code either. - **the code-execution block:** its `return_code`, as before. An unknown code goes out as: - **v1, Zed and plain clients:** `_meta.terminal_exit.exit_code: null`. The v1 `TerminalExitStatus` allows null. Zed (`as_u64()` → `None`) and Delta's v1 adapter (`Option<u32>`) read it as an optional code. Zed then shows a failed command as failed, without the "Exited with code N" tooltip, and every other exit as before. `compare.ts` allows the null code against the origin/main baseline, a documented difference like the `mcp` command in #1218. - **v2:** `exitStatus` without `exitCode`, which the v2 schema treats the same as null. - **AIR:** the numbers it always got (1 for a failed or interrupted command, else the code, or 0). Whether AIR accepts a null code is unchecked, so this stays out of AIR's contract. The command fact carries `interrupted` for that. The AIR golden files and `docs/air-extensions.md` don't change. ## For review - **Interrupted commands** no longer show as failed in Zed, since the tool call completes and no code is known. Their output still ends with "[Command was aborted before completion]". ## Not changed - **A backgrounded command's terminal still exits at launch**, now without a code. It could stay running until the task's `task_notification`. That needs the task's end routed to the terminal, also on replay, so it's a separate change. - **Replay has no `tool_use_result`** (the same gap as replayed diffs), so a replayed success still reports 0 when Claude Code interpreted the code or backgrounded the command. ## Verification A table test pins, for each kind of exit, the code Zed gets and the number AIR gets: a success, a failure that names its code, one that names none, an interrupted, a backgrounded, and an interpreted command. A unit test covers the v2 `exitStatus` without a code. Removing any one of the rules, including AIR's, fails tests. Golden files change for 3 v2 scenarios, each on its exit line only. Build, lint and all 2399 tests pass.v0.85.2-preview.21 |
||
|
|
8589a9cf15 |
fix: restore main-thread agent config option (#1250)
## Why #1112 removed the main-thread agent picker on the assumption that ACP clients don't use it. That was wrong: Zed exposes the `Agent` config option and users rely on it daily to start sessions with a custom agent (see the feedback on #1112). It is also still the only way to pick a main-thread agent for enterprise Claude Code setups. Restoring it has no meaningful cost: `supportedAgents()` reads from the already-awaited initialization response, so session creation doesn't gain a round-trip. ## Summary Reverts #1112: - restore the `agent` session config option (shown only when custom agents are configured) and custom-agent discovery - restore live agent switching via `applyFlagSettings({ agent })` - forward `_meta.claudeCode.options.agent` again and keep it across context resets - restore the exports `DEFAULT_AGENT_ID`, `AGENT_CONFIG_ID`, `BUILTIN_AGENT_NAMES`, `discoverCustomAgents` Conflicts with later changes on `main` (MCP command import, initial permission mode / bypass resolution, context-window refresh after model switch, effort settings) were resolved keeping the newer code. ## Testing - npm run check - npm run build - npm run test:run (2399 passed) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>v0.85.2-preview.20 |
||
|
|
a44c486019 |
fix: end a cancelled turn when the force-cancel fires during an update (#1243)
Found while writing the test for #1242. It affects ACP v1 and v2. ## Problem When Claude Code wedges after a cancel (issue #680), the force-cancel backstop aborts the consumer's cancel signal, so the consumer settles the turn as `cancelled`. The consumer races each next message against that signal by adding an `abort` listener on each iteration. But a listener added to an already-aborted signal never fires. So if the backstop fired while the consumer was busy outside the race, such as sending an update to a slow client, the consumer missed it. It then waited for a message from the wedged Claude Code that never came, and the cancelled `session/prompt` was never answered (v2: no `idle cancelled`). Teardown aborts the same signal, which is how #1242's test ran into it. ## Change The race also wakes for a signal that's already aborted: - The consumer re-arms its signal after each abort it handles, so an aborted signal there is always an unhandled abort. It can't loop. - A message that's already waiting still wins the race, since `Promise.race` checks it first, so no message is dropped. ## Verification A new turn-events test has Claude Code stream an answer and then wedge. The client takes the chunk slowly, the user cancels, and the backstop (10 ms in the test) fires while the consumer is still sending. The turn ends `cancelled`; without the change it never ends. Build, lint and all 2389 tests pass. |
||
|
|
d599333b72 |
fix: count a subagent's permission requests as the turn waiting on the user (#1241)
Found while planning the permission requests of opt-in ACP v2 support (step 5 of the v2 plan). It affects ACP v1 clients that show native subagent sessions (`subagents` capability, or AIR's native subagent sessions). ## Problem For such a client, a native subagent's permission request or AskUserQuestion goes to the subagent's child session. The agent counted the open request on that session id, but child sessions aren't in `sessions`, so `withPendingUserInput` counted nothing: - **Steering:** a message steered while the subagent's request was open didn't see it, so it went out with interrupting priority instead of `later`, the priority used while the user has a prompt open. - **Turn state:** the turn didn't report that it awaits the user. That's `requires_action` in ACP v2, though the v2 surface doesn't show child sessions today, so v2 isn't affected yet. ## Change The request is still sent to the child session, but it now counts against the owner session, whose turn and steering it blocks. Both paths take the owner id: `requestPermissionFromClient` already had it (`ownerSessionId`), and `handleAskUserQuestion` gets it next to the session it sends the elicitation to. ## Verification The existing "uses priority:'later' while … is pending" test gains two cases: a subagent's permission request and a subagent's AskUserQuestion, both sent to an announced child session. Each fails when its half of the fix is reverted. Build, lint and all 2390 tests pass.v0.85.2-preview.19 |
||
|
|
b1d6cfc6c9 |
fix: end a closed session's turns before session/close answers (#1242)
Found while reviewing the ACP v2 prompt lifecycle (#1226). It affects ACP v1 and v2. ## Problem `session/close` tears the session down: `teardownSession` cancels its turns, wakes the consumer, and closes the query. It returned without waiting for the cancelled turns to end, so the close could answer first: - **v1:** the `session/prompt` response arrived after the `session/close` response. - **v2:** the turn's `idle cancelled` arrived after the close response. v2's `session/close` cancels "as if `session/cancel` had been called", then frees the session. It happens when the consumer settles the turn late: for example, while it's still sending an update to a slow client, or when it settles the turn only at the end of the stream that the close ends. ## Change `teardownSession` now returns once the turns it cancels have ended (each turn's `completion`), for at most `TEARDOWN_TURN_END_TIMEOUT_MS` (5 s), which it logs if a wedged consumer hits it. Its other callers get the same order too: `session/delete`, the recreation of a session whose parameters changed on resume or load, and `dispose()`. ## Found along the way, not changed here The consumer's abort race adds an `abort` listener to the cancel signal on each iteration. A listener added to an already-aborted signal never fires, so an abort that lands while the consumer is busy, such as sending an update, is missed. In production the close still ends the turn, through the end of the stream, which is the path this PR waits for. But the same gap can defeat the force-cancel backstop for a wedged Claude Code (issue #680). That gets its own PR next. ## Verification A new turn-events test has the client accept an answer chunk slowly, closes the session while the consumer is still sending it, and checks that the turn's end comes before the close returns. It fails without the change: the close returns first. Build, lint and all 2389 tests pass.v0.85.2-preview.18 |
||
|
|
3d9bae941c |
refactor: report commands as display terminals on the experimental ACP v2 surface (#1240)
Third part of step 4 of opt-in ACP v2 support (after #1231, #1232, #1237): a v2 client now sees each command the agent runs in an Agent-owned display terminal, instead of its output as a code block. Nothing changes on ACP v1. ## Why a mapping The agent already reports commands through the terminal extension of ACP v1 clients, as codex-acp does: - `_meta.terminal_info` names a terminal, which the tool call's content references; - `terminal_output` (a snapshot) or `terminal_output_delta` (a chunk) carries the output; - `terminal_exit` carries the exit. v2 standardizes exactly this as display terminals: the same `{ type: "terminal", terminalId }` content, plus `terminal_update` and `terminal_output_chunk` for the terminal's state. So the v2 surface maps the extension rather than adding new agent types. Unlike the agent's private `_meta.claudeCode` keys, these keys are a cross-agent protocol extension, so this maps one protocol form to another. ## Change - `ToolCallClientCapabilities.from` reports a v2 client as taking the extension. - `V2Terminals` (`src/v2/terminal.ts`), one per connection, turns the extension's keys into v2 updates, sent before the tool call report without those keys: - `terminal_info` → `terminal_update` naming the terminal; - `terminal_output` → `terminal_update` with an `output` snapshot, base64-encoded; - `terminal_output_delta` → `terminal_output_chunk`; - `terminal_exit` → `terminal_update` with `exitStatus`. - **The command:** the extension names none, so the terminal gets the command from the tool call's `rawInput` once a report carries it. The first report of a streamed tool use has no input yet. `V2Terminals` remembers each running command's terminal for that, and forgets it at exit or `session/close`. - **A report that carried only the output is dropped.** Its remaining `_meta` (the `parentToolUseId` stamp, for a subagent's command) would replace the tool call's stored `_meta` in v2. - Terminal content passes through the tool call translation. A v2 client has no client terminals, so these are the agent's only terminals. The 8 v2 scenarios that run a command change their golden files: `terminal_update`s in place of the code block, the terminal referenced in the permission request, and the exit before the tool call's status. ## Not changed here The agent reports a backgrounded command as exiting with 0 when it launches, and a failure whose exit code it doesn't know as 1. v1 terminal clients get the same values, since the extension requires a number. v2 can leave the exit status out, or say it's unknown, but that needs the agent's command fact to carry it, and it affects v1 too, so it's left for its own PR. ## Verification - Unit tests cover output chunks and snapshots, signals, the command arriving once, the dropped output report, a report without terminal keys passing untouched, and forgetting a closed session. - Each rule fails a test when removed. - Build, lint and all 2388 tests pass.v0.85.2-preview.17 |
||
|
|
98b17c28c8 |
refactor: advertise sessions on the experimental ACP v2 surface (#1239)
Step 4's last session piece for opt-in ACP v2 support (after #1237, #1238). The v2 surface now serves the whole v2 session baseline, so its `initialize` response advertises `capabilities.session`. Until now a conforming v2 client wouldn't call the session methods. Nothing changes on ACP v1, and the v2 surface stays behind `CLAUDE_AGENT_ACP_EXPERIMENTAL_V2`. ## Change `V2_SESSION_CAPABILITIES` is a constant, since it describes what the v2 surface serves: - **the session baseline:** `session/new`, `list`, `resume` (with replay since #1237), `close`, `prompt`, `cancel`, and `update`; - **`session/delete` and `additionalDirectories`.** Not `session/fork` yet, which v1 advertises but the v2 surface doesn't serve; - **prompt content and MCP transports,** as the agent's v1 `initialize` lists them: images and embedded context; stdio and HTTP servers. v2 has no SSE transport. The top-level `_meta` (`promptQueueing`, `authStatus`) passes through as before. `providers` stays unadvertised. Every v2 golden file changes on its `initialize` line only. ## Release Titled `refactor:` like the other v2 PRs, so it triggers no release on its own. This is the point where the v2 surface becomes usable by a conforming client, so `feat:` would also fit if you want the next release to be a minor one. ## Verification The handshake test pins the advertised capabilities, the schema check validates every recorded `initialize` response, and build, lint and all 2386 tests pass.v0.85.2-preview.16 |
||
|
|
22139f73c4 |
fix: replay a session with a Write on the experimental ACP v2 surface (#1238)
Fixes a bug in #1237 on the opt-in ACP v2 surface. Nothing changes on ACP v1. ## Problem A v2 `session/resume` with `replayFrom` failed with an internal error on any session that contains a Write: - A replay has no approval preview and no PostToolUse hook, so the Write reporter builds a diff from the input for every client on replay. - That's a v1 diff. It even claims a creation, since the input doesn't say whether the file existed. - The v2 surface rejects v1 diffs (#1232), so the replay threw and the resume failed. `session-load-replay` has an Edit but no Write, so the scenario suite didn't catch it. ## Change A v2 client gets no diff from a Write's input, live or replayed: a v2 diff names the operation (`add` or `modify`), which the input doesn't tell. Replayed Writes and Edits show no diff on v2 yet; building them needs the tool's structured result, which the SDK's transcript reader leaves out (see the follow-up discussion). ## Verification A new test replays a transcript with a Write through a v2 `session/resume`: it failed with "Internal error" before the change and passes after. The v1 golden files are unchanged. Build, lint and all 2386 tests pass.v0.85.2-preview.15 |
||
|
|
bd8a161c2e |
refactor: replay session history on the experimental ACP v2 surface (#1237)
Last part of step 4's session baseline for opt-in ACP v2 support (after
#1231–#1235): a v2 `session/resume` can now replay the session's
history. Nothing changes on ACP v1.
## Change
- **Routing.** v2 restores a session with `session/resume` and replays
only when `replayFrom` asks for it. v1 splits that into
`session/resume`, which never replays, and `session/load`, which replays
everything before it responds. That's exactly what v2 requires of
`replayFrom: { type: "start" }`. So `v1RestoreSessionRequest` maps the
v2 request to one of the two. A cursor that starts elsewhere is
rejected, since v1 can only replay from the start.
- **Clearing replayed messages.** The agent replays messages as chunks,
and chunks append. A v2 client may still hold a replayed message, so
before the first replayed chunk of each message, the v2 surface sends
that message with `content: []`. The draft requires that of a message
replayed as chunks. It happens only while that session replays
(`V2ClientConnection.replaying`), and the clear and the chunk go to the
connection together, so nothing comes between them.
- **Prompt ids.** The draft requires a prompt's user message to replay
under the id its `session/prompt` response returned. Since #1226 that id
is the user message's uuid, which Claude Code keeps in the transcript,
so no new code is needed. A test checks it end to end.
## Scenario suite
`session-load-replay` passes, so every scenario now passes on v2. The
status table becomes `V2_STOPS`: the scenarios that stop on v2, which is
empty today and stays as the place for a scenario v2 can't run yet. The
replay golden file shows each message cleared before its chunks, and the
`session/resume` response after the last replayed update.
A replayed Edit or Write shows no diff yet on v2: replay has no
PostToolUse hook. Building it from the transcript is the next PR.
## Verification
- **Replay test:** a live prompt, then resume with replay from a
transcript holding the uuid Claude Code saved. The replayed user message
carries the prompt response's id, each message is cleared before its
chunks, everything arrives before the response, and a live prompt
afterwards gets no clears.
- **Mutations:** each rule fails a test when removed: no clears, clears
that stay on after the replay, a replay served as a plain resume, and
one clear per id rather than per message.
- Build, lint and all 2385 tests pass.
v0.85.2-preview.14
|
||
|
|
63aa3b8428 |
refactor: report plans on the experimental ACP v2 surface (#1235)
Second part of step 5 of opt-in ACP v2 support (after #1234): the v2 surface now sends the agent's plan. Nothing changes on ACP v1. ## Change - v1 `plan` becomes v2 `plan_update` with an items plan: `{ type: "items", planId, entries }`. Plan entries are identical in both versions, and a v2 items plan is replaced whole on each update, as a v1 plan is. The update's `_meta` passes through. - **One plan id per session** (`SESSION_PLAN_ID`). v1 has one plan per session, which every update replaces, whether it comes from TodoWrite or from the Task tools. Several plans, such as one per subagent (a subagent's TodoWrite replaces the session's plan today, marked by `_meta.claudeCode.parentToolUseId`), and v2's unstable markdown plans wait for step 6. ## Scenario suite `todo-write` and `task-create-update` pass, with golden files. That leaves only `session-load-replay` in the status table. ## Verification A unit test covers the mapping; build, lint and all 2383 tests pass.v0.85.2-preview.13 |