Compare commits

...

89 Commits

Author SHA1 Message Date
Ben Brandt
5ccf9432be chore: reject imports of the v2 agent from src/v1/ too
Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-10 00:40:44 +01:00
Ben Brandt
6950f3f3fb chore: lint the import boundaries between v1, v2 and shared modules
ESLint now rejects imports from src/v2/ into src/v1/ or src/acp-agent.ts,
and from shared modules into src/v1/, src/v2/ or src/acp-agent.ts. This
covers static imports, re-exports (type-only ones too), import() calls and
import("...") types. Entry points and tests are exempt. The tree already
passes, so there is no allow-list.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-10 00:37:00 +01:00
Ben Brandt
fcf2a636cb refactor: move the v1 agent into src/v1
Move the modules only the v1 agent uses into src/v1/ (acp-agent.ts,
background-work.ts, goal-extension.ts, turn-events.ts,
tool-calls/changed-meta-filter.ts and session-index/ except
explicit-title.ts, which session-titles.ts shares with v2).
src/acp-agent.ts re-exports src/v1/acp-agent.ts so dist/acp-agent.js
keeps its exports. No behavior change.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-10 00:30:35 +01:00
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
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
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
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
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
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
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
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
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
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
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
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
153 changed files with 33176 additions and 18096 deletions

View File

@@ -1,7 +1,12 @@
# Agent guide
An ACP-compatible coding agent powered by the Claude Agent SDK. Source in `src/`,
TypeScript, ESM, Node >= 22.
TypeScript, ESM, Node >= 22. `src/v1/` holds the v1 agent (`ClaudeAcpAgent` in
`src/v1/acp-agent.ts`), `src/v2/` the v2 agent, and the rest of `src/` the modules
both share. `src/acp-agent.ts` only re-exports `src/v1/acp-agent.ts` for the
published `dist/acp-agent.js` path. ESLint rejects imports between `src/v1/` and
`src/v2/`, and from shared modules into either agent. The entry points (`index.ts`, `lib.ts`,
`serve.ts`, `binary-entry.ts`, `acp-agent.ts`) are exempt.
## Commands
@@ -40,9 +45,9 @@ opening a PR.
- 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.
- Don't add new features or components to `src/v1/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 `src/v1/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
@@ -58,11 +63,14 @@ opening a PR.
- 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. `src/v2/` converts v2 requests to v1
and reuses the v1 logic; keep v2-only differences in the conversion.
- A session runs one turn at a time. "Claude is working" means `startTurn`'s
check (an unsettled turn in `turnQueue`, or `foregroundWork` for the session);
don't write a narrower one. Anything that sends `session/update` must go
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.

View File

@@ -142,7 +142,11 @@ On v2 the adapter serves the session baseline (`session/new`, `session/list`, `s
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 and the AIR extensions are v1 only for now.
[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

@@ -145,6 +145,15 @@ The agent list does not depend on the capabilities that AIR declares, except `se
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 |
@@ -1062,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
{
@@ -1638,7 +1648,8 @@ 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. ACP v2 keeps the hold, and the agent does not advertise the capability to ACP v2.
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.

View File

@@ -4,6 +4,23 @@ import tsparser from "@typescript-eslint/parser";
import prettierConfig from "eslint-config-prettier";
import globals from "globals";
/**
* Rules that reject a module specifier matching `regex` in static imports and
* re-exports (type-only ones too), `import()` calls and `import("...")` types.
*/
function protocolBoundary(regex, message) {
// esquery regex literals end at the first `/`, so spell it `\x2F`.
const literal = `/${regex.replaceAll("/", "\\x2F")}/`;
return {
"no-restricted-imports": ["error", { patterns: [{ regex, message }] }],
"no-restricted-syntax": [
"error",
{ selector: `ImportExpression[source.value=${literal}]`, message },
{ selector: `TSImportType[argument.literal.value=${literal}]`, message },
],
};
}
export default [
{
ignores: ["node_modules/", "dist/", "coverage/", "*.min.js", "*.config.js", ".github/"],
@@ -44,5 +61,39 @@ export default [
curly: ["error", "all"],
},
},
// `src/v2/` must not import the v1 agent (`src/v1/`, or `src/acp-agent.ts`, which re-exports it).
{
files: ["src/v2/**/*.ts"],
rules: protocolBoundary(
"^(\\.\\./)+(v1/|acp-agent\\.js$)",
"src/v2/ must not import the v1 agent. Move what both versions need into a shared module in src/.",
),
},
// `src/v1/` must not import the v2 agent.
{
files: ["src/v1/**/*.ts"],
rules: protocolBoundary(
"^(\\.\\./)+v2/",
"src/v1/ must not import the v2 agent. Move what both versions need into a shared module in src/.",
),
},
// Shared modules must not import either agent. The entry points wire the agents up.
{
files: ["src/**/*.ts"],
ignores: [
"src/v1/**",
"src/v2/**",
"src/tests/**",
"src/index.ts",
"src/lib.ts",
"src/serve.ts",
"src/binary-entry.ts",
"src/acp-agent.ts",
],
rules: protocolBoundary(
"^(\\./|(\\.\\./)+)(v1/|v2/|acp-agent\\.js$)",
"Shared modules must not import src/v1/, src/v2/ or src/acp-agent.ts. Move the code into the shared module, or pass it in from the agent.",
),
},
prettierConfig,
];

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` (`v1/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

@@ -17,8 +17,7 @@ export { AIR_NATIVE_SUBAGENT_SESSIONS_CAPABILITY } from "./air-extension.js";
* - `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. (An ACP v2 client gets it without declaring anything, since subagents
* are part of v2's baseline.)
* 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`).

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

@@ -1,755 +0,0 @@
/**
* The background work of a session: the work that Claude Code keeps running
* independently of the session's foreground work, which ACP v2 lists on every
* `state_update` of the session as `background` (the v2 prompt lifecycle,
* "Background work").
*
* The lists are built from Claude Code's live background task set
* (`background_tasks_changed`). Claude Code marks `ambient` its own
* housekeeping and every live-update watcher; those are left out, except a
* task that a tool call started, such as a `Monitor` call's. The root session and each
* announced subagent session (the subagents RFD form) has its own list. Each
* task is listed once, in the list of the session that runs it:
*
* - A command that went to the background: its display terminal, from the
* Bash tool result until its task ends (`Session.backgroundTerminals`).
* - A detached subagent: its child session, in its parent's list, while it
* works, and while it is idle with background work of its own.
* - A task that a tool call started, such as a `Monitor`, a `Workflow` or a
* backgrounded MCP call: that tool call, kept `in_progress` (or `paused`)
* from its tool result until its task ends (`Session.backgroundToolCalls`).
* - Any other task, once its `task_started` came and the tool call that
* started it, if any, returned: a tool call that the adapter starts for
* it (see `SessionBackgroundWork.adoptTasks`). For example, a task that a
* hook started, or one whose tool call ended before the task joined the
* level.
*
* Only a session whose interrupt spares background tasks reports its lists
* (`Session.reportsBackground`): `session/cancel` must leave listed work
* running.
*/
import { RequestError, type ToolCallUpdate } from "@agentclientprotocol/sdk/unstable";
import type { AcpSessionNotification } from "./acp-subagents.js";
import type { NativeSubagent } from "./native-subagents.js";
import { type AcpToolCallRenderer, V2_CANCELLED, V2_PAUSED } from "./tool-calls/renderer.js";
import { type BackgroundOutput, BackgroundOutputTail } from "./background-output.js";
import type { TaskProgressFields } from "./async-tasks.js";
import { type WorkflowSnapshot, workflowSnapshotText } from "./workflow-progress.js";
import { textContent } from "./tool-calls/content.js";
/** An item of a `background` list. */
export type BackgroundItem =
| { type: "terminal"; terminalId: string }
| { type: "tool_call"; toolCallId: string }
| { type: "subagent"; sessionId: string };
/** The display terminal of a command that went to the background (see `Session.backgroundTerminals`). */
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 file that Claude Code writes the command's output to, from the Bash result. */
outputFilePath?: string;
/** Reads that file into the terminal while the command runs. */
output?: BackgroundOutputTail;
}
/** A tool call whose task runs on in the background (see `Session.backgroundToolCalls`). */
export interface BackgroundToolCall {
toolCallId: string;
/**
* The tool use of the call, which Claude Code no longer caches after its
* result. Undefined for a call that the adapter started for a task (see
* `SessionBackgroundWork.adoptTasks`), which has `title` instead.
*/
toolUse?: { name: string; input: unknown };
/** The title of a call that the adapter started for a task: the task's description. */
title?: string;
/** The subagent tool call that made the call, if any. */
parentToolUseId?: string;
paused?: boolean;
}
/** How a background task ended, as Claude Code reports it. Undefined when it did not say. */
export type BackgroundTaskEnd = "completed" | "failed" | "stopped" | undefined;
/** The live background task set of `background_tasks_changed`, by task id. */
export type BackgroundLevel = Map<
string,
{
ambient: boolean;
taskType: string;
description: string;
parentTaskId?: string;
progress?: WorkflowCardProgress;
}
>;
/** What a workflow card shows: the task's progress fields and its latest phase and agent list. */
export type WorkflowCardProgress = TaskProgressFields & { workflow?: WorkflowSnapshot };
/** The work state of a session, as its `state_update` names it. */
export type WorkStateName = "idle" | "running" | "requires_action" | "unknown";
/** A new list of the session `sessionId`, with what its repeated state needs. */
export interface BackgroundReport {
sessionId: string;
items: BackgroundItem[];
/** The session's work state now, or undefined when it has none yet. */
state: WorkStateName | undefined;
/** The parent of a subagent session, which mirrors the child's state. */
parentSessionId?: string;
}
/** Where the agent reports the lists (see the agent's `background` option). */
export interface BackgroundSink {
/** The list of a session changed, or is reported for the first time. */
changed(report: BackgroundReport): void;
/** The session ended: its list is no longer reported. */
forget(sessionId: string): void;
}
/**
* The state of a session that other modules own, and that the background
* work reads: Claude Code's tasks as the consumer tracks them, the subagent
* sessions, and the query that stops a task.
*/
export interface BackgroundOwner {
liveBackgroundTasks: Map<string, { parentToolUseId?: string; isSubagent: boolean }>;
nativeSubagentsByTaskId?: Map<string, NativeSubagent>;
query: { stopTask(taskId: string): Promise<void> };
}
/** A task of a `background_tasks_changed` payload. */
export type LevelTask = {
task_id: string;
task_type: string;
description: string;
parent_task_id?: string;
ambient?: boolean;
};
/** The most returned tool calls that {@link SessionBackgroundWork} remembers. */
const MAX_RETURNED_CALLS = 1024;
/** The tools whose structured result names the task that the call started. */
const TASK_TOOLS = new Set(["Monitor", "Workflow"]);
/**
* The background work of one root session, and of its subagent sessions: the
* terminals and tool calls of the tasks that run in the background, and what
* the client is told of them.
*
* Terminals are kept for every client that renders them, as before the
* background lists. The rest is kept only when `reported`.
*/
export class SessionBackgroundWork {
/** Claude Code's live background task set, from its last `background_tasks_changed`. */
private level: BackgroundLevel = new Map();
/** The terminals of the commands that went to the background, by task id. */
private readonly terminals = new Map<string, BackgroundTerminal>();
/** The tool calls whose tasks went on in the background, by task id. */
private readonly toolCalls = new Map<string, BackgroundToolCall>();
/**
* The tool calls that returned, oldest first. A task that a call started
* is part of that call until it returns, and the foreground work waits on
* it. The task's `task_started` can come after the call returned. Bounded
* by {@link MAX_RETURNED_CALLS}: a task joins the level around the time its
* call returns.
*/
private readonly returned = new Set<string>();
/**
* `reported`: whether the client gets the lists. Only when Claude Code's
* interrupt spares background tasks, as `perTaskStopAffordance` makes it.
*/
constructor(
private readonly sessionId: string,
readonly reported: boolean,
) {}
/**
* 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);
}
/**
* Reads what a tool result message tells of the background tasks:
*
* - The returned calls no longer hold the tasks that they started.
* - Each call whose task goes on in the background is registered: a
* `Monitor` or `Workflow` call, whose structured result names its task,
* or a call that a live task names as its tool use, such as a
* backgrounded MCP call. The task must be in the level: one that ended
* already leaves its call as it is. Claude Code marks every live-update
* watcher `ambient`, including the task of a `Monitor` call; a call
* started it, so it is work the client can see and stop, and is
* registered all the same. Ambient tasks without a call of their own,
* such as watchers Claude Code starts by itself, are never registered.
*
* Returns the ids of the registered calls, whose result then reports
* `in_progress` (see `withRunningToolCall`).
*/
toolResults(
owner: BackgroundOwner,
content: unknown,
toolUseResult: unknown,
toolUses: Record<string, { name: string; input: unknown }>,
parentToolUseId: string | null,
): Set<string> {
const registered = new Set<string>();
if (!this.reported || !Array.isArray(content)) return registered;
const results = content.filter(
(block): block is { type: "tool_result"; tool_use_id: string; is_error?: boolean } =>
typeof block === "object" &&
block !== null &&
block.type === "tool_result" &&
typeof block.tool_use_id === "string",
);
for (const { tool_use_id: toolCallId } of results) {
this.returned.add(toolCallId);
if (this.returned.size > MAX_RETURNED_CALLS) {
this.returned.delete(this.returned.values().next().value!);
}
}
const succeeded = results.filter((block) => block.is_error !== true);
const structuredTaskId =
succeeded.length === 1 &&
typeof toolUseResult === "object" &&
toolUseResult !== null &&
typeof (toolUseResult as { taskId?: unknown }).taskId === "string"
? (toolUseResult as { taskId: string }).taskId
: undefined;
for (const { tool_use_id: toolCallId } of succeeded) {
const toolUse = toolUses[toolCallId];
if (!toolUse) continue;
const taskId =
(TASK_TOOLS.has(toolUse.name) ? structuredTaskId : undefined) ??
[...owner.liveBackgroundTasks].find(
([, task]) => task.parentToolUseId === toolCallId && !task.isSubagent,
)?.[0];
const task = taskId === undefined ? undefined : this.level.get(taskId);
if (!task || task.taskType === "local_bash" || task.taskType === "local_agent") {
continue;
}
this.toolCalls.set(taskId!, {
toolCallId,
toolUse,
...(parentToolUseId ? { parentToolUseId } : {}),
});
registered.add(toolCallId);
}
return registered;
}
/**
* Starts reading the output of the background command `taskId` into its
* terminal, when the client gets the lists and the Bash result named the
* command's output file. `send` sends an update of the terminal's tool
* call, routed as the call's other updates. Called once the Bash result's
* own updates are sent: the first output replaces the placeholder that they
* gave the terminal.
*/
followOutput(taskId: string, send: Send): void {
const terminal = this.terminals.get(taskId);
if (!this.reported || !terminal?.outputFilePath || terminal.output) return;
terminal.output = new BackgroundOutputTail(terminal.outputFilePath, (output) =>
send(terminalOutputUpdate(this.sessionId, terminal, output)),
);
}
/**
* The task `taskId` ended. Reads the rest of its command's output, then
* exits its terminal, and gives a tool call whose task it was its final
* status: `cancelled` for a task that Claude Code stopped, and `completed`
* for one that ended without saying how, such as one that the level
* dropped: it no longer runs, and reported no failure. Claude Code reports
* no exit code for a command, and `completed` does not mean 0: Claude Code
* accepts some non-zero codes as a success, such as grep's 1.
*/
async taskEnded(taskId: string, end: BackgroundTaskEnd, send: Send): Promise<void> {
const terminal = this.terminals.get(taskId);
if (terminal) {
await terminal.output?.finish();
this.terminals.delete(taskId);
await send(terminalExit(this.sessionId, terminal));
}
const toolCall = this.toolCalls.get(taskId);
if (toolCall) {
this.toolCalls.delete(taskId);
await send(
toolCallUpdate(this.sessionId, toolCall, {
status: end === "stopped" ? V2_CANCELLED : end === "failed" ? "failed" : "completed",
}),
);
}
}
/**
* The update that reports the background tool call of the task `taskId` as
* `paused`, or as `in_progress` again, when `paused` changes it. Claude Code
* pauses a workflow this way.
*/
taskPaused(taskId: string, paused: boolean): AcpSessionNotification | undefined {
const toolCall = this.toolCalls.get(taskId);
if (!toolCall || (toolCall.paused ?? false) === paused) return undefined;
toolCall.paused = paused;
return toolCallUpdate(this.sessionId, toolCall, { status: paused ? V2_PAUSED : "in_progress" });
}
/**
* Updates an existing workflow card without changing its work state. A
* report without `workflow` keeps the last phase and agent list.
*/
taskProgress(taskId: string, progress: WorkflowCardProgress): AcpSessionNotification | undefined {
const task = this.level.get(taskId);
if (!task || task.taskType !== "local_workflow") return undefined;
const previous = workflowProgress(task.progress);
task.progress = { ...task.progress, ...progress };
const current = workflowProgress(task.progress);
if (!current || JSON.stringify(previous) === JSON.stringify(current)) return undefined;
const toolCall = this.toolCalls.get(taskId);
return toolCall ? toolCallUpdate(this.sessionId, toolCall, current) : undefined;
}
/**
* The latest progress of the workflow calls in `toolCallIds`.
*
* A task can report progress before its launch tool returns. Those fields
* wait in the live task level and are sent after the launch result.
*/
toolCallProgress(toolCallIds: ReadonlySet<string>): AcpSessionNotification[] {
return [...this.toolCalls].flatMap(([taskId, toolCall]) => {
if (!toolCallIds.has(toolCall.toolCallId)) return [];
const progress = workflowProgress(this.level.get(taskId)?.progress);
return progress ? [toolCallUpdate(this.sessionId, toolCall, progress)] : [];
});
}
/**
* Takes Claude Code's new live background task set. The terminals and tool
* calls whose tasks it no longer holds end first, before a list drops them,
* as v2 requires. A terminal or tool call is registered at its tool result,
* after its task joined the level, so a later level without the task means
* that it ended. Claude Code usually sends a task's end before that level,
* which ends them first, but the SDK does not promise that order.
*/
async levelChanged(tasks: LevelTask[], send: Send): Promise<void> {
if (!this.reported) return;
const level: BackgroundLevel = new Map(
tasks.map((task) => [
task.task_id,
{
ambient: task.ambient === true,
taskType: task.task_type,
description: task.description,
...(task.parent_task_id !== undefined ? { parentTaskId: task.parent_task_id } : {}),
progress: this.level.get(task.task_id)?.progress,
},
]),
);
for (const taskId of [...this.terminals.keys(), ...this.toolCalls.keys()]) {
if (!level.has(taskId)) await this.taskEnded(taskId, undefined, send);
}
this.level = level;
}
/**
* The query closed: Claude Code's background tasks end with its process.
* Stops reading output, and returns the updates that exit the root
* session's terminals and cancel its tool calls. The level empties, so the
* next lists are empty. A subagent's command or tool call is left as it
* was: it is on the child's stream, which the closed query's routing owned.
*/
queryClosed(): AcpSessionNotification[] {
for (const terminal of this.terminals.values()) terminal.output?.stop();
if (!this.reported) return [];
const updates: AcpSessionNotification[] = [];
for (const [taskId, terminal] of this.terminals) {
if (terminal.parentToolUseId) continue;
this.terminals.delete(taskId);
updates.push(terminalExit(this.sessionId, terminal));
}
for (const [taskId, toolCall] of this.toolCalls) {
if (toolCall.parentToolUseId) continue;
this.toolCalls.delete(taskId);
updates.push(toolCallUpdate(this.sessionId, toolCall, { status: V2_CANCELLED }));
}
this.level = new Map();
this.returned.clear();
return updates;
}
/**
* Starts a tool call for each live task that no terminal or tool call
* stands for yet, and returns the `tool_call`s to send: v2 lists background work only by an entity
* the client knows. Such a task is one that no tool call started, such as
* one a hook started, or one whose tool call returned, with its final
* status, before the task started. The call has the task's description as
* its title, is `in_progress`, and ends with the task (see `taskEnded`). A
* task that an announced subagent session runs gets its call on that
* session's stream. Ambient tasks are housekeeping and get no call.
*
* The agent sends them before each list it publishes, so the call comes
* before the first list that names it, as v2 requires.
*/
adoptTasks(owner: BackgroundOwner): AcpSessionNotification[] {
if (!this.reported) return [];
const started: AcpSessionNotification[] = [];
const children = new Map(
[...(owner.nativeSubagentsByTaskId ?? [])].filter(([, child]) => child.announced),
);
for (const [taskId, task] of this.level) {
if (task.ambient || this.toolCalls.has(taskId) || this.terminals.has(taskId)) continue;
// A command without a terminal yet is one that foreground work waits on.
// A subagent is listed as its session.
if (task.taskType === "local_bash" || task.taskType === "local_agent") continue;
// A task joins the level before its `task_started`, and before the tool
// call that starts it returns. Until both, the task is unknown or part
// of that tool call.
const live = owner.liveBackgroundTasks.get(taskId);
if (!live || (live.parentToolUseId && !this.returned.has(live.parentToolUseId))) continue;
const parent = task.parentTaskId ? children.get(task.parentTaskId) : undefined;
const toolCall: BackgroundToolCall = {
toolCallId: adoptedToolCallId(taskId),
title: task.description,
...(parent?.parentToolUseId ? { parentToolUseId: parent.parentToolUseId } : {}),
};
this.toolCalls.set(taskId, toolCall);
started.push(adoptedToolCall(this.sessionId, toolCall));
}
return started;
}
/**
* The `background` list of the root session and of each of its announced
* subagent sessions, with what their repeated states need, deeper sessions
* first and the root last: a child's own list goes out before its parent's
* list drops it. Each list is empty while the level has not been reported:
* Claude Code sends none before the first background task starts.
*/
reports(owner: BackgroundOwner, rootState: WorkStateName): BackgroundReport[] {
const children = [...(owner.nativeSubagentsByTaskId ?? [])].filter(
([, child]) => child.announced,
);
const childByToolUse = new Map(
children.flatMap(([, child]) =>
child.parentToolUseId ? [[child.parentToolUseId, child] as const] : [],
),
);
const lists = new Map<string, BackgroundItem[]>([
[this.sessionId, []],
...children.map(([, child]): [string, BackgroundItem[]] => [child.sessionId, []]),
]);
// A subagent's command or tool call runs on the child's stream.
const ownerOf = (parentToolUseId: string | undefined) =>
parentToolUseId ? childByToolUse.get(parentToolUseId)?.sessionId : this.sessionId;
for (const [taskId, task] of this.level) {
// A registered call's task is listed even when ambient, as a `Monitor`
// call's is (see `toolResults`).
const toolCall = this.toolCalls.get(taskId);
if (toolCall) {
const session = ownerOf(toolCall.parentToolUseId);
if (session)
lists.get(session)!.push({ type: "tool_call", toolCallId: toolCall.toolCallId });
continue;
}
if (task.ambient) continue;
const terminal = this.terminals.get(taskId);
if (terminal) {
const session = ownerOf(terminal.parentToolUseId);
if (session)
lists.get(session)!.push({ type: "terminal", terminalId: terminal.toolCallId });
}
// Any other task is listed by the tool call that `adoptTasks` starts
// for it, or is not background work. A subagent is listed below, as
// its session.
}
// A child is listed in its parent's list while it works in the background,
// and while it is idle with background work of its own. Deeper children
// first, so a child's list is whole before its parent's reads it.
const sessions = new Map(children.map(([, child]) => [child.sessionId, child]));
const depth = (child: NativeSubagent): number => {
const parent = sessions.get(child.parentSessionId);
return parent ? depth(parent) + 1 : 0;
};
const ordered = children.sort(([, a], [, b]) => depth(b) - depth(a));
for (const [taskId, child] of ordered) {
const task = this.level.get(taskId);
const working =
child.workState !== undefined &&
child.workState !== "idle" &&
task !== undefined &&
!task.ambient;
if (working || lists.get(child.sessionId)!.length > 0) {
lists.get(child.parentSessionId)?.push({ type: "subagent", sessionId: child.sessionId });
}
}
return [
...ordered.map(([, child]) => ({
sessionId: child.sessionId,
items: lists.get(child.sessionId)!,
state: workStateName(child.workState),
parentSessionId: child.parentSessionId,
})),
{ sessionId: this.sessionId, items: lists.get(this.sessionId)!, state: rootState },
];
}
/**
* The current state of each background tool call in `toolCallIds`: the
* call as `renderer` shows its tool use, with its status now (see
* `ClaudeAcpAgent.publishBackgroundToolCalls`).
*/
toolCallStates(
toolCallIds: string[],
renderer: AcpToolCallRenderer,
cwd: string,
): AcpSessionNotification[] {
return [...this.toolCalls]
.filter(([, toolCall]) => toolCallIds.includes(toolCall.toolCallId))
.map(([taskId, toolCall]) => {
const update = toolCall.toolUse
? renderer.toolCall({ id: toolCall.toolCallId, ...toolCall.toolUse }, { cwd })
: adoptedToolCall(this.sessionId, toolCall).update;
const status = toolCall.paused ? V2_PAUSED : "in_progress";
return {
sessionId: this.sessionId,
update: {
...update,
...workflowProgress(this.level.get(taskId)?.progress),
status,
} as AcpSessionNotification["update"],
};
});
}
/**
* The task of the terminal or tool call `target` that the session
* `sessionId` lists, or undefined when it lists no such item.
*/
listedTask(
owner: BackgroundOwner,
sessionId: string,
target: BackgroundTarget,
): string | undefined {
if (!this.reported) return undefined;
const listed = this.reports(owner, "idle")
.find((report) => report.sessionId === sessionId)
?.items.some((item) =>
"terminalId" in target
? item.type === "terminal" && item.terminalId === target.terminalId
: item.type === "tool_call" && item.toolCallId === target.toolCallId,
);
if (!listed) return undefined;
const id = "terminalId" in target ? target.terminalId : target.toolCallId;
const entries = "terminalId" in target ? this.terminals : this.toolCalls;
return [...entries].find(([, entry]) => entry.toolCallId === id)?.[0];
}
}
type Send = (notification: AcpSessionNotification) => Promise<void>;
/** A terminal or tool call that `terminal/cancel` or `tool_call/cancel` names. */
export type BackgroundTarget = { terminalId: string } | { toolCallId: string };
/**
* Stops the task of a terminal or tool call that the session `sessionId`
* lists (v2 `terminal/cancel` and `tool_call/cancel`). `sessions` are the
* root sessions: a subagent session's list is built with its root's. The
* terminal exits, or the tool call gets its final status, when Claude Code
* reports the task stopped.
*/
export async function cancelBackgroundWork(
sessions: Array<BackgroundOwner & { background?: SessionBackgroundWork }>,
sessionId: string,
target: BackgroundTarget,
): Promise<void> {
const notCancellable = () =>
"terminalId" in target
? RequestError.invalidParams(
{ sessionId, terminalId: target.terminalId },
`Terminal ${target.terminalId} is not a command running in the background, so it cannot be cancelled.`,
)
: RequestError.invalidParams(
{ sessionId, toolCallId: target.toolCallId },
`Tool call ${target.toolCallId} is not running in the background, so it cannot be cancelled.`,
);
for (const session of sessions) {
const taskId = session.background?.listedTask(session, sessionId, target);
if (taskId === undefined) continue;
try {
await session.query.stopTask(taskId);
} catch (error) {
// The task ended meanwhile: it has ended on the client too, or is about to.
if (session.background?.listedTask(session, sessionId, target) === undefined) {
throw notCancellable();
}
throw error;
}
return;
}
throw notCancellable();
}
/** The update that gives the terminal of `terminal` the text `output`. */
function terminalOutputUpdate(
sessionId: string,
terminal: BackgroundTerminal,
output: BackgroundOutput,
): AcpSessionNotification {
const data = { terminal_id: terminal.toolCallId, data: output.text };
return {
sessionId,
update: {
sessionUpdate: "tool_call_update",
toolCallId: terminal.toolCallId,
_meta: {
...(terminal.parentToolUseId
? { claudeCode: { parentToolUseId: terminal.parentToolUseId } }
: {}),
...(output.replaces ? { terminal_output: data } : { terminal_output_delta: data }),
},
},
};
}
/** 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 },
},
},
};
}
function toolCallUpdate(
sessionId: string,
toolCall: BackgroundToolCall,
fields: Omit<ToolCallUpdate, "toolCallId">,
): AcpSessionNotification {
return {
sessionId,
update: {
sessionUpdate: "tool_call_update",
toolCallId: toolCall.toolCallId,
...fields,
...(toolCall.parentToolUseId
? { _meta: { claudeCode: { parentToolUseId: toolCall.parentToolUseId } } }
: {}),
},
};
}
/**
* The id of the tool call that the adapter starts for the task `taskId`.
* Claude Code's tool use ids start with `toolu_`, so it names no other call.
*/
function adoptedToolCallId(taskId: string): string {
return `task_${taskId}`;
}
/** The `tool_call` that the adapter starts for a task (see `SessionBackgroundWork.adoptTasks`). */
function adoptedToolCall(sessionId: string, toolCall: BackgroundToolCall): AcpSessionNotification {
return {
sessionId,
update: {
sessionUpdate: "tool_call",
toolCallId: toolCall.toolCallId,
title: toolCall.title ?? "Background task",
kind: "other",
status: toolCall.paused ? V2_PAUSED : "in_progress",
...(toolCall.parentToolUseId
? { _meta: { claudeCode: { parentToolUseId: toolCall.parentToolUseId } } }
: {}),
} as AcpSessionNotification["update"],
};
}
/** The latest workflow activity, rendered as one replaceable tool-card body. */
function workflowProgress(progress: WorkflowCardProgress | undefined) {
if (!progress || Object.keys(progress).length === 0) return undefined;
const activity = progress.description ?? progress.summary;
const usage = progress.usage;
const text = [
progress.summary ?? progress.description,
progress.lastToolName ? `Last activity: ${progress.lastToolName}` : undefined,
usage
? `${usage.totalTokens.toLocaleString("en-US")} tokens · ${usage.toolUses.toLocaleString("en-US")} tool calls · ${Math.floor(usage.durationMs / 1000)}s`
: undefined,
progress.workflow ? workflowSnapshotText(progress.workflow) : undefined,
]
.filter((part) => part !== undefined)
.join("\n\n");
return {
title: activity ? `Workflow: ${activity}` : "Workflow",
content: [textContent(text)],
};
}
/**
* Publishes the lists of each session that reports them, each time one
* changes. The sink sends them to the client synchronously, so a list follows
* the updates sent before it.
*/
export class BackgroundWorkReporter {
/** The list last published for each session, as JSON, and the root session it is built with. */
private readonly published = new Map<string, { json: string; root: string }>();
constructor(private readonly sink?: BackgroundSink) {}
/** Whether the lists are reported at all. */
get enabled(): boolean {
return this.sink !== undefined;
}
/**
* Publishes each list of the root session `rootSessionId` that changed. The
* first call for a session publishes its list, also when it is empty.
* `rootState` is the root session's state now.
*/
sync(
rootSessionId: string,
session: BackgroundOwner & { background?: SessionBackgroundWork },
rootState: WorkStateName,
): void {
if (!this.sink || !session.background?.reported) return;
for (const report of session.background.reports(session, rootState)) {
const json = JSON.stringify(report.items);
if (this.published.get(report.sessionId)?.json === json) continue;
this.published.set(report.sessionId, { json, root: rootSessionId });
this.sink.changed(report);
}
}
/** The root session `rootSessionId` ended, and its subagent sessions with it. */
forget(rootSessionId: string): void {
for (const [sessionId, { root }] of this.published) {
if (root !== rootSessionId) continue;
this.published.delete(sessionId);
this.sink?.forget(sessionId);
}
}
}
/** A child's work state, when it is one that v2 names. */
function workStateName(state: string | undefined): WorkStateName | undefined {
return state === "idle" ||
state === "running" ||
state === "requires_action" ||
state === "unknown"
? state
: undefined;
}

