# Conflicts: # docs/v2-native-core.md # src/v2-native/agent.ts # src/v2-native/session.ts
20 KiB
The native ACP v2 core
Status: experimental, opt-in with CLAUDE_AGENT_ACP_V2_CORE=native. The default v2 core is
still the bridge.
Why
The adapter serves ACP v2 through a bridge today: src/v2/ converts each v2 request to v1, runs
it through ClaudeAcpAgent, and converts what comes back. So every v2 session runs the v1 core,
including the machinery that exists only because of v1:
- Held prompts. A v1
session/promptanswers when the turn ends, so the core keeps a turn queue and settles each turn itself. That needs echo matching, a per-uuid orphan account for prompts a cancel settled after they were pushed, owed-trailing-idle debt (turns settle at theresult, and Claude Code'sidlelags behind it), the steer lane,cancelledThrough, and the hold for background subagents (deferredSettle). - AIR branches. The JetBrains AIR extensions are gated on
air.clientthroughout the core.
The v1-only part is about 2,000 lines inline in acp-agent.ts, and it all runs on v2 too. For
example, deferredSettle holds a v2 turn open and v2/prompt.ts ignores the result.
ACP v2 describes a session the way Claude Code already does. A prompt is inserted into a queue and answered at insertion, and the session reports whether it is working. Claude Code reports both, so a v2 core can follow Claude Code's own events instead of rebuilding a turn model.
How it maps
| ACP v2 | Claude Code |
|---|---|
session/prompt answers |
command_lifecycle started for the prompt's uuid, or the prompt's echo |
session/prompt fails |
session/cancel or cancelled (-32800), discarded or refused (-32603) |
state_update running |
a turn starts: a prompt's started or echo, or the main conversation's output |
state_update idle |
the turn's result, with its stop reason, usage, _meta.quota and error |
state_update requires_action |
a turn is open and a permission request or elicitation of the session is open |
session/cancel |
interrupt({ cancelQueued: true }), also while idle, as on the bridge |
session/resume with replayFrom |
getSessionMessages, mapped like live output |
session/fork |
forkSession (fork-session.ts), then a resume of the copy |
availableCommands |
supportedCommands, filtered by getAvailableSlashCommands, and the adapter's /mcp |
elicitation/create |
AskUserQuestion, onElicitation, onUserDialog, and MCP OAuth (see below) |
elicitation/complete |
system/elicitation_complete, and an MCP OAuth sign-in that finished |
| output updates | the shared SDK mapping (toAcpNotifications), translated by V2UpdateTranslator |
The reports that are not Claude's output come from src/v2-native/reports.ts, built as on v1:
| ACP v2 | Claude Code |
|---|---|
usage_update (used, size) |
the root message_start/message_delta usage, the assistant message's usage |
usage_update with cost |
the turn's result (total_cost_usd, modelUsage for the window, origin) |
usage_update _meta |
the model (_claude/model), a rate_limit_event (_claude/rateLimit) |
_meta.quota of the turn's idle |
the result's usage and its increment of modelUsage |
notice |
informational (not level info), a client MCP server failed at init |
compaction_update, summary chunks |
status compacting and compact_result, compact_boundary, PostCompact |
compaction_update on replay |
a transcript message with isCompactSummary |
session_info_update (title) |
SessionTitles: the title Claude Code stores or generates, read at idle |
plan_update of the Task* list |
TaskCreate/TaskUpdate/TaskList results, the TaskCreated and TaskCompleted hooks |
tool_call_update progress |
tool_progress |
running, then idle, no user_message |
the adapter's idle /compact (IdleCompaction) |
The session keeps little of its own: a map of prompts that are not inserted yet, whether a turn
is open, and its count of open permission requests and elicitations (src/v2-native/session.ts).
Claude Code's session_state_changed is not the foreground state. It stays running while
background subagents work: across the turn's result, the next prompts' turns, and the turn
that Claude Code starts on its own when a subagent ends. The bundled CLI shows it (a background
subagent that sleeps 20 seconds):
0.4 session_state_changed running · command_lifecycle started (P1) · system init
9.3 result "launched" · command_lifecycle completed (P1)
12.1 command_lifecycle started (P2) · system init
15.3 result "second" · command_lifecycle completed (P2)
33.3 task_notification completed · background_tasks_changed []
33.4 system init
35.9 result · session_state_changed idle
So each turn is foreground work from its start to its result, as v2 asks ("When foreground
work ends, the Agent MUST report idle, even if background activity continues"). An idle
without a result, or the completed or cancelled frame of the turn's prompt, also ends a turn,
such as one that an interrupt stopped before it ran. The idle that follows a result is that
result's, also when it comes after the next cycle's first output. A placeholder result
(num_turns: 0 of a task notification) ends nothing: Claude Code answers queued notifications
with one model call. The idle's usage and _meta.quota are the result's (outcome.ts, with the
quota helpers of session-usage.ts that v1 uses). A failed turn's idle carries its error instead.
session/cancel answers every prompt not inserted yet with -32800, as the bridge does. Claude
Code can still run one that it had taken in before the interrupt arrived, or a cycle that it
started while the client saw no work. The client never saw that work, so it is dropped: its
output, its states, and its permission requests, which are denied (stopped-work.ts). An aborted
result after a cancel ends the work that the interrupt stopped as cancelled. Claude Code's
interrupt withdraws the open permission requests. Their end reports no running before the
idle, and one that the user answers after the cancel reports running, as on the bridge.
Subagents and background work
tasks.ts is written for v2 only, rather than reusing native-subagents.ts, which also serves
AIR's draft and restores subagents from history. It follows the subagents RFD, from Claude
Code's task events (with forwardSubagentText and perTaskStopAffordance):
| ACP v2 | Claude Code |
|---|---|
| child session id | the agent id (task_id), the same for every delegation to it |
subagent_update announcement, on the parent's stream |
task_started of a local_agent; the parent is parent_task_id, or the root |
| the child's traffic | messages whose parent_tool_use_id is the Agent call that launched it |
incoming session_message in the child |
the first user text of each run: the delegation |
cwd of the child |
the SubagentStart hook's cwd, when it is not the parent's (such as a worktree) |
child running / idle, mirrored on the parent |
task_started (or a running patch) to task_notification |
child requires_action |
a permission request or question whose agentID is the child |
session/cancel of a child |
stopTask(agentId) |
background of a session |
its background subagents, commands (terminals), task tool calls and other tasks |
terminal/cancel, tool_call/cancel |
stopTask of the listed item's task |
A child that its parent waits on (is_backgrounded: false) is part of the parent's foreground
work: its requests block the parent too (requires_action), and cancelling the parent stops
it. A background child is listed in its parent's background while it works, and cancelling
the parent leaves it running. The client can only watch a child and cancel its run: prompt,
close, resume, delete and config changes on it fail with -32602.
Layout
src/v2-native/agent.ts: the request handlers, session setup, and the SDKqueryoptions.src/v2-native/session.ts: one session: its prompts, its state, and the routing of the query's messages.src/v2-native/stream.ts: the SDK output, and replayed history, as v2 updates.src/v2-native/tasks.ts: Claude Code's tasks: subagents as child sessions, and thebackgroundlist of each session (see below).src/v2-native/permissions.ts:canUseToolas v2 permission requests, on the session of the agent that asks.src/v2-native/elicitation.ts: elicitations, for the modes the client advertised. A form for AskUserQuestion (on the session of the agent that asks), for an MCP server's form, and for the refusal fallback's consent prompt. A URL for an MCP server's URL request and for MCP OAuth. All but MCP OAuth are open requests of their session, like permission requests: a cancel or a close withdraws them. MCP OAuth starts after the setup response, and a close withdraws it. Without form elicitation, AskUserQuestion is disallowed and the refusal fallback is not declared. An MCP server's request in a mode the client did not advertise, or that the client answers with an error, is answeredcancel.src/v2-native/config.ts: the mode and model config options.src/v2-native/setup.ts: what session setup adds on the bridge's rules: the client's_meta.claudeCode.options,CLAUDE_MODEL_CONFIG, the models (theavailableModelsallowlist,ANTHROPIC_MODELand the settings'model, the resumed transcript's model), the current title on resume, and AIR'sloadReplayEndevent.src/v2-native/commands.ts: the command list, and the adapter's/mcp, whose server list replaces Claude Code's text before the turn's idle.src/v2-native/file-preview.ts: the PreToolUse hook that shows an Edit's or Write's exact patch before it runs.src/v2-native/auth.ts:_auth/status_update, gatewayauth/login,auth/logout, andproviders/*, on the v1 core's state rules (AuthStatusReporter,providers.ts). The identity is read afterinitialize, from each session's account, after a login or logout, and at the start of each prompt, as on the bridge.src/v2-native/reports.ts: usage, notices, compactions, titles, and the idle compaction (see the table above), and the hooks that feed them (PostCompact, TaskCreated, TaskCompleted).
About 2,600 lines in total. The native core shares these with v1 and the bridge:
- the SDK message mapping and tool call rendering (
toAcpNotifications,streamEventToAcpNotifications,tool-calls/*) - permission presentation, options and decoding (
permissions/*) - the mode catalog (
buildAvailableModes) promptToClaude- the v2 converters (
v2/session.ts,v2/permission.ts,v2/prompt.ts) - the
backgroundstamping and thecanceladvertisement of listed items (V2Background) - the background command helpers:
backgroundBashTaskFromToolResult,withRunningTerminal,withRunningToolCall,BackgroundOutputTail
These pieces were extracted so that both cores use the same code:
V2UpdateTranslatorandreplayClear(v2/update-translator.ts), taken from the bridge's client connectionterminalAuthMethods(auth-methods.ts), taken fromClaudeAcpAgent.initializesdkMcpServers(mcp-servers.ts), taken fromClaudeAcpAgent.createSessionresolveThinkingConfigandparseModelConfig(session-env.ts), andsessionSystemPrompt(client-system-prompt.ts), taken fromClaudeAcpAgent.createSessiongetAvailableSlashCommands, exported fromacp-agent.tsAuthStatusReporterandcliLogout(auth-status-reporter.ts): theauthStatusstate, theclaude auth statusprobe and the_auth/status_updatepushes, taken fromClaudeAcpAgent- the elicitation answers (
elicitation.ts):answerAskUserQuestion,answerMcpElicitation,answerUserDialogandelicitUnlessWithdrawn, taken fromClaudeAcpAgent's handlers - MCP OAuth (
mcp-oauth.ts):startMcpServerAuthenticationand its status polling, taken fromacp-agent.ts - the provider routing (
providers.ts):ProviderConfig, the gateway login methods and their validation, theproviders/listresponse,providers/setvalidation and model listing, andcreateEnvForProvider, taken fromClaudeAcpAgent - the token usage helpers (
session-usage.ts): usage snapshots, the_meta.quotashape, themodelUsageincrements and the context window guess, taken fromacp-agent.ts informationalNoticeandmcpServerStartupNotice(session-notices.ts), andreplayCompactSummary(context-compaction.ts), taken fromClaudeAcpAgentSessionTitles(session-titles.ts) now reads its session and agent through theTitledSessionandTitleOwnerinterfaces, whichClaudeAcpAgentmatches as it is
How it is checked
src/tests/v2-native.test.tsdrives the core with a Claude Code double, message by message: insertion, folding, lifecycle failures, cancel, permission states, config, local commands, a crashed query, and replay.src/tests/acp-scenarios-v2-native.test.tsruns every scenario of the shared suite on the native core. All 40 send only schema-valid v2, and all pass.SAME_AS_BRIDGElists the scenarios whose session updates equal the bridge's golden file. The others differ in what is listed under "Not served yet" (subagent details, the resume snapshot).- The scenario double now sends
session_state_changedwhen the query asks for it, as the real CLI does. The bridge and v1 scenario golden files did not change. - A manual run against the bundled Claude Code CLI checked the following:
- a prompt answers at insertion
- Bash runs as display terminals
- a permission request goes
running→requires_action→running session/cancelfails a queued prompt with -32800 and goesidlewithcancelled- the next prompt runs normally
Intended differences from the bridge
- Output that comes after
session/cancel, until the turn'sresult, is still sent: the work is still stopping. The bridge drops the output of stopped work. - A text block that an interrupt cut short does not get its tail from the consolidated message.
- The idle of a turn that ended without a
result, such as one that an interrupt stopped before it ran, has nousageand no_meta.quota. The bridge sends the turn's tally, which is zero then. - An AIR client gets no AIR extensions:
initializeadvertises no_meta.jetbrains.air, and its state updates carry the v2backgroundlist. The bridge gives it AIR's subagent form, which reports no list. AIR'sloadReplayEndevent, which the client declares, is sent. /remote-controlis not listed: this core does not serve it.
Not served yet
- A gateway login,
providers/set,providers/disableorauth/logoutroutes only the sessions started after it. The bridge recreates the queries of live sessions between turns; on the native core they keep their route until they are closed and resumed. - Subagents:
- the child's configuration (
config_option_updatefrom theSubagentStarthook) - the incoming view of a SendMessage, which the child's transcript does not show
- replaying children's history on resume (
getSubagentMessages) - a subagent whose parent Claude Code no longer tracks, such as after a restart, stays unexposed: its traffic is the root's
- the child's configuration (
- Reports:
- the model fallback notice (
model_refusal_fallback), and its swap of the model option - the context window before the first result: no
getContextUsageread, and no cross-session cache of windows (the bridge'scontextWindowCache) conversation_reset: the plan, title and idle compaction state are not reset- the bridge sends API retries and prompt suggestions to AIR only, so v2 clients get neither
- the model fallback notice (
- Session options:
- effort, fast mode, ultracode and agent options (the bridge's
buildConfigOptionsand their set paths) - a resume of the active session with other MCP servers calls
setMcpServers; the bridge recreates the query
- effort, fast mode, ultracode and agent options (the bridge's
- Resume:
replayFromother thanstart- the notice for subagent history that a replay cannot restore
- ExitPlanMode's "clear context" option.
- The v1-only features: AIR extensions, steering, Remote Control, the Claude subscription guard, and file change reports.
Replacing the bridge
- Move the SDK mapping out of
acp-agent.ts:toAcpNotifications,streamEventToAcpNotifications,promptToClaude,messageIdForGrouping, and the helpers they use. The native core imports them fromacp-agent.tstoday, which is its only tie to the v1 core's module. - Port the gaps one feature at a time, each in a module that the native core owns. Some are
typed for v1 (
native-subagents.ts,background-work.ts). - Ratchet
NATIVE_STOPSandSAME_AS_BRIDGE, and makenativethe default once it serves what v2 clients need. - Delete the bridge:
v2/agent.ts,v2/prompt.ts,v2/background.ts, the v1 request conversions, and the v2 paths ofClaudeAcpAgent. Those paths are thev2option, theforegroundandbackgroundhooks,ForegroundEvents,foregroundWork, and thethis.v2branches. The v1 core then serves v1 and AIR only.ClientCapabilities.v2stays: the shared renderer uses it.
Spec checks
Beyond the scenarios, v2-native.test.ts covers these requirements of the draft spec:
- A cancel covers only the prompts received before it. A later prompt waits for the interrupt,
whose
cancelQueuedwould end it otherwise. Every covered prompt fails at once, and the run of one that Claude Code had taken in already is dropped, its tool uses denied. - A cancel that ended only queued prompts leaves the next turn's stop reason alone.
session/closesends the cancelled work's final updates andidle(cancelled)before its response, and nothing after. As on the bridge, it ends the work at once, without waiting for Claude Code.- Closing or deleting a session that does not exist succeeds.
- Stopped work gives its unfinished tool calls a final status before its idle:
cancelledafter a cancel,failedwhen the query ends. At any other end, a tool call that Claude dropped before it ran endscancelled. - A session whose query ended is no longer active (-32002 with
data.sessionId), and a resume starts it again from its transcript. - Resuming the active session checks the request as a new one, keeps its work, and replaces its
MCP servers. A different
cwdor other additional directories are rejected. - Config values must have the
idtype, additional directories must be absolute, and terminal logins are advertised only withauth.terminal.
requires_action comes from the session's own open requests, not Claude Code's: it covers
exactly this session's permission requests and elicitations. MCP OAuth is not one: it does not
block a turn.