Compare commits

...

39 Commits

Author SHA1 Message Date
Ben Brandt
5268661b7f Prep 0.23.1 release (#470) 2026-03-26 10:24:34 +00:00
Ben Brandt
9583f97536 Add back error_during_execution break point (#469)
Closes #468
2026-03-26 11:13:40 +01:00
Ben Brandt
95721e4e39 Prep 0.23.0 (#465) 2026-03-25 22:18:12 +00:00
Ben Brandt
7f1c6c2078 Use idle session state as end of turn (#463)
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.
2026-03-25 22:50:01 +01:00
Ben Brandt
3500b6c16e Update claude-agent-sdk to 0.2.83 (#462) 2026-03-25 13:48:23 +00:00
qystishere
07db59ef0d Fix handling of local-only slash commands (#432)
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.
2026-03-24 21:23:26 +00:00
tbounsiar
34c847a9e3 fix: correct null check for gatewayAuthMeta in subscription validation (#455)
## 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
2026-03-24 21:22:02 +00:00
tbounsiar
d2428a49ef fix: include both stdout and stderr in Bash tool output (#456)
## Summary

- When a Bash tool execution produced both stdout and stderr, only
stdout was shown due to the `||` operator short-circuiting
- Changed to concatenate both streams with `[stdout,
stderr].filter(Boolean).join("\n")` so error output is no longer
silently dropped

## Test plan

- [x] `npm run build` compiles clean
- [x] `npm run lint` passes
- [x] All 34 tools tests pass
2026-03-24 21:21:30 +00:00
tbounsiar
91d629d6c6 fix: prevent prompt loop hang when cancel races with first result (#458)
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
2026-03-24 21:19:35 +00:00
tbounsiar
5fd7873c7c fix: dispose SettingsManager on session close to prevent resource leaks (#454)
## 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
2026-03-24 21:19:02 +00:00
Cengiz Han
83496544b2 fix: restore plan content in ExitPlanMode tool call (#451)
## Summary

The plan text was accidentally dropped from the `ExitPlanMode` content
array during the switch to built-in Claude Code tools (#316, commit
`be618f5`). This meant ACP clients could not display the plan when
asking the user for approval — they only saw the title "Ready to code?"
with no plan body.

We noticed this in the Fabriqa app where our plan approval UI was
rendering an empty card.

## What changed

Restored the original `input.plan` → `content` mapping in
`toolInfoFromToolUse()` so the plan markdown is included in the
`tool_call` and `request_permission` events.

**Before (broken):**
```typescript
case "ExitPlanMode": {
  return {
    title: "Ready to code?",
    kind: "switch_mode",
    content: [],
  };
}
```

**After (fixed):**
```typescript
case "ExitPlanMode": {
  const planInput = toolUse.input as { plan?: string };
  return {
    title: "Ready to code?",
    kind: "switch_mode",
    content:
      planInput?.plan
        ? [{ type: "content" as const, content: { type: "text" as const, text: planInput.plan } }]
        : [],
  };
}
```

## Where does `input.plan` come from?

The Claude Agent SDK passes tool inputs to the `canUseTool` callback as
`Record<string, unknown>` (see `Options.canUseTool` in
`@anthropic-ai/claude-agent-sdk/sdk.d.ts`). When Claude calls
`ExitPlanMode`, the model includes the plan text in the input.

The formal `ExitPlanModeInput` type (in
`@anthropic-ai/claude-agent-sdk/sdk-tools.d.ts`) only declares
`allowedPrompts`, but it has a `[k: string]: unknown` index signature
that allows additional properties. The model consistently sends `plan`
(string) and `planFilePath` (string). SDK v0.2.76 explicitly added
`planFilePath` to the tool input for hooks and SDK consumers, confirming
these fields are intentional.

The original implementation (commit `0289dd9`, "Support plan mode and
all other permission modes" #40) relied on `input.plan` and worked
correctly until the refactor in #316 dropped it.

### SDK history confirms `input.plan` is reliable

The Claude Agent SDK itself had a regression where `ExitPlanMode` input
was empty
([anthropics/claude-code#12288](https://github.com/anthropics/claude-code/issues/12288)):

| Version | Status |
|---------|--------|
| Claude Code 2.0.34 | `input.plan` populated |
| Claude Code 2.0.51 | Regression — `input: {}` empty |
| Agent SDK 0.1.54 | Fixed — `input.plan` restored |
| Agent SDK 0.2.76 (current) | Working — also added `planFilePath` |

The SDK team treated the empty input as a critical bug and fixed it
promptly. The current SDK (0.2.76) reliably sends `input.plan`, so this
adapter fix is safe to depend on.

## Test plan

- [x] Added test: plan text is included in content when `input.plan` is
provided
- [x] Added test: content is empty when `input.plan` is not provided
- [x] All existing tests pass

Fixes #450
2026-03-24 22:16:16 +01:00
dependabot[bot]
c5d5c11f40 Bump flatted from 3.4.1 to 3.4.2 in the npm_and_yarn group across 1 directory (#445)
Bumps the npm_and_yarn group with 1 update in the / directory:
[flatted](https://github.com/WebReflection/flatted).

Updates `flatted` from 3.4.1 to 3.4.2
<details>
<summary>Commits</summary>
<ul>
<li><a
href="3bf09091c3"><code>3bf0909</code></a>
3.4.2</li>
<li><a
href="885ddcc33c"><code>885ddcc</code></a>
fix CWE-1321</li>
<li><a
href="0bdba705d1"><code>0bdba70</code></a>
added flatted-view to the benchmark</li>
<li>See full diff in <a
href="https://github.com/WebReflection/flatted/compare/v3.4.1...v3.4.2">compare
view</a></li>
</ul>
</details>
<br />


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

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

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

---

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

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

</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-23 10:28:16 +01:00
Ben Brandt
521d1f766d Prep 0.22.2 release (#440) 2026-03-18 14:26:49 +00:00
Egor Baranov
f11435df13 Support additional skill directories via additionalRoots meta parameter (#406)
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.
2026-03-18 14:20:30 +00:00
Ben Brandt
e8545266fe Prep 0.22.1 release (#438) 2026-03-17 21:58:33 +00:00
Aleksandr Suhinin
ff3b97aece fix: suppress account assertion if gateway authentication is used (#436) 2026-03-17 17:51:02 +01:00
Steffen Deusch
c40d7bc30f fix session close method name (#431)
that's the nice part about unit tests, all green and still broken...

Relates to https://github.com/zed-industries/claude-agent-acp/pull/409.
2026-03-16 19:06:55 +01:00
Ben Brandt
152fe91152 Prep 0.22.0 release (#430) 2026-03-16 15:48:04 +00:00
Ben Brandt
45de3d2847 Use stable list sessions method (#429) 2026-03-16 15:41:27 +00:00
Ben Brandt
c7ae07da6b Use correct Claude CLI path for static binaries (#428)
Closes #424
2026-03-16 15:30:24 +00:00
Ben Brandt
0af467c400 Update claude-agent-sdk to 0.2.76 (#427)
Closes #420
2026-03-16 15:18:51 +00:00
mudrii
f4ce899223 fix: resolve model aliases in setSessionConfigOption (#401) (#403)
## 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)
2026-03-16 16:05:01 +01:00
Ben Brandt
31539b4ee9 Reuse existing sessions for load/resume if possible (#426)
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.
2026-03-16 15:59:45 +01:00
Ben Brandt
61a39ea801 Remove interrupt flag from deny responses (#425)
Now that the models are doing more parallel tool calls, we don't
necessarily want to interrupt in all of these cases.

Closes #402
2026-03-16 14:04:38 +00:00
Shivansh
c0b7752b45 Allow Bypass permissions mode after Exiting plan (#410)
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.
2026-03-13 16:01:46 +00:00
Ben Brandt
3002539e4a Fix test (#416) 2026-03-13 15:58:18 +00:00
Steffen Deusch
87a0886a34 Don't get out of sync when background task creates new init/result (try 2) (#400)
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.
2026-03-13 16:55:42 +01:00
Steffen Deusch
2193a13a3b Add session/close support (#409)
~Waiting for
https://github.com/agentclientprotocol/agent-client-protocol/pull/723.~

---------

Co-authored-by: Ben Brandt <benjamin.j.brandt@gmail.com>
2026-03-13 16:53:22 +01:00
Ben Brandt
c13edf3b02 Prep 0.21.0 release (#399) 2026-03-09 15:10:34 +00:00
Ben Brandt
a217891e6c Revert "Don't get out of sync when background task creates new init/result (#386)" (#398) 2026-03-09 16:07:23 +01:00
Ben Brandt
867de85f0b Revert "Don't get out of sync when background task creates new init/result (#386)" (#398) 2026-03-09 15:05:30 +00:00
Ben Brandt
af11cdbba5 Update to claude-agent-sdk 0.2.71 (#397) 2026-03-09 15:00:58 +00:00
Cengiz Han
f7af07758c feat: pass through tools array to control built-in tool availability (#387)
## 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>
2026-03-09 13:31:48 +00:00
Steffen Deusch
3524606555 Don't get out of sync when background task creates new init/result (#386)
Relates to #336.

This does NOT ensure that the results are immediately sent to the
client.

See
https://github.com/zed-industries/claude-agent-acp/issues/336#issuecomment-4011939471.

I guess some tests for this would be useful.
2026-03-09 14:19:49 +01:00
Rohan Patra
9ecb222807 feat: show project-relative paths in tool call titles (#393)
## 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
2026-03-09 14:13:08 +01:00
Steffen Deusch
dd9fe3a98e skip user replay (#385)
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.
2026-03-06 14:38:31 +01:00
Rohan Patra
1c641cd6a7 fix: handle renamed Agent tool in toolInfoFromToolUse (#384)
## Summary
- The Claude SDK renamed the `Task` tool to `Agent`, but
`toolInfoFromToolUse` only matched `case "Task":`, so the `Agent` tool
fell through to the `default` case — rendering as a bare "Agent" label
with no description or content in the chat UI.
- Adds `case "Agent":` as a fallthrough before `case "Task":` so both
names are handled identically.

## Test plan
- [ ] Verify that when Claude spawns a subagent, the tool call in Zed's
chat shows the agent's description as the title (not a bare "Agent"
label)
2026-03-06 07:23:07 +00:00
Ben Brandt
a02ef184d3 Put back gateway auth check (#380) 2026-03-04 21:28:34 +00:00
Ben Brandt
3beddee766 Update to claude-agent-sdk 0.2.68 (#379) 2026-03-04 21:26:24 +00:00
13 changed files with 1582 additions and 1151 deletions

View File

@@ -1,5 +1,52 @@
# Changelog
## 0.23.1
- Add back error_during_execution break point (#469)
## 0.23.0
- Use idle session state as end of turn (#463)
- Update claude-agent-sdk to 0.2.83 (#462)
- Fix handling of local-only slash commands (#432)
- fix: correct null check for gatewayAuthMeta in subscription validation (#455)
- fix: include both stdout and stderr in Bash tool output (#456)
- fix: prevent prompt loop hang when cancel races with first result (#458)
- fix: dispose SettingsManager on session close to prevent resource leaks (#454)
- fix: restore plan content in ExitPlanMode tool call (#451)
## 0.22.2
- Add experimental meta param for testing additional directories
## 0.22.1
- Fix: invalid auth required state in gateway mode
## 0.22.0
- Use stable list sessions method (#429)
- Use correct Claude CLI path for static binaries (#428)
- Update claude-agent-sdk to 0.2.76 (#427)
- fix: resolve model aliases in setSessionConfigOption (#401) (#403)
- Reuse existing sessions for load/resume if possible (#426)
- Remove interrupt flag from deny responses (#425)
- Allow Bypass permissions mode after Exiting plan (#410)
- Don't get out of sync when background task creates new init/result (try 2) (#400)
- Add session/close support (#409)
## 0.21.0
- Update to claude-agent-sdk 0.2.71
- show project-relative paths in tool call titles
- Lib: pass through tools array to control built-in tool availability
- fix: skip user replay
- fix: handle renamed Agent tool in toolInfoFromToolUse
## 0.20.2
- Update to @anthropic-ai/claude-agent-sdk@0.2.68
## 0.20.1
- fix: inherit process.env when spawning agent subprocess

1610
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -3,7 +3,7 @@
"publishConfig": {
"access": "public"
},
"version": "0.20.1",
"version": "0.23.1",
"description": "An ACP-compatible coding agent powered by the Claude Agent SDK (TypeScript)",
"main": "dist/lib.js",
"types": "dist/lib.d.ts",
@@ -60,22 +60,22 @@
"author": "Zed Industries",
"license": "Apache-2.0",
"dependencies": {
"@agentclientprotocol/sdk": "0.14.1",
"@anthropic-ai/claude-agent-sdk": "0.2.63",
"@agentclientprotocol/sdk": "0.17.0",
"@anthropic-ai/claude-agent-sdk": "0.2.83",
"zod": "^3.25.0 || ^4.0.0"
},
"devDependencies": {
"@anthropic-ai/sdk": "0.78.0",
"@anthropic-ai/sdk": "0.80.0",
"@eslint/js": "10.0.1",
"@types/node": "25.3.3",
"@typescript-eslint/eslint-plugin": "8.56.1",
"@typescript-eslint/parser": "8.56.1",
"eslint": "10.0.2",
"@types/node": "25.5.0",
"@typescript-eslint/eslint-plugin": "8.57.2",
"@typescript-eslint/parser": "8.57.2",
"eslint": "10.1.0",
"eslint-config-prettier": "10.1.8",
"globals": "17.4.0",
"prettier": "3.8.1",
"ts-node": "10.9.2",
"typescript": "5.9.3",
"vitest": "4.0.18"
"vitest": "4.1.1"
}
}

View File

@@ -34,6 +34,8 @@ import {
SetSessionModelResponse,
SetSessionModeRequest,
SetSessionModeResponse,
CloseSessionRequest,
CloseSessionResponse,
TerminalHandle,
TerminalOutputResponse,
WriteTextFileRequest,
@@ -60,7 +62,6 @@ import { randomUUID } from "node:crypto";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { fileURLToPath } from "node:url";
import packageJson from "../package.json" with { type: "json" };
import { SettingsManager } from "./settings.js";
import {
@@ -110,13 +111,16 @@ type Session = {
query: Query;
input: Pushable<SDKUserMessage>;
cancelled: boolean;
permissionMode: PermissionMode;
cwd: string;
settingsManager: SettingsManager;
accumulatedUsage: AccumulatedUsage;
modes: SessionModeState;
models: SessionModelState;
configOptions: SessionConfigOption[];
promptRunning: boolean;
pendingMessages: Map<string, { resolve: (cancelled: boolean) => void; order: number }>;
nextPendingOrder: number;
abortController: AbortController;
};
type BackgroundTerminal =
@@ -148,21 +152,11 @@ export type NewSessionMeta = {
* - 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)
*/
options?: Options;
};
};
/**
* Extended ClientCapabilities with `auth` field.
* TODO: Remove once `auth` is added to the ACP SDK schema.
*/
type ClientCapabilitiesWithAuth = ClientCapabilities & {
auth?: {
_meta?: {
gateway?: boolean;
};
};
additionalRoots?: string[];
};
/**
@@ -219,6 +213,12 @@ function isStaticBinary(): boolean {
return process.env.CLAUDE_AGENT_ACP_IS_SINGLE_FILE_BUN !== undefined;
}
export async function claudeCliPath(): Promise<string> {
return isStaticBinary()
? (await import("@anthropic-ai/claude-agent-sdk/embed")).default
: import.meta.resolve("@anthropic-ai/claude-agent-sdk").replace("sdk.mjs", "cli.js");
}
function shouldHideClaudeAuth(): boolean {
return process.argv.includes("--hide-claude-auth");
}
@@ -227,6 +227,10 @@ function shouldHideClaudeAuth(): boolean {
const IS_ROOT = (process.geteuid?.() ?? process.getuid?.()) === 0;
const ALLOW_BYPASS = !IS_ROOT || !!process.env.IS_SANDBOX;
// Slash commands that the SDK handles locally without replaying the user
// message and without invoking the model.
const LOCAL_ONLY_COMMANDS = new Set(["/context", "/heapdump", "/extra-usage"]);
const PERMISSION_MODE_ALIASES: Record<string, PermissionMode> = {
default: "default",
acceptedits: "acceptEdits",
@@ -286,17 +290,9 @@ export class ClaudeAcpAgent implements Agent {
async initialize(request: InitializeRequest): Promise<InitializeResponse> {
this.clientCapabilities = request.clientCapabilities;
// Default authMethod
const authMethod: any = {
description: "Run `claude /login` in the terminal",
name: "Log in with Claude",
id: "claude-login",
};
// Bypasses standard auth by routing requests through a custom Anthropic-protocol gateway.
// Only offered when the client advertises `auth._meta.gateway` capability.
const clientCaps = request.clientCapabilities as ClientCapabilitiesWithAuth | undefined;
const supportsGatewayAuth = clientCaps?.auth?._meta?.gateway === true;
const supportsGatewayAuth = request.clientCapabilities?.auth?._meta?.gateway === true;
const gatewayAuthMethod: AuthMethod = {
id: "gateway",
@@ -309,25 +305,22 @@ export class ClaudeAcpAgent implements Agent {
},
};
const terminalAuthMethod: any = {
description: "Run `claude /login` in the terminal",
name: "Log in with Claude",
id: "claude-login",
type: "terminal",
args: ["--cli"],
};
const supportsTerminalAuth = request.clientCapabilities?.auth?.terminal === true;
// If client supports terminal-auth capability, use that instead.
const supportsTerminalAuth = request.clientCapabilities?._meta?.["terminal-auth"] === true;
if (supportsTerminalAuth) {
let command: string;
let args: string[];
if (isStaticBinary()) {
command = process.execPath;
args = ["--cli"];
} else {
const cliPath = fileURLToPath(import.meta.resolve("@anthropic-ai/claude-agent-sdk/cli.js"));
command = "node";
args = [cliPath];
}
authMethod._meta = {
const supportsMetaTerminalAuth = request.clientCapabilities?._meta?.["terminal-auth"] === true;
if (supportsMetaTerminalAuth) {
terminalAuthMethod._meta = {
"terminal-auth": {
command,
args,
command: process.execPath,
args: [...process.argv.slice(1), "--cli"],
label: "Claude Login",
},
};
@@ -354,6 +347,7 @@ export class ClaudeAcpAgent implements Agent {
fork: {},
list: {},
resume: {},
close: {},
},
},
agentInfo: {
@@ -362,8 +356,9 @@ export class ClaudeAcpAgent implements Agent {
version: packageJson.version,
},
authMethods: [
// Terminal auth can also be used for API keys, so don't gate it on --hide-claude-auth.
...(shouldHideClaudeAuth() && !supportsTerminalAuth ? [] : [authMethod]),
...(!shouldHideClaudeAuth() && (supportsTerminalAuth || supportsMetaTerminalAuth)
? [terminalAuthMethod]
: []),
...(supportsGatewayAuth ? [gatewayAuthMethod] : []),
],
};
@@ -409,34 +404,17 @@ export class ClaudeAcpAgent implements Agent {
}
async unstable_resumeSession(params: ResumeSessionRequest): Promise<ResumeSessionResponse> {
const response = await this.createSession(
{
cwd: params.cwd,
mcpServers: params.mcpServers ?? [],
_meta: params._meta,
},
{
resume: params.sessionId,
},
);
const result = await this.getOrCreateSession(params);
// Needs to happen after we return the session
setTimeout(() => {
this.sendAvailableCommandsUpdate(response.sessionId);
this.sendAvailableCommandsUpdate(params.sessionId);
}, 0);
return response;
return result;
}
async loadSession(params: LoadSessionRequest): Promise<LoadSessionResponse> {
const response = await this.createSession(
{
cwd: params.cwd,
mcpServers: params.mcpServers ?? [],
_meta: params._meta,
},
{
resume: params.sessionId,
},
);
const result = await this.getOrCreateSession(params);
await this.replaySessionHistory(params.sessionId);
@@ -445,14 +423,10 @@ export class ClaudeAcpAgent implements Agent {
this.sendAvailableCommandsUpdate(params.sessionId);
}, 0);
return {
modes: response.modes,
models: response.models,
configOptions: response.configOptions,
};
return result;
}
async unstable_listSessions(params: ListSessionsRequest): Promise<ListSessionsResponse> {
async listSessions(params: ListSessionsRequest): Promise<ListSessionsResponse> {
const sdk_sessions = await listSessions({ dir: params.cwd ?? undefined });
const sessions = [];
@@ -496,17 +470,33 @@ export class ClaudeAcpAgent implements Agent {
const userMessage = promptToClaude(params);
const promptUuid = randomUUID();
userMessage.uuid = promptUuid;
let promptReplayed = false;
// These local-only commands return a result without replaying the user
// message. Mark promptReplayed=true so their result isn't consumed as a
// background task result.
const firstText = params.prompt[0]?.type === "text" ? params.prompt[0].text : "";
const isLocalOnlyCommand =
firstText.startsWith("/") && LOCAL_ONLY_COMMANDS.has(firstText.split(" ", 1)[0]);
if (isLocalOnlyCommand) {
promptReplayed = true;
}
if (session.promptRunning) {
const uuid = randomUUID();
userMessage.uuid = uuid;
session.input.push(userMessage);
const order = session.nextPendingOrder++;
const cancelled = await new Promise<boolean>((resolve) => {
session.pendingMessages.set(uuid, { resolve, order });
session.pendingMessages.set(promptUuid, { resolve, order });
});
if (cancelled) {
return { stopReason: "cancelled" };
}
// The replay resolved the promise, mark in this loop too,
// so we don't treat the next result as a background task's result.
promptReplayed = true;
} else {
session.input.push(userMessage);
}
@@ -553,6 +543,7 @@ export class ClaudeAcpAgent implements Agent {
content: { type: "text", text: "\n\nCompacting completed." },
},
});
promptReplayed = true;
break;
}
case "local_command_output": {
@@ -563,6 +554,13 @@ export class ClaudeAcpAgent implements Agent {
content: { type: "text", text: message.content },
},
});
promptReplayed = true;
break;
}
case "session_state_changed": {
if (message.state === "idle") {
return { stopReason: "end_turn", usage: sessionUsage(session) };
}
break;
}
case "hook_started":
@@ -573,6 +571,7 @@ export class ClaudeAcpAgent implements Agent {
case "task_notification":
case "task_progress":
case "elicitation_complete":
case "api_retry":
// Todo: process via status api: https://docs.claude.com/en/docs/claude-code/hooks#hook-output
break;
default:
@@ -581,10 +580,6 @@ export class ClaudeAcpAgent implements Agent {
}
break;
case "result": {
if (session.cancelled) {
return { stopReason: "cancelled" };
}
// Accumulate usage from this result
session.accumulatedUsage.inputTokens += message.usage.input_tokens;
session.accumulatedUsage.outputTokens += message.usage.output_tokens;
@@ -612,18 +607,24 @@ export class ClaudeAcpAgent implements Agent {
});
}
// Check cancelled before promptReplayed — when a cancel races
// with the first result, promptReplayed is still false and the
// result would be consumed as a background task, blocking the
// loop forever (see #442).
if (session.cancelled) {
return { stopReason: "cancelled" };
}
if (!promptReplayed) {
// This result is from a background task that finished after
// the previous prompt loop ended. Consume it and continue
// waiting for our own prompt's result.
this.logger.log(`Session ${params.sessionId}: consuming background task result`);
break;
}
// Build the usage response
const usage: PromptResponse["usage"] = {
inputTokens: session.accumulatedUsage.inputTokens,
outputTokens: session.accumulatedUsage.outputTokens,
cachedReadTokens: session.accumulatedUsage.cachedReadTokens,
cachedWriteTokens: session.accumulatedUsage.cachedWriteTokens,
totalTokens:
session.accumulatedUsage.inputTokens +
session.accumulatedUsage.outputTokens +
session.accumulatedUsage.cachedReadTokens +
session.accumulatedUsage.cachedWriteTokens,
};
const usage = sessionUsage(session);
switch (message.subtype) {
case "success": {
@@ -636,9 +637,23 @@ export class ClaudeAcpAgent implements Agent {
if (message.is_error) {
throw RequestError.internalError(undefined, message.result);
}
return { stopReason: "end_turn", usage };
// For local-only commands (no model invocation), the result
// text is the command output — forward it to the client.
if (isLocalOnlyCommand) {
for (const notification of toAcpNotifications(
message.result,
"assistant",
params.sessionId,
this.toolUseCache,
this.client,
this.logger,
)) {
await this.client.sessionUpdate(notification);
}
}
break;
}
case "error_during_execution":
case "error_during_execution": {
if (message.stop_reason === "max_tokens") {
return { stopReason: "max_tokens", usage };
}
@@ -648,7 +663,8 @@ export class ClaudeAcpAgent implements Agent {
message.errors.join(", ") || message.subtype,
);
}
return { stopReason: "end_turn", usage };
return { stopReason: "end_turn", usage: sessionUsage(session) };
}
case "error_max_budget_usd":
case "error_max_turns":
case "error_max_structured_output_retries":
@@ -672,7 +688,10 @@ export class ClaudeAcpAgent implements Agent {
this.toolUseCache,
this.client,
this.logger,
{ clientCapabilities: this.clientCapabilities },
{
clientCapabilities: this.clientCapabilities,
cwd: session.cwd,
},
)) {
await this.client.sessionUpdate(notification);
}
@@ -684,8 +703,15 @@ export class ClaudeAcpAgent implements Agent {
break;
}
// Check for queued prompt replay
// Check for prompt replay
if (message.type === "user" && "uuid" in message && message.uuid) {
if (message.uuid === promptUuid) {
// Our own prompt was replayed back — we're now processing
// our prompt's response (not a background task's).
promptReplayed = true;
break;
}
const pending = session.pendingMessages.get(message.uuid as string);
if (pending) {
pending.resolve(false);
@@ -693,7 +719,11 @@ export class ClaudeAcpAgent implements Agent {
handedOff = true;
// the current loop stops with end_turn,
// the loop of the next prompt continues running
return { stopReason: "end_turn" };
return { stopReason: "end_turn", usage: sessionUsage(session) };
}
if ("isReplay" in message && message.isReplay) {
// not pending or unrelated replay message
break;
}
}
@@ -713,22 +743,6 @@ export class ClaudeAcpAgent implements Agent {
typeof message.message.content === "string" &&
message.message.content.includes("<local-command-stdout>")
) {
// Handle /context by sending its reply as regular agent message.
if (message.message.content.includes("Context Usage")) {
for (const notification of toAcpNotifications(
message.message.content
.replace("<local-command-stdout>", "")
.replace("</local-command-stdout>", ""),
"assistant",
params.sessionId,
this.toolUseCache,
this.client,
this.logger,
{ clientCapabilities: this.clientCapabilities },
)) {
await this.client.sessionUpdate(notification);
}
}
this.logger.log(message.message.content);
break;
}
@@ -780,6 +794,7 @@ export class ClaudeAcpAgent implements Agent {
{
clientCapabilities: this.clientCapabilities,
parentToolUseId: message.parent_tool_use_id,
cwd: session.cwd,
},
)) {
await this.client.sessionUpdate(notification);
@@ -811,6 +826,7 @@ export class ClaudeAcpAgent implements Agent {
message.includes("Failed to write to process stdin")
) {
this.logger.error(`Session ${params.sessionId}: Claude Agent process died: ${message}`);
session.settingsManager.dispose();
session.input.end();
delete this.sessions[params.sessionId];
throw RequestError.internalError(
@@ -851,6 +867,20 @@ export class ClaudeAcpAgent implements Agent {
await session.query.interrupt();
}
async unstable_closeSession(params: CloseSessionRequest): Promise<CloseSessionResponse> {
const session = this.sessions[params.sessionId];
if (!session) {
throw new Error("Session not found");
}
await this.cancel({ sessionId: params.sessionId });
session.settingsManager.dispose();
session.abortController.abort();
delete this.sessions[params.sessionId];
return {};
}
async unstable_setSessionModel(
params: SetSessionModelRequest,
): Promise<SetSessionModelResponse | void> {
@@ -878,6 +908,9 @@ export class ClaudeAcpAgent implements Agent {
if (!session) {
throw new Error("Session not found");
}
if (typeof params.value !== "string") {
throw new Error(`Invalid value for config option ${params.configId}: ${params.value}`);
}
const option = session.configOptions.find((o) => o.id === params.configId);
if (!option) {
@@ -888,26 +921,50 @@ export class ClaudeAcpAgent implements Agent {
"options" in option && Array.isArray(option.options)
? option.options.flatMap((o) => ("options" in o ? o.options : [o]))
: [];
const validValue = allValues.find((o) => o.value === params.value);
let validValue = allValues.find((o) => o.value === params.value);
// For model options, fall back to resolveModelPreference when the exact
// value doesn't match. This lets callers use human-friendly aliases like
// "opus" or "sonnet" instead of full model IDs like "claude-opus-4-6".
if (!validValue && params.configId === "model") {
const modelInfos: ModelInfo[] = allValues.map((o) => ({
value: o.value,
displayName: o.name,
description: o.description ?? "",
}));
const resolved = resolveModelPreference(modelInfos, params.value);
if (resolved) {
validValue = allValues.find((o) => o.value === resolved.value);
}
}
if (!validValue) {
throw new Error(`Invalid value for config option ${params.configId}: ${params.value}`);
}
// Use the canonical option value so downstream code always receives the
// model ID rather than the caller-supplied alias.
const resolvedValue = validValue.value;
if (params.configId === "mode") {
await this.applySessionMode(params.sessionId, params.value);
await this.applySessionMode(params.sessionId, resolvedValue);
await this.client.sessionUpdate({
sessionId: params.sessionId,
update: {
sessionUpdate: "current_mode_update",
currentModeId: params.value,
currentModeId: resolvedValue,
},
});
} else if (params.configId === "model") {
await this.sessions[params.sessionId].query.setModel(params.value);
await this.sessions[params.sessionId].query.setModel(resolvedValue);
}
this.syncSessionConfigState(session, params.configId, params.value);
session.configOptions = session.configOptions.map((o) =>
o.id === params.configId ? { ...o, currentValue: params.value } : o,
o.id === params.configId && typeof o.currentValue === "string"
? { ...o, currentValue: resolvedValue }
: o,
);
return { configOptions: session.configOptions };
@@ -924,7 +981,6 @@ export class ClaudeAcpAgent implements Agent {
default:
throw new Error("Invalid Mode");
}
this.sessions[sessionId].permissionMode = modeId;
try {
await this.sessions[sessionId].query.setPermissionMode(modeId);
} catch (error) {
@@ -954,7 +1010,11 @@ export class ClaudeAcpAgent implements Agent {
toolUseCache,
this.client,
this.logger,
{ registerHooks: false, clientCapabilities: this.clientCapabilities },
{
registerHooks: false,
clientCapabilities: this.clientCapabilities,
cwd: this.sessions[sessionId]?.cwd,
},
)) {
await this.client.sessionUpdate(notification);
}
@@ -979,21 +1039,29 @@ export class ClaudeAcpAgent implements Agent {
return {
behavior: "deny",
message: "Session not found",
interrupt: true,
};
}
if (toolName === "ExitPlanMode") {
const options = [
{
kind: "allow_always",
name: "Yes, and auto-accept edits",
optionId: "acceptEdits",
},
{ kind: "allow_once", name: "Yes, and manually approve edits", optionId: "default" },
{ kind: "reject_once", name: "No, keep planning", optionId: "plan" },
];
if (ALLOW_BYPASS) {
options.unshift({
kind: "allow_always",
name: "Yes, and bypass permissions",
optionId: "bypassPermissions",
});
}
const response = await this.client.requestPermission({
options: [
{
kind: "allow_always",
name: "Yes, and auto-accept edits",
optionId: "acceptEdits",
},
{ kind: "allow_once", name: "Yes, and manually approve edits", optionId: "default" },
{ kind: "reject_once", name: "No, keep planning", optionId: "plan" },
],
options,
sessionId,
toolCall: {
toolCallId: toolUseID,
@@ -1001,6 +1069,7 @@ export class ClaudeAcpAgent implements Agent {
...toolInfoFromToolUse(
{ name: toolName, input: toolInput, id: toolUseID },
supportsTerminalOutput,
session?.cwd,
),
},
});
@@ -1010,9 +1079,10 @@ export class ClaudeAcpAgent implements Agent {
}
if (
response.outcome?.outcome === "selected" &&
(response.outcome.optionId === "default" || response.outcome.optionId === "acceptEdits")
(response.outcome.optionId === "default" ||
response.outcome.optionId === "acceptEdits" ||
response.outcome.optionId === "bypassPermissions")
) {
session.permissionMode = response.outcome.optionId;
await this.client.sessionUpdate({
sessionId,
update: {
@@ -1033,12 +1103,11 @@ export class ClaudeAcpAgent implements Agent {
return {
behavior: "deny",
message: "User rejected request to exit plan mode.",
interrupt: true,
};
}
}
if (session.permissionMode === "bypassPermissions") {
if (session.modes.currentModeId === "bypassPermissions") {
return {
behavior: "allow",
updatedInput: toolInput,
@@ -1065,6 +1134,7 @@ export class ClaudeAcpAgent implements Agent {
...toolInfoFromToolUse(
{ name: toolName, input: toolInput, id: toolUseID },
supportsTerminalOutput,
session?.cwd,
),
},
});
@@ -1098,7 +1168,6 @@ export class ClaudeAcpAgent implements Agent {
return {
behavior: "deny",
message: "User refused permission to run tool",
interrupt: true,
};
}
};
@@ -1125,8 +1194,10 @@ export class ClaudeAcpAgent implements Agent {
const session = this.sessions[sessionId];
if (!session) return;
this.syncSessionConfigState(session, configId, value);
session.configOptions = session.configOptions.map((o) =>
o.id === configId ? { ...o, currentValue: value } : o,
o.id === configId && typeof o.currentValue === "string" ? { ...o, currentValue: value } : o,
);
await this.client.sessionUpdate({
@@ -1138,6 +1209,49 @@ export class ClaudeAcpAgent implements Agent {
});
}
private syncSessionConfigState(session: Session, configId: string, value: string): void {
if (configId === "mode") {
session.modes = { ...session.modes, currentModeId: value };
} else if (configId === "model") {
session.models = { ...session.models, currentModelId: value };
}
}
private async getOrCreateSession(params: {
sessionId: string;
cwd: string;
mcpServers?: NewSessionRequest["mcpServers"];
_meta?: NewSessionRequest["_meta"];
}): Promise<NewSessionResponse> {
const existingSession = this.sessions[params.sessionId];
if (existingSession) {
return {
sessionId: params.sessionId,
modes: existingSession.modes,
models: existingSession.models,
configOptions: existingSession.configOptions,
};
}
const response = await this.createSession(
{
cwd: params.cwd,
mcpServers: params.mcpServers ?? [],
_meta: params._meta,
},
{
resume: params.sessionId,
},
);
return {
sessionId: response.sessionId,
modes: response.modes,
models: response.models,
configOptions: response.configOptions,
};
}
private async createSession(
params: NewSessionRequest,
creationOpts: { resume?: string; forkSession?: boolean } = {},
@@ -1203,7 +1317,8 @@ export class ClaudeAcpAgent implements Agent {
);
// Extract options from _meta if provided
const userProvidedOptions = (params._meta as NewSessionMeta | undefined)?.claudeCode?.options;
const sessionMeta = params._meta as NewSessionMeta | undefined;
const userProvidedOptions = sessionMeta?.claudeCode?.options;
// Configure thinking tokens from environment variable
const maxThinkingTokens = process.env.MAX_THINKING_TOKENS
@@ -1213,26 +1328,15 @@ export class ClaudeAcpAgent implements Agent {
// Disable this for now, not a great way to expose this over ACP at the moment (in progress work so we can revisit)
const disallowedTools = ["AskUserQuestion"];
// Check if built-in tools should be disabled
if (params._meta?.disableBuiltInTools === true) {
// When built-in tools are disabled, explicitly disallow all of them
disallowedTools.push(
"Read",
"Write",
"Edit",
"Bash",
"Glob",
"Grep",
"Task",
"TodoWrite",
"ExitPlanMode",
"WebSearch",
"WebFetch",
"SlashCommand",
"Skill",
"NotebookEdit",
);
}
// Resolve which built-in tools to expose.
// Explicit tools array from _meta.claudeCode.options takes precedence.
// disableBuiltInTools is a legacy shorthand for tools: [] — kept for
// backward compatibility but callers should prefer the tools array.
const tools: Options["tools"] =
userProvidedOptions?.tools ??
(params._meta?.disableBuiltInTools === true ? [] : { type: "preset", preset: "claude_code" });
const abortController = userProvidedOptions?.abortController || new AbortController();
const options: Options = {
systemPrompt,
@@ -1243,6 +1347,8 @@ export class ClaudeAcpAgent implements Agent {
...process.env,
...userProvidedOptions?.env,
...createEnvForGateway(this.gatewayAuthMeta),
// Opt-in to session state events like when the agent is idle
CLAUDE_CODE_EMIT_SESSION_STATE_EVENTS: "1",
},
// Override certain fields that must be controlled by ACP
cwd: params.cwd,
@@ -1255,19 +1361,18 @@ export class ClaudeAcpAgent implements Agent {
canUseTool: this.canUseTool(sessionId),
// note: although not documented by the types, passing an absolute path
// here works to find zed's managed node version.
executable: process.execPath as any,
executableArgs: isStaticBinary() ? ["--cli"] : undefined,
executable: isStaticBinary() ? undefined : (process.execPath as any),
...(process.env.CLAUDE_CODE_EXECUTABLE
? { pathToClaudeCodeExecutable: process.env.CLAUDE_CODE_EXECUTABLE }
: isStaticBinary()
? { pathToClaudeCodeExecutable: process.execPath }
? { pathToClaudeCodeExecutable: await claudeCliPath() }
: {}),
extraArgs: {
...userProvidedOptions?.extraArgs,
"replay-user-messages": "",
},
disallowedTools: [...(userProvidedOptions?.disallowedTools || []), ...disallowedTools],
tools: { type: "preset", preset: "claude_code" },
tools,
hooks: {
...userProvidedOptions?.hooks,
PostToolUse: [
@@ -1276,10 +1381,6 @@ export class ClaudeAcpAgent implements Agent {
hooks: [
createPostToolUseHook(this.logger, {
onEnterPlanMode: async () => {
const session = this.sessions[sessionId];
if (session) {
session.permissionMode = "plan";
}
await this.client.sessionUpdate({
sessionId,
update: {
@@ -1295,15 +1396,20 @@ export class ClaudeAcpAgent implements Agent {
],
},
...creationOpts,
abortController,
};
options.additionalDirectories = [
...(userProvidedOptions?.additionalDirectories ?? []),
...(sessionMeta?.additionalRoots ?? []),
];
if (creationOpts?.resume === undefined || creationOpts?.forkSession) {
// Set our own session id if not resuming an existing session.
options.sessionId = sessionId;
}
// Handle abort controller from meta options
const abortController = userProvidedOptions?.abortController;
if (abortController?.signal.aborted) {
throw new Error("Cancelled");
}
@@ -1327,7 +1433,11 @@ export class ClaudeAcpAgent implements Agent {
throw error;
}
if (shouldHideClaudeAuth() && initializationResult.account.subscriptionType) {
if (
shouldHideClaudeAuth() &&
initializationResult.account.subscriptionType &&
!this.gatewayAuthMeta
) {
throw RequestError.authRequired(
undefined,
"This integration does not support using claude.ai subscriptions.",
@@ -1378,7 +1488,7 @@ export class ClaudeAcpAgent implements Agent {
query: q,
input: input,
cancelled: false,
permissionMode,
cwd: params.cwd,
settingsManager,
accumulatedUsage: {
inputTokens: 0,
@@ -1386,10 +1496,13 @@ export class ClaudeAcpAgent implements Agent {
cachedReadTokens: 0,
cachedWriteTokens: 0,
},
modes,
models,
configOptions,
promptRunning: false,
pendingMessages: new Map(),
nextPendingOrder: 0,
abortController,
};
return {
@@ -1401,6 +1514,20 @@ export class ClaudeAcpAgent implements Agent {
}
}
function sessionUsage(session: Session) {
return {
inputTokens: session.accumulatedUsage.inputTokens,
outputTokens: session.accumulatedUsage.outputTokens,
cachedReadTokens: session.accumulatedUsage.cachedReadTokens,
cachedWriteTokens: session.accumulatedUsage.cachedWriteTokens,
totalTokens:
session.accumulatedUsage.inputTokens +
session.accumulatedUsage.outputTokens +
session.accumulatedUsage.cachedReadTokens +
session.accumulatedUsage.cachedWriteTokens,
};
}
function createEnvForGateway(gatewayMeta?: GatewayAuthMeta) {
if (!gatewayMeta) {
return {};
@@ -1693,6 +1820,7 @@ export function toAcpNotifications(
registerHooks?: boolean;
clientCapabilities?: ClientCapabilities;
parentToolUseId?: string | null;
cwd?: string;
},
): SessionNotification[] {
const registerHooks = options?.registerHooks !== false;
@@ -1821,7 +1949,7 @@ export function toAcpNotifications(
toolCallId: chunk.id,
sessionUpdate: "tool_call_update",
rawInput,
...toolInfoFromToolUse(chunk, supportsTerminalOutput),
...toolInfoFromToolUse(chunk, supportsTerminalOutput, options?.cwd),
};
} else {
// First encounter (streaming content_block_start or replay) —
@@ -1839,7 +1967,7 @@ export function toAcpNotifications(
sessionUpdate: "tool_call",
rawInput,
status: "pending",
...toolInfoFromToolUse(chunk, supportsTerminalOutput),
...toolInfoFromToolUse(chunk, supportsTerminalOutput, options?.cwd),
};
}
}
@@ -1945,7 +2073,10 @@ export function streamEventToAcpNotifications(
toolUseCache: ToolUseCache,
client: AgentSideConnection,
logger: Logger,
options?: { clientCapabilities?: ClientCapabilities },
options?: {
clientCapabilities?: ClientCapabilities;
cwd?: string;
},
): SessionNotification[] {
const event = message.event;
switch (event.type) {
@@ -1960,6 +2091,7 @@ export function streamEventToAcpNotifications(
{
clientCapabilities: options?.clientCapabilities,
parentToolUseId: message.parent_tool_use_id,
cwd: options?.cwd,
},
);
case "content_block_delta":
@@ -1973,6 +2105,7 @@ export function streamEventToAcpNotifications(
{
clientCapabilities: options?.clientCapabilities,
parentToolUseId: message.parent_tool_use_id,
cwd: options?.cwd,
},
);
// No content

View File

@@ -2,12 +2,11 @@
// Load managed settings and apply environment variables
import { loadManagedSettings, applyEnvironmentSettings } from "./utils.js";
import { runAcp } from "./acp-agent.js";
import { claudeCliPath, runAcp } from "./acp-agent.js";
if (process.argv.includes("--cli")) {
process.argv = process.argv.filter((arg) => arg !== "--cli");
// @ts-expect-error -- no types
await import("@anthropic-ai/claude-agent-sdk/cli.js");
await import(await claudeCliPath());
} else {
const managedSettings = loadManagedSettings();
if (managedSettings) {

View File

@@ -15,7 +15,12 @@ export {
Pushable,
unreachable,
} from "./utils.js";
export { toolInfoFromToolUse, planEntries, toolUpdateFromToolResult } from "./tools.js";
export {
toolInfoFromToolUse,
toDisplayPath,
planEntries,
toolUpdateFromToolResult,
} from "./tools.js";
export {
SettingsManager,
type ClaudeCodeSettings,

View File

@@ -1,4 +1,4 @@
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import { describe, it, expect, beforeAll, afterAll, vi } from "vitest";
import { spawn, spawnSync } from "child_process";
import {
Agent,
@@ -20,10 +20,11 @@ import { nodeToWebWritable, nodeToWebReadable } from "../utils.js";
import {
markdownEscape,
toolInfoFromToolUse,
toDisplayPath,
toolUpdateFromToolResult,
toolUpdateFromEditToolResponse,
} from "../tools.js";
import { toAcpNotifications, promptToClaude, ClaudeAcpAgent } from "../acp-agent.js";
import { toAcpNotifications, promptToClaude, ClaudeAcpAgent, claudeCliPath } from "../acp-agent.js";
import { Pushable } from "../utils.js";
import { query, SDKAssistantMessage } from "@anthropic-ai/claude-agent-sdk";
import { randomUUID } from "crypto";
@@ -227,7 +228,7 @@ describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)("ACP subprocess integration"
sessionId: newSessionResponse.sessionId,
});
expect(client.takeReceivedText()).toBe("Error: No messages to compact");
expect(client.takeReceivedText()).toBe("Compacting...\n\nCompacting completed.");
// Send something
await connection.prompt({
@@ -248,7 +249,7 @@ describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)("ACP subprocess integration"
sessionId: newSessionResponse.sessionId,
});
expect(client.takeReceivedText()).toContain("");
expect(client.takeReceivedText()).toContain("Compacting...\n\nCompacting completed.");
}, 30000);
});
@@ -563,6 +564,20 @@ describe("tool conversions", () => {
});
});
it("should use relative path in title when cwd is provided", () => {
const tool_use = {
type: "tool_use",
id: "toolu_01READ_CWD",
name: "Read",
input: { file_path: "/Users/test/project/src/main.ts" },
};
const result = toolInfoFromToolUse(tool_use, false, "/Users/test/project");
expect(result.title).toBe("Read src/main.ts");
// locations.path stays absolute for navigation
expect(result.locations).toStrictEqual([{ path: "/Users/test/project/src/main.ts", line: 1 }]);
});
it("should handle plan entries", () => {
const received: SDKAssistantMessage = {
type: "assistant",
@@ -969,6 +984,22 @@ describe("tool conversions", () => {
});
});
describe("toDisplayPath", () => {
it("should relativize paths inside cwd and keep absolute paths outside", () => {
expect(toDisplayPath("/Users/test/project/src/main.ts", "/Users/test/project")).toBe(
"src/main.ts",
);
expect(toDisplayPath("/etc/hosts", "/Users/test/project")).toBe("/etc/hosts");
expect(toDisplayPath("/Users/test/project/src/main.ts")).toBe(
"/Users/test/project/src/main.ts",
);
// Partial directory name match should not be treated as inside cwd
expect(toDisplayPath("/Users/test/project-other/file.ts", "/Users/test/project")).toBe(
"/Users/test/project-other/file.ts",
);
});
});
describe("toolUpdateFromEditToolResponse", () => {
it("should return empty for non-object input", () => {
expect(toolUpdateFromEditToolResponse(null)).toEqual({});
@@ -1140,6 +1171,11 @@ describe("prompt conversion", () => {
});
describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)("SDK behavior", () => {
it("finds vendored cli path", async () => {
const path = await claudeCliPath();
expect(path).toContain("@anthropic-ai/claude-agent-sdk/cli.js");
});
it("query has a 'default' model", async () => {
const q = query({ prompt: "hi" });
const models = await q.supportedModels();
@@ -1269,18 +1305,38 @@ describe("stop reason propagation", () => {
};
}
function* messageGenerator(messages: any[]) {
yield* messages;
}
function injectSession(agent: ClaudeAcpAgent, messages: any[]) {
const gen = messageGenerator(messages);
const input = new Pushable<any>();
async function* messageGenerator() {
// Wait for the prompt to push its user message so we can replay it
const iter = input[Symbol.asyncIterator]();
const { value: userMessage, done } = await iter.next();
if (!done && userMessage) {
yield {
type: "user",
message: userMessage.message,
parent_tool_use_id: null,
uuid: userMessage.uuid,
session_id: "test-session",
isReplay: true,
};
}
yield* messages;
}
agent.sessions["test-session"] = {
query: gen as any,
input: new Pushable(),
query: messageGenerator() as any,
input,
cancelled: false,
permissionMode: "default",
settingsManager: {} as any,
cwd: "/test",
modes: {
currentModeId: "default",
availableModes: [],
},
models: {
currentModelId: "default",
availableModels: [],
},
settingsManager: { dispose: vi.fn() } as any,
accumulatedUsage: {
inputTokens: 0,
outputTokens: 0,
@@ -1291,6 +1347,7 @@ describe("stop reason propagation", () => {
promptRunning: false,
pendingMessages: new Map(),
nextPendingOrder: 0,
abortController: new AbortController(),
};
}
@@ -1350,6 +1407,7 @@ describe("stop reason propagation", () => {
const agent = createMockAgent();
injectSession(agent, [
createResultMessage({ subtype: "success", stop_reason: null, is_error: false }),
{ type: "system", subtype: "session_state_changed", state: "idle" },
]);
const response = await agent.prompt({
@@ -1360,6 +1418,89 @@ describe("stop reason propagation", () => {
expect(response.stopReason).toBe("end_turn");
});
it("should consume background task results and return the prompt's own result", async () => {
const agent = createMockAgent();
const input = new Pushable<any>();
const backgroundTaskResult = createResultMessage({
subtype: "success",
stop_reason: null,
is_error: false,
});
// Background task used some tokens
backgroundTaskResult.usage.input_tokens = 100;
backgroundTaskResult.usage.output_tokens = 50;
const promptResult = createResultMessage({
subtype: "success",
stop_reason: null,
is_error: false,
});
async function* messageGenerator() {
// Background task init + result arrive before our prompt's replay
yield { type: "system", subtype: "init", session_id: "test-session" };
yield backgroundTaskResult;
// Now the prompt's user message replay arrives
const iter = input[Symbol.asyncIterator]();
const { value: userMessage } = await iter.next();
yield {
type: "user",
message: userMessage.message,
parent_tool_use_id: null,
uuid: userMessage.uuid,
session_id: "test-session",
isReplay: true,
};
// Then the prompt's own result
yield promptResult;
yield { type: "system", subtype: "session_state_changed", state: "idle" };
}
agent.sessions["test-session"] = {
query: messageGenerator() as any,
input,
cwd: "/tmp/test",
cancelled: false,
modes: {
currentModeId: "default",
availableModes: [],
},
models: {
currentModelId: "default",
availableModels: [],
},
settingsManager: { dispose: vi.fn() } as any,
accumulatedUsage: {
inputTokens: 0,
outputTokens: 0,
cachedReadTokens: 0,
cachedWriteTokens: 0,
},
abortController: new AbortController(),
configOptions: [],
promptRunning: false,
pendingMessages: new Map(),
nextPendingOrder: 0,
};
const response = await agent.prompt({
sessionId: "test-session",
prompt: [{ type: "text", text: "test" }],
});
expect(response.stopReason).toBe("end_turn");
// Usage should include both background task and prompt result tokens
expect(response.usage?.inputTokens).toBe(
backgroundTaskResult.usage.input_tokens + promptResult.usage.input_tokens,
);
expect(response.usage?.outputTokens).toBe(
backgroundTaskResult.usage.output_tokens + promptResult.usage.output_tokens,
);
});
it("should throw internal error for success with is_error true and no max_tokens", async () => {
const agent = createMockAgent();
injectSession(agent, [
@@ -1379,3 +1520,88 @@ describe("stop reason propagation", () => {
).rejects.toThrow("Internal error");
});
});
describe("session/close", () => {
function createMockAgent() {
const mockClient = {
sessionUpdate: async () => {},
} as unknown as AgentSideConnection;
return new ClaudeAcpAgent(mockClient, { log: () => {}, error: () => {} });
}
function injectSession(agent: ClaudeAcpAgent, sessionId: string) {
function* empty() {}
const gen = Object.assign(empty(), { interrupt: vi.fn() });
agent.sessions[sessionId] = {
query: gen as any,
input: new Pushable(),
cancelled: false,
cwd: "/test",
modes: {
currentModeId: "default",
availableModes: [],
},
models: {
currentModelId: "default",
availableModels: [],
},
settingsManager: { dispose: vi.fn() } as any,
accumulatedUsage: {
inputTokens: 0,
outputTokens: 0,
cachedReadTokens: 0,
cachedWriteTokens: 0,
},
configOptions: [],
promptRunning: false,
pendingMessages: new Map(),
nextPendingOrder: 0,
abortController: new AbortController(),
};
return agent.sessions[sessionId]!;
}
it("should close an existing session and remove it", async () => {
const agent = createMockAgent();
const session = injectSession(agent, "session-1");
expect(agent.sessions["session-1"]).toBeDefined();
const result = await agent.unstable_closeSession({ sessionId: "session-1" });
expect(result).toEqual({});
expect(agent.sessions["session-1"]).toBeUndefined();
expect(session.query.interrupt).toHaveBeenCalled();
expect(session.settingsManager.dispose).toHaveBeenCalled();
});
it("should abort the session's abort controller", async () => {
const agent = createMockAgent();
const session = injectSession(agent, "session-2");
expect(session.abortController.signal.aborted).toBe(false);
await agent.unstable_closeSession({ sessionId: "session-2" });
expect(session.abortController.signal.aborted).toBe(true);
});
it("should throw when closing a non-existent session", async () => {
const agent = createMockAgent();
await expect(agent.unstable_closeSession({ sessionId: "non-existent" })).rejects.toThrow(
"Session not found",
);
});
it("should not affect other sessions when closing one", async () => {
const agent = createMockAgent();
injectSession(agent, "session-a");
injectSession(agent, "session-b");
await agent.unstable_closeSession({ sessionId: "session-a" });
expect(agent.sessions["session-a"]).toBeUndefined();
expect(agent.sessions["session-b"]).toBeDefined();
});
});

View File

@@ -0,0 +1,71 @@
import { mkdtemp, rm } from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import { AgentSideConnection, SessionNotification } from "@agentclientprotocol/sdk";
import type { Options } from "@anthropic-ai/claude-agent-sdk";
import type { ClaudeAcpAgent as ClaudeAcpAgentType } from "../acp-agent.js";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
let capturedOptions: Options | undefined;
vi.mock("@anthropic-ai/claude-agent-sdk", async () => ({
...(await vi.importActual<typeof import("@anthropic-ai/claude-agent-sdk")>(
"@anthropic-ai/claude-agent-sdk",
)),
query: ({ options }: { options: Options }) => {
capturedOptions = options;
return {
initializationResult: async () => ({
models: [{ value: "claude-sonnet-4-5", displayName: "Claude Sonnet", description: "Fast" }],
}),
setModel: async () => {},
supportedCommands: async () => [],
[Symbol.asyncIterator]: async function* () {},
};
},
}));
vi.mock("../tools.js", async () => ({
...(await vi.importActual<typeof import("../tools.js")>("../tools.js")),
registerHookCallback: vi.fn(),
}));
describe("additionalRoots", () => {
let agent: ClaudeAcpAgentType;
const tempDirs: string[] = [];
const newSession = (meta: Record<string, unknown>, cwd = "/test") =>
agent.newSession({ cwd, mcpServers: [], _meta: meta });
beforeEach(async () => {
capturedOptions = undefined;
tempDirs.length = 0;
vi.resetModules();
const { ClaudeAcpAgent } = await import("../acp-agent.js");
agent = new ClaudeAcpAgent({
sessionUpdate: async (_notification: SessionNotification) => {},
requestPermission: async () => ({ outcome: { outcome: "cancelled" } }),
readTextFile: async () => ({ content: "" }),
writeTextFile: async () => ({}),
} as unknown as AgentSideConnection);
});
afterEach(
async () =>
void (await Promise.all(tempDirs.map((dir) => rm(dir, { recursive: true, force: true })))),
);
it("passes through relative roots as provided", async () => {
const projectRoot = await mkdtemp(path.join(os.tmpdir(), "claude-project-"));
tempDirs.push(projectRoot);
await newSession({ additionalRoots: ["."] }, projectRoot);
expect(capturedOptions!.additionalDirectories).toEqual(["."]);
});
it("merges additionalRoots with user additionalDirectories without normalization", async () => {
const root = await mkdtemp(path.join(os.tmpdir(), "claude-root-"));
tempDirs.push(root);
await newSession({
additionalRoots: ["", root],
claudeCode: { options: { additionalDirectories: ["/workspace/shared"] } },
});
expect(capturedOptions!.additionalDirectories).toEqual(["/workspace/shared", "", root]);
});
});

View File

@@ -72,7 +72,7 @@ describe("authorization", () => {
const initializeResponse = await agent.initialize({
protocolVersion: 1,
clientCapabilities: {
auth: { _meta: { gateway: true } },
auth: { terminal: true, _meta: { gateway: true } },
} as any,
});
expect(initializeResponse.authMethods).toContainEqual(
@@ -130,7 +130,7 @@ describe("authorization", () => {
);
});
it("terminal auth still offered when --hide-claude-auth is set", async () => {
it("hide terminal auth even when terminal-auth is set", async () => {
const [agent] = await createAgentMock();
vi.stubGlobal("process", { ...process, argv: ["--hide-claude-auth"] });
@@ -140,9 +140,7 @@ describe("authorization", () => {
_meta: { "terminal-auth": true },
},
});
expect(initializeResponse.authMethods).toContainEqual(
expect.objectContaining({ id: "claude-login" }),
);
expect(initializeResponse.authMethods).toStrictEqual([]);
});
it("show claude authentication", async () => {
@@ -150,7 +148,7 @@ describe("authorization", () => {
const initializeResponse = await agent.initialize({
protocolVersion: 1,
clientCapabilities: {},
clientCapabilities: { auth: { terminal: true } },
});
expect(initializeResponse.authMethods).toContainEqual(

View File

@@ -102,7 +102,7 @@ describe("createSession options merging", () => {
expect(capturedOptions!.disallowedTools).toContain("AskUserQuestion");
});
it("includes both user and built-in disallowed tools when disableBuiltInTools is true", async () => {
it("sets tools to empty array when disableBuiltInTools is true", async () => {
await agent.newSession({
cwd: "/test",
mcpServers: [],
@@ -116,15 +116,11 @@ describe("createSession options merging", () => {
},
});
const disallowed = capturedOptions!.disallowedTools!;
// User-provided
expect(disallowed).toContain("CustomTool");
// ACP internal
expect(disallowed).toContain("AskUserQuestion");
// Built-in tools disabled by disableBuiltInTools
expect(disallowed).toContain("Read");
expect(disallowed).toContain("Write");
expect(disallowed).toContain("Bash");
// disableBuiltInTools removes all built-in tools from context
expect(capturedOptions!.tools).toEqual([]);
// User-provided and ACP disallowedTools still apply
expect(capturedOptions!.disallowedTools).toContain("CustomTool");
expect(capturedOptions!.disallowedTools).toContain("AskUserQuestion");
});
it("merges user-provided hooks with ACP hooks", async () => {
@@ -199,6 +195,64 @@ describe("createSession options merging", () => {
expect(capturedOptions?.env?.HOME).toBe("/custom/home");
});
it("defaults tools to claude_code preset when not provided", async () => {
await agent.newSession({
cwd: "/test",
mcpServers: [],
});
expect(capturedOptions!.tools).toEqual({ type: "preset", preset: "claude_code" });
});
it("passes through user-provided tools string array", async () => {
await agent.newSession({
cwd: "/test",
mcpServers: [],
_meta: {
claudeCode: {
options: {
tools: ["Read", "Glob"],
},
},
},
});
expect(capturedOptions!.tools).toEqual(["Read", "Glob"]);
});
it("explicit tools array takes precedence over disableBuiltInTools", async () => {
await agent.newSession({
cwd: "/test",
mcpServers: [],
_meta: {
disableBuiltInTools: true,
claudeCode: {
options: {
tools: ["Read", "Glob"],
},
},
},
});
expect(capturedOptions!.tools).toEqual(["Read", "Glob"]);
});
it("passes through empty tools array to disable all built-in tools", async () => {
await agent.newSession({
cwd: "/test",
mcpServers: [],
_meta: {
claudeCode: {
options: {
tools: [],
},
},
},
});
expect(capturedOptions!.tools).toEqual([]);
});
it("merges user-provided mcpServers with ACP mcpServers", async () => {
await agent.newSession({
cwd: "/test",

View File

@@ -224,6 +224,61 @@ describe("session config options", () => {
expect(configUpdate).toBeUndefined();
});
it("resolves model alias 'opus' to full model ID", async () => {
const response = await agent.setSessionConfigOption({
sessionId: SESSION_ID,
configId: "model",
value: "opus",
});
expect(setModelSpy).toHaveBeenCalledWith("claude-opus-4-5");
const modelOption = response.configOptions.find((o) => o.id === "model");
expect(modelOption?.currentValue).toBe("claude-opus-4-5");
});
it("resolves model alias 'sonnet' to full model ID", async () => {
await agent.setSessionConfigOption({
sessionId: SESSION_ID,
configId: "model",
value: "sonnet",
});
expect(setModelSpy).toHaveBeenCalledWith("claude-sonnet-4-5");
});
it("resolves display name to model ID", async () => {
await agent.setSessionConfigOption({
sessionId: SESSION_ID,
configId: "model",
value: "Claude Sonnet",
});
expect(setModelSpy).toHaveBeenCalledWith("claude-sonnet-4-5");
});
it("still works with exact model ID", async () => {
const response = await agent.setSessionConfigOption({
sessionId: SESSION_ID,
configId: "model",
value: "claude-sonnet-4-5",
});
expect(setModelSpy).toHaveBeenCalledWith("claude-sonnet-4-5");
const modelOption = response.configOptions.find((o) => o.id === "model");
expect(modelOption?.currentValue).toBe("claude-sonnet-4-5");
});
it("throws for completely invalid model value", async () => {
await expect(
agent.setSessionConfigOption({
sessionId: SESSION_ID,
configId: "model",
value: "gpt-4",
}),
).rejects.toThrow("Invalid value for config option model: gpt-4");
});
it("returns full configOptions in the response", async () => {
const response = await agent.setSessionConfigOption({
sessionId: SESSION_ID,

View File

@@ -11,7 +11,7 @@ import {
BetaBashCodeExecutionToolResultBlockParam,
} from "@anthropic-ai/sdk/resources/beta.mjs";
import { toAcpNotifications, ToolUseCache, Logger } from "../acp-agent.js";
import { toolUpdateFromToolResult, createPostToolUseHook } from "../tools.js";
import { toolUpdateFromToolResult, createPostToolUseHook, toolInfoFromToolUse } from "../tools.js";
describe("rawOutput in tool call updates", () => {
const mockClient = {} as AgentSideConnection;
@@ -1235,3 +1235,39 @@ describe("Bash terminal output", () => {
});
});
});
describe("toolInfoFromToolUse - ExitPlanMode", () => {
it("should include plan text in content when input.plan is provided", () => {
const toolUse = {
name: "ExitPlanMode",
id: "toolu_plan_1",
input: {
plan: "# My Plan\n\n## Step 1\nDo something",
planFilePath: "/tmp/plan.md",
},
};
const info = toolInfoFromToolUse(toolUse, false);
expect(info.kind).toBe("switch_mode");
expect(info.title).toBe("Ready to code?");
expect(info.content).toHaveLength(1);
expect(info.content![0]).toEqual({
type: "content",
content: { type: "text", text: "# My Plan\n\n## Step 1\nDo something" },
});
});
it("should return empty content when input.plan is not provided", () => {
const toolUse = {
name: "ExitPlanMode",
id: "toolu_plan_2",
input: {},
};
const info = toolInfoFromToolUse(toolUse, false);
expect(info.kind).toBe("switch_mode");
expect(info.content).toEqual([]);
});
});

View File

@@ -5,6 +5,19 @@ import {
ToolCallLocation,
ToolKind,
} from "@agentclientprotocol/sdk";
import { HookCallback } from "@anthropic-ai/claude-agent-sdk";
import {
AgentInput,
BashInput,
FileEditInput,
FileReadInput,
FileWriteInput,
GlobInput,
GrepInput,
TodoWriteInput,
WebFetchInput,
WebSearchInput,
} from "@anthropic-ai/claude-agent-sdk/sdk-tools.js";
import {
ImageBlockParam,
TextBlockParam,
@@ -14,29 +27,31 @@ import {
WebSearchToolResultError,
} from "@anthropic-ai/sdk/resources";
import {
BetaBashCodeExecutionToolResultBlockParam,
BetaBashCodeExecutionResultBlock,
BetaBashCodeExecutionToolResultBlockParam,
BetaBashCodeExecutionToolResultError,
BetaCodeExecutionToolResultBlockParam,
BetaCodeExecutionResultBlock,
BetaCodeExecutionToolResultBlockParam,
BetaCodeExecutionToolResultError,
BetaImageBlockParam,
BetaRequestMCPToolResultBlockParam,
BetaTextEditorCodeExecutionToolResultBlockParam,
BetaTextEditorCodeExecutionViewResultBlock,
BetaTextEditorCodeExecutionCreateResultBlock,
BetaTextEditorCodeExecutionStrReplaceResultBlock,
BetaTextEditorCodeExecutionToolResultBlockParam,
BetaTextEditorCodeExecutionToolResultError,
BetaTextEditorCodeExecutionViewResultBlock,
BetaToolReferenceBlock,
BetaToolResultBlockParam,
BetaToolSearchToolResultBlockParam,
BetaToolReferenceBlock,
BetaToolSearchToolSearchResultBlock,
BetaToolSearchToolResultError,
BetaWebFetchToolResultBlockParam,
BetaToolSearchToolSearchResultBlock,
BetaWebFetchBlock,
BetaWebFetchToolResultBlockParam,
BetaWebFetchToolResultErrorBlock,
BetaWebSearchToolResultBlockParam,
BetaImageBlockParam,
} from "@anthropic-ai/sdk/resources/beta.mjs";
import path from "node:path";
import { Logger } from "./acp-agent.js";
/**
* Union of all possible content types that can appear in tool results from the Anthropic SDK.
@@ -61,20 +76,6 @@ type ToolResultContent =
| BetaTextEditorCodeExecutionCreateResultBlock
| BetaTextEditorCodeExecutionStrReplaceResultBlock
| BetaTextEditorCodeExecutionToolResultError;
import { HookCallback } from "@anthropic-ai/claude-agent-sdk";
import { Logger } from "./acp-agent.js";
import {
AgentInput,
BashInput,
FileEditInput,
FileReadInput,
FileWriteInput,
GlobInput,
GrepInput,
TodoWriteInput,
WebFetchInput,
WebSearchInput,
} from "@anthropic-ai/claude-agent-sdk/sdk-tools.js";
interface ToolInfo {
title: string;
@@ -103,13 +104,29 @@ interface ToolUpdate {
};
}
/**
* Convert an absolute file path to a project-relative path for display.
* Returns the original path if it's outside the project directory or if no cwd is provided.
*/
export function toDisplayPath(filePath: string, cwd?: string): string {
if (!cwd) return filePath;
const resolvedCwd = path.resolve(cwd);
const resolvedFile = path.resolve(filePath);
if (resolvedFile.startsWith(resolvedCwd + path.sep) || resolvedFile === resolvedCwd) {
return path.relative(resolvedCwd, resolvedFile);
}
return filePath;
}
export function toolInfoFromToolUse(
toolUse: any,
supportsTerminalOutput: boolean = false,
cwd?: string,
): ToolInfo {
const name = toolUse.name;
switch (name) {
case "Agent":
case "Task": {
const input = toolUse.input as AgentInput | BashInput;
return {
@@ -153,8 +170,9 @@ export function toolInfoFromToolUse(
} else if (input.offset) {
limit = " (from line " + input.offset + ")";
}
const displayPath = input.file_path ? toDisplayPath(input.file_path, cwd) : "File";
return {
title: "Read " + (input.file_path ?? "File") + limit,
title: "Read " + displayPath + limit,
kind: "read",
locations: input.file_path
? [
@@ -188,8 +206,9 @@ export function toolInfoFromToolUse(
},
];
}
const displayPath = input?.file_path ? toDisplayPath(input.file_path, cwd) : undefined;
return {
title: input?.file_path ? `Write ${input.file_path}` : "Write",
title: displayPath ? `Write ${displayPath}` : "Write",
kind: "edit",
content,
locations: input?.file_path ? [{ path: input.file_path }] : [],
@@ -209,8 +228,9 @@ export function toolInfoFromToolUse(
},
];
}
const displayPath = input?.file_path ? toDisplayPath(input.file_path, cwd) : undefined;
return {
title: input?.file_path ? `Edit ${input.file_path}` : "Edit",
title: displayPath ? `Edit ${displayPath}` : "Edit",
kind: "edit",
content,
locations: input?.file_path ? [{ path: input.file_path }] : [],
@@ -348,10 +368,13 @@ export function toolInfoFromToolUse(
}
case "ExitPlanMode": {
const planInput = toolUse.input as { plan?: string };
return {
title: "Ready to code?",
kind: "switch_mode",
content: [],
content: planInput?.plan
? [{ type: "content" as const, content: { type: "text" as const, text: planInput.plan } }]
: [],
};
}
@@ -461,7 +484,7 @@ export function toolUpdateFromToolResult(
result.type === "bash_code_execution_result"
) {
const bashResult = result as BetaBashCodeExecutionResultBlock;
output = bashResult.stdout || bashResult.stderr || "";
output = [bashResult.stdout, bashResult.stderr].filter(Boolean).join("\n");
exitCode = bashResult.return_code;
} else if (typeof result === "string") {
output = result;