View File

@@ -72,3 +72,30 @@ export function sdkSystemPrompt(
? { 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

@@ -4,6 +4,7 @@ import {
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";
@@ -132,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.
*

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

@@ -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

@@ -6,7 +6,13 @@ import type {
ElicitationSchema,
EnumOption,
} from "@agentclientprotocol/sdk/unstable";
import type { ElicitationRequest, ElicitationResult } from "@anthropic-ai/claude-agent-sdk";
import type {
ElicitationRequest,
ElicitationResult,
PermissionResult,
UserDialogRequest,
UserDialogResult,
} from "@anthropic-ai/claude-agent-sdk";
import type {
AskUserQuestionInput,
AskUserQuestionOutput,
@@ -512,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

@@ -104,11 +104,12 @@ 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. Must run before `toAcpNotifications`
* drops the tool use cache entries.
* 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,
turn: (NativeFileChangeReportTurn & { settled?: boolean }) | null | undefined,
parentToolUseId: string | null,
content: unknown,
toolUses: Record<string, { name: string; input: unknown } | undefined>,

View File

@@ -2,7 +2,7 @@
* `--hide-claude-auth`: this integration must never bill a claude.ai
* subscription. Under the flag the agent hides the claude.ai login method from
* `initialize`, and refuses with `authRequired` every session and every turn
* that a subscription would pay for. `acp-agent.ts` decides when the guard
* that a subscription would pay for. `v1/acp-agent.ts` decides when the guard
* applies (flag on, no provider override); this module decides what it refuses.
*
* The guard judges the `AccountInfo` that the SDK caches at `initialize`. That
@@ -208,14 +208,14 @@ type GuardOptions = {
* covers a session created under a provider override that `providers/disable`
* later removed, so the create-time check never ran for the routing in force
* now. A sign-out ends the query (see `markSessionForSignOutRespawn` in
* `acp-agent.ts`), so the next turn recreates it and `initialize` reports the
* `v1/acp-agent.ts`), so the next turn recreates it and `initialize` reports the
* real account. This guard does not see the residual case the module header
* names either.
*
* A turn is refused when the account bills a subscription, and also when it
* holds nothing this integration accepts, which is the user signing out in
* mid-session. The refusal mirrors the signed-out path exactly (see
* `failActiveWithSessionFailure` for `auth_required` in `acp-agent.ts`): every
* `failActiveWithSessionFailure` for `auth_required` in `v1/acp-agent.ts`): every
* client gets the `authRequired` JSON-RPC rejection that starts its own auth
* flow. The guard sends no separate access message.
*

View File

@@ -83,7 +83,7 @@ export class IdleCompaction {
this.setTimer = options.setTimeout ?? ((cb, ms) => setTimeout(cb, ms));
}
/** Whether an idle compaction it started is still running. `acp-agent.ts` reports
/** Whether an idle compaction it started is still running. `v1/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

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 "./v1/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;
}

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)
);
}

View File

@@ -8,6 +8,7 @@ import path from "node:path";
import type { AcpSessionNotification, SubagentForm, SubagentState } from "./acp-subagents.js";
import { AIR_SUBAGENT_KEY, airExtensionMeta } from "./air-extension.js";
import { MODEL_CONFIG_ID } from "./session-model.js";
import { orderedSubagentConfiguration } from "./subagent-configuration.js";
import { isFinalToolCallStatus } from "./tool-calls/renderer.js";
/** The work state of a child, as the RFD's `subagent_update.state` carries it. */
@@ -130,7 +131,7 @@ type TaskStarted = {
prompt?: unknown;
};
type SubagentIdentity = {
export type SubagentIdentity = {
name?: string;
description?: string;
prompt?: string;
@@ -292,30 +293,6 @@ export class NativeSubagentRuntime {
return child?.announced ? child.workState : undefined;
}
/**
* The RFD form: the `subagent_update` that tells a client that holds nothing
* of the announced child `sessionId` of this live session what it is now:
* its labels, its directory, its `cancel` capability, and its work state.
* Undefined when no such child runs here.
*/
currentUpdate(sessionId: string): AcpSessionNotification | undefined {
if (this.form !== "rfd") return undefined;
const child = this.childOfSession(sessionId);
if (!child?.announced || !child.workState) return undefined;
return {
sessionId: child.parentSessionId,
update: {
sessionUpdate: "subagent_update",
sessionId: child.sessionId,
...(child.title ? { title: child.title } : {}),
...(child.description ? { description: child.description } : {}),
...(child.reportedCwd ? { cwd: child.reportedCwd } : {}),
capabilities: { cancel: {} },
state: { state: child.workState },
},
};
}
/**
* Reports the model of a child response, an exact model id, as the entry of
* the root session's `model` option that matches it, else as the id (see
@@ -412,14 +389,7 @@ export class NativeSubagentRuntime {
// so a delayed publication cannot replace newer settings with older ones.
const publishing = config.publishing.then(async () => {
if (this.configBySession.get(child.sessionId) !== config || ended()) return;
const priority = ["mode", "model", "thought_level", "model_config"];
const rank = (option: SessionConfigOption) => {
const index = priority.indexOf(option.category ?? "");
return index < 0 ? priority.length : index;
};
const configOptions = [...config.options.values()].sort(
(left, right) => rank(left) - rank(right),
);
const configOptions = orderedSubagentConfiguration(config.options.values());
const snapshot = JSON.stringify(configOptions);
if (config.published === snapshot) return;
await this.publish({
@@ -1978,7 +1948,7 @@ function isWorking(child: NativeSubagent): boolean {
* A failure is idle with the `error` stop reason and, when the SDK gives its
* text, the JSON-RPC `error`, as the RFD asks: a v1 child has no prompt
* response, so this is how a client learns that its work failed. The error is
* an internal error, as for a failed turn on the v2 surface.
* an internal error, as for a failed turn on v2.
*
* A task that a worker restart orphaned, or that was still running when the
* stream ended, is idle with no stop reason: its work ended, but the adapter
@@ -2133,7 +2103,7 @@ function isFailedToolCallUpdate(update: AcpSessionNotification["update"]): boole
* one), a SendMessage call whose message reached no announced child, or a
* failed SubagentHandback call.
*/
function unexposedControlFallback(
export function unexposedControlFallback(
initial: AcpSessionNotification | undefined,
terminal: AcpSessionNotification,
sessionId: string,
@@ -2227,7 +2197,8 @@ function subagentDescription(prompt: unknown, description: unknown): string {
return "Delegated task";
}
function subagentIdentity(input: unknown): SubagentIdentity | undefined {
/** The labels and prompt of a child, from the input of the Agent or Task call that launched it. */
export function subagentIdentity(input: unknown): SubagentIdentity | undefined {
if (typeof input !== "object" || input === null || Array.isArray(input)) return undefined;
const value = input as Record<string, unknown>;
const identity: SubagentIdentity = {
@@ -2239,7 +2210,7 @@ function subagentIdentity(input: unknown): SubagentIdentity | undefined {
return Object.values(identity).some(Boolean) ? identity : undefined;
}
function mergeSubagentIdentity(
export function mergeSubagentIdentity(
previous: SubagentIdentity | undefined,
next: SubagentIdentity,
): SubagentIdentity {

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

@@ -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

@@ -42,3 +42,51 @@ export function promptSuggestionsMeta(text: string | null): Record<string, unkno
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
* `v1/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,7 +1,8 @@
import * as v2 from "@agentclientprotocol/sdk/experimental/v2";
import { v1AgentApp, type ClaudeAcpAgent, type Logger } from "./acp-agent.js";
import { v1AgentApp, type ClaudeAcpAgent } from "./v1/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";
/**
* The ACP versions that the adapter serves: `v1` or `v2` alone, or both with
@@ -32,13 +33,13 @@ export function serveAcp(
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 } = acpProtocolRouter(logger, onAgent, options.protocols).connect(
v2.ndJsonStream(output, input),
);
const { closed } = acpProtocolRouter(logger, onAgent, options.protocols, {
onV2Agent: onAgent,
}).connect(v2.ndJsonStream(output, input));
return {
closed,
dispose: async () => {
@@ -53,15 +54,20 @@ export function serveAcp(
* 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 the connection once the router has opened it.
* 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 {
const router = v2.agentProtocolRouter();
if (protocols !== "v2") router.withV1(v1AgentApp(logger, onAgent));
if (protocols !== "v1") router.withV2(v2AgentApp(logger, onAgent));
if (protocols !== "v1") {
router.withV2(v2AgentApp(logger, onV2Agent));
}
return router;
}

View File

@@ -1,5 +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

@@ -25,6 +25,22 @@ export function effortFlagSettings(
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 {
return value
.trim()
@@ -133,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;
}

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

@@ -405,7 +405,7 @@ export class SessionModeManager<S extends SessionMode> {
}
}
function buildAvailableModes(
export function buildAvailableModes(
{ allowBypass, allowAuto }: ModeAvailability,
air = false,
): SessionModeState["availableModes"] {

View File

@@ -84,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) =>
@@ -134,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 {
@@ -517,6 +549,61 @@ export function narrowToGatewayModels(
: [];
}
/**
* 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:

View File

@@ -118,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

@@ -11,19 +11,42 @@
* `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 five 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
* {@link SessionTitles.reset} on `conversation_reset`.
* {@link SessionTitles} holds the per-session state and is owned by the
* session, in the v1 core (`v1/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/unstable";
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
@@ -137,7 +160,7 @@ export class SessionTitles {
}
constructor(
private readonly agent: ClaudeAcpAgent,
private readonly agent: TitleOwner,
private readonly sessionId: string,
) {}
@@ -158,7 +181,7 @@ export class SessionTitles {
* 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: Session, prompt: PromptRequest["prompt"]): void {
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.
@@ -209,7 +232,7 @@ export class SessionTitles {
* 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: Session): Promise<void> {
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.
@@ -301,7 +324,7 @@ export class SessionTitles {
* 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: Session): Promise<void> {
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.
@@ -329,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) {
@@ -357,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;
}
@@ -383,7 +406,7 @@ export class SessionTitles {
* 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: Session, description: string): Promise<void> {
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;
@@ -405,7 +428,7 @@ export class SessionTitles {
* 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> {

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 `v1/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] };
}
}

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;
});
}

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

@@ -376,17 +376,7 @@ export class RfdSubagentReplay {
}
const missing = this.omitted.size + this.withoutHistory;
if (missing > 0 && supportsNotices) {
await this.publish({
sessionId: rootSessionId,
update: noticeUpdate({
severity: "info",
title: "Some subagent history is not available",
description:
missing === 1
? "The history of 1 subagent of this session could not be restored."
: `The history of ${missing} subagents of this session could not be restored.`,
}),
});
await this.publish({ sessionId: rootSessionId, update: missingHistoryNotice(missing) });
}
}
@@ -498,12 +488,10 @@ export class RfdSubagentReplay {
message: SessionMessage,
text: string,
): Promise<void> {
const names = [child.sessionId, child.record.name, child.input.name];
const call = this.sendMessages.find(
(candidate) =>
candidate.recipientSessionId === undefined &&
names.includes(candidate.to) &&
text.includes(candidate.message.trim()),
const call = deliveredCall(
this.sendMessages,
[child.sessionId, child.record.name, child.input.name],
text,
);
if (call) {
call.recipientSessionId = child.sessionId;
@@ -619,6 +607,38 @@ function isDirectory(dir: string): boolean {
}
/** A message that a SendMessage call delivered to the child. */
function isDelivery(message: SessionMessage): boolean {
export function isDelivery(message: SessionMessage): boolean {
return (message as { origin?: { kind?: unknown } | null }).origin?.kind === "coordinator";
}
/**
* The SendMessage call of `calls` that a delivered message `text` came from,
* for the child that `names` names (its agent id, its name). Claude Code
* frames the message for the child's model ("The coordinator sent a message
* while you were working:", then the message, then an instruction), so it is
* the first undelivered call to the child whose message the text contains.
*/
export function deliveredCall<
T extends { to: string; message: string; recipientSessionId?: string },
>(calls: readonly T[], names: readonly unknown[], text: string): T | undefined {
return calls.find(
(call) =>
call.recipientSessionId === undefined &&
names.includes(call.to) &&
text.includes(call.message.trim()),
);
}
/** The notice that the history of `missing` subagents of a replayed session is not available. */
export function missingHistoryNotice(
missing: number,
): Extract<AcpSessionNotification["update"], { sessionUpdate: "notice" }> {
return noticeUpdate({
severity: "info",
title: "Some subagent history is not available",
description:
missing === 1
? "The history of 1 subagent of this session could not be restored."
: `The history of ${missing} subagents of this session could not be restored.`,
}) as Extract<AcpSessionNotification["update"], { sessionUpdate: "notice" }>;
}

View File

@@ -1,7 +1,7 @@
/**
* The `<task-notification>` inputs that Claude Code writes to a transcript
* when a background task stops. The replay of a session (`acp-agent.ts`) and
* the session index (`session-index/background-tasks.ts`) read them here, so
* when a background task stops. The replay of a session (`v1/acp-agent.ts`) and
* the session index (`v1/session-index/background-tasks.ts`) read them here, so
* both read the same fields.
*/

View File

@@ -2,7 +2,8 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import * as fs from "node:fs";
import * as path from "node:path";
import * as os from "node:os";
import type { AcpClient, ClaudeAcpAgent as ClaudeAcpAgentType } from "../acp-agent.js";
import type { ClaudeAcpAgent as ClaudeAcpAgentType } from "../v1/acp-agent.js";
import type { AcpClient } from "../acp-client.js";
import { makeMockQuery } from "./helpers.js";
const { querySpy } = vi.hoisted(() => ({
@@ -84,7 +85,7 @@ describe("ClaudeAcpAgent settings", () => {
const { getCapturedOptions } = mockQuery();
const { ClaudeAcpAgent } = await import("../acp-agent.js");
const { ClaudeAcpAgent } = await import("../v1/acp-agent.js");
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient());
const response = await (agent as any).createSession({
@@ -128,7 +129,7 @@ describe("ClaudeAcpAgent settings", () => {
applyFlagSettings,
}),
);
const { ClaudeAcpAgent } = await import("../acp-agent.js");
const { ClaudeAcpAgent } = await import("../v1/acp-agent.js");
const agent = new ClaudeAcpAgent(createMockClient());
if (recommended) {
(agent as any).clientCapabilities = {
@@ -179,7 +180,7 @@ describe("ClaudeAcpAgent settings", () => {
applyFlagSettings,
}),
);
const { ClaudeAcpAgent } = await import("../acp-agent.js");
const { ClaudeAcpAgent } = await import("../v1/acp-agent.js");
const agent = new ClaudeAcpAgent(createMockClient());
(agent as any).clientCapabilities = {
_meta: { jetbrains: { air: { version: 1, capabilities: ["recommendedValue"] } } },
@@ -226,7 +227,7 @@ describe("ClaudeAcpAgent settings", () => {
applyFlagSettings,
}),
);
const { ClaudeAcpAgent } = await import("../acp-agent.js");
const { ClaudeAcpAgent } = await import("../v1/acp-agent.js");
const agent = new ClaudeAcpAgent(createMockClient());
(agent as any).clientCapabilities = {
_meta: { jetbrains: { air: { version: 1, capabilities: ["recommendedValue"] } } },
@@ -270,7 +271,7 @@ describe("ClaudeAcpAgent settings", () => {
const { getCapturedOptions } = mockQuery();
const { ClaudeAcpAgent } = await import("../acp-agent.js");
const { ClaudeAcpAgent } = await import("../v1/acp-agent.js");
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient());
const response = await (agent as any).createSession({
@@ -295,7 +296,7 @@ describe("ClaudeAcpAgent settings", () => {
const { getCapturedOptions } = mockQuery();
const { ClaudeAcpAgent } = await import("../acp-agent.js");
const { ClaudeAcpAgent } = await import("../v1/acp-agent.js");
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient());
const response = await (agent as any).createSession({
@@ -318,7 +319,7 @@ describe("ClaudeAcpAgent settings", () => {
const { getCapturedOptions } = mockQuery();
const { ClaudeAcpAgent } = await import("../acp-agent.js");
const { ClaudeAcpAgent } = await import("../v1/acp-agent.js");
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient());
(agent as any).logger = { log: () => {}, error: () => {} };
@@ -362,7 +363,7 @@ describe("ClaudeAcpAgent settings", () => {
const { getCapturedOptions } = mockQuery();
const { ClaudeAcpAgent } = await import("../acp-agent.js");
const { ClaudeAcpAgent } = await import("../v1/acp-agent.js");
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient());
(agent as any).logger = { log: () => {}, error: () => {} };
@@ -387,7 +388,7 @@ describe("ClaudeAcpAgent settings", () => {
const { getCapturedOptions } = mockQuery();
const { ClaudeAcpAgent } = await import("../acp-agent.js");
const { ClaudeAcpAgent } = await import("../v1/acp-agent.js");
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient());
const response = await (agent as any).createSession({
@@ -413,7 +414,7 @@ describe("ClaudeAcpAgent settings", () => {
mockQuery();
const { ClaudeAcpAgent } = await import("../acp-agent.js");
const { ClaudeAcpAgent } = await import("../v1/acp-agent.js");
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient());
(agent as any).logger = { log: () => {}, error: () => {} };
@@ -447,7 +448,7 @@ describe("ClaudeAcpAgent settings", () => {
);
const { getCapturedOptions, setModelSpy } = mockQuery(models);
const { ClaudeAcpAgent } = await import("../acp-agent.js");
const { ClaudeAcpAgent } = await import("../v1/acp-agent.js");
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient());
(agent as any).logger = { log: () => {}, error: () => {} };
return { agent, projectDir, getCapturedOptions, setModelSpy };
@@ -646,7 +647,7 @@ describe("ClaudeAcpAgent settings", () => {
const { getCapturedOptions } = mockQuery();
const { ClaudeAcpAgent } = await import("../acp-agent.js");
const { ClaudeAcpAgent } = await import("../v1/acp-agent.js");
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient());
const response = await (agent as any).createSession({
@@ -673,7 +674,7 @@ describe("ClaudeAcpAgent settings", () => {
const { setModelSpy } = mockQuery();
const { ClaudeAcpAgent } = await import("../acp-agent.js");
const { ClaudeAcpAgent } = await import("../v1/acp-agent.js");
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient());
const response = await (agent as any).createSession({
@@ -728,7 +729,7 @@ describe("ClaudeAcpAgent settings", () => {
},
]);
const { ClaudeAcpAgent } = await import("../acp-agent.js");
const { ClaudeAcpAgent } = await import("../v1/acp-agent.js");
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient());
const response = await (agent as any).createSession({
@@ -755,7 +756,7 @@ describe("ClaudeAcpAgent settings", () => {
},
]);
const { ClaudeAcpAgent } = await import("../acp-agent.js");
const { ClaudeAcpAgent } = await import("../v1/acp-agent.js");
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient());
const response = await (agent as any).createSession({
@@ -807,7 +808,7 @@ describe("ClaudeAcpAgent settings", () => {
{ value: "claude-haiku-4-5", displayName: "Claude Haiku", description: "Fast" },
]);
const { ClaudeAcpAgent } = await import("../acp-agent.js");
const { ClaudeAcpAgent } = await import("../v1/acp-agent.js");
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient());
const response = await (agent as any).createSession({
@@ -844,7 +845,7 @@ describe("ClaudeAcpAgent settings", () => {
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
try {
const { ClaudeAcpAgent } = await import("../acp-agent.js");
const { ClaudeAcpAgent } = await import("../v1/acp-agent.js");
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient());
const response = await (agent as any).createSession({
@@ -891,7 +892,7 @@ describe("ClaudeAcpAgent settings", () => {
},
]);
const { ClaudeAcpAgent } = await import("../acp-agent.js");
const { ClaudeAcpAgent } = await import("../v1/acp-agent.js");
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient());
const response = await (agent as any).createSession({
@@ -956,7 +957,7 @@ describe("ClaudeAcpAgent settings", () => {
{ value: "haiku", displayName: "Haiku", description: "Fast" },
]);
const { ClaudeAcpAgent } = await import("../acp-agent.js");
const { ClaudeAcpAgent } = await import("../v1/acp-agent.js");
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient());
const response = await (agent as any).createSession({
@@ -996,7 +997,7 @@ describe("ClaudeAcpAgent settings", () => {
{ value: "haiku", displayName: "Haiku", description: "Fast" },
]);
const { ClaudeAcpAgent } = await import("../acp-agent.js");
const { ClaudeAcpAgent } = await import("../v1/acp-agent.js");
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient());
const response = await (agent as any).createSession({
@@ -1028,7 +1029,7 @@ describe("ClaudeAcpAgent settings", () => {
{ value: "haiku", displayName: "Haiku", description: "Fast" },
]);
const { ClaudeAcpAgent } = await import("../acp-agent.js");
const { ClaudeAcpAgent } = await import("../v1/acp-agent.js");
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient());
const response = await (agent as any).createSession({
@@ -1050,7 +1051,7 @@ describe("ClaudeAcpAgent settings", () => {
{ value: "haiku", displayName: "Haiku", description: "Fast" },
]);
const { ClaudeAcpAgent } = await import("../acp-agent.js");
const { ClaudeAcpAgent } = await import("../v1/acp-agent.js");
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient());
const response = await (agent as any).createSession({
@@ -1084,7 +1085,7 @@ describe("ClaudeAcpAgent settings", () => {
{ value: "haiku", displayName: "Haiku", description: "Fast" },
]);
const { ClaudeAcpAgent } = await import("../acp-agent.js");
const { ClaudeAcpAgent } = await import("../v1/acp-agent.js");
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient());
const response = await (agent as any).createSession({
@@ -1135,7 +1136,7 @@ describe("ClaudeAcpAgent settings", () => {
},
]);
const { ClaudeAcpAgent } = await import("../acp-agent.js");
const { ClaudeAcpAgent } = await import("../v1/acp-agent.js");
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient());
const response = await (agent as any).createSession({
@@ -1196,7 +1197,7 @@ describe("ClaudeAcpAgent settings", () => {
},
]);
const { ClaudeAcpAgent } = await import("../acp-agent.js");
const { ClaudeAcpAgent } = await import("../v1/acp-agent.js");
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient());
const response = await (agent as any).createSession({
@@ -1248,7 +1249,7 @@ describe("ClaudeAcpAgent settings", () => {
},
]);
const { ClaudeAcpAgent } = await import("../acp-agent.js");
const { ClaudeAcpAgent } = await import("../v1/acp-agent.js");
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient());
const response = await (agent as any).createSession({
@@ -1303,7 +1304,7 @@ describe("ClaudeAcpAgent settings", () => {
},
]);
const { ClaudeAcpAgent } = await import("../acp-agent.js");
const { ClaudeAcpAgent } = await import("../v1/acp-agent.js");
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient());
const response = await (agent as any).createSession({
@@ -1354,7 +1355,7 @@ describe("ClaudeAcpAgent settings", () => {
{ value: "sonnet", displayName: "Sonnet", description: "Claude Sonnet 4.6" },
]);
const { ClaudeAcpAgent } = await import("../acp-agent.js");
const { ClaudeAcpAgent } = await import("../v1/acp-agent.js");
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient());
const response = await (agent as any).createSession({
@@ -1409,7 +1410,7 @@ describe("ClaudeAcpAgent settings", () => {
}) as any;
});
const { ClaudeAcpAgent } = await import("../acp-agent.js");
const { ClaudeAcpAgent } = await import("../v1/acp-agent.js");
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient());
const response = await (agent as any).createSession({
@@ -1450,7 +1451,7 @@ describe("ClaudeAcpAgent settings", () => {
});
try {
const { ClaudeAcpAgent } = await import("../acp-agent.js");
const { ClaudeAcpAgent } = await import("../v1/acp-agent.js");
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient());
const response = await (agent as any).createSession({
@@ -1501,7 +1502,7 @@ describe("ClaudeAcpAgent settings", () => {
}) as any;
});
const { ClaudeAcpAgent } = await import("../acp-agent.js");
const { ClaudeAcpAgent } = await import("../v1/acp-agent.js");
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient());
const response = await (agent as any).createSession({

View File

@@ -35,27 +35,31 @@ import {
import { toolUpdateFromDiffToolResponse } from "../diff.js";
import { ToolCallFieldTracker } from "../tool-calls/field-tracker.js";
import {
toAcpNotifications,
promptToClaude,
isLocalCommandMetadata,
isReplayHiddenMetaMessage,
isSyntheticLoginMessage,
stripLocalCommandMetadata,
ClaudeAcpAgent,
computeSessionFingerprint,
streamEventToAcpNotifications,
messageIdForGrouping,
buildConfigOptions,
createFastModeConfigOption,
discoverCustomAgents,
runPromptWithCancellation,
type AcpClient,
type SDKMessageFilter,
type SteerRequest,
} from "../v1/acp-agent.js";
import {
toAcpNotifications,
streamEventToAcpNotifications,
messageIdForGrouping,
type StreamedToolInputCache,
} from "../acp-agent.js";
} from "../sdk-to-acp.js";
import { promptToClaude } from "../prompt-to-claude.js";
import {
isLocalCommandMetadata,
isReplayHiddenMetaMessage,
isSyntheticLoginMessage,
stripLocalCommandMetadata,
} from "../message-filters.js";
import type { AcpClient } from "../acp-client.js";
import type { SDKMessageFilter } from "../new-session-meta.js";
import { SessionTitles } from "../session-titles.js";
import { SessionBackgroundWork } from "../background-work.js";
import { SessionBackgroundWork } from "../v1/background-work.js";
import { formatUsageResponse, isUsageCommandText, parseUsageResponse } from "../usage-markdown.js";
import { Pushable } from "../utils.js";
import { heldRequest, initializeClient, makeMockQuery } from "./helpers.js";
@@ -67,7 +71,6 @@ import {
getSessionInfo,
getSessionMessages,
getSubagentMessages,
type HookCallback,
importSessionToStore,
listSessions,
type ModelInfo,
@@ -92,7 +95,7 @@ import {
goalUpdateFromPrompt,
parseGoalRequest,
toGoalSnapshot,
} from "../goal-extension.js";
} from "../v1/goal-extension.js";
vi.mock("@anthropic-ai/claude-agent-sdk", async (importOriginal) => {
const actual = await importOriginal<typeof import("@anthropic-ai/claude-agent-sdk")>();
@@ -5297,7 +5300,7 @@ describe("permission request cancellation", () => {
toolUseCache: {},
emittedToolCalls: new Set(),
liveBackgroundTasks: new Map(),
background: new SessionBackgroundWork(sessionId, false),
background: new SessionBackgroundWork(sessionId),
emittedAssistantText: false,
owedTrailingIdles: 0,
messageIdToUuid: new Map(),
@@ -5367,14 +5370,14 @@ describe("permission request cancellation", () => {
});
describe("a cancelled outcome", () => {
function setup(options: { v2?: boolean } = {}) {
function setup() {
const mockClient = {
sessionUpdate: async () => {},
requestPermission: async (): Promise<RequestPermissionResponse> => ({
outcome: { outcome: "cancelled" },
}),
} as unknown as AcpClient;
const agent = new ClaudeAcpAgent(mockClient, { log: () => {}, error: () => {} }, options);
const agent = new ClaudeAcpAgent(mockClient, { log: () => {}, error: () => {} });
const session = injectSession(agent, "session-1");
session.emittedToolCalls.add("tool-1");
const ask = (agentID?: string) =>
@@ -5396,29 +5399,6 @@ describe("permission request cancellation", () => {
await expect(ask("agent-fg")).rejects.toThrow("Tool use aborted");
});
it("denies the tool use of foreground work on v2 without a session/cancel", async () => {
// On v2 the client also answers `cancelled` when the user dismissed the
// prompt or its policy declined it. The turn goes on without the tool.
const { session, ask } = setup({ v2: true });
session.liveBackgroundTasks.set("agent-fg", { isSubagent: true });
for (const agentID of [undefined, "agent-fg"]) {
await expect(ask(agentID)).resolves.toEqual({
behavior: "deny",
message: "User refused permission to run tool",
toolUseID: "tool-1",
decisionClassification: "user_reject",
});
}
});
it("aborts the tool use of foreground work on v2 during a session/cancel", async () => {
const { session, ask } = setup({ v2: true });
session.cancelled = true;
await expect(ask()).rejects.toThrow("Tool use aborted");
});
it("denies the tool use of a background subagent", async () => {
const { session, ask } = setup();
session.liveBackgroundTasks.set("agent-bg", { isSubagent: true, backgrounded: true });
@@ -7610,76 +7590,6 @@ describe("subagent permission attribution (issue #851)", () => {
};
}
it("releases a tool call that ends cancelled on v2, as one that ends failed", async () => {
const updates: AcpSessionNotification[] = [];
const agent = new ClaudeAcpAgent(
{
sessionUpdate: async (update: AcpSessionNotification) => {
updates.push(update);
},
} as unknown as AcpClient,
{ log: () => {}, error: () => {} },
{ v2: true },
);
await agent.initialize({ protocolVersion: 1, clientCapabilities: {} });
injectGeneratorSession(
agent,
makeGenerator([
{
type: "assistant",
uuid: randomUUID(),
session_id: "test-session",
parent_tool_use_id: null,
message: {
id: "msg-bash",
model: "claude-sonnet-4-5",
role: "assistant",
type: "message",
stop_reason: "tool_use",
content: [
{ type: "tool_use", id: "toolu_bash", name: "Bash", input: { command: "make" } },
],
usage: { input_tokens: 0, output_tokens: 0 },
},
},
{
type: "user",
uuid: randomUUID(),
session_id: "test-session",
parent_tool_use_id: null,
// The user interrupted the turn while the command ran.
tool_result_meta: [{ id: "toolu_bash", non_execution_kind: "interrupted" }],
message: {
role: "user",
content: [
{
type: "tool_result",
tool_use_id: "toolu_bash",
content: "[Request interrupted by user for tool use]",
is_error: true,
},
],
},
},
successResult(),
]),
);
await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "go" }] });
expect(
updates
.map(({ update }) => update)
.filter(
(update) =>
update.sessionUpdate === "tool_call_update" && "status" in update && update.status,
)
.at(-1),
).toMatchObject({ toolCallId: "toolu_bash", status: "cancelled" });
// The agent stops tracking the call once it ends, whichever way.
expect(agent.sessions["test-session"]?.dispatchedToolCalls?.has("toolu_bash")).toBe(false);
});
it("records task_started's task_id → tool_use_id mapping while consuming the stream", async () => {
const agent = new ClaudeAcpAgent(
{ sessionUpdate: vi.fn(async () => {}) } as unknown as AcpClient,
@@ -15096,7 +15006,7 @@ describe("session/close", () => {
toolUseCache: {},
emittedToolCalls: new Set(),
liveBackgroundTasks: new Map(),
background: new SessionBackgroundWork(sessionId, false),
background: new SessionBackgroundWork(sessionId),
emittedAssistantText: false,
owedTrailingIdles: 0,
messageIdToUuid: new Map(),
@@ -15220,7 +15130,7 @@ describe("session/delete", () => {
toolUseCache: {},
emittedToolCalls: new Set(),
liveBackgroundTasks: new Map(),
background: new SessionBackgroundWork(sessionId, false),
background: new SessionBackgroundWork(sessionId),
emittedAssistantText: false,
owedTrailingIdles: 0,
messageIdToUuid: new Map(),
@@ -15385,7 +15295,7 @@ describe("getOrCreateSession param change detection", () => {
toolUseCache: {},
emittedToolCalls: new Set(),
liveBackgroundTasks: new Map(),
background: new SessionBackgroundWork(sessionId, false),
background: new SessionBackgroundWork(sessionId),
emittedAssistantText: false,
owedTrailingIdles: 0,
messageIdToUuid: new Map(),
@@ -19923,7 +19833,7 @@ describe("post-error recovery", () => {
toolUseCache: {},
emittedToolCalls: new Set(),
liveBackgroundTasks: new Map(),
background: new SessionBackgroundWork("test-session", false),
background: new SessionBackgroundWork("test-session"),
emittedAssistantText: false,
owedTrailingIdles: 0,
messageIdToUuid: new Map(),
@@ -21919,23 +21829,6 @@ describe("output after a cancel", () => {
toolUseID: id,
} as any);
/** The v2 PreToolUse preview hook for the Write call `id`, as Claude Code calls it. */
const previewWrite = (agent: ClaudeAcpAgent, id: string) =>
(
agent as unknown as { fileChangePreviewHook(sessionId: string): HookCallback }
).fileChangePreviewHook("test-session")(
{
hook_event_name: "PreToolUse",
tool_name: "Write",
tool_input: writeInput,
tool_use_id: id,
session_id: "test-session",
transcript_path: "",
cwd: "/workspace/proj",
} as any,
id,
{ signal: new AbortController().signal },
);
/** Sends the prompt `text` and waits until its turn is active. Returns the
* prompt and the session's consumer, which ends with the scripted stream. */
async function promptActive(agent: ClaudeAcpAgent, text: string) {
@@ -22084,7 +21977,6 @@ describe("output after a cancel", () => {
async (_when, order, streamed) => {
const cancelled = Promise.withResolvers<void>();
let asked: Promise<unknown> | undefined;
let previewed: Promise<unknown> | undefined;
const { agent, texts, updates, permissionRequests } = setup((input) =>
(async function* () {
const iter = input[Symbol.asyncIterator]();
@@ -22097,14 +21989,12 @@ describe("output after a cancel", () => {
yield abortedResult(first.uuid);
yield lifecycleFrame(first.uuid, "cancelled");
// Claude Code had already taken the second prompt, so it still runs
// it, and its answer writes a file. It calls the preview hook and
// asks for the permission on its control channel, which can run
// ahead of the consumer.
// it, and its answer writes a file. It asks for the permission on
// its control channel, which can run ahead of the consumer.
yield lifecycleFrame(second.uuid, "started");
yield requesting(second.uuid);
yield userEcho(second);
const ask = () => {
previewed = previewWrite(agent, "toolu_late");
asked = askToWrite(agent, "toolu_late");
};
if (order === "before") ask();
@@ -22113,13 +22003,13 @@ describe("output after a cancel", () => {
// Claude Code can run a tool whose input is complete before it
// sends the whole message.
ask();
await Promise.all([previewed, asked]);
await asked;
}
yield* streamed
? toolUseMessage("toolu_late", "Write", writeInput)
: [toolUseMessage("toolu_late", "Write", writeInput)[0]];
if (order === "after") ask();
await Promise.all([previewed, asked]);
await asked;
yield toolResult("toolu_late", "The user cancelled this prompt.", true);
yield stampedResult(second.uuid);
yield lifecycleFrame(second.uuid, "completed");
@@ -23184,67 +23074,6 @@ describe("deferred settlement for live background subagents (issues #864/#866)",
await agent.sessions["test-session"]?.consumer;
});
it("resumes a held turn's foreground work for a steer, and ends it again at the steered result", async () => {
const foreground: string[] = [];
const agent = new ClaudeAcpAgent(
{ sessionUpdate: async () => {} } as unknown as AcpClient,
{ log: () => {}, error: () => {} },
{
foreground: () => ({
running: () => foreground.push("running"),
awaitingUser: () => foreground.push("awaitingUser"),
ended: (outcome) =>
foreground.push(`ended ${outcome.stopReason} ${outcome.usage?.totalTokens}`),
failed: () => foreground.push("failed"),
}),
},
);
injectGeneratorSession(agent, (input) => {
async function* messageGenerator() {
const iter = input[Symbol.asyncIterator]();
yield userEcho((await iter.next()).value);
yield running();
yield subagentStarted("agent-1");
yield resultMessage(); // held for agent-1
yield idle();
// The steer runs as a cycle of the held turn.
yield userEcho((await iter.next()).value);
yield assistantText("Steered answer");
yield resultMessage();
// The steered turn settles at an idle, held again for agent-1.
yield idle();
yield taskNotification("agent-1");
yield assistantText("promised summary");
yield resultMessage({ origin: { kind: "task-notification" } });
yield idle();
}
return messageGenerator();
});
const turn = agent.prompt({
sessionId: "test-session",
prompt: [{ type: "text", text: "explore" }],
});
await vi.waitFor(() =>
expect(agent.sessions["test-session"]?.activeTurn?.deferredSettle).toBeDefined(),
);
await agent.steer({ sessionId: "test-session", prompt: [{ type: "text", text: "also X" }] });
expect((await turn).stopReason).toBe("end_turn");
await agent.sessions["test-session"]?.consumer;
expect(foreground).toEqual([
"running",
"ended end_turn 15",
// The steer resumes the turn's work, which ends at the steered result
// with the turn's usage, both of its cycles.
"running",
"ended end_turn 30",
// The follow-up after the subagent finished is a cycle of its own.
"running",
"ended end_turn 15",
]);
});
describe("ExitPlanMode answered inside a held turn's followup (issue #1167)", () => {
// Plan mode + a background Explore agent: the user turn's result holds,
// the subagent finishes, and the model writes the plan and calls
@@ -23445,98 +23274,6 @@ describe("deferred settlement for live background subagents (issues #864/#866)",
]);
});
it("keeps the turn as the session's foreground work across the clear-context restart", async () => {
const foreground: string[] = [];
const agent = new ClaudeAcpAgent(
{
sessionUpdate: async () => {},
requestPermission: async (request: any) => ({
outcome: {
outcome: "selected",
optionId: request.options.find((option: any) => option.kind === "allow_once")
.optionId,
},
}),
} as unknown as AcpClient,
{ log: () => {}, error: () => {} },
{
foreground: () => ({
running: () => foreground.push("running"),
awaitingUser: () => foreground.push("awaitingUser"),
ended: (outcome) =>
foreground.push(`ended ${outcome.stopReason} ${outcome.usage?.totalTokens}`),
failed: () => foreground.push("failed"),
}),
},
);
vi.spyOn(agent as any, "createSession").mockImplementation(
async (_params: any, options: any) => {
const input = new Pushable<any>();
async function* freshGenerator() {
const next = await input[Symbol.asyncIterator]().next();
yield userEcho(next.value);
yield running();
// The continuation asks for permission in the fresh query.
await agent.canUseTool("test-session")(
"Bash",
{ command: "ls" },
{
signal: new AbortController().signal,
suggestions: [],
toolUseID: "toolu_ls",
requestId: "request-ls",
},
);
yield assistantText("Implemented");
yield resultMessage({ stop_reason: "max_tokens" });
yield idle();
}
agent.sessions["test-session"] = mockSessionState({
query: wrapQuery(freshGenerator()),
input,
modes: { currentModeId: options.permissionMode, availableModes: [] },
fastModeEnabled: false,
});
return { sessionId: "test-session" };
},
);
injectGeneratorSession(
agent,
(input) => {
async function* messageGenerator() {
const { value: first } = await input[Symbol.asyncIterator]().next();
yield* heldTurnPlanningFollowup(first);
yield idle();
}
return messageGenerator();
},
{
creationParams: { cwd: "/test", mcpServers: [] },
pendingExitPlanContextReset: {
toolUseId: "tool-plan",
plan: "Implement it",
mode: "default",
},
},
);
await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "plan" }] });
// The turn's work ends at its result, though v1's answer is held. The
// follow-up that plans is a cycle, which the continuation takes over
// without a new `running`. The restart replaces the session behind the
// id; the work stays with the id.
expect(foreground).toEqual([
"running",
"ended end_turn 15",
"running",
"awaitingUser",
"running",
// The turn's usage: the carried 15 and the continuation's.
"ended max_tokens 30",
]);
});
it("cancels the held turn when the user keeps planning", async () => {
const agent = createMockAgent();
const createSession = vi.spyOn(agent as any, "createSession");
@@ -23561,40 +23298,6 @@ describe("deferred settlement for live background subagents (issues #864/#866)",
expect(agent.sessions["test-session"].pendingExitPlanModeInterruption).toBeUndefined();
});
it("ends the follow-up cycle cancelled, not failed, when the user keeps planning", async () => {
const foreground: string[] = [];
const agent = new ClaudeAcpAgent(
{ sessionUpdate: async () => {} } as unknown as AcpClient,
{ log: () => {}, error: () => {} },
{
foreground: () => ({
running: () => foreground.push("running"),
awaitingUser: () => foreground.push("awaitingUser"),
ended: (outcome) => foreground.push(`ended ${outcome.stopReason}`),
failed: () => foreground.push("failed"),
}),
},
);
injectGeneratorSession(agent, (input) => {
async function* messageGenerator() {
const { value: first } = await input[Symbol.asyncIterator]().next();
yield* heldTurnPlanningFollowup(first);
yield idle();
}
return messageGenerator();
});
const response = await agent.prompt({
sessionId: "test-session",
prompt: [{ type: "text", text: "plan" }],
});
expect(response.stopReason).toBe("cancelled");
// The turn's work ended at its result; the follow-up that planned is a
// cycle, which the error-shaped diagnostic of the answer ends cancelled.
expect(foreground).toEqual(["running", "ended end_turn", "running", "ended cancelled"]);
});
describe("with backgroundSubagents", () => {
it("asks out of the turn without a clear-context option and drops the interrupt", async () => {
let request: RequestPermissionRequest | undefined;
@@ -28436,7 +28139,7 @@ describe("session/cancel wedge recovery (issue #680)", () => {
toolUseCache: {},
emittedToolCalls: new Set(),
liveBackgroundTasks: new Map(),
background: new SessionBackgroundWork("test-session", false),
background: new SessionBackgroundWork("test-session"),
emittedAssistantText: false,
owedTrailingIdles: 0,
messageIdToUuid: new Map(),
@@ -30346,7 +30049,7 @@ describe("agent selection config option", () => {
toolUseCache: {},
emittedToolCalls: new Set(),
liveBackgroundTasks: new Map(),
background: new SessionBackgroundWork(sessionId, false),
background: new SessionBackgroundWork(sessionId),
emittedAssistantText: false,
owedTrailingIdles: 0,
messageIdToUuid: new Map(),

View File

@@ -1,6 +1,7 @@
/**
* Every scenario of `acp-scenarios/scenarios.ts` on the experimental ACP v2
* surface: through the protocol router, with the SDK's v2 client.
* core (`src/v2/`): through the protocol router, with the SDK's v2
* client.
*
* - Status: each scenario passes, every prompt turn ending with a stop reason,
* unless {@link V2_STOPS} names why it stops. Every scenario passes today;
@@ -11,15 +12,15 @@
* `tool_call`, so a report of a tool call that the client has not seen
* creates it, where v1 drops it.
* - Golden files: `acp-scenarios/__snapshots__/v2/<scenario>.jsonl` for each
* passing scenario. Run `npx vitest run src/tests/acp-scenarios-v2.test.ts -u`
* to update them.
* passing scenario, every message of the run. Run
* `npx vitest run src/tests/acp-scenarios-v2.test.ts -u` to update them.
*/
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { fileURLToPath } from "node:url";
import type { ClaudeAcpAgent } from "../acp-agent.js";
import type { AgentHandle } from "../v2/agent.js";
import {
resetIds,
runScenarioV2,
@@ -61,8 +62,8 @@ const here = path.dirname(fileURLToPath(import.meta.url));
const PASSES = "passes";
/**
* The scenarios that stop on the v2 surface, each with the reason, which names
* the step of `docs/acp-v2.md` that removes it. Every other scenario passes.
* The scenarios that stop on the v2 surface, each with the reason. Every
* other scenario passes.
*/
const V2_STOPS: Record<string, string> = {};
@@ -86,12 +87,15 @@ beforeAll(async () => {
const { acpProtocolRouter } = await import("../serve.js");
for (const scenario of SCENARIOS) {
resetIds();
const agents: ClaudeAcpAgent[] = [];
const agents: { dispose(): Promise<void> }[] = [];
const quiet = { log: () => {}, error: () => {} };
runs.set(
scenario.name,
await runScenarioV2(
(stream) => acpProtocolRouter(quiet, (agent) => agents.push(agent)).connect(stream),
(stream) =>
acpProtocolRouter(quiet, (agent) => agents.push(agent), "auto", {
onV2Agent: (agent: AgentHandle) => agents.push(agent),
}).connect(stream),
scenario,
),
);
@@ -163,10 +167,10 @@ describe("ACP v2 scenarios", () => {
const passing = SCENARIOS.map((scenario) => scenario.name).filter(
(scenario) => statusOf(scenario) === PASSES,
);
const golden = (scenario: string) =>
path.join(here, "acp-scenarios", "__snapshots__", "v2", `${scenario}.jsonl`);
it.each(passing)("%s", async (scenario) => {
await expect(toJsonLines(run(scenario).normalized)).toMatchFileSnapshot(
path.join(here, "acp-scenarios", "__snapshots__", "v2", `${scenario}.jsonl`),
);
await expect(toJsonLines(run(scenario).normalized)).toMatchFileSnapshot(golden(scenario));
});
});
});

View File

@@ -97,7 +97,7 @@ beforeAll(async () => {
vi.stubEnv("CLAUDE_CODE_EXECUTABLE", "/usr/bin/false");
configDir = fs.mkdtempSync(path.join(os.tmpdir(), "acp-scenario-config-"));
vi.stubEnv("CLAUDE_CONFIG_DIR", configDir);
const { ClaudeAcpAgent } = await import("../acp-agent.js");
const { ClaudeAcpAgent } = await import("../v1/acp-agent.js");
Agent = ClaudeAcpAgent;
for (const profile of profiles) {
for (const scenario of SCENARIOS) {

View File

@@ -94,6 +94,7 @@ export const PROFILES: Record<Profile["name"], Profile> = {
type QueryOptions = {
sessionId?: string;
resume?: string;
env?: Record<string, string | undefined>;
canUseTool?: (
toolName: string,
input: Record<string, unknown>,
@@ -234,6 +235,16 @@ export function mockedQuery(args: { prompt: AsyncIterable<any>; options: QueryOp
);
},
};
// With `CLAUDE_CODE_EMIT_SESSION_STATE_EVENTS`, Claude Code reports that it
// works once it takes a prompt in, and that it is idle after the result.
const stateEvents = options.env?.CLAUDE_CODE_EMIT_SESSION_STATE_EVENTS === "1";
const state = (value: "running" | "idle") => ({
type: "system",
subtype: "session_state_changed",
state: value,
uuid: `state-${value}`,
session_id: sessionId,
});
async function* run() {
const iterator = args.prompt[Symbol.asyncIterator]();
for (const turn of script.scenario.turns) {
@@ -241,6 +252,7 @@ export function mockedQuery(args: { prompt: AsyncIterable<any>; options: QueryOp
if (next.done) return;
const user = next.value;
ctx.promptUuid = user.uuid;
if (stateEvents) yield state("running");
yield {
type: "user",
message: user.message,
@@ -250,6 +262,7 @@ export function mockedQuery(args: { prompt: AsyncIterable<any>; options: QueryOp
isReplay: true,
};
yield* turn(ctx);
if (stateEvents) yield state("idle");
}
// Stay open like a live Claude Code process.
await iterator.next();

File diff suppressed because it is too large Load Diff

View File

@@ -3,7 +3,8 @@ import * as os from "node:os";
import * as path from "node:path";
import { SessionNotification } from "@agentclientprotocol/sdk/unstable";
import type { Options } from "@anthropic-ai/claude-agent-sdk";
import type { AcpClient, ClaudeAcpAgent as ClaudeAcpAgentType } from "../acp-agent.js";
import type { ClaudeAcpAgent as ClaudeAcpAgentType } from "../v1/acp-agent.js";
import type { AcpClient } from "../acp-client.js";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
let capturedOptions: Options | undefined;
@@ -46,7 +47,7 @@ describe("additionalRoots", () => {
capturedOptions = undefined;
tempDirs.length = 0;
vi.resetModules();
const { ClaudeAcpAgent } = await import("../acp-agent.js");
const { ClaudeAcpAgent } = await import("../v1/acp-agent.js");
agent = new ClaudeAcpAgent({
sessionUpdate: async (_notification: SessionNotification) => {},
requestPermission: async () => ({ outcome: { outcome: "cancelled" } }),

View File

@@ -3,7 +3,7 @@ import { randomUUID } from "crypto";
import { query } from "@anthropic-ai/claude-agent-sdk";
import type { SDKUserMessage } from "@anthropic-ai/claude-agent-sdk";
import { Pushable } from "../utils.js";
import { discoverCustomAgents, BUILTIN_AGENT_NAMES } from "../acp-agent.js";
import { discoverCustomAgents, BUILTIN_AGENT_NAMES } from "../v1/acp-agent.js";
// `discoverCustomAgents` distinguishes user/plugin-configured agents from
// Claude Code's built-in subagents by name (BUILTIN_AGENT_NAMES), since the

View File

@@ -1,7 +1,9 @@
import type { Mock } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { AcpClient, ClaudeAcpAgent as ClaudeAcpAgentType, Logger } from "../acp-agent.js";
import { ClaudeAcpAgent } from "../acp-agent.js";
import type { ClaudeAcpAgent as ClaudeAcpAgentType } from "../v1/acp-agent.js";
import type { AcpClient } from "../acp-client.js";
import type { Logger } from "../logger.js";
import { ClaudeAcpAgent } from "../v1/acp-agent.js";
import type { AuthStatus } from "../auth-status.js";
import { fromAccountInfo, fromCliStatus, mergeAuthStatus, sameIdentity } from "../auth-status.js";
import { DEFAULT_CONTEXT_USAGE, makeMockQuery } from "./helpers.js";

View File

@@ -4,7 +4,8 @@ import os from "node:os";
import path from "node:path";
import { describe, expect, it, Mock, vi, afterEach, beforeEach } from "vitest";
import type { AccountInfo } from "@anthropic-ai/claude-agent-sdk";
import { AcpClient, ClaudeAcpAgent } from "../acp-agent.js";
import { ClaudeAcpAgent } from "../v1/acp-agent.js";
import { AcpClient } from "../acp-client.js";
import {
billsClaudeSubscription,
CLAUDE_LOGIN_REQUIRED_MESSAGE,

View File

@@ -11,11 +11,11 @@ import * as fsSync from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import type { SessionInfo } from "@agentclientprotocol/sdk/unstable";
import type { ActivityState } from "../session-index/activity.js";
import { endedTaskId, searchBackgroundTasks } from "../session-index/background-tasks.js";
import { encodeProjectPath } from "../session-index/project-dirs.js";
import { SessionIndex } from "../session-index/session-index.js";
import { SessionIndexService } from "../session-index/service.js";
import type { ActivityState } from "../v1/session-index/activity.js";
import { endedTaskId, searchBackgroundTasks } from "../v1/session-index/background-tasks.js";
import { encodeProjectPath } from "../v1/session-index/project-dirs.js";
import { SessionIndex } from "../v1/session-index/session-index.js";
import { SessionIndexService } from "../v1/session-index/service.js";
let configDir: string;
let workspace: string;

View File

@@ -6,7 +6,8 @@ import {
} from "@agentclientprotocol/sdk/unstable";
import type { ClientCapabilities } from "@agentclientprotocol/sdk/unstable";
import { getSessionMessages, type Options } from "@anthropic-ai/claude-agent-sdk";
import type { AcpClient, ClaudeAcpAgent as ClaudeAcpAgentType } from "../acp-agent.js";
import type { ClaudeAcpAgent as ClaudeAcpAgentType } from "../v1/acp-agent.js";
import type { AcpClient } from "../acp-client.js";
import type { AcpSessionNotification } from "../acp-subagents.js";
import { ALLOW_BYPASS } from "../permissions/modes.js";
import { NativeSubagentRuntime } from "../native-subagents.js";
@@ -117,7 +118,7 @@ describe("createSession options merging", () => {
});
vi.resetModules();
const acpAgent = await import("../acp-agent.js");
const acpAgent = await import("../v1/acp-agent.js");
ClaudeAcpAgent = acpAgent.ClaudeAcpAgent;
agent = new ClaudeAcpAgent(createMockClient());
@@ -176,25 +177,19 @@ describe("createSession options merging", () => {
});
});
it("registers the PreToolUse hook of the change previews only on a v2 connection", async () => {
it("registers no PreToolUse hook of change previews", async () => {
await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
// A v1 client gets the input's diff, so Claude Code waits on no hook.
expect(capturedOptions!.hooks?.PreToolUse).toBeUndefined();
const v2Agent = new ClaudeAcpAgent(createMockClient(), undefined, { v2: true });
await v2Agent.newSession({ cwd: process.cwd(), mcpServers: [] });
expect(capturedOptions!.hooks?.PreToolUse).toEqual([
{ matcher: "Edit|Write", hooks: [expect.any(Function)] },
]);
});
it.each([false, true])("reports child settings through registered hooks (v2: %s)", async (v2) => {
it("reports child settings through registered hooks", async () => {
const updates: AcpSessionNotification[] = [];
const client = createMockClient();
client.sessionUpdate = async (notification) => {
updates.push(notification);
};
const observedAgent = new ClaudeAcpAgent(client, undefined, { v2 });
const observedAgent = new ClaudeAcpAgent(client);
await observedAgent.initialize({ protocolVersion: 1, clientCapabilities: { subagents: {} } });
const { sessionId } = await observedAgent.newSession({ cwd: process.cwd(), mcpServers: [] });
const session = observedAgent.sessions[sessionId]!;

View File

@@ -1,7 +1,8 @@
import { randomUUID } from "node:crypto";
import { describe, expect, it, vi } from "vitest";
import type { SessionNotification } from "@agentclientprotocol/sdk/unstable";
import { ClaudeAcpAgent, type AcpClient } from "../acp-agent.js";
import { ClaudeAcpAgent } from "../v1/acp-agent.js";
import type { AcpClient } from "../acp-client.js";
import { Writable } from "node:stream";
import { EventLoopYielder, nodeToWebWritable, Pushable } from "../utils.js";
import {

View File

@@ -12,8 +12,8 @@ import {
FAST_MODE_ON,
FAST_MODE_OFF,
ClaudeAcpAgent,
type AcpClient,
} from "../acp-agent.js";
} from "../v1/acp-agent.js";
import type { AcpClient } from "../acp-client.js";
const MODES = {
currentModeId: "default",

View File

@@ -3,13 +3,13 @@ import { mkdtemp, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { AcpClient } from "../acp-client.js";
import {
AcpClient,
StreamedToolInputCache,
streamEventToAcpNotifications,
toAcpNotifications,
ToolUseCache,
} from "../acp-agent.js";
} from "../sdk-to-acp.js";
import { ToolCallFieldTracker } from "../tool-calls/field-tracker.js";
import { createPostToolUseHook } from "../tools.js";

View File

@@ -3,7 +3,8 @@ import type { SessionNotification } from "@agentclientprotocol/sdk/unstable";
import { randomUUID } from "node:crypto";
import * as path from "node:path";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { AcpClient, ClaudeAcpAgent as ClaudeAcpAgentType } from "../acp-agent.js";
import type { ClaudeAcpAgent as ClaudeAcpAgentType } from "../v1/acp-agent.js";
import type { AcpClient } from "../acp-client.js";
import {
AGENT_FILE_CHANGE_REPORT_CAPABILITY,
type AgentFileChangeReportResult,
@@ -257,7 +258,7 @@ describe("native file-change report integration", () => {
readTextFile: async () => ({ content: "" }),
writeTextFile: async () => ({}),
} as unknown as AcpClient;
const { ClaudeAcpAgent } = await import("../acp-agent.js");
const { ClaudeAcpAgent } = await import("../v1/acp-agent.js");
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(client, {
log: () => {},
error: () => {},

View File

@@ -170,7 +170,7 @@ beforeAll(async () => {
vi.stubEnv("CLAUDE_CODE_EXECUTABLE", "/usr/bin/false");
configDir = fs.mkdtempSync(path.join(os.tmpdir(), "acp-scenario-config-"));
vi.stubEnv("CLAUDE_CONFIG_DIR", configDir);
const { ClaudeAcpAgent } = await import("../acp-agent.js");
const { ClaudeAcpAgent } = await import("../v1/acp-agent.js");
for (const profile of runProfiles) {
resetIds();
const run = await unlessHung(runScenario(ClaudeAcpAgent, profile, scenario));

View File

@@ -1,6 +1,7 @@
import { describe, expect, it, vi } from "vitest";
import { randomUUID } from "node:crypto";
import { ClaudeAcpAgent, type AcpClient } from "../acp-agent.js";
import { ClaudeAcpAgent } from "../v1/acp-agent.js";
import type { AcpClient } from "../acp-client.js";
import { Pushable } from "../utils.js";
import {
mockSessionState,

View File

@@ -4,9 +4,9 @@ import { createHash, randomUUID } from "node:crypto";
import * as fs from "node:fs/promises";
import * as path from "node:path";
import * as os from "node:os";
import { messageIdForGrouping } from "../acp-agent.js";
import { messageIdForGrouping } from "../sdk-to-acp.js";
import { forkSession } from "../fork-session.js";
import { encodeProjectPath } from "../session-index/project-dirs.js";
import { encodeProjectPath } from "../v1/session-index/project-dirs.js";
// Use the real SDK against temporary transcripts, without starting Claude Code.
let root: string;

View File

@@ -34,6 +34,7 @@ export function makeMockQuery(overrides: Record<string, unknown> = {}) {
setPermissionMode: async () => {},
supportedCommands: async () => [],
mcpServerStatus: async () => [],
setMcpServers: async () => ({ added: [], removed: [], errors: {} }),
getContextUsage: async () => DEFAULT_CONTEXT_USAGE,
// No ultracode fields: session creation falls back to settings and the
// model's effort levels, as with a CLI that does not report them.

View File

@@ -9,7 +9,8 @@ import * as os from "node:os";
import * as path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { Options, SDKUserMessage } from "@anthropic-ai/claude-agent-sdk";
import type { AcpClient, ClaudeAcpAgent } from "../acp-agent.js";
import type { ClaudeAcpAgent } from "../v1/acp-agent.js";
import type { AcpClient } from "../acp-client.js";
import { initializeClient } from "./helpers.js";
import { successfulResultMessage } from "./session-doubles.js";
@@ -227,7 +228,7 @@ function scriptClaudeCode({
async function connect(clientCapabilities: Record<string, unknown> = {}) {
vi.resetModules();
const { ClaudeAcpAgent } = await import("../acp-agent.js");
const { ClaudeAcpAgent } = await import("../v1/acp-agent.js");
const updates: Array<{ update: Record<string, any> }> = [];
const client = {
sessionUpdate: async (notification: { update: Record<string, any> }) => {

View File

@@ -1,5 +1,6 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { ClaudeAcpAgent, type AcpClient } from "../acp-agent.js";
import { ClaudeAcpAgent } from "../v1/acp-agent.js";
import type { AcpClient } from "../acp-client.js";
import { clearHookCallbacks, createPostToolUseHook, hasHookCallback } from "../tools.js";
import { Pushable } from "../utils.js";
import {

View File

@@ -9,7 +9,8 @@ import type {
SessionNotification,
} from "@agentclientprotocol/sdk/unstable";
import { describe, expect, it } from "vitest";
import { ClaudeAcpAgent, type AcpClient } from "../acp-agent.js";
import { ClaudeAcpAgent } from "../v1/acp-agent.js";
import type { AcpClient } from "../acp-client.js";
type JsonObject = Record<string, unknown>;

View File

@@ -9,7 +9,8 @@ import type {
SessionNotification,
} from "@agentclientprotocol/sdk/unstable";
import { describe, expect, it } from "vitest";
import { ClaudeAcpAgent, type AcpClient } from "../acp-agent.js";
import { ClaudeAcpAgent } from "../v1/acp-agent.js";
import type { AcpClient } from "../acp-client.js";
type JsonObject = Record<string, unknown>;

View File

@@ -2,7 +2,9 @@ import { randomUUID } from "node:crypto";
import { describe, expect, it, vi } from "vitest";
import type { McpServerStatus } from "@anthropic-ai/claude-agent-sdk";
import type { SessionNotification } from "@agentclientprotocol/sdk/unstable";
import { ClaudeAcpAgent, stripLocalCommandMetadata, type AcpClient } from "../acp-agent.js";
import { ClaudeAcpAgent } from "../v1/acp-agent.js";
import { stripLocalCommandMetadata } from "../message-filters.js";
import type { AcpClient } from "../acp-client.js";
import {
cleanMcpError,
formatMcpStatus,

View File

@@ -74,10 +74,72 @@ describe("resolveModelPreference - resolvedModel matching", () => {
expect(resolveModelPreference(LIVE_SHAPED_MODELS, "claude-opus-4-8")?.value).toBe("opus[1m]");
});
it("returns null for a preference that matches nothing", () => {
expect(resolveModelPreference(LIVE_SHAPED_MODELS, "claude-gpt-99")).toBeNull();
// Words match whole: "a" is inside "Default", "son" inside "sonnet".
it.each(["claude-gpt-99", "not-a-model", "a", "son", "foo-sonnet", "not-default-model"])(
"returns null for %s, which names no model",
(preference) => {
expect(resolveModelPreference(LIVE_SHAPED_MODELS, preference)).toBeNull();
},
);
it.each([
["us.anthropic.claude-sonnet-5-v1:0", "sonnet"],
["global.anthropic.claude-haiku-4-5-20251001-v1:0", "haiku"],
["anthropic/claude-sonnet-5:batch", "sonnet"],
["claude-opus-4-8@20260101", "opus[1m]"],
["claude-sonnet-5-fast", "sonnet"],
["claude-haiku-4-5", "haiku"],
["opusplan", "opus[1m]"],
["opus", "opus[1m]"],
["Sonnet 5", "sonnet"],
// The CLI's `best` alias runs Opus and ignores a `[1m]` hint.
["best", "opus[1m]"],
["BEST", "opus[1m]"],
["best[1m]", "opus[1m]"],
])("resolves the spelling %s to %s", (preference, value) => {
expect(resolveModelPreference(LIVE_SHAPED_MODELS, preference)?.value).toBe(value);
});
it("resolves best to the Opus entry rather than the default when the default runs Sonnet", () => {
const models: ModelInfo[] = [
{
value: "default",
resolvedModel: "claude-sonnet-5",
displayName: "Default (recommended)",
description: "Use the default model (currently Sonnet 5)",
},
{ value: "opus", resolvedModel: "claude-opus-4-8", displayName: "Opus", description: "" },
{ value: "sonnet", resolvedModel: "claude-sonnet-5", displayName: "Sonnet", description: "" },
];
expect(resolveModelPreference(models, "best")?.value).toBe("opus");
});
// A gateway listing of dated ids, as `ANTHROPIC_MODEL`, `settings.model` or
// `set_config_option` may spell them.
const DATED_MODELS: ModelInfo[] = [
{ value: "claude-3-7-sonnet-20250219", displayName: "Claude 3.7 Sonnet", description: "" },
{ value: "claude-3-5-haiku-20241022", displayName: "Claude 3.5 Haiku", description: "" },
{ value: "claude-sonnet-4-5-20250929", displayName: "Claude Sonnet 4.5", description: "" },
];
it.each([
["claude-3-7-sonnet-latest", "claude-3-7-sonnet-20250219"],
["claude-3-5-haiku-latest", "claude-3-5-haiku-20241022"],
["openrouter/anthropic/claude-sonnet-4.5", "claude-sonnet-4-5-20250929"],
["vertex_ai/claude-sonnet-4-5@20250929", "claude-sonnet-4-5-20250929"],
["litellm/claude-sonnet-4-5", "claude-sonnet-4-5-20250929"],
["publishers/anthropic/models/claude-sonnet-4-5", "claude-sonnet-4-5-20250929"],
])("resolves the gateway spelling %s to %s", (preference, value) => {
expect(resolveModelPreference(DATED_MODELS, preference)?.value).toBe(value);
});
it.each(["openai/gpt-5", "openrouter/foo-sonnet", "claude-3-5-opus-latest"])(
"returns null for the gateway spelling %s, which names no listed model",
(preference) => {
expect(resolveModelPreference(DATED_MODELS, preference)).toBeNull();
},
);
it("matches a '-1m' id-suffix preference against a '[1m]'-spelled resolvedModel", () => {
// Without hint canonicalization the exact tier misses and the substring
// tier lands on the bare 200k sibling, silently downgrading a 1M pin.

View File

@@ -32,6 +32,7 @@ describe("module runtime exports", () => {
it("acp-agent.ts exports the expected names", async () => {
expect(Object.keys(await import("../acp-agent.js")).sort()).toEqual([
"AGENT_CONFIG_ID",
"AUTONOMOUS_RESULT_ORIGINS",
"BUILTIN_AGENT_NAMES",
"ClaudeAcpAgent",
"DEFAULT_AGENT_ID",
@@ -49,6 +50,8 @@ describe("module runtime exports", () => {
"createFastModeConfigOption",
"discoverCustomAgents",
"fastModeStateEnabled",
"getAvailableSlashCommands",
"isAbortedResult",
"isLocalCommandMetadata",
"isLoginRequiredText",
"isReplayHiddenMetaMessage",

View File

@@ -1,6 +1,7 @@
import { describe, it, expect } from "vitest";
import type { ClientCapabilities } from "@agentclientprotocol/sdk/unstable";
import { ClaudeAcpAgent, type AcpClient } from "../acp-agent.js";
import { ClaudeAcpAgent } from "../v1/acp-agent.js";
import type { AcpClient } from "../acp-client.js";
import { promptSuggestionsMeta, promptSuggestionText } from "../prompt-suggestions.js";
import { Pushable } from "../utils.js";
import {

View File

@@ -3,10 +3,9 @@ import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { ModelInfo, Options } from "@anthropic-ai/claude-agent-sdk";
import * as v2 from "@agentclientprotocol/sdk/experimental/v2/unstable";
import { ClaudeAcpAgent, FAST_MODE_CONFIG_ID, type AcpClient } from "../acp-agent.js";
import { ClaudeAcpAgent, FAST_MODE_CONFIG_ID } from "../v1/acp-agent.js";
import type { AcpClient } from "../acp-client.js";
import { AIR_RECOMMENDED_CONFIG_VALUE_CAPABILITY } from "../air-extension.js";
import { acpProtocolRouter } from "../serve.js";
import { initializeClient, makeMockQuery } from "./helpers.js";
import { successfulResultMessage, userEcho } from "./session-doubles.js";
@@ -747,50 +746,4 @@ describe("provider updates preserve session configuration", { timeout: 10_000 },
expect(lastQuery().state).toMatchObject({ model: "opus", effort: "high" });
expect(lastQuery().request.options.env?.ANTHROPIC_BASE_URL).toBe("https://recovered.example");
});
it("publishes the restored configuration through the v2 protocol router", async () => {
await agent.dispose();
const toAgent = new TransformStream<v2.AnyMessage, v2.AnyMessage>();
const toClient = new TransformStream<v2.AnyMessage, v2.AnyMessage>();
acpProtocolRouter({ log() {}, error() {} }, (created) => (agent = created)).connect({
readable: toAgent.readable,
writable: toClient.writable,
});
const received: v2.UpdateSessionNotification[] = [];
await v2
.client({ name: "provider-test" })
.onNotification(v2.methods.client.session.update, ({ params }) => void received.push(params))
.connectWith({ readable: toClient.readable, writable: toAgent.writable }, async (client) => {
await client.request(v2.methods.agent.initialize, {
protocolVersion: v2.PROTOCOL_VERSION,
info: { name: "provider-test", version: "1" },
capabilities: AIR_CAPABILITIES,
});
await client.request(v2.methods.agent.providers.set, PROVIDER);
const { sessionId } = await client.request(v2.methods.agent.session.new, { cwd });
for (const [configId, value] of [
["model", "opus"],
["effort", "high"],
]) {
await client.request(v2.methods.agent.session.setConfigOption, {
sessionId,
configId,
value,
type: "id",
});
}
await client.request(v2.methods.agent.providers.set, PROVIDER);
const config = received.findLast(
({ update }) => update.sessionUpdate === "config_option_update",
);
expect(config?.update).toMatchObject({
configOptions: expect.arrayContaining([
expect.objectContaining({ configId: "model", currentValue: "opus" }),
expect.objectContaining({ configId: "effort", currentValue: "high" }),
]),
});
});
});
});

View File

@@ -1,5 +1,7 @@
import { describe, expect, it, Mock, vi, afterEach, beforeEach } from "vitest";
import { AcpClient, ClaudeAcpAgent } from "../acp-agent.js";
import { ClaudeAcpAgent } from "../v1/acp-agent.js";
import { AcpClient } from "../acp-client.js";
import { loggableBaseUrl } from "../providers.js";
const mockQuery = vi.hoisted(() =>
vi.fn(() => ({
@@ -922,3 +924,14 @@ describe("providers", () => {
});
});
});
describe("loggableBaseUrl", () => {
it.each([
["https://user:secret@gateway.example.com/v1?key=abc", "https://gateway.example.com"],
["https://gateway.example.com:8443/proxy/sk-123", "https://gateway.example.com:8443"],
["not a url", "invalid"],
[undefined, "native"],
])("logs %s as %s", (baseUrl, logged) => {
expect(loggableBaseUrl(baseUrl)).toBe(logged);
});
});

View File

@@ -1,7 +1,7 @@
import { describe, expect, it } from "vitest";
import type { ClientCapabilities } from "@agentclientprotocol/sdk/unstable";
import type { ModelInfo } from "@anthropic-ai/claude-agent-sdk";
import { buildConfigOptions, clientSupportsRecommendedConfigValue } from "../acp-agent.js";
import { buildConfigOptions, clientSupportsRecommendedConfigValue } from "../v1/acp-agent.js";
import { AIR_RECOMMENDED_CONFIG_VALUE_CAPABILITY } from "../air-extension.js";
const MODES = {

View File

@@ -1,5 +1,6 @@
import { describe, it, expect, vi } from "vitest";
import { ClaudeAcpAgent, type AcpClient } from "../acp-agent.js";
import { ClaudeAcpAgent } from "../v1/acp-agent.js";
import type { AcpClient } from "../acp-client.js";
import { mockSessionState } from "./session-doubles.js";
/** A stream that stays open, like the real one: a consumer that reaches its end

View File

@@ -1,5 +1,6 @@
import { describe, it, expect, vi } from "vitest";
import { ClaudeAcpAgent, type AcpClient } from "../acp-agent.js";
import { ClaudeAcpAgent } from "../v1/acp-agent.js";
import type { AcpClient } from "../acp-client.js";
import { Pushable } from "../utils.js";
import { mockSessionState, successfulResultMessage, wrapQuery } from "./session-doubles.js";

View File

@@ -1,5 +1,5 @@
import { describe, it, expect, vi } from "vitest";
import type { Logger } from "../acp-agent.js";
import type { Logger } from "../logger.js";
import { resolvePermissionMode } from "../permissions/modes.js";
function mockLogger() {

View File

@@ -0,0 +1,64 @@
import { describe, expect, it } from "vitest";
import type { SDKResultMessage } from "@anthropic-ai/claude-agent-sdk";
import { resultEnd, type ResultEnd } from "../result-end.js";
const LIMITS = ["error_max_turns", "error_max_budget_usd", "error_max_structured_output_retries"];
/** A result of `subtype`, with the error text `text` on `success` and `["a", "b"]` otherwise. */
function result(
subtype: string,
stopReason: string | null,
isError: boolean,
text = "API Error: 500",
): SDKResultMessage {
return {
type: "result",
subtype,
stop_reason: stopReason,
is_error: isError,
...(subtype === "success" ? { result: text } : { errors: ["a", "b"] }),
} as unknown as SDKResultMessage;
}
const failure = (error: string, loginRequired = false): ResultEnd => ({ error, loginRequired });
/** The subtype, `stop_reason` and `is_error` of a result, and how it ends the work. */
type Row = [string, string | null, boolean, ResultEnd];
describe("resultEnd", () => {
it.each<Row>([
...["success", "error_during_execution", ...LIMITS].flatMap((subtype) =>
[true, false].map((isError): Row => [subtype, "refusal", isError, { stopReason: "refusal" }]),
),
["success", "max_tokens", true, { stopReason: "max_tokens" }],
["success", "max_tokens", false, { stopReason: "max_tokens" }],
["success", "end_turn", true, failure("API Error: 500")],
["success", null, false, { stopReason: "end_turn" }],
["error_during_execution", "max_tokens", true, { stopReason: "max_tokens" }],
["error_during_execution", "max_tokens", false, { stopReason: "max_tokens" }],
["error_during_execution", "end_turn", true, failure("a, b")],
["error_during_execution", null, false, { stopReason: "end_turn" }],
// A limit ignores `max_tokens`.
...LIMITS.flatMap((subtype) =>
["max_tokens", null].flatMap((stopReason): Row[] => [
[subtype, stopReason, true, failure("a, b")],
[subtype, stopReason, false, { stopReason: "max_turn_requests" }],
]),
),
])("reads %s with stop_reason %s and is_error %s", (subtype, stopReason, isError, expected) => {
expect(resultEnd(result(subtype, stopReason, isError))).toEqual(expected);
});
it.each([null, "max_tokens"])(
"fails a success error with the /login text, also with stop_reason %s",
(stopReason) => {
const text = "Not logged in · Please run /login";
expect(resultEnd(result("success", stopReason, true, text))).toEqual(failure(text, true));
},
);
it("fails a limit error with its subtype when Claude Code lists no errors", () => {
const limit = { ...result("error_max_turns", null, true), errors: [] } as SDKResultMessage;
expect(resultEnd(limit)).toEqual(failure("error_max_turns"));
});
});

View File

@@ -1,7 +1,8 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { SessionNotification } from "@agentclientprotocol/sdk/unstable";
import type { ModelInfo } from "@anthropic-ai/claude-agent-sdk";
import type { AcpClient, ClaudeAcpAgent as ClaudeAcpAgentType } from "../acp-agent.js";
import type { ClaudeAcpAgent as ClaudeAcpAgentType } from "../v1/acp-agent.js";
import type { AcpClient } from "../acp-client.js";
import { makeMockQuery } from "./helpers.js";
const { registerHookCallbackSpy } = vi.hoisted(() => ({
@@ -132,7 +133,7 @@ describe("session config options", () => {
registerHookCallbackSpy.mockClear();
vi.resetModules();
const acpAgent = await import("../acp-agent.js");
const acpAgent = await import("../v1/acp-agent.js");
ClaudeAcpAgent = acpAgent.ClaudeAcpAgent;
agent = new ClaudeAcpAgent(createMockClient());
@@ -330,18 +331,28 @@ describe("session config options", () => {
expect(modelOption?.currentValue).toBe("claude-sonnet-4-6");
});
it("throws for completely invalid model value", async () => {
await expect(
agent.setSessionConfigOption({
sessionId: SESSION_ID,
configId: "model",
value: "gpt-4",
}),
).rejects.toMatchObject({
code: -32602,
message: expect.stringContaining("Invalid value for config option model: gpt-4"),
});
});
// "a" is a substring of "Claude", "son" of "sonnet": only whole words match.
it.each(["gpt-4", "not-a-model", "son", "foo-sonnet"])(
"rejects the model %s and leaves the model as it was",
async (value) => {
await expect(
agent.setSessionConfigOption({ sessionId: SESSION_ID, configId: "model", value }),
).rejects.toMatchObject({
code: -32602,
message: expect.stringContaining(`Invalid value for config option model: ${value}`),
});
expect(setModelSpy).not.toHaveBeenCalled();
const session = (
agent as unknown as {
sessions: Record<string, { configOptions: typeof MOCK_CONFIG_OPTIONS }>;
}
).sessions[SESSION_ID];
expect(session.configOptions.find((o) => o.id === "model")?.currentValue).toBe(
"claude-opus-4-5",
);
},
);
it("rejects a non-string value for a select option with invalid params", async () => {
await expect(

View File

@@ -7,7 +7,8 @@
import { vi } from "vitest";
import { randomUUID } from "crypto";
import { SessionBackgroundWork } from "../background-work.js";
import { SessionBackgroundWork } from "../v1/background-work.js";
import { PromptSuggestions } from "../prompt-suggestions.js";
import { SessionTitles } from "../session-titles.js";
/** Stand-in agent for a `SessionTitles` whose test doesn't care about titles:
@@ -93,11 +94,15 @@ export function mockSessionState(
toolUseCache: {},
emittedToolCalls: new Set(),
liveBackgroundTasks: new Map(),
background: new SessionBackgroundWork(sessionId, false),
background: new SessionBackgroundWork(sessionId),
emittedAssistantText: false,
owedTrailingIdles: 0,
messageIdToUuid: new Map(),
sessionFailureState: { epoch: randomUUID(), revisions: new Map(), active: new Map() },
// As `createSession` gives it to the session of an AIR client.
...(agent?.toolCallCapabilities?.air?.client
? { promptSuggestions: new PromptSuggestions() }
: {}),
...overrides,
} as any;
}

View File

@@ -11,7 +11,8 @@ import { tmpdir } from "node:os";
import path from "node:path";
import type { NewSessionRequest } from "@agentclientprotocol/sdk/unstable";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { ClaudeAcpAgent, type AcpClient } from "../acp-agent.js";
import { ClaudeAcpAgent } from "../v1/acp-agent.js";
import type { AcpClient } from "../acp-client.js";
function sse(type: string, data: object): string {
return `event: ${type}\ndata: ${JSON.stringify({ type, ...data })}\n\n`;

View File

@@ -2,28 +2,28 @@ import { describe, expect, it } from "vitest";
import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import { deriveActivity, selectCost, type ActivityState } from "../session-index/activity.js";
import { LiveSessionRegistry, type LiveRecord } from "../session-index/live-registry.js";
import { deriveActivity, selectCost, type ActivityState } from "../v1/session-index/activity.js";
import { LiveSessionRegistry, type LiveRecord } from "../v1/session-index/live-registry.js";
import {
encodeProjectPath,
isSessionId,
pathAndAncestors,
projectDirMatches,
} from "../session-index/project-dirs.js";
} from "../v1/session-index/project-dirs.js";
import {
decodeListCursor,
encodeListCursor,
parseListOptions,
parseRenameSessionRequest,
type ListScope,
} from "../session-index/service.js";
} from "../v1/session-index/service.js";
import {
effectiveTitle,
isArchivedTitle,
storedTitle,
titleRecords,
visibleTitle,
} from "../session-index/archive-title.js";
} from "../v1/session-index/archive-title.js";
import {
agentWorktreeOf,
continuedInSessionId,
@@ -33,11 +33,11 @@ import {
sdkTitles,
transcriptAgentName,
type TranscriptFacts,
} from "../session-index/transcript-scan.js";
import { firstPrompt } from "../session-index/first-prompt.js";
import { DirListings, statFiles } from "../session-index/dir-listing.js";
import { SessionIndex, type IndexRow } from "../session-index/session-index.js";
import { changeSignature, sessionInfoOf } from "../session-index/session-info.js";
} from "../v1/session-index/transcript-scan.js";
import { firstPrompt } from "../v1/session-index/first-prompt.js";
import { DirListings, statFiles } from "../v1/session-index/dir-listing.js";
import { SessionIndex, type IndexRow } from "../v1/session-index/session-index.js";
import { changeSignature, sessionInfoOf } from "../v1/session-index/session-info.js";
const SESSION = "11111111-1111-4111-8111-111111111111";
const lines = (...entries: object[]) => entries.map((entry) => JSON.stringify(entry)).join("\n");

View File

@@ -16,13 +16,14 @@ import {
listSessions,
} from "@anthropic-ai/claude-agent-sdk";
import { spawn } from "node:child_process";
import { ClaudeAcpAgent, v1AgentApp, type AcpClient } from "../acp-agent.js";
import { ClaudeAcpAgent, v1AgentApp } from "../v1/acp-agent.js";
import type { AcpClient } from "../acp-client.js";
import {
client as acpClient,
type InitializeResponse,
type SessionInfo,
} from "@agentclientprotocol/sdk/unstable";
import { encodeProjectPath } from "../session-index/project-dirs.js";
import { encodeProjectPath } from "../v1/session-index/project-dirs.js";
import {
archiveInsteadOfDelete,
archiveTo,
@@ -30,16 +31,16 @@ import {
parseBatchGetSessionsRequest,
SessionIndexService,
writeCustomTitleSidecar,
} from "../session-index/service.js";
import { scanTranscriptFile } from "../session-index/transcript-scan.js";
import { repositoryWorktrees } from "../session-index/worktrees.js";
} from "../v1/session-index/service.js";
import { scanTranscriptFile } from "../v1/session-index/transcript-scan.js";
import { repositoryWorktrees } from "../v1/session-index/worktrees.js";
import {
ListSubscriptions,
MAX_SUBSCRIPTIONS,
parseListSubscribeRequest,
parseListUnsubscribeRequest,
type ListChanges,
} from "../session-index/list-subscriptions.js";
} from "../v1/session-index/list-subscriptions.js";
import { SessionTitles } from "../session-titles.js";
import { initializeClient } from "./helpers.js";
import {
@@ -164,7 +165,7 @@ async function writeTranscript(options: TranscriptOptions): Promise<{ id: string
return { id, file };
}
function createAgent(options: { v2?: boolean } = {}) {
function createAgent() {
const notifications: { method: string; params: Record<string, unknown> }[] = [];
const updates: unknown[] = [];
const client = {
@@ -175,7 +176,7 @@ function createAgent(options: { v2?: boolean } = {}) {
notifications.push({ method, params });
},
} as unknown as AcpClient;
const agent = new ClaudeAcpAgent(client, { log: () => {}, error: () => {} }, options);
const agent = new ClaudeAcpAgent(client, { log: () => {}, error: () => {} });
return { agent, notifications, updates };
}
@@ -317,16 +318,6 @@ describe("sessionIndex negotiation", () => {
expect(nonAir._meta).toEqual({ steering: { supported: true } });
});
it("is not advertised under ACP v2", async () => {
const response = await createAgent({ v2: true }).agent.initialize({
protocolVersion: 1,
clientCapabilities: air("sessionIndex"),
});
for (const capability of indexCapabilities) {
expect(airCapabilities(response)).not.toContain(capability);
}
});
it("answers method-not-found to the new methods without the capability", async () => {
// Declaring sessionArchive or sessionRename without sessionIndex enables
// nothing either.
@@ -745,15 +736,6 @@ describe("_session/batch_get", () => {
}
},
);
it("rejects under ACP v2 even when sessionIndex was declared", async () => {
const { agent } = createAgent({ v2: true });
await initializeClient(agent, air("sessionIndex"));
await expect(agent.batchGetSessions({ sessionIds: [] })).rejects.toMatchObject({
code: -32601,
});
await agent.dispose();
});
});
describe("session/list of a sessionIndex client", () => {
@@ -1872,7 +1854,7 @@ describe("_session/list/subscribe", () => {
});
it("clears the failed turn of a session that runs here when a new turn starts", async () => {
const { noteSessionState } = await import("../session-index/connection.js");
const { noteSessionState } = await import("../v1/session-index/connection.js");
const session = { lastTurnFailed: true } as any;
noteSessionState(session, "idle", "running");
expect(session.lastTurnFailed).toBe(false);

View File

@@ -2,7 +2,8 @@ import { describe, it, expect } from "vitest";
import { randomUUID } from "crypto";
import { RequestError, SessionNotification } from "@agentclientprotocol/sdk/unstable";
import { query, getSessionMessages } from "@anthropic-ai/claude-agent-sdk";
import { AcpClient, ClaudeAcpAgent } from "../acp-agent.js";
import { ClaudeAcpAgent } from "../v1/acp-agent.js";
import { AcpClient } from "../acp-client.js";
import { Pushable } from "../utils.js";
import type { SDKUserMessage } from "@anthropic-ai/claude-agent-sdk";

View File

@@ -1,6 +1,7 @@
import { beforeEach, describe, expect, it } from "vitest";
import type { SessionNotification } from "@agentclientprotocol/sdk/unstable";
import { ClaudeAcpAgent, type AcpClient } from "../acp-agent.js";
import { ClaudeAcpAgent } from "../v1/acp-agent.js";
import type { AcpClient } from "../acp-client.js";
import { makeMockQuery } from "./helpers.js";
const SESSION_ID = "test-session-id";

View File

@@ -2,7 +2,8 @@ import { describe, it, expect, beforeEach, vi } from "vitest";
import { randomUUID } from "node:crypto";
import { readFile } from "node:fs/promises";
import { createRequire } from "node:module";
import { ClaudeAcpAgent, type AcpClient } from "../acp-agent.js";
import { ClaudeAcpAgent } from "../v1/acp-agent.js";
import type { AcpClient } from "../acp-client.js";
import { appendTitleContext, sanitizeTitle } from "../session-titles.js";
import { Pushable } from "../utils.js";
import { getSessionInfo } from "@anthropic-ai/claude-agent-sdk";
@@ -115,6 +116,12 @@ describe("session titles", () => {
return new ClaudeAcpAgent(client, { log: () => {}, error: () => {} });
}
/** Sends the current title of `test-session` (see `SessionTitles.publishCurrent`). */
async function publishCurrentSessionInfo(agent: ClaudeAcpAgent) {
const session = agent.sessions["test-session"]!;
await session.titles.publishCurrent(session);
}
const LONG_PROMPT = "Explain what the add function in hello.py does, in one sentence";
it("pushes a session_info_update when the SDK generates a title at turn-end", async () => {
@@ -198,8 +205,8 @@ describe("session titles", () => {
agent,
);
await agent.publishCurrentSessionInfo("test-session");
await agent.publishCurrentSessionInfo("test-session");
await publishCurrentSessionInfo(agent);
await publishCurrentSessionInfo(agent);
const current = {
sessionUpdate: "session_info_update",
@@ -221,11 +228,11 @@ describe("session titles", () => {
// No file and no title yet: nothing to send.
vi.mocked(getSessionInfo).mockResolvedValue(undefined);
await agent.publishCurrentSessionInfo("test-session");
await publishCurrentSessionInfo(agent);
expect(titles()).toEqual([]);
await agent.sessions["test-session"]!.titles.setExplicitTitle("Renamed", async () => undefined);
await agent.publishCurrentSessionInfo("test-session");
await publishCurrentSessionInfo(agent);
expect(titles()).toEqual(["Renamed", "Renamed"]);
});
@@ -245,7 +252,7 @@ describe("session titles", () => {
summary: "please fix the build",
lastModified: 1_700_000_000_000,
} as any);
await agent.publishCurrentSessionInfo("test-session");
await publishCurrentSessionInfo(agent);
expect(titles()).toEqual(["Renamed", "Renamed"]);
});

View File

@@ -3,10 +3,11 @@ import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import type { ClientCapabilities } from "@agentclientprotocol/sdk/unstable";
import { AcpClient, toAcpNotifications, ToolUseCache } from "../acp-agent.js";
import { AcpClient } from "../acp-client.js";
import { toAcpNotifications, ToolUseCache } from "../sdk-to-acp.js";
import { ClientCapabilities as ToolCallCapabilities } from "../tool-calls/client-capabilities.js";
import { backgroundedBashToolCall, withRunningTerminal } from "../tool-calls/background.js";
import { ChangedMetaFilter } from "../tool-calls/changed-meta-filter.js";
import { ChangedMetaFilter } from "../v1/tool-calls/changed-meta-filter.js";
import { ToolCallFieldTracker } from "../tool-calls/field-tracker.js";
import { AcpToolCallRenderer, mcpToolName } from "../tool-calls/renderer.js";
import { changedTaskPlanEntries, createTaskHook, type TaskState } from "../tools.js";

View File

@@ -22,7 +22,9 @@ import {
BetaBashCodeExecutionResultBlock,
BetaBashCodeExecutionToolResultBlockParam,
} from "@anthropic-ai/sdk/resources/beta.mjs";
import { AcpClient, toAcpNotifications, ToolUseCache, Logger } from "../acp-agent.js";
import { AcpClient } from "../acp-client.js";
import { toAcpNotifications, ToolUseCache } from "../sdk-to-acp.js";
import { Logger } from "../logger.js";
import {
toolUpdateFromToolResult,
createPostToolUseHook,

View File

@@ -4,12 +4,10 @@
* Claude Code: it echoes each prompt, then runs the turn's script.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type {
RequestPermissionRequest,
SessionNotification,
} from "@agentclientprotocol/sdk/unstable";
import type { AcpClient, ClaudeAcpAgent } from "../acp-agent.js";
import type { ForegroundEvents, TurnEvents } from "../turn-events.js";
import type { SessionNotification } from "@agentclientprotocol/sdk/unstable";
import type { ClaudeAcpAgent } from "../v1/acp-agent.js";
import type { AcpClient } from "../acp-client.js";
import type { TurnEvents } from "../v1/turn-events.js";
import { DEFAULT_CONTEXT_USAGE } from "./helpers.js";
import { randomUUID } from "node:crypto";
@@ -195,41 +193,19 @@ function system(subtype: string, sessionId: string, fields: Record<string, unkno
describe("turn events", () => {
let agent: ClaudeAcpAgent;
let permissionRequests: RequestPermissionRequest[];
/** The user answers a request for this tool call only once it resolves. */
let backgroundAnswer: PromiseWithResolvers<void>;
/** Sees each session update; the agent waits until it returns. */
let onSessionUpdate: (notification: SessionNotification) => Promise<void> | void;
/** What the agent reported of the session's foreground work. */
let foreground: string[];
beforeEach(async () => {
vi.resetModules();
permissionRequests = [];
backgroundAnswer = Promise.withResolvers<void>();
onSessionUpdate = () => {};
foreground = [];
const { ClaudeAcpAgent } = await import("../acp-agent.js");
const foregroundEvents: ForegroundEvents = {
running: () => foreground.push("running"),
awaitingUser: () => foreground.push("awaitingUser"),
ended: (outcome) => foreground.push(`ended ${outcome.stopReason}`),
failed: () => foreground.push("failed"),
};
const { ClaudeAcpAgent } = await import("../v1/acp-agent.js");
agent = new ClaudeAcpAgent(
{
sessionUpdate: async (notification: SessionNotification) => onSessionUpdate(notification),
extNotification: async () => {},
requestPermission: async (request: RequestPermissionRequest) => {
permissionRequests.push(request);
await new Promise((resolve) => setTimeout(resolve, 1));
if (request.toolCall.toolCallId === "toolu_background") await backgroundAnswer.promise;
const allow = request.options.find((option) => option.kind === "allow_once");
return { outcome: { outcome: "selected", optionId: allow!.optionId } };
},
} as unknown as AcpClient,
undefined,
{ foreground: () => foregroundEvents },
);
});
@@ -333,172 +309,6 @@ describe("turn events", () => {
]);
});
it("reports waiting on a permission request, then resuming", async () => {
scriptTurns([
async function* (options) {
await options.canUseTool(
"Bash",
{ command: "ls" },
{ signal: new AbortController().signal, suggestions: [], toolUseID: "toolu_ls" },
);
yield {
type: "user",
message: {
role: "user",
content: [{ type: "tool_result", tool_use_id: "toolu_ls", content: "README.md" }],
},
parent_tool_use_id: null,
uuid: randomUUID(),
session_id: options.sessionId!,
};
yield result(options.sessionId!);
},
]);
const sessionId = await newSession();
const turn = recordEvents();
await agent.startTurn(prompt(sessionId, "list files"), turn.events);
await turn.done;
expect(permissionRequests).toHaveLength(1);
expect(turn.log.slice(1)).toEqual(["ended end_turn"]);
expect(foreground).toEqual(["running", "awaitingUser", "running", "ended end_turn"]);
});
it("reports waiting on a request that Claude Code opens for a started turn before its echo", async () => {
let whileOpen: string[] = [];
scriptQuery(async function* (input, options) {
const { value } = await input.next();
yield lifecycle(value.uuid, "started", options.sessionId!);
// Claude Code asks for the prompt before the consumer reaches its echo.
// The user answers it only once the test resolves `backgroundAnswer`.
const decision = options.canUseTool!(
"Bash",
{ command: "ls" },
{ signal: new AbortController().signal, suggestions: [], toolUseID: "toolu_background" },
);
await vi.waitFor(() => expect(permissionRequests).toHaveLength(1));
whileOpen = [...foreground];
backgroundAnswer.resolve();
await decision;
yield echo(value, options.sessionId!);
yield {
type: "user",
message: {
role: "user",
content: [{ type: "tool_result", tool_use_id: "toolu_background", content: "README.md" }],
},
parent_tool_use_id: null,
uuid: randomUUID(),
session_id: options.sessionId!,
};
yield result(options.sessionId!);
await input.next();
});
const sessionId = await newSession();
const turn = recordEvents();
await agent.startTurn(prompt(sessionId, "list files"), turn.events);
await turn.done;
expect(whileOpen).toEqual(["running", "awaitingUser"]);
expect(foreground).toEqual(["running", "awaitingUser", "running", "ended end_turn"]);
});
it("reports work that starts while a request is open as waiting at once", async () => {
let atStart: string[] = [];
scriptQuery(async function* (input, options) {
const { value } = await input.next();
// A request that no work opened, such as a background task's. The user
// answers it only once the test resolves `backgroundAnswer`.
const background = options.canUseTool!(
"Bash",
{ command: "sleep 60" },
{ signal: new AbortController().signal, suggestions: [], toolUseID: "toolu_background" },
);
await vi.waitFor(() => expect(permissionRequests).toHaveLength(1));
yield lifecycle(value.uuid, "started", options.sessionId!);
// Before the prompt's echo, which would report the request too.
atStart = [...foreground];
backgroundAnswer.resolve();
await background;
yield echo(value, options.sessionId!);
yield result(options.sessionId!);
await input.next();
});
const sessionId = await newSession();
const turn = recordEvents();
await agent.startTurn(prompt(sessionId, "list files"), turn.events);
await turn.done;
expect(atStart).toEqual(["running", "awaitingUser"]);
});
it("awaits the user while any request in the session is open, also one opened before the turn", async () => {
const first = recordEvents();
const second = recordEvents();
let backgroundRequest: Promise<unknown> | undefined;
let atStart: string[] = [];
let afterOwnAnswer: string[] | undefined;
scriptTurns([
async function* (options) {
yield result(options.sessionId!);
await first.done;
// A request that no active turn opened, such as a background task's.
backgroundRequest = options.canUseTool(
"Bash",
{ command: "sleep 60" },
{ signal: new AbortController().signal, suggestions: [], toolUseID: "toolu_background" },
);
},
async function* (options) {
atStart = [...foreground];
await options.canUseTool(
"Bash",
{ command: "ls" },
{ signal: new AbortController().signal, suggestions: [], toolUseID: "toolu_ls" },
);
afterOwnAnswer = [...foreground];
await backgroundRequest;
yield {
type: "user",
message: {
role: "user",
content: [{ type: "tool_result", tool_use_id: "toolu_ls", content: "README.md" }],
},
parent_tool_use_id: null,
uuid: randomUUID(),
session_id: options.sessionId!,
};
yield result(options.sessionId!);
},
]);
const sessionId = await newSession();
await agent.startTurn(prompt(sessionId, "start a background task"), first.events);
await vi.waitFor(() => expect(permissionRequests).toHaveLength(1));
await agent.startTurn(prompt(sessionId, "list files"), second.events);
await vi.waitFor(() => expect(afterOwnAnswer).toBeDefined());
backgroundAnswer.resolve();
await second.done;
expect(first.log.slice(1)).toEqual(["ended end_turn"]);
// The first turn's work ended before the background request opened, and
// the second turn waits from insertion, through its own answered request,
// until the earlier request is answered too.
const firstWork = ["running", "ended end_turn"];
expect(atStart).toEqual([...firstWork, "running", "awaitingUser"]);
expect(afterOwnAnswer).toEqual([...firstWork, "running", "awaitingUser"]);
expect(foreground).toEqual([
...firstWork,
"running",
"awaitingUser",
"running",
"ended end_turn",
]);
});
it("ends a queued turn that is cancelled before it is inserted, without inserting it", async () => {
const release = Promise.withResolvers<void>();
scriptTurns([

View File

@@ -4,7 +4,8 @@ import * as os from "node:os";
import * as path from "node:path";
import type { ClientCapabilities } from "@agentclientprotocol/sdk/unstable";
import type { ModelInfo, Query } from "@anthropic-ai/claude-agent-sdk";
import type { AcpClient, ClaudeAcpAgent as ClaudeAcpAgentType } from "../acp-agent.js";
import type { ClaudeAcpAgent as ClaudeAcpAgentType } from "../v1/acp-agent.js";
import type { AcpClient } from "../acp-client.js";
import {
createUltracodeConfigOption,
modelSupportsUltracode,
@@ -168,7 +169,7 @@ describe("ultracode session config option", () => {
...(opts.getSettings ? { getSettings: opts.getSettings } : {}),
}),
);
const { ClaudeAcpAgent } = await import("../acp-agent.js");
const { ClaudeAcpAgent } = await import("../v1/acp-agent.js");
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent({
sessionUpdate: async () => {},
requestPermission: async () => ({ outcome: { outcome: "cancelled" } }),

View File

@@ -0,0 +1,179 @@
import { spawn, spawnSync, type ChildProcess } from "node:child_process";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
import * as v2 from "@agentclientprotocol/sdk/experimental/v2/unstable";
import { nodeToWebReadable, nodeToWebWritable } from "../utils.js";
/**
* The ACP v2 core (`src/v2/`) against the bundled Claude Code
* CLI, in the adapter's own process as a client starts it. The doubles of
* `v2.test.ts` send the frames the core builds its turn model on
* (`command_lifecycle`, `session_state_changed`, `interrupt({ cancelQueued })`);
* these cases check that the real CLI still sends them. They cost API usage.
*/
describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)("v2 core with Claude Code", () => {
let child: ChildProcess;
let agent: v2.ClientContext;
let cwd: string;
const updates: v2.UpdateSessionNotification[] = [];
const waiters: Array<() => void> = [];
let permissionAnswer: v2.PermissionOptionKind = "allow_once";
beforeAll(() => {
if (spawnSync("npx", ["tsc"], { stdio: "inherit" }).status) {
throw new Error("failed to compile");
}
}, 120_000);
beforeEach(async () => {
cwd = await mkdtemp(path.join(os.tmpdir(), "v2-integration-"));
await writeFile(path.join(cwd, "file.txt"), "one\n");
updates.length = 0;
permissionAnswer = "allow_once";
child = spawn("node", [path.resolve("dist/index.js")], {
cwd,
env: { ...process.env, CLAUDE_AGENT_ACP_PROTOCOL: "v2" },
stdio: ["pipe", "pipe", "inherit"],
});
const connection = v2
.client({ name: "v2-integration" })
.onNotification(v2.methods.client.session.update, ({ params }) => {
updates.push(params);
for (const notify of waiters.splice(0)) notify();
})
.onRequest(v2.methods.client.session.requestPermission, ({ params }) => {
const option = params.options.find((candidate) => candidate.kind === permissionAnswer);
return option
? { outcome: { outcome: "selected", optionId: option.optionId } }
: { outcome: { outcome: "cancelled" } };
})
.connect(v2.ndJsonStream(nodeToWebWritable(child.stdin!), nodeToWebReadable(child.stdout!)));
agent = connection.agent;
await agent.request(v2.methods.agent.initialize, {
protocolVersion: v2.PROTOCOL_VERSION,
info: { name: "v2-integration", version: "1" },
capabilities: {},
});
}, 60_000);
afterEach(async () => {
// Not `connection.close()`: it cancels the stdout stream, and the socket's
// later `end` then closes it again (`nodeToWebReadable`), which throws.
child.kill();
await rm(cwd, { recursive: true, force: true, maxRetries: 10 });
});
afterAll(() => child?.kill());
/** The states of the root session's `state_update`s, in order. */
const states = (sessionId: string) =>
updates.flatMap(({ sessionId: id, update }) =>
id === sessionId && update.sessionUpdate === "state_update" ? [update] : [],
);
/** Resolves once `test` holds for the updates received so far. */
async function until(test: () => boolean, ms = 120_000): Promise<void> {
const deadline = Date.now() + ms;
while (!test()) {
if (Date.now() > deadline) throw new Error("timed out waiting for the session's updates");
await new Promise<void>((resolve) => {
waiters.push(resolve);
setTimeout(resolve, 1000);
});
}
}
/** A new session on its haiku model, the cheapest, in `mode`. */
async function newSession(mode: string): Promise<string> {
const { sessionId, configOptions } = await agent.request(v2.methods.agent.session.new, {
cwd,
});
type Entry = { value: string; name: string; options?: Entry[] };
const model = configOptions?.find((option) => option.configId === "model") as
{ options?: Entry[] } | undefined;
const values = (model?.options ?? []).flatMap((entry) => entry.options ?? [entry]);
const haiku = values.find((entry) => /haiku/i.test(`${entry.value} ${entry.name}`));
if (haiku) {
await agent.request(v2.methods.agent.session.setConfigOption, {
sessionId,
configId: "model",
type: "id",
value: haiku.value,
});
}
await agent.request(v2.methods.agent.session.setConfigOption, {
sessionId,
configId: "mode",
type: "id",
value: mode,
});
return sessionId;
}
const prompt = (sessionId: string, text: string) =>
agent.request(v2.methods.agent.session.prompt, {
sessionId,
prompt: [{ type: "text", text }],
});
it("answers a prompt at insertion and reports running, then idle", async () => {
const sessionId = await newSession("default");
await prompt(sessionId, "Reply with exactly the single word: pong");
// The prompt answers at insertion, before the turn's idle.
expect(states(sessionId).some((update) => update.state === "idle")).toBe(false);
await until(() => states(sessionId).some((update) => update.state === "idle"));
expect(states(sessionId).map((update) => update.state)).toEqual(["running", "idle"]);
expect(states(sessionId).at(-1)).toMatchObject({ stopReason: "end_turn" });
}, 180_000);
it("reports requires_action while a permission request waits", async () => {
const sessionId = await newSession("default");
await prompt(
sessionId,
"Use the Edit tool on file.txt to replace the word one with the word two. Then reply with just: done",
);
await until(() => states(sessionId).some((update) => update.state === "idle"));
expect(states(sessionId).map((update) => update.state)).toEqual([
"running",
"requires_action",
"running",
"idle",
]);
}, 180_000);
it("fails a queued prompt with -32800 at a cancel and goes idle with cancelled", async () => {
const sessionId = await newSession("bypassPermissions");
await prompt(
sessionId,
"Use the Bash tool in the foreground (not run_in_background) to run exactly: ping -c 30 127.0.0.1 > /dev/null . Then reply with just: woke",
);
await until(() =>
updates.some(
({ update }) =>
update.sessionUpdate === "terminal_update" &&
/ping/.test((update as v2.TerminalUpdate).command ?? ""),
),
);
// Claude Code takes in a prompt sent while it works only at its next
// step, so this one is still queued at the cancel.
// It answers when Claude Code starts it, so the cancel fails it instead.
const queued = prompt(sessionId, "Reply with just: queued");
await new Promise((resolve) => setTimeout(resolve, 500));
await agent.notify(v2.methods.agent.session.cancel, { sessionId });
await expect(queued).rejects.toMatchObject({ code: -32800 });
await until(() => states(sessionId).some((update) => update.state === "idle"));
expect(states(sessionId).at(-1)).toMatchObject({ state: "idle", stopReason: "cancelled" });
// The next prompt runs normally.
const before = states(sessionId).length;
await prompt(sessionId, "Reply with exactly the single word: ok");
await until(() =>
states(sessionId)
.slice(before)
.some((update) => update.state === "idle"),
);
expect(states(sessionId).slice(before).at(-1)).toMatchObject({ stopReason: "end_turn" });
}, 240_000);
});

3966
src/tests/v2.test.ts Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -27,10 +27,10 @@ export class ClientCapabilities {
readonly air: AirCapabilities = NO_AIR,
/**
* The client speaks ACP v2. Its diffs carry structured `changes` and an
* optional git patch, which a v1 diff cannot express. Only the v2 surface
* sets this, never the capabilities that a client sends. A v2 client also
* takes the terminal extension (`terminalOutput`), which the v2 surface
* sends as display terminals.
* optional git patch, which a v1 diff cannot express. Only the v2 core
* (`src/v2/`) sets this, never the capabilities that a client
* sends. A v2 client also takes the terminal extension (`terminalOutput`),
* which the v2 core sends as display terminals.
*/
readonly v2: boolean = false,
) {}

View File

@@ -29,13 +29,12 @@ export type ToolCallUpdate = SessionNotification["update"];
const PLAN_FILE_CLEAR_SUFFIX = ":plan-file-clear";
/**
* ACP v2's `cancelled` and `paused` tool call statuses. v1's statuses have no
* such values, and the agent builds its reports in v1 types, so they travel as
* v1 statuses. Only a v2 client gets them, and the v2 surface sends them as
* they are (`src/v2/tool-call.ts`).
* ACP v2's `cancelled` tool call status. v1's statuses have no such value, and
* the shared mapping builds its reports in v1 types, so it travels as a v1
* status. Only a v2 client gets it, and the v2 core sends it as it is
* (`src/v2/tool-call.ts`).
*/
export const V2_CANCELLED = "cancelled" as unknown as ToolCallStatus;
export const V2_PAUSED = "paused" as unknown as ToolCallStatus;
/** Whether `status` ends a tool call: `completed`, `failed`, or, on v2, `cancelled`. */
export function isFinalToolCallStatus(status: ToolCallStatus | null | undefined): boolean {

View File

@@ -1,100 +0,0 @@
/**
* A prompt turn and the foreground work of a session, as the adapter
* understands them, independent of the ACP version.
*
* The agent reports how each prompt is answered through {@link TurnEvents}:
* ACP v1 answers `session/prompt` when the turn ends or fails, and ACP v2 when
* the prompt is inserted. ACP v2 also reports the session's work as state,
* which the agent reports through {@link ForegroundEvents}.
*
* The agent calls these synchronously while it updates its own state, so a
* handler must not throw, nor call back into the agent before it returns.
*/
export interface TurnEvents {
/**
* Claude Code took the prompt in as the user message `messageId` of the
* conversation. Reported at most once per turn, and never after
* {@link ended} or {@link failed}.
*
* When Claude Code reports command lifecycles (`msg_lifecycle_v1`), that is
* when the prompt starts a turn, before the turn's output. Otherwise, and
* while another turn is still active (held for its background work,
* steered, or cancelled but not yet ended), it is the prompt's echo, after
* that turn ended; for a command Claude Code does not echo (such as
* `/compact`), it is the command's result, after any output it streamed.
*/
inserted(messageId: string): void;
/**
* The turn ended. A queued turn that is cancelled before it is inserted
* ends too, without {@link inserted}; Claude Code may still run its prompt.
*/
ended(outcome: TurnOutcome): void;
/**
* The turn failed with `error`, the JSON-RPC error that v1 answers the
* prompt with. Without {@link inserted} before, Claude Code was not seen
* taking the prompt in.
*/
failed(error: unknown): void;
}
/**
* The foreground work of a session: what a client would wait for before it
* sends the next prompt. One piece of work runs at a time, and the next starts
* with {@link running}. The work is either:
*
* - A prompt's turn, from {@link TurnEvents.inserted} until its result, or
* until it fails or is cancelled. A turn that v1 holds open for the
* background subagents it spawned (`Turn.deferredSettle`) ends its work at
* its result all the same: the hold only delays v1's answer.
* - A model cycle that Claude Code starts on its own while no work runs: for
* example, its follow-up after a background command or subagent finished,
* or its answer to a message from another session. It runs until its result.
*
* A prompt that Claude Code takes in while a cycle runs takes the cycle over:
* the cycle reports nothing more, and the turn reports from there.
*/
export interface ForegroundEvents {
/**
* The work started, or works again after it waited for the user. Not after
* a wait that a cancel ended: Claude Code's interrupt withdrew the last
* request, so the work is being stopped, and {@link ended} follows at once.
*/
running(): void;
/**
* The work waits for the user: a permission request or a question is open in
* the session. Requests do not say which work they are for, so this includes
* one opened before the work started.
*/
awaitingUser(): void;
/** The work ended. */
ended(outcome: TurnOutcome): void;
/**
* The work failed with `error`, the JSON-RPC error that v1 answers the
* prompt with.
*/
failed(error: unknown): void;
}
/** Why a turn ended. Every ACP version has these reasons. */
export type StopReason = "end_turn" | "max_tokens" | "max_turn_requests" | "refusal" | "cancelled";
/** How a turn ended. */
export type TurnOutcome = {
stopReason: StopReason;
/** The tokens that the main agent loop of the turn used. */
usage?: TurnUsage;
/**
* Extension data for the client, such as the quota breakdown of the turn
* and a terminal session failure.
*/
_meta?: Record<string, unknown>;
};
export type TurnUsage = {
totalTokens: number;
inputTokens: number;
outputTokens: number;
thoughtTokens?: number;
cachedReadTokens?: number;
cachedWriteTokens?: number;
};

Some files were not shown because too many files have changed in this diff Show More