Compare commits

...

254 Commits

Author SHA1 Message Date
Ben Brandt
231178127a refactor: move the Claude Code to ACP mapping out of acp-agent.ts
The v2 agent in src/v2/ imported the mapping, shared types and helpers from
acp-agent.ts, so it loaded the whole v1 agent module. Move them into modules
that own them, and have acp-agent.ts import them back and re-export every
name it exported before:

- sdk-to-acp.ts: toAcpNotifications, streamEventToAcpNotifications,
  messageIdForGrouping, the tool use and streamed input caches
- message-filters.ts: local command markers, the synthetic /login message,
  replay-hidden meta messages
- prompt-to-claude.ts: promptToClaude
- slash-commands.ts: getAvailableSlashCommands
- result-end.ts: AUTONOMOUS_RESULT_ORIGINS, isAbortedResult
- logger.ts, acp-client.ts, new-session-meta.ts: shared types

No behavior change.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-10 00:21:02 +01:00
Ben Brandt
071b2e8098 fix: log a provider base URL's origin only
The providers/list, providers/set and session/query log lines printed the
whole base URL, which comes from the env or the client and can carry
credentials or a key in its path or query (CodeQL: clear-text logging of
sensitive information).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-10 00:18:22 +01:00
Ben Brandt
e42cc82304 fix(v2): send no file-change report for the adapter's own /remote-control
The report was registered for every prompt, before the session knew
whether the adapter's /remote-control serves it, so an AIR client got an
unavailable providerError report even for a successful command. v1 sends
none. The report is now registered for a /remote-control prompt only
once it goes to Claude Code. Other prompts register as before, so a
prompt that the guard refuses or that fails before its insertion still
reports.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-10 00:03:25 +01:00
Ben Brandt
d8d8d3b6dd fix(v2): restart a session whose start overlapped a route change
launchSession read auth.routing() before restarts.track() registered the
session, and reroute() only restarts tracked sessions, so a session whose
query start overlapped providers/set, providers/disable or a route-dropping
logout kept the old route. V2Auth now counts route changes; a start that
saw the count change is tracked as stale and restarts through the reroute
path once its setup response went out.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-10 00:02:26 +01:00
Ben Brandt
885e765662 fix(v2): close a session that session/delete finds still starting
A delete checked for a live session without waiting for a resume that
runs or its query start, so a delete during the start left the session
running. It now waits for them, as session/close does, before it decides
whether to close the session.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-10 00:00:55 +01:00
Ben Brandt
4be19db524 fix(v2): answer a session/close after the resumes of the session that run
A close waited only for the query start of a resume, so a resume that was
still replaying or reading its commands sent its snapshot and answered
after the close response. The close now waits for the resume handlers of
the session that run, so their updates go out before its response and
their restarts.track() comes while the close still blocks it.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-10 00:00:25 +01:00
Ben Brandt
8c53cfa2be docs: drop the v2 agent design doc
Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-09 23:39:37 +01:00
Ben Brandt
9a47567e59 test(v2): keep the subscription guard tests off the machine's real Claude login
A real-CLI run (npm run test:integration) points CLAUDE_CODE_EXECUTABLE at
the installed CLI, so the guard read the machine's own account and
refused the scripted prompts.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-09 23:39:36 +01:00
Ben Brandt
f541c267f2 fix(v2): publish a subagent's TodoWrite checklist as plan in its child session
The shared mapping now gives a subagent's TodoWrite plan:<parentToolUseId>
unless the client has subagent sessions. Every v2 client does, so the v2
stream declares subagents in the v1 capabilities it hands the mapping, and
the checklist stays plan in the child session.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-09 23:28:32 +01:00
Ben Brandt
6c709a98d7 Merge origin/main into v2-core-exploration
Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-09 23:28:32 +01:00
Ben Brandt
89cdd12e29 Merge branch 'v2/review2-b' into v2-core-exploration
# Conflicts:
#	src/tests/v2.test.ts
2026-11-09 23:23:02 +01:00
Ben Brandt
2f7e386f36 fix: give subagent checklists their own plan ID (#1558)
A subagent's TodoWrite checklist was published with the main checklist's
`planId: "plan"`, so it replaced the main conversation's plan.

This only happened on ACP v1 clients that advertise `plan` but not
`subagents`. With native subagent sessions (every v2 client, AIR, and v1
with `subagents`), the checklist already goes to the child session, and
that's unchanged.

Changes:

- In that case, a subagent's TodoWrite gets `planId:
"plan:<parentToolUseId>"`, so it sits next to the main checklist
(`todoWritePlanUpdate` in `src/plans.ts`).
- An empty subagent TodoWrite sends `plan_removed` for its own plan ID,
not `"plan"`.
- `/clear` also removes the subagent checklist plans it published.
- Nothing is removed when a subagent finishes. The plan operations RFD
now says Agents remove cancelled plans, not finished ones.

Not changed:

- Task* tools keep `"plan"`. Claude Code shares one task list across all
agents in a session, so a per-subagent ID would show the same list
twice.
- Clients without the `plan` capability can hold only one plan, so a
subagent's checklist still replaces it there, as before.

Tests cover v1 (main and subagent plans side by side, then `/clear`) and
v2 (the checklist arrives as `"plan"` in the child session, and the
root's plan is untouched).
2026-11-09 23:22:19 +01:00
Ben Brandt
dc24f8a7bc docs: drop the stale line count of the v2 agent
Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-09 23:19:24 +01:00
Ben Brandt
05ea8b2a5b docs: say that ACP v2 has no hold for background subagents
The v2 agent answers session/prompt at insertion and reports background
subagents in their parent's background list and their child sessions'
states.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-09 23:19:24 +01:00
Ben Brandt
cf902892d3 test(v2): cover each unavailable file-change report reason
One it.each covers a cancel before and after the prompt's insertion, an
error result, a query that ends without a result, and a failure before
insertion other than -32800.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-09 23:18:30 +01:00
Ben Brandt
51a9cbc480 fix(v2): read a cleared-context turn's file-change report from the plan's prompt
ExitPlanMode's "Yes, clear context" sends /clear, which resets Claude
Code's file history, so the report read from the turn's own prompt was
unavailable and missed the plan's changes. The open turn's reports now
read the checkpoint of the plan's prompt once it starts.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-09 23:17:35 +01:00
Ben Brandt
21f8666709 fix(v2): push the plan's prompts of a fresh context ahead of a held /remote-control
The adapter's /clear and plan prompt for ExitPlanMode's "Yes, clear context"
chained on the client's prompt pushes, behind a /remote-control that waits
there for the turn to end, which needs those prompts. The adapter's own
prompts of the open turn now go out on their own chain; the client's prompts
keep their order among themselves.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-09 23:15:31 +01:00
Ben Brandt
600fb6f15d fix(v2): undo a failed child cancel for the subagents it withdrew
A session/cancel of a child withdraws the child and the foreground
subagents that it waits on. When stopTask failed, only the child was
restored, so the nested subagents' later permission requests were
aborted silently. The failure now restores each of them and reports it
again, through the code that a failed root interrupt uses.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-09 23:14:39 +01:00
Ben Brandt
da70e3031a fix(v2): end a cancel's interrupt reach when it can no longer stop anything
A cancel of an idle session left the interrupt pending across later
turns, so a later aborted result, such as of a remote-control interrupt,
reported cancelled. The interrupt now reaches the next result after a
cancel of an idle session and the next two after a cancel of open work,
and a prompt that the cancel did not abandon ends it.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-09 23:11:52 +01:00
Ben Brandt
bc476f6a2f fix(v2): start an inactive session once for concurrent resumes
Two concurrent resumes of an inactive session both started a query, and the
second replaced the first in the session map, orphaning a running Claude
Code query. The agent now tracks the query starts that run per session: a
resume waits for a pending start and attaches to the session it started, a
close ends what a pending start started, and dispose waits for the starts.
A failed start clears its entry.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-09 23:10:22 +01:00
Ben Brandt
a031eabe10 fix(v2): end an open compaction when the query ends on its own
The error idle of a query that ended went out without the turn-end cleanup
of closeTurn, so a compaction reported in_progress never ended. ended() now
resets it through reports.turnEnded() before the idle.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-09 23:05:46 +01:00
Ben Brandt
1607023bdf fix(v2): start MCP OAuth again on the new query of a restart
Ending the old query withdrew its OAuth runs and nothing started them on
the new one. A resume with other additional directories no longer starts
OAuth on the session that its restart retires.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-09 23:05:06 +01:00
Ben Brandt
854a347bf0 fix(v2): keep a background command running when a resume replays its result
A replay of an active session re-rendered a background Bash call's result
with its terminal exit, and the shared V2Terminals replaced the running
entry. Replay now rewrites the results of the session's background work as
the live path does (SessionTasks.replayedBackground), and a replayed
terminal_info keeps the terminal's running entry.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-09 23:03:13 +01:00
Ben Brandt
32d530a3dc docs: describe the ported plan, task and workflow behavior of the v2 agent
Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-09 22:33:29 +01:00
Ben Brandt
c66464eace test: pin the acp-agent.js exports of the v2 agent branch
Keeps isLoginRequiredText re-exported from acp-agent.js after its move to
result-end.ts, and adds the names the v2 agent imports from acp-agent.js
(AUTONOMOUS_RESULT_ORIGINS, getAvailableSlashCommands, isAbortedResult)
to the pinned list.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-09 22:33:29 +01:00
Ben Brandt
6db2e6ae20 fix(v2): list a resumed Fable session's model as fable
Ports #1548 to the v2 agent: sessionModels applies aliasResumedModelRow
before the allowlist and model selection, as ClaudeAcpAgent.createSession
does, so a resumed Fable 5.2 session offers `fable` as a new one does.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-09 22:33:29 +01:00
Ben Brandt
1bae40d6cc feat(v2): remove the checklist and the approved plan on a conversation reset
Ports #1554 to the v2 agent: a conversation_reset (such as /clear) sends
plan_removed for the checklist (`plan`) and the approved ExitPlanMode
plan (`plan-mode`) through the shared resetPlanUpdates, as v1 does for a
client with plan operations. Before, it sent an empty plan_update. The
v2 agent publishes approved plans under the plain `plan-mode` id, also
to AIR, so AIR's plan is removed too. The fresh context of a plan
accepted with clearing the context still removes nothing: that plan
becomes the session's plan in it.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-09 22:33:28 +01:00
Ben Brandt
7af0804b8a fix(v2): port workflow cards, task tool calls and hook-only previews to the v2 agent
Ports #1550, #1552 and #1556, which changed the v2 output of the old
bridge, to the v2 agent:

- A workflow's task_progress shows on its tool call as its title and
  body, with the workflow_progress phases and agents
  (workflowCard in workflow-progress.ts). Progress before the launch
  result waits for it, and a session/resume snapshot carries it.
- A live background task that no tool call or terminal stands for gets
  a tool call of its own (task_<taskId>), sent before the first
  background list that names it, instead of a _claude/task item.
- An Edit or Write preview from the PreToolUse hook waits for the tool
  call's first publication instead of publishing a call that a
  workflow agent never ends.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-09 22:33:11 +01:00
Ben Brandt
97b77d7cf7 Merge origin/main into v2-core-exploration
Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-09 22:32:52 +01:00
Ben Brandt
dbe8a3fce8 refactor: read CLAUDE_MODEL_CONFIG in the shared configuredSettings
Both cores call session-env's configuredSettings, which now applies the
models of CLAUDE_MODEL_CONFIG itself. The v2 wrapper of the same name is
gone.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-09 20:58:20 +01:00
Ben Brandt
3dc4582b95 refactor: drop the unread settled flag of v2's file-change reports
Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-09 20:55:05 +01:00
Ben Brandt
d31f4a9306 refactor: forget a session's held prompts once the last one settles
Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-09 20:53:53 +01:00
Ben Brandt
f0e99ea5a0 refactor: drop the dead query check before the subscription guard
Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-09 20:53:14 +01:00
Ben Brandt
c9c3f092d2 refactor: read results by one set of rules in both cores
resultEnd now makes exactly the decisions of v1's subtype switch, and the
v1 prompt loop calls it, keeping its own session failure categories and
result forwarding. v1 is unchanged. v2 now follows v1 where the copies
differed: a limit result (error_max_turns, error_max_budget_usd,
error_max_structured_output_retries) ignores stop_reason max_tokens, and a
success error with the /login text fails with authRequired even at
max_tokens. isLoginRequiredText moves to result-end.ts.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-09 20:52:20 +01:00
acp-release-bot[bot]
8e18eb7c75 chore(main): release 0.102.0 (#1553)
🤖 I have created a release *beep* *boop*
---


##
[0.102.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.101.1...v0.102.0)
(2026-11-09)


### Features

* list workflow phases and agents on the workflow card
([#1552](https://github.com/agentclientprotocol/claude-agent-acp/issues/1552))
([176ae06](176ae0634c))
* send the checklist as a plan operation and remove plans on reset
([#1554](https://github.com/agentclientprotocol/claude-agent-acp/issues/1554))
([766fd14](766fd142f6))


### Bug Fixes

* report v2 background tasks without a tool call as their own tool call
([#1556](https://github.com/agentclientprotocol/claude-agent-acp/issues/1556))
([d098fb0](d098fb08d5))
* update to @anthropic-ai/claude-agent-sdk 0.3.321
([#1557](https://github.com/agentclientprotocol/claude-agent-acp/issues/1557))
([855447c](855447c7cd))

---
This PR was generated with [Release
Please](https://github.com/googleapis/release-please). See
[documentation](https://github.com/googleapis/release-please#release-please).

Co-authored-by: acp-release-bot[bot] <246668977+acp-release-bot[bot]@users.noreply.github.com>
2026-11-09 20:52:05 +01:00
Ben Brandt
400e531404 fix: don't hold prompts or the route change on a busy session's restart
A route change (providers/set, providers/disable, an auth/logout that
drops a provider or gateway login, or a resume with new additional
directories) now restarts a session at a turn boundary right away, and
the request waits only for those restarts. A busy session (an open turn,
a pending prompt, a live subagent, or a restart that runs) restarts at
its next turn boundary without the request waiting, and its prompts keep
going to the current query until then. Prompts wait only for a restart
that runs.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-09 20:45:41 +01:00
Ben Brandt
855447c7cd fix: update to @anthropic-ai/claude-agent-sdk 0.3.321 (#1557)
## Summary

Updates `@anthropic-ai/claude-agent-sdk` from 0.3.319 to 0.3.321. It
bundles Claude Code 2.1.321, which adds Fable 5.2 (`claude-fable-5-2`)
as the new default `fable` model. Also bumps these dev dependencies:
`@anthropic-ai/sdk` 1.6.0 → 1.7.0 and `@typescript-eslint/*` 8.72.1 →
8.72.2. `typescript` 7 is left for its own PR.

## Fable 5.2

Checked against the real CLI:

- A new session lists `fable` → `claude-fable-5-2`, and
`claude-fable-5-1` as its own row.
- A resumed Fable 5.2 session lists `claude-fable-5-2` and no `fable`
row, the same pattern 5.1 had.

`aliasResumedModelRow` already handles any version, so the code is
unchanged. Its doc comment and test fixture now describe the 2.1.321
lists. The fixture also covers a resumed Fable 5.1 session keeping
`claude-fable-5-1`, because a newer Fable is listed.

## Changelog review

- No SDK type changes that affect us. The only changes are doc text and
a new optional `endedLoopWakeup` field in `sdk-tools.d.ts`.
- Queued subagent reports in auto mode now share one model call, and all
but the last end in a `num_turns: 0` placeholder result. These results
carry `task-notification` or `peer` origins, which we already treat as
placeholders. That handling dates from CLI 2.1.274, which did the same
for background-task notifications.
- Fixes we pick up: one `undefined` entry in `hooks` no longer drops
every hook; a PreToolUse `ask` is no longer lost when another hook's
`defer` finishes first; project skills and commands no longer go missing
after entering a worktree; MCP OAuth no longer sends refresh tokens to
another host after a redirect.

## Testing

- `npm run build`, `npm run check`, `npm run test:run`
- Real CLI: listed the models for a new session and for a resumed Fable
5.2 session

Co-authored-by: Claude <noreply@anthropic.com>
2026-11-09 19:43:47 +00:00
Ben Brandt
d098fb08d5 fix: report v2 background tasks without a tool call as their own tool call (#1556)
## Summary

Background tasks that have no tool call or terminal on v2 now get a tool
call of their own. Before, they showed up as a `_claude/task` vendor
item. This follows the spec rule in
agentclientprotocol/agent-client-protocol#2786.

Examples: a workflow, or an MCP task that starts after its tool call
already returned, or a task a hook started.

- `BackgroundWork.adoptTasks` starts a tool call `task_<taskId>` for
each such task. It uses the task description as title, `kind: "other"`,
and `in_progress` or `paused`. A subagent's task goes on the subagent's
stream. The message loop sends these calls before `sync`, so a call is
always announced before the first `background` list names it. The same
path covers the `state_update` snapshot on `session/resume`.
- These calls are listed as `tool_call` items, so `tool_call/cancel`
stops the task. When the task ends they get a final status like any
other listed call (`completed`, `failed`, or `cancelled`).
- The `_claude/task` item type is gone. `BackgroundItem` is now
`terminal | tool_call | subagent`.
- Ambient tasks such as the monitor, and tasks whose own tool call is
still open, are unchanged.

`adoptTasks` is synchronous and returns the notifications, so the loop
only awaits when there is something to send. An extra await on every
pass broke the steering-cancel timing in `acp-agent.test.ts`.

## Compatibility

- ACP v1 clients, including JetBrains AIR's `_session/async_task/*`
extension, are unaffected. Background lists, and therefore this path,
only run when the v2 background sink exists and the session has
`perTaskStopAffordance`. On v1, `adoptTasks` returns `[]`.
- v2 clients that saw `_claude/task` items (v0.100.0 to v0.101.1) now
see `task_<id>` tool calls instead.

## Tests

`acp-v2.test.ts` replaces the `_claude/task` tests. New tests cover:

- starting and listing a call for a task that starts after its tool call
returned
- cancelling such a task with `tool_call/cancel`
- the call being sent before the `session/resume` snapshot
- a subagent's task appearing on the subagent's stream

`npm run check`, `build` and `test:run` pass.
2026-11-09 20:40:39 +01:00
Ben Brandt
8af9df957c refactor: rename docs/v2-native-core.md to docs/acp-v2.md
Describes the adapter's ACP v2 implementation as "the v2 agent" in
src/v2/, keeps the differences from and history of the old v2 bridge,
and points README.md, AGENTS.md and docs/air-extensions.md at the new
path.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-09 20:10:39 +01:00
Ben Brandt
1cf208b68b refactor: rename the v2-native tests to v2
Renames v2-native.test.ts to v2.test.ts and v2-native-integration.test.ts
to v2-integration.test.ts, and drops "native" from their describe blocks,
helpers and client names.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-09 20:10:19 +01:00
Ben Brandt
3f0beb033b refactor: move the v2 agent from src/v2-native/ into src/v2/
There is one ACP v2 implementation now, so it lives in src/v2/ beside
the v2 wire conversions it uses. src/v2/permission.ts merges into
permissions.ts, which owns the v2 permission requests.

Renames nativeV2AgentApp to v2AgentApp, NativeV2Agent to V2Agent,
NativeSession to V2Session and NativeAuth to V2Auth, and rewords
"native v2 core" comments to "v2 core". No behavior change.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-09 20:10:07 +01:00
Ben Brandt
4a79f0487a refactor: rename src/v2/session.ts to session-requests.ts
The file holds the v1 form of the v2 session setup and config requests
and the v2 form of config options and commands. The rename frees
src/v2/session.ts for the v2 session that moves in next.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-09 20:09:27 +01:00
Ben Brandt
aa13302252 docs: describe the native core as the only ACP v2 implementation
Updates `docs/v2-native-core.md` (status, a history note in place of
"Replacing the bridge", the differences from the old bridge in the past
tense, the test setup), the README, the AIR extensions doc and
AGENTS.md, and rewrites code comments that described the bridge.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-09 19:25:53 +01:00
Ben Brandt
71792d29a9 refactor: serve only ACP v1 with ClaudeAcpAgent
Removes the v2 paths that only the deleted bridge reached: the `v2`,
`foreground` and `background` constructor options, `ForegroundEvents` and
the awaiting-user tracking, the background list reporting
(`BackgroundWorkReporter`, `cancelBackgroundWork`, the reported paths of
`SessionBackgroundWork`), the Edit/Write preview hook, `this.v2` branches
in initialize, permissions and setup, and the public methods only the
bridge called (`foregroundState`, `publishLiveSubagents`,
`publishBackgroundToolCalls`, `cancelBackgroundTerminal`,
`cancelBackgroundToolCall`, `authenticateMcpServers`,
`publishCurrentSessionInfo`).

`foregroundWork` stays as the record of who owns the foreground work
(a turn or an autonomous cycle): v1 still reads it in `claudeIsWorking`,
`workEndReported` and `requestOwnerTurn`. The `background` list types
move to `src/v2/background.ts`, next to `V2Background`.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-09 19:25:53 +01:00
Ben Brandt
c99d3a1e59 refactor: serve every ACP v2 client with the native core and delete the bridge
The protocol router now always serves v2 with `src/v2-native/`. Removes
`CLAUDE_AGENT_ACP_V2_CORE`, `V2Core`, `v2CoreFromEnv`, the bridge's
`src/v2/agent.ts`, the conversions only it used (v1 initialize, restore and
setup responses, the prompt turn events, child state copies) and
`scripts/compare-v2-cores.mjs`. The rest of `src/v2/` stays as the v2 wire
conversions that the native core imports.

The v2 tests and scenarios run once, on the native core, against the
existing golden files.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-09 19:01:10 +01:00
Ben Brandt
92e695d1cc Merge branch 'native-v2/air-features' into v2-core-exploration 2026-11-09 18:48:12 +01:00
Ben Brandt
e3c5a8af96 feat: serve JetBrains AIR v2 clients with the native core
A v2 client that declares _meta.jetbrains.air now gets the native core,
which serves it the AIR extensions that ACP v2 does not cover, with the
v1 core's wire output:

- recommendedValue on the model and effort options, and the shown effort
  set on the query at the start and at each model switch
- the AIR permission presentation and AIR's permission tool call meta
- AskUserQuestion's custom answer marker
- the predicted follow-up prompt, cleared at the next prompt and at a
  conversation reset
- agentFileChangeReport, sent at the end of the turn that serves the
  prompt, before its idle, since a v2 prompt is answered at insertion

initialize lists agentFileChangeReport and recommendedValue to an AIR
client only. The extensions that v2 covers (subagent sessions,
background lists, file plans, patches, compaction, systemPrompt, failed
idles) are not served. CLAUDE_AGENT_ACP_V2_CORE=bridge still serves AIR
with the bridge.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-09 18:46:06 +01:00
Ben Brandt
afe4434be7 refactor: share the prompt suggestion state and the AIR permission meta between cores
PromptSuggestions holds the prediction a session's client holds and the
window a result opens, which the v1 core kept in two session fields.
airPermissionMeta builds _meta.jetbrains.air.permission apart from the
renderer, so the native v2 core can add it to a v2-rendered request.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-09 18:45:55 +01:00
Ben Brandt
05a827c69f fix: stop a foreground subagent reported after the root's cancel
A foreground subagent whose task_started reached the native v2 core after
the root's session/cancel, but before the cancelled work's result, kept
its permission and elicitation requests going to the client and its
consolidated messages going out. SessionTasks now keeps the root's cancel
in effect until the stopped work's result, Claude Code's idle, the next
prompt's start, or a failed interrupt, and stops such a subagent the same
way as one present at the cancel. A request of a subagent whose run a
cancel stops is withdrawn before it goes out.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-09 18:25:28 +01:00
Ben Brandt
acf21d2468 fix: resolve the best alias and gateway model spellings, keep v2 AIR clients on the bridge, and test the native v2 core against the real CLI
- `resolveModelPreference` resolves `best` (and `best[1m]`, whose hint the
  CLI ignores) as `opus`, the CLI's flagship fallback, unless an entry's
  value is `best`. Its preference words drop leading gateway segments before
  `claude-` (`vertex_ai/`, `openrouter/anthropic/`, `litellm/`,
  `publishers/anthropic/models/`) and a trailing `-latest`, which resolved
  before 51ab745 and gave null since.
- With `v2Core: "native"`, a v2 client that declares `_meta.jetbrains.air`
  gets the bridge (`airClientsOnBridge` in serve.ts): the native core serves
  no AIR extension, so a v2 AIR client would silently lose sessionFailure
  (API retry warnings), prompt suggestions, file change reports and AIR's
  subagent form. The docs' "Not served yet" section is corrected.
- `v2-native-integration.test.ts` (RUN_INTEGRATION_TESTS) runs the default
  v2 core in the adapter's process against the bundled CLI: insertion,
  running -> idle, requires_action at a permission request, and a cancel
  that fails a queued prompt with -32800.
2026-11-09 16:15:41 +01:00
Ben Brandt
766fd142f6 feat: send the checklist as a plan operation and remove plans on reset (#1554)
## Summary

Follow-up to the [plan operations
RFD](https://agentclientprotocol.com/rfds/plan-operations). The adapter
already sent approved ExitPlanMode plans as `markdown` plans to clients
with the `plan` capability, but two parts of the RFD were unused:

- **The checklist is now a plan operation on v1 too.** Clients with
`plan` (every v2 client, and v1 clients that opt in) get the
TodoWrite/Task checklist as `plan_update` with type `items` and the ID
`plan`. Before, v1 clients with the capability still got an ID-less
legacy `plan` next to the `plan-mode` plan. Clients without the
capability still get the legacy `plan`.
- **`plan_removed` is now sent.**
- An empty checklist sends `plan_removed` for `plan`. v2 clients used to
get a `plan_update` with no entries.
- A conversation reset (`/clear`) removes the checklist and the old
conversation's approved `plan-mode` plan. AIR keeps its approved plans,
which are joined to their approval cards by ID (#1469).

The new `src/plans.ts` builds the checklist updates. All three places
that send the checklist use it, and so does the v2 conversion of a
legacy `plan`, so v1 and v2 follow one rule.

The reset sends `plan_removed` for `plan-mode` even when the session
never published an approved plan. Tracking whether one was published
would be state that a restart loses, so the reset would behave
differently before and after one.

`file` plans are left out. A `file://` URI only works for a client on
the same machine, and v2 has no client filesystem.

## Tests

- `tools.test.ts`: TodoWrite with and without todos, and a TaskCreate
snapshot, for a v1 client with `plan`.
- `acp-agent.test.ts`: the reset test now runs for a client without plan
operations, a client with them, and AIR.
- `acp-v2.test.ts`: v2 end to end, TodoWrite then `/clear`, and an empty
legacy plan converting to `plan_removed`.
2026-11-09 14:58:36 +00:00
Ben Brandt
0dc4b370e0 feat: serve ACP v2 with the native core by default
CLAUDE_AGENT_ACP_V2_CORE now defaults to native; CLAUDE_AGENT_ACP_V2_CORE=bridge
selects the bridge as a fallback. acpProtocolRouter's v2Core option defaults to
native too. The unit vitest project pins the v2 tests and scenarios to the
bridge, and the v2-native-core project runs them on the native core.
2026-11-09 15:52:55 +01:00
Ben Brandt
176ae0634c feat: list workflow phases and agents on the workflow card (#1552)
## Summary

Show the phases and agents of a running workflow on its ACP v2 tool
card, not only the current activity and totals.

Claude Code attaches a `workflow_progress` snapshot to some of a
workflow's `task_progress` events: every phase, and every agent with its
label, phase, state, tokens, tool calls, duration and last tool. #1550
deliberately left it out. This change renders it under the existing card
body:

```
**Count**
- two: done · 14,629 tokens · 0 tool calls · 1s
- three: running · Bash: echo 3

**Sum**
- not started
```

### Why an untyped field

The SDK (0.3.319) does not type `workflow_progress`. Workflow agents
also don't stream their messages through the root query, so this
snapshot is the only per-agent data the adapter gets.
`src/workflow-progress.ts` reads the shape the CLI sends and skips
entries that don't match it. If the CLI changes the format, the card
loses the list but still renders. The right long-term fix is for the SDK
to type this field, or to stream workflow agents with a parent id so
each one can become a child session.

### Behavior

- The snapshot is a full list, and the CLI leaves it out of some
reports. A report without one keeps the last list.
- A running agent shows its last tool (e.g. `Bash: echo 3`). A finished
one shows tokens, tool calls and duration.
- Agents with no known phase are listed first. A phase with no agent yet
shows `not started`. A retry shows its attempt. A state other than
`start`/`done` is shown as the CLI sends it.
- The existing duplicate suppression and the `session/resume` snapshot
cover the list too.
- AIR `async_task_progress` is unchanged. Like the rest of the card
progress from #1550, this only reaches v2 clients that get workflow
progress on the card.

Result previews, models and prompt previews are left out to keep the
card short.

## Validation

- Live probe on Claude Code 2.1.319 (SDK `query()`, two-phase
three-agent workflow, permissions limited to `Workflow` and one `echo
3`). It captured the snapshot shape used in the test doubles. Every
captured snapshot rendered through the built module as shown above. This
checked the protocol output, not the Delta UI.
- New v2 scenario built from the captured payload, plus unit cases in
`src/tests/workflow-progress.test.ts`.
- `npm run check`, `npm run build`, `npm run test:run` (3,691 passed, 30
skipped).
2026-11-09 14:49:48 +00:00
Ben Brandt
a14c72b080 Merge branch 'native-v2/r3-gaps' into v2-core-exploration 2026-11-09 15:45:59 +01:00
Ben Brandt
4f0780584d Merge branch 'native-v2/r3-config-values' into v2-core-exploration 2026-11-09 15:44:16 +01:00
Ben Brandt
01fe4b00d7 fix: end subagents at a conversation reset and drop a cancelled child's consolidated messages on the native v2 core
As the bridge does: after the root's session/cancel, a foreground child's
consolidated messages are dropped until its run ends, apart from the results
of the tool calls the client holds; a conversation_reset ends each working
child idle with no stop reason and forgets the children. Documents that both
cores reject a replayFrom cursor other than start with -32602, and the
remaining intended differences.
2026-11-09 15:43:38 +01:00
dependabot[bot]
fcb7e175ee ci(deps): Bump amannn/action-semantic-pull-request from 6.1.1 to 6.1.2 (#1551)
Bumps
[amannn/action-semantic-pull-request](https://github.com/amannn/action-semantic-pull-request)
from 6.1.1 to 6.1.2.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/amannn/action-semantic-pull-request/releases">amannn/action-semantic-pull-request's
releases</a>.</em></p>
<blockquote>
<h2>v6.1.2</h2>
<h2><a
href="https://github.com/amannn/action-semantic-pull-request/compare/v6.1.1...v6.1.2">6.1.2</a>
(2026-11-05)</h2>
<h3>Bug Fixes</h3>
<ul>
<li>Release accumulated dependency updates (closes <a
href="https://redirect.github.com/amannn/action-semantic-pull-request/issues/321">#321</a>)
(<a
href="fc7da0cece">fc7da0c</a>)</li>
</ul>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/amannn/action-semantic-pull-request/blob/main/CHANGELOG.md">amannn/action-semantic-pull-request's
changelog</a>.</em></p>
<blockquote>
<h1>Changelog</h1>
<h2><a
href="https://github.com/amannn/action-semantic-pull-request/compare/v6.1.1...v6.1.2">6.1.2</a>
(2026-11-05)</h2>
<h3>Bug Fixes</h3>
<ul>
<li>Release accumulated dependency updates (closes <a
href="https://redirect.github.com/amannn/action-semantic-pull-request/issues/321">#321</a>)
(<a
href="fc7da0cece">fc7da0c</a>)</li>
</ul>
<h2><a
href="https://github.com/amannn/action-semantic-pull-request/compare/v6.1.0...v6.1.1">6.1.1</a>
(2025-08-22)</h2>
<h3>Bug Fixes</h3>
<ul>
<li>Parse <code>headerPatternCorrespondence</code> properly (<a
href="https://redirect.github.com/amannn/action-semantic-pull-request/issues/295">#295</a>)
(<a
href="800da4c97f">800da4c</a>)</li>
</ul>
<h2><a
href="https://github.com/amannn/action-semantic-pull-request/compare/v6.0.1...v6.1.0">6.1.0</a>
(2025-08-19)</h2>
<h3>Features</h3>
<ul>
<li>Support providing regexps for types (<a
href="https://redirect.github.com/amannn/action-semantic-pull-request/issues/292">#292</a>)
(<a
href="a30288bf13">a30288b</a>)</li>
</ul>
<h3>Bug Fixes</h3>
<ul>
<li>Remove trailing whitespace from &quot;unknown release type&quot;
error message (<a
href="https://redirect.github.com/amannn/action-semantic-pull-request/issues/291">#291</a>)
(<a
href="afa4edb1c4">afa4edb</a>)</li>
</ul>
<h2><a
href="https://github.com/amannn/action-semantic-pull-request/compare/v6.0.0...v6.0.1">6.0.1</a>
(2025-08-13)</h2>
<h3>Bug Fixes</h3>
<ul>
<li>Actually execute action (<a
href="https://redirect.github.com/amannn/action-semantic-pull-request/issues/289">#289</a>)
(<a
href="58e4ab40f5">58e4ab4</a>)</li>
</ul>
<h2><a
href="https://github.com/amannn/action-semantic-pull-request/compare/v5.5.3...v6.0.0">6.0.0</a>
(2025-08-13)</h2>
<h3>⚠ BREAKING CHANGES</h3>
<ul>
<li>Upgrade action to use Node.js 24 and ESM (<a
href="https://redirect.github.com/amannn/action-semantic-pull-request/issues/287">#287</a>)</li>
</ul>
<h3>Features</h3>
<ul>
<li>Upgrade action to use Node.js 24 and ESM (<a
href="https://redirect.github.com/amannn/action-semantic-pull-request/issues/287">#287</a>)
(<a
href="bc0c9a79ab">bc0c9a7</a>)</li>
</ul>
<h2><a
href="https://github.com/amannn/action-semantic-pull-request/compare/v5.5.2...v5.5.3">5.5.3</a>
(2024-06-28)</h2>
<h3>Bug Fixes</h3>
<ul>
<li>Bump <code>braces</code> dependency (<a
href="https://redirect.github.com/amannn/action-semantic-pull-request/issues/269">#269</a>.
by <a href="https://github.com/EelcoLos"><code>@​EelcoLos</code></a>)
(<a
href="2d952a1bf9">2d952a1</a>)</li>
</ul>
<h2><a
href="https://github.com/amannn/action-semantic-pull-request/compare/v5.5.1...v5.5.2">5.5.2</a>
(2024-04-24)</h2>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="2252a14e81"><code>2252a14</code></a>
chore: Release 6.1.2 [skip ci]</li>
<li><a
href="af97545064"><code>af97545</code></a>
ci: pin conventional-changelog-conventionalcommits to v9 (<a
href="https://redirect.github.com/amannn/action-semantic-pull-request/issues/323">#323</a>)</li>
<li><a
href="fc7da0cece"><code>fc7da0c</code></a>
fix: Release accumulated dependency updates (closes <a
href="https://redirect.github.com/amannn/action-semantic-pull-request/issues/321">#321</a>)</li>
<li><a
href="4e8becf171"><code>4e8becf</code></a>
ci: bump the npm-packages group with 10 updates (<a
href="https://redirect.github.com/amannn/action-semantic-pull-request/issues/319">#319</a>)</li>
<li><a
href="b3fa6edcb1"><code>b3fa6ed</code></a>
ci: bump actions/setup-node from 6 to 7 in the github-action-workflows
group ...</li>
<li><a
href="2c9480cf28"><code>2c9480c</code></a>
chore: bump the npm-packages group with 12 updates (<a
href="https://redirect.github.com/amannn/action-semantic-pull-request/issues/315">#315</a>)</li>
<li><a
href="58e675f6ba"><code>58e675f</code></a>
ci: bump the github-action-workflows group with 2 updates (<a
href="https://redirect.github.com/amannn/action-semantic-pull-request/issues/313">#313</a>)</li>
<li><a
href="45b9ed7cf2"><code>45b9ed7</code></a>
chore: bump the npm-packages group with 12 updates (<a
href="https://redirect.github.com/amannn/action-semantic-pull-request/issues/308">#308</a>)</li>
<li><a
href="9d28649546"><code>9d28649</code></a>
ci: Bump the github-action-workflows group with 2 updates (<a
href="https://redirect.github.com/amannn/action-semantic-pull-request/issues/309">#309</a>)</li>
<li><a
href="ac7e3fc035"><code>ac7e3fc</code></a>
docs: Fix snippet (<a
href="https://redirect.github.com/amannn/action-semantic-pull-request/issues/306">#306</a>)</li>
<li>Additional commits viewable in <a
href="48f256284b...2252a14e81">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=amannn/action-semantic-pull-request&package-manager=github_actions&previous-version=6.1.1&new-version=6.1.2)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-11-09 15:39:29 +01:00
Ben Brandt
f90e379f6c fix: serve the bridge's SendMessage permission, resumedAgentId restore and replayed child configuration on the native v2 core 2026-11-09 15:28:18 +01:00
Ben Brandt
51ab74538c fix: reject a set_config_option model that names no entry with -32602
resolveModelPreference matched preference tokens as substrings, so
"not-a-model" resolved to Default ("a" is inside "default") and
"son" or "foo-sonnet" to Sonnet. configOptionValue, which v1, the v2
bridge and the native v2 core share, then accepted the value. Words now
match whole, and the fuzzy tiers need every model word of the
preference; provider ids, dates, -fast and context hints are dropped
with normalizeModelId first, so aliases and provider spellings still
resolve.
2026-11-09 15:19:43 +01:00
acp-release-bot[bot]
e9e0d414fe chore(main): release 0.101.1 (#1539)
🤖 I have created a release *beep* *boop*
---


##
[0.101.1](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.101.0...v0.101.1)
(2026-11-09)


### Bug Fixes

* keep autonomous turn tasks open across owed trailing idles
([#1455](https://github.com/agentclientprotocol/claude-agent-acp/issues/1455))
([1ee2422](1ee2422934))
* list a resumed Fable 5.1 session's model as `fable`
([#1548](https://github.com/agentclientprotocol/claude-agent-acp/issues/1548))
([4cceb06](4cceb06b31))
* list a subagent's background Bash commands
([#1540](https://github.com/agentclientprotocol/claude-agent-acp/issues/1540))
([6597626](6597626d86))
* list Monitor calls in the session's background work
([#1538](https://github.com/agentclientprotocol/claude-agent-acp/issues/1538))
([ba16234](ba162346c9))
* relay a rate_limit_event that arrives before the turn's first
assistant frame
([#1534](https://github.com/agentclientprotocol/claude-agent-acp/issues/1534))
([54392e1](54392e144d))
* render workflow tool cards and progress correctly in ACP v2
([#1550](https://github.com/agentclientprotocol/claude-agent-acp/issues/1550))
([30600ba](30600ba348))
* replay a compaction summary by its flag, not its framing
([#1536](https://github.com/agentclientprotocol/claude-agent-acp/issues/1536))
([aa0c123](aa0c1234b5))
* report setup-token logins as logged in
([#1376](https://github.com/agentclientprotocol/claude-agent-acp/issues/1376))
([7d1ef76](7d1ef76c1d))
* start transcript notices on their own line for clients without
session.notices
([#1362](https://github.com/agentclientprotocol/claude-agent-acp/issues/1362))
([47b64c0](47b64c04de))
* **test:** assert the blockquoted MCP startup transcript line
([#1543](https://github.com/agentclientprotocol/claude-agent-acp/issues/1543))
([3b22586](3b22586cf6))
* update to @agentclientprotocol/sdk 2.5.0
([#1549](https://github.com/agentclientprotocol/claude-agent-acp/issues/1549))
([ef65ab3](ef65ab31b4))
* update to @anthropic-ai/claude-agent-sdk 0.3.319
([#1545](https://github.com/agentclientprotocol/claude-agent-acp/issues/1545))
([a58293f](a58293f4c9))

---
This PR was generated with [Release
Please](https://github.com/googleapis/release-please). See
[documentation](https://github.com/googleapis/release-please#release-please).

Co-authored-by: acp-release-bot[bot] <246668977+acp-release-bot[bot]@users.noreply.github.com>
2026-11-09 14:03:18 +00:00
Ben Brandt
4dc6e656a6 docs: list the cancelled idle that waits for Claude Code's aborted result as an intended difference 2026-11-09 15:01:13 +01:00
Ben Brandt
30600ba348 fix: render workflow tool cards and progress correctly in ACP v2 (#1550)
## Summary

Fix workflow tool cards that remain pending indefinitely, and surface
workflow progress through standard ACP v2 updates.

### Prevent orphan preview cards

Claude Code invokes Edit/Write preview hooks for workflow agents without
streaming those tools' uses and results through the root query.
Publishing a card from the hook alone leaves a pending call with no
completion path.

Stage early preview patches until the stream or a permission request
publishes the actual tool call. Keep patches pinned against later input
refinements, bound unclaimed previews, and clear them on teardown. A
call's own content takes precedence over a staged preview.

### Show workflow progress in place

Forward the SDK's typed task-progress fields to the existing workflow
card: current activity, summary, last activity, token count, tool-call
count, and elapsed time. Preserve progress arriving before the launch
result, suppress duplicate display updates, and include the latest
progress on session resume. Progress does not change paused/running
state or reopen completed calls.

This reads no workflow transcripts, journals, or metadata files and
introduces no ACP extension. The CLI also emits an untyped
`workflow_progress` snapshot; consuming that richer phase/agent data is
deliberately left out of this change. Existing v1 AIR progress uses the
same field normalization as before.

## Validation

- Regression reproduced before the orphan-preview fix.
- Nine relevant Vitest suites: **1,972 passed, 10 skipped**, including
v1/v2 scenarios, preview ordering, progress-before-launch, idle and
paused updates, duplicate suppression, resume snapshots, and late
progress after completion.
- `npm run check`
- `npm run build`
- `git diff --check`
- Live Claude Code **2.1.319** probes: captured SDK workflow events and
ran a real workflow through the updated ACP v2 adapter. One card
received two progress updates and then reached `completed`. This
verified protocol updates, not the Delta UI.

Rebased onto current `main` and reran the automated checks before
opening the PR.
2026-11-09 14:59:02 +01:00
Ben Brandt
39244f7952 test: check the v2 scenario golden files in full on the native core
acp-scenarios-v2.test.ts runs again in the v2-native-core vitest project
with V2_CORE_UNDER_TEST=native, comparing every message of each run with
the bridge's golden files and never writing them. It supersedes
acp-scenarios-v2-native.test.ts, which compared only session updates.
2026-11-09 14:49:54 +01:00
Ben Brandt
911ba98f14 fix: answer a native v2 prompt before its turn's running, as the bridge does
The native core's async prompt handler added promise hops, so the turn's
running state_update went out before the session/prompt response. v2
allows either order; the bridge's golden files hold the response first.
2026-11-09 14:49:54 +01:00
Ben Brandt
9a15f4889a fix: close the native v2 core's restart, guard, config and setup gaps
- Restarts restore effort, Fast mode, ultracode, the picked agent and
  prePlanMode as the bridge does (SessionConfig.restoreFrom), and start
  without the setup's _meta effort (withoutEffort, moved to session-effort).
- A route restart waits for a running account restart; the old query ends
  at its next turn boundary after the new one started; a close during a
  restart forgets the session.
- Config changes of a session run one at a time.
- The subscription guard runs in the session's prompt chain: cancel and
  close answer a prompt it judges with -32800, and /remote-control keeps
  its place.
- systemPrompt is validated on every setup path before it acts (active
  resume, fork before the copy).
- A session whose query ended stays active until close: its requests fail
  with -32603 and what happened instead of -32002.
- A resume of the active session with other additional directories
  restarts it with them at its next turn boundary.
- A replayed tool call without a result ends failed.
- The clear-context prompts get no user_message; the clear-context mode is
  resolved at the answer, with no fallback notice.
- No "needs authentication" notice when the session runs MCP OAuth.
2026-11-09 14:43:52 +01:00
Mark Tkachenko
dd9eed4e46 test: pin runtime exports of lib.js and acp-agent.js (#1546)
Adds a test that pins the runtime export names of `lib.js` and
`acp-agent.js`.

`acp-agent.ts` is about to be split into smaller modules in a series of
pure-move PRs. Each move keeps the old exports as re-exports, because
`"exports": { "./*": "./*" }` lets consumers deep-import
`dist/acp-agent.js`. tsc only catches a missing export that something in
the repo imports; this test catches the rest. The list should change
only on purpose.
2026-11-09 14:07:08 +01:00
Ben Brandt
1a56268aae Merge branch 'native-v2/r2-compare' into v2-core-exploration 2026-11-09 13:59:33 +01:00
Ben Brandt
ce892a59ae Merge branch 'native-v2/r2-subagents' into v2-core-exploration
# Conflicts:
#	docs/v2-native-core.md
#	src/acp-agent.ts
#	src/v2-native/agent.ts
#	src/v2-native/stream.ts
2026-11-09 13:59:33 +01:00
Ben Brandt
19f42bfb96 fix: restore the model and mode through the native config's request form after a restart 2026-11-09 13:56:08 +01:00
Ben Brandt
b658390078 Merge branch 'native-v2/r2-lifecycle' into v2-core-exploration
# Conflicts:
#	docs/v2-native-core.md
#	src/v2-native/agent.ts
#	src/v2-native/auth.ts
2026-11-09 13:56:08 +01:00
Ben Brandt
d649d2c423 Merge branch 'native-v2/r2-config' into v2-core-exploration 2026-11-09 13:50:15 +01:00
Ben Brandt
529531f726 Merge branch 'native-v2/r2-turns' into v2-core-exploration 2026-11-09 13:47:35 +01:00
Ben Brandt
4cceb06b31 fix: list a resumed Fable 5.1 session's model as fable (#1548)
## Problem

The Claude Code CLI gives Fable 5.1 a different option value depending
on how the session starts:

- **New session:** listed as `fable` (`resolvedModel:
"claude-fable-5-1"`).
- **Resumed session whose transcript last ran Fable 5.1:** the same row
is listed as `claude-fable-5-1`, and there is no `fable` row.

Reproduced with CLI 2.1.310. Other families don't do this: a session
resumed on `claude-opus-5-5` still lists `opus`.

Clients store the option value as the session's choice. A choice saved
in a new session (`fable`) is not among the options of a resumed one,
and the reverse. A client that checks its saved value against the
options then refuses to apply it, and the model choice keeps flipping
between the two ids.

## Fix

`aliasResumedModelRow` in `session-model.ts` gives the resumed model's
row its family alias back. It changes a row only when:

- the row's value is the model read from the resumed transcript, equals
its own `resolvedModel` and has no context hint,
- no row already has the alias, and
- no newer version of the family is listed, since the alias runs the
family's newest model.

`createSession` applies it before the allowlist and model selection, so
new, resumed and loaded sessions over ACP v1 and v2 all get the same
list.

This is a workaround for the CLI listing the row under a different
value. The CLI also lists rows this way when `ANTHROPIC_MODEL` or
`settings.model` pins `claude-fable-5-1`. Those rows keep the pinned id
on purpose: relabelling them would turn the user's pinned version into
the alias, which moves to a newer Fable when one ships.

## Tests

- `acp-agent-settings.test.ts`: a session resumed on `claude-fable-5-1`
offers `fable` as the current value and option, with no `setModel` call.
Fails without the fix.
- `model-resolution.test.ts`: unit cases for the relabel and for each
case that keeps the list as it is.
- Checked end to end with the real CLI: a Fable turn, then
`session/load`, reports `fable`.
2026-11-09 13:47:11 +01:00
Ben Brandt
ab2ff616a8 feat: serve the config options, model fallback, context window, conversation reset and clear-context plans on the native v2 core
Port the bridge's effort, Fast mode, ultracode and agent options and their set
paths, the refusal fallback notice and model swap, the context window seed
(getContextUsage and the cross-session cache), conversation_reset handling, and
ExitPlanMode's clear-context option to the native v2 core.

The protocol-neutral code moves out of acp-agent.ts into shared modules that
both cores use: session-config-options.ts, session-fast-mode.ts,
context-window.ts, the effort switch helpers in session-effort.ts,
withCurrentModelInfo, and modelRefusalFallback.

The native core continues an accepted clear-context plan with Claude Code's own
/clear on the same query instead of the bridge's fresh query; the client sees
the same updates.
2026-11-09 13:46:34 +01:00
Ben Brandt
ef65ab31b4 fix: update to @agentclientprotocol/sdk 2.5.0 (#1549)
Bumps `@agentclientprotocol/sdk` from 2.4.0 to 2.5.0 (exact pin).

2.5.0 updates the SDK to ACP schema v1.30.0 and v2.0.0-alpha.14
([release
notes](https://github.com/agentclientprotocol/typescript-sdk/releases/tag/v2.5.0)).
No source changes were needed; new schema fields and capabilities are
not adopted here.

## Testing

`npm run build`, `npm run check` and `npm run test:run` pass. `npm run
test:integration` was not run.
2026-11-09 12:43:12 +00:00
Ben Brandt
e83770c609 feat: re-route live sessions, the subscription guard and /remote-control on the native v2 core
- providers/set, providers/disable and an auth/logout that drops a route
  restart each live session's query at its next turn boundary, resuming the
  same Claude session, as the bridge does (src/v2-native/restarts.ts).
  Prompts sent meanwhile wait and go to the new query.
- The --hide-claude-auth subscription guard holds on the native core, as the
  bridge applies it to v2 sessions (src/v2-native/subscription-guard.ts).
- /remote-control is listed and run as a local command of its own turn, and
  messages typed in another client show as user messages
  (src/v2-native/remote-control.ts). Its helpers move out of acp-agent.ts
  into src/remote-control.ts.
- Docs: the gateway login routes only new sessions on the bridge too;
  steering is not served to v2 by the bridge either; file change reports are
  an AIR extension; the active-resume MCP server and /remote-control
  differences are listed as intended.
2026-11-09 13:41:32 +01:00
Ben Brandt
6494eaa95a test: add a real-CLI comparison of the ACP v2 bridge and native cores
scripts/compare-v2-cores.mjs runs each case on both v2 cores with the bundled Claude Code CLI, records every client-visible message, normalizes ids, paths, timestamps and usage numbers, and diffs the two.
2026-11-09 13:37:42 +01:00
Ben Brandt
9bf4fedee6 feat: serve the remaining subagent behavior on the native v2 core
A child's configuration from its hooks (mode, effort, agent type,
PostModelSwitch) merged with its response models, SendMessage and
SubagentHandback calls as session messages live and in a replay, a
child's tool progress, the replay's notice for missing subagent history,
and the restore of an agent from before a restart from the session's
subagent history, as the bridge does. The hook options and their order
move to subagent-configuration.ts, shared with the v1 core.
2026-11-09 13:35:56 +01:00
Ben Brandt
6db28b2cba feat: match the bridge's turn output after a cancel on the native v2 core
- share recordStreamedDelta and unstreamedRemainder (streamed-text.ts) with
  the v1 core: a consolidated message adds the tail of a cut-short text block
- after session/cancel, drop the stopped work's consolidated messages except
  the results of tool calls the client holds, as the bridge does
- report zero usage and quota on the idle of a prompt's turn without a result
- run acp-v2.test.ts against the native core too, as a vitest project
2026-11-09 13:22:03 +01:00
Ben Brandt
35dee0a20f Merge remote-tracking branch 'origin/main' into v2-core-exploration 2026-11-09 13:03:01 +01:00
Ben Brandt
c700127ad2 fix: address review findings in the native v2 core
- End a child's unfinished tool calls before its idle (cancelled, failed,
  or dropped before it ran), as v2 asks of updates after a cancel.
- Skip the late result of a dead turn (cancelled after started), and keep
  dropping an abandoned prompt's run when another one's cancelled frame comes.
- Send no request of a session after session/close answered.
- Resume of the active session: announce every working child, send each
  listed item of a child before its state, and keep live children's states
  after a replay.
- Report running again when a cancel's stop or interrupt fails.
- Keep requires_action for an accepted MCP URL elicitation until Claude Code
  reports it complete, and only when every foreground subagent is blocked.
- Read the idle's stop reason with the v1 core's rules (shared resultEnd),
  map assistant error kinds and the sign-in message like the bridge, and use
  model_refusal_no_fallback's explanation.
- Serve SandboxNetworkAccess tool calls, permission_denied, the subject's
  _meta (shared permissionToolCallMeta), prePlanMode, the Auto mode fallback,
  PostModelSwitch, MCP OAuth after fork, and the stopped-work check for
  AskUserQuestion.
- Read the settings file before the settings watchers open (shared
  configuredSettings), and forget canUseTool waiters that timed out.
2026-11-09 12:58:25 +01:00
Ben Brandt
a32fe7a6cc docs: list outcome.ts and stopped-work.ts in the native v2 core layout 2026-11-09 12:07:39 +01:00
Ben Brandt
d81307c108 test: drop the native-only interrupt in the idle background-subagent cancel test
The merged native core interrupts Claude Code on session/cancel also while
idle, as the bridge does, so the test runs the same on both cores.
2026-11-09 12:03:13 +01:00
Ben Brandt
a9bc3b81ae Merge branch 'native-v2/subagents' into v2-core-exploration
# Conflicts:
#	docs/v2-native-core.md
#	src/tests/v2-native.test.ts
#	src/v2-native/agent.ts
#	src/v2-native/config.ts
#	src/v2-native/session.ts
#	src/v2-native/stream.ts
#	src/v2-native/tasks.ts
2026-11-09 12:03:13 +01:00
Ben Brandt
fd41fb0055 Merge branch 'native-v2/elicitation' into v2-core-exploration
# Conflicts:
#	docs/v2-native-core.md
#	src/v2-native/agent.ts
#	src/v2-native/session.ts
2026-11-09 11:58:07 +01:00
Ben Brandt
b5ada6848a fix: skip the Edit preview for a tool use that a cancel dropped on the native v2 core 2026-11-09 11:55:01 +01:00
Ben Brandt
cad5166361 Merge branch 'native-v2/setup' into v2-core-exploration
# Conflicts:
#	docs/v2-native-core.md
#	src/acp-agent.ts
#	src/tests/acp-v2.test.ts
#	src/tests/v2-native.test.ts
#	src/v2-native/agent.ts
#	src/v2-native/session.ts
#	src/v2-native/stream.ts
2026-11-09 11:55:00 +01:00
Ben Brandt
94a7731257 Merge branch 'native-v2/reports' into v2-core-exploration
# Conflicts:
#	docs/v2-native-core.md
#	src/acp-agent.ts
#	src/tests/acp-scenarios-v2-native.test.ts
#	src/tests/v2-native.test.ts
#	src/v2-native/session.ts
#	src/v2-native/stream.ts
2026-11-09 11:48:02 +01:00
Ben Brandt
4cea4c8e22 Merge branch 'native-v2/turns' into v2-core-exploration 2026-11-09 11:42:21 +01:00
Ben Brandt
63a69d09fb feat: serve usage, quota, notices, compactions, titles and task plans on the native v2 core
Ports the session reports that the bridge sends to v2 clients to the native
core, in src/v2-native/reports.ts: usage_update (context window, cost, model,
rate limit), the _meta.quota of each idle, Claude Code's warnings and client MCP
startup failures as notices, live and replayed compaction updates, the idle
compaction (/compact as running then idle, with no user_message), titles,
Task* plans, tool progress and memory recalls.

Shared with v1 instead of copied: the usage helpers move from acp-agent.ts to
session-usage.ts, the informational and MCP startup notices and the replayed
compaction move to session-notices.ts and context-compaction.ts, and
SessionTitles reads its session and agent through interfaces that
ClaudeAcpAgent already matches.

session/cancel now interrupts Claude Code also when no turn is open, as the
bridge does, which stops a turn Claude Code started but did not report yet.
2026-11-09 11:41:42 +01:00
Ben Brandt
7b3cd48edd feat: serve subagents and background work on the native v2 core as the bridge does
Announce a child once its task_started and its Agent call came, on the
session that made the call, with its state; replace the Agent call with the
linked session messages of its prompt and a foreground child's reply; stream
the child's traffic and report its model. Requests of a foreground subagent
block the work that waits on it also before its announcement, a stopped
child's tool calls end cancelled after its idle, and the background lists
follow Claude Code's live task set. Replay each subagent's history to its own
session on resume with replayFrom.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-09 11:41:04 +01:00
Ben Brandt
c4a4899cdb feat: serve turns, cancels and Claude Code's own cycles like the bridge on the native v2 core
- Idles carry the result's usage and `_meta.quota` (quota helpers moved from
  acp-agent.ts to usage-quota.ts); a failed turn's idle carries its error only.
- A turn starts at a prompt's start or echo, or at the main conversation's
  output; `system/init` no longer opens one. The idle that follows a result
  is that result's, and a placeholder result ends nothing.
- session/cancel answers every prompt not inserted yet with -32800 and
  always interrupts. The run of a prompt Claude Code had taken in, or of a
  cycle it started while the client saw none, is dropped, and its tool uses
  are denied (stopped-work.ts). An aborted result after a cancel ends as
  cancelled.
- Permission requests stay open for Claude Code's interrupt to withdraw;
  their end reports no running only when a cancel withdrew them.
- session/close ends the work at once, as the bridge does.
- Tool calls that Claude dropped before they ran end cancelled before the
  idle; after a cancel every open call does, and tool results render as the
  cancelled run's.
- Local command output and refusal explanations get their messageIds.
2026-11-09 11:34:40 +01:00
Ben Brandt
50dd028877 feat: serve session setup parity on the native v2 core
Advertise and serve session/fork, list the bridge's command shape (with
the adapter's /mcp, served) and send it in an update when reading it for
the response fails, follow _meta.claudeCode.options, MAX_THINKING_TOKENS,
CLAUDE_MODEL_CONFIG, the availableModels allowlist and the model
preference, restore the resumed transcript's mode and model, replay
compactions and approved plans, send the current title and AIR's
loadReplayEnd on resume, and show an Edit's exact patch before it runs.
2026-11-09 11:34:27 +01:00
Ben Brandt
64fdb9364f feat: serve elicitations on the native v2 core
AskUserQuestion as a form elicitation (no longer disallowed with form
support), MCP server elicitations (form and URL, cancel when the client
cannot render them), the refusal fallback's consent prompt, and MCP OAuth
after the setup response. They are open requests of their session
(requires_action), withdrawn by a cancel or close like permission requests.

The answer logic moves to shared helpers in elicitation.ts and the OAuth
flow to mcp-oauth.ts, which the v1 core now calls too.
2026-11-09 11:23:09 +01:00
Ben Brandt
4a86415a37 test: dump the native core's scenario updates with DUMP_NATIVE_SCENARIOS 2026-11-09 10:51:44 +01:00
Ben Brandt
ecbc310304 feat: serve auth status, gateway login and providers on the native v2 core
Move the authStatus state and CLI probe (AuthStatusReporter, cliLogout) and the
provider routing (providers.ts) out of ClaudeAcpAgent, so the v1 core and the
native v2 core share them. The native core serves _meta.authStatus,
_auth/status_update, gateway auth/login, auth/logout and providers/* from
src/v2-native/auth.ts. A route change applies to sessions started after it;
unlike the bridge, live native sessions are not recreated.
2026-11-09 10:50:55 +01:00
Ben Brandt
4a19a9dfb8 test: run the bridge's v2 tests against the native core with V2_CORE_UNDER_TEST=native
Also sends a prompt's user_message before the turn's running, as the
bridge does.
2026-11-09 10:34:53 +01:00
Ben Brandt
1801c037b8 feat: add an experimental native ACP v2 core
Serves ACP v2 straight from the Claude Agent SDK query, without the
v1 core, behind CLAUDE_AGENT_ACP_V2_CORE=native. Foreground work is
each Claude Code turn, subagents are child sessions (subagents RFD),
and every state carries its background list. See
docs/v2-native-core.md.
2026-11-09 09:58:58 +01:00
Ben Brandt
a58293f4c9 fix: update to @anthropic-ai/claude-agent-sdk 0.3.319 (#1545)
Updates the Claude Agent SDK from 0.3.318 to 0.3.319, which bundles
Claude Code 2.1.319. Also updates `@anthropic-ai/sdk` (1.4.0 → 1.6.0)
and the transitive dependencies in the lockfile (`@babel/runtime`,
`@eslint-community/regexpp`, `@rolldown/pluginutils`, `hono`, `jose`,
`vite`).

## Reviewed, no change needed

- **`mcp_task` background tasks:** SDK sessions get these only with
`CLAUDE_AUTO_BACKGROUND_TASKS=1` and streaming input. The adapter does
not set that variable, so they don't occur by default. If a user sets
it, the Async Tasks panel shows the raw `mcp_task` type, and the
`resource_links` on the task notification are not forwarded. Left as a
possible follow-up.
- **`deleteSession()` rejects with `ENOTEMPTY`** when the session folder
is only partly removed. `session/delete` already rethrows any error
other than "not found".
- **`session_writes_lost`** is now sent only when the CLI exits 0. The
adapter only logs it.
- **`permissions.denyInsteadOfPrompting`:** with it set, the CLI denies
instead of calling `canUseTool`. That is the setting's intent.
- **`mcp_call.check_permissions`, `set_has_subscribers`,
`disableWindowsBashCommandSplit`:** the adapter does not use these.
- The comments on the `getSettings` and `unanswered` type workarounds
now name 0.3.319; both are still missing from the SDK's types.

## Not updated

TypeScript stays on 6.0.3: typescript-eslint 8.72.1 requires `typescript
<6.1.0`, so 7.0.2 has to wait for a typescript-eslint release that
supports it.

## Testing

`npm run build`, `npm run check` and `npm run test:run` pass. `npm run
test:integration` was not run.
2026-11-09 08:56:37 +00:00
PerryLink
3b22586cf6 fix(test): assert the blockquoted MCP startup transcript line (#1543)
`#1362` moved every transcript fallback for clients without
`session.notices` into its own blockquote paragraph (`\n\n> …\n\n`) and
updated the existing assertions, but missed the MCP server startup one.
`main` has been red since that merge:

- The `Build` job on `47b64c0` fails with an annotation at
`src/tests/acp-agent.test.ts` line 13860:

  ```
AssertionError: expected '\n\n> **MCP server "client-fs" failed…' to be
'**MCP server "client-fs" failed to st…'
  ```

- The neighbouring fallback tests already assert the blockquote form,
e.g. `"\n\n> **Warning:** hook says no\n\n"` and the two-line `Stop
says:` message.

This updates the expected string to the blockquoted text; product code
is untouched.

## Validation

- `npx vitest run src/tests/acp-agent.test.ts -t "falls back to a
transcript line without the notices capability"` → 1 passed
- `npx vitest run src/tests/acp-agent.test.ts` → 759 passed | 9 skipped
- `npm run check` → passes
2026-11-08 07:57:15 +00:00
alexwan
47b64c04de fix: start transcript notices on their own line for clients without session.notices (#1362)
## What

When a client does not advertise `session.notices`, a notice falls back
to an `agent_message_chunk` like `**Notice:** …`. Clients render
consecutive chunks as one markdown stream, so the fallback is glued to
Claude's last sentence. In Zed 1.24 a Stop hook's `systemMessage` shows
up as:

```
…推上去。**Notice:** Stop says: token: 3 calls
Stop says: hand off now
```

The fallback is now its own blockquote paragraph, with blank lines on
both sides:

```
…推上去。

> **Notice:** Stop says: token: 3 calls
> hand off now
```

## Behavior

- `noticeOrTranscriptUpdate` wraps the transcript text with
`transcriptBlock`: a leading blank line so it starts on a new line, `> `
on every line, and a trailing blank line so Claude's next chunk does not
continue the quote. This covers every transcript fallback (hook notices,
"Task stopped by user", "Fast mode turned off", "Auto mode
unavailable").
- Claude Code prefixes every line of a hook's `systemMessage` with
`<HookEvent> says: `. The informational fallback keeps that prefix on
the first line only, and only when every line repeats the same prefix
(`collapseHookSpeaker`).
- Clients that advertise `session.notices` get the same `notice` update
as before.
- `Turn.noticeTexts` still records `message.content` unchanged, so the
hook-blocked result de-duplication keeps working.

## Tests

- New unit tests for `transcriptBlock` and `collapseHookSpeaker`, plus
an end-to-end test for a two-line `notice`-level hook message.
- Updated the existing transcript fallback assertions to the blockquote
form.
- `npm run check`, `npm run build` and `npm run test:run` pass (3111
tests).

Co-authored-by: alex <hongchang.wan@longbridge-inc.com>
2026-11-06 22:14:44 +00:00
Jonathan Ellis
1ee2422934 fix: keep autonomous turn tasks open across owed trailing idles (#1455)
When Claude Code starts a new autonomous cycle before the previous
cycle's trailing idle arrives, that late idle completes the new cycle's
AIR async task while Claude is still working. The next output then
creates a second task for the same cycle.

This change completes the autonomous task inside the existing cycle-end
branch, which already checks that `owedTrailingIdles` is zero. An owed
idle is now only consumed by the existing accounting and leaves the
current task open. The task still completes at its own terminal result
or at an unowed idle, so the task and the cycle end on the same
decision.

It adds a scripted SDK-stream regression for two consecutive autonomous
cycles with no running transition between them. The test checks that the
late idle leaves the second task open and that later output stays on the
same task, for a cycle that ends at a result and for one that ends at an
unowed idle. Both cases fail before the fix and pass after it.

Fixes #1453.

Validation, rebased on 0.98.0 (c90f856):
- `npm run build` and `npm run check` pass.
- `npm run test:run`: 3,378 passed, 32 skipped. One unrelated test,
`tools.test.ts` "picks up a rewrite that restored the modification time
of the file", failed once in the full run on a heavily loaded machine.
It passed 3 of 3 runs alone, both on this branch and on `main`, and this
change does not touch `tools.ts` or its tests.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 22:11:36 +00:00
Kerekes Dániel
7d1ef76c1d fix: report setup-token logins as logged in (#1376)
Fixes #1295

With a `claude setup-token` login (`CLAUDE_CODE_OAUTH_TOKEN`), `claude
auth status --json` only returns `{"loggedIn": true, "authMethod":
"oauth_token", "apiProvider": "firstParty"}`. `fromCliStatus()` handed
that to `mapAuthFields()`, which has no plan or key fields to go on and
fell back to "Not logged in". Clients then showed a working session as
logged out.

This maps that exact state to `kind: "account"` with the label suggested
in the issue ("Claude subscription", detail "OAuth token"). Everything
else still goes through `mapAuthFields()`.

Added a case to `auth-status.test.ts` that fails without the change.
`npm run check`, `npm run build` and `npm run test:run` pass.
2026-11-06 23:10:04 +01:00
Hussain Hammad
aa0c1234b5 fix: replay a compaction summary by its flag, not its framing (#1536)
Fixes #1535

A compaction summary on replay was only sent as a `compaction_update` if
its text also parsed, so a change in Claude Code's framing turned it
into a `user_message_chunk`, shown as text the user typed. That is what
happens to summaries from Claude Code 2.1.312 and 2.1.316.

`isCompactSummary` now decides on its own. A summary whose framing isn't
recognized is sent as a completed `compaction_update` without summary
text. This reverses the choice to keep such text visible: the
model-facing framing isn't kept as a summary either way, and showing it
as the user's words is the worse failure.

`PERSISTED_PREAMBLE` also allows content before the `Summary:` line, so
current summaries keep their text. All 31 summaries in my own
transcripts now parse, and a real `session/load` on one from 2.1.316
sends a `compaction_update` with its text and no summary
`user_message_chunk`.
2026-11-06 22:09:30 +00:00
Alexis Rouillard
54392e144d fix: relay a rate_limit_event that arrives before the turn's first assistant frame (#1534)
## Problem

`rate_limit_event` is relayed to the client as
`usage_update._meta["_claude/rateLimit"]`, but only when
`lastAssistantTotalUsage !== null`. With partial messages on, the SDK
emits `rate_limit_event` before the turn's first `assistant` message,
while `lastAssistantTotalUsage` is still null (reset at each turn). The
SDK emits it once per turn, so in practice clients never receive
`_claude/rateLimit`.

Observed with a real subscription session (SDK 0.3.316): the SDK sends
`stream_event`s, then `rate_limit_event`, then the first `assistant`.
The adapter dropped the event and sent only `usage_update`s with
`_claude/model`.

## Fix

When the event arrives before the turn has any usage, keep its
`rate_limit_info` and attach it to the next `usage_update`. That happens
in `attachUsageModel`, which every `usage_update` already goes through.
A rate limit that the update already carries wins over the pending one.
When usage is already known, the event is sent right away, as before.

## Tests

- New test in `acp-agent.test.ts`: a `rate_limit_event` before the first
assistant frame ends up on the turn's first `usage_update`, and on no
later one. It fails without the fix.
- `npm run check`, `npm run build` and `npm run test:run` pass.
- Checked by hand over stdio with a real session: the turn's first
`usage_update` now carries `_claude/rateLimit`, including
`unifiedWindows.seven_day`.
2026-11-06 22:07:33 +00:00
Ben Brandt
6597626d86 fix: list a subagent's background Bash commands (#1540)
Claude Code sends no `tool_use_result` with a subagent's tool results.
The adapter recognizes a background Bash command only from the
`backgroundTaskId` in that structured result, so it never recognized one
that a subagent started. The command's terminal was reported exited
(code 0) as soon as the result came, while the command kept running, and
the command was missing from the subagent's `state_update.background`.

The real SDK stream (CLI 2.1.318) for a subagent running `sleep 30` in
the background:

```
task_started  task_id=beure6fer task_type=local_bash tool_use_id=toolu_018M… owned_by_subagent=true is_backgrounded=true
user          parent_tool_use_id=toolu_01HY… tool_result for toolu_018M…, no tool_use_result
```

The local transcripts agree: none of the 35 subagent background Bash
results across CLI 2.1.293 to 2.1.318 has a `toolUseResult`.

When a Bash result has no structured task id, the adapter now uses the
live background `local_bash` task whose `task_started` named that Bash
call. The existing subagent test now sends the stream the CLI sends. It
fails without the fix.
2026-11-06 19:12:42 +01:00
Ben Brandt
ba162346c9 fix: list Monitor calls in the session's background work (#1538)
Claude Code marks every live-update watcher `ambient`, "requested or
auto-started" per the SDK's `background_tasks_changed` docs, so the task
of a `Monitor` call the model made is ambient too. `background-work.ts`
left out every ambient task, so a running Monitor was never listed:

- its tool call went straight to `completed` instead of staying
`in_progress` until its task ends;
- a client couldn't stop it with `tool_call/cancel`;
- a client deciding whether a session is quiet, for example before
restarting or releasing it, saw no background work while the Monitor
kept starting turns.

Seen live in Delta against 0.101.0: a Monitor watching a loop kept
waking the agent every 20 seconds while every `state_update` reported
`background: []`.

## Change

- `toolResults` registers a task a tool call started even when it is
ambient, so the call stays `in_progress` and advertises `cancel`.
- The list builder lists a registered call's task before skipping
ambient tasks. Ambient tasks without a call of their own, such as
watchers Claude Code starts by itself, and ambient terminals stay out.

## Tests

- The Monitor fixture in `acp-v2.test.ts` now carries `ambient: true`,
as the CLI sends it. Without the fix, the four existing Monitor tests
fail with it.
- The `_claude/task` test is now an `it.each`: a non-ambient task
without a call is listed, and an ambient watcher without a call is not.
- `npm run check`, `npm run build`, `npm run test:run` pass. Not run:
`npm run test:integration` against the real CLI.
2026-11-06 12:12:58 +01:00
acp-release-bot[bot]
85aec26ef9 chore(main): release 0.101.0 (#1516)
🤖 I have created a release *beep* *boop*
---


##
[0.101.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.100.0...v0.101.0)
(2026-11-05)


### Features

* compact idle sessions before the prompt cache expires
([#1522](https://github.com/agentclientprotocol/claude-agent-acp/issues/1522))
([a79d073](a79d07324d)),
closes
[#1454](https://github.com/agentclientprotocol/claude-agent-acp/issues/1454)
[#1518](https://github.com/agentclientprotocol/claude-agent-acp/issues/1518)
* ship single-file executables built with bun
([#1524](https://github.com/agentclientprotocol/claude-agent-acp/issues/1524))
([e3f0b6c](e3f0b6c89c))
* start sessions in Auto mode by default
([#1533](https://github.com/agentclientprotocol/claude-agent-acp/issues/1533))
([7ea9eec](7ea9eec839))
* stream the output of v2 background commands
([#1512](https://github.com/agentclientprotocol/claude-agent-acp/issues/1512))
([53e3eec](53e3eec013))


### Bug Fixes

* honor configured fallback for unavailable restored modes
([#1520](https://github.com/agentclientprotocol/claude-agent-acp/issues/1520))
([8bbdea6](8bbdea6cc6))
* update to @anthropic-ai/claude-agent-sdk 0.3.318
([#1525](https://github.com/agentclientprotocol/claude-agent-acp/issues/1525))
([1d4418f](1d4418f80d))

---
This PR was generated with [Release
Please](https://github.com/googleapis/release-please). See
[documentation](https://github.com/googleapis/release-please#release-please).

Co-authored-by: acp-release-bot[bot] <246668977+acp-release-bot[bot]@users.noreply.github.com>
2026-11-05 22:07:42 +01:00
Ben Brandt
7ea9eec839 feat: start sessions in Auto mode by default (#1533)
Claude Code now starts interactive terminal and VS Code sessions in Auto
(v2.1.283+). Agent SDK runs with no `permissionMode` still start in
Manual ([docs](https://code.claude.com/docs/en/permission-modes)). Until
now the adapter followed the SDK default. ACP clients are interactive
editors, so this PR makes them start like the VS Code extension.

## How a session now starts

- **No mode configured:** with no `permissions.defaultMode`, or an
invalid one that settings resolution drops, a new session starts in
`auto`.
- **Model without Auto support:** that built-in Auto default starts in
Manual (`default`) with no warning, as Claude Code does. A user who
explicitly sets `"auto"` still gets the existing fallback to
`acceptEdits` with a warning.
- **`disableAutoMode: "disable"`:** the adapter ignored this setting
before. Now, set at the top level or under `permissions`, it removes
Auto from the mode list, the plan-exit options and mode switching, and
the session starts in `default`. The bundled CLI reads both spellings.
- **Unchanged:** an explicit `permissionMode`, a resumed transcript's
mode and a configured `defaultMode` behave as before.

## Limitations

- **HIPAA:** Claude Code starts organizations with the HIPAA
configuration in Manual. The SDK doesn't report that configuration (it
is not in `AccountInfo`, the `init` message or the model list), so those
sessions start in Auto here. This is noted in a code comment.
- **Mid-session setting changes:** like the bypass setting,
`disableAutoMode` is read when the session is created.

## Tests

- Updated the tests that expected the old default.
- Added cases for the built-in default, the Manual fallback on a model
without Auto, and both spellings of `disableAutoMode`.
- Regenerated the scenario snapshots for ACP v1 and v2. The 4
`origin-main` baseline files change only the mode value, from
`"default"` to `"auto"`.
2026-11-05 12:01:11 +00:00
Mark Tkachenko
a79d07324d feat: compact idle sessions before the prompt cache expires (#1522)
Claude Code compacts a large idle session shortly before its one-hour
prompt cache expires, but only in attended sessions. SDK sessions
(entrypoint `sdk-ts`) never arm that timer, so every return after an
hour re-writes the whole context to the cache.

The adapter now runs its own timer: 54 minutes after the last
main-thread request, if that request used the 1h cache, the context is
at least 200k tokens and the session is idle, it sends `/compact`
through the normal prompt path. It respects `idleCompaction: false`,
`autoCompactEnabled: false` and `DISABLE_COMPACT` /
`DISABLE_AUTO_COMPACT`. AIR gets `trigger: "idle"` in the compaction
`_meta`.

Closes #1454
Closes #1518
2026-11-04 20:04:11 +01:00
Ben Brandt
1d4418f80d fix: update to @anthropic-ai/claude-agent-sdk 0.3.318 (#1525)
Updates the Claude Agent SDK from 0.3.316 to 0.3.318, which bundles
Claude Code 2.1.318. Also updates `@anthropic-ai/sdk` (1.3.0 → 1.4.0),
`vitest` (5.0.5 → 5.1.0) and the transitive dependencies in the
lockfile.

## Changes from the changelogs

- **`session_writes_lost` (SDK 0.3.317):** a new system message that the
CLI sends as it exits, after the last result, when a full disk or
used-up quota kept it from saving the transcript or `~/.claude.json`. No
turn is open to carry a `session/update` then, so the adapter logs it,
as it does `mirror_error`. Without a case for it, the build fails on the
exhaustive switch.
- **TaskCreate `task.description` (SDK 0.3.317):** the plan now takes a
task's subject and description from the TaskCreate result, which holds
the task as saved. It differs from the input when Claude Code corrects a
malformed call or a PreToolUse hook changes it. This applies to the live
stream and to `session/load` replay.

## Reviewed, no change needed

- New `startup_failure_reason` values (`config_file_invalid`,
`initialize_not_first`) already reach the client as an internal error
with the CLI's own message.
- `onFailure: "block"` on hook matchers: none of the adapter's hooks
guard permissions, and a failed Edit/Write preview must not block the
edit.
- Removed `file_unchanged` Read output, optional `status` in
`PushNotificationInput`, and Grep/Glob `timedOut`: nothing in the
adapter reads these.
- `workingDirectoriesAsGiven`: it makes Claude Code not follow links at
the listed working directories. Listing the ACP `cwd` would put every
file of a symlinked workspace outside the working directories. Hosts
that want it can already pass it through
`_meta.claudeCode.options.settings`.
- The comments on the `getSettings` and `unanswered` type workarounds
now name 0.3.318; both are still missing from the SDK's types.

## Not updated

TypeScript stays on 6.0.3: typescript-eslint 8.72.1 requires `typescript
<6.1.0`, so 7.0.2 has to wait for a typescript-eslint release that
supports it.

## Testing

`npm run build`, `npm run check` and `npm run test:run` pass. New tests
cover both changes. `npm run test:integration` was not run.
2026-11-04 12:50:19 +00:00
Ben Brandt
21d24f9e8b ci: build the executables with the latest Bun (#1526)
Follow-up to #1524. The last commit on that branch didn't make it into
the merge.

- `binaries.yml` installs `bun-version: latest` instead of pinning
`1.4.4`. The compiled executable contains the Bun runtime, so the pin
also fixed the runtime we ship. But nothing checks for new Bun releases,
so the pin would just go stale. Each build is smoke-tested on its own
platform before it uploads, so a Bun release that breaks the executable
fails that job instead of shipping. npm is unaffected, and a re-run
fixes it once Bun does. If a Bun version ever regresses past the smoke
test, we pin it then.
- Each build job writes `Built <target> with Bun x.y.z` to its step
summary, so it's visible which Bun built each release's archives.
- Re-wraps an overlong line in the file's header comment.

`ci:` because nothing published changes until the next release builds
with it.
2026-11-04 12:50:03 +00:00
Ben Brandt
e3f0b6c89c feat: ship single-file executables built with bun (#1524)
Brings back the standalone executables that #525 and #572 turned off.
npm publishing is unchanged. Each stable release also attaches 8
archives (Linux glibc and musl, macOS, Windows, on x64 and arm64) to its
GitHub release.

## Why it works now

The old executables ran the SDK's JS CLI from inside Bun's `$bunfs`, and
it couldn't reach its `vendor/` files there. The SDK's CLI is now a
native binary shipped in a per-platform npm package. So each archive
ships that binary as `claude` next to `claude-agent-acp`, and the agent
runs it from its own directory. Nothing is embedded or extracted, and
the agent has no `node_modules` to resolve.

The first commit embedded the CLI and extracted it with the SDK's
`@anthropic-ai/claude-agent-sdk/extract` helper. That works, but the OS
can only run a real file, and Bun-embedded files only exist inside the
agent process. So it wrote a second ~300 MB copy to `/tmp` on first run,
once per version. Shipping the CLI next to the executable avoids that,
and the download size is the same (~180 MB `.tar.gz`).

Claude Code's helper tools work too. Native Claude Code has no Grep or
Glob tools: its Bash shell wraps `grep` and `find` in functions that run
the ugrep and bfs built into the binary at `$CLAUDE_CODE_EXECPATH`. That
variable points at the `claude` shipped next to the agent.

## Changes

- `src/claude-cli.ts` now owns `claudeCliPath()`, moved out of
`acp-agent.ts`. `useBundledClaudeCli(dir)` makes it return
`<dir>/claude[.exe]`. If that file is missing, it throws an error saying
to keep `claude` next to `claude-agent-acp` or set
`CLAUDE_CODE_EXECUTABLE`; before, the user got a bare ENOENT.
`CLAUDE_CODE_EXECUTABLE` still wins.
- `src/binary-entry.ts` is the executable's entry. It registers the
directory of `realpath(process.execPath)`, so a symlink in
`/usr/local/bin` works, then imports the agent. It's left out of the npm
tarball.
- `scripts/build-binary.mjs` (`npm run build:binary`) runs `bun build
--compile` for a target and copies that target's `claude` next to the
output.
- `scripts/smoke-binary.mjs` (`npm run smoke:binary`) runs:
  - `--version` and `--cli --version`
  - a symlinked executable
  - the missing-`claude` error
  - `initialize` over v1 and v2
- the `terminal-auth` login command, run with `--version` (checks that
no `$bunfs` path leaks into it)

  With `--full` it also drives a real session:
- Bash `grep`/`find`, checking that they ran Claude's embedded ugrep/bfs
  - Read on text and on a PNG
  - Edit
  - a subagent
  - `session/cancel` during a running command
  - `session/list`
  - `session/load` in a new process
  - `session/fork` and `session/delete`
- `src/agent-command.ts` builds the `terminal-auth` login command, which
reruns the agent. Under Node that's `node <script> ...flags`. In a
compiled executable, `argv[1]` is a path inside the executable
(`/$bunfs/root/claude-agent-acp`), so it's dropped. Passing it back, as
the old `argv.slice(1)` did, handed it to the Claude CLI as an argument
and broke `auth login`.
- `.github/workflows/binaries.yml` builds and smoke-tests each target on
its own platform's runner; the musl targets are tested in an Alpine
container. CI runs it on every PR, using the basic smoke test, since CI
has no Claude login. On a stable release, `publish.yml` attaches the
archives (same names as before). These jobs don't gate the npm publish,
and previews get no executables. On stable releases the registry
dispatch waits for `upload-binaries`, so the archives (and the digests
the registry reads) exist first. It only waits: a failed upload doesn't
stop the dispatch.

## Testing

I also ran the whole unit suite on Bun's runtime (`bun --bun vitest
run`). 3,573 of 3,575 tests pass. The two failures are test artifacts:
Bun's `os.homedir()` ignores a `HOME` changed at runtime, and one
steer-and-cancel test asserts before the mock delivers its last idle. An
idle-accounting trace is identical on both runtimes.


On macOS arm64 with Bun 1.4.4, `smoke:binary --full` passes all checks.
`CLAUDE_CODE_EXECPATH` pointed at the shipped `claude`, and nothing was
written to the temp directory. The basic smoke test also passes from an
unpacked archive outside the repo with `PATH=/usr/bin:/bin`, and `node
dist/index.js` passes the full run. On Linux, Windows and Intel macOS,
only the basic smoke test runs, in this PR's CI.
2026-11-04 11:00:25 +00:00
Slapoguzov Alexander
8bbdea6cc6 fix: honor configured fallback for unavailable restored modes (#1520)
A restored transcript mode such as `dontAsk` is recognized by the Claude
SDK but absent from the ACP mode catalog. It previously reached
initialization and was forced to Manual, skipping a supported
`permissions.defaultMode` from settings.

Use the same catalog for restored-mode selection and initialization.
Supported restored modes retain precedence; an unavailable restored mode
falls back to an available configured mode, then Manual. Explicit
initial modes, bypass restrictions, model-specific Auto handling, and
new-session configuration behavior remain unchanged.

Validation: TypeScript build, ESLint, Prettier, and 243 focused tests
pass. Added regressions cover helper selection plus v1 and v2 responses;
nine checks fail against unchanged production code.

The full local suite **FAILED**: 3,549 passed, 20 failed, 30 skipped.
Four selected failures also reproduce on unchanged base: three
session-index cases (watcher setup, live notification, SDK path-list
parity) and one background-work case. The remaining 16 failures were not
individually classified. No test timeouts or product behavior were
changed to suppress them. Live CLI and IDE permission-enforcement
acceptance remain unverified.

Co-authored-by: Codex <codex@openai.com>
2026-11-03 10:40:14 +01:00
Ben Brandt
1e986b526d refactor: give each session one owner of its background work (#1517)
Follow-up to #1510, #1511 and #1512. Clients receive exactly what they
did before.

## Why

The v2 background work grew as about 14 free functions taking `(session,
sessionId, …)` and reaching into six `Session` fields. Four of them it
kept itself: the task set, terminals, tool calls, and
`reportsBackground`. Three belong to other modules:
`liveBackgroundTasks`, the subagent map, and `toolUseCache`. The repo
guidelines ask for one module that owns the state.

## What changed

- **One owner per session.** `SessionBackgroundWork`, stored on
`Session.background`, owns the task set, terminals, tool calls, output
tails, and whether the client gets the lists. It has one method per
event: a Bash result, tool results, a task's end or pause, a new task
set, and a closed query.
- **The reporter only filters.** The object builds each session's
report, so `BackgroundWorkReporter` only drops unchanged lists and
forwards the rest.
- **No reference back to the session.** A recreated session (sign-out
respawn, provider change) gets a new `Session` under the same id, so an
old consumer must not reach the new one. Callers pass in the state other
modules own.
- **No `toolUseCache` dependency.** Whether a task's launching call has
returned used to be judged by the call still being in `toolUseCache`,
which Claude Code prunes at the tool's result. The object now records
returned calls itself, capped at 1024 like the other per-session sets.
- **Stricter typing on the output path.** Typing the terminals map
properly exposed that the parsed `outputFilePath` was loosely typed, so
it is now checked to be a string.
- **Test doubles.** The hand-built `Session` doubles in
`acp-agent.test.ts` and `session-doubles.ts` now carry the new required
field.

## Tests

- The existing suite passes unchanged.
- One new v2 test covers a task whose `task_started` arrives after its
call returned, so it is listed as `_claude/task`. A first version of
this refactor recorded "returned" per task and silently missed that
case. The test fails against that version, and against listing tasks
before their call returns.

`npm run check`, `npm run build` and `npm run test:run` pass (3556
passed, 30 skipped).
2026-11-02 19:52:25 +01:00
Ben Brandt
53e3eec013 feat: stream the output of v2 background commands (#1512)
Follow-up to #1510 and #1511, phase 2b of the v2 [Background Work
RFD](https://agentclientprotocol.com/rfds/v2/background-work).

Claude Code streams no output of a Bash command that went to the
background: it writes the output to the task's output file. So the
command's terminal showed only the Bash result's placeholder ("Command
running in background with ID …") until the command exited. The v2 spec
says the Agent **SHOULD** keep a listed terminal's output current
whenever it can read the process output.

## What a v2 client gets

- **Snapshot:** the file's first text replaces the placeholder as a
`terminal_output` snapshot.
- **Chunks:** later text is appended as `terminal_output_chunk`s.
- **Task end:** the rest of the file is read before the terminal's
`exitStatus`, as the spec asks.
- **Query close:** reading stops.

## Design

- New `src/background-output.ts` (`BackgroundOutputTail`) follows one
file:
- It polls every 500 ms, because watching a file is not reliable on
every platform and a missing file just means nothing was written yet.
- It reads at most 256 KiB per read, so a burst of output doesn't block
the event loop. Each read is sent before the next, including when
reading to the end at the task's end.
- Past 1 MiB of unread output (a command writing faster than the polls
read, about 512 KiB/s), it skips to the file's last 1 MiB and sends it
as a replacing snapshot, so the terminal shows the latest output without
a gap in it. A file that shrinks is read again from its start, also as a
snapshot.
- Its timer is unref'd, so a long-running command doesn't keep the agent
process alive.
- A `StringDecoder` holds a UTF-8 character split across reads until its
last byte is written.
  - Reads are serialised.
- `background-work.ts` starts the tail once the Bash result's own
updates are sent, so the first output replaces the placeholder rather
than coming before it. It finishes the tail before the terminal's exit
and stops it when the query closes. `acp-agent.ts` only gets wiring.
- The output path comes from the Bash result's text, the only place the
SDK gives it before the task ends. The existing
`asyncTaskOutputFilePath` parser only accepts an absolute path ending in
`tasks/<taskId>.output` for this task. Without a path, the terminal
keeps the placeholder as before.
- v2 only (`reportsBackground`), like the rest of the background work.
v1 clients that render terminals through the `_meta` extension could use
it too. That is a small follow-up, but it changes what v1 clients
receive.

## Tests

- Nine unit tests for `BackgroundOutputTail`:
  - the replacing first read, and appends
  - a file that appears later, and a file that never appears
  - a UTF-8 character split across reads
  - finishing, an empty output, and stopping
  - a backlog past 1 MiB, and a file that shrinks
- One v2 test over a real connection with a real output file. It checks
that the placeholder is replaced, later output arrives as chunks, and
the rest arrives before the exit.
- I checked by mutation that removing the read-before-exit, the
replacing snapshot, the UTF-8 decoder, the backlog cap or the shrink
restart each fails a test.

`npm run check`, `npm run build` and `npm run test:run` pass (3555
passed, 30 skipped).
2026-11-02 19:37:49 +01:00
acp-release-bot[bot]
981bf9a28a chore(main): release 0.100.0 (#1485)
🤖 I have created a release *beep* *boop*
---


##
[0.100.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.99.1...v0.100.0)
(2026-11-02)


### Features

* list v2 background tool calls
([#1511](https://github.com/agentclientprotocol/claude-agent-acp/issues/1511))
([4d79b9f](4d79b9ffbc))
* report subagent working directories
([#1496](https://github.com/agentclientprotocol/claude-agent-acp/issues/1496))
([eb78afa](eb78afa855))
* report v2 background work
([#1510](https://github.com/agentclientprotocol/claude-agent-acp/issues/1510))
([5565259](5565259771))
* report why Claude Code could not start a session
([#1508](https://github.com/agentclientprotocol/claude-agent-acp/issues/1508))
([38cf656](38cf656fc9))


### Bug Fixes

* align session/cancel, session/close, and v2 setup with the ACP v2 spec
([#1494](https://github.com/agentclientprotocol/claude-agent-acp/issues/1494))
([f50598a](f50598a39c))
* forward v2 elicitation values and read dismissed v2 permission prompts
as a refusal
([#1493](https://github.com/agentclientprotocol/claude-agent-acp/issues/1493))
([3bc6711](3bc671193c))
* keep native Windows paths in v2 git patches
([#1492](https://github.com/agentclientprotocol/claude-agent-acp/issues/1492))
([c3428de](c3428de1bf))
* preserve live session settings across provider refreshes
([#1474](https://github.com/agentclientprotocol/claude-agent-acp/issues/1474))
([e01a288](e01a288384))
* reject unreadable skill manifests
([#1482](https://github.com/agentclientprotocol/claude-agent-acp/issues/1482))
([99a6e76](99a6e76158))
* report the fresh context's usage when ExitPlanMode clears context
([#1484](https://github.com/agentclientprotocol/claude-agent-acp/issues/1484))
([2fd46c8](2fd46c866d))
* update to @agentclientprotocol/sdk 2.2.0
([#1491](https://github.com/agentclientprotocol/claude-agent-acp/issues/1491))
([1d2b92f](1d2b92feae))
* update to @agentclientprotocol/sdk 2.4.0
([#1509](https://github.com/agentclientprotocol/claude-agent-acp/issues/1509))
([720bbb3](720bbb3e4c))
* update to Claude Agent SDK 0.3.316 (Claude Code 2.1.316)
([#1507](https://github.com/agentclientprotocol/claude-agent-acp/issues/1507))
([1d03064](1d0306468b))
* validate session, prompt, config, and auth requests per the latest ACP
spec
([#1495](https://github.com/agentclientprotocol/claude-agent-acp/issues/1495))
([d2ef490](d2ef490a31))

---
This PR was generated with [Release
Please](https://github.com/googleapis/release-please). See
[documentation](https://github.com/googleapis/release-please#release-please).

Co-authored-by: acp-release-bot[bot] <246668977+acp-release-bot[bot]@users.noreply.github.com>
2026-11-02 15:33:56 +01:00
Ben Brandt
4d79b9ffbc feat: list v2 background tool calls (#1511)
Follow-up to #1510, phase 2a of the v2 [Background Work
RFD](https://agentclientprotocol.com/rfds/v2/background-work).

A Monitor, Workflow or backgrounded MCP call returns as soon as its task
starts, so its tool call completed at its result while the task ran on.
#1510 listed such tasks as `_claude/task` items, which clients don't
recognise. This PR lists them as the tool calls that started them, as
the RFD's adapter table describes.

## What a v2 client gets

| When | The tool call |
|---|---|
| Tool result, while the task is in Claude Code's live background task
set | Reports `in_progress` instead of `completed`, and is listed as a
`tool_call` item |
| While listed | Advertises `capabilities.cancel`. `tool_call/cancel`
stops the task with `stopTask`, or returns `-32602` if the call isn't
listed (also when its task ended mid-cancel) |
| `task_updated` with `paused` / `running` | `paused` / `in_progress` |
| The task ends | `completed`, `failed`, or `cancelled` for a stopped
(`killed`) task, with `capabilities: null`, before the list drops it.
The same happens when the live set drops the task without an end event
(`completed`, since nothing was reported), or the query closes
(`cancelled`) |
| `session/resume` | The call's current state, rebuilt from its tool
use, then its capability, before the snapshot |

The task is matched to its call through the structured `taskId` of a
Monitor or Workflow result, or through the live task whose
`task_started.tool_use_id` names the call (backgrounded MCP calls).

`session/cancel` no longer stops background workflows for sessions that
report their background work, because listed work must keep running.
Clients stop them with `tool_call/cancel` instead. v1 and AIR are
unchanged.

A task without such a call is still a `_claude/task` item. It is now
listed only once its `task_started` has arrived and the call that
started it, if any, has returned. Before that, the foreground work is
still waiting on that call, and the spec says foreground work is not
listed.

## Design

- `background-work.ts` holds the logic: registering background tool
calls, pause and end updates, the shared `cancelBackgroundWork` for
`terminal/cancel` and `tool_call/cancel`, and the resume states.
- `acp-agent.ts` only gets wiring.
- `paused` travels as a cast v1 status, alongside the existing
`V2_CANCELLED`.
- `V2Background` advertises and clears the cancel capability.

## Tests

- Six new v2 tests, using the real CLI's event order (tool use, task
joins the live set, `task_started`, tool result) and the `killed` +
`stopped` pair for a stopped task. They cover:
  - listing, pause and resume, and the final status
  - `tool_call/cancel`
  - a query that closes
  - a listed Workflow left running on `session/cancel`
  - resume
  - the `_claude/task` fallback
- I checked by mutation that each new guard fails a test when removed.

`npm run check`, `npm run build` and `npm run test:run` pass (3545
passed, 30 skipped).

## Still to come

- Tailing background Bash `output_file` into terminal output (phase 2b).
- `ScheduleWakeup` / `CronCreate`, which need a signal from the SDK.
- The SHOULD gap about follow-up work is being relaxed upstream in
agentclientprotocol/agent-client-protocol#2745.
2026-11-02 14:40:01 +01:00
Ben Brandt
5565259771 feat: report v2 background work (#1510)
Implements phase 1 of the ACP v2 [Background Work
RFD](https://agentclientprotocol.com/rfds/v2/background-work), now part
of schema v2.0.0-alpha.13. v2 sessions report the work Claude Code keeps
running independently of foreground work as `background` on every
`state_update`.

## What the client gets

The lists are built from Claude Code's live background task set
(`background_tasks_changed`). Tasks Claude Code marks `ambient` are
skipped, since they are housekeeping. The root session and each subagent
session (subagents RFD form) has its own list. Each task is listed once,
in the list of the session that runs it:

| Work | Listed as |
|---|---|
| A Bash command that went to the background | Its display terminal,
from the Bash result until its task ends. While listed, the terminal
advertises `capabilities.cancel`, and `terminal/cancel` stops its task
with `stopTask`. |
| A detached subagent | Its child session, while it works, and while it
is idle with background work of its own. The child reports its own list
on its `state_update` and on the parent's `subagent_update.state`
mirror. |
| Any other task (`Monitor`, `Workflow`, `mcp_task`, …) | A
`_claude/task` item with `taskId` and `description`. Its tool call ends
at its tool result, so it cannot be a `tool_call` item yet. |

The ordering rules the spec requires:
- **List changes:** the state is re-sent with the new list and no stop
reason, so a repeated `idle` is not a new completion.
- **Before a terminal is listed:** a `terminal_update` advertising
`cancel` goes out first.
- **Before a terminal leaves the list:** its `exitStatus` goes out, with
`capabilities: null`. This also holds when the level drops the task
without an end event, or when the query closes (process death, stream
error, sign-out recreation, `session/close`).
- **A child and its parent:** the child's own list goes out before the
parent's list drops it.
- **`session/resume`:** each listed subagent's current `subagent_update`
and each listed terminal's current `terminal_update` come before the
state snapshot.
- **`terminal/cancel`:** it returns `-32602` for a terminal that is not
listed, including one whose command ended while the stop was in flight.

## Gating

Only v2 sessions with `perTaskStopAffordance` report lists. That is the
default for a non-AIR v2 client. Without it, Claude Code's interrupt
kills background tasks, which would break the rule that `session/cancel`
leaves listed work running. v1 and AIR are unchanged.

## Design

- `src/background-work.ts` owns the lists. It builds them from session
state and publishes changes. `acp-agent.ts` only gets wiring calls: one
sync per message-loop iteration, the level update, terminal exits when a
query closes, and the `terminal/cancel` entry point.
- `src/v2/background.ts` adds the lists to v2 state updates and to
subagent state mirrors. The cancel capability lives in `V2Terminals`,
which already owns terminal state.
- `NativeSubagentRuntime.currentUpdate` builds a child's live
`subagent_update` for resume.

## Not in this PR

- **SHOULD gap:** when finished background work starts a follow-up
cycle, the spec wants `running` in the same snapshot that drops the
item. We send `idle []` and then `running`, because the adapter can't
know in advance that Claude Code will start a follow-up. The RFD
acknowledges this.
- **Phase 2:**
- List `Monitor`, `Workflow` and `mcp_task` as `tool_call` items that
stay `in_progress` (or `paused`), with `tool_call/cancel`.
  - Tail background Bash `output_file` into terminal output.
- **Phase 3:** `ScheduleWakeup` / `CronCreate`. The SDK gives no signal
for when one fires or is removed.

## Tests

- New v2 tests cover:
- terminal listing, cancel advertisement, exit ordering, ambient tasks,
and a level drop without an end event
- query death, `terminal/cancel` including the race, and resume for both
terminals and subagents
- a background subagent, an idle subagent kept listed by its own
command, `_claude/task` items, and sessions without
`perTaskStopAffordance`
- I checked by mutation that the guards for each required ordering fail
a test when removed.
- The v2 golden files gain `background: []`, and the subagent ones also
gain child lists.

`npm run check`, `npm run build` and `npm run test:run` pass (3540
passed, 30 skipped).
2026-11-02 14:16:10 +01:00
Ben Brandt
720bbb3e4c fix: update to @agentclientprotocol/sdk 2.4.0 (#1509)
Updates `@agentclientprotocol/sdk` from 2.2.0 to 2.4.0 (schema v1.29.1
and v2.0.0-alpha.13).

## What changed in the protocol

- **v1:** the stable schema and spec pages only changed wording. There
are no requirement changes.
- **v2 `session/cancel`:** the Client no longer answers pending
permission requests with `cancelled`. The Agent MUST stop waiting for
the cancelled work's permission requests and SHOULD cancel each one with
`$/cancel_request`.
- **v2 `session/close`:** the existing MUST to cancel pending
elicitations with `$/cancel_request` before responding now covers
permission requests too.

The adapter already did both: the tool call's signal and the session's
abort controller cancel the request, and a hand-written abort race
stopped the wait. Until now, no test checked that a v2 client actually
receives `$/cancel_request` for a permission request.

## Changes

- Permission requests and elicitations, on both v1 and v2, now use SDK
2.4.0's `stopWaitingOnCancel` option instead of `raceWithAbort`. The SDK
sends `$/cancel_request`, rejects at once with `-32800`, and quietly
drops any late response. Every caller already maps a rejection after an
abort to "Tool use aborted" or to `cancel`, so nothing else changes.
`raceWithAbort` is still used for the eager `tool_call` notification.
- **Tests:**
- **v2:** the existing tests where a `session/cancel` or `session/close`
withdraws a held request now also check that the client received
`$/cancel_request`. For `session/close`, it must arrive before the
response.
- **v1:** new tests over a real SDK connection with a client that holds
requests. These cover the v1 `ClientConnection` wiring, which the
mocked-client tests bypass.
- The v1 mock clients that "ignore cancellation" now model the SDK's
behaviour with a `heldRequest` helper.
- Removed one test that only covered `raceWithAbort` handling a
rejection from the client.
- I checked that the new assertions fail if the `stopWaitingOnCancel`
option is removed or v2 permission requests stop passing their
cancellation signal.

## Follow-ups (not in this PR)

- v2 background work: the `background` list on state updates, the
`paused` status, and `tool_call/cancel` / `terminal/cancel`. The AIR
`asyncTasks` extension stays as it is.
- The new unstable v1 methods (`session/set_title`,
`session/archive`/`unarchive`, `session/status`, `auth/status`).

`npm run check`, `npm run build` and `npm run test:run` pass (3520
passed, 30 skipped).
2026-11-02 11:41:32 +00:00
Ben Brandt
38cf656fc9 feat: report why Claude Code could not start a session (#1508)
When Claude Code refuses to start (invalid proxy URL, wrong organization
sign-in, a required sandbox that can't run, …), `session/new`,
`session/load`, `session/resume` and `session/fork` used to fail with a
generic `Claude Code process exited with code 1. stderr: …` error.

This sets `CLAUDE_CODE_STARTUP_FAILURE_RESULTS=1` on the query env, so
the CLI writes an error result with a `startup_failure_reason` before
exiting on a known startup failure. The SDK then rejects
`initializationResult()` with an error that carries
`startupFailureReason`. The new `src/startup-failure.ts` maps it to an
ACP error:

| `startupFailureReason` | ACP error |
|---|---|
| `gateway_signin_required`, `org_pin_mismatch`, `org_config_refused` |
`authRequired` (signing in again, or with another account, fixes it) |
| `cwd_unavailable` | `invalidParams` (matches the existing cwd
validation) |
| any other reason | `internalError` |

The message is the CLI's own text, which says what to fix (e.g. `Invalid
proxy URL in HTTPS_PROXY: … Fix or unset HTTPS_PROXY and restart Claude
Code.`), and `data.startupFailureReason` carries the reason. Errors
without a reason (older CLIs, unknown causes) pass through unchanged,
and the existing `sessionNotFound` mapping for resumes is untouched.

Checked against the real CLI (SDK 0.3.316): with `HTTPS_PROXY="not a
url"`, `initializationResult()` rejects with `startupFailureReason:
"proxy_invalid"` when the variable is set, and with a plain exit-code
error without it.

## Testing

- v1: `create-session-options.test.ts` covers the env var, each mapping,
and pass-through of errors without a reason.
- v2: `acp-v2.test.ts` covers the `session/new` error over the wire.
- `npm run build`, `npm run check`, `npm run test:run` pass. The
subprocess integration tests also passed locally with this change.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude <noreply@anthropic.com>
2026-11-02 11:23:33 +00:00
Ben Brandt
1d0306468b fix: update to Claude Agent SDK 0.3.316 (Claude Code 2.1.316) (#1507)
Updates dependencies to their latest versions.

- `@anthropic-ai/claude-agent-sdk` 0.3.312 → 0.3.316 (bundles Claude
Code 2.1.316)
- `@types/node` 26.6.5 → 26.6.6, `eslint` 10.13.0 → 10.14.0, `globals`
17.13.0 → 17.14.0
- `npm update` for transitive patch/minor bumps in the lockfile
- Bumps the "as of" version in two comments about SDK type gaps
(`Query.getSettings()` still missing from the declarations; `unanswered`
still only in the AskUserQuestion output schema). Both were re-checked
against 0.3.316.

TypeScript 7.0.2 is left out: `@typescript-eslint` 8.72.1 supports
TypeScript `<6.1.0`.

## Changelog review (SDK 0.3.313–0.3.316, Claude Code 2.1.313–2.1.316)

No required changes. The SDK type changes only add fields and union
members (`sandbox_required_unavailable` startup failure reason,
`blockWritesOutsideWorkingDirectories` / `blockReadsExemptPaths`
settings, `pending_elicitation_requests` on `initialize`, alpha
`prewarm()` in `/core`), none of which the adapter reads or needs. No
new `fast_mode_disabled_reason` values accompany the Opus 4.8 fast mode
deprecation.

Follow-up planned separately: use `CLAUDE_CODE_STARTUP_FAILURE_RESULTS`
/ `startup_failure_reason` to report typed startup failures to clients.

## Testing

`npm run build`, `npm run check`, `npm run test:run` pass.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-11-02 12:18:36 +01:00
Andrey Bragin
c6de68797b docs: add design-fit and test-duplication guidelines to AGENTS.md (#1480)
Adds guidelines to `AGENTS.md` that raise the baseline quality of new
PRs and features. Changes should fit the existing architecture instead
of working around it, respect the session's turn lifecycle, stay
compatible with existing clients, and come with focused, non-duplicated
tests. Authors and their agents can check these points before review.

- **Coding guidelines** replaces the **Code comments** section and
covers only the code itself. The comment rules are now one bullet, which
also requires comments and docs to be true on every code path they
describe. It adds rules for refusing requests with an actionable
`RequestError`, not reporting success for work started without awaiting
it, not letting results depend on cache state, and stating unenforced
ordering assumptions.
- **Architecture and design fit** is a new section. Build on the module
that owns the concept, put new features and components in dedicated
modules instead of `acp-agent.ts`, prefer extending existing mechanisms,
reuse sibling logic so paths that handle the same input (live stream and
`session/load` replay, command list and dispatch, `prompt` and `steer`)
apply the same rules, keep ACP v1 and v2 on one implementation, use
`startTurn`'s busy check and the turn queue for anything that sends
`session/update`, avoid branching on stale local copies of remote state,
don't shadow existing commands, keep backward compatibility, and explain
any unavoidable workaround.
- **Testing guidelines** is a new section. It adds rules against
duplicate or parametrizable tests, asks to reuse the existing test files
and helpers, to build test doubles from what the real Claude Code CLI
sends, to cover both protocol versions when client-visible output
changes, and to have a test for each behavior the docs promise.
2026-11-02 11:40:07 +01:00
Ben Brandt
eb78afa855 feat: report subagent working directories (#1496)
Schema v1.28.0 / v2.0.0-alpha.11 adds `cwd` and `additionalDirectories`
to `subagent_update` (agentclientprotocol/agent-client-protocol#2593,
#2660). When a child runs in a different directory from its parent, for
example an Agent call with `isolation: "worktree"`, the Agent must
report the child's `cwd` before sending child traffic that depends on
it.

**Signal**
- The agent reads the `cwd` of the child's own hooks, matched by
`agent_id`. The existing per-child configuration hook already runs on
SubagentStart, PreToolUse, PostToolUse and the others.
- Claude Code runs SubagentStart before the child's first model request
and waits for the hook, so the `cwd` reaches the client before the
child's first tool call.
- `worktreePath` in the Agent tool result arrives only when the child
finishes, which is too late.

**Behaviour (RFD form only; AIR is unchanged)**
- `cwd` is sent only when the child's directory differs from its
parent's, compared with `realpath`. A hook that runs before the
announcement puts the `cwd` in the announcing `subagent_update`.
Otherwise a separate `subagent_update { cwd }` follows.
- A child that moves back to its parent's directory gets `cwd: null`. A
child whose hook `cwd` changes later, for example after a `cd` that
Claude Code keeps, is reported again, since that is where it now runs.
- `additionalDirectories` is never sent. Claude Code gives subagents the
session's additional directories, so inheriting them is accurate.
- Replay uses the child's `.meta.json` (`cwd`, `worktreePath`,
`inheritedWorktreePath`) and the first `cwd` in its transcript. After
the load response, a child whose directory no longer exists gets `cwd:
null`.
- v2 passes the fields through unchanged.

**Not covered**
- `remote` isolation, which fires no local hooks.
- After a reconnect to a live session, the `cwd` the runtime holds is
not sent again.
2026-10-30 16:05:38 +01:00
Ben Brandt
d2ef490a31 fix: validate session, prompt, config, and auth requests per the latest ACP spec (#1495)
Request validation from the ACP schema v1.28.0 / v2.0.0-alpha.11 review.
Each change has a test.

**Paths**
- `session/list` rejects a relative `cwd` with -32602. Without a session
index it passes `includeWorktrees: false`, so the filter matches only
sessions of exactly that directory, not other git worktrees of the same
repo.
- Resume, load and fork check `cwd` and `additionalDirectories` before
anything else. Before, a resume with a bad `cwd` tore down the live
session and then failed, and a fork copied the transcript first.
- Relative `additionalDirectories` entries are rejected with -32602.

**Session setup (v2)**
- Uses the SDK's `assertSupportedMcpServers`, which also rejects
duplicate MCP server names with -32602.
- Rejects an empty `prompt` with -32602.
- The v2 initialize response no longer advertises the AIR `asyncTasks`
capability or the `goal` entry, since the v2 app has no handlers for
`_session/async_task/stop` or `_session/goal`. v1 AIR is unchanged.

**Errors**
- `session/fork` of an unknown session returns -32002 with
`data.sessionId` instead of -32603. This matches the Claude SDK's
"Session … not found" and "Invalid sessionId" messages.
- `authenticate` with an unknown `methodId` returns -32602 instead of
-32603.
- `set_config_option` rejects a value whose type doesn't match the
advertised option type. A `type: "boolean"` option takes `true`/`false`,
and a select option takes a string. Model aliases such as `"opus"` are
still accepted.
- An AIR `session/delete` of a session that has no transcript now
succeeds instead of failing with a generic error.

**Prompts and commands**
- v1 `session/prompt` rejects `audio` and unknown content blocks with
-32602 before queueing them. Before, they were silently dropped.
- Advertised command names that contain whitespace are dropped, and of
duplicate names only the first is kept.
- The `/mcp:server:prompt` rewrite now accepts arguments that span
lines, and applies only to the first text block.
2026-10-30 16:03:58 +01:00
Ben Brandt
f50598a39c fix: align session/cancel, session/close, and v2 setup with the ACP v2 spec (#1494)
**`session/cancel` cancels only earlier prompts**
(agentclientprotocol/agent-client-protocol#2685)
- `cancelTurns` used to read the turn queue again after two awaits: the
compaction interrupt and publishing the subagent state. A prompt that
arrived during those awaits was cancelled too, which is -32800 on v2.
- Each prompt now gets a sequence number when `startTurn` receives it.
The cancel records the count before its first await and cancels only
turns at or below it. That includes a prompt received earlier that
`startTurn` had not queued yet.
- Known gap: if the cancelled turn ends during those awaits and Claude
Code starts the newer prompt, the `query.interrupt()` that follows can
still stop that prompt. Fixing it means skipping the interrupt when
nothing the cancel covers is still running, which also affects
cancelling background work, so it is left for later.

**`session/close` cancels pending requests first**
(agentclientprotocol/agent-client-protocol#2683)
- MCP elicitations, AskUserQuestion, the refusal-fallback dialog and
permission requests now combine their callback signal with the session's
`abortController.signal`.
- `teardownSession` therefore sends `$/cancel_request` for each of them
before `session/close` responds, instead of relying on Claude Code to
abort its callback signals in time.

**v2 setup responses** (agentclientprotocol/agent-client-protocol#2688,
#2603)
- If reading the commands for the setup response fails, the agent now
sends them in an `available_commands_update` after the response. Before,
the client was never sent them.
- On v2, MCP OAuth starts after the `session/new`, resume or fork
response is sent, so its URL elicitation no longer reaches the client
before the response.

Not done: withdrawing a held prompt with `$/cancel_request`. The user
message is already in Claude Code's input once it is queued, and
removing it safely needs `cancel_async_message`, which not every CLI
version has. agentclientprotocol/agent-client-protocol#2636 allows
ignoring the cancel and sending the normal response.
2026-10-30 16:03:03 +01:00
Ben Brandt
3bc671193c fix: forward v2 elicitation values and read dismissed v2 permission prompts as a refusal (#1493)
**Elicitation content** (agentclientprotocol/agent-client-protocol#2595)
- v2 lets an elicitation answer hold any JSON value, such as an object.
The agent checked accepted content with the v1 guard, which allows only
strings, numbers, booleans and string arrays. A v2 accept with an object
therefore reached the MCP server as `{ action: "accept", content: {} }`.
- The agent now forwards the accepted content unchanged, and the MCP
server checks it against its own schema. If Claude Code's MCP client
still uses the older value types, it rejects such an answer with an
error instead of passing on empty content.
- AskUserQuestion reads only string, number and boolean picks, so it no
longer shows an object as `[object Object]`.
- The MCP OAuth url-mode check now tests `action !== "accept"` instead
of the v1 guard.

**Permission `cancelled` on v2**
(agentclientprotocol/agent-client-protocol#2582, #2658)
- In v2, `cancelled` also means the user dismissed the prompt or the
client declined it by policy.
- When no `session/cancel` is in progress, a foreground v2 `cancelled`
is now denied as "User refused permission to run tool", the same denial
background subagents already get, and the turn goes on. Before, Claude
Code reported it as "Tool permission request failed".
- During a `session/cancel` it is still aborted. v1 is unchanged.
- Unknown v2 outcomes map to `cancelled`, so they are also denied now
instead of aborted.
2026-10-30 14:44:34 +00:00
Ben Brandt
c3428de1bf fix: keep native Windows paths in v2 git patches (#1492)
ACP v2 now says each `git_patch` path, once unquoted, is the same native
absolute path string as the matching `changes` entry
(agentclientprotocol/agent-client-protocol#2650).

The v2 form converted `C:\work\App.ts` to `C:/work/App.ts`, so on
Windows the patch and `changes[].path` named different strings. The v2
form now keeps the native path, which Git quoting writes as
`"C:\\work\\App.ts"`. Git's own form, which the AIR extension uses,
still uses forward slashes and the `a/`/`b/` prefixes.
2026-10-30 14:39:21 +00:00
Ben Brandt
1d2b92feae fix: update to @agentclientprotocol/sdk 2.2.0 (#1491)
Moves to ACP schema v1.28.0 and v2.0.0-alpha.11.

- v2 dropped JSON-RPC batching, so `AnyWireMessage` is gone. The test
harnesses use `AnyMessage`, and a request id can now be `null` in the
types.
- The v2 elicitation schema now requires `properties`. A forwarded MCP
schema without them, such as `{ "type": "object" }`, gets an empty map,
so clients on SDK 2.2.0 don't reject it.

Runtime changes that come with the SDK and need no agent code:
- The SDK handles a repeated `initialize`, rejects batches, and rejects
messages sent before initialization. A failed `initialize` now leaves
the connection open.
- v2 `usage_update.cost` is now a patch field, where omitted means
unchanged. The agent sends `cost` only on the result update, so the
client keeps showing the last cost during a turn.

Follow-up PRs for the spec changes in this range are stacked on this
branch.
2026-10-30 14:39:09 +00:00
xiaofeilovecoding
99a6e76158 fix: reject unreadable skill manifests (#1482)
Fixes #1478.

Skip SKILL.md candidates when opening or reading the file fails, while
preserving readable unnamed manifests. Do not cache failed reads so
later resolutions can recover.

Adds nine resolver regressions for open/read errors, descriptor cleanup,
recovery, and valid alternatives.

Validation (Windows, Node 24.18.0):
- Nine new tests, TypeScript build, and ESLint pass.
- Full suite: 3,354 passed, 57 failed, 52 skipped. The same 57 failures
reproduce on unmodified 52c241e in the affected files.
- npm run check fails only on unchanged CLAUDE.md: Windows checked out
the symlink as a plain target file. The baseline has the same issue.

Co-authored-by: xiaofeilovecoding <fxy850328059@gmail.com>
2026-10-30 13:57:42 +01:00
Slapoguzov Alexander
e01a288384 fix: preserve live session settings across provider refreshes (#1474)
## Problem

When the provider changes on a running agent (`providers/set`,
`providers/disable`, or logout with a provider override), every open
session gets a new Claude Code process. For example, this happens after
a BYOK key edit or a proxy recovery. The new process started from the
original `session/new` params only. The session lost the model, effort,
mode, Fast mode, ultracode and agent the user had picked, while the
client still showed the old values (IJAI-4592).

## Fix

- After the new process starts, the session's previous choices are
applied again through the same code paths as client requests. The client
then gets a `config_option_update` with the actual configuration.
- If the new provider doesn't support a choice (for example, the new key
has no access to that model), the choice is logged and skipped. The
client sees the real value.
- Effort: only the user's current pin comes back. An effort the user
reset to Default, or that a model switch dropped, stays off.
- Mode, config changes and restarts run one at a time per session. A
choice sent during a restart lands after the restored ones. Other
sessions are not blocked.
- The respawn after a sign-out goes through the same path, so it keeps
the choices too.
- When a provider change overlaps that respawn, it replaces the current
recovered session and holds waiting prompts until the provider update
finishes. Those prompts then run once on the latest route with the
restored choices.
- Closing a session while it restarts no longer leaves a running process
behind.

## Tests

- `provider-session-configuration.test.ts`: the overlapping
sign-out/provider change and two waiting prompts held until the final
replacement initializes.

- `provider-session-configuration.test.ts`: restore order, effort edge
cases, races, and close or dispose during a restart.
- `session-hooks.test.ts`: real Claude Code CLI against a local fake
API. After a refresh, the next request goes out with the chosen model
and `effort: high`.

## Validation

[CI on the published
follow-up](https://github.com/agentclientprotocol/claude-agent-acp/actions/runs/40989963422)
passes formatting, lint, build and tests: 3,474 passed, 32 skipped,
including 37 provider, 107 authorization and five real CLI-hook tests
against a local fake API. Independent review passed; 151 scoped tests
also covered seven additional recovery/failure schedules.

<details><summary>Local baseline comparison and scope</summary>

The added overlap regression failed on the preceding draft and passes
with this repair. Complete local runs used identical locked dependencies
and two workers: baseline 3,431 passed / 19 failed / 30 skipped;
candidate 3,433 passed / the same 19 failed / 30 skipped. All local
failures are existing session-index cases; filesystem watching reported
EMFILE, with two SDK path-list assertions also failing. The complete
Linux CI run above passes.

This follow-up is a normal appended commit preserving the existing
implementation. The PR remains a draft. Released-package Air acceptance
follows merge and release.

</details>

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Aleksandr Slapoguzov <aleksandr.slapoguzov@jetbrains.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 13:57:13 +01:00
Ben Brandt
2fd46c866d fix: report the fresh context's usage when ExitPlanMode clears context (#1484)
Choosing one of `ExitPlanMode`'s "clear context" options restarts
Claude's conversation with only the plan. The ACP session ID and the
turn stay the same, and the adapter resets its context count to 0, but
it didn't tell the client. Clients kept showing the old context's usage
until the fresh context first reported its own, so nothing on the client
side showed that the context had been cleared.

`publishSessionState` now sends a `usage_update` for the empty fresh
context (`used: 0`, the fresh session's context window as `size`) right
after the `current_mode_update` and `config_option_update` it already
sends. `used: 0` slightly understates the fresh context, which already
holds the system prompt and the plan, until Claude's next usage report.

Testing:
- `npm run build`, `npm run test:run`, and `npm run check` pass.
- `clear-context-coordinator.test.ts` checks the update's order and
values.
2026-10-29 22:09:59 +01:00
acp-release-bot[bot]
0f78dbbdd0 chore(main): release 0.99.1 (#1472)
🤖 I have created a release *beep* *boop*
---


##
[0.99.1](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.99.0...v0.99.1)
(2026-10-29)


### Bug Fixes

* add approval id to plan
([#1469](https://github.com/agentclientprotocol/claude-agent-acp/issues/1469))
([4b4b2c4](4b4b2c4de7))
* allow forking sessions into a different directory
([#1483](https://github.com/agentclientprotocol/claude-agent-acp/issues/1483))
([8bd0bc9](8bd0bc9b03))
* detect "Run /login" login-expired messages as authRequired
([#1481](https://github.com/agentclientprotocol/claude-agent-acp/issues/1481))
([fccaebc](fccaebcf87))
* mark plugin, bundled and nested Claude skills as skills for AIR
([#1473](https://github.com/agentclientprotocol/claude-agent-acp/issues/1473))
([52c241e](52c241e715))
* title subagent sessions by their task
([#1479](https://github.com/agentclientprotocol/claude-agent-acp/issues/1479))
([7dcb629](7dcb62923d))

---
This PR was generated with [Release
Please](https://github.com/googleapis/release-please). See
[documentation](https://github.com/googleapis/release-please#release-please).

Co-authored-by: acp-release-bot[bot] <246668977+acp-release-bot[bot]@users.noreply.github.com>
2026-10-29 20:34:22 +01:00
Ben Brandt
8bd0bc9b03 fix: allow forking sessions into a different directory (#1483)
Fix `session/fork` when the requested working directory differs from the
directory containing the source session's transcript, such as a
subthread in another worktree.

The Claude Agent SDK's `dir` option restricts **source transcript
lookup**; it does not select the fork's runtime directory. Passing ACP's
destination `cwd` therefore prevents the SDK from finding a source
session stored elsewhere.

- Omit `dir` from both SDK fork calls, active-history lookup, and
full-history import so every fork-resolution path searches all projects.
- Continue starting the forked query with the requested `cwd`, MCP
servers, and other session settings.
- Add 12 real-SDK tests using isolated temporary transcripts, covering
same- and cross-directory forks through latest history, active messages,
inactive branches, fingerprints, and live message maps, plus
missing-message errors. Check copied history, fresh UUIDs, and an
unchanged source transcript.
- Extend ACP v2 coverage to verify same- and cross-directory forks
remain immediately promptable and use the requested runtime directory.

## Validation

- Before the fix, all six cross-directory regression cases failed while
the six same-directory cases passed; after the fix, all 12 pass.
- `npm run check` — passed.
- `npm run build` — passed.
- `npm run test:run -- --reporter=dot --silent` — passed: 60 files,
3,437 tests; 4 files and 30 tests skipped.
- `git diff --check` — passed.

Final validation ran on macOS with Node 24. Earlier Windows validation
passed all fork-related tests but encountered unrelated path-separator
assertions and checkout line-ending formatting failures; those are
outside this change.

No authenticated Claude prompt or end-to-end Delta subthread test was
run. The new transcript tests use the real SDK without starting Claude
Code; ACP query execution is mocked in the protocol test.
2026-10-29 19:20:46 +00:00
Mark Tkachenko
52c241e715 fix: mark plugin, bundled and nested Claude skills as skills for AIR (#1473)
AIR lists a command as a skill only when the adapter finds its
`SKILL.md`, and the resolver only probed `<cwd|~>/.claude/skills`.
Plugin skills, CLI-bundled skills and nested `.claude/skills` therefore
reached AIR as plain commands.

The adapter now takes the paths from Claude Code's own skill list,
`getContextUsage().skills.skillFrontmatter`:
- a skill with a `path` gets `kind: "skill"` and `skillPath`; one
without (bundled `simplify`, remote connections) gets `kind` only;
- a skill not in the list falls back to the existing `SKILL.md` lookup;
builtin commands like `compact` stay unmarked;
- the read runs in the background (the first call takes about 1 s) on
the first `system/init` and on every `commands_changed`, and the command
list is re-sent when the result changes.
2026-10-29 14:42:08 +01:00
Ben Brandt
fccaebcf87 fix: detect "Run /login" login-expired messages as authRequired (#1481)
The CLI now reports an expired login (no refresh token) as:

Login expired · Run /login to sign in again, or re-authenticate your
Anthropic profile

and tags the synthetic assistant message `error: "invalid_request"`, not
`authentication_failed`. The adapter only recognised the exact phrase
"Please run /login", so this message streamed to the client as agent
text and the turn failed with a generic internal error instead of
`RequestError.authRequired()`. Clients never learned auth was required,
so they couldn't offer their terminal auth methods. "Not logged in · Run
/login" had the same gap.

Changes:
- Add `isLoginRequiredText()`, which matches "run /login"
case-insensitively, and use it in `isSyntheticLoginMessage()` (live loop
and replay) and the two result-text checks.
- Exclude synthetic usage-limit banners from the login match, since some
mention `/login` to switch accounts and have their own handling.

Tests cover both new wordings, the usage-limit exclusion, and an
end-to-end turn where the `invalid_request`-tagged "Login expired"
message must reject with `authRequired` and not leak "/login" text to
the client.
2026-10-29 13:17:53 +00:00
Ben Brandt
7dcb62923d fix: title subagent sessions by their task (#1479)
A subagent's `subagent_update.title` was its type (`general-purpose`),
or
`name (type)`, so a client showing the title couldn't tell siblings
apart;
the task was only in `description`. The RFD makes the title the parent's
display label and the description the child's role or purpose.

The title is now the agent's name when it has one, else the task's short
description, else its type. The description is the type, unless the type
is already the title.
2026-10-29 11:16:05 +00:00
Kirill Alekhnovich
4b4b2c4de7 fix: add approval id to plan (#1469)
Include the approval tool call ID in each Air plan ID so the client can
bind the plan to its review. Keep the legacy plan ID for other clients.
2026-10-28 16:34:01 +01:00
acp-release-bot[bot]
106e3031e3 chore(main): release 0.99.0 (#1456)
🤖 I have created a release *beep* *boop*
---


##
[0.99.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.98.0...v0.99.0)
(2026-10-28)


### Features

* Add thinking display configuration
([#1081](https://github.com/agentclientprotocol/claude-agent-acp/issues/1081))
([2f1f4d1](2f1f4d1441))
* send embedded blob resources in prompts to Claude
([#1460](https://github.com/agentclientprotocol/claude-agent-acp/issues/1460))
([d0ee308](d0ee30813f))
* tell the client when an MCP server fails to start
([#1464](https://github.com/agentclientprotocol/claude-agent-acp/issues/1464))
([54b35ee](54b35ee0f4))
* update to Claude Agent SDK 0.3.312
([#1471](https://github.com/agentclientprotocol/claude-agent-acp/issues/1471))
([4cb8906](4cb8906d32))
* **v2:** send the current session info on session/resume
([#1462](https://github.com/agentclientprotocol/claude-agent-acp/issues/1462))
([f6ed4a4](f6ed4a4f7d))


### Bug Fixes

* deny a background permission request that the client cancels
([#1466](https://github.com/agentclientprotocol/claude-agent-acp/issues/1466))
([f646c82](f646c821f1))
* don't report decline when the client can't render an MCP elicitation
([#1465](https://github.com/agentclientprotocol/claude-agent-acp/issues/1465))
([54b869c](54b869c990))
* list the current model among the model config option's values
([#1463](https://github.com/agentclientprotocol/claude-agent-acp/issues/1463))
([8e6fe92](8e6fe92a98))
* report EnterPlanMode tool calls with the switch_mode kind
([#1467](https://github.com/agentclientprotocol/claude-agent-acp/issues/1467))
([93c1f43](93c1f43519))
* return ACP error codes for unknown sessions and invalid config values
([#1458](https://github.com/agentclientprotocol/claude-agent-acp/issues/1458))
([c2e85cb](c2e85cb580))
* update ACP SDK to 2.1.0 (schema v1.27.0 / v2.0.0-alpha.10)
([9f9aebf](9f9aebf44b))
* **v2:** keep clients in sync with config changes and replayed tool
calls
([#1461](https://github.com/agentclientprotocol/claude-agent-acp/issues/1461))
([2a0c188](2a0c188a0a))
* **v2:** reject MCP transports and prompt content the agent doesn't
advertise
([#1459](https://github.com/agentclientprotocol/claude-agent-acp/issues/1459))
([2480888](2480888b90))

---
This PR was generated with [Release
Please](https://github.com/googleapis/release-please). See
[documentation](https://github.com/googleapis/release-please#release-please).

Co-authored-by: acp-release-bot[bot] <246668977+acp-release-bot[bot]@users.noreply.github.com>
2026-10-28 15:24:45 +01:00
Ben Brandt
4cb8906d32 feat: update to Claude Agent SDK 0.3.312 (#1471)
Updates `@anthropic-ai/claude-agent-sdk` from 0.3.310 to 0.3.312, which
bundles Claude Code 2.1.312.

## Changes

- `@anthropic-ai/claude-agent-sdk` 0.3.310 → 0.3.312
- `@typescript-eslint/eslint-plugin` and `@typescript-eslint/parser`
8.72.0 → 8.72.1
- `npm update` for transitive packages (`hono`, `ignore`, `ip-address`,
`magic-string`, `postcss`, `qs`)
- TypeScript stays on 6.0.3. typescript-eslint 8.72.1 only supports
TypeScript below 6.1, so 7.0.2 has to wait.

No code changes were needed for the upgrade itself.

## Report why an AskUserQuestion question went unanswered

SDK 0.3.312 lets an AskUserQuestion answer set
`annotations[question].unanswered` to `skipped`, `replied_in_chat` or
`dismissed`. A session can receive this only if its `system/init` lists
the `ask_user_question_unanswered_v1` capability, because an older CLI
refuses the whole answer when the field is present.

- The session records the capability from `system/init`, the same way it
already records `msg_lifecycle_v1`.
- `applyAskElicitationResponse` takes a `reportUnanswered` option. If
the user declines the form, every question is marked `dismissed`. If the
user accepts it with a question left blank, that question is marked
`skipped`.
- The model does not see this field. It goes into the tool result for
clients and transcripts.
- `annotations` is now typed with `AskUserQuestionOutput`, since as of
0.3.312 only the output schema lists `unanswered`.

`replied_in_chat` is not used because ACP has no way to report that the
user replied in chat instead.

## Testing

`npm run build`, `npm run check` and `npm run test:run` pass. Two new
tests in `src/tests/elicitation.test.ts` cover a declined form and an
accepted form with a blank question.
2026-10-28 15:20:03 +01:00
Ben Brandt
d0ee30813f feat: send embedded blob resources in prompts to Claude (#1460)
Stacked on #1457 (SDK 2.1.0).

`promptToClaude` used to drop embedded `resource` blocks whose content
is a base64 `blob`, even though the agent advertises `embeddedContext`.
They are now sent to Claude:

- **Images** (PNG, JPEG, including `image/jpg`, GIF, WebP) become
`image` blocks, right after the `[@name](uri)` link.
- **PDFs** become `document` blocks.
- **Text types** (`text/*`, JSON, XML, YAML, TOML, JS/TS, shell, SQL,
GraphQL, `+json`/`+xml`) are decoded with their `charset` parameter
(UTF-8 by default, BOM dropped) and sent like text resources.
- **Other types:** v2 rejects them with `-32602`. v1 still skips them,
as before.

Known gap: a replayed user PDF does not appear in the replayed history,
because `document` blocks have no ACP mapping on replay.
2026-10-28 12:45:07 +00:00
Ben Brandt
54b35ee0f4 feat: tell the client when an MCP server fails to start (#1464)
Stacked on #1457 (SDK 2.1.0).

Agents SHOULD let the user know when a client-provided MCP server fails
(agentclientprotocol/agent-client-protocol#2512). Before this PR,
failures only showed up in `/mcp`.

- **On `system`/init:**
  - A client-provided server with status `failed` gets a warning notice.
- A `needs-auth` server gets an info notice when the OAuth flow can't
run: the client has no URL elicitation, or the query has no
`mcpAuthenticate`.
- **Once per server per session.** This holds across repeated inits and
across the session rebuilt after a sign-out.
- **Servers from the user's settings are left to `/mcp`.** Otherwise a
broken one would be reported in every session.
- **Rendering:** v1 clients without the notices capability get a
transcript line instead.
2026-10-28 13:43:34 +01:00
Ben Brandt
93c1f43519 fix: report EnterPlanMode tool calls with the switch_mode kind (#1467)
Stacked on #1457 (SDK 2.1.0).

EnterPlanMode tool calls now use `kind: "switch_mode"`, like
ExitPlanMode (agentclientprotocol/agent-client-protocol#2543). The title
is unchanged.
2026-10-28 13:40:19 +01:00
Ben Brandt
f646c821f1 fix: deny a background permission request that the client cancels (#1466)
Stacked on #1457 (SDK 2.1.0).

Per agentclientprotocol/agent-client-protocol#2508, `session/cancel`
cancels foreground work, and the client answers every pending permission
request with `cancelled`, including background ones.

- **Before:** for a background subagent, which keeps running after the
interrupt, `canUseTool` threw "Tool use aborted". The CLI turned that
into "Tool permission request failed", which the model can take for a
transient failure and retry.
- **Now:** a `cancelled` outcome for background work (decided by
`blocksForeground`, including subagents nested under a backgrounded one)
returns a `deny` with `decisionClassification: "user_reject"`.
- **Foreground** work still throws, and its turn ends through
`query.interrupt()`.

Open question: AskUserQuestion's `cancel` still throws for background
work.
2026-10-28 13:39:46 +01:00
Ben Brandt
54b869c990 fix: don't report decline when the client can't render an MCP elicitation (#1465)
Stacked on #1457 (SDK 2.1.0).

When an MCP server's elicitation is forwarded and the client answers
with an error (for example `-32602` for a form it can't render,
agentclientprotocol/agent-client-protocol#2556), the agent used to tell
the MCP server `decline`. That reported a choice the user never made.

- It now returns `cancel`.
- The same applies to the two early returns where the user never saw
anything: a mode the client didn't advertise, and a request that can't
be converted to ACP. These apply to v1 clients too.
- `decline` now only comes from an actual decline.
2026-10-28 13:39:04 +01:00
Ben Brandt
8e6fe92a98 fix: list the current model among the model config option's values (#1463)
Stacked on #1457 (SDK 2.1.0).

A select option's `currentValue` MUST be one of its `options`
(agentclientprotocol/agent-client-protocol#2557). A session on a model
outside the picker (resumed onto a model the allowlist excludes, or a
refusal fallback) used to report a `currentValue` with no matching
entry.

- The live model is now added as an option, named from its model info,
or by its raw id when there is none.
- The entry is not added to `availableModels`, so it disappears after
you switch to a listed model.
- The special case in `setSessionConfigOption` that accepted an unlisted
`currentValue` is removed.
- Allowlisted models still win alias resolution, so no model outside the
allowlist becomes selectable.
- Effort, Agent, Fast mode and ultracode already kept the invariant.
2026-10-28 13:37:54 +01:00
Ben Brandt
f6ed4a4f7d feat(v2): send the current session info on session/resume (#1462)
Stacked on #1457 (SDK 2.1.0).

On v2 `session/resume`, the agent now sends `session_info_update` with
the current title and `updatedAt`, after any replay and before the
response (agentclientprotocol/agent-client-protocol#2492).

- The agent already sent this update when the title changed. Only the
snapshot on resume was missing.
- The title comes from the same source as `session/list`. If a title has
been published but not yet stored, for example a generated title still
being written, that title is sent instead of the raw first prompt.
- A read failure is logged and never fails the resume.
- v1 is unchanged. The shared helper `publishCurrentSessionInfo` would
let v1 adopt it later.
2026-10-28 13:37:04 +01:00
Ben Brandt
2a0c188a0a fix(v2): keep clients in sync with config changes and replayed tool calls (#1461)
Stacked on #1457 (SDK 2.1.0).

- **`config_option_update` on `session/set_config_option`:** v2 now
sends the update before the response, as `session-config-options.mdx`
says Agents SHOULD. This is v2 only, because v1 tests intentionally
assert that no duplicate is sent.
- **Replayed tool calls reset their fields:** a v1 `tool_call` for an id
the client may already hold (during `session/resume` replay, or an id
already reported on this connection) now sends `null` for the fields it
leaves out: `name`, `title`, `kind`, `status`, `content`, `locations`,
`rawInput`, `rawOutput`. In v2 an omitted field keeps its old value and
`null` resets it (agentclientprotocol/agent-client-protocol#2544).
- This also fixes an earlier bug: the replay's `_meta: null` reset never
reached the wire, because `v2ToolCallPatch` dropped every `null`.
  - The only golden that changes is the replay one.
2026-10-28 13:36:21 +01:00
Ben Brandt
2480888b90 fix(v2): reject MCP transports and prompt content the agent doesn't advertise (#1459)
Stacked on #1457 (SDK 2.1.0).

The v2 spec says Agents MUST reject with `-32602` a transport or content
type they did not advertise. Before this PR, both of these were accepted
and then silently dropped:

- **`acp` MCP servers:** the agent advertises only `stdio` and `http`.
The v1 setup skipped these servers.
- **`audio` blocks:** not advertised, and `promptToClaude` ignored them.
This also applies to a v2 `systemPrompt`, since it is converted the same
way.

Embedded blob `resource` blocks are still accepted; see the blob
resources PR.
2026-10-28 13:35:01 +01:00
Ben Brandt
c2e85cb580 fix: return ACP error codes for unknown sessions and invalid config values (#1458)
Stacked on #1457 (SDK 2.1.0).

The spec now defines these error codes. They are a MUST in v2 and a
SHOULD in v1 (agentclientprotocol/agent-client-protocol#2504,
agentclientprotocol/agent-client-protocol#2497). Before this PR, plain
`Error`s reached clients as `-32603`.

- **`RequestError.sessionNotFound` (`-32002`, `data.sessionId`)** for an
unknown session in prompt, steer, sign-out respawn, `set_config_option`
and `set_mode`. Resume/load of a missing session switches from
`resourceNotFound` (same code, `data.uri`) to `sessionNotFound`; the
fallback that starts a fresh query checks the same code, so it behaves
as before.
- **`RequestError.invalidParams` (`-32602`)** for an unknown config
option, an invalid value (including fast mode and ultracode) and an
unknown or unavailable mode. A failure inside the CLI's
`setPermissionMode` stays `-32603`, since it is an internal error.
- **`session/close` of an unknown session still succeeds.**
agentclientprotocol/agent-client-protocol#2575 makes close idempotent,
and a new test pins this.

This also changes v1 responses, which now carry the codes the v1 schema
defines instead of `-32603`.
2026-10-28 13:34:31 +01:00
Ben Brandt
9f9aebf44b fix: update ACP SDK to 2.1.0 (schema v1.27.0 / v2.0.0-alpha.10)
v2 available commands now carry the required `type: "command"` tag;
SDK 2.1.0 receivers drop commands without it. Read tool locations clamp
`offset: 0` to line 1 since v2 lines are 1-based, and the session_message
casts are gone now that the SDK types senderMessageId/recipientMessageId.
2026-10-28 13:28:03 +01:00
Levin Rickert
2f1f4d1441 feat: Add thinking display configuration (#1081)
Opus 5 and Sonnet 5 are hiding thinking display by default. This PR adds
an env variable to allow users to override this default.

Fixes https://github.com/zed-industries/zed/issues/56375
2026-10-28 07:42:44 +00:00
acp-release-bot[bot]
c90f856c11 chore(main): release 0.98.0 (#1442) 2026-10-27 19:48:23 +01:00
dependabot[bot]
059d76e14a ci(deps): Bump @modelcontextprotocol/sdk from 1.32.1 to 1.32.2 in the minor group (#1449)
Bumps the minor group with 1 update:
[@modelcontextprotocol/sdk](https://github.com/modelcontextprotocol/typescript-sdk).

Updates `@modelcontextprotocol/sdk` from 1.32.1 to 1.32.2
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/modelcontextprotocol/typescript-sdk/releases">@​modelcontextprotocol/sdk's
releases</a>.</em></p>
<blockquote>
<h2>1.32.2</h2>
<h2>What's Changed</h2>
<ul>
<li>fix: add explicit | undefined to Transport optional properties (v1.x
backport of <a
href="https://redirect.github.com/modelcontextprotocol/typescript-sdk/issues/1766">#1766</a>)
by <a
href="https://github.com/kishormorol"><code>@​kishormorol</code></a> in
<a
href="https://redirect.github.com/modelcontextprotocol/typescript-sdk/pull/2814">modelcontextprotocol/typescript-sdk#2814</a></li>
<li>[v1.x] fix: allow registering after connect() when the capability
was declared by <a
href="https://github.com/claude"><code>@​claude</code></a>[bot] in <a
href="https://redirect.github.com/modelcontextprotocol/typescript-sdk/pull/2958">modelcontextprotocol/typescript-sdk#2958</a></li>
<li>chore: bump version to 1.32.2 by <a
href="https://github.com/claude"><code>@​claude</code></a>[bot] in <a
href="https://redirect.github.com/modelcontextprotocol/typescript-sdk/pull/3089">modelcontextprotocol/typescript-sdk#3089</a></li>
</ul>
<h2>New Contributors</h2>
<ul>
<li><a
href="https://github.com/kishormorol"><code>@​kishormorol</code></a>
made their first contribution in <a
href="https://redirect.github.com/modelcontextprotocol/typescript-sdk/pull/2814">modelcontextprotocol/typescript-sdk#2814</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/modelcontextprotocol/typescript-sdk/compare/1.32.1...1.32.2">https://github.com/modelcontextprotocol/typescript-sdk/compare/1.32.1...1.32.2</a></p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="3b73021a0a"><code>3b73021</code></a>
chore: bump version to 1.32.2 (<a
href="https://redirect.github.com/modelcontextprotocol/typescript-sdk/issues/3089">#3089</a>)</li>
<li><a
href="fa56d815ac"><code>fa56d81</code></a>
[v1.x] fix: allow registering after connect() when the capability was
declare...</li>
<li><a
href="6919e9526a"><code>6919e95</code></a>
fix: add explicit | undefined to Transport optional properties (v1.x
backport...</li>
<li>See full diff in <a
href="https://github.com/modelcontextprotocol/typescript-sdk/compare/1.32.1...1.32.2">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=@modelcontextprotocol/sdk&package-manager=npm_and_yarn&previous-version=1.32.1&new-version=1.32.2)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore <dependency name> major version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's major version (unless you unignore this specific
dependency's major version or upgrade to it yourself)
- `@dependabot ignore <dependency name> minor version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's minor version (unless you unignore this specific
dependency's minor version or upgrade to it yourself)
- `@dependabot ignore <dependency name>` will close this group update PR
and stop Dependabot creating any more for the specific dependency
(unless you unignore this specific dependency or upgrade to it yourself)
- `@dependabot unignore <dependency name>` will remove all of the ignore
conditions of the specified dependency
- `@dependabot unignore <dependency name> <ignore condition>` will
remove the ignore condition of the specified dependency and ignore
conditions


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-27 17:05:30 +01:00
Mark Tkachenko
4324dce4be fix: publish Monitor async tasks as monitors (#1452)
Since #1249 the adapter hides every async task started by the `Monitor`
tool, so Air cannot link the task to the Monitor row and counts the
session as having no background work.

Monitor tasks (and SDK `local_monitor` tasks) are published again, but
as monitors instead of shells: `taskType: "monitor"`, `showInTranscript:
false`, `canStop: false`. That keeps the two reasons from #1249: no
empty shell card, and no Stop that only breaks the agent's wait.
Background Bash, `mcp` and subagent tasks are unchanged.
2026-10-27 16:40:16 +01:00
Ben Brandt
c7c80f7b26 fix(v2): leave the tool call status out of permission subjects (#1447)
## Summary

A v2 `session/request_permission` sends its subject as the tool call
that `renderer.permissionToolCall` builds. For a client that isn't AIR,
that is the upstream shape, which includes `status: "pending"`.

The call is already emitted before the request
(`ensureToolCallEmitted`), and its status belongs to the call's own
updates. The v2 spec describes the subject as "a `toolCall` update
containing details about the operation". A client that applies it as an
update, as that shape invites, would see a call that already reported
progress go back to `pending`. `permissionToolCall` now leaves `status`
out for v2 clients; v1 clients keep the upstream shape.

## Changes

- `src/tool-calls/renderer.ts`: no `status` in a v2 client's permission
tool call.
- v2 golden files: each permission request's subject loses
`"status":"pending"`. Nothing else changes.

## Testing

- `npx vitest run`: 3379 passed, 30 skipped.
- `npm run check`: clean.

Found while adding participant approval of permission requests to Delta,
which applies a tool call subject as an update to its call.
2026-10-27 13:07:48 +00:00
Ben Brandt
8f43ac8089 fix: settle the tool call of a SandboxNetworkAccess permission request (#1448)
## Summary

`SandboxNetworkAccess` is a permission-only callback. The SDK asks under
a synthetic tool use id and never sends a `tool_result` for it.
`ensureToolCallEmitted` emits a `tool_call` for that id before asking,
so clients have the call the request refers to (#851). But nothing ever
ended that call, so clients showed a tool call stuck at `pending`
(related: #1061).

Once the request is answered, the adapter now sends a `tool_call_update`
with the call's final status: `completed` when the network access was
allowed, `failed` otherwise. Delivery follows the same routing as the
call's other updates: the native subagent deliverer when there is one,
otherwise the call's session.

A request withdrawn by a cancel, rather than answered, still leaves the
call unsettled, as before.

## Changes

- `src/acp-agent.ts`: `settleSandboxNetworkCall`, called after the
permission response is decoded.
- `src/tests/acp-scenarios/compare.ts`: documents the new update as an
allowed difference from origin/main for plain and Zed clients.
- `network-permission` golden files (v2 and AIR) gain the one settling
update.

## Testing

- `npx vitest run`: 3379 passed, 30 skipped.
- `npm run check`: clean.

Independent of #1447. The two touch the same v2 golden file, so
whichever lands second needs a snapshot refresh.
2026-10-27 13:55:40 +01:00
Slapoguzov Alexander
0ce42f2c31 feat: add batch session metadata lookup (#1408)
A session browser may load only the 50 most recent conversations, while
pinned sessions and saved folders refer to much older ones. After a
restart, the client still has those IDs but needs their current titles,
locations and archive state to rebuild the saved views. Finding a
handful of retained sessions by paging through the entire history
becomes increasingly expensive as that history grows.

Add `_session/batch_get` to retrieve metadata for known session IDs in
one request; the client chooses how many IDs to send per request.
Successful results use the same row format as `session/list`, including
archived sessions and sessions in worktrees, so the client can merge
them with its recent page and apply its usual display filters.

The response contains the sessions whose metadata is available, in
request order. Other IDs are simply omitted. The client can refresh the
rows it receives while keeping its saved references, so pinned and
folder views stay independent of the recent-list window.

---------

Co-authored-by: Aleksandr Slapoguzov <aleksandr.slapoguzov@jetbrains.com>
2026-10-27 00:44:18 +01:00
dependabot[bot]
782e463ff4 ci(deps): Bump the minor group with 3 updates (#1440)
Bumps the minor group with 3 updates:
[on-finished](https://github.com/jshttp/on-finished),
[rolldown](https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown)
and [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite).

Updates `on-finished` from 2.4.1 to 2.4.2
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/jshttp/on-finished/releases">on-finished's
releases</a>.</em></p>
<blockquote>
<h2>2.4.2</h2>
<h2>Highlights</h2>
<h3>🔧 Node.js deprecation</h3>
<ul>
<li><code>isFinished(res)</code> now reads <code>writableEnded</code>
instead of the deprecated <code>finished</code> property (<a
href="https://nodejs.org/api/deprecations.html#DEP0136">DEP0136</a>),
with a fallback to <code>finished</code> on older Node.js versions. No
behavior change for consumers. (<a
href="https://redirect.github.com/jshttp/on-finished/issues/54">#54</a>)</li>
</ul>
<h3>📦 Packaging</h3>
<ul>
<li><code>HISTORY.md</code> is no longer shipped in the npm tarball. (<a
href="https://redirect.github.com/jshttp/on-finished/issues/109">#109</a>)</li>
<li><code>package.json</code> now declares <code>&quot;type&quot;:
&quot;commonjs&quot;</code> explicitly. (<a
href="https://redirect.github.com/jshttp/on-finished/issues/108">#108</a>)</li>
</ul>
<h3>🧪 Testing and CI</h3>
<ul>
<li>Added edge case tests for <code>isFinished</code> with invalid
input. (<a
href="https://redirect.github.com/jshttp/on-finished/issues/88">#88</a>)</li>
<li>Node.js 19 through 26 added to the test matrix. (<a
href="https://redirect.github.com/jshttp/on-finished/issues/53">#53</a>,
<a
href="https://redirect.github.com/jshttp/on-finished/issues/106">#106</a>)</li>
<li><code>mocha</code> bumped to 12.0.3 and the CI pipeline refreshed.
(<a
href="https://redirect.github.com/jshttp/on-finished/issues/103">#103</a>)</li>
<li>OSSF Scorecard enabled and security best practices applied to the
workflows. (<a
href="https://redirect.github.com/jshttp/on-finished/issues/47">#47</a>,
<a
href="https://redirect.github.com/jshttp/on-finished/issues/60">#60</a>)</li>
<li>Dependabot now groups GitHub Actions updates. (<a
href="https://redirect.github.com/jshttp/on-finished/issues/104">#104</a>)</li>
</ul>
<h2>What's Changed</h2>
<ul>
<li>Update node 8 version and pin nyc. Also added missing 9.x to
ci-tests… by <a
href="https://github.com/carpasse"><code>@​carpasse</code></a> in <a
href="https://redirect.github.com/jshttp/on-finished/pull/49">jshttp/on-finished#49</a></li>
<li>Add OSSF scorecard by <a
href="https://github.com/carpasse"><code>@​carpasse</code></a> in <a
href="https://redirect.github.com/jshttp/on-finished/pull/47">jshttp/on-finished#47</a></li>
<li>build: Node.js 19+ by <a
href="https://github.com/bjohansebas"><code>@​bjohansebas</code></a> in
<a
href="https://redirect.github.com/jshttp/on-finished/pull/53">jshttp/on-finished#53</a></li>
<li>ci: apply OSSF Scorecard security best practices by <a
href="https://github.com/UlisesGascon"><code>@​UlisesGascon</code></a>
in <a
href="https://redirect.github.com/jshttp/on-finished/pull/60">jshttp/on-finished#60</a></li>
<li>chore: add funding to package.json by <a
href="https://github.com/Phillip9587"><code>@​Phillip9587</code></a> in
<a
href="https://redirect.github.com/jshttp/on-finished/pull/76">jshttp/on-finished#76</a></li>
<li>use writableEnd instead finished by <a
href="https://github.com/bjohansebas"><code>@​bjohansebas</code></a> in
<a
href="https://redirect.github.com/jshttp/on-finished/pull/54">jshttp/on-finished#54</a></li>
<li>build(deps): bump ossf/scorecard-action from 2.0.6 to 2.4.3 by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/jshttp/on-finished/pull/85">jshttp/on-finished#85</a></li>
<li>build(deps): bump actions/upload-artifact from 3.1.3 to 4.6.2 by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/jshttp/on-finished/pull/64">jshttp/on-finished#64</a></li>
<li>build(deps): bump actions/checkout from 3.6.0 to 5.0.0 by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/jshttp/on-finished/pull/84">jshttp/on-finished#84</a></li>
<li>build(deps): bump actions/download-artifact from 4.3.0 to 5.0.0 by
<a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/jshttp/on-finished/pull/82">jshttp/on-finished#82</a></li>
<li>test: add edge case tests for isFinished with invalid input by <a
href="https://github.com/Ayoub-Mabrouk"><code>@​Ayoub-Mabrouk</code></a>
in <a
href="https://redirect.github.com/jshttp/on-finished/pull/88">jshttp/on-finished#88</a></li>
<li>build(deps): bump actions/checkout from 5.0.0 to 6.0.0 by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/jshttp/on-finished/pull/93">jshttp/on-finished#93</a></li>
<li>build(deps): bump actions/upload-artifact from 4.6.2 to 5.0.0 by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/jshttp/on-finished/pull/92">jshttp/on-finished#92</a></li>
<li>build(deps): bump actions/download-artifact from 5.0.0 to 6.0.0 by
<a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/jshttp/on-finished/pull/91">jshttp/on-finished#91</a></li>
<li>build(deps): bump github/codeql-action from 2.23.2 to 4.31.6 by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/jshttp/on-finished/pull/90">jshttp/on-finished#90</a></li>
<li>build(deps): bump coverallsapp/github-action from 2.3.6 to 2.3.7 by
<a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/jshttp/on-finished/pull/89">jshttp/on-finished#89</a></li>
<li>chore: group github actions updates by <a
href="https://github.com/Phillip9587"><code>@​Phillip9587</code></a> in
<a
href="https://redirect.github.com/jshttp/on-finished/pull/104">jshttp/on-finished#104</a></li>
<li>bump Mocha to version 12.0.3 by <a
href="https://github.com/rafapaezbas"><code>@​rafapaezbas</code></a> in
<a
href="https://redirect.github.com/jshttp/on-finished/pull/103">jshttp/on-finished#103</a></li>
<li>ci: add Node 25 and 26 to workflow by <a
href="https://github.com/Phillip9587"><code>@​Phillip9587</code></a> in
<a
href="https://redirect.github.com/jshttp/on-finished/pull/106">jshttp/on-finished#106</a></li>
<li>build(deps): bump the github-actions group with 10 updates by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/jshttp/on-finished/pull/105">jshttp/on-finished#105</a></li>
<li>chore: add explicit type commonjs to package.json by <a
href="https://github.com/Phillip9587"><code>@​Phillip9587</code></a> in
<a
href="https://redirect.github.com/jshttp/on-finished/pull/108">jshttp/on-finished#108</a></li>
<li>chore: remove HISTORY.md from being packaged on publish by <a
href="https://github.com/Phillip9587"><code>@​Phillip9587</code></a> in
<a
href="https://redirect.github.com/jshttp/on-finished/pull/109">jshttp/on-finished#109</a></li>
<li>chore(ci): npm-publish via stage publish by <a
href="https://github.com/UlisesGascon"><code>@​UlisesGascon</code></a>
in <a
href="https://redirect.github.com/jshttp/on-finished/pull/111">jshttp/on-finished#111</a></li>
<li>Release: 2.4.2 by <a
href="https://github.com/Phillip9587"><code>@​Phillip9587</code></a> in
<a
href="https://redirect.github.com/jshttp/on-finished/pull/110">jshttp/on-finished#110</a></li>
</ul>
<h2>New Contributors</h2>
<ul>
<li><a href="https://github.com/carpasse"><code>@​carpasse</code></a>
made their first contribution in <a
href="https://redirect.github.com/jshttp/on-finished/pull/49">jshttp/on-finished#49</a></li>
<li><a
href="https://github.com/UlisesGascon"><code>@​UlisesGascon</code></a>
made their first contribution in <a
href="https://redirect.github.com/jshttp/on-finished/pull/60">jshttp/on-finished#60</a></li>
<li><a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
made their first contribution in <a
href="https://redirect.github.com/jshttp/on-finished/pull/85">jshttp/on-finished#85</a></li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/jshttp/on-finished/blob/master/HISTORY.md">on-finished's
changelog</a>.</em></p>
<blockquote>
<h1>2.4.2</h1>
<ul>
<li>Use <code>writableEnded</code> instead of deprecated
<code>finished</code>, with a fallback for older Node.js versions (<a
href="https://redirect.github.com/jshttp/on-finished/issues/54">#54</a>)</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="9a73f9c1c0"><code>9a73f9c</code></a>
2.4.2 (<a
href="https://redirect.github.com/jshttp/on-finished/issues/110">#110</a>)</li>
<li><a
href="0ec28ddc1d"><code>0ec28dd</code></a>
chore(ci): npm-publish via stage publish (<a
href="https://redirect.github.com/jshttp/on-finished/issues/111">#111</a>)</li>
<li><a
href="4b8ac4669c"><code>4b8ac46</code></a>
chore: remove HISTORY.md from being packaged on publish (<a
href="https://redirect.github.com/jshttp/on-finished/issues/109">#109</a>)</li>
<li><a
href="9c5f7b805b"><code>9c5f7b8</code></a>
chore: add explicit type commonjs to package.json (<a
href="https://redirect.github.com/jshttp/on-finished/issues/108">#108</a>)</li>
<li><a
href="8c33090d5b"><code>8c33090</code></a>
build(deps): bump the github-actions group with 10 updates (<a
href="https://redirect.github.com/jshttp/on-finished/issues/105">#105</a>)</li>
<li><a
href="1ca32c56e7"><code>1ca32c5</code></a>
ci: add Node 25 and 26 to workflow (<a
href="https://redirect.github.com/jshttp/on-finished/issues/106">#106</a>)</li>
<li><a
href="4ea365ed85"><code>4ea365e</code></a>
deps: bump mocha to v12.0.3 and update CI pipeline (<a
href="https://redirect.github.com/jshttp/on-finished/issues/103">#103</a>)</li>
<li><a
href="673ff9fbb2"><code>673ff9f</code></a>
chore: group github actions updates (<a
href="https://redirect.github.com/jshttp/on-finished/issues/104">#104</a>)</li>
<li><a
href="500c84cec0"><code>500c84c</code></a>
build(deps): bump coverallsapp/github-action from 2.3.6 to 2.3.7 (<a
href="https://redirect.github.com/jshttp/on-finished/issues/89">#89</a>)</li>
<li><a
href="1dbd9cd615"><code>1dbd9cd</code></a>
build(deps): bump github/codeql-action from 2.23.2 to 4.31.6 (<a
href="https://redirect.github.com/jshttp/on-finished/issues/90">#90</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/jshttp/on-finished/compare/v2.4.1...v2.4.2">compare
view</a></li>
</ul>
</details>
<details>
<summary>Maintainer changes</summary>
<p>This version was pushed to npm by <a
href="https://www.npmjs.com/~GitHub%20Actions">GitHub Actions</a>, a new
releaser for on-finished since your current version.</p>
</details>
<br />

Updates `rolldown` from 1.2.14 to 1.2.15
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/rolldown/rolldown/releases">rolldown's
releases</a>.</em></p>
<blockquote>
<h2>v1.2.15</h2>
<h2>[1.2.15] - 2026-10-21</h2>
<h3>🚀 Features</h3>
<ul>
<li>rolldown_plugin_vite_resolve: add <code>isBundled</code> to pick the
browser-external stub format (<a
href="https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown/issues/11260">#11260</a>)
by <a href="https://github.com/h-a-n-a"><code>@​h-a-n-a</code></a></li>
<li>dev: mark entry modules in the HMR module graph (<a
href="https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown/issues/11252">#11252</a>)
by <a href="https://github.com/h-a-n-a"><code>@​h-a-n-a</code></a></li>
<li>mark module starts with <code>// &lt;id&gt;</code> comments instead
of <code>#region</code> pairs (<a
href="https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown/issues/11235">#11235</a>)
by <a
href="https://github.com/IWANABETHATGUY"><code>@​IWANABETHATGUY</code></a></li>
</ul>
<h3>🐛 Bug Fixes</h3>
<ul>
<li>plugin: stop <code>this.resolve</code> recursion between plugins
with <code>skipSelf: true</code> (<a
href="https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown/issues/11181">#11181</a>)
by <a href="https://github.com/anzal1"><code>@​anzal1</code></a></li>
<li>rolldown_plugin_vite_resolve: handle external <code>require</code>
for <code>ssr.target: 'webworker'</code> (<a
href="https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown/issues/11205">#11205</a>)
by <a
href="https://github.com/sapphi-red"><code>@​sapphi-red</code></a></li>
<li>rolldown_plugin_vite_build_import_analysis: only wrap dynamic
imports with static specifiers in __vitePreload (<a
href="https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown/issues/10879">#10879</a>)
by <a href="https://github.com/smeng9"><code>@​smeng9</code></a></li>
<li>strict-execution-order: wrap eager modules that forward wrapped
definers (<a
href="https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown/issues/11186">#11186</a>)
by <a href="https://github.com/hyfdev"><code>@​hyfdev</code></a></li>
<li>chunk-import-map: keep common chunk keys unique when enabling inline
common chunks (<a
href="https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown/issues/11230">#11230</a>)
by <a href="https://github.com/hyfdev"><code>@​hyfdev</code></a></li>
<li>only register signal-exit handler when a trace subscriber is active
(<a
href="https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown/issues/11208">#11208</a>)
by <a
href="https://github.com/DaniilIsupov"><code>@​DaniilIsupov</code></a></li>
</ul>
<h3>🧪 Testing</h3>
<ul>
<li>fold hand-unrolled Rust test tables (<a
href="https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown/issues/11212">#11212</a>)
by <a
href="https://github.com/Brooooooklyn"><code>@​Brooooooklyn</code></a></li>
</ul>
<h3>⚙️ Miscellaneous Tasks</h3>
<ul>
<li>deps: upgrade oxc to 0.155.0 (<a
href="https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown/issues/11228">#11228</a>)
by <a href="https://github.com/Dunqing"><code>@​Dunqing</code></a></li>
<li>rollup-tests: retriage failed tests (<a
href="https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown/issues/11251">#11251</a>)
by <a
href="https://github.com/sapphi-red"><code>@​sapphi-red</code></a></li>
<li>deps: update rollup submodule for tests to v4.64.12 (<a
href="https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown/issues/11249">#11249</a>)
by <a
href="https://github.com/rolldown-guard"><code>@​rolldown-guard</code></a>[bot]</li>
<li>deps: update test262 submodule for tests (<a
href="https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown/issues/11242">#11242</a>)
by <a
href="https://github.com/rolldown-guard"><code>@​rolldown-guard</code></a>[bot]</li>
</ul>
<h3>❤️ New Contributors</h3>
<ul>
<li><a href="https://github.com/anzal1"><code>@​anzal1</code></a> made
their first contribution in <a
href="https://redirect.github.com/rolldown/rolldown/pull/11181">#11181</a></li>
<li><a href="https://github.com/smeng9"><code>@​smeng9</code></a> made
their first contribution in <a
href="https://redirect.github.com/rolldown/rolldown/pull/10879">#10879</a></li>
<li><a
href="https://github.com/DaniilIsupov"><code>@​DaniilIsupov</code></a>
made their first contribution in <a
href="https://redirect.github.com/rolldown/rolldown/pull/11208">#11208</a></li>
</ul>
<p>Co-authored-by: shulaoda <a
href="mailto:165626830+shulaoda@users.noreply.github.com">165626830+shulaoda@users.noreply.github.com</a></p>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/rolldown/rolldown/blob/main/CHANGELOG.md">rolldown's
changelog</a>.</em></p>
<blockquote>
<h2>[1.2.15] - 2026-10-21</h2>
<h3>🚀 Features</h3>
<ul>
<li>rolldown_plugin_vite_resolve: add <code>isBundled</code> to pick the
browser-external stub format (<a
href="https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown/issues/11260">#11260</a>)
by <a href="https://github.com/h-a-n-a"><code>@​h-a-n-a</code></a></li>
<li>dev: mark entry modules in the HMR module graph (<a
href="https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown/issues/11252">#11252</a>)
by <a href="https://github.com/h-a-n-a"><code>@​h-a-n-a</code></a></li>
<li>mark module starts with <code>// &lt;id&gt;</code> comments instead
of <code>#region</code> pairs (<a
href="https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown/issues/11235">#11235</a>)
by <a
href="https://github.com/IWANABETHATGUY"><code>@​IWANABETHATGUY</code></a></li>
</ul>
<h3>🐛 Bug Fixes</h3>
<ul>
<li>plugin: stop <code>this.resolve</code> recursion between plugins
with <code>skipSelf: true</code> (<a
href="https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown/issues/11181">#11181</a>)
by <a href="https://github.com/anzal1"><code>@​anzal1</code></a></li>
<li>rolldown_plugin_vite_resolve: handle external <code>require</code>
for <code>ssr.target: 'webworker'</code> (<a
href="https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown/issues/11205">#11205</a>)
by <a
href="https://github.com/sapphi-red"><code>@​sapphi-red</code></a></li>
<li>rolldown_plugin_vite_build_import_analysis: only wrap dynamic
imports with static specifiers in __vitePreload (<a
href="https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown/issues/10879">#10879</a>)
by <a href="https://github.com/smeng9"><code>@​smeng9</code></a></li>
<li>strict-execution-order: wrap eager modules that forward wrapped
definers (<a
href="https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown/issues/11186">#11186</a>)
by <a href="https://github.com/hyfdev"><code>@​hyfdev</code></a></li>
<li>chunk-import-map: keep common chunk keys unique when enabling inline
common chunks (<a
href="https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown/issues/11230">#11230</a>)
by <a href="https://github.com/hyfdev"><code>@​hyfdev</code></a></li>
<li>only register signal-exit handler when a trace subscriber is active
(<a
href="https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown/issues/11208">#11208</a>)
by <a
href="https://github.com/DaniilIsupov"><code>@​DaniilIsupov</code></a></li>
</ul>
<h3>🧪 Testing</h3>
<ul>
<li>fold hand-unrolled Rust test tables (<a
href="https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown/issues/11212">#11212</a>)
by <a
href="https://github.com/Brooooooklyn"><code>@​Brooooooklyn</code></a></li>
</ul>
<h3>⚙️ Miscellaneous Tasks</h3>
<ul>
<li>deps: upgrade oxc to 0.155.0 (<a
href="https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown/issues/11228">#11228</a>)
by <a href="https://github.com/Dunqing"><code>@​Dunqing</code></a></li>
<li>rollup-tests: retriage failed tests (<a
href="https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown/issues/11251">#11251</a>)
by <a
href="https://github.com/sapphi-red"><code>@​sapphi-red</code></a></li>
<li>deps: update rollup submodule for tests to v4.64.12 (<a
href="https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown/issues/11249">#11249</a>)
by <a
href="https://github.com/rolldown-guard"><code>@​rolldown-guard</code></a>[bot]</li>
<li>deps: update test262 submodule for tests (<a
href="https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown/issues/11242">#11242</a>)
by <a
href="https://github.com/rolldown-guard"><code>@​rolldown-guard</code></a>[bot]</li>
</ul>
<h3>❤️ New Contributors</h3>
<ul>
<li><a href="https://github.com/anzal1"><code>@​anzal1</code></a> made
their first contribution in <a
href="https://redirect.github.com/rolldown/rolldown/pull/11181">#11181</a></li>
<li><a href="https://github.com/smeng9"><code>@​smeng9</code></a> made
their first contribution in <a
href="https://redirect.github.com/rolldown/rolldown/pull/10879">#10879</a></li>
<li><a
href="https://github.com/DaniilIsupov"><code>@​DaniilIsupov</code></a>
made their first contribution in <a
href="https://redirect.github.com/rolldown/rolldown/pull/11208">#11208</a></li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="89d821ca07"><code>89d821c</code></a>
release: v1.2.15 (<a
href="https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown/issues/11261">#11261</a>)</li>
<li><a
href="3b0841ef80"><code>3b0841e</code></a>
feat(rolldown_plugin_vite_resolve): add <code>isBundled</code> to pick
the browser-exter...</li>
<li><a
href="a973d41247"><code>a973d41</code></a>
chore(deps): upgrade oxc to 0.155.0 (<a
href="https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown/issues/11228">#11228</a>)</li>
<li><a
href="5bfb74e2b0"><code>5bfb74e</code></a>
feat(dev): mark entry modules in the HMR module graph (<a
href="https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown/issues/11252">#11252</a>)</li>
<li><a
href="fb0a8db6f1"><code>fb0a8db</code></a>
fix(rolldown_plugin_vite_resolve): handle external <code>require</code>
for `ssr.target:...</li>
<li><a
href="547a5a733c"><code>547a5a7</code></a>
fix(rolldown_plugin_vite_build_import_analysis): only wrap dynamic
imports wi...</li>
<li><a
href="648591f0eb"><code>648591f</code></a>
feat: mark module starts with <code>// \&lt;id&gt;</code> comments
instead of <code>#region</code> pairs (...</li>
<li><a
href="3f0374181d"><code>3f03741</code></a>
fix(chunk-import-map): keep common chunk keys unique when enabling
inline com...</li>
<li><a
href="33a987d8bf"><code>33a987d</code></a>
fix: only register signal-exit handler when a trace subscriber is active
(<a
href="https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown/issues/11">#11</a>...</li>
<li>See full diff in <a
href="https://github.com/rolldown/rolldown/commits/v1.2.15/packages/rolldown">compare
view</a></li>
</ul>
</details>
<br />

Updates `vite` from 8.3.4 to 8.3.5
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/vitejs/vite/releases">vite's
releases</a>.</em></p>
<blockquote>
<h2>v8.3.5</h2>
<h3>Bug Fixes</h3>
<ul>
<li><strong>build:</strong> handle missing chunk import maps (<a
href="https://redirect.github.com/vitejs/vite/issues/23777">#23777</a>)
(<a
href="8d7623d9b5">8d7623d</a>)</li>
<li><strong>build:</strong> include preload dependencies to chunk import
map hashing (<a
href="https://redirect.github.com/vitejs/vite/issues/23793">#23793</a>)
(<a
href="4d16e7c14d">4d16e7c</a>)</li>
<li><strong>bundled-dev:</strong> apply <code>server.origin</code> to
asset URLs (<a
href="https://redirect.github.com/vitejs/vite/issues/23683">#23683</a>)
(<a
href="52ba877693">52ba877</a>)</li>
<li><strong>bundled-dev:</strong> declare utf-8 charset in fallback page
(<a
href="https://redirect.github.com/vitejs/vite/issues/23846">#23846</a>)
(<a
href="197daf6c4c">197daf6</a>)</li>
<li><strong>bundled-dev:</strong> ignore build.chunkImportMap (<a
href="https://redirect.github.com/vitejs/vite/issues/23685">#23685</a>)
(<a
href="cfe917d67d">cfe917d</a>)</li>
<li><strong>bundled-dev:</strong> include user
<code>import.meta.env.*</code> defines in the
<code>import.meta.env</code> object (<a
href="https://redirect.github.com/vitejs/vite/issues/23713">#23713</a>)
(<a
href="6019aa7997">6019aa7</a>)</li>
<li><strong>bundled-dev:</strong> map forwarded error stacks through the
bundle source maps (<a
href="https://redirect.github.com/vitejs/vite/issues/23741">#23741</a>)
(<a
href="11fe1e46ff">11fe1e4</a>)</li>
<li><strong>bundled-dev:</strong> normalize served output files (<a
href="https://redirect.github.com/vitejs/vite/issues/23684">#23684</a>)
(<a
href="df4e02cc83">df4e02c</a>)</li>
<li><strong>bundled-dev:</strong> stop hmr propagation at modules that
are no longer imported (<a
href="https://redirect.github.com/vitejs/vite/issues/23839">#23839</a>)
(<a
href="7471e8c3b3">7471e8c</a>)</li>
<li><strong>css:</strong> don't fail on a literal <code>%</code> in
<code>url()</code> (<a
href="https://redirect.github.com/vitejs/vite/issues/23676">#23676</a>)
(<a
href="50b382d318">50b382d</a>)</li>
<li><strong>deps:</strong> update all non-major dependencies (<a
href="https://redirect.github.com/vitejs/vite/issues/23731">#23731</a>)
(<a
href="cf80aa552c">cf80aa5</a>)</li>
<li><strong>deps:</strong> update all non-major dependencies (<a
href="https://redirect.github.com/vitejs/vite/issues/23826">#23826</a>)
(<a
href="77d8373510">77d8373</a>)</li>
<li><strong>deps:</strong> update rolldown-related dependencies (<a
href="https://redirect.github.com/vitejs/vite/issues/23733">#23733</a>)
(<a
href="43f0d052e5">43f0d05</a>)</li>
<li><strong>deps:</strong> update rolldown-related dependencies (<a
href="https://redirect.github.com/vitejs/vite/issues/23827">#23827</a>)
(<a
href="c2e04187e4">c2e0418</a>)</li>
<li><strong>optimizer:</strong> fall back to yarn.lock for lockfile hash
(<a
href="https://redirect.github.com/vitejs/vite/issues/23667">#23667</a>)
(<a
href="f20609630f">f206096</a>)</li>
<li><strong>optimizer:</strong> scan <code>oxc.jsxInject</code> imports
(<a
href="https://redirect.github.com/vitejs/vite/issues/23758">#23758</a>)
(<a
href="38f3cb45b1">38f3cb4</a>)</li>
<li><strong>optimizer:</strong> skip patches dir check for yarn.lock
fallback (<a
href="https://redirect.github.com/vitejs/vite/issues/23725">#23725</a>)
(<a
href="8e890e1ce4">8e890e1</a>)</li>
<li><strong>optimizer:</strong> skip patches dir hash for nub lockfile
(<a
href="https://redirect.github.com/vitejs/vite/issues/23761">#23761</a>)
(<a
href="a7d56967ef">a7d5696</a>)</li>
<li><strong>resolve:</strong> support subpath imports with multiple
<code>#</code> (<a
href="https://redirect.github.com/vitejs/vite/issues/23631">#23631</a>)
(<a
href="0d86b3485f">0d86b34</a>)</li>
<li><strong>resolve:</strong> update resolve.exports to 2.0.4 (<a
href="https://redirect.github.com/vitejs/vite/issues/23739">#23739</a>)
(<a
href="4ea708a3ae">4ea708a</a>)</li>
<li><strong>server:</strong> handle socket errors on upgrade requests
not taken by the HMR server (<a
href="https://redirect.github.com/vitejs/vite/issues/23728">#23728</a>)
(<a
href="c2c9ef4dcd">c2c9ef4</a>)</li>
<li><strong>server:</strong> only strip base when path matches a full
segment (<a
href="https://redirect.github.com/vitejs/vite/issues/23641">#23641</a>)
(<a
href="3dfde87b90">3dfde87</a>)</li>
</ul>
<h3>Performance Improvements</h3>
<ul>
<li><strong>ssr:</strong> extract inline source maps without a regexp
(<a
href="https://redirect.github.com/vitejs/vite/issues/23775">#23775</a>)
(<a
href="6a0278055d">6a02780</a>)</li>
</ul>
<h3>Miscellaneous Chores</h3>
<ul>
<li>migrate to Oxlint (<a
href="https://redirect.github.com/vitejs/vite/issues/23746">#23746</a>)
(<a
href="5b73935881">5b73935</a>)</li>
</ul>
<h3>Code Refactoring</h3>
<ul>
<li><strong>deps:</strong> replace <code>@vercel/detect-agent</code>
with <code>std-env</code> (<a
href="https://redirect.github.com/vitejs/vite/issues/23744">#23744</a>)
(<a
href="ce4293f15c">ce4293f</a>)</li>
<li><strong>dev:</strong> consolidate moduleURL to file path utilities
(<a
href="https://redirect.github.com/vitejs/vite/issues/23828">#23828</a>)
(<a
href="df7b40ee9d">df7b40e</a>)</li>
<li><strong>dev:</strong> separate import resolution from URL conversion
(<a
href="https://redirect.github.com/vitejs/vite/issues/23791">#23791</a>)
(<a
href="611668983d">6116689</a>)</li>
<li><strong>dev:</strong> share ModuleURL and BrowserURL conversions (<a
href="https://redirect.github.com/vitejs/vite/issues/23790">#23790</a>)
(<a
href="446954c69c">446954c</a>)</li>
<li><strong>dev:</strong> use ModuleURLs throughout HTML transforms (<a
href="https://redirect.github.com/vitejs/vite/issues/23815">#23815</a>)
(<a
href="a16299092c">a162990</a>)</li>
</ul>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md">vite's
changelog</a>.</em></p>
<blockquote>
<h2><!-- raw HTML omitted --><a
href="https://github.com/vitejs/vite/compare/v8.3.4...v8.3.5">8.3.5</a>
(2026-10-21)<!-- raw HTML omitted --></h2>
<h3>Bug Fixes</h3>
<ul>
<li><strong>build:</strong> handle missing chunk import maps (<a
href="https://redirect.github.com/vitejs/vite/issues/23777">#23777</a>)
(<a
href="8d7623d9b5">8d7623d</a>)</li>
<li><strong>build:</strong> include preload dependencies to chunk import
map hashing (<a
href="https://redirect.github.com/vitejs/vite/issues/23793">#23793</a>)
(<a
href="4d16e7c14d">4d16e7c</a>)</li>
<li><strong>bundled-dev:</strong> apply <code>server.origin</code> to
asset URLs (<a
href="https://redirect.github.com/vitejs/vite/issues/23683">#23683</a>)
(<a
href="52ba877693">52ba877</a>)</li>
<li><strong>bundled-dev:</strong> declare utf-8 charset in fallback page
(<a
href="https://redirect.github.com/vitejs/vite/issues/23846">#23846</a>)
(<a
href="197daf6c4c">197daf6</a>)</li>
<li><strong>bundled-dev:</strong> ignore build.chunkImportMap (<a
href="https://redirect.github.com/vitejs/vite/issues/23685">#23685</a>)
(<a
href="cfe917d67d">cfe917d</a>)</li>
<li><strong>bundled-dev:</strong> include user
<code>import.meta.env.*</code> defines in the
<code>import.meta.env</code> object (<a
href="https://redirect.github.com/vitejs/vite/issues/23713">#23713</a>)
(<a
href="6019aa7997">6019aa7</a>)</li>
<li><strong>bundled-dev:</strong> map forwarded error stacks through the
bundle source maps (<a
href="https://redirect.github.com/vitejs/vite/issues/23741">#23741</a>)
(<a
href="11fe1e46ff">11fe1e4</a>)</li>
<li><strong>bundled-dev:</strong> normalize served output files (<a
href="https://redirect.github.com/vitejs/vite/issues/23684">#23684</a>)
(<a
href="df4e02cc83">df4e02c</a>)</li>
<li><strong>bundled-dev:</strong> stop hmr propagation at modules that
are no longer imported (<a
href="https://redirect.github.com/vitejs/vite/issues/23839">#23839</a>)
(<a
href="7471e8c3b3">7471e8c</a>)</li>
<li><strong>css:</strong> don't fail on a literal <code>%</code> in
<code>url()</code> (<a
href="https://redirect.github.com/vitejs/vite/issues/23676">#23676</a>)
(<a
href="50b382d318">50b382d</a>)</li>
<li><strong>deps:</strong> update all non-major dependencies (<a
href="https://redirect.github.com/vitejs/vite/issues/23731">#23731</a>)
(<a
href="cf80aa552c">cf80aa5</a>)</li>
<li><strong>deps:</strong> update all non-major dependencies (<a
href="https://redirect.github.com/vitejs/vite/issues/23826">#23826</a>)
(<a
href="77d8373510">77d8373</a>)</li>
<li><strong>deps:</strong> update rolldown-related dependencies (<a
href="https://redirect.github.com/vitejs/vite/issues/23733">#23733</a>)
(<a
href="43f0d052e5">43f0d05</a>)</li>
<li><strong>deps:</strong> update rolldown-related dependencies (<a
href="https://redirect.github.com/vitejs/vite/issues/23827">#23827</a>)
(<a
href="c2e04187e4">c2e0418</a>)</li>
<li><strong>optimizer:</strong> fall back to yarn.lock for lockfile hash
(<a
href="https://redirect.github.com/vitejs/vite/issues/23667">#23667</a>)
(<a
href="f20609630f">f206096</a>)</li>
<li><strong>optimizer:</strong> scan <code>oxc.jsxInject</code> imports
(<a
href="https://redirect.github.com/vitejs/vite/issues/23758">#23758</a>)
(<a
href="38f3cb45b1">38f3cb4</a>)</li>
<li><strong>optimizer:</strong> skip patches dir check for yarn.lock
fallback (<a
href="https://redirect.github.com/vitejs/vite/issues/23725">#23725</a>)
(<a
href="8e890e1ce4">8e890e1</a>)</li>
<li><strong>optimizer:</strong> skip patches dir hash for nub lockfile
(<a
href="https://redirect.github.com/vitejs/vite/issues/23761">#23761</a>)
(<a
href="a7d56967ef">a7d5696</a>)</li>
<li><strong>resolve:</strong> support subpath imports with multiple
<code>#</code> (<a
href="https://redirect.github.com/vitejs/vite/issues/23631">#23631</a>)
(<a
href="0d86b3485f">0d86b34</a>)</li>
<li><strong>resolve:</strong> update resolve.exports to 2.0.4 (<a
href="https://redirect.github.com/vitejs/vite/issues/23739">#23739</a>)
(<a
href="4ea708a3ae">4ea708a</a>)</li>
<li><strong>server:</strong> handle socket errors on upgrade requests
not taken by the HMR server (<a
href="https://redirect.github.com/vitejs/vite/issues/23728">#23728</a>)
(<a
href="c2c9ef4dcd">c2c9ef4</a>)</li>
<li><strong>server:</strong> only strip base when path matches a full
segment (<a
href="https://redirect.github.com/vitejs/vite/issues/23641">#23641</a>)
(<a
href="3dfde87b90">3dfde87</a>)</li>
</ul>
<h3>Performance Improvements</h3>
<ul>
<li><strong>ssr:</strong> extract inline source maps without a regexp
(<a
href="https://redirect.github.com/vitejs/vite/issues/23775">#23775</a>)
(<a
href="6a0278055d">6a02780</a>)</li>
</ul>
<h3>Miscellaneous Chores</h3>
<ul>
<li>migrate to Oxlint (<a
href="https://redirect.github.com/vitejs/vite/issues/23746">#23746</a>)
(<a
href="5b73935881">5b73935</a>)</li>
</ul>
<h3>Code Refactoring</h3>
<ul>
<li><strong>deps:</strong> replace <code>@vercel/detect-agent</code>
with <code>std-env</code> (<a
href="https://redirect.github.com/vitejs/vite/issues/23744">#23744</a>)
(<a
href="ce4293f15c">ce4293f</a>)</li>
<li><strong>dev:</strong> consolidate moduleURL to file path utilities
(<a
href="https://redirect.github.com/vitejs/vite/issues/23828">#23828</a>)
(<a
href="df7b40ee9d">df7b40e</a>)</li>
<li><strong>dev:</strong> separate import resolution from URL conversion
(<a
href="https://redirect.github.com/vitejs/vite/issues/23791">#23791</a>)
(<a
href="611668983d">6116689</a>)</li>
<li><strong>dev:</strong> share ModuleURL and BrowserURL conversions (<a
href="https://redirect.github.com/vitejs/vite/issues/23790">#23790</a>)
(<a
href="446954c69c">446954c</a>)</li>
<li><strong>dev:</strong> use ModuleURLs throughout HTML transforms (<a
href="https://redirect.github.com/vitejs/vite/issues/23815">#23815</a>)
(<a
href="a16299092c">a162990</a>)</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="981f07fa45"><code>981f07f</code></a>
release: v8.3.5 (<a
href="https://github.com/vitejs/vite/tree/HEAD/packages/vite/issues/23856">#23856</a>)</li>
<li><a
href="0d86b3485f"><code>0d86b34</code></a>
fix(resolve): support subpath imports with multiple <code>#</code> (<a
href="https://github.com/vitejs/vite/tree/HEAD/packages/vite/issues/23631">#23631</a>)</li>
<li><a
href="6a0278055d"><code>6a02780</code></a>
perf(ssr): extract inline source maps without a regexp (<a
href="https://github.com/vitejs/vite/tree/HEAD/packages/vite/issues/23775">#23775</a>)</li>
<li><a
href="8e890e1ce4"><code>8e890e1</code></a>
fix(optimizer): skip patches dir check for yarn.lock fallback (<a
href="https://github.com/vitejs/vite/tree/HEAD/packages/vite/issues/23725">#23725</a>)</li>
<li><a
href="197daf6c4c"><code>197daf6</code></a>
fix(bundled-dev): declare utf-8 charset in fallback page (<a
href="https://github.com/vitejs/vite/tree/HEAD/packages/vite/issues/23846">#23846</a>)</li>
<li><a
href="4d16e7c14d"><code>4d16e7c</code></a>
fix(build): include preload dependencies to chunk import map hashing (<a
href="https://github.com/vitejs/vite/tree/HEAD/packages/vite/issues/23793">#23793</a>)</li>
<li><a
href="df7b40ee9d"><code>df7b40e</code></a>
refactor(dev): consolidate moduleURL to file path utilities (<a
href="https://github.com/vitejs/vite/tree/HEAD/packages/vite/issues/23828">#23828</a>)</li>
<li><a
href="a16299092c"><code>a162990</code></a>
refactor(dev): use ModuleURLs throughout HTML transforms (<a
href="https://github.com/vitejs/vite/tree/HEAD/packages/vite/issues/23815">#23815</a>)</li>
<li><a
href="611668983d"><code>6116689</code></a>
refactor(dev): separate import resolution from URL conversion (<a
href="https://github.com/vitejs/vite/tree/HEAD/packages/vite/issues/23791">#23791</a>)</li>
<li><a
href="446954c69c"><code>446954c</code></a>
refactor(dev): share ModuleURL and BrowserURL conversions (<a
href="https://github.com/vitejs/vite/tree/HEAD/packages/vite/issues/23790">#23790</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/vitejs/vite/commits/v8.3.5/packages/vite">compare
view</a></li>
</ul>
</details>
<br />


Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore <dependency name> major version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's major version (unless you unignore this specific
dependency's major version or upgrade to it yourself)
- `@dependabot ignore <dependency name> minor version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's minor version (unless you unignore this specific
dependency's minor version or upgrade to it yourself)
- `@dependabot ignore <dependency name>` will close this group update PR
and stop Dependabot creating any more for the specific dependency
(unless you unignore this specific dependency or upgrade to it yourself)
- `@dependabot unignore <dependency name>` will remove all of the ignore
conditions of the specified dependency
- `@dependabot unignore <dependency name> <ignore condition>` will
remove the ignore condition of the specified dependency and ignore
conditions


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-26 16:18:48 +01:00
Ben Brandt
c9374abfc7 test: fix integration-run failures in provider, model and session-index tests (#1439)
## Summary

Fixes the 23 failures seen with `RUN_INTEGRATION_TESTS=true`. All are
test-side; no adapter changes.

- **Provider tests (20 failures):** since #1340, `providers/set` lists
the gateway's models with `fetch` in the test process.
`vitest.config.ts` only loaded `setup-no-network.ts` outside integration
runs, so tests that point at `https://gateway.example.com` made real
requests and failed with `fetch failed`. The stub now loads in every
run. `gateway-models.ts` is the only in-process `fetch` caller, and no
integration test targets a real gateway.
- **"the real SDK exposes one plain Opus row":** the CLI catalogue now
also lists pinned older models (`claude-opus-5`, `claude-opus-4-8`, …),
and the alias row's display name is "Opus 5.5". The check now covers
only alias rows (`value` starting with `opus`) and still asserts there
is a single row with no 1M variant.
- **session-index `ENOTEMPTY` in teardown:** integration runs leave
`CLAUDE_CODE_EXECUTABLE` unset, so initialize ran the real `claude auth
status` in the background. It wrote into the temp `CLAUDE_CONFIG_DIR`
while `afterEach` removed it. The file now stubs the executable to
`/usr/bin/false`, as the scenario tests do.
- **session-index relocation test:** the wait ended on any removal from
the old path's subscription. That removal can name the other path's
session, so the test now waits for the relocated session's own id.

## Testing

- The six previously failing files pass with
`RUN_INTEGRATION_TESTS=true` (454 tests).
- `session-index.test.ts` passed 3× with integration on and 1× without
(173 tests). Those failures were intermittent, so a few clean runs don't
fully prove the fix.
- `npx vitest run` (3360 tests) and `npm run check` pass.
- The full integration suite was not rerun end to end.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 14:15:14 +01:00
acp-release-bot[bot]
d52c7c64d8 chore(main): release 0.97.0 (#1437)
🤖 I have created a release *beep* *boop*
---


##
[0.97.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.96.0...v0.97.0)
(2026-10-26)


### Features

* add ultracode session config option
([#1438](https://github.com/agentclientprotocol/claude-agent-acp/issues/1438))
([de96cef](de96cef382))


### Bug Fixes

* bump body-parser from 2.3.0 to 2.4.0
([#1436](https://github.com/agentclientprotocol/claude-agent-acp/issues/1436))
([c26e640](c26e640915))
* distinguish model options with duplicate labels
([#1286](https://github.com/agentclientprotocol/claude-agent-acp/issues/1286))
([434be13](434be13a33))
* keep the usage-limit error when it arrives after compaction
([#1377](https://github.com/agentclientprotocol/claude-agent-acp/issues/1377))
([2a1525e](2a1525e57c))
* pass --settings before the auth status subcommand
([#1392](https://github.com/agentclientprotocol/claude-agent-acp/issues/1392))
([6a33ded](6a33dedcbb))
* preserve foreground fork reports until launch completes
([#1431](https://github.com/agentclientprotocol/claude-agent-acp/issues/1431))
([e369479](e369479f93))
* settle advisor tool results before completing turns
([#1247](https://github.com/agentclientprotocol/claude-agent-acp/issues/1247))
([bed795d](bed795d261))
* settle live steers folded into autonomous cycles
([#1185](https://github.com/agentclientprotocol/claude-agent-acp/issues/1185))
([a4957d1](a4957d17c1))

---
This PR was generated with [Release
Please](https://github.com/googleapis/release-please). See
[documentation](https://github.com/googleapis/release-please#release-please).

Co-authored-by: acp-release-bot[bot] <246668977+acp-release-bot[bot]@users.noreply.github.com>
2026-10-26 13:46:13 +01:00
Ben Brandt
de96cef382 feat: add ultracode session config option (#1438)
Adds an **Ultracode** session config option. It turns on Claude Code's
dynamic workflows for every task, at any effort level. Clients that
support boolean config options get a native switch; the rest get an
On/Off select. This follows the Fast mode pattern.

## How the CLI handles ultracode (2.1.310, checked against the bundled
binary and a live session)

- **Which models:** ultracode works on exactly the models that support
`xhigh` effort. The CLI uses the same check for both. So Opus 4.7+,
Sonnet 5+, Haiku 5.5 and Fable qualify; Opus 4.6 and Haiku 4.5 don't.
Effort level no longer matters.
- **Workflows:** dynamic workflows must be enabled. They are off by
default on Pro plans, and org or managed policy can turn them off.
- **State reporting:** `query.getSettings()` returns
`applied.ultracodeAvailable` and `applied.ultracodeRequested`. The CLI's
schema says hosts should "offer an Ultracode control only while this is
true". This method exists at runtime but is missing from the SDK's
`.d.ts`, so the adapter reads it with a cast and falls back to settings
and model data when it is absent.
- **Unsupported requests:** `applyFlagSettings({ultracode})` is never
validated. The request is stored and stays in place across model
switches; it takes effect whenever the model supports it.
- **Effort changes:** an `effortLevel` change sent without an
`ultracode` key turns ultracode off.

## Behavior

- **Visibility:** at session start, one `getSettings()` call decides
availability and the starting value. Model switches combine the
workflows result learned at start with the new model's
`supportedEffortLevels`, so the switch path makes no extra control
request.
- **Value:** the session tracks the user's choice, like
`fastModeEnabled`. The option hides on unsupported models and comes back
with the same value.
- **Effort applies:** `effortFlagSettings` now carries the session's
toggle instead of `settings.ultracode`. This applies at the initial
apply, on model switch, and from the effort picker, so effort changes no
longer turn ultracode off.
- **Clear context:** the clear-context restart restores the toggle, as
it does Fast mode.

## Known limitation

If a user types `/effort ultracode on` as a prompt, the toggle does not
update. The adapter already doesn't pick up effort changes typed as
`/effort` commands.

## Testing

- New `src/tests/ultracode-config.test.ts`. It covers the helpers and
session behavior: seeding from the CLI's report, hiding while workflows
are off, following the model's support, keeping the value across effort
and model switches, and rejected applies.
- New clear-context restore test. `effortFlagSettings` tests updated.
- Live probe against CLI 2.1.310 confirmed the `getSettings` fields, the
`xhigh` proxy (`claude-opus-4-6` reports unavailable while the request
stays in place), and the effort/key behavior.
- `npm run check`, `npm run build`, `npm run test:run` all pass.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 12:34:13 +00:00
Sean McCullough
a4957d17c1 fix: settle live steers folded into autonomous cycles (#1185)
#1233 fixed queued prompts folded into task-notification cycles. One
case remains: a steer sent to the active prompt can be folded into an
autonomous cycle, leaving `session/prompt` pending when the result names
only the steer UUID.

Keep queued prompt matching and also match the active turn's steer UUID.
Exclude settled turns and turns held open for background tasks. Preserve
plural UUID precedence.

The six new settlement cases fail on main and pass with the fix; all 11
new tests pass. Build, lint and format checks pass. The full default
suite reports 2,497 passed and 30 skipped with an identical Node binary
at a separate path. At `/usr/bin/node`, it reports 2,496 passed, one
failed and 30 skipped: an unchanged path-normalization test also fails
on main. No test was weakened. No live-provider tests were run.

Co-authored-by: Sean McCullough <sean@mcculloughs.io>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 11:35:31 +01:00
Nacho Lopez
bed795d261 fix: settle advisor tool results before completing turns (#1247)
When Claude uses the advisor server tool, a completed turn can fail with
`ACP error -32603: Internal error: Claude ended the turn without
returning results for tool calls: srvtoolu_...`. `server_tool_use` is
tracked as a foreground call, but the matching `advisor_tool_result` is
discarded, leaving that call unfinished at settlement.

Route advisor results through the existing tool-result handler so they
clear the outstanding call and publish a terminal update. Plaintext
advice is displayed, redacted advice gets a readable completion message
without exposing ciphertext, and advisor error results publish a failed
tool status with the error code. Release the advisor's hook callback
immediately because the server tool has no later client-side
`PostToolUse` hook.

Addresses the result-lifecycle part of #914.

Validation:

- Regression coverage through the real prompt consumer for plaintext,
redacted, and error results, including the complete assistant message
that dispatches the advisor call. All three cases fail without the fix.
A dispatched advisor call without a result still fails with
`incomplete_tool_call`.
- `npm run test:run -- src/tests/incomplete-tools.test.ts`: 59 passed.
- `npm run test:run`: 3,055 passed, 30 skipped.
- `npm run build` and `npm run check`: passed.
- Exact-head Linux CI passed in the fork:
https://github.com/mrmans0n/claude-agent-acp/actions/runs/38595756003.

The upstream PR workflows are waiting for maintainer approval to run.
The required upstream `Build` check remains pending; the fork CI result
does not satisfy that merge gate.
2026-10-26 11:28:57 +01:00
Jasper
434be13a33 fix: distinguish model options with duplicate labels (#1286)
Fixes #1246.

When `availableModels` includes aliases and pinned IDs that inherit the
same SDK display name, name-only model pickers show indistinguishable
rows. Append the selectable model ID to names that remain duplicated
after the existing version-label normalization. For example, the four
`Opus 5.5` rows become `Opus 5.5 (opus)`, `Opus 5.5 (opus[1m])`, and
their explicit-ID equivalents.

Keep every option and its ID, description, selection, and recommendation
behavior. Existing distinct labels stay unchanged. Regression tests
exercise the real allowlist transformation and cover duplicate custom
labels too.

Validation:

- Linux, locked dependencies and the repository's `lts/*` Node
configuration: `npm run check`, `npm run build`, and the full test suite
passed at ba37dd8: **2,491 passed, 30 skipped** ([fix
job](https://github.com/jipeng6036-del/claude-agent-acp/actions/runs/37923655565/job/113797353655)).
- Exact baseline 908c994: **2,489 passed, 30 skipped**; both new
regression tests fail against the original implementation. All existing
test outcomes match ([baseline
verification](https://github.com/jipeng6036-del/claude-agent-acp/actions/runs/37923859076)).
- Windows/Node 24: build and 105 focused tests passed. The full suite
has the same 48 failures on baseline and fix; only the two new tests add
passes. Authenticated SDK and JetBrains UI integration were not run.

Signed-off-by: Jasper <241105014+jipeng6036-del@users.noreply.github.com>
Co-authored-by: Jasper <241105014+jipeng6036-del@users.noreply.github.com>
2026-10-26 10:27:07 +00:00
Kerekes Dániel
2a1525e57c fix: keep the usage-limit error when it arrives after compaction (#1377)
Fixes #1370

After a compaction in the same turn, the synthetic usage-limit frame is
dropped by the stale-frame suppression before its failure details are
read. The prompt then ends with a generic internal error instead of the
quota error, so clients can't show the limit UI.

The suppression branch now records the usage-limit title and error
before it skips the frame. Only recognized usage-limit frames are read
there, so a stale replayed frame still can't overwrite the failure of
the current turn.

Added a test for a usage-limit frame after a completed compaction, for
both the legacy `errorKind` and the typed session failure, plus a stale
frame arriving after it. It fails without the change. `npm run check`,
`npm run build` and `npm run test:run` pass.
2026-10-26 10:26:18 +00:00
PerryLink
54f1e21bc4 test: retry the acp-v2 teardown rm on Windows (#1373)
## What breaks

On Windows, `npm run test:run` fails inside `src/tests/acp-v2.test.ts`,
and every failure is raised by the teardown rather than by the behaviour
the test checks. Measured on Windows 11 build 26200, Node 22.22.3,
vitest 5.0.4, two runs:

```
$ node node_modules/vitest/vitest.mjs run src/tests/acp-v2.test.ts
 Test Files  1 failed (1)
      Tests  45 failed | 91 passed (136)
   Duration  11.75s (tests 91%, transform 7%, import 2%)
```

```
 Test Files  1 failed (1)
      Tests  53 failed | 83 passed (136)
   Duration  11.79s (tests 96%, import 2%, transform 2%)
```

Exit code 1 both times. Every failure is the same error, and it comes
from `afterEach`, after the test body has finished:

```
 FAIL  src/tests/acp-v2.test.ts > ACP v2 prompts > does not report the next turn as waiting for a question that its client never settled
Error: EBUSY: resource busy or locked, rmdir 'C:\Users\...\Temp\acp-v2-TNkkd2\project-J9PwxJ'

 FAIL  src/tests/acp-v2.test.ts > ACP v2 prompts > the tool calls that a cancel stops > ends a stopped tool call and its subagent's as cancelled, before it reports idle cancelled
Error: EBUSY: resource busy or locked, rmdir 'C:\Users\...\Temp\acp-v2-O3wrrr\project-NgBUMz'
```

The string `EBUSY` occurs 45 and 53 times in those two logs, matching
the two failure counts. The failing set moves between runs; which test
loses the race depends on when Windows releases each directory handle.

CI runs the suite on `ubuntu-latest` only
(`.github/workflows/ci.yml:20`, `:45`), so CI cannot show this. Anyone
who runs the documented `npm run test:run` on Windows gets a red file
and a non-zero exit.

## Why

The per-test teardown at `src/tests/acp-v2.test.ts:156` is:

```ts
await rm(tempDir, { recursive: true, force: true });
```

`force: true` suppresses ENOENT only; it does not retry EBUSY. Each
test's cwd is a child of `tempDir` (`cwd = await
mkdtemp(path.join(tempDir, "project-"))`, line 147), and Windows keeps a
directory busy briefly after the process holding it exits, so `rmdir`
returns EBUSY, `afterEach` rejects, and vitest marks the test failed.

## The fix

```diff
-  await rm(tempDir, { recursive: true, force: true });
+  await rm(tempDir, { recursive: true, force: true, maxRetries: 10 });
```

`maxRetries` is Node's own retry loop for `fs.rm`, covering EBUSY,
EMFILE, ENFILE, ENOTEMPTY and EPERM with a backoff between attempts. The
repository already uses it for the same class of problem in
`src/tests/session-hooks.test.ts:150`:

```ts
// The CLI may still write the transcript of the last session after dispose.
if (root) await rm(root, { recursive: true, force: true, maxRetries: 10 });
```

## Measured after

```
$ node node_modules/vitest/vitest.mjs run src/tests/acp-v2.test.ts
 Test Files  1 passed (1)
      Tests  136 passed (136)
   Duration  16.16s (tests 96%, transform 2%, import 2%)
```

A second run after restoring the patched file: `Tests 136 passed (136)`,
`Duration 16.56s`, exit code 0, `EBUSY` count 0. `npm run check` (eslint
plus `prettier --check .`) also passes, and `npm run build` (`tsc`,
whose `include` covers `src/**/*.ts` so the test file is typechecked
too) exits 0.

## Scope

1 file changed, 1 insertion, 1 deletion: `src/tests/acp-v2.test.ts`.

Deliberately not changed:

- No other test file, and no teardown other than this one call.
`session-hooks.test.ts` is untouched.
- No assertion, fixture, mock or production code, so the 136 tests still
check exactly what they checked before.
- No CI change. Adding a Windows job is a separate call about CI time
and runner budget, and this fix can be verified on Windows without one.

## Unproven

- I have one Windows 11 machine. On `ubuntu-latest` the teardown did not
fail here before or after the patch, and I did not run the job on Linux;
the Linux side is "no behaviour change" by inspection, not something I
measured.
- `maxRetries: 10` covers a handle that closes shortly after the process
exits. A handle held for longer than the whole retry window would still
surface EBUSY; I did not observe one.
- I ran only this file, not the full suite, so I cannot say whether
other files fail on Windows for other reasons.
2026-10-26 10:25:48 +00:00
dependabot[bot]
01b686713d ci(deps): Bump actions/create-github-app-token from 3.2.0 to 3.3.0 (#1383)
Bumps
[actions/create-github-app-token](https://github.com/actions/create-github-app-token)
from 3.2.0 to 3.3.0.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/actions/create-github-app-token/releases">actions/create-github-app-token's
releases</a>.</em></p>
<blockquote>
<h2>v3.3.0</h2>
<h2><a
href="https://github.com/actions/create-github-app-token/compare/v3.2.0...v3.3.0">3.3.0</a>
(2026-10-12)</h2>
<h3>Features</h3>
<ul>
<li>update permission inputs (<a
href="https://redirect.github.com/actions/create-github-app-token/issues/399">#399</a>)
(<a
href="a47236f79f">a47236f</a>)</li>
</ul>
<h3>Bug Fixes</h3>
<ul>
<li><strong>deps:</strong> bump nested undici from 6.24.0 to 6.29.0 (<a
href="https://redirect.github.com/actions/create-github-app-token/issues/403">#403</a>)
(<a
href="12461a3a79">12461a3</a>)</li>
<li><strong>deps:</strong> bump the production-dependencies group across
1 directory with 3 updates (<a
href="https://redirect.github.com/actions/create-github-app-token/issues/395">#395</a>)
(<a
href="a69c19ddfd">a69c19d</a>)</li>
</ul>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/actions/create-github-app-token/blob/main/CHANGELOG.md">actions/create-github-app-token's
changelog</a>.</em></p>
<blockquote>
<h1>Changelog</h1>
<h2><a
href="https://github.com/actions/create-github-app-token/compare/v3.2.0...v3.3.0">3.3.0</a>
(2026-10-12)</h2>
<h3>Features</h3>
<ul>
<li>update permission inputs (<a
href="https://redirect.github.com/actions/create-github-app-token/issues/399">#399</a>)
(<a
href="a47236f79f">a47236f</a>)</li>
</ul>
<h3>Bug Fixes</h3>
<ul>
<li><strong>deps:</strong> bump nested undici from 6.24.0 to 6.29.0 (<a
href="https://redirect.github.com/actions/create-github-app-token/issues/403">#403</a>)
(<a
href="12461a3a79">12461a3</a>)</li>
<li><strong>deps:</strong> bump the production-dependencies group across
1 directory with 3 updates (<a
href="https://redirect.github.com/actions/create-github-app-token/issues/395">#395</a>)
(<a
href="a69c19ddfd">a69c19d</a>)</li>
</ul>
<h2><a
href="https://github.com/actions/create-github-app-token/compare/v3.1.1...v3.2.0">3.2.0</a>
(2026-05-12)</h2>
<h3>Features</h3>
<ul>
<li>add support for enterprise-level GitHub Apps (<a
href="https://redirect.github.com/actions/create-github-app-token/issues/263">#263</a>)
(<a
href="952a2a7073">952a2a7</a>)</li>
<li>support full repository names in <code>repositories</code> input (<a
href="https://redirect.github.com/actions/create-github-app-token/issues/372">#372</a>)
(<a
href="85eb8dd414">85eb8dd</a>)</li>
</ul>
<h3>Bug Fixes</h3>
<ul>
<li><strong>deps:</strong> bump <code>@​actions/core</code> from 3.0.0
to 3.0.1 in the production-dependencies group (<a
href="https://redirect.github.com/actions/create-github-app-token/issues/364">#364</a>)
(<a
href="43e5c345bf">43e5c34</a>)</li>
<li>validate private-key input (<a
href="https://redirect.github.com/actions/create-github-app-token/issues/376">#376</a>)
(<a
href="f24bbd8964">f24bbd8</a>)</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="64ab729a62"><code>64ab729</code></a>
chore(main): release 3.3.0 (<a
href="https://redirect.github.com/actions/create-github-app-token/issues/402">#402</a>)</li>
<li><a
href="f3a828e4b9"><code>f3a828e</code></a>
ci: create signed dist commits in release workflow (<a
href="https://redirect.github.com/actions/create-github-app-token/issues/404">#404</a>)</li>
<li><a
href="655017ffa7"><code>655017f</code></a>
docs: clarify when owner grants access to all repositories (<a
href="https://redirect.github.com/actions/create-github-app-token/issues/378">#378</a>)</li>
<li><a
href="12461a3a79"><code>12461a3</code></a>
fix(deps): bump nested undici from 6.24.0 to 6.29.0 (<a
href="https://redirect.github.com/actions/create-github-app-token/issues/403">#403</a>)</li>
<li><a
href="a47236f79f"><code>a47236f</code></a>
feat: update permission inputs (<a
href="https://redirect.github.com/actions/create-github-app-token/issues/399">#399</a>)</li>
<li><a
href="a69c19ddfd"><code>a69c19d</code></a>
fix(deps): bump the production-dependencies group across 1 directory
with 3 u...</li>
<li><a
href="d5cb7ae41d"><code>d5cb7ae</code></a>
build(deps): bump actions/checkout from 6 to 7 (<a
href="https://redirect.github.com/actions/create-github-app-token/issues/384">#384</a>)</li>
<li><a
href="575da866bd"><code>575da86</code></a>
build(deps): bump stefanzweifel/git-auto-commit-action from 7.1.0 to
7.2.0 in...</li>
<li><a
href="e85f677b44"><code>e85f677</code></a>
test: omit stack traces from snapshots (<a
href="https://redirect.github.com/actions/create-github-app-token/issues/401">#401</a>)</li>
<li><a
href="9419396df4"><code>9419396</code></a>
build(deps-dev): bump c8 from 11.0.0 to 12.0.0 (<a
href="https://redirect.github.com/actions/create-github-app-token/issues/400">#400</a>)</li>
<li>Additional commits viewable in <a
href="bcd2ba4921...64ab729a62">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=actions/create-github-app-token&package-manager=github_actions&previous-version=3.2.0&new-version=3.3.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Ben Brandt <benjamin.j.brandt@gmail.com>
2026-10-26 10:24:25 +00:00
KlausUllrich
6a33dedcbb fix: pass --settings before the auth status subcommand (#1392)
`runCliAuthProbe` builds `["auth", "status", "--json"]` and, when the
session has a settings
override, appends `--settings <json>`. `--settings` is a global option
of the Claude Code CLI;
the `auth status` subcommand only accepts `--json` and `--text` (see
`claude auth status --help`).
So every probe with an override fails with `error: unknown option
'--settings'` and an empty
stdout. The catch branch logs `claude auth status failed: …` on every
call and returns
`undefined`. For sessions that pass `_meta.claudeCode.options.settings`,
the session-creation and
per-prompt refreshes therefore never update the auth status. Only the
initialize probe, which has
no override, still works.

This moves `--settings <json>` in front of the subcommand. Without an
override the argv is
unchanged.

**Reproduce** (no credentials needed; an empty config dir answers "not
logged in"):

```sh
d=$(mktemp -d)
env -i HOME="$d" PATH="$PATH" CLAUDE_CONFIG_DIR="$d/.claude" \
  claude auth status --json --settings '{}'
# error: unknown option '--settings'
env -i HOME="$d" PATH="$PATH" CLAUDE_CONFIG_DIR="$d/.claude" \
  claude --settings '{}' auth status --json
# {"loggedIn": false, "authMethod": "none", ...}
```

The option is applied in the global position, not just parsed:
`claude --settings '{"apiKeyHelper":"/bin/echo rt-fake-key"}' auth
status --json` reports
`"loggedIn": true, "authMethod": "api_key_helper"`.

Tested with 10 native Claude Code binaries (linux-x64), each in an empty
`HOME`:

| Claude Code | `auth status --json --settings '{}'` | `--settings '{}'
auth status --json` |
|---|---|---|
| 2.1.286 | unknown option | JSON |
| 2.1.294 | unknown option | JSON |
| 2.1.295, 2.1.296, 2.1.299 | unknown option | JSON |
| 2.1.300, 2.1.301, 2.1.302 | unknown option | JSON |
| 2.1.303 (the SDK's `claudeCodeVersion`) | unknown option | JSON |
| 2.1.304 | unknown option | JSON |

The existing test in `auth-status.test.ts` pinned the old order; it now
expects `--settings`
first. Its mock and `statusCalls()` matched probes by `args[1] ===
"status"`, which a leading
global option shifts, so both now locate the subcommand instead.

`npm run build`, `format:check`, `lint` and `test:run` pass, apart from
one test that fails
identically on `main` without this change.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 10:24:11 +00:00
Slapoguzov Alexander
e369479f93 fix: preserve foreground fork reports until launch completes (#1431)
A foreground fork can stream a copied `tool_result` for the Agent call
that launched it. That placeholder completed the call and removed its
tracked state, so the real report arriving later was dropped. Ignore
only results whose tool ID equals the message's parent tool ID, keeping
the launching call open for its actual report.

Keep other results in the same message and discard the message-level
`tool_use_result` sidecar when its launch result was removed, so it
cannot be attributed to a remaining tool result. Regression coverage
exercises plain and Air clients, root and nested launch results, and a
mixed message containing a genuine nested Agent result.

Fixes #1389.

Validation:

- [Linux
CI](https://github.com/agentclientprotocol/claude-agent-acp/actions/runs/40217593917)
passes the full suite: 3,304 passed, 32 skipped (57 test files passed, 4
skipped). All PR checks are green.
- `npm run check` and `npm run build` pass.
- `vitest run src/tests/acp-agent.test.ts --maxWorkers=1`: 712 passed,
10 skipped. The four new regression cases fail on the original code and
pass with the fix.
- Eight actual-SDK runs against localhost fake gateways complete
successfully without model calls: plain and Air foreground forks, native
Air child sessions, nested forks with the background option omitted or
false, the SDK's fork-inside-fork rejection, a background launch
receipt, and history replay. Foreground reports arrive exactly once and
no launch-result tracking warning remains.

The local full-suite run encountered 19 failures in
`session-index.test.ts` and then stopped progressing, so it was
interrupted. A standalone recursive-file-watcher probe also emits
`EMFILE` on this machine. The successful Linux CI run above provides the
complete-suite verification.

Review dependencies (updated 26 October):

| Related proposal | Relationship to this PR |
| --- | --- |
|
[#1408](https://github.com/agentclientprotocol/claude-agent-acp/pull/1408)
— targeted session lookup | Independent. Both orders merge cleanly onto
the checked main and produce the same tree. |
|
[#1422](https://github.com/agentclientprotocol/claude-agent-acp/pull/1422)
— resumed subagents | Complementary, with no dependency on this PR. It
now conflicts with upstream main in `src/acp-agent.ts`; its author needs
to resolve that conflict, integrate the [separately shared AIR routing
patch](https://github.com/agentclientprotocol/claude-agent-acp/pull/1422#issuecomment-6401008862)
and complete its validation. |

<details>
<summary>Additional local integration validation — 25 October</summary>

A local combination of the recorded main, #1408, #1431, #1422 and the
already-reviewed routing patch passed 199 focused
foreground/resume/native-subagent checks (647 other cases filtered or
skipped), TypeScript build, and two replays of previously captured SDK
frames for AIR and standard subagents. The two captured-frame replays
were independently repeated. No new model calls were made.

The combination is a private test tree, not a published change. It does
not replace full CI for #1422 or live Air acceptance. The routing patch
needed three-way application against that recorded main because its
surrounding source had moved; it applied cleanly that way. The
current-main conflict in #1422 means this historical result does not
establish today’s combined compatibility.

Checked main `739dde3`, #1408 `e3b3aa4`, #1431 `8f96f46`, #1422
`dd3bf2c`. The two orders of #1408 and #1431 produce tree `1716d7e`; the
full local test tree is `61c0447`. Refresh this evidence if these inputs
change.

</details>

Co-authored-by: Codex <codex@openai.com>
2026-10-26 11:20:15 +01:00
dependabot[bot]
c26e640915 fix: bump body-parser from 2.3.0 to 2.4.0 (#1436)
Bumps [body-parser](https://github.com/expressjs/body-parser) from 2.3.0
to 2.4.0.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/expressjs/body-parser/releases">body-parser's
releases</a>.</em></p>
<blockquote>
<h2>v2.4.0</h2>
<h2>Important: Security</h2>
<ul>
<li>Security fix for <a
href="https://www.cve.org/CVERecord?id=CVE-2026-111181">CVE-2026-111181</a>
(<a
href="https://github.com/expressjs/body-parser/security/advisories/GHSA-27r3-jxmh-p828">GHSA-27r3-jxmh-p828</a>)</li>
</ul>
<h2>What's Changed</h2>
<ul>
<li>build(deps): bump actions/checkout from 6.0.3 to 7.0.0 by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/expressjs/body-parser/pull/737">expressjs/body-parser#737</a></li>
<li>chore: group github actions updates by <a
href="https://github.com/Phillip9587"><code>@​Phillip9587</code></a> in
<a
href="https://redirect.github.com/expressjs/body-parser/pull/743">expressjs/body-parser#743</a></li>
<li>build(deps): bump the github-actions group with 3 updates by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/expressjs/body-parser/pull/745">expressjs/body-parser#745</a></li>
<li>test: fix 'presist' typo in test descriptions by <a
href="https://github.com/maxtaran2010"><code>@​maxtaran2010</code></a>
in <a
href="https://redirect.github.com/expressjs/body-parser/pull/749">expressjs/body-parser#749</a></li>
<li>docs: include security fix in 2.3.0 changes by <a
href="https://github.com/UlisesGascon"><code>@​UlisesGascon</code></a>
in <a
href="https://redirect.github.com/expressjs/body-parser/pull/748">expressjs/body-parser#748</a></li>
<li>fix: avoid mutating read options when reading body by <a
href="https://github.com/Phillip9587"><code>@​Phillip9587</code></a> in
<a
href="https://redirect.github.com/expressjs/body-parser/pull/742">expressjs/body-parser#742</a></li>
<li>refactor: move getCharset helper to only usage by <a
href="https://github.com/Phillip9587"><code>@​Phillip9587</code></a> in
<a
href="https://redirect.github.com/expressjs/body-parser/pull/707">expressjs/body-parser#707</a></li>
<li>build(deps): bump the github-actions group with 7 updates by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/expressjs/body-parser/pull/753">expressjs/body-parser#753</a></li>
<li>refactor: switch to neostandard by <a
href="https://github.com/Phillip9587"><code>@​Phillip9587</code></a> in
<a
href="https://redirect.github.com/expressjs/body-parser/pull/751">expressjs/body-parser#751</a></li>
<li>perf(json): avoid first-char regex for direct JSON bodies by <a
href="https://github.com/Phillip9587"><code>@​Phillip9587</code></a> in
<a
href="https://redirect.github.com/expressjs/body-parser/pull/744">expressjs/body-parser#744</a></li>
<li>docs: fix entity verify error description by <a
href="https://github.com/Rawal27"><code>@​Rawal27</code></a> in <a
href="https://redirect.github.com/expressjs/body-parser/pull/755">expressjs/body-parser#755</a></li>
<li>feat: zstandard support by <a
href="https://github.com/Phillip9587"><code>@​Phillip9587</code></a> in
<a
href="https://redirect.github.com/expressjs/body-parser/pull/586">expressjs/body-parser#586</a></li>
<li>test: remove unused experimentalZstd option by <a
href="https://github.com/Phillip9587"><code>@​Phillip9587</code></a> in
<a
href="https://redirect.github.com/expressjs/body-parser/pull/757">expressjs/body-parser#757</a></li>
<li>ci: run tests on windows and macos matrix by <a
href="https://github.com/kilisamemarisaaa"><code>@​kilisamemarisaaa</code></a>
in <a
href="https://redirect.github.com/expressjs/body-parser/pull/759">expressjs/body-parser#759</a></li>
<li>deps: qs@^6.16.0 by <a
href="https://github.com/krzysdz"><code>@​krzysdz</code></a> in <a
href="https://redirect.github.com/expressjs/body-parser/pull/762">expressjs/body-parser#762</a></li>
<li>build(deps): bump the github-actions group with 3 updates by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/expressjs/body-parser/pull/763">expressjs/body-parser#763</a></li>
<li>ci: trigger npm publish on release published and set dist-tag by <a
href="https://github.com/UlisesGascon"><code>@​UlisesGascon</code></a>
in <a
href="https://redirect.github.com/expressjs/body-parser/pull/768">expressjs/body-parser#768</a></li>
<li>build(deps): bump the github-actions group with 3 updates by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/expressjs/body-parser/pull/771">expressjs/body-parser#771</a></li>
<li>fix(test): pin supertest to 7.3.0 to avoid 7.3.1 regression by <a
href="https://github.com/saadkhantareen"><code>@​saadkhantareen</code></a>
in <a
href="https://redirect.github.com/expressjs/body-parser/pull/773">expressjs/body-parser#773</a></li>
<li>perf: inline content stream setup by <a
href="https://github.com/Phillip9587"><code>@​Phillip9587</code></a> in
<a
href="https://redirect.github.com/expressjs/body-parser/pull/750">expressjs/body-parser#750</a></li>
<li>docs: note that req.body may be undefined in v2 by <a
href="https://github.com/Hashim1999164"><code>@​Hashim1999164</code></a>
in <a
href="https://redirect.github.com/expressjs/body-parser/pull/752">expressjs/body-parser#752</a></li>
<li>docs: fix strict option description by <a
href="https://github.com/Rawal27"><code>@​Rawal27</code></a> in <a
href="https://redirect.github.com/expressjs/body-parser/pull/756">expressjs/body-parser#756</a></li>
<li>docs: fix depth error description by <a
href="https://github.com/johnnyhuirilef"><code>@​johnnyhuirilef</code></a>
in <a
href="https://redirect.github.com/expressjs/body-parser/pull/776">expressjs/body-parser#776</a></li>
<li>chore: update dependencies by <a
href="https://github.com/Phillip9587"><code>@​Phillip9587</code></a> in
<a
href="https://redirect.github.com/expressjs/body-parser/pull/778">expressjs/body-parser#778</a></li>
<li>Release: 2.4.0 by <a
href="https://github.com/UlisesGascon"><code>@​UlisesGascon</code></a>
in <a
href="https://redirect.github.com/expressjs/body-parser/pull/777">expressjs/body-parser#777</a></li>
</ul>
<h2>New Contributors</h2>
<ul>
<li><a
href="https://github.com/maxtaran2010"><code>@​maxtaran2010</code></a>
made their first contribution in <a
href="https://redirect.github.com/expressjs/body-parser/pull/749">expressjs/body-parser#749</a></li>
<li><a href="https://github.com/Rawal27"><code>@​Rawal27</code></a> made
their first contribution in <a
href="https://redirect.github.com/expressjs/body-parser/pull/755">expressjs/body-parser#755</a></li>
<li><a
href="https://github.com/kilisamemarisaaa"><code>@​kilisamemarisaaa</code></a>
made their first contribution in <a
href="https://redirect.github.com/expressjs/body-parser/pull/759">expressjs/body-parser#759</a></li>
<li><a
href="https://github.com/saadkhantareen"><code>@​saadkhantareen</code></a>
made their first contribution in <a
href="https://redirect.github.com/expressjs/body-parser/pull/773">expressjs/body-parser#773</a></li>
<li><a
href="https://github.com/Hashim1999164"><code>@​Hashim1999164</code></a>
made their first contribution in <a
href="https://redirect.github.com/expressjs/body-parser/pull/752">expressjs/body-parser#752</a></li>
<li><a
href="https://github.com/johnnyhuirilef"><code>@​johnnyhuirilef</code></a>
made their first contribution in <a
href="https://redirect.github.com/expressjs/body-parser/pull/776">expressjs/body-parser#776</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/expressjs/body-parser/compare/v2.3.0...v2.4.0">https://github.com/expressjs/body-parser/compare/v2.3.0...v2.4.0</a></p>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/expressjs/body-parser/blob/master/HISTORY.md">body-parser's
changelog</a>.</em></p>
<blockquote>
<h1>2.4.0</h1>
<ul>
<li>Security fix for <a
href="https://github.com/expressjs/body-parser/security/advisories/GHSA-27r3-jxmh-p828">GHSA-27r3-jxmh-p828</a>
(CVE-2026-111181)</li>
<li>feat: add <code>zstd</code> content-encoding support (<a
href="https://redirect.github.com/expressjs/body-parser/issues/586">#586</a>)
<ul>
<li>Requires Node.js <code>^22.15.0</code> or <code>&gt;=23.8.0</code>;
older versions keep returning <code>415</code> for <code>zstd</code>
encoded bodies</li>
</ul>
</li>
<li>fix: avoid mutating the parser options and the request object while
reading the body (<a
href="https://redirect.github.com/expressjs/body-parser/issues/742">#742</a>,
<a
href="https://redirect.github.com/expressjs/body-parser/issues/750">#750</a>)</li>
<li>perf(json): avoid first-char regex for direct JSON bodies (<a
href="https://redirect.github.com/expressjs/body-parser/issues/744">#744</a>)</li>
<li>deps:
<ul>
<li>content-type@^2.1.0</li>
<li>iconv-lite^0.7.3</li>
<li>qs@^6.16.1</li>
</ul>
</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="b4744d739e"><code>b4744d7</code></a>
2.4.0 (<a
href="https://redirect.github.com/expressjs/body-parser/issues/777">#777</a>)</li>
<li><a
href="f7075eae3a"><code>f7075ea</code></a>
fix: prevent process crash from trailing bytes after a compressed
body</li>
<li><a
href="185ff1d555"><code>185ff1d</code></a>
chore: update dependencies (<a
href="https://redirect.github.com/expressjs/body-parser/issues/778">#778</a>)</li>
<li><a
href="73713f76c4"><code>73713f7</code></a>
docs: fix depth error description (<a
href="https://redirect.github.com/expressjs/body-parser/issues/776">#776</a>)</li>
<li><a
href="2dcc826c6d"><code>2dcc826</code></a>
docs: improve readability (<a
href="https://redirect.github.com/expressjs/body-parser/issues/756">#756</a>)</li>
<li><a
href="402dbdc6f1"><code>402dbdc</code></a>
docs: note that req.body may be undefined in v2 (<a
href="https://redirect.github.com/expressjs/body-parser/issues/752">#752</a>)</li>
<li><a
href="9883a1eeb4"><code>9883a1e</code></a>
perf: inline content stream setup (<a
href="https://redirect.github.com/expressjs/body-parser/issues/750">#750</a>)</li>
<li><a
href="02101da415"><code>02101da</code></a>
fix(test): pin supertest to 7.3.0 to avoid 7.3.1 regression (<a
href="https://redirect.github.com/expressjs/body-parser/issues/773">#773</a>)</li>
<li><a
href="822a0fd462"><code>822a0fd</code></a>
build(deps): bump the github-actions group with 3 updates (<a
href="https://redirect.github.com/expressjs/body-parser/issues/771">#771</a>)</li>
<li><a
href="aa9889bc35"><code>aa9889b</code></a>
ci: trigger npm publish on release published and set dist-tag (<a
href="https://redirect.github.com/expressjs/body-parser/issues/768">#768</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/expressjs/body-parser/compare/v2.3.0...v2.4.0">compare
view</a></li>
</ul>
</details>
<details>
<summary>Maintainer changes</summary>
<p>This version was pushed to npm by <a
href="https://www.npmjs.com/~GitHub%20Actions">GitHub Actions</a>, a new
releaser for body-parser since your current version.</p>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=body-parser&package-manager=npm_and_yarn&previous-version=2.3.0&new-version=2.4.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)
You can disable automated security fix PRs for this repo from the
[Security Alerts
page](https://github.com/agentclientprotocol/claude-agent-acp/network/alerts).

</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-26 11:19:09 +01:00
acp-release-bot[bot]
f6958db1ee chore(main): release 0.96.0 (#1425)
🤖 I have created a release *beep* *boop*
---


##
[0.96.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.95.0...v0.96.0)
(2026-10-26)


### Features

* report SendMessage delegations as linked session messages
([#1424](https://github.com/agentclientprotocol/claude-agent-acp/issues/1424))
([739dde3](739dde3ae5))
* report the model of each native subagent
([#1432](https://github.com/agentclientprotocol/claude-agent-acp/issues/1432))
([c319e5e](c319e5e542))


### Bug Fixes

* do not hold an AIR load replay until the CLI starts
([#1433](https://github.com/agentclientprotocol/claude-agent-acp/issues/1433))
([eb9d63e](eb9d63e5fd))
* refine RFD child models after a load, and retry a failed model update
([#1434](https://github.com/agentclientprotocol/claude-agent-acp/issues/1434))
([9a8bf71](9a8bf71d45))
* send subagents' current state after a load response
([#1423](https://github.com/agentclientprotocol/claude-agent-acp/issues/1423))
([17a967b](17a967b4da))
* update to Claude Code 2.1.310
([#1435](https://github.com/agentclientprotocol/claude-agent-acp/issues/1435))
([2f3c6bb](2f3c6bb7b3))

---
This PR was generated with [Release
Please](https://github.com/googleapis/release-please). See
[documentation](https://github.com/googleapis/release-please#release-please).

Co-authored-by: acp-release-bot[bot] <246668977+acp-release-bot[bot]@users.noreply.github.com>
2026-10-26 09:50:05 +00:00
Ben Brandt
2f3c6bb7b3 fix: update to Claude Code 2.1.310 (#1435)
Updates `@anthropic-ai/claude-agent-sdk` from 0.3.309 to **0.3.310**
(Claude Code **2.1.310**), `@agentclientprotocol/sdk` 2.0.1 → 2.0.2 and
`@anthropic-ai/sdk` 1.1.0 → 1.3.0.

TypeScript stays on 6.0.3, because typescript-eslint 8.72 still requires
`typescript <6.1.0`.

## Behaviour changes ACP users may notice

- **2.1.310:** the Monitor tool and plugin monitors now run on Bedrock,
Vertex and Foundry, and with telemetry or nonessential traffic disabled.
On those setups, long foreground sleeps are now blocked. We already hide
Monitor tasks from the client (`local_monitor` / `HIDDEN_TASK_TOOLS`),
so the only visible change is that a blocked sleep shows up as a failed
Bash call.
- **2.1.310:** when an MCP tool call times out because its elicitation
went unanswered, the error now tells Claude not to repeat the call.

## Upstream fixes this picks up

- Requests behind gateways that reject the `effort-2025-11-24` beta or
mid-conversation system messages are retried without them. This affects
our `providers/set` gateway sessions.
- After a "Prompt is too long" error, compaction now trims dense content
(base64, SVG) properly, so the next request no longer fails again.
Compaction in SDK sessions with very large transcripts also uses less
memory.
- File read permission prompts now show where a symbolic link leads.
- Windows: PreToolUse and PermissionRequest hooks no longer let a tool
call through when Git Bash is missing and the hook's command is only
quoted strings.

## Checked, no change needed

- **Agent SDK 0.3.310:** the `sdk.d.ts` diff is doc text only
(`connectorId` in the `sdkMcpServerConfigs` comment, and the provider
list for `'highlights'` thinking display). Every `CLAUDE_*` env var the
adapter reads is still in the 2.1.310 binary.
- **ACP SDK 2.0.2:** only fixes the HTTP transport (CR line endings in
SSE, request-scoped POST failures), which we don't use.
- **`@anthropic-ai/sdk` 1.2/1.3:** adds API endpoints (message threads,
usage/cost reports, Claude Tag); we only use its types.

Verified: `npm run build`, `npm run check`, `npm run test:run` (3323
passed, 30 skipped), and a typecheck of `examples/`.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-10-26 10:46:44 +01:00
nikita-ashihmin
9a8bf71d45 fix: refine RFD child models after a load, and retry a failed model update (#1434)
## Summary

Follow-up to #1433, which was merged before these review fixes landed.

- **RFD child models after a load.** On a real `session/load` (session
not live in this process), #1433 also gave `RfdSubagentReplay` no model
list, so after the load response `currentStates` could not map a child's
model to the root session's `model` entry. RFD children kept the raw
model ID. The replay now reads the session's models when needed; only
the in-replay mapping is off while the load replays.
- **Retry after a failed publish.** `reportModel` skipped a model that
matched the stored options, which are written before the publish. A
failed publish then blocked every later report of the same model. It now
compares with the last model the client actually received.
- **Failed load replay.** The replayed child models are also dropped
when the replay itself fails.
- The buffer of an unannounced child collapses only model reports from
`reportModel`; other config updates stay as they are.

## Tests

- `replayRfd` gained a `load` option that replays like a real load;
"sends the model of each child's responses, and after the load response
only a changed entry" uses it (fails without the fix).
- A retry after a failed publish, for the RFD and AIR forms.
- "refines an ended AIR child's model" now goes through the load path.
- `npm run build`, `npm run check`, `npm run test:run`: 3323 passed, 30
skipped.

## Related

- Follow-up to #1433 and #1432
- RFD change:
https://github.com/agentclientprotocol/agent-client-protocol/pull/2482
- RFD section: [Child
configuration](https://github.com/agentclientprotocol/agent-client-protocol/blob/main/docs/rfds/subagents.mdx#child-configuration)
- JetBrains issue:
[IJAI-4754](https://youtrack.jetbrains.com/issue/IJAI-4754)
2026-10-25 23:24:19 -07:00
nikita-ashihmin
eb9d63e5fd fix: do not hold an AIR load replay until the CLI starts (#1433)
## Summary

#1432 made an AIR `session/load` replay wait for the CLI start before it
sent the children's models. So the end of every such replay
(`loadReplayEnd` and the children's `subagent_state_update`) came only
after the CLI started, also for a history without subagents.

- The replay no longer waits. It sends each child's model as the replay
reaches it. During a load the session's model list is not known yet, so
the model goes as its id.
- After the load response, each replayed child whose model the list maps
to another entry gets that entry. This also applies to a child that the
replay already ended. It is a more exact snapshot of the same
configuration.
- `modelEntryFor` and `modelDisplayName` keep their results per model
list. `reportModel` skips a model that the child already has.
- The buffer of a child that is not announced yet keeps only its latest
configuration update, so model reports do not crowd out the child's
output.
- AIR: the runtime forgets the configuration of an ended child.

## Validation

- `npm run build`, `npm run check`
- `npm run test:run`, two runs: 3321 passed, 30 skipped.
- `src/tests/acp-scenarios.test.ts`, 30 runs in 6 parallel workers: no
failure.
- New tests: the load end event before the CLI start, with and without
subagents (both fail on `main`); the post-load refinement of an ended
child; the buffer; the cleanup; the memo after a list change.
- Microbenchmark of 10,000 child responses over 200 children, with 51
models:

  | | before | after |
  |---|---|---|
  | `modelEntryFor`, last entry | 12.16 µs | 0.23 µs |
  | `modelEntryFor`, no match | 64.60 µs | 0.08 µs |
  | `reportModel`, AIR | 13.66 µs | 4.03 µs |
  | `reportModel`, RFD | 12.80 µs | 3.23 µs |

## Related

- Follow-up to
https://github.com/agentclientprotocol/claude-agent-acp/pull/1432
- RFD change:
https://github.com/agentclientprotocol/agent-client-protocol/pull/2482
- JetBrains issue:
[IJAI-4754](https://youtrack.jetbrains.com/issue/IJAI-4754)
2026-10-25 23:05:36 -07:00
nikita-ashihmin
c319e5e542 feat: report the model of each native subagent (#1432)
## Summary

A native subagent child session gets its model only from what the child
reports. The update is a `config_option_update` with the child's session
id and a single `model` select option. This follows the subagents RFD,
[Child
configuration](https://github.com/agentclientprotocol/agent-client-protocol/blob/main/docs/rfds/subagents.mdx#child-configuration)
(see also agentclientprotocol/agent-client-protocol#2482).

## Relation to #1417

#1417 reports a child's model from its responses, in the `rfd` form
only, with the raw model id as the name. This PR keeps that source and
extends it:

- The `air` form gets the same update.
- History replay sends each child's model from its transcript.
- The value maps to an entry of the root session's `model` option, so a
client can compare the child's model with the session's.

There is no model at spawn. The adapter cannot tell the model that
Claude Code picks for a new child, for example with
`CLAUDE_CODE_SUBAGENT_MODEL_FORCE`, or when an alias runs on the
parent's exact model. So the first update comes with the child's first
real response (`message.model`, not `<synthetic>`). A new update goes
only when the entry changes.

The configuration hooks of #1417 (`PostModelSwitch` and the others) now
also run for `air` clients. A hook names the SDK agent id, so in the
`air` form its options go to the agent's current generation session
(`<agent id>:generation:<n>`). In the `air` form, no configuration goes
after a child's end.

## Notification

```json
{
  "sessionId": "<child session id>",
  "update": {
    "sessionUpdate": "config_option_update",
    "configOptions": [{
      "id": "model", "name": "Model", "category": "model", "type": "select",
      "currentValue": "opus[1m]",
      "options": [{ "value": "opus[1m]", "name": "Opus 4.6 (1M context)" }]
    }]
  }
}
```

- The client cannot change a child's configuration, so the select offers
only the current value.
- No field is added to `subagent_spawned` or `subagent_update`.

## Matching a model id to an entry

Best first:

1. the root session's current entry, when it runs the same model up to a
context hint, such as `opus[1m]` or `default` for `claude-opus-4-6`;
2. the listed entry whose value or resolved model is the id;
3. a listed entry that runs the same model up to a context hint.

When several entries run the model, a concrete entry comes before
`default`, unless `default` is the current entry. A model id is compared
as it is, so a dated or provider id (`claude-haiku-4-5-20251001`,
`eu.anthropic.claude-sonnet-5`) matches only the same id:
`ModelInfo.resolvedModel` is the wire id that a response reports. A
model that matches no entry goes as its full id, with its display name
when the session's model list has one. A `PostModelSwitch` hook of a
child uses the same matching.

## Replay

- A child without responses in its transcript gets no model.
- `rfd` form: the replay sends a child's model right after its
announcement, from its first response, and again when a later response
changes it. During a load, the session's model list can be unknown, so
the replay can send the full id. After the load response, a child's
model goes again only when the list maps it to another entry.
- `air` form: the replay ends every child, and nothing may follow the
end. So each child's latest response model goes once the session
creation ended, just before the child's end, as the entry of the
session's model list.

## Validation

- `npm run build`
- `npm run check`
- `npm run test:run`, three runs: 3315 passed, 30 skipped.
- `src/tests/acp-scenarios.test.ts`, 30 runs in 6 parallel workers: no
failure.

New tests cover:

- no model before the child's first response, in both forms;
- an update only when the entry changes;
- no update to an ended `air` child, also with an update in flight at
the end;
- a hook's options under the current `air` generation;
- matching for `opus[1m]` and `default`, concrete entries before
`default`, listed entries, dated and provider ids, and unlisted ids;
- the `PostModelSwitch` entry, in both forms;
- an `air` load replay that maps a child's model only once the session
exists;
- `rfd` replay with an unlisted full id, and no duplicate update after
the load response.

The scenario snapshots for both forms and v2 are updated.
2026-10-25 22:37:14 -07:00
Ben Brandt
739dde3ae5 feat: report SendMessage delegations as linked session messages (#1424)
In the subagents RFD form, a `SendMessage` call to a child now becomes a
message between the two sessions, as a launch's prompt already does
(#1405). Before, it was a tool call of its sender plus an unlinked
message in the child. Every delegation now has a parent-side message,
which a client can anchor a per-delegation card on, and both views name
each other with `senderMessageId`/`recipientMessageId`.

## Live

- The call is held until its final update. The SDK reports that the
message resumed a child before it reports the call's result.
- **Message reached an announced child:** the sender gets the outgoing
view and the child the incoming one (`linkedSessionMessages`). The
call's result isn't shown.
- **Message reached no announced child**, for example one sent to the
parent: the call is shown after all, with its final status
(`unexposedControlFallback`).
- **Call a permission request already showed:** it keeps its updates,
and its message goes only to the child.

## Replay

- A child's history replays right after its launch, so a call from a
parent to its child is always matched to its delivery first.
- The child's view then names the call, and the sender's view goes out
where the call sits.
- A call the replay reaches before its delivery stays a tool call,
unlinked. That can happen with a child's message to a sibling whose
history replays later.

`publishPrompt`'s `replacesLaunch` option is now `replacesCall`.

## Testing

- **Live:** the existing `SendMessage` tests in
`native-subagents.test.ts` and `acp-agent.test.ts` now expect both views
and check that the call and its completed result are hidden. A new test
checks that a call reaching no announced child is shown with its final
status.
- **Replay:** the existing test expects the linked views in place of
`root tool_call toolu_send`. A new test covers a call reached before its
delivery, and fails if delivery ignores that the call was already shown.
- `npm run check`, `npm run build` and `npm run test:run` (3301 tests)
pass locally.

Not tried against a real Claude Code run yet.
2026-10-25 00:14:54 +02:00
Ben Brandt
17a967b4da fix: send subagents' current state after a load response (#1423)
The subagents RFD makes the response to a load (v1 `session/load`, v2
`session/resume` with replay) the freshness boundary. Work states and
`cancel` grants sent before it are history, and "current snapshots are
sent after the response". The adapter sent each replayed child's end
only during the replay, so a client that follows the RFD strictly showed
every child as unknown after a reload. This replaces the RFD carve-out
proposed in agentclientprotocol/agent-client-protocol#2475.

After the response, each replayed child now gets one current
`subagent_update`:

- **A child still running in this process**, as when a live session is
loaded again, reports its live state and `capabilities.cancel`, as it
did live.
- **Any other child** can't be running, because the process that ran it
is gone. Its replayed end becomes its current state, with no grant.

When it's sent:

- **v1:** with the commands, in the timer that follows the setup
response (`afterSetupResponse`).
- **v2:** the surface sends it only once its own resume response is out,
after `setupCommands`. A timer set inside `loadSession` could fire
before that response.

`RfdSubagentReplay.finish` and the new `currentStates` share one
`endState`. `NativeSubagentRuntime.liveWorkState` exposes the live
state.

## Testing

- `sends each child's current state after the load response`: a live
child and a finished one, after the replay and its response.
- `replays a subagent's history to its own session…` (v2) now records
when the client receives the response. It checks that the child's
current `state_update` arrives after it, and that the parent's copy
grants nothing. It fails without the v2 change.
- `npm run check`, `npm run build` and `npm run test:run` (3300 tests)
pass locally.

Not tested end to end: the v1 timer path. It reuses the timer that
already sends v1 commands after the setup response.
2026-10-24 23:28:11 +02:00
acp-release-bot[bot]
ebfbfbdaa9 chore(main): release 0.95.0 (#1391)
🤖 I have created a release *beep* *boop*
---


##
[0.95.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.94.0...v0.95.0)
(2026-10-23)


### Features

* end a turn at its result for AIR clients with backgroundSubagents
([#1262](https://github.com/agentclientprotocol/claude-agent-acp/issues/1262))
([9b3a5c8](9b3a5c891c))
* report read-only subagent configuration
([#1417](https://github.com/agentclientprotocol/claude-agent-acp/issues/1417))
([6ab670b](6ab670baae))
* report RFD delegations as linked session messages
([#1405](https://github.com/agentclientprotocol/claude-agent-acp/issues/1405))
([59e292e](59e292e29f))
* serve experimental ACP v2
([#1400](https://github.com/agentclientprotocol/claude-agent-acp/issues/1400))
([4d378f9](4d378f93f3))
* show messages typed in other Remote Control clients
([#1371](https://github.com/agentclientprotocol/claude-agent-acp/issues/1371))
([5b11f47](5b11f470b1))
* update to Claude Code 2.1.309
([#1413](https://github.com/agentclientprotocol/claude-agent-acp/issues/1413))
([a1521b6](a1521b6196))


### Bug Fixes

* bump fast-uri from 3.1.8 to 3.1.9
([#1410](https://github.com/agentclientprotocol/claude-agent-acp/issues/1410))
([0fe22ae](0fe22ae5a1))
* bump postcss from 8.5.30 to 8.5.31 in the minor group
([#1403](https://github.com/agentclientprotocol/claude-agent-acp/issues/1403))
([ecaa2c9](ecaa2c935b))
* bump the minor group across 1 directory with 2 updates
([#1416](https://github.com/agentclientprotocol/claude-agent-acp/issues/1416))
([f7d6030](f7d6030c04))
* IJAI-3744 keep a session running while its background tasks are open
([#1399](https://github.com/agentclientprotocol/claude-agent-acp/issues/1399))
([e9f986b](e9f986b8a9))
* publish the session title as soon as the CLI has it
([#1406](https://github.com/agentclientprotocol/claude-agent-acp/issues/1406))
([50cd76e](50cd76e78d))
* publish turns that Claude Code starts after end_turn as an async task
in v1
([#1390](https://github.com/agentclientprotocol/claude-agent-acp/issues/1390))
([a1d0eee](a1d0eeeaad))

---
This PR was generated with [Release
Please](https://github.com/googleapis/release-please). See
[documentation](https://github.com/googleapis/release-please#release-please).

Co-authored-by: acp-release-bot[bot] <246668977+acp-release-bot[bot]@users.noreply.github.com>
2026-10-23 21:58:47 +02:00
Ben Brandt
6ab670baae feat: report read-only subagent configuration (#1417)
## Summary

Expose the observed configuration of RFD subagent sessions using the
same config-option IDs as ordinary sessions, without granting
client-initiated configuration changes.

- Report model from child responses and model-switch hooks, and mode,
applied reasoning effort, and agent type from child-attributed SDK
hooks.
- Merge partial observations into complete, ordered config snapshots;
buffer early observations until truthful child announcement and
serialize concurrent publications.
- Advertise only each observed current value. Do not inherit parent
settings or invent child Fast-mode state.
- Keep child plan-mode and model-switch hooks from changing the parent's
configuration.
- Cover initial/changed settings, announcement ordering, publication
failures, root isolation, and hook registration in both protocol
versions; update v2 scenario snapshots.

## Related

Protocol clarification:
https://github.com/agentclientprotocol/agent-client-protocol/pull/2468

Zed client implementation:
https://github.com/zed-industries/zed/pull/66058

## Validation

Passed after rebasing onto current `main`:

- `npm run build`
- `npm run check`
- `npm run test:run -- --silent`: 3,294 passed, 30 skipped.

No live Claude-to-Zed smoke test was run. Settings are reported only
when observed from the SDK; availability depends on runtime support and
events received.
2026-10-23 21:51:35 +02:00
dependabot[bot]
f7d6030c04 fix: bump the minor group across 1 directory with 2 updates (#1416)
Bumps the minor group with 2 updates in the / directory:
[@opentelemetry/api](https://github.com/open-telemetry/opentelemetry-js)
and [jose](https://github.com/panva/jose).

Updates `@opentelemetry/api` from 1.9.1 to 1.10.0
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/open-telemetry/opentelemetry-js/releases">@​opentelemetry/api's
releases</a>.</em></p>
<blockquote>
<h2>api/v1.10.0</h2>
<h2>1.10.0</h2>
<h3>🚀 Features</h3>
<ul>
<li>feat(api): integrate the Logs API into
<code>@opentelemetry/api</code> <a
href="https://redirect.github.com/open-telemetry/opentelemetry-js/pull/4862">#4862</a>
<a
href="https://github.com/hectorhdzg"><code>@​hectorhdzg</code></a></li>
<li>feat(api): add imperative <code>context.attach()</code> for setting
context across callback boundaries that <code>with()</code> cannot wrap.
<a
href="https://redirect.github.com/open-telemetry/opentelemetry-js/pull/6845">#6845</a>
<a href="https://github.com/pichlermarc"><code>@​pichlermarc</code></a>
<ul>
<li><code>attach()</code> returns a <code>Token</code> whose
<code>dispose()</code> method restores the previous context. Use
<code>token.dispose()</code> to detach.</li>
<li><code>attach</code> is an optional method on the
<code>ContextManager</code> interface; when the active context manager
does not implement it, <code>context.attach()</code> logs a warning and
returns a no-op token.</li>
</ul>
</li>
<li>feat(api): add <code>AnyValue</code> type (of type
<code>unknown</code>) and extend <code>Attributes</code> type values to
use <code>AnyValue</code> <a
href="https://redirect.github.com/open-telemetry/opentelemetry-js/pull/6780">#6780</a>
<a href="https://github.com/trentm"><code>@​trentm</code></a>
<ul>
<li>This is part of supporting <a
href="https://github.com/open-telemetry/opentelemetry-specification/blob/main/oteps/4485-extending-attributes-to-support-complex-values.md#how">OTEP
4485</a>.</li>
<li>This is a <strong>breaking change for SDK implementations</strong>,
in that they may need to support more attribute types for any APIs that
accept <code>Attributes</code> or <code>AnyValue</code>. Implementors
should refer to the OTEP 4485 link above for &quot;MUST / SHOULD&quot;
requirements for complex attributes handling. See also the <a
href="https://github.com/open-telemetry/opentelemetry-js/blob/main/packages/opentelemetry-core/src/common/attributes.ts">attributes-handling
utilities provided by <code>@opentelemetry/core</code> to help</a>. For
example, <code>cleanSimpleAttributes()</code> can be used to limit
user-provided attributes to the set of value types supported in earlier
<code>@opentelemetry/api</code> versions.</li>
<li>This change is <strong>typically not a breaking change for
users</strong> of <code>@opentelemetry/api</code>. There is one possible
breaking case: If a TypeScript user was using the
<code>Attributes</code> type in their own code and using <a
href="https://www.typescriptlang.org/docs/handbook/2/narrowing.html">TypeScript
Narrowing</a> to reduce an attribute value to an expected type (<a
href="https://redirect.github.com/open-telemetry/opentelemetry-js/pull/6579#issuecomment-4345316200">example
here</a>). It is expected that this usage should be rare and, likely,
limited to SDK implementations, if at all. (If this impacted your code,
<a
href="https://github.com/open-telemetry/opentelemetry-js/issues/new">please
let us know</a>.)</li>
<li><strong>Note for users of attributes:</strong> Simple attributes
SHOULD be used whenever possible. Instrumentations SHOULD assume that
backends do not index individual properties of complex attributes, that
querying or aggregating on such properties is inefficient and
complicated, and that reporting complex attributes carries higher
performance overhead.</li>
</ul>
</li>
</ul>
<h3>🐛 Bug Fixes</h3>
<ul>
<li>fix(api): cap tracestate list-members and total length in the
deprecated <code>TraceState.set()</code> so it stays in step with
<code>TraceState</code> from <code>@opentelemetry/core</code> <a
href="https://redirect.github.com/open-telemetry/opentelemetry-js/pull/6964">#6964</a>
<a href="https://github.com/Zuhef"><code>@​Zuhef</code></a></li>
<li>fix: resolve the ESM build through the <code>default</code> export
condition instead of <code>import</code>, so a resolver that activates
neither <code>import</code> nor <code>require</code> still finds an
entry point <a
href="https://redirect.github.com/open-telemetry/opentelemetry-js/pull/7132">#7132</a>
<a
href="https://github.com/overbalance"><code>@​overbalance</code></a></li>
</ul>
<h3>📚 Documentation</h3>
<ul>
<li>docs(api): complete <code>@since</code> annotations for the public
API <a
href="https://redirect.github.com/open-telemetry/opentelemetry-js/pull/7023">#7023</a>
<a
href="https://github.com/nabeelamjadsheikh"><code>@​nabeelamjadsheikh</code></a>
<ul>
<li>Adds the three missing exported-type annotations and the
member-level annotations for methods and properties introduced after
their enclosing type.</li>
<li>Corrects <code>Attributes</code>, <code>AttributeValue</code> and
<code>TracerOptions</code> from <code>1.3.0</code> to
<code>1.1.0</code>; all three were already importable in 1.1.0 via
wildcard re-export.</li>
</ul>
</li>
</ul>
<h3>🏠 Internal</h3>
<ul>
<li>build: migrate the package build from <code>tsc</code> to
<code>tsdown</code>, emitting dual CJS/ESM output from a single
<code>dist/</code> directory <a
href="https://redirect.github.com/open-telemetry/opentelemetry-js/pull/6293">#6293</a>
<a href="https://github.com/overbalance"><code>@​overbalance</code></a>
<ul>
<li>The public entry points are unchanged:
<code>@opentelemetry/api</code> and
<code>@opentelemetry/api/experimental</code>. This package already
declared an <code>exports</code> map, so deep imports such as
<code>@opentelemetry/api/build/src/...</code> were rejected before this
change and continue to be.</li>
<li>The emitted files moved out of <code>build/src</code> (CJS),
<code>build/esm</code> and <code>build/esnext</code> (ESM) into
<code>dist/</code>, using <code>.cjs</code>/<code>.mjs</code> extensions
with matching <code>.d.cts</code>/<code>.d.mts</code> declarations. The
non-standard <code>esnext</code> condition has been dropped in favour of
the standard <code>import</code>/<code>require</code> conditions.</li>
</ul>
</li>
<li>perf(api): add getGlobal fast-path <a
href="https://redirect.github.com/open-telemetry/opentelemetry-js/pull/6956">#6956</a>
<a
href="https://github.com/legendecas"><code>@​legendecas</code></a></li>
<li>chore: remove the redundant <code>module</code> field from
<code>package.json</code>; bundlers resolve the ESM build through the
<code>exports</code> map <a
href="https://redirect.github.com/open-telemetry/opentelemetry-js/pull/7130">#7130</a>
<a
href="https://github.com/overbalance"><code>@​overbalance</code></a></li>
</ul>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/open-telemetry/opentelemetry-js/blob/main/CHANGELOG.md">@​opentelemetry/api's
changelog</a>.</em></p>
<blockquote>
<h2>1.10.0</h2>
<h3>🚀 (Enhancement)</h3>
<ul>
<li>feat(resource): create sync resource with some attributes that
resolve asynchronously <a
href="https://redirect.github.com/open-telemetry/opentelemetry-js/pull/3460">#3460</a>
<a
href="https://github.com/samimusallam"><code>@​samimusallam</code></a></li>
<li>feat: collect host id for non-cloud environments <a
href="https://redirect.github.com/open-telemetry/opentelemetry-js/pull/3575">#3575</a>
<a href="https://github.com/mwear"><code>@​mwear</code></a></li>
<li>feat (api-logs): separate Events API into its own package <a
href="https://redirect.github.com/open-telemetry/opentelemetry-js/pull/3550">3550</a>
<a
href="https://github.com/martinkuba"><code>@​martinkuba</code></a></li>
<li>feat(sdk-metrics): apply binary search in histogram recording <a
href="https://redirect.github.com/open-telemetry/opentelemetry-js/pull/3539">#3539</a>
<a
href="https://github.com/legendecas"><code>@​legendecas</code></a></li>
<li>perf(propagator-jaeger): improve deserializeSpanContext performance
<a
href="https://redirect.github.com/open-telemetry/opentelemetry-js/pull/3541">#3541</a>
<a href="https://github.com/doochik"><code>@​doochik</code></a></li>
<li>feat: support TraceState in SamplingResult <a
href="https://redirect.github.com/open-telemetry/opentelemetry-js/pull/3530">#3530</a>
<a
href="https://github.com/raphael-theriault-swi"><code>@​raphael-theriault-swi</code></a></li>
<li>feat(sdk-trace-base): add diagnostic logging when spans are dropped
<a
href="https://redirect.github.com/open-telemetry/opentelemetry-js/pull/3610">#3610</a>
<a
href="https://github.com/neoeinstein"><code>@​neoeinstein</code></a></li>
<li>feat: add unit to view instrument selection criteria <a
href="https://redirect.github.com/open-telemetry/opentelemetry-js/pull/3647">#3647</a>
<a href="https://github.com/jlabatut"><code>@​jlabatut</code></a></li>
<li>feat(tracing): expose dropped counts for attributes, events and
links on span <a
href="https://redirect.github.com/open-telemetry/opentelemetry-js/pull/3576">#3576</a>
<a href="https://github.com/mohitk05"><code>@​mohitk05</code></a></li>
</ul>
<h3>🐛 (Bug Fix)</h3>
<ul>
<li>fix(core): added falsy check to make otel core work with browser
where webpack config had process as false or null <a
href="https://redirect.github.com/open-telemetry/opentelemetry-js/issues/3613">#3613</a>
<a
href="https://github.com/ravindra-dyte"><code>@​ravindra-dyte</code></a></li>
<li>fix(instrumentation-http): include query params in http.target <a
href="https://redirect.github.com/open-telemetry/opentelemetry-js/pull/3646">#3646</a>
<a href="https://github.com/kobi-co"><code>@​kobi-co</code></a></li>
</ul>
<h3>📚 (Refine Doc)</h3>
<ul>
<li>chore: update http example <a
href="https://redirect.github.com/open-telemetry/opentelemetry-js/pull/3651">#3651</a>
<a
href="https://github.com/JamieDanielson"><code>@​JamieDanielson</code></a></li>
</ul>
<h3>🏠 (Internal)</h3>
<ul>
<li>chore(exporter-jaeger): deprecate jaeger exporter <a
href="https://redirect.github.com/open-telemetry/opentelemetry-js/pull/3585">#3585</a>
<a
href="https://github.com/pichlermarc"><code>@​pichlermarc</code></a></li>
<li>fix(sdk-metrics): fix flaky LastValueAggregator test by using fake
timer <a
href="https://redirect.github.com/open-telemetry/opentelemetry-js/pull/3587">#3587</a>
<a
href="https://github.com/pichlermarc"><code>@​pichlermarc</code></a></li>
<li>fix(test): fix failing tests by preventing source-map generation <a
href="https://redirect.github.com/open-telemetry/opentelemetry-js/pull/3642">#3642</a>
<a
href="https://github.com/pichlermarc"><code>@​pichlermarc</code></a></li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="56e6b1bb89"><code>56e6b1b</code></a>
chore: Release API 1.4.1, SDK 1.10.0, Experimental 0.36.0 (<a
href="https://redirect.github.com/open-telemetry/opentelemetry-js/issues/3654">#3654</a>)</li>
<li><a
href="ecb5ebe86e"><code>ecb5ebe</code></a>
fix: Minor typing issues for web sandbox (<a
href="https://redirect.github.com/open-telemetry/opentelemetry-js/issues/3660">#3660</a>)</li>
<li><a
href="48ecf229eb"><code>48ecf22</code></a>
feat(sdk-trace-base): add dropped attributes and events count on span
(<a
href="https://redirect.github.com/open-telemetry/opentelemetry-js/issues/3576">#3576</a>)</li>
<li><a
href="95bf6fc7cc"><code>95bf6fc</code></a>
fix(build): add <code>example/http</code> to ignore list in
<code>update-ts-configs</code> (<a
href="https://redirect.github.com/open-telemetry/opentelemetry-js/issues/3657">#3657</a>)</li>
<li><a
href="3a2ca58075"><code>3a2ca58</code></a>
feat: add unit to view instrument selection criteria (<a
href="https://redirect.github.com/open-telemetry/opentelemetry-js/issues/3647">#3647</a>)</li>
<li><a
href="ebc8575a0c"><code>ebc8575</code></a>
chore(jeager-exporter): deprecate jaeger exporter (<a
href="https://redirect.github.com/open-telemetry/opentelemetry-js/issues/3585">#3585</a>)</li>
<li><a
href="2f715bd78d"><code>2f715bd</code></a>
feat: allow to add attributes from grpc metadata in the patched server
(<a
href="https://redirect.github.com/open-telemetry/opentelemetry-js/issues/3589">#3589</a>)</li>
<li><a
href="cbbdfd3798"><code>cbbdfd3</code></a>
fix(sdk-node): update instrumentations once MeterProvider is initialized
(<a
href="https://redirect.github.com/open-telemetry/opentelemetry-js/issues/3624">#3624</a>)</li>
<li><a
href="4a4484a910"><code>4a4484a</code></a>
chore: update http example (<a
href="https://redirect.github.com/open-telemetry/opentelemetry-js/issues/3651">#3651</a>)</li>
<li><a
href="494efffcf4"><code>494efff</code></a>
fix(otlp-exporter-base): fix changelog for merged retry pr (<a
href="https://redirect.github.com/open-telemetry/opentelemetry-js/issues/3650">#3650</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/open-telemetry/opentelemetry-js/compare/v1.9.1...v1.10.0">compare
view</a></li>
</ul>
</details>
<br />

Updates `jose` from 6.2.12 to 6.2.13
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/panva/jose/releases">jose's
releases</a>.</em></p>
<blockquote>
<h2>v6.2.13</h2>
<h3>Refactor</h3>
<ul>
<li>align terminology with JOSE specifications (<a
href="bafcb74f5f">bafcb74</a>)</li>
<li>preserve underlying error causes (<a
href="724ba24b5a">724ba24</a>),
closes <a
href="https://redirect.github.com/panva/jose/issues/908">#908</a></li>
</ul>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/panva/jose/blob/main/CHANGELOG.md">jose's
changelog</a>.</em></p>
<blockquote>
<h2><a
href="https://github.com/panva/jose/compare/v6.2.12...v6.2.13">6.2.13</a>
(2026-10-20)</h2>
<h3>Refactor</h3>
<ul>
<li>align terminology with JOSE specifications (<a
href="bafcb74f5f">bafcb74</a>)</li>
<li>preserve underlying error causes (<a
href="724ba24b5a">724ba24</a>),
closes <a
href="https://redirect.github.com/panva/jose/issues/908">#908</a></li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="316b7f2cb4"><code>316b7f2</code></a>
chore(release): 6.2.13</li>
<li><a
href="8106395886"><code>8106395</code></a>
chore: bump packages</li>
<li><a
href="724ba24b5a"><code>724ba24</code></a>
refactor: preserve underlying error causes</li>
<li><a
href="b112a5e6e1"><code>b112a5e</code></a>
chore: bump packages</li>
<li><a
href="2a3a525968"><code>2a3a525</code></a>
chore(deps-dev): bump workerd in /tap/workerd</li>
<li><a
href="8f540d89de"><code>8f540d8</code></a>
chore(deps): bump the actions group with 3 updates</li>
<li><a
href="8d6c02b5fa"><code>8d6c02b</code></a>
chore(deps-dev): bump electron from 44.0.0 to 44.4.5 in
/tap/electron</li>
<li><a
href="55c959fd16"><code>55c959f</code></a>
ci: use gh run rerun for retries</li>
<li><a
href="5b00c649d7"><code>5b00c64</code></a>
ci: address zizmor findings and bump shared workflows</li>
<li><a
href="1761d7f1b5"><code>1761d7f</code></a>
chore: bump packages</li>
<li>Additional commits viewable in <a
href="https://github.com/panva/jose/compare/v6.2.12...v6.2.13">compare
view</a></li>
</ul>
</details>
<br />


Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore <dependency name> major version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's major version (unless you unignore this specific
dependency's major version or upgrade to it yourself)
- `@dependabot ignore <dependency name> minor version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's minor version (unless you unignore this specific
dependency's minor version or upgrade to it yourself)
- `@dependabot ignore <dependency name>` will close this group update PR
and stop Dependabot creating any more for the specific dependency
(unless you unignore this specific dependency or upgrade to it yourself)
- `@dependabot unignore <dependency name>` will remove all of the ignore
conditions of the specified dependency
- `@dependabot unignore <dependency name> <ignore condition>` will
remove the ignore condition of the specified dependency and ignore
conditions


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-23 19:49:50 +00:00
Ben Brandt
28a09b1008 chore: remove the unused ts-node devDependency (#1414)
Removes `ts-node` 10.9.2 from devDependencies. Nothing in the repo uses
it: no script, config or test references it. `npm run build` uses `tsc`,
tests run through vitest, and `npm run example:simple-client` uses `node
--experimental-strip-types`.

Verified: `npm run build`, `npm run check`, `npm run test:run`.
2026-10-23 21:43:16 +02:00
Ben Brandt
a1521b6196 feat: update to Claude Code 2.1.309 (#1413)
Updates `@anthropic-ai/claude-agent-sdk` from 0.3.303 to **0.3.309**
(Claude Code **2.1.309**), `@agentclientprotocol/sdk` 2.0.0 → 2.0.1,
`@anthropic-ai/sdk` 0.135.0 → 1.1.0 and typescript-eslint 8.71.2 →
8.72.0.

TypeScript stays on 6.0.3. typescript-eslint requires `typescript
<6.1.0` and has no TS 7 support yet
(typescript-eslint/typescript-eslint#10940), so we wait for that rather
than running two TypeScript versions side by side.

## Uses new SDK features

- **`/usage` recommendations (0.3.305):** our structured `/usage`
Markdown now lists Claude Code's per-window suggestions (`- **41%**
Disable my_server · /mcp`) above the MCP server table, in the order
Claude Code sends them. `fix` is not rendered: its messages must be
queued at priority `later` with `client_composed` set, which an ACP
prompt cannot express. A malformed recommendation makes the response
fail validation, so we fall back to Claude Code's own `/usage` text.
- **`wakeup_source` on `command_lifecycle` "started" (0.3.307):** when a
ScheduleWakeup or another scheduled task starts a turn with no prompt
open, the async-task (AIR) "Claude is working" task now says `A
scheduled wakeup fired (set for <UTC time>).` or `A scheduled task
fired.` instead of `Claude Code started a turn without a prompt.` It
works whether the frame arrives before or after `session_state_changed:
running`; older CLIs and unknown values behave as before.

## Fixes for changes in the new CLI

- **Fork transcripts (2.1.307):** the placeholder `tool_result` text in
a fork's copied prefix changed (`Fork started — that fork is you.` /
`This is the parent's call, not yours. …`). `stripForkPrefix` matches
only the directive text, so behaviour is unchanged; its doc comment and
the replay fixture now describe both formats.

## Behaviour changes ACP users may notice

- **2.1.304:** tool search is on by default behind a custom
`ANTHROPIC_BASE_URL` (our `providers/set` gateway sessions), and a
gateway that rejects it is retried without it (2.1.305).
- **2.1.304:** `rm -r` of a mounted drive or a top-level folder on it
(`/Volumes/…`, `/media/…`, `/mnt/c/…`) now asks for permission, even in
bypass mode, without an always-allow option.
`CLAUDE_CODE_DISABLE_MOUNTED_DRIVE_RM_PROMPT` turns it off.
- **2.1.309:** with `sandbox.enabled` but no runnable sandbox, commands
in `sandbox.excludedCommands` now ask before writing to the sandbox's
write-list paths, and the unsandboxed fallback is refused.
- **2.1.304:** `allowedMcpServers` no longer filters `managed-mcp.json`
stdio servers that use `${VAR}` only in `env`.

## Upstream fixes this picks up

- Model switches, prompts and interrupts no longer wait behind our
background `getContextUsage` call (2.1.304); `setModel()` waits up to 15
s for the API to confirm a model (2.1.307).
- A PreToolUse hook's deny reason is no longer replaced by a timeout of
our `Edit|Write` preview callback hook (2.1.304).
- Host-supplied `managedSettings` no longer drop `disableAllHooks`,
`disableSkillShellExecution` and empty HTTP hook lists (0.3.309).
- Gateway fixes: `context_management` rejections are retried without the
field (2.1.304); no `prompt-caching-scope` header behind custom gateways
(2.1.307).
- A disabled MCP server is no longer reconnected by a later permission
prompt in SDK sessions; HTTP MCP tool calls are no longer sent twice
when the connection drops (2.1.307/2.1.309).
- Bash permission and sandbox hardening (2.1.309), fewer "prompt is too
long" and autocompact-thrashing errors, and faster resume of large
compacted sessions.

## Checked, no change needed

- **ACP SDK 2.0.1** only changes the client-side `createHttpStream`
transport, which we don't use.
- **`@anthropic-ai/sdk` 1.x** (API version 2026-10-21, Bearer auth,
sampling params removed): we only use its types, none of which changed
in a way that affects us. Neither our `dist/` nor the Agent SDK runtime
imports it, and the bundled CLI carries its own client. It is Apache-2.0
from 1.0.0 (previously MIT).
- **`sed -i` (2.1.309)** runs as plain Bash; SDK sessions already did,
since `_simulatedSedEdit` only exists in the TUI preview.
- The remaining type changes (`liveReply`, context-usage row `id`s,
`pluginId`/`path` on agents and skills, new settings keys) are additive
and unused.

Verified: `npm run build`, `npm run check`, `npm run test:run` (3277
passed, 30 skipped), and a typecheck of `examples/`.
2026-10-23 21:43:02 +02:00
dependabot[bot]
0fe22ae5a1 fix: bump fast-uri from 3.1.8 to 3.1.9 (#1410)
Bumps [fast-uri](https://github.com/fastify/fast-uri) from 3.1.8 to
3.1.9.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/fastify/fast-uri/releases">fast-uri's
releases</a>.</em></p>
<blockquote>
<h2>v3.1.9</h2>
<h2>Security</h2>
<p>This security release fixes four URI parsing and normalization
vulnerabilities in fast-uri 3.x. Applications that process untrusted URI
input should upgrade to v3.1.9.</p>
<ul>
<li><strong>Medium — URI scheme spoofing through percent-encoded
schemes</strong> (<a
href="https://github.com/fastify/fast-uri/security/advisories/GHSA-w74x-v6f9-j3xg">GHSA-w74x-v6f9-j3xg</a>,
CVE-2026-110306): percent-encoded scheme input is now rejected instead
of being decoded into a live scheme.</li>
<li><strong>Medium — Host confusion during domain-host
canonicalization</strong> (<a
href="https://github.com/fastify/fast-uri/security/advisories/GHSA-wjr9-j795-jxcr">GHSA-wjr9-j795-jxcr</a>,
CVE-2026-110180): malformed domain hosts containing ambiguous delimiters
or stripped control characters are now rejected instead of being
silently rewritten.</li>
<li><strong>Low — Incorrect relative-reference normalization</strong>
(<a
href="https://github.com/fastify/fast-uri/security/advisories/GHSA-xhq7-xmqj-769h">GHSA-xhq7-xmqj-769h</a>,
CVE-2026-110095): dot-segment removal now preserves the resolution
semantics of relative references.</li>
<li><strong>Low — Scheme injection in suffix references</strong> (<a
href="https://github.com/fastify/fast-uri/security/advisories/GHSA-333x-w4r6-6crp">GHSA-333x-w4r6-6crp</a>,
CVE-2026-110725): first-segment colons in path-only suffix references
are now escaped so the output is not reparsed as a scheme.</li>
</ul>
<p>Upgrade with:</p>
<pre lang="sh"><code>npm install fast-uri@3.1.9
</code></pre>
<p>Thanks to <a href="https://github.com/zekth"><code>@​zekth</code></a>
for reporting the scheme, host, and relative-reference issues, and to <a
href="https://github.com/UlisesGascon"><code>@​UlisesGascon</code></a>
for finding the suffix-reference issue and reviewing the
remediations.</p>
<h2>What's Changed</h2>
<ul>
<li>ci: enable trusted publishing releases (v3.x) by <a
href="https://github.com/mcollina"><code>@​mcollina</code></a> in <a
href="https://redirect.github.com/fastify/fast-uri/pull/229">fastify/fast-uri#229</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/fastify/fast-uri/compare/v3.1.8...v3.1.9">https://github.com/fastify/fast-uri/compare/v3.1.8...v3.1.9</a></p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="0def70338a"><code>0def703</code></a>
Bumped v3.1.9</li>
<li><a
href="a1d0da2f5c"><code>a1d0da2</code></a>
test: generate only relative path references</li>
<li><a
href="9eb8759a98"><code>9eb8759</code></a>
fix: preserve relative paths in suffix and relative resolution</li>
<li><a
href="54c30f7589"><code>54c30f7</code></a>
docs: note suffix-reference authority ambiguity</li>
<li><a
href="39e6493c13"><code>39e6493</code></a>
fix: escape a first-segment colon in suffix references</li>
<li><a
href="745183c1d9"><code>745183c</code></a>
test: cover first-segment colon in suffix references</li>
<li><a
href="ddfc6a5112"><code>ddfc6a5</code></a>
fix(SEC-03): reject non reg-name characters in domain hosts</li>
<li><a
href="d86a8d29f4"><code>d86a8d2</code></a>
fix: keep a colon-first segment behind &quot;./&quot; in relative
normalization</li>
<li><a
href="eb483b5da2"><code>eb483b5</code></a>
test: cover colon-first relative references in normalization</li>
<li><a
href="b110a3571c"><code>b110a35</code></a>
fix(SEC-02): preserve meaning of relative references during
normalization</li>
<li>Additional commits viewable in <a
href="https://github.com/fastify/fast-uri/compare/v3.1.8...v3.1.9">compare
view</a></li>
</ul>
</details>
<details>
<summary>Maintainer changes</summary>
<p>This version was pushed to npm by <a
href="https://www.npmjs.com/~GitHub%20Actions">GitHub Actions</a>, a new
releaser for fast-uri since your current version.</p>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=fast-uri&package-manager=npm_and_yarn&previous-version=3.1.8&new-version=3.1.9)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-23 20:54:32 +02:00
Ben Brandt
59e292e29f feat: report RFD delegations as linked session messages (#1405)
## Summary

For a client on the subagents RFD (every ACP v2 client, and v1 clients
that declare `subagents`), session messages now replace the Agent/Task
call that launches a child. The parent no longer sees that tool call.
AIR's form is unchanged.

- **Prompt.** The launch's prompt is a `session_message` in both
transcripts: the parent's outgoing view and the child's incoming one.
Both use the launch's tool use id, and each names the other with
`recipientMessageId` / `senderMessageId` from
agentclientprotocol/agent-client-protocol#2465.
- **Answer.** A foreground child's answer, from the launch's result, is
the child's `session_message` to the parent (`<launch id>:reply`). It
uses the result as `AgentReporter` renders it, so the model-facing
`agentId: …` line and `<usage>` block are gone. A failure or
cancellation comes from the child's `idle` stop reason, which it already
reported. A background launch's result gets no reply.
- **Hand-back mode.** Claude Code (seen with 2.1.303) can have the child
hand its report back with a `SubagentHandback` call; the Agent result
then only says that the report "was delivered to you as a message". That
call is reported as the child's message to the parent, in both
transcripts and linked the same way, and the launch's reply is skipped
for that run. A failed hand-back delivered nothing, so it stays a tool
call of the child.
- **Unexposed work.** A launch whose child is never exposed is shown as
a tool call after all, with its final status
(`unexposedControlFallback`, the former `failedControlFallback`).
- **Hidden launches by id.** The adapter's own report that closes an
unfinished call (for example at a forced cancel) carries no `toolName`,
so the runtime tracks hidden launches by id to keep that report from
reaching the client for a call it never saw.
- **Replay** sends the same messages from the child's transcript: the
launch's prompt, the reply, and hand-backs. A launch without Claude
Code's metadata file keeps its tool call and the missing-history notice.
Replay can't tell a child's runs apart, so any hand-back in its
transcript suppresses its launch's reply.
- **SendMessage** calls stay tool calls of their sender, and the child
gets the incoming view without references, as before.
- README's subagent section describes the new behavior.

Do not merge before agentclientprotocol/agent-client-protocol#2465
settles the field names: a merge to `main` publishes a preview right
away. Clients on the published schema ignore the two fields (no
`deny_unknown_fields` in the Rust schema; the scenario validation with
the SDK's zod schema passes).

## Testing

- `npm run build`, `npm run check` and `npm run test:run` pass (57
files, 4 skipped as before).
- The six v2 `subagent-*.jsonl` scenario snapshots changed: the launch's
tool-call updates are gone, the parent gets the outgoing view, the
child's view carries `senderMessageId`, and foreground results became
`:reply` messages. AIR snapshots are unchanged.
- New unit tests cover hidden launches, nested launches, replies, failed
and background launches, unexposed launches with both final statuses,
the close report without a tool name, and hand-back success and failure,
live and on replay.
- A real run: the built adapter driven over v2 stdio with Claude Code,
one foreground subagent asked to answer PONG, then `session/resume` with
replay in a fresh process. Live and replay both showed the linked prompt
(parent → child), the linked hand-back "PONG" (child → parent), and no
Agent tool call.
2026-10-23 13:12:33 +02:00
Ben Brandt
50cd76e78d fix: publish the session title as soon as the CLI has it (#1406)
## Summary

Session titles showed up only after the first turn ended. With
background subagents, that could be minutes or hours later. Now the
adapter asks for the title when the prompt is sent to the CLI. In a live
run the title arrived **0.8s** after the prompt, compared with **17.9s**
(at turn end) on `main`.

## Why

- **The CLI already titles SDK sessions.** The bundled CLI (2.1.303)
titles a new session from its first prompt within seconds and stores it
as an `ai-title` transcript entry. `getSessionInfo` returns it as
`customTitle`. The header comment in `session-titles.ts` said the CLI
never titles SDK sessions; that is out of date. The CLI's off switch
only applies to the `sdk-cli` entrypoint without
`CLAUDE_AGENT_SDK_VERSION`, and the TS SDK sets `sdk-ts` and that
variable.
- **No message carries an AI title.** The CLI's internal
`system/session_title_changed` covers only `/rename`-style names, and
its schema says so.
- **The adapter only checked at `idle`.** `SessionTitles.onTurnEnd` ran
at `session_state_changed: idle`, which comes after the whole turn.
Since CLI 2.1.270 it also waits for background subagents to finish. In
the common case, `onTurnEnd` just picked up the title that had been on
disk for the whole turn.

## Change

- **New `SessionTitles.onPromptQueued`**, called right after the prompt
is pushed to the SDK. Until the session has a title, it:
1. reads the session file, and publishes a stored title if there is one
(a resumed session, or a `/rename`);
2. otherwise sends `generate_session_title` with the prompt text only,
in the background, and publishes the result.
- **No second model call.** The CLI runs this request alongside the turn
instead of queuing it behind it. Its handler returns the stored custom
title, then the AI title, and otherwise waits for the CLI's own title
call already in flight.
- **One try per prompt, like the CLI.** After a prompt-time request, the
turn end does not ask again. If nothing came back, it publishes the
stored summary as before, and the next prompt tries again.
- **Unchanged:**
- the turn end still titles a prompt too short to title from (under 10
characters, like "hi") from the prompt plus the reply;
  - the turn end still picks up a later `/rename`;
  - a title the client set still wins.

## Verification

- `npm run check` and `npm run test:run` pass (3275 passed, 30 skipped).
- New tests:
  - the title is published while the first turn is still running;
- the raw prompt is not published at turn end while a title is still
pending;
- a short "hi" prompt is titled at turn end from the prompt and the
reply.
- The latch-release test now pins one request per prompt. Removing the
new call site makes two of the tests fail.
- Live check: one real text-only turn against this branch and against
`main`, both on the bundled CLI 2.1.303, timing `session_info_update`
against the prompt response:

  | build | title published | prompt response |
  | --- | --- | --- |
  | this branch | 0.8s | 17.1s |
  | `main` | 17.9s | 17.9s |

Both runs got the same title, and both transcripts contain the same two
`ai-title` entries. So the change adds no title generation; the CLI
itself writes the entry twice.

## Risks

- **Undocumented CLI behaviour.** This depends on the CLI answering
`generate_session_title` mid-turn and returning the title it is already
generating, which `sdk.d.ts` does not document. If a future CLI changes
that, the worst case is one extra small-model call per session. The
latch still limits it to one success.
- **Titles now arrive mid-turn.** Clients that expected
`session_info_update` only between turns should be checked.
- **Steering is not covered.** Messages injected with
`_session/steering` don't trigger a title request.
2026-10-23 11:08:18 +00:00
Ben Brandt
aa62f44886 ci: resolve latest Node LTS patches before using runner cache (#1404)
## Summary
- Enable `check-latest: true` for all three Node setup steps: CI, stable
publishing and preview publishing.
- Preserve the existing `lts/*` policy, action pins, npm settings,
package version and `engines.node` requirement.

## Why
The latest sampled
[CI](https://github.com/agentclientprotocol/claude-agent-acp/actions/runs/39865544394)
and [preview
publishing](https://github.com/agentclientprotocol/claude-agent-acp/actions/runs/39865544489)
runs both selected cached Node **24.21.0**. That predates **24.22.1**,
the fixed Node 24 release for the [October 21 security
updates](https://nodejs.org/en/blog/vulnerability/october-2026-security-releases),
including CVE-2026-111497.

## Validation
The same checks passed before and after:
- Baseline on local Node **24.22.0**: `npm ci && npm run check && npm
run build && npm run test:run`.
- After on official Node **24.22.1** (archive SHA-256 verified): `npm ci
&& npm run check && npm run build && npm run test:run`.
- Both runs: **3,272 passed, 30 skipped**; the skipped/integration
coverage remains unverified.
- Parsed workflow configuration to verify all three setup steps keep
`lts/*` and enable `check-latest`.
- Tests ran natively on macOS; GitHub runner selection remains to be
confirmed by PR CI. No publish or release commands were run.

## Risk / rollout
Low-risk CI-only runtime freshness change. Verify the next
CI/stable/preview job's setup-node log resolves at least **24.22.1** for
Node 24. Reverting restores cache-first selection, which can restore an
unpatched runtime; it is not a mitigation.

This patches the build/publishing environment, not end users' separately
installed Node runtimes. No application-code or release-version changes
are included.
2026-10-23 11:19:16 +02:00
nikita-ashihmin
9b3a5c891c feat: end a turn at its result for AIR clients with backgroundSubagents (#1262)
## What

This PR adds the AIR client capability `backgroundSubagents`. A client
declares it in
`initialize.clientCapabilities._meta.jetbrains.air.capabilities`.

With the capability, an ACP v1 prompt turn ends at its result. This is
also true while background subagents of the turn still run, including a
subagent that SendMessage resumed. Subagent updates, permission requests
and elicitations then go out after the prompt response. Their routing
does not change. The agent advertises the capability only to ACP v1 AIR
clients.

## Why

The hold of #864/#866 keeps `session/prompt` open until the subagents
finish. The Claude CLI ends the model turn at `result`, and only `idle`
waits for the background agents. In AIR the hold hides the end of the
answer, and the next user message steers into an old turn. One
SendMessage-resumed subagent kept a turn open for about two hours.

## Unchanged

- A client without the capability keeps the hold exactly. So #866 stays
fixed for clients that stop listening at the prompt response.
- ACP v2 keeps the hold too.

## Related changes

- Out of a turn, the ExitPlanMode request of such a client does not
offer the clear-context option. The fresh query must continue an ACP
turn, and there is none.
- An ExitPlanMode interruption of an autonomous cycle with no held turn
now clears its markers.

## Docs

`docs/air-extensions.md` has a new "Background subagents" section.

## Tests

New capability cases:

- a fresh subagent;
- a SendMessage-resumed subagent;
- a permission request after the prompt response, with the
`parentToolUseId` attribution;
- an autonomous followup that does not end the queued next prompt;
- ExitPlanMode out of a turn, with and without a reset;
- the exclusion of a running subagent from the incomplete-tool check.

The existing tests cover the path without the capability. When the gate
is disabled, 7 of the 8 new cases fail. The eighth is the in-turn
control case and passes in both configurations.

`npm run build`, `npm run check` and `npm run test:run` pass. The
exception is 4 `providers.test.ts` cases. They depend on the environment
(a local proxy `baseUrl`) and fail on `main` too.
2026-10-22 15:31:05 -07:00
dependabot[bot]
ecaa2c935b fix: bump postcss from 8.5.30 to 8.5.31 in the minor group (#1403)
Bumps the minor group with 1 update:
[postcss](https://github.com/postcss/postcss).

Updates `postcss` from 8.5.30 to 8.5.31
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/postcss/postcss/releases">postcss's
releases</a>.</em></p>
<blockquote>
<h2>8.5.31</h2>
<ul>
<li>Fix better source map on <code>&lt;/style&gt;</code> escaping (by <a
href="https://github.com/firefoxic"><code>@​firefoxic</code></a>).</li>
</ul>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/postcss/postcss/blob/main/CHANGELOG.md">postcss's
changelog</a>.</em></p>
<blockquote>
<h2>8.5.31</h2>
<ul>
<li>Fix better source map on <code>&lt;/style&gt;</code> escaping (by <a
href="https://github.com/firefoxic"><code>@​firefoxic</code></a>).</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="d1908579d6"><code>d190857</code></a>
Release 8.5.31 version</li>
<li><a
href="4ef6d8cc03"><code>4ef6d8c</code></a>
Update dependencies</li>
<li><a
href="6abd674231"><code>6abd674</code></a>
Call the builder with every node when the output is escaped (<a
href="https://redirect.github.com/postcss/postcss/issues/2175">#2175</a>)</li>
<li>See full diff in <a
href="https://github.com/postcss/postcss/compare/8.5.30...8.5.31">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=postcss&package-manager=npm_and_yarn&previous-version=8.5.30&new-version=8.5.31)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore <dependency name> major version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's major version (unless you unignore this specific
dependency's major version or upgrade to it yourself)
- `@dependabot ignore <dependency name> minor version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's minor version (unless you unignore this specific
dependency's minor version or upgrade to it yourself)
- `@dependabot ignore <dependency name>` will close this group update PR
and stop Dependabot creating any more for the specific dependency
(unless you unignore this specific dependency or upgrade to it yourself)
- `@dependabot unignore <dependency name>` will remove all of the ignore
conditions of the specified dependency
- `@dependabot unignore <dependency name> <ignore condition>` will
remove the ignore condition of the specified dependency and ignore
conditions


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-22 16:38:37 +02:00
Charles McLaughlin
5b11f470b1 feat: show messages typed in other Remote Control clients (#1371)
A message typed in claude.ai or the Claude mobile app while Remote
Control is on never appeared in the client that hosts the session, even
though Claude's reply to it did. This makes both sides of that
conversation show up.

Two causes, one commit each:

- **Messages from other clients were dropped.** Claude Code echoes them
to the adapter as a replayed user message with `origin: { kind: "human"
}` and a uuid the adapter never created. That matched no turn, so it
fell into the "unrelated replay" branch and was ignored. Human-origin,
top-level, non-synthetic messages that match no turn are now forwarded
as `user_message_chunk` (mapped to `user_message` on ACP v2). The echoes
of the adapter's own prompts still match their turn first and stay out
of the feed.
- **The stream reader was not running after `/remote-control`.** The
reader that forwards Claude Code's output starts with the first prompt,
and `/remote-control` answers without a turn. In a thread where it was
the first thing run, nothing read the stream, so neither the phone's
message nor Claude's reply reached the client. The command now starts
the reader. This also fixes the reply side for #1197.

## Testing

- `npm run check`, `npm run build` and `npm run test:run` pass, with new
tests in `remote-user-messages.test.ts` and `remote-control.test.ts`.
The tests for both fixes fail without them.
- Ran the built adapter as a custom agent in Zed: after
`/remote-control`, a message sent from the Claude iOS app showed up in
the Zed thread along with Claude's reply.
- Not tested: messages with images or attachments, and whether resuming
a session can replay old human messages through the same path.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-21 18:05:56 +00:00
Ilya Kirillov
e9f986b8a9 fix: IJAI-3744 keep a session running while its background tasks are open (#1399)
## Summary

A Claude Code session can start a background shell command or a
background sub-agent, and then end its turn. When a task ends, Claude
Code wakes the session for a short turn. The session index reported
`idle` at each turn end, so a client saw one completion for each task.

Now the index finds the open background tasks in the transcript. A turn
end with an open task stays `running`, also after 10 minutes. The
session gets one `idle`, when the last task ends. The rules are the same
as in the native Claude provider of AIR (IJAI-3744).

## Rules

- A task opens with a tool result that has `backgroundTaskId`, or
`isAsync` with an `agentId`.
- A task closes with its `<task-notification>` (a user record or a queue
`remove`), or with a `TaskStop` result.
- The wait ends when no live process holds the session, or when the
registry status is `idle`.
- A task can start before the tail window. A later scan applies the tail
on top of the earlier task set. When it cannot, the index reads the
whole transcript, but only for a finished turn of a live session.

## Not in this PR

- A background task that a sub-agent starts. Its start record is only in
the transcript of the sub-agent.
- A session that this connection runs. Its state comes from the SDK.

## Tests

- `background-tasks.test.ts`: the record orders of the native provider
tests, in a small transcript and in one longer than the tail window. The
tests also cover a task before the tail window, the end of the wait, and
the count of whole-file reads.
- A real check with Claude Code 2.1.306 in a terminal. With two
background shells or two background sub-agents, `main` gave three `idle`
and this change gave one.

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 15:27:16 +02:00
Ben Brandt
4d378f93f3 feat: serve experimental ACP v2 (#1400)
## Summary

The draft ACP v2 is no longer opt-in. The adapter always connects
through the SDK's protocol router (as codex-acp does), so a client that
asks for protocol version 2 or later in `initialize` gets v2, and every
other client gets v1 as before. v2 is still experimental and follows the
draft as it changes.

- Removes `CLAUDE_AGENT_ACP_EXPERIMENTAL_V2`, which only turned on what
is now the default.
- Adds `CLAUDE_AGENT_ACP_PROTOCOL` to limit the adapter to one version:
  - unset, empty, or `auto`: both, picked by the client's `initialize`.
- `v1`: a client that asks for v2 is negotiated down to v1. This is the
escape hatch if a client's v2 handling breaks.
- `v2`: a v1 client's `initialize` is rejected with `-32022 Unsupported
protocol version`.
- Any other value fails at startup, so a typo like
`CLAUDE_AGENT_ACP_PROTOCOL=1` doesn't silently serve both.
- Fixes the comment in `src/acp-subagents.ts` that said v2 clients don't
get subagents.

Note: a client that already sends `protocolVersion >= 2` switches to the
v2 surface as soon as this lands on `main`, which publishes a preview.

## Testing

- New routing tests: down-negotiation to v1, rejecting v1 when limited
to v2, env parsing.
- `npm run check`, `npm run build`, `npm run test:run` pass.
- Ran the built binary over stdio with each setting: default → v2 for a
v2 client, `v1` → protocol version 1, `v2` → `-32022` for a v1 client,
invalid value → exits with an error at startup.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 13:37:06 +02:00
Andrey Bragin
a1d0eeeaad fix: publish turns that Claude Code starts after end_turn as an async task in v1 (#1390)
## Motivation

Claude Code can start a turn of its own after a prompt has ended, for
example to answer the notification of a finished background task. ACP v1
has no session state, and the client already got `end_turn` for its last
prompt. The output of that turn just keeps arriving as session updates,
and the client has no signal that the agent is working again, or when it
stops.

## Change

The adapter now publishes such a turn as an async task (AIR async tasks
extension) with the id `autonomous-turn-<uuid>`, the name `Claude is
working`, `taskType: "turn"` and `showInTranscript: false`. Its
description is the summary of the last task notification, when there is
one.

- **Start:** the SDK moves to `running` while no prompt is active or
queued. From CLI 2.1.270 the state stays `running` while background
agents run, so the task also starts at the first root output with no
prompt open.
- **End:** the task ends `completed` at the turn's result or at `idle`,
and `failed` at an error result. A coalesced placeholder result
(`num_turns: 0`) does not end it.
- **Folded prompt:** a prompt that the turn takes in ends the task,
because that prompt owns the turn now.
- **Stop:** `_session/async_task/stop` on this task interrupts the turn,
since the SDK has no task to stop. It returns `{ "stopped": false }`
while a prompt waits for its turn, because the interrupt would drop that
prompt.
- **Lifecycle:** the SDK task list does not end the task, because the
SDK does not know about it. Shutdown fails it like other open tasks.

Clients without the async tasks capability get nothing new. The behavior
is documented in `docs/air-extensions.md` under "Autonomous turn".

## Tests

`acp-agent.test.ts` covers the start at `running` and at the first
output, the end at the result, the folded prompt, stop as an interrupt,
and a client without async tasks. `async-tasks.test.ts` covers the SDK
task list reconciliation and shutdown.
2026-10-20 15:24:42 +02:00
acp-release-bot[bot]
7bcbfc7a5c chore(main): release 0.94.0 (#1381)
🤖 I have created a release *beep* *boop*
---


##
[0.94.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.93.0...v0.94.0)
(2026-10-19)


### Features

* send one end event after the session/load history replay for AIR
([#1361](https://github.com/agentclientprotocol/claude-agent-acp/issues/1361))
([1420994](1420994ced))


### Bug Fixes

* match OpenRouter model ids in the gateway model listing
([#1365](https://github.com/agentclientprotocol/claude-agent-acp/issues/1365))
([bcf74c2](bcf74c2fe8))
* resolve a skill's SKILL.md by its declared name, not by its directory
name
([#1372](https://github.com/agentclientprotocol/claude-agent-acp/issues/1372))
([424db87](424db87535))
* update to ACP SDK 2.0.0
([#1386](https://github.com/agentclientprotocol/claude-agent-acp/issues/1386))
([ff01e06](ff01e06579))
* update to Claude Code 2.1.303
([#1384](https://github.com/agentclientprotocol/claude-agent-acp/issues/1384))
([2dab922](2dab922644))

---
This PR was generated with [Release
Please](https://github.com/googleapis/release-please). See
[documentation](https://github.com/googleapis/release-please#release-please).

Co-authored-by: acp-release-bot[bot] <246668977+acp-release-bot[bot]@users.noreply.github.com>
2026-10-19 21:29:13 +00:00
dependabot[bot]
2dab922644 fix: update to Claude Code 2.1.303 (#1384)
Updates `@anthropic-ai/claude-agent-sdk` from 0.3.300 to **0.3.303**
(Claude Code **2.1.303**), along with dependabot's minor-group bumps
(`@anthropic-ai/sdk`, eslint, vitest and transitive packages). 0.3.304
is only on the `next` dist-tag, so it is not included.

## Fixes for changes in the new SDK

The Agent SDK turns on fork subagents (`subagent_type: "fork"`) by
default in SDK sessions (0.3.302). Before this, ACP sessions never got
them. Live streaming already handles forks. History replay did not:

- **Replay showed the parent's launching message inside the fork.** A
fork's transcript starts with a copy of the parent's launching assistant
message (with the same API message id and every `tool_use`), then a user
message with a `"Fork started — processing in background"` result per
`tool_use` and a `<fork-boilerplate>…Your directive: <prompt>` text. On
`session/load`, the parent's text, tool calls and those results appeared
in the fork, and the fork's prompt showed the full boilerplate.
Replaying the copy also consumed the parent's tool-use cache entries, so
the parent's real Agent/parallel tool results were dropped. In the AIR
form, sibling forks were re-registered under the fork.
`readSubagentMessages` now drops everything before the directive message
and reduces it to the prompt (`stripForkPrefix`).
- **Lineage scan** (`subagentLineage`) ignores launch calls in a fork's
copied prefix, so it no longer attributes the parent's launches to the
fork.
- **Agent title** no longer shows `(high effort)` for a fork: a fork
ignores `effort` and runs at the parent's effort.

## Changelog review: nothing else to change

- **0.3.302: an omitted `permissionMode` now starts in `default` on
third-party providers.** The agent always passes `permissionMode`
explicitly, so this does not apply.
- **0.3.301: a full-disk login refresh now reports `invalid_request`
instead of `authentication_failed`/401.** This now maps to `bad_request`
rather than `auth_required`. That's the right outcome, since `/login`
cannot fix a full disk. Its text has no "Please run /login", so the
synthetic-login check does not catch it.
- **0.3.301: `setModel()` no longer fails at the usage limit.** This is
a free fix for model switches.
- **0.3.302: structured output refusals are now error results.** These
are covered by the existing result handling.
- **New APIs not adopted:**
  - Per-tool `permission_policy` on sdk MCP servers: we register none.
  - `reloadPlugins({ addPlugins })`: there's no plugin reload surface.
- `BUILTIN_TOOL_NAMES`/`LEGACY_TOOL_NAME_ALIASES`: `@alpha`, and nothing
here needs them.
  - MCP `_meta.ui`: unused.

Verified: `npm run build`, `npm run check`, `npm run test:run` (3205
passed). New tests cover the fork replay trace (RFD form),
`stripForkPrefix`, the lineage scan with a fork transcript (it fails
without the fix), and the fork title.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Ben Brandt <benjamin.j.brandt@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 23:26:23 +02:00
Ben Brandt
ff01e06579 fix: update to ACP SDK 2.0.0 (#1386)
Updates `@agentclientprotocol/sdk` from 1.9.0 to 2.0.0.

SDK 2.0.0 makes the default entrypoints stable-only and removes the
legacy connection classes ([migration
guide](https://github.com/agentclientprotocol/typescript-sdk/blob/main/MIGRATION_STABLE_UNSTABLE.md)).

- Protocol imports move to `@agentclientprotocol/sdk/unstable` and
`@agentclientprotocol/sdk/experimental/v2/unstable`. The agent uses
unstable features (session forking, `systemPrompt`, turn usage,
subagents, v2 draft extras), so this keeps the same protocol surface as
1.9.0. No wire changes.
- The scenario schema validator loads `schema/v1/schema.unstable.json`,
because the unversioned `schema/schema.json` export was removed.
- `remote-control.test.ts` uses the repo's own `AcpClient` type instead
of the removed `AgentSideConnection`.
- Runtime code already used the `agent()` app API, so it needed no
migration.

Verified: `npm run build`, `npm run test:run` (3199 passed), `npm run
check`, and a separate typecheck of `examples/`.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 21:01:53 +00:00
Charles McLaughlin
e155ecb419 test: expect the v2 state snapshot after the replay end event (#1385)
`main` has failed CI since #1361, on one test: `ACP v2 prompts > ends a
resume with replay with one end event for a client with loadReplayEnd`.

Two PRs that merged separately collided:

- #1366 made a v2 resume send a `state_update` snapshot after the replay
and before the response ("v2 requires a snapshot of the foreground state
after the replay", in `src/v2/agent.ts`).
- #1361 added the `loadReplayEnd` end event and this test, which
expected the end event to be the last update of the response. The test
predates the snapshot, so each PR passed CI on its own.

The snapshot is required by the v2 spec, so the code is right and the
test is stale. The test now expects the end event followed by the
snapshot, and still checks that the end event is sent once. Only
`src/tests/acp-v2.test.ts` changes.

## Testing

- `npm run check`, `npm run build` and `npm run test:run` pass (3199
passed, 30 skipped).
- The same test fails on `main` at `bcf74c2` without this change.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 22:12:23 +02:00
Andrey Efimov
bcf74c2fe8 fix: match OpenRouter model ids in the gateway model listing (#1365)
The model picker was empty behind OpenRouter: `normalizeModelId` did not
match `anthropic/claude-opus-5.5:batch[1m]` to `claude-opus-5-5`. It now
strips a leading `anthropic/` and a `:variant` suffix, and maps `5.5` to
`5-5`.
2026-10-19 08:33:33 +00:00
nikita-ashihmin
1420994ced feat: send one end event after the session/load history replay for AIR (#1361)
## What

A client that declares the AIR capability `loadReplayEnd` gets one extra
`session/update` after the history replay of `session/load`:

```json
{
  "sessionId": "<loaded session>",
  "update": {
    "sessionUpdate": "session_info_update",
    "_meta": { "jetbrains": { "air": { "version": 1, "loadReplayEnd": true } } }
  }
}
```

The client declares the capability in
`clientCapabilities._meta.jetbrains.air.capabilities`, as it declares
the other AIR extensions. The agent does not advertise it.

## Why

ACP has no signal that the history replay of `session/load` is over,
other than the response. This adapter replays the transcript while the
Claude Code CLI starts, and the CLI start takes most of the load time.
So the history is whole long before the response. With this event, the
client can show the history at once.

The protocol side is the RFD
https://github.com/agentclientprotocol/agent-client-protocol/pull/2407.

## Design change

The first version of this PR marked each replayed notification with an
index (`loadSessionReplayIndex`) and the last one with
`loadSessionEndOfRecordedHistory`. To know the last one, it held the
latest update and queued every other outbound message of the session
behind it. That design is replaced with a capability-gated single end
event. The replay sends as before. Only one event is added at its end.
There are no indexes, no held update and no queue.

## When the event is sent

- After every replayed update, the subagent history that the parent
replay sends to the child sessions included, and before the
`session/load` response.
- Also for an empty history.
- For `session/load` of a session that starts while the replay runs, for
`session/load` of a session that is already live, and for the ACP v2
`session/resume` with `replayFrom: {type: "start"}`.

It is not sent:

- When the replay stops (the CLI start failed during the replay) or
fails.
- For a live update.
- To a client without the capability.

The update has no `title` and no `updatedAt`. A `null` there would clear
the value. The load can still fail after the event, for example when the
CLI start fails after the replay. The event means that the recorded
history arrived whole.

## Client status

JetBrains Air shows the history before the load answer only after the
Kotlin SDK delivers the updates of a loading session before the answer.
An SDK PR for this is in progress.

## Tests

- `create-session-options.test.ts`: the event comes once after the
replay and before the response; before the CLI start finishes; for an
empty history; for an already live session; not when the CLI start fails
part way; not when the replay fails; not without the capability. The
event has no `title` and no `updatedAt` key.
- `acp-agent.test.ts`: the subagent history from its own transcript file
comes before the event.
- `acp-v2.test.ts`: v2 `session/resume` with a replay ends with the
event before the response; a resume without a replay sends none.
- `docs/air-extensions.md`: a new "Load replay end" section, and the
capability and key rows.
2026-10-19 08:05:46 +04:00
nikita-ashihmin
424db87535 fix: resolve a skill's SKILL.md by its declared name, not by its directory name (#1372)
## Problem

Claude Code advertises a skill by the `name:` of its `SKILL.md`
frontmatter, which does not have to match the name of the directory — or
of the symlink — that holds the file. `resolveSkillPath` joined the
advertised name into each container path and only checked whether the
result exists, so every such skill resolved to nothing.

For an AIR client the consequence is visible:
`getAvailableSlashCommands` attaches `_meta.jetbrains.air.kind =
"skill"` and the `SKILL.md` path only when the path resolves, so the
skill reached the client as a plain command and disappeared from the
skill completion, while `/name` kept working. Symlinked skills hit this
most often, because the link name is usually chosen independently of the
skill's declared name.

Reproduced with a project whose `.claude/skills/mismatch/SKILL.md`
declares `name: frontname`, and with `.claude/skills/aliased2 ->
…/target2` declaring `name: front2`: both were advertised without any
`_meta`. The same happens in a large repo where
`.claude/skills/rubymine` declares `name: rubymine-workflows`.

## Fix

The existing join-by-name probe stays the first attempt, so nothing
changes for the common layout. When it misses, the same containers are
scanned, in the same order, for a `SKILL.md` whose frontmatter declares
the requested name:

- `statSync`/`existsSync` follow links, so a skill behind a symlinked
directory is scanned like any other;
- each container is listed per lookup, and a `SKILL.md` whose
modification time has not changed keeps the name of the previous scan,
so an unchanged file is parsed once;
- only the frontmatter block of at most 8 KB is parsed, so a `name:` in
the body is not a name;
- a directory that is itself named like the skill still wins over
another skill that merely declares the name.

## Tests

Eight cases in `src/tests/tools.test.ts` cover a directory/declared-name
mismatch, a symlink with a differing link name in both `.claude/skills`
and `.agents/skills`, the precedence of the directory that carries the
name, scoped and plugin spellings resolved by declared name, a file
without frontmatter, and a declared name that changes after the first
resolution.

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Happy <yesreply@happy.engineering>
2026-10-19 08:05:25 +04:00
acp-release-bot[bot]
dbf386d8d3 chore(main): release 0.93.0 (#1369)
🤖 I have created a release *beep* *boop*
---


##
[0.93.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.92.0...v0.93.0)
(2026-10-16)


### Features

* add /remote-control command to start Remote Control for a session
([#1197](https://github.com/agentclientprotocol/claude-agent-acp/issues/1197))
([db9f50f](db9f50f021))


### Bug Fixes

* hold a fork back from the session list until its session/fork response
is out
([#1368](https://github.com/agentclientprotocol/claude-agent-acp/issues/1368))
([f5967fd](f5967fd6b4))

---
This PR was generated with [Release
Please](https://github.com/googleapis/release-please). See
[documentation](https://github.com/googleapis/release-please#release-please).

Co-authored-by: acp-release-bot[bot] <246668977+acp-release-bot[bot]@users.noreply.github.com>
2026-10-16 21:19:20 +02:00
Charles McLaughlin
db9f50f021 feat: add /remote-control command to start Remote Control for a session (#1197)
Adds a `/remote-control [name]` command (alias `/rc`) so a session
hosted over ACP can be continued from claude.ai/code or the Claude
mobile app. Addresses #459.

The CLI's own `/remote-control` is terminal-bound and isn't advertised
to SDK hosts, and `remoteControlAtStartup` only applies to interactive
sessions. The SDK does expose a runtime `enableRemoteControl(enabled,
name?)` on the query object, which returns the session URL. See
anthropics/claude-agent-sdk-typescript#460.

- The adapter handles the command itself in `prompt()`, calls
`enableRemoteControl(true, name)`, and replies with the session URL as
an agent message. The turn ends without going to the model.
- Failures (for example Remote Control disabled by policy) are reported
to the user in the same way.
- The command is added to `available_commands_update` only when the
query exposes the method.
- `enableRemoteControl` is not in the published `Query` types yet, so it
is typed locally the same way as `mcpAuthenticate`.

## Testing

- `npm run check`, `npm run build` and `npm run test:run` pass,
including new tests in `src/tests/remote-control.test.ts`.
- Ran the built agent through `examples/simple-client.ts` against a real
Claude Max session: the command was advertised, `/remote-control`
returned a working `claude.ai/code` URL, and prompts sent from the
Claude iOS app ran in the session and were answered.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Noah Eisenbruch <noaheisenbruch@gmail.com>
Co-authored-by: Andrey Bragin <andrey.bragin@jetbrains.com>
2026-10-16 21:07:25 +02:00
nikita-ashihmin
f5967fd6b4 fix: hold a fork back from the session list until its session/fork response is out (#1368)
## Problem

`session/fork` copies the transcript and then starts the forked session,
which takes several seconds. Meanwhile the list subscription sees the
new transcript and sends `_session/list/changes` with the fork row
before the fork response. The client meets an unknown session and lists
it as a session of its own. JetBrains Air then refuses the fork
(IJAI-4105).

## Fix

- `ForkHolds` (in `session-index/list-subscriptions.ts`, owned by the
session index service) holds the row of a fork until the `session/fork`
response is out:
  - by `forkedFrom`, for an unsent row, while the transcript is copied;
- by the fork id after the copy. This covers a fork whose first record
is over 64 KB, because the scan reads `forkedFrom` from the head only.
- `session/list` leaves a held fork out too.
- The v1 and v2 fork handlers run in `forking(...)`. A `setTimeout(0)`
releases the rows after the SDK has written the response.
- The holds live in the service, so a subscription that starts during a
fork holds that fork too.

## Tests

`holds a fork back until the response of its session/fork is out`
(session-index.test.ts). It fails without the fix.
2026-10-16 22:38:37 +04:00
acp-release-bot[bot]
af3d7f9335 chore(main): release 0.92.0 (#1358)
🤖 I have created a release *beep* *boop*
---


##
[0.92.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.91.0...v0.92.0)
(2026-10-16)


### Features

* add the MCP server and tool to AIR tool calls
([#1359](https://github.com/agentclientprotocol/claude-agent-acp/issues/1359))
([872e93f](872e93f11f))
* publish the predicted follow-up prompt as _meta.promptSuggestions
([#1356](https://github.com/agentclientprotocol/claude-agent-acp/issues/1356))
([78a29f5](78a29f58ba))
* update ACP SDK to 1.9.0 and adopt the v2 spec clarifications
([#1366](https://github.com/agentclientprotocol/claude-agent-acp/issues/1366))
([68bf74f](68bf74f58e))


### Bug Fixes

* include subagent file edits in the file-change report
([#1357](https://github.com/agentclientprotocol/claude-agent-acp/issues/1357))
([910d83c](910d83ceea))
* restore resumed subagents after an adapter restart
([#1364](https://github.com/agentclientprotocol/claude-agent-acp/issues/1364))
([26f144a](26f144a2a5))

---
This PR was generated with [Release
Please](https://github.com/googleapis/release-please). See
[documentation](https://github.com/googleapis/release-please#release-please).

Co-authored-by: acp-release-bot[bot] <246668977+acp-release-bot[bot]@users.noreply.github.com>
2026-10-16 14:39:40 +02:00
Ben Brandt
68bf74f58e feat: update ACP SDK to 1.9.0 and adopt the v2 spec clarifications (#1366)
Updates `@agentclientprotocol/sdk` to 1.9.0 (schema v1.26.0,
v2.0.0-alpha.9) and adapts the agent to the v2 spec clarifications in
that release.

## Changes

**Client-provided system prompt (unstable)**
- Advertises `sessionCapabilities.systemPrompt` with `mode.override` on
v1 and `session.systemPrompt` on v2.
- Accepted on `session/new`, `load`, `resume`, and `fork`:
  - `append` adds the text after the Claude Code preset.
- `override` replaces the preset with the text. Tools, MCP servers,
permissions, and settings (including CLAUDE.md through `settingSources`)
are unchanged.
- An unsupported mode or non-text content fails with `invalid_params`
before any session is created, reused, or forked.
- Takes precedence over the `_meta.systemPrompt` extension. AIR custom
instructions still follow the client text.
- A different prompt on resume recreates a live session. Claude Code
reuses its saved system prompt until the next compaction, which the RFD
allows.

**v2 session/resume state snapshot**
- Sends the current foreground state (`idle`, `running`, or
`requires_action`) after any replay and before the response, as the
clarified spec requires. This applies even without a replay.

**v2 replay of subagents**
- No longer replays a child's past `running`/`idle` as current activity.
Each replayed child gets one `state_update` with its last state after
the replay.

**v2 tool call `_meta` patches**
- Reports carry only the top-level keys that changed. `claudeCode` is
still sent whole, since clients don't merge nested objects.
- A removed key is sent as `null`.
- A tool call id that starts again removes the old keys.
- A replayed tool call this connection has not reported sends `_meta:
null` first, then the current keys.

**Cancellation and close**
- A `session/close` whose turns don't end within the teardown timeout
settles them as cancelled. The client gets `idle` with `cancelled`, or
`-32800` for a prompt never inserted, before the close response.
- A prompt racing `session/close` ends as cancelled (`-32800` on v2)
instead of `-32603`. On v1 it now returns `stopReason: cancelled`,
matching a prompt queued just before the close.
- A stream error after a cancel ends the work as `cancelled` instead of
`error`.

## Not in this PR
- After a URL-mode MCP elicitation is accepted, the session reports
`running` right away. The spec asks for `requires_action` until the
external interaction finishes, but a server may never send
`elicitation_complete`, so this needs its own design.
- `requires_action` is reported while one foreground subagent waits,
even if other foreground work can continue. This is SHOULD-level in the
spec.

## Testing
- `npm run build`, `npm run check`, and `npm run test:run` pass.
- Golden files changed because of the new capability and the resume
snapshot. `compare.ts` allows the new capability against origin/main.
- `session index cost > reads only the changed transcript for a
subscription` failed intermittently locally (about 3 in 15 runs) on a
loaded machine. This PR doesn't touch the session index, but watch it in
CI.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 14:37:08 +02:00
Mark Tkachenko
26f144a2a5 fix: restore resumed subagents after an adapter restart (#1364)
After a restart the AIR subagent map is empty, so a SendMessage resume
opened no generation and the child's messages stayed buffered under the
old launch id. Restore the agent from the session history on a resume
signal or on a message with an unseen parent_tool_use_id, with a
generation id that can't collide with ids the client stored before.
2026-10-16 14:15:35 +02:00
Mark Tkachenko
872e93f11f feat: add the MCP server and tool to AIR tool calls (#1359)
AIR gets `_meta.jetbrains.air.mcp = {server, tool}` on MCP tool calls,
so the MCP card does not depend on the title format.

The adapter splits the `mcp__<server>__<tool>` tool name the way Claude
Code does. `server` is the normalized name. Clients other than AIR get
nothing new.

Split from IJAI-872. The SDK does not expose MCP progress yet, so this
PR does not forward it.
2026-10-15 17:04:17 +02:00
nikita-ashihmin
78a29f58ba feat: publish the predicted follow-up prompt as _meta.promptSuggestions (#1356)
## What

The Claude Code CLI can predict the prompt the user is likely to send
next. This adapter asked for nothing and dropped the `prompt_suggestion`
message. It now asks for the prediction (`promptSuggestions: true`) and
forwards the one prediction of a turn.

ACP has no field for a composer prefill, so the value travels as

```json
{
  "sessionUpdate": "session_info_update",
  "_meta": { "promptSuggestions": { "suggestions": [{ "text": "Run the tests" }] } }
}
```

The key names no vendor, so another client can read it. AIR already
renders the first non-blank text as gray text in an empty composer,
where Tab fills the composer and sends nothing.

## Behavior

- `suggestions: []` clears the prediction the client holds: at the next
`session/prompt`, because that turn's prediction is spent, and on a
`conversation_reset`, because the transcript it was drawn from is gone.
- A prediction the next prompt outran — the user sent the next prompt
before the adapter read the prediction off the SDK stream — is dropped
instead of published mid-turn.
- A blank prediction never goes on the wire.
- The update is sent only to an AIR client, like the other extension
keys, so non-AIR traffic is unchanged (the key is added to
`AIR_ONLY_META_KEYS`; the `origin/main` scenario baselines stay
byte-identical).
- The CLI predicts nothing on the first turn, after an API error, in
plan mode, and at the usage limit, so a turn without a prediction is
normal. A caller can opt out with
`_meta.claudeCode.options.promptSuggestions: false`.

Documented in `docs/air-extensions.md`.

## Tests

`src/tests/prompt-suggestions.test.ts` covers the wire shape, the
publish after the turn's result, the clear at the next prompt, the
dropped stale prediction, the blank prediction, and a non-AIR client.
`src/tests/create-session-options.test.ts` covers the SDK option and the
caller opt-out.

`npm run check`, `npm run build` and the touched suites are green; the
full `test:run` has pre-existing flakiness on this machine
(session-index watchers, providers) unrelated to this change.

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Happy <yesreply@happy.engineering>
2026-10-15 17:55:44 +04:00
Mark Tkachenko
910d83ceea fix: include subagent file edits in the file-change report (#1357)
## Summary

`agentFileChangeReport` took its path list only from Claude's file
checkpoints (`rewindFiles` dry run). A checkpoint tracks only the files
that the root agent edits with its own file tool. So a file that only a
subagent edited never appeared in the report. Air then showed no "Since
last turn" row for it, and Last Turn review could not include it.

- Collect the `file_path` / `notebook_path` of successful
`Edit`/`Write`/`MultiEdit`/`NotebookEdit` results from subagent messages
in the stream. Store them on the active turn. This also covers
background subagents, because the turn stays open until they finish.
- Merge these paths with the checkpoint list. When the checkpoint
preview times out or is unavailable, report the subagent paths instead
of `unavailable`.
2026-10-15 15:39:18 +02:00
acp-release-bot[bot]
c0af592d25 chore(main): release 0.91.0 (#1333)
🤖 I have created a release *beep* *boop*
---


##
[0.91.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.90.0...v0.91.0)
(2026-10-15)


### Features

* keep background subagents running after a parent cancel, for clients
that can cancel them
([#1348](https://github.com/agentclientprotocol/claude-agent-acp/issues/1348))
([ba3c7be](ba3c7be05e))
* let a client cancel one subagent with session/cancel on its child
session
([#1347](https://github.com/agentclientprotocol/claude-agent-acp/issues/1347))
([aeb69e7](aeb69e7ed1))
* mark session list rows of worktrees Claude Code made (agentWorktree)
([#1352](https://github.com/agentclientprotocol/claude-agent-acp/issues/1352))
([1a1f1a0](1a1f1a0e80))
* narrow models to what the providers/set gateway lists
([#1340](https://github.com/agentclientprotocol/claude-agent-acp/issues/1340))
([e8eb9d3](e8eb9d340f))
* restore a subagent from before a reload when SendMessage resumes it,
for subagents RFD clients
([#1332](https://github.com/agentclientprotocol/claude-agent-acp/issues/1332))
([df21b3f](df21b3fb56))


### Bug Fixes

* close the tool calls of a background subagent that the SDK reports
stopped after a cancel
([#1336](https://github.com/agentclientprotocol/claude-agent-acp/issues/1336))
([9a1b90f](9a1b90fb9d))
* close the tool calls of a cycle that Claude Code started and a cancel
stopped
([#1339](https://github.com/agentclientprotocol/claude-agent-acp/issues/1339))
([1fd217f](1fd217f3e7))
* close the tool calls that a cycle Claude Code started abandoned, at
its end
([#1344](https://github.com/agentclientprotocol/claude-agent-acp/issues/1344))
([594aa8c](594aa8c955))
* deliver the output of a background subagent that a cancel did not stop
([#1345](https://github.com/agentclientprotocol/claude-agent-acp/issues/1345))
([bcc024d](bcc024d2c9))
* end the terminal of a command whose tool call is closed without a
result
([#1335](https://github.com/agentclientprotocol/claude-agent-acp/issues/1335))
([764f339](764f339c3e))
* end the tool calls of a cycle that Claude Code started and a cancel
stopped, on v1
([#1342](https://github.com/agentclientprotocol/claude-agent-acp/issues/1342))
([b4788d6](b4788d61ea))
* keep a live session that was never prompted when a resume or load
changes its settings
([#1353](https://github.com/agentclientprotocol/claude-agent-acp/issues/1353))
([0c8de13](0c8de1380f))
* let session/delete succeed for a session without a transcript
([#1351](https://github.com/agentclientprotocol/claude-agent-acp/issues/1351))
([fadbe18](fadbe1881c))
* report no running for the session's own request that a cancel
withdraws, on ACP v2
([#1346](https://github.com/agentclientprotocol/claude-agent-acp/issues/1346))
([d528647](d528647162))
* show nothing of a tool that cancelled work asks to run after the
cancel was reported
([#1334](https://github.com/agentclientprotocol/claude-agent-acp/issues/1334))
([ebf8b49](ebf8b49fd6))
* stop waiting for a question the client leaves open after Claude Code
cancels it
([#1338](https://github.com/agentclientprotocol/claude-agent-acp/issues/1338))
([c1e83b4](c1e83b46b7))
* update Claude Agent SDK to 0.3.300
([#1355](https://github.com/agentclientprotocol/claude-agent-acp/issues/1355))
([761b4c8](761b4c8359))

---
This PR was generated with [Release
Please](https://github.com/googleapis/release-please). See
[documentation](https://github.com/googleapis/release-please#release-please).

Co-authored-by: acp-release-bot[bot] <246668977+acp-release-bot[bot]@users.noreply.github.com>
2026-10-15 12:22:03 +00:00
Ben Brandt
761b4c8359 fix: update Claude Agent SDK to 0.3.300 (#1355)
Updates `@anthropic-ai/claude-agent-sdk` from 0.3.296 to 0.3.300 (parity
with Claude Code v2.1.300), and `@anthropic-ai/sdk` from 0.133.0 to
0.134.0.

No code changes needed. The new types are an optional `property_order`
on elicitation requests, `nextFireAt` on the CronCreate/CronList
outputs, and `owned` on Artifact outputs. We don't need any of them.

Fixes picked up that affect this agent:
- `importSessionToStore` (used for session forks) no longer skips
transcript rows containing U+2028/U+2029 on Node 24.
- Resuming an SDK session after a compaction no longer loses the recent
messages or the compaction summary in some cases.
- Background subagents in auto mode no longer send their final report
twice or merge it into another turn.
- Queued content-block prompts merged into one turn are now separated by
a line break.
- `ScheduleWakeup` now fires while a background task is running.

Behaviour change: `MAX_THINKING_TOKENS=0` (sent as `thinking: { type:
"disabled" }`) now runs adaptive thinking on models that can't turn
thinking off.

`npm run build`, `npm run check` and `npm run test:run` all pass.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 12:17:45 +00:00
nikita-ashihmin
1a1f1a0e80 feat: mark session list rows of worktrees Claude Code made (agentWorktree) (#1352)
## What

Marks `session/list` and `_session/list/changes` rows whose session runs
in a linked Git worktree that Claude Code itself created (`claude
--worktree`, the `EnterWorktree` tool), as
`_meta.jetbrains.air.agentWorktree: true`. A new agent capability
`sessionWorktrees`, advertised together with `sessionIndex`, tells the
client that rows carry the field, so a missing field means "not made by
the agent" rather than "unknown".

## Why

With `includeWorktrees`, a client gets sessions from every linked
worktree with nothing to tell them apart. A worktree Claude Code made
for a session belongs to the project the user started it from; a
worktree the user made by hand is a separate checkout. Only the agent
knows which is which.

## How

- The rule reads the transcript's `worktree-state` records: the latest
one wins; the session counts only when `worktreeSession` is non-null and
`enteredExisting` is not `true`. `environment.isWorktree` is not used,
since plain git detection is also true for user-made worktrees.
- The record is parsed in the existing head/tail transcript scan, so no
extra file reads. The row is marked only when its `cwd` is in a linked
worktree (`<common-dir>/worktrees/<name>`), never for the main checkout
or a submodule.
- The row's `cwd` stays the session's own directory. The field is `true`
or absent.

Proposed as `SessionInfo.agentWorktree` plus a `agentWorktrees`
capability marker in the session list extensions RFD:
agentclientprotocol/agent-client-protocol#2323. Docs:
`docs/air-extensions.md`.

## Tests

`src/tests/session-index.test.ts`: marked worktree, `enteredExisting`,
no record (user worktree), a later `null` record, main checkout,
`_session/list/changes` rows, capability negotiation, and scan windows.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 12:15:52 +04:00
Ben Brandt
0c8de1380f fix: keep a live session that was never prompted when a resume or load changes its settings (#1353)
Found by running the ACP TCK's v2 suite against the adapter
(ACP-ADDDIRS-202). The code is shared, so it also fixes v1's
`session/load` and `session/resume`.

## The bug

A `session/resume` or `session/load` can change a live session's
settings: its `cwd`, additional directories, MCP servers, or the other
settings in its fingerprint. When that happens, `getOrCreateSession`
tears the live query down and starts a new one with `resume: sessionId`.
Claude Code writes the conversation only at the first prompt, so for a
session that was never prompted, the resume failed with `-32002`
"Resource not found". The live session was already torn down by then, so
the client lost a session it had a moment earlier.

## The fix

The sign-out path already handled the same case: it tries `resume`, and
on `resourceNotFound` it starts a fresh query under the same id with
`reuseSessionId`. That logic is now one helper, `recreateLiveSession`,
used by both:
- **`getOrCreateSession`** uses it when the session was live. A session
that was never prompted then starts a fresh query under its own id. It
loses no history, because it had none, and it keeps its mode.
- **A session that isn't loaded here** still resumes directly. If it has
no stored conversation, that still fails with `-32002`, since the
session really is unknown.
- **`recreateSignedOutQuery`** keeps its own handling: if both attempts
fail, it puts the old session object back.

## Checks

- **ACP TCK v2** (`agentclientprotocol/acp-tck` at `fa00ccd`) against
this build: ACP-ADDDIRS-202 now passes, where it failed before.
ACP-ADDDIRS-201 and ACP-RESUME-201 to 205 still pass.
- **Live v1 `session/load`**, Claude Code 2.1.296: create a session,
then load it with a new additional directory before any prompt.
  - On `main`, the load failed with `-32002`.
- With this fix, the load succeeds. A prompt then read a file that
exists only in the new directory, and the transcript was saved under the
same session id.
- **Tests:** three new tests:
- a live session that was never prompted starts a fresh query with its
mode;
  - an unknown session still fails;
- another failure of a live session's resume doesn't start a fresh
query.

Removing the fallback, applying it to every error, or applying it to
sessions that aren't loaded each fails a test. The sign-out tests pass
through the shared helper.
- **Suite:** `npm run check`, build and all 3068 tests pass, with no
golden changes.

**Not covered:** a model chosen with `session/set_config_option` before
the first prompt resets to the default when such a session is recreated,
as it already did after a sign-out. The mode is kept.
2026-10-14 23:16:29 +02:00
Ben Brandt
fadbe1881c fix: let session/delete succeed for a session without a transcript (#1351)
Found by running the ACP TCK's v2 suite against the adapter
(ACP-DELETE-201 and ACP-DELETE-203). The code is shared, so v1 clients
get the fix too.

## The bug

ACP says: "Deleting an already-deleted session, or a session that never
existed, **SHOULD** succeed silently." For a client that is neither AIR
nor a `sessionIndex` client, `session/delete` instead failed with
`-32603` "Internal error" in three cases:

- **A session this connection ran but never prompted.** Claude Code
writes the transcript at the first prompt. So the adapter closed the
live session, then the SDK delete threw "not found in any project
directory".
- **An unknown session.** The SDK delete throws the same "not found"
error.
- **An id that isn't a UUID.** The SDK delete throws "Invalid
sessionId".

## The fix

A new `deleteSessionIfStored` in `src/session-index/service.ts` handles
every other client's delete:
- An id that isn't a session id can't name a transcript, so it returns
`{}` without calling the SDK.
- The SDK's own "not found" means nothing is on disk, so that also
returns `{}`. It's recognised by the existing `isSdkNotFound`.
- Any other SDK failure, such as a permission error, still fails the
request.

The two AIR paths are unchanged, because `docs/air-extensions.md`
documents their errors: `-32002` for an unknown session on a
`sessionIndex` client, and the SDK's error for AIR without
`sessionIndex`. The doc now describes the plain client's behavior.

## Checks

- **ACP TCK v2** (`agentclientprotocol/acp-tck` at `fa00ccd`) against
this build: ACP-DELETE-201 and ACP-DELETE-203 now pass, where both
failed before. ACP-DELETE-202 is still skipped, because `session/list`
doesn't show a session that was never prompted.
- **Tests:** three new tests: a never-prompted live session, an unknown
session, and an id that isn't a UUID. Removing either rule, or
swallowing every error, fails a test. The existing delete tests now use
UUID ids, which is what real sessions have.
- **Suite:** `npm run check`, build and all 3068 tests pass, with no
golden changes.
2026-10-14 23:14:54 +02:00
Ben Brandt
ba3c7be05e feat: keep background subagents running after a parent cancel, for clients that can cancel them (#1348)
Stacked on #1347, which gives each running child the `cancel`
capability. Merge #1347 first; this PR's base is its branch.

## What changes

On a client that gets the subagents RFD form (v1 with
`clientCapabilities.subagents` and not AIR, and v2), `session/cancel` on
the parent now spares background subagents. They keep running, as
acp#2308 prescribes ("a background child that the cancelled work did not
wait on may continue"), and the client can stop each one with its
child's `cancel` from #1347. Foreground subagents that the turn waits on
still stop.

Plain v1 clients and AIR are unchanged: the interrupt still stops their
background subagents, since they have no way to stop one.

## How

- **The declaration.** Claude Code's interrupt kills background
subagents and workflows unless the query declares
`perTaskStopAffordance`. The SDK describes it as "this consumer renders
a per-task stop control", and an RFD client now has that control. The
adapter declares the option for those clients. It comes before
`_meta.claudeCode.options`, so a client can turn it off.
- **Workflows.** The declaration also spares background workflows
(`local_workflow`). A workflow's agents are not subagent tasks and get
no child session, so a client can't stop a workflow unless it is AIR
with `asyncTasks`. When a session declares the option and the client has
no such control, `session/cancel` stops each live background workflow
with `Query.stopTask` after the interrupt. Workflows therefore behave as
they did before.
- **Background commands** survive an interrupt with or without the
declaration (checked live and in the CLI code), so nothing changes for
them.

Where I read Claude Code 2.1.296's CLI: without the declaration, the
interrupt runs a kill of every running background `local_agent` and
`local_workflow` task. With it, the interrupt kills only observer tasks.
Adapter interrupts name no prompt, so this runs on every
`session/cancel`, including one with no turn running. The declaration's
only other effect is on requests with `interrupt_if_running`, which the
adapter never sends.

## Checks

**Live, Claude Code 2.1.296, through the built adapter.** Every update
was checked against the schema, and no run had a violation or left a
tool call open. Survival was checked by whether the background command's
process was alive 2 s after the cancel.

| Client | Background work at the cancel | Result |
|---|---|---|
| v1 RFD | subagent running a command | Prompt `cancelled` 9 ms after
the cancel. The child kept running, completed its command and reported
`idle end_turn`. Claude Code's follow-up cycle then streamed to the
root. |
| v1 RFD | subagent with an open permission request | The request stayed
open through the cancel. The client allowed it 5 s later, and the child
ran the command and ended `idle end_turn`. |
| v1 RFD | workflow | The workflow and its command stopped, with no
follow-up cycle. Same as the plain client. |
| v1 plain | subagent | Unchanged: the interrupt killed the child, and
its command's tool call ended `failed`. |
| v1 plain | workflow | Unchanged: stopped, no follow-up. |
| v2 | subagent | Root `idle cancelled` 11 ms after the cancel. The
child kept running and later reported `idle end_turn`, and the root went
`running`, then `idle end_turn`, for the follow-up. |
| v2 | workflow | Stopped, no follow-up. |

**Direct SDK probes** decided the design:
- With the declaration, an interrupt spares a background workflow.
- Without it, the interrupt kills the workflow.
- A `stopTask` on a workflow, before or after the interrupt, stops it
and its command, and Claude Code starts no follow-up cycle, the same as
when the interrupt killed it.

**Tests:**
- Option tests: the declaration for each client form, a caller's
override, and when a cancel stops workflows.
- Cancel tests: only workflows are stopped, after the interrupt and only
when the flag is set.
- Removing any of the eight rules fails a test.
- `npm run check`, build and all 3020 tests pass, with no golden
changes.

## Not covered

- A paused background workflow (one Claude Code restored on a resume)
would also be stopped at a cancel, where the interrupt left it alone. I
didn't check whether such a workflow reports `task_started`.
- Standard ACP has no control to stop background work that is not a
session, such as a workflow or a background command. Only AIR has one,
through its async tasks extension.
2026-10-14 21:56:01 +02:00
Ben Brandt
aeb69e7ed1 feat: let a client cancel one subagent with session/cancel on its child session (#1347)
A client on the subagents RFD can now stop one subagent without
cancelling the turn. Each running child is announced with `capabilities:
{ cancel: {} }`, and `session/cancel` with the child's session id stops
that child's current work.

## What Claude Code does

From direct SDK probes of `Query.stopTask(taskId)` and runs through the
adapter (Claude Code 2.1.293 and 2.1.296):

| Case | What Claude Code sends |
|---|---|
| Foreground subagent running a command | At once: `task_updated`
`killed` and `task_notification` `stopped` for the subagent, `stopped`
for its command's task, the command's interrupted result, then the Agent
call's interrupted result (`non_execution_kind: "interrupted"`). The
parent's turn goes on and ends normally. |
| Background subagent | The same events and result, then Claude Code's
follow-up cycle about the stop. |
| Subagent with an open permission request | The request is withdrawn
(its `AbortSignal` fires), and both calls get results. |
| Subagent waiting on its own foreground subagent | The grandchild is
stopped too, with its own `killed` and `stopped`, which is the cascade
the RFD requires. |

## The change

- **The grant:** `announceNativeSubagent` grants `cancel` to each live
RFD child. A replayed child gets no capabilities, since it no longer
runs (the RFD's freshness rule for reconnection). A routing-only
ancestor that a restore announces gets none either.
- **The cancel:** `cancel` checks first whether the session id is a
child of one of its sessions (`NativeSubagentRuntime.cancelChild`), and
if so calls `Query.stopTask` with the child's agent id. It sends no
interrupt, so the parent's work goes on. A stop that Claude Code refuses
is logged.
- **The report comes from the SDK's events,** `killed` or `stopped`,
which `finishTask` already turns into `idle` with `cancelled`. Accepting
the stop is no evidence, as the RFD says, and a cancel that races with a
child's end does not rewrite its outcome.
- **Requests:** a request that the stop withdraws reports no `running`
before `idle cancelled`. `awaitingUser` now counts cancels of the child
and of its ancestors, as it counted the root's (#1337). An ancestor's
cancel stops the foreground subagents under it.
- **Unchanged:** AIR keeps its draft and grants nothing, and a cancel of
a root session works as before.

## Evidence

**Live, through the adapter** (Claude Code 2.1.296, Sonnet), every
update checked against the schema, with no violations:

| Run | Child | Parent |
|---|---|---|
| v1 RFD, foreground child running a command | `running`,
`requires_action`, `running`, `idle cancelled` 9 ms after the cancel;
its command `failed` | Agent call `failed`; turn ends `end_turn` |
| v1 RFD, background child while v1 holds the turn | `idle cancelled`;
its command `failed` | the held turn settles with `end_turn` |
| v1 RFD, child with its permission request open | `requires_action`,
then `idle cancelled`, with no `running` between | turn ends `end_turn`
|
| v2, foreground child | `idle cancelled` on the root's stream and its
own | Agent call `cancelled`; root `idle end_turn` |

No tool call was left open in any run.

**Tests:**

- `native-subagents.test.ts`:
- `cancelChild` stops the child's task and reports nothing itself, and
the SDK's `killed` reports `idle cancelled`;
  - no stop for a session that is no child, or for AIR;
- no `running` for a request that the child's own cancel withdraws, or
that its parent's cancel withdraws.
- `acp-agent.test.ts`: `session/cancel` on a child calls `stopTask`, not
`interrupt`, and the turn ends `end_turn`; a refused stop is logged;
nothing happens for an unknown session id or an AIR child.
- `acp-v2.test.ts`: the same over v2, with the child's `idle cancelled`
on both streams.
- **Golden files:** the six v2 subagent golden files gain `capabilities:
{ cancel: {} }` on each announcement, and nothing else. No AIR golden
file changes.
- **Mutation checks:** removing each of these fails at least one test:
  - the grant;
  - the routing to the child;
  - the child's cancel count;
  - the ancestors in the count;
  - the AIR exclusion;
  - the error handling.

  So does reporting `idle cancelled` as soon as the stop is accepted.

`npm run check`, build and the full suite (3014 tests) pass.

## Not changed, and a decision

- **A parent cancel still stops background subagents.** Claude Code does
that because the adapter does not declare the SDK's
`perTaskStopAffordance` option. Declared, an interrupt spares background
agents and background shell commands (checked live: both ran to
completion after the interrupt), as
agentclientprotocol/agent-client-protocol#2308 prefers. Background shell
commands would then outlive a cancel too, and ACP has no control to stop
them, so declaring it is left for a separate decision.
- **Order:** Claude Code sends the stopped child's command result right
after its `killed` event, so the client sees `idle cancelled` a few
milliseconds before that call ends. The RFD has the client mark the
child's unfinished calls cancelled at that point and still accept later
updates.
- **On v2,** the stopped child's command ends `failed`, not `cancelled`:
Claude Code's result for a subagent's command carries no non-execution
kind.
2026-10-14 21:56:01 +02:00
Sergey Shkredov
e8eb9d340f feat: narrow models to what the providers/set gateway lists (#1340)
### Summary

Addresses [IJAI-1783](https://youtrack.jetbrains.com/issue/IJAI-1783).

An organization can restrict the models its JetBrains gateway serves,
but a session routed through `providers/set` offered the whole Claude
Code catalogue, so the picker showed models the gateway then refused.
`providers/set` now lists the gateway's models before it responds, and
the model picker offers only the SDK models the gateway lists.

- `anthropic`: `GET {baseUrl}/v1/models?limit=1000`, paginated by
`has_more`/`last_id`, with the provider headers and `anthropic-version`.
- `bedrock`: `GET {baseUrl}/foundation-models` (`ListFoundationModels`,
`modelSummaries[].modelId`), the listing the JetBrains gateway answers
on its Bedrock route. Only `anthropic.` ids count. The cross-region
prefix `global` when listed, else that of the first listed id, becomes
the session's `ANTHROPIC_BEDROCK_REGION_PREFIX`, so the CLI calls the
models under the prefix the gateway serves; ids of other prefixes, and
SDK rows resolved to another prefix, are dropped.
- Rows match by `resolvedModel` (or `value`) without dates, `-vN:M`
suffixes, `[1m]` hints and provider prefixes; the narrowing runs after
the `availableModels` allowlist. When the gateway does not list the
model a session runs (the dropped default, or a resumed or recreated
session's last model), the session switches to the first listed one with
`setModel`, so the CLI does not keep running an unlisted model.
- The listing runs in the provider-update queue, so `providers/set` and
`providers/disable` apply in the order they arrive; logout also clears a
`providers/set` that is still listing.
- A 4xx answer means the gateway has no listing: `providers/set`
succeeds and nothing is narrowed. Any other failed listing (network
error, 5xx, unexpected body, 10 s timeout) fails `providers/set` with
`internal_error`, and the previous provider stays in effect. An invalid
header or a base URL with credentials fails without repeating the value.
A listing that matches no SDK model leaves the session with no model to
offer: there is no model config option.
- `vertex`, gateways set through `authenticate`, and native routing are
not listed. Setting the same gateway again reuses its listing;
`providers/disable` and logout drop it.
- `docs/model-configuration.md` describes the narrowing.

### Not covered

Background haiku requests, subagents, and a `/model` typed into the
prompt choose their own model and are not checked against the listing.

### Verification

```bash
npm run check
npm run build
npm run test:run -- --no-file-parallelism   # 2964 passed, 5 failed, 30 skipped; all 5 in session-index.test.ts, flaky on main too
```

Tests stub `fetch` (`src/tests/setup-no-network.ts` answers an empty
listing by default) and cover the listing requests and pagination, the
Bedrock region prefix and its env, region-aware matching, the dropped
default, the failing `providers/set`, and the empty picker. The
JetBrains Bedrock gateway's `/foundation-models` answers
`global.anthropic.claude-{haiku,opus,sonnet}-5-5` (seen through Junie,
which lists the same route); this adapter has not yet been run end to
end against it.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 17:03:27 +02:00
Ben Brandt
d528647162 fix: report no running for the session's own request that a cancel withdraws, on ACP v2 (#1346)
## Problem

On ACP v2 the adapter reports a session's foreground work as
`state_update`s: `running`, then `requires_action` while a permission
request or question that blocks the work is open, then `running` again,
and finally `idle` with a stop reason. A request that ends because
`session/cancel` or `session/close` stopped the work is not an answer,
but it still reported `running`. #1337 fixed that for a subagent's child
session and listed the session's own request under "Not covered". That
request did not change, and since #1338 neither did its AskUserQuestion,
which now ends at the abort too.

On `main`, with Claude Code 2.1.296, v2, a Bash permission request that
the client holds open, and then a cancel or a close:

| What stops the work | States the session reported |
| --- | --- |
| `session/cancel` | `running`, `requires_action`, **`running`**, `idle
cancelled` |
| `session/close` | `running`, `requires_action`, **`running`**, `idle
cancelled`, all before the close response |

Claude Code's interrupt withdraws the request, so the SDK aborts the
signal of the `canUseTool` call, the request's `finally` calls
`syncAwaitingUser`, and with no blocking request left it reports
`running`. The result that ends the work comes 2 to 9 ms later in these
runs, so the client shows work that is being stopped as working.

## Cause

`syncAwaitingUser` reported `running` whenever the last blocking request
ended, whatever ended it. A request ends when the user answers it or
when Claude Code withdraws it, and a withdrawal alone does not say
whether the work was stopped. A `PermissionRequest` hook that answers
also makes Claude Code withdraw the request, and the work goes on (9 s
more in a live run), as #1337 found. So "a withdrawn request reports no
`running`" is wrong. The rule that fits is #1337's: no `running` when
the request was withdrawn after a cancel that came while it was open.

## Change

- `cancelTurns` (called for `session/cancel`, and by the teardown of
`session/close`) marks each request that is open, in
`Session.openUserInputs`.
- `withPendingUserInput` takes the request's `signal`. When the request
ends with its signal aborted and the mark set, `syncAwaitingUser` stops
waiting but reports no `running`. The four callers pass their signal:
permission requests, AskUserQuestion, MCP elicitation, and the refusal
fallback prompt. (#1337 counts the parent's cancels and compares the
count when the request ends. Here each open request is already an entry
of `openUserInputs`, so the cancel marks it. Same rule.)
- Everything else is as before:
  - an answered request reports `running`, also after the cancel;
  - a withdrawal without a cancel reports `running`;
- with two requests open, only the last one to end reports, by how that
one ended;
- a request that opens after the cancel is judged on its own, so a hook
that withdraws it still reports `running`.
- A foreground subagent's request blocks the turn, so it behaves like
the turn's own. A background subagent's request does not block it
(`blocksForeground`), so the root's states do not change for it. #1337's
child states are unchanged.
- `ForegroundEvents.running` and `v2Foreground` say when `running` is
not reported.

v1 reports no session state, so nothing changes there. AIR is unchanged:
the scenario golden files do not change.

## Evidence

Live runs against Claude Code 2.1.296 (Sonnet), driving `dist/index.js`
over stdio as a v2 client (`CLAUDE_AGENT_ACP_EXPERIMENTAL_V2=1`) that
holds the request and ignores `$/cancel_request`, with the session's cwd
in default permission mode. Every update and request validated against
the SDK's v2 JSON schema: 0 violations in all runs. The root's states:

| Run | `main` | This PR |
| --- | --- | --- |
| `session/cancel`, Bash permission request | `running`,
`requires_action`, `running`, `idle cancelled` | `running`,
`requires_action`, `idle cancelled` |
| `session/cancel`, AskUserQuestion | `running`, `requires_action`,
`running`, `idle cancelled` | `running`, `requires_action`, `idle
cancelled` |
| `session/close` | `running`, `requires_action`, `running`, `idle
cancelled` | `running`, `requires_action`, `idle cancelled` |
| `session/cancel`, request of a foreground subagent (#1337's run) |
root as above; child `running`, `requires_action`, `idle cancelled` |
root `running`, `requires_action`, `idle cancelled`; child the same |
| `PermissionRequest` hook allows after 3 s, no cancel | `running`,
`requires_action`, `running`, `idle end_turn` | the same |
| the client answers after 2 s | `running`, `requires_action`,
`running`, `idle end_turn` | the same |
| `kill -9` of Claude Code, no cancel | `running`, `requires_action`,
`running`, `idle error` | the same (see below) |

The hook run is why a withdrawal alone cannot mean "stopped": Claude
Code withdrew the request 3 s in, and the command then ran 9 s more. A
v1 run of the cancel (no state updates there) is identical on both
builds.

Tests, in `acp-v2.test.ts`, through the v2 client app, which validates
every update:

- For each of the four kinds of request (permission request,
AskUserQuestion, an MCP server's elicitation, the refusal fallback
prompt): a `session/cancel` withdraws it, a `session/close` withdraws it
(the harness gets a `close` hook, as the SDK's cleanup of a closed query
aborts open requests), and Claude Code withdraws it without a cancel.
- The user answers after the cancel; a request that opens after the
cancel and is then withdrawn; two requests open together (both withdrawn
by the cancel, and one withdrawn and the other answered, in each order);
a request of a cycle that Claude Code started.
- Subagents: a foreground subagent's request also holds the root (an
assertion added to #1337's test); a background subagent's request
changes nothing of the root's states, alone and next to the turn's own
request.

13 of them fail on `main` (12 new tests and the extended one); the other
8 new tests pass there and guard what must not change.

Removal checks, each run against `acp-v2.test.ts`:

| Removed or changed | Tests that fail |
| --- | --- |
| no rule: the end always reports `running` | 13 |
| the cancel marks no request | 13 |
| a mark is enough, the signal is not required | 2 (answered after the
cancel) |
| the simple rule "any withdrawal means stopped" | 5 (the hook cases,
and the request after the cancel) |
| `session.cancelled` in place of the mark of a cancel that came while
the request was open | 1 (the request after the cancel) |
| sticky: after one request was withdrawn by a cancel, no later request
reports `running` | 1 (answers the one the cancel did not withdraw) |
| permission requests do not pass their signal | 6 |
| AskUserQuestion does not pass its signal | 3 |
| MCP elicitation does not pass its signal | 2 |
| the refusal fallback prompt does not pass its signal | 2 |

`npm run check`, `npm run build` and `npm run test:run` pass (2999 tests
passed, 30 skipped; the scenario golden files do not change).

## Not covered

- **A stream end without a cancel.** If Claude Code exits by itself, the
SDK's cleanup aborts every request before the consumer reads the end of
the stream, and nothing at that moment tells it from a hook's
withdrawal. Live (`kill -9` of the CLI, table above): `running` 2 ms
before the `idle`, and the session is dead right after. Left as it is,
for the reasons #1337 gives for children.
- The rule assumes, like #1337's, that the work reports its end right
after such a withdrawal. A turn always does (the force-cancel backstop
ends a wedged one); a cycle with no active turn has no such backstop.
- Open PRs #1344 and #1345 also change `src/acp-agent.ts`; this branch
merges cleanly with each (checked with `git merge-tree`).
2026-10-14 13:22:12 +02:00
Ben Brandt
594aa8c955 fix: close the tool calls that a cycle Claude Code started abandoned, at its end (#1344)
A model cycle that Claude Code starts on its own now closes, at its end,
the tool calls that Claude abandoned before they ran, as a turn's end
already does.

## The bug

Claude can start a tool use and never finish the message that holds it,
for example when the stream is cut off. The tool never runs, and no
result comes. At a turn's end, `settleActive` closes such a call
(`closeUnfinished`: "Claude stopped this tool call before it ran.",
`failed` on v1, `cancelled` on v2). A cycle that Claude Code started on
its own, such as its follow-up after a background command finished, has
no turn. So nothing closed its abandoned calls, and the client kept them
open.

## The fix

`closeCancelledCycleToolCalls` becomes `closeCycleToolCalls` and runs at
every end of a cycle: its result, or an idle that no result precedes.

- **After a cancel:** unchanged (#1339, #1342).
- **At any other end:** every call that ran already has its result. What
is open is a call of the main conversation that was never dispatched,
which is closed with the same report a turn's end sends. A subagent's
calls are not the cycle's: a background subagent runs on.

On v2 the report comes before the cycle's `idle`.

## Evidence

**Tests:**

- v1, a cycle that runs a command, starts a background subagent that is
streaming a tool use as the cycle ends, and abandons a tool use: only
the abandoned call is closed, once. The command keeps its one result,
and the subagent's call stays open.
- v1, a cycle that ends at an idle with no result (the stream dropped
mid-cycle): its abandoned call is closed there.
- v2: the abandoned call goes `pending`, then `cancelled`, before the
cycle's `idle end_turn`.
- **Mutation checks:** each of these fails at least one test:
  - no closing at a normal end;
  - closing a subagent's calls;
  - closing dispatched calls (an existing test catches it);
  - closing only at the result, not at the idle.

`npm run check`, build and the full suite (2982 tests) pass, with no
golden changes.

**Not checked live:** Claude abandons a tool use only when something
cuts its message off, such as a steer or a dropped stream, which a live
run cannot set up reliably.
2026-10-14 13:21:04 +02:00
Ben Brandt
bcc024d2c9 fix: deliver the output of a background subagent that a cancel did not stop (#1345)
After a cancel, the output of a background subagent that the cancel did
not stop now reaches the client: its tool results, its new tool calls,
and its permission requests.

## The bug

The adapter has one rule for work that a cancel does not stop, as
agentclientprotocol/agent-client-protocol#2308 asks: a background
subagent that still runs (`runsInBackground`). The closing at the cancel
leaves that subagent's tool calls open for their results
(`closedAtCancel`). But the window after the cancel dropped the same
subagent's output, results included:

- **A subagent that completes after the cancel** kept its calls open.
Its commands' results came after the cancel was reported and were
dropped, and since it was not stopped, nothing closed them.
- **A subagent that runs on** after the cancel showed nothing more: new
tool calls were dropped, and its permission requests were denied as the
stopped work's (#1334).

Claude Code currently stops background subagents at an interrupt, as
live runs show, so today this applies only to the output that comes
before the SDK reports a subagent's end. It decides the rest once Claude
Code lets them run.

## The fix

`isRunningBackgroundSubagentOutput` uses the closing's predicate for the
three places the window applies:

- the drop of streamed output;
- the record that `canUseTool` and the v2 preview hook read (#1334);
- the guard of the user/assistant messages.

A subagent's output after the SDK reported its end is still dropped, and
#1336 closes its calls.

## Evidence

**Tests** (`src/tests/incomplete-tools.test.ts`):

- **A command's own result,** for a subagent that completes after the
cancel, reaches the client, for a held turn and with no turn. This
replaces "leaves the command of a subagent that completed after the
cancel as it is", whose fixture sent no result at all.
- **New work after the cancel was reported:** a command's streamed
start, complete message and result reach the client.
- **Permission requests after the cancel:** they are allowed, for a tool
use that streamed first and for one that only its complete message
holds.
- **Unchanged:** a stopped subagent's late result is still dropped, and
its call closed.
- **Mutation checks:** removing each of the three uses, or widening the
predicate to every subagent, fails at least one test.

**Live, Claude Code 2.1.296,** a v1 cancel of a turn held for a
background subagent that runs a command, on a plain client and an RFD
client: Claude Code stopped the subagent, and its command was closed
after the cancelled response, as on `main`. No schema violations. A
subagent that survives a cancel cannot be set up live today.

`npm run check`, build and the full suite (2983 tests) pass, with no
golden changes.

## Not covered

- **A foreground subagent nested in a background one:** its own Agent
call is not backgrounded, so the closing closes its calls at the cancel,
and the window drops its output, both as before. The two stay
consistent.
2026-10-14 13:20:36 +02:00
Ben Brandt
b4788d61ea fix: end the tool calls of a cycle that Claude Code started and a cancel stopped, on v1 (#1342)
On v1, when a cancel stops a model cycle that Claude Code started on its
own, the cycle's open tool calls now end. Before, they stayed `pending`
for good.

## The bug

Example, from a live run (Claude Code 2.1.296, a v1 client):

1. A prompt starts a background command, and its turn ends.
2. The command finishes, and Claude Code starts a cycle on its own: it
launches a foreground subagent, which runs a command.
3. The client sends `session/cancel` while no prompt runs.

Claude Code stops both and sends the Agent call's interrupted result
(`user-rejected`). The client never got it, and the Agent call stayed
`pending`. Before #1336, the subagent's command stayed `pending` too.

**Why:** the window after a cancel drops output once the client has been
told that the stopped work ended (#1313). On v1 that report is the
prompt response, read as "no active turn". A cycle never has a turn, so
from the cancel on, everything it sent counted as late output, including
Claude Code's results for its own tool calls. v2 tracks a cycle as
foreground work and closes its calls before `idle cancelled` (#1339). v1
tracked nothing.

## The fix

- **The agent tracks foreground work on every connection.** Only v2
reports it, through the `foreground` option; v1 gets events that do
nothing (`UNREPORTED_FOREGROUND`). The rules for when a cycle starts and
ends are the ones v2 already uses.
- **On v1, a cycle's end is its result or its idle**
(`workEndReported`). Until then, Claude Code's interrupted results reach
the client. At that end, `closeCancelledCycleToolCalls` closes the calls
that got none, as on v2. After it, the window drops what comes, as
before. A v1 turn's rule is unchanged: no active turn.
- **A held turn's follow-up shares the held turn's closing.** v1 holds a
turn for its background subagents, and the held turn's follow-up is a
cycle too, now on v1 as well. When a second cancel settled the held turn
in `cancelTurns` while the cycle's closing was in flight, the two ran
separate passes. The response then came before the cycle's first report,
and on v2 a call could be closed twice. `closeCancelledCycleToolCalls`
now uses the held turn's closing (`Turn.closingCancelledToolCalls`),
which every settle of the turn waits for. The existing test "closes each
tool call once, before the response, when a cancel settles a held turn
that the consumer is closing" caught this.

## Evidence

**Live, Claude Code 2.1.296, the scenario above**, every update checked
against the schema (no violations):

| Client | Agent call | Subagent's command |
|---|---|---|
| v1, before | `pending` | `failed` (closed by #1336) |
| v1, this PR | `failed`, with Claude Code's own `user-rejected` result
| `failed`, closed at the cycle's end |
| v1 RFD client, this PR | the same | the same, in the child's session |
| v2, this PR | `cancelled`, before `idle cancelled` | `cancelled`,
before `idle cancelled` (unchanged) |

A v1 cancel with a queued prompt, followed by the next prompt, still
left no tool call open, and the next prompt's Write asked for its
permission and ran.

**Tests** (`src/tests/incomplete-tools.test.ts`):

- **#1339's test for v1 now asserts the fix:** a cycle's command gets
Claude Code's interrupted result.
- **New:** the live scenario, where the Agent call gets Claude Code's
result and the subagent's command is closed; and a cycle that ends at an
idle with no result, where its call is closed at the idle and Claude
Code's later result is dropped.
- **#1336's "no turn" fixture** launched the background subagents in a
cycle it never ended before the cancel, so v1 now delivered Claude
Code's real result there. The fixture now ends that cycle, which is the
"cancel while nothing runs" case the tests mean.
- **Mutation checks:** each of these fails at least one test:
  - the v1 rule without the cycle;
  - no cycles on v1;
  - no foreground tracking on v1;
  - a separate closing for a held turn's follow-up.

`npm run check`, build and the full suite (2979 tests) pass, with no
golden changes.

## Not covered

- A cycle that ends normally with a `tool_use` that Claude abandoned
keeps that call open.
- A subagent that completes after a cancel keeps its calls open.
2026-10-14 12:19:29 +02:00
Ben Brandt
c1e83b46b7 fix: stop waiting for a question the client leaves open after Claude Code cancels it (#1338)
## Problem

A permission request is abandoned when Claude Code withdraws it: its
`signal` is raced against the client's answer
(`requestPermissionFromClient`), because the SDK only sends
`$/cancel_request` and keeps waiting for the client's reply, which a
client that ignores the cancel never sends. An elicitation is not raced.
A question that the client leaves open after Claude Code cancels it
never settles, and its request stays open on our side:

- **The session** keeps the question in `openUserInputs`
(`withPendingUserInput`) for good. Steering stays non-interrupting
(`priority: later`) for the rest of the session, and on v2 the next turn
starts as `requires_action` though nothing is open.
- **A child** that asked keeps its `openRequests` up. When it is resumed
later, `resumeChild` reports `requires_action` instead of `running`.

Live, on `main` (Claude Code 2.1.296, v2): the main conversation asks
with AskUserQuestion, the client holds the elicitation and ignores
`$/cancel_request`, the user cancels, then sends a second prompt, "Reply
with just the word hi.":

| | States of the second turn |
| --- | --- |
| `main` | `running`, **`requires_action`**, `idle end_turn` (the answer
"hi" streams while it waits) |
| This PR | `running`, `idle end_turn` |

## Change

One helper, `elicitFromClient`, sends the elicitation and races it
against the callback's `signal`, as `requestPermissionFromClient` does.
The three places that send an elicitation for a callback of Claude Code
use it:

- AskUserQuestion (`handleAskUserQuestion`), the one that a child can
send;
- MCP elicitations (`onElicitation`, `handleMcpElicitation`);
- the refusal-fallback dialog (`onUserDialog`, `handleUserDialog`).

They had the same defect, each tracks its request with
`withPendingUserInput`, and their handlers already read an abort as a
cancel (`{ action: "cancel" }`, `{ behavior: "cancelled" }`, "Tool use
aborted"), so only the wait changes.

Nothing else in the flow needs the answer to arrive. After the
elicitation, a question's tool call ends with Claude Code's own
`tool_result`. A url-mode elicitation completes with the SDK's
`elicitation_complete`, not with the answer. A client that does reply to
the cancel still settles it first, to the same outcome as before.

AIR is unchanged, and the golden files do not change.

## Tests

All of them fail without the helper (checked by removing the race, and
then by reverting each of the three call sites alone):

- `acp-agent.test.ts`, "does not wait for … when its client ignores
cancellation": each of the three paths ends as aborted and leaves
nothing open.
- `acp-agent.test.ts`, "uses priority:'now' again once … is withdrawn":
the existing table of every request that waits for the user (permission,
AskUserQuestion, MCP elicitation, refusal dialog, and a child's
permission request and question), with a client that never settles. The
permission rows are controls that already passed. I moved the table into
a constant so both tests use it.
- `acp-agent.test.ts`: a child's question that Claude Code withdraws,
and a SendMessage resume after its end, reports `running`.
- `acp-v2.test.ts`: the live scenario above: the next turn does not
report `requires_action`.

Removal checks: no race in the helper (9 tests fail), the
AskUserQuestion site reverted (5), the MCP site (2), the dialog site
(2).

`npm run check`, `npm run build` and `npm run test:run` pass (2935
tests).

## Not covered

- **A child's question live.** Claude Code 2.1.296 does not give
subagents AskUserQuestion: a general-purpose subagent, and a custom
agent with `tools: AskUserQuestion`, both reported that they could not
ask. So the child's path is covered by the unit test only. The root's
question runs the same `handleAskUserQuestion`, which the live run above
covers.
- **The root session at a cancel.** Now that the question settles at the
abort, the root reports `requires_action`, `running`, `idle cancelled`
for it, as it already does for a permission request (live, v2). That
flicker comes from `syncAwaitingUser`, and is not changed here.
- A signal that already aborted when the elicitation is sent still sends
it, and cancels it at once. A permission request checks first. Nothing
depends on it.
- The URL elicitation of an MCP server's sign-in
(`authenticateMcpServer`) keeps its own race against the authentication,
and is not a tracked request.

Related, and independent: #1337 reports no `running` for a child's
request that a cancel withdraws. The two PRs touch the same call site
and merge cleanly in either order (checked with `git merge-tree`).
2026-10-14 11:48:41 +02:00
Ben Brandt
c7425e4e04 fix: report no running for a subagent's request that a cancel withdraws, for subagents RFD clients (#1337)
## Problem

For a subagents RFD client, a subagent's child session reports
`requires_action` while one of its permission or question requests is
open, and `running` again once its last open request ends (#1322,
`NativeSubagentRuntime.awaitingUser`). A request that ends because the
work was **stopped** is not an answer, but it still reported `running`.

On `main`, with Claude Code 2.1.296, a foreground child whose Bash
permission request is open, and a cancel on the parent:

| What stops the work | States the child reported |
| --- | --- |
| `session/cancel` (v1, and v2 on the child's own stream) | `running`,
`requires_action`, **`running`**, `idle cancelled` |
| `session/close` | `running`, `requires_action`, **`running`**, and
then nothing: a closed session reports no `idle` |

Claude Code's interrupt withdraws the request, so the SDK aborts the
signal of the `canUseTool` call, the adapter sends `$/cancel_request`,
and the request's `finally` reports `running`. The SDK's own report of
the child's end (`task_updated killed`) comes a few ms later (3 to 4 ms
in these runs), so the client shows a child that is being stopped as
working. After a `session/close` nothing corrects it.

## Cause

`awaitingUser` reported `running` whenever the last open request ended,
whatever ended it. A request ends in two ways, the user answers it or
Claude Code withdraws it, and a withdrawal alone does not say whether
the work was stopped. Claude Code also withdraws a request when
something else answers it, and then the child runs on. I ran one such
case, a `PermissionRequest` hook that allows the command after 3 s: the
child worked for 9 more seconds after the withdrawal, and `running` was
right. (The CLI also re-checks a pending ask when its permission context
changes, and withdraws it if that now allows the call. I did not manage
to trigger that: `session/set_mode` to `bypassPermissions` left the
request open in my run.) So "a withdrawn request reports no `running`"
is wrong: that child would sit on `requires_action` for the rest of its
run. Waiting for the child's next update instead does not help either:
in that run the child's first update after the withdrawal came 8 s
later, with the tool's result.

## Change

A request reports no `running` when it was **withdrawn after a cancel
that came while it was open**. Then Claude Code's interrupt withdrew it
as it stopped the child, and the SDK's events report the child `idle`.

- `NativeSubagentRuntime.parentCancelled` (called for `session/cancel`,
and by the teardown of `session/close`) counts the cancels in the RFD
form. It still reports nothing, so #2308 is unchanged: a child that
keeps running keeps its requests and its state.
- `awaitingUser` takes the request's `signal`, which the agent passes
for permission requests and AskUserQuestion (`withChildAwaitingUser`). A
request records the count when it opens. If its signal aborted and the
count changed, its end reports no `running`.
- Everything else is as before: a request that the user answers, also
after the cancel, reports `running`; a withdrawal without a cancel
reports `running`; two overlapping requests report once, when the last
one ends, by how that one ended.
- README: one clause in the work-state bullet of "Subagent sessions".

AIR is unchanged: `awaitingUser` only runs in the RFD form, and the AIR
golden files do not change.

## Evidence

Live runs against Claude Code 2.1.296 (Sonnet), driving `dist/index.js`
over stdio with a client that holds the child's permission request,
validating every update and request against the SDK's JSON schema (0
violations in all runs):

| Run | `main` | This PR |
| --- | --- | --- |
| v1, `session/cancel` | `running`, `requires_action`, `running`, `idle
cancelled` | `running`, `requires_action`, `idle cancelled` |
| v2, `session/cancel` (child's stream) | `running`, `requires_action`,
`running`, `idle cancelled` | `running`, `requires_action`, `idle
cancelled` |
| v1, `session/close` | `running`, `requires_action`, `running` |
`running`, `requires_action` |
| v1, hook allows after 3 s, no cancel | `running`, `requires_action`,
`running`, `idle end_turn` | the same |

Tests (all fail without the rule, as checked by removing it):

- `native-subagents.test.ts`: the cancel withdrawal (and the child's
`idle cancelled` after it); a withdrawal without a cancel (the hook
case, which a simpler "abort means stopped" rule fails); a request that
outlives a cancel and is answered later; a cancel that came before the
request opened; two overlapping requests, in each order.
- `acp-agent.test.ts`: through `canUseTool`, with `agent.cancel` and
`agent.closeSession` aborting the request as Claude Code does, and the
hook case.
- `acp-v2.test.ts`: the child's own `state_update` stream and the
parent's copy.

Removal checks, each run against the three test files: no rule (6 tests
fail), the cancel not counted (6), the agent not passing the signal (3:
agent cancel and close, v2), and the simple "any withdrawal" rule (3:
the hook cases).

`npm run check`, `npm run build` and `npm run test:run` pass (2935
tests; the scenario golden files do not change).

## Not covered

- **A stream end without a cancel.** If Claude Code exits by itself, the
SDK's cleanup aborts every request before the consumer reads the end of
the stream, and nothing at that moment tells it from a hook's
withdrawal. Live (`kill -9` of the CLI): `running`, then `idle`, 1 ms
apart. The session is dead right after. A rule for it would be a timing
one: report `running` for a withdrawal with no cancel one event-loop
turn later, so that `finishAll` goes first. I tried it (`await new
Promise((resolve) => setImmediate(resolve))` before the check in
`awaitingUser`'s `finally`): the `kill -9` run then reports `running`,
`requires_action`, `idle`, and the hook run still reports `running` 1 ms
after the withdrawal. I did not include it. It only works when the end
of the stream reaches `finishAll` in the same turn (a worker shutdown
publishes its failure notice first, for one), and it hides an ordering
assumption about the consumer. Your call.
- **Claude Code stopping a child on its own** (the model's TaskStop)
while its request is open: the same shape, no cancel.
- **The root session on v2** has the same flicker for its own request:
`requires_action`, `running`, `idle cancelled` (live, v2). It comes from
`syncAwaitingUser`, not from this runtime, and is not changed here.
- A background child that outlives a cancel and whose still-open request
a hook then withdraws would report no `running`. Claude Code stops
background children at an interrupt today, so it does not arise.

Related, and independent: a child's AskUserQuestion is not abandoned on
an abort when its client ignores `$/cancel_request` (#1338). The two PRs
touch the same call site and merge cleanly in either order (checked with
`git merge-tree`).
2026-10-14 11:47:28 +02:00
Ben Brandt
1fd217f3e7 fix: close the tool calls of a cycle that Claude Code started and a cancel stopped (#1339)
## Problem

On ACP v2, a model cycle that Claude Code starts on its own, such as its
follow-up after a background command finished, is foreground work:
`state_update` `running`, then `idle`. When a cancel stops such a cycle,
the tool calls that it left open can stay `pending` after `idle`
`cancelled`.

Claude Code answers each tool of the main conversation with an
interrupted result, and the adapter delivers it before the cycle's
`idle`. It sends nothing for what a foreground subagent of the cycle
ran, nor for a tool call that Claude abandoned before it ran. No turn
settles at the cycle's end, so `closeCancelledToolCalls` (#1327, #1221)
never runs for it.

Example, live (Claude Code 2.1.296, Sonnet): a background `sleep 6` ends
its turn. When it finishes, Claude Code's follow-up runs a foreground
subagent, which runs a 60 s command, and the client cancels while it
runs:

```
root  state_update running                 <- Claude Code's own cycle
child tool_call gKT4K4 (the subagent's command) pending
      ... session/cancel ...
root  tool_call_update PLh9rB (Agent call) cancelled     <- Claude Code's result
child state_update idle cancelled
root  state_update idle cancelled
child gKT4K4: still pending, for good
```

## Change

- `closeCancelledCycleToolCalls` ends the open tool calls with the
cancelled report before the cycle's end is reported, at the cycle's
result and at the idle that ends it when no result comes. The calls are
the ones the turn's closing takes: every open call except those of a
background subagent that still runs.
- It applies when a cancel is in effect (`session.cancelled`) and the
cycle is the foreground work (`runsAutonomousCycle`). A turn that took
the cycle over closes its own calls when it settles.
- The loop of `closeCancelledToolCalls` is now `closeOpenToolCalls`, and
its filter is named `closedAtCancel`. These two hunks are the same as in
#1336, so that the PRs merge in any order.
- The follow-up cycle of a turn that v1 holds for its background
subagents is a cycle on v2, and its closing now comes before its `idle`.
It came at the held turn's settle, after it.

## Evidence

**Tests** (6 new):

- `acp-v2.test.ts`: the command of the cycle's foreground subagent ends
`cancelled` in the child's session, after the child's idle and before
the cycle's, once; a tool call that Claude abandoned in the cycle ends
before `idle` `cancelled`; so does one at the idle that ends a cycle
without a result; the follow-up cycle of a held turn closes before
`idle` `cancelled`, and a background subagent that keeps running keeps
its command open; a cycle that the SDK aborted without a cancel is left
to Claude Code's result, which still reaches the client.
- `incomplete-tools.test.ts`: on v1, which reports no cycle, a cancelled
cycle's tool call is not closed (see below).

**Removal checks.** Each of these fails at least one test: no closing at
the result (3), none at the idle (1), closing a cycle that no cancel
stopped (1), without the cycle being the foreground work (2), closing a
running background subagent's calls (1), closing after the cycle's end
is reported (3), the turn's closing taking those calls too (5).

**Live**, every update validated against the SDK's v2 JSON schema (0
violations):

| cancel while Claude Code's cycle runs | `main` | this PR |
| --- | --- | --- |
| a foreground subagent's command | `pending` in the child's session,
after the root's `idle cancelled` | `cancelled` in the child's session,
after the child's idle and before the root's |
| a command of the cycle itself | Claude Code's result, `cancelled`,
before `idle` | the same, unchanged |

`npm run check`, `npm run build` and `npm run test:run` (2930 tests)
pass. No golden file changes. With #1335 and #1336 merged into it, in
any of the six orders, the branches merge without conflicts and all 2948
tests pass.

## Not covered, and a decision for the maintainer

- **v1.** Nothing tracks a cycle on v1, and with no turn active every
result after the cancel is dropped, Claude Code's for the cycle's own
commands too. In the same live run on v1 the Agent call and the
subagent's command both stay `pending`. Closing at the cycle's cancelled
result there would end both, with a made-up status for the Agent call
too. I left it: it changes what v1 clients see for a cancel while idle,
and one existing test (a held turn that the consumer closes while a
cancel settles it inline) fails once the cycle check is removed, so it
is not only a gate. It also touches the post-cancel window of #1061,
which another change owns.
- A cycle that ends without a cancel keeps an abandoned `tool_use` open.
`settleActive` closes that for a turn, and no turn owns a cycle.
- A session that ends or closes with a cycle running ends the cycle as
cancelled without closing its calls.
2026-10-14 11:46:20 +02:00
Ben Brandt
9a1b90fb9d fix: close the tool calls of a background subagent that the SDK reports stopped after a cancel (#1336)
## Problem

After `session/cancel`, the tool call of a **background** subagent's
command stays `pending` for good when no turn is left to settle.

Example, live (Claude Code 2.1.296, Sonnet): a background subagent runs
`python3 -c "import time; time.sleep(60)"`, and the client cancels while
it runs. Either the session is idle, or v1 holds the prompt open for the
subagent and the cancel settles it at once. Claude Code's interrupt
stops the subagent: the SDK reports `task_updated` `killed` and
`task_notification` `stopped`, and the RFD form reports the child `idle`
`cancelled`. The command's call is never closed, in the root session or
in the child's.

## Cause

#1327 closes a cancelled turn's open tool calls when the turn settles
(`closeCancelledToolCalls`), and leaves a background subagent's calls
open, because the interrupt may not stop it. Here the turn is already
settled (the cancel settled the held turn before the interrupt) or there
is none, and the stop comes after that. Nothing closes the calls then.

Claude Code does send the command's interrupted result. It comes after
the stop reports, so it is output after the work's end, and the
`session.cancelled` check of the user/assistant case drops it, as it
drops everything after that. The raw stream, in the order Claude Code
sends it:

```
task_updated      agent patch killed
task_notification agent stopped
task_notification the command's task stopped
user (ptu=agent)  tool_result for the command, is_error     <- dropped
```

## Change

- **A subagent's stop closes its open tool calls**
(`closeStoppedSubagentToolCalls`), at `task_notification` `stopped` and
at `task_updated` `killed`, after `finishTask` has reported the child
idle. It closes the calls whose recorded parent is that subagent's Agent
call (`Session.toolCallParents`) with the cancelled report that #1327
sends, naming the subagent so that every client form routes it like the
call's other updates.
- **Only where nothing else will end the calls**: a cancel is in effect
(`session.cancelled`), and the client was told that the work ended
(`workEndReported`: the prompt response on v1, `idle` on v2). While the
work runs, a stopped subagent's calls get Claude Code's own result or
`closeCancelledToolCalls` at the work's end, as before.
- The loop of `closeCancelledToolCalls` is now `closeOpenToolCalls`,
which takes the filter for the calls: the turn's closing, named
`closedAtCancel`, skips a running background subagent's calls, and this
closing takes one subagent's. These hunks are the same as in #1339,
which closes the calls of a cycle that Claude Code started, so that the
PRs merge in any order.
- README: one sentence on when such a call ends.

Nothing is closed twice: a closing forgets the call before it reports
it, so a later closing, and the result that Claude Code sends after the
stop, find nothing. The cancel settles a held turn and closes its calls
before it interrupts Claude Code, so the stop reports always come after
that closing.

## Per client form

- **v1 without subagents:** the call goes to the root session, after the
response when the cancel settled a held turn.
- **RFD:** the call ends in the child's session right after the child's
`idle` `cancelled`, as #1327 does at a turn's settle.
- **AIR:** the cancel already ended every child, so the runtime drops
the report (`ignoring late update for terminal subagent`). The call's
state is released, which it was not before.
- **v2:** as RFD. The root reports nothing, since it had no work.

## Evidence

**Tests** (10 new):

- `incomplete-tools.test.ts`: for a held turn and for no turn, the
command ends once with the cancelled report, after the cancel's
response, and the other subagent's command stays open; each of the two
reports ends it alone; a subagent that completed after the cancel is
left alone; one that stopped without a cancel, and one that stopped
while the work still runs, get Claude Code's result, not a made-up
report.
- `acp-agent.test.ts`: the RFD form (the child's session, after its
idle, after the response) and AIR (nothing reaches the ended child).
- `acp-v2.test.ts`: the child's command ends `cancelled` in its session
after its `idle cancelled`, and the root reports nothing more.

**Removal checks.** Each of these fails at least one test: no closing at
the notification (2), none at the patch (1), closing without a cancel
(1), before the work's end is reported (2), at any status of the
notification (1), of the patch (1), every subagent's calls (4), calls of
another parent (7), the turn's closing no longer skipping a running
background subagent (7), the report without the subagent's name (7), and
the closing before the child reports idle (2).

**Live**, 60 s command in a background subagent, cancel 4 s after its
permission request was answered, every update validated against the
SDK's JSON schema (0 violations):

| client | cancel while | `main` | this PR |
| --- | --- | --- | --- |
| v1, no capabilities | the held turn waits for the subagent |
`pending`, response `cancelled` | `failed` 4 ms after the response |
| v1, no capabilities | no turn runs | `pending` | `failed` after the
SDK's first stop report |
| v1, RFD | no turn runs | `pending` in the child's session | `failed`
in the child's session, after its `idle` `cancelled` |
| v1, RFD | the held turn | not run on `main` | `failed` in the child's
session, after the response |
| v1, AIR | the held turn | not closed | report dropped by the runtime,
state released |
| v2 | root idle | `pending` in the child's session | `cancelled` in the
child's session, after its `idle cancelled` |

`npm run check`, `npm run build` and `npm run test:run` (2934 tests)
pass. No golden file changes. With #1335 and #1339 merged into it, in
any of the six orders, the branch merges without conflicts and all 2948
tests pass.

## For the maintainer

- **Closing, not delivering.** Claude Code 2.1.296 sends the result, so
delivering it after the work's end would also end the call, with Claude
Code's text. I closed instead: 2.1.293 sent none for a subagent's call
(#1327), the result comes after the response in the window #1313 closes,
and closing works for both. The cost is the made-up text ("The turn was
cancelled before this tool call returned a result.") in place of Claude
Code's.

## Not covered

- A background subagent that the interrupt does not stop keeps running,
and its calls stay open. Its output after a cancel is dropped until the
next prompt, as before.
- A subagent that completes after a cancel: Claude Code's results for
its calls were dropped, and the calls stay open. Nothing says they were
cancelled, so none is reported. Delivering those results would end them.
- A subagent that a SendMessage resumed: its calls carry the id of the
call that launched it, while the registry holds the SendMessage id.
`runsInBackground` has the same limit.
- The calls of a cycle that Claude Code started on its own are #1339.
2026-10-14 11:45:36 +02:00
Ben Brandt
764f339c3e fix: end the terminal of a command whose tool call is closed without a result (#1335)
## Problem

A tool call that ends without a tool result is closed by a report that
only sets its status (`AcpToolCallRenderer.unfinished`). That happens
after `session/cancel`, for a subagent's command (Claude Code sends no
result for it) or a call that Claude abandoned, and when a turn ends
with a call that has no result (`closeUnfinished` in `settleActive`).

A client that renders terminals (the `terminal_output` /
`terminal_output_delta` extension, and the display terminals of ACP v2)
holds a terminal for a Bash command, which its `tool_call` started with
`_meta.terminal_info`. The closing report did not end it, so the call
shows as ended next to a terminal that never exits.

Example, live (Claude Code 2.1.296, Sonnet): a foreground Agent call
whose subagent runs `python3 -c "import time; time.sleep(45)"`, and
`session/cancel` while it runs. Claude Code answers the Agent call and
sends nothing for the subagent's command, so the adapter closes that
call itself:

- v1 client with `_meta.terminal_output`: `tool_call_update` `failed`,
with no `terminal_exit`.
- v2 client: `terminal_update` with the command, then `tool_call_update`
`cancelled`, and no `terminal_update` that carries an exit.

## Change

- `closeToolCall` is the one function that every closing report goes
through (the cancelled-turn closing and the end-of-turn closing today,
and any closing added later). It now also ends the call's terminal, in
the same update, as the result of a command does. It reads the tool from
the tool use cache before it drops the entry.
- The closings that #1336 and #1339 add go through `closeToolCall` too,
so they end the terminal as well once merged.
- `AcpToolCallRenderer.terminalExit` decides whether the call has a
terminal. It uses the rule that `toolCall` uses to start one, a command
on a client that renders terminals, which is now one private method
(`hasTerminal`) that `toolCall`, `toolUseContent` and `terminalExit`
share.
- The exit code is unknown, since the command ended without a result:
`exit_code: null`, as `endBackgroundTerminal` sends for a background
command. AIR always gets a number and reads 1 for a command that did not
succeed (#1252), so it gets 1. A v2 client gets `terminal_update` with
an empty `exitStatus`.
- `docs/air-extensions.md`: one line in the terminal conventions.

## Evidence

**Tests** (8 new):

- `incomplete-tools.test.ts`: the cancel's closing of a subagent's
command ends its terminal, still names the subagent, and sends
`exit_code: null`; AIR gets `exit_code: 1`; a client without terminals
gets no exit; a subagent's `Read` gets none; a command that ran but got
no result by the end of the turn gets the exit; so does one that Claude
abandoned before it ran.
- `tools.test.ts`: `terminalExit` for Bash, PowerShell, v2, AIR, a
client without terminals, and tools that are not commands.
- `acp-v2.test.ts`: the stopped subagent's terminal exits in the child's
session, after the command's terminal started and before the root's
`idle cancelled`, and the call ends `cancelled`.

**Removal checks.** Each of these fails at least one test: no exit in
`closeToolCall` (5), AIR gets null (2), an exit for a client without
terminals (2), an exit for a tool that is not a command (3), another
terminal id (5), the exit replacing the parent meta (1), the tool read
after the cache entry is dropped (5).

**Live**, each update validated against the SDK's JSON schema (v1
`SessionNotification`, v2 `session/update`), 0 violations. The Agent
call and the subagent's command from the example, cancelled 4 s after
the command's permission request was answered:

| client | `main` | this PR |
| --- | --- | --- |
| v1, `_meta.terminal_output` | closing report `failed`, no
`terminal_exit` | the same update carries `terminal_exit {exit_code:
null, signal: null}` |
| v1, AIR with `terminal_output` | not run on `main` (same code path) |
`terminal_exit` with `exit_code: 1` |
| v2 | `cancelled`, no terminal update | `terminal_update` with
`exitStatus: {}`, then `cancelled`, before the root's `idle cancelled` |

`npm run check`, `npm run build` and `npm run test:run` (2932 tests)
pass. No golden file changes. With #1336 and #1339 merged into it, in
any of the six orders, the branch merges without conflicts and all 2948
tests pass.

## Not covered

- A call that only a permission request announced, whose tool use never
streamed before it was closed, has no entry in the tool use cache. Its
tool is unknown here, so its terminal stays open. This is the lookup
that `AsyncTaskRuntime` uses too (`toolUseCache`, then
`resolvedToolNames`). Covering it needs the tool name where the
permission request emits the call.
- The fallback for a tool result without a tool use
(`toAcpNotifications`) still reports the status only, with no exit.
- The end-of-turn closing is covered by a test only: Claude Code does
not end a turn with an unanswered call on demand.
2026-10-14 11:44:15 +02:00
Ben Brandt
ebf8b49fd6 fix: show nothing of a tool that cancelled work asks to run after the cancel was reported (#1334)
Closes #1061.

## Where #1061 stands

The incident in #1061 was tool starts streamed after `session/cancel`
that never got a final status, so a client's ledger of open tool calls
kept them forever. Since that report:

- **#1313** drops streamed text, thinking and tool calls of the stopped
work once the client has been told that the work ended (the cancelled
response on v1, `idle cancelled` on v2), while the window after the
cancel is open.
- **#1327** and **#1221** close the tool calls that streamed before that
report, before it is sent.

So a late streamed tool start no longer reaches the client. This PR adds
regression tests for that, and closes the one path that still produced
such a tool call. #1313 named it as not changed: a tool call that the
stopped work creates outside the stream.

## The remaining path

Example: the user sends a second prompt while the first runs, then
cancels. Claude Code had already taken the second prompt off its queue,
so it still runs it, and its answer writes a file. The consumer drops
the stream and the messages of that answer (#1313). But Claude Code asks
for the Write's permission on its control channel:

- **`canUseTool`** created the tool call (`ensureToolCallEmitted`) and
sent the permission request. AskUserQuestion's elicitation goes the same
way.
- **The v2 PreToolUse preview hook** created the tool call with the
Write's patch.

The tool's result is dropped too, so that tool call never ended: the
ghost of #1061, now for tools that need a permission.

## The fix

- **The consumer records each tool use it reaches,** at its streamed
start or, for one that did not stream, at its complete message, with
whether it dropped it. The condition is the stream's existing one, now
`dropsStoppedWorkOutput`: the client was told that the stopped work
ended, and the window after the cancel is open.
- **`canUseTool` denies a dropped tool use without showing anything:**
no tool call, no permission request, no elicitation. The model gets "The
user cancelled this prompt." The **preview hook** sends nothing for it.
- **The control channel runs ahead of the consumer,** which is why #1313
left this open. While a cancel's window may be open, `canUseTool` and
the hook wait until the consumer reaches the tool use. Claude Code
streams the tool use before it asks, so the consumer is usually there
within a few ticks. Example: the next prompt's tool asks while the
session still looks cancelled, because the consumer has not reached that
prompt's echo yet. The wait sees the consumer reach the echo, which
closes the window, and then the tool use, which it shows, so the request
goes out.
- **The wait is bounded:** 2 s, or until Claude Code withdraws the
request. The stream never shows some tool uses, such as
SandboxNetworkAccess's, and those then go out as before.
- **Without a cancel, nothing changes,** not even timing: the check
answers synchronously and adds no tick before the permission request.

## Evidence

**Tests** (`src/tests/acp-agent.test.ts`, "output after a cancel";
`src/tests/acp-v2.test.ts`):

- An abandoned prompt's Write: nothing reaches the client, and the tool
is denied. Claude Code asks before the consumer reaches the tool use,
between its streamed start and its message, after its message, or for a
tool use that did not stream. The same on v2, with Claude Code running
the registered PreToolUse hooks and then `canUseTool`.
- The next prompt's tool, asked while the window still looks open: its
request goes out once the consumer reaches it.
- A tool use the stream never shows: the wait ends when the request is
withdrawn (before it asked, or while it waits), or after the bound, and
then the request goes out.
- **Mutation checks:** removing each of the 11 rules fails at least one
test. That covers the `canUseTool` check, the hook check, the wait,
waiting instead of denying, the stream record, the message record, the
stream's dropped flag, the abort exit, the aborted-signal guard, the
timeout, and the synchronous answer.

**Live, against Claude Code** (v1 and v2, `default` permission mode,
every update checked against the schema):

- After a cancel, the next prompt's Writes asked for their permissions
at once and ran. Nothing was denied, and no tool call stayed open; on v2
the turn went `idle cancelled`, `running`, `requires_action`, `running`,
`idle end_turn`. No schema violations.
- The abandoned prompt's race did not happen live: Claude Code folded
the queued prompt into the cancelled request, and the cancel stopped
both. #1313 could not reproduce it either. The tests follow the order a
direct CLI probe recorded for #1309.

`npm run check`, build and the full suite (2931 tests) pass, with no
golden changes.

## Not covered

- On CLIs without `request_marker_lists_v1`, the window still lasts
until the next prompt's echo, as before.
- A tool that runs without a permission, in `acceptEdits` or
`bypassPermissions` mode, still runs for an abandoned prompt. Nothing of
it reaches the client, since its output is dropped. Stopping such tools
would need the adapter to block tools it is not asked about, which this
PR does not do.
2026-10-14 11:42:35 +02:00
Ben Brandt
df21b3fb56 feat: restore a subagent from before a reload when SendMessage resumes it, for subagents RFD clients (#1332)
After `session/load` or `session/resume`, Claude Code can resume an
agent that ran before the reload: a SendMessage to it runs it again from
its transcript. The adapter did not know that agent, so it was never
exposed, and its activity was dropped. Now it runs in its session from
before, the agent id, for every subagents RFD client: v1 clients that
declare `subagents` and are not AIR, and every v2 client.

## What Claude Code sends

From a live run, after a resume in a new adapter process:

- `task_started` with the agent id, the **SendMessage** call's id as
`tool_use_id`, and the message as `prompt`.
- The resumed run's messages under `parent_tool_use_id` = the agent's
**original Agent launch id**, from before the reload (the `toolUseId` in
`subagents/agent-<id>.meta.json`), not the SendMessage id.
- The SendMessage result with `resumedAgentId`, then `task_updated` /
`task_notification` as usual.

The runtime found no child for the agent id, and a SendMessage call
establishes no parent, so the child waited for a parent that never came
and was withdrawn when it ended.

## What changes

- **The signal:** an agent that the runtime does not know, resumed by a
SendMessage call that it saw. A new launch never counts: its
`task_started` names an Agent or Task call.
- **`subagentLineage`** (`subagent-history.ts`) reads the agent and its
ancestors from the session's history, root first. The metadata names
each launch call but not who made it, so a launch's parent is the
session whose transcript has the call: the root's, or an agent's. That
is the rule the replay (#1328) uses, so live restore and replay agree.
An agent or ancestor the history cannot place gives an empty lineage.
- **The runtime** (`NativeSubagentRuntime.restore`) registers the agent
and its ancestors under their agent ids and their launch ids. Then it
announces, parent before child, each ancestor that the client was not
told about in this connection, as the RFD requires before the agent's
traffic. An ancestor is announced with no work state, since nothing says
that it runs. Then the agent, as `running`. From there the resume goes
through the existing path (#1321): the SendMessage text is the child's
`session_message`, and its end is `idle`.
- **Unchanged:** an agent the history cannot place stays unexposed, as
the RFD forbids guessing a parent.
- **Load and replay:** after `session/load`, the replay has already
announced the agent. The live resume announces it once more, as
`running`, which repeats an association the client has (the RFD allows
it).
- **Refactor:** `subagentLaunchIds` moves from `acp-agent.ts` to
`subagent-history.ts`, so the replay and the lineage find launches the
same way.

## Evidence

**Live, against Claude Code, with every update checked against the
schema** (no violations):

| Run | Result |
|---|---|
| v1, `session/resume`, SendMessage to a root-level agent |
`subagent_update` running under its old id, the message from the root,
its `PING` in its own session, `idle end_turn` |
| v1, `session/load`, same | The replay announces it; the live resume
after the response runs in the same session |
| v1, `session/resume`, root → coordinator → echoer, SendMessage from
the root to echoer | coordinator announced under the root with no state,
then echoer under coordinator as running, its run in its session, its
`idle` on coordinator |
| v2, `session/resume` without replay, same nested session | as above;
echoer also reports `running` and `idle` as its own `state_update`,
coordinator has none |

Before this change, the first run showed no `subagent_update` at all,
and the child's `PING` was dropped.

**Tests:**

- `native-subagents.test.ts`: restore under the agent id, with routing
by the launch id; ancestors first and with no state; an ancestor
announced once for two agents; output that waited is delivered; restore
at the SendMessage result alone; an agent the history cannot place or
read stays unexposed; no history read for a new launch or a known child.
- `subagent-history.test.ts` (real transcripts on disk): a root-level
agent, a nested agent next to a sibling, an agent whose launch no
transcript has, a nested agent whose ancestor no transcript placed, an
agent without metadata.
- `acp-agent.test.ts`: the agent's own runtime restores an agent from a
history on disk, from the stream Claude Code sends.
- **Mutation checks:** removing each of the 12 rules fails at least one
test.

`npm run check`, build and the full suite (2922 tests) pass, with no
golden changes.

## Not covered

- **AIR** is unchanged: this PR changes only the RFD form, so on AIR an
agent from before a reload still stays unexposed.
- **A fork** cannot resume an agent of the original session, since the
SDK's `forkSession` copies no `subagents/` history.
2026-10-13 19:09:07 +02:00
acp-release-bot[bot]
7f7a1404a1 chore(main): release 0.90.0 (#1302)
🤖 I have created a release *beep* *boop*
---


##
[0.90.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.89.1...v0.90.0)
(2026-10-13)


### Features

* accept IDE session hooks without dropping model settings
([#1265](https://github.com/agentclientprotocol/claude-agent-acp/issues/1265))
([fe8b69e](fe8b69edd4))
* **deps:** Bump actions/setup-node from 7.0.0 to 7.1.0
([#1329](https://github.com/agentclientprotocol/claude-agent-acp/issues/1329))
([c6510c2](c6510c27c1))
* keep a subagent's session across SendMessage resumes for subagents RFD
clients
([#1321](https://github.com/agentclientprotocol/claude-agent-acp/issues/1321))
([bd06804](bd06804b67))
* replay each subagent under its live session id for subagents RFD
clients
([#1328](https://github.com/agentclientprotocol/claude-agent-acp/issues/1328))
([21eef57](21eef57655))
* report a subagent as requires_action while its request is open for
subagents RFD clients
([#1322](https://github.com/agentclientprotocol/claude-agent-acp/issues/1322))
([252126f](252126f616))
* report subagents as the subagents RFD defines, for clients that are
not AIR
([#1320](https://github.com/agentclientprotocol/claude-agent-acp/issues/1320))
([5758ac8](5758ac84a4))


### Bug Fixes

* close the open tool calls of a held or settling turn that a cancel
ends
([#1221](https://github.com/agentclientprotocol/claude-agent-acp/issues/1221))
([32c23db](32c23db06d))
* display active Central helpers as JetBrains Air Gateway
([#1266](https://github.com/agentclientprotocol/claude-agent-acp/issues/1266))
([d6c3717](d6c371704f))
* drop streamed output of prompts a cancel abandoned
([#1313](https://github.com/agentclientprotocol/claude-agent-acp/issues/1313))
([1137df3](1137df33aa))
* end a cancelled turn at its interrupted result and deliver Claude
Code's follow-up after a cancel
([#1309](https://github.com/agentclientprotocol/claude-agent-acp/issues/1309))
([b007ac7](b007ac7c45))
* end the tool calls of a cancelled turn before it reports the cancel
([#1327](https://github.com/agentclientprotocol/claude-agent-acp/issues/1327))
([53df1c7](53df1c7b22))
* keep the terminal of a command in the background running until its
task ends
([#1312](https://github.com/agentclientprotocol/claude-agent-acp/issues/1312))
([d074558](d074558e4a))
* report a command whose result says interrupted as cancelled on the
experimental ACP v2 surface
([#1301](https://github.com/agentclientprotocol/claude-agent-acp/issues/1301))
([b3bed2f](b3bed2f763))
* route a subagent's PostToolUse hook update to its child session
([#1324](https://github.com/agentclientprotocol/claude-agent-acp/issues/1324))
([4615a91](4615a9141c))
* start a forked session so the client can prompt it at once
([#1317](https://github.com/agentclientprotocol/claude-agent-acp/issues/1317))
([383bc7d](383bc7dcf0))

---
This PR was generated with [Release
Please](https://github.com/googleapis/release-please). See
[documentation](https://github.com/googleapis/release-please#release-please).

Co-authored-by: acp-release-bot[bot] <246668977+acp-release-bot[bot]@users.noreply.github.com>
2026-10-13 18:54:08 +02:00
dependabot[bot]
d6f60f512a feat(deps): Bump @anthropic-ai/claude-agent-sdk to 0.3.296 (#1330)
Bumps the minor group with 14 updates:

| Package | From | To |
| --- | --- | --- |
|
[@anthropic-ai/claude-agent-sdk](https://github.com/anthropics/claude-agent-sdk-typescript)
| `0.3.293` | `0.3.296` |
|
[@anthropic-ai/sdk](https://github.com/anthropics/anthropic-sdk-typescript)
| `0.132.1` | `0.133.0` |
|
[@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node)
| `26.6.4` | `26.6.5` |
| [prettier](https://github.com/prettier/prettier) | `3.9.9` | `3.9.10`
|
| @anthropic-ai/claude-agent-sdk-darwin-arm64 | `0.3.293` | `0.3.296` |
| @anthropic-ai/claude-agent-sdk-darwin-x64 | `0.3.293` | `0.3.296` |
| @anthropic-ai/claude-agent-sdk-linux-arm64 | `0.3.293` | `0.3.296` |
| @anthropic-ai/claude-agent-sdk-linux-arm64-musl | `0.3.293` |
`0.3.296` |
| @anthropic-ai/claude-agent-sdk-linux-x64 | `0.3.293` | `0.3.296` |
| @anthropic-ai/claude-agent-sdk-linux-x64-musl | `0.3.293` | `0.3.296`
|
| @anthropic-ai/claude-agent-sdk-win32-arm64 | `0.3.293` | `0.3.296` |
| @anthropic-ai/claude-agent-sdk-win32-x64 | `0.3.293` | `0.3.296` |
| [express](https://github.com/expressjs/express) | `5.2.1` | `5.3.0` |
| [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) |
`8.3.3` | `8.3.4` |

Updates `@anthropic-ai/claude-agent-sdk` from 0.3.293 to 0.3.296
<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.296</h2>
<h2>What's changed</h2>
<ul>
<li>Added <code>claude_code_version</code> to the
<code>initialize</code> control response so clients know the CLI version
before the first turn</li>
<li>Added <code>autoCompactWindow</code> to
<code>AgentDefinition</code>, so a subagent can auto-compact earlier
than the main conversation's window</li>
<li>Fixed the <code>sandbox</code> option discarding an inline
<code>settings.sandbox</code> block: the two now merge, the option's
values win, and deny and credential lists from both sides are
combined</li>
<li>Changed the default limit on MCP tool descriptions sent up front and
on MCP server instructions, in-process SDK servers included, from 2,048
to 4,096 characters</li>
<li>Changed permission answers that arrive after a restart to be parsed
like live ones: over 4,096 <code>updatedPermissions</code> entries count
as a denial, and a malformed list is ignored as a whole</li>
<li>Updated to parity with Claude Code v2.1.296</li>
</ul>
<h2>Update</h2>
<pre lang="sh"><code>npm install @anthropic-ai/claude-agent-sdk@0.3.296
# or
yarn add @anthropic-ai/claude-agent-sdk@0.3.296
# or
pnpm add @anthropic-ai/claude-agent-sdk@0.3.296
# or
bun add @anthropic-ai/claude-agent-sdk@0.3.296
</code></pre>
<h2>v0.3.295</h2>
<h2>What's changed</h2>
<ul>
<li>Added <code>overageEnabled</code> to <code>SDKRateLimitInfo</code>:
on usage-limit warnings, whether the account has extra usage turned
on</li>
<li>Fixed an MCP server added with <code>mcp_set_servers</code>
sometimes keeping an outdated tool list when another server in the same
request connected later</li>
<li>Fixed assistant text blocks losing their <code>citations</code> in
streamed responses</li>
<li>Fixed Claude being told &quot;disabled by the user&quot; about an
in-process MCP server that was switched off with
<code>toggleMcpServer()</code></li>
<li>Fixed <code>rate_limit_event</code> with status
<code>allowed_warning</code> omitting <code>overageStatus</code>,
<code>overageResetsAt</code> and <code>overageDisabledReason</code>,
which <code>allowed</code> and <code>rejected</code> events already
carry</li>
<li>Changed MCP tool descriptions the model loads through tool search,
including those from in-process SDK servers, to be cut at 16,384
characters instead of 2,048</li>
<li>Changed a permission answer whose <code>updatedPermissions</code>
holds over 4,096 updates, rules and directories to count as a
denial</li>
<li>Changed <code>tool_result_meta[].non_execution_kind</code> to be
absent when a mod withheld the result of a tool that ran without error;
it was <code>permission-rule</code></li>
<li>Changed <code>pasted_content</code> on user messages to be ignored
when it holds over 1,000 entries and blocks in all</li>
<li>Changed how the SDK passes option values to Claude Code: the values
of its named options are now sent in the same argument as their flag
(<code>--flag=value</code>)</li>
<li>Changed <code>tool_use_result</code> for MCP Apps tools:
<code>structuredContent</code> or <code>_meta</code> over 8,388,608 JSON
characters is left off, and <code>_meta.ui</code> with no
<code>ui://</code> resource no longer lifts the ordinary caps</li>
<li>Changed <code>tool_use_result</code> for MCP tools: when a long
result is saved to a file, or cut because it could not be, the text kept
past 500,000 characters is dropped, with a note of how much</li>
<li>Changed <code>tool_use_result</code> for MCP tools: a result
<code>_meta</code> over 1,048,576 JSON characters is left off, except
for SDK-server tools; so is text blocks' <code>_meta</code> that totals
over 65,536</li>
<li>Updated to parity with Claude Code v2.1.295</li>
</ul>
<h2>Update</h2>
<pre lang="sh"><code>npm install @anthropic-ai/claude-agent-sdk@0.3.295
# or
yarn add @anthropic-ai/claude-agent-sdk@0.3.295
# or
pnpm add @anthropic-ai/claude-agent-sdk@0.3.295
# or
bun add @anthropic-ai/claude-agent-sdk@0.3.295
&lt;/tr&gt;&lt;/table&gt; 
</code></pre>
</blockquote>
<p>... (truncated)</p>
</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.296</h2>
<ul>
<li>Added <code>claude_code_version</code> to the
<code>initialize</code> control response so clients know the CLI version
before the first turn</li>
<li>Added <code>autoCompactWindow</code> to
<code>AgentDefinition</code>, so a subagent can auto-compact earlier
than the main conversation's window</li>
<li>Fixed the <code>sandbox</code> option discarding an inline
<code>settings.sandbox</code> block: the two now merge, the option's
values win, and deny and credential lists from both sides are
combined</li>
<li>Changed the default limit on MCP tool descriptions sent up front and
on MCP server instructions, in-process SDK servers included, from 2,048
to 4,096 characters</li>
<li>Changed permission answers that arrive after a restart to be parsed
like live ones: over 4,096 <code>updatedPermissions</code> entries count
as a denial, and a malformed list is ignored as a whole</li>
<li>Updated to parity with Claude Code v2.1.296</li>
</ul>
<h2>0.3.295</h2>
<ul>
<li>Added <code>overageEnabled</code> to <code>SDKRateLimitInfo</code>:
on usage-limit warnings, whether the account has extra usage turned
on</li>
<li>Fixed an MCP server added with <code>mcp_set_servers</code>
sometimes keeping an outdated tool list when another server in the same
request connected later</li>
<li>Fixed assistant text blocks losing their <code>citations</code> in
streamed responses</li>
<li>Fixed Claude being told &quot;disabled by the user&quot; about an
in-process MCP server that was switched off with
<code>toggleMcpServer()</code></li>
<li>Fixed <code>rate_limit_event</code> with status
<code>allowed_warning</code> omitting <code>overageStatus</code>,
<code>overageResetsAt</code> and <code>overageDisabledReason</code>,
which <code>allowed</code> and <code>rejected</code> events already
carry</li>
<li>Changed MCP tool descriptions the model loads through tool search,
including those from in-process SDK servers, to be cut at 16,384
characters instead of 2,048</li>
<li>Changed a permission answer whose <code>updatedPermissions</code>
holds over 4,096 updates, rules and directories to count as a
denial</li>
<li>Changed <code>tool_result_meta[].non_execution_kind</code> to be
absent when a mod withheld the result of a tool that ran without error;
it was <code>permission-rule</code></li>
<li>Changed <code>pasted_content</code> on user messages to be ignored
when it holds over 1,000 entries and blocks in all</li>
<li>Changed how the SDK passes option values to Claude Code: the values
of its named options are now sent in the same argument as their flag
(<code>--flag=value</code>)</li>
<li>Changed <code>tool_use_result</code> for MCP Apps tools:
<code>structuredContent</code> or <code>_meta</code> over 8,388,608 JSON
characters is left off, and <code>_meta.ui</code> with no
<code>ui://</code> resource no longer lifts the ordinary caps</li>
<li>Changed <code>tool_use_result</code> for MCP tools: when a long
result is saved to a file, or cut because it could not be, the text kept
past 500,000 characters is dropped, with a note of how much</li>
<li>Changed <code>tool_use_result</code> for MCP tools: a result
<code>_meta</code> over 1,048,576 JSON characters is left off, except
for SDK-server tools; so is text blocks' <code>_meta</code> that totals
over 65,536</li>
<li>Updated to parity with Claude Code v2.1.295</li>
</ul>
<h2>0.3.294</h2>
<ul>
<li>Updated to parity with Claude Code v2.1.294</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="85d8f8e077"><code>85d8f8e</code></a>
chore: Update CHANGELOG.md</li>
<li><a
href="da321bbe62"><code>da321bb</code></a>
chore: Update CHANGELOG.md</li>
<li><a
href="799dc78749"><code>799dc78</code></a>
chore: Update CHANGELOG.md</li>
<li>See full diff in <a
href="https://github.com/anthropics/claude-agent-sdk-typescript/compare/v0.3.293...v0.3.296">compare
view</a></li>
</ul>
</details>
<br />

Updates `@anthropic-ai/sdk` from 0.132.1 to 0.133.0
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/anthropics/anthropic-sdk-typescript/releases">@​anthropic-ai/sdk's
releases</a>.</em></p>
<blockquote>
<h2>sdk-v0.133.0</h2>
<h3>Features</h3>
<ul>
<li><strong>api:</strong> add types for the Chat and Cowork unified
analytics metrics</li>
<li><strong>api:</strong> add workflows, multiagent configuration and
thread status filtering to Managed Agents</li>
</ul>
<h3>Chores</h3>
<ul>
<li><strong>internal:</strong> remove the next branch rule from
CLAUDE.md</li>
</ul>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/anthropics/anthropic-sdk-typescript/blob/main/CHANGELOG.md">@​anthropic-ai/sdk's
changelog</a>.</em></p>
<blockquote>
<h2><a
href="https://github.com/anthropics/anthropic-sdk-typescript/compare/sdk-v0.132.1...sdk-v0.133.0">0.133.0</a>
(2026-10-09)</h2>
<h3>Features</h3>
<ul>
<li><strong>api:</strong> add types for the Chat and Cowork unified
analytics metrics</li>
<li><strong>api:</strong> add workflows, multiagent configuration and
thread status filtering to Managed Agents</li>
</ul>
<h3>Chores</h3>
<ul>
<li><strong>internal:</strong> remove the next branch rule from
CLAUDE.md</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="2979ed5cef"><code>2979ed5</code></a>
release: . 0.133.0</li>
<li><a
href="8486d9bd22"><code>8486d9b</code></a>
Merge pull request <a
href="https://redirect.github.com/anthropics/anthropic-sdk-typescript/issues/908">#908</a>
from anthropics/synced/published</li>
<li><a
href="e0995e4bde"><code>e0995e4</code></a>
feat(api): add workflows, multiagent configuration and thread status
filterin...</li>
<li><a
href="31eb3bea68"><code>31eb3be</code></a>
codegen metadata</li>
<li><a
href="5127b320bd"><code>5127b32</code></a>
codegen metadata</li>
<li><a
href="f702a46d87"><code>f702a46</code></a>
codegen metadata</li>
<li><a
href="d64e976b4e"><code>d64e976</code></a>
codegen metadata</li>
<li><a
href="9f754653fd"><code>9f75465</code></a>
codegen metadata</li>
<li><a
href="6ec2ea17ac"><code>6ec2ea1</code></a>
chore(internal): remove the next branch rule from CLAUDE.md</li>
<li><a
href="373c33dedf"><code>373c33d</code></a>
codegen metadata</li>
<li>Additional commits viewable in <a
href="https://github.com/anthropics/anthropic-sdk-typescript/compare/sdk-v0.132.1...sdk-v0.133.0">compare
view</a></li>
</ul>
</details>
<br />

Updates `@types/node` from 26.6.4 to 26.6.5
<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 `prettier` from 3.9.9 to 3.9.10
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/prettier/prettier/releases">prettier's
releases</a>.</em></p>
<blockquote>
<h2>3.9.10</h2>
<ul>
<li>Markdown: Update <code>micromark-extension-gfm-table</code> to fix
the table parse issue on performance (<a
href="https://redirect.github.com/prettier/prettier/pull/20266">#20266</a>
by <a href="https://github.com/fisker"><code>@​fisker</code></a>)</li>
</ul>
<p>🔗 <a
href="https://github.com/prettier/prettier/blob/3.9.10/CHANGELOG.md#3910">Changelog</a></p>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/prettier/prettier/blob/main/CHANGELOG.md">prettier's
changelog</a>.</em></p>
<blockquote>
<h1>3.9.10</h1>
<p><a
href="https://github.com/prettier/prettier/compare/3.9.9...3.9.10">diff</a></p>
<h4>Markdown: Update <code>micromark-extension-gfm-table</code> to fix
the table parse issue on performance (<a
href="https://redirect.github.com/prettier/prettier/pull/20266">#20266</a>
by <a href="https://github.com/fisker"><code>@​fisker</code></a>)</h4>
<p>See <a
href="4f43ead37b</a>
for details.</p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="1172b82c29"><code>1172b82</code></a>
Release 3.9.10</li>
<li><a
href="d0142273af"><code>d014227</code></a>
Update <code>micromark-extension-gfm-table</code> to fix the table parse
issue on perfor...</li>
<li><a
href="953b908432"><code>953b908</code></a>
Bump Prettier dependency to 3.9.9</li>
<li><a
href="ad8a6bf4fa"><code>ad8a6bf</code></a>
Clean changelog_unreleased</li>
<li>See full diff in <a
href="https://github.com/prettier/prettier/compare/3.9.9...3.9.10">compare
view</a></li>
</ul>
</details>
<br />

Updates `@anthropic-ai/claude-agent-sdk-darwin-arm64` from 0.3.293 to
0.3.296

Updates `@anthropic-ai/claude-agent-sdk-darwin-x64` from 0.3.293 to
0.3.296

Updates `@anthropic-ai/claude-agent-sdk-linux-arm64` from 0.3.293 to
0.3.296

Updates `@anthropic-ai/claude-agent-sdk-linux-arm64-musl` from 0.3.293
to 0.3.296

Updates `@anthropic-ai/claude-agent-sdk-linux-x64` from 0.3.293 to
0.3.296

Updates `@anthropic-ai/claude-agent-sdk-linux-x64-musl` from 0.3.293 to
0.3.296

Updates `@anthropic-ai/claude-agent-sdk-win32-arm64` from 0.3.293 to
0.3.296

Updates `@anthropic-ai/claude-agent-sdk-win32-x64` from 0.3.293 to
0.3.296

Updates `express` from 5.2.1 to 5.3.0
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/expressjs/express/releases">express's
releases</a>.</em></p>
<blockquote>
<h2>v5.3.0</h2>
<h2>Highlights</h2>
<h3>🐞 Bug fixes</h3>
<ul>
<li>
<p>Fixed HTTP header conflict between Content-Length and
Transfer-Encoding in res.send - by <a
href="https://github.com/YuryShkoda"><code>@​YuryShkoda</code></a> in <a
href="https://redirect.github.com/expressjs/express/pull/4893">#4893</a></p>
<p>Fixed the behavior of <code>res.send()</code> to prevent conflicts
between <code>Content-Length</code> and <code>Transfer-Encoding</code>
HTTP headers in responses. The <code>Content-Length</code> header in
<code>res.send()</code> is now only added when a
<code>Transfer-Encoding</code> header is not present, complying with the
HTTP specification that states both headers should not coexist in the
same response. ETag generation is unaffected by the presence of a
<code>Transfer-Encoding</code> header - by <a
href="https://github.com/cuishuang"><code>@​cuishuang</code></a> in <a
href="https://redirect.github.com/expressjs/express/pull/7459">#7459</a></p>
</li>
</ul>
<ul>
<li>
<p>Upgrade <code>qs</code> to <code>^6.16.0</code>, which fixes <a
href="https://www.cve.org/CVERecord?id=CVE-2026-2391">CVE-2026-2391</a>
(<a
href="https://github.com/ljharb/qs/security/advisories/GHSA-w7fw-mjwx-w883">GHSA-w7fw-mjwx-w883</a>),
<a
href="https://www.cve.org/CVERecord?id=CVE-2026-82417">CVE-2026-82417</a>
(<a
href="https://github.com/advisories/GHSA-4mjr-xmp4-gh2g">GHSA-4mjr-xmp4-gh2g</a>)
and <a
href="https://www.cve.org/CVERecord?id=CVE-2026-82562">CVE-2026-82562</a>
(<a
href="https://github.com/advisories/GHSA-x5fp-wj9c-mxmx">GHSA-x5fp-wj9c-mxmx</a>):
<code>arrayLimit</code> bypasses in comma parsing and a denial of
service via an attacker-controlled <code>isBuffer</code> - by <a
href="https://github.com/davetashner"><code>@​davetashner</code></a> in
<a
href="https://redirect.github.com/expressjs/express/pull/7057">#7057</a>
and <a
href="https://redirect.github.com/expressjs/express/pull/7478">#7478</a>,
and <a
href="https://github.com/cyphercodes"><code>@​cyphercodes</code></a> in
<a
href="https://redirect.github.com/expressjs/express/pull/7305">#7305</a></p>
</li>
<li>
<p>Upgrade <code>proxy-addr</code> to <code>^2.0.8</code>, which fixes
<a
href="https://www.cve.org/CVERecord?id=CVE-2026-90711">CVE-2026-90711</a>
- by <a href="https://github.com/lazerg"><code>@​lazerg</code></a> in <a
href="https://redirect.github.com/expressjs/express/pull/7474">#7474</a></p>
</li>
</ul>
<h3>🚀 Improvements</h3>
<ul>
<li>
<p>Allow conditional revalidation for QUERY requests.
<code>req.fresh</code> previously only validated freshness for GET and
HEAD requests, so QUERY responses never returned 304 despite a matching
validator. Since QUERY is a safe, idempotent, and cacheable method that
supports conditional requests, it is now included in the freshness check
- by <a href="https://github.com/Cherry"><code>@​Cherry</code></a> in <a
href="https://redirect.github.com/expressjs/express/pull/7366">#7366</a></p>
<pre lang="js"><code>// QUERY /reports with If-None-Match:
&quot;12345&quot;
app.query('/reports', (req, res) =&gt; {
  res.set('ETag', '&quot;12345&quot;');
  res.send(results); // now responds 304 Not Modified
});
</code></pre>
</li>
<li>
<p>Improve HTML structure in <code>res.redirect()</code> responses when
HTML format is accepted by adding <code>&lt;!DOCTYPE html&gt;</code>,
<code>&lt;title&gt;</code>, and <code>&lt;body&gt;</code> tags for
better browser compatibility - by <a
href="https://github.com/Bernice55231"><code>@​Bernice55231</code></a>
in <a
href="https://redirect.github.com/expressjs/express/pull/5167">#5167</a></p>
</li>
<li>
<p>When calling <code>app.render</code> with options set to null, the
locals object is handled correctly, preventing unexpected errors and
making the method behave the same as when options is omitted or an empty
object is passed - by <a
href="https://github.com/AkaHarshit">AkaHarshit</a> in <a
href="https://redirect.github.com/expressjs/express/pull/6903">#6903</a></p>
<pre lang="js"><code>app.render('index', null, callback); // now works
as expected
</code></pre>
</li>
<li>
<p>Upgrade <code>content-type</code> to <code>^2.0.0</code>, bringing a
faster parser (~1.5x quicker <code>Content-Type</code>
parsing/formatting in <code>res.send()</code>) along with a behavior
change: <code>res.send()</code> now keeps any existing parameters when
adding the charset and no longer throws on a <code>Content-Type</code>
that fails to parse. <code>type-is</code> is upgraded to
<code>^2.1.0</code> as part of the same change - by <a
href="https://github.com/blakeembrey"><code>@​blakeembrey</code></a> in
<a
href="https://redirect.github.com/expressjs/express/pull/7234">#7234</a></p>
<pre lang="js"><code>res.set('Content-Type', 'text/plain;
foo=bar').send('hey');
// -&gt; Content-Type: text/plain; foo=bar; charset=utf-8
</code></pre>
</li>
<li>
<p>The default error handler now logs the full error object instead of
only its stack trace, so nested details such as <code>Error.cause</code>
and library-specific properties (e.g. Sequelize's
<code>parent</code>/<code>original</code>) are no longer swallowed - by
<a
href="https://github.com/Nitin-Mohapatra"><code>@​Nitin-Mohapatra</code></a>
in <a
href="https://redirect.github.com/expressjs/express/pull/6464">#6464</a></p>
</li>
<li>
<p>Upgrade <code>content-disposition</code> to <code>^2.0.1</code>,
which changes the <code>Content-Disposition</code> header emitted by
<code>res.download()</code>, <code>res.attachment()</code>, and
<code>res.sendFile()</code>: file names that are valid HTTP tokens are
no longer wrapped in quotes. This is equivalent per RFC 6266, but
applications asserting on the exact header bytes should update their
expectations - by <a
href="https://github.com/blakeembrey"><code>@​blakeembrey</code></a> in
<a
href="https://redirect.github.com/expressjs/express/pull/7233">#7233</a></p>
<pre lang="js"><code>res.attachment('user.html');
// before -&gt; Content-Disposition: attachment;
filename=&quot;user.html&quot;
// after  -&gt; Content-Disposition: attachment; filename=user.html
</code></pre>
</li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/expressjs/express/blob/master/History.md">express's
changelog</a>.</em></p>
<blockquote>
<h1>5.3.0</h1>
<h2>🐞 Bug fixes</h2>
<ul>
<li>
<p>Fixed HTTP header conflict between Content-Length and
Transfer-Encoding in res.send - by <a
href="https://github.com/YuryShkoda"><code>@​YuryShkoda</code></a> in <a
href="https://redirect.github.com/expressjs/express/pull/4893">#4893</a></p>
<p>Fixed the behavior of <code>res.send()</code> to prevent conflicts
between <code>Content-Length</code> and <code>Transfer-Encoding</code>
HTTP headers in responses. The <code>Content-Length</code> header in
<code>res.send()</code> is now only added when a
<code>Transfer-Encoding</code> header is not present, complying with the
HTTP specification that states both headers should not coexist in the
same response. ETag generation is unaffected by the presence of a
<code>Transfer-Encoding</code> header - by <a
href="https://github.com/cuishuang"><code>@​cuishuang</code></a> in <a
href="https://redirect.github.com/expressjs/express/pull/7459">#7459</a></p>
</li>
</ul>
<ul>
<li>
<p>Upgrade <code>qs</code> to <code>^6.16.0</code>, which fixes <a
href="https://www.cve.org/CVERecord?id=CVE-2026-2391">CVE-2026-2391</a>
(<a
href="https://github.com/ljharb/qs/security/advisories/GHSA-w7fw-mjwx-w883">GHSA-w7fw-mjwx-w883</a>),
<a
href="https://www.cve.org/CVERecord?id=CVE-2026-82417">CVE-2026-82417</a>
(<a
href="https://github.com/advisories/GHSA-4mjr-xmp4-gh2g">GHSA-4mjr-xmp4-gh2g</a>)
and <a
href="https://www.cve.org/CVERecord?id=CVE-2026-82562">CVE-2026-82562</a>
(<a
href="https://github.com/advisories/GHSA-x5fp-wj9c-mxmx">GHSA-x5fp-wj9c-mxmx</a>):
<code>arrayLimit</code> bypasses in comma parsing and a denial of
service via an attacker-controlled <code>isBuffer</code> - by <a
href="https://github.com/davetashner"><code>@​davetashner</code></a> in
<a
href="https://redirect.github.com/expressjs/express/pull/7057">#7057</a>
and <a
href="https://redirect.github.com/expressjs/express/pull/7478">#7478</a>,
and <a
href="https://github.com/cyphercodes"><code>@​cyphercodes</code></a> in
<a
href="https://redirect.github.com/expressjs/express/pull/7305">#7305</a></p>
</li>
<li>
<p>Upgrade <code>proxy-addr</code> to <code>^2.0.8</code>, which fixes
<a
href="https://www.cve.org/CVERecord?id=CVE-2026-90711">CVE-2026-90711</a>
- by <a href="https://github.com/lazerg"><code>@​lazerg</code></a> in <a
href="https://redirect.github.com/expressjs/express/pull/7474">#7474</a></p>
</li>
</ul>
<h2>🚀 Improvements</h2>
<ul>
<li>
<p>Allow conditional revalidation for QUERY requests.
<code>req.fresh</code> previously only validated freshness for GET and
HEAD requests, so QUERY responses never returned 304 despite a matching
validator. Since QUERY is a safe, idempotent, and cacheable method that
supports conditional requests, it is now included in the freshness check
- by <a href="https://github.com/Cherry"><code>@​Cherry</code></a> in <a
href="https://redirect.github.com/expressjs/express/pull/7366">#7366</a></p>
<pre lang="js"><code>// QUERY /reports with If-None-Match:
&quot;12345&quot;
app.query('/reports', (req, res) =&gt; {
  res.set('ETag', '&quot;12345&quot;');
  res.send(results); // now responds 304 Not Modified
});
</code></pre>
</li>
<li>
<p>Improve HTML structure in <code>res.redirect()</code> responses when
HTML format is accepted by adding <code>&lt;!DOCTYPE html&gt;</code>,
<code>&lt;title&gt;</code>, and <code>&lt;body&gt;</code> tags for
better browser compatibility - by <a
href="https://github.com/Bernice55231"><code>@​Bernice55231</code></a>
in <a
href="https://redirect.github.com/expressjs/express/pull/5167">#5167</a></p>
</li>
<li>
<p>When calling <code>app.render</code> with options set to null, the
locals object is handled correctly, preventing unexpected errors and
making the method behave the same as when options is omitted or an empty
object is passed - by <a
href="https://github.com/AkaHarshit">AkaHarshit</a> in <a
href="https://redirect.github.com/expressjs/express/pull/6903">#6903</a></p>
<pre lang="js"><code>app.render('index', null, callback); // now works
as expected
</code></pre>
</li>
<li>
<p>Upgrade <code>content-type</code> to <code>^2.0.0</code>, bringing a
faster parser (~1.5x quicker <code>Content-Type</code>
parsing/formatting in <code>res.send()</code>) along with a behavior
change: <code>res.send()</code> now keeps any existing parameters when
adding the charset and no longer throws on a <code>Content-Type</code>
that fails to parse. <code>type-is</code> is upgraded to
<code>^2.1.0</code> as part of the same change - by <a
href="https://github.com/blakeembrey"><code>@​blakeembrey</code></a> in
<a
href="https://redirect.github.com/expressjs/express/pull/7234">#7234</a></p>
<pre lang="js"><code>res.set('Content-Type', 'text/plain;
foo=bar').send('hey');
// -&gt; Content-Type: text/plain; foo=bar; charset=utf-8
</code></pre>
</li>
<li>
<p>The default error handler now logs the full error object instead of
only its stack trace, so nested details such as <code>Error.cause</code>
and library-specific properties (e.g. Sequelize's
<code>parent</code>/<code>original</code>) are no longer swallowed - by
<a
href="https://github.com/Nitin-Mohapatra"><code>@​Nitin-Mohapatra</code></a>
in <a
href="https://redirect.github.com/expressjs/express/pull/6464">#6464</a></p>
</li>
<li>
<p>Upgrade <code>content-disposition</code> to <code>^2.0.1</code>,
which changes the <code>Content-Disposition</code> header emitted by
<code>res.download()</code>, <code>res.attachment()</code>, and
<code>res.sendFile()</code>: file names that are valid HTTP tokens are
no longer wrapped in quotes. This is equivalent per RFC 6266, but
applications asserting on the exact header bytes should update their
expectations - by <a
href="https://github.com/blakeembrey"><code>@​blakeembrey</code></a> in
<a
href="https://redirect.github.com/expressjs/express/pull/7233">#7233</a></p>
<pre lang="js"><code>res.attachment('user.html');
// before -&gt; Content-Disposition: attachment;
filename=&quot;user.html&quot;
// after  -&gt; Content-Disposition: attachment; filename=user.html
</code></pre>
</li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="cf6722bf89"><code>cf6722b</code></a>
5.3.0 (<a
href="https://redirect.github.com/expressjs/express/issues/7468">#7468</a>)</li>
<li><a
href="8c2fcfcb7d"><code>8c2fcfc</code></a>
docs: fix typos and a stale API reference in lib comments (<a
href="https://redirect.github.com/expressjs/express/issues/7512">#7512</a>)</li>
<li><a
href="9efc29e280"><code>9efc29e</code></a>
docs: fix pass-through typos (<a
href="https://redirect.github.com/expressjs/express/issues/7509">#7509</a>)</li>
<li><a
href="2717ae3cbf"><code>2717ae3</code></a>
build(deps): bump the github-actions group with 3 updates (<a
href="https://redirect.github.com/expressjs/express/issues/7499">#7499</a>)</li>
<li><a
href="de777714df"><code>de77771</code></a>
build(deps-dev): bump hbs from 4.3.0 to 4.3.1 (<a
href="https://redirect.github.com/expressjs/express/issues/7500">#7500</a>)</li>
<li><a
href="9169cbe112"><code>9169cbe</code></a>
docs: add missing apostrophe in example (<a
href="https://redirect.github.com/expressjs/express/issues/7483">#7483</a>)</li>
<li><a
href="7ef98448f8"><code>7ef9844</code></a>
build(deps-dev): bump morgan from 1.12.0 to 1.12.1 (<a
href="https://redirect.github.com/expressjs/express/issues/7493">#7493</a>)</li>
<li><a
href="3b7e39fe74"><code>3b7e39f</code></a>
deps: bump qs minimum to 6.16.0 (<a
href="https://redirect.github.com/expressjs/express/issues/7478">#7478</a>)</li>
<li><a
href="98bd4cd96b"><code>98bd4cd</code></a>
deps: proxy-addr@^2.0.8 (<a
href="https://redirect.github.com/expressjs/express/issues/7474">#7474</a>)</li>
<li><a
href="9a34acf03c"><code>9a34acf</code></a>
fix(res.send): preserve ETag generation with Transfer-Encoding (<a
href="https://redirect.github.com/expressjs/express/issues/7459">#7459</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/expressjs/express/compare/v5.2.1...v5.3.0">compare
view</a></li>
</ul>
</details>
<details>
<summary>Maintainer changes</summary>
<p>This version was pushed to npm by <a
href="https://www.npmjs.com/~GitHub%20Actions">GitHub Actions</a>, a new
releaser for express since your current version.</p>
</details>
<br />

Updates `vite` from 8.3.3 to 8.3.4
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/vitejs/vite/releases">vite's
releases</a>.</em></p>
<blockquote>
<h2>v8.3.4</h2>
<h3>Features</h3>
<ul>
<li><strong>bundled-dev:</strong> support
<code>import.meta.hot.acceptExports</code> (<a
href="https://redirect.github.com/vitejs/vite/issues/23463">#23463</a>)
(<a
href="bc0f21c56a">bc0f21c</a>)</li>
</ul>
<h3>Bug Fixes</h3>
<ul>
<li>avoid mutating hmr defaults (fix <a
href="https://redirect.github.com/vitejs/vite/issues/23575">#23575</a>)
(<a
href="https://redirect.github.com/vitejs/vite/issues/23576">#23576</a>)
(<a
href="574d5e3519">574d5e3</a>)</li>
<li><strong>build:</strong> correct preloads when chunkImportMap is
enabled with non-root base (<a
href="https://redirect.github.com/vitejs/vite/issues/23698">#23698</a>)
(<a
href="2a8c0f05c7">2a8c0f0</a>)</li>
<li><strong>build:</strong> wait for in-flight stylesheets in preload
helper (fix <a
href="https://redirect.github.com/vitejs/vite/issues/23652">#23652</a>)
(<a
href="https://redirect.github.com/vitejs/vite/issues/23662">#23662</a>)
(<a
href="036b74599b">036b745</a>)</li>
<li><strong>bundled-dev:</strong> serve <code>/@vite/client</code> and
stub <code>@vite/env</code> (<a
href="https://redirect.github.com/vitejs/vite/issues/23664">#23664</a>)
(<a
href="5afc5a8876">5afc5a8</a>)</li>
<li><strong>client:</strong> skip rewriting unchanged adopted styles (<a
href="https://redirect.github.com/vitejs/vite/issues/23697">#23697</a>)
(<a
href="fa7146938b">fa71469</a>)</li>
<li><strong>css:</strong> resolve imported preprocessors from their file
path with lightningcss (<a
href="https://redirect.github.com/vitejs/vite/issues/23688">#23688</a>)
(<a
href="cf52011c46">cf52011</a>)</li>
<li><strong>deps:</strong> update all non-major dependencies (<a
href="https://redirect.github.com/vitejs/vite/issues/23648">#23648</a>)
(<a
href="8a4c19cfc0">8a4c19c</a>)</li>
<li><strong>deps:</strong> update rolldown-related dependencies (<a
href="https://redirect.github.com/vitejs/vite/issues/23649">#23649</a>)
(<a
href="794516d50c">794516d</a>)</li>
<li><strong>dev:</strong> a restart requested during a restart used to
be dropped (fix <a
href="https://redirect.github.com/vitejs/vite/issues/23392">#23392</a>)
(<a
href="https://redirect.github.com/vitejs/vite/issues/23484">#23484</a>)
(<a
href="70d56ea184">70d56ea</a>)</li>
<li>don't treat modules ending with js-like query as js (<a
href="https://redirect.github.com/vitejs/vite/issues/23687">#23687</a>)
(<a
href="fcb6f1252a">fcb6f12</a>)</li>
<li><strong>html:</strong> avoid adding filesystem root to watcher (<a
href="https://redirect.github.com/vitejs/vite/issues/23689">#23689</a>)
(<a
href="3a7f2aea75">3a7f2ae</a>)</li>
<li><strong>html:</strong> skip caching CSS of chunks in a cycle (fix <a
href="https://redirect.github.com/vitejs/vite/issues/23628">#23628</a>)
(<a
href="https://redirect.github.com/vitejs/vite/issues/23636">#23636</a>)
(<a
href="15d82f7dc6">15d82f7</a>)</li>
<li><strong>optimizer:</strong> scan deep imports with custom extensions
(<a
href="https://redirect.github.com/vitejs/vite/issues/23678">#23678</a>)
(<a
href="8579189bae">8579189</a>)</li>
<li>remove input option unescaping for now (<a
href="https://redirect.github.com/vitejs/vite/issues/23694">#23694</a>)
(<a
href="facc2fb1ff">facc2fb</a>)</li>
<li><strong>server:</strong> match static aliases on path boundaries (<a
href="https://redirect.github.com/vitejs/vite/issues/23643">#23643</a>)
(<a
href="b28db28e9e">b28db28</a>)</li>
<li><strong>watcher:</strong> normalize file before root check in
<code>ensureWatchedFile</code> (<a
href="https://redirect.github.com/vitejs/vite/issues/23673">#23673</a>)
(<a
href="12bb28c223">12bb28c</a>)</li>
</ul>
<h3>Performance Improvements</h3>
<ul>
<li><strong>module-runner:</strong> skip cloning call sites that have no
source map (<a
href="https://redirect.github.com/vitejs/vite/issues/23646">#23646</a>)
(<a
href="3d674869a5">3d67486</a>)</li>
</ul>
<h3>Code Refactoring</h3>
<ul>
<li><strong>bundled-dev:</strong> import CSS HMR helpers from
<code>/@vite/client</code> (<a
href="https://redirect.github.com/vitejs/vite/issues/23674">#23674</a>)
(<a
href="182f5b2a65">182f5b2</a>)</li>
</ul>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md">vite's
changelog</a>.</em></p>
<blockquote>
<h2><!-- raw HTML omitted --><a
href="https://github.com/vitejs/vite/compare/v8.3.3...v8.3.4">8.3.4</a>
(2026-10-08)<!-- raw HTML omitted --></h2>
<h3>Features</h3>
<ul>
<li><strong>bundled-dev:</strong> support
<code>import.meta.hot.acceptExports</code> (<a
href="https://redirect.github.com/vitejs/vite/issues/23463">#23463</a>)
(<a
href="bc0f21c56a">bc0f21c</a>)</li>
</ul>
<h3>Bug Fixes</h3>
<ul>
<li>avoid mutating hmr defaults (fix <a
href="https://redirect.github.com/vitejs/vite/issues/23575">#23575</a>)
(<a
href="https://redirect.github.com/vitejs/vite/issues/23576">#23576</a>)
(<a
href="574d5e3519">574d5e3</a>)</li>
<li><strong>build:</strong> correct preloads when chunkImportMap is
enabled with non-root base (<a
href="https://redirect.github.com/vitejs/vite/issues/23698">#23698</a>)
(<a
href="2a8c0f05c7">2a8c0f0</a>)</li>
<li><strong>build:</strong> wait for in-flight stylesheets in preload
helper (fix <a
href="https://redirect.github.com/vitejs/vite/issues/23652">#23652</a>)
(<a
href="https://redirect.github.com/vitejs/vite/issues/23662">#23662</a>)
(<a
href="036b74599b">036b745</a>)</li>
<li><strong>bundled-dev:</strong> serve <code>/@vite/client</code> and
stub <code>@vite/env</code> (<a
href="https://redirect.github.com/vitejs/vite/issues/23664">#23664</a>)
(<a
href="5afc5a8876">5afc5a8</a>)</li>
<li><strong>client:</strong> skip rewriting unchanged adopted styles (<a
href="https://redirect.github.com/vitejs/vite/issues/23697">#23697</a>)
(<a
href="fa7146938b">fa71469</a>)</li>
<li><strong>css:</strong> resolve imported preprocessors from their file
path with lightningcss (<a
href="https://redirect.github.com/vitejs/vite/issues/23688">#23688</a>)
(<a
href="cf52011c46">cf52011</a>)</li>
<li><strong>deps:</strong> update all non-major dependencies (<a
href="https://redirect.github.com/vitejs/vite/issues/23648">#23648</a>)
(<a
href="8a4c19cfc0">8a4c19c</a>)</li>
<li><strong>deps:</strong> update rolldown-related dependencies (<a
href="https://redirect.github.com/vitejs/vite/issues/23649">#23649</a>)
(<a
href="794516d50c">794516d</a>)</li>
<li><strong>dev:</strong> a restart requested during a restart used to
be dropped (fix <a
href="https://redirect.github.com/vitejs/vite/issues/23392">#23392</a>)
(<a
href="https://redirect.github.com/vitejs/vite/issues/23484">#23484</a>)
(<a
href="70d56ea184">70d56ea</a>)</li>
<li>don't treat modules ending with js-like query as js (<a
href="https://redirect.github.com/vitejs/vite/issues/23687">#23687</a>)
(<a
href="fcb6f1252a">fcb6f12</a>)</li>
<li><strong>html:</strong> avoid adding filesystem root to watcher (<a
href="https://redirect.github.com/vitejs/vite/issues/23689">#23689</a>)
(<a
href="3a7f2aea75">3a7f2ae</a>)</li>
<li><strong>html:</strong> skip caching CSS of chunks in a cycle (fix <a
href="https://redirect.github.com/vitejs/vite/issues/23628">#23628</a>)
(<a
href="https://redirect.github.com/vitejs/vite/issues/23636">#23636</a>)
(<a
href="15d82f7dc6">15d82f7</a>)</li>
<li><strong>optimizer:</strong> scan deep imports with custom extensions
(<a
href="https://redirect.github.com/vitejs/vite/issues/23678">#23678</a>)
(<a
href="8579189bae">8579189</a>)</li>
<li>remove input option unescaping for now (<a
href="https://redirect.github.com/vitejs/vite/issues/23694">#23694</a>)
(<a
href="facc2fb1ff">facc2fb</a>)</li>
<li><strong>server:</strong> match static aliases on path boundaries (<a
href="https://redirect.github.com/vitejs/vite/issues/23643">#23643</a>)
(<a
href="b28db28e9e">b28db28</a>)</li>
<li><strong>watcher:</strong> normalize file before root check in
<code>ensureWatchedFile</code> (<a
href="https://redirect.github.com/vitejs/vite/issues/23673">#23673</a>)
(<a
href="12bb28c223">12bb28c</a>)</li>
</ul>
<h3>Performance Improvements</h3>
<ul>
<li><strong>module-runner:</strong> skip cloning call sites that have no
source map (<a
href="https://redirect.github.com/vitejs/vite/issues/23646">#23646</a>)
(<a
href="3d674869a5">3d67486</a>)</li>
</ul>
<h3>Code Refactoring</h3>
<ul>
<li><strong>bundled-dev:</strong> import CSS HMR helpers from
<code>/@vite/client</code> (<a
href="https://redirect.github.com/vitejs/vite/issues/23674">#23674</a>)
(<a
href="182f5b2a65">182f5b2</a>)</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="b6c20f6a81"><code>b6c20f6</code></a>
release: v8.3.4 (<a
href="https://github.com/vitejs/vite/tree/HEAD/packages/vite/issues/23702">#23702</a>)</li>
<li><a
href="b28db28e9e"><code>b28db28</code></a>
fix(server): match static aliases on path boundaries (<a
href="https://github.com/vitejs/vite/tree/HEAD/packages/vite/issues/23643">#23643</a>)</li>
<li><a
href="bc0f21c56a"><code>bc0f21c</code></a>
feat(bundled-dev): support <code>import.meta.hot.acceptExports</code>
(<a
href="https://github.com/vitejs/vite/tree/HEAD/packages/vite/issues/23463">#23463</a>)</li>
<li><a
href="fa7146938b"><code>fa71469</code></a>
fix(client): skip rewriting unchanged adopted styles (<a
href="https://github.com/vitejs/vite/tree/HEAD/packages/vite/issues/23697">#23697</a>)</li>
<li><a
href="2a8c0f05c7"><code>2a8c0f0</code></a>
fix(build): correct preloads when chunkImportMap is enabled with
non-root bas...</li>
<li><a
href="3a7f2aea75"><code>3a7f2ae</code></a>
fix(html): avoid adding filesystem root to watcher (<a
href="https://github.com/vitejs/vite/tree/HEAD/packages/vite/issues/23689">#23689</a>)</li>
<li><a
href="facc2fb1ff"><code>facc2fb</code></a>
fix: remove input option unescaping for now (<a
href="https://github.com/vitejs/vite/tree/HEAD/packages/vite/issues/23694">#23694</a>)</li>
<li><a
href="70d56ea184"><code>70d56ea</code></a>
fix(dev): a restart requested during a restart used to be dropped (fix
<a
href="https://github.com/vitejs/vite/tree/HEAD/packages/vite/issues/23392">#23392</a>...</li>
<li><a
href="cf52011c46"><code>cf52011</code></a>
fix(css): resolve imported preprocessors from their file path with
lightningc...</li>
<li><a
href="fcb6f1252a"><code>fcb6f12</code></a>
fix: don't treat modules ending with js-like query as js (<a
href="https://github.com/vitejs/vite/tree/HEAD/packages/vite/issues/23687">#23687</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/vitejs/vite/commits/v8.3.4/packages/vite">compare
view</a></li>
</ul>
</details>
<br />


Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore <dependency name> major version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's major version (unless you unignore this specific
dependency's major version or upgrade to it yourself)
- `@dependabot ignore <dependency name> minor version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's minor version (unless you unignore this specific
dependency's minor version or upgrade to it yourself)
- `@dependabot ignore <dependency name>` will close this group update PR
and stop Dependabot creating any more for the specific dependency
(unless you unignore this specific dependency or upgrade to it yourself)
- `@dependabot unignore <dependency name>` will remove all of the ignore
conditions of the specified dependency
- `@dependabot unignore <dependency name> <ignore condition>` will
remove the ignore condition of the specified dependency and ignore
conditions


</details>

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Ben Brandt <benjamin.j.brandt@gmail.com>
2026-10-13 16:41:54 +00:00
Demetrios Chiuratto Agourakis
32c23db06d fix: close the open tool calls of a held or settling turn that a cancel ends (#1221)
Refs #1061.

> Reworked on top of #1327, which merged in the meantime and already
closes a cancelled turn's open tool calls in the consumer. This PR now
covers the one path #1327 does not reach. The original description is
kept at the end.

## What it fixes

When a cancel lands on a turn that already has its result, `cancelTurns`
settles the turn itself instead of leaving it to the consumer:

- a turn held open for its background subagents, while the session is
idle;
- a turn waiting on its checkpoint preview.

That path skipped #1327's `closeCancelledToolCalls`, so a tool call
still open there stayed `pending` after the cancelled response.
`cancelTurns` now runs the same closing before it responds.

## Changes

- **One rule for both paths.** The consumer exposes its closing as
`Session.closeCancelledToolCalls`, the way it already exposes
`nativeSubagentDeliver`. Every open call ends with
`AcpToolCallRenderer.unfinished`, except a background subagent's calls,
which the cancel did not stop.
- **The closing runs once per turn** (`Turn.closingCancelledToolCalls`).
Example: a first cancel lands during a held turn's follow-up cycle, so
the consumer settles the turn at the idle and starts closing its calls.
A second cancel then settles the turn inline. It waits for the
consumer's closing instead of starting its own, so each call gets one
report and the response comes after all of them.
- **`cancelTurns`** marks the turn settled before it waits for the
reports, so the consumer cannot settle it again. If a report fails, it
logs the error and still responds and interrupts.
- **Removed:** this PR's original consumer-side branch and its `failed`
report, which #1327's closing replaces.

## Tests

- **Kept from this PR:** inline settles of a deferred turn and of a
checkpoint turn, overlapping and simultaneous cancels, a failed report,
a checkpoint that finishes during the closing.
- **Changed:** the deferred fixture's background shell had no tool
result, though Claude Code answers a `run_in_background` call when it
starts. It is now a background subagent whose own command stays open.
- **Removed:** `terminates a tool whose start streams in after the
cancel`, which #1327 covers.
- **New:** a second cancel that settles a held turn while the consumer
is closing its calls.
- **Removal checks:** removing the inline closing fails 6 tests, the
once-per-turn closing 1, settling before the wait 2, and the error
handling 1.

`npm run check`, build and the full suite (2909 tests) pass, with no
golden changes.

## Not covered

A tool start that streams in after the turn has settled (the incident in
#1061) still opens a tool call that nothing closes. Neither this PR nor
#1327 changes that.

<details>
<summary>Original description</summary>

Refs #1061. This closes part of the issue, not all of it; the part it
leaves open is described below.

## What it fixes

`settleActive` already fails every unfinished foreground tool when a
turn ends as `end_turn` ("Claude ended the turn without returning a
result for this tool"). It did nothing when the turn settled as
`cancelled`, so a tool start that never got a `tool_result` stayed in
`emittedToolCalls` and the client was left with an open `tool_call`
forever.

How a start can end up with no terminal: the `stream_event` case has no
`session.cancelled` gate, so `content_block_start` for a `tool_use`
keeps becoming a `tool_call` after `cancel()`. The consolidated
`user`/`assistant` path, which is where `tool_result`-derived terminals
come from, drops everything at its `session.cancelled` guard.

This adds a `cancelled` branch next to the `end_turn` one. It reuses the
same selection (this turn's `foregroundToolCallIds` still in
`emittedToolCalls`, minus confirmed background tasks) and sends one
`tool_call_update` with `status: "failed"` per tool ("The turn was
cancelled before this tool call finished."). ACP's `ToolCallStatus` has
no cancelled value, so `failed` is the available terminal. Unlike
`end_turn`, cancellation is expected, so there is no session failure and
no early return.

## What it does not fix

A tool start that arrives **after** the turn has already settled (the
reporter's incident: starts streaming in minutes after the cancel).
There is no unsettled turn to attach it to, and I could not find an
exact gate: stream events carry no `origin` or turn attribution (the
existing comment on `emittedAssistantText` says as much), so such a
start cannot be told apart from an autonomous cycle or from the next
turn's pre-echo events. Gating on `session.cancelled` has the inverse
problem the reporter described. If maintainers see an attribution signal
I missed, a lifecycle-scoped gate would be the complement to this
change.

## Test changes

- `preserves cancellation and does not attribute its unfinished tool to
the next turn` asserted that the cancelled turn's tool gets **no**
`failed` update. That encoded the old behaviour, so it now asserts
exactly one cancellation terminal that the next turn neither repeats nor
reports as `incomplete_tool_call`. The cancel-then-next-prompt
assertions are unchanged.
- New: `terminates a tool whose start streams in after the cancel`
(start arrives between `cancel()` and the trailing idle).

Both fail without the change and pass with it. Full suite (2127 tests),
`npm run check` and `build` pass. One unrelated timeout in
`acp-scenarios` (`schema check`) showed up under load in one full run
and passes when run on its own.

## Live check

Against the built adapter (real credentials), I asked for `sleep 45`,
cancelled once the `tool_call` appeared, and listed every tool call and
its statuses at the end. Before and after the change the result is the
same: one tool call, `pending > ... > failed`, nothing open. So in the
plain cancel-mid-tool case the SDK already delivers an interrupted
result and there is no ghost to remove; I could not reproduce the
reporter's leak live, only in the unit fixture above. What the live run
does show is that the new branch does not send a duplicate terminal for
a tool that was already terminated.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_019vH6BXrsYQN3MqQ93JgStf


</details>

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-authored-by: Ben Brandt <benjamin.j.brandt@gmail.com>
2026-10-13 18:36:39 +02:00
dependabot[bot]
c6510c27c1 feat(deps): Bump actions/setup-node from 7.0.0 to 7.1.0 (#1329)
Bumps [actions/setup-node](https://github.com/actions/setup-node) from
7.0.0 to 7.1.0.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/actions/setup-node/releases">actions/setup-node's
releases</a>.</em></p>
<blockquote>
<h2>v7.1.0</h2>
<h2>What's Changed</h2>
<h3>Enhancements:</h3>
<ul>
<li>Add support for mise.toml file by <a
href="https://github.com/gperdomor"><code>@​gperdomor</code></a> in <a
href="https://redirect.github.com/actions/setup-node/pull/1421">actions/setup-node#1421</a></li>
<li>Support absolute paths in node-version-file by <a
href="https://github.com/v-mahabaleshwars"><code>@​v-mahabaleshwars</code></a>
in <a
href="https://redirect.github.com/actions/setup-node/pull/1633">actions/setup-node#1633</a></li>
</ul>
<h3>Bug fixes:</h3>
<ul>
<li>Add Node version validation and manifest fetch retry by <a
href="https://github.com/v-jitenderpalsingh"><code>@​v-jitenderpalsingh</code></a>
in <a
href="https://redirect.github.com/actions/setup-node/pull/1580">actions/setup-node#1580</a></li>
</ul>
<h3>Documentation updates:</h3>
<ul>
<li>README.md: bump actions/checkout to latest version 7 by <a
href="https://github.com/deining"><code>@​deining</code></a> in <a
href="https://redirect.github.com/actions/setup-node/pull/1578">actions/setup-node#1578</a></li>
<li>docs: add V7 ESM migration and NODE_AUTH_TOKEN behavior change by <a
href="https://github.com/v-gowridurgad"><code>@​v-gowridurgad</code></a>
in <a
href="https://redirect.github.com/actions/setup-node/pull/1593">actions/setup-node#1593</a></li>
<li>Fixed cache recommendation syntax by <a
href="https://github.com/SubJunk"><code>@​SubJunk</code></a> in <a
href="https://redirect.github.com/actions/setup-node/pull/1603">actions/setup-node#1603</a></li>
</ul>
<h3>Dependency updates:</h3>
<ul>
<li>Update workflow pins and migrate Node matrices to modern versions by
<a
href="https://github.com/v-HarithaVattikuti"><code>@​v-HarithaVattikuti</code></a>
in <a
href="https://redirect.github.com/actions/setup-node/pull/1583">actions/setup-node#1583</a></li>
<li>Upgrade <code>@​actions/cache</code> to 6.2.0 by <a
href="https://github.com/philip-gai"><code>@​philip-gai</code></a> in <a
href="https://redirect.github.com/actions/setup-node/pull/1584">actions/setup-node#1584</a></li>
<li>Pin <code>@​types/node</code> to v24 by <a
href="https://github.com/v-priyagupta108"><code>@​v-priyagupta108</code></a>
in <a
href="https://redirect.github.com/actions/setup-node/pull/1585">actions/setup-node#1585</a></li>
<li>Override brace-expansion to 5.0.8 by <a
href="https://github.com/v-priyagupta108"><code>@​v-priyagupta108</code></a>
with <a href="https://github.com/Copilot"><code>@​Copilot</code></a> in
<a
href="https://redirect.github.com/actions/setup-node/pull/1599">actions/setup-node#1599</a></li>
<li>Upgrade actions/checkout from 7.0.0 to 7.0.1 by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a> in <a
href="https://redirect.github.com/actions/setup-node/pull/1598">actions/setup-node#1598</a></li>
<li>Upgrade prettier from 3.8.4 to 3.9.6 by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a> in <a
href="https://redirect.github.com/actions/setup-node/pull/1590">actions/setup-node#1590</a></li>
<li>Upgrade <code>@​vercel/ncc</code> from 0.44.0 to 0.45.0 by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a> in <a
href="https://redirect.github.com/actions/setup-node/pull/1610">actions/setup-node#1610</a></li>
<li>Upgrade pnpm/action-setup from 6.0.9 to 6.0.10 by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a> in <a
href="https://redirect.github.com/actions/setup-node/pull/1602">actions/setup-node#1602</a></li>
<li>Upgrade baseline-browser-mapping from 2.10.38 to 2.11.22 by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a> in <a
href="https://redirect.github.com/actions/setup-node/pull/1629">actions/setup-node#1629</a></li>
<li>Upgrade <code>@​types/semver</code> from 7.7.1 to 7.8.0 by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a> in <a
href="https://redirect.github.com/actions/setup-node/pull/1616">actions/setup-node#1616</a></li>
<li>Upgrade <code>@​typescript-eslint/parser</code> from 8.62.0 to
8.70.0 by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a> in <a
href="https://redirect.github.com/actions/setup-node/pull/1615">actions/setup-node#1615</a></li>
<li>Upgrade pnpm/action-setup from 6.0.10 to 6.1.0 by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a> in <a
href="https://redirect.github.com/actions/setup-node/pull/1631">actions/setup-node#1631</a></li>
<li>Upgrade jest-each from 30.4.1 to 30.5.2 by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a> in <a
href="https://redirect.github.com/actions/setup-node/pull/1636">actions/setup-node#1636</a></li>
<li>Upgrade eslint-plugin-n from 18.1.0 to 18.3.0 by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a> in <a
href="https://redirect.github.com/actions/setup-node/pull/1638">actions/setup-node#1638</a></li>
<li>Update dependencies and add license files by <a
href="https://github.com/v-HarithaVattikuti"><code>@​v-HarithaVattikuti</code></a>
in <a
href="https://redirect.github.com/actions/setup-node/pull/1642">actions/setup-node#1642</a></li>
<li>Upgrade eslint from 10.5.0 to 10.11.0 and package.json from 7.0.0 to
7.1.0 by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a> in <a
href="https://redirect.github.com/actions/setup-node/pull/1635">actions/setup-node#1635</a></li>
</ul>
<h2>New Contributors</h2>
<ul>
<li><a
href="https://github.com/v-jitenderpalsingh"><code>@​v-jitenderpalsingh</code></a>
made their first contribution in <a
href="https://redirect.github.com/actions/setup-node/pull/1580">actions/setup-node#1580</a></li>
<li><a
href="https://github.com/philip-gai"><code>@​philip-gai</code></a> made
their first contribution in <a
href="https://redirect.github.com/actions/setup-node/pull/1584">actions/setup-node#1584</a></li>
<li><a href="https://github.com/gperdomor"><code>@​gperdomor</code></a>
made their first contribution in <a
href="https://redirect.github.com/actions/setup-node/pull/1421">actions/setup-node#1421</a></li>
<li><a href="https://github.com/SubJunk"><code>@​SubJunk</code></a> made
their first contribution in <a
href="https://redirect.github.com/actions/setup-node/pull/1603">actions/setup-node#1603</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/actions/setup-node/compare/v7.0.0...v7.1.0">https://github.com/actions/setup-node/compare/v7.0.0...v7.1.0</a></p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="949feb2413"><code>949feb2</code></a>
Bump eslint from 10.5.0 to 10.11.0 and package.json from 7.0.0 to 7.1.0
(<a
href="https://redirect.github.com/actions/setup-node/issues/1635">#1635</a>)</li>
<li><a
href="7207c4f118"><code>7207c4f</code></a>
chore: update dependencies and add license files (<a
href="https://redirect.github.com/actions/setup-node/issues/1642">#1642</a>)</li>
<li><a
href="54337e0d02"><code>54337e0</code></a>
Bump eslint-plugin-n from 18.1.0 to 18.3.0 (<a
href="https://redirect.github.com/actions/setup-node/issues/1638">#1638</a>)</li>
<li><a
href="fb54fa8c14"><code>fb54fa8</code></a>
Bump jest-each from 30.4.1 to 30.5.2 (<a
href="https://redirect.github.com/actions/setup-node/issues/1636">#1636</a>)</li>
<li><a
href="7f84f1d52d"><code>7f84f1d</code></a>
Bump pnpm/action-setup from 6.0.10 to 6.1.0 (<a
href="https://redirect.github.com/actions/setup-node/issues/1631">#1631</a>)</li>
<li><a
href="0c54c7b074"><code>0c54c7b</code></a>
Bump <code>@​typescript-eslint/parser</code> from 8.62.0 to 8.70.0 (<a
href="https://redirect.github.com/actions/setup-node/issues/1615">#1615</a>)</li>
<li><a
href="a829419004"><code>a829419</code></a>
Bump <code>@​types/semver</code> from 7.7.1 to 7.8.0 (<a
href="https://redirect.github.com/actions/setup-node/issues/1616">#1616</a>)</li>
<li><a
href="680d1e489b"><code>680d1e4</code></a>
Support absolute paths in node-version-file (<a
href="https://redirect.github.com/actions/setup-node/issues/1633">#1633</a>)</li>
<li><a
href="db0fb3a98e"><code>db0fb3a</code></a>
Bump baseline-browser-mapping from 2.10.38 to 2.11.22 (<a
href="https://redirect.github.com/actions/setup-node/issues/1629">#1629</a>)</li>
<li><a
href="94196ee1d1"><code>94196ee</code></a>
Bump pnpm/action-setup from 6.0.9 to 6.0.10 (<a
href="https://redirect.github.com/actions/setup-node/issues/1602">#1602</a>)</li>
<li>Additional commits viewable in <a
href="8207627860...949feb2413">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=actions/setup-node&package-manager=github_actions&previous-version=7.0.0&new-version=7.1.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-13 18:13:43 +02:00
Ben Brandt
21eef57655 feat: replay each subagent under its live session id for subagents RFD clients (#1328)
A replay now restores subagents for a client on the [subagents
RFD](https://github.com/agentclientprotocol/agent-client-protocol/blob/main/docs/rfds/subagents.mdx)
form: a v1 client that declares `subagents` and isn't AIR, and every v2
client. Until now, its replay left children out. **AIR's replay is
unchanged.**

## The rules

The RFD asks that replayed children keep their original session ids and
known parentage, and that announcements come before child traffic. It
forbids guessing a parent or inventing an id, and asks that known gaps
be made apparent. It also requires all selected history before the
response.

- **Ids:** each child replays under the session id it had live, the SDK
agent id. Claude Code's `subagents/agent-<id>.meta.json` names the Agent
or Task call that launched agent `<id>`.
- **Order:**
- The child is announced on the session that made the launch, right
after the launch, with the same labels as live: the `name` and the role
as `title`, and the short `description`.
  - Its own transcript then goes to the child session.
  - A nested child is announced on the child that launched it.
- **Incoming messages:** the child's prompt is a `session_message` with
the launch's id and the parent as sender, as live. A message that a
SendMessage call delivered (`origin.kind: "coordinator"`) is matched to
its call by the call's text. Claude Code frames it with a preface and an
instruction, so the match checks that the delivered text contains the
call's message. It then has the SendMessage's id, text, and sender, as
live; without a match, its transcript id and the delivered text.
- **Harness text:** other user text in a child's transcript, such as
"[Request interrupted by user for tool use]", isn't shown, as the live
child session doesn't show it. Tool results stay.
- **Ends:** every child's end is an `idle` before the response. The end
comes from the first of these that applies:
  1. its last persisted task notification (its task id is the agent id)
  2. `cancelled`, if `meta.json` says the user stopped it
3. its foreground launch's result (a background launch's result is only
its launch)
  4. otherwise, `idle` with no stop reason
- **Gaps:**
- A launch without metadata has no known id, so it isn't announced; its
Agent call stays an ordinary tool call of the parent. A child whose
transcript can't be read replays only its association, as the RFD
allows.
- Either case adds one notice for a client that takes notices: "Some
subagent history is not available".
- A subagent message that the session transcript stores inline is left
out, since the child's history comes from its own transcript. Current
Claude Code doesn't store such messages.
- **v2:** a replayed child's messages are cleared before their first
replayed chunk, as the parent's already are. A v2 client may still hold
them.

## Changes in the code

- **`src/subagent-replay.ts` (new):** `RfdSubagentReplay` holds the
replay's children, deliveries, and ends. The replay loop in
`acp-agent.ts` calls it at the launch, for each child message, and at
the end.
- **`subagentHistory`:** returns each launch's whole metadata record
(name, role, description, background, stopped by the user). AIR and the
RFD form share `readSubagentMessages`.
- **The v2 bridge:** registers a child that a replay announces as
replaying too.

## Evidence

- **Live, v1:** I loaded a real Claude Code session from a live run: a
foreground `scout` resumed once by SendMessage, and a background
`sleeper` that a cancel stopped.
  - Both children replay under their live agent ids, with their labels.
- `scout`'s prompt and its SendMessage delivery have exactly the ids and
senders the live run used (`toolu_…HvPNZm` and `toolu_…cB3K9t`, from the
root).
- `scout` ends `end_turn`, and `sleeper` ends `cancelled`, from
`stoppedByUser`.
- **Live, v2:** the same session over v2 with replay. There were no v2
schema violations, each child's state is mirrored on its own stream, and
all of it comes before the resume response.
- **Tests:**
- v1: a full replay order with a nested child, a matched delivery,
dropped harness text, the ends, and the notice for a launch without
metadata; ends for a stopped child and for one with no known end; inline
subagent messages left out.
  - v2: a child's replayed message is cleared on the child session.
- **Mutation checks:** removing any of the rules fails a test:
  - the v2 child registration
  - the RFD replay
  - the delivery match
  - the inline skip
  - `stoppedByUser`
  - ignoring a background launch's result
  - dropping harness text
  - the notice
- **Golden files:** only the v2 `session-load-replay` changes. It gains
the notice, because its Task launch has no metadata. The AIR golden
files are unchanged.
- **Checks:** `npm run check`, build and all 2886 tests pass.

## Not in this PR

- **A SendMessage after a reload:** a SendMessage to an agent from
before a reload still finds no known child in the live runtime, so the
agent stays unexposed. A follow-up registers the replayed children, and
the children of a resume without replay, in the runtime.

## Release

Titled `feat:`, since a v1 client on the RFD form gets new replay
output. Retitle it if you'd rather not release it.
2026-10-13 18:13:21 +02:00
Ben Brandt
53df1c7b22 fix: end the tool calls of a cancelled turn before it reports the cancel (#1327)
## Problem

After `session/cancel`, a tool call that was still running never gets a
final status. On `main` (Claude Code 2.1.293, a v1 client that declares
no capabilities) all three of these stay `pending` for good:

- the root's own Bash call, cancelled while its command runs;
- a foreground Agent call;
- the Bash call of that Agent's subagent, which this client sees as a
root tool call with `_meta.claudeCode.parentToolUseId`.

v2 has the same problem, and so does the subagents RFD form: the
subagent's call stays pending in the child's session after the child
reports `idle cancelled`. Found in the live run of #1257.

## Cause

I recorded the raw SDK stream (`emitRawSDKMessages`) and the session
transcripts of a live run on `main`. There are two gaps.

1. **The window drops Claude Code's interrupted result.** For the root
Bash and the Agent call, Claude Code does send a result, inside the
post-cancel window of #1309 and #1313, before the turn's `result`: a
`tool_result` with `is_error: true`, stamped `non_execution_kind:
"user-rejected"`, then `[Request interrupted by user for tool use]`,
then the aborted result. The `if (session.cancelled) break;` in the
`user`/`assistant` case drops it.
2. **The subagent's call gets no result at all.** Claude Code writes its
interrupted result only to the subagent's own transcript
(`<session>/subagents/agent-*.jsonl`). The stream never carries it,
before or after the response, so no change to what the window delivers
can reach it.

Nothing else closes these calls. `closeUnfinished` runs only at
`end_turn`, over the turn's `foregroundToolCallIds`, and that set holds
none of the three: `task_started` removes a Bash or Agent call from it
(`registerLiveTask`), and a subagent's call is never in it.

## Change

Two rules, in the stream consumer:

- **Deliver the real result.** Until the client is told that the stopped
work ended (the prompt response on v1, `idle` on v2), the window
delivers the `tool_result` of a tool call that the client holds open,
with Claude Code's own text and `nonExecutionKind`. Only the tool call
is updated. The other effects of a tool result (async tasks, plan state,
the ExitPlanMode flow) stay dropped, and so does everything after that
report, which is what #1313 closed.
- **Close what has no result.** When a turn settles `cancelled` because
of `session/cancel`, the adapter closes every tool call still open with
`AcpToolCallRenderer.unfinished`, before it resolves the turn. That
covers the subagent's call, a `tool_use` that Claude abandoned before it
ran, and every call when the stream gives no results (the force-cancel
backstop). Both protocols end the work at `turn.resolve`, so one rule
serves v1 and v2.

Supporting pieces:

- The closing report names the subagent that made the call
(`Session.toolCallParents`, bounded like `dispatchedToolCalls`), so the
subagent runtime routes it with the call's other updates: to the child
session in the RFD form (an idle child still takes updates), to no child
that AIR ended, to the root for a client without subagents.
- A **background** subagent that the SDK has not reported ended keeps
its calls open. The RFD form lets a child run on through a cancel
(`NativeSubagentRuntime.parentCancelled`). Two tests merged with #1320
failed against my first version, which closed those calls, and that is
why the registry exists. A foreground subagent ends with the work that
waits on it.
- For a v2 client, an error result in the window is `cancelled`,
whichever kind Claude Code stamps. CLI 2.1.293 stamps a command that the
interrupt killed `user-rejected`, which `isAbortedToolResult` reads as a
refusal (`failed`). A tool that finished as the cancel landed stays
`completed`. A v1 client gets `failed`, as before, since v1 has no
`cancelled`.
- `unfinished` gets a `cancelled` variant: "The turn was cancelled
before this tool call returned a result."
- The rule applies to `session/cancel` only (`session.cancelled`), not
to the ExitPlanMode "keep planning" outcome, which also ends a turn
`cancelled`.
- README: one sentence on what an RFD client gets at a cancel.

## Why both rules

| | root Bash | Agent call | subagent's Bash |
| --- | --- | --- | --- |
| Deliver the real result only | fixed | fixed | **still pending** (no
result in the stream) |
| Close every open call only | fixed, made up | fixed, made up | fixed,
made up |
| Both (this PR) | Claude Code's result | Claude Code's result | made up
|

Closing alone would fix all three, but it throws away a result that
Claude Code already sent: its text, and the `nonExecutionKind` that lets
a client tell a rejection from an interruption. So the real result goes
out where one exists, and the adapter makes up a status only for what
Claude Code never reports. The Agent call ends first, then its
subagent's call, in the same turn and before the response.

## What does not change

- Output after the report stays dropped. A test pins that the tool of a
prompt the cancel abandoned, which a permission request announces after
the response, gets no result (it fails with the `workEndReported` check
removed).
- A result for a call the client does not hold open sends nothing and
logs nothing.
- AIR ends every child at the cancel, and the runtime drops every later
update to an ended child. The closing reports go through the same
routing and are dropped there, so nothing reaches an ended child (the
live AIR run logs one `ignoring late update for terminal subagent`).
- No golden file changes. None has a cancel, and the diff touches none.

## Evidence

**Live** (Claude Code 2.1.293, Sonnet, each tool call's statuses before
and after the prompt response; the probe allows each permission request
after 1.5 s and cancels 4 s later, with `python3 -c "import time;
time.sleep(30)"` so the command runs; every update is checked against
the SDK's v1 schema):

| | root Bash | Agent call | subagent's Bash | after the response |
| --- | --- | --- | --- | --- |
| `main`, plain v1 | `pending` | `pending` | `pending` | 0 updates |
| this PR, plain v1 | `failed`, Claude Code's text | `failed`, Claude
Code's text | `failed`, closed here | 0 updates, 0 schema violations |
| this PR, RFD client | `failed` | `failed` | `failed`, in the child's
session after its `idle cancelled` | 0 updates, 0 violations |
| this PR, AIR | `failed` | hidden, as before | none: the child ended,
and the runtime dropped the closing update | 0 updates |
| this PR, v2 | `cancelled` | `cancelled` | `cancelled`, in the child's
session, before the root's `idle cancelled` | 0 updates |

The plain and v2 runs are on this commit. The RFD and AIR runs were on
this change before it was rebased onto #1324 and #1325, which change the
v2 subagent surface and the routing of a subagent's hook update, and not
the cancel path. In the raw stream, the subagent's Bash has
`task_notification stopped` and no result. Its transcript has the
`is_error` result.

**Tests** (16 new, one rewritten, one extended), scripted in the order
recorded live:

- `incomplete-tools.test.ts`, plain client: the real result of a running
tool; an Agent call and its subagent's call; the force-cancel backstop;
a start that streams in after the cancel; a result after the response; a
stray result; an abandoned prompt's tool; a background subagent's call
left open; a call that a permission request announced and a later update
attributed; the registry's cleanup.
- `acp-agent.test.ts`: the RFD form (the stopped child's call closed in
its session, the running child's left open, the Agent call's result) and
AIR (nothing to an ended child, including a permission-announced call).
- `acp-v2.test.ts`: `cancelled` for the Agent call and its subagent's
call before `idle cancelled`, `completed` for a tool that finished, and
a cycle that Claude Code started.
- `preserves cancellation and does not attribute its unfinished tool to
the next turn` asserted that the cancelled turn's tool gets no `failed`
update, which is the behavior this PR changes. It now asserts one
report, before the cancelled response and not attributed to the next
turn.

**Mutation checks.** Each of these fails at least one test: no delivery
of the real result (8), no closing at the settle (10), delivery after
the report (1), delivery for calls the client does not hold open (1),
closing a running background subagent's calls (3), no parent on the
report (3), v2 real result not `cancelled` (2), `unfinished` not
`cancelled` on v2 (1), the closing text (4), `ran` ignored (2), the
registry kept after a call ends (1, added after the first run showed
that nothing caught it), the parent recorded only from a `tool_call`
(1), the v1 end-of-work signal on v2 (3), AIR's drop of updates to an
ended child removed (5).

`npm run check`, `npm run build` and `npm run test:run` (2898 tests)
pass.

## Related, and not covered

- #1221 closes the turn's `foregroundToolCallIds` at a cancelled settle
with a made-up `failed`. It overlaps with this change in `settleActive`
and in the test above, so the two will conflict. As far as I can tell
from `registerLiveTask`, it would not have closed the three calls here.
Its scenario, a start that streams in after the cancel, is covered by a
test here. #1221 also hooks the inline settle of a held turn in
`cancelTurns`, which this PR does not touch.
- Not covered: a background subagent's calls when a cancel stops it
while no turn settles (a cancel while idle, or of a held turn that
`cancelTurns` settles inline); the calls of a v2 cycle that Claude Code
started get Claude Code's results, but nothing closes the ones it sends
none for.
- The closing report carries no terminal exit, so a client that shows
command output sees the command's call end without one.
`closeUnfinished` does the same at `end_turn`.
- A background subagent whose end the SDK reports only after the turn's
result keeps its calls open. Claude Code 2.1.293 reports it before.
2026-10-13 18:10:14 +02:00
Ben Brandt
c7393d27ff refactor: experimental ACP v2 support for subagents (#1325)
Adds subagents to the experimental [ACP
v2](https://agentclientprotocol.com/protocol/v2/overview) surface. With
them, it covers v2's baseline session model, so this is the `feat:` that
announces v2 support. The README gains a section on how to enable it and
what it serves.

Based on #1324, a v1 fix that this PR's golden scenarios exposed. Merge
#1324 first; this PR then retargets to `main`.

## Experimental ACP v2

Started with `CLAUDE_AGENT_ACP_EXPERIMENTAL_V2=1`, the adapter serves
the draft ACP v2 to a client that asks for protocol version 2 or later
in `initialize`, and v1 to every other client, as before.

- **Served on v2:**
- the session baseline (`session/new`, `list`, `resume` with replay,
`close`, `prompt`, `cancel`)
  - `session/delete`, `session/fork`, and additional directories
  - config options (the mode is one), plans, notices, compaction
  - display terminals for commands
  - permission requests, elicitation, providers, auth
  - and now subagents
- **v1 only for now:** steering and the AIR extensions.

The draft can still change, and the surface changes with it.

## What changes

The [subagents
RFD](https://github.com/agentclientprotocol/agent-client-protocol/blob/main/docs/rfds/subagents.mdx)
makes subagent updates part of v2's baseline: "support is assumed rather
than negotiated".

- **Every v2 client gets subagents.** The v2 bridge declares `subagents`
in the v1 request it makes for a v2 client (`v1InitializeRequest`), so
the agent reports each Agent or Task subagent in the RFD form from
#1320–#1322.
- **The updates pass through:** `subagent_update`, `session_message` and
`session_message_chunk` have the same shape in v1 and v2.
- **The child's state goes to its own stream too.** v2 reports a child's
work state on the child's own stream, mirrored on the parent. So a
`subagent_update` with a `state` also becomes the child's
`state_update`, sent right after the parent's copy
(`v2ChildStateUpdate`). The announcement thus comes first, and both
copies of a state come before any later one, as the RFD requires. This
covers `requires_action`, and a failed child's `idle`/`error` with its
JSON-RPC `error`.
- **A state the RFD doesn't define throws,** as an update kind the
surface doesn't translate already does. The SDK types the open members
of v1's and v2's `StateUpdate` apart, so the bridge checks each state
against the RFD's before passing it on.
- **Replay doesn't flatten a child's inline text onto the root.** On
replay, a subagent's text goes to the root only for a client that gets
it there live: one without native subagent sessions that asked for the
flattened transcript. Before, an RFD client (v1, and now v2) got the
inline subagent text of a transcript replayed on the root, though the
RFD form doesn't replay children yet. Current Claude Code keeps subagent
messages in `subagents/`, not inline, so only the `session-load-replay`
scenario's inline message showed it. AIR's replay is unchanged.

## Live run

Against real Claude Code (Sonnet), with a v2 client that checks every
`session/update` and permission request against the SDK's v2 schema,
there were no schema violations:

- **Launch:**
- the child is announced on the root, then reports its own `running`,
then gets its prompt as a `session_message` from the root
- its tool calls arrive on its session, each with a title on its first
report
  - the Agent call's 35-second heartbeat stays on the root
- **Permission:**
- `requires_action` appears on the child's stream and on the parent's
copy, and on the root, whose turn waits on the child
  - the request goes to the child's session
  - then the child is back to `running`
- **SendMessage resume:**
  - the same child session runs again, with the root as sender
- the root turn goes idle while the resumed child keeps running, and the
child reports `idle`/`end_turn` later, which v2 allows for background
work
- **Cancel:** the child is `idle`/`cancelled` on both streams, then the
root is `idle`/`cancelled`.
- A tool call still running at the cancel never gets a final status.
That's a known bug on `main` for every client, fixed in a separate PR.

## Tests

- **End to end on v2:** an Agent call whose child fails with an SDK
error. Traffic arrives in order on both streams, and the child's `idle`
carries `error: { code: -32603, message }`.
- **Unit:**
  - the pass-through of the three updates
- the child's `state_update`, and no copy for an association update
without a state
  - the throw for a state and a stop reason the RFD doesn't define
- **v2 golden files:**
- the six subagent scenarios now show the RFD form: announcements,
prompts, child sessions, both copies of each state, and permission
requests on the child session
  - `session-load-replay` is unchanged from `main`
- the scenarios also check every message against the v2 schema, and that
each tool call's first report has a title (that check found #1324)
- **Mutation checks:** each of these fails tests:
  - not declaring `subagents`
  - dropping the child's copy
  - sending it before the parent's
  - dropping the state check
  - dropping the replay rule
- **v1 golden files:** unchanged.
- **Checks:** `npm run check`, build and all 2882 tests pass. CI runs
only on PRs to `main`, so I ran `ci.yml` on this branch by hand.

## Release

Titled `feat:`, so its title becomes the release note that announces v2.
2026-10-13 16:57:14 +02:00
Ben Brandt
4615a9141c fix: route a subagent's PostToolUse hook update to its child session (#1324)
## Problem

A PostToolUse hook update skipped the subagent router, for every client
with native subagent sessions (AIR's draft and the subagents RFD form):

- **A child's Edit or Write:** its final diff went to the **root**
session, with `parentToolUseId` set. All its other reports go to the
child session, so the client got an update for a tool call it doesn't
have on the root, and the child's tool call never got its final diff.
The RFD forbids moving a tool call between sessions.
- **AIR's hidden Agent/Task call:** AIR's draft hides the Agent/Task
tool call, but its hook update reached AIR anyway, for a tool call AIR
never received.

AIR's golden files had recorded both.

## Cause

`toAcpNotifications` registers a tool's PostToolUse hook the first time
it sees the `tool_use`, with the client passed to it. Claude Code
streams, so that first time is a stream event, and the stream path
passed `this.client` instead of the routed client that every other path
uses. The hook's update then went straight to the client.

## Change

The stream path passes the routed client, so a hook update goes through
`sendUpdate` and the subagent router like every other update. That
client is the only one the stream path uses.

Clients without native subagent sessions are unchanged: the router
passes their updates through as they are.

## Evidence

- **New test:** a child's Edit that is streamed, completed, and then
gets its PostToolUse hook. All the Edit's reports, including the hook's
diff, go to the child session. Without the fix, the hook's report goes
to the root (`Set{ 'agent-42', 'test-session' }`).
- **AIR golden files**, both intended:
- `subagent-task-legacy`: the child Edit's hook update (`locations`)
moves from `<session>` to `agent_1`, and the hidden Task call's hook
update is gone.
- `subagent-agent-async`: the hidden Agent call's hook update is gone.
In both cases it was the only report AIR got for that tool call.
- **How it was found:** declaring subagents for ACP v2 clients (the next
PR) runs the RFD form through the v2 golden scenarios, whose check that
a tool call's first report has a title caught the root-session diff.
- **Checks:** `npm run check`, build and all 2878 tests pass.
2026-10-13 16:54:34 +02:00
Ben Brandt
252126f616 feat: report a subagent as requires_action while its request is open for subagents RFD clients (#1322)
The third of three stacked PRs that replace #1257. It's based on #1321,
so its diff shows only the `requires_action` part. Merge #1320 and #1321
first; this PR then retargets to `main`.

1. #1320: 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. **This PR:** `requires_action` while a child's request is open.

**AIR is unchanged.**

## What changes

The [subagents
RFD](https://github.com/agentclientprotocol/agent-client-protocol/blob/main/docs/rfds/subagents.mdx)
reports a child that waits for the user as `requires_action`.

- **While a request is open:** a child whose permission or question
request is open reports `requires_action` on its parent, then `running`
again when its last open request ends
(`NativeSubagentRuntime.awaitingUser`). The agent wraps the permission
and elicitation requests of a child session in it
(`withChildAwaitingUser`).
- **Overlapping requests** keep `requires_action` until the last one
ends. A child that ended while it waited doesn't report `running` again.
- **A resume while a request is open** reports `requires_action`, not
`running`.

## Live run

Against real Claude Code (Sonnet), a child's Bash permission request
showed `running` → `requires_action` while the request was open →
`running` once it was answered. No schema violations.

## Follow-ups (low severity, not in this PR)

- **Brief `running` after a withdrawal:** after a cancel withdraws a
child's request, the child briefly reports `running` before the SDK's
stop event. The same happens at a stream end, whose cleanup aborts every
pending request.
- **Ignored cancel requests:** AskUserQuestion's `createElicitation` is
not raced against the abort. If a client ignores `$/cancel_request`, the
child's open-request count stays up, and a later resume reports
`requires_action`.

## Tests

- **End to end:** `requires_action` around a child's permission request.
The cancel test now also expects the background child's
`requires_action` before the cancel.
- **Runtime:** overlapping requests, a child that ended while it waited,
and a resume with an open request.
- **Checks:** `npm run check`, build and all 2877 tests pass on this
branch, whose tree is #1257's with `main` merged in. CI runs only on PRs
to `main`, so I ran `ci.yml` on this branch by hand.
2026-10-13 16:11:25 +02:00
Ben Brandt
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.
2026-10-13 16:11:25 +02:00
Ben Brandt
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.
2026-10-13 16:11:24 +02:00
Sergey Shkredov
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>
2026-10-13 14:29:48 +02:00
Ben Brandt
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.
2026-10-13 07:57:05 +02:00
Ben Brandt
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.
2026-10-13 05:28:48 +00:00
Ben Brandt
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.
2026-10-13 07:13:50 +02:00
Ben Brandt
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.
2026-10-12 22:18:38 +02:00
Ben Brandt
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.
2026-10-12 20:53:07 +02:00
Ben Brandt
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.
2026-10-12 20:23:46 +02:00
Ben Brandt
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.
2026-10-12 20:08:01 +02:00
Ben Brandt
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.
2026-10-12 19:17:16 +02:00
Ben Brandt
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.
2026-10-12 14:28:15 +02:00
Ben Brandt
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.
2026-10-12 14:23:37 +02:00
Maksim Zheltoukhov
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>
2026-10-12 13:00:07 +02:00
Ben Brandt
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.
2026-10-12 12:15:06 +02:00
Ben Brandt
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.
2026-10-12 11:57:21 +02:00
Ben Brandt
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.
2026-10-12 11:31:13 +02:00
277 changed files with 60776 additions and 6887 deletions

125
.github/workflows/binaries.yml vendored Normal file
View File

@@ -0,0 +1,125 @@
# Builds the standalone executables (scripts/build-binary.mjs) and smoke-tests
# each one on a runner of its own platform. Each archive holds
# `claude-agent-acp` and the Claude CLI (`claude`) it runs from its directory.
# CI calls this to check that every target still builds and starts; the
# release calls it with `upload: true` and attaches the archives to the GitHub
# release.
name: Binaries
on:
workflow_call:
inputs:
upload:
description: Upload each archive as a workflow artifact for the release job.
type: boolean
default: false
permissions:
contents: read
jobs:
build:
name: Binary (${{ matrix.target }})
runs-on: ${{ matrix.runner }}
timeout-minutes: 20
strategy:
fail-fast: false
matrix:
include:
- target: bun-linux-x64
runner: ubuntu-latest
archive: claude-agent-acp-linux-x64.tar.gz
- target: bun-linux-arm64
runner: ubuntu-24.04-arm
archive: claude-agent-acp-linux-arm64.tar.gz
# npm skips the musl package on a glibc host, so these are
# cross-built and smoke-tested in an Alpine container.
- target: bun-linux-x64-musl
runner: ubuntu-latest
archive: claude-agent-acp-linux-x64-musl.tar.gz
musl: true
- target: bun-linux-arm64-musl
runner: ubuntu-24.04-arm
archive: claude-agent-acp-linux-arm64-musl.tar.gz
musl: true
- target: bun-darwin-x64
runner: macos-15-intel
archive: claude-agent-acp-darwin-x64.zip
- target: bun-darwin-arm64
runner: macos-latest
archive: claude-agent-acp-darwin-arm64.zip
- target: bun-windows-x64
runner: windows-latest
archive: claude-agent-acp-windows-x64.zip
- target: bun-windows-arm64
runner: windows-11-arm
archive: claude-agent-acp-windows-arm64.zip
defaults:
run:
shell: bash
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1
with:
node-version: "lts/*"
check-latest: true
cache: "npm"
# Unpinned: each build is smoke-tested on its platform before it uploads,
# so a Bun release that breaks the executable fails here instead of
# shipping. Pin a version if one ever regresses past the smoke test.
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version: latest
- run: npm ci
- name: Install the musl Claude CLI package
if: ${{ matrix.musl }}
run: |
version="$(node -p "require('./node_modules/@anthropic-ai/claude-agent-sdk/package.json').version")"
npm install --no-save --force "@anthropic-ai/claude-agent-sdk-linux-${RUNNER_ARCH,,}-musl@$version"
- name: Build
run: |
echo "Built \`${{ matrix.target }}\` with Bun $(bun --version)" >> "$GITHUB_STEP_SUMMARY"
node scripts/build-binary.mjs --target ${{ matrix.target }}
- name: Smoke test
if: ${{ !matrix.musl }}
run: node scripts/smoke-binary.mjs
# Claude Code's Alpine requirements: libgcc and libstdc++.
- name: Smoke test (Alpine)
if: ${{ matrix.musl }}
run: |
docker run --rm -v "$PWD:/work" -w /work node:lts-alpine \
sh -c 'apk add --no-cache libgcc libstdc++ >/dev/null && node scripts/smoke-binary.mjs'
- name: Archive
if: ${{ inputs.upload }}
env:
ARCHIVE: ${{ matrix.archive }}
run: |
cd dist-bin
if [[ "$ARCHIVE" == *.zip ]]; then
if [[ "$RUNNER_OS" == "Windows" ]]; then
pwsh -Command "Compress-Archive -Path claude-agent-acp.exe,claude.exe -DestinationPath ../$ARCHIVE"
else
zip "../$ARCHIVE" claude-agent-acp claude
fi
else
tar czf "../$ARCHIVE" claude-agent-acp claude
fi
- name: Upload
if: ${{ inputs.upload }}
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: ${{ matrix.archive }}
path: ${{ matrix.archive }}
retention-days: 7
if-no-files-found: error

View File

@@ -24,9 +24,10 @@ jobs:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
- name: Use Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020
uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1
with:
node-version: "lts/*"
check-latest: true
cache: "npm"
- name: Install dependencies
@@ -43,3 +44,7 @@ jobs:
- name: Run tests
run: npm run test:run
binaries:
name: Binaries
uses: ./.github/workflows/binaries.yml

View File

@@ -24,7 +24,7 @@ jobs:
steps:
# Config reference: https://github.com/amannn/action-semantic-pull-request#configuration
- uses: amannn/action-semantic-pull-request@48f256284bd46cdaab1048c3721360e808335d50 # v6.1.1
- uses: amannn/action-semantic-pull-request@2252a14e810aa9b85c2d560bd7070c802206ecf6 # v6.1.2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:

View File

@@ -36,7 +36,7 @@ jobs:
# Generating a GitHub token, so that PRs and tags created by
# the release-please-action can trigger actions workflows.
- name: Generate GitHub token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1
uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510
id: generate-token
with:
# GitHub App ID secret name
@@ -74,9 +74,10 @@ jobs:
with:
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || github.ref }}
# Setup .npmrc file to publish to npm
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1
with:
node-version: "lts/*"
check-latest: true
registry-url: "https://registry.npmjs.org"
- run: npm ci
- run: npm run build
@@ -87,6 +88,38 @@ jobs:
npm publish
echo "version=$version" >> "$GITHUB_OUTPUT"
# Standalone executables for the GitHub release, stable releases only. They
# don't gate the npm publish: a failed build leaves the release without
# binaries, and re-running the failed jobs attaches them.
build-binaries:
name: Build binaries
needs: [release-please]
if: ${{ needs.release-please.outputs.release_created == 'true' }}
# The called workflow only reads the repository; upload-binaries does the write.
permissions:
contents: read
uses: ./.github/workflows/binaries.yml
with:
upload: true
upload-binaries:
name: Attach binaries to the release
runs-on: ubuntu-latest
needs: [release-please, build-binaries]
permissions:
contents: write
timeout-minutes: 15
steps:
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
path: artifacts
merge-multiple: true
- name: Upload to the release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ needs.release-please.outputs.tag_name }}
run: gh release upload "$TAG" artifacts/* --clobber --repo "$GITHUB_REPOSITORY"
# Same shape as publish-npm. Tagging is a separate job, so nothing here needs
# write access to the repository.
publish-npm-preview:
@@ -129,9 +162,10 @@ jobs:
# few megabytes packed, so a full fetch is cheap and sidesteps every
# shallow-clone tag caveat.
fetch-depth: 0
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1
with:
node-version: "lts/*"
check-latest: true
registry-url: "https://registry.npmjs.org"
# Ahead of the version bump, so this still validates the committed lockfile.
- run: npm ci
@@ -197,7 +231,11 @@ jobs:
echo "Tagged \`v$VERSION\` at \`$SHA\`" >> "$GITHUB_STEP_SUMMARY"
trigger-registry-update:
needs: [publish-npm, publish-npm-preview]
# upload-binaries too, so a stable release's archives are attached before the
# registry reads the release: it takes each archive's sha256 from the release
# assets when it bumps the version. It only waits; a failed or skipped upload
# (every preview) doesn't stop the dispatch.
needs: [publish-npm, publish-npm-preview, upload-binaries]
# Both channels dispatch this: the registry has its own handling for preview
# versions. The two never fire together — a release merge publishes stable and
# skips the preview, and every other push does the reverse — so the registry sees
@@ -254,7 +292,7 @@ jobs:
exit 1
- name: Generate token scoped to the registry repo
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1
uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510
id: registry-token
with:
app-id: ${{ secrets.REGISTRY_UPDATER_APP_ID }}

3
.gitignore vendored
View File

@@ -3,4 +3,5 @@ dist
.DS_Store
.claude/*.local.*
CLAUDE.local.md
.idea/
.idea/
dist-bin

View File

@@ -1,3 +1,3 @@
{
".": "0.89.1"
".": "0.102.0"
}

View File

@@ -16,13 +16,88 @@ npm run format # prettier --write
CI runs `format:check`, `lint`, `build` and `test:run`. Run `npm run check` before
opening a PR.
## Code comments
## Coding guidelines
Keep comments straightforward, concise, and concrete. Say what the code does and
why, in plain terms: name the actual state, call, or failure involved instead of
alluding to it. Prefer a short concrete example over an abstract description.
Don't use vague phrases like "keeps it honest" or "the residual case" that make
the reader reconstruct the meaning.
- Keep edits consistent with the surrounding code's formatting, naming and idioms.
- Keep comments concise and concrete. Name the actual state, call or failure
instead of alluding to it, prefer a short example over an abstract
description, and avoid vague phrases like "keeps it honest". A comment or doc
must be true on every code path it describes, and a doc that lists what a
result omits or rejects must name the actual cases.
- When a request can't be served, throw a `RequestError` whose message says what
happened and what to do (e.g. "Try again when the turn ends"). Don't drop the
request silently.
- Don't report success for work you start without awaiting it. If the detached
call can still refuse and its `.catch` only logs, check the refusal condition
first or report the failure to the client.
- A result must not depend on what an earlier call happened to cache. A request
right after a restart, with cold caches, must return the same thing as one
after a warm-up.
- If code relies on an ordering or call sequence that nothing enforces (e.g. an
echo arriving before the result), say so in a short comment.
## Architecture and design fit
- Before coding, read the code the change plugs into and find the module that
owns the state or concept. Build on that module, not beside it.
- Don't add new features or components to `acp-agent.ts`. If no existing module fits the new
code cleanly, create a dedicated one that owns the feature's state and logic,
and keep the change in `acp-agent.ts` to the calls that wire it in.
- Prefer extending an existing mechanism over adding a new one, even if it takes
a small refactor (moving a helper, generalizing a function). Add a parallel
path, special case or flag only when the existing structure can't support the
change.
- Before writing new logic, check whether it already exists in a sibling handler
of the same flow or in a helper that works with the same data, and reuse it.
Paths that handle the same input must apply the same rules through the same
code: the live stream and the `session/load` replay, the command list and
command dispatch, `prompt` and `steer`. Two copies of one check drift apart.
- Put guards, filters and cleanup in the module that owns the state, so every
caller gets them. If guards, flags or fallbacks pile up in one place,
restructure instead of adding another.
- If a result is meant to match another request's output, reuse that request's
logic.
- The adapter serves ACP v1 and v2. A change that is not meant for one protocol
version only must work correctly in both. `ClaudeAcpAgent` serves v1, and
`src/v2/` serves v2; they share modules (the SDK mapping, tool call
rendering, permissions, config options). Put a change meant for both versions
in the shared module, or make it in both cores.
- A session runs one turn at a time. On v1, "Claude is working" means
`startTurn`'s check (an unsettled turn in `turnQueue`, or `foregroundWork` for
the session); on v2, an open turn of the v2 session. Don't write a
narrower one. Anything that sends `session/update` must go
through the turn queue or be refused while the session is busy, or its output
lands inside another turn. Keep the session marked busy across awaited network
calls.
- Don't branch on a local copy of state that Claude Code or a remote service
owns: it goes stale when the other side changes. Have a command set the state
the user asks for instead of toggling it.
- A command or name the adapter adds must not shadow one that Claude Code or the
user's own commands and skills already provide.
- New optional request fields and `_meta` keys must not reject requests that
were valid before.
- If a workaround or new path is unavoidable (e.g. a Claude Code or SDK
limitation), say why in a short code comment and in the PR description.
## Testing guidelines
- Before adding a test, search `src/tests/` for existing tests of the same API
and scenario, and add to their file and `describe` block. Use the helpers in
`helpers.ts` and `session-doubles.ts` instead of copying setup.
- Don't add cases that take the same code path. Tests that differ only in input
and expected output belong in one `it.each`. Don't repeat another test's
assertions; assert only what this test is about.
- Reach a state the shortest way (e.g. set `session.tornDown = true`) instead of
driving a full flow with extra mocks, unless the flow is what you test.
- Build test doubles from what the bundled Claude Code CLI really sends,
including fields like `origin` it echoes back and fields it omits. A double
simpler than the real message hides bugs. If unsure, check against the real
CLI (`npm run test:integration`).
- Cover both ACP v1 and v2 when a change affects what a client receives
(updates, responses, replay). A change to internal logic only does not need
its tests duplicated per version.
- For each behavior the docs or comments promise, including what is omitted or
rejected, have a test that would fail if it broke.
## Pull requests

View File

@@ -1,5 +1,275 @@
# Changelog
## [0.102.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.101.1...v0.102.0) (2026-11-09)
### Features
* list workflow phases and agents on the workflow card ([#1552](https://github.com/agentclientprotocol/claude-agent-acp/issues/1552)) ([176ae06](https://github.com/agentclientprotocol/claude-agent-acp/commit/176ae0634c2ef7cb0549339f4b9824884574b2f0))
* send the checklist as a plan operation and remove plans on reset ([#1554](https://github.com/agentclientprotocol/claude-agent-acp/issues/1554)) ([766fd14](https://github.com/agentclientprotocol/claude-agent-acp/commit/766fd142f60bda2f434d55baaa8e72939f215800))
### Bug Fixes
* report v2 background tasks without a tool call as their own tool call ([#1556](https://github.com/agentclientprotocol/claude-agent-acp/issues/1556)) ([d098fb0](https://github.com/agentclientprotocol/claude-agent-acp/commit/d098fb08d54f521d5056d920ebcc5a2656334083))
* update to @anthropic-ai/claude-agent-sdk 0.3.321 ([#1557](https://github.com/agentclientprotocol/claude-agent-acp/issues/1557)) ([855447c](https://github.com/agentclientprotocol/claude-agent-acp/commit/855447c7cd41aa566bdf361d6d59e6a5b038fe6e))
## [0.101.1](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.101.0...v0.101.1) (2026-11-09)
### Bug Fixes
* keep autonomous turn tasks open across owed trailing idles ([#1455](https://github.com/agentclientprotocol/claude-agent-acp/issues/1455)) ([1ee2422](https://github.com/agentclientprotocol/claude-agent-acp/commit/1ee242293401372aa71ea5765e8dfd0679146dc9))
* list a resumed Fable 5.1 session's model as `fable` ([#1548](https://github.com/agentclientprotocol/claude-agent-acp/issues/1548)) ([4cceb06](https://github.com/agentclientprotocol/claude-agent-acp/commit/4cceb06b31525ccaaecfd999ff9b21d401b2659a))
* list a subagent's background Bash commands ([#1540](https://github.com/agentclientprotocol/claude-agent-acp/issues/1540)) ([6597626](https://github.com/agentclientprotocol/claude-agent-acp/commit/6597626d86ddb8c10ea9f1091d921806752d045a))
* list Monitor calls in the session's background work ([#1538](https://github.com/agentclientprotocol/claude-agent-acp/issues/1538)) ([ba16234](https://github.com/agentclientprotocol/claude-agent-acp/commit/ba162346c9ccb4be51f13a3271198351d7822e78))
* relay a rate_limit_event that arrives before the turn's first assistant frame ([#1534](https://github.com/agentclientprotocol/claude-agent-acp/issues/1534)) ([54392e1](https://github.com/agentclientprotocol/claude-agent-acp/commit/54392e144d307d5dd5b7732ae14634d4b2678bd5))
* render workflow tool cards and progress correctly in ACP v2 ([#1550](https://github.com/agentclientprotocol/claude-agent-acp/issues/1550)) ([30600ba](https://github.com/agentclientprotocol/claude-agent-acp/commit/30600ba348e5f5c3755a50f5797fd24645e0ff48))
* replay a compaction summary by its flag, not its framing ([#1536](https://github.com/agentclientprotocol/claude-agent-acp/issues/1536)) ([aa0c123](https://github.com/agentclientprotocol/claude-agent-acp/commit/aa0c1234b5c52a63d96c46c9826631bd0f428062))
* report setup-token logins as logged in ([#1376](https://github.com/agentclientprotocol/claude-agent-acp/issues/1376)) ([7d1ef76](https://github.com/agentclientprotocol/claude-agent-acp/commit/7d1ef76c1d32e390f8ff8824087df3003825a170))
* start transcript notices on their own line for clients without session.notices ([#1362](https://github.com/agentclientprotocol/claude-agent-acp/issues/1362)) ([47b64c0](https://github.com/agentclientprotocol/claude-agent-acp/commit/47b64c04de83145c4d42033c4408c811736c9c40))
* **test:** assert the blockquoted MCP startup transcript line ([#1543](https://github.com/agentclientprotocol/claude-agent-acp/issues/1543)) ([3b22586](https://github.com/agentclientprotocol/claude-agent-acp/commit/3b22586cf63cef06f1029a8418a9abb3b2cc3236))
* update to @agentclientprotocol/sdk 2.5.0 ([#1549](https://github.com/agentclientprotocol/claude-agent-acp/issues/1549)) ([ef65ab3](https://github.com/agentclientprotocol/claude-agent-acp/commit/ef65ab31b405a5d339133c980eb606a70a2d3624))
* update to @anthropic-ai/claude-agent-sdk 0.3.319 ([#1545](https://github.com/agentclientprotocol/claude-agent-acp/issues/1545)) ([a58293f](https://github.com/agentclientprotocol/claude-agent-acp/commit/a58293f4c9aa559a500d61834ef97d2a23b3722b))
## [0.101.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.100.0...v0.101.0) (2026-11-05)
### Features
* compact idle sessions before the prompt cache expires ([#1522](https://github.com/agentclientprotocol/claude-agent-acp/issues/1522)) ([a79d073](https://github.com/agentclientprotocol/claude-agent-acp/commit/a79d07324dc15808c94a889a3b60615cca5a9fbd)), closes [#1454](https://github.com/agentclientprotocol/claude-agent-acp/issues/1454) [#1518](https://github.com/agentclientprotocol/claude-agent-acp/issues/1518)
* ship single-file executables built with bun ([#1524](https://github.com/agentclientprotocol/claude-agent-acp/issues/1524)) ([e3f0b6c](https://github.com/agentclientprotocol/claude-agent-acp/commit/e3f0b6c89c783939e6ca8c19c220c21b58263f46))
* start sessions in Auto mode by default ([#1533](https://github.com/agentclientprotocol/claude-agent-acp/issues/1533)) ([7ea9eec](https://github.com/agentclientprotocol/claude-agent-acp/commit/7ea9eec8392a43191b25a5c3636c181cc7eb42f3))
* stream the output of v2 background commands ([#1512](https://github.com/agentclientprotocol/claude-agent-acp/issues/1512)) ([53e3eec](https://github.com/agentclientprotocol/claude-agent-acp/commit/53e3eec0134209654f53c46f319aaac851b8fff0))
### Bug Fixes
* honor configured fallback for unavailable restored modes ([#1520](https://github.com/agentclientprotocol/claude-agent-acp/issues/1520)) ([8bbdea6](https://github.com/agentclientprotocol/claude-agent-acp/commit/8bbdea6cc62a7ae080cb73ff476ed28ad7574018))
* update to @anthropic-ai/claude-agent-sdk 0.3.318 ([#1525](https://github.com/agentclientprotocol/claude-agent-acp/issues/1525)) ([1d4418f](https://github.com/agentclientprotocol/claude-agent-acp/commit/1d4418f80daf14c29cecc5b23286f52c7f0402ec))
## [0.100.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.99.1...v0.100.0) (2026-11-02)
### Features
* list v2 background tool calls ([#1511](https://github.com/agentclientprotocol/claude-agent-acp/issues/1511)) ([4d79b9f](https://github.com/agentclientprotocol/claude-agent-acp/commit/4d79b9ffbce95c23928a5dd771d6c87476d37160))
* report subagent working directories ([#1496](https://github.com/agentclientprotocol/claude-agent-acp/issues/1496)) ([eb78afa](https://github.com/agentclientprotocol/claude-agent-acp/commit/eb78afa8556b8fc4082495e48e3fd1762fb6a50f))
* report v2 background work ([#1510](https://github.com/agentclientprotocol/claude-agent-acp/issues/1510)) ([5565259](https://github.com/agentclientprotocol/claude-agent-acp/commit/5565259771cb74e3a9bdbacfcbce43e061d81422))
* report why Claude Code could not start a session ([#1508](https://github.com/agentclientprotocol/claude-agent-acp/issues/1508)) ([38cf656](https://github.com/agentclientprotocol/claude-agent-acp/commit/38cf656fc9d80fec2d1a16c472779803e2c802d2))
### Bug Fixes
* align session/cancel, session/close, and v2 setup with the ACP v2 spec ([#1494](https://github.com/agentclientprotocol/claude-agent-acp/issues/1494)) ([f50598a](https://github.com/agentclientprotocol/claude-agent-acp/commit/f50598a39c5dc3908d4b98a7213e01d63ea867d5))
* forward v2 elicitation values and read dismissed v2 permission prompts as a refusal ([#1493](https://github.com/agentclientprotocol/claude-agent-acp/issues/1493)) ([3bc6711](https://github.com/agentclientprotocol/claude-agent-acp/commit/3bc671193c6456516fe05a0eaa53c17bdfb6a190))
* keep native Windows paths in v2 git patches ([#1492](https://github.com/agentclientprotocol/claude-agent-acp/issues/1492)) ([c3428de](https://github.com/agentclientprotocol/claude-agent-acp/commit/c3428de1bf411c513b0703ddd56bb6c5fdff583c))
* preserve live session settings across provider refreshes ([#1474](https://github.com/agentclientprotocol/claude-agent-acp/issues/1474)) ([e01a288](https://github.com/agentclientprotocol/claude-agent-acp/commit/e01a28838499dd6dd7c31e35708090b938e852c6))
* reject unreadable skill manifests ([#1482](https://github.com/agentclientprotocol/claude-agent-acp/issues/1482)) ([99a6e76](https://github.com/agentclientprotocol/claude-agent-acp/commit/99a6e76158b9164e51bd62e3b89113beadea5ce2))
* report the fresh context's usage when ExitPlanMode clears context ([#1484](https://github.com/agentclientprotocol/claude-agent-acp/issues/1484)) ([2fd46c8](https://github.com/agentclientprotocol/claude-agent-acp/commit/2fd46c866d4ff2939fe1b1245b15a536d6de7c0b))
* update to @agentclientprotocol/sdk 2.2.0 ([#1491](https://github.com/agentclientprotocol/claude-agent-acp/issues/1491)) ([1d2b92f](https://github.com/agentclientprotocol/claude-agent-acp/commit/1d2b92feae5eeccde3ac738edca1f1d71250762e))
* update to @agentclientprotocol/sdk 2.4.0 ([#1509](https://github.com/agentclientprotocol/claude-agent-acp/issues/1509)) ([720bbb3](https://github.com/agentclientprotocol/claude-agent-acp/commit/720bbb3e4c65874775a0bb9418799baf4b6e3680))
* update to Claude Agent SDK 0.3.316 (Claude Code 2.1.316) ([#1507](https://github.com/agentclientprotocol/claude-agent-acp/issues/1507)) ([1d03064](https://github.com/agentclientprotocol/claude-agent-acp/commit/1d0306468b632c6a56b4fb076ad7a19f23d9e7b5))
* validate session, prompt, config, and auth requests per the latest ACP spec ([#1495](https://github.com/agentclientprotocol/claude-agent-acp/issues/1495)) ([d2ef490](https://github.com/agentclientprotocol/claude-agent-acp/commit/d2ef490a318033049af00cb9164a39ac2dd1eb95))
## [0.99.1](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.99.0...v0.99.1) (2026-10-29)
### Bug Fixes
* add approval id to plan ([#1469](https://github.com/agentclientprotocol/claude-agent-acp/issues/1469)) ([4b4b2c4](https://github.com/agentclientprotocol/claude-agent-acp/commit/4b4b2c4de7c492070f6260cacea5680b2e045a5a))
* allow forking sessions into a different directory ([#1483](https://github.com/agentclientprotocol/claude-agent-acp/issues/1483)) ([8bd0bc9](https://github.com/agentclientprotocol/claude-agent-acp/commit/8bd0bc9b03fb4a30e0b1755128baad12a214a58d))
* detect "Run /login" login-expired messages as authRequired ([#1481](https://github.com/agentclientprotocol/claude-agent-acp/issues/1481)) ([fccaebc](https://github.com/agentclientprotocol/claude-agent-acp/commit/fccaebcf87e8f37144ec1a64920c0c53997edabe))
* mark plugin, bundled and nested Claude skills as skills for AIR ([#1473](https://github.com/agentclientprotocol/claude-agent-acp/issues/1473)) ([52c241e](https://github.com/agentclientprotocol/claude-agent-acp/commit/52c241e71512ec19c4dd79c95c51ce67f04d7aac))
* title subagent sessions by their task ([#1479](https://github.com/agentclientprotocol/claude-agent-acp/issues/1479)) ([7dcb629](https://github.com/agentclientprotocol/claude-agent-acp/commit/7dcb62923d38ac24e386b0626fe123334341c445))
## [0.99.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.98.0...v0.99.0) (2026-10-28)
### Features
* Add thinking display configuration ([#1081](https://github.com/agentclientprotocol/claude-agent-acp/issues/1081)) ([2f1f4d1](https://github.com/agentclientprotocol/claude-agent-acp/commit/2f1f4d144196c7518a7c072f9ae0e70ac9b91a44))
* send embedded blob resources in prompts to Claude ([#1460](https://github.com/agentclientprotocol/claude-agent-acp/issues/1460)) ([d0ee308](https://github.com/agentclientprotocol/claude-agent-acp/commit/d0ee30813fbc79dbe122f7455c8e1746e09be458))
* tell the client when an MCP server fails to start ([#1464](https://github.com/agentclientprotocol/claude-agent-acp/issues/1464)) ([54b35ee](https://github.com/agentclientprotocol/claude-agent-acp/commit/54b35ee0f4d765efc5bc39107a524891266c42ac))
* update to Claude Agent SDK 0.3.312 ([#1471](https://github.com/agentclientprotocol/claude-agent-acp/issues/1471)) ([4cb8906](https://github.com/agentclientprotocol/claude-agent-acp/commit/4cb8906d32a618011cfeba3260e4eb1db2fd829f))
* **v2:** send the current session info on session/resume ([#1462](https://github.com/agentclientprotocol/claude-agent-acp/issues/1462)) ([f6ed4a4](https://github.com/agentclientprotocol/claude-agent-acp/commit/f6ed4a4f7d9dd5b5e7ed4e9973d9661becb9f583))
### Bug Fixes
* deny a background permission request that the client cancels ([#1466](https://github.com/agentclientprotocol/claude-agent-acp/issues/1466)) ([f646c82](https://github.com/agentclientprotocol/claude-agent-acp/commit/f646c821f1b05df471aac8f1018ea08e2a793f6e))
* don't report decline when the client can't render an MCP elicitation ([#1465](https://github.com/agentclientprotocol/claude-agent-acp/issues/1465)) ([54b869c](https://github.com/agentclientprotocol/claude-agent-acp/commit/54b869c990ad3efa25c976e8eb3ff5a37808a6a3))
* list the current model among the model config option's values ([#1463](https://github.com/agentclientprotocol/claude-agent-acp/issues/1463)) ([8e6fe92](https://github.com/agentclientprotocol/claude-agent-acp/commit/8e6fe92a98dab8cec868a8a782e0ded55c94e571))
* report EnterPlanMode tool calls with the switch_mode kind ([#1467](https://github.com/agentclientprotocol/claude-agent-acp/issues/1467)) ([93c1f43](https://github.com/agentclientprotocol/claude-agent-acp/commit/93c1f43519a6a542dc55a39985d0abd12195129d))
* return ACP error codes for unknown sessions and invalid config values ([#1458](https://github.com/agentclientprotocol/claude-agent-acp/issues/1458)) ([c2e85cb](https://github.com/agentclientprotocol/claude-agent-acp/commit/c2e85cb58067d9b00e6609ce9e90d4ec3a5d0feb))
* update ACP SDK to 2.1.0 (schema v1.27.0 / v2.0.0-alpha.10) ([9f9aebf](https://github.com/agentclientprotocol/claude-agent-acp/commit/9f9aebf44bdecab887b03871fee8a2885ceac19a))
* **v2:** keep clients in sync with config changes and replayed tool calls ([#1461](https://github.com/agentclientprotocol/claude-agent-acp/issues/1461)) ([2a0c188](https://github.com/agentclientprotocol/claude-agent-acp/commit/2a0c188a0aebd0ea03fb449f72dfa53f98c7622d))
* **v2:** reject MCP transports and prompt content the agent doesn't advertise ([#1459](https://github.com/agentclientprotocol/claude-agent-acp/issues/1459)) ([2480888](https://github.com/agentclientprotocol/claude-agent-acp/commit/2480888b90498158054266bdb3bd7f22822e1b73))
## [0.98.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.97.0...v0.98.0) (2026-10-27)
### Features
* add batch session metadata lookup ([#1408](https://github.com/agentclientprotocol/claude-agent-acp/issues/1408)) ([0ce42f2](https://github.com/agentclientprotocol/claude-agent-acp/commit/0ce42f2c312d190f78024d8b4b1ae5f00d9e5939))
### Bug Fixes
* publish Monitor async tasks as monitors ([#1452](https://github.com/agentclientprotocol/claude-agent-acp/issues/1452)) ([4324dce](https://github.com/agentclientprotocol/claude-agent-acp/commit/4324dce4be877c55385b3389cebac1c63d08f456))
* settle the tool call of a SandboxNetworkAccess permission request ([#1448](https://github.com/agentclientprotocol/claude-agent-acp/issues/1448)) ([8f43ac8](https://github.com/agentclientprotocol/claude-agent-acp/commit/8f43ac808931a3f14719bbed37b9b153c9933139))
* **v2:** leave the tool call status out of permission subjects ([#1447](https://github.com/agentclientprotocol/claude-agent-acp/issues/1447)) ([c7c80f7](https://github.com/agentclientprotocol/claude-agent-acp/commit/c7c80f7b26f8d90f7f1c828036db64bc46dddddf))
## [0.97.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.96.0...v0.97.0) (2026-10-26)
### Features
* add ultracode session config option ([#1438](https://github.com/agentclientprotocol/claude-agent-acp/issues/1438)) ([de96cef](https://github.com/agentclientprotocol/claude-agent-acp/commit/de96cef382d8daf27301f1f90ae4b5e7f665f73a))
### Bug Fixes
* bump body-parser from 2.3.0 to 2.4.0 ([#1436](https://github.com/agentclientprotocol/claude-agent-acp/issues/1436)) ([c26e640](https://github.com/agentclientprotocol/claude-agent-acp/commit/c26e640915739dc7a3cd0664f8f5879098e0fd83))
* distinguish model options with duplicate labels ([#1286](https://github.com/agentclientprotocol/claude-agent-acp/issues/1286)) ([434be13](https://github.com/agentclientprotocol/claude-agent-acp/commit/434be13a33141af8655c6f7c30f574841bf2279c))
* keep the usage-limit error when it arrives after compaction ([#1377](https://github.com/agentclientprotocol/claude-agent-acp/issues/1377)) ([2a1525e](https://github.com/agentclientprotocol/claude-agent-acp/commit/2a1525e57cd89246d1a50993fd6ba335c4e634ca))
* pass --settings before the auth status subcommand ([#1392](https://github.com/agentclientprotocol/claude-agent-acp/issues/1392)) ([6a33ded](https://github.com/agentclientprotocol/claude-agent-acp/commit/6a33dedcbb90e7bf9db91207f0edbf19ca6d705e))
* preserve foreground fork reports until launch completes ([#1431](https://github.com/agentclientprotocol/claude-agent-acp/issues/1431)) ([e369479](https://github.com/agentclientprotocol/claude-agent-acp/commit/e369479f93b34f84a11103323d097c44df42e02e))
* settle advisor tool results before completing turns ([#1247](https://github.com/agentclientprotocol/claude-agent-acp/issues/1247)) ([bed795d](https://github.com/agentclientprotocol/claude-agent-acp/commit/bed795d261620e6d5113e427a7196d94e66e7034))
* settle live steers folded into autonomous cycles ([#1185](https://github.com/agentclientprotocol/claude-agent-acp/issues/1185)) ([a4957d1](https://github.com/agentclientprotocol/claude-agent-acp/commit/a4957d17c1e1a925ba3b72b29c5dcde4faabbe17))
## [0.96.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.95.0...v0.96.0) (2026-10-26)
### Features
* report SendMessage delegations as linked session messages ([#1424](https://github.com/agentclientprotocol/claude-agent-acp/issues/1424)) ([739dde3](https://github.com/agentclientprotocol/claude-agent-acp/commit/739dde3ae5bb848bdb90e2c9150e2cc4687d480c))
* report the model of each native subagent ([#1432](https://github.com/agentclientprotocol/claude-agent-acp/issues/1432)) ([c319e5e](https://github.com/agentclientprotocol/claude-agent-acp/commit/c319e5e542aabe353bb2c5e177e4c5853392b8c4))
### Bug Fixes
* do not hold an AIR load replay until the CLI starts ([#1433](https://github.com/agentclientprotocol/claude-agent-acp/issues/1433)) ([eb9d63e](https://github.com/agentclientprotocol/claude-agent-acp/commit/eb9d63e5fde9b8fd8fe692dfb3280faba6626a42))
* refine RFD child models after a load, and retry a failed model update ([#1434](https://github.com/agentclientprotocol/claude-agent-acp/issues/1434)) ([9a8bf71](https://github.com/agentclientprotocol/claude-agent-acp/commit/9a8bf71d454fa85d03f55d4101a77e3ea7a149d0))
* send subagents' current state after a load response ([#1423](https://github.com/agentclientprotocol/claude-agent-acp/issues/1423)) ([17a967b](https://github.com/agentclientprotocol/claude-agent-acp/commit/17a967b4dab78bbb9cf9fedae60e6821578dc5cb))
* update to Claude Code 2.1.310 ([#1435](https://github.com/agentclientprotocol/claude-agent-acp/issues/1435)) ([2f3c6bb](https://github.com/agentclientprotocol/claude-agent-acp/commit/2f3c6bb7b3451a00cd77d7bb31c09e690ca220b3))
## [0.95.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.94.0...v0.95.0) (2026-10-23)
### Features
* end a turn at its result for AIR clients with backgroundSubagents ([#1262](https://github.com/agentclientprotocol/claude-agent-acp/issues/1262)) ([9b3a5c8](https://github.com/agentclientprotocol/claude-agent-acp/commit/9b3a5c891c75e78b985adb31f4e4b59a16ad2ebc))
* report read-only subagent configuration ([#1417](https://github.com/agentclientprotocol/claude-agent-acp/issues/1417)) ([6ab670b](https://github.com/agentclientprotocol/claude-agent-acp/commit/6ab670baaefb30aaac2606d8ee6524e08243e5b5))
* report RFD delegations as linked session messages ([#1405](https://github.com/agentclientprotocol/claude-agent-acp/issues/1405)) ([59e292e](https://github.com/agentclientprotocol/claude-agent-acp/commit/59e292e29f773cc6f03c0999993cbe8a2d97a7e3))
* serve experimental ACP v2 ([#1400](https://github.com/agentclientprotocol/claude-agent-acp/issues/1400)) ([4d378f9](https://github.com/agentclientprotocol/claude-agent-acp/commit/4d378f93f36d37ba5ef64af87b617706dd9c6e82))
* show messages typed in other Remote Control clients ([#1371](https://github.com/agentclientprotocol/claude-agent-acp/issues/1371)) ([5b11f47](https://github.com/agentclientprotocol/claude-agent-acp/commit/5b11f470b1c05b114c876b3b6e5b4df9441b1087))
* update to Claude Code 2.1.309 ([#1413](https://github.com/agentclientprotocol/claude-agent-acp/issues/1413)) ([a1521b6](https://github.com/agentclientprotocol/claude-agent-acp/commit/a1521b6196c58dbc5065f9c19fc9694273d3d435))
### Bug Fixes
* bump fast-uri from 3.1.8 to 3.1.9 ([#1410](https://github.com/agentclientprotocol/claude-agent-acp/issues/1410)) ([0fe22ae](https://github.com/agentclientprotocol/claude-agent-acp/commit/0fe22ae5a172b228a574665bb7136b900746041d))
* bump postcss from 8.5.30 to 8.5.31 in the minor group ([#1403](https://github.com/agentclientprotocol/claude-agent-acp/issues/1403)) ([ecaa2c9](https://github.com/agentclientprotocol/claude-agent-acp/commit/ecaa2c935b9f2b63c1846ca17c6c3398390fb0ef))
* bump the minor group across 1 directory with 2 updates ([#1416](https://github.com/agentclientprotocol/claude-agent-acp/issues/1416)) ([f7d6030](https://github.com/agentclientprotocol/claude-agent-acp/commit/f7d6030c04af23171058772480d55f6d2aee5298))
* IJAI-3744 keep a session running while its background tasks are open ([#1399](https://github.com/agentclientprotocol/claude-agent-acp/issues/1399)) ([e9f986b](https://github.com/agentclientprotocol/claude-agent-acp/commit/e9f986b8a941f5c1789078a39e474f247bd0db60))
* publish the session title as soon as the CLI has it ([#1406](https://github.com/agentclientprotocol/claude-agent-acp/issues/1406)) ([50cd76e](https://github.com/agentclientprotocol/claude-agent-acp/commit/50cd76e78dd9bb1ab0e4850a8d95431175ab5947))
* publish turns that Claude Code starts after end_turn as an async task in v1 ([#1390](https://github.com/agentclientprotocol/claude-agent-acp/issues/1390)) ([a1d0eee](https://github.com/agentclientprotocol/claude-agent-acp/commit/a1d0eeeaad3d67a0765f4b89e3d81c1af6ca0dbb))
## [0.94.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.93.0...v0.94.0) (2026-10-19)
### Features
* send one end event after the session/load history replay for AIR ([#1361](https://github.com/agentclientprotocol/claude-agent-acp/issues/1361)) ([1420994](https://github.com/agentclientprotocol/claude-agent-acp/commit/1420994ced20919aad127a8bd0294de022449e35))
### Bug Fixes
* match OpenRouter model ids in the gateway model listing ([#1365](https://github.com/agentclientprotocol/claude-agent-acp/issues/1365)) ([bcf74c2](https://github.com/agentclientprotocol/claude-agent-acp/commit/bcf74c2fe8e32bd24714d45122529dcf2f99a279))
* resolve a skill's SKILL.md by its declared name, not by its directory name ([#1372](https://github.com/agentclientprotocol/claude-agent-acp/issues/1372)) ([424db87](https://github.com/agentclientprotocol/claude-agent-acp/commit/424db8753581bfaf4c5bf278f8df8c089eec35da))
* update to ACP SDK 2.0.0 ([#1386](https://github.com/agentclientprotocol/claude-agent-acp/issues/1386)) ([ff01e06](https://github.com/agentclientprotocol/claude-agent-acp/commit/ff01e06579225e00643914692994cd60da4d6225))
* update to Claude Code 2.1.303 ([#1384](https://github.com/agentclientprotocol/claude-agent-acp/issues/1384)) ([2dab922](https://github.com/agentclientprotocol/claude-agent-acp/commit/2dab92264414d133fd6774f7a90241ea5192ba32))
## [0.93.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.92.0...v0.93.0) (2026-10-16)
### Features
* add /remote-control command to start Remote Control for a session ([#1197](https://github.com/agentclientprotocol/claude-agent-acp/issues/1197)) ([db9f50f](https://github.com/agentclientprotocol/claude-agent-acp/commit/db9f50f02145d088c0f6080672969a0673ba6359))
### Bug Fixes
* hold a fork back from the session list until its session/fork response is out ([#1368](https://github.com/agentclientprotocol/claude-agent-acp/issues/1368)) ([f5967fd](https://github.com/agentclientprotocol/claude-agent-acp/commit/f5967fd6b49ce4821cc829c26902ccf46f671992))
## [0.92.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.91.0...v0.92.0) (2026-10-16)
### Features
* add the MCP server and tool to AIR tool calls ([#1359](https://github.com/agentclientprotocol/claude-agent-acp/issues/1359)) ([872e93f](https://github.com/agentclientprotocol/claude-agent-acp/commit/872e93f11fde8abc528822d29cebcc542b05e9e8))
* publish the predicted follow-up prompt as _meta.promptSuggestions ([#1356](https://github.com/agentclientprotocol/claude-agent-acp/issues/1356)) ([78a29f5](https://github.com/agentclientprotocol/claude-agent-acp/commit/78a29f58ba1eb1b54896d8d73f49402ee50aa087))
* update ACP SDK to 1.9.0 and adopt the v2 spec clarifications ([#1366](https://github.com/agentclientprotocol/claude-agent-acp/issues/1366)) ([68bf74f](https://github.com/agentclientprotocol/claude-agent-acp/commit/68bf74f58ee378f21502ac93c44878b712905af2))
### Bug Fixes
* include subagent file edits in the file-change report ([#1357](https://github.com/agentclientprotocol/claude-agent-acp/issues/1357)) ([910d83c](https://github.com/agentclientprotocol/claude-agent-acp/commit/910d83ceea78a03b39002cf2dcc275442838e2db))
* restore resumed subagents after an adapter restart ([#1364](https://github.com/agentclientprotocol/claude-agent-acp/issues/1364)) ([26f144a](https://github.com/agentclientprotocol/claude-agent-acp/commit/26f144a2a5c60452805d11c87433963318ef4e65))
## [0.91.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.90.0...v0.91.0) (2026-10-15)
### Features
* keep background subagents running after a parent cancel, for clients that can cancel them ([#1348](https://github.com/agentclientprotocol/claude-agent-acp/issues/1348)) ([ba3c7be](https://github.com/agentclientprotocol/claude-agent-acp/commit/ba3c7be05e9af297fe95b0ac647da2340dd9e15b))
* let a client cancel one subagent with session/cancel on its child session ([#1347](https://github.com/agentclientprotocol/claude-agent-acp/issues/1347)) ([aeb69e7](https://github.com/agentclientprotocol/claude-agent-acp/commit/aeb69e7ed1170e8bce236378432ef8b83cdf2554))
* mark session list rows of worktrees Claude Code made (agentWorktree) ([#1352](https://github.com/agentclientprotocol/claude-agent-acp/issues/1352)) ([1a1f1a0](https://github.com/agentclientprotocol/claude-agent-acp/commit/1a1f1a0e802cc2ee097d9c78c1f7bafccef7c3df))
* narrow models to what the providers/set gateway lists ([#1340](https://github.com/agentclientprotocol/claude-agent-acp/issues/1340)) ([e8eb9d3](https://github.com/agentclientprotocol/claude-agent-acp/commit/e8eb9d340fd4f6dfd9826cf58461edf5ee165f44))
* restore a subagent from before a reload when SendMessage resumes it, for subagents RFD clients ([#1332](https://github.com/agentclientprotocol/claude-agent-acp/issues/1332)) ([df21b3f](https://github.com/agentclientprotocol/claude-agent-acp/commit/df21b3fb56c3449b6f86c9c9df558be351815ed3))
### Bug Fixes
* close the tool calls of a background subagent that the SDK reports stopped after a cancel ([#1336](https://github.com/agentclientprotocol/claude-agent-acp/issues/1336)) ([9a1b90f](https://github.com/agentclientprotocol/claude-agent-acp/commit/9a1b90fb9db8b04ef6756cbcdc8df22afc82a2ba))
* close the tool calls of a cycle that Claude Code started and a cancel stopped ([#1339](https://github.com/agentclientprotocol/claude-agent-acp/issues/1339)) ([1fd217f](https://github.com/agentclientprotocol/claude-agent-acp/commit/1fd217f3e77d0b7883ebd220bc19c7899d3af77f))
* close the tool calls that a cycle Claude Code started abandoned, at its end ([#1344](https://github.com/agentclientprotocol/claude-agent-acp/issues/1344)) ([594aa8c](https://github.com/agentclientprotocol/claude-agent-acp/commit/594aa8c9556609c16946c889e9c8f1f76337b203))
* deliver the output of a background subagent that a cancel did not stop ([#1345](https://github.com/agentclientprotocol/claude-agent-acp/issues/1345)) ([bcc024d](https://github.com/agentclientprotocol/claude-agent-acp/commit/bcc024d2c9a34d62e19999bc6cfc393ad56fbcde))
* end the terminal of a command whose tool call is closed without a result ([#1335](https://github.com/agentclientprotocol/claude-agent-acp/issues/1335)) ([764f339](https://github.com/agentclientprotocol/claude-agent-acp/commit/764f339c3e83eac8ca34f4ddc8ed65f8199b651d))
* end the tool calls of a cycle that Claude Code started and a cancel stopped, on v1 ([#1342](https://github.com/agentclientprotocol/claude-agent-acp/issues/1342)) ([b4788d6](https://github.com/agentclientprotocol/claude-agent-acp/commit/b4788d61eaf9372b376d56c0f6fb7c6156822ae6))
* keep a live session that was never prompted when a resume or load changes its settings ([#1353](https://github.com/agentclientprotocol/claude-agent-acp/issues/1353)) ([0c8de13](https://github.com/agentclientprotocol/claude-agent-acp/commit/0c8de1380fd990291fc51cf17c6742402efb5c94))
* let session/delete succeed for a session without a transcript ([#1351](https://github.com/agentclientprotocol/claude-agent-acp/issues/1351)) ([fadbe18](https://github.com/agentclientprotocol/claude-agent-acp/commit/fadbe1881cd441609a1fde8c43286d9a404dc425))
* report no running for the session's own request that a cancel withdraws, on ACP v2 ([#1346](https://github.com/agentclientprotocol/claude-agent-acp/issues/1346)) ([d528647](https://github.com/agentclientprotocol/claude-agent-acp/commit/d528647162ba6747654125cf6ebc2297ced02ed0))
* show nothing of a tool that cancelled work asks to run after the cancel was reported ([#1334](https://github.com/agentclientprotocol/claude-agent-acp/issues/1334)) ([ebf8b49](https://github.com/agentclientprotocol/claude-agent-acp/commit/ebf8b49fd68924f2c50680e7e73241f625801980))
* stop waiting for a question the client leaves open after Claude Code cancels it ([#1338](https://github.com/agentclientprotocol/claude-agent-acp/issues/1338)) ([c1e83b4](https://github.com/agentclientprotocol/claude-agent-acp/commit/c1e83b46b717230a5d623c3ac591d915a776e0ae))
* update Claude Agent SDK to 0.3.300 ([#1355](https://github.com/agentclientprotocol/claude-agent-acp/issues/1355)) ([761b4c8](https://github.com/agentclientprotocol/claude-agent-acp/commit/761b4c835985ff265d35338df35eee6e6861d8f3))
## [0.90.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.89.1...v0.90.0) (2026-10-13)
### Features
* accept IDE session hooks without dropping model settings ([#1265](https://github.com/agentclientprotocol/claude-agent-acp/issues/1265)) ([fe8b69e](https://github.com/agentclientprotocol/claude-agent-acp/commit/fe8b69edd438ee1bc4206752b029dd45e71975fe))
* **deps:** Bump actions/setup-node from 7.0.0 to 7.1.0 ([#1329](https://github.com/agentclientprotocol/claude-agent-acp/issues/1329)) ([c6510c2](https://github.com/agentclientprotocol/claude-agent-acp/commit/c6510c27c11bb59ad10090c8c18ba60c0fa1c3fa))
* keep a subagent's session across SendMessage resumes for subagents RFD clients ([#1321](https://github.com/agentclientprotocol/claude-agent-acp/issues/1321)) ([bd06804](https://github.com/agentclientprotocol/claude-agent-acp/commit/bd06804b67d7b4efa8180db2f36203a5d34c3ac4))
* replay each subagent under its live session id for subagents RFD clients ([#1328](https://github.com/agentclientprotocol/claude-agent-acp/issues/1328)) ([21eef57](https://github.com/agentclientprotocol/claude-agent-acp/commit/21eef576557c845bfe9acc66499dab347f813a98))
* report a subagent as requires_action while its request is open for subagents RFD clients ([#1322](https://github.com/agentclientprotocol/claude-agent-acp/issues/1322)) ([252126f](https://github.com/agentclientprotocol/claude-agent-acp/commit/252126f61654cf7ac2cad74be53b4c6add569e1f))
* report subagents as the subagents RFD defines, for clients that are not AIR ([#1320](https://github.com/agentclientprotocol/claude-agent-acp/issues/1320)) ([5758ac8](https://github.com/agentclientprotocol/claude-agent-acp/commit/5758ac84a4a3b95ace7542a7e71fd58d93a581b3))
### Bug Fixes
* close the open tool calls of a held or settling turn that a cancel ends ([#1221](https://github.com/agentclientprotocol/claude-agent-acp/issues/1221)) ([32c23db](https://github.com/agentclientprotocol/claude-agent-acp/commit/32c23db06d53d7d4e1295d78fc0129f8278d75df))
* display active Central helpers as JetBrains Air Gateway ([#1266](https://github.com/agentclientprotocol/claude-agent-acp/issues/1266)) ([d6c3717](https://github.com/agentclientprotocol/claude-agent-acp/commit/d6c371704fb5fd74c3b329a3d756486975e3f180))
* drop streamed output of prompts a cancel abandoned ([#1313](https://github.com/agentclientprotocol/claude-agent-acp/issues/1313)) ([1137df3](https://github.com/agentclientprotocol/claude-agent-acp/commit/1137df33aa4fe387916596ff489c02ee48ccbba3))
* end a cancelled turn at its interrupted result and deliver Claude Code's follow-up after a cancel ([#1309](https://github.com/agentclientprotocol/claude-agent-acp/issues/1309)) ([b007ac7](https://github.com/agentclientprotocol/claude-agent-acp/commit/b007ac7c45b85755877fee802c2c901a0e3057c6))
* end the tool calls of a cancelled turn before it reports the cancel ([#1327](https://github.com/agentclientprotocol/claude-agent-acp/issues/1327)) ([53df1c7](https://github.com/agentclientprotocol/claude-agent-acp/commit/53df1c7b22bf08f9a2866389be812be80f34c8a3))
* keep the terminal of a command in the background running until its task ends ([#1312](https://github.com/agentclientprotocol/claude-agent-acp/issues/1312)) ([d074558](https://github.com/agentclientprotocol/claude-agent-acp/commit/d074558e4a2ef4ee6f4b39aecb5ebf36d8c0f84b))
* report a command whose result says interrupted as cancelled on the experimental ACP v2 surface ([#1301](https://github.com/agentclientprotocol/claude-agent-acp/issues/1301)) ([b3bed2f](https://github.com/agentclientprotocol/claude-agent-acp/commit/b3bed2f7630f4ddb97978246255ba89ca6e4613e))
* route a subagent's PostToolUse hook update to its child session ([#1324](https://github.com/agentclientprotocol/claude-agent-acp/issues/1324)) ([4615a91](https://github.com/agentclientprotocol/claude-agent-acp/commit/4615a9141c12e8cd9093a5bbc0e62a7d778cf0cf))
* start a forked session so the client can prompt it at once ([#1317](https://github.com/agentclientprotocol/claude-agent-acp/issues/1317)) ([383bc7d](https://github.com/agentclientprotocol/claude-agent-acp/commit/383bc7dcf0d4d624d2d74405eb3cee6453eb9b11))
## [0.89.1](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.89.0...v0.89.1) (2026-10-10)

118
README.md
View File

@@ -17,6 +17,7 @@ This tool implements an ACP agent by using the official [Claude Agent SDK](https
- Interactive (and background) terminals
- Custom [Slash commands](https://docs.anthropic.com/en/docs/claude-code/slash-commands)
- Client MCP servers
- An Ultracode session config option, which turns on Claude Code's dynamic workflows for every task. It appears when dynamic workflows are enabled and the model supports ultracode (any model with `xhigh` effort)
- `/mcp` in the chat: the MCP server status as a list. The adapter runs `/mcp reconnect`, `/mcp enable`, and `/mcp disable` through the SDK control API, because Claude Code refuses them in SDK mode. A reconnect of an ACP server that needs authentication starts MCP OAuth through URL elicitation
- Session-scoped long-running goals for AIR through the [goal extension](docs/air-extensions.md#goal) under `_meta.jetbrains.air.goal`
- Structured errors, recovery, and warnings through the opt-in [session failure extension](docs/air-extensions.md#session-failure)
@@ -35,16 +36,117 @@ To try changes that have landed on `main` but are not released yet, install from
npm install @agentclientprotocol/claude-agent-acp@preview
```
### Standalone executables
Each release attaches archives for Linux (glibc and musl), macOS and Windows, on
x64 and arm64, to its [GitHub release](https://github.com/agentclientprotocol/claude-agent-acp/releases).
They need no Node.js install. Each holds two files: `claude-agent-acp`, and the
Claude CLI (`claude`) it runs. Keep them in the same directory; a symlink to
`claude-agent-acp` elsewhere works. To use a Claude CLI you already have
instead, set `CLAUDE_CODE_EXECUTABLE`. On Alpine, install `libgcc` and
`libstdc++` first.
To build one for your machine (needs [Bun](https://bun.sh)) and check it:
```sh
npm run build:binary
npm run smoke:binary # add `-- --full` to drive a real session; needs a Claude login
```
### Subagent sessions
Subagents are exposed only after bilateral capability negotiation. Until the released ACP SDKs
preserve the draft `clientCapabilities.subagents` field, a supporting client may advertise
`nativeSubagentSessions` in `_meta.jetbrains.air.capabilities`; the adapter mirrors the capability
in its initialize response. The canonical field remains supported and takes precedence once it is
available. Without either client signal, Agent/Task lifecycle keeps its legacy ordinary ACP
tool-call representation and child interactions stay on the root session. Clients that use the
historical `_meta["subagent-transcript"]` capability or `forwardSubagentText` session option retain
the flattened child transcript behavior. See [AIR extensions](docs/air-extensions.md#native-subagent-sessions).
A client that declares `clientCapabilities.subagents`, other than JetBrains AIR, gets each Agent or
Task subagent as its own child session, following the [subagents RFD](https://github.com/agentclientprotocol/agent-client-protocol/blob/main/docs/rfds/subagents.mdx):
- `subagent_update` on the parent announces the child before any of its traffic, with the
parent's `title` and `description` for it, and reports its work state: `running`,
`requires_action` while one of its requests is open (and `running` again when the last one ends,
unless a cancel withdrew it: the child is stopping then), and `idle` when its work ends, with
`end_turn` or `cancelled` when the SDK gives the reason. A failure is `idle` with the `error`
stop reason and the SDK's error text as its `error`. The child keeps its session for every
delegation to it.
- A child that runs outside its parent's working directory, such as an Agent call with
`isolation: "worktree"`, gets its `cwd` on `subagent_update` before its first tool call. The
adapter takes it from the `cwd` of the child's own hooks: its SubagentStart hook runs before its
first model request. It sends `cwd: null` when the child is back in its parent's directory. A
child in its parent's directory gets no `cwd`, and no child gets `additionalDirectories`: Claude
Code gives a subagent the session's additional directories, so it inherits them. A replay
announces a child with the `cwd` that its transcript recorded, and clears it after the response
when that directory no longer exists.
- Session messages replace the Agent or Task call that launches the child, which its parent is
not shown:
- The launch's prompt is a `session_message` in both transcripts: the parent's outgoing view and
the child's incoming one. Both use the launch's tool use id, and each names the other with the
draft RFD's `recipientMessageId` and `senderMessageId`.
- A foreground child's answer, from the launch's result, is the child's `session_message` to
the parent. In Claude Code's hand-back mode, the child's `SubagentHandback` call is its
message to the parent instead, in both transcripts and linked the same way.
- A launch whose child is never exposed is shown as a tool call after all, with its final
status.
- The message of a SendMessage call that resumes the child is a `session_message` in the child's
transcript. The SendMessage call stays a tool call of the session that made it.
- `session/cancel` on the parent interrupts Claude Code, which stops the foreground children that
the turn waits on. Background children keep running, as the RFD prefers; a client stops one with
its own `cancel`. Each stopped child reports `idle` with `cancelled` from Claude Code's own
events, and its open requests are withdrawn. The adapter ends no child at a cancel itself, so a
child that keeps running keeps its open requests and its open tool calls. To spare background
children, the adapter declares the SDK option `perTaskStopAffordance`, which a client can turn
off in `_meta.claudeCode.options`. That option spares background workflows too, which a client
cannot stop, so the adapter stops them after the interrupt, as Claude Code did without it.
Background commands keep running after a cancel either way. A tool call of a stopped child that Claude Code reports no
result for, such as the command it was running, ends in the child's session before the cancelled
prompt resolves. When Claude Code stops a background child after that, or while no prompt runs,
the tool call ends right after the child reports `idle`. A child that still runs when Claude Code
exits is `idle` with no stop reason.
- A child whose parent is not yet known when it ends is not exposed, since the RFD forbids
guessing its parent.
- A replay restores each child under its live session id, from its own transcript, with the same
session messages as live, and its end is an `idle` before the response.
A launch without Claude Code's metadata file for the child stays an ordinary tool call of the
parent, and a notice, for a client that takes notices, says that subagent history is missing.
- After a load or resume, with or without replay, a SendMessage that resumes an agent from before
it runs that agent in its session from before. Before the agent's traffic, each of its ancestors
that the client was not told about in this connection is announced, parent before child and
with no work state, as the RFD requires. An agent whose launch the session's history does not
record stays unexposed.
- Each running child is announced with the `cancel` capability. `session/cancel` with the child's
session id stops that child's current work, through the SDK's `Query.stopTask`, and not its
parent's: Claude Code also stops the foreground children it waits on, and the parent's turn goes
on. Each stopped child reports `idle` with `cancelled` from Claude Code's own events, and its
requests that the stop withdraws report no `running` first. A replayed child, which no longer
runs, gets no capabilities. A client cannot prompt or otherwise change a child.
An [ACP v2](#experimental-acp-v2) client gets the same without declaring anything, since subagents
are part of v2's baseline. Each child also reports its work state as its own `state_update`, right
after the parent's copy.
JetBrains AIR keeps the earlier draft of the RFD (`subagent_spawned`, `subagent_state_update`),
which it enables with `nativeSubagentSessions` in `_meta.jetbrains.air.capabilities`. See
[AIR extensions](docs/air-extensions.md#native-subagent-sessions).
Without either signal, Agent and Task stay ordinary tool calls and child interactions stay on the
root session. Clients that use the historical `_meta["subagent-transcript"]` capability or the
`forwardSubagentText` session option retain the flattened child transcript.
### Experimental ACP v2
The adapter also serves the [draft ACP v2](https://agentclientprotocol.com/protocol/v2/overview):
a client that asks for protocol version 2 or later in `initialize` gets v2, and every other client
gets v1. The draft can still change, and this surface changes with it.
`CLAUDE_AGENT_ACP_PROTOCOL` limits the adapter to one version. With `v1`, a client that asks
for v2 is negotiated down to v1. With `v2`, the adapter rejects the `initialize` of a v1 client.
`auto`, the default, serves both.
On v2 the adapter serves the session baseline (`session/new`, `session/list`, `session/resume`
with replay, `session/close`, `session/prompt`, `session/cancel`), `session/delete`,
`session/fork`, additional directories, config options (the mode is one), plans, notices,
compaction, display terminals for commands, permission requests, elicitation, providers, auth, and
[subagents](#subagent-sessions). Steering is v1 only for now.
v2 is served by the v2 agent in `src/v2/`, which runs straight on Claude
Code instead of translating to v1. A v2 client that declares JetBrains AIR
(`_meta.jetbrains.air`) also gets the AIR extensions that ACP v2 does not cover.
## Contribution Policy

View File

@@ -7,7 +7,9 @@ keeps a single release PR open, titled `chore(main): release X.Y.Z` and labelled
`autorelease: pending`.
Merging that PR is what releases. It tags `vX.Y.Z`, creates the GitHub release,
publishes to npm, and dispatches a version update to the agent registry.
publishes to npm, dispatches a version update to the agent registry, and
attaches the [standalone executables](../README.md#standalone-executables) to
the GitHub release. Previews get no executables.
There is no manual release button, and versions are never typed in by hand: the
version is an output of the commit history, not an input.
@@ -45,6 +47,9 @@ gh release view "v<version>"
npm view "@agentclientprotocol/claude-agent-acp@<version>"
```
The release should list 8 `claude-agent-acp-*` archives. They are attached a
few minutes after the release is created, once every platform has built.
## Preview releases
Every push to `main` that is not a release merge publishes a preview from the
@@ -203,6 +208,13 @@ npm versions are immutable. If the package already published and only the
registry update failed, pass `-f publish_npm=false` so the run skips publishing
and only re-dispatches the registry update.
### The release has no executables
`build-binaries` and `upload-binaries` don't gate npm, so a failed build only
leaves the release without archives. Re-run the failed jobs of that publish run
(`gh run rerun <run-id> --failed`); the upload uses `--clobber`, so archives
already attached are replaced rather than duplicated.
### A preview published but the commit was not tagged
Only the publish is irreversible, so re-run just the tag job:

View File

@@ -17,16 +17,20 @@ It describes only this adapter.
- [Claude tools and ACP fields](#claude-tools-and-acp-fields)
- [Diff patch](#diff-patch)
- [Plan file](#plan-file)
- [Session hooks](#session-hooks)
- [Permission presentation](#permission-presentation)
- [Goal](#goal)
- [Predicted follow-up prompt](#predicted-follow-up-prompt)
- [Recommended config values](#recommended-config-values)
- [Async tasks](#async-tasks)
- [Agent file-change report](#agent-file-change-report)
- [Session failure](#session-failure)
- [Native subagent sessions](#native-subagent-sessions)
- [Session index](#session-index)
- [Background subagents](#background-subagents)
- [Context compaction](#context-compaction)
- [Question custom answers](#question-custom-answers)
- [Load replay end](#load-replay-end)
- [Session fork point](#session-fork-point)
- [Presentation hints](#presentation-hints)
- [Removed keys](#removed-keys)
@@ -122,7 +126,8 @@ The `initialize` response of an AIR client carries the agent side of the extensi
"asyncTasks",
"recommendedValue",
"diffPatch",
"planFile"
"planFile",
"backgroundSubagents"
],
"goal": {
"version": 1,
@@ -136,26 +141,39 @@ The `initialize` response of an AIR client carries the agent side of the extensi
}
```
The agent list does not depend on the capabilities that AIR declares, except `sessionIndex`: the agent lists it, together with `sessionArchive`, `sessionRename` and `sessionListSubscribe`, only for a client that declared `sessionIndex`.
An extension is active only when the client declared its capability. The client does not declare `sessionArchive`, `sessionRename` and `sessionListSubscribe`.
The agent list does not depend on the capabilities that AIR declares, except `sessionIndex`: the agent lists it, together with `sessionBatchGet`, `sessionArchive`, `sessionRename`, `sessionListSubscribe` and `sessionWorktrees`, only for a client that declared `sessionIndex`.
An extension is active only when the client declared its capability. The client does not declare `sessionBatchGet`, `sessionArchive`, `sessionRename`, `sessionListSubscribe` and `sessionWorktrees`.
The response to a client that is not AIR has no `_meta.jetbrains` key.
### ACP v2
The v2 core (`src/v2/`) serves an AIR client only the extensions that ACP v2 does not cover.
Its `initialize` response lists `agentFileChangeReport` and `recommendedValue`.
It also serves the [permission presentation](#permission-presentation), the [question custom answers](#question-custom-answers), the [predicted follow-up prompt](#predicted-follow-up-prompt), and [load replay end](#load-replay-end).
The [agent file-change report](#agent-file-change-report) goes out at the end of the turn that serves the prompt, before the turn's idle `state_update`, because a v2 `session/prompt` is answered when Claude Code takes the prompt in.
ACP v2 covers subagents, background work, file plans, patches, compaction, the system prompt, and turn failures itself, so the v2 core serves no `nativeSubagentSessions`, `backgroundSubagents`, `asyncTasks`, `planFile`, `diffPatch`, `contextCompaction`, `customInstructions`, or `sessionFailure`.
It does not serve the [tool call contract](#tool-call-contract) yet.
### Capabilities
| Capability | Advertised | What the adapter does when the client declares it | Section |
| ------------------------ | ------------------- | ------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------- |
| `diffPatch` | yes | Sends an exact Git patch in the diff block of an Edit or a Write. | [Diff patch](#diff-patch) |
| `sessionFailure` | yes | Sends warnings and errors as typed transcript records. | [Session failure](#session-failure) |
| `recommendedValue` | yes | Replaces the `default` model and effort rows with concrete values and a recommendation. | [Recommended config values](#recommended-config-values) |
| `asyncTasks` | yes | Publishes background work that is not a subagent as async tasks. | [Async tasks](#async-tasks) |
| `agentFileChangeReport` | yes | Accepts a report request on `session/prompt` and sends the changed file list. | [Agent file-change report](#agent-file-change-report) |
| `nativeSubagentSessions` | yes | Reports an Agent or Task subagent as a native ACP child session. | [Native subagent sessions](#native-subagent-sessions) |
| `planFile` | yes | Sends the path of the plan file in place of the plan text of an ExitPlanMode. | [Plan file](#plan-file) |
| `sessionIndex` | on request | Pages, orders and annotates `session/list`; adds rename, archive and a list subscription. | [Session index](#session-index) |
| `sessionArchive` | with `sessionIndex` | Nothing on its own: the agent lists it to tell that it supports `_session/archive` and `_session/unarchive`. | [Archive](#archive) |
| `sessionRename` | with `sessionIndex` | Nothing on its own: the agent lists it to tell that it supports `_session/rename`. | [Rename](#rename) |
| `sessionListSubscribe` | with `sessionIndex` | Nothing on its own: the agent lists it to tell that it supports `_session/list/subscribe`. | [List subscription](#list-subscription) |
| `rawInputRendering` | no | Sends no display copy of readable input in `content`. The client renders `rawInput`. | [Tool call contract](#tool-call-contract) |
| Capability | Advertised | What the adapter does when the client declares it | Section |
| ------------------------ | ------------------- | --------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- |
| `diffPatch` | yes | Sends an exact Git patch in the diff block of an Edit or a Write. | [Diff patch](#diff-patch) |
| `sessionFailure` | yes | Sends warnings and errors as typed transcript records. | [Session failure](#session-failure) |
| `recommendedValue` | yes | Replaces the `default` model and effort rows with concrete values and a recommendation. | [Recommended config values](#recommended-config-values) |
| `asyncTasks` | yes | Publishes background work that is not a subagent as async tasks. | [Async tasks](#async-tasks) |
| `agentFileChangeReport` | yes | Accepts a report request on `session/prompt` and sends the changed file list. | [Agent file-change report](#agent-file-change-report) |
| `nativeSubagentSessions` | yes | Reports an Agent or Task subagent as a native ACP child session. | [Native subagent sessions](#native-subagent-sessions) |
| `planFile` | yes | Sends the path of the plan file in place of the plan text of an ExitPlanMode. | [Plan file](#plan-file) |
| `backgroundSubagents` | yes | Ends a prompt turn at its result while background subagents still run. | [Background subagents](#background-subagents) |
| `sessionIndex` | on request | Pages, orders and annotates `session/list`; adds rename, archive and a list subscription. | [Session index](#session-index) |
| `sessionBatchGet` | with `sessionIndex` | Supports `_session/batch_get` for metadata of known session IDs. | [Batch get](#batch-get) |
| `sessionArchive` | with `sessionIndex` | Nothing on its own: the agent lists it to tell that it supports `_session/archive` and `_session/unarchive`. | [Archive](#archive) |
| `sessionRename` | with `sessionIndex` | Nothing on its own: the agent lists it to tell that it supports `_session/rename`. | [Rename](#rename) |
| `sessionListSubscribe` | with `sessionIndex` | Nothing on its own: the agent lists it to tell that it supports `_session/list/subscribe`. | [List subscription](#list-subscription) |
| `sessionWorktrees` | with `sessionIndex` | Nothing on its own: the agent lists it to tell that rows carry `agentWorktree` for a worktree Claude Code made. | [List response](#list-response) |
| `loadReplayEnd` | no | Sends one `session_info_update` after the whole history replay of a loaded session. | [Load replay end](#load-replay-end) |
| `rawInputRendering` | no | Sends no display copy of readable input in `content`. The client renders `rawInput`. | [Tool call contract](#tool-call-contract) |
The adapter ignores `planContentDelta`.
Claude does not stream a plan, so the adapter never sends `contentDelta` (see [Plan file](#plan-file)).
@@ -184,11 +202,13 @@ The adapter sends these keys only to an AIR client. "AIR" in the Gate column mea
| `commandTitle` | Bash and PowerShell tool call `_meta.jetbrains.air` | the `description` input string | AIR |
| `subagent` | Agent and Task tool call `_meta.jetbrains.air` | `true` | AIR |
| `skill` | Skill tool call `_meta.jetbrains.air` | `{name, path?}` | AIR |
| `mcp` | MCP tool call `_meta.jetbrains.air` | `{server, tool}` | AIR |
| `contextCompaction` | compaction tool call or `compaction_update`, `_meta.jetbrains.air` | `{version: 1, trigger?, preTokens?, postTokens?, durationMs?, error?}` | AIR |
| `customAnswer` | elicitation schema property `_meta.jetbrains.air` | `{questionId, isCustomAnswer: true}` | AIR, when the client supports form elicitation |
| `kind` | session mode `_meta.jetbrains.air` and mode config option value `_meta.jetbrains.air` | `standard`, `plan`, `auto_review`, or `full_access` | AIR |
| `kind` | available command `_meta.jetbrains.air` of a skill | `skill` | AIR, when the skill's `SKILL.md` is found; not for a built-in command or an MCP prompt |
| `skillPath` | available command `_meta.jetbrains.air` of a skill | absolute path of the skill's `SKILL.md` | AIR, together with `kind: "skill"` |
| `loadReplayEnd` | `session_info_update._meta.jetbrains.air` | `true` | `loadReplayEnd` |
| `fork` | `session/fork` request `_meta.jetbrains.air` (client to agent) | `{version: 1, messageId, messageFingerprint?, messageOccurrence?}` | none |
## JetBrains shared keys
@@ -204,6 +224,21 @@ The adapter keeps them where they are.
| `steering` | `initialize` response `_meta.steering = {supported: true}` | The agent accepts `_session/steering` for a running turn. |
| `quota` | `PromptResponse._meta.quota` | Token usage of the turn, also split by model. |
| `authStatus` | `initialize` response `agentCapabilities._meta.authStatus` | The agent pushes `_auth/status_update`. The object carries no payload. |
| `promptSuggestions` | `session_info_update._meta.promptSuggestions = {suggestions: [{text}]}`, AIR only | The prompt the agent predicts for the next turn. An empty list clears it. |
## Auth source display
The connection-scoped `_auth/status_update` continues to report agent-owned authentication, not client-owned `providers/set` routing. When the active key source is `apiKeyHelper`, the adapter reads the effective helper command through SDK `resolveSettings` / the session's `SettingsManager`, including programmatic settings overrides.
A command containing the whole word `central` and the word sequence `proxy start` is displayed as:
```json
{ "authStatus": { "kind": "gateway", "label": "JetBrains Air Gateway" } }
```
Matching is case-insensitive and accepts multiple whitespace characters between `proxy` and `start`. The command's flags, URL, host and route are not checked. This is a weak display heuristic, not proof of configuration provenance, account ownership or entitlement. Inactive helpers and external cloud backends retain their existing classification. No helper command, key, headers, private route or inferred account is included in this payload.
Other active helpers use `label: "Anthropic API key"` and `detail: "API key helper"`; unknown key-source identifiers are not exposed as display text. CLI and SDK reads share this presentation, retain stale-probe protection, and suppress unchanged notifications.
## Zed conventions
@@ -214,6 +249,7 @@ An AIR client that declares `terminal_output_delta` gets appends instead of `ter
A command tool call then has `content: [{type: "terminal", terminalId}]` and `_meta.terminal_info = {terminal_id}`.
The output goes in `_meta.terminal_output = {terminal_id, data}`.
The end sends `_meta.terminal_exit = {terminal_id, exit_code, signal: null}`.
A command that ends without a result (a cancel stopped its subagent, say) gets the exit on the report that closes its tool call, with no exit code (`null`; AIR gets `1`).
- A client that declares neither `terminal_output` nor `terminal_output_delta` gets no terminal.
The command output is a `console` code block in `content`.
- A client that declares `clientCapabilities._meta["terminal-auth"]: true` gets `_meta["terminal-auth"] = {command, args, label}` on the terminal login methods.
@@ -306,10 +342,14 @@ For every tool, a client that is not AIR also gets these upstream fields:
| `Skill` | `kind: other`, `title: "Load skill: <name>"`. | `_meta.jetbrains.air.skill`. | none |
| `TodoWrite`, `TaskCreate`, `TaskUpdate`, `TaskList`, `TaskGet` | The stream reports them as a standard `plan` with entries. Only a permission request shows them as a tool call. | A plan that repeats the previous plan is not sent. | Every plan goes out. |
| `ReportFindings` | `kind: think`, `title: "Report <n> findings"`. | With `rawInputRendering`, no copy of the findings in `content`. | none |
| MCP tools (`mcp__*`) and other tools | `kind: other`, `title` is the tool name. The result text goes to `content`. | `_meta.is_mcp_tool_call` for MCP. | none |
| MCP tools (`mcp__*`) and other tools | `kind: other`, `title` is the tool name. The result text goes to `content`. | `_meta.is_mcp_tool_call` for MCP, and `_meta.jetbrains.air.mcp = {server, tool}`. | none |
| Context compaction | `compaction_update` when the client declares `session.compaction`. Otherwise a synthetic tool call. | Both forms carry `_meta.jetbrains.air.contextCompaction`. | See [Context compaction](#context-compaction). |
| Background work that is not a subagent | none | With `asyncTasks`, async task updates. | none |
`_meta.jetbrains.air.mcp` comes from the tool name `mcp__<server>__<tool>`, on `tool_call` and on a `tool_call_update` that changes it.
`server` is the part up to the next `__`. `tool` is the whole rest and can contain `__`, as in Claude Code.
A name that does not split (`mcp__x`) gets no `mcp` key, and `_meta.is_mcp_tool_call` still marks it.
## Diff patch
The diff patch extension lets the adapter send one compact Git patch instead of file text snapshots.
@@ -484,6 +524,17 @@ While the adapter does not know the path, a report carries no `planFilePath` key
The adapter never sends a blank path, because AIR reads a present non-file value as a retraction.
Without `rawInputRendering`, a `planFile` client gets no copy of the plan in `content` either.
### Approved plan identity
For an AIR client with plan operations, the approved plan ID is `plan-mode:<tool-call ID>`.
Claude uses the SDK tool-call ID of ExitPlanMode.
Each review keeps its own plan. Repeated updates of that review keep the same plan ID.
AIR joins the plan to its review by ID, even when the text includes user edits.
This link needs no extra metadata.
The live result, history replay, and clear-context approval use the same identity.
The identity does not depend on `planFile`: it also applies when the request carries inline plan text.
Other clients keep the `plan-mode` ID.
### Fallback
The plan text goes out as before in these cases:
@@ -496,6 +547,21 @@ The plan text goes out as before in these cases:
AIR then shows the in-memory `plan` text.
A client that is not AIR always gets the whole SDK input, like upstream.
## Session hooks
The adapter accepts session hooks from any client, without a capability.
AIR puts the hooks into `_meta.claudeCode.options.settings.hooks` of `session/new` and `session/load`:
```json
{ "claudeCode": { "options": { "settings": { "hooks": { "Stop": [{ "hooks": [{ "type": "command", "command": "..." }] }] } } } }
```
- The settings go to the flag settings tier of that session only. Other sessions and the settings files do not change.
- Claude Code runs the hooks of every settings tier, so the user, project and IDE hooks of one event all run. `src/tests/session-hooks.test.ts` checks this with the Claude Code binary.
- `CLAUDE_MODEL_CONFIG` stays in effect (see [Model configuration](model-configuration.md#precedence)).
- A `session/load` with different settings recreates the query, so it applies the new hooks.
- Claude asks for no hook trust step.
## Permission presentation
The adapter uses standard ACP permission requests and responses.
@@ -569,6 +635,7 @@ The client already has the rest of the tool call, because the adapter sends the
- `content` is present only with an exact preview patch for a `diffPatch` client.
- `locations` is present only when a valid `blockedPath` is not a location of the tool call yet.
- `_meta.claudeCode.mcpServer` names the MCP server of an `mcp__*` tool.
It is the server name as authored in the config. `_meta.jetbrains.air.mcp.server` on the tool call is the normalized name (characters outside `[a-zA-Z0-9_-]` replaced by `_`), so the two can differ.
A client that is not AIR gets the upstream request `toolCall`: `toolCallId`, `name`, `status: pending`, the whole `rawInput`, `title`, `kind`, `content`, and `locations`.
Its `_meta.claudeCode` carries `toolName`, and `parentToolUseId` for a subagent tool call, next to `mcpServer`.
@@ -661,7 +728,7 @@ A non-empty plan also gets one clear-context choice with the same priority.
After that choice, the adapter stops the old private Claude query and hides that internal rejection.
It starts a new private query under the same ACP session.
It continues the turn with `Implement the following plan:` and the complete plan.
The mode, model, agent, effort, Fast mode, and turn usage stay the same.
The mode, model, agent, effort, Fast mode, ultracode, and turn usage stay the same.
The SDK cannot apply `allowedPrompts` again, so those hints do not survive the handoff.
The adapter does not allow a callback only because the session mode is `bypassPermissions`.
@@ -784,6 +851,37 @@ While that command is pending, the adapter ignores late updates for the previous
A matching runtime `active_goal` stays authoritative and adds the iteration, reason, and timestamp.
A failed command restores the previous state.
## Predicted follow-up prompt
After a turn, the Claude Code CLI can predict the prompt the user is likely to send next.
The adapter asks for it with the SDK option `promptSuggestions: true` and forwards the one prediction of the turn.
ACP has no field for a composer prefill, so the prediction travels outside the AIR namespace:
```json
{
"sessionUpdate": "session_info_update",
"_meta": { "promptSuggestions": { "suggestions": [{ "text": "Run the tests" }] } }
}
```
The key names no vendor, so another client can read it.
The adapter sends it only to an AIR client; see the [compatibility rule](#compatibility-rule).
AIR shows the first non-blank `suggestions[].text` as gray text in an empty composer.
Tab fills the composer with it and sends nothing.
`suggestions: []` clears the prediction the client holds. The adapter clears it:
- when the next `session/prompt` arrives, because that turn's prediction is spent;
- on a `conversation_reset`, because the prediction was drawn from the transcript the reset dropped.
A prediction the next prompt outran — the user sent it before the adapter read the prediction off the SDK stream — is dropped instead of published.
The CLI predicts nothing on the first turn of a session, after an API error, in plan mode, and at the account's usage limit, so many turns end with no prediction.
A client must treat a turn without one as normal.
A caller can turn the whole feature off with `_meta.claudeCode.options.promptSuggestions: false` on `session/new`; a user can turn it off in Claude Code settings or with `CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION=false`.
## Recommended config values
The `recommendedValue` extension lets a client show concrete model and effort choices without an ambiguous `default` row.
@@ -920,6 +1018,20 @@ When an announced task leaves that list, the adapter reports `stopped`.
A later terminal event can correct that state to `completed` or `failed`.
At shutdown, the adapter finishes each open task as `failed` or `stopped`.
### Autonomous turn
Claude Code can start a turn of its own after the prompt ended, for example to answer the notification of a finished background task.
ACP v1 has no session state, so the adapter publishes such a turn as an async task.
The task has the id `autonomous-turn-<uuid>`, the name `Claude is working`, `taskType: "turn"`, and `showInTranscript: false`.
Its description is the summary of the last task notification, when there is one.
The task starts at the SDK `running` state while no prompt is active or queued.
When the state stays `running`, as it does while background agents run, it starts at the first root output with no prompt open.
It ends `completed` at the result of the turn or at the SDK `idle` state, and `failed` at an error result.
A coalesced placeholder result (`num_turns: 0`) does not end it.
A prompt that the turn takes in ends the task, because that prompt owns the turn now.
A stop request interrupts the turn instead of stopping an SDK task.
It returns `{ "stopped": false }` while a prompt waits for its turn, because the interrupt would drop that prompt.
### Stop request
The client sends `_session/async_task/stop`:
@@ -959,7 +1071,8 @@ The adapter ignores a malformed request and a repeated request id.
### Report
The adapter sends one `session_info_update` before the `PromptResponse`:
The adapter sends one `session_info_update` before the `PromptResponse`.
On ACP v2 it comes before the idle `state_update` of the turn that serves the prompt (see [ACP v2](#acp-v2)):
```json
{
@@ -1170,8 +1283,9 @@ This section covers only the AIR bridge.
- The canonical client field is `clientCapabilities.subagents: {}`.
- Released ACP SDKs can strip that draft field.
AIR can instead declare `nativeSubagentSessions` in `_meta.jetbrains.air.capabilities`.
- Either signal enables native subagent sessions. The canonical field takes precedence when it is available.
- The agent advertises `nativeSubagentSessions` to AIR, and `agentCapabilities.sessionCapabilities.subagents` to every client.
- Either signal enables native subagent sessions, and AIR gets this earlier draft of the RFD whichever it declares.
- The agent advertises `nativeSubagentSessions` and `agentCapabilities.sessionCapabilities.subagents` to AIR.
- A client that is not AIR and declares `clientCapabilities.subagents` gets the revised RFD instead (`subagent_update`, `session_message`), see the README.
- With native sessions, the adapter sends `subagent_spawned` and `subagent_state_update`.
The child output goes to the child session. The Agent or Task tool call is not the subagent card.
- `subagent_spawned` can carry an optional `prompt`: the exact prompt of the child session.
@@ -1190,8 +1304,8 @@ The session index makes `session/list` a bounded, ordered page with row metadata
AIR declares `sessionIndex` in `_meta.jetbrains.air.capabilities`.
Unlike the other capabilities, the agent advertises `sessionIndex` only to a client that declared it, and never under ACP v2.
Exactly when it advertises `sessionIndex`, the agent also advertises `sessionArchive` (it supports `_session/archive` and `_session/unarchive`), `sessionRename` (it supports `_session/rename`) and `sessionListSubscribe` (it supports `_session/list/subscribe` and `_session/list/unsubscribe`).
The client does not declare these three; declaring them without `sessionIndex` enables nothing.
Exactly when it advertises `sessionIndex`, the agent also advertises `sessionBatchGet` (it supports `_session/batch_get`), `sessionArchive` (it supports `_session/archive` and `_session/unarchive`), `sessionRename` (it supports `_session/rename`), `sessionListSubscribe` (it supports `_session/list/subscribe` and `_session/list/unsubscribe`) and `sessionWorktrees` (rows carry `agentWorktree`, see [List response](#list-response)).
The client does not declare these five; declaring them without `sessionIndex` enables nothing.
Everything in this section applies only to such a client, except where [Delete and close](#delete-and-close) says otherwise.
A client without the capability gets the `session/list` of before: the SDK `listSessions` call, pages of 1000 by file mtime, `offset:N` cursors, and no row `_meta`.
It gets no new notification, and the adapter starts no watcher for it.
@@ -1259,6 +1373,7 @@ Each row carries `_meta.jetbrains.air` with flat row fields of the RFDs. `archiv
"lastPromptAt": "2026-10-06T09:57:40.000Z",
"model": "claude-opus-5-5",
"forkedFrom": "8f0c1d2e-0000-4000-8000-000000000000",
"agentWorktree": true,
"state": "idle",
"lastTurnEndedAt": "2026-10-06T09:58:10.000Z",
"cost": { "amount": 1.23, "currency": "USD" }
@@ -1268,10 +1383,19 @@ Each row carries `_meta.jetbrains.air` with flat row fields of the RFDs. `archiv
- `lastPromptAt` is the time of the last real user prompt in the transcript tail: prompt text (the predicate of the SDK title extractor) or an image or a document, but not a tool result, a meta or compact summary record, a slash command or an interrupt. When a long answer or tool output follows the last prompt, the tail window grows (256 KB, 1 MB, 4 MB) to find it; it is omitted only when the last 4 MB hold no prompt.
- `model` is the model of the last assistant message in the tail; messages the CLI makes up (`<synthetic>`) do not count.
- `forkedFrom` is the parent session id that the SDK `forkSession` and the CLI fork write on every copied record (`forkedFrom.sessionId`). Sessions started any other way have none.
- `agentWorktree` is `true` when the session runs in a linked git worktree that Claude Code made for it (`claude --worktree`, or the worktree tools), and omitted otherwise, never `false`. The transcript rule is the one of AIR's native Claude provider; the adapter adds the git check of the row's `cwd`:
- the transcript holds `worktree-state` records, `{"type":"worktree-state","worktreeSession":{originalCwd, worktreePath, worktreeName, worktreeBranch, originalBranch, originalHeadCommit, sessionId, hookBased?, enteredExisting?}}`, which the CLI writes when the session enters or leaves a worktree;
- the last line that parses as a `worktree-state` record decides, whatever it holds: the session is marked only when its `worktreeSession` is an object without `enteredExisting: true` (`enteredExisting` means a worktree that existed before, so not made for the session). Any other `worktreeSession` (`null`, which the CLI writes when the session leaves the worktree, a missing key, or a value that is no object) means not marked, and an older record does not count then. Only lines that are not JSON, such as the cut first line of a window, are skipped;
- the row's `cwd` must lie in a linked worktree: the adapter reads the git files, without running git. It takes the nearest `.git` above `cwd`, follows a `.git` file to its git dir, reads `commondir`, and requires the git dir to be `<git-common-dir>/worktrees/<name>`. This is checked on every `session/list` and on every row a subscription reads, not cached. The main checkout, a submodule and a directory outside a repository never get the field, and neither does a worktree the user made (it has no record). The adapter does not use `environment.isWorktree` or `git worktree list`;
- `hookBased` is not read: a worktree a `WorktreeCreate` hook made is marked only when its directory is a linked git worktree as above, so a hook that makes a plain directory or a checkout of another VCS gives no field. The repository is the nearest one to `cwd`: a session in a repository nested in a linked worktree is not marked, and one in a linked worktree of a nested repository is;
- the record is read from the 64 KB tail, else from the 64 KB head, and from the wider tail when the window grows to find the last prompt (see `lastPromptAt`). `claude --worktree` writes it at the start of the session, and the CLI writes it again with the other session metadata at the end of the transcript (on exit and as the transcript grows), so one of these windows almost always holds the latest one. A session that entered or left a worktree in the middle of a long transcript, with no metadata written since, keeps its old value until the CLI writes the metadata again;
- a session that made a worktree mid-session (EnterWorktree) is marked too: the CLI moves its transcript to the worktree (a `relocated` record), so the row's `cwd` is the worktree path, not the directory the session started in. The row's `cwd` is always the session's own directory, so the worktree path;
- a subscription does not send a row again when only git metadata changes, such as a manual `git worktree remove` or `git worktree move`: the field is checked again only when the transcript changes, or when the rescan finds that the worktrees of the scope changed and resolves the rows again. `session/list` checks it on every read.
- `state` is `running`, `idle`, `requires_action`, or `error`, never `unknown`. `error` means the session is not running and its last turn ended with an error; a user cancel or interrupt is no error, and a new turn clears it:
- a session this connection runs: the SDK `session_state_changed` state; `error` instead of `idle` when its last turn failed (an error result, `is_error` or an `error_*` subtype, that was not a cancel). A session whose query failed here (not a cancel) is reported as any other session, but `error` until its transcript shows a prompt or a turn end after the failure, unless a busy or waiting interactive CLI holds it;
- a session no live Claude Code process holds: `idle`, or `error` when its last turn ended with an API error (an assistant record with `isApiErrorMessage: true`, also when the CLI's turn-end records follow it, with no prompt after it);
- a session that an interactive CLI holds (not an `sdk-*` entrypoint) whose registry status is newer than the transcript: `busy` and `shell` are `running`, `waiting` is `requires_action`, `idle` is `idle`, or `error` after such an API error;
- any other held session whose last turn ended with an open background task: `running`, also after 10 minutes, until the registry status is `idle`. Claude Code wakes the session again when the task ends, so the session gets one `idle` at the last turn end, not one for each task. A background shell command (`toolUseResult.backgroundTaskId`) or an async sub-agent (`isAsync` with an `agentId`) opens a task. Its `<task-notification>` with a `<status>`, as a user record or a `queue-operation` `remove`, or the result of a `TaskStop` call closes it. A notification without a `<status>`, such as the check-in of a quiet shell or a `Monitor` event, keeps it open. Sidechain records open no task. The tail gives the tasks on top of those of an earlier scan when the file only grew within the tail; else the whole transcript is read, and only for such a held session. A background task that a sub-agent starts is not seen: it is only in the transcript of the sub-agent;
- any other held session: `idle` after a finished turn (an assistant `end_turn`, a user interrupt, or the CLI turn-end records), `error` after a turn that ended with an API error, `running` for an unfinished turn written in the last 10 minutes, and no state otherwise.
- The live processes come from `<config>/sessions/<pid>.json`, with the liveness rules of Claude Desktop: `kill(pid, 0)` (or `EPERM`), a matching `procStart`, no foreign `pidDomain`, and records older than 24 hours only with a matching `procStart`.
The adapter never reads the `.key` files there and never changes the directory.
@@ -1340,6 +1464,71 @@ In all of the session index methods and in `session/delete`, a session id matche
Loading an archived session does not unarchive it.
The CLI cleanup still deletes old transcripts (`cleanupPeriodDays`), archived or not.
### Batch get
`_session/batch_get` refreshes metadata for retained session IDs, such as pinned
sessions and folder members outside the recent list page. Check for
`sessionBatchGet` in `initialize.result._meta.jetbrains.air.capabilities` before
calling it.
The params object must contain only `sessionIds`: an array of distinct,
non-blank strings. IDs match exactly, without trimming or case normalization.
Malformed params, duplicates or extra fields return `Invalid params` (`-32602`).
The agent sets no limit on the number of IDs; the client chooses the batch size
for the latency it accepts. Archived sessions and sessions in other projects
or worktrees are eligible.
```json
{
"jsonrpc": "2.0",
"id": 7,
"method": "_session/batch_get",
"params": { "sessionIds": ["oldA", "oldB", "oldC"] }
}
```
```json
{
"jsonrpc": "2.0",
"id": 7,
"result": {
"sessions": [
{
"sessionId": "oldA",
"cwd": "/project",
"title": "Fix the build",
"updatedAt": "2026-01-01T00:00:00.000Z",
"_meta": { "jetbrains": { "air": { "version": 1, "archived": false } } }
}
]
}
}
```
The example IDs are placeholders for stored Claude session UUIDs. `sessions`
contains the same full rows as `session/list` without a project filter, including
archived sessions and `_meta.jetbrains.air`. Copies use the same
newest-readable-copy selection as the list. Returned sessions preserve their
relative request order. Non-UUID IDs and IDs without a listable transcript are
omitted: absent, empty, unreadable or malformed transcripts, sidechains, sessions
whose continuation has history, and transcripts whose cwd cannot be recovered.
A sidecar alone does not supply a row.
`{ "sessionIds": [] }` returns `{ "sessions": [] }` without reading the store.
A store-wide failure, such as an unreadable projects root, rejects the request.
Individual results reflect the store as each ID is read.
Lookup normally addresses only the requested transcript filenames in each project
and reads their metadata. A transcript without a cwd can use a sibling's cwd,
as the list does. This fallback enumerates and stats sibling transcripts only
in the directories of rows still missing a cwd, then reads at most 64 siblings
per directory, newest mtime first. A failed recovery is cached for 60 seconds
unless the directory mtime changes. Other project histories are not enumerated.
The agent looks up 100 IDs at a time; the client may request any number of IDs.
Merge returned rows with the recent page by `sessionId`. A batch read does not
expand an existing list subscription's scope; refresh retained IDs outside that
scope explicitly.
### List subscription
`_session/list/subscribe { "cwd": "…" }` returns `{ "subscriptionId": "…" }`.
@@ -1363,7 +1552,8 @@ From then on the agent sends `_session/list/changes` notifications for the sessi
- `cwd` is required and must be an absolute path; a missing, non-string or relative `cwd` is `-32602`. Other parameters are ignored.
- The scope is the sessions of `cwd` and of the same subdirectory in every existing linked worktree of its repository, as a list with `includeWorktrees: true` resolves it, in any archive state. Sidechain and subagent transcripts are left out, as in the list.
- `sessions` holds the full current row of each session in scope that appeared, or whose `title`, `lastPromptAt`, `state`, `lastTurnEndedAt`, `cost`, `model`, `forkedFrom` or `archived` changed since the last row sent for it on this subscription. A row is exactly the row of `session/list`, `_meta.jetbrains.air` included.
- There is no page or recent-session limit: changes to older pinned or folder sessions within that scope are reported even outside the page AIR loaded. Pinned IDs outside the subscribed project and worktrees are not tracked; `_session/batch_get` does not add them to the subscription. AIR must subscribe to their project scope or refresh them explicitly.
- `sessions` holds the full current row of each session in scope that appeared, or whose `title`, `lastPromptAt`, `state`, `lastTurnEndedAt`, `cost`, `model`, `forkedFrom`, `agentWorktree` or `archived` changed since the last row sent for it on this subscription. A row is exactly the row of `session/list`, `_meta.jetbrains.air` included.
- After subscribe, the first change of each session sends its row in full, even when only `updatedAt` changed. From then on, a change of `updatedAt` alone sends nothing; the new `updatedAt` comes with the next change.
- `removed` holds the ids of sessions that left the scope: deleted (the transcript is gone), or moved to a path outside it. It may name a session the client does not list (one it never listed, one the list hides, such as a session continued in another one when that one changes); the client ignores such an id.
- Archive and unarchive are row changes (`archived`): their title records are a transcript change. The agent applies no archive filter: the client filters.
@@ -1389,9 +1579,10 @@ It is idempotent, and an unknown id also returns `{}`.
`session/delete`:
- a `sessionIndex` client: deletes every transcript of the session (empty ones too) and every `<sessionId>/` directory, also one left without a transcript by an earlier failed delete. Anything left behind fails the request. A session that runs here is closed first. As with the CLI, the session is deleted also when another Claude Code process has it open. An unknown session is `-32002`.
- an AIR client without `sessionIndex`: archives the session instead of deleting it (the title records of [Archive](#archive)), because AIR uses delete for "Done" and can reopen the session. A session that runs here is closed first. The list hides archived sessions from such a client too: those whose custom title, as the SDK list reports it, is archived. A session without a transcript fails with the error of the SDK delete, as before.
- an AIR client without `sessionIndex`: archives the session instead of deleting it (the title records of [Archive](#archive)), because AIR uses delete for "Done" and can reopen the session. A session that runs here is closed first. The list hides archived sessions from such a client too: those whose custom title, as the SDK list reports it, is archived. A session without a transcript has nothing to archive, so the delete succeeds, as ACP asks of a session that does not exist.
A `sessionIndex` client sees such a session as archived (`archived: true`), as AIR's native Claude path does.
- any other client: the SDK delete, as before.
- any other client: the SDK delete. A session without a transcript (unknown, deleted before, or
never prompted) succeeds, as ACP asks.
`session/close` of a session that is not loaded returns `{}` for every client.
@@ -1409,21 +1600,23 @@ It is idempotent, and an unknown id also returns `{}`.
The session index is the `_meta` form of two ACP RFDs: "Session list extensions: limit, order, row metadata and change hints" and "Session Archive and Unarchive" (#2161).
Names and semantics follow them; the transport differs, and so do the `archived` list filter and the archive of a running session (see [Relation to ACP RFD #2161](#relation-to-acp-rfd-2161)):
| Extension | RFD |
| ------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- |
| capability `sessionIndex` (`_meta.jetbrains.air`) | `sessionCapabilities.list.limit` |
| capability `sessionArchive` (`_meta.jetbrains.air`) | `sessionCapabilities.archive` (#2161) |
| capability `sessionRename` (`_meta.jetbrains.air`) | `sessionCapabilities.setTitle` (#1987) |
| `_meta.jetbrains.air.list.limit` | `session/list` `limit` |
| `_meta.jetbrains.air.list.includeWorktrees` | `session/list` `includeWorktrees` |
| `_meta.jetbrains.air.list.archived` (`"unarchived"`, `"archived"`, `"all"`) | `session/list` `archived` (#2161), a boolean there |
| row `_meta.jetbrains.air.lastPromptAt`, `model`, `forkedFrom`, `state`, `lastTurnEndedAt`, `cost` | `SessionInfo` fields of the same names; the RFD `state` has no `error` |
| row `_meta.jetbrains.air.archived` | `SessionInfo.archived` (#2161) |
| `session_info_update` `_meta.jetbrains.air.archived` | `SessionInfoUpdate.archived` (#2161) |
| capability `sessionListSubscribe`, `_session/list/subscribe`, `_session/list/unsubscribe` | none |
| `_session/list/changes { subscriptionId, sessions, removed }` | none |
| `_session/archive`, `_session/unarchive` | `session/archive`, `session/unarchive` (#2161) |
| `_session/rename` | `session/set_title` (#1987) |
| Extension | RFD |
| ------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| capability `sessionIndex` (`_meta.jetbrains.air`) | `sessionCapabilities.list.limit` |
| capability `sessionArchive` (`_meta.jetbrains.air`) | `sessionCapabilities.archive` (#2161) |
| capability `sessionRename` (`_meta.jetbrains.air`) | `sessionCapabilities.setTitle` (#1987) |
| capability `sessionBatchGet`, `_session/batch_get` | none |
| `_meta.jetbrains.air.list.limit` | `session/list` `limit` |
| `_meta.jetbrains.air.list.includeWorktrees` | `session/list` `includeWorktrees` |
| `_meta.jetbrains.air.list.archived` (`"unarchived"`, `"archived"`, `"all"`) | `session/list` `archived` (#2161), a boolean there |
| row `_meta.jetbrains.air.lastPromptAt`, `model`, `forkedFrom`, `state`, `lastTurnEndedAt`, `cost` | `SessionInfo` fields of the same names; the RFD `state` has no `error` |
| row `_meta.jetbrains.air.archived` | `SessionInfo.archived` (#2161) |
| `session_info_update` `_meta.jetbrains.air.archived` | `SessionInfoUpdate.archived` (#2161) |
| capability `sessionWorktrees`, row `_meta.jetbrains.air.agentWorktree` | none yet: proposed in the session list extensions RFD (agentclientprotocol/agent-client-protocol#2323) as `SessionInfo.agentWorktree`, with the capability named `agentWorktrees` there, not `sessionWorktrees` |
| capability `sessionListSubscribe`, `_session/list/subscribe`, `_session/list/unsubscribe` | none |
| `_session/list/changes { subscriptionId, sessions, removed }` | none |
| `_session/archive`, `_session/unarchive` | `session/archive`, `session/unarchive` (#2161) |
| `_session/rename` | `session/set_title` (#1987) |
### Relation to ACP RFD #2161
@@ -1438,6 +1631,46 @@ It differs in transport, until the RFD lands in the SDK, in the list filter, and
- the list parameter is `_meta.jetbrains.air.list.archived`, and the state is `_meta.jetbrains.air.archived` on rows and in `session_info_update`, not the `archived` fields;
- an AIR client without `sessionIndex` still archives with `session/delete` (see [Delete and close](#delete-and-close)), which the RFD asks clients not to do.
## Background subagents
A prompt turn can start a background Agent or Task subagent.
A SendMessage tool call can also resume a subagent that ended.
Claude Code sends the result of the turn when the main agent stops, also while such a subagent still runs.
### Hold without the capability
A client that does not declare `backgroundSubagents` gets the hold of issues #864 and #866.
The adapter keeps the `session/prompt` request open while a subagent that the turn started or resumed runs.
The subagent output, its permission requests and the followup summary of the model then go out inside the turn.
The turn ends at the followup result, at an `idle` with no live subagent, at `session/cancel`, or when the next prompt starts.
The hold protects a client that stops listening at the prompt response.
### Behavior with the capability
AIR declares `backgroundSubagents` in `initialize.clientCapabilities._meta.jetbrains.air.capabilities`.
The capability applies to ACP v1 only, and the agent does not advertise it to ACP v2.
ACP v2 has no hold: the v2 agent answers `session/prompt` when Claude Code takes the prompt in, lists a background subagent in its parent session's `background` while it works, and reports each subagent's work in the state of its child session (see [ACP v2](#acp-v2)).
- The turn ends at its result with the stop reason of the result, usually `end_turn`.
- The subagent updates, permission requests and elicitations go out after the prompt response.
- The routing does not change: the native child session, else the root session with the parent tool call in `_meta.claudeCode.parentToolUseId`.
- The followup cycle after a subagent ends is an autonomous cycle. Its updates go out of a turn. Its result does not end a turn.
- A steer with no turn in flight starts a new turn, or returns `promptRequired` when the client asks for it.
- `session/cancel` with no turn works as before: the adapter interrupts the SDK query and reports each native subagent session as cancelled.
- The tool call of a running subagent does not fail the incomplete tool call check of the turn.
- The [agent file-change report](#agent-file-change-report) of the turn does not include an edit that a subagent makes after the result.
- An open permission request of a subagent counts as an open request for the next turn and for the steer priority.
### Plan approval out of a turn
The followup cycle can call ExitPlanMode. AIR then gets the permission request out of a turn.
The request has no clear-context option, because the fresh query must continue an ACP turn.
"No, keep planning" stops only the followup cycle. There is no turn to end.
A prompt turn owns the request when the turn is active, or when Claude Code asks before the echo of its prompt.
Then the request has the clear-context option.
A turn that took over a followup cycle also owns the request of that cycle, and the accepted plan continues in that turn.
## Context compaction
The adapter implements the ACP session compaction RFD for a client that declares `clientCapabilities.session.compaction`.
@@ -1457,14 +1690,14 @@ For AIR, both forms carry `_meta.jetbrains.air.contextCompaction`:
}
```
| Field | Meaning |
| ------------ | --------------------------------------------------------------------- |
| `version` | Must equal `1`. |
| `trigger` | `manual` or `automatic`. |
| `preTokens` | The context size before the compaction. |
| `postTokens` | The context size after the compaction. |
| `durationMs` | The duration of the compaction. |
| `error` | The error of a failed compaction. Only the tool call form carries it. |
| Field | Meaning |
| ------------ | -------------------------------------------------------------------------------------------------------- |
| `version` | Must equal `1`. |
| `trigger` | `manual`, `automatic`, or `idle`: the adapter compacted an idle session before its prompt cache expired. |
| `preTokens` | The context size before the compaction. |
| `postTokens` | The context size after the compaction. |
| `durationMs` | The duration of the compaction. |
| `error` | The error of a failed compaction. Only the tool call form carries it. |
The standard `toolCallId` or `compactionId` and the `status` own the identity and the phase.
The `compaction_update` form puts the error in the standard `error` field.
@@ -1493,6 +1726,32 @@ For AIR, the companion field also carries the same value under the root key `_me
Released AIR versions read only that key.
A client that is not AIR gets neither key.
## Load replay end
ACP has no signal that the history replay of `session/load` is over, other than the response.
The adapter replays the transcript while the Claude Code CLI starts, so the history can arrive long before the response.
The client declares `loadReplayEnd` in its capabilities. The agent does not advertise it.
After the history replay of the loaded session, the agent then sends one more update for that session:
```json
{
"sessionId": "<loaded session>",
"update": {
"sessionUpdate": "session_info_update",
"_meta": { "jetbrains": { "air": { "version": 1, "loadReplayEnd": true } } }
}
}
```
- The update has no `title` and no `updatedAt`. A `null` there would clear the value.
- It comes after every replayed update, the subagent history that the replay sends to the child sessions included, and before the `session/load` response.
- It comes also for an empty history.
- It comes for `session/load` of a new or an already live session, and for the ACP v2 `session/resume` with `replayFrom: {type: "start"}`.
- It does not come when the replay stops or fails. The replay stops when the CLI start fails during the replay.
- It does not come for a live update, and it does not come to a client without the capability.
- The `session/load` can still fail after it, for example when the CLI start fails after the replay. The event means that the recorded history arrived whole. The client decides what to do with the failed load.
## Session fork point
AIR can fork a session at one agent message.
@@ -1525,6 +1784,8 @@ It adds this object to the `session/fork` request:
When only one stored message has the fingerprint, the adapter uses that message.
- When no message matches, the request fails with `invalidParams`.
- The fork keeps the history up to that message.
- With or without this object, the forked session runs once the response arrives, so the client can prompt it without `session/load` or `session/resume`.
A later load replays its history into it, and a later resume returns it. A load or resume with other settings (`cwd`, MCP servers, additional directories, or the session options in `_meta`) restarts it, as for any loaded session.
## Presentation hints

View File

@@ -42,15 +42,26 @@ node dist/index.js
## Precedence
When an ACP caller provides `settings` via `_meta.claudeCode.options.settings` in the `sessions/create` request, `CLAUDE_MODEL_CONFIG` is ignored entirely. The env var is a deployment-level fallback for cases where the caller does not configure model settings itself.
An ACP caller can provide `settings` via `_meta.claudeCode.options.settings` in `session/new` or `session/load`, as an object or a path to a settings file. The adapter merges `CLAUDE_MODEL_CONFIG` into those settings, so a caller that only adds hooks keeps the model settings. When both set the same key, the caller wins; the merge is shallow, so a caller's `modelOverrides` replaces the env var's map.
| Source | Priority |
| -------------------------------------------- | ----------------------------------------------------- |
| `_meta.claudeCode.options.settings` (caller) | Highest — used if present |
| `CLAUDE_MODEL_CONFIG` (env var) | Fallback — used only when caller provides no settings |
| Source | Priority |
| -------------------------------------------- | ---------------------------------------------- |
| `_meta.claudeCode.options.settings` (caller) | Highest — wins for each key it sets |
| `CLAUDE_MODEL_CONFIG` (env var) | Used for the keys that the caller does not set |
## Format details
- The value must be valid JSON. Invalid JSON will cause session creation to fail with a parse error.
- Only `modelOverrides` and `availableModels` keys are read; other keys in the JSON are ignored.
- Both fields map directly to the Claude Agent SDK's `Settings` type.
## Models of a `providers/set` gateway
When an ACP client routes the agent through a gateway with `providers/set`, the adapter lists the gateway's models before `providers/set` returns, with the provider headers:
- `anthropic`: `GET {baseUrl}/v1/models`, paginated by `has_more`/`last_id`.
- `bedrock`: `GET {baseUrl}/foundation-models` (Bedrock `ListFoundationModels`, `modelSummaries[].modelId`). Only `anthropic.` ids count. The cross-region prefix `global` when listed, else that of the first listed id with one (`eu` for `eu.anthropic.claude-sonnet-5`), becomes the session's `ANTHROPIC_BEDROCK_REGION_PREFIX`, and ids with other prefixes, or without one, are dropped.
The model picker then offers only the SDK models whose resolved model the gateway lists, matched without dates, version suffixes (`-v1:0`), `-fast`, `@` suffixes, context hints (`[1m]`) and provider prefixes, after the `availableModels` allowlist above. On Bedrock, a model the CLI resolved to another region prefix is dropped as well. When the gateway does not list the model a session runs, that is the default model or a resumed session's last model, the session switches to the first remaining picker row.
A 4xx answer means the gateway has no listing: `providers/set` succeeds and nothing is narrowed. A failed listing (network error, a status other than 2xx or 4xx, unexpected body, the 10 s timeout, an invalid header, or a base URL with credentials) fails `providers/set` with a JSON-RPC internal error, and the previous provider stays in effect. A listing that matches no SDK model leaves the session with no model to offer: there is no model config option. Gateways set through `authenticate`, `vertex` providers, and native routing are not listed. Setting the same gateway again (the same protocol, base URL and headers) reuses its listing, unless it answered 4xx; `providers/set` and `providers/disable` apply in the order they arrive, and logout clears the routing after `claude auth logout`, including a `providers/set` still listing; `providers/disable` and logout drop it.

View File

@@ -82,10 +82,10 @@ import {
client as acpClient,
methods,
ndJsonStream,
} from "@agentclientprotocol/sdk";
} from "@agentclientprotocol/sdk/unstable";
// Type-only: Node's type stripping does not elide value imports, and these two
// exist only in the .d.ts — importing them normally is a runtime SyntaxError.
import type { AnyMessage, Stream } from "@agentclientprotocol/sdk";
import type { AnyMessage, Stream } from "@agentclientprotocol/sdk/unstable";
// ---------------------------------------------------------------- steering

View File

@@ -46,7 +46,7 @@ import {
ndJsonStream,
type PromptRequest,
type PromptResponse,
} from "@agentclientprotocol/sdk";
} from "@agentclientprotocol/sdk/unstable";
/** The steering extension method, per the ACP steering wire protocol. */
const STEERING_METHOD = "_session/steering";

856
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -3,7 +3,7 @@
"publishConfig": {
"access": "public"
},
"version": "0.89.1",
"version": "0.102.0",
"description": "An ACP-compatible coding agent powered by the Claude Agent SDK (TypeScript)",
"main": "dist/lib.js",
"types": "dist/lib.d.ts",
@@ -21,12 +21,15 @@
"files": [
"dist/",
"!dist/tests/",
"!dist/binary-entry.*",
"README.md",
"LICENSE",
"package.json"
],
"scripts": {
"build": "tsc",
"build:binary": "node scripts/build-binary.mjs",
"smoke:binary": "node scripts/smoke-binary.mjs",
"start": "node dist/index.js",
"dev": "npm run build && npm run start",
"example:simple-client": "node --experimental-strip-types --disable-warning=ExperimentalWarning examples/simple-client.ts",
@@ -65,26 +68,25 @@
"node": ">=22"
},
"dependencies": {
"@agentclientprotocol/sdk": "1.8.0",
"@anthropic-ai/claude-agent-sdk": "0.3.293",
"@agentclientprotocol/sdk": "2.5.0",
"@anthropic-ai/claude-agent-sdk": "0.3.321",
"diff": "9.0.0",
"zod": "4.6.5"
"zod": "4.6.7"
},
"devDependencies": {
"@anthropic-ai/sdk": "0.132.1",
"@anthropic-ai/sdk": "1.7.0",
"@eslint/js": "10.0.1",
"@tsconfig/node22": "22.0.6",
"@types/node": "26.6.4",
"@typescript-eslint/eslint-plugin": "8.71.1",
"@typescript-eslint/parser": "8.71.1",
"@types/node": "26.6.6",
"@typescript-eslint/eslint-plugin": "8.72.2",
"@typescript-eslint/parser": "8.72.2",
"ajv": "8.20.0",
"ajv-formats": "3.0.1",
"eslint": "10.12.0",
"eslint": "10.14.0",
"eslint-config-prettier": "10.1.8",
"globals": "17.13.0",
"prettier": "3.9.9",
"ts-node": "10.9.2",
"globals": "17.14.0",
"prettier": "3.9.10",
"typescript": "6.0.3",
"vitest": "5.0.3"
"vitest": "5.1.0"
}
}

92
scripts/build-binary.mjs Normal file
View File

@@ -0,0 +1,92 @@
#!/usr/bin/env node
// Builds an executable of the agent with `bun build --compile` and copies the
// target platform's Claude CLI next to it. Both files go in the release
// archive: the executable runs the CLI from its own directory.
//
// node scripts/build-binary.mjs [--target bun-linux-x64-musl] [--outfile path]
//
// --target defaults to the host. Cross-compiling needs the target's SDK
// platform package installed; the script prints the install command when it
// is missing.
import { spawnSync } from "node:child_process";
import { chmodSync, copyFileSync, readFileSync } from "node:fs";
import { createRequire } from "node:module";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { parseArgs } from "node:util";
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
function hostTarget() {
const os = process.platform === "win32" ? "windows" : process.platform;
// Node linked against glibc reports its version; musl builds don't.
const musl =
process.platform === "linux" && !process.report.getReport().header.glibcVersionRuntime;
return `bun-${os}-${process.arch}${musl ? "-musl" : ""}`;
}
const { values } = parseArgs({
options: {
target: { type: "string", default: hostTarget() },
outfile: { type: "string" },
},
});
const target = values.target;
// Bun targets look like `bun-<os>-<arch>` with optional `-musl`, `-baseline` or
// `-modern` suffixes; the SDK names its packages `<platform>-<arch>[-musl]`.
const match = /^bun-(linux|darwin|windows)-(x64|arm64|aarch64)(?:-.+)?$/.exec(target);
if (!match) {
console.error(
`Unsupported target "${target}". Expected e.g. bun-linux-x64, bun-darwin-arm64, bun-windows-x64.`,
);
process.exit(1);
}
const [, os, rawArch] = match;
const platform = os === "windows" ? "win32" : os;
const arch = rawArch === "aarch64" ? "arm64" : rawArch;
const musl = target.split("-").includes("musl");
const exe = os === "windows" ? ".exe" : "";
const cliPackage = `@anthropic-ai/claude-agent-sdk-${platform}-${arch}${musl ? "-musl" : ""}`;
const cliSpecifier = `${cliPackage}/claude${exe}`;
const outfile = resolve(values.outfile ?? join(repoRoot, "dist-bin", `claude-agent-acp${exe}`));
// Resolve from the SDK, the way claudeCliPath() does at runtime, so a nested
// install is checked rather than only a hoisted one.
const sdkRequire = createRequire(
createRequire(join(repoRoot, "package.json")).resolve("@anthropic-ai/claude-agent-sdk"),
);
let cliPath;
try {
cliPath = sdkRequire.resolve(cliSpecifier);
} catch {
const sdkVersion = JSON.parse(
readFileSync(
join(dirname(sdkRequire.resolve("@anthropic-ai/claude-agent-sdk")), "package.json"),
"utf8",
),
).version;
console.error(
`${cliSpecifier} is not installed. npm only installs the host's platform package; for ${target} run:\n\n` +
` npm install --no-save --force ${cliPackage}@${sdkVersion}\n`,
);
process.exit(1);
}
const bun = spawnSync(
"bun",
["build", "src/binary-entry.ts", "--compile", "--target", target, "--outfile", outfile],
{ cwd: repoRoot, stdio: "inherit", shell: process.platform === "win32" },
);
if (bun.error) {
console.error(`Could not run bun: ${bun.error.message}. Install it from https://bun.sh.`);
process.exit(1);
}
if (bun.status !== 0) process.exit(bun.status ?? 1);
// The executable runs the CLI from its own directory (src/binary-entry.ts).
const cliOutfile = join(dirname(outfile), `claude${exe}`);
copyFileSync(cliPath, cliOutfile);
chmodSync(cliOutfile, 0o755);
console.log(`Copied ${cliSpecifier} to ${cliOutfile}`);

470
scripts/smoke-binary.mjs Normal file
View File

@@ -0,0 +1,470 @@
#!/usr/bin/env node
// Smoke-tests a built agent over ACP. Defaults to the executable from
// scripts/build-binary.mjs, with `claude` next to it. Pass another command
// after `--` to compare, e.g.
// `node scripts/smoke-binary.mjs --full -- node "$PWD/dist/index.js"` (the full
// run starts the agent in a scratch directory, so paths must be absolute).
//
// node scripts/smoke-binary.mjs # no login needed
// node scripts/smoke-binary.mjs --full # talks to the model; needs a logged-in Claude
//
// SMOKE_DEBUG=1 prints every tool call the full run saw.
//
// The basic run checks `--version`, `--cli --version`, `initialize` (v1 and
// v2) and the `terminal-auth` login command. The
// full run also has Claude use Bash, Read (text and image), Edit and a
// subagent in a scratch directory, then cancels a turn, lists the session,
// loads it in a fresh agent process, and forks and deletes it.
//
// Claude Code's native builds have no Grep or Glob tool: its Bash shell wraps
// `grep` and `find` in functions that run the ugrep and bfs built into the
// Claude binary at `$CLAUDE_CODE_EXECPATH`. The full run checks that those
// run, since they are the helpers a packaged build could fail to reach.
import { spawn, spawnSync } from "node:child_process";
import {
copyFileSync,
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
symlinkSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import readline from "node:readline";
import { fileURLToPath } from "node:url";
import { parseArgs } from "node:util";
import { deflateSync } from "node:zlib";
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const { values, positionals } = parseArgs({
options: { full: { type: "boolean", default: false } },
allowPositionals: true,
});
const exe = process.platform === "win32" ? ".exe" : "";
const [command, ...commandArgs] =
positionals.length > 0 ? positionals : [join(repoRoot, "dist-bin", `claude-agent-acp${exe}`)];
let failures = 0;
function check(ok, label, detail) {
console.log(
`${ok ? "ok " : "FAIL"} ${label}${!ok && detail !== undefined ? `: ${detail}` : ""}`,
);
if (!ok) failures++;
return ok;
}
// ---------------------------------------------------------------- basic
function runSync(args, [cmd, ...cmdArgs] = [command, ...commandArgs]) {
const result = spawnSync(cmd, [...cmdArgs, ...args], {
encoding: "utf8",
timeout: 60_000,
});
return { stdout: (result.stdout ?? "").trim(), error: result.error ?? result.stderr };
}
const version = JSON.parse(readFileSync(join(repoRoot, "package.json"), "utf8")).version;
const own = runSync(["--version"]);
check(own.stdout === version, `--version prints ${version}`, own.stdout || own.error);
const cli = runSync(["--cli", "--version"]);
check(
/\(Claude Code\)$/.test(cli.stdout),
"--cli --version runs the bundled Claude CLI",
cli.stdout || cli.error,
);
// The release archive's layout: the executable finds `claude` in the directory
// of its real path. Only for the default executable; a `--` command has no
// such layout.
if (positionals.length === 0) {
const scratch = mkdtempSync(join(tmpdir(), "acp-layout-"));
try {
if (process.platform !== "win32") {
const link = join(scratch, "claude-agent-acp");
symlinkSync(command, link);
const linked = runSync(["--cli", "--version"], [link]);
check(
/\(Claude Code\)$/.test(linked.stdout),
"a symlink to the executable finds the bundled CLI",
linked.stdout || linked.error,
);
rmSync(link);
}
const alone = join(scratch, `claude-agent-acp${exe}`);
copyFileSync(command, alone);
const missing = runSync(["--cli", "--version"], [alone]);
check(
String(missing.error).includes("The Claude CLI is missing"),
"without claude next to it, says the CLI is missing",
missing.stdout || missing.error,
);
} finally {
rmSync(scratch, { recursive: true, force: true });
}
}
/** Starts the agent and speaks newline-delimited JSON-RPC to it. */
function startAgent(cwd, onNotification) {
const child = spawn(command, commandArgs, { cwd, stdio: ["pipe", "pipe", "pipe"] });
const stderr = [];
child.stderr.on("data", (chunk) => stderr.push(chunk));
let nextId = 0;
const pending = new Map();
const write = (message) =>
child.stdin.write(JSON.stringify({ jsonrpc: "2.0", ...message }) + "\n");
readline.createInterface({ input: child.stdout }).on("line", (line) => {
let message;
try {
message = JSON.parse(line);
} catch {
check(false, "stdout carries only JSON-RPC", line.slice(0, 200));
return;
}
if (message.method === undefined && pending.has(message.id)) {
pending.get(message.id)(message);
pending.delete(message.id);
} else if (message.method === "session/request_permission") {
const allow =
message.params.options.find((o) => o.kind === "allow_once") ?? message.params.options[0];
write({
id: message.id,
result: { outcome: { outcome: "selected", optionId: allow.optionId } },
});
} else if (message.id !== undefined && message.method !== undefined) {
write({
id: message.id,
error: { code: -32601, message: `smoke client does not handle ${message.method}` },
});
} else {
onNotification?.(message);
}
});
const exited = new Promise((resolve) => child.on("exit", resolve));
// Answer anything still pending if the agent dies, instead of hanging.
let exitError;
exited.then((code) => {
exitError = { message: `agent exited with ${code}: ${Buffer.concat(stderr)}` };
for (const resolve of pending.values()) resolve({ error: exitError });
pending.clear();
});
return {
request(method, params) {
const id = ++nextId;
return new Promise((resolve) => {
if (exitError) return resolve({ error: exitError });
pending.set(id, resolve);
write({ id, method, params });
});
},
notify: (method, params) => write({ method, params }),
async stop() {
child.stdin.end();
const code = await Promise.race([
exited,
new Promise((r) => setTimeout(() => r("timeout"), 10_000)),
]);
if (code === "timeout") child.kill();
return { code, stderr: Buffer.concat(stderr).toString() };
},
};
}
const initializeParams = { protocolVersion: 1, clientCapabilities: {} };
{
const agent = startAgent(process.cwd());
const init = await agent.request("initialize", initializeParams);
check(
init.result?.agentInfo?.version === version,
"initialize answers",
JSON.stringify(init.error),
);
const { code } = await agent.stop();
check(code === 0, "exits 0 when stdin closes", code);
}
{
// The `terminal-auth` login command reruns the agent; run it with
// `--version` in place of `auth login --console` instead of logging in.
// `--version` wins over a stray argument, so also check that no path inside
// a compiled executable leaked into the arguments.
const agent = startAgent(process.cwd());
const init = await agent.request("initialize", {
protocolVersion: 1,
clientCapabilities: { _meta: { "terminal-auth": true } },
});
await agent.stop();
const login = init.result?.authMethods?.find((m) => m.id === "console-login")?._meta?.[
"terminal-auth"
];
const cliArgs = login?.args?.slice(0, login.args.indexOf("--cli") + 1);
const rerun = login
? runSync([...cliArgs, "--version"], [login.command])
: { stdout: "", error: "no terminal-auth console-login method" };
check(
/\(Claude Code\)$/.test(rerun.stdout) &&
!login.args.some((arg) => arg.includes("$bunfs") || arg.includes("~BUN")),
"the terminal-auth login command reruns the agent",
`${JSON.stringify(login)} -> ${rerun.stdout || rerun.error}`,
);
}
{
const agent = startAgent(process.cwd());
const init = await agent.request("initialize", {
protocolVersion: 2,
info: { name: "smoke-binary", version },
capabilities: {},
});
check(
init.result?.protocolVersion === 2,
"initialize answers ACP v2",
JSON.stringify(init.error),
);
await agent.stop();
}
// ---------------------------------------------------------------- full
if (values.full) {
const work = mkdtempSync(join(tmpdir(), "acp-smoke-"));
try {
await fullSmoke(work);
} finally {
rmSync(work, { recursive: true, force: true });
}
}
/** A solid red 16x16 PNG, so the model has something to name. */
function redPng() {
const crcTable = Array.from({ length: 256 }, (_, n) => {
let c = n;
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
return c >>> 0;
});
const crc = (buf) => {
let c = 0xffffffff;
for (const byte of buf) c = crcTable[(c ^ byte) & 0xff] ^ (c >>> 8);
return (c ^ 0xffffffff) >>> 0;
};
const chunk = (type, data) => {
const body = Buffer.concat([Buffer.from(type), data]);
const out = Buffer.alloc(body.length + 8);
out.writeUInt32BE(data.length, 0);
body.copy(out, 4);
out.writeUInt32BE(crc(body), body.length + 4);
return out;
};
const size = 16;
const header = Buffer.alloc(13);
header.writeUInt32BE(size, 0);
header.writeUInt32BE(size, 4);
header[8] = 8; // bit depth
header[9] = 2; // RGB
const row = Buffer.concat([Buffer.from([0]), Buffer.alloc(size * 3, Buffer.from([255, 0, 0]))]);
return Buffer.concat([
Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]),
chunk("IHDR", header),
chunk("IDAT", deflateSync(Buffer.concat(Array(size).fill(row)))),
chunk("IEND", Buffer.alloc(0)),
]);
}
/** Writes its results to files so the checks don't depend on the model's reply. */
function searchCommand(token) {
return [
`grep -rl "${token}" . > grep.out`,
'find . -name "*.md" > find.out',
'{ echo "$CLAUDE_CODE_EXECPATH"; grep --version | head -1; find --version | head -1; } > tools.out',
].join("; ");
}
function searchResults(work) {
const read = (name) => {
try {
return readFileSync(join(work, name), "utf8");
} catch (error) {
return String(error);
}
};
const [execPath = "", grepVersion = "", findVersion = ""] = read("tools.out").split("\n");
return { grep: read("grep.out"), find: read("find.out"), execPath, grepVersion, findVersion };
}
async function fullSmoke(work) {
const token = `SMOKE_TOKEN_${process.pid}`;
mkdirSync(join(work, "notes"));
mkdirSync(join(work, "src"));
writeFileSync(join(work, "notes", "alpha.md"), `# Alpha\n\nThe token is ${token}.\n`);
writeFileSync(join(work, "notes", "beta.md"), "# Beta\n\nNothing here.\n");
writeFileSync(join(work, "src", "gamma.ts"), `export const value = "${token}";\n`);
writeFileSync(join(work, "pixel.png"), redPng());
const toolCalls = new Map();
let text = "";
const onNotification = (message) => {
if (message.method !== "session/update") return;
const update = message.params.update;
if (update.sessionUpdate === "agent_message_chunk" && update.content.type === "text") {
text += update.content.text;
} else if (
update.sessionUpdate === "tool_call" ||
update.sessionUpdate === "tool_call_update"
) {
const call = toolCalls.get(update.toolCallId) ?? {};
toolCalls.set(update.toolCallId, {
...call,
name: update._meta?.claudeCode?.toolName ?? call.name,
status: update.status ?? call.status,
title: update.title ?? call.title,
});
}
};
const agent = startAgent(work, onNotification);
await agent.request("initialize", initializeParams);
const created = await agent.request("session/new", { cwd: work, mcpServers: [] });
if (!check(created.result?.sessionId, "session/new", JSON.stringify(created.error))) {
console.error((await agent.stop()).stderr);
return;
}
const sessionId = created.result.sessionId;
const started = Date.now();
const prompt = await agent.request("session/prompt", {
sessionId,
prompt: [
{
type: "text",
text: [
"This is an automated smoke test of your tools. Do each step with the named tool, in order, one tool call per step:",
`1. Bash: run exactly \`${searchCommand(token)}\``,
"2. Read: read notes/alpha.md.",
"3. Read: read pixel.png and note its color.",
`4. Edit: in notes/alpha.md replace "${token}" with "EDITED".`,
"5. Bash: run `cat notes/alpha.md`.",
'6. Agent: launch a subagent (subagent_type "Explore") that counts the .ts files under src/ and reports the number.',
"Then reply with one line: SMOKE-DONE color=<the color of pixel.png> tsfiles=<the subagent's count>.",
].join("\n"),
},
],
});
check(
prompt.result?.stopReason === "end_turn",
"prompt ends with end_turn",
JSON.stringify(prompt.result ?? prompt.error),
);
console.log(` (turn took ${Math.round((Date.now() - started) / 1000)}s)`);
const calls = [...toolCalls.values()];
if (process.env.SMOKE_DEBUG) console.log(JSON.stringify(calls, null, 1));
const searched = searchResults(work);
check(
searched.grep.includes("alpha.md") && searched.grep.includes("gamma.ts"),
"grep found the token",
searched.grep,
);
check(
searched.find.includes("alpha.md") && searched.find.includes("beta.md"),
"find listed the .md files",
searched.find,
);
check(
/^ugrep /.test(searched.grepVersion),
"grep ran Claude's embedded ugrep",
searched.grepVersion,
);
check(/^bfs /.test(searched.findVersion), "find ran Claude's embedded bfs", searched.findVersion);
console.log(` (CLAUDE_CODE_EXECPATH=${searched.execPath})`);
for (const name of ["Read", "Edit", "Bash"]) {
const used = calls.filter((c) => c.name === name);
check(
used.length > 0 && used.every((c) => c.status === "completed"),
`${name} ran and completed`,
JSON.stringify(used),
);
}
const subagent = calls.filter((c) => c.name === "Agent" || c.name === "Task");
check(
subagent.some((c) => c.status === "completed"),
"subagent ran and completed",
JSON.stringify(subagent),
);
const failed = calls.filter((c) => c.status === "failed");
check(failed.length === 0, "no tool call failed", JSON.stringify(failed));
check(
readFileSync(join(work, "notes", "alpha.md"), "utf8").includes("EDITED"),
"Edit changed the file on disk",
);
check(
/SMOKE-DONE/.test(text) && /color=\W*red/i.test(text),
"image read as red",
text.slice(-300),
);
check(/tsfiles=\W*1\b/.test(text), "subagent counted 1 .ts file", text.slice(-300));
// A cancel interrupts a running Bash command and ends the turn as cancelled.
const cancelled = agent.request("session/prompt", {
sessionId,
prompt: [
{ type: "text", text: "Use the Bash tool to run `sleep 60`. Do not run anything else." },
],
});
const deadline = Date.now() + 60_000;
const bashRunning = () =>
[...toolCalls.values()].some((c) => c.name === "Bash" && c.status !== "completed");
while (!bashRunning() && Date.now() < deadline) {
await new Promise((r) => setTimeout(r, 200));
}
agent.notify("session/cancel", { sessionId });
const cancelResult = await cancelled;
check(
cancelResult.result?.stopReason === "cancelled",
"session/cancel ends the turn as cancelled",
JSON.stringify(cancelResult.result ?? cancelResult.error),
);
const listed = await agent.request("session/list", { cwd: work });
check(
listed.result?.sessions?.some((s) => s.sessionId === sessionId),
"session/list finds the session",
JSON.stringify(listed.result ?? listed.error),
);
const { code, stderr } = await agent.stop();
check(code === 0, "exits 0 when stdin closes", code);
if (failures > 0) console.error(stderr);
// A fresh process, so the replay comes from the transcript on disk.
let replayed = "";
const reloaded = startAgent(work, (message) => {
const update = message.params?.update;
if (update?.sessionUpdate === "user_message_chunk" && update.content.type === "text")
replayed += update.content.text;
});
await reloaded.request("initialize", initializeParams);
const loaded = await reloaded.request("session/load", { sessionId, cwd: work, mcpServers: [] });
check(!loaded.error, "session/load in a new process", JSON.stringify(loaded.error));
check(replayed.includes(token), "session/load replays the first prompt", replayed.slice(0, 200));
// Fork and delete go through the SDK's transcript helpers, not the CLI.
const forked = await reloaded.request("session/fork", { sessionId, cwd: work, mcpServers: [] });
const forkId = forked.result?.sessionId;
check(
forkId && forkId !== sessionId,
"session/fork",
JSON.stringify(forked.result ?? forked.error),
);
if (forkId) {
const deleted = await reloaded.request("session/delete", { sessionId: forkId });
const after = await reloaded.request("session/list", { cwd: work });
check(
!deleted.error && !after.result?.sessions?.some((s) => s.sessionId === forkId),
"session/delete removes the fork",
JSON.stringify(deleted.error ?? after.result),
);
}
await reloaded.stop();
}
console.log(failures === 0 ? "\nsmoke test passed" : `\n${failures} check(s) failed`);
process.exit(failures === 0 ? 0 : 1);

File diff suppressed because it is too large Load Diff

43
src/acp-client.ts Normal file
View File

@@ -0,0 +1,43 @@
import type {
CompleteElicitationNotification,
CreateElicitationRequest,
CreateElicitationResponse,
ReadTextFileRequest,
ReadTextFileResponse,
RequestPermissionResponse,
WriteTextFileRequest,
WriteTextFileResponse,
} from "@agentclientprotocol/sdk/unstable";
import type { AcpSessionNotification } from "./acp-subagents.js";
import type { AcpPermissionRequest } from "./permissions/presentation.js";
/**
* Client-facing surface the agent calls back into. This is the subset of ACP
* client methods the agent actually uses, expressed as a narrow interface so
* tests can supply lightweight mocks. In the v1 agent it is backed by
* `ClientConnection` (`acp-agent.ts`) over the SDK's typed `AgentContext`.
* The v2 session output passes one that only takes `sessionUpdate`
* (`v2/stream.ts`).
*/
export interface AcpClient {
sessionUpdate(params: AcpSessionNotification): Promise<void>;
/** `signal`, when aborted, sends `$/cancel_request` for the in-flight
* permission request so the client can dismiss its prompt, and rejects the
* call at once: a client that ignores the cancel never answers. */
requestPermission(
params: AcpPermissionRequest,
signal?: AbortSignal,
): Promise<RequestPermissionResponse>;
readTextFile(params: ReadTextFileRequest): Promise<ReadTextFileResponse>;
writeTextFile(params: WriteTextFileRequest): Promise<WriteTextFileResponse>;
/** `signal`, when aborted, sends `$/cancel_request` for the in-flight
* elicitation so the client can dismiss its prompt, and rejects the call
* at once, as {@link requestPermission} does. */
createElicitation(
params: CreateElicitationRequest,
signal?: AbortSignal,
): Promise<CreateElicitationResponse>;
completeElicitation(params: CompleteElicitationNotification): Promise<void>;
/** Send a custom (extension) notification, e.g. `_claude/sdkMessage`. */
extNotification(method: string, params: Record<string, unknown>): Promise<void>;
}

View File

@@ -2,21 +2,37 @@ import type {
ClientCapabilities,
SessionCapabilities,
SessionNotification,
} from "@agentclientprotocol/sdk";
} from "@agentclientprotocol/sdk/unstable";
import {
AIR_NATIVE_SUBAGENT_SESSIONS_CAPABILITY,
clientSupportsAirCapability,
isAirClient,
} from "./air-extension.js";
export { AIR_NATIVE_SUBAGENT_SESSIONS_CAPABILITY } from "./air-extension.js";
/**
* Temporary typed surface for agentclientprotocol/agent-client-protocol#1992.
* How a client gets native subagent sessions.
*
* The wire contract is already defined by the ACP draft, but the published
* TypeScript SDK does not contain it yet. Keep the compatibility boundary in
* this file so it can be replaced by SDK exports without changing lifecycle
* code when the draft ships.
* - `rfd`: the subagents RFD (agentclientprotocol/agent-client-protocol#1992,
* as revised): `subagent_update` and `session_message`, which the SDK types.
* A client that declares `clientCapabilities.subagents` and is not AIR gets
* it.
* - `air`: the earlier draft of that RFD, which AIR implements:
* `subagent_spawned` and `subagent_state_update`, typed below. AIR gets it,
* whichever capability it declares (`docs/air-extensions.md`).
*/
export type SubagentForm = "rfd" | "air";
/** The form of native subagent sessions that the client gets, or none. */
export function subagentForm(capabilities?: ClientCapabilities | null): SubagentForm | undefined {
if (!clientSupportsSubagents(capabilities)) return undefined;
return isAirClient(capabilities) ? "air" : "rfd";
}
/**
* The earlier draft of the subagents RFD, which AIR implements and the SDK
* does not type.
*/
export type SubagentSessionCapabilities = {
cancel?: boolean;
@@ -116,7 +132,7 @@ export function clientSupportsSubagents(capabilities?: ClientCapabilities | null
return clientSupportsAirCapability(capabilities, AIR_NATIVE_SUBAGENT_SESSIONS_CAPABILITY);
}
/** The only cast needed until the TypeScript SDK publishes PR #1992. */
/** The cast that AIR's draft updates and the async task updates need. */
export function asSdkSessionNotification(
notification: AcpSessionNotification,
): SessionNotification {

19
src/agent-command.ts Normal file
View File

@@ -0,0 +1,19 @@
/**
* The command that starts this agent again with the flags it was started with,
* for a client to run in a terminal (e.g. `terminal-auth` login).
*
* Under Node, `process.argv` is `[node, script, ...flags]`, so the command is
* `node script ...flags`. In a `bun build --compile` executable it is
* `["bun", "/$bunfs/root/<name>", ...flags]` (`B:\~BUN\root\<name>` on
* Windows): `process.execPath` is the executable itself and `argv[1]` names a
* file inside it, which must not be passed back as an argument.
*/
export function agentCommand(argv: readonly string[] = process.argv): {
command: string;
args: string[];
} {
const [, script, ...flags] = argv;
if (script === undefined) return { command: process.execPath, args: [] };
const compiled = script.includes("$bunfs") || script.includes("~BUN");
return { command: process.execPath, args: compiled ? flags : [script, ...flags] };
}

View File

@@ -1,5 +1,10 @@
export const AIR_NATIVE_SUBAGENT_SESSIONS_CAPABILITY = "nativeSubagentSessions";
export const AIR_ASYNC_TASKS_CAPABILITY = "asyncTasks";
/**
* A prompt turn ends at its result also while its background subagents run.
* The subagent updates and requests then go out after the prompt response.
*/
export const AIR_BACKGROUND_SUBAGENTS_CAPABILITY = "backgroundSubagents";
export const AIR_SESSION_FAILURE_CAPABILITY = "sessionFailure";
export const AIR_RECOMMENDED_CONFIG_VALUE_CAPABILITY = "recommendedValue";
export const AIR_CUSTOM_INSTRUCTIONS_CAPABILITY = "customInstructions";
@@ -17,6 +22,9 @@ export const AIR_PLAN_FILE_CAPABILITY = "planFile";
* subscription. Advertised only to a client that declares it.
*/
export const AIR_SESSION_INDEX_CAPABILITY = "sessionIndex";
/** Metadata lookup of known ids with `_session/batch_get`. Advertised
* exactly when {@link AIR_SESSION_INDEX_CAPABILITY} is negotiated. */
export const AIR_SESSION_BATCH_GET_CAPABILITY = "sessionBatchGet";
/**
* The agent supports `_session/archive` and `_session/unarchive`. Advertised
* exactly when {@link AIR_SESSION_INDEX_CAPABILITY} is; the client does not
@@ -34,11 +42,26 @@ export const AIR_SESSION_RENAME_CAPABILITY = "sessionRename";
* {@link AIR_SESSION_INDEX_CAPABILITY} is; the client does not declare it.
*/
export const AIR_SESSION_LIST_SUBSCRIBE_CAPABILITY = "sessionListSubscribe";
/**
* Session index rows carry `agentWorktree: true` when the session runs in a
* linked git worktree that Claude Code made for it. Advertised exactly when
* {@link AIR_SESSION_INDEX_CAPABILITY} is; the client does not declare it.
*/
export const AIR_SESSION_WORKTREES_CAPABILITY = "sessionWorktrees";
/**
* The client wants to know when the history replay of a loaded session is
* whole. After the last replayed update, the agent sends one
* `session_info_update` with `_meta.jetbrains.air.loadReplayEnd: true` (see
* {@link AIR_LOAD_REPLAY_END_KEY}). The client declares it; the agent does not
* advertise it.
*/
export const AIR_LOAD_REPLAY_END_CAPABILITY = "loadReplayEnd";
/** The `_meta.jetbrains.air` keys that the ACP tool call contract defines. */
export const AIR_COMMAND_TITLE_KEY = "commandTitle";
export const AIR_SUBAGENT_KEY = "subagent";
export const AIR_SKILL_KEY = "skill";
export const AIR_MCP_KEY = "mcp";
export const AIR_CONTEXT_COMPACTION_KEY = "contextCompaction";
export const AIR_GOAL_KEY = "goal";
export const AIR_KIND_KEY = "kind";
@@ -46,6 +69,8 @@ export const AIR_KIND_KEY = "kind";
export const AIR_SKILL_PATH_KEY = "skillPath";
export const AIR_PERMISSION_KEY = "permission";
export const AIR_CUSTOM_ANSWER_KEY = "customAnswer";
/** The `_meta.jetbrains.air` key of the update that ends a whole history replay. */
export const AIR_LOAD_REPLAY_END_KEY = "loadReplayEnd";
/**
* The root `_meta` spelling of {@link AIR_CUSTOM_ANSWER_KEY} that released
* AIR versions read. Remove it when the ACP registry no longer serves AIR

View File

@@ -1,4 +1,5 @@
import type { ClientCapabilities } from "@agentclientprotocol/sdk";
import { randomUUID } from "node:crypto";
import type { ClientCapabilities } from "@agentclientprotocol/sdk/unstable";
import type { AcpSessionNotification, AsyncTaskState } from "./acp-subagents.js";
import { AIR_ASYNC_TASKS_CAPABILITY, clientSupportsAirCapability } from "./air-extension.js";
import { noticeOrTranscriptUpdate } from "./session-notices.js";
@@ -14,6 +15,8 @@ type AsyncTask = {
taskType: string;
description: string;
showInTranscript: boolean;
/** A Monitor tool call or an SDK `local_monitor` task: published as a monitor the client cannot stop. */
monitor: boolean;
outputFilePath?: string;
toolCallId?: string;
/**
@@ -39,6 +42,12 @@ type AsyncTask = {
terminalSource?: TerminalSource;
/** The optional fields that the client holds now, as JSON. */
published: Map<string, string>;
/**
* Whether the task stands for a model turn that Claude Code started on its
* own, see {@link AsyncTaskRuntime.autonomousTurnStarted}. The SDK knows no
* such task, so its background task list never ends it.
*/
autonomousTurn: boolean;
};
type TaskIdentity = { taskId?: unknown; task_id?: unknown };
@@ -96,6 +105,28 @@ type TaskProgress = TaskIdentity & {
output_file?: unknown;
};
/** The display fields of a task progress report, shared by AIR and v2 cards. */
export type TaskProgressFields = {
description?: string;
summary?: string;
lastToolName?: string;
usage?: { totalTokens: number; toolUses: number; durationMs: number };
};
/** Reads the display fields supplied by a task progress report. */
export function taskProgressFields(message: TaskProgress): TaskProgressFields {
const usage = taskUsage(message.usage);
const description = nonBlankString(message.description);
const summary = nonBlankString(message.summary);
const lastToolName = nonBlankString(field(message, "lastToolName", "last_tool_name"));
return {
...(description ? { description } : {}),
...(summary ? { summary } : {}),
...(lastToolName ? { lastToolName } : {}),
...(usage ? { usage } : {}),
};
}
type BackgroundTaskLevel = TaskIdentity & {
taskType?: unknown;
task_type?: unknown;
@@ -121,6 +152,10 @@ export class AsyncTaskRuntime {
* The oldest entries are dropped.
*/
private readonly outputPathsByToolCall = new Map<string, string[]>();
/** The task of the autonomous turn that runs now. */
private autonomousTurn?: AsyncTask;
/** The summary of the last task notification, which usually starts the next autonomous turn. */
private lastNotificationSummary?: string;
constructor(
readonly enabled: boolean,
@@ -131,7 +166,8 @@ export class AsyncTaskRuntime {
* the tasks that a tool call in another session started, for example in
* a native subagent session. `toolNameOf`: the name of the tool of a tool
* call, or `undefined` when the tool call is unknown. A task that a
* `Monitor` tool call started is ignored. */
* `Monitor` tool call started is published as a monitor: the SDK reports
* it as `local_bash`, so the adapter names it a monitor. */
private readonly options: {
notices?: boolean;
routeOf?: (toolCallId: string) => Route | undefined;
@@ -144,7 +180,7 @@ export class AsyncTaskRuntime {
const taskId = taskIdOf(message);
if (!taskId) return;
const task = this.task(taskId);
if (isSubagentTask(message) || isMonitorTask(message)) {
if (isSubagentTask(message)) {
this.ignore(task);
return;
}
@@ -175,7 +211,7 @@ export class AsyncTaskRuntime {
const taskId = taskIdOf(message);
if (!taskId) return;
const task = this.task(taskId);
if (isSubagentTask(message) || isMonitorTask(message)) {
if (isSubagentTask(message)) {
this.ignore(task);
return;
}
@@ -253,15 +289,8 @@ export class AsyncTaskRuntime {
return;
}
const usage = taskUsage(message.usage);
const description = nonBlankString(message.description);
const summary = nonBlankString(message.summary);
const lastToolName = nonBlankString(field(message, "lastToolName", "last_tool_name"));
const progress = {
...(description ? { description } : {}),
...(summary ? { summary } : {}),
...(lastToolName ? { lastToolName } : {}),
...(usage ? { usage } : {}),
...taskProgressFields(message),
...(outputChanged && task.outputFilePath ? { outputFilePath: task.outputFilePath } : {}),
...(task.toolCallId !== previousToolCallId && task.toolCallId
? { toolCallId: task.toolCallId }
@@ -300,6 +329,7 @@ export class AsyncTaskRuntime {
const state = taskState(message.status);
if (!taskId || !state || state === "running" || state === "paused") return;
this.lastNotificationSummary = nonBlankString(message.summary) ?? this.lastNotificationSummary;
const task = this.task(taskId);
const wasTerminal = isTerminal(task.state);
const correctsLevelState = wasTerminal && task.terminalSource === "level";
@@ -343,7 +373,7 @@ export class AsyncTaskRuntime {
const task = this.task(taskId);
if (task.ignored || isTerminal(task.state)) continue;
const levelTaskType = field(item, "taskType", "task_type");
if (levelTaskType === "local_agent" || levelTaskType === "local_monitor") {
if (levelTaskType === "local_agent") {
this.ignore(task);
continue;
}
@@ -363,6 +393,7 @@ export class AsyncTaskRuntime {
if (
(task.announced || task.held) &&
!task.ignored &&
!task.autonomousTurn &&
!isTerminal(task.state) &&
!live.has(task.id)
) {
@@ -378,6 +409,7 @@ export class AsyncTaskRuntime {
}
async finishAll(state: Extract<AsyncTaskState, "failed" | "stopped">): Promise<void> {
this.autonomousTurn = undefined;
const errors: unknown[] = [];
for (const task of this.tasks.values()) {
if ((task.announced || task.held) && !task.ignored && !isTerminal(task.state)) {
@@ -394,7 +426,13 @@ export class AsyncTaskRuntime {
canStop(taskId: string): boolean {
const task = this.tasks.get(taskId);
return task?.announced === true && !task.ignored && !task.stopping && !isTerminal(task.state);
return (
task?.announced === true &&
!task.monitor &&
!task.ignored &&
!task.stopping &&
!isTerminal(task.state)
);
}
claimStop(taskId: string): boolean {
@@ -412,6 +450,7 @@ export class AsyncTaskRuntime {
const task = this.tasks.get(taskId);
if (!task || !task.announced || task.ignored || task.stopAnnounced) return;
task.stopAnnounced = true;
if (this.autonomousTurn === task) this.autonomousTurn = undefined;
// No terminal summary: a stopped task leaves the Async Tasks panel at once,
// so anything said there is said to nobody.
await this.finish(task, "stopped", undefined, "event");
@@ -438,6 +477,54 @@ export class AsyncTaskRuntime {
);
}
/**
* Publishes a model turn that Claude Code started with no prompt open, for
* example to answer a task notification. ACP v1 has no session state, and
* the client already got the stop reason of its last prompt, so this task is
* the only sign that the agent works. The tool calls of the turn are in the
* transcript already, so the task is not shown there.
*
* [cause] names what started the turn when Claude Code says so, for example
* "A scheduled wakeup fired." It replaces the description of a task that is
* already open; without it, an open task is left as it is.
*/
async autonomousTurnStarted(cause?: string): Promise<void> {
if (!this.enabled) return;
if (this.autonomousTurn) {
if (cause) await this.publishProgress(this.autonomousTurn, { description: cause });
return;
}
const task = this.task(`autonomous-turn-${randomUUID()}`);
task.autonomousTurn = true;
task.startedObserved = true;
task.name = "Claude is working";
task.taskType = "turn";
task.description =
cause ?? this.lastNotificationSummary ?? "Claude Code started a turn without a prompt.";
task.showInTranscript = false;
this.lastNotificationSummary = undefined;
this.autonomousTurn = task;
await this.announce(task, { withoutToolCall: true });
}
/** Whether the task of an autonomous turn is open. */
get autonomousTurnOpen(): boolean {
return this.autonomousTurn !== undefined;
}
/** Ends the task of the autonomous turn that runs now, if there is one. */
async autonomousTurnEnded(state: "completed" | "failed"): Promise<void> {
const task = this.autonomousTurn;
if (!task) return;
this.autonomousTurn = undefined;
await this.finish(task, state, undefined, "event");
}
/** Whether the task stands for an autonomous turn, which a stop interrupts. */
isAutonomousTurn(taskId: string): boolean {
return this.tasks.get(taskId)?.autonomousTurn === true;
}
/** Whether a task of the tool call went to the background, so the client gets it. */
isBackgroundedToolCall(toolCallId: string): boolean {
for (const task of this.tasks.values()) {
@@ -488,6 +575,8 @@ export class AsyncTaskRuntime {
clear(): void {
this.tasks.clear();
this.outputPathsByToolCall.clear();
this.autonomousTurn = undefined;
this.lastNotificationSummary = undefined;
}
/**
@@ -510,6 +599,7 @@ export class AsyncTaskRuntime {
taskType: "task",
description: "Background task",
showInTranscript: true,
monitor: false,
announced: false,
held: false,
deferred: [],
@@ -520,6 +610,7 @@ export class AsyncTaskRuntime {
stopAnnounced: false,
state: "running",
published: new Map(),
autonomousTurn: false,
};
this.tasks.set(taskId, task);
return task;
@@ -529,6 +620,7 @@ export class AsyncTaskRuntime {
task.startedObserved = true;
const rawTaskType = field(message, "taskType", "task_type");
if (rawTaskType !== undefined) task.taskType = friendlyTaskType(rawTaskType);
if (rawTaskType === "local_monitor") task.monitor = true;
const description = nonBlankString(message.description);
if (description) task.description = description;
else if (task.description === "Background task")
@@ -586,6 +678,7 @@ export class AsyncTaskRuntime {
private mergeLevel(task: AsyncTask, level: BackgroundTaskLevel): void {
const rawTaskType = field(level, "taskType", "task_type");
if (rawTaskType !== undefined) task.taskType = friendlyTaskType(rawTaskType);
if (rawTaskType === "local_monitor") task.monitor = true;
const description = nonBlankString(level.description);
if (description) {
task.description = description;
@@ -610,25 +703,29 @@ export class AsyncTaskRuntime {
* ends first, or when the prompt result ends the model turn
* ({@link releaseHeld}). No guess by the command text binds a task.
*
* A task that a `Monitor` tool call started is ignored: Monitor streams its
* output to the model only, so the task has nothing for the client. The
* tool_use streams before the tool runs, so the tool name is known when the
* spawn has the tool call id.
* A task that a `Monitor` tool call started is published as a monitor. The
* SDK reports it as `local_bash`, so the adapter names it a monitor instead of
* a shell. It has no transcript card because Monitor streams its output to
* the model only, and no Stop because stopping it only ends the agent's wait
* (the agent's own TaskStop still works). The tool_use streams before the
* tool runs, so the tool name is known when the spawn has the tool call id.
*/
private async announce(
task: AsyncTask,
options: { withoutToolCall?: boolean } = {},
): Promise<void> {
if (task.announced || task.ignored) return;
const toolName = task.toolCallId ? this.options.toolNameOf?.(task.toolCallId) : undefined;
if (toolName !== undefined && HIDDEN_TASK_TOOLS.has(toolName)) {
this.ignore(task);
return;
}
if (!task.toolCallId && !options.withoutToolCall && !isTerminal(task.state)) {
task.held = true;
return;
}
if (task.toolCallId && this.options.toolNameOf?.(task.toolCallId) === "Monitor") {
task.monitor = true;
}
if (task.monitor) {
task.taskType = "monitor";
task.showInTranscript = false;
}
if (task.toolCallId) task.route ??= this.options.routeOf?.(task.toolCallId);
await this.send(task, {
sessionUpdate: "async_task_spawned",
@@ -637,7 +734,7 @@ export class AsyncTaskRuntime {
taskType: task.taskType,
description: task.description,
showInTranscript: task.showInTranscript,
canStop: true,
canStop: !task.monitor,
...(task.outputFilePath ? { outputFilePath: task.outputFilePath } : {}),
...(task.toolCallId ? { toolCallId: task.toolCallId } : {}),
});
@@ -781,18 +878,29 @@ function recordPublished(task: AsyncTask, fields: Record<string, unknown>): void
* Recovers a background Bash task from the structured `backgroundTaskId` of
* its tool result. The text of a tool result never gives a task id: a
* foreground command can print any text.
*
* Claude Code sends no `tool_use_result` with a subagent's tool results. For
* those, `startedTaskOf` gives the background `local_bash` task whose
* `task_started` named the Bash call, which comes before the result.
*/
export function backgroundBashTaskFromToolResult(
content: unknown,
toolUseResult: unknown,
toolUses: Record<string, { name: string; input: unknown }>,
): AsyncTaskStarted | undefined {
startedTaskOf?: (toolUseId: string) => string | undefined,
): (AsyncTaskStarted & { taskId: string; toolCallId: string }) | undefined {
if (!Array.isArray(content)) return undefined;
const toolResults = content.flatMap(toolResultBlock);
const bashResults = toolResults.filter((result) => toolUses[result.toolUseId]?.name === "Bash");
if (bashResults.length === 0) return undefined;
const backgroundResults = structuredBackgroundResults(toolUseResult, toolUses);
let backgroundResults = structuredBackgroundResults(toolUseResult, toolUses);
if (backgroundResults.length === 0 && startedTaskOf) {
backgroundResults = bashResults.flatMap((result) => {
const taskId = result.isError ? undefined : startedTaskOf(result.toolUseId);
return taskId ? [{ taskId, toolUseId: result.toolUseId }] : [];
});
}
const distinctTaskIds = new Set(backgroundResults.map((result) => result.taskId));
if (distinctTaskIds.size !== 1) return undefined;
const taskId = [...distinctTaskIds][0];
@@ -985,13 +1093,6 @@ function isBackgroundTask(isBackgrounded: unknown, taskType: unknown): boolean {
return taskType !== "local_bash" && taskType !== "local_agent";
}
/** The tools whose async tasks the client never sees. */
const HIDDEN_TASK_TOOLS = new Set(["Monitor"]);
function isMonitorTask(message: AsyncTaskStarted): boolean {
return field(message, "taskType", "task_type") === "local_monitor";
}
function isSubagentTask(message: AsyncTaskStarted): boolean {
return (
field(message, "subagentType", "subagent_type") !== undefined ||

88
src/auth-methods.ts Normal file
View File

@@ -0,0 +1,88 @@
import type { AuthMethod } from "@agentclientprotocol/sdk/unstable";
import { agentCommand } from "./agent-command.js";
import { shouldHideClaudeAuth } from "./hide-claude-auth.js";
/**
* The terminal login methods the agent advertises, in v1 form.
*
* - `terminal`: the client runs `args` against the agent's own command
* (`auth.terminal` in v1, an `auth.terminal` object in v2).
* - `metaTerminal`: the client reads `_meta["terminal-auth"]`, which names
* the command too.
*
* Without either, the client cannot run a login, and the list is empty. On a
* remote machine (SSH, `NO_BROWSER`, `CLAUDE_CODE_REMOTE`, as the SDK's own
* isRemote check) the OAuth redirect to localhost cannot reach a browser, and
* `claude auth login` falls back to a manual flow that does not work over ACP,
* so the one method runs the TUI's `claude /login` instead. The Claude
* subscription method is left out when `shouldHideClaudeAuth()` says so.
*/
export function terminalAuthMethods({
terminal,
metaTerminal,
}: {
terminal: boolean;
metaTerminal: boolean;
}): AuthMethod[] {
if (!terminal && !metaTerminal) return [];
const withMeta = (method: AuthMethod & { args: string[] }, label: string): AuthMethod => {
if (!metaTerminal) return method;
const agent = agentCommand();
return {
...method,
_meta: {
"terminal-auth": { command: agent.command, args: [...agent.args, ...method.args], label },
},
};
};
const isRemote = !!(
process.env.NO_BROWSER ||
process.env.SSH_CONNECTION ||
process.env.SSH_CLIENT ||
process.env.SSH_TTY ||
process.env.CLAUDE_CODE_REMOTE
);
if (isRemote) {
if (shouldHideClaudeAuth()) return [];
return [
withMeta(
{
description: "Run `claude /login` in the terminal",
name: "Log in with Claude",
id: "claude-login",
type: "terminal",
args: ["--cli"],
},
"Claude Login",
),
];
}
const methods: AuthMethod[] = [];
if (!shouldHideClaudeAuth()) {
methods.push(
withMeta(
{
description: "Use Claude subscription ",
name: "Claude Subscription",
id: "claude-ai-login",
type: "terminal",
args: ["--cli", "auth", "login", "--claudeai"],
},
"Claude Login",
),
);
}
methods.push(
withMeta(
{
description: "Use Anthropic Console (API usage billing)",
name: "Anthropic Console",
id: "console-login",
type: "terminal",
args: ["--cli", "auth", "login", "--console"],
},
"Anthropic Console Login",
),
);
return methods;
}

309
src/auth-status-reporter.ts Normal file
View File

@@ -0,0 +1,309 @@
/**
* The connection's `authStatus` state, shared by the v1 core and the v2
* core: the last identity it reported, the `claude auth status --json` probe,
* and the `_auth/status_update` pushes (see `auth-status.ts` for when each
* core reads the identity).
*/
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { RequestError } from "@agentclientprotocol/sdk/unstable";
import {
type AccountInfo,
filterEscalatingDefaultMode,
type Options,
resolveSettings,
type Settings,
} from "@anthropic-ai/claude-agent-sdk";
import {
AUTH_STATUS_PROBE_TIMEOUT_MS,
type AuthStatus,
type AuthStatusKind,
fromAccountInfo,
fromCliStatus,
mergeAuthStatus,
sameAuthStatus,
} from "./auth-status.js";
import { claudeCliPath } from "./claude-cli.js";
import type { SettingsManager } from "./settings.js";
const execFileAsync = promisify(execFile);
type ReporterLogger = { log: (...args: any[]) => void; error: (...args: any[]) => void };
/** The settings a probe runs under: a session's cwd, its key helper, and its launch env. */
export type AuthStatusSettings = Pick<SettingsManager, "getCwd" | "getApiKeyHelper">;
export interface AuthStatusReporterOptions {
/** Sends `_auth/status_update`. */
push: (status: AuthStatus) => Promise<void>;
logger: ReporterLogger;
/** An ACP gateway login is active: the gateway, not the CLI store, is the identity. */
gatewayLogin: () => boolean;
/** A `providers/set` override is active: a session account describes its route. */
providerOverride: () => boolean;
/** A completed probe read `kind`. */
onProbe?: (kind: AuthStatusKind) => void;
}
export class AuthStatusReporter {
/** Last identity reported to the client. Undefined means "not determined yet". */
current?: AuthStatus;
/**
* Counts auth-affecting events (a login, a logout, a new settings context).
* A probe records it at start and publishes only if it has not moved, so a
* slow read can never overwrite a newer login or logout.
*/
epoch = 0;
private settings?: {
manager: AuthStatusSettings;
override?: Settings;
env?: Options["env"];
};
private helper?: string;
/** In-flight `claude auth status --json` probe, shared by every caller so
* a concurrent `initialize` and start-of-prompt read never spawn two CLI
* processes. */
private probing: Promise<AuthStatus | undefined> | null = null;
/** Keeps the "session account told us nothing" note to one line per process
* instead of one per session. */
private loggedUninformativeAccount = false;
/** Same, for the "client provider override active" note. */
private loggedOverriddenAccount = false;
/** Same, for the "the CLI probe timed out" warning: a wedged CLI stays wedged
* and would otherwise warn once per probe, i.e. once per user prompt. */
private loggedProbeTimeout = false;
constructor(private readonly options: AuthStatusReporterOptions) {}
/**
* Stores `next` and pushes it to the client.
*
* A push goes out only when the payload changed. The identity is read on many
* occasions — each session create, each guarded turn, the start of each user
* prompt — and almost all of them see the same login. Clients replace their
* whole state on each update and tolerate duplicates, so a repeat is
* harmless, but it is also pure noise; {@link sameAuthStatus} drops it.
*
* What the agent owes is truth — a stale or uninformative source must not
* reach here at all (see the epoch check in the probe and the guards at
* session create).
*/
set(next: AuthStatus | undefined): void {
if (!next) {
return;
}
if (sameAuthStatus(this.current, next)) {
return;
}
this.current = next;
// ACP notifications get no reply, and clients that do not know the method
// drop it silently — send unconditionally and never fail a caller on it.
void Promise.resolve()
.then(() => this.options.push(next))
.catch((error) => {
this.options.logger.error("Failed to send _auth/status_update:", error);
});
}
/** A login or a logout: any probe already running read the world before it. */
changed(): void {
this.epoch += 1;
}
/**
* Probes run under these settings from now on. A new context (cwd, key
* helper, or launch env) starts a fresh probe in it.
*/
useSettings(
manager: AuthStatusSettings,
settingsOverride?: Settings,
launchEnv?: Options["env"],
) {
const helper = manager.getApiKeyHelper(settingsOverride);
const changed =
manager.getCwd() !== (this.settings?.manager.getCwd() ?? process.cwd()) ||
helper !== this.helper ||
JSON.stringify(launchEnv) !== JSON.stringify(this.settings?.env);
this.settings = { manager, override: settingsOverride, env: launchEnv };
this.helper = helper;
if (changed) {
// The epoch bump discards any probe that read the old context, and the
// session account may carry no identity signal (an apiKeyHelper), so
// only a probe in the new context can classify it.
this.epoch += 1;
void this.probe({ fresh: true });
}
}
/**
* Report the identity behind a session's `AccountInfo`.
*
* `AccountInfo` is richer than the CLI probe (it is what the live query
* actually authenticates with). The v1 core calls it before the
* `--hide-claude-auth` guard may refuse the session or the turn: that flag
* blocks the *use* of a subscription, it does not make the state secret, and
* a refusal is when the client most needs to know the account it was
* refused for.
*
* Two cases skip it:
*
* - ACP gateway *authentication*: the gateway, not this account, is the
* agent-owned identity — already reported as `kind: "gateway"` by the login.
* - A client-driven provider override (`providers/set`): the session then
* routes through the client's endpoint and `AccountInfo` describes that
* route (e.g. `apiProvider: "gateway"`), which is NOT agent-owned state.
* `authStatus` reports the agent's own login only, so the CLI probe —
* which reads the credential store the override never touches — stays
* authoritative.
*
* An account with no identity signal (e.g. `{apiProvider: "firstParty"}`
* under an apiKeyHelper) means "nothing to add", not "logged out" — keep
* what the CLI probe already established instead of overwriting it.
*/
publishSessionAccount(account: AccountInfo | undefined, apiKeyHelper?: string): void {
if (this.options.providerOverride()) {
if (!this.loggedOverriddenAccount) {
this.loggedOverriddenAccount = true;
this.options.logger.log(
"[authStatus] client provider override active; keeping agent-owned probe state",
);
}
return;
}
if (this.options.gatewayLogin()) {
return;
}
const fromSession = fromAccountInfo(account, apiKeyHelper);
if (fromSession) {
this.set(fromSession);
} else if (!this.loggedUninformativeAccount) {
this.loggedUninformativeAccount = true;
this.options.logger.log(
"[authStatus] session account carries no identity signal; keeping probe",
);
}
}
/** Shares one in-flight `claude auth status --json` run between callers. The
* promise is released once settled so a later call re-probes instead of
* replaying a stale verdict. `fresh` forces a new run even when one is in
* flight — `logout` needs a read that started after the credentials were
* cleared. */
probe(options?: { fresh?: boolean }): Promise<AuthStatus | undefined> {
if (!options?.fresh && this.probing) {
return this.probing;
}
const probe = this.runProbe();
this.probing = probe;
void probe.finally(() => {
if (this.probing === probe) {
this.probing = null;
}
});
return probe;
}
/** Never rejects: an unavailable CLI means "not reported", not an error. */
private async runProbe(): Promise<AuthStatus | undefined> {
const { logger } = this.options;
// Monotonicity: a read that started before the connection's latest
// auth-affecting event describes a world that no longer exists. Remember
// which one this read belongs to and drop the answer if it moved on.
const epoch = this.epoch;
const context = this.settings;
const apiKeyHelper = context?.manager.getApiKeyHelper(context.override);
// ACP gateway auth bypasses the CLI credential store entirely, so the
// probe would report an identity that is not the one being used. A mere
// client provider override (`providers/set`) does NOT skip the probe: it
// reroutes traffic without touching the credential store, and the store is
// exactly the agent-owned login `authStatus` reports.
if (this.options.gatewayLogin()) {
return this.current;
}
let stdout: string;
try {
const cliPath = await claudeCliPath();
const args = ["auth", "status", "--json"];
// `--settings` is a global CLI option: after the `auth status` subcommand the CLI rejects
// it with "unknown option '--settings'", so it has to come first.
if (context?.override) args.unshift("--settings", JSON.stringify(context.override));
({ stdout } = await execFileAsync(cliPath, args, {
timeout: AUTH_STATUS_PROBE_TIMEOUT_MS,
cwd: context?.manager.getCwd() ?? process.cwd(),
env: context?.env ? { ...process.env, ...context.env } : process.env,
}));
} catch (error) {
const failed = error as { stdout?: unknown; killed?: boolean; signal?: unknown } | null;
// Node killed the child on the timeout. Whatever it printed so far is a
// truncated fragment, never valid JSON: report nothing and keep the last
// known state, so nothing regresses and no push goes out.
if (failed?.killed === true && failed.signal) {
if (!this.loggedProbeTimeout) {
this.loggedProbeTimeout = true;
logger.error(
"claude auth status did not answer within 5 s; the identity is not refreshed",
);
}
return this.current;
}
// The logged-out case exits 1 while still printing valid JSON, so the
// stdout of a failed exec is parsed just like a successful one.
if (typeof failed?.stdout !== "string" || failed.stdout.trim().length === 0) {
logger.error(
"claude auth status failed:",
error instanceof Error ? error.message : String(error),
);
return undefined;
}
stdout = failed.stdout;
}
let status = fromCliStatus(stdout, apiKeyHelper);
if (!context && status?.kind === "api_key" && status.detail === "API key helper") {
try {
const resolved = await resolveSettings({ cwd: process.cwd() });
status = fromCliStatus(stdout, filterEscalatingDefaultMode(resolved).apiKeyHelper);
} catch {
logger.error("Could not resolve auth source settings");
}
}
if (!status) {
logger.error("claude auth status returned unparseable output");
return undefined;
}
if (epoch !== this.epoch) {
// A login or logout landed while this probe was running; the newer
// state wins and this answer is discarded, never published.
logger.log("[authStatus] discarding a probe that predates the latest auth change");
return this.current;
}
// The CLI probe can be poorer than the session `AccountInfo` for the very
// same login (no organization, say). Keep those extra fields rather than
// regressing the payload; a different identity replaces it wholesale.
const merged = mergeAuthStatus(this.current, status);
this.set(merged);
this.options.onProbe?.(status.kind);
return merged;
}
}
/**
* Runs `claude auth logout`. The Claude and Console logins keep their
* credentials in the CLI's store (keychain or config dir), which only the
* binary can clear. The command is non-interactive and idempotent. Throws an
* `internal_error` with the CLI's stderr when it fails.
*/
export async function cliLogout(): Promise<void> {
try {
const cliPath = await claudeCliPath();
await execFileAsync(cliPath, ["auth", "logout"]);
} catch (error) {
const stderr =
typeof error === "object" && error && "stderr" in error
? String((error as { stderr: unknown }).stderr).trim()
: undefined;
throw RequestError.internalError(
{ stderr: stderr || undefined },
`claude auth logout failed: ${error instanceof Error ? error.message : String(error)}`,
);
}
}

View File

@@ -103,6 +103,13 @@ const NOT_LOGGED_IN: AuthStatus = {
label: "Not logged in",
};
const API_KEY_SOURCE_DETAILS = new Map([
["apiKeyHelper", "API key helper"],
["env", "env"],
["ANTHROPIC_API_KEY", "ANTHROPIC_API_KEY"],
["ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_AUTH_TOKEN"],
]);
/** Display names for the non-firstParty backends, where auth lives outside the
* agent (AWS credentials, gcloud ADC, …). */
const EXTERNAL_PROVIDER_LABELS: Record<string, string> = {
@@ -162,7 +169,10 @@ export function accountInfoHasIdentitySignal(account: AccountInfo | undefined):
/** Session-time mapping from the SDK's `AccountInfo`, the richest source when
* it is populated. Returns `undefined` when the account carries no identity
* signal, so callers keep the status they already know. */
export function fromAccountInfo(account: AccountInfo | undefined): AuthStatus | undefined {
export function fromAccountInfo(
account: AccountInfo | undefined,
apiKeyHelper?: string,
): AuthStatus | undefined {
if (!account || !accountInfoHasIdentitySignal(account)) {
return undefined;
}
@@ -172,13 +182,14 @@ export function fromAccountInfo(account: AccountInfo | undefined): AuthStatus |
apiKeySource: account.apiKeySource,
email: account.email,
organization: account.organization,
apiKeyHelper,
});
}
/** Pre-session mapping from `claude auth status --json` stdout. Returns
* `undefined` when the output is not the expected JSON object, so callers can
* report "not known" instead of guessing. */
export function fromCliStatus(stdout: string): AuthStatus | undefined {
export function fromCliStatus(stdout: string, apiKeyHelper?: string): AuthStatus | undefined {
let parsed: unknown;
try {
parsed = JSON.parse(stdout);
@@ -199,12 +210,22 @@ export function fromCliStatus(stdout: string): AuthStatus | undefined {
if (!externalBackend && !status.loggedIn && !status.apiKeySource) {
return notLoggedInAuthStatus();
}
if (
status.loggedIn &&
status.authMethod === "oauth_token" &&
status.apiProvider === "firstParty" &&
!status.apiKeySource &&
!status.subscriptionType
) {
return { kind: "account", label: "Claude subscription", detail: "OAuth token" };
}
return mapAuthFields({
apiProvider: status.apiProvider,
subscriptionType: status.subscriptionType,
apiKeySource: status.apiKeySource,
email: status.email,
organization: status.orgName,
apiKeyHelper,
});
}
@@ -224,6 +245,7 @@ function mapAuthFields(fields: {
apiKeySource?: string;
email?: string;
organization?: string;
apiKeyHelper?: string;
}): AuthStatus {
const { apiProvider, subscriptionType, apiKeySource, email, organization } = fields;
@@ -237,11 +259,19 @@ function mapAuthFields(fields: {
};
}
if (apiKeySource) {
if (
apiKeySource === "apiKeyHelper" &&
typeof fields.apiKeyHelper === "string" &&
/\bcentral\b/i.test(fields.apiKeyHelper) &&
/\bproxy\s+start\b/i.test(fields.apiKeyHelper)
) {
return { kind: "gateway", label: "JetBrains Air Gateway" };
}
const detail = API_KEY_SOURCE_DETAILS.get(apiKeySource);
return {
kind: "api_key",
label: "Anthropic API key",
// The source ("apiKeyHelper", "env", …) is the specifics line.
detail: apiKeySource,
...(detail ? { detail } : {}),
};
}
if (subscriptionType) {

131
src/background-output.ts Normal file
View File

@@ -0,0 +1,131 @@
/**
* The output of a command that went to the background, read from the file
* that Claude Code writes it to. Claude Code streams no output of such a
* command, so its display terminal would show only the placeholder of the
* Bash result ("Command running in background with ID …") until the command
* exits. ACP v2 asks an Agent to keep a listed terminal's output current
* whenever it can read it (the v2 prompt lifecycle, "Background work").
*
* The file is polled: watching a file is not reliable on every platform, and
* a missing file only means that nothing was written yet.
*/
import { open } from "node:fs/promises";
import { StringDecoder } from "node:string_decoder";
/** How often a running command's output file is read. */
export const BACKGROUND_OUTPUT_POLL_MS = 500;
/** The most bytes one read takes, so a burst of output does not block the loop. */
export const BACKGROUND_OUTPUT_READ_BYTES = 256 * 1024;
/**
* The most unread bytes that are still sent. A command that writes faster
* than the polls read falls behind. Past this, the reads skip to the file's
* last bytes, as a snapshot: a terminal shows the latest output, and a
* client keeps a bounded amount of it anyway.
*/
export const BACKGROUND_OUTPUT_MAX_BACKLOG_BYTES = 1024 * 1024;
/**
* Text that the file adds. A snapshot replaces the terminal's output so far:
* the first one replaces the placeholder of the Bash result, and a later one
* follows a skip or a file that shrank. Other text appends to it.
*/
export type BackgroundOutput = { text: string; replaces: boolean };
/**
* Follows the output file `path` of one background command and hands each
* piece of new text to `publish`, in order. Reads never overlap. A read that
* fails, for example because the file does not exist yet, is tried again at
* the next poll. `publish` must not reject: the next read waits for it.
*/
export class BackgroundOutputTail {
private offset = 0;
private decoder = new StringDecoder("utf8");
/** Whether the next text replaces the terminal's output. */
private replaces = true;
private stopped = false;
private reading: Promise<void> = Promise.resolve();
private readonly timer: ReturnType<typeof setInterval>;
constructor(
private readonly path: string,
private readonly publish: (output: BackgroundOutput) => Promise<void>,
) {
this.timer = setInterval(() => void this.read(false), BACKGROUND_OUTPUT_POLL_MS);
// A command that runs on must not keep the agent process alive.
this.timer.unref();
}
/**
* Reads the rest of the file and stops. The command ended, so what the file
* holds is its whole output, and it goes out before the terminal's exit.
*/
async finish(): Promise<void> {
clearInterval(this.timer);
await this.read(true);
this.stopped = true;
}
/** Stops without reading more: the session ended. */
stop(): void {
clearInterval(this.timer);
this.stopped = true;
}
/**
* Reads what the file adds, one read of at most
* {@link BACKGROUND_OUTPUT_READ_BYTES} at a time, and publishes each read's
* text before the next. `toEnd` reads until the end of the file, and ends a
* character that the command cut off with a replacement character.
*/
private read(toEnd: boolean): Promise<void> {
this.reading = this.reading.then(async () => {
if (this.stopped) return;
let file;
try {
file = await open(this.path, "r");
} catch {
// Nothing written yet, or the file is gone: try again at the next
// poll. At the end, the terminal keeps the output it has.
return;
}
try {
const { size } = await file.stat();
// A file that shrank is a new one: its output starts over. Past the
// backlog, the reads skip to the file's last bytes.
const start = size < this.offset ? 0 : this.offset;
const skipTo = Math.max(start, size - BACKGROUND_OUTPUT_MAX_BACKLOG_BYTES);
if (skipTo !== this.offset) {
this.offset = skipTo;
this.decoder = new StringDecoder("utf8");
this.replaces = true;
}
const buffer = Buffer.alloc(BACKGROUND_OUTPUT_READ_BYTES);
let bytesRead: number;
do {
({ bytesRead } = await file.read(buffer, 0, buffer.length, this.offset));
this.offset += bytesRead;
const text = this.decoder.write(buffer.subarray(0, bytesRead));
if (text !== "") await this.send(text);
} while (toEnd && bytesRead > 0);
if (toEnd) {
const rest = this.decoder.end();
// A command that wrote nothing still replaces the placeholder.
if (rest !== "" || this.replaces) await this.send(rest);
}
} catch {
// The read failed: try again at the next poll.
} finally {
await file.close();
}
});
return this.reading;
}
private async send(text: string): Promise<void> {
const replaces = this.replaces;
this.replaces = false;
await this.publish({ text, replaces });
}
}

61
src/background-work.ts Normal file
View File

@@ -0,0 +1,61 @@
/**
* The commands of a session that went to the background, for a client that
* renders terminals from the terminal extension and is not AIR (AIR shows a
* command through its async tasks): the display terminal of each command
* runs from its Bash tool result until its task ends.
*/
import type { AcpSessionNotification } from "./acp-subagents.js";
/** The display terminal of a command that went to the background. */
export interface BackgroundTerminal {
/** The Bash tool call, whose id names the terminal. */
toolCallId: string;
/** The subagent tool call that ran the command, if any. */
parentToolUseId?: string;
}
/** The terminals of the commands that one root session, and its subagents, run in the background. */
export class SessionBackgroundWork {
/** The terminals of the commands that went to the background, by task id. */
private readonly terminals = new Map<string, BackgroundTerminal>();
constructor(private readonly sessionId: string) {}
/**
* Keeps the display terminal of the command `taskId` running after its Bash
* result, until its task ends (see `withRunningTerminal`).
*/
terminalStarted(taskId: string, terminal: BackgroundTerminal): void {
this.terminals.set(taskId, terminal);
}
/**
* The task `taskId` ended: exits its command's terminal, with no exit code.
* Claude Code reports no exit code for a command.
*/
async taskEnded(taskId: string, send: Send): Promise<void> {
const terminal = this.terminals.get(taskId);
if (!terminal) return;
this.terminals.delete(taskId);
await send(terminalExit(this.sessionId, terminal));
}
}
type Send = (notification: AcpSessionNotification) => Promise<void>;
/** The update that exits the terminal of `terminal`, with no exit code. */
function terminalExit(sessionId: string, terminal: BackgroundTerminal): AcpSessionNotification {
return {
sessionId,
update: {
sessionUpdate: "tool_call_update",
toolCallId: terminal.toolCallId,
_meta: {
...(terminal.parentToolUseId
? { claudeCode: { parentToolUseId: terminal.parentToolUseId } }
: {}),
terminal_exit: { terminal_id: terminal.toolCallId, exit_code: null, signal: null },
},
},
};
}

12
src/binary-entry.ts Normal file
View File

@@ -0,0 +1,12 @@
// Entry of the release executable (`scripts/build-binary.mjs`). It registers the
// Claude CLI shipped next to the executable before importing the agent:
// index.ts starts serving (or, with --cli, spawns the CLI) while it evaluates,
// and a static import would evaluate it before this module's body runs.
import { realpathSync } from "node:fs";
import { dirname } from "node:path";
import { useBundledClaudeCli } from "./claude-cli.js";
// realpath, so a symlink to the executable (e.g. in /usr/local/bin) still finds
// the CLI next to the real file.
useBundledClaudeCli(dirname(realpathSync(process.execPath)));
await import("./index.js");

125
src/blob-resources.ts Normal file
View File

@@ -0,0 +1,125 @@
/**
* Embedded `resource` prompt blocks whose resource is a base64 `blob`, and the
* Claude content each supported MIME type becomes.
*/
import type { BlobResourceContents } from "@agentclientprotocol/sdk/unstable";
/** Image types the Messages API accepts as a base64 `image` block. */
const IMAGE_MIME_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"] as const;
type ImageMimeType = (typeof IMAGE_MIME_TYPES)[number];
/** Non-`text/*` types whose bytes are text, e.g. a `.json` or `.ts` file. */
const TEXT_APPLICATION_MIME_TYPES = new Set([
"application/json",
"application/xml",
"application/javascript",
"application/typescript",
"application/x-yaml",
"application/yaml",
"application/toml",
"application/x-sh",
"application/sql",
"application/graphql",
]);
export type BlobResourceContent =
| {
kind: "image";
block: {
type: "image";
source: { type: "base64"; media_type: ImageMimeType; data: string };
};
}
| {
kind: "document";
block: {
type: "document";
source: { type: "base64"; media_type: "application/pdf"; data: string };
};
}
/** The decoded text, to send like a text resource. */
| { kind: "text"; text: string };
/**
* The MIME type without parameters or case, e.g. `text/plain; charset=utf-8` ->
* `text/plain`. `image/jpg`, which some clients send, becomes `image/jpeg`.
*/
function baseMimeType(mimeType: string | null | undefined): string | undefined {
const base = mimeType?.split(";")[0].trim().toLowerCase();
if (base === "image/jpg") return "image/jpeg";
return base || undefined;
}
/**
* Decodes a text blob with the `charset` parameter of its MIME type, e.g.
* `text/csv; charset=iso-8859-1`, and UTF-8 when there is none or the label is
* unknown. A leading byte order mark is dropped.
*/
function decodeText(blob: string, mimeType: string | null | undefined): string {
const charset = /;\s*charset\s*=\s*"?([^";\s]+)/i.exec(mimeType ?? "")?.[1];
let decoder: TextDecoder;
try {
decoder = new TextDecoder(charset ?? "utf-8");
} catch {
decoder = new TextDecoder("utf-8");
}
return decoder.decode(Buffer.from(blob, "base64"));
}
function isImageMimeType(mimeType: string): mimeType is ImageMimeType {
return (IMAGE_MIME_TYPES as readonly string[]).includes(mimeType);
}
function isTextMimeType(mimeType: string): boolean {
return (
mimeType.startsWith("text/") ||
TEXT_APPLICATION_MIME_TYPES.has(mimeType) ||
mimeType.endsWith("+json") ||
mimeType.endsWith("+xml")
);
}
/**
* Whether {@link blobResourceToClaude} can send a blob of this MIME type. A blob
* without a MIME type is not supported: its bytes could be anything.
*/
export function isSupportedBlobMimeType(mimeType: string | null | undefined): boolean {
const base = baseMimeType(mimeType);
if (!base) return false;
return isImageMimeType(base) || base === "application/pdf" || isTextMimeType(base);
}
/**
* The Claude content for a blob resource: an `image` block for PNG, JPEG, GIF
* and WebP, a `document` block for PDF, and the decoded text for text
* types such as `text/markdown` or `application/json`. Returns `undefined` for
* any other type, e.g. `application/zip`.
*/
export function blobResourceToClaude(
resource: BlobResourceContents,
): BlobResourceContent | undefined {
const mimeType = baseMimeType(resource.mimeType);
if (!mimeType) return undefined;
if (isImageMimeType(mimeType)) {
return {
kind: "image",
block: {
type: "image",
source: { type: "base64", media_type: mimeType, data: resource.blob },
},
};
}
if (mimeType === "application/pdf") {
return {
kind: "document",
block: {
type: "document",
source: { type: "base64", media_type: "application/pdf", data: resource.blob },
},
};
}
if (isTextMimeType(mimeType)) {
return { kind: "text", text: decodeText(resource.blob, resource.mimeType) };
}
return undefined;
}

88
src/claude-cli.ts Normal file
View File

@@ -0,0 +1,88 @@
import { existsSync } from "node:fs";
import { join } from "node:path";
const CLI_FILE_NAME = process.platform === "win32" ? "claude.exe" : "claude";
let bundledCliDirectory: string | undefined;
/**
* Called by the executable's entry (`src/binary-entry.ts`) before the agent
* starts. A `bun build --compile` executable has no `node_modules` to resolve
* the SDK's platform package from, so the release archive ships the Claude CLI
* next to it. The CLI isn't embedded: a child process can't run a file inside
* Bun's `$bunfs`, so an embedded copy would have to be extracted to disk first.
*/
export function useBundledClaudeCli(directory: string): void {
bundledCliDirectory = directory;
}
/**
* The Claude CLI binary to spawn: `CLAUDE_CODE_EXECUTABLE` if set, then the CLI
* shipped next to the executable, then the SDK's platform-specific package.
*/
export async function claudeCliPath(): Promise<string> {
if (process.env.CLAUDE_CODE_EXECUTABLE) {
return process.env.CLAUDE_CODE_EXECUTABLE;
}
if (bundledCliDirectory !== undefined) {
return bundledClaudeCliPath(bundledCliDirectory);
}
return installedClaudeCliPath();
}
function bundledClaudeCliPath(directory: string): string {
const path = join(directory, CLI_FILE_NAME);
// Without this check, spawning a missing CLI fails with a bare ENOENT.
if (!existsSync(path)) {
throw new Error(
`The Claude CLI is missing: expected ${path}. Keep ${CLI_FILE_NAME} from the release archive ` +
"in the same directory as claude-agent-acp, or set CLAUDE_CODE_EXECUTABLE to a Claude CLI binary.",
);
}
return path;
}
async function installedClaudeCliPath(): Promise<string> {
// The SDK's CLI is a native binary shipped as a platform-specific optional
// dependency of @anthropic-ai/claude-agent-sdk. Resolve via a require bound
// to the SDK so nested installs are found even when npm doesn't hoist.
const { createRequire } = await import("node:module");
const req = createRequire(import.meta.resolve("@anthropic-ai/claude-agent-sdk"));
const ext = process.platform === "win32" ? ".exe" : "";
// On linux, both glibc and musl variants may be installed side-by-side
// (e.g. bunx hydrates every optional dep), so picking one by trial is
// unreliable: the wrong binary segfaults at runtime instead of failing to
// spawn. Detect the runtime libc and prefer the matching variant, falling
// back to the other only if the preferred one isn't installed.
const candidates =
process.platform === "linux"
? isMuslLibc()
? [
`@anthropic-ai/claude-agent-sdk-linux-${process.arch}-musl/claude${ext}`,
`@anthropic-ai/claude-agent-sdk-linux-${process.arch}/claude${ext}`,
]
: [
`@anthropic-ai/claude-agent-sdk-linux-${process.arch}/claude${ext}`,
`@anthropic-ai/claude-agent-sdk-linux-${process.arch}-musl/claude${ext}`,
]
: [`@anthropic-ai/claude-agent-sdk-${process.platform}-${process.arch}/claude${ext}`];
for (const candidate of candidates) {
try {
return req.resolve(candidate);
} catch {
// try next candidate
}
}
throw new Error(
`Claude native binary not found for ${process.platform}-${process.arch}. ` +
`Reinstall @anthropic-ai/claude-agent-sdk without --omit=optional, or set CLAUDE_CODE_EXECUTABLE.`,
);
}
function isMuslLibc(): boolean {
// process.report.getReport().header.glibcVersionRuntime is populated when
// Node is dynamically linked against glibc, and absent on musl.
const report = process.report?.getReport() as
{ header?: { glibcVersionRuntime?: string } } | undefined;
return !report?.header?.glibcVersionRuntime;
}

View File

@@ -1,4 +1,4 @@
import type { NewSessionRequest, SessionConfigOption } from "@agentclientprotocol/sdk";
import type { NewSessionRequest, SessionConfigOption } from "@agentclientprotocol/sdk/unstable";
import type {
EffortLevel,
Options,
@@ -39,11 +39,14 @@ export type ClearContextSession<Turn extends ClearContextTurn = ClearContextTurn
configOptions: SessionConfigOption[];
currentAgent: string;
fastModeEnabled: boolean;
ultracodeEnabled: boolean;
effortPinnedLevel?: string;
activeTurn?: Turn | null;
turnQueue?: Turn[];
pendingExitPlanContextReset?: ClearContextReset;
contextUsedTokens?: number;
contextWindowSize: number;
sentUserUuids?: Set<string>;
input: { push(message: SDKUserMessage): unknown };
};
@@ -58,10 +61,14 @@ export type ClearContextCoordinatorHost<
options: { publicSessionId: string; permissionMode: PermissionMode },
): Promise<Session>;
applyFastMode(session: Session, enabled: boolean): Promise<void>;
applyUltracode(session: Session, enabled: boolean): Promise<void>;
/** Publishes the fresh context's mode, config options, and usage: the
* context starts empty in a window of `contextWindowSize` tokens. */
publishSessionState(
sessionId: string,
mode: PermissionMode,
configOptions: SessionConfigOption[],
contextWindowSize: number,
): Promise<void>;
continuationMessage(sessionId: string, plan: string, promptUuid: string): SDKUserMessage;
ensureConsumer(session: Session, sessionId: string): void;
@@ -145,6 +152,16 @@ export async function continuePlanInFreshContext<
}
assertRestartActive(freshSession);
}
// The fresh query seeds ultracode from settings only, so a toggle made
// during the old session would otherwise be lost.
if (oldSession.ultracodeEnabled !== freshSession.ultracodeEnabled) {
try {
await host.applyUltracode(freshSession, oldSession.ultracodeEnabled);
} catch (error) {
host.logError("Failed to restore ultracode after clearing context:", error);
}
assertRestartActive(freshSession);
}
oldSession.activeTurn = null;
oldSession.turnQueue = [];
@@ -152,12 +169,18 @@ export async function continuePlanInFreshContext<
freshSession.contextUsedTokens = 0;
try {
await host.publishSessionState(sessionId, reset.mode, freshSession.configOptions);
await host.publishSessionState(
sessionId,
reset.mode,
freshSession.configOptions,
freshSession.contextWindowSize,
);
} catch (error) {
host.logError("Failed to publish clear-context session state:", error);
}
assertRestartActive(freshSession);
(freshSession.sentUserUuids ??= new Set()).add(turn.promptUuid);
freshSession.input.push(host.continuationMessage(sessionId, reset.plan, turn.promptUuid));
assertRestartActive(freshSession);
host.ensureConsumer(freshSession, sessionId);

101
src/client-system-prompt.ts Normal file
View File

@@ -0,0 +1,101 @@
/**
* The client-provided system prompt of the unstable `systemPrompt` field on
* `session/new`, `session/load`, `session/resume`, and `session/fork`.
*
* - `append` keeps the Claude Code preset and adds the text after it, the
* SDK's `{ type: "preset", preset: "claude_code", append }`.
* - `override` replaces the Claude Code preset with the text, the SDK's custom
* string prompt. Tools, MCP servers, permissions, and settings (including the
* CLAUDE.md files that `settingSources` loads) are unchanged.
*
* The CLI records the system prompt of a conversation on its first request
* and reuses it on resume, so a different `systemPrompt` on a later
* `session/resume` takes effect at the next compaction. The RFD allows this
* deferral.
*/
import type { Options } from "@anthropic-ai/claude-agent-sdk";
import {
RequestError,
type SessionCapabilities,
type SystemPrompt,
} from "@agentclientprotocol/sdk/unstable";
/** The `sessionCapabilities.systemPrompt` the agent advertises: both modes. */
export const SYSTEM_PROMPT_CAPABILITY = {
mode: { override: {} },
} satisfies NonNullable<SessionCapabilities["systemPrompt"]>;
export type ClientSystemPrompt = { mode: "append" | "override"; text: string };
/**
* The mode and text of a `systemPrompt`, or `undefined` when the request has
* none. Throws `invalid_params` for a mode other than `append` and `override`
* and for a content block other than text: the RFD requires an error, and no
* session, for a prompt the agent cannot apply.
*/
export function clientSystemPrompt(
systemPrompt: SystemPrompt | null | undefined,
): ClientSystemPrompt | undefined {
if (systemPrompt == null) return undefined;
const { mode, content } = systemPrompt;
if (mode !== "append" && mode !== "override") {
throw RequestError.invalidParams(
{ systemPrompt: { mode } },
`Unsupported systemPrompt mode: ${mode}`,
);
}
const text: string[] = [];
for (const block of content) {
if (block.type !== "text") {
throw RequestError.invalidParams(
{ systemPrompt: { content: { type: block.type } } },
`systemPrompt content must be text, but received a ${block.type} block`,
);
}
text.push(block.text);
}
return { mode, text: text.join("\n\n") };
}
/**
* The SDK `systemPrompt` option for a client system prompt. `customInstructions`
* (AIR's `customInstructions` on `session/new` metadata) still follows the
* client text in both modes.
*/
export function sdkSystemPrompt(
prompt: ClientSystemPrompt,
customInstructions: string | undefined,
): NonNullable<Options["systemPrompt"]> {
const text = [prompt.text, customInstructions].filter((part) => part).join("\n\n");
if (prompt.mode === "override") return text;
return text
? { type: "preset", preset: "claude_code", append: text }
: { type: "preset", preset: "claude_code" };
}
/**
* The SDK `systemPrompt` of a session: the spec's `systemPrompt`
* (`clientPrompt`), else the `_meta.systemPrompt` extension (`metaPrompt`), a
* string that replaces the preset or an object of preset options, else the
* Claude Code preset. `customInstructions` are appended to the preset.
*/
export function sessionSystemPrompt(
clientPrompt: ClientSystemPrompt | undefined,
metaPrompt: unknown,
customInstructions?: string,
): NonNullable<Options["systemPrompt"]> {
if (clientPrompt) return sdkSystemPrompt(clientPrompt, customInstructions);
if (typeof metaPrompt === "string" && metaPrompt) return metaPrompt;
if (typeof metaPrompt === "object" && metaPrompt !== null && !Array.isArray(metaPrompt)) {
// Forward all preset options (append, excludeDynamicSections, and
// anything the SDK adds later) while locking type/preset.
return {
...(metaPrompt as object),
type: "preset",
preset: "claude_code",
} as NonNullable<Options["systemPrompt"]>;
}
return customInstructions === undefined
? { type: "preset", preset: "claude_code" }
: { type: "preset", preset: "claude_code", append: customInstructions };
}

View File

@@ -2,7 +2,7 @@ import { AIR_CONTEXT_COMPACTION_KEY, withAirMeta } from "./air-extension.js";
export const CONTEXT_COMPACTION_META_VERSION = 1;
export type ContextCompactionTrigger = "manual" | "automatic";
export type ContextCompactionTrigger = "manual" | "automatic" | "idle";
export interface ContextCompactionMetadata {
version: typeof CONTEXT_COMPACTION_META_VERSION;

View File

@@ -1,8 +1,10 @@
import { ClientCapabilities, SessionNotification } from "@agentclientprotocol/sdk";
import { ClientCapabilities, SessionNotification } from "@agentclientprotocol/sdk/unstable";
import {
ContextCompactionMetadata,
ContextCompactionTrigger,
createContextCompactionMeta,
} from "./context-compaction-meta.js";
import { assistantMessageText } from "./session-failure-extension.js";
import { compactionToolCall } from "./tool-calls/reporters/compaction.js";
type CompactionStatus = "completed" | "failed";
@@ -66,7 +68,7 @@ const ANALYSIS_BLOCKS = [
* it as the post-compaction user message. */
const CONTINUATION_PREAMBLE = /^\s*This session is being continued from a previous conversation/i;
const PERSISTED_PREAMBLE =
/^\s*This session is being continued from a previous conversation[^\n]*\n+Summary:[ \t]*\r?\n/;
/^\s*This session is being continued from a previous conversation[^\n]*\n(?:[\s\S]*?\n)?Summary:[ \t]*\r?\n/;
const PERSISTED_TRAILERS = [
/\n+If you need specific details from before compaction[\s\S]*$/,
/\n+(?:Please )?[Cc]ontinue the conversation from where it left (?:it )?off[\s\S]*$/,
@@ -131,6 +133,36 @@ export function isCompactSummaryMessage(message: unknown): boolean {
);
}
/**
* Replays the compact summary message `message` (see
* {@link isCompactSummaryMessage}) of the root conversation as one completed
* `compaction_update` at its transcript position, keyed by the message's
* uuid. Returns false, and sends nothing, for any other message.
*
* The boundary's own system record comes back from `getSessionMessages`
* without its metadata, so the summary message is the one durable marker.
* Its uuid differs from the live id (the `compacting` status uuid): replay
* is terminal-first by the RFD, and no client merges a replay into a live
* entity store. A summary whose framing is unrecognized is still this
* compaction, without summary text.
*/
export async function replayCompactSummary(
message: { uuid?: unknown; message?: unknown },
sendUpdate: SendUpdate,
options: { sessionId: string; airClient?: boolean },
): Promise<boolean> {
if (!isCompactSummaryMessage(message) || typeof message.uuid !== "string" || !message.uuid) {
return false;
}
const replayed = new ContextCompactionLifecycle(sendUpdate, {
...options,
presentation: "compaction_update",
});
replayed.recordSummary(assistantMessageText(message.message));
await replayed.finish(message.uuid, "completed");
return true;
}
/**
* Translates Claude's compaction signals into one idempotent ACP lifecycle.
*
@@ -408,14 +440,29 @@ function withoutError({
return facts;
}
export function contextCompactionMetadataFromBoundary(compactMetadata: {
trigger: "manual" | "auto";
pre_tokens: number;
post_tokens?: number;
duration_ms?: number;
}): Omit<ContextCompactionMetadata, "version"> {
function compactionTrigger(
cliTrigger: "manual" | "auto",
sentByAdapter: boolean,
): ContextCompactionTrigger {
// The CLI's own automatic compaction stays automatic, even inside the adapter's /compact turn.
if (cliTrigger === "auto") return "automatic";
// The CLI reports every /compact as manual; only the adapter knows it sent this one itself.
if (sentByAdapter) return "idle";
return "manual";
}
/** `idleCompaction` marks a `/compact` that the adapter sent itself (see `IdleCompaction`). */
export function contextCompactionMetadataFromBoundary(
compactMetadata: {
trigger: "manual" | "auto";
pre_tokens: number;
post_tokens?: number;
duration_ms?: number;
},
{ idleCompaction = false }: { idleCompaction?: boolean } = {},
): Omit<ContextCompactionMetadata, "version"> {
return {
trigger: compactMetadata.trigger === "auto" ? "automatic" : "manual",
trigger: compactionTrigger(compactMetadata.trigger, idleCompaction),
preTokens: compactMetadata.pre_tokens,
...(compactMetadata.post_tokens !== undefined
? { postTokens: compactMetadata.post_tokens }

166
src/context-window.ts Normal file
View File

@@ -0,0 +1,166 @@
/**
* The context window of a session's model, as both the v1 core and the
* v2 core report it in `usage_update.size`: seeded without IPC when the
* session starts or switches models, refined by a background
* `getContextUsage`, and learned from each result's `modelUsage`.
*/
import type { ModelInfo, Query } from "@anthropic-ai/claude-agent-sdk";
import { DEFAULT_CONTEXT_WINDOW, inferContextWindowFromModel } from "./session-usage.js";
/** Cross-session cache of authoritative context windows, keyed by
* `${providerCacheKey}\0${modelId}` (see {@link contextWindowCacheKey}).
* The window is a property of (model id, backend): the same resolved model id
* (e.g. "claude-sonnet-5[1m]", the spelling of the `result.modelUsage` keys)
* can name different context lanes behind different base URLs, routing
* headers, or credentials, so the key carries both. Caching it module-level
* lets a later session/new or switch that resolves to the same (backend,
* model) — in this session or any other, within the adapter's lifetime — seed
* the correct window synchronously with no IPC. Keying on the resolved id
* (rather than the picker value) means aliases that resolve to the same
* concrete model share one entry; the result handler additionally writes the
* bare assistant-message spelling so seed-time reads that fall back to a
* verbatim live id (rows without `resolvedModel`) can hit too.
*
* Populated authoritatively by each `result.modelUsage` a turn confirms (see
* the consumer's result handler). We deliberately never populate it from
* `getContextUsage`: its `model` spelling and `rawMaxTokens` can differ from
* the `result.modelUsage` key and window (e.g. 967000 vs 1000000 for sonnet),
* so it only refines the live session (`refreshContextWindowInBackground`).
* Cleared on `logout`: 1M-context entitlement can differ per account/tier, so
* windows learned under one login must not seed sessions under the next. */
const contextWindowCache = new Map<string, number>();
/** The env vars that determine which LLM backend — and which context lane on
* it — a query's API traffic reaches: endpoint selection (base URLs and the
* Bedrock/Vertex switches with their project/region), routing/beta headers
* (an `anthropic-beta: context-1m-…` header flips the same model id at the
* same endpoint between context lanes), and credential identity (extended
* context is entitlement-gated per account). Used to derive the
* provider-cache key from the exact env a query is created with, so
* `providers/set` config, per-session `_meta` env overrides, and ambient
* process env are all distinguished exactly as the CLI will see them. */
const PROVIDER_ROUTING_ENV_VARS = [
"ANTHROPIC_BASE_URL",
"ANTHROPIC_BEDROCK_BASE_URL",
"ANTHROPIC_VERTEX_BASE_URL",
"CLAUDE_CODE_USE_BEDROCK",
"CLAUDE_CODE_USE_VERTEX",
"ANTHROPIC_VERTEX_PROJECT_ID",
"CLOUD_ML_REGION",
"AWS_REGION",
"ANTHROPIC_CUSTOM_HEADERS",
"ANTHROPIC_API_KEY",
"ANTHROPIC_AUTH_TOKEN",
"CLAUDE_CODE_OAUTH_TOKEN",
] as const;
/** Stable identifier for the LLM backend a session's query is created against,
* used to scope {@link contextWindowCache} per backend. Positional `\0`-join
* of {@link PROVIDER_ROUTING_ENV_VARS} values, so no segment can masquerade
* as another and unset vars everywhere yield one stable "default" bucket.
* Header/credential values can be secrets; the key only ever lives as an
* in-memory Map key and is never logged or surfaced. Over-keying is the safe
* side: a var change that didn't really change the backend costs one cache
* miss (heuristic seed until the next result), while under-keying would serve
* one backend's window for another's. */
export function providerCacheKeyFor(env: Record<string, string | undefined>): string {
return PROVIDER_ROUTING_ENV_VARS.map((name) => env[name] ?? "").join("\0");
}
/** Compose the `contextWindowCache` key from a session's provider key and a
* model id. `\0`-joined so the model segment can't collide with a provider
* segment. */
export function contextWindowCacheKey(providerCacheKey: string, modelId: string): string {
return `${providerCacheKey}\0${modelId}`;
}
function cacheContextWindow(modelKey: string, window: number): void {
if (window > 0) {
contextWindowCache.set(modelKey, window);
}
}
/** Caches the window that a result's `modelUsage` reported, keyed on (the
* session's provider, the resolved model id — the `modelUsage` key, e.g.
* "claude-sonnet-5[1m]") so a later session/new or switch on the same
* provider that resolves to this model seeds the correct window
* synchronously, with no getContextUsage IPC.
*
* Also caches it under the assistant message's own (bare) spelling. Seed-time
* reads fall back to a picker value / verbatim live id when a row carries no
* resolvedModel (the synthesized out-of-allowlist resume row sets it
* undefined on purpose), and those spellings match `.model` from the
* assistant message, not the decorated modelUsage key — without this entry
* such rows could never hit the cache. A non-positive window is not cached. */
export function cacheResultContextWindow(
providerCacheKey: string,
modelUsageKey: string,
assistantModel: string | null | undefined,
window: number,
): void {
cacheContextWindow(contextWindowCacheKey(providerCacheKey, modelUsageKey), window);
if (assistantModel && assistantModel !== modelUsageKey) {
cacheContextWindow(contextWindowCacheKey(providerCacheKey, assistantModel), window);
}
}
/** The context window to report *right now* for a model, with NO IPC on the
* critical path: the cached authoritative value if we've learned it (from a
* prior turn's `result.modelUsage`, this or any session on the same backend),
* else the text heuristic over the model row's identity strings, else the
* default. Derives the cache key itself — `modelInfo?.resolvedModel ?? modelId`,
* the same rule at every seed site — so read keys can't drift from the write
* site's spelling. `authoritative` reports whether the value came from the
* cache: an authoritative window can legitimately equal
* DEFAULT_CONTEXT_WINDOW, so the value alone can't tell the caller. */
export function immediateContextWindow(
providerCacheKey: string,
modelId: string,
modelInfo?: Pick<ModelInfo, "resolvedModel" | "displayName" | "description">,
): { size: number; authoritative: boolean } {
const cached = contextWindowCache.get(
contextWindowCacheKey(providerCacheKey, modelInfo?.resolvedModel ?? modelId),
);
if (cached !== undefined) return { size: cached, authoritative: true };
return {
size:
inferContextWindowFromModel(
modelId,
modelInfo?.resolvedModel,
modelInfo?.displayName,
modelInfo?.description,
) ?? DEFAULT_CONTEXT_WINDOW,
authoritative: false,
};
}
/** Forgets the learned windows, as a logout asks: 1M-context entitlement is
* gated per org/tier, and an OAuth re-login is invisible to the env-derived
* provider cache key, so windows learned under the old login must not seed
* sessions under the next. */
export function clearContextWindowCache(): void {
contextWindowCache.clear();
}
/**
* Reads the window of the query's current model with
* `getContextUsage().rawMaxTokens`. Undefined when Claude Code reports none.
*
* Requests the `summary` detail instead of the default `full` breakdown,
* which issues one `messages/count_tokens` call per category (~18 for a bare
* session) and hit rate limits on every model switch; `summary` answers
* locally with the same `rawMaxTokens`. Callers never await it on the setup
* or switch path: SDK control requests are serialized, so awaiting would
* delay session/new or a model switch (~15s on older CLIs, issues
* #886/#880; ~0.5s on 2.1.283).
*
* The result does not go into the cross-session cache: its `rawMaxTokens` can
* differ from the `result.modelUsage` window (e.g. 967000 vs 1000000 for
* sonnet), so it only refines the live session.
*/
export async function readContextWindow(
query: Pick<Query, "getContextUsage">,
): Promise<number | undefined> {
const usage = await query.getContextUsage({ detail: "summary" });
return usage.rawMaxTokens > 0 ? usage.rawMaxTokens : undefined;
}

View File

@@ -1,5 +1,5 @@
import { ToolCallContent, ToolCallLocation } from "@agentclientprotocol/sdk";
import type * as v2 from "@agentclientprotocol/sdk/experimental/v2";
import { ToolCallContent, ToolCallLocation } from "@agentclientprotocol/sdk/unstable";
import type * as v2 from "@agentclientprotocol/sdk/experimental/v2/unstable";
import { structuredPatch } from "diff";
import { constants } from "node:fs";
import { type FileHandle, open } from "node:fs/promises";
@@ -89,7 +89,7 @@ export type PatchForm = "air" | "v2";
/**
* How a git patch names its file. `git` is git's own form: the leading slash
* dropped and the `a/` and `b/` prefixes added. `absolute` is the form of an
* ACP v2 `git_patch`, whose paths must be absolute: the path itself.
* ACP v2 `git_patch`, whose paths must be absolute: the native path itself.
*/
type GitPatchPaths = "git" | "absolute";
@@ -438,11 +438,11 @@ export function gitPatchText(
}
function gitPatchHeader(filePath: string, change: FileChange, paths: GitPatchPaths): string[] {
// A Windows path gets forward slashes so the header names the same file on
// every platform.
const slashed = filePath.replaceAll("\\", "/");
// git drops one leading slash of an absolute path.
const name = paths === "git" ? slashed.replace(/^\/+/u, "") : slashed;
// git's form uses forward slashes, so the header names the same file on
// every platform, and drops one leading slash of an absolute path. An ACP v2
// patch keeps the native path so that, once unquoted, it equals the `changes`
// entry: `C:\work\App.ts` is written `"C:\\work\\App.ts"`.
const name = paths === "git" ? filePath.replaceAll("\\", "/").replace(/^\/+/u, "") : filePath;
const oldName = quoteGitPath(paths === "git" ? "a/" : "", name);
const newName = quoteGitPath(paths === "git" ? "b/" : "", name);
// git ends a ---/+++ name that contains a space with a tab, for GNU patch.
@@ -717,7 +717,7 @@ function airPatchContent(filePath: string, { change, hunks }: FilePatch): ToolCa
*
* The agent builds tool call content in v1 types, and v1 cannot express a v2
* diff, so the diff travels as v1 content. Nothing in the agent reads inside a
* diff, and only a v2 client gets one: the v2 surface sends it as it is
* diff, and only a v2 client gets one: the v2 core sends it as it is
* (`src/v2/tool-call.ts`).
*/
export function v2DiffContent(

View File

@@ -1,13 +1,18 @@
import { randomUUID } from "node:crypto";
import { CreateElicitationResponse } from "@agentclientprotocol/sdk";
import type {
CreateElicitationRequest,
ElicitationAcceptAction,
CreateElicitationResponse,
ElicitationPropertySchema,
ElicitationSchema,
EnumOption,
} from "@agentclientprotocol/sdk";
import type { ElicitationRequest, ElicitationResult } from "@anthropic-ai/claude-agent-sdk";
} from "@agentclientprotocol/sdk/unstable";
import type {
ElicitationRequest,
ElicitationResult,
PermissionResult,
UserDialogRequest,
UserDialogResult,
} from "@anthropic-ai/claude-agent-sdk";
import type {
AskUserQuestionInput,
AskUserQuestionOutput,
@@ -75,17 +80,22 @@ export function mcpElicitationToCreateRequest(
}
/**
* Content of an accepted elicitation response.
* Content of an accepted elicitation response, or `{}` when the response is
* no accept or its `content` is no JSON object.
*
* Uses the SDK's validating guard rather than an `action === "accept"` check:
* the guard both narrows past the union's custom/future variant and validates
* the payload, so a malformed accept (right tag, ill-typed content) yields
* empty content — the same classification the SDK's wire validators apply.
* The values are not checked. v1 allows a string, number, boolean, or string
* array; v2 also allows any other JSON value, such as an object. An MCP
* server checks the content against its own `requestedSchema`, so it gets the
* content as the client sent it: before, an accept whose content held an
* object reached the server as `{ action: "accept", content: {} }`. Callers
* that read a field must check its type.
*/
function acceptedElicitationContent(
response: CreateElicitationResponse,
): NonNullable<ElicitationAcceptAction["content"]> {
return CreateElicitationResponse.isAccept(response) ? (response.content ?? {}) : {};
function acceptedElicitationContent(response: CreateElicitationResponse): Record<string, unknown> {
if (response.action !== "accept") return {};
const content: unknown = (response as { content?: unknown }).content;
return content !== null && typeof content === "object" && !Array.isArray(content)
? (content as Record<string, unknown>)
: {};
}
/**
@@ -97,7 +107,11 @@ export function createElicitationResponseToElicitResult(
): ElicitationResult {
switch (response.action) {
case "accept":
return { action: "accept", content: acceptedElicitationContent(response) };
// `ElicitResult` types the values as v1 does. The MCP server checks them.
return {
action: "accept",
content: acceptedElicitationContent(response) as ElicitationResult["content"],
};
case "decline":
return { action: "decline" };
case "cancel":
@@ -280,14 +294,37 @@ function joinMultiSelectAnswer(items: string[]): string {
* cannot make the selection disappear. Decline yields empty answers (the model
* is told the user skipped rather than the turn aborting); cancel — and any
* custom/future action we don't understand — aborts the tool call.
*
* With `reportUnanswered` set (the session's `system/init` listed
* `ask_user_question_unanswered_v1`), each question left without an answer
* also gets `annotations[question].unanswered`: `"dismissed"` for every
* question when the user declined the form, `"skipped"` for a question left
* blank in an accepted form. The model does not see this field; the tool
* result carries it for clients and transcripts. A CLI without the capability
* can refuse the whole answer over it, so it is left out there.
*/
export function applyAskElicitationResponse(
response: CreateElicitationResponse,
toolInput: Record<string, unknown>,
questions: AskUserQuestion[],
options: { reportUnanswered?: boolean } = {},
): AskUserQuestionOutcome {
// Typed against the output schema: as of SDK 0.3.319 only it lists
// `unanswered`, though the tool reads the same annotations from its input.
const annotations: NonNullable<AskUserQuestionOutput["annotations"]> = {};
const withAnnotations = (answers: AskUserQuestionOutput["answers"]) => ({
...toolInput,
answers,
...(Object.keys(annotations).length > 0 ? { annotations } : {}),
});
if (response.action === "decline") {
return { action: "answered", updatedInput: { ...toolInput, answers: {} } };
if (options.reportUnanswered) {
for (const question of questions) {
annotations[question.question] = { unanswered: "dismissed" };
}
}
return { action: "answered", updatedInput: withAnnotations({}) };
}
if (response.action !== "accept") {
@@ -298,18 +335,17 @@ export function applyAskElicitationResponse(
// Typed against the tool's own output schema so the answer/response shapes
// stay in sync with what the built-in tool's call() expects to read back.
const answers: AskUserQuestionOutput["answers"] = {};
const annotations: NonNullable<AskUserQuestionInput["annotations"]> = {};
questions.forEach((question, index) => {
const custom = content[questionCustomFieldKey(index)];
const customText = typeof custom === "string" ? custom.trim() : "";
// A pick is a string, number, or boolean. Other values, such as an object
// that a v2 client may send, are dropped instead of read as "[object Object]".
const value = content[questionFieldKey(index)];
const picks: string[] =
value === undefined || value === null
? []
: Array.isArray(value)
? value.filter((item) => item !== undefined && item !== null && item !== "").map(String)
: [String(value)];
const picks: string[] = (Array.isArray(value) ? value : [value])
.filter(isElicitationScalar)
.map(String)
.filter((item) => item !== "");
// A multi-select is additive, and the form offers the selection and the
// custom box as independent fields — a user who fills both means both, so
@@ -341,20 +377,27 @@ export function applyAskElicitationResponse(
}
});
return {
action: "answered",
updatedInput: {
...toolInput,
answers,
...(Object.keys(annotations).length > 0 ? { annotations } : {}),
},
};
if (options.reportUnanswered) {
for (const question of questions) {
if (answers[question.question] === undefined) {
annotations[question.question] = { unanswered: "skipped" };
}
}
}
return { action: "answered", updatedInput: withAnnotations(answers) };
}
function isElicitationScalar(value: unknown): value is string | number | boolean {
return typeof value === "string" || typeof value === "number" || typeof value === "boolean";
}
/**
* Coerce an arbitrary MCP `requestedSchema` into an ACP `ElicitationSchema`.
* The two are structurally compatible JSON Schemas; we just guarantee the
* `type: "object"` discriminator is present.
* `type: "object"` discriminator and a `properties` map are present. The v2
* schema requires both, so a client on SDK 2.2.0 or later rejects an MCP
* schema such as `{ type: "object" }` that has no `properties`.
*/
function normalizeElicitationSchema(
schema: Record<string, unknown> | undefined,
@@ -362,7 +405,8 @@ function normalizeElicitationSchema(
if (!schema || typeof schema !== "object") {
return { type: "object", properties: {} };
}
return { ...(schema as ElicitationSchema), type: "object" };
const { properties } = schema as Partial<ElicitationSchema>;
return { ...(schema as ElicitationSchema), type: "object", properties: properties ?? {} };
}
/**
@@ -474,3 +518,156 @@ export function refusalFallbackResultFromResponse(response: CreateElicitationRes
const choice = acceptedElicitationContent(response)[REFUSAL_FALLBACK_CHOICE_KEY];
return choice === RETRY_FALLBACK_RESULT ? RETRY_FALLBACK_RESULT : KEEP_REFUSAL_RESULT;
}
/**
* What became of an elicitation: the client's response, or `withdrawn` when
* its signal aborted first (Claude Code withdrew the request, or a cancel or
* close of the session did). A response that comes after the abort counts as
* withdrawn too.
*/
export type ElicitationOutcome = { response: CreateElicitationResponse } | { withdrawn: true };
/**
* Sends an elicitation with `send` and reads what became of it. A rejection
* while `signal` is not aborted, such as the client's -32602 for a form it
* cannot render, is rethrown.
*/
export async function elicitUnlessWithdrawn(
signal: AbortSignal,
send: (signal: AbortSignal) => Promise<CreateElicitationResponse>,
): Promise<ElicitationOutcome> {
try {
const response = await send(signal);
return signal.aborted ? { withdrawn: true } : { response };
} catch (error) {
if (signal.aborted) return { withdrawn: true };
throw error;
}
}
/** Sends a built elicitation to the client (see {@link elicitUnlessWithdrawn}). */
export type Elicit = (request: CreateElicitationRequest) => Promise<ElicitationOutcome>;
type ElicitationLogger = { error: (...args: unknown[]) => void };
/**
* Answers an MCP server's elicitation (the SDK's `onElicitation`) through
* `elicit`. Only the user's own decline reaches the MCP server as `decline`.
* A request the user never saw, because the client did not advertise its
* mode, it cannot be represented, the client answered with an error, or it
* was withdrawn, is a `cancel`.
*/
export async function answerMcpElicitation(
request: ElicitationRequest,
{
sessionId,
support,
elicit,
logger,
}: { sessionId: string; support: ElicitationSupport; elicit: Elicit; logger: ElicitationLogger },
): Promise<ElicitationResult> {
const isUrl = request.mode === "url";
if ((isUrl && !support.url) || (!isUrl && !support.form)) {
return { action: "cancel" };
}
const createRequest = mcpElicitationToCreateRequest(request, sessionId);
if (!createRequest) {
return { action: "cancel" };
}
try {
const outcome = await elicit(createRequest);
if ("withdrawn" in outcome) return { action: "cancel" };
return createElicitationResponseToElicitResult(outcome.response);
} catch (error) {
// The client answered with an error, e.g. -32602 for a form it cannot
// render, so the user never saw the request. MCP's `decline` means the
// user refused; `cancel` means they made no explicit choice.
logger.error(`Failed to forward MCP elicitation: ${error}`);
return { action: "cancel" };
}
}
/**
* Presents the AskUserQuestion tool's questions as a form elicitation of
* `sessionId` through `elicit`, and returns the answers as the tool's
* `updatedInput`. A withdrawn question, or one the user cancelled, aborts the
* tool use. `reportUnanswered` is read once the answer is in (see
* {@link applyAskElicitationResponse}).
*/
export async function answerAskUserQuestion(
toolInput: Record<string, unknown>,
{
sessionId,
toolUseID,
airClient = false,
reportUnanswered,
elicit,
logger,
}: {
sessionId: string;
toolUseID: string;
airClient?: boolean;
reportUnanswered: () => boolean;
elicit: Elicit;
logger: ElicitationLogger;
},
): Promise<PermissionResult> {
const questions = extractAskUserQuestions(toolInput);
if (!questions) {
return { behavior: "deny", message: "AskUserQuestion called with no valid questions." };
}
const createRequest = askUserQuestionsToCreateRequest(questions, sessionId, toolUseID, airClient);
let outcome: ElicitationOutcome;
try {
outcome = await elicit(createRequest);
} catch (error) {
logger.error(`Failed to present AskUserQuestion elicitation: ${error}`);
return { behavior: "deny", message: "Could not present the question to the user." };
}
if ("withdrawn" in outcome) {
throw new Error("Tool use aborted");
}
const answer = applyAskElicitationResponse(outcome.response, toolInput, questions, {
reportUnanswered: reportUnanswered(),
});
if (answer.action === "cancel") {
throw new Error("Tool use aborted");
}
return { behavior: "allow", updatedInput: answer.updatedInput };
}
/**
* Answers a `request_user_dialog` of Claude Code (the SDK's `onUserDialog`).
* Only kinds declared in `supportedDialogKinds` are ever emitted; everything
* unexpected is answered `cancelled` (the required answer for unrecognized
* kinds), which applies the dialog's default behavior CLI-side. The only
* declared kind is the refusal-fallback consent prompt, rendered as a form
* elicitation. A failed or withdrawn elicitation is `cancelled` too: the CLI
* then keeps the refusal rather than switching models without consent.
*/
export async function answerUserDialog(
request: UserDialogRequest,
{ sessionId, elicit, logger }: { sessionId: string; elicit: Elicit; logger: ElicitationLogger },
): Promise<UserDialogResult> {
if (request.dialogKind !== REFUSAL_FALLBACK_DIALOG_KIND) {
return { behavior: "cancelled" };
}
const prompt = extractRefusalFallbackPrompt(request.payload);
if (!prompt) {
logger.error(
`refusal_fallback_prompt payload had an unexpected shape; cancelling the dialog: ${JSON.stringify(request.payload)}`,
);
return { behavior: "cancelled" };
}
let outcome: ElicitationOutcome;
try {
outcome = await elicit(refusalFallbackToCreateRequest(prompt, sessionId));
} catch (error) {
logger.error(`Failed to present refusal fallback elicitation: ${error}`);
return { behavior: "cancelled" };
}
if ("withdrawn" in outcome) {
return { behavior: "cancelled" };
}
return { behavior: "completed", result: refusalFallbackResultFromResponse(outcome.response) };
}

View File

@@ -1,4 +1,4 @@
import type { SessionNotification } from "@agentclientprotocol/sdk";
import type { SessionNotification } from "@agentclientprotocol/sdk/unstable";
import type { SDKResultMessage } from "@anthropic-ai/claude-agent-sdk";
import {
type ClearContextCoordinatorHost,
@@ -126,6 +126,45 @@ export function isExitPlanInterruptionResult(
);
}
/** The base ID of an approved plan. AIR appends the review ID to keep each
* approval in the transcript. Other clients replace one plan per session.
* The TodoWrite checklist is a separate plan. */
export const PLAN_MODE_PLAN_ID = "plan-mode";
/** The plan, trimmed, that a successful ExitPlanMode result approved: the plan
* of the structured `output`, which includes the user's edits, else the plan
* that Claude Code adds to the tool's `input`. Replay has only the input: the
* SDK's `getSessionMessages` drops the structured output that the transcript
* keeps. Undefined for a subagent's plan, one that waits for its team lead
* (both only known from the output), and an empty plan. */
export function approvedPlan(input: unknown, output: unknown): string | undefined {
const result = output as
{ plan?: unknown; isAgent?: unknown; awaitingLeaderApproval?: unknown } | undefined;
if (result?.isAgent === true || result?.awaitingLeaderApproval === true) return undefined;
const plan =
typeof result?.plan === "string"
? result.plan
: (input as { plan?: unknown } | undefined)?.plan;
return typeof plan === "string" && plan.trim() !== "" ? plan.trim() : undefined;
}
/** The update that shows an approved plan to a client that supports plan
* operations (the `plan` client capability, which every v2 client has).
* For AIR, pass the review ID to join the plan to its approval card. */
export function planModeUpdate(
plan: string,
reviewToolCallId?: string,
): SessionNotification["update"] {
return {
sessionUpdate: "plan_update",
plan: {
type: "markdown",
planId: reviewToolCallId ? `${PLAN_MODE_PLAN_ID}:${reviewToolCallId}` : PLAN_MODE_PLAN_ID,
content: plan,
},
};
}
/** Claude wraps a rejected ExitPlanMode explanation in a Markdown code fence.
* Strip exactly one complete outer fence for that tool only. */
export function exitPlanModeRawOutput(toolName: string, content: unknown): unknown {
@@ -144,6 +183,9 @@ export type ExitPlanRestartHost<
"publishSessionState" | "continuationMessage"
> & {
sessionUpdate(notification: SessionNotification): Promise<void>;
/** Whether the client takes plan operations (see planModeUpdate). */
supportsPlanOperations(): boolean;
isAirClient(): boolean;
destroyReplacement(sessionId: string, session: Session): void;
settleCancelledTurn(oldSession: Session, turnSession: Session, turn: Turn): void;
settleFailedTurn(turnSession: Session, turn: Turn, error: unknown): void;
@@ -171,7 +213,7 @@ export class ExitPlanCoordinator<
try {
const clearContextHost: ClearContextCoordinatorHost<Session, Turn> = {
...this.host,
publishSessionState: async (id, mode, configOptions) => {
publishSessionState: async (id, mode, configOptions, contextWindowSize) => {
await this.host.sessionUpdate({
sessionId: id,
update: { sessionUpdate: "current_mode_update", currentModeId: mode },
@@ -180,6 +222,24 @@ export class ExitPlanCoordinator<
sessionId: id,
update: { sessionUpdate: "config_option_update", configOptions },
});
// Otherwise the client shows the old context's usage until the fresh
// context first reports its own.
await this.host.sessionUpdate({
sessionId: id,
update: { sessionUpdate: "usage_update", used: 0, size: contextWindowSize },
});
// The approved plan becomes the session's plan once the fresh
// context that carries it out exists. Its ExitPlanMode ended with
// an error result, so toAcpNotifications did not publish it.
if (this.host.supportsPlanOperations()) {
await this.host.sessionUpdate({
sessionId: id,
update: planModeUpdate(
reset.plan,
this.host.isAirClient() ? reset.toolUseId : undefined,
),
});
}
},
continuationMessage: (id, plan, promptUuid) => ({
type: "user",

View File

@@ -19,6 +19,10 @@ export type FileChangeReportTurnState = {
export type NativeFileChangeReportTurn = {
promptUuid: string;
fileChangeReport?: FileChangeReportTurnState;
/** Raw `file_path`/`notebook_path` values of the file-tool edits that
* subagents of this turn completed. Checkpoints do not track files that
* only a subagent edited. */
editedPaths?: Set<string>;
};
export type FileChangeReportWorkspace = {
@@ -94,6 +98,42 @@ export function agentFileChangeReportMeta(
return withAirMeta(undefined, AGENT_FILE_CHANGE_REPORT_CAPABILITY, result);
}
const FILE_EDIT_TOOLS = new Set(["Edit", "Write", "MultiEdit", "NotebookEdit"]);
/**
* Adds to `turn.editedPaths` the `file_path` (`notebook_path` for NotebookEdit)
* of each successful file-tool edit among the tool results of a subagent
* message. Root-agent messages (`parentToolUseId === null`) are skipped: the
* checkpoint already tracks their files. A turn that v1 marked `settled`
* records nothing. Must run before `toAcpNotifications` drops the tool use
* cache entries.
*/
export function recordSubagentFileEdits(
turn: (NativeFileChangeReportTurn & { settled?: boolean }) | null | undefined,
parentToolUseId: string | null,
content: unknown,
toolUses: Record<string, { name: string; input: unknown } | undefined>,
): void {
if (parentToolUseId === null || !turn || turn.settled || !turn.fileChangeReport) return;
if (!Array.isArray(content)) return;
for (const block of content) {
if (
block?.type !== "tool_result" ||
block.is_error === true ||
typeof block.tool_use_id !== "string"
) {
continue;
}
const toolUse = toolUses[block.tool_use_id];
if (!toolUse || !FILE_EDIT_TOOLS.has(toolUse.name)) continue;
const input = toolUse.input as Record<string, unknown> | undefined;
const editedPath = toolUse.name === "NotebookEdit" ? input?.notebook_path : input?.file_path;
if (typeof editedPath === "string" && editedPath.length > 0) {
(turn.editedPaths ??= new Set()).add(editedPath);
}
}
}
export function createNativeFileChangeReporter(
options: NativeFileChangeReporterOptions,
): NativeFileChangeReporter {
@@ -101,7 +141,7 @@ export function createNativeFileChangeReporter(
const timeoutMs = options.timeoutMs ?? AGENT_FILE_CHANGE_REPORT_TIMEOUT_MS;
const requestIds = new Set<string>();
const publish = async (state: FileChangeReportTurnState, result: AgentFileChangeReportResult) => {
async function publish(state: FileChangeReportTurnState, result: AgentFileChangeReportResult) {
if (state.phase === "finished") return;
state.phase = "finished";
try {
@@ -109,24 +149,55 @@ export function createNativeFileChangeReporter(
} catch (error) {
options.logError(`Failed to publish file-change report ${state.requestId}: ${error}`);
}
};
}
const finishUnavailable = async (
async function finishUnavailable(
state: FileChangeReportTurnState,
reason: FileChangeReportUnavailableReason,
) => {
) {
await publish(state, {
version: 1,
requestId: state.requestId,
status: "unavailable",
reason,
});
};
}
const report = async (
async function publishReported(state: FileChangeReportTurnState, reportedPaths: string[]) {
const normalized = normalizeReportedPaths(reportedPaths, workspace);
await publish(
state,
fitReportedAgentFileChangeReport({
version: 1,
requestId: state.requestId,
status: "reported",
paths: normalized.paths,
// Checkpoints miss Bash mutations. Subagent file-tool edits are added
// from the message stream, but the list is still not exhaustive.
declaredComplete: false,
truncated: normalized.truncated,
}),
);
}
// Used when the checkpoint preview fails: the subagent edits seen in the
// stream are still worth reporting.
async function finishWithoutPreview(
state: FileChangeReportTurnState,
editedPaths: Set<string> | undefined,
reason: FileChangeReportUnavailableReason,
) {
if (editedPaths && editedPaths.size > 0) {
await publishReported(state, [...editedPaths]);
} else {
await finishUnavailable(state, reason);
}
}
async function report(
turn: NativeFileChangeReportTurn | null | undefined,
query: Pick<Query, "rewindFiles">,
) => {
) {
const state = turn?.fileChangeReport;
if (!state || state.phase !== "requested") return;
state.phase = "collecting";
@@ -145,35 +216,22 @@ export function createNativeFileChangeReporter(
}),
]);
if (preview === null) {
await finishUnavailable(state, "timeout");
await finishWithoutPreview(state, turn.editedPaths, "timeout");
return;
}
if (!preview.canRewind || !Array.isArray(preview.filesChanged)) {
await finishUnavailable(state, "invalidOutput");
await finishWithoutPreview(state, turn.editedPaths, "invalidOutput");
return;
}
const normalized = normalizeReportedPaths(preview.filesChanged, workspace);
await publish(
state,
fitReportedAgentFileChangeReport({
version: 1,
requestId: state.requestId,
status: "reported",
paths: normalized.paths,
// Checkpoints cover Claude file tools, but not every mutation source
// (notably Bash and most subagents), so this list is never exhaustive.
declaredComplete: false,
truncated: normalized.truncated,
}),
);
await publishReported(state, [...preview.filesChanged, ...(turn.editedPaths ?? [])]);
} catch (error) {
options.logError(`Failed to inspect Claude file checkpoint ${turn.promptUuid}: ${error}`);
await finishUnavailable(state, "providerError");
await finishWithoutPreview(state, turn.editedPaths, "providerError");
} finally {
if (timer) clearTimeout(timer);
}
};
}
return {
request(meta) {

View File

@@ -1,4 +1,8 @@
import { ForkSessionRequest, ForkSessionResponse, RequestError } from "@agentclientprotocol/sdk";
import {
ForkSessionRequest,
ForkSessionResponse,
RequestError,
} from "@agentclientprotocol/sdk/unstable";
import {
forkSession as forkClaudeSession,
getSessionMessages,
@@ -65,10 +69,7 @@ function forkPointMessageIdCandidates(messageId: string): string[] {
return protocolMessageId === messageId ? [messageId] : [messageId, protocolMessageId];
}
async function loadFullSessionHistory(
sessionId: string,
cwd: string,
): Promise<SessionStoreEntry[]> {
async function loadFullSessionHistory(sessionId: string): Promise<SessionStoreEntry[]> {
// getSessionMessages returns only the active parentUuid chain. The import keeps inactive branches that AIR can still reference.
const entries: SessionStoreEntry[] = [];
const store: SessionStore = {
@@ -77,7 +78,7 @@ async function loadFullSessionHistory(
},
load: async () => null,
};
await importSessionToStore(sessionId, store, { dir: cwd, includeSubagents: false });
await importSessionToStore(sessionId, store, { includeSubagents: false });
return entries;
}
@@ -175,14 +176,44 @@ function resolveFromFullHistory(
return occurrenceMatches.length === 1 ? occurrenceMatches[0]?.uuid : undefined;
}
/**
* Whether `error` is the Claude SDK's report that `sessionId` has no
* transcript: `forkSession` and `importSessionToStore` throw
* `Error("Session <id> not found")`, and both throw
* `"Invalid sessionId: <id>"` for an id that is not a UUID.
*/
function isUnknownSessionError(error: unknown, sessionId: string): boolean {
if (!(error instanceof Error)) return false;
return (
error.message === `Session ${sessionId} not found` ||
error.message === `Invalid sessionId: ${sessionId}`
);
}
export async function forkSession(
params: ForkSessionRequest,
dependencies: ForkSessionDependencies,
): Promise<ForkSessionResponse> {
try {
return await forkTranscript(params, dependencies);
} catch (error) {
if (isUnknownSessionError(error, params.sessionId)) {
throw RequestError.sessionNotFound(params.sessionId);
}
throw error;
}
}
async function forkTranscript(
params: ForkSessionRequest,
dependencies: ForkSessionDependencies,
): Promise<ForkSessionResponse> {
const timing = new SessionTiming(dependencies.logger, "fork", params.sessionId);
const point = forkPoint(params._meta);
// SDK `dir` scopes source lookup, not the fork's working directory. Search
// all projects: params.cwd can name a different workspace for the new session.
if (!point) {
const forked = await forkClaudeSession(params.sessionId, { dir: params.cwd });
const forked = await forkClaudeSession(params.sessionId);
timing.phase("sdk-fork", " resolution=latest");
return { sessionId: forked.sessionId };
}
@@ -191,9 +222,7 @@ export async function forkSession(
const liveUuid = candidateIds
.map((candidateId) => dependencies.liveMessageIdToUuid?.get(candidateId))
.find(Boolean);
const history = liveUuid
? undefined
: await getSessionMessages(params.sessionId, { dir: params.cwd });
const history = liveUuid ? undefined : await getSessionMessages(params.sessionId);
timing.phase("active-history", ` resolution=${liveUuid ? "live" : "pending"}`);
const messageUuid =
liveUuid ??
@@ -209,7 +238,7 @@ export async function forkSession(
const fullHistoryUuid = messageUuid
? undefined
: resolveFromFullHistory(
await loadFullSessionHistory(params.sessionId, params.cwd),
await loadFullSessionHistory(params.sessionId),
candidateIds,
point,
dependencies.messageIdForGrouping,
@@ -227,7 +256,6 @@ export async function forkSession(
}
const forked = await forkClaudeSession(params.sessionId, {
dir: params.cwd,
upToMessageId: messageUuid ?? fullHistoryUuid,
});
timing.phase("sdk-fork");

223
src/gateway-models.ts Normal file
View File

@@ -0,0 +1,223 @@
/**
* Model discovery for a gateway set via `providers/set`.
*
* An organization can restrict which models its gateway serves, and the
* gateway's listing returns only those: `GET /v1/models` for an `anthropic`
* gateway, `GET /foundation-models` (Bedrock `ListFoundationModels`) for a
* `bedrock` one. The adapter lists them once when the provider is set, and the
* model picker then offers only the SDK models the gateway listed (see
* `narrowToGatewayModels` in `session-model.ts`). A listing that fails fails
* `providers/set` with a {@link GatewayListingError}.
*/
const LISTING_TIMEOUT_MS = 10_000;
const ANTHROPIC_PAGE_LIMIT = "1000";
const MAX_LISTING_PAGES = 20;
const MAX_LOGGED_BODY_CHARS = 500;
const ANTHROPIC_VERSION = "2023-06-01";
/**
* The bearer token `createEnvForProvider` sets as `ANTHROPIC_AUTH_TOKEN` and
* `AWS_BEARER_TOKEN_BEDROCK`, so the CLI sends `Authorization: Bearer acp-proxy`
* unless the provider headers replace it. The listing sends the same, so the
* gateway sees the same auth as on chat requests.
*/
export const PROVIDER_AUTH_TOKEN_PLACEHOLDER = "acp-proxy";
/**
* The cross-region inference profile prefixes the CLI knows:
* `eu.anthropic.claude-sonnet-5`. `ANTHROPIC_BEDROCK_REGION_PREFIX` accepts all
* of them but `us-gov`, which the CLI takes only from a `us-gov-*` AWS region;
* a gateway listing only `us-gov.*` ids would then get an empty picker. That
* case is theoretical, so it is left as is.
*/
const BEDROCK_REGION_PREFIXES = ["us", "eu", "apac", "jp", "au", "us-gov", "global"];
type ListingLogger = { log: (...args: any[]) => void; error: (...args: any[]) => void };
type ListingPage = { ids: string[]; nextQuery?: Record<string, string> };
/**
* A gateway listing that failed: a network error, a status other than 2xx or
* 4xx, a body that is not the expected listing, or the 10 s timeout. The
* message names the request path and the reason, never a header value.
*/
export class GatewayListingError extends Error {
override name = "GatewayListingError";
}
/**
* Lists the model ids an `anthropic` gateway at `baseUrl` serves, following
* `has_more` / `last_id` pagination. Returns `undefined` when the gateway
* answers 4xx: it has no listing, and nothing is narrowed. Throws a
* {@link GatewayListingError} when the listing fails. Header values are never
* logged.
*/
export async function listGatewayModels(
baseUrl: string,
providerHeaders: Record<string, string>,
logger: ListingLogger,
): Promise<string[] | undefined> {
return listPages(
`${baseUrl.replace(/\/+$/, "")}/v1/models`,
{ limit: ANTHROPIC_PAGE_LIMIT },
listingHeaders(providerHeaders, { "anthropic-version": ANTHROPIC_VERSION }),
logger,
(body) => {
const page = body as { data?: unknown; has_more?: unknown; last_id?: unknown };
if (!Array.isArray(page.data)) return undefined;
return {
ids: stringField(page.data, "id"),
nextQuery:
page.has_more === true && typeof page.last_id === "string"
? { limit: ANTHROPIC_PAGE_LIMIT, after_id: page.last_id }
: undefined,
};
},
);
}
/**
* Lists the Anthropic models a `bedrock` gateway at `baseUrl` serves
* (`GET /foundation-models`, Bedrock `ListFoundationModels`, one page), the
* listing AI Router answers on its Bedrock route. Returns the listed ids, such
* as `global.anthropic.claude-sonnet-5-5`; `undefined` and the errors are
* those of {@link listGatewayModels}.
*/
export async function listBedrockModels(
baseUrl: string,
providerHeaders: Record<string, string>,
logger: ListingLogger,
): Promise<string[] | undefined> {
const ids = await listPages(
`${baseUrl.replace(/\/+$/, "")}/foundation-models`,
{},
listingHeaders(providerHeaders, {}),
logger,
(body) => {
const page = body as { modelSummaries?: unknown };
if (!Array.isArray(page.modelSummaries)) return undefined;
return { ids: stringField(page.modelSummaries, "modelId") };
},
);
return ids?.filter((id) => id.includes("anthropic."));
}
/** The cross-region prefix of a Bedrock model id or ARN, if it has one. */
export function bedrockRegionPrefix(modelId: string): string | undefined {
const id = modelId.slice(modelId.lastIndexOf("/") + 1).toLowerCase();
return BEDROCK_REGION_PREFIXES.find((prefix) => id.startsWith(`${prefix}.anthropic.`));
}
/**
* Picks the region of the listed Bedrock models: `global` when the gateway
* lists it, else the prefix of the first id that has one. A gateway that lists
* `eu.*` and `us.*` ids gets the prefix that comes first, and the ids of the
* other prefixes are dropped, so the picker offers only models the CLI can
* call under that prefix. Ids without a prefix (`anthropic.claude-…`) are
* dropped too once a prefix is picked; without any prefix every id is kept.
*/
export function selectBedrockRegion(modelIds: readonly string[]): {
regionPrefix?: string;
modelIds: string[];
} {
const prefixes = modelIds.map(bedrockRegionPrefix);
const regionPrefix = prefixes.includes("global")
? "global"
: prefixes.find((prefix) => prefix !== undefined);
if (!regionPrefix) return { modelIds: [...modelIds] };
return {
regionPrefix,
modelIds: modelIds.filter((id) => bedrockRegionPrefix(id) === regionPrefix),
};
}
// Provider headers win over the defaults, matched case-insensitively, the way
// `ANTHROPIC_CUSTOM_HEADERS` overrides the CLI's own headers.
function listingHeaders(
providerHeaders: Record<string, string>,
protocolHeaders: Record<string, string>,
): Headers {
const headers = new Headers({
authorization: `Bearer ${PROVIDER_AUTH_TOKEN_PLACEHOLDER}`,
accept: "application/json",
...protocolHeaders,
});
for (const [name, value] of Object.entries(providerHeaders)) {
// `Headers.set` puts an invalid value into its error message, so name only the header.
try {
headers.set(name, value);
} catch {
throw new GatewayListingError(`Model listing cannot send the invalid header "${name}"`);
}
}
return headers;
}
function stringField(entries: unknown[], field: string): string[] {
return entries.flatMap((entry) => {
const value = (entry as Record<string, unknown> | null)?.[field];
return typeof value === "string" && value.trim() !== "" ? [value] : [];
});
}
/**
* Reads `endpoint` page by page; `parse` returns `undefined` for a body it does
* not understand. A 4xx answer on any page means the gateway has no listing:
* the result is `undefined`, and nothing is narrowed.
*/
async function listPages(
endpoint: string,
firstQuery: Record<string, string>,
headers: Headers,
logger: ListingLogger,
parse: (body: unknown) => ListingPage | undefined,
): Promise<string[] | undefined> {
const { pathname: path, username, password } = new URL(endpoint);
const failure = (reason: string): GatewayListingError => {
const error = new GatewayListingError(`Model listing GET ${path} ${reason}`);
logger.error(`[providers/set] ${error.message}`);
return error;
};
// fetch refuses such a URL and repeats it, credentials included, in its error.
if (username || password) throw failure("cannot be sent: the base URL carries credentials");
const signal = AbortSignal.timeout(LISTING_TIMEOUT_MS);
const ids: string[] = [];
let query: Record<string, string> | undefined = firstQuery;
for (let page = 0; page < MAX_LISTING_PAGES && query; page++) {
const url = new URL(endpoint);
for (const [name, value] of Object.entries(query)) url.searchParams.set(name, value);
let response: Response;
let body: string;
try {
response = await fetch(url, { headers, signal });
body = await response.text();
} catch (error) {
throw failure(`failed: ${error instanceof Error ? error.message : String(error)}`);
}
if (response.status >= 400 && response.status < 500) {
logger.log(
`[providers/set] Model listing GET ${path} returned ${response.status}: the gateway has no listing, models are not narrowed`,
);
return undefined;
}
if (!response.ok) {
throw failure(`returned ${response.status}: ${body.slice(0, MAX_LOGGED_BODY_CHARS)}`);
}
let parsed: ListingPage | undefined;
try {
parsed = parse(JSON.parse(body));
} catch {
parsed = undefined;
}
if (!parsed) throw failure("returned an unexpected body");
ids.push(...parsed.ids);
query = parsed.nextQuery;
}
if (query) {
logger.error(
`[providers/set] Model listing GET ${path} still had more pages after ${MAX_LISTING_PAGES}; using what was read`,
);
}
return ids;
}

View File

@@ -1,4 +1,4 @@
import { RequestError } from "@agentclientprotocol/sdk";
import { RequestError } from "@agentclientprotocol/sdk/unstable";
import type { SDKActiveGoalMessage } from "@anthropic-ai/claude-agent-sdk";
export const GOAL_EXTENSION_VERSION = 1 as const;

View File

@@ -32,7 +32,7 @@
* until the next prompt. Awaiting it would add a CLI run to every turn.
*/
import { RequestError } from "@agentclientprotocol/sdk";
import { RequestError } from "@agentclientprotocol/sdk/unstable";
import type { AccountInfo, Query } from "@anthropic-ai/claude-agent-sdk";
export function shouldHideClaudeAuth(): boolean {

240
src/idle-compaction.ts Normal file
View File

@@ -0,0 +1,240 @@
export type PromptCacheTtl = "5m" | "1h";
export type IdleCompactionHost = {
/** Claude is working (`startTurn`'s check) or a permission request / user input is pending. */
isBusy(): boolean;
/** Context size after the last main-thread request, or undefined if unknown. */
contextTokens(): number | undefined;
/** Current merged Claude Code settings. */
settings(): {
idleCompaction?: boolean;
autoCompactEnabled?: boolean;
env?: Record<string, string | undefined>;
};
/** Runs `/compact` as an adapter-originated turn; settles with that turn. */
compact(): Promise<unknown>;
/** Debug-level log. */
log(message: string): void;
};
export type IdleCompactionOptions = {
env?: Record<string, string | undefined>;
setTimeout?: (callback: () => void, ms: number) => unknown;
};
/** The longest delay `setTimeout` takes; a longer one fires at once. */
const MAX_TIMER_MS = 2_147_483_647;
const HOUR_MS = 3_600_000;
const DEFAULT_MIN_TOKENS = 200_000;
const MIN_TOKENS_FLOOR = 100_000;
// 54 min for a 1h cache: leaves a margin for the gap between the provider
// writing the cache and the adapter receiving the response.
const DEFAULT_DELAY_MS = 0.9 * HOUR_MS;
// Node timers do not run while the machine sleeps, so one can fire hours after
// its deadline, on a cache that expired meanwhile. Same threshold as Claude Code.
const LAPSED_AFTER_MS = 60_000;
// The values Claude Code reads as "on" for env flags such as DISABLE_COMPACT
// (case-insensitive, trimmed).
const TRUTHY_ENV = new Set(["1", "true", "yes", "on"]);
function isTruthyEnv(value: string | undefined): boolean {
return value !== undefined && TRUTHY_ENV.has(value.trim().toLowerCase());
}
function parseCacheTtl(usage: unknown): PromptCacheTtl | undefined {
const creation = (usage as { cache_creation?: unknown } | null | undefined)?.cache_creation;
if (typeof creation !== "object" || creation === null) return undefined;
const { ephemeral_1h_input_tokens: hour, ephemeral_5m_input_tokens: fiveMin } = creation as {
ephemeral_1h_input_tokens?: unknown;
ephemeral_5m_input_tokens?: unknown;
};
if (typeof hour === "number" && hour > 0) return "1h";
if (typeof fiveMin === "number" && fiveMin > 0) return "5m";
return undefined;
}
/**
* Compacts a session shortly before its one-hour prompt cache expires, so the
* next prompt writes a short summary instead of the whole context. Claude Code
* has this feature but arms it only for "attended" sessions, which SDK sessions
* are not. One instance per session.
*/
export class IdleCompaction {
private readonly env: Record<string, string | undefined>;
private readonly setTimer: (callback: () => void, ms: number) => unknown;
private ttl: PromptCacheTtl | undefined;
private lastRequestAt: number | undefined;
private lastMessageId: string | undefined;
/** Set when `/compact` was sent, cleared by the next request: a compaction that failed,
* or left the context large, would otherwise re-arm at once and loop until the user prompts. */
private compactedSinceLastRequest = false;
private timer: unknown;
private compacting = false;
private disposed = false;
private warnedInvalidDelay = false;
/** `status` of the last `rate_limit_event`; unknown until one arrives. */
private rateLimitStatus: string | undefined;
constructor(
private readonly host: IdleCompactionHost,
options: IdleCompactionOptions = {},
) {
this.env = options.env ?? process.env;
this.setTimer = options.setTimeout ?? ((cb, ms) => setTimeout(cb, ms));
}
/** Whether an idle compaction it started is still running. `acp-agent.ts` reports
* `trigger: "idle"` while it is true. A client's `/compact` queued behind is read
* only after the adapter's turn's `result`, which clears `compacting`; this relies
* on the CLI emitting that compaction's `compact_boundary` after the previous
* result, which nothing here enforces. */
get isCompacting(): boolean {
return this.compacting;
}
/** A main-thread response arrived. Records its time and cache TTL, and drops any armed timer.
* The provider writes the cache when the request starts, so the deadline is measured from the
* response's first sighting: the `message_start` stream event, or its first `assistant` message
* when it was not streamed. The CLI sends one `assistant` message per content block, and a
* 7-minute response would otherwise put the deadline 7 minutes after the cache was written.
* A later call with the same `messageId` only updates the TTL. */
noteRequest(usage: unknown, messageId?: string): void {
if (this.disposed) return;
this.ttl = parseCacheTtl(usage) ?? this.ttl;
if (messageId !== undefined && messageId === this.lastMessageId) return;
this.lastMessageId = messageId;
this.lastRequestAt = Date.now();
this.compactedSinceLastRequest = false;
this.disarm("new request");
}
/** The session became idle. Arms the timer if every condition holds, else logs why not. */
onIdle(): void {
if (this.disposed) return;
const skip = this.skipReason();
if (skip) {
this.host.log(`[idle-compaction] skip: ${skip}`);
return;
}
this.disarm("rearm");
const deadline = this.lastRequestAt! + this.delayMs();
this.timer = this.setTimer(() => void this.fire(), Math.max(0, deadline - Date.now()));
(this.timer as { unref?: () => void } | undefined)?.unref?.();
this.host.log(`[idle-compaction] armed deadline=${new Date(deadline).toISOString()}`);
}
/** A `rate_limit_event` arrived (`SDKRateLimitInfo`). Anything but `allowed` skips compaction,
* as in Claude Code. This is account state, so `reset()` keeps it. */
noteRateLimit(info: unknown): void {
const status = (info as { status?: unknown } | null | undefined)?.status;
if (typeof status === "string") this.rateLimitStatus = status;
}
/** Claude Code reported its compaction as failed. Its turn still ends normally, so `compact()` does not reject. */
noteCompactionFailed(error: string | undefined): void {
if (!this.compacting) return;
this.host.log(`[idle-compaction] compaction failed: ${error ?? "no reason given"}`);
}
/** The conversation was replaced (`/clear`): forgets the recorded request and TTL, and drops any armed timer. */
reset(): void {
this.ttl = undefined;
this.lastRequestAt = undefined;
this.lastMessageId = undefined;
this.compactedSinceLastRequest = false;
this.disarm("conversation reset");
}
/** Drops the armed timer, if any. `reason` is only logged. */
disarm(reason: string): void {
if (this.timer === undefined) return;
clearTimeout(this.timer as ReturnType<typeof setTimeout>);
this.timer = undefined;
this.host.log(`[idle-compaction] disarmed: ${reason}`);
}
/** Disarms and ignores every later call. */
dispose(): void {
this.disarm("disposed");
this.disposed = true;
}
private async fire(): Promise<void> {
this.timer = undefined;
if (this.disposed) return;
const skip = this.skipReason();
if (skip) {
this.host.log(`[idle-compaction] skip at fire: ${skip}`);
return;
}
this.host.log("[idle-compaction] firing /compact");
this.compacting = true;
this.compactedSinceLastRequest = true;
try {
await this.host.compact();
} catch (error) {
this.host.log(`[idle-compaction] compaction failed: ${error}`);
} finally {
this.compacting = false;
}
}
/** Why no compaction should be scheduled right now, or undefined if all conditions hold. */
private skipReason(): string | undefined {
if (this.compacting) return "compaction already running";
const disabled = this.disabledBy();
if (disabled) return `disabled by ${disabled}`;
if (this.lastRequestAt === undefined) return "no request recorded in this process";
if (Date.now() > this.lastRequestAt + this.delayMs() + LAPSED_AFTER_MS) {
return "cache lapsed: more than 60 s past the deadline";
}
if (this.rateLimitStatus !== undefined && this.rateLimitStatus !== "allowed") {
return `rate limit status is ${this.rateLimitStatus}`;
}
if (this.compactedSinceLastRequest) return "already compacted for this request";
if (this.ttl !== "1h") return `cache TTL is ${this.ttl ?? "unknown"}, not 1h`;
const tokens = this.host.contextTokens();
const min = this.minTokens();
if (tokens === undefined || tokens < min) {
return `context ${tokens ?? "unknown"} tokens is below the ${min} minimum`;
}
if (this.host.isBusy()) return "session is busy";
return undefined;
}
private disabledBy(): string | undefined {
const settings = this.host.settings();
if (settings.idleCompaction === false) return "idleCompaction setting";
if (settings.autoCompactEnabled === false) return "autoCompactEnabled setting";
for (const name of ["DISABLE_COMPACT", "DISABLE_AUTO_COMPACT"]) {
if (isTruthyEnv(this.claudeCodeEnv(name))) return name;
}
return undefined;
}
/** The CLI applies the settings `env` block after the launch env, so it wins. */
private claudeCodeEnv(name: string): string | undefined {
return this.host.settings().env?.[name] ?? this.env[name];
}
/** `0` and invalid values fall back to 200,000, as in the CLI, which declares the variable
* with `min: 1` and computes `Math.max(100000, env ?? 200000)`. */
private minTokens(): number {
const raw = this.claudeCodeEnv("CLAUDE_CODE_IDLE_COMPACT_MIN_TOKENS");
const parsed = raw !== undefined && /^\d+$/.test(raw.trim()) ? Number(raw.trim()) : 0;
return Math.max(MIN_TOKENS_FLOOR, parsed > 0 ? parsed : DEFAULT_MIN_TOKENS);
}
private delayMs(): number {
const raw = this.env.CLAUDE_AGENT_ACP_IDLE_COMPACT_DELAY_MS;
if (raw === undefined) return DEFAULT_DELAY_MS;
if (/^\d+$/.test(raw.trim()) && Number(raw.trim()) <= MAX_TIMER_MS) return Number(raw.trim());
if (!this.warnedInvalidDelay) {
this.warnedInvalidDelay = true;
this.host.log(
`[idle-compaction] ignoring invalid CLAUDE_AGENT_ACP_IDLE_COMPACT_DELAY_MS=${raw}`,
);
}
return DEFAULT_DELAY_MS;
}
}

View File

@@ -1,8 +1,8 @@
#!/usr/bin/env node
import { claudeCliPath } from "./acp-agent.js";
import { claudeCliPath } from "./claude-cli.js";
import { applyManagedPolicyEnv } from "./managed-policy.js";
import { serveAcp } from "./serve.js";
import { acpProtocolsFromEnv, serveAcp } from "./serve.js";
import packageJson from "../package.json" with { type: "json" };
import { appendFileSync, mkdirSync } from "node:fs";
import { join } from "node:path";
@@ -83,9 +83,8 @@ if (process.argv.includes("--cli")) {
: undefined;
logger?.log("Claude ACP started");
// The draft ACP v2 is opt-in while it is a draft.
const acp = serveAcp(logger, {
experimentalV2: process.env.CLAUDE_AGENT_ACP_EXPERIMENTAL_V2 === "1",
protocols: acpProtocolsFromEnv(process.env.CLAUDE_AGENT_ACP_PROTOCOL),
});
async function shutdown() {

View File

@@ -1,15 +1,9 @@
// Export the main agent class and utilities for library usage
export {
ClaudeAcpAgent,
isLocalCommandMetadata,
stripLocalCommandMetadata,
runAcp,
toAcpNotifications,
streamEventToAcpNotifications,
type ToolUpdateMeta,
type NewSessionMeta,
type SDKMessageFilter,
} from "./acp-agent.js";
export { ClaudeAcpAgent, runAcp } from "./acp-agent.js";
export { isLocalCommandMetadata, stripLocalCommandMetadata } from "./message-filters.js";
export { toAcpNotifications, streamEventToAcpNotifications } from "./sdk-to-acp.js";
export type { NewSessionMeta, SDKMessageFilter } from "./new-session-meta.js";
export type { ToolUpdateMeta } from "./tool-calls/renderer.js";
export { nodeToWebReadable, nodeToWebWritable, Pushable, unreachable } from "./utils.js";
export {
toolInfoFromToolUse,

9
src/logger.ts Normal file
View File

@@ -0,0 +1,9 @@
/**
* Logger interface for customizing logging output
*/
export interface Logger {
log: (...args: any[]) => void;
error: (...args: any[]) => void;
/** Optional: a caller that supplies no `warn` gets its warnings on `error`. */
warn?: (...args: any[]) => void;
}

View File

@@ -1,4 +1,4 @@
import type { AvailableCommand } from "@agentclientprotocol/sdk";
import type { AvailableCommand } from "@agentclientprotocol/sdk/unstable";
import type { McpServerStatus, Query } from "@anthropic-ai/claude-agent-sdk";
import { escapeMarkdown } from "./usage-markdown.js";
import { raceTimeoutAndAbort } from "./utils.js";
@@ -111,8 +111,11 @@ function needsReconnect(status: McpServerStatus): boolean {
function codeSpan(text: string): string {
const longestRun = Math.max(0, ...(text.match(/`+/g) ?? []).map((run) => run.length));
const fence = "`".repeat(longestRun + 1);
// CommonMark strips one space from each end of a span, so a pad keeps the text unchanged.
const pad = text.startsWith("`") || text.endsWith("`") || /^ .*[^ ].* $/.test(text) ? " " : "";
// CommonMark strips one space from each end of a span that is not all spaces
// (line endings count as spaces), so a pad keeps the text unchanged. Plain
// string checks, not one regex: `^ .*[^ ].* $` backtracks quadratically.
const strippedByCommonMark = text.startsWith(" ") && text.endsWith(" ") && /[^ \r\n]/.test(text);
const pad = text.startsWith("`") || text.endsWith("`") || strippedByCommonMark ? " " : "";
return `${fence}${pad}${text}${pad}${fence}`;
}

214
src/mcp-oauth.ts Normal file
View File

@@ -0,0 +1,214 @@
/**
* OAuth for the MCP servers that Claude Code reports as `needs-auth`, as an
* ACP URL elicitation. Protocol-neutral: the v1 core and the v2 core
* both start it once a session's setup response is sent.
*/
import { randomUUID } from "node:crypto";
import type {
CompleteElicitationNotification,
CreateElicitationRequest,
CreateElicitationResponse,
} from "@agentclientprotocol/sdk/unstable";
import type { McpServerStatus, Query } from "@anthropic-ai/claude-agent-sdk";
/** Claude Code keeps the OAuth callback listener open in the background after
* `mcpAuthenticate` returns the authorization URL. The SDK does not expose
* that listener's completion promise, so watch the server status while the
* ACP client has the URL elicitation open. */
const MCP_OAUTH_STATUS_POLL_MS = 1_000;
const MCP_OAUTH_TIMEOUT_MS = 10 * 60_000;
/** Runtime MCP OAuth control exposed by the pinned Agent SDK. It is not yet in
* the public `Query` declaration, even though the method is present on the
* SDK query object and backed by Claude Code's `mcp_authenticate` control. */
type McpOAuthQuery = Query & {
mcpAuthenticate(
serverName: string,
redirectUri?: string,
): Promise<{
authUrl?: string;
requiresUserAction: boolean;
callbackExpected: boolean;
redirectScheme?: "localhost" | "custom";
callbackPort?: number;
state?: string;
}>;
};
export function supportsMcpOAuth(query: Query): query is McpOAuthQuery {
return typeof (query as Partial<McpOAuthQuery>).mcpAuthenticate === "function";
}
/** What an OAuth run needs of the client connection. */
export type McpOAuthClient = {
/** Whether the client advertised URL elicitation. Without it, OAuth does not start. */
readonly urlElicitation: boolean;
createElicitation(
params: CreateElicitationRequest,
signal: AbortSignal,
): Promise<CreateElicitationResponse>;
completeElicitation(params: CompleteElicitationNotification): Promise<void>;
readonly logger: { error: (...args: unknown[]) => void };
};
/** What an OAuth run needs of the session. */
export type McpOAuthSession = {
/** One OAuth run at a time per key: the session object. */
readonly key: object;
readonly sessionId: string;
/** The session's query now, or `undefined` once the session is gone. */
currentQuery(): Query | undefined;
/** Aborts when the session closes. */
readonly closed: AbortSignal;
};
const mcpAuthentications = new WeakMap<object, Promise<void>>();
/** Start OAuth in the background for the servers in `requestedServers` that
* Claude reports as needing authentication. It does nothing while another
* OAuth run of the session is in progress. */
export function startMcpServerAuthentication(
client: McpOAuthClient,
session: McpOAuthSession,
requestedServers: Set<string>,
): void {
if (!client.urlElicitation || requestedServers.size === 0) return;
const query = session.currentQuery();
if (!query || mcpAuthentications.has(session.key)) return;
const authentication = authenticateMcpServers(client, session, query, requestedServers)
.catch((error) => {
if (!session.closed.aborted) {
client.logger.error(`Failed to inspect MCP servers for OAuth: ${error}`);
}
})
.finally(() => {
if (mcpAuthentications.get(session.key) === authentication) {
mcpAuthentications.delete(session.key);
}
});
mcpAuthentications.set(session.key, authentication);
}
async function authenticateMcpServers(
client: McpOAuthClient,
session: McpOAuthSession,
query: Query,
requestedServers: Set<string>,
): Promise<void> {
if (!supportsMcpOAuth(query)) {
client.logger.error("The Claude Agent SDK does not expose MCP OAuth authentication.");
return;
}
const statuses = await query.mcpServerStatus();
for (const status of statuses) {
if (status.status !== "needs-auth" || !requestedServers.has(status.name)) continue;
try {
await authenticateMcpServer(client, session, query, status.name);
} catch (error) {
if (session.currentQuery() && !session.closed.aborted) {
client.logger.error(`Failed to authenticate MCP server ${status.name}: ${error}`);
}
}
}
}
/** Bridge Claude Code's startup MCP OAuth control to ACP URL elicitation.
* Claude opens and owns the localhost callback listener; the ACP client only
* needs to present the returned authorization URL. */
async function authenticateMcpServer(
client: McpOAuthClient,
session: McpOAuthSession,
query: McpOAuthQuery,
serverName: string,
): Promise<void> {
if (!session.currentQuery()) return;
const login = await query.mcpAuthenticate(serverName);
if (!login.requiresUserAction) return;
if (!login.authUrl) {
throw new Error("Claude Code requested user action without returning an authorization URL");
}
const elicitationId = `mcp-oauth-${randomUUID()}`;
const flowAbort = new AbortController();
const abortFlow = () => flowAbort.abort(session.closed.reason);
session.closed.addEventListener("abort", abortFlow, { once: true });
if (session.closed.aborted) abortFlow();
try {
const completed = waitForMcpAuthentication(session, query, serverName, flowAbort.signal);
const elicitation = client.createElicitation(
{
mode: "url",
sessionId: session.sessionId,
message: `Authenticate with MCP server ${serverName}`,
url: login.authUrl,
elicitationId,
},
flowAbort.signal,
);
const first = await Promise.race([
completed.then((authenticated) => ({ type: "completed" as const, authenticated })),
elicitation.then((response) => ({ type: "elicitation" as const, response })),
]);
// A url-mode accept carries no content to check. The guard
// `CreateElicitationResponse.isAccept` would also reject an accept whose
// `content` holds a v2-only value, such as an object.
if (first.type === "elicitation" && first.response.action !== "accept") {
return;
}
if (first.type === "elicitation") {
await completed;
}
try {
await client.completeElicitation({ elicitationId });
} catch (error) {
if (!flowAbort.signal.aborted) {
client.logger.error(`Failed to complete MCP OAuth elicitation: ${error}`);
}
}
} finally {
flowAbort.abort();
session.closed.removeEventListener("abort", abortFlow);
}
}
async function waitForMcpAuthentication(
session: McpOAuthSession,
query: Query,
serverName: string,
signal: AbortSignal,
): Promise<boolean> {
const deadline = Date.now() + MCP_OAUTH_TIMEOUT_MS;
while (!signal.aborted && Date.now() < deadline) {
if (session.currentQuery() !== query) return false;
const status: McpServerStatus | undefined = (await query.mcpServerStatus()).find(
(server) => server.name === serverName,
);
if (!status || status.status === "failed" || status.status === "disabled") return false;
if (status.status === "connected") return true;
if (!(await waitUnlessAborted(MCP_OAUTH_STATUS_POLL_MS, signal))) return false;
}
return false;
}
/** Wait for a polling interval, resolving false when the session is aborted. */
export function waitUnlessAborted(milliseconds: number, signal: AbortSignal): Promise<boolean> {
if (signal.aborted) return Promise.resolve(false);
return new Promise((resolve) => {
const timer = setTimeout(() => {
signal.removeEventListener("abort", onAbort);
resolve(true);
}, milliseconds);
const onAbort = () => {
clearTimeout(timer);
resolve(false);
};
signal.addEventListener("abort", onAbort, { once: true });
});
}

33
src/mcp-servers.ts Normal file
View File

@@ -0,0 +1,33 @@
import type { McpServer } from "@agentclientprotocol/sdk/unstable";
import type { McpServerConfig } from "@anthropic-ai/claude-agent-sdk";
/**
* The SDK configs of a session's ACP MCP servers, by name. HTTP and SSE
* servers keep their URL and headers; a server without `type` is stdio. A
* server of any other transport is left out: the session's `initialize`
* advertises none.
*/
export function sdkMcpServers(
servers: readonly McpServer[] | null | undefined,
): Record<string, McpServerConfig> {
const configs: Record<string, McpServerConfig> = {};
for (const server of servers ?? []) {
if ("type" in server && (server.type === "http" || server.type === "sse")) {
configs[server.name] = {
type: server.type,
url: server.url,
headers: server.headers
? Object.fromEntries(server.headers.map((e) => [e.name, e.value]))
: undefined,
};
} else if (!("type" in server)) {
configs[server.name] = {
type: "stdio",
command: server.command,
args: server.args,
env: server.env ? Object.fromEntries(server.env.map((e) => [e.name, e.value])) : undefined,
};
}
}
return configs;
}

217
src/message-filters.ts Normal file
View File

@@ -0,0 +1,217 @@
/**
* The Claude Code messages and content that a client never sees as the user's
* or the agent's text: local slash command markers, context the CLI injects,
* the synthetic `/login` message and meta messages from other agents. The live
* stream and the `session/load` replay both filter with these.
*/
import { isLoginRequiredText } from "./result-end.js";
import { isSyntheticUsageLimitMessage } from "./session-failure-extension.js";
import { markerTagText, TASK_NOTIFICATION_TAG } from "./task-notifications.js";
// Slash commands that the SDK handles locally without replaying the user
// message and without invoking the model.
export const LOCAL_ONLY_COMMANDS = new Set(["/context", "/heapdump", "/extra-usage"]);
// Commands whose marker-only transcript records are ACP/client-local UI noise,
// not model-visible user prompts. Marker-only custom slash skills are not in
// this list, so replay can reconstruct them from `<command-name>`/`<command-args>`.
const REPLAY_HIDDEN_COMMANDS = new Set([
...LOCAL_ONLY_COMMANDS,
"/compact",
"/model",
"/mcp",
"/status",
"/usage",
]);
// The Claude SDK persists local slash command invocations (e.g. `/model`) and
// their output as user messages in the session transcript, wrapping the
// payload in these XML-like markers that the CLI uses for its own display.
// The live prompt loop drops them; replay must strip them too or they leak
// into the UI on session/load.
const LOCAL_COMMAND_MARKERS = [
"command-name",
"command-message",
"command-args",
"local-command-stdout",
"local-command-stderr",
].map((tag) => ({ open: `<${tag}>`, close: `</${tag}>` }));
// Context the CLI injects into a user turn to steer the model, appended to
// whatever the user typed. Nobody wrote it and no client sees it live — the
// prompt loop's user-message skip covers the whole message — but it is
// persisted alongside that prose, so replay would hand the client a prompt
// with instructions in it the user never gave. A `task-notification` tells the
// model that a background task stopped; live, the SDK `task_notification`
// frame reports the same stop, so replay restores the task state from it (see
// taskNotificationsOf) instead of showing it.
const INJECTED_CONTEXT_MARKERS = ["system-reminder", TASK_NOTIFICATION_TAG].map((tag) => ({
open: `<${tag}>`,
close: `</${tag}>`,
}));
// Everything a replayed user message may be wrapped in that is not speech.
const TRANSCRIPT_MARKERS = [...LOCAL_COMMAND_MARKERS, ...INJECTED_CONTEXT_MARKERS];
// Single-pass scanner that removes each `<tag>…</tag>` marker (matching the
// nearest closing tag of the same name, like a lazy regex would).
function stripMarkerTags(text: string): string {
const dead = new Set<string>();
let result = "";
let copiedUpTo = 0;
let i = 0;
while (i < text.length) {
if (text[i] === "<") {
const marker = TRANSCRIPT_MARKERS.find(
(m) => !dead.has(m.open) && text.startsWith(m.open, i),
);
if (marker) {
const end = text.indexOf(marker.close, i + marker.open.length);
if (end !== -1) {
result += text.slice(copiedUpTo, i);
i = copiedUpTo = end + marker.close.length;
continue;
}
// No closing marker remains anywhere ahead, and `indexOf` only ever
// searches forward from here on, so stop treating this tag as an
// opener — that avoids rescanning the tail for it on every match.
dead.add(marker.open);
}
}
i++;
}
return result + text.slice(copiedUpTo);
}
function hasMarkerTag(text: string, tag: string): boolean {
return markerTagText(text, tag) !== undefined;
}
function commandInvocationFromMarkerOnlyText(text: string): string | null {
if (hasMarkerTag(text, "local-command-stdout") || hasMarkerTag(text, "local-command-stderr")) {
return null;
}
const commandName = markerTagText(text, "command-name")?.trim();
if (!commandName?.startsWith("/")) return null;
if (REPLAY_HIDDEN_COMMANDS.has(commandName.split(" ", 1)[0])) return null;
const commandArgs = markerTagText(text, "command-args")?.trim();
return commandArgs ? `${commandName} ${commandArgs}` : commandName;
}
function stripLocalCommandMetadataText(text: string): string | null {
const stripped = stripMarkerTags(text);
if (stripped.trim() !== "") return stripped;
return commandInvocationFromMarkerOnlyText(text);
}
/**
* Return user-message content with local-command and injected-context marker
* tags removed, or `null` if nothing meaningful remains (caller should skip
* the message). Preserves real prose that's mixed in alongside the markers —
* e.g. a message like `<command-name>…</command-name>hi` becomes `hi`, and
* `hi<system-reminder>…</system-reminder>` becomes `hi`.
*/
export function stripLocalCommandMetadata(content: unknown): unknown | null {
if (typeof content === "string") {
return stripLocalCommandMetadataText(content);
}
if (!Array.isArray(content)) return content;
const kept: unknown[] = [];
for (const block of content) {
if (
block &&
typeof block === "object" &&
"type" in block &&
(block as { type: unknown }).type === "text" &&
"text" in block &&
typeof (block as { text: unknown }).text === "string"
) {
const stripped = stripLocalCommandMetadataText((block as { text: string }).text);
if (stripped === null) continue;
kept.push({ ...(block as object), text: stripped });
} else {
kept.push(block);
}
}
if (kept.length === 0) return null;
return kept;
}
export function isLocalCommandMetadata(content: unknown): boolean {
return stripLocalCommandMetadata(content) === null;
}
/**
* True for the synthetic assistant message the CLI injects into the transcript
* when a turn fails authentication (e.g. "Not logged in · Please run /login",
* "Session expired. Please run /login to sign in again."). The `/login`
* instruction is Claude Code TUI-specific and meaningless to ACP clients
* (issue #863). The live prompt loop suppresses the text and fails the turn
* with `authRequired` so the client can run its own auth flow; replay must
* skip it too — both for parity with what the client saw live and because the
* message stays in the transcript forever, so it would resurface on every
* session/load even after the user has logged back in.
*
* Takes the API message (`message.message`), which replay only knows as
* `unknown`. The persisted record's structured `error` marker is stripped by
* `getSessionMessages`, and the CLI does not always set it to
* `authentication_failed` anyway: an expired login with no refresh token
* ("Login expired · Run /login to sign in again, …") arrives as
* `invalid_request`. So the synthetic model + text is what both paths match on.
* Usage-limit banners are excluded because some of them also mention `/login`
* (to switch accounts) and have their own handling.
*/
export function isSyntheticLoginMessage(apiMessage: unknown): boolean {
if (!apiMessage || typeof apiMessage !== "object") {
return false;
}
const { model, content } = apiMessage as { model?: unknown; content?: unknown };
if (model !== "<synthetic>" || !Array.isArray(content) || content.length !== 1) {
return false;
}
const block = content[0] as { type?: unknown; text?: unknown } | null;
return (
!!block &&
block.type === "text" &&
typeof block.text === "string" &&
isLoginRequiredText(block.text) &&
!isSyntheticUsageLimitMessage(apiMessage)
);
}
/** Origin kinds of the meta user messages `getSessionMessages` returns since
* SDK 0.3.284 (messages from other agents, sessions and channels). */
const REPLAY_HIDDEN_META_ORIGIN_KINDS = new Set([
"peer",
"channel",
"observer",
"observer-activity",
"slack-ping",
]);
/**
* True for a transcript message delivered to the model from another agent,
* session or channel. Since SDK 0.3.284 `getSessionMessages` returns these as
* `is_meta` user messages whose content is the full harness framing (envelope
* XML plus the "not typed by your user" preamble). The live prompt loop never
* renders them, so replay skips them too rather than presenting them as text
* the user typed. Other `is_meta` messages (compact summaries) are kept.
*
* `is_meta` and `origin` are runtime fields not declared on `SessionMessage`.
*/
export function isReplayHiddenMetaMessage(message: unknown): boolean {
if (!message || typeof message !== "object") return false;
const { type, is_meta, origin } = message as {
type?: unknown;
is_meta?: unknown;
origin?: { kind?: unknown } | null;
};
return (
type === "user" &&
is_meta === true &&
typeof origin?.kind === "string" &&
REPLAY_HIDDEN_META_ORIGIN_KINDS.has(origin.kind)
);
}

File diff suppressed because it is too large Load Diff

41
src/new-session-meta.ts Normal file
View File

@@ -0,0 +1,41 @@
import type { Options, SDKMessageOrigin } from "@anthropic-ai/claude-agent-sdk";
export type SDKMessageFilter = {
type: string;
subtype?: string;
origin?: SDKMessageOrigin["kind"];
};
/**
* Extra metadata that can be given when creating a new session.
*/
export type NewSessionMeta = {
claudeCode?: {
/**
* Options forwarded to Claude Code when starting a new session.
* Those parameters will be ignored and managed by ACP:
* - cwd
* - includePartialMessages
* - permissionMode
* - canUseTool
* - executable
* Those parameters will be used and updated to work with ACP:
* - hooks (merged with ACP's hooks)
* - mcpServers (merged with ACP's mcpServers)
* - disallowedTools (merged with ACP's disallowedTools)
* - tools (passed through; defaults to claude_code preset if not provided)
* - allowDangerouslySkipPermissions (set to `false` to remove bypassPermissions
* from this session; repeat it on session/load. `true` cannot override root)
*/
options?: Options;
/**
* When set, raw SDK messages are emitted as extNotification("_claude/sdkMessage", message)
* in addition to normal processing.
* - true: emit all messages
* - false/undefined: emit nothing (default)
* - SDKMessageFilter[]: emit only messages matching at least one filter
*/
emitRawSDKMessages?: boolean | SDKMessageFilter[];
};
additionalRoots?: string[];
};

View File

@@ -1,4 +1,4 @@
import type { RequestPermissionResponse } from "@agentclientprotocol/sdk";
import type { RequestPermissionResponse } from "@agentclientprotocol/sdk/unstable";
import type {
PermissionMode,
PermissionResult,

View File

@@ -1,4 +1,4 @@
import type { PermissionMode } from "@anthropic-ai/claude-agent-sdk";
import type { PermissionMode, Settings } from "@anthropic-ai/claude-agent-sdk";
interface PermissionModeLogger {
error: (...args: unknown[]) => void;
@@ -58,3 +58,10 @@ export function resolvePermissionMode(
return mapped;
}
/** Claude Code reads `disableAutoMode` both at the top level and under `permissions`. */
export function autoModeDisabled(settings: Settings): boolean {
return (
settings.disableAutoMode === "disable" || settings.permissions?.disableAutoMode === "disable"
);
}

View File

@@ -1,4 +1,4 @@
import type { PermissionOption } from "@agentclientprotocol/sdk";
import type { PermissionOption } from "@agentclientprotocol/sdk/unstable";
import {
buildEditPermissionOptions,
buildGlobPermissionOptions,

View File

@@ -1,4 +1,4 @@
import type { PermissionOption } from "@agentclientprotocol/sdk";
import type { PermissionOption } from "@agentclientprotocol/sdk/unstable";
import os from "node:os";
import path from "node:path";
import type { DurablePermissionChangeSet } from "../normalization.js";

View File

@@ -1,4 +1,4 @@
import type { PermissionOption } from "@agentclientprotocol/sdk";
import type { PermissionOption } from "@agentclientprotocol/sdk/unstable";
import type { DurablePermissionChangeSet } from "../normalization.js";
export const PERMISSION_OPTION_ID = {
@@ -30,6 +30,9 @@ export interface PermissionOptionContext {
/** The mode the session left when it entered plan mode. */
prePlanMode?: string;
contextUsedPercent?: number;
/** False when no ACP turn can continue an accepted plan in a fresh
* context. Then an ExitPlanMode request offers no clear-context option. */
allowContextReset?: boolean;
}
export function allowOnce(name = "Yes"): PermissionOption {

View File

@@ -1,4 +1,4 @@
import type { PermissionOption } from "@agentclientprotocol/sdk";
import type { PermissionOption } from "@agentclientprotocol/sdk/unstable";
import path from "node:path";
import type { DurablePermissionChangeSet } from "../normalization.js";
import { plainString, type PermissionOptionContext, withOptionalUpdate } from "./shared.js";

View File

@@ -1,4 +1,4 @@
import type { PermissionOption } from "@agentclientprotocol/sdk";
import type { PermissionOption } from "@agentclientprotocol/sdk/unstable";
import {
allowOnce,
exactLocalAllowRule,
@@ -70,7 +70,7 @@ export function buildExitPlanModePermissionOptions(
: (["auto", "bypassPermissions"] as const)
).filter((mode) => modes.has(mode));
const preferredMode = elevatedModes[0] ?? "acceptEdits";
if (plainString(context.input.plan)) {
if (context.allowContextReset !== false && plainString(context.input.plan)) {
const usage =
context.contextUsedPercent === undefined ? "" : ` (${context.contextUsedPercent}% used)`;
options.push(

View File

@@ -1,4 +1,4 @@
import type { RequestPermissionRequest, ToolCallContent } from "@agentclientprotocol/sdk";
import type { RequestPermissionRequest, ToolCallContent } from "@agentclientprotocol/sdk/unstable";
import { AIR_PERMISSION_KEY, withAirMeta } from "../air-extension.js";
import { ClientCapabilities } from "../tool-calls/client-capabilities.js";
import { AcpToolCallRenderer } from "../tool-calls/renderer.js";
@@ -120,18 +120,64 @@ export function buildClaudePermissionPresentation(
}),
// Only AIR gets the permission presentation.
...(title && capabilities.air.client
? {
_meta: withAirMeta(undefined, AIR_PERMISSION_KEY, {
version: 1,
title,
...(description ? { description } : {}),
// The CLI's own hint, forwarded so a client that can pre-select
// an option keeps the decline focused; the option order already
// leads with the reject options when this is set.
...(value.defaultToNo === true ? { defaultToNo: true } : {}),
}),
}
? { _meta: airPermissionMeta({ title, description, defaultToNo: value.defaultToNo }) }
: {}),
...(capabilities.v2 ? { title, ...(description ? { description } : {}) } : {}),
};
}
/**
* The `_meta.jetbrains.air.permission` of a permission request, from the
* heading and explanation that {@link buildClaudePermissionPresentation}
* built. The v2 core adds it for an AIR client, whose tool calls it
* renders without the AIR tool call contract.
*/
export function airPermissionMeta({
title,
description,
defaultToNo,
}: {
title: string;
description?: string;
defaultToNo?: boolean;
}): Record<string, unknown> {
return withAirMeta(undefined, AIR_PERMISSION_KEY, {
version: 1,
title,
...(description ? { description } : {}),
// The CLI's own hint, forwarded so a client that can pre-select an
// option keeps the decline focused; the option order already leads with
// the reject options when this is set.
...(defaultToNo === true ? { defaultToNo: true } : {}),
});
}
/**
* The `_meta.claudeCode` of a permission request's tool call: which MCP
* server serves an `mcp__*` tool and where its definition came from
* (`mcpServer`, SDK 0.3.274+), so a client can key trust on `source`, and,
* for clients other than AIR, the tool name and the Agent or Task call of a
* subagent's request. Undefined when there is nothing to add.
*/
export function permissionToolCallMeta({
toolName,
parentToolUseId,
mcpServer,
airClient,
}: {
toolName: string;
parentToolUseId: string | undefined;
mcpServer: { name: string; source: string } | undefined;
airClient: boolean;
}): { claudeCode: Record<string, unknown> } | undefined {
if (!mcpServer && !(parentToolUseId && !airClient)) return undefined;
// The name is the config key as authored: untrusted text, so it rides
// `_meta` rather than the title.
return {
claudeCode: {
...(airClient ? {} : { toolName }),
...(parentToolUseId && !airClient ? { parentToolUseId } : {}),
...(mcpServer ? { mcpServer: { name: mcpServer.name, source: mcpServer.source } } : {}),
},
};
}

View File

@@ -1,5 +1,5 @@
import type { RequestPermissionResponse } from "@agentclientprotocol/sdk";
import type { PermissionOption } from "@agentclientprotocol/sdk";
import type { RequestPermissionResponse } from "@agentclientprotocol/sdk/unstable";
import type { PermissionOption } from "@agentclientprotocol/sdk/unstable";
import type { PermissionMode, PermissionResult } from "@anthropic-ai/claude-agent-sdk";
import type { DurablePermissionChangeSet } from "./normalization.js";
import { applyClaudePermissionSelection, parseClaudePermissionSelection } from "./effects.js";

110
src/plans.ts Normal file
View File

@@ -0,0 +1,110 @@
/**
* The session updates that show the session's plans. A client with the `plan`
* capability (every v2 client, and a v1 client that asks for it) tracks plans
* by ID and gets `plan_update` and `plan_removed`; any other client gets the
* legacy `plan` update, which holds one ID-less plan per session.
*/
import type {
PlanEntry,
PlanItems,
PlanRemoved,
SessionNotification,
} from "@agentclientprotocol/sdk/unstable";
import { PLAN_MODE_PLAN_ID } from "./exit-plan.js";
type PlanRemovalUpdate = PlanRemoved & { sessionUpdate: "plan_removed" };
/** The ID of the session's checklist, from TodoWrite and the Task tools. Every
* update replaces it whole. An approved ExitPlanMode plan is a separate plan
* ({@link PLAN_MODE_PLAN_ID}), and so is a subagent's TodoWrite checklist in
* its parent's session ({@link subagentChecklistPlanId}). */
export const CHECKLIST_PLAN_ID = "plan";
/** The ID of the TodoWrite checklist of the subagent that the tool call
* `parentToolUseId` launched, when the checklist goes to its parent's session.
* Claude Code keeps a TodoWrite list per agent, so it is a separate plan from
* the main conversation's checklist. The Task tools share one task list
* between all agents of a session, so their checklist is always
* {@link CHECKLIST_PLAN_ID}. */
export function subagentChecklistPlanId(parentToolUseId: string): string {
return `${CHECKLIST_PLAN_ID}:${parentToolUseId}`;
}
/** The plan operation that shows the checklist `entries` as the plan `planId`:
* `plan_update`, or `plan_removed` for an empty checklist. */
export function checklistPlanOperation(
entries: PlanEntry[],
planId: string = CHECKLIST_PLAN_ID,
): { sessionUpdate: "plan_update"; plan: PlanItems & { type: "items" } } | PlanRemovalUpdate {
if (entries.length === 0) return { sessionUpdate: "plan_removed", planId };
return { sessionUpdate: "plan_update", plan: { type: "items", planId, entries } };
}
/** The update that shows the checklist `entries`: a plan operation (see
* {@link checklistPlanOperation}), else a legacy `plan`, which with no entries
* clears the client's plan. */
export function checklistPlanUpdate(
entries: PlanEntry[],
planOperations: boolean,
): SessionNotification["update"] {
return planOperations ? checklistPlanOperation(entries) : { sessionUpdate: "plan", entries };
}
/** The plan IDs of the subagent checklists that the client holds in a session
* (see {@link subagentChecklistPlanId}). */
export type SubagentChecklists = Set<string>;
/**
* The update that shows the TodoWrite checklist `entries` of the agent that
* made the call: the main conversation, or the subagent that the tool call
* `parentToolUseId` launched.
*
* A subagent's checklist gets its own plan ID, recorded in `shown` while the
* client holds it, when it goes to the parent's session. It does not with
* `childSessions` (native subagent sessions), where it goes to the subagent's
* own session, which holds no other checklist. A client without plan
* operations holds one plan per session, so there a subagent's checklist
* replaces the main one.
*/
export function todoWritePlanUpdate(
entries: PlanEntry[],
options: {
planOperations: boolean;
parentToolUseId?: string | null;
childSessions: boolean;
shown?: SubagentChecklists;
},
): SessionNotification["update"] {
const { planOperations, parentToolUseId, childSessions, shown } = options;
if (!planOperations || !parentToolUseId || childSessions) {
return checklistPlanUpdate(entries, planOperations);
}
const planId = subagentChecklistPlanId(parentToolUseId);
if (entries.length === 0) shown?.delete(planId);
else shown?.add(planId);
return checklistPlanOperation(entries, planId);
}
/** The updates that dismiss the plans of a conversation that a reset (`/clear`)
* replaced: the checklist, the subagent checklists in `subagentChecklists`
* (which it then empties), and the approved ExitPlanMode plan. AIR keeps its
* approved plans, each joined to its approval card in the transcript (see
* planModeUpdate). Without plan operations, a legacy empty `plan` clears the
* one plan the client holds. */
export function resetPlanUpdates(
planOperations: boolean,
airClient: boolean,
subagentChecklists?: SubagentChecklists,
): SessionNotification["update"][] {
const updates = [checklistPlanUpdate([], planOperations)];
if (planOperations) {
for (const planId of subagentChecklists ?? []) {
updates.push({ sessionUpdate: "plan_removed", planId });
}
}
subagentChecklists?.clear();
if (planOperations && !airClient) {
updates.push({ sessionUpdate: "plan_removed", planId: PLAN_MODE_PLAN_ID });
}
return updates;
}

92
src/prompt-suggestions.ts Normal file
View File

@@ -0,0 +1,92 @@
/**
* The follow-up prompt the SDK predicts for the next turn.
*
* With `promptSuggestions` on, the CLI emits one `prompt_suggestion` message
* after a turn's `result` holding the prompt it expects the user to send next.
* ACP has no field for it, so it travels as
* `session_info_update._meta.promptSuggestions`: a client that does not read
* the key sees a session info update that says nothing and ignores it.
*
* AIR renders the first non-blank `suggestions[].text` as gray text in an empty
* composer, where Tab fills the composer without sending.
*
* The CLI sends no suggestion on the first turn of a session, after an API
* error, in plan mode, and at the account's usage limit, so most sessions have
* turns that end with no prediction at all.
*/
/** The `_meta` key the suggestion travels under. It names no vendor. */
export const PROMPT_SUGGESTIONS_META_KEY = "promptSuggestions";
/** One predicted prompt, as the `_meta` value carries it. */
export type PromptSuggestionsMetaValue = {
suggestions: { text: string }[];
};
/**
* The suggestion as it goes on the wire, or `null` for a suggestion that says
* nothing: the CLI can predict whitespace, and an empty text would otherwise
* reach the composer as a hint with no content.
*/
export function promptSuggestionText(raw: string | null | undefined): string | null {
const text = raw?.trim();
return text ? text : null;
}
/**
* The `_meta` of the session info update that publishes `text`. `null` sends an
* empty `suggestions` list, which clears the prompt the client holds — the turn
* predicted nothing, so the composer must not keep the previous prediction.
*/
export function promptSuggestionsMeta(text: string | null): Record<string, unknown> {
const value: PromptSuggestionsMetaValue = { suggestions: text === null ? [] : [{ text }] };
return { [PROMPT_SUGGESTIONS_META_KEY]: value };
}
/**
* The prediction a session holds on the client, and whether an arriving
* `prompt_suggestion` still answers the turn the user waits on. Both cores
* keep one per session and send the `_meta` that its methods return as a
* `session_info_update`, to an AIR client only. Undefined: nothing to send.
*/
export class PromptSuggestions {
/** The last text sent (`null` after a clear). Undefined until the first send. */
private published: string | null | undefined;
/**
* Set at a turn's result, cleared when the next prompt arrives. A
* prediction that the next prompt outran (sent before the prediction was
* read off the stream) follows a turn that is answered already, so it is
* dropped instead of published mid-turn.
*/
private expected = false;
/** A result came: the CLI predicts the next prompt right after it. */
resultArrived(): void {
this.expected = true;
}
/** The `_meta` of the CLI's `prompt_suggestion`, if it answers the last result and changes what the client holds. */
suggested(raw: string | null | undefined): Record<string, unknown> | undefined {
if (!this.expected) return undefined;
this.expected = false;
return this.publish(promptSuggestionText(raw));
}
/**
* The `_meta` that clears the prediction: a client prompt spent it, or a
* `conversation_reset` dropped the transcript it came from. It also drops
* a prediction still in flight for the last result. Undefined when the
* client holds none.
*/
clear(): Record<string, unknown> | undefined {
this.expected = false;
return this.publish(null);
}
private publish(text: string | null): Record<string, unknown> | undefined {
// An unchanged prediction is not sent again.
if ((this.published ?? null) === text) return undefined;
this.published = text;
return promptSuggestionsMeta(text);
}
}

129
src/prompt-to-claude.ts Normal file
View File

@@ -0,0 +1,129 @@
/**
* The conversion of an ACP prompt into the Claude Code user message.
*/
import type { PromptRequest } from "@agentclientprotocol/sdk/unstable";
import type { SDKUserMessage } from "@anthropic-ai/claude-agent-sdk";
import { blobResourceToClaude } from "./blob-resources.js";
function formatUriAsLink(uri: string): string {
try {
if (uri.startsWith("file://")) {
const path = uri.slice(7); // Remove "file://"
const name = path.split("/").pop() || path;
return `[@${name}](${uri})`;
} else if (uri.startsWith("zed://")) {
const parts = uri.split("/");
const name = parts[parts.length - 1] || uri;
return `[@${name}](${uri})`;
}
return uri;
} catch {
return uri;
}
}
/** The prompt content block types that `promptToClaude` converts. The agent
* advertises no `audio` prompt capability. */
export const SUPPORTED_PROMPT_CONTENT_TYPES: readonly string[] = [
"text",
"image",
"resource_link",
"resource",
];
export function promptToClaude(prompt: PromptRequest): SDKUserMessage {
const content: any[] = [];
const context: any[] = [];
const firstText = prompt.prompt.find((chunk) => chunk.type === "text");
for (const chunk of prompt.prompt) {
switch (chunk.type) {
case "text": {
let text = chunk.text;
// A slash command is the first text block. Change
// `/mcp:server:command args` to `/server:command (MCP) args`; the
// arguments can span lines.
const mcpMatch =
chunk === firstText ? text.match(/^\/mcp:([^:\s]+):(\S+)(?:\s+([\s\S]*))?$/) : null;
if (mcpMatch) {
const [, server, command, args] = mcpMatch;
text = `/${server}:${command} (MCP)${args ? ` ${args}` : ""}`;
}
content.push({ type: "text", text });
break;
}
case "resource_link": {
const formattedUri = formatUriAsLink(chunk.uri);
content.push({
type: "text",
text: formattedUri,
});
break;
}
case "resource": {
const { uri } = chunk.resource;
// A blob resource becomes an image or document block, or its decoded
// text. A v1 prompt skips a blob of any other type, e.g.
// `application/zip`; a v2 prompt rejects it (see `v1PromptRequest`).
const blob = "blob" in chunk.resource ? blobResourceToClaude(chunk.resource) : undefined;
const text =
"text" in chunk.resource
? chunk.resource.text
: blob?.kind === "text"
? blob.text
: undefined;
if (blob?.kind === "image" || blob?.kind === "document") {
content.push({ type: "text", text: formatUriAsLink(uri) }, blob.block);
} else if (text !== undefined) {
content.push({ type: "text", text: formatUriAsLink(uri) });
context.push({
type: "text",
text: `\n<context ref="${uri}">\n${text}\n</context>`,
});
}
break;
}
case "image":
if (chunk.data) {
content.push({
type: "image",
source: {
type: "base64",
data: chunk.data,
media_type: chunk.mimeType,
},
});
} else if (chunk.uri && chunk.uri.startsWith("http")) {
content.push({
type: "image",
source: {
type: "url",
url: chunk.uri,
},
});
}
break;
// Ignore audio and other unsupported types
default:
break;
}
}
content.push(...context);
return {
type: "user",
message: {
role: "user",
content: content,
},
session_id: prompt.sessionId,
parent_tool_use_id: null,
// ACP prompts are the user's own input relayed by the client. Stamp the
// provenance explicitly: per the SDK, a host wrapping keyboard input must
// send `{kind: "human"}` — an absent `origin` is treated as unattributed
// and fails closed at the CLI's strict isHuman() trust gates (e.g. the
// ultracode keyword opt-in honors only human-originated turns).
origin: { kind: "human" },
};
}

421
src/providers.ts Normal file
View File

@@ -0,0 +1,421 @@
/**
* Client-managed LLM routing, shared by the v1 core and the v2 core:
* the `main` provider of `providers/list`, `providers/set` and
* `providers/disable`, and the legacy gateway logins (`authenticate` or
* `auth/login` with `gateway` or `gateway-bedrock`). Each core holds its own
* {@link ProviderConfig} and gateway login, and builds the query env of its
* sessions with {@link createEnvForProvider}.
*/
import {
RequestError,
type AuthenticateRequest,
type AuthMethod,
type ListProvidersResponse,
type LlmProtocol,
type ProviderInfo,
type SetProviderRequest,
} from "@agentclientprotocol/sdk/unstable";
import {
GatewayListingError,
listBedrockModels,
listGatewayModels,
PROVIDER_AUTH_TOKEN_PLACEHOLDER,
selectBedrockRegion,
} from "./gateway-models.js";
type ProviderLogger = { log: (...args: any[]) => void; error: (...args: any[]) => void };
/**
* Extra metadata for 'gateway' authentication requests.
*/
export type GatewayAuthMeta = {
/**
* These parameters are mapped to environment variables to:
* - Redirect API calls via baseUrl
* - Inject custom headers
* - Bypass the default Claude login requirement
*
* Both members are optional in the type because the payload arrives
* unvalidated from the client. `authenticate` rejects a request that lacks a
* usable `baseUrl`.
*/
gateway?: {
baseUrl?: string;
headers?: Record<string, string>;
};
};
export type GatewayAuthRequest = AuthenticateRequest & { _meta?: GatewayAuthMeta };
export const SUPPORTED_PROTOCOLS: LlmProtocol[] = ["anthropic", "bedrock", "vertex"];
export const PROVIDER_ID = "main";
export const DEFAULT_ANTHROPIC_BASE_URL = "https://api.anthropic.com";
export const DEFAULT_VERTEX_BASE_URL = "https://aiplatform.googleapis.com";
/**
* Vertex needs project + region that the standard `providers/set` payload
* (`apiType`/`baseUrl`/`headers`) does not model, so clients pass them through
* `_meta.claudeCode.vertex`. Required only when `apiType === "vertex"`.
*/
export type SetProviderMeta = {
claudeCode?: {
vertex?: {
projectId: string;
region: string;
};
};
};
/**
* Resolved, non-secret + secret routing config for the `main` provider. This is
* the shared shape produced by both `providers/set` and the legacy gateway auth
* path, and consumed by {@link createEnvForProvider}. `null` means the provider
* is unconfigured (no client-managed routing in effect).
*/
export type ProviderConfig = {
apiType: LlmProtocol;
baseUrl: string;
headers: Record<string, string>;
/** Present only for `apiType === "vertex"`. */
vertex?: {
projectId: string;
region: string;
};
/**
* Model ids an `anthropic` or `bedrock` gateway listed when it was set via
* `providers/set`; the model picker offers only these. Absent when the
* listing failed or the config came from elsewhere (gateway `authenticate`),
* so nothing is narrowed.
*/
listedModels?: string[];
/**
* The cross-region prefix of the model ids a `bedrock` gateway
* listed (`eu` for `eu.anthropic.claude-sonnet-5`), passed to the CLI as
* `ANTHROPIC_BEDROCK_REGION_PREFIX` so it calls the models under that prefix.
*/
bedrockRegionPrefix?: string;
};
/**
* Adapt a legacy gateway `authenticate` request into the shared
* {@link ProviderConfig} shape. Returns `null` when no gateway request is
* present. `methodId` selects the protocol: `gateway-bedrock` → bedrock,
* otherwise anthropic.
*/
export function gatewayRequestToProviderConfig(
request?: GatewayAuthRequest,
): ProviderConfig | null {
// `authenticate` validates the payload before it stores one, so a stored
// request always carries a usable gateway. Re-check the shape here anyway:
// this function decides whether a provider override is active, and the
// `--hide-claude-auth` guard is off while one is.
const gateway = request?._meta?.gateway;
if (!gateway || !isValidBaseUrl(gateway.baseUrl)) {
return null;
}
return {
apiType: request?.methodId === "gateway-bedrock" ? "bedrock" : "anthropic",
baseUrl: gateway.baseUrl,
headers: gateway.headers ?? {},
};
}
/**
* Map a resolved provider config into the Claude Code env vars that redirect API
* traffic and inject headers. Returns an empty object when routing is
* unconfigured. The token/bypass placeholders (`" "`) are required so the CLI
* skips its normal login/credential checks when a gateway is in use.
*/
export function createEnvForProvider(config: ProviderConfig | null): Record<string, string> {
if (!config) {
return {};
}
const resetRouting = {
ANTHROPIC_BASE_URL: "",
ANTHROPIC_BEDROCK_BASE_URL: "",
ANTHROPIC_VERTEX_BASE_URL: "",
CLAUDE_CODE_USE_BEDROCK: "0",
CLAUDE_CODE_USE_VERTEX: "0",
ANTHROPIC_VERTEX_PROJECT_ID: "",
CLOUD_ML_REGION: "",
AWS_REGION: "",
ANTHROPIC_API_KEY: "",
ANTHROPIC_AUTH_TOKEN: "",
CLAUDE_CODE_OAUTH_TOKEN: "",
};
const customHeaders = Object.entries(config.headers)
.map(([key, value]) => `${key}: ${value}`)
.join("\n");
if (config.apiType === "bedrock") {
return {
...resetRouting,
CLAUDE_CODE_USE_BEDROCK: "1",
AWS_BEARER_TOKEN_BEDROCK: PROVIDER_AUTH_TOKEN_PLACEHOLDER, // Bypass local AWS credential checks
ANTHROPIC_BEDROCK_BASE_URL: config.baseUrl,
ANTHROPIC_CUSTOM_HEADERS: customHeaders,
...(config.bedrockRegionPrefix
? { ANTHROPIC_BEDROCK_REGION_PREFIX: config.bedrockRegionPrefix }
: {}),
};
}
if (config.apiType === "vertex") {
// `config.vertex` is guaranteed present for vertex by `unstable_setProvider`
// validation; fall back to empty strings defensively.
// Leaving ANTHROPIC_VERTEX_BASE_URL empty for the default endpoint lets
// Claude Code use its native Vertex endpoint/model resolution. Supplying
// even the default host marks the session as a custom Vertex deployment and
// can hide otherwise-valid models.
return {
...resetRouting,
CLAUDE_CODE_USE_VERTEX: "1",
...(config.baseUrl !== DEFAULT_VERTEX_BASE_URL
? { ANTHROPIC_VERTEX_BASE_URL: config.baseUrl }
: {}),
ANTHROPIC_VERTEX_PROJECT_ID: config.vertex?.projectId ?? "",
CLOUD_ML_REGION: config.vertex?.region ?? "",
ANTHROPIC_CUSTOM_HEADERS: customHeaders,
};
}
return {
...resetRouting,
ANTHROPIC_BASE_URL: config.baseUrl,
ANTHROPIC_CUSTOM_HEADERS: customHeaders,
ANTHROPIC_AUTH_TOKEN: PROVIDER_AUTH_TOKEN_PLACEHOLDER, // Bypass local Claude login checks
};
}
function sameHeaders(a: Record<string, string>, b: Record<string, string>): boolean {
const aKeys = Object.keys(a);
return aKeys.length === Object.keys(b).length && aKeys.every((key) => b[key] === a[key]);
}
/**
* A provider base URL as logs may show it: its origin only. The URL can come
* from the env or the client and can carry credentials (`https://user:key@host`)
* or a key in its path or query, so a log line must not print it whole.
*/
export function loggableBaseUrl(baseUrl: string | undefined): string {
if (baseUrl === undefined) return "native";
try {
return new URL(baseUrl).origin;
} catch {
return "invalid";
}
}
/**
* Validate a provider base URL: must be a non-empty absolute http(s) URL.
*/
export function isValidBaseUrl(baseUrl: string | undefined): baseUrl is string {
if (typeof baseUrl !== "string" || baseUrl.trim() === "") {
return false;
}
let parsed: URL;
try {
parsed = new URL(baseUrl);
} catch {
return false;
}
return parsed.protocol === "http:" || parsed.protocol === "https:";
}
/** The gateway login methods, advertised to a client with `auth._meta.gateway`. */
export function gatewayAuthMethods(): AuthMethod[] {
return (["anthropic", "bedrock"] as const).map((protocol) => ({
id: protocol === "anthropic" ? "gateway" : "gateway-bedrock",
name: "Custom model gateway",
description: "Use a custom gateway to authenticate and access models",
_meta: { gateway: { protocol } },
}));
}
export function isGatewayAuthMethod(methodId: string): boolean {
return methodId === "gateway" || methodId === "gateway-bedrock";
}
/**
* The gateway login `params` asks for, once its payload is usable: the same
* base URL rule as `providers/set`. An unchecked payload either throws a
* `TypeError` deep in session creation, or installs an empty base URL that
* silently turns the `--hide-claude-auth` subscription guard off.
*
* A request that carries no gateway payload at all keeps its historical
* meaning: it installs no override and succeeds. A client that probes the
* method this way must keep working. Only a payload that IS present has to be
* usable.
*/
export function validatedGatewayAuthRequest(params: AuthenticateRequest): GatewayAuthRequest {
const gateway = (params as GatewayAuthRequest)._meta?.gateway;
if (gateway !== undefined && gateway !== null) {
if (typeof gateway !== "object" || !isValidBaseUrl(gateway.baseUrl)) {
throw RequestError.invalidParams(
{ baseUrl: (gateway as { baseUrl?: unknown }).baseUrl },
"`_meta.gateway.baseUrl` must be a non-empty absolute http(s) URL.",
);
}
if (gateway.headers !== undefined && typeof gateway.headers !== "object") {
throw RequestError.invalidParams(
undefined,
"`_meta.gateway.headers` must be an object of header names to values.",
);
}
}
return params as GatewayAuthRequest;
}
/** The native routing of the agent: a gateway login, else the process env. */
export function defaultProviderConfig(gatewayAuthRequest?: GatewayAuthRequest): ProviderConfig {
const gatewayConfig = gatewayRequestToProviderConfig(gatewayAuthRequest);
if (gatewayConfig) {
return gatewayConfig;
}
if (process.env.CLAUDE_CODE_USE_BEDROCK) {
return {
apiType: "bedrock",
baseUrl: process.env.ANTHROPIC_BEDROCK_BASE_URL ?? "https://bedrock-runtime.amazonaws.com",
headers: {},
};
}
if (process.env.CLAUDE_CODE_USE_VERTEX) {
return {
apiType: "vertex",
baseUrl: process.env.ANTHROPIC_VERTEX_BASE_URL ?? DEFAULT_VERTEX_BASE_URL,
headers: {},
};
}
return {
apiType: "anthropic",
baseUrl: process.env.ANTHROPIC_BASE_URL ?? DEFAULT_ANTHROPIC_BASE_URL,
headers: {},
};
}
/** The `providers/list` response for the `main` provider routed by `config`. */
export function listProvidersResponse(config: ProviderConfig): ListProvidersResponse {
const provider: ProviderInfo = {
providerId: PROVIDER_ID,
supported: SUPPORTED_PROTOCOLS,
required: false,
current: { apiType: config.apiType, baseUrl: config.baseUrl },
};
return { providers: [provider] };
}
/**
* The configuration a `providers/set` request asks for. Rejects unknown IDs,
* unsupported protocols, empty or invalid base URLs, and a `vertex` request
* without its project and region, with `invalid_params`.
*/
export function providerConfigFromSetRequest(params: SetProviderRequest): ProviderConfig {
if (params.providerId !== PROVIDER_ID) {
throw RequestError.invalidParams(
{ providerId: params.providerId },
`Unknown provider ID "${params.providerId}"; expected "${PROVIDER_ID}".`,
);
}
if (!SUPPORTED_PROTOCOLS.includes(params.apiType)) {
throw RequestError.invalidParams(
{ apiType: params.apiType, supported: SUPPORTED_PROTOCOLS },
`Unsupported apiType "${params.apiType}" for provider "${PROVIDER_ID}".`,
);
}
if (!isValidBaseUrl(params.baseUrl)) {
throw RequestError.invalidParams(
{ baseUrl: params.baseUrl },
"baseUrl must be a non-empty absolute http(s) URL.",
);
}
const config: ProviderConfig = {
apiType: params.apiType,
baseUrl: params.baseUrl,
headers: params.headers ?? {},
};
// Vertex requires project + region, which the standard payload cannot
// carry, so they arrive via `_meta.claudeCode.vertex`.
if (params.apiType === "vertex") {
const vertex = (params._meta as SetProviderMeta | undefined)?.claudeCode?.vertex;
if (
!vertex ||
typeof vertex.projectId !== "string" ||
vertex.projectId.trim() === "" ||
typeof vertex.region !== "string" ||
vertex.region.trim() === ""
) {
throw RequestError.invalidParams(
undefined,
"vertex apiType requires non-empty `_meta.claudeCode.vertex.projectId` and `_meta.claudeCode.vertex.region`.",
);
}
config.vertex = { projectId: vertex.projectId, region: vertex.region };
}
return config;
}
/**
* Adds to `config` the model ids its gateway lists, and for `bedrock` the
* region prefix picked from them, so the sessions it routes offer only those.
* Setting the same gateway as `current` again reuses its listing. `vertex`
* gateways are not listed. A failed listing throws an `internal_error`.
*/
export async function withListedModels(
config: ProviderConfig,
current: ProviderConfig | undefined,
logger: ProviderLogger,
): Promise<ProviderConfig> {
try {
Object.assign(config, await listProviderModels(config, current, logger));
} catch (error) {
if (error instanceof GatewayListingError) {
throw RequestError.internalError(
{ providerId: PROVIDER_ID, apiType: config.apiType, baseUrl: config.baseUrl },
`Model discovery for provider "${PROVIDER_ID}" failed: ${error.message}`,
);
}
throw error;
}
logger.log(
`[providers/set] listedModels=${config.listedModels?.length ?? "none"} bedrockRegionPrefix=${config.bedrockRegionPrefix ?? "none"}`,
);
return config;
}
async function listProviderModels(
config: ProviderConfig,
current: ProviderConfig | undefined,
logger: ProviderLogger,
): Promise<Pick<ProviderConfig, "listedModels" | "bedrockRegionPrefix">> {
// Reuse needs the very same headers, so a rotated token lists again, and a
// failed listing then fails that `providers/set`. Deliberate: AIR keeps its
// proxy key for the IDE's lifetime and does not rotate it.
if (
current?.listedModels &&
current.apiType === config.apiType &&
current.baseUrl === config.baseUrl &&
sameHeaders(current.headers, config.headers)
) {
return {
listedModels: current.listedModels,
bedrockRegionPrefix: current.bedrockRegionPrefix,
};
}
if (config.apiType === "anthropic") {
return { listedModels: await listGatewayModels(config.baseUrl, config.headers, logger) };
}
if (config.apiType === "bedrock") {
const modelIds = await listBedrockModels(config.baseUrl, config.headers, logger);
if (!modelIds) return {};
const region = selectBedrockRegion(modelIds);
if (region.modelIds.length < modelIds.length) {
logger.log(
`[providers/set] Bedrock models span several region prefixes; using ${region.regionPrefix} and dropping ${modelIds.length - region.modelIds.length} id(s) of other prefixes`,
);
}
return { listedModels: region.modelIds, bedrockRegionPrefix: region.regionPrefix };
}
return {};
}

129
src/remote-control.ts Normal file
View File

@@ -0,0 +1,129 @@
/**
* `/remote-control`, which the adapter serves itself, and the messages that a
* person types in another client of the session (claude.ai or the Claude
* mobile app, over Remote Control). Shared by the v1 core and the v2
* core, which run the command each in their own turn model.
*/
/** The content blocks of a prompt, in v1 or v2 form: the command is one text block. */
type PromptContent = ReadonlyArray<{ type: string; text?: unknown }>;
import type {
Query,
SDKUserMessage,
SDKUserMessageReplay,
SlashCommand,
} from "@anthropic-ai/claude-agent-sdk";
/** Runtime Remote Control control exposed by the pinned Agent SDK. Like
* `mcpAuthenticate`, it is not in the public `Query` declaration yet. The CLI's
* own `/remote-control` is terminal-bound and not offered to SDK hosts, so the
* adapter handles the command itself and answers with the session URL. */
export type RemoteControlQuery = Query & {
enableRemoteControl(
enabled: boolean,
name?: string,
): Promise<{ session_url?: string; connect_url?: string }>;
};
export function supportsRemoteControl(query: Query): query is RemoteControlQuery {
return typeof (query as Partial<RemoteControlQuery>).enableRemoteControl === "function";
}
export const REMOTE_CONTROL_COMMAND_NAME = "remote-control";
export type RemoteControlCommand = { action: "connect"; name?: string } | { action: "disconnect" };
/** Parses `/remote-control [name]` and `/remote-control off` from a single-text
* prompt. Connecting is idempotent, so the command never needs to know whether
* Remote Control is already on. */
export function parseRemoteControlCommand(prompt: PromptContent): RemoteControlCommand | null {
const [first] = prompt;
if (prompt.length !== 1 || first?.type !== "text" || typeof first.text !== "string") return null;
const match = /^\/remote-control(?:\s+([\s\S]*))?$/.exec(first.text.trim());
if (!match) return null;
const argument = match[1]?.trim();
if (argument?.toLowerCase() === "off") return { action: "disconnect" };
return argument ? { action: "connect", name: argument } : { action: "connect" };
}
/** Whether Claude Code offers a slash command of this name to an ACP client.
* This is the rule `getAvailableSlashCommands` applies when it lists the
* commands, so the adapter stands in for the command exactly when the client
* sees the adapter's entry: Claude Code's terminal-bound commands are not
* offered, and a user's own command or skill of the name is never shadowed. */
export function claudeCodeOffersCommand(
commands: readonly SlashCommand[],
terminalCommands: readonly string[] | undefined,
name: string,
): boolean {
return commands.some(
(command) => command.name === name && !terminalCommands?.includes(command.name),
);
}
/**
* The `/remote-control` command of `prompt` that the adapter runs: the query
* serves Remote Control and Claude Code does not offer a command of that name.
* `null` for any other prompt, which goes to Claude Code.
*/
export async function adapterRemoteControlCommand(
prompt: PromptContent,
query: Query,
terminalCommands: readonly string[] | undefined,
): Promise<{ query: RemoteControlQuery; command: RemoteControlCommand } | null> {
const command = parseRemoteControlCommand(prompt);
if (!command || !supportsRemoteControl(query)) return null;
if (
claudeCodeOffersCommand(
await query.supportedCommands(),
terminalCommands,
REMOTE_CONTROL_COMMAND_NAME,
)
) {
return null;
}
return { query, command };
}
/**
* Connects Remote Control (again, if it is already on) or disconnects it, and
* returns the text that tells the user how it went. A failure is in the text:
* this never throws.
*/
export async function runRemoteControlCommand(
query: RemoteControlQuery,
command: RemoteControlCommand,
logError: (message: string) => void,
): Promise<string> {
if (command.action === "disconnect") {
try {
await query.enableRemoteControl(false);
return "Remote Control is off. Run /remote-control to reconnect.";
} catch (error) {
logError(`Failed to disable Remote Control: ${error}`);
return `Could not stop Remote Control: ${error instanceof Error ? error.message : error}`;
}
}
try {
const result = await query.enableRemoteControl(true, command.name);
return result.session_url
? `Remote Control is active. Continue this session from claude.ai/code or the Claude mobile app:\n\n${result.session_url}\n\nRun /remote-control off to disconnect.`
: "Remote Control is active, but Claude Code did not return a session URL.";
} catch (error) {
logError(`Failed to enable Remote Control: ${error}`);
return `Could not start Remote Control: ${error instanceof Error ? error.message : error}`;
}
}
/** A top-level message a person typed in another client of the session, such
* as claude.ai or the Claude mobile app over Remote Control. Tool results and
* the CLI's own synthetic messages are user-role too, but carry no human
* origin. */
export function isRemoteHumanMessage(message: SDKUserMessage | SDKUserMessageReplay): boolean {
return (
message.type === "user" &&
message.origin?.kind === "human" &&
message.parent_tool_use_id === null &&
!message.isSynthetic
);
}

105
src/result-end.ts Normal file
View File

@@ -0,0 +1,105 @@
/**
* How Claude Code's `result` ends the work that it reports, and whether that
* work was the user's turn or a cycle Claude Code started on its own: the
* rules that both cores read a turn's result with (the v1 prompt loop in
* `acp-agent.ts` and `v2/`). Each core adds its own side effects, such as v1's
* session failure category.
*/
import type {
SDKMessage,
SDKMessageOrigin,
SDKResultMessage,
} from "@anthropic-ai/claude-agent-sdk";
export type ResultEnd =
| { stopReason: "refusal" | "max_tokens" | "end_turn" | "max_turn_requests" }
/**
* The result failed the work, with Claude Code's error text. `loginRequired`:
* the text of a `success` result tells the user to run `/login`.
*/
| { error: string; loginRequired: boolean };
/**
* True when CLI error text tells the user to sign in with `/login`. The CLI
* words this several ways — "Not logged in · Please run /login", "Not logged
* in · Run /login", "Login expired · Run /login to sign in again, or
* re-authenticate your Anthropic profile" — so match the "run /login"
* instruction rather than one sentence.
*/
export function isLoginRequiredText(text: string): boolean {
return /\brun \/login\b/i.test(text);
}
/**
* - Any subtype with `stop_reason: "refusal"`: `refusal`, also on an error.
* - `success`: an error with the `/login` text fails the work, then
* `stop_reason: "max_tokens"` is `max_tokens`, then any other error fails
* the work. Otherwise `end_turn`.
* - `error_during_execution`: `max_tokens` as for `success`, then an error
* fails the work. Otherwise `end_turn`.
* - `error_max_turns`, `error_max_budget_usd` and
* `error_max_structured_output_retries`: an error fails the work, whatever
* the stop reason. Otherwise `max_turn_requests`.
*/
export function resultEnd(result: SDKResultMessage): ResultEnd {
if (result.stop_reason === "refusal") return { stopReason: "refusal" };
switch (result.subtype) {
case "success":
if (result.is_error && isLoginRequiredText(result.result)) {
return { error: result.result, loginRequired: true };
}
if (result.stop_reason === "max_tokens") return { stopReason: "max_tokens" };
if (result.is_error) return { error: result.result, loginRequired: false };
return { stopReason: "end_turn" };
case "error_during_execution":
if (result.stop_reason === "max_tokens") return { stopReason: "max_tokens" };
if (result.is_error) return failed(result);
return { stopReason: "end_turn" };
default:
// `error_max_turns`, `error_max_budget_usd`, `error_max_structured_output_retries`.
if (result.is_error) return failed(result);
return { stopReason: "max_turn_requests" };
}
}
function failed(result: Exclude<SDKResultMessage, { subtype: "success" }>): ResultEnd {
return { error: result.errors.join(", ") || result.subtype, loginRequired: false };
}
/** Result-message origin kinds that mark an AUTONOMOUS cycle — work the
* model did on its own (a task-notification followup, a peer/coordinator/
* observer message it handled) rather than the user's prompt. Absent,
* `human`, and `channel` origins are the user's own turn (this adapter's
* prompts arrive as the ACP channel on some CLI configurations — ALL
* channel servers are treated as user, so a foreign channel integration's
* autonomously-handled result is misclassified as the user's; accepted,
* see below), and `auto-continuation` continues the user's turn, so its
* result is the turn's real terminal.
*
* Deliberately fail-OPEN: an unknown future kind defaults to the user
* lane — including `unclassified` (SDK 0.3.232+), the CLI's own "couldn't
* attribute this" marker, which gets the same safe default. Misrouting a
* USER result into the autonomous lane hangs the prompt un-detectably
* (the result is skipped, its trailing idle absorbed as owed, so the
* #825 detector can't fire); misrouting an autonomous result into the
* user lane is the bounded misattribution class this set exists to
* reduce.
*
* Exception: a result from one of these origins that names a pending prompt
* in `user_message_uuids` consumed that folded prompt and takes the user
* lane (see the result handler of each core). */
export const AUTONOMOUS_RESULT_ORIGINS: ReadonlySet<SDKMessageOrigin["kind"]> = new Set([
"task-notification",
"peer",
"coordinator",
"observer",
"observer-activity",
]);
/** Whether Claude Code ended the work of `result` because an interrupt
* stopped it, while the model streamed or while a tool ran. */
export function isAbortedResult(result: Extract<SDKMessage, { type: "result" }>): boolean {
return (
result.terminal_reason === "aborted_streaming" || result.terminal_reason === "aborted_tools"
);
}

853
src/sdk-to-acp.ts Normal file
View File

@@ -0,0 +1,853 @@
/**
* The mapping of Claude Code output to ACP session updates: content blocks
* (`toAcpNotifications`) and partial stream events
* (`streamEventToAcpNotifications`) become message chunks, thoughts, tool
* calls and plans. The live stream and the `session/load` replay of both
* protocol versions use it; v2 converts the v1 updates it returns.
*/
import type { ClientCapabilities, SessionNotification } from "@agentclientprotocol/sdk/unstable";
import type { SDKPartialAssistantMessage } from "@anthropic-ai/claude-agent-sdk";
import type { ContentBlockParam } from "@anthropic-ai/sdk/resources";
import type {
BetaContentBlock,
BetaRawContentBlockDelta,
} from "@anthropic-ai/sdk/resources/beta.mjs";
import type { AcpClient } from "./acp-client.js";
import { clientSupportsSubagents } from "./acp-subagents.js";
import { approvedPlan, planModeUpdate } from "./exit-plan.js";
import type { Logger } from "./logger.js";
import { checklistPlanUpdate, todoWritePlanUpdate, type SubagentChecklists } from "./plans.js";
import { ClientCapabilities as ToolCallClientCapabilities } from "./tool-calls/client-capabilities.js";
import type { ToolCallFieldTracker } from "./tool-calls/field-tracker.js";
import { AcpToolCallRenderer, type ToolUpdateMeta } from "./tool-calls/renderer.js";
import { parseToolResultMeta } from "./tool-result-meta.js";
import {
applyTaskCreate,
applyTaskList,
applyTaskUpdate,
changedTaskPlanEntries,
type ClaudePlanEntry,
completeHookCallback,
hasHookCallback,
parseTaskCreateOutput,
parseTaskListOutput,
parseTaskUpdateOutput,
planEntries,
registerHookCallback,
type TaskState,
unregisterHookCallback,
} from "./tools.js";
import { unreachable } from "./utils.js";
/** Attributes a report to the Agent/Task tool call of the subagent that made it. */
export function stampParentToolUseId(
update: SessionNotification["update"],
parentToolUseId: string,
) {
update._meta = {
...update._meta,
claudeCode: {
...((update._meta?.claudeCode as Record<string, unknown> | undefined) ?? {}),
parentToolUseId,
},
};
}
export type ToolUseCache = {
[key: string]: {
type: "tool_use" | "server_tool_use" | "mcp_tool_use";
id: string;
name: string;
input: unknown;
};
};
type StreamedToolInput = {
id: string;
name: string;
partialJson: string;
inString: boolean;
escaped: boolean;
objectDepth: number;
arrayDepth: number;
/** Offset of the most recent comma at the top level of the input object
* (-1 before the first). Everything before it is a complete field. */
lastTopLevelComma: number;
/** The comma offset the last emitted refinement was sliced at (-1 before the
* first), so a field boundary only triggers one recovery attempt. */
emittedThroughComma: number;
};
export type StreamedToolInputCache = Map<string, Map<number, StreamedToolInput>>;
/**
* Advance the lexer state across the fragment appended since the last delta:
* just enough JSON awareness (string/escape, nesting depth) to spot commas
* that sit at the top level of the input object — everything before such a
* comma is a set of complete fields. Returns true once the input object's
* closing brace arrives.
*
* The lexer reads the fragment itself, never `partialJson` by index. V8 keeps a
* string built with `+=` as a chain of parts, and the first index access
* copies the whole chain into one flat string. An index scan of `partialJson`
* would copy the whole input on each delta, so a large Write would cost
* quadratic time. A test in `acp-agent.test.ts` checks this.
*/
function scanStreamedToolInput(state: StreamedToolInput, fragment: string): boolean {
const offset = state.partialJson.length;
state.partialJson += fragment;
let complete = false;
for (let index = 0; index < fragment.length; index++) {
const character = fragment[index];
if (state.inString) {
if (state.escaped) {
state.escaped = false;
} else if (character === "\\") {
state.escaped = true;
} else if (character === '"') {
state.inString = false;
}
continue;
}
if (character === '"') {
state.inString = true;
} else if (character === "{") {
state.objectDepth++;
} else if (character === "}") {
state.objectDepth--;
if (state.objectDepth === 0) {
complete = true;
}
} else if (character === "[") {
state.arrayDepth++;
} else if (character === "]") {
state.arrayDepth--;
} else if (character === "," && state.objectDepth === 1 && state.arrayDepth === 0) {
state.lastTopLevelComma = offset + index;
}
}
return complete;
}
/** Parse the complete top-level fields before a top-level comma by closing the
* object at that boundary. */
function recoveredToolInput(prefix: string): Record<string, unknown> | undefined {
try {
const value: unknown = JSON.parse(prefix + "}");
return value !== null && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: undefined;
} catch {
return undefined;
}
}
/**
* Resolves the ACP `messageId` for a Claude SDK message (live) or a persisted
* transcript message (replay) so chunk grouping is identical in both views.
*
* Assistant turns are keyed by the Anthropic API message id (`message.id`),
* which is identical at `message_start`, on the consolidated assistant message,
* and in the persisted transcript — unlike the per-`stream_event` uuid, which is
* unique per event and never persisted. User messages have no API id, but they
* are never streamed, so their (stable) SDK uuid is used instead. ACP message
* ids are opaque strings, so no particular format is required.
*/
export function messageIdForGrouping(message: {
type?: string;
uuid?: string | null;
message?: unknown;
}): string | undefined {
if (message.type === "assistant") {
const inner = message.message;
const apiId =
inner && typeof inner === "object" && "id" in inner
? (inner as { id?: unknown }).id
: undefined;
if (typeof apiId === "string" && apiId.length > 0) {
return apiId;
}
}
return typeof message.uuid === "string" && message.uuid.length > 0 ? message.uuid : undefined;
}
/**
* Stamps an ACP `messageId` onto a session update, but only on the message/
* thought chunk variants that carry one — tool_call/plan/etc. updates never do.
* No-op when `messageId` is falsy, so callers can pass it through unconditionally.
*/
function applyMessageId(
update: SessionNotification["update"],
messageId: string | undefined,
): void {
if (
messageId &&
(update.sessionUpdate === "agent_message_chunk" ||
update.sessionUpdate === "user_message_chunk" ||
update.sessionUpdate === "agent_thought_chunk")
) {
update.messageId = messageId;
}
}
/** Built-in tools that drive the task list (headless/SDK sessions use these
* instead of TodoWrite). Their tool_use/tool_result are surfaced as `plan`
* snapshots rather than as tool_calls. */
function isTaskTool(toolName: string): boolean {
return (
toolName === "TaskCreate" ||
toolName === "TaskUpdate" ||
toolName === "TaskList" ||
toolName === "TaskGet"
);
}
/** Whether the streamed tool_use path surfaces this tool as a standalone
* `tool_call`. TodoWrite is rendered as a `plan` and Task* tools are
* suppressed (their plan snapshot is emitted at tool_result time), so neither
* produces a streamed tool_call/tool_call_update — which means a
* permission-surfaced tool_call for them (see `ensureToolCallEmitted`) must be
* resolved explicitly at tool_result time. */
function shouldEmitToolCall(toolName: string): boolean {
return toolName !== "TodoWrite" && !isTaskTool(toolName);
}
/**
* The renderer of the notification functions: the agent's tool call
* capabilities when they are passed, else those of the ACP capabilities.
*/
function toolCallRenderer(options?: {
clientCapabilities?: ClientCapabilities;
toolCallCapabilities?: ToolCallClientCapabilities;
replay?: boolean;
}): AcpToolCallRenderer {
return options?.toolCallCapabilities
? new AcpToolCallRenderer(options.toolCallCapabilities, options.replay)
: AcpToolCallRenderer.for(options?.clientCapabilities, options?.replay);
}
/**
* Convert an SDKAssistantMessage (Claude) to a SessionNotification (ACP).
* Only handles text, image, and thinking chunks for now.
*/
export function toAcpNotifications(
content: string | ContentBlockParam[] | BetaContentBlock[] | BetaRawContentBlockDelta[],
role: "assistant" | "user",
sessionId: string,
toolUseCache: ToolUseCache,
client: AcpClient,
logger: Logger,
options?: {
registerHooks?: boolean;
clientCapabilities?: ClientCapabilities;
// The agent's tool call capabilities. They win over clientCapabilities,
// and carry what ACP capabilities cannot (ToolCallClientCapabilities.v2).
toolCallCapabilities?: ToolCallClientCapabilities;
parentToolUseId?: string | null;
cwd?: string;
taskState?: TaskState;
// The subagent TodoWrite checklists that the client holds in the session.
// Mutated in place.
subagentChecklists?: SubagentChecklists;
// Tracks tool_use ids already emitted as a `tool_call` so a permission
// request (which emits the tool_call eagerly) and the streamed tool_use
// chunk don't both emit one — whichever arrives second emits a
// `tool_call_update` instead. Mutated in place. When omitted, the
// tool_call/update decision falls back to `toolUseCache` presence (the
// historical single-source behavior).
emittedToolCalls?: Set<string>;
// False while the input of a streamed tool_use still streams: the first
// tool_call then leaves `rawInput` out, and the consolidated message
// sends it once it is complete.
inputComplete?: boolean;
// Remembers the fields sent for each open tool call. When present, a
// tool_call_update carries only the fields that changed, and an update
// with nothing new is dropped. Mutated in place.
toolCallFields?: ToolCallFieldTracker;
// Opaque id identifying the message these chunks belong to (ACP message ids
// are opaque strings — no particular format is required). Attached to
// user/agent message and thought chunks so clients can group streamed chunks
// into a single message. Omit it (leave undefined) when unknown — never send
// an explicit `null`.
messageId?: string;
// The SDK user message's `tool_use_result`: the structured Output object of
// the tool_result this message carries (shape is per-tool). Used to render
// Agent/Task results from the structured subagent report instead of the raw
// text (which ends in a model-directed agentId/usage trailer).
toolUseResult?: unknown;
// The SDK user message's `tool_result_meta` sidecar, passed raw (it's
// untyped in sdk.d.ts) and validated by `parseToolResultMeta`. Stamps
// denied/interrupted tool_call_updates with why the tool never ran.
toolResultMeta?: unknown;
// True when the content is a result of the work that a `session/cancel`
// stopped. An error result then ends a run that the cancel aborted,
// whichever kind the SDK stamped on it (see `resultStatus`).
cancelled?: boolean;
// True when the content comes from the history of a loaded session.
replay?: boolean;
},
): SessionNotification[] {
const taskState = options?.taskState ?? new Map();
const registerHooks = options?.registerHooks !== false;
const renderer = toolCallRenderer(options);
if (typeof content === "string") {
if (content.length === 0) {
return [];
}
const update: SessionNotification["update"] = {
sessionUpdate: role === "assistant" ? "agent_message_chunk" : "user_message_chunk",
content: {
type: "text",
text: content,
},
};
applyMessageId(update, options?.messageId);
if (options?.parentToolUseId) stampParentToolUseId(update, options.parentToolUseId);
return [{ sessionId, update }];
}
// `tool_use_result` is message-level and carries no tool_use_id of its own:
// it describes "the" tool_result block of the message it rode in on. If
// several tool_result blocks were ever batched into one message it couldn't
// be attributed, so it is only honored when the message carries exactly one.
const toolUseResult =
options?.toolUseResult !== undefined &&
content.filter((c) => typeof c === "object" && c !== null && c.type === "tool_result")
.length === 1
? options.toolUseResult
: undefined;
// Unlike `tool_use_result`, entries carry their own tool_use_id, so batched
// messages need no single-block guard.
const toolResultMeta = parseToolResultMeta(options?.toolResultMeta);
const output = [];
// Only handle the first chunk for streaming; extend as needed for batching
for (const chunk of content) {
let update: SessionNotification["update"] | null = null;
// The tool call that this chunk finishes, if it is a tool result.
let finishedToolCallId: string | undefined;
// The plan that this chunk approves, if it is an ExitPlanMode result.
let approvedPlanUpdate: SessionNotification["update"] | undefined;
switch (chunk.type) {
case "text":
case "text_delta": {
if (chunk.text) {
update = {
sessionUpdate: role === "assistant" ? "agent_message_chunk" : "user_message_chunk",
content: {
type: "text",
text: chunk.text,
},
};
}
break;
}
case "image":
update = {
sessionUpdate: role === "assistant" ? "agent_message_chunk" : "user_message_chunk",
content: {
type: "image",
data: chunk.source.type === "base64" ? chunk.source.data : "",
mimeType: chunk.source.type === "base64" ? chunk.source.media_type : "",
uri: chunk.source.type === "url" ? chunk.source.url : undefined,
},
};
break;
case "thinking":
case "thinking_delta": {
// Recent models default `thinking.display` to "omitted", which streams
// signature-only thinking blocks whose text is empty.
if (chunk.thinking) {
update = {
sessionUpdate: "agent_thought_chunk",
content: {
type: "text",
text: chunk.thinking,
},
};
}
break;
}
case "tool_use":
case "server_tool_use":
case "mcp_tool_use": {
const alreadyCached = chunk.id in toolUseCache;
toolUseCache[chunk.id] = chunk;
if (chunk.name === "TodoWrite") {
// @ts-expect-error - sometimes input is empty object or undefined
if (Array.isArray(chunk.input?.todos)) {
update = todoWritePlanUpdate(planEntries(chunk.input as { todos: ClaudePlanEntry[] }), {
planOperations: options?.clientCapabilities?.plan != null,
parentToolUseId: options?.parentToolUseId,
// With native subagent sessions, an update of a subagent goes
// to the subagent's session (see NativeSubagentRuntime).
childSessions: clientSupportsSubagents(options?.clientCapabilities),
shown: options?.subagentChecklists,
});
}
} else if (isTaskTool(chunk.name)) {
// Task* tool_use is suppressed; the plan update is emitted at
// tool_result time once we have the task ID (for TaskCreate) and
// confirmation that the change took effect.
} else {
// Only register hooks on first encounter to avoid double-firing
if (registerHooks && !alreadyCached) {
// Capture the tool name in the closure rather than re-reading the
// cache when the hook fires. The cache entry is pruned at
// tool_result time, and a PostToolUse hook can fire after that, so
// closing over the name keeps the diff working without depending on
// (or pinning) the cache entry's lifetime.
const toolName = chunk.name;
const hookToolCallId = chunk.id;
registerHookCallback(
chunk.id,
{
onPostToolUseHook: async (toolUseId, _toolInput, toolResponse) => {
// The final diff of an Edit or a Write replaces the
// optimistic content built from the input. Only the marker
// fields of the tool_response travel: the rest repeats
// output that the content already carries.
const update = await renderer.hookResult(
{ id: toolUseId, name: toolName },
toolResponse,
options?.cwd,
);
if (!update) return;
if (options?.parentToolUseId) {
stampParentToolUseId(update, options.parentToolUseId);
}
// The final result may replace a pinned approval patch.
if (
options?.toolCallFields &&
!options.toolCallFields.apply(update, { replacePinnedContent: true })
) {
return;
}
await client.sessionUpdate({ sessionId, update });
},
onRelease: () => options?.toolCallFields?.finishHook(hookToolCallId),
},
sessionId,
);
}
let rawInput;
try {
rawInput = JSON.parse(JSON.stringify(chunk.input));
} catch {
// ignore if we can't turn it to JSON
}
const toolUse = { id: chunk.id, name: chunk.name, input: rawInput };
// Emit a `tool_call` only the first time this id surfaces to the
// client; afterwards refine it with a `tool_call_update`. The first
// surface may be this stream chunk OR an earlier permission request
// (see `ensureToolCallEmitted`), so emission is tracked separately
// from `toolUseCache`. Without an `emittedToolCalls` set we fall back
// to cache presence — the historical streaming-only behavior.
const emittedToolCalls = options?.emittedToolCalls;
const alreadyEmitted = emittedToolCalls ? emittedToolCalls.has(chunk.id) : alreadyCached;
emittedToolCalls?.add(chunk.id);
// A permission request surfaced the tool call with its complete
// input. The empty input at the stream start has nothing to add.
if (alreadyEmitted && options?.inputComplete === false) break;
update = alreadyEmitted
? // Already surfaced (full assistant message after streaming, or a
// permission request emitted it first): refine it with the
// complete input.
renderer.refinement(toolUse, options?.cwd)
: // First surface (streaming content_block_start or replay).
renderer.toolCall(toolUse, {
cwd: options?.cwd,
inputComplete: options?.inputComplete,
});
}
break;
}
case "advisor_tool_result": {
// Advisor results arrive in assistant content, including encrypted advice.
// Resolve the server tool through the normal result path without rendering
// opaque ciphertext. Server tools have no later PostToolUse hook to await.
const result = chunk.content;
unregisterHookCallback(chunk.tool_use_id);
output.push(
...toAcpNotifications(
[
{
type: "tool_result",
tool_use_id: chunk.tool_use_id,
is_error: result.type === "advisor_tool_result_error",
content:
result.type === "advisor_result"
? result.text
: result.type === "advisor_redacted_result"
? "Advisor guidance applied server-side."
: `Advisor error: ${result.error_code}`,
},
],
role,
sessionId,
toolUseCache,
client,
logger,
options,
),
);
break;
}
case "tool_result":
case "tool_search_tool_result":
case "web_fetch_tool_result":
case "web_search_tool_result":
case "code_execution_tool_result":
case "bash_code_execution_tool_result":
case "text_editor_code_execution_tool_result":
case "mcp_tool_result": {
const wasEmitted = options?.emittedToolCalls?.has(chunk.tool_use_id) === true;
options?.emittedToolCalls?.delete(chunk.tool_use_id);
completeHookCallback(chunk.tool_use_id);
finishedToolCallId = chunk.tool_use_id;
// Why this is_error result carries harness prose instead of tool
// output (user-rejected / interrupted / …), when the SDK said so.
// Spread into the claudeCode meta of every update emitted below; the
// untracked-tool fallback can't carry it (claudeCode metas always
// carry `toolName`, which is unknown there).
const nonExecution = toolResultMeta?.get(chunk.tool_use_id);
const toolUse = toolUseCache[chunk.tool_use_id];
if (!toolUse) {
// The permission flow may have surfaced this tool_call even though
// its tool_use never reached the cache (e.g. the assistant message
// carrying it was dropped by the cancelled-turn guard and a straggler
// result landed later). Resolve the surfaced call anyway so it can't
// stay pending in the client forever; without the cache entry the
// tool name is unknown, so no claudeCode meta is attached.
if (wasEmitted) {
output.push({
sessionId,
update: {
toolCallId: chunk.tool_use_id,
sessionUpdate: "tool_call_update" as const,
status: renderer.resultStatus(
"is_error" in chunk && chunk.is_error === true,
nonExecution,
{ cancelled: options?.cancelled },
),
rawOutput: chunk.content,
},
});
}
logger.error(
`[claude-agent-acp] Got a tool result for tool use that wasn't tracked: ${chunk.tool_use_id}`,
);
break;
}
// A permission request may have surfaced a plan-rendered (TodoWrite) or
// suppressed (Task*) tool as a real tool_call so the request referenced
// a tool call the client knows about (see `ensureToolCallEmitted`,
// issue #851). The branches below never emit a tool_call_update for
// those tools, which would leave the surfaced call pending in the
// client forever — resolve it here. `wasEmitted` is only ever true for
// these tools via the permission flow: the streamed plan/suppressed
// branches don't record emissions.
if (wasEmitted && !shouldEmitToolCall(toolUse.name)) {
output.push({
sessionId,
update: {
_meta: {
claudeCode: {
toolName: toolUse.name,
...(nonExecution ?? {}),
...(options?.parentToolUseId ? { parentToolUseId: options.parentToolUseId } : {}),
},
} satisfies ToolUpdateMeta,
toolCallId: chunk.tool_use_id,
sessionUpdate: "tool_call_update" as const,
status: renderer.resultStatus(
"is_error" in chunk && chunk.is_error === true,
nonExecution,
{ cancelled: options?.cancelled },
),
rawOutput: chunk.content,
},
});
}
if (isTaskTool(toolUse.name)) {
// Headless/SDK sessions emit Task* tools instead of TodoWrite.
// TaskCreate / TaskUpdate mutate the accumulated task list. TaskList
// reconciles it from the SDK's authoritative snapshot, which repairs
// resumed or compacted sessions whose creating calls are no longer in
// replay history. TaskGet is read-only and remains suppressed. Plan
// updates always carry the full accumulated snapshot, mirroring the
// legacy TodoWrite behavior.
const isError = "is_error" in chunk && chunk.is_error;
let shouldEmitTaskPlan = false;
if (!isError) {
if (toolUse.name === "TaskCreate") {
applyTaskCreate(
taskState,
toolUse.input as Parameters<typeof applyTaskCreate>[1],
parseTaskCreateOutput(toolUseResult) ?? parseTaskCreateOutput(chunk.content),
);
shouldEmitTaskPlan = true;
} else if (toolUse.name === "TaskUpdate") {
const input = toolUse.input as Parameters<typeof applyTaskUpdate>[1];
const output =
parseTaskUpdateOutput(toolUseResult, input?.taskId) ??
parseTaskUpdateOutput(chunk.content, input?.taskId);
// Older CLI transcripts have no structured output, so retain the
// input-based fallback. When an output is available, only apply a
// confirmed update for the same task.
if (!output || (output.success && output.taskId === input?.taskId)) {
applyTaskUpdate(taskState, input);
shouldEmitTaskPlan = true;
}
} else if (toolUse.name === "TaskList") {
const output =
parseTaskListOutput(toolUseResult) ?? parseTaskListOutput(chunk.content);
if (output) {
applyTaskList(taskState, output);
shouldEmitTaskPlan = true;
}
}
}
const entries = shouldEmitTaskPlan
? changedTaskPlanEntries(taskState, renderer.capabilities.air.client)
: undefined;
if (entries) {
update = checklistPlanUpdate(entries, options?.clientCapabilities?.plan != null);
}
} else if (toolUse.name !== "TodoWrite") {
// A command sends its output first, then the exit and the status.
const [finalUpdate, ...rest] = renderer
.result(toolUse, chunk as Parameters<AcpToolCallRenderer["result"]>[1], {
structured: toolUseResult,
nonExecution,
cancelled: options?.cancelled,
})
.reverse();
for (const outputUpdate of rest.reverse()) {
if (options?.parentToolUseId) {
stampParentToolUseId(outputUpdate, options.parentToolUseId);
}
output.push({ sessionId, update: outputUpdate });
}
// An approved plan of the main conversation becomes the session's
// markdown plan, after the tool call's result. An approval that
// clears the context ends the tool with an error result instead, and
// the restart into the fresh context publishes that plan (see
// ExitPlanCoordinator.restart).
if (
toolUse.name === "ExitPlanMode" &&
options?.clientCapabilities?.plan &&
!options.parentToolUseId &&
!("is_error" in chunk && chunk.is_error)
) {
const plan = approvedPlan(toolUse.input, toolUseResult);
if (plan) {
approvedPlanUpdate = planModeUpdate(
plan,
renderer.capabilities.air.client ? toolUse.id : undefined,
);
}
}
update = finalUpdate;
}
// The tool_use is fully resolved now — drop it so a long session doesn't
// retain every tool call. The PostToolUse hook (Edit/Write diffs) closes
// over the tool name and no longer reads the cache, so pruning here is
// safe regardless of hook/result ordering.
delete toolUseCache[chunk.tool_use_id];
break;
}
case "document":
case "search_result":
case "redacted_thinking":
case "input_json_delta":
case "citations_delta":
case "signature_delta":
case "container_upload":
case "compaction":
case "compaction_delta":
case "fallback":
case "mcp_tool_listing":
break;
default:
unreachable(chunk, logger);
break;
}
if (update) {
if (options?.parentToolUseId) stampParentToolUseId(update, options.parentToolUseId);
applyMessageId(update, options?.messageId);
// A tool result is final, so it may replace a pinned approval patch,
// for example with the error text of a rejected Edit.
if (
!options?.toolCallFields ||
options.toolCallFields.apply(update, {
replacePinnedContent: finishedToolCallId !== undefined,
})
) {
output.push({ sessionId, update });
}
}
if (approvedPlanUpdate) output.push({ sessionId, update: approvedPlanUpdate });
if (finishedToolCallId !== undefined) {
// The PostToolUse hook can still send the final diff, so the fields
// stay tracked until its callback leaves the registry.
options?.toolCallFields?.finishResult(
finishedToolCallId,
hasHookCallback(finishedToolCallId),
);
}
}
return output;
}
export function streamEventToAcpNotifications(
message: SDKPartialAssistantMessage,
sessionId: string,
toolUseCache: ToolUseCache,
client: AcpClient,
logger: Logger,
options?: {
clientCapabilities?: ClientCapabilities;
// See toAcpNotifications.
toolCallCapabilities?: ToolCallClientCapabilities;
cwd?: string;
taskState?: TaskState;
subagentChecklists?: SubagentChecklists;
emittedToolCalls?: Set<string>;
toolCallFields?: ToolCallFieldTracker;
messageId?: string;
streamedToolInputs?: StreamedToolInputCache;
},
): SessionNotification[] {
const event = message.event;
const streamKey = message.parent_tool_use_id ?? "";
const streamedToolInputs = options?.streamedToolInputs;
const forwardedOptions = {
clientCapabilities: options?.clientCapabilities,
toolCallCapabilities: options?.toolCallCapabilities,
parentToolUseId: message.parent_tool_use_id,
cwd: options?.cwd,
taskState: options?.taskState,
subagentChecklists: options?.subagentChecklists,
emittedToolCalls: options?.emittedToolCalls,
toolCallFields: options?.toolCallFields,
messageId: options?.messageId,
};
switch (event.type) {
case "content_block_start": {
const block = event.content_block;
if (
streamedToolInputs &&
(block.type === "tool_use" ||
block.type === "server_tool_use" ||
block.type === "mcp_tool_use")
) {
let inputsForMessage = streamedToolInputs.get(streamKey);
if (!inputsForMessage) {
inputsForMessage = new Map();
streamedToolInputs.set(streamKey, inputsForMessage);
}
inputsForMessage.set(event.index, {
id: block.id,
name: block.name,
partialJson: "",
inString: false,
escaped: false,
objectDepth: 0,
arrayDepth: 0,
lastTopLevelComma: -1,
emittedThroughComma: -1,
});
}
// The input of a streamed tool_use starts empty and streams after this
// event, so the tool_call waits for the complete input.
return toAcpNotifications([block], "assistant", sessionId, toolUseCache, client, logger, {
...forwardedOptions,
inputComplete: false,
});
}
case "content_block_delta": {
if (event.delta.type === "input_json_delta") {
const streamedInput = streamedToolInputs?.get(streamKey)?.get(event.index);
if (!streamedInput) return [];
if (scanStreamedToolInput(streamedInput, event.delta.partial_json)) {
// Input complete: the consolidated assistant message replays the
// block with its full input and refines the call there; emitting
// here too would send a duplicate identical update.
const inputsForMessage = streamedToolInputs?.get(streamKey);
inputsForMessage?.delete(event.index);
if (inputsForMessage?.size === 0) streamedToolInputs?.delete(streamKey);
return [];
}
if (streamedInput.lastTopLevelComma <= streamedInput.emittedThroughComma) {
return [];
}
streamedInput.emittedThroughComma = streamedInput.lastTopLevelComma;
const input = recoveredToolInput(
streamedInput.partialJson.slice(0, streamedInput.lastTopLevelComma),
);
if (!input) return [];
// TodoWrite and the Task* tools never surfaced a tool_call to refine.
if (!shouldEmitToolCall(streamedInput.name)) return [];
const update: SessionNotification["update"] = toolCallRenderer(options).partialRefinement(
streamedInput,
input,
options?.cwd,
);
if (message.parent_tool_use_id) stampParentToolUseId(update, message.parent_tool_use_id);
applyMessageId(update, options?.messageId);
// A refinement resends only what changed: rawInput grows with every
// field, and title, kind, and locations usually stay the same.
if (options?.toolCallFields && !options.toolCallFields.apply(update)) return [];
return [{ sessionId, update }];
}
return toAcpNotifications(
[event.delta],
"assistant",
sessionId,
toolUseCache,
client,
logger,
forwardedOptions,
);
}
// No content. `ping` is a Messages-API keep-alive event that the SDK's
// `BetaRawMessageStreamEvent` union doesn't include even though the
// wire format emits it; the `as never` cast lets us no-op it here
// instead of letting it fall through to `unreachable`.
case "ping" as never:
case "message_delta":
return [];
// A message boundary ends every input stream on this lane: message_stop is
// the normal end, and a message_start clears anything a prior message on
// the lane left behind (e.g. a stream cut short mid-block).
case "message_start":
case "message_stop":
streamedToolInputs?.delete(streamKey);
return [];
case "content_block_stop": {
const inputsForMessage = streamedToolInputs?.get(streamKey);
inputsForMessage?.delete(event.index);
if (inputsForMessage?.size === 0) streamedToolInputs?.delete(streamKey);
return [];
}
default:
unreachable(event, logger);
return [];
}
}

View File

@@ -1,31 +1,45 @@
import { ndJsonStream } from "@agentclientprotocol/sdk";
import * as v2 from "@agentclientprotocol/sdk/experimental/v2";
import { v1AgentApp, type ClaudeAcpAgent, type Logger } from "./acp-agent.js";
import { v1AgentApp, type ClaudeAcpAgent } from "./acp-agent.js";
import type { Logger } from "./logger.js";
import { nodeToWebReadable, nodeToWebWritable } from "./utils.js";
import { v2AgentApp } from "./v2/agent.js";
import { v2AgentApp, type AgentHandle } from "./v2/agent.js";
/**
* Serves ACP on stdio.
*
* With `experimentalV2`, the connection goes through {@link acpProtocolRouter}.
* Without it, the adapter serves ACP v1 alone and never routes by protocol
* version, exactly as before.
* The ACP versions that the adapter serves: `v1` or `v2` alone, or both with
* `auto`, where the client's `initialize` picks the version.
*/
export type AcpProtocols = "auto" | "v1" | "v2";
/**
* Reads `CLAUDE_AGENT_ACP_PROTOCOL`: unset, empty, or `auto` serves both
* versions, and `v1` or `v2` serves that version alone. Throws on any other
* value, so that a typo such as `CLAUDE_AGENT_ACP_PROTOCOL=1` fails at startup
* instead of silently serving both.
*/
export function acpProtocolsFromEnv(value: string | undefined): AcpProtocols {
if (value === undefined || value === "") return "auto";
if (value === "auto" || value === "v1" || value === "v2") return value;
throw new Error(
`CLAUDE_AGENT_ACP_PROTOCOL must be "auto", "v1", or "v2", not ${JSON.stringify(value)}`,
);
}
/** Serves ACP on stdio through {@link acpProtocolRouter}. */
export function serveAcp(
logger: Logger | undefined,
options: { experimentalV2: boolean },
options: { protocols: AcpProtocols },
): { closed: Promise<void>; dispose(): Promise<void> } {
const output = nodeToWebWritable(process.stdout);
const input = nodeToWebReadable(process.stdin);
// The router opens the connection of the negotiated version only when it
// reads `initialize`, so a client that leaves before then leaves no agent.
let agent: ClaudeAcpAgent | undefined;
const onAgent = (created: ClaudeAcpAgent) => {
let agent: AgentHandle | undefined;
const onAgent = (created: AgentHandle) => {
agent = created;
};
const { closed } = options.experimentalV2
? acpProtocolRouter(logger, onAgent).connect(v2.ndJsonStream(output, input))
: v1AgentApp(logger, onAgent).connect(ndJsonStream(output, input));
const { closed } = acpProtocolRouter(logger, onAgent, options.protocols, {
onV2Agent: onAgent,
}).connect(v2.ndJsonStream(output, input));
return {
closed,
dispose: async () => {
@@ -36,16 +50,24 @@ export function serveAcp(
/**
* Routes a connection to the protocol version that the client negotiates in
* `initialize`: the experimental draft ACP v2 for a client that requests
* version 2 or later, and ACP v1 otherwise. `onAgent` receives the agent of
* the connection once the router has opened it.
* `initialize`. With `auto`, a client that requests version 2 or later gets
* the draft ACP v2, and every other client gets ACP v1. With `v1`, a v2
* client is negotiated down to v1. With `v2`, the router rejects the
* `initialize` of a v1 client as an unsupported protocol version. `onAgent`
* receives the agent of a v1 connection (`ClaudeAcpAgent`) once the router
* has opened it, and `onV2Agent` the agent of a v2 connection, which the
* v2 core (`src/v2/`) serves.
*/
export function acpProtocolRouter(
logger: Logger | undefined,
onAgent: (agent: ClaudeAcpAgent) => void,
protocols: AcpProtocols = "auto",
{ onV2Agent = () => {} }: { onV2Agent?: (agent: AgentHandle) => void } = {},
): v2.AgentProtocolRouter {
return v2
.agentProtocolRouter()
.withV1(v1AgentApp(logger, onAgent))
.withV2(v2AgentApp(logger, onAgent));
const router = v2.agentProtocolRouter();
if (protocols !== "v2") router.withV1(v1AgentApp(logger, onAgent));
if (protocols !== "v1") {
router.withV2(v2AgentApp(logger, onV2Agent));
}
return router;
}

View File

@@ -1,4 +1,6 @@
/** Shared sentinels and ids for session preferences managed by the adapter. */
export const DEFAULT_MODEL_ID = "default";
export const DEFAULT_AGENT_ID = "default";
export const AGENT_CONFIG_ID = "agent";
export const EFFORT_CONFIG_ID = "effort";
export const ULTRACODE_CONFIG_ID = "ultracode";

View File

@@ -0,0 +1,244 @@
/**
* The config options of a session, as both the v1 core and the v2 core
* build them: mode, model, effort, Fast mode, ultracode and the main-thread
* agent.
*/
import {
RequestError,
type ClientCapabilities,
type SessionConfigOption,
type SessionModeState,
type SetSessionConfigOptionRequest,
} from "@agentclientprotocol/sdk/unstable";
import type { AgentInfo, ModelInfo, Query } from "@anthropic-ai/claude-agent-sdk";
import {
AIR_RECOMMENDED_CONFIG_VALUE_CAPABILITY,
clientSupportsAirCapability,
} from "./air-extension.js";
import { AGENT_CONFIG_ID, DEFAULT_AGENT_ID } from "./session-config-ids.js";
import { buildEffortConfigOption } from "./session-effort.js";
import {
createFastModeConfigOption,
FAST_MODE_CONFIG_ID,
resolveFastModeEnabled,
type FastModeOptionState,
} from "./session-fast-mode.js";
import { MODE_CONFIG_ID, SessionModeManager } from "./session-mode.js";
import {
buildModelConfigOption,
MODEL_CONFIG_ID,
resolveModelPreference,
type SessionModelState,
} from "./session-model.js";
import {
createUltracodeConfigOption,
resolveUltracodeEnabled,
ULTRACODE_CONFIG_ID,
type UltracodeOptionState,
} from "./session-ultracode.js";
// `supportedAgents()` always returns Claude Code's built-in subagents — the
// ones used for Task-tool delegation (Explore, Plan, etc.) — even when the user
// has configured none of their own. Those aren't meaningful *main-thread*
// personas, so we filter them out and only surface the Agent picker when the
// user (or a plugin/project) has configured custom agents. Update this set if
// the SDK's built-in roster changes.
export const BUILTIN_AGENT_NAMES = new Set([
"claude",
"general-purpose",
"Explore",
"Plan",
"statusline-setup",
]);
// Value of the synthetic "Default" entry in the agent picker, which maps to the
// standard Claude Code agent (`applyFlagSettings({ agent: null })`). It is a
// reserved sentinel: a custom agent named exactly this would collide with it
// (two options sharing the value, selection silently routing to `null`), so we
// exclude that name from discovery.
/** Discover user/plugin/project-configured main-thread agents, excluding the
* built-in subagents and the reserved "default" sentinel. Returns an empty
* list if discovery fails so a flaky control request never blocks session
* creation. */
export async function discoverCustomAgents(q: Query): Promise<AgentInfo[]> {
try {
return customAgents(await q.supportedAgents());
} catch {
return [];
}
}
/** The configured agents of `agents`: neither a built-in subagent nor the "default" sentinel. */
export function customAgents(agents: readonly AgentInfo[]): AgentInfo[] {
return agents.filter((a) => !BUILTIN_AGENT_NAMES.has(a.name) && a.name !== DEFAULT_AGENT_ID);
}
/** Stable ids for the session config options surfaced via `configOptions`.
* Centralized so the option declarations in `buildConfigOptions` and the
* handlers in `setSessionConfigOption`/`applyConfigOptionValue` reference the
* same identifiers and can't drift apart. */
export { MODE_CONFIG_ID };
export { AGENT_CONFIG_ID };
/** Whether the Client advertised support for boolean session config options
* (`session.configOptions.boolean`). Agents MUST only send `type: "boolean"`
* config options to Clients that opt in; otherwise we fall back to a `select`.
* See https://agentclientprotocol.com/rfds/boolean-config-option. */
export function clientSupportsBooleanConfigOptions(
clientCapabilities?: ClientCapabilities | null,
): boolean {
return clientCapabilities?.session?.configOptions?.boolean != null;
}
/** Whether the Client advertised the AIR `recommendedValue` capability for
* select-style model and effort options. Missing or malformed AIR metadata
* retains the legacy `default` entries. */
export function clientSupportsRecommendedConfigValue(
clientCapabilities?: ClientCapabilities | null,
): boolean {
return clientSupportsAirCapability(clientCapabilities, AIR_RECOMMENDED_CONFIG_VALUE_CAPABILITY);
}
export type ConfigOptionPresentation = {
/** Replace ambiguous `default` rows with concrete values and advertise the
* SDK/adapter recommendation as `_meta.jetbrains.air.recommendedValue`. */
useRecommendedValue: boolean;
};
export function buildConfigOptions(
modes: SessionModeState,
models: SessionModelState,
modelInfos: ModelInfo[],
currentEffortLevel?: string,
agents: AgentInfo[] = [],
currentAgent: string = DEFAULT_AGENT_ID,
fastMode?: FastModeOptionState,
presentation?: ConfigOptionPresentation,
ultracode?: UltracodeOptionState,
): SessionConfigOption[] {
const options: SessionConfigOption[] = [SessionModeManager.configOption(modes)];
// A gateway that lists none of the SDK's models leaves no model to offer, so
// there is no model option at all.
if (models.availableModels.length > 0) {
options.push(
buildModelConfigOption(models, modelInfos, presentation?.useRecommendedValue === true),
);
}
const effort = buildEffortConfigOption(
modelInfos,
models.currentModelId,
currentEffortLevel,
presentation?.useRecommendedValue === true,
);
if (effort) options.push(effort);
// Surface the Fast mode toggle only when the current model supports it. The
// option renders as a native boolean toggle for Clients that opted in, and a
// two-value select otherwise.
if (fastMode?.supported) {
options.push(
createFastModeConfigOption(
fastMode.enabled,
fastMode.useBooleanOption,
fastMode.disabledReason,
),
);
}
// Surface the ultracode toggle only while the CLI can run it: dynamic
// workflows are enabled and the current model supports it.
if (ultracode?.supported) {
options.push(createUltracodeConfigOption(ultracode.enabled, ultracode.useBooleanOption));
}
// Only surface the Agent picker when there's a real choice — i.e. the user
// has configured at least one custom agent (built-ins are filtered out in
// discoverCustomAgents). With none configured, "Default" would be the only
// entry, so we omit the option entirely.
if (agents.length > 0) {
options.push({
id: AGENT_CONFIG_ID,
name: "Agent",
description: "Main-thread agent persona",
type: "select",
currentValue: currentAgent,
options: [
{ value: DEFAULT_AGENT_ID, name: "Default", description: "Standard Claude Code agent" },
...agents.map((a) => ({
value: a.name,
name: a.name,
description: a.description || undefined,
})),
],
});
}
return options;
}
/**
* The value that a `session/set_config_option` request sets on `option`, the
* option as the session advertised it. The value must match the option's
* type: a `type: "boolean"` option takes `true`/`false`, not "on"/"off", and a
* `type: "select"` option takes one of its values.
*
* Fast mode and ultracode give `enabled`: a boolean, or the "on"/"off" select
* fallback for clients without boolean config options. A model takes an alias
* too, such as "opus" or "sonnet" for a full model id. It is resolved against
* `modelInfos` first: those entries carry `resolvedModel`, so a full model id
* (in either hint spelling) lands on the right row via the exact tier instead
* of a fuzzier one picking a same-family sibling from a different context
* lane. The option's own entries (which never carry `resolvedModel`) remain as
* a fallback for resolutions that don't map back onto a selectable option
* (e.g. a fuzzy hit on an out-of-picker verbatim entry). `value` is then the
* canonical option value, the model id rather than the alias.
*/
export function configOptionValue(
option: SessionConfigOption,
params: SetSessionConfigOptionRequest,
modelInfos: ModelInfo[],
): { enabled: boolean } | { value: string } {
const expectedType = option.type === "boolean" ? "boolean" : "string";
if (typeof params.value !== expectedType) {
throw RequestError.invalidParams(
{ configId: params.configId, value: params.value },
`Config option ${params.configId} takes a ${expectedType} value, but received: ${JSON.stringify(params.value)}`,
);
}
if (params.configId === FAST_MODE_CONFIG_ID) return { enabled: resolveFastModeEnabled(params) };
if (params.configId === ULTRACODE_CONFIG_ID) {
return { enabled: resolveUltracodeEnabled(params) };
}
if (typeof params.value !== "string") {
throw RequestError.invalidParams(
undefined,
`Invalid value for config option ${params.configId}: ${params.value}`,
);
}
const requested = params.value;
const allValues =
"options" in option && Array.isArray(option.options)
? option.options.flatMap((o) => ("options" in o ? o.options : [o]))
: [];
let validValue = allValues.find((o) => o.value === requested);
if (!validValue && params.configId === MODEL_CONFIG_ID) {
const toOptionValue = (resolved: ModelInfo | null) =>
resolved ? allValues.find((o) => o.value === resolved.value) : undefined;
validValue = toOptionValue(resolveModelPreference(modelInfos, requested));
if (!validValue) {
const optionInfos: ModelInfo[] = allValues.map((o) => ({
value: o.value,
displayName: o.name,
description: o.description ?? "",
}));
validValue = toOptionValue(resolveModelPreference(optionInfos, requested));
}
}
if (!validValue) {
throw RequestError.invalidParams(
undefined,
`Invalid value for config option ${params.configId}: ${requested}`,
);
}
return { value: validValue.value };
}

View File

@@ -1,4 +1,4 @@
import type { SessionConfigOption } from "@agentclientprotocol/sdk";
import type { SessionConfigOption } from "@agentclientprotocol/sdk/unstable";
import type { EffortLevel, ModelInfo, Settings } from "@anthropic-ai/claude-agent-sdk";
import { AIR_RECOMMENDED_CONFIG_VALUE_CAPABILITY, withAirMeta } from "./air-extension.js";
import { EFFORT_CONFIG_ID } from "./session-config-ids.js";
@@ -14,14 +14,31 @@ export function toSdkEffortLevel(value: string | undefined): EffortLevel | null
// Since CLI 2.1.284 an `effortLevel` that changes the level, sent without an
// `ultracode` key, turns ultracode off. The CLI's own effort control keeps
// ultracode on at any level, so carry a settings-requested ultracode along
// with every effort apply.
// ultracode on at any level, so send `ultracode: true` with every effort apply
// while the session has ultracode on. When it is off, the key is omitted: an
// effort change then leaves ultracode off, as it already was.
export function effortFlagSettings(
value: string | undefined,
settings: Settings,
ultracode: boolean,
): { effortLevel: EffortLevel | null; ultracode?: true } {
const effortLevel = toSdkEffortLevel(value);
return settings.ultracode === true ? { effortLevel, ultracode: true } : { effortLevel };
return ultracode ? { effortLevel, ultracode: true } : { effortLevel };
}
/**
* `params` without `_meta.claudeCode.options.effort`, for a restart of the
* session's query: the restart restores the current effort pin instead of
* the one the session was created with.
*/
export function withoutEffort<T extends { _meta?: unknown }>(params: T): T {
const meta = params._meta as
{ claudeCode?: { options?: { effort?: unknown } } } | null | undefined;
if (meta?.claudeCode?.options?.effort === undefined) {
return params;
}
const options = { ...meta.claudeCode.options };
delete options.effort;
return { ...params, _meta: { ...meta, claudeCode: { ...meta.claudeCode, options } } };
}
function canonicalizeModelSettingsKey(value: string): string {
@@ -79,6 +96,13 @@ export function mergeEffortSettings(base: Settings, override: Settings | undefin
};
}
export function effortLevelName(level: string): string {
return level
.split(/[_-]/)
.map((part) => (part ? part.charAt(0).toUpperCase() + part.slice(1) : part))
.join(" ");
}
export function buildEffortConfigOption(
modelInfos: ModelInfo[],
currentModelId: string,
@@ -98,10 +122,7 @@ export function buildEffortConfigOption(
...(useRecommendedValue ? [] : [{ value: "default", name: "Default" }]),
...supportedLevels.map((level) => ({
value: level,
name: level
.split(/[_-]/)
.map((part) => (part ? part.charAt(0).toUpperCase() + part.slice(1) : part))
.join(" "),
name: effortLevelName(level),
})),
];
const includes = (level: string) =>
@@ -128,3 +149,130 @@ export function buildEffortConfigOption(
: {}),
};
}
/** A session's effort bookkeeping: the level the user pinned through the
* picker, and the level last applied to the SDK's flag layer. */
export type EffortState = {
pinned: string | undefined;
applied: string | undefined;
};
/** The effort after a switch to `newModelInfo`, before the option is rebuilt
* (see {@link settleEffortAfterModelSwitch}). */
export type EffortSwitch = {
/** The effort to build the new model's option with. */
seed: string | undefined;
/** Whether the user's pin is one of the new model's levels. */
pinnedForNewModel: boolean;
before: EffortState;
/** The level applied before the switch, restored if the sync fails. */
appliedBeforeSwitch: string | undefined;
};
/** The effort seed of a model switch. It depends on who owns the choice: a
* user pin made through the ACP picker this session lives at the SDK's flag
* layer and follows the session across switches, so it carries forward.
* Otherwise the CLI resolves effort itself from the persisted settings — the
* NEW model's `modelSettings` entry first (the CLI persists /effort per
* model), then the legacy top-level value — so the option shows what it
* will actually run instead of dragging the old model's value along. */
export function effortForModelSwitch(input: {
state: EffortState;
/** The current value of the effort option, if it is shown. */
displayed: string | undefined;
newModelInfo: ModelInfo | undefined;
modelId: string;
/** The merged effort settings (see {@link mergeEffortSettings}). */
settings: Settings;
useRecommendedValue: boolean;
}): EffortSwitch {
const { state, displayed, newModelInfo, useRecommendedValue } = input;
const pinned = state.pinned;
const pinnedForNewModel =
pinned !== undefined &&
newModelInfo?.supportsEffort === true &&
newModelInfo.supportedEffortLevels?.some((level) => level === pinned) === true;
return {
seed: pinnedForNewModel
? pinned
: settingsEffortForModel(input.settings, newModelInfo, input.modelId),
pinnedForNewModel,
before: state,
appliedBeforeSwitch:
state.applied ??
(useRecommendedValue && typeof displayed === "string" && displayed !== "default"
? displayed
: pinned),
};
}
/**
* Applies the effort of a model switch to the SDK's flag layer, once the new
* model's effort `option` is built from `change.seed`. Returns the new state
* and the option to show (undefined to hide it).
*
* Opted-in clients (`useRecommendedValue`) apply the concrete displayed effort
* on every switch, including settings-derived values, so a previous automatic
* flag cannot shadow the new model's settings. This does not create a user
* pin. For other clients, the flag is synced only when a user pin is lost
* across the switch — the new model clamped it away, where the flag must be
* cleared too or the SDK would keep running the old pin invisibly.
* Settings-derived seeds are display-only: the CLI resolves persisted effort
* itself, and pinning it at the flag layer would shadow the per-model values
* on every later switch.
*
* The model switch already succeeded, so a failed sync is returned as
* `error`, not thrown. The old bookkeeping stays, and the unapplied value is
* never shown: the option shows the last applied value when the new model can
* select it, else it is hidden until a later switch rebuilds it.
*/
export async function settleEffortAfterModelSwitch(
change: EffortSwitch,
option: SessionConfigOption | undefined,
ultracode: boolean,
useRecommendedValue: boolean,
applyFlagSettings: (settings: ReturnType<typeof effortFlagSettings>) => Promise<void>,
): Promise<{ state: EffortState; option: SessionConfigOption | undefined; error?: unknown }> {
const { pinnedForNewModel, before } = change;
const sync = useRecommendedValue || (before.pinned !== undefined && !pinnedForNewModel);
if (!sync) {
return {
state: pinnedForNewModel ? { pinned: before.pinned, applied: before.pinned } : before,
option,
};
}
const newEffort = typeof option?.currentValue === "string" ? option.currentValue : undefined;
try {
// A legacy client's unpinned effort is display-only: the CLI resolves the
// persisted value for the new model. When an old user pin is no longer
// supported, clear the flag layer instead of replacing it with that
// displayed value. Opted-in clients deliberately apply their concrete
// displayed effort.
await applyFlagSettings(
effortFlagSettings(useRecommendedValue ? newEffort : undefined, ultracode),
);
return {
state: {
pinned: pinnedForNewModel ? before.pinned : undefined,
applied: useRecommendedValue && newEffort !== "default" ? newEffort : undefined,
},
option,
};
} catch (error) {
const applied = change.appliedBeforeSwitch;
const selectable =
applied !== undefined &&
option?.type === "select" &&
option.options.some((entry) =>
"value" in entry
? entry.value === applied
: entry.options.some((nested) => nested.value === applied),
);
return {
state: { pinned: before.pinned, applied },
option:
selectable && option?.type === "select" ? { ...option, currentValue: applied } : undefined,
error,
};
}
}

81
src/session-env.ts Normal file
View File

@@ -0,0 +1,81 @@
/**
* Session options that come from the agent's environment, shared by the v1
* core and the v2 core.
*/
import * as fs from "node:fs/promises";
import * as path from "node:path";
import type { Options, Settings, ThinkingConfig } from "@anthropic-ai/claude-agent-sdk";
/** Translate the legacy `MAX_THINKING_TOKENS` env var, plus the optional
* `CLAUDE_CODE_THINKING_DISPLAY` env var, into the SDK's `thinking` option.
* The `maxThinkingTokens` option it used to feed is deprecated and reduced
* to on/off on current models, so map the value to explicit thinking config
* instead: unset → `undefined` (SDK default, adaptive on models that support
* it) unless a display mode is requested, in which case it becomes explicit
* `adaptive`; `0` → disabled; a positive integer → a fixed token budget.
* Anything else is ignored with a warning. `CLAUDE_CODE_THINKING_DISPLAY`
* must be `summarized` or `omitted`; it has no effect when thinking is
* disabled. */
export function resolveThinkingConfig(
rawTokens: string | undefined,
rawDisplay: string | undefined,
logger: { error: (...args: unknown[]) => void },
): ThinkingConfig | undefined {
let display: "summarized" | "omitted" | undefined;
if (rawDisplay !== undefined) {
if (rawDisplay === "summarized" || rawDisplay === "omitted") {
display = rawDisplay;
} else {
logger.error(
`Ignoring CLAUDE_CODE_THINKING_DISPLAY: expected 'summarized' or 'omitted', got '${rawDisplay}'.`,
);
}
}
if (rawTokens === undefined) {
return display ? { type: "adaptive", display } : undefined;
}
const parsed = Number.parseInt(rawTokens, 10);
if (Number.isNaN(parsed) || parsed < 0) {
logger.error(
`Ignoring MAX_THINKING_TOKENS: expected a non-negative integer, got '${rawTokens}'.`,
);
return display ? { type: "adaptive", display } : undefined;
}
return parsed === 0
? { type: "disabled" }
: { type: "enabled", budgetTokens: parsed, ...(display && { display }) };
}
/** The `CLAUDE_MODEL_CONFIG` JSON: Bedrock model overrides and an allowlist, say. */
function parseModelConfig(
raw: string | undefined,
): { modelOverrides?: Record<string, string>; availableModels?: string[] } | undefined {
if (!raw) return undefined;
const parsed = JSON.parse(raw);
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
throw new Error("CLAUDE_MODEL_CONFIG must be a JSON object");
}
const result: { modelOverrides?: Record<string, string>; availableModels?: string[] } = {};
if (parsed.modelOverrides !== undefined) result.modelOverrides = parsed.modelOverrides;
if (parsed.availableModels !== undefined) result.availableModels = parsed.availableModels;
return Object.keys(result).length > 0 ? result : undefined;
}
/**
* The programmatic settings of a session, before the provider routing: the
* models of `CLAUDE_MODEL_CONFIG`, then the client's `settings` option, an
* object or a file path relative to `cwd`. Throws for a `CLAUDE_MODEL_CONFIG`
* that is no JSON object, before it reads the file.
*/
export async function configuredSettings(
provided: Options["settings"],
cwd: string,
): Promise<Settings | undefined> {
const modelConfig = parseModelConfig(process.env.CLAUDE_MODEL_CONFIG);
const object =
typeof provided === "string"
? (JSON.parse(await fs.readFile(path.resolve(cwd, provided), "utf8")) as Settings)
: (provided as Settings | undefined);
return modelConfig ? { ...modelConfig, ...object } : object;
}

View File

@@ -1,4 +1,4 @@
import type { ClientCapabilities, SessionNotification } from "@agentclientprotocol/sdk";
import type { ClientCapabilities, SessionNotification } from "@agentclientprotocol/sdk/unstable";
import {
getSessionMessages,
type SDKAssistantMessageError,

190
src/session-fast-mode.ts Normal file
View File

@@ -0,0 +1,190 @@
/**
* The Fast mode toggle of a session: its config option, the value a
* `session/set_config_option` request sets, and the state Claude Code reports
* (`fast_mode_state` and `fast_mode_disabled_reason`, on `system`/init and on
* results). Shared by the v1 core and the v2 core.
*/
import { RequestError } from "@agentclientprotocol/sdk/unstable";
import type {
SessionConfigOption,
SetSessionConfigOptionRequest,
} from "@agentclientprotocol/sdk/unstable";
import type { FastModeDisabledReason, FastModeState } from "@anthropic-ai/claude-agent-sdk";
import { sentenceCase } from "./session-notices.js";
export const FAST_MODE_CONFIG_ID = "fast";
/** Select-fallback values used when the client has not opted into boolean
* config options (see {@link createFastModeConfigOption}). */
export const FAST_MODE_ON = "on";
export const FAST_MODE_OFF = "off";
const FAST_MODE_DESCRIPTION = "Faster responses on supported models";
/** Map the SDK's tri-state `fast_mode_state` onto the boolean config toggle.
* `cooldown` (fast mode temporarily suspended after a rate limit, per the SDK
* docs) keeps the toggle on so it reflects the user's intent — only an
* explicit `off` clears it. */
export function fastModeStateEnabled(state: FastModeState): boolean {
return state !== "off";
}
/** User-facing explanations for the SDK's `fast_mode_disabled_reason` values
* that a user can act on (or at least wants to know about). Deliberately
* partial — the omitted reasons are not worth surfacing:
* - `sdk_opt_in_required`: every SDK session starts here (the toggle IS the
* opt-in), so it describes the default, not a problem.
* - `preference`: the user turned Fast mode off themselves.
* - `pending`: eligibility is still resolving; the next report supersedes it.
* - `unknown`: nothing meaningful to say.
* Unknown future reasons fall through the same way (open set — the SDK's docs
* say to ignore values you don't handle). */
const FAST_MODE_UNAVAILABLE_EXPLANATIONS: Partial<Record<FastModeDisabledReason, string>> = {
free: "not available on the free plan",
extra_usage_disabled: "requires extra usage to be enabled for this account",
model_not_allowed: "not available for the selected model",
not_first_party: "not available on this API provider",
disabled_by_env: "disabled by environment configuration",
network_error: "eligibility could not be verified (network error)",
};
/** Normalize an SDK-reported `fast_mode_disabled_reason` to the one we retain:
* a reason we have an explanation for, else `undefined`. Keeping only
* explainable reasons means state comparisons (see `fastModeStateChange`) track
* exactly what the user can see, so routine reports like
* `sdk_opt_in_required` never churn the config option. */
export function normalizeFastModeDisabledReason(
reason: FastModeDisabledReason | undefined,
): FastModeDisabledReason | undefined {
return reason && FAST_MODE_UNAVAILABLE_EXPLANATIONS[reason] ? reason : undefined;
}
/** Build the Fast mode config option. When the Client supports boolean config
* options we expose a native `type: "boolean"` toggle; otherwise we degrade to
* a two-value `select` ("on"/"off") so older Clients still get a usable
* control.
*
* `disabledReason` (the SDK's `fast_mode_disabled_reason`) is folded into the
* description while the toggle reads off, so a user whose account or provider
* can't serve Fast mode sees why instead of a switch that silently refuses to
* stay on. Ignored while enabled: a reason reported alongside an `on`/`cooldown`
* state isn't blocking anything right now. */
export function createFastModeConfigOption(
enabled: boolean,
useBooleanOption: boolean,
disabledReason?: FastModeDisabledReason,
): SessionConfigOption {
const explanation = enabled
? undefined
: disabledReason && FAST_MODE_UNAVAILABLE_EXPLANATIONS[disabledReason];
const base = {
id: FAST_MODE_CONFIG_ID,
name: "Fast mode",
description: explanation ? `${FAST_MODE_DESCRIPTION} — ${explanation}` : FAST_MODE_DESCRIPTION,
category: "model_config",
} as const;
if (useBooleanOption) {
return { ...base, type: "boolean", currentValue: enabled };
}
return {
...base,
type: "select",
currentValue: enabled ? FAST_MODE_ON : FAST_MODE_OFF,
options: [
{ value: FAST_MODE_ON, name: "On" },
{ value: FAST_MODE_OFF, name: "Off" },
],
};
}
/** Resolve the requested Fast mode value from a `session/set_config_option`
* request. Accepts a native boolean (boolean-capable Clients) or the
* "on"/"off" select-fallback strings. */
export function resolveFastModeEnabled(params: SetSessionConfigOptionRequest): boolean {
const value = params.value;
if (typeof value === "boolean") {
return value;
}
if (value === FAST_MODE_ON) {
return true;
}
if (value === FAST_MODE_OFF) {
return false;
}
throw RequestError.invalidParams(
undefined,
`Invalid value for config option ${FAST_MODE_CONFIG_ID}: ${value}`,
);
}
/** Per-model Fast mode state threaded into `buildConfigOptions`. The
* option is only surfaced when the current model `supported`s fast mode. */
export type FastModeOptionState = {
supported: boolean;
enabled: boolean;
/** Whether the Client opted into boolean config options. */
useBooleanOption: boolean;
/** Latest explainable `fast_mode_disabled_reason`, folded into the option's
* description while the toggle reads off. */
disabledReason?: FastModeDisabledReason;
};
/** What a reported `fast_mode_state` changes in a Fast mode toggle that the
* current model shows. The SDK can flip fast mode independently of the
* user — e.g. back to `on` once a rate-limit `cooldown` clears — so
* definitive on/off changes are mirrored into the option.
*
* Undefined when nothing changes: an absent state, a `cooldown` (a transient
* suspension of an already-enabled fast mode: the toggle stays as-is rather
* than flapping, and a stray cooldown never enables a toggle the user has
* off), or the same state with the same explainable reason.
*
* `reason` is the SDK's `fast_mode_disabled_reason`, reported alongside the
* state. Only explainable reasons are retained (see
* {@link normalizeFastModeDisabledReason}), so a routine
* `sdk_opt_in_required` report on every turn's result can't churn the
* option, while a real blocker updates the description even when the
* toggle's own value is unchanged.
*
* `notice` is set when the user had Fast mode on and the SDK turned it off
* for an explainable reason: the description carries the same explanation,
* but a toggle silently snapping back is worth saying out loud once. */
export function fastModeStateChange(
current: { enabled: boolean; disabledReason?: FastModeDisabledReason },
state: FastModeState | undefined,
reason: FastModeDisabledReason | undefined,
):
| { enabled: boolean; disabledReason?: FastModeDisabledReason; notice?: FastModeOffNotice }
| undefined {
if (state === undefined || state === "cooldown") return undefined;
const enabled = state === "on";
// A reason only describes an off state; drop any that rides an `on` report
// so it can't decorate the option the next time fast mode goes off.
const disabledReason = enabled ? undefined : normalizeFastModeDisabledReason(reason);
if (enabled === current.enabled && disabledReason === current.disabledReason) return undefined;
const explanation =
disabledReason !== undefined ? FAST_MODE_UNAVAILABLE_EXPLANATIONS[disabledReason] : undefined;
if (!current.enabled || enabled || explanation === undefined) {
return { enabled, disabledReason };
}
return {
enabled,
disabledReason,
notice: {
notice: {
severity: "warning",
title: "Fast mode turned off",
description: `${sentenceCase(explanation)}.`,
},
transcript: `**Fast mode turned off:** ${explanation}.`,
},
};
}
/** The notice of Fast mode that the SDK turned off, and its transcript line
* for a client without `notice` updates. */
export type FastModeOffNotice = {
notice: { severity: "warning"; title: string; description: string };
transcript: string;
};

View File

@@ -11,6 +11,10 @@
* - an interactive CLI that is not driven by an SDK holds it, and its registry
* status is newer than the transcript: the registry status (an idle one is
* `error` after such a turn);
* - a turn that ended with an open background task is `running`, also after
* 10 minutes: Claude Code wakes the session again when the task ends. The
* wait ends when the registry status is `idle`, because a task can end
* without a transcript record (a stop in the task UI);
* - otherwise the transcript tail: a finished turn is `idle` (`error` after
* an API error), an unfinished turn written in the last 10 minutes is
* `running`, and anything else has no known state.
@@ -72,6 +76,14 @@ function usesRegistryStatus(record: LiveRecord, transcriptMtimeMs: number): bool
);
}
/** Whether the last turn ended while a background task of the session is
* open. */
function waitsForBackgroundTasks(facts: TranscriptFacts): boolean {
if (facts.turnState !== "finished") return false;
const openTasks = facts.backgroundTasks?.open ?? [];
return openTasks.length > 0;
}
function iso(ms: number | undefined): string | undefined {
return ms === undefined || !Number.isFinite(ms) ? undefined : new Date(ms).toISOString();
}
@@ -103,6 +115,9 @@ export function deriveActivity(input: {
// A busy or waiting CLI wins; an idle one shows how its turn ended.
state = registryState(live.status);
if (state === "idle" && failed) state = "error";
} else if (waitsForBackgroundTasks(facts) && live.status !== "idle") {
// Claude Code wakes the session again when an open task ends.
state = "running";
} else if (failed) {
state = "error";
} else if (facts.turnState === "finished") {

View File

@@ -0,0 +1,223 @@
/**
* The background tasks of a session that did not end yet, from its transcript.
*
* A Claude Code session can start a background shell command or a background
* sub-agent and end its turn at once. When the task ends, Claude Code wakes
* the session with a `<task-notification>` input for a short turn. So a turn
* end with an open task is no end of the work: the session list keeps such a
* session `running` (see `activity.ts`).
*
* The records, as Claude Code 2.1.299 writes them, in the rules of AIR's
* native Claude provider:
*
* - a tool result opens a task when its `toolUseResult` has a
* `backgroundTaskId` (a shell command), or `isAsync: true` with an
* `agentId` (a sub-agent);
* - a task closes when its notification reaches the session: as a user
* record, or as a `queue-operation` `remove` when a running turn takes it
* in. A notification that waits in the queue (`enqueue`) keeps the task
* open, because the session still runs a turn for it;
* - only a notification with a `<status>` closes a task. A notification
* without one comes while the task still runs: the check-in of a quiet
* shell ("is still running after …", since Claude Code 2.1.303) or a
* `Monitor` event;
* - a `TaskStop` result closes the task of its `toolUseResult.task_id`:
* Claude Code writes no notification for it. The result does not name its
* tool, so its `tool_use_id` must match a `TaskStop` call. A `task_id` in
* the result of another tool keeps the task open;
* - a resumed CLI writes a `stopped` notification for each task of the
* previous process;
* - a sidechain record belongs to a sub-agent and opens or closes no task of
* the session.
*
* A background shell that does not end, for example a dev server, keeps its
* task open, so its session stays `running` while the CLI holds it.
*
* A task that a sub-agent starts is not seen. Its start record is only in the
* transcript of the sub-agent, so the session can be `idle` while the
* sub-agent waits for it.
*/
import { createReadStream } from "node:fs";
import { createInterface } from "node:readline";
import { TASK_NOTIFICATION_TAG, taskNotificationsOf } from "../task-notifications.js";
export type BackgroundTasks = {
/** The ids of the tasks that started and did not end, in start order. */
open: string[];
/** The `tool_use` ids of the `TaskStop` calls without a result. */
stopCalls: string[];
/** Whether the sets hold the records of the whole file. A set from the
* tail window only does not know a task that started before it. */
complete: boolean;
};
/** The fields of a transcript record that bear on a task. */
type TranscriptRecord = {
type?: string;
isSidechain?: boolean;
/** Of a `queue-operation` record: `enqueue`, `remove`, ... */
operation?: string;
/** Of a `queue-operation` record: the queued input. */
content?: string;
message?: { content?: string | ContentBlock[] };
toolUseResult?: ToolUseResult;
};
type ContentBlock = {
type?: string;
text?: string;
/** Of a `tool_use` block. */
id?: string;
name?: string;
/** Of a `tool_result` block. */
tool_use_id?: string;
};
type ToolUseResult = {
/** The task of a background shell command. */
backgroundTaskId?: string;
/** With `agentId`: an async sub-agent. */
isAsync?: boolean;
agentId?: string;
/** The task that a `TaskStop` call stopped. Another tool can give one. */
task_id?: string;
};
const NOTIFICATION_TAG = `<${TASK_NOTIFICATION_TAG}>`;
/** Text that a line must hold to bear on a task, so that the other lines
* are not parsed. */
const TASK_MARKERS = ["backgroundTaskId", '"isAsync"', NOTIFICATION_TAG, "TaskStop", "task_id"];
function parseRecord(line: string): TranscriptRecord | undefined {
try {
return JSON.parse(line) as TranscriptRecord;
} catch {
return undefined;
}
}
function blocksOf(record: TranscriptRecord): ContentBlock[] {
const content = record.message?.content;
return Array.isArray(content) ? content : [];
}
/** The input of a user record: its string content, else its first text
* block. */
function inputTextOf(record: TranscriptRecord): string | undefined {
const content = record.message?.content;
if (typeof content === "string") return content;
return blocksOf(record).find((block) => block.type === "text")?.text;
}
/** The task that a `<task-notification>` input ends: only an input that
* starts with the tag, after whitespace, as AIR's `TaskNotification` reads
* it. Only a notification with a `<status>` ends a task (see
* `taskNotificationsOf`). */
export function endedTaskId(input: string | undefined): string | undefined {
if (!input?.trimStart().startsWith(NOTIFICATION_TAG)) return undefined;
return taskNotificationsOf(input)[0]?.task_id;
}
/** The task that a tool result started: a background shell command, or an
* async sub-agent. */
function startedTaskId(record: TranscriptRecord): string | undefined {
if (record.type !== "user") return undefined;
const result = record.toolUseResult;
if (result?.backgroundTaskId) return result.backgroundTaskId;
if (result?.isAsync) return result.agentId;
return undefined;
}
/** The task whose notification reached the session: as a user record, or
* as a queue `remove` when a running turn takes it in. */
function deliveredTaskId(record: TranscriptRecord): string | undefined {
if (record.type === "user") return endedTaskId(inputTextOf(record));
if (record.type === "queue-operation" && record.operation === "remove") {
return endedTaskId(record.content);
}
return undefined;
}
/** Collects the tasks of the lines it is given, in file order. */
class TaskTracker {
private readonly open: Set<string>;
private readonly stopCalls: Set<string>;
constructor(after?: BackgroundTasks) {
this.open = new Set(after?.open);
this.stopCalls = new Set(after?.stopCalls);
}
add(line: string): void {
const mayBearOnTask = TASK_MARKERS.some((marker) => line.includes(marker));
if (!mayBearOnTask) return;
const record = parseRecord(line);
// A sidechain record belongs to a sub-agent, which receives the
// notifications of its own tasks.
if (!record || record.isSidechain) return;
this.apply(record);
}
result({ complete }: { complete: boolean }): BackgroundTasks {
return { open: [...this.open], stopCalls: [...this.stopCalls], complete };
}
private apply(record: TranscriptRecord): void {
const started = startedTaskId(record);
if (started) this.open.add(started);
const delivered = deliveredTaskId(record);
if (delivered) this.open.delete(delivered);
this.applyTaskStop(record);
}
/** A `task_id` stops its task only in the result of a `TaskStop` call:
* another tool can give a `task_id` too. */
private applyTaskStop(record: TranscriptRecord): void {
let isTaskStopResult = false;
for (const block of blocksOf(record)) {
if (block.type === "tool_use" && block.name === "TaskStop" && block.id) {
this.stopCalls.add(block.id);
}
const resultId = block.tool_use_id;
if (resultId && this.stopCalls.has(resultId)) {
this.stopCalls.delete(resultId);
isTaskStopResult = true;
}
}
const stopped = record.toolUseResult?.task_id;
if (isTaskStopResult && stopped) this.open.delete(stopped);
}
}
/**
* The tasks of the lines of `text`, applied on the tasks `after`.
*
* `after` can be what an earlier scan found for a shorter copy of the file,
* and `text` can repeat records of that copy. The result is still the same:
* the last record that opens or closes a task decides, and `text` holds the
* last records.
*/
export function backgroundTasksOf(
text: string,
options: { complete: boolean; after?: BackgroundTasks },
): BackgroundTasks {
const tracker = new TaskTracker(options.after);
for (const line of text.split("\n")) tracker.add(line);
return tracker.result({ complete: options.complete });
}
/** The tasks of the first `size` bytes of `filePath`, from all its records.
* Reads the whole file, so only for a session that a live process holds. */
export async function searchBackgroundTasks(
filePath: string,
size: number,
): Promise<BackgroundTasks> {
const tracker = new TaskTracker();
if (size === 0) return tracker.result({ complete: true });
const stream = createReadStream(filePath, { start: 0, end: size - 1, encoding: "utf8" });
const lines = createInterface({ input: stream, crlfDelay: Infinity });
for await (const line of lines) tracker.add(line);
return tracker.result({ complete: true });
}

View File

@@ -15,15 +15,18 @@ import {
type InitializeRequest,
type ListSessionsRequest,
type ListSessionsResponse,
} from "@agentclientprotocol/sdk";
import { deleteSession as sdkDeleteSession } from "@anthropic-ai/claude-agent-sdk";
} from "@agentclientprotocol/sdk/unstable";
import type { Query } from "@anthropic-ai/claude-agent-sdk";
import type { AcpClient, Logger, Session } from "../acp-agent.js";
import type { Session } from "../acp-agent.js";
import type { AcpClient } from "../acp-client.js";
import type { Logger } from "../logger.js";
import {
AIR_SESSION_ARCHIVE_CAPABILITY,
AIR_SESSION_BATCH_GET_CAPABILITY,
AIR_SESSION_INDEX_CAPABILITY,
AIR_SESSION_LIST_SUBSCRIBE_CAPABILITY,
AIR_SESSION_RENAME_CAPABILITY,
AIR_SESSION_WORKTREES_CAPABILITY,
clientSupportsAirCapability,
withAirMeta,
} from "../air-extension.js";
@@ -41,10 +44,13 @@ import {
} from "./list-subscriptions.js";
import {
archiveInsteadOfDelete,
deleteSessionIfStored,
archiveTo,
renameTo,
parseRenameSessionRequest,
parseSessionIdRequest,
parseBatchGetSessionsRequest,
SESSION_BATCH_GET_METHOD,
SESSION_ARCHIVE_METHOD,
SESSION_RENAME_METHOD,
SESSION_UNARCHIVE_METHOD,
@@ -53,6 +59,8 @@ import {
type RetitleOptions,
type SessionIdRequest,
type TitleChange,
type BatchGetSessionsRequest,
type BatchGetSessionsResponse,
} from "./service.js";
/** What the session index keeps on each `Session`. */
@@ -115,13 +123,11 @@ export class SessionIndexConnection {
constructor(private readonly host: SessionIndexHost) {}
/** Sets the index up for the client of `initialize`. ACP v2 does not route
* the session index methods yet. */
negotiate(request: InitializeRequest, options: { v2: boolean }): void {
/** Sets the index up for the client of `initialize`. */
negotiate(request: InitializeRequest): void {
this.service?.dispose();
this.service = undefined;
if (
!options.v2 &&
this.host.isAirClient() &&
clientSupportsAirCapability(request.clientCapabilities, AIR_SESSION_INDEX_CAPABILITY)
) {
@@ -143,15 +149,17 @@ export class SessionIndexConnection {
}
/** The AIR capabilities the agent advertises for the index: the index
* itself, archive, rename and the list subscription, all only to a client
* that declared `sessionIndex`. */
* itself, batch get, archive, rename, the list subscription and the `agentWorktree`
* row field, all only to a client that declared `sessionIndex`. */
capabilities(): string[] {
return this.service
? [
AIR_SESSION_INDEX_CAPABILITY,
AIR_SESSION_BATCH_GET_CAPABILITY,
AIR_SESSION_ARCHIVE_CAPABILITY,
AIR_SESSION_RENAME_CAPABILITY,
AIR_SESSION_LIST_SUBSCRIBE_CAPABILITY,
AIR_SESSION_WORKTREES_CAPABILITY,
]
: [];
}
@@ -187,6 +195,31 @@ export class SessionIndexConnection {
this.service?.ownSessionChanged(sessionId);
}
/**
* Runs `fork`, the handler of a `session/fork` request of `sourceId`. The
* session list holds back the forks of the source until the response is
* out. The client then knows a fork from the response before a list shows
* it.
*/
async forking<T>(sourceId: string, fork: () => Promise<T>): Promise<T> {
// The forks of this connection: the request handler reads the service
// once, so a later `initialize` does not split the start and the answer.
const service = this.service;
service?.forkStarted(sourceId);
try {
return await fork();
} finally {
// The SDK writes the response in the microtasks after the handler
// resolves, so a `setTimeout(0)` callback runs after it.
setTimeout(() => service?.forkAnswered(sourceId), 0);
}
}
/** The `session/fork` of `sourceId` copied the transcript into `forkId`. */
onForkCreated(sourceId: string, forkId: string): void {
this.service?.forkCreated(sourceId, forkId);
}
/** `_session/list/subscribe`. */
subscribeList(params: ListSubscribeRequest): Promise<ListSubscribeResponse> {
return this.requireService(LIST_SUBSCRIBE_METHOD).subscribeList(params.cwd);
@@ -203,6 +236,12 @@ export class SessionIndexConnection {
return this.service?.list(params, (sessionId) => this.ownSessionState(sessionId));
}
batchGet(params: BatchGetSessionsRequest): Promise<BatchGetSessionsResponse> {
return this.requireService(SESSION_BATCH_GET_METHOD).batchGet(params, (sessionId) =>
this.ownSessionState(sessionId),
);
}
/** An AIR client archives with session/delete (see {@link deleteSession}):
* its archived sessions (an archived custom title) stay hidden from the
* old list, as when the delete removed them. */
@@ -336,7 +375,8 @@ export class SessionIndexConnection {
* - Another AIR client uses delete to mark a session done, and may reopen
* it later: the adapter archives it instead (as `_session/archive` does,
* after the CLI closed), so the transcript survives.
* - Every other client: the SDK delete, as before.
* - Every other client: the SDK delete. A session without a transcript
* succeeds (see {@link deleteSessionIfStored}).
*/
async deleteSession(params: DeleteSessionRequest): Promise<DeleteSessionResponse> {
const loaded = this.host.agent.sessions[params.sessionId] !== undefined;
@@ -350,7 +390,7 @@ export class SessionIndexConnection {
} else if (this.host.isAirClient()) {
await archiveInsteadOfDelete(params.sessionId, this.airArchiver());
} else {
await sdkDeleteSession(params.sessionId);
await deleteSessionIfStored(params.sessionId);
}
return {};
}
@@ -420,6 +460,7 @@ export class SessionIndexConnection {
/** The agent methods behind the session index extension methods. */
type SessionIndexMethods = {
batchGetSessions(params: BatchGetSessionsRequest): Promise<BatchGetSessionsResponse>;
subscribeSessionList(params: ListSubscribeRequest): Promise<ListSubscribeResponse>;
unsubscribeSessionList(params: ListUnsubscribeRequest): Promise<EmptyResponse>;
renameSessionTitle(params: RenameSessionRequest): Promise<EmptyResponse>;
@@ -427,10 +468,15 @@ type SessionIndexMethods = {
unarchiveSession(params: SessionIdRequest): Promise<EmptyResponse>;
};
/** Routes `_session/list/subscribe`, `_session/list/unsubscribe`,
/** Routes `_session/batch_get`, `_session/list/subscribe`, `_session/list/unsubscribe`,
* `_session/rename`, `_session/archive` and `_session/unarchive`. */
export function onSessionIndexRequests(app: AgentApp, agent: () => SessionIndexMethods): AgentApp {
return app
.onRequest<BatchGetSessionsRequest, BatchGetSessionsResponse>(
SESSION_BATCH_GET_METHOD,
{ parse: parseBatchGetSessionsRequest },
(ctx) => agent().batchGetSessions(ctx.params),
)
.onRequest<ListSubscribeRequest, ListSubscribeResponse>(
LIST_SUBSCRIBE_METHOD,
{ parse: parseListSubscribeRequest },

View File

@@ -40,7 +40,7 @@ import { randomUUID } from "node:crypto";
import * as fs from "node:fs";
import * as fsp from "node:fs/promises";
import * as path from "node:path";
import { RequestError, type SessionInfo } from "@agentclientprotocol/sdk";
import { RequestError, type SessionInfo } from "@agentclientprotocol/sdk/unstable";
import type { OwnSessionState } from "./activity.js";
import {
bySession,
@@ -111,6 +111,8 @@ export type ListSubscriptionDeps = {
/** What this connection knows of a session it runs. */
own: (sessionId: string) => OwnSessionState | undefined;
notify: (changes: ListChanges) => Promise<void>;
/** The forks whose rows wait for their fork response. */
forkHolds?: ForkHolds;
logError: (message: string, error: unknown) => void;
now?: () => number;
debounceMs?: number;
@@ -242,6 +244,54 @@ function transcriptId(filename: string): string | undefined {
return isSessionId(id) ? id : undefined;
}
/**
* The `session/fork` requests in flight. A client must get the id of a fork
* from the fork response before a list shows the fork. Otherwise the client
* sees an unknown session and lists it as a session of its own.
*/
export class ForkHolds {
/** Source session id → the number of its forks in flight, and the forks
* they copied the transcript into. */
private readonly sources = new Map<string, { count: number; forks: Set<string> }>();
started(sourceId: string): void {
const source = this.sources.get(sourceId);
if (source) source.count++;
else this.sources.set(sourceId, { count: 1, forks: new Set() });
}
/** A fork of `sourceId` copied the transcript into `forkId`. */
created(sourceId: string, forkId: string): void {
this.sources.get(sourceId)?.forks.add(forkId);
}
/** The response of a fork of `sourceId` is out. Returns whether that
* freed rows: the last fork of the source answered. */
answered(sourceId: string): boolean {
const source = this.sources.get(sourceId);
if (!source) return false;
if (--source.count > 0) return false;
this.sources.delete(sourceId);
return true;
}
/** Whether `id` is a fork whose fork response is not out yet. */
isHeld(id: string): boolean {
for (const { forks } of this.sources.values()) if (forks.has(id)) return true;
return false;
}
/**
* Whether the row of `id` waits. A fork that a fork in flight created
* waits. Before the copy returns its id, an unsent row of a fork of a
* source in flight waits too, by its `forkedFrom`.
*/
holds(id: string, sent: boolean, forkedFrom: string | undefined): boolean {
if (this.isHeld(id)) return true;
return !sent && forkedFrom !== undefined && this.sources.has(forkedFrom);
}
}
export class ListSubscriptions {
private readonly subscriptions = new Map<string, Subscription>();
/** Sessions whose row shows this connection's SDK state: the rescan
@@ -348,6 +398,13 @@ export class ListSubscriptions {
}
}
/** Sends the pending sessions now: {@link ForkHolds} held some back. */
deliverPending(): void {
for (const subscription of this.subscriptions.values()) {
if (subscription.pending.size > 0) this.deliver(subscription);
}
}
/**
* This connection wrote the transcripts of a session: a rename, archive,
* unarchive or delete. Its transcripts are stat'ed and its row read again
@@ -1199,7 +1256,10 @@ export class ListSubscriptions {
if (!watch.rows.has(id)) candidates.push(...(priorFiles.get(id) ?? []));
}
if (candidates.length === 0) return new Set();
const { rows, unknown } = await this.deps.index.cachedRowsOf(paths, candidates);
// Only the ids count: no worktree check.
const { rows, unknown } = await this.deps.index.cachedRowsOf(paths, candidates, {
mark: false,
});
for (const row of rows) unknown.add(row.sessionId);
return unknown;
}
@@ -1224,6 +1284,12 @@ export class ListSubscriptions {
subscription.pending.delete(id);
continue;
}
// A fork whose fork response is not out yet stays pending.
if (
this.deps.forkHolds?.holds(id, subscription.sent.has(id), current?.row.facts.forkedFrom)
) {
continue;
}
const dueAt = (subscription.sentAt.get(id) ?? -Infinity) + this.minIntervalMs;
if (dueAt > now) {
nextAt = Math.min(nextAt, dueAt);

View File

@@ -104,6 +104,17 @@ export async function canonicalPath(cwd: string): Promise<string> {
}
}
/** Whether the path exists, including a symlink whose target is missing. */
export async function exists(target: string): Promise<boolean> {
try {
await fs.lstat(target);
return true;
} catch (error) {
if (errorCode(error) === "ENOENT" || errorCode(error) === "ENOTDIR") return false;
throw error;
}
}
/** The names of the project directories of `cwd` among `rootEntries`. */
export function projectDirsOf(cwd: string, rootEntries: readonly string[]): string[] {
const exact = encodeProjectPath(cwd);

View File

@@ -15,7 +15,8 @@ import {
RequestError,
type ListSessionsRequest,
type ListSessionsResponse,
} from "@agentclientprotocol/sdk";
type SessionInfo,
} from "@agentclientprotocol/sdk/unstable";
import { deleteSession as sdkDeleteSession } from "@anthropic-ai/claude-agent-sdk";
import { airExtensionMeta } from "../air-extension.js";
import { sanitizeTitle } from "../session-titles.js";
@@ -28,6 +29,7 @@ import {
visibleTitle,
} from "./archive-title.js";
import {
ForkHolds,
ListSubscriptions,
type ListChanges,
type ListSubscribeResponse,
@@ -36,7 +38,7 @@ import {
import { LiveSessionRegistry } from "./live-registry.js";
import {
canonicalPath,
errorCode,
exists,
isExactProjectDir,
isSessionId,
projectDirMatches,
@@ -64,6 +66,7 @@ import { sessionInfoOf } from "./session-info.js";
export const SESSION_RENAME_METHOD = "_session/rename";
export const SESSION_ARCHIVE_METHOD = "_session/archive";
export const SESSION_UNARCHIVE_METHOD = "_session/unarchive";
export const SESSION_BATCH_GET_METHOD = "_session/batch_get";
/** The JSON-RPC code of an unknown session (ACP `ResourceNotFound`). */
const RESOURCE_NOT_FOUND = -32002;
@@ -72,6 +75,27 @@ const CURSOR_VERSION = 5;
export type SessionIdRequest = { sessionId: string };
export type RenameSessionRequest = { sessionId: string; title: string };
export type BatchGetSessionsRequest = { sessionIds: string[] };
export type BatchGetSessionsResponse = {
sessions: SessionInfo[];
};
export function parseBatchGetSessionsRequest(value: unknown): BatchGetSessionsRequest {
const record = asRecord(value);
const ids = record.sessionIds;
if (
Object.keys(record).length !== 1 ||
!Array.isArray(ids) ||
ids.some((id) => typeof id !== "string" || !id.trim()) ||
new Set(ids).size !== ids.length
) {
throw RequestError.invalidParams(
undefined,
"params require only sessionIds: distinct, non-blank strings",
);
}
return { sessionIds: ids };
}
export function sessionNotFound(sessionId: string): RequestError {
return new RequestError(RESOURCE_NOT_FOUND, `Session not found: ${sessionId}`, { sessionId });
@@ -363,39 +387,43 @@ function isSdkNotFound(error: unknown): boolean {
return error instanceof Error && /^Session \S+ not found in /.test(error.message);
}
async function exists(target: string): Promise<boolean> {
/**
* `session/delete` of a client that is neither AIR nor a `sessionIndex` client:
* the SDK delete of the session's transcript. A session without one succeeds,
* as ACP asks ("Deleting an already-deleted session, or a session that never
* existed, SHOULD succeed silently"): an id that is not a session id, a
* session deleted before, or a session this connection ran but never prompted,
* since Claude Code writes the transcript at the first prompt. Other failures
* still fail the request.
*/
export async function deleteSessionIfStored(
sessionId: string,
deleteSession: (sessionId: string) => Promise<void> = sdkDeleteSession,
): Promise<void> {
if (!isSessionId(sessionId)) return;
try {
await fs.lstat(target);
return true;
await deleteSession(sessionId);
} catch (error) {
if (errorCode(error) === "ENOENT" || errorCode(error) === "ENOTDIR") return false;
if (isSdkNotFound(error)) return;
throw error;
}
}
/** The error the SDK `deleteSession` gives for a session id it rejects or
* does not find, which a client without `sessionIndex` got before. */
function sdkDeleteError(sessionId: string): Error {
return new Error(
isSessionId(sessionId)
? `Session ${sessionId} not found in any project directory`
: `Invalid sessionId: ${sessionId}`,
);
}
/**
* `session/delete` of an AIR client without `sessionIndex`, which uses delete
* to mark a session done: archives it instead, so the transcript survives. A
* session without a transcript fails as the SDK delete did.
* session without a transcript has nothing to archive and succeeds, as ACP
* asks of a delete of a session that never existed (see
* {@link deleteSessionIfStored}).
*/
export async function archiveInsteadOfDelete(
sessionId: string,
service: SessionIndexService,
): Promise<void> {
if (!isSessionId(sessionId)) throw sdkDeleteError(sessionId);
if (!isSessionId(sessionId)) return;
// The SDK delete skips empty transcripts, so they do not count.
const found = await service.index.findTranscripts(sessionId);
if (found.length === 0) throw sdkDeleteError(sessionId);
if (found.length === 0) return;
await service.retitle(sessionId, archiveTo(true), { sidecar: "existing" });
}
@@ -442,6 +470,9 @@ export class SessionIndexService {
readonly index: SessionIndex;
readonly registry: LiveSessionRegistry;
private subscriptions?: ListSubscriptions;
/** Here and not in the subscriptions, which start with the first subscribe:
* a subscribe during a fork holds that fork back too. */
private readonly forkHolds = new ForkHolds();
/** Set for good by {@link dispose}: no subscription is made after it. */
private disposed = false;
/** The mutation in flight per session, which the next one waits for. */
@@ -450,12 +481,18 @@ export class SessionIndexService {
private readonly deleteSession: (sessionId: string) => Promise<void>;
constructor(private readonly deps: SessionIndexDeps) {
this.index = new SessionIndex();
this.registry = deps.registry ?? new LiveSessionRegistry();
this.index = new SessionIndex({ isLive: (sessionId) => this.isLive(sessionId) });
this.now = deps.now ?? Date.now;
this.deleteSession = deps.deleteSession ?? ((id) => sdkDeleteSession(id));
}
/** Whether a live process holds the session, by the live registry. */
private async isLive(sessionId: string): Promise<boolean> {
const live = await this.registry.snapshot();
return live.has(sessionId);
}
/** `session/list` of a `sessionIndex` client. `own` reports the sessions
* that this connection runs. */
async list(
@@ -480,13 +517,30 @@ export class SessionIndexService {
});
const live = await livePromise;
const now = this.now();
const sessions = rows.map((row) =>
sessionInfoOf(row, own(row.sessionId), live.get(row.sessionId), now),
);
const sessions = rows
// A fork whose fork response is not out yet is left out, as in the list subscriptions.
.filter((row) => !this.forkHolds.isHeld(row.sessionId))
.map((row) => sessionInfoOf(row, own(row.sessionId), live.get(row.sessionId), now));
const last = rows[rows.length - 1];
return hasMore && last ? { sessions, nextCursor: encodeListCursor(last, scope) } : { sessions };
}
/** Metadata for known ids, independent of list pages and project filters. */
async batchGet(
{ sessionIds }: BatchGetSessionsRequest,
own: (sessionId: string) => OwnSessionState | undefined,
): Promise<BatchGetSessionsResponse> {
if (sessionIds.length === 0) return { sessions: [] };
const live = await this.registry.snapshot();
const now = this.now();
const rows = await this.index.getSessions(sessionIds);
return {
sessions: rows.map((row) =>
sessionInfoOf(row, own(row.sessionId), live.get(row.sessionId), now),
),
};
}
/** Runs `mutation` after the previous mutation of the session ended. */
private exclusive<T>(sessionId: string, mutation: () => Promise<T>): Promise<T> {
const previous = this.mutations.get(sessionId) ?? Promise.resolve();
@@ -686,6 +740,7 @@ export class SessionIndexService {
registry: this.registry,
own: (sessionId) => this.deps.ownSessionState?.(sessionId),
notify: (changes) => this.deps.notifyListChanges?.(changes) ?? Promise.resolve(),
forkHolds: this.forkHolds,
logError: this.deps.logError,
...this.deps.listSubscriptionTiming,
});
@@ -702,6 +757,22 @@ export class SessionIndexService {
this.subscriptions?.ownSessionChanged(sessionId);
}
/** A `session/fork` of `sourceId` started (see {@link ForkHolds}). */
forkStarted(sourceId: string): void {
this.forkHolds.started(sourceId);
}
/** The `session/fork` of `sourceId` copied the transcript into `forkId`. */
forkCreated(sourceId: string, forkId: string): void {
this.forkHolds.created(sourceId, forkId);
}
/** The response of a `session/fork` of `sourceId` is out: the list
* subscriptions send the rows they held back. */
forkAnswered(sourceId: string): void {
if (this.forkHolds.answered(sourceId)) this.subscriptions?.deliverPending();
}
dispose(): void {
this.disposed = true;
this.subscriptions?.dispose();

View File

@@ -26,6 +26,8 @@ import { effectiveTitle, visibleTitle } from "./archive-title.js";
import {
canonicalPath,
encodeProjectPath,
errorCode,
exists,
isExactProjectDir,
isSessionId,
normalizePath,
@@ -52,16 +54,21 @@ import {
type SdkTitles,
type TranscriptFacts,
} from "./transcript-scan.js";
import { searchBackgroundTasks } from "./background-tasks.js";
import { DirListings, statFiles } from "./dir-listing.js";
import { worktreeCounterparts } from "./worktrees.js";
import { isInLinkedWorktree, worktreeCounterparts } from "./worktrees.js";
export const DEFAULT_LIST_LIMIT = 50;
export const MAX_LIST_LIMIT = 200;
/** Discovery could not establish which sessions the store contains. */
export class SessionStoreUnavailableError extends Error {}
/** Entries of the metadata cache: a few hundred bytes each, so the
* transcripts of a large history (tens of thousands) stay cached and deep
* pages do not read them again. */
const METADATA_CACHE_SIZE = 50_000;
const READ_BATCH_SIZE = 16;
/** Session ids looked up together by {@link SessionIndex.getSessions}. */
const LOOKUP_CHUNK_SIZE = 100;
/** Transcripts read at most per directory to recover a sibling's cwd. */
const MAX_CWD_PROBES_PER_DIR = 64;
/** How long a directory that gave no cwd is not probed again. */
@@ -96,6 +103,24 @@ export type TranscriptCandidate = {
ino: number;
};
type TranscriptFile = Pick<
TranscriptCandidate,
"sessionId" | "filePath" | "dirName" | "projectPath"
>;
/** Non-empty transcript files, with bounded stats shared by lists and id lookups. */
async function transcriptCandidates(
files: readonly TranscriptFile[],
): Promise<TranscriptCandidate[]> {
const stats = await statFiles(files.map(({ filePath }) => filePath));
return files.flatMap((file, i) => {
const current = stats[i];
return current?.isFile() && current.size > 0
? [{ ...file, mtimeMs: current.mtimeMs, size: current.size, ino: current.ino }]
: [];
});
}
/** The cached metadata of one transcript. */
type TranscriptMetadata = {
/** The visible title: without the archive prefix. */
@@ -125,6 +150,10 @@ export type IndexRow = {
facts: TranscriptFacts;
mtimeMs: number;
archived: boolean;
/** The session runs in a linked git worktree that Claude Code made for
* it: its transcript says so (`facts.agentWorktree`) and `cwd` lies in
* a linked worktree. See {@link markAgentWorktrees}. */
agentWorktree?: true;
};
/** A keyset position: the order key and the session id of the last row. */
@@ -249,6 +278,22 @@ function toRow({ candidate, metadata }: Resolved, cwd: string): IndexRow {
};
}
/**
* Sets `agentWorktree` on the rows whose transcript says Claude Code made
* their worktree and whose cwd lies in a linked worktree now. Only those rows
* are checked; the check is not cached, so a removed worktree shows at once.
*/
async function markAgentWorktrees(rows: IndexRow[]): Promise<IndexRow[]> {
await Promise.all(
rows.map(async (row) => {
if (row.facts.agentWorktree === true && (await isInLinkedWorktree(row.cwd))) {
row.agentWorktree = true;
}
}),
);
return rows;
}
function isAfter(row: { orderAtMs: number; sessionId: string }, cursor: ListCursor): boolean {
return compareRows(row, cursor) > 0;
}
@@ -380,7 +425,15 @@ function scopeOf(
};
}
export type SessionIndexOptions = {
/** Whether a live process holds the session. Only for such a session the
* whole transcript is searched for its background tasks. */
isLive?: (sessionId: string) => Promise<boolean>;
};
export class SessionIndex {
constructor(private readonly options: SessionIndexOptions = {}) {}
private readonly listings = new DirListings();
private readonly metadata = new Lru<string, CacheEntry>(METADATA_CACHE_SIZE);
/** The metadata reads in flight, by path: a list and a subscription that
@@ -497,25 +550,16 @@ export class SessionIndex {
const listed = await this.listings.namesOfAll(dirPaths);
const files = dirs.flatMap(({ dirName, projectPath }, i) =>
listed[i]!.filter((name) => name.endsWith(".jsonl") && isSessionId(name.slice(0, -6))).map(
(name) => ({ name, filePath: path.join(root, dirName, name), dirName, projectPath }),
(name) => ({
sessionId: name.slice(0, -6),
filePath: path.join(root, dirName, name),
dirName,
projectPath,
}),
),
);
const stats = await statFiles(files.map(({ filePath }) => filePath));
const all: TranscriptCandidate[] = [];
files.forEach(({ name, filePath, dirName, projectPath }, i) => {
const fileStats = stats[i];
if (!fileStats?.isFile() || fileStats.size === 0) return;
all.push({
sessionId: name.slice(0, -6),
filePath,
dirName,
projectPath,
mtimeMs: fileStats.mtimeMs,
size: fileStats.size,
ino: fileStats.ino,
});
});
return { dirs: [root, ...dirPaths], names: [rootNames, ...listed], candidates: all };
const candidates = await transcriptCandidates(files);
return { dirs: [root, ...dirPaths], names: [rootNames, ...listed], candidates };
}
/**
@@ -551,6 +595,90 @@ export class SessionIndex {
};
}
/** Known ids in request order, using the same rows as the list.
* Addresses exact filenames; only rows without a cwd need sibling discovery. */
async getSessions(sessionIds: readonly string[]): Promise<IndexRow[]> {
if (sessionIds.length === 0) return [];
const root = projectsRoot();
let projectEntries: string[];
try {
projectEntries = await fs.readdir(root);
} catch (cause) {
if (errorCode(cause) === "ENOENT" && !(await exists(root))) {
return [];
}
throw new SessionStoreUnavailableError("Session store could not be read", { cause });
}
const ids = sessionIds.filter(isSessionId);
const byId = new Map<string, IndexRow>();
// Paths are project directories × ids: bound them per chunk, not per request.
for (let start = 0; start < ids.length; start += LOOKUP_CHUNK_SIZE) {
const chunk = ids.slice(start, start + LOOKUP_CHUNK_SIZE);
for (const row of await this.lookupRows(root, projectEntries, chunk)) {
byId.set(row.sessionId, row);
}
}
return sessionIds.flatMap((id) => {
const row = byId.get(id);
return row ? [row] : [];
});
}
/** The rows of `ids` found under `projectEntries`. All copies of an id are
* in the same call, so copy selection is unchanged by chunking. */
private async lookupRows(
root: string,
projectEntries: readonly string[],
ids: readonly string[],
): Promise<IndexRow[]> {
let candidates = await transcriptCandidates(
projectEntries.flatMap((dirName) =>
ids.map((sessionId) => ({
sessionId,
filePath: path.join(root, dirName, `${sessionId}.jsonl`),
dirName,
})),
),
);
if (CASE_INSENSITIVE_PATHS) {
// On a case-insensitive file system, stat of `ABC.jsonl` also finds
// `abc.jsonl`. Keep a file only when its real name is the requested one,
// or differs by more than case (a symlink, as the list would read it).
// A failed realpath means the file is unreadable, so the id is omitted.
const exact = await Promise.all(
candidates.map(async ({ filePath }) => {
try {
const actual = path.basename(await fs.realpath(filePath));
const expected = path.basename(filePath);
return actual === expected || actual.toLowerCase() !== expected.toLowerCase();
} catch {
return false;
}
}),
);
candidates = candidates.filter((_, i) => exact[i]);
}
return this.collectRows(candidates.sort(byMtimeDescending), {
inScope: scopeOf(undefined),
archived: "all",
wanted: Infinity,
unread: async (read, dirNames) => {
const dirs = [...dirNames];
const listed = await this.listings.namesOfAll(dirs.map((dir) => path.join(root, dir)));
const siblings = dirs.flatMap((dirName, i) =>
listed[i]!.filter((name) => name.endsWith(".jsonl") && isSessionId(name.slice(0, -6)))
.map((name) => ({
sessionId: name.slice(0, -6),
filePath: path.join(root, dirName, name),
dirName,
}))
.filter(({ filePath }) => !read.has(filePath)),
);
return (await transcriptCandidates(siblings)).sort(byMtimeDescending);
},
});
}
/** Every transcript file of `sessionId` (see {@link scanSession}). Empty
* files only with `includeEmpty`. */
async findTranscripts(
@@ -686,11 +814,14 @@ export class SessionIndex {
* alone gives, read from it at once, and the ids of the sessions it gives
* no answer for: a transcript of it has nothing cached at its stat, no cwd
* known, or is continued in another session (whether the successor hides
* it depends on the successor now). Reads no transcript.
* it depends on the successor now). Reads no transcript. The rows carry
* `agentWorktree` ({@link markAgentWorktrees}, which reads git files)
* unless `mark` is false, for a caller that uses only their ids.
*/
async cachedRowsOf(
paths: readonly string[],
candidates: readonly TranscriptCandidate[],
{ mark = true }: { mark?: boolean } = {},
): Promise<{ rows: IndexRow[]; unknown: Set<string> }> {
const unknown = new Set<string>();
const resolved: { item: Resolved; cwd: string }[] = [];
@@ -721,7 +852,8 @@ export class SessionIndex {
if (previous && previous.mtimeMs >= item.candidate.mtimeMs) continue;
newest.set(id, { row: toRow(item, cwd), mtimeMs: item.candidate.mtimeMs });
}
return { rows: [...newest.values()].map(({ row }) => row), unknown };
const rows = [...newest.values()].map(({ row }) => row);
return { rows: mark ? await markAgentWorktrees(rows) : rows, unknown };
}
/**
@@ -745,9 +877,12 @@ export class SessionIndex {
archived: ArchivedFilter;
after?: ListCursor;
wanted: number;
/** The transcripts not read (by path), which may supply a sibling's
* cwd. */
unread: (read: ReadonlySet<string>) => readonly TranscriptCandidate[];
/** The unread transcripts of the directories still eligible for cwd
* recovery. Called only after checking the negative cache. */
unread?: (
read: ReadonlySet<string>,
dirNames: ReadonlySet<string>,
) => readonly TranscriptCandidate[] | Promise<readonly TranscriptCandidate[]>;
restat?: boolean;
},
): Promise<IndexRow[]> {
@@ -819,19 +954,20 @@ export class SessionIndex {
}
await settlePending();
}
if (pending.length > 0) {
const unread = options.unread;
if (pending.length > 0 && unread) {
// A row recovered after the scan stopped still sorts into the page: it
// was read before the stop, and more rows only raise the bound. A
// sibling that the archive filter or the cursor leaves out may still
// supply the cwd.
await this.learnDirCwds(
new Set(pending.map(({ candidate }) => candidate.dirName)),
options.unread(read),
(dirNames) => unread(read, dirNames),
options.restat ?? false,
);
await settlePending();
}
return rows();
return markAgentWorktrees(rows());
}
/** Whether the cached metadata of `candidate`, still current, places it
@@ -860,7 +996,9 @@ export class SessionIndex {
* changes. `restat` as in {@link collectRows}. */
private async learnDirCwds(
dirNames: ReadonlySet<string>,
unread: readonly TranscriptCandidate[],
unread: (
dirNames: ReadonlySet<string>,
) => readonly TranscriptCandidate[] | Promise<readonly TranscriptCandidate[]>,
restatFirst: boolean,
): Promise<void> {
const root = projectsRoot();
@@ -876,8 +1014,9 @@ export class SessionIndex {
if (known && known.mtimeMs === mtimeMs && now - known.at < NO_CWD_RETRY_MS) continue;
mtimes.set(dirName, mtimeMs);
}
if (mtimes.size === 0) return;
const probes = new Map<string, number>();
let remaining = unread.filter((candidate) => {
let remaining = (await unread(new Set(mtimes.keys()))).filter((candidate) => {
if (!mtimes.has(candidate.dirName)) return false;
const count = probes.get(candidate.dirName) ?? 0;
if (count >= MAX_CWD_PROBES_PER_DIR) return false;
@@ -968,14 +1107,15 @@ export class SessionIndex {
this.reads++;
const headTail = await readHeadTail(candidate.filePath, candidate.size);
if (isSidechainTranscript(headTail.head)) return null;
const facts = await scanTranscriptFile(
const scanned = await scanTranscriptFile(
candidate.filePath,
candidate.size,
headTail,
previous,
candidate.ino,
);
if (!facts.hasMessages) return null;
if (!scanned.hasMessages) return null;
const facts = await this.withAllBackgroundTasks(candidate, scanned);
// The last relocation names the session's cwd, as the SDK reads it; the
// messages before it keep the old one.
const fileCwd = await this.recoverCwd(candidate.dirName, [
@@ -1008,6 +1148,24 @@ export class SessionIndex {
};
}
/**
* `facts` with the background tasks of the whole transcript, when the
* tasks of its tail are not complete: a task can start before the tail.
* Only a finished turn of a live session waits for the tasks, so only
* then the whole transcript is read.
*/
private async withAllBackgroundTasks(
candidate: TranscriptCandidate,
facts: TranscriptFacts,
): Promise<TranscriptFacts> {
if (facts.turnState !== "finished") return facts;
if (facts.backgroundTasks?.complete) return facts;
const isLive = await this.options.isLive?.(candidate.sessionId);
if (!isLive) return facts;
const backgroundTasks = await searchBackgroundTasks(candidate.filePath, candidate.size);
return { ...facts, backgroundTasks };
}
/**
* The titles the SDK gives the listed transcript (`customTitle` and
* `summary` of its `getSessionInfo`), by the SDK's rule from the head and

View File

@@ -4,7 +4,7 @@
* the RFDs in `_meta.jetbrains.air`.
*/
import type { SessionInfo } from "@agentclientprotocol/sdk";
import type { SessionInfo } from "@agentclientprotocol/sdk/unstable";
import { withAirMeta } from "../air-extension.js";
import { deriveActivity, selectCost, type OwnSessionState } from "./activity.js";
import type { LiveRecord } from "./live-registry.js";
@@ -38,6 +38,7 @@ export function sessionInfoOf(
lastPromptAt: iso(facts.lastPromptAt),
model: facts.model,
forkedFrom: facts.forkedFrom,
agentWorktree: row.agentWorktree,
state: activity?.state,
lastTurnEndedAt: activity?.lastTurnEndedAt,
cost: cost === undefined ? undefined : { amount: cost, currency: "USD" },

View File

@@ -7,9 +7,13 @@
*
* A last message longer than the tail window leaves no message in it: then
* the tail window grows, up to {@link MAX_TAIL_SIZE}, until it holds one.
*
* The open background tasks come from the tail, on top of the tasks of an
* earlier scan (see `background-tasks.ts`).
*/
import * as fs from "node:fs/promises";
import { backgroundTasksOf, type BackgroundTasks } from "./background-tasks.js";
import { firstPrompt, isUserPrompt, mediaPrompt, promptOf } from "./first-prompt.js";
const CHUNK_SIZE = 64 * 1024;
@@ -51,6 +55,14 @@ export type TranscriptFacts = {
/** The session this one was forked from: the `forkedFrom.sessionId` that
* the SDK and CLI fork write on every copied record. */
forkedFrom?: string;
/** What the last `worktree-state` record says: true when its
* `worktreeSession` is set and not `enteredExisting` (Claude Code made the
* worktree and the session is in it), false when it says otherwise.
* Undefined when no such record was read. See {@link agentWorktreeOf}. */
agentWorktree?: boolean;
/** The background tasks that did not end. From the tail, on top of what
* an earlier scan found when the file only grew within the tail. */
backgroundTasks?: BackgroundTasks;
/** Whether the head or the tail holds a user or an assistant message. A
* transcript without one is a metadata-only stub. */
hasMessages: boolean;
@@ -67,12 +79,12 @@ export type HeadTail = {
};
/** Facts that an earlier scan may have found before the tail window. */
const INHERITED_FACTS = ["model", "tailCwd", "lastTurnEndedAt"] as const;
const INHERITED_FACTS = ["model", "tailCwd", "lastTurnEndedAt", "agentWorktree"] as const;
/** What an earlier scan of the same file found, at `size`. */
export type PreviousScan = { size: number } & Pick<
TranscriptFacts,
"lastPromptAt" | "promptSearched" | (typeof INHERITED_FACTS)[number]
"lastPromptAt" | "promptSearched" | "backgroundTasks" | (typeof INHERITED_FACTS)[number]
>;
/** The last `window` bytes of a file of `size` bytes, without the first,
@@ -152,6 +164,14 @@ export async function scanTranscriptFile(
}
}
}
const previousTasks = grownWithinTail ? previous.backgroundTasks : undefined;
facts.backgroundTasks = tailBackgroundTasks(read.tail, size, previousTasks);
// No worktree record in the tail (or before it, by an earlier scan): the
// one `claude --worktree` writes at the start may still be in the head.
if (facts.agentWorktree === undefined) {
const agentWorktree = agentWorktreeOf(read.head);
if (agentWorktree !== undefined) facts.agentWorktree = agentWorktree;
}
if (facts.lastPromptAt !== undefined || size <= CHUNK_SIZE) {
facts.promptSearched = true;
} else if (grownWithinTail && (previous.lastPromptAt !== undefined || previous.promptSearched)) {
@@ -163,7 +183,8 @@ export async function scanTranscriptFile(
if (!facts.hasMessages || complete(facts) || size <= CHUNK_SIZE) return facts;
const key = `${filePath}\0${size}\0${identity ?? ""}`;
const searched = fullySearched.get(key);
if (searched) return searched;
// The tasks can differ by the earlier scan.
if (searched) return { ...searched, backgroundTasks: facts.backgroundTasks };
let result = facts;
const handle = await fs.open(filePath, "r");
try {
@@ -199,9 +220,15 @@ export async function scanTranscriptFile(
...wider,
...(facts.model !== undefined && { model: facts.model }),
...(facts.tailCwd !== undefined && { tailCwd: facts.tailCwd }),
// The wider tail holds the narrow one, so a record it has is the
// latest; else the narrow scan's (inherited or from the head) stands.
...((wider.agentWorktree ?? facts.agentWorktree) !== undefined && {
agentWorktree: wider.agentWorktree ?? facts.agentWorktree,
}),
// A turn end the wider tail lacks may lie before it: the
// narrow scan inherited it from an earlier scan of this file.
lastTurnEndedAt: wider.lastTurnEndedAt ?? facts.lastTurnEndedAt,
backgroundTasks: facts.backgroundTasks,
hasMessages: facts.hasMessages,
lastPromptAt,
// Searched to the start of the file, or found.
@@ -221,6 +248,22 @@ export async function scanTranscriptFile(
return result;
}
/**
* The background tasks of the tail of a file of `size` bytes. `previous`:
* the tasks of an earlier scan, when the file only grew within the tail
* since. The set is complete when the tail is the whole file, or when it
* holds all records after a complete earlier set.
*/
function tailBackgroundTasks(
tail: string,
size: number,
previous: BackgroundTasks | undefined,
): BackgroundTasks {
if (size <= CHUNK_SIZE) return backgroundTasksOf(tail, { complete: true });
if (previous?.complete) return backgroundTasksOf(tail, { complete: true, after: previous });
return backgroundTasksOf(tail, { complete: false });
}
const RELOCATED_MARKER = '"relocated"';
/** The cwd of the last `relocated` record of the tail: where the session
@@ -238,6 +281,43 @@ export function relocatedCwd(tail: string): string | undefined {
return undefined;
}
const WORKTREE_STATE_MARKER = '"worktree-state"';
/**
* Whether the last `worktree-state` record of `text` says the session is in
* a worktree that Claude Code made for it, as AIR's native Claude provider
* reads it: a `worktreeSession` that is an object without
* `enteredExisting: true`. The last line that parses as a `worktree-state`
* record decides; any other `worktreeSession` (`null`, which the CLI writes
* when the session leaves the worktree, missing, or no object) means false.
* Only lines that are not JSON (such as the cut first line of a window) are
* skipped. Undefined when `text` holds no such record.
*
* The CLI writes the record when it enters or leaves a worktree (for
* `claude --worktree`, at the start of the session, so in the head), and
* writes it again with the other session metadata at the end of the
* transcript (on exit and as the transcript grows), so the tail usually
* holds the latest one. Only the head and the tail are read: a session that
* entered a worktree midway through a long transcript, with no metadata
* written after it yet, is not seen until the CLI writes the metadata again.
*/
export function agentWorktreeOf(text: string): boolean | undefined {
if (!text.includes(WORKTREE_STATE_MARKER)) return undefined;
const lines = text.split("\n");
for (let i = lines.length - 1; i >= 0; i--) {
const line = lines[i]!;
if (!line.includes(WORKTREE_STATE_MARKER)) continue;
const entry = parseLine(line);
// Only a line that is no JSON record is skipped: the latest record that
// parses decides, even when its `worktreeSession` is missing or no object.
if (entry?.type !== "worktree-state") continue;
const session = entry.worktreeSession;
if (!session || typeof session !== "object" || Array.isArray(session)) return false;
return (session as { enteredExisting?: unknown }).enteredExisting !== true;
}
return undefined;
}
/** The cwd a transcript belongs to, as the SDK reads it: the last
* `relocated` record of the tail, else the first cwd of the head. */
export function transcriptProjectCwd({ head, tail }: HeadTail): string | undefined {
@@ -344,6 +424,10 @@ export function scanTranscript(
if (headCwd !== undefined) facts.headCwd = decodeJsonString(headCwd);
const forkedFrom = FORKED_FROM_PATTERN.exec(head)?.[1];
if (forkedFrom !== undefined) facts.forkedFrom = forkedFrom;
// The tail only: scanTranscriptFile falls back to the head after it
// inherits what an earlier scan found before the tail.
const agentWorktree = agentWorktreeOf(tail);
if (agentWorktree !== undefined) facts.agentWorktree = agentWorktree;
const lines = tail.split("\n");
let turnEndFound = false;

View File

@@ -115,6 +115,18 @@ export async function worktreeCounterparts(cwd: string): Promise<string[]> {
return [...new Set(existing.filter((value): value is string => value !== undefined))];
}
/**
* Whether `cwd` lies in a linked worktree: its git dir is
* `<common-dir>/worktrees/<name>`. False in the main worktree, in a
* submodule and outside a repository.
*/
export async function isInLinkedWorktree(cwd: string): Promise<boolean> {
const gitDir = await findGitDir(cwd);
if (!gitDir) return false;
const commonDir = await commonDirOf(gitDir);
return path.resolve(path.dirname(gitDir)) === path.resolve(commonDir, "worktrees");
}
/**
* Every existing worktree of the repository that contains `cwd`: the main
* worktree and the linked ones. Empty outside a repository.

View File

@@ -5,7 +5,7 @@ import {
type SessionNotification,
type SetSessionModeRequest,
type SetSessionModeResponse,
} from "@agentclientprotocol/sdk";
} from "@agentclientprotocol/sdk/unstable";
import type {
ModelInfo,
PermissionMode,
@@ -66,29 +66,50 @@ type ModeConfigSession = SessionMode & {
configOptions: SessionConfigOption[];
};
type InitializeSessionModeParams = {
query: Pick<Query, "setPermissionMode">;
requestedMode: PermissionMode;
currentModelInfo?: ModelInfo;
currentModelId: string;
/** Which optional modes the session's mode catalog offers. */
export type ModeAvailability = {
/** When false, bypassPermissions is omitted from the mode catalog. */
allowBypass: boolean;
/** When false (`disableAutoMode: "disable"` in settings), auto is omitted. */
allowAuto: boolean;
};
type InitializeSessionModeParams = ModeAvailability & {
query: Pick<Query, "setPermissionMode">;
requestedMode: PermissionMode;
/** The mode is the built-in default: no request, transcript or setting chose it. */
builtInDefault?: boolean;
currentModelInfo?: ModelInfo;
currentModelId: string;
};
/**
* The permission mode a new Claude query starts in.
*
* An explicit mode wins. A resume or a load continues in the mode of the last
* user record of the transcript. If that mode is unknown or not available now,
* for example bypass, the session starts in `defaultMode` of the settings.
* user record of the transcript. If that mode is unknown or absent from the
* session's mode catalog, use an available `defaultMode` from settings, or
* `default` when that setting is unavailable too.
*
* With no `defaultMode` set, the session starts in `auto` when the catalog
* offers it and in `default` otherwise (`builtInDefault: true`), like an
* interactive Claude Code session. Claude Code also starts organizations with
* the HIPAA configuration in `default`; the SDK does not report that
* configuration, so those sessions start in `auto` here.
*/
export function resolveInitialPermissionMode(
sources: { explicit?: PermissionMode; resumed?: string; defaultMode?: unknown },
logger: { error: (...args: unknown[]) => void },
allowBypass: boolean,
): PermissionMode {
availability: ModeAvailability,
): { mode: PermissionMode; builtInDefault: boolean } {
const { allowBypass } = availability;
const available = (mode: PermissionMode) =>
buildAvailableModes(availability).some((entry) => entry.id === mode);
if (sources.explicit !== undefined) {
return resolvePermissionMode(sources.explicit, logger, allowBypass);
return {
mode: resolvePermissionMode(sources.explicit, logger, allowBypass),
builtInDefault: false,
};
}
if (sources.resumed !== undefined) {
let rejected = false;
@@ -97,13 +118,23 @@ export function resolveInitialPermissionMode(
{ error: () => (rejected = true) },
allowBypass,
);
if (!rejected) return mode;
if (!rejected && available(mode)) {
return { mode, builtInDefault: false };
}
logger.error(
`Ignoring permission mode "${sources.resumed}" of the resumed transcript: ` +
`not available in this session.`,
);
}
return resolvePermissionMode(sources.defaultMode, logger, allowBypass);
if (sources.defaultMode === undefined) {
return { mode: available("auto") ? "auto" : "default", builtInDefault: true };
}
const configuredMode = resolvePermissionMode(sources.defaultMode, logger, allowBypass);
// A rejected transcript mode falls back only to a mode this session can offer.
if (sources.resumed !== undefined && !available(configuredMode)) {
return { mode: "default", builtInDefault: false };
}
return { mode: configuredMode, builtInDefault: false };
}
/** Owns session-mode policy and the ACP/SDK synchronization it requires. */
@@ -113,22 +144,40 @@ export class SessionModeManager<S extends SessionMode> {
async initialize({
query,
requestedMode,
builtInDefault = false,
currentModelInfo,
currentModelId,
allowBypass,
allowAuto,
}: InitializeSessionModeParams): Promise<{
modes: SessionModeState;
autoModeFallbackWarningPending: boolean;
}> {
const availableModes = this.buildAvailableModes(allowBypass);
const availableModes = buildAvailableModes(
{ allowBypass, allowAuto },
this.options.airClient?.() ?? false,
);
let effectiveMode = requestedMode;
let autoModeFallbackWarningPending = false;
if (
const autoUnsupported =
effectiveMode === "auto" &&
currentModelInfo !== undefined &&
currentModelInfo.supportsAutoMode !== true
) {
currentModelInfo.supportsAutoMode !== true;
if (!this.isAvailable(availableModes, effectiveMode)) {
this.options.logError(
`permissions.defaultMode "${effectiveMode}" is not available in ` +
`this session; falling back to "default".`,
);
effectiveMode = "default";
await this.trySyncMode(query, effectiveMode, "Failed to sync clamped permissionMode to SDK:");
} else if (autoUnsupported && builtInDefault) {
// Like Claude Code: a built-in Auto default the model can't serve starts
// in Manual, without a warning. Nobody chose Auto, so don't escalate to
// acceptEdits.
effectiveMode = "default";
await this.trySyncMode(query, effectiveMode, "Failed to sync clamped permissionMode to SDK:");
} else if (autoUnsupported) {
this.options.logError(
`permissions.defaultMode "auto" is not available for model ` +
`"${currentModelId}"; falling back to "${AUTO_MODE_FALLBACK}".`,
@@ -136,13 +185,6 @@ export class SessionModeManager<S extends SessionMode> {
effectiveMode = AUTO_MODE_FALLBACK;
autoModeFallbackWarningPending = true;
await this.trySyncMode(query, effectiveMode, "Failed to sync clamped permissionMode to SDK:");
} else if (!this.isAvailable(availableModes, effectiveMode)) {
this.options.logError(
`permissions.defaultMode "${effectiveMode}" is not available in ` +
`this session; falling back to "default".`,
);
effectiveMode = "default";
await this.trySyncMode(query, effectiveMode, "Failed to sync clamped permissionMode to SDK:");
}
return {
@@ -247,7 +289,10 @@ export class SessionModeManager<S extends SessionMode> {
const session = this.requireOpenSession(sessionId);
const requestedMode = this.parseMode(modeId);
if (!this.isAvailable(session.modes.availableModes, requestedMode)) {
throw new Error(`Mode ${modeId} is not available in this session`);
throw RequestError.invalidParams(
undefined,
`Mode ${modeId} is not available in this session`,
);
}
const effectiveMode = this.effectiveMode(session, requestedMode);
@@ -314,7 +359,7 @@ export class SessionModeManager<S extends SessionMode> {
private requireOpenSession(sessionId: string): S {
const session = this.options.getSession(sessionId);
if (!session) throw new Error("Session not found");
if (!session) throw RequestError.sessionNotFound(sessionId);
if (session.queryClosed) {
throw RequestError.internalError(undefined, this.options.sessionEndedMessage);
}
@@ -343,54 +388,10 @@ export class SessionModeManager<S extends SessionMode> {
case "plan":
return modeId;
default:
throw new Error("Invalid Mode");
throw RequestError.invalidParams(undefined, `Invalid Mode: ${modeId}`);
}
}
private buildAvailableModes(allowBypass: boolean): SessionModeState["availableModes"] {
// Only AIR gets the mode kind.
const air = this.options.airClient?.() ?? false;
const kind = (value: string) => {
const meta = airOnlyMeta(air, AIR_KIND_KEY, value);
return meta ? { _meta: meta } : {};
};
const modes: SessionModeState["availableModes"] = [
{
id: "default",
name: "Manual",
description: "Always ask before making changes",
...kind("standard"),
},
{
id: "acceptEdits",
name: "Accept edits",
description: "Automatically accept all file edits",
...kind("standard"),
},
{
id: "plan",
name: "Plan",
description: "Create a plan before making changes",
...kind("plan"),
},
{
id: "auto",
name: "Auto",
description: "Claude handles permission decisions",
...kind("auto_review"),
},
];
if (allowBypass) {
modes.push({
id: "bypassPermissions",
name: "Bypass permissions",
description: "Accepts all permissions",
...kind("full_access"),
});
}
return modes;
}
private async trySyncMode(
query: Pick<Query, "setPermissionMode">,
mode: PermissionMode,
@@ -403,3 +404,51 @@ export class SessionModeManager<S extends SessionMode> {
}
}
}
export function buildAvailableModes(
{ allowBypass, allowAuto }: ModeAvailability,
air = false,
): SessionModeState["availableModes"] {
// Only AIR gets the mode kind.
const kind = (value: string) => {
const meta = airOnlyMeta(air, AIR_KIND_KEY, value);
return meta ? { _meta: meta } : {};
};
const modes: SessionModeState["availableModes"] = [
{
id: "default",
name: "Manual",
description: "Always ask before making changes",
...kind("standard"),
},
{
id: "acceptEdits",
name: "Accept edits",
description: "Automatically accept all file edits",
...kind("standard"),
},
{
id: "plan",
name: "Plan",
description: "Create a plan before making changes",
...kind("plan"),
},
];
if (allowAuto) {
modes.push({
id: "auto",
name: "Auto",
description: "Claude handles permission decisions",
...kind("auto_review"),
});
}
if (allowBypass) {
modes.push({
id: "bypassPermissions",
name: "Bypass permissions",
description: "Accepts all permissions",
...kind("full_access"),
});
}
return modes;
}

View File

@@ -1,6 +1,7 @@
import type { SessionConfigOption } from "@agentclientprotocol/sdk";
import type { SessionConfigOption } from "@agentclientprotocol/sdk/unstable";
import type { ModelInfo, Query, Settings } from "@anthropic-ai/claude-agent-sdk";
import { AIR_RECOMMENDED_CONFIG_VALUE_CAPABILITY, withAirMeta } from "./air-extension.js";
import { bedrockRegionPrefix } from "./gateway-models.js";
export const MODEL_CONFIG_ID = "model";
@@ -83,39 +84,73 @@ function versionedModelDisplayName(model: ModelInfo): string {
return `${withoutContext} ${version}${contextSuffix[0]}`;
}
function tokenizeModelPreference(model: string): { tokens: string[]; contextHint?: string } {
const lower = model.trim().toLowerCase();
const contextHint = lower.match(MODEL_CONTEXT_HINT_PATTERN)?.[1]?.toLowerCase();
const tokens = lower
.replace(MODEL_CONTEXT_HINT_PATTERN, " $1 ")
function modelWords(value: string): string[] {
return value
.toLowerCase()
.split(/[^a-z0-9]+/)
.filter(Boolean)
.map((token) => {
if (token === "opusplan") return "opus";
if (token === "best" || token === "default") return "";
return token;
})
.filter((token) => token && token !== "claude")
.filter((token) => /[a-z]/.test(token) || token.endsWith("m"));
return { tokens, contextHint };
.filter(Boolean);
}
function scoreModelMatch(model: ModelInfo, tokens: string[], contextHint?: string): number {
const haystack = `${model.value} ${model.displayName}`.toLowerCase();
let score = 0;
let nonHintMatched = false;
for (const token of tokens) {
if (!haystack.includes(token)) continue;
if (token !== contextHint) nonHintMatched = true;
score += token === contextHint ? 3 : 1;
/** Whether `needle`'s words appear in `haystack`'s words, adjacent and in
* order: "opus" in "claude-opus-4-5", but not "son" in "sonnet" or "a" in
* "Default (recommended)". */
function containsWords(haystack: string, needle: string): boolean {
const words = modelWords(haystack);
const wanted = modelWords(needle);
if (wanted.length === 0) return false;
for (let start = 0; start + wanted.length <= words.length; start++) {
if (wanted.every((word, offset) => words[start + offset] === word)) return true;
}
return contextHint && !nonHintMatched ? 0 : score;
return false;
}
/** `id` without the gateway spellings that `normalizeModelId` keeps for
* `deniedModels` and gateway listings: path segments before `claude-`
* (`vertex_ai/`, `openrouter/anthropic/`, `publishers/anthropic/models/`)
* and the Anthropic API's `-latest` suffix (`claude-3-5-haiku-latest`). */
function withoutGatewaySpelling(id: string): string {
return id.replace(/^(?:[^/]+\/)+(?=claude-)/, "").replace(/-latest$/, "");
}
/** The words of `preference` that name a model: without what
* `normalizeModelId` drops (provider prefixes, dates, `-v1:0`, `-fast`,
* context hints), version numbers, "claude", and "best"/"default".
* "us.anthropic.claude-sonnet-4-5-v1:0" gives ["sonnet"], "opusplan" gives
* ["opus"]. */
function modelPreferenceTokens(preference: string): string[] {
return modelWords(withoutGatewaySpelling(normalizeModelId(preference)))
.map((token) => (token === "opusplan" ? "opus" : token))
.filter((token) => /[a-z]/.test(token) && !["claude", "best", "default"].includes(token));
}
/** Whether every one of `tokens` is a word of `model`'s value or display name. */
function modelCoversTokens(model: ModelInfo, tokens: string[]): boolean {
const words = new Set(modelWords(`${model.value} ${model.displayName}`));
return tokens.length > 0 && tokens.every((token) => words.has(token));
}
/**
* The entry of `models` that `preference` names, or null when it names none.
* `best` names the entry `opus` resolves to, unless an entry's value is `best`.
* In order: an entry whose value, display name or `resolvedModel` equals it
* (in either context-hint spelling); an entry whose value or display name
* contains its words ("opus" for "claude-opus-4-5"), or whose value it
* contains while every model word of the preference is the entry's
* ("sonnet" for "us.anthropic.claude-sonnet-5-v1:0"); then any entry with all
* of its model words, preferring one with the same context hint ("sonnet[1m]"
* for "claude-sonnet-5-1m"). The fuzzy tiers need a compatible version, and
* words match whole: "not-a-model", "son" and "foo-sonnet" name no entry.
*/
export function resolveModelPreference(models: ModelInfo[], preference: string): ModelInfo | null {
const trimmed = preference.trim();
if (!trimmed) return null;
const lower = trimmed.toLowerCase();
// The CLI resolves `best` to its flagship: Opus, unless a server-side flag
// names another family that this adapter cannot read. The CLI ignores a
// `[1m]` hint on `best`. A listed entry named `best` still matches directly.
if (stripContextHints(lower) === "best" && !models.some((model) => model.value === trimmed)) {
return resolveModelPreference(models, "opus");
}
const canonicalPreference = canonicalizeModelId(trimmed);
const directMatch = models.find(
(model) =>
@@ -133,28 +168,26 @@ export function resolveModelPreference(models: ModelInfo[], preference: string):
if (resolvedMatch) return resolvedMatch;
const preferenceHint = contextHintOf(trimmed);
const tokens = modelPreferenceTokens(trimmed);
const includesMatch = models.find((model) => {
if (!modelVersionsCompatible(trimmed, model)) return false;
if (contextHintOf(model.value) !== preferenceHint) return false;
const value = model.value.toLowerCase();
const display = model.displayName.toLowerCase();
return value.includes(lower) || display.includes(lower) || lower.includes(value);
return (
containsWords(model.value, trimmed) ||
containsWords(model.displayName, trimmed) ||
(containsWords(trimmed, model.value) && modelCoversTokens(model, tokens))
);
});
if (includesMatch) return includesMatch;
const { tokens, contextHint } = tokenizeModelPreference(trimmed);
if (tokens.length === 0) return null;
let bestMatch: ModelInfo | null = null;
let bestScore = 0;
for (const model of models) {
if (!modelVersionsCompatible(trimmed, model)) continue;
const score = scoreModelMatch(model, tokens, contextHint);
if (score > 0 && (!bestMatch || score > bestScore)) {
bestMatch = model;
bestScore = score;
}
}
return bestMatch;
const candidates = models.filter(
(model) => modelVersionsCompatible(trimmed, model) && modelCoversTokens(model, tokens),
);
return (
candidates.find((model) => contextHintOf(model.value) === preferenceHint) ??
candidates[0] ??
null
);
}
export function matchResumedModel(models: ModelInfo[], liveModel: string): ModelInfo {
@@ -209,22 +242,12 @@ export function buildModelConfigOption(
? models.availableModels.filter((model) => model.modelId !== "default")
: models.availableModels;
return {
id: MODEL_CONFIG_ID,
name: "Model",
description: "AI model to use",
category: "model",
type: "select",
currentValue:
concreteRecommendation && models.currentModelId === "default"
? recommended.value
: models.currentModelId,
...(concreteRecommendation
? {
_meta: withAirMeta(undefined, AIR_RECOMMENDED_CONFIG_VALUE_CAPABILITY, recommended.value),
}
: {}),
options: available.map((model) => {
const currentValue =
concreteRecommendation && models.currentModelId === "default"
? recommended.value
: models.currentModelId;
const options: Array<{ value: string; name: string; description?: string }> = available.map(
(model) => {
if (model.modelId === "default" && defaultInfo?.resolvedModel) {
const named = modelInfos.find(
(info) => info.value !== "default" && info.resolvedModel === defaultInfo.resolvedModel,
@@ -240,7 +263,35 @@ export function buildModelConfigOption(
name: model.name,
description: model.description ?? undefined,
};
}),
},
);
// ACP requires `currentValue` to be one of `options`. A session can run a
// model the picker does not list: resumed onto a model excluded by the
// `availableModels` allowlist, or switched by a refusal fallback. List that
// model as its own entry. It is not added to `models.availableModels`, so the
// next rebuild after switching to a listed model drops it.
if (!options.some((option) => option.value === currentValue)) {
const info = modelInfos.find((model) => model.value === currentValue);
options.push({
value: currentValue,
name: info?.displayName || currentValue,
description: info?.description || undefined,
});
}
return {
id: MODEL_CONFIG_ID,
name: "Model",
description: "AI model to use",
category: "model",
type: "select",
currentValue,
...(concreteRecommendation
? {
_meta: withAirMeta(undefined, AIR_RECOMMENDED_CONFIG_VALUE_CAPABILITY, recommended.value),
}
: {}),
options,
};
}
@@ -262,31 +313,108 @@ function resolveSettingsModel(
const MODEL_FAMILY_ALIASES = new Set(["opus", "sonnet", "haiku", "fable"]);
/** A model id without the spellings `deniedModels` ignores: provider
* prefixes, dates, version suffixes, `-fast` and context hints. */
function normalizeDeniedModelId(value: string): string {
/** The family and version of a `claude-<family>-<major>[-<minor>]` id:
* `claude-fable-5-1` is fable 5.1, `claude-haiku-4-5-20251001` haiku 4.5. */
function familyVersionOf(id: string): { family: string; major: number; minor: number } | null {
const match = id.toLowerCase().match(/^claude-([a-z]+)-(\d+)(?:-(\d{1,2}))?(?![\d.])/);
if (!match) return null;
return { family: match[1], major: Number(match[2]), minor: Number(match[3] ?? 0) };
}
/**
* Give back the family alias value to the row the CLI lists under a resumed
* session's model id.
*
* The CLI lists Fable 5.2 as `fable`, but when it resumes a session that ran
* Fable 5.2 it lists the same row as `claude-fable-5-2` and has no `fable`
* row. Clients store the option value as the session's choice, so a value
* from a new session would not be offered by a resumed one, and the reverse.
*
* A row gets its family's alias as its value when its `value` is
* `resumedModel`, equals its own `resolvedModel` and has no context hint, no
* row has that alias, and no row of the family has a newer version (an alias
* runs its family's newest model). Every other row is returned as it is.
*
* The CLI lists a row the same way when `ANTHROPIC_MODEL` or `settings.model`
* pins `claude-fable-5-2`. Those rows keep the pinned id, so a client's choice
* stays on that version after a newer Fable becomes `fable`. A resumed Fable
* 5.1 session keeps `claude-fable-5-1` too, because Fable 5.2 is newer.
*/
export function aliasResumedModelRow(
models: ModelInfo[],
resumedModel: string | undefined,
): ModelInfo[] {
if (resumedModel === undefined) return models;
const resumed = canonicalizeModelId(resumedModel);
const values = new Set(models.map((model) => model.value));
const versions = models.flatMap((model) => {
const version = familyVersionOf(model.resolvedModel ?? model.value);
return version ? [version] : [];
});
return models.map((model) => {
const version = familyVersionOf(model.value);
if (
version === null ||
!MODEL_FAMILY_ALIASES.has(version.family) ||
values.has(version.family) ||
model.resolvedModel !== model.value ||
contextHintOf(model.value) !== null ||
canonicalizeModelId(model.value) !== resumed ||
versions.some(
(other) =>
other.family === version.family &&
(other.major > version.major ||
(other.major === version.major && other.minor > version.minor)),
)
) {
return model;
}
values.add(version.family);
return { ...model, value: version.family };
});
}
/** A model id without the spellings that name the same model: provider
* prefixes, dates, version suffixes, `:variant` suffixes, dotted versions,
* `-fast` and context hints. `deniedModels` and gateway listings both match on
* it. OpenRouter lists `anthropic/claude-opus-5.5:batch[1m]`, which normalizes
* to `claude-opus-5-5`; `anthropic/openai/gpt-5` keeps its `openai/` prefix and
* matches no Claude id. */
function normalizeModelId(value: string): string {
let id = stripContextHints(value.trim().toLowerCase());
const providerPrefix = id.lastIndexOf("anthropic.");
if (providerPrefix !== -1) id = id.slice(providerPrefix + "anthropic.".length);
return id
.replace(/@.*$/, "")
.replace(/-v\d+(?::\d+)?$/, "")
.replace(/-fast$/, "")
.replace(/-\d{8}$/, "");
return (
id
// Only a leading `anthropic/`: OpenRouter's `anthropic/openai/gpt-5` must
// keep `openai/`, which `lastIndexOf` would strip along with it.
.replace(/^anthropic\//, "")
// `:batch`, `:free`. A letter must follow the colon, so Bedrock's `-v1:0`
// is left for the `-vN:M` step below.
.replace(/:[a-z][a-z0-9-]*$/, "")
// `claude-opus-5.5` -> `claude-opus-5-5`; only dots between two digits.
.replace(/(?<=\d)\.(?=\d)/g, "-")
.replace(/@.*$/, "")
.replace(/-v\d+(?::\d+)?$/, "")
.replace(/-fast$/, "")
.replace(/-\d{8}$/, "")
);
}
/**
* Whether managed `deniedModels` blocks a model id, following the CLI's rules
* closely enough for the picker: a family alias blocks the whole family, and a
* model id blocks that version in every spelling plus the later minor versions
* it prefixes (`claude-opus-5` also blocks `claude-opus-5-5`). The CLI stays
* the enforcer; this only keeps the picker from offering a refused row.
* it prefixes (`claude-opus-5` also blocks `claude-opus-5-5`). Entries share
* `normalizeModelId`, so `claude-opus-4.5` or `anthropic/claude-opus-4.5` also
* blocks `claude-opus-4-5`, which the CLI may still allow. The CLI stays the
* enforcer; this only keeps the picker from offering a refused row.
*/
export function isDeniedModel(modelId: string, deniedModels: readonly string[]): boolean {
const id = normalizeDeniedModelId(modelId);
const id = normalizeModelId(modelId);
if (!id) return false;
return deniedModels.some((entry) => {
const denied = normalizeDeniedModelId(entry);
const denied = normalizeModelId(entry);
if (!denied) return false;
if (MODEL_FAMILY_ALIASES.has(denied)) return id === denied || id.includes(`-${denied}`);
return id === denied || id.startsWith(`${denied}-`);
@@ -379,6 +507,103 @@ export function applyAvailableModelsAllowlist(
return result;
}
/**
* Keep the SDK models the `providers/set` gateway listed (`listGatewayModels`,
* `listBedrockModels`). A row matches when its `resolvedModel` (or
* its `value` when the SDK gives none) names a listed model in any spelling:
* `claude-sonnet-5[1m]` matches a listed `claude-sonnet-5-20260101`, and
* `eu.anthropic.claude-sonnet-5` matches a listed `eu.anthropic.claude-sonnet-5-v1:0`.
*
* With a Bedrock `regionPrefix`, a row resolved to another cross-region prefix
* (`us.anthropic.claude-sonnet-5` under `eu`) is dropped even when the same
* model is listed under `eu`: the CLI would call it in the other region.
*
* A row the SDK gives no `resolvedModel` names its model only by an alias, so
* it is checked as far as the alias allows: `default` is kept, and a family
* alias such as `opus` is kept when the gateway lists a model of that family.
*
* When no row matches by its model (a `default` row kept only for lacking a
* `resolvedModel` does not count), the gateway serves none of the SDK's
* models, and nothing is left: the session offers no model.
*/
export function narrowToGatewayModels(
models: ModelInfo[],
listedModelIds: readonly string[],
regionPrefix?: string,
): ModelInfo[] {
const listed = new Set(listedModelIds.map(normalizeModelId).filter(Boolean));
const narrowed = models.filter((model) => {
const modelId = model.resolvedModel ?? model.value;
const prefix = bedrockRegionPrefix(modelId);
if (regionPrefix !== undefined && prefix !== undefined && prefix !== regionPrefix) {
return false;
}
const id = normalizeModelId(modelId);
if (listed.has(id)) return true;
if (model.resolvedModel !== undefined) return false;
if (model.value === "default") return true;
return MODEL_FAMILY_ALIASES.has(id) && [...listed].some((entry) => entry.includes(`-${id}`));
});
return narrowed.some((model) => listed.has(normalizeModelId(model.resolvedModel ?? model.value)))
? narrowed
: [];
}
/**
* The capabilities of the session's current model, and the model infos that
* every later lookup reads.
*
* A resumed session can be running a model outside the `availableModels`
* allowlist (currentModelId is then the verbatim live id, see
* `matchResumedModel`); its capabilities are still known to the SDK's
* unfiltered list, so they are resolved from that before the model is treated
* as unknown — otherwise auto mode would be spuriously clamped and the
* Fast-mode/Effort options hidden for a model that supports them.
*
* The fallback-resolved capabilities are registered under the verbatim live
* id, so every modelInfos consumer (the effort option, later rebuilds) agrees
* with that gating. `buildModelConfigOption` lists this model as its own
* entry while it is current and drops it once the user switches to a listed
* model; it is never added to `availableModels`. The spread keeps every
* capability flag (current and future); the identity fields are overridden
* because the fuzzy-matched sibling's resolvedModel/displayName/description
* can describe a different context lane and would poison later resolvedModel
* matching (a model switch that the adapter did not make) and context-window
* inference if they traveled under this id. `allowlistedModelInfo` is the
* allowlisted entry alone, the only one whose identity strings are a
* trustworthy window signal.
*/
export function withCurrentModelInfo(
allowedModels: ModelInfo[],
sdkModels: ModelInfo[],
currentModelId: string,
): {
allowlistedModelInfo: ModelInfo | undefined;
currentModelInfo: ModelInfo | undefined;
modelInfos: ModelInfo[];
} {
const allowlistedModelInfo = allowedModels.find((m) => m.value === currentModelId);
const fallbackModelInfo = allowlistedModelInfo
? undefined
: (resolveModelPreference(sdkModels, currentModelId) ?? undefined);
return {
allowlistedModelInfo,
currentModelInfo: allowlistedModelInfo ?? fallbackModelInfo,
modelInfos: fallbackModelInfo
? [
...allowedModels,
{
...fallbackModelInfo,
value: currentModelId,
displayName: currentModelId,
description: "",
resolvedModel: undefined,
},
]
: allowedModels,
};
}
/** Whether a rejected `setModel` was vetoed by a user-configured
* PreModelSwitch hook. The CLI's rejection message is the stable,
* distinguishable marker ("Model switch blocked by a PreModelSwitch hook:
@@ -397,7 +622,18 @@ export async function getAvailableModels(
isResumedSession: boolean,
sessionId: string,
resumedModelHint?: string,
narrowedByGateway = false,
): Promise<SessionModelState> {
// A gateway that lists none of the SDK's models leaves none to offer
// (`narrowToGatewayModels`). The session stays on the model the SDK runs,
// and the gateway refuses its requests.
if (models.length === 0) {
return {
availableModels: [],
currentModelId: (isResumedSession && resumedModelHint) || sdkModels[0]?.value || "default",
};
}
const settings = settingsManager.getSettings();
let currentModel = models[0];
@@ -444,9 +680,34 @@ export async function getAvailableModels(
// be re-asserted to keep the reported model truthful.
// Anything else (fuzzy match, allowlist-synthesized value, alias) gets a
// setModel call so we don't drift from the user's intended pin.
// Case (a) does not hold when `narrowToGatewayModels` left the SDK on a
// model the gateway does not list: the dropped `default` row of a fresh or
// transcript-less session, or a resumed transcript model outside the list.
// Such a session switches to models[0].
const sdkSawSameValue = sdkModels.some((m) => m.value === currentModel.value);
const sdkDefault = sdkModels.find((m) => m.value === "default");
const sdkDefaultDropped = sdkDefault !== undefined && !models.some((m) => m.value === "default");
let switchToListed = false;
if (resolvedFromInput === undefined) {
if (!isResumedSession || !resumedModelHint) {
switchToListed = sdkDefaultDropped;
} else if (
narrowedByGateway &&
(!models.some((m) => m.value === currentModel.value) ||
// A loose match can map the transcript's model onto a listed row of
// another version, which the CLI does not resume on.
(currentModel.resolvedModel !== undefined &&
normalizeModelId(currentModel.resolvedModel) !== normalizeModelId(resumedModelHint)))
) {
currentModel = models[0];
switchToListed = true;
}
}
// When the switch fails, the SDK stays on the model it ran before it.
const modelBeforeSwitch = (): ModelInfo =>
switchToListed && sdkDefault !== undefined ? sdkDefault : models[0];
const skipSetModel =
resolvedFromInput === undefined ||
(resolvedFromInput === undefined && !switchToListed) ||
(!isResumedSession && currentModel.value === resolvedFromInput && sdkSawSameValue);
if (!skipSetModel) {
const setModelStartedAt = performance.now();
@@ -478,15 +739,19 @@ export async function getAvailableModels(
// live model back on this path: getContextUsage isn't serviced on a
// fresh session until the first prompt turn (issues #886/#880).
if (!isResumedSession) {
if (!isPreModelSwitchHookBlock(error)) throw error;
// A switch onto a listed model is advisory too: the session stays on
// the model the SDK runs and reports it.
if (!switchToListed && !isPreModelSwitchHookBlock(error)) throw error;
logger.error(
`Model pin "${currentModel.value}" was vetoed by a PreModelSwitch hook; staying on the default model:`,
`Model switch to "${currentModel.value}" failed or was vetoed by a PreModelSwitch hook; staying on the default model:`,
error,
);
currentModel = models[0];
currentModel = modelBeforeSwitch();
} else {
logger.error(`Failed to re-assert model "${currentModel.value}" on resume:`, error);
currentModel = resumedModelHint ? matchResumedModel(models, resumedModelHint) : models[0];
currentModel = resumedModelHint
? matchResumedModel(models, resumedModelHint)
: modelBeforeSwitch();
}
}
}
@@ -496,14 +761,107 @@ export async function getAvailableModels(
for (const name of displayNames) {
displayNameCounts.set(name, (displayNameCounts.get(name) ?? 0) + 1);
}
const names = models.map((model, index) =>
displayNameCounts.get(displayNames[index]) === 1 ? displayNames[index] : model.displayName,
);
const nameCounts = new Map<string, number>();
for (const name of names) {
nameCounts.set(name, (nameCounts.get(name) ?? 0) + 1);
}
return {
availableModels: models.map((model, index) => ({
modelId: model.value,
name:
displayNameCounts.get(displayNames[index]) === 1 ? displayNames[index] : model.displayName,
// Allowlisted aliases can inherit the same SDK label. Keep every ID
// selectable, but distinguish the rows for clients that show only names.
name: nameCounts.get(names[index]) === 1 ? names[index] : `${names[index]} (${model.value})`,
description: model.description,
})),
currentModelId: currentModel.value,
};
}
/** The display name of the model id `model` in `modelInfos`, if it is there. */
export function modelDisplayName(modelInfos: ModelInfo[], model: string): string | undefined {
let byModel = displayNameCache.get(modelInfos);
if (!byModel) displayNameCache.set(modelInfos, (byModel = new Map()));
if (!byModel.has(model)) {
const info = modelInfos.find((info) => info.value === model || info.resolvedModel === model);
byModel.set(model, info?.displayName);
}
return byModel.get(model);
}
/** {@link modelDisplayName} per model list, which a session replaces and never edits. */
const displayNameCache = new WeakMap<ModelInfo[], Map<string, string | undefined>>();
/** The flat entries of a session's `model` select option. */
function modelOptionEntries(option: SessionConfigOption): Array<{ value: string; name: string }> {
if (option.type !== "select") return [];
return option.options.flatMap((entry) =>
"value" in entry ? [{ value: entry.value, name: entry.name }] : entry.options,
);
}
/**
* The entry of the `model` option among `configOptions` for `model`, a model
* id that a response reported, so that a client can compare it with the
* session's own model. Best first:
*
* 1. the current entry, when it runs that model up to a context hint: a
* session on `opus[1m]` or `default` that answers as `claude-opus-4-6`;
* 2. the entry whose value or resolved model is that id;
* 3. an entry that runs that model up to a context hint.
*
* Among several entries for one model, a concrete entry comes before
* `default`, as in {@link resolveModelPreference}. Undefined when no entry
* matches. A model's resolved id comes from `modelInfos`
* (`ModelInfo.resolvedModel`), the wire id that a response reports, as for
* {@link matchResumedModel}. It is compared as it is: a dated id such as
* `claude-haiku-4-5-20251001` matches only the same dated id.
*/
export function modelEntryFor(
configOptions: readonly SessionConfigOption[],
modelInfos: ModelInfo[],
model: string,
): { value: string; name: string } | undefined {
// Each response of a subagent reports its model, so the result is kept per
// list. A session replaces its lists on a change and never edits them.
let byInfos = modelEntryCache.get(configOptions);
if (!byInfos) modelEntryCache.set(configOptions, (byInfos = new WeakMap()));
let byModel = byInfos.get(modelInfos);
if (!byModel) byInfos.set(modelInfos, (byModel = new Map()));
if (byModel.has(model)) return byModel.get(model);
const entry = findModelEntry(configOptions, modelInfos, model);
byModel.set(model, entry);
return entry;
}
const modelEntryCache = new WeakMap<
readonly SessionConfigOption[],
WeakMap<ModelInfo[], Map<string, { value: string; name: string } | undefined>>
>();
function findModelEntry(
configOptions: readonly SessionConfigOption[],
modelInfos: ModelInfo[],
model: string,
): { value: string; name: string } | undefined {
const option = configOptions.find((candidate) => candidate.id === MODEL_CONFIG_ID);
if (option?.type !== "select") return undefined;
// Concrete entries first; `sort` keeps the list order otherwise.
const entries = modelOptionEntries(option).sort(
(left, right) => Number(left.value === "default") - Number(right.value === "default"),
);
const observed = canonicalizeModelId(model);
const resolved = (value: string) =>
canonicalizeModelId(modelInfos.find((info) => info.value === value)?.resolvedModel ?? value);
const sameModel = (value: string) =>
stripContextHints(resolved(value)) === stripContextHints(observed);
const current = entries.find((entry) => entry.value === option.currentValue);
if (current && sameModel(current.value)) return current;
return (
entries.find((entry) => entry.value === model || resolved(entry.value) === observed) ??
entries.find((entry) => sameModel(entry.value))
);
}

View File

@@ -1,4 +1,4 @@
import type { ClientCapabilities, SessionNotification } from "@agentclientprotocol/sdk";
import type { ClientCapabilities, SessionNotification } from "@agentclientprotocol/sdk/unstable";
/** Fire-and-forget advisory text for the user: live events rather than
* conversation history. See the ACP Session Notices RFD
@@ -50,11 +50,35 @@ export function noticeOrTranscriptUpdate(
if (supportsNotices) return noticeUpdate(notice);
return {
sessionUpdate: "agent_message_chunk",
content: { type: "text", text: transcriptText },
content: { type: "text", text: transcriptBlock(transcriptText) },
...(transcriptMeta ? { _meta: transcriptMeta } : {}),
};
}
/** Clients render consecutive agent_message_chunks as one markdown stream, so
* a bare `**Notice:** …` chunk lands mid-sentence: "…all done.**Notice:** Stop
* says: …". Put it in its own blockquote paragraph, blank lines on both sides,
* so it starts on a new line and Claude's next chunk does not continue the
* quote. */
export function transcriptBlock(text: string): string {
const lines = text.trim().split(/\r?\n/);
return `\n\n${lines.map((line) => (line ? `> ${line}` : ">")).join("\n")}\n\n`;
}
const HOOK_SPEAKER = /^[^\n:]{1,80} says: /;
/** Claude Code prefixes every line of a hook's `systemMessage` with
* "<HookEvent> says: ", so a two-line message reads "Stop says: a\nStop says:
* b". Keep the prefix on the first line only when every line repeats it. */
export function collapseHookSpeaker(text: string): string {
const lines = text.split(/\r?\n/);
const prefix = lines[0].match(HOOK_SPEAKER)?.[0];
if (!prefix || lines.length < 2 || !lines.every((line) => line.startsWith(prefix))) {
return text;
}
return [lines[0], ...lines.slice(1).map((line) => line.slice(prefix.length))].join("\n");
}
/** Fragments that read on after a bold label stand alone as a notice
* description, so start them with a capital. */
export function sentenceCase(text: string): string {
@@ -94,3 +118,98 @@ export function splitNoticeText(
}
return rest ? { title: firstLine, description: rest } : { title: firstLine };
}
/** The notice of Claude Code's `informational` message (e.g. why a Stop hook
* blocked continuation): a warning at level `warning`, else info. */
export function informationalNotice(message: { level: string; content: string }): SessionNotice {
const severity = message.level === "warning" ? "warning" : "info";
return {
severity,
...splitNoticeText(
message.content,
severity === "warning" ? "Claude reported a warning" : "Claude reported a notice",
),
};
}
/** The notice of a client MCP server that the `system/init` lists as not
* started: `failed`, or `needs-auth` when the client cannot run the OAuth
* flow (`oauthAvailable`). Undefined for any other status. */
export function mcpServerStartupNotice(
server: { name: string; status: string },
oauthAvailable: boolean,
): SessionNotice | undefined {
if (server.status === "failed") {
return {
severity: "warning",
title: `MCP server "${server.name}" failed to start`,
description: "Its tools are unavailable in this session. Run /mcp for the error.",
};
}
if (server.status === "needs-auth" && !oauthAvailable) {
return {
severity: "info",
title: `MCP server "${server.name}" needs authentication`,
description: "Its tools are unavailable until the server is authenticated.",
};
}
return undefined;
}
/**
* What Claude Code's `model_refusal_fallback` tells the user: the SDK retried
* a refused turn on the fallback model.
*
* Current CLIs only emit direction "retry", a swap that lasts for the
* session. "revert"/"sticky" are retained in the SDK enum for older CLIs,
* where "revert" marked a turn-only fallback: the session stays on the
* original model. `scope` (CLI 2.1.232+) "local" means a subagent,
* side-question or background fork response fell back and the session model
* is unchanged. Absent scope means an older CLI, where every retry was a
* session-level swap. `persistent` is whether the session now runs
* `fallback_model`, so its model option must follow.
*/
export function modelRefusalFallback(message: {
original_model: string;
fallback_model: string;
direction?: string;
scope?: string;
api_refusal_category?: string | null;
api_refusal_explanation?: string | null;
}): {
persistent: boolean;
/** The one-line summary. */
summary: string;
explanation: string | undefined;
/** The notice: the summary as its title when it is short enough, else a
* generic title with everything in the description. */
notice: SessionNotice;
/** The transcript line for a client without `notice` updates. */
transcript: string;
} {
const local = message.scope === "local";
const persistent = message.direction !== "revert" && !local;
const category = message.api_refusal_category ? ` (${message.api_refusal_category})` : "";
const outcome = persistent
? `The session will continue on ${message.fallback_model}.`
: local
? `Only that response came from ${message.fallback_model}; the session stays on ${message.original_model}.`
: `The session stays on ${message.original_model}.`;
const summary =
`${message.original_model} declined this request${category}; ` +
`retried with ${message.fallback_model}. ${outcome}`;
const explanation = message.api_refusal_explanation || undefined;
const full = explanation ? `${summary}\n\n${explanation}` : summary;
return {
persistent,
summary,
explanation,
notice: {
severity: "warning",
...(summary.length > MAX_NOTICE_TITLE_LENGTH
? { title: "Model fallback", description: full }
: { title: summary, ...(explanation ? { description: explanation } : {}) }),
},
transcript: `**Model fallback:** ${full}`,
};
}

View File

@@ -1,24 +1,52 @@
/**
* AI-generated session titles.
*
* Claude Code's own auto-title generation never runs under the Agent SDK — the
* latch that arms it is pre-set in the headless path — so `SDKSessionInfo.summary`
* degrades to the raw first prompt. This module asks the CLI for a real title
* instead, via the `generate_session_title` control request, and publishes it
* over ACP as a `session_info_update`.
* Claude Code titles a new SDK session itself: when it reads the first prompt,
* it asks a small model for a title and stores it in the transcript as an
* `ai-title` entry, a few seconds in. It sends no message when it does. This
* module asks for the title with the `generate_session_title` control request,
* which returns the stored title or waits for the one being generated, and
* publishes it over ACP as a `session_info_update`. Where the CLI does not
* title the session itself (a resumed session, or one with
* `CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC` or
* `CLAUDE_CODE_DISABLE_TERMINAL_TITLE` set), the same request generates it.
*
* {@link SessionTitles} holds the per-session state and is owned by `Session`.
* `acp-agent.ts` drives it from four places: {@link SessionTitles.onPrompt} and
* {@link SessionTitles.onAssistantText} to collect text,
* {@link SessionTitles.onTurnEnd} at `session_state_changed: idle`, and
* {@link SessionTitles.reset} on `conversation_reset`.
* {@link SessionTitles} holds the per-session state and is owned by the
* session, in the v1 core (`acp-agent.ts`) and the v2 core
* (`v2/session.ts`). Each drives it from the same places:
* {@link SessionTitles.onPrompt} and {@link SessionTitles.onAssistantText} to
* collect text, {@link SessionTitles.onPromptQueued} once a prompt is sent to
* the CLI, {@link SessionTitles.onTurnEnd} at `session_state_changed: idle`,
* and, in v1, {@link SessionTitles.reset} on `conversation_reset`.
*/
import type { ContentBlock, PromptRequest } from "@agentclientprotocol/sdk";
import type {
ContentBlock,
PromptRequest,
SessionNotification,
} from "@agentclientprotocol/sdk/unstable";
import { getSessionInfo, type Query, type SDKSessionInfo } from "@anthropic-ai/claude-agent-sdk";
import type { ClaudeAcpAgent, Session } from "./acp-agent.js";
import { ExplicitTitle } from "./session-index/explicit-title.js";
/** What titles read of their session. */
export interface TitledSession {
readonly query: Query;
readonly queryClosed?: boolean;
/** Whether a cancel stopped the session's work: no title is generated then. */
readonly cancelled?: boolean;
readonly cwd: string;
}
/** What titles need of the core that serves the session (`ClaudeAcpAgent` in v1). */
export interface TitleOwner {
readonly client: { sessionUpdate(notification: SessionNotification): Promise<void> };
readonly logger: { error: (...args: any[]) => void };
/** The live sessions by id: a session replaced or closed meanwhile adopts no title. */
readonly sessions: Readonly<Record<string, TitledSession | undefined>>;
/** The title as the client shows it, such as with an archive prefix removed. */
clientTitle?(title: string): string;
}
const MAX_TITLE_LENGTH = 256;
/** How much session text the title is generated from. The CLI caps its own
@@ -77,6 +105,11 @@ export function appendTitleContext(previous: string | undefined, text: string):
return next.length > MAX_TITLE_CONTEXT_LENGTH ? next.slice(-MAX_TITLE_CONTEXT_LENGTH) : next;
}
/** The text chunks of a prompt, one per line. */
function promptText(prompt: PromptRequest["prompt"]): string {
return prompt.flatMap((chunk) => (chunk.type === "text" ? [chunk.text] : [])).join("\n");
}
/** Whether this SDK still offers on-demand title generation. */
function supportsTitleGeneration(query: Query): boolean {
return typeof (query as TitleCapableQuery).generateSessionTitle === "function";
@@ -100,6 +133,11 @@ export class SessionTitles {
* one. Released by {@link reset}, and when generation yields nothing. */
private settled = false;
/** Whether {@link onPromptQueued} asked for a title from the latest prompt.
* A turn end then does not ask again: like the CLI, a prompt that yields
* no title gets one try, and the next prompt tries again. */
private askedAtPrompt = false;
/** The title a client set, which no generated title replaces. */
private readonly explicit = new ExplicitTitle();
@@ -122,7 +160,7 @@ export class SessionTitles {
}
constructor(
private readonly agent: ClaudeAcpAgent,
private readonly agent: TitleOwner,
private readonly sessionId: string,
) {}
@@ -132,11 +170,33 @@ export class SessionTitles {
if (this.settled) {
return;
}
const promptText = prompt
.flatMap((chunk) => (chunk.type === "text" ? [chunk.text] : []))
.join("\n");
this.context = appendTitleContext(this.context, `\n${promptText(prompt)}\n`);
}
this.context = appendTitleContext(this.context, `\n${promptText}\n`);
/** Ask for the title as soon as a prompt is sent to the CLI, instead of
* waiting for `idle`: `idle` comes only after the whole turn and its
* background tasks, often minutes after the CLI stored its title. The CLI
* answers `generate_session_title` while the turn runs, and with the title
* it stored or is generating, so this makes no second model call. The
* prompt alone is the description, because the CLI titles from the prompt
* alone. When this yields no title, the stored summary is published and
* the next prompt tries again. */
onPromptQueued(session: TitledSession, prompt: PromptRequest["prompt"]): void {
this.askedAtPrompt = false;
// `session.cancelled` is not checked: after a cancelled turn it stays set
// until the next turn starts, which is after this prompt is queued.
if (this.settled || this.explicit.active || session.queryClosed) {
return;
}
if (!supportsTitleGeneration(session.query)) {
return;
}
const description = promptText(prompt).trim();
if (description.length < MIN_TITLE_CONTEXT_LENGTH) {
return;
}
this.askedAtPrompt = true;
this.startGeneration(() => this.titleFromPrompt(session, description));
}
/** Collect the assistant's own answer for the title. Chunks are appended
@@ -152,6 +212,7 @@ export class SessionTitles {
* `conversation_reset`, which mounts a fresh transcript. */
reset(): void {
this.settled = false;
this.askedAtPrompt = false;
this.explicit.reset();
this.context = undefined;
this.lastTitle = undefined;
@@ -165,11 +226,13 @@ export class SessionTitles {
* title — a user `/rename` or one generated earlier; the SDK folds both into
* that one field. Adopt it and latch, so neither is ever titled over.
*
* With no title yet, ask the SDK to generate one in the background: it is a
* ~2s small-model call and turn-end must not wait on it. Only when that is
* not possible do we fall back to `summary`, which for an SDK-driven session
* is just the raw first prompt. */
async onTurnEnd(session: Session): Promise<void> {
* With no title yet, and none asked for at the latest prompt (see
* {@link onPromptQueued}), ask the SDK to generate one in the background
* from the prompts and replies so far: it is a ~2s small-model call and
* turn-end must not wait on it. That covers a prompt too short to title
* from, such as "hi". Only when that is not possible do we fall back to
* `summary`, which for an SDK-driven session is just the raw first prompt. */
async onTurnEnd(session: TitledSession): Promise<void> {
if (this.explicit.settledByClient) {
// No title is generated over the one the client set, but a custom title
// stored since by someone else (a `/rename`) is adopted, as before.
@@ -200,17 +263,9 @@ export class SessionTitles {
? { title: info.summary, lastModified: info.lastModified }
: undefined;
if (this.canRequest(session)) {
this.settled = true;
this.explicit.track(
this.requestGenerateTitle(session, fallback).catch((error) => {
this.agent.logger.error(
`Session ${this.sessionId}: session title update failed: ${error}`,
);
}),
);
if (!this.askedAtPrompt && this.canRequest(session)) {
const description = this.context?.trim() ?? "";
this.startGeneration(() => this.requestGenerateTitle(session, description, fallback));
return;
}
// Only while this session has no title of its own: `summary` degrades to the
@@ -264,6 +319,32 @@ export class SessionTitles {
});
}
/** Sends the session's current title and `updatedAt`, even when the client
* was already sent that title: a client that resumes the session may not
* have it. Both come from the session file, as `session/list` reports
* them; a session with no file yet sends the last title published, if any,
* without `updatedAt`. */
async publishCurrent(session: TitledSession): Promise<void> {
const info = await this.readSessionInfo(session);
// A title published but not yet stored (a generated title still being
// written) wins over `summary`, which is then only the raw first prompt.
const stored =
info?.customTitle || (this.settled && this.lastTitle ? undefined : info?.summary);
const title = stored
? sanitizeTitle(this.agent.clientTitle?.(stored) ?? stored)
: this.lastTitle;
if (!title) return;
this.lastTitle = title;
await this.agent.client.sessionUpdate({
sessionId: this.sessionId,
update: {
sessionUpdate: "session_info_update",
title,
...(info ? { updatedAt: new Date(info.lastModified).toISOString() } : {}),
},
});
}
/** Records a title the CLI took, as soon as it took it. */
rememberStoredTitle(title: string): void {
this.persistedTitle = title;
@@ -271,7 +352,7 @@ export class SessionTitles {
/** Read the SDK's stored info for this session. A missing session file or read
* error is non-fatal: the title is best-effort and another turn will retry. */
private async readSessionInfo(session: Session): Promise<SDKSessionInfo | undefined> {
private async readSessionInfo(session: TitledSession): Promise<SDKSessionInfo | undefined> {
try {
return await getSessionInfo(this.sessionId, { dir: session.cwd });
} catch (error) {
@@ -299,7 +380,7 @@ export class SessionTitles {
/** Whether to ask the SDK for a generated title now: once per session, on a
* live query, with enough collected text for the generator to work with. */
private canRequest(session: Session): boolean {
private canRequest(session: TitledSession): boolean {
if (this.settled || session.queryClosed || session.cancelled) {
return false;
}
@@ -309,17 +390,48 @@ export class SessionTitles {
return (this.context?.trim().length ?? 0) >= MIN_TITLE_CONTEXT_LENGTH;
}
/** Generate a title from the session's own text and publish it. `persist`
* writes it to the session file, which is what carries it into
* `session/list` and `session/load`. A null or failed generation releases the
* latch so a later turn retries, and publishes `fallback` (the stored
* summary) so a backend that can't generate titles is no worse off than
* before. */
/** Latch the title and run `generation` in the background, so neither the
* prompt nor turn-end waits on a model call. A client title change waits
* for it to finish (see {@link ExplicitTitle.track}). */
private startGeneration(generation: () => Promise<void>): void {
this.settled = true;
this.explicit.track(
generation().catch((error) => {
this.agent.logger.error(`Session ${this.sessionId}: session title update failed: ${error}`);
}),
);
}
/** The prompt-time title: the one the session file already holds (a resumed
* session's title, or a `/rename`), else one generated from `description`.
* The file is read first because the CLI checks only the titles it holds in
* memory before generating one. */
private async titleFromPrompt(session: TitledSession, description: string): Promise<void> {
const info = await this.readSessionInfo(session);
if (this.agent.sessions[this.sessionId] !== session || this.explicit.active) {
return;
}
if (info?.customTitle) {
this.context = undefined;
await this.publish(info.customTitle, info.lastModified);
return;
}
const fallback = info?.summary
? { title: info.summary, lastModified: info.lastModified }
: undefined;
await this.requestGenerateTitle(session, description, fallback);
}
/** Generate a title from `description` and publish it. `persist` writes it
* to the session file, which is what carries it into `session/list` and
* `session/load`. A null or failed generation releases the latch so a later
* prompt or turn end retries, and publishes `fallback` (the stored summary)
* so a backend that can't generate titles is no worse off than before. */
private async requestGenerateTitle(
session: Session,
session: TitledSession,
description: string,
fallback: TitleFallback | undefined,
): Promise<void> {
const description = this.context?.trim() ?? "";
let title: string | null = null;
try {

140
src/session-ultracode.ts Normal file
View File

@@ -0,0 +1,140 @@
import { RequestError } from "@agentclientprotocol/sdk/unstable";
import type {
SessionConfigOption,
SetSessionConfigOptionRequest,
} from "@agentclientprotocol/sdk/unstable";
import type { ModelInfo, Query } from "@anthropic-ai/claude-agent-sdk";
import { ULTRACODE_CONFIG_ID } from "./session-config-ids.js";
export { ULTRACODE_CONFIG_ID } from "./session-config-ids.js";
/** Select-fallback values for clients without boolean config options. */
export const ULTRACODE_ON = "on";
export const ULTRACODE_OFF = "off";
const ULTRACODE_DESCRIPTION = "Dynamic workflows on every task, at any effort level";
/** Ultracode state from the CLI's `get_settings` control response
* (`applied.ultracodeAvailable` / `applied.ultracodeRequested`). Each field is
* undefined when the CLI does not report it. */
export type UltracodeSettingsReport = {
/** Workflows are enabled and the current model supports ultracode. */
available?: boolean;
/** Ultracode is turned on for the session, whether or not it can take
* effect on the current model. */
requested?: boolean;
};
// `Query.getSettings()` exists at runtime (sdk.mjs) but is missing from the
// SDK's type declarations as of 0.3.319.
type GetSettingsQuery = Query & {
getSettings?: () => Promise<{
applied?: { ultracodeAvailable?: unknown; ultracodeRequested?: unknown };
}>;
};
/** Ask the CLI whether ultracode is available and requested for the session.
* Returns undefined when the SDK has no `getSettings` or the request fails;
* callers then fall back to {@link modelSupportsUltracode}. */
export async function readUltracodeSettings(
query: Query,
): Promise<UltracodeSettingsReport | undefined> {
const getSettings = (query as GetSettingsQuery).getSettings;
if (typeof getSettings !== "function") return undefined;
try {
const applied = (await getSettings.call(query))?.applied;
return {
available:
typeof applied?.ultracodeAvailable === "boolean" ? applied.ultracodeAvailable : undefined,
requested:
typeof applied?.ultracodeRequested === "boolean" ? applied.ultracodeRequested : undefined,
};
} catch {
return undefined;
}
}
/** The CLI supports ultracode on exactly the models that support `xhigh`
* effort (both use the same capability check), and `supportedEffortLevels`
* lists `xhigh` only for those models. E.g. Opus 4.7+, Sonnet 5+ and Haiku
* 5.5 qualify; Opus 4.6 and Haiku 4.5 do not. */
export function modelSupportsUltracode(modelInfo: ModelInfo | undefined): boolean {
return (
modelInfo?.supportsEffort === true &&
modelInfo.supportedEffortLevels?.includes("xhigh") === true
);
}
/** Whether dynamic workflows are enabled for the session, inferred from the
* CLI's `ultracodeAvailable` for the model the session started on. This is
* the part of availability that does not change with the model: the plan
* default (off on Pro), `enableWorkflows`/`disableWorkflows` and org policy.
* Undefined when the report can't tell, e.g. `ultracodeAvailable: false` on a
* model that lacks ultracode support anyway. */
export function ultracodeWorkflowsEnabled(
report: UltracodeSettingsReport | undefined,
modelInfo: ModelInfo | undefined,
): boolean | undefined {
if (report?.available === true) return true;
if (report?.available === false && modelSupportsUltracode(modelInfo)) return false;
return undefined;
}
/** Whether to offer the ultracode toggle for `modelInfo`. An unknown workflows
* state shows the toggle: the CLI keeps an unavailable request without
* error, so the toggle is merely inert there. */
export function ultracodeSupported(
workflowsEnabled: boolean | undefined,
modelInfo: ModelInfo | undefined,
): boolean {
return workflowsEnabled !== false && modelSupportsUltracode(modelInfo);
}
/** Ultracode state threaded into `buildConfigOptions`. The option is only
* surfaced while `supported`. */
export type UltracodeOptionState = {
supported: boolean;
enabled: boolean;
/** Whether the client opted into boolean config options. */
useBooleanOption: boolean;
};
/** Build the ultracode toggle: a native boolean for clients that opted into
* boolean config options, otherwise an "on"/"off" select. */
export function createUltracodeConfigOption(
enabled: boolean,
useBooleanOption: boolean,
): SessionConfigOption {
const base = {
id: ULTRACODE_CONFIG_ID,
name: "Ultracode",
description: ULTRACODE_DESCRIPTION,
category: "model_config",
} as const;
if (useBooleanOption) {
return { ...base, type: "boolean", currentValue: enabled };
}
return {
...base,
type: "select",
currentValue: enabled ? ULTRACODE_ON : ULTRACODE_OFF,
options: [
{ value: ULTRACODE_ON, name: "On" },
{ value: ULTRACODE_OFF, name: "Off" },
],
};
}
/** Read the requested ultracode value from a `session/set_config_option`
* request: a boolean, or the "on"/"off" select-fallback strings. */
export function resolveUltracodeEnabled(params: SetSessionConfigOptionRequest): boolean {
const value = params.value;
if (typeof value === "boolean") return value;
if (value === ULTRACODE_ON) return true;
if (value === ULTRACODE_OFF) return false;
throw RequestError.invalidParams(
undefined,
`Invalid value for config option ${ULTRACODE_CONFIG_ID}: ${value}`,
);
}

259
src/session-usage.ts Normal file
View File

@@ -0,0 +1,259 @@
/**
* Token usage as Claude Code reports it: the context window figures of
* `usage_update` and the `_meta.quota` of a turn. Shared by the v1 core and
* the v2 core.
*/
import type { ModelUsage } from "@anthropic-ai/claude-agent-sdk";
export type AccumulatedUsage = {
inputTokens: number;
outputTokens: number;
cachedReadTokens: number;
cachedWriteTokens: number;
};
/** Per-model token tallies keyed by the model id the SDK reported them under
* (its resolved spelling, e.g. "claude-opus-5[1m]"). */
export type ModelTokenTally = Record<string, AccumulatedUsage>;
export type UsageSnapshot = {
input_tokens: number;
output_tokens: number;
cache_read_input_tokens: number;
cache_creation_input_tokens: number;
};
export const ZERO_USAGE = Object.freeze({
input_tokens: 0,
output_tokens: 0,
cache_read_input_tokens: 0,
cache_creation_input_tokens: 0,
});
export const DEFAULT_CONTEXT_WINDOW = 200000;
/** `_meta.quota` for work that spent `usage` in its main agent loop and
* `modelUsage` by model (see `turnQuotaMeta` in `acp-agent.ts`). */
export function quotaMeta(usage: AccumulatedUsage, modelUsage: ModelTokenTally | undefined) {
return {
quota: {
token_count: quotaTokenCount(usage),
model_usage: Object.entries(modelUsage ?? {}).map(([model, usage]) => ({
model,
token_count: quotaTokenCount(usage),
})),
},
};
}
/** One `token_count` object. `cachedInputTokens` is cache reads, matching
* codex's field; Claude also reports cache writes, which codex has no slot
* for, so those ride along in an extra sibling (the same name the ACP `usage`
* field already uses) and are counted in `totalTokens`. `reasoningOutputTokens`
* is always 0: Claude bills thinking inside its output tokens and never breaks
* it out — the key is kept so the shape stays uniform across agents. */
function quotaTokenCount(usage: AccumulatedUsage) {
return {
totalTokens: tallyTotal(usage),
inputTokens: usage.inputTokens,
cachedInputTokens: usage.cachedReadTokens,
cachedWriteTokens: usage.cachedWriteTokens,
outputTokens: usage.outputTokens,
reasoningOutputTokens: 0,
};
}
/** Sum a tally's four counters. Per the Anthropic API `inputTokens` excludes
* the cache counters, so this is not double-counting (see `totalTokens`). */
export function tallyTotal(usage: AccumulatedUsage): number {
return usage.inputTokens + usage.outputTokens + usage.cachedReadTokens + usage.cachedWriteTokens;
}
/** Project `result.modelUsage` into our tally shape. Counters are coerced the
* way `snapshotFromUsage` coerces the stream's: third-party backends have been
* observed omitting fields, and a missing or NaN counter must not reach the
* wire as NaN (which `JSON.stringify` writes as `null`). */
export function normalizeModelUsage(
modelUsage: Record<string, ModelUsage> | undefined,
): ModelTokenTally {
const tally: ModelTokenTally = {};
for (const [model, usage] of Object.entries(modelUsage ?? {})) {
tally[model] = {
inputTokens: finiteCount(usage?.inputTokens),
outputTokens: finiteCount(usage?.outputTokens),
cachedReadTokens: finiteCount(usage?.cacheReadInputTokens),
cachedWriteTokens: finiteCount(usage?.cacheCreationInputTokens),
};
}
return tally;
}
function finiteCount(value: number | null | undefined): number {
return typeof value === "number" && Number.isFinite(value) ? value : 0;
}
/** `current - previous` per model, dropping models with nothing to report so a
* turn only lists the models it actually ran on. A reading that fell BELOW the
* previous one means the running total restarted under us (a mid-session
* /clear, a zeroed crash result): there is no usable reference left to
* subtract, so the reading itself is the increment. */
export function modelUsageIncrement(
current: ModelTokenTally,
previous: ModelTokenTally,
): ModelTokenTally {
const increment: ModelTokenTally = {};
for (const [model, usage] of Object.entries(current)) {
const base = previous[model];
const subtracted: AccumulatedUsage = base
? {
inputTokens: usage.inputTokens - base.inputTokens,
outputTokens: usage.outputTokens - base.outputTokens,
cachedReadTokens: usage.cachedReadTokens - base.cachedReadTokens,
cachedWriteTokens: usage.cachedWriteTokens - base.cachedWriteTokens,
}
: usage;
const rewound = Object.values(subtracted).some((count) => count < 0);
const resolved = rewound ? usage : subtracted;
if (tallyTotal(resolved) > 0) {
increment[model] = resolved;
}
}
return increment;
}
/** The per-model rows for the first result of a resumed session. Its
* `modelUsage` reading continues from the totals the transcript saved (SDK
* 0.3.277+; older transcripts may hold none), so there is no earlier reading
* to subtract from, and taking the reading itself would charge the whole
* pre-resume history to this one turn. The result's own `usage` is per-turn
* (main agent loop only, like the response's `usage`), so it stands in under
* the turn's top-level model — `key` is that model's `modelUsage` spelling
* when the reading has one, else the assistant message's own. Any subagent
* spend on this one turn goes unlisted rather than over-listed. */
export function resumedFirstResultModelUsage(
usage: {
input_tokens?: number | null;
output_tokens?: number | null;
cache_read_input_tokens?: number | null;
cache_creation_input_tokens?: number | null;
},
key: string | null,
): ModelTokenTally {
if (key === null) {
return {};
}
const row: AccumulatedUsage = {
inputTokens: finiteCount(usage.input_tokens),
outputTokens: finiteCount(usage.output_tokens),
cachedReadTokens: finiteCount(usage.cache_read_input_tokens),
cachedWriteTokens: finiteCount(usage.cache_creation_input_tokens),
};
return tallyTotal(row) > 0 ? { [key]: row } : {};
}
/** Fold `increment` into `base` per model — the per-model counterpart of the
* `+=` the turn's flat accumulator uses. */
export function addModelUsage(base: ModelTokenTally, increment: ModelTokenTally): ModelTokenTally {
const merged: ModelTokenTally = { ...base };
for (const [model, usage] of Object.entries(increment)) {
const existing = merged[model];
merged[model] = existing
? {
inputTokens: existing.inputTokens + usage.inputTokens,
outputTokens: existing.outputTokens + usage.outputTokens,
cachedReadTokens: existing.cachedReadTokens + usage.cachedReadTokens,
cachedWriteTokens: existing.cachedWriteTokens + usage.cachedWriteTokens,
}
: usage;
}
return merged;
}
/** Sum all four fields as a proxy for post-turn context occupancy: the current
* turn's output becomes next turn's input. Per the Anthropic API, input_tokens
* excludes cache tokens — cache_read and cache_creation are reported
* separately — so summing all four is not double-counting. */
export function totalTokens(usage: UsageSnapshot): number {
return (
usage.input_tokens +
usage.output_tokens +
usage.cache_read_input_tokens +
usage.cache_creation_input_tokens
);
}
/** Project a nullable API usage object into our non-null snapshot shape.
* Both SDK message_start and assistant message `usage` have `number | null`
* cache fields; we coerce absent values to 0 so `totalTokens` never hits
* NaN. `input_tokens`/`output_tokens` are typed `number` by the SDK but
* synthetic or third-party-backend stream events have been observed emitting
* them as null/undefined — coerce those too so a malformed upstream event
* can't leak NaN into the wire `used` field. Delta events have different
* semantics (cumulative + prev fallback) and are handled inline. */
export function snapshotFromUsage(usage: {
input_tokens?: number | null;
output_tokens?: number | null;
cache_read_input_tokens?: number | null;
cache_creation_input_tokens?: number | null;
}): UsageSnapshot {
return {
input_tokens: usage.input_tokens ?? 0,
output_tokens: usage.output_tokens ?? 0,
cache_read_input_tokens: usage.cache_read_input_tokens ?? 0,
cache_creation_input_tokens: usage.cache_creation_input_tokens ?? 0,
};
}
function commonPrefixLength(a: string, b: string) {
let i = 0;
while (i < a.length && i < b.length && a[i] === b[i]) {
i++;
}
return i;
}
/** Best-effort first guess of a model's context window, used to seed the
* window synchronously (via `immediateContextWindow`) until a `result` message
* arrives with the authoritative `modelUsage` value.
*
* Anthropic 1M-context variants encode "1m" as a distinct token in the SDK
* model ID (e.g., "claude-opus-4-6-1m"), which `\b1m\b` catches without also
* matching things like "10m" or embedded substrings. Semantic aliases like
* `default` carry no such token in the ID, but their `resolvedModel` and the
* SDK's human-facing `displayName`/`description` can (e.g.
* "claude-opus-4-8[1m]", "Opus 4.7 (1M context)"), so callers pass those too.
* This text scan can't catch every model — some resolve to extended-context
* models with no "1m" anywhere (e.g. `sonnet` → claude-sonnet-5, natively
* ~1M). Such a miss falls back to the default window, is refined by the
* background `getContextUsage` (`refreshContextWindowInBackground`), and is
* corrected by `result.modelUsage` (and cached) within one turn. */
export function inferContextWindowFromModel(...texts: Array<string | undefined>): number | null {
if (texts.some((text) => text != null && /\b1m\b/i.test(text))) return 1_000_000;
return null;
}
export function getMatchingModelUsage(
modelUsage: Record<string, ModelUsage>,
currentModel: string,
) {
let bestKey: string | null = null;
let bestLen = 0;
for (const key of Object.keys(modelUsage)) {
const len = commonPrefixLength(key, currentModel);
if (len > bestLen) {
bestLen = len;
bestKey = key;
}
}
if (bestKey) {
// `bestKey` is the SDK's resolved model id (e.g. "claude-sonnet-5[1m]"),
// the same spelling as ModelInfo.resolvedModel — the primary key the
// window is cached under. `currentModel` (the assistant message's
// `.model`) can be the bare form (e.g. "claude-sonnet-5"); the result
// handler caches under that spelling too, for seed-time reads that fall
// back to a bare id (rows without `resolvedModel`).
return { key: bestKey, usage: modelUsage[bestKey] };
}
}

View File

@@ -50,6 +50,7 @@ export class SettingsManager {
private cwd: string;
private effective: Settings = {};
private managedDeniedModels: string[] = [];
private managedApiKeyHelper?: string;
private watchers: fs.FSWatcher[] = [];
private onChange?: () => void;
private logger: { log: (...args: any[]) => void; error: (...args: any[]) => void };
@@ -111,10 +112,15 @@ export class SettingsManager {
this.managedDeniedModels = resolved.sources
.filter((entry) => entry.source === "managed")
.flatMap((entry) => entry.settings.deniedModels ?? []);
this.managedApiKeyHelper =
resolved.provenance.apiKeyHelper?.source === "managed"
? resolved.effective.apiKeyHelper
: undefined;
} catch (error) {
this.logger.error("Failed to resolve settings:", error);
this.effective = {};
this.managedDeniedModels = [];
this.managedApiKeyHelper = undefined;
}
}
@@ -177,6 +183,16 @@ export class SettingsManager {
return this.effective;
}
/**
* The `apiKeyHelper` the CLI runs: managed settings outrank the `--settings`
* override, which outranks the user/project/local files.
*/
getApiKeyHelper(settingsOverride?: Settings): string | undefined {
return (
this.managedApiKeyHelper ?? settingsOverride?.apiKeyHelper ?? this.effective.apiKeyHelper
);
}
/**
* `deniedModels` entries from the managed tier, the only tier the CLI
* honors them from.

124
src/slash-commands.ts Normal file
View File

@@ -0,0 +1,124 @@
/**
* The `available_commands_update` list: the slash commands Claude Code reports,
* minus the ones ACP clients can't run, plus the commands the adapter adds.
*/
import type { AvailableCommand } from "@agentclientprotocol/sdk/unstable";
import type { Query, SlashCommand } from "@anthropic-ai/claude-agent-sdk";
import { AIR_KIND_KEY, AIR_SKILL_PATH_KEY, withAirMeta } from "./air-extension.js";
import { MCP_AVAILABLE_COMMAND } from "./mcp-command.js";
import {
claudeCodeOffersCommand,
REMOTE_CONTROL_COMMAND_NAME,
supportsRemoteControl,
} from "./remote-control.js";
import { resolveSkillPath, type SkillScanCache } from "./tool-calls/reporters/interaction.js";
export function getAvailableSlashCommands(
commands: SlashCommand[],
// Names the CLI tagged terminal-bound on `system`/init (their UX lives in
// the CLI's own terminal, which ACP clients aren't) — filtered alongside
// the static list. Raw CLI names, matched before the MCP rename.
terminalCommands?: readonly string[],
// The session cwd of an AIR client, or undefined for every other client.
// AIR gets the kind and the SKILL.md path of each skill command.
airSkillCwd?: string,
// The resolved SKILL.md paths of the session, keyed by the cwd and the name.
skillPaths?: Map<string, string | undefined>,
query?: Query,
// The skill files Claude Code reports (`getContextUsage().skills.skillFrontmatter`).
// A listed skill gets its path from here; one that is not listed, or has no
// path (remote connections), is looked up on disk.
skillFiles?: ReadonlyMap<string, { path?: string }>,
): AvailableCommand[] {
// The container listings of this one pass, so a list of a hundred commands lists each container once.
const skillScans: SkillScanCache = new Map();
const skillPath = (name: string, cwd: string): string | undefined => {
const key = `${cwd}\0${name}`;
if (skillPaths?.has(key)) return skillPaths.get(key);
const resolved = resolveSkillPath(name, cwd, skillScans);
skillPaths?.set(key, resolved);
return resolved;
};
const UNSUPPORTED_COMMANDS = [
"clear",
"cost",
"keybindings-help",
"login",
"logout",
"output-style:new",
"release-notes",
"todos",
];
const remoteControlCommands: AvailableCommand[] =
query && supportsRemoteControl(query)
? [
{
name: REMOTE_CONTROL_COMMAND_NAME,
description:
"Continue this session from claude.ai/code or the Claude mobile app; `off` disconnects",
input: { hint: "[session name | off]" },
},
]
: [];
// The adapter replaces the terminal text of `/mcp` and runs its actions,
// even when Claude Code tags the command terminal-bound.
const advertised = commands
.filter((command) => command.name !== "mcp" && !terminalCommands?.includes(command.name))
.map((command) => {
const input = command.argumentHint
? {
hint: Array.isArray(command.argumentHint)
? command.argumentHint.join(" ")
: command.argumentHint,
}
: null;
let name = command.name;
const mcpPrompt = command.name.endsWith(" (MCP)");
if (mcpPrompt) {
name = `mcp:${name.replace(" (MCP)", "")}`;
}
const skillFile = airSkillCwd && !mcpPrompt ? skillFiles?.get(command.name) : undefined;
const skillMdPath =
skillFile?.path ??
(airSkillCwd && !command.builtin && !mcpPrompt
? skillPath(command.name, airSkillCwd)
: undefined);
return {
name,
description: command.description || "",
input,
...(skillMdPath
? {
_meta: withAirMeta(
withAirMeta(undefined, AIR_KIND_KEY, "skill"),
AIR_SKILL_PATH_KEY,
skillMdPath,
),
}
: skillFile
? // A skill without a file (bundled with the CLI, like `simplify`, or a remote connection): kind only.
{ _meta: withAirMeta(undefined, AIR_KIND_KEY, "skill") }
: {}),
};
})
.filter((command: AvailableCommand) => !UNSUPPORTED_COMMANDS.includes(command.name));
// Command names must not contain whitespace and must be unique. A name
// like "my command" is dropped, and of two commands named "review" only the
// first is kept.
const names = new Set<string>();
return [
...advertised,
MCP_AVAILABLE_COMMAND,
...remoteControlCommands.filter(
(command) => !claudeCodeOffersCommand(commands, terminalCommands, command.name),
),
].filter((command) => {
if (/\s/.test(command.name) || names.has(command.name)) return false;
names.add(command.name);
return true;
});
}

42
src/startup-failure.ts Normal file
View File

@@ -0,0 +1,42 @@
import { RequestError } from "@agentclientprotocol/sdk/unstable";
import type { SDKStartupFailureReason } from "@anthropic-ai/claude-agent-sdk";
/** Makes the CLI write an `error_during_execution` result with a
* `startup_failure_reason` before it exits on a known startup failure. Without
* it, some failures (e.g. `proxy_invalid`) end with stderr and an exit code
* alone. The SDK then rejects `initializationResult()` with an error that
* carries the reason as `startupFailureReason`. */
export const STARTUP_FAILURE_RESULTS_ENV = { CLAUDE_CODE_STARTUP_FAILURE_RESULTS: "1" };
/** The SDK's prefix on the message of an error built from an error result. */
const ERROR_RESULT_PREFIX = "Claude Code returned an error result: ";
/** Reasons that signing in again, or with another account, can fix. */
const AUTH_REQUIRED_REASONS: ReadonlySet<SDKStartupFailureReason> = new Set([
"gateway_signin_required",
"org_pin_mismatch",
"org_config_refused",
]);
/** The ACP error for a CLI that refused to start, or `undefined` when `error`
* carries no `startupFailureReason` (older CLIs, failures without a known
* cause). The message is the CLI's own, which says what to fix, e.g.
* `Invalid proxy URL in HTTPS_PROXY: … Fix or unset HTTPS_PROXY and restart
* Claude Code.`; `data.startupFailureReason` carries the reason. */
export function startupFailureError(error: unknown): RequestError | undefined {
if (!(error instanceof Error)) return undefined;
const reason = (error as { startupFailureReason?: unknown }).startupFailureReason;
if (typeof reason !== "string" || reason === "") return undefined;
const message = error.message.startsWith(ERROR_RESULT_PREFIX)
? error.message.slice(ERROR_RESULT_PREFIX.length)
: error.message;
const data = { startupFailureReason: reason };
if (AUTH_REQUIRED_REASONS.has(reason as SDKStartupFailureReason)) {
return RequestError.authRequired(data, message);
}
if (reason === "cwd_unavailable") {
return RequestError.invalidParams(data, message);
}
return RequestError.internalError(data, message);
}

86
src/streamed-text.ts Normal file
View File

@@ -0,0 +1,86 @@
/**
* The text and thinking of an assistant message that streamed live as
* deltas, and what its consolidated message adds to them. Shared by the v1
* core and the v2 core.
*/
import type { SDKPartialAssistantMessage } from "@anthropic-ai/claude-agent-sdk";
/** Text or thinking that streamed live as deltas, accumulated per block. */
export type StreamedBlock = { index: number; type: "text" | "thinking"; text: string };
/**
* Records the text or thinking that `event` streamed in `streamedBlocks`.
* Contiguous deltas of the same block (same index and type) extend the
* current entry; anything else opens a new one. Other events change nothing.
*/
export function recordStreamedDelta(
streamedBlocks: StreamedBlock[],
event: SDKPartialAssistantMessage["event"],
): void {
if (event.type !== "content_block_delta") return;
const delta = event.delta;
const chunk =
delta.type === "text_delta"
? { type: "text" as const, text: delta.text }
: delta.type === "thinking_delta"
? { type: "thinking" as const, text: delta.thinking }
: undefined;
// Skip empty deltas (some gateways emit empty thinking chunks — #793):
// appending "" is a no-op, but pushing a "" entry would create a block the
// `text.length > 0` guard of `unstreamedRemainder` can never consume,
// stalling the diff cursor and re-emitting the next block as a duplicate.
if (!chunk?.text) return;
const last = streamedBlocks[streamedBlocks.length - 1];
if (last && last.index === event.index && last.type === chunk.type) {
last.text += chunk.text;
} else {
streamedBlocks.push({ index: event.index, type: chunk.type, text: chunk.text });
}
}
/**
* The blocks of a consolidated assistant message without the text that
* already streamed as deltas.
*
* Each assembled text/thinking block is diffed against the streamed blocks in
* document order: nothing is left if it streamed in full (the common case),
* the whole block if it never streamed (a non-streaming gateway), and just the
* tail if the stream was cut short mid-block. Matching on content rather than
* the message id keeps the dedupe robust for gateways without a stable id.
* Tool-use and other blocks pass through untouched.
*/
export function unstreamedRemainder<Block extends { type: string }>(
blocks: Block[],
streamedBlocks: StreamedBlock[],
): Block[] {
const kept: Block[] = [];
let streamPos = 0;
for (const item of blocks) {
if (item.type !== "text" && item.type !== "thinking") {
kept.push(item);
continue;
}
const block = item as Block & { text?: string; thinking?: string };
const full = (item.type === "text" ? block.text : block.thinking) ?? "";
// Empty assembled blocks carry nothing: drop them.
if (full.length === 0) continue;
// A streamed block of the same type whose text is a prefix of this one
// was already delivered, at least partly. A non-empty streamed text is
// required so an empty or aborted streamed block does not swallow it.
const streamed = streamedBlocks[streamPos];
if (
streamed &&
streamed.type === item.type &&
streamed.text.length > 0 &&
full.startsWith(streamed.text)
) {
streamPos++;
const remainder = full.slice(streamed.text.length);
if (remainder.length === 0) continue;
kept.push({ ...item, [item.type === "text" ? "text" : "thinking"]: remainder });
continue;
}
kept.push(item);
}
return kept;
}

View File

@@ -0,0 +1,96 @@
/**
* The configuration of a subagent's session, as its hooks report it. Both
* the v1 core (`ClaudeAcpAgent.subagentConfigurationHook`, for both forms of
* subagent sessions) and the v2 core (`v2/child-config.ts`)
* build a child's config options with these.
*/
import type { SessionConfigOption, SessionModeState } from "@agentclientprotocol/sdk/unstable";
import type { AgentInfo, HookInput } from "@anthropic-ai/claude-agent-sdk";
import { childModelOption, responseModelEntry, type SubagentModels } from "./native-subagents.js";
import { AGENT_CONFIG_ID, EFFORT_CONFIG_ID } from "./session-config-ids.js";
import { effortLevelName } from "./session-effort.js";
import { SessionModeManager } from "./session-mode.js";
/** What the root session knows that a child's options name. */
export type SubagentConfigurationContext = {
/** The root session's modes, for the name of the child's mode. */
availableModes: SessionModeState["availableModes"];
/** The root session's `model` entries, for the child's model. */
models: SubagentModels | undefined;
/** The custom agents of the session, for the description of the child's agent type. */
agents: readonly AgentInfo[];
};
/**
* The config options that a hook of a subagent (`agent_id`) reports of it:
* its permission mode, its model after a `PostModelSwitch`, its effort, and
* its agent type. Each option lists only the child's current value. Empty
* for a hook of the root session, and for one that reports none of these.
*/
export function subagentHookOptions(
input: HookInput,
context: SubagentConfigurationContext,
): SessionConfigOption[] {
if (!input.agent_id) return [];
const options: SessionConfigOption[] = [];
if (input.permission_mode) {
const mode = context.availableModes.find((mode) => mode.id === input.permission_mode);
options.push(
SessionModeManager.configOption({
currentModeId: input.permission_mode,
availableModes: [mode ?? { id: input.permission_mode, name: input.permission_mode }],
}),
);
}
const switchedModel =
input.hook_event_name === "PostModelSwitch"
? responseModelEntry(input.to_model, context.models)
: undefined;
// The entry of the root session's `model` option, as for a child response.
if (switchedModel) options.push(childModelOption(switchedModel));
if (input.effort?.level) {
options.push({
id: EFFORT_CONFIG_ID,
name: "Effort",
description: "Current reasoning effort",
category: "thought_level",
type: "select",
currentValue: input.effort.level,
options: [{ value: input.effort.level, name: effortLevelName(input.effort.level) }],
});
}
if (input.agent_type) {
const agent = context.agents.find((agent) => agent.name === input.agent_type);
options.push({
id: AGENT_CONFIG_ID,
name: "Agent",
description: "Subagent type",
type: "select",
currentValue: input.agent_type,
options: [
{
value: input.agent_type,
name: input.agent_type,
...(agent?.description ? { description: agent.description } : {}),
},
],
});
}
return options;
}
/**
* A child's configuration, as it goes to the client: every option it holds,
* the mode first, then the model, the effort and the model config, then the
* rest in the order they came.
*/
export function orderedSubagentConfiguration(
options: Iterable<SessionConfigOption>,
): SessionConfigOption[] {
const priority = ["mode", "model", "thought_level", "model_config"];
const rank = (option: SessionConfigOption) => {
const index = priority.indexOf(option.category ?? "");
return index < 0 ? priority.length : index;
};
return [...options].sort((left, right) => rank(left) - rank(right));
}

View File

@@ -1,12 +1,40 @@
import { getSessionInfo } from "@anthropic-ai/claude-agent-sdk";
import {
getSessionInfo,
getSubagentMessages,
type SessionMessage,
} from "@anthropic-ai/claude-agent-sdk";
import { createReadStream } from "node:fs";
import { readdir, readFile } from "node:fs/promises";
import path from "node:path";
import readline from "node:readline";
import { isNativeSubagentControlTool, type HistoricalSubagent } from "./native-subagents.js";
import { findTranscript } from "./resumed-session.js";
/** What the metadata file of a subagent says about it. */
export interface SubagentRecord {
/** The SDK agent id, the name of its transcript. */
agentId: string;
/** The `name` that the Agent call gave it. */
name?: string;
/** Its role, the `subagent_type` of the Agent call. */
agentType?: string;
/** The short `description` of the Agent call. */
description?: string;
/** Whether the Agent call ran it in the background. */
background: boolean;
/** Whether the user stopped it. */
stoppedByUser: boolean;
/**
* Its working directory, when Claude Code placed it outside its parent's:
* in a worktree of its own or of an ancestor, or in a `cwd` of its own.
*/
cwd?: string;
}
/** Where the subagent transcripts of a session are. */
export interface SubagentHistory {
/** The subagent id of each Agent or Task tool use that launched a subagent. */
ids: Map<string, string>;
/** The subagent of each Agent or Task tool use that launched one. */
agents: Map<string, SubagentRecord>;
/**
* The project directory of the session, as the SDK takes it in `dir`. With
* it, the SDK reads a subagent transcript without a search of every project.
@@ -20,35 +48,320 @@ export interface SubagentHistory {
* Claude keeps the history of a subagent in its own transcript,
* `<session>/subagents/agent-<id>.jsonl`, next to the session transcript.
* The `agent-<id>.meta.json` file beside it names the launching tool use.
* The ids are empty for a session without a local transcript.
* For a child that Claude Code placed elsewhere, it has `worktreePath`,
* `inheritedWorktreePath` or `cwd`; then the child's working directory is the
* `cwd` of its transcript's records. The ids are empty for a session without a local transcript.
*/
export async function subagentHistory(sessionId: string): Promise<SubagentHistory> {
const ids = new Map<string, string>();
const agents = new Map<string, SubagentRecord>();
const transcript = await findTranscript(sessionId);
if (!transcript) return { ids };
const directory = path.join(path.dirname(transcript), sessionId, "subagents");
if (!transcript) return { agents };
const directory = subagentsDirectory(transcript, sessionId);
let names: string[];
try {
names = await readdir(directory);
} catch {
return { ids };
return { agents };
}
await Promise.all(
names.map(async (name) => {
const match = /^agent-(.+)\.meta\.json$/.exec(name);
if (!match) return;
try {
const meta: unknown = JSON.parse(await readFile(path.join(directory, name), "utf8"));
const toolUseId = (meta as { toolUseId?: unknown } | null)?.toolUseId;
if (typeof toolUseId === "string") ids.set(toolUseId, match[1]!);
const meta = JSON.parse(await readFile(path.join(directory, name), "utf8")) as Record<
string,
unknown
> | null;
if (typeof meta?.toolUseId !== "string") return;
const text = (value: unknown) => (typeof value === "string" ? value : undefined);
const placement = [meta.cwd, meta.worktreePath, meta.inheritedWorktreePath]
.map(text)
.find((value) => value && path.isAbsolute(value));
const cwd = placement
? ((await recordedCwd(path.join(directory, `agent-${match[1]!}.jsonl`))) ?? placement)
: undefined;
agents.set(meta.toolUseId, {
agentId: match[1]!,
name: text(meta.name),
agentType: text(meta.agentType),
description: text(meta.description),
background: meta.requestShape === "background",
stoppedByUser: meta.stoppedByUser === true,
...(cwd ? { cwd } : {}),
});
} catch {
// A damaged meta file leaves its subagent without history.
}
}),
);
if (ids.size === 0) return { ids };
if (agents.size === 0) return { agents };
// The folder name encodes the project directory, and the encoding loses
// characters. The session info reads it back from the transcript.
const dir = (await getSessionInfo(sessionId).catch(() => undefined))?.cwd;
return dir ? { ids, dir } : { ids };
return dir ? { agents, dir } : { agents };
}
/**
* The `cwd` of the first record of the transcript `file` that has one, or
* undefined when the file cannot be read. Claude Code writes the working
* directory of the session or subagent on each record.
*/
async function recordedCwd(file: string): Promise<string | undefined> {
const stream = createReadStream(file, { encoding: "utf8" });
try {
for await (const line of readline.createInterface({ input: stream, crlfDelay: Infinity })) {
if (!line.includes('"cwd"')) continue;
try {
const cwd = (JSON.parse(line) as { cwd?: unknown } | null)?.cwd;
if (typeof cwd === "string" && path.isAbsolute(cwd)) return cwd;
} catch {
// A damaged record; a later one can have the `cwd`.
}
}
} catch {
// An unreadable transcript; the caller falls back to the metadata's path.
} finally {
stream.destroy();
}
return undefined;
}
/** The folder of the subagent transcripts, next to the session transcript `transcript`. */
function subagentsDirectory(transcript: string, sessionId: string): string {
return path.join(path.dirname(transcript), sessionId, "subagents");
}
/**
* The agent that the Agent or Task call `launchId` launched, from the subagent
* history of the session `sessionId`.
*/
export async function subagentLaunchedBy(
sessionId: string,
launchId: string,
): Promise<string | undefined> {
return (await subagentHistory(sessionId)).agents.get(launchId)?.agentId;
}
/**
* The agent `agentId` and its ancestors, root first, from the subagent history
* of the session `sessionId`. The runtime restores an agent from it when Claude
* Code resumes one that ran before the session was loaded or resumed.
*
* The metadata file of an agent names the call that launched it, but not the
* agent that made the call. So the parent of a launch is the session whose
* transcript has the call, as in the replay: the session's own transcript, or
* an agent's. The list is empty when the agent or one of its ancestors has no
* metadata, or no transcript has its launch.
*/
export async function subagentLineage(
sessionId: string,
agentId: string,
logger: { error(...args: unknown[]): void },
): Promise<HistoricalSubagent[]> {
const history = await subagentHistory(sessionId);
const launchOf = new Map<string, { launchId: string; record: SubagentRecord }>();
for (const [launchId, record] of history.agents) {
launchOf.set(record.agentId, { launchId, record });
}
if (!launchOf.has(agentId)) return [];
// The SDK's readers return only the active chain, which starts after the
// last compaction, so the launch calls come from the transcript files.
const transcript = await findTranscript(sessionId);
if (!transcript) return [];
const rootLaunches = await readLaunchIdsFromTranscript(transcript, null, logger);
const agentLaunches = new Map<string, Set<string>>();
/** The agent whose transcript has the launch `launchId`, `null` for the root's. */
const launcherOf = async (launchId: string): Promise<string | null | undefined> => {
if (rootLaunches.has(launchId)) return null;
for (const candidate of launchOf.keys()) {
let launches = agentLaunches.get(candidate);
if (!launches) {
launches = await readLaunchIdsFromTranscript(
path.join(subagentsDirectory(transcript, sessionId), `agent-${candidate}.jsonl`),
candidate,
logger,
);
agentLaunches.set(candidate, launches);
}
if (launches.has(launchId)) return candidate;
}
return undefined;
};
const lineage: HistoricalSubagent[] = [];
let current: string | null = agentId;
while (current !== null) {
const launch = launchOf.get(current);
// A cycle, or an ancestor without metadata.
if (!launch || lineage.some((entry) => entry.agentId === current)) return [];
const launcher = await launcherOf(launch.launchId);
if (launcher === undefined) return [];
lineage.unshift({
agentId: current,
launchId: launch.launchId,
...(launcher !== null ? { parentAgentId: launcher } : {}),
...(launch.record.cwd ? { cwd: launch.record.cwd } : {}),
...(launch.record.name ? { name: launch.record.name } : {}),
...(launch.record.agentType ? { agentType: launch.record.agentType } : {}),
...(launch.record.description ? { description: launch.record.description } : {}),
});
current = launcher;
}
return lineage;
}
/** The ids of the Agent and Task calls that launched subagents in `content`. */
export function subagentLaunchIds(content: unknown): string[] {
if (!Array.isArray(content)) return [];
return content.flatMap((block) =>
typeof block === "object" &&
block !== null &&
block.type === "tool_use" &&
typeof block.id === "string" &&
isNativeSubagentControlTool(block.name)
? [block.id as string]
: [],
);
}
/**
* The ids of the launch calls in the transcript `file`, every record of it,
* including those before a compaction. `owner` is the agent the transcript
* belongs to, null for the session's. The session's transcript can hold
* sidechain records, which are an agent's, not the root's; an agent's
* transcript keeps only the records of that agent. A file that cannot be read
* has no launches, so the agent is not placed under the root.
*/
async function readLaunchIdsFromTranscript(
file: string,
owner: string | null,
logger: { error(...args: unknown[]): void },
): Promise<Set<string>> {
const launches = new Set<string>();
const stream = createReadStream(file, { encoding: "utf8" });
try {
for await (const line of readline.createInterface({ input: stream, crlfDelay: Infinity })) {
const forkDirective = owner !== null && line.includes(FORK_BOILERPLATE_OPEN);
if (!line.includes('"tool_use"') && !forkDirective) continue;
let record: {
type?: unknown;
isSidechain?: unknown;
agentId?: unknown;
message?: { content?: unknown };
} | null;
try {
record = JSON.parse(line);
} catch {
continue;
}
// Every record of a fork child's transcript before its directive message
// is inherited context: a copy of the launching message, whose launch
// calls are the parent's, not the fork's.
if (forkDirective && record !== null && isForkDirectiveMessage(record)) {
launches.clear();
continue;
}
if (record?.type !== "assistant") continue;
if (
owner === null
? record.isSidechain === true
: record.agentId !== undefined && record.agentId !== owner
) {
continue;
}
for (const id of subagentLaunchIds(record.message?.content)) launches.add(id);
}
} catch (error) {
logger.error(`Failed to read the transcript ${file}:`, error);
return new Set();
} finally {
stream.destroy();
}
return launches;
}
/**
* The transcript of the subagent `agentId` of a session, or an empty list
* when it cannot be read. With the project directory, the SDK skips its
* search of every project; a session opened from another directory falls
* back to that search.
*/
export async function readSubagentMessages(
sessionId: string,
agentId: string,
history: SubagentHistory,
logger: { error(...args: unknown[]): void },
): Promise<SessionMessage[]> {
try {
if (history.dir) {
const messages = await getSubagentMessages(sessionId, agentId, { dir: history.dir });
if (messages.length > 0) return stripForkPrefix(messages);
}
return stripForkPrefix(await getSubagentMessages(sessionId, agentId));
} catch (error) {
logger.error(`Failed to read the history of subagent ${agentId}:`, error);
return [];
}
}
/** How Claude Code opens the directive message of a `subagent_type: "fork"` child. */
const FORK_BOILERPLATE_OPEN = "<fork-boilerplate>";
/** What follows the boilerplate, right before the prompt of the Agent call. */
const FORK_DIRECTIVE_MARKER = "</fork-boilerplate>\nYour directive: ";
/** Whether `block` is the text block of a fork child's directive message. */
function isForkDirectiveText(block: unknown): block is { type: "text"; text: string } {
return (
typeof block === "object" &&
block !== null &&
(block as { type?: unknown }).type === "text" &&
typeof (block as { text?: unknown }).text === "string" &&
(block as { text: string }).text.startsWith(FORK_BOILERPLATE_OPEN)
);
}
/** Whether the transcript record `message` is the directive message of a fork child. */
function isForkDirectiveMessage(message: { type?: unknown; message?: unknown }): boolean {
if (message.type !== "user") return false;
const content = (message.message as { content?: unknown } | null | undefined)?.content;
return Array.isArray(content) && content.some(isForkDirectiveText);
}
/**
* The transcript of a `subagent_type: "fork"` child without the context it
* inherited. A fork transcript starts with a copy of the parent's launching
* assistant message (its text, thinking and every tool_use, including the
* Agent call itself), then a user message with a placeholder tool_result for
* each of those tool_uses and the text
* `<fork-boilerplate>…</fork-boilerplate>\nYour directive: <prompt>`. Since
* Claude Code 2.1.307 the fork's own Agent call gets "Fork started — that fork
* is you." and the other tool_uses get "This is the parent's call, not
* yours. …"; before that, every one got "Fork started — processing in
* background". Only the directive text is matched here. A fork
* launched from inside a subagent also gets a copy of that subagent's history
* before them. None of it is the child's own work: replayed as is, the
* parent's text and tool calls would show again in the child.
*
* This drops every record before the directive message, and reduces that
* message to the prompt. Other transcripts are returned unchanged.
*/
export function stripForkPrefix(messages: SessionMessage[]): SessionMessage[] {
const index = messages.findIndex(isForkDirectiveMessage);
if (index === -1) return messages;
const directive = messages[index];
const content = (directive.message as { content: unknown[] }).content;
const prompt = content.flatMap((block) => {
if (!isForkDirectiveText(block)) return [];
const start = block.text.indexOf(FORK_DIRECTIVE_MARKER);
return start === -1
? []
: [{ type: "text", text: block.text.slice(start + FORK_DIRECTIVE_MARKER.length) }];
});
const rest = messages.slice(index + 1);
if (prompt.length === 0) return rest;
return [
{ ...directive, message: { ...(directive.message as object), content: prompt } },
...rest,
];
}

Some files were not shown because too many files have changed in this diff Show More