## Summary
- emit ACP `usage_update` notifications from top-level Claude
`stream_event` message usage
- keep tracking a usage snapshot across `message_start` and
`message_delta` so partial deltas still produce a complete total
- preserve the existing `result`-path update as the final
exact/cost-bearing usage event
## Why
Claude SDK stream events already carry incremental usage on
`message_delta`, but `claude-agent-acp` currently ignores that path and
only emits `usage_update` from the final `result`. This makes usage
appear stale until the turn ends in ACP clients that expect live context
usage updates.
## Verification
- built the repo with `npm run build`
- ran a direct ACP stdio client against the patched local build
- observed `usage_update` notifications emitted before the final
result-path usage update
Patched local smoke-test output included this sequence:
- `usage_update used=20973 size=200000`
- `agent_message_chunk text=TEST_OK`
- `usage_update used=20989 size=200000`
- `usage_update used=20989 size=1000000 cost=0.1314875`
---------
Co-authored-by: Ben Brandt <benjamin.j.brandt@gmail.com>
Make cancellation a no-op for unknown sessions, close each
session query during disposal, and run the same cleanup path on
SIGINT and SIGTERM in CLI mode.
Follow up to #530
## Summary
- Return `AgentSideConnection` from `runAcp()` instead of discarding it
- Listen for `connection.closed` and call `process.exit(0)` on
resolution
- Fixes orphan process accumulation in oneshot mode (e.g. via ACPX)
## Problem
When used in oneshot mode, `claude-agent-acp` does not exit after the
client closes the stdin pipe. `process.stdin.resume()` keeps the Node.js
event loop alive indefinitely. Over time, orphan processes accumulate on
the host (~97MB RSS each), eventually exhausting system resources.
## Root cause
`runAcp()` creates an `AgentSideConnection` but discards the reference.
The `Connection.#receive()` loop detects stdin EOF and fires
`abortController.abort()`, resolving `connection.closed` — but no code
listens for it to trigger `process.exit()`.
## Fix
1. **`acp-agent.ts`**: `runAcp()` now returns the `AgentSideConnection`
instance
2. **`index.ts`**: Awaits `connection.closed` and calls
`process.exit(0)`
## Impact
- **Oneshot sessions**: Process now exits cleanly when the client closes
stdin (EOF)
- **Persistent sessions**: Unaffected — the client keeps stdin open, so
`connection.closed` does not fire until explicit disconnect
## Test plan
- [x] Verified oneshot: process exits within 1s after stdin EOF
- [x] Verified persistent: process remains alive while stdin is open
- [x] Measured idle RSS: ~97MB → freed on exit
- [x] Tested with ACPX runtime (OpenClaw gateway) in production
🤖 Generated with [Claude Code](https://claude.ai/code)
---------
Co-authored-by: WayChan <dove.h14@gmail.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Ben Brandt <benjamin.j.brandt@gmail.com>
The auth methods don't have interactive modes, which means they don't
work on remote machines when you need to paste a code in.
Our previous change might not have been enough, so added some heuristics
similar to what the SDK does for checking for remote.
It's not perfect because I think they are attempting to open the URL and
then falling back when it fails, but heopfully this is at least closer.
## Summary
- Guard `toolInfoFromToolUse()` against `undefined` input for Read,
Glob, Grep, WebSearch, and TodoWrite cases
- Make `planEntries()` resilient to `undefined` input (returns `[]`
instead of crashing)
- Guard TodoWrite plan updates in `streamEventToAcpNotifications()` with
optional chaining on `chunk.input?.todos`
## Why this change
Claude `tool_use` blocks can temporarily carry `input: undefined` during
streaming (before `input_json_delta` arrives), and historical `tool_use`
messages with incomplete input may be replayed when a JetBrains IDEA
session is resumed. Without these guards, ACP crashes with:
```
TypeError: Cannot read properties of undefined (reading 'limit')
TypeError: Cannot read properties of undefined (reading '-i')
TypeError: Cannot read properties of undefined (reading 'todos')
```
All three crashes share the same root cause: the ACP bridge assumes
`tool_use.input` is always defined, but it can be `undefined` during
streaming early chunks or session history replay.
## Test plan
- [x] Added regression tests for `toolInfoFromToolUse()` with `input:
undefined` (Read, Grep, Glob, WebSearch, TodoWrite)
- [x] Added regression test for `planEntries(undefined)` — returns `[]`
- [x] Added regression test for `planEntries({})` — graceful handling
- [x] Added regression test for `toAcpNotifications()` with TodoWrite +
undefined input — no crash, no spurious plan update
- [x] Added regression test verifying TodoWrite + valid input still
emits plan update
- [x] `npm run build` passes with no TypeScript errors
- [x] All 167 tests pass (10 new)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Ben Brandt <benjamin.j.brandt@gmail.com>
The auth subcommand goes through a flow that requires pasting in a code
on remote environments, but does not allow for pasting that code in.
Until they fix this in the CLI subcommand, we'll have to go to the old
flow of launching the full CLI.
Closes#512
Fingerprint session-defining params to detect cwd or MCP server
changes, reuse sessions when unchanged, and ignore MCP server
ordering differences. Extract session teardown into a helper and add
tests covering reuse and recreation paths.
Title:
fix: ANTHROPIC_MODEL env var not respected in ACP agent model selection
Body:
## Summary
The `ANTHROPIC_MODEL` environment variable is not being respected when
using Claude Code via the ACP (Agent Client Protocol). This causes users
who set their preferred model via
environment variable to have their setting ignored, with the default
model (`claude-sonnet-4-6`) being used instead.
## Problem
When using Claude Code through ACP integrations (e.g., agent platforms),
users expect the `ANTHROPIC_MODEL` environment variable to control which
model is used for LLM requests.
However, the current implementation only checks `settings.model` (from
configuration files) and completely ignores `ANTHROPIC_MODEL`.
## Root Cause
In `src/acp-agent.ts`, the `getAvailableModels` function only checks
`settings.model`:
```typescript
let currentModel = models[0];
if (settings.model) {
const match = resolveModelPreference(models, settings.model);
if (match) {
currentModel = match;
}
}
The ANTHROPIC_MODEL environment variable is never read, so
environment-based model configuration is silently ignored.
Reproduction
1. Set ANTHROPIC_MODEL=glm-4.7 (or any custom model)
2. Start Claude Code via ACP protocol
3. Send a prompt request
4. Capture network traffic - the actual model used is claude-sonnet-4-6
instead of glm-4.7
Fix
Added priority-based model selection that respects the ANTHROPIC_MODEL
environment variable, aligning with the main Claude Code CLI behavior:
// Model priority (highest to lowest):
// 1. ANTHROPIC_MODEL environment variable
// 2. settings.model (user configuration)
// 3. models[0] (default first model)
if (process.env.ANTHROPIC_MODEL) {
const match = resolveModelPreference(models,
process.env.ANTHROPIC_MODEL);
if (match) {
currentModel = match;
}
} else if (settings.model) {
const match = resolveModelPreference(models, settings.model);
if (match) {
currentModel = match;
}
}
This matches the model selection priority used by the main Claude Code
CLI.
Testing
- Verified ANTHROPIC_MODEL is now respected when set
- Verified settings.model still works when ANTHROPIC_MODEL is not set
- Verified default model (models[0]) is used when neither is set
Breaking Change
None - this fix makes behavior more consistent with user expectations
and the main CLI.
ACP version is `v0.25.0`. Should be the newest.
I get this error when pressing Shift+Tab to cycle through the modes:
```
Caused by:
RPC request LspQuery failed: unknown buffer id 12884902333
2026-04-05T21:46:02+00:00 WARN [agent_servers::acp] agent stderr: Error handling request {
2026-04-05T21:46:02+00:00 WARN [agent_servers::acp] agent stderr: jsonrpc: '2.0',
2026-04-05T21:46:02+00:00 WARN [agent_servers::acp] agent stderr: id: 48,
2026-04-05T21:46:02+00:00 WARN [agent_servers::acp] agent stderr: method: 'session/set_config_option',
2026-04-05T21:46:02+00:00 WARN [agent_servers::acp] agent stderr: params: {
2026-04-05T21:46:02+00:00 WARN [agent_servers::acp] agent stderr: sessionId: '$UUID_GOES_HERE',
2026-04-05T21:46:02+00:00 WARN [agent_servers::acp] agent stderr: configId: 'mode',
2026-04-05T21:46:02+00:00 WARN [agent_servers::acp] agent stderr: value: 'auto'
2026-04-05T21:46:02+00:00 WARN [agent_servers::acp] agent stderr: }
2026-04-05T21:46:02+00:00 WARN [agent_servers::acp] agent stderr: } {
2026-04-05T21:46:02+00:00 WARN [agent_servers::acp] agent stderr: code: -32603,
2026-04-05T21:46:02+00:00 WARN [agent_servers::acp] agent stderr: message: 'Internal error',
2026-04-05T21:46:02+00:00 WARN [agent_servers::acp] agent stderr: data: { details: 'Invalid Mode' }
2026-04-05T21:46:02+00:00 WARN [agent_servers::acp] agent stderr: }
2026-04-05T21:46:02+00:00 ERROR [agent_ui::config_options] Failed to set config option: Internal error: {
"details": "Invalid Mode"
}
```
I looked through the code on both sides and I think what might be
happening is, here are the modes:
f422c7c954/src/acp-agent.ts (L236-L244)
When a user gets to `bypassPermissions` and cycle again, the next
selected mode is `auto` but that option seems to be missing from here:
f422c7c954/src/acp-agent.ts (L1007-L1013)
Could the maintainers please help verify and test?
Signed-off-by: k4yt3x <i@k4yt3x.com>
The `usage_update` notification reports `size: 200000` even when the
active model has a 1M context window (e.g. `opus[1m]`), causing clients
to display incorrect context utilization (e.g. `689k/200k (344.3%)`
instead of `689k/1000k (68.9%)`).
Four bugs fixed:
- **Min across all models**: The original code used `Math.min` across
all `modelUsage` entries, so subagent models (Sonnet/Haiku with 200k
windows) dragged down the reported size for the main Opus 1M model. Now
tracks the top-level assistant model and looks up its context window
specifically.
- **Model name mismatch**: The SDK's streaming path keys `modelUsage` by
the requested model alias (e.g. `claude-opus-4-6`) while
`BetaMessage.model` on assistant messages has the resolved API response
model (e.g. `claude-opus-4-6-20250514`). The exact-match lookup always
missed, falling back to the hardcoded 200k default. Now falls back to
prefix matching, preferring the longest/most-specific match.
- **Synthetic messages corrupt model tracking**: `/compact` and similar
commands emit assistant messages with `model: "<synthetic>"`. These were
updating `lastAssistantModel`, causing the next `usage_update` to miss
the `modelUsage` lookup and fall back to the 200k default. Now filters
out `<synthetic>` models.
- **Stale usage after compaction**: No `usage_update` was sent on
`compact_boundary`, so clients kept showing the pre-compaction context
size (e.g. `944k/1m`) right after "Compacting completed" until the next
full turn. Now sends `used: 0` immediately on compaction. This is a
deliberate approximation — the exact post-compaction size isn't known
until the SDK's next API call, which replaces it within seconds. The
alternative (no update) is worse UX: showing a full context bar right
after compaction.
Eight new tests cover: token sum correctness, current-model context
window lookup, model switching, subagent isolation, prefix matching in
both directions, and synthetic message filtering.
Note: `bin/test` (local CI validation script) is cherry-picked from
#353.
Would fix `agent-shell`'s usage indicator which currently has to defend
against this broken math:
https://github.com/xenodium/agent-shell/pull/364
## Testing
- [x] Manual riding with it for a bit.
---------
Co-authored-by: Tim Visher <194828183+timvisher-dd@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Steffen Deusch <steffen@deusch.me>
#463 introduced a problem with cancelling prompts. Because cancel exited
the loop early, the idle session state message was not consumed. Then,
in the next prompt, we'd immediately get the idle state and assume it's
done.
Instead, we always wait for the idle state (and still finish early when
detecting a queued prompt).
Note that this is based on my tests that showed Claude only sending the
idle state when all background agents are done.
---------
Co-authored-by: Ben Brandt <benjamin.j.brandt@gmail.com>
According to the type docs, this should be a more reliable indicator of
turn end:
> Mirrors notifySessionStateChanged. 'idle' fires after heldBackResult
flushes and the bg-agent do-while exits — authoritative turn-over
signal.
Instead of parsing <local-command-stdout> tags from the message stream,
intercept these commands early and forward
the clean result from the success handler directly to the client.
Applies to /context, /heapdump, and /extra-usage.
## Summary
- `gatewayAuthMeta` is declared as an optional property (`undefined`
when unset), but the subscription validation compared it against `null`
- This made the condition always false, so `--hide-claude-auth` never
blocked claude.ai subscription users when no gateway was configured
- Changed `=== null` to `!this.gatewayAuthMeta` to match the actual
property semantics
## Test plan
- [x] `npm run build` compiles clean
- [x] `npm run lint` passes
- [x] All 6 authorization tests pass
Fixes#442
## Summary
- When `cancel()` was called immediately after a prompt, the result
message arrived before `promptReplayed` was set to `true`
- The `!promptReplayed` check consumed it as a "background task result",
making the `session.cancelled` check unreachable
- The loop then blocked forever on `query.next()` since no further
messages would arrive
- Fix: move the `session.cancelled` check **before** the
`!promptReplayed` check so cancellation is always detected
## Test plan
- [x] `npm run build` compiles clean
- [x] `npm run lint` passes
- [x] All unit tests pass
- [x] Logic verified against the race condition sequence described in
#442
## Summary
- `SettingsManager.dispose()` was never called when a session was closed
via `unstable_closeSession` or when the Claude Agent process died
unexpectedly
- Each leaked session left up to 4 active `fs.watch()` file descriptors
and a pending debounce timer
- Added `session.settingsManager.dispose()` in both cleanup paths and
updated test mocks to assert the call
## Test plan
- [x] `npm run build` compiles clean
- [x] `npm run lint` passes
- [x] Existing `session/close` tests updated to verify `dispose()` is
called
- [x] All 4 session/close tests pass
Add support for `_meta.additionalRoots` in `claude-agent-acp`.
This change introduces a top-level `additionalRoots` field in session
metadata and forwards it to Claude by merging it into
`additionalDirectories` when creating a session.
Behavior:
- reads `_meta.additionalRoots` from the ACP session request
- treats it as a `string[]`
- merges it with any user-provided
`claudeCode.options.additionalDirectories`
- passes the combined list through to Claude unchanged
This keeps `additionalRoots` as an ACP-level extension rather than a
Claude-specific option, while mapping it onto Claude's existing
`additionalDirectories` support.
## Summary
Fixes#401 by enabling model alias/fuzzy resolution in
`setSessionConfigOption` when updating the `model` config option.
## Root cause
`setSessionConfigOption` validated model values using an exact string
match against config option values (model IDs like `claude-opus-4-5`):
```ts
const validValue = allValues.find((o) => o.value === params.value);
```
Clients often send human-friendly model preferences (`opus`, `sonnet`,
`Claude Sonnet`), which failed this exact check and produced:
- `Invalid value for config option model: <value>`
The file already had `resolveModelPreference(...)`, which supports
exact/display/substring/tokenized matching, but that logic was only used
in model initialization paths and not in `setSessionConfigOption`.
## What changed
### 1) Fuzzy model resolution in `setSessionConfigOption`
- Kept the existing exact-match validation path.
- When `configId === "model"` and exact match fails, map config option
entries to `ModelInfo[]` (`value` + `displayName`) and call
`resolveModelPreference(...)`.
- If resolution succeeds, use the resolved canonical model ID
(`resolvedValue`) for:
- `query.setModel(...)`
- `session.configOptions` current value update
- response payload (`configOptions`)
### 2) Notification behavior unchanged by design
- Intentionally **did not** add `updateConfigOption(...)` in
`setSessionConfigOption`.
- This preserves existing behavior/tests that verify no
`config_option_update` notification is sent for this RPC (response
already returns full `configOptions`).
## Tests
Added focused coverage in `src/tests/session-config-options.test.ts`
under `describe("setSessionConfigOption")`:
- resolves model alias `opus` -> `claude-opus-4-5`
- resolves model alias `sonnet` -> `claude-sonnet-4-5`
- resolves display name `Claude Sonnet` -> `claude-sonnet-4-5`
- still accepts exact model ID
- still throws for invalid model value (`gpt-4`)
## Behavior before / after
### Before
- `setSessionConfigOption({ configId: "model", value: "opus" })` ->
error
### After
- same call resolves to canonical ID and succeeds:
- `setModel("claude-opus-4-5")`
- returned `configOptions.model.currentValue === "claude-opus-4-5"`
## Verification
- `npm run build` ✅
- `npm test` ✅ (all tests passing)
Closes#415
Add `getOrCreateSession` so resume/load returns existing session config
without recreating the session. Replace ad-hoc permission tracking with
`modes.currentModeId` and centralize config updates through
`syncSessionConfigState` to keep `modes` and `models` in sync with
config option changes. Update tests for the new session shape.
Adds a new `ExitPlanMode` choice so users can switch directly to
`bypassPermissions` after accepting a plan, instead of only
`acceptEdits` or `default`.
### Changes
- Updated the `ExitPlanMode` permission options in `src/acp-agent.ts` to
conditionally include:
- `Yes, and bypass permissions` (`optionId: "bypassPermissions"`)
- Reused existing `ALLOW_BYPASS` guard so the option is only shown when
bypass is supported.
- Updated selection handling so `bypassPermissions` is treated as a
valid accepted outcome and applies mode updates like the existing paths.
Followup for
https://github.com/zed-industries/claude-agent-acp/pull/386.
When sending `/compact`, there's no replay for the user message, so we
did not resolve the prompt. Same for `/context`.
I tried what happens when sending a `/context` or `/compact` while
another prompt is running and Claude does not properly handle it.
Instead, it responds with something like
> I also noticed you sent /compact while I was working. That's a
built-in CLI command (not a skill), which compacts the conversation
context. It should be invoked directly in the CLI rather than as a
message to me.
The important part is
f52f214937.
## Summary
- Pass through `_meta.claudeCode.options.tools` to the Claude Agent SDK
instead of hard-coding `{ type: "preset", preset: "claude_code" }`
- Reimplement `disableBuiltInTools` as `tools: []` instead of pushing 14
tool names into `disallowedTools`
- Explicit `tools` array takes precedence over `disableBuiltInTools`
## Problem
The ACP adapter hard-coded `tools: { type: "preset", preset:
"claude_code" }`, ignoring any value the caller provided via
`_meta.claudeCode.options.tools`. This caused two problems:
1. **Wasted context tokens** — All ~15 tool schemas were always sent to
the model, even when the caller only needed a subset (e.g., Read and
Glob for a review-only workflow) or none at all (MCP-only sessions).
2. **Rejected tool call round trips** — When `disableBuiltInTools: true`
was used, the tools were still registered in the model's context (the
flag only added them to `disallowedTools`). The model would see the tool
schemas, attempt to call them, get rejected, and waste a round trip.
Additionally, the existing `disableBuiltInTools` flag maintained a
hard-coded list of 14 tool names to blocklist. If the SDK added a new
built-in tool, `disableBuiltInTools` wouldn't cover it — the new tool
would leak through.
## SDK Contract Validation
The `tools` option is documented in the [Claude Agent SDK TypeScript
reference](https://platform.claude.com/docs/en/agent-sdk/typescript) as:
```typescript
tools?: string[] | { type: 'preset'; preset: 'claude_code' };
```
> Tool configuration. Pass an array of tool names or use the preset to
get Claude Code's default tools.
The type `string[]` includes `[]` (empty array) with no minimum length
constraint. Passing an empty array to disable all built-in tools is a
valid use of the SDK contract, not a workaround.
## Changes
### `src/acp-agent.ts`
**One line of behavioral change** — the `tools` field now uses the
caller-provided value if present, falling back to the preset:
```typescript
tools: userProvidedOptions?.tools ?? { type: "preset", preset: "claude_code" },
```
**`disableBuiltInTools` reimplemented** — instead of pushing 14 tool
names into `disallowedTools`, it resolves to `tools: []`:
```typescript
const resolvedTools: Options["tools"] =
userProvidedOptions?.tools ??
(params._meta?.disableBuiltInTools === true
? []
: { type: "preset", preset: "claude_code" });
```
Resolution priority:
```
_meta.claudeCode.options.tools → use it (explicit whitelist)
_meta.disableBuiltInTools: true → tools: [] (legacy shorthand)
neither → { type: "preset", preset: "claude_code" } (default)
```
### `src/tests/create-session-options.test.ts`
4 new tests + 1 updated test:
| Test | Verifies |
|------|----------|
| `defaults tools to claude_code preset when not provided` | Backward
compatibility — no `tools` = full preset |
| `passes through user-provided tools string array` | Whitelist:
`["Read", "Glob"]` arrives as-is |
| `passes through empty tools array to disable all built-in tools` |
`tools: []` disables everything |
| `explicit tools array takes precedence over disableBuiltInTools` |
Resolution priority: explicit `tools` wins over `disableBuiltInTools` |
| `sets tools to empty array when disableBuiltInTools is true` | Legacy
flag now produces `tools: []` instead of blocklist |
## Usage Examples
```typescript
// Review-only session: model can only read files
_meta: { claudeCode: { options: { tools: ["Read", "Glob"] } } }
// MCP-only session: no built-in tools
_meta: { claudeCode: { options: { tools: [] } } }
// Legacy: still works, now equivalent to tools: []
_meta: { disableBuiltInTools: true }
```
## Non-breaking
Callers that don't provide `tools` get identical behavior (full
`claude_code` preset). `disableBuiltInTools: true` still works but is
now a less flexible alias for `tools: []`.
## Test plan
- [x] All existing tests pass (`npm test` — 131 tests, 126 passed, 5
skipped)
- [x] New tests cover: default preset, explicit array, empty array,
precedence over `disableBuiltInTools`
- [x] Verified `tools` type against [official SDK
docs](https://platform.claude.com/docs/en/agent-sdk/typescript)
---------
Co-authored-by: Ben Brandt <benjamin.j.brandt@gmail.com>
## Summary
- Tool call titles (Read, Write, Edit) currently display absolute file
paths like `/Users/jane/my-project/src/main.ts`, which are long and
redundant since the user already knows their project root.
- This PR converts those paths to project-relative display paths (e.g.,
`src/main.ts`) by comparing against the session's `cwd`. Paths outside
the project directory remain absolute.
- The `locations.path` field is **not** changed, it stays absolute for
Zed's file navigation.
## Test plan
- [x] Added unit tests for `toDisplayPath` (relativizes paths inside
cwd, keeps absolute paths outside, handles partial directory name
matches)
- [x] Added unit test for `toolInfoFromToolUse` with `cwd` parameter
(verifies title uses relative path, locations stay absolute)
- [x] All 124 existing tests pass
Closes#381.
Relates to #348.
In #348 I added replay to handle queued prompts.
In Tidewave, we ignore user_message_chunks, so I did not experience the
issue when testing. But clients that show the user_message_chunk like
Zed showed the replayed content. This commit ensures that replayed
messages are skipped.
In 0.20.0, the env option was introduced with an explicit object spread,
but without including process.env. This caused the subprocess to receive
an empty environment, stripping HOME, PATH and other inherited variables
when spawned from an external editor such as Zed.
Add ...process.env as the base so user-provided and gateway env vars
still override it, while preserving the parent process environment.
Add tests to verify HOME/PATH are inherited and that user-provided vars
are merged on top correctly.
Fixes#373
---------
Co-authored-by: Ben Brandt <benjamin.j.brandt@gmail.com>