Compare commits

...

9 Commits

Author SHA1 Message Date
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
10 changed files with 291 additions and 152 deletions

View File

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

127
package-lock.json generated
View File

@@ -1,28 +1,29 @@
{
"name": "@zed-industries/claude-agent-acp",
"version": "0.20.2",
"version": "0.21.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@zed-industries/claude-agent-acp",
"version": "0.20.2",
"version": "0.21.0",
"license": "Apache-2.0",
"dependencies": {
"@agentclientprotocol/sdk": "0.14.1",
"@anthropic-ai/claude-agent-sdk": "0.2.68",
"@agentclientprotocol/sdk": "0.15.0",
"@anthropic-ai/claude-agent-sdk": "0.2.71",
"zod": "^3.25.0 || ^4.0.0"
},
"bin": {
"claude-agent-acp": "dist/index.js"
},
"devDependencies": {
"@anthropic-ai/claude-code": "2.1.71",
"@anthropic-ai/sdk": "0.78.0",
"@eslint/js": "10.0.1",
"@types/node": "25.3.3",
"@types/node": "25.3.5",
"@typescript-eslint/eslint-plugin": "8.56.1",
"@typescript-eslint/parser": "8.56.1",
"eslint": "10.0.2",
"eslint": "10.0.3",
"eslint-config-prettier": "10.1.8",
"globals": "17.4.0",
"prettier": "3.8.1",
@@ -32,18 +33,18 @@
}
},
"node_modules/@agentclientprotocol/sdk": {
"version": "0.14.1",
"resolved": "https://registry.npmjs.org/@agentclientprotocol/sdk/-/sdk-0.14.1.tgz",
"integrity": "sha512-b6r3PS3Nly+Wyw9U+0nOr47bV8tfS476EgyEMhoKvJCZLbgqoDFN7DJwkxL88RR0aiOqOYV1ZnESHqb+RmdH8w==",
"version": "0.15.0",
"resolved": "https://registry.npmjs.org/@agentclientprotocol/sdk/-/sdk-0.15.0.tgz",
"integrity": "sha512-TH4utu23Ix8ec34srBHmDD4p3HI0cYleS1jN9lghRczPfhFlMBNrQgZWeBBe12DWy27L11eIrtciY2MXFSEiDg==",
"license": "Apache-2.0",
"peerDependencies": {
"zod": "^3.25.0 || ^4.0.0"
}
},
"node_modules/@anthropic-ai/claude-agent-sdk": {
"version": "0.2.68",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.2.68.tgz",
"integrity": "sha512-y4n6hTTgAqmiV/pqy1G4OgIdg6gDiAKPJaEgO1NOh7/rdsrXyc/HQoUmUy0ty4HkBq1hasm7hB92wtX3W1UMEw==",
"version": "0.2.71",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.2.71.tgz",
"integrity": "sha512-pIsQJnM7Y+cJHL7aFY6SCCW3FIni218gVEpPqG8XGowfYxboFNBbNssWiUNRwthT8bp9jypcX7q5kx0Xsw14xg==",
"license": "SEE LICENSE IN README.md",
"engines": {
"node": ">=18.0.0"
@@ -63,6 +64,30 @@
"zod": "^4.0.0"
}
},
"node_modules/@anthropic-ai/claude-code": {
"version": "2.1.71",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-code/-/claude-code-2.1.71.tgz",
"integrity": "sha512-DHvvzMifwmLnnUlR3cZ//Y8VjGmZxvs6+uJmiI1B0jewQykfI5dT9tfK1sdLfHj8ukofu2lx93/CzamqDInDxg==",
"dev": true,
"license": "SEE LICENSE IN README.md",
"bin": {
"claude": "cli.js"
},
"engines": {
"node": ">=18.0.0"
},
"optionalDependencies": {
"@img/sharp-darwin-arm64": "^0.34.2",
"@img/sharp-darwin-x64": "^0.34.2",
"@img/sharp-linux-arm": "^0.34.2",
"@img/sharp-linux-arm64": "^0.34.2",
"@img/sharp-linux-x64": "^0.34.2",
"@img/sharp-linuxmusl-arm64": "^0.34.2",
"@img/sharp-linuxmusl-x64": "^0.34.2",
"@img/sharp-win32-arm64": "^0.34.2",
"@img/sharp-win32-x64": "^0.34.2"
}
},
"node_modules/@anthropic-ai/sdk": {
"version": "0.78.0",
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.78.0.tgz",
@@ -579,37 +604,37 @@
}
},
"node_modules/@eslint/config-array": {
"version": "0.23.2",
"resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.2.tgz",
"integrity": "sha512-YF+fE6LV4v5MGWRGj7G404/OZzGNepVF8fxk7jqmqo3lrza7a0uUcDnROGRBG1WFC1omYUS/Wp1f42i0M+3Q3A==",
"version": "0.23.3",
"resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.3.tgz",
"integrity": "sha512-j+eEWmB6YYLwcNOdlwQ6L2OsptI/LO6lNBuLIqe5R7RetD658HLoF+Mn7LzYmAWWNNzdC6cqP+L6r8ujeYXWLw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@eslint/object-schema": "^3.0.2",
"@eslint/object-schema": "^3.0.3",
"debug": "^4.3.1",
"minimatch": "^10.2.1"
"minimatch": "^10.2.4"
},
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=24"
}
},
"node_modules/@eslint/config-helpers": {
"version": "0.5.2",
"resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.5.2.tgz",
"integrity": "sha512-a5MxrdDXEvqnIq+LisyCX6tQMPF/dSJpCfBgBauY+pNZ28yCtSsTvyTYrMhaI+LK26bVyCJfJkT0u8KIj2i1dQ==",
"version": "0.5.3",
"resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.5.3.tgz",
"integrity": "sha512-lzGN0onllOZCGroKJmRwY6QcEHxbjBw1gwB8SgRSqK8YbbtEXMvKynsXc3553ckIEBxsbMBU7oOZXKIPGZNeZw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@eslint/core": "^1.1.0"
"@eslint/core": "^1.1.1"
},
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=24"
}
},
"node_modules/@eslint/core": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.1.0.tgz",
"integrity": "sha512-/nr9K9wkr3P1EzFTdFdMoLuo1PmIxjmwvPozwoSodjNBdefGujXQUF93u1DDZpEaTuDvMsIQddsd35BwtrW9Xw==",
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.1.1.tgz",
"integrity": "sha512-QUPblTtE51/7/Zhfv8BDwO0qkkzQL7P/aWWbqcf4xWLEYn1oKjdO0gglQBB4GAsu7u6wjijbCmzsUTy6mnk6oQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
@@ -641,9 +666,9 @@
}
},
"node_modules/@eslint/object-schema": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.2.tgz",
"integrity": "sha512-HOy56KJt48Bx8KmJ+XGQNSUMT/6dZee/M54XyUyuvTvPXJmsERRvBchsUVx1UMe1WwIH49XLAczNC7V2INsuUw==",
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.3.tgz",
"integrity": "sha512-iM869Pugn9Nsxbh/YHRqYiqd23AmIbxJOcpUMOuWCVNdoQJ5ZtwL6h3t0bcZzJUlC3Dq9jCFCESBZnX0GTv7iQ==",
"dev": true,
"license": "Apache-2.0",
"engines": {
@@ -651,13 +676,13 @@
}
},
"node_modules/@eslint/plugin-kit": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.6.0.tgz",
"integrity": "sha512-bIZEUzOI1jkhviX2cp5vNyXQc6olzb2ohewQubuYlMXZ2Q/XjBO0x0XhGPvc9fjSIiUN0vw+0hq53BJ4eQSJKQ==",
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.6.1.tgz",
"integrity": "sha512-iH1B076HoAshH1mLpHMgwdGeTs0CYwL0SPMkGuSebZrwBp16v415e9NZXg2jtrqPVQjf6IANe2Vtlr5KswtcZQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@eslint/core": "^1.1.0",
"@eslint/core": "^1.1.1",
"levn": "^0.4.1"
},
"engines": {
@@ -1473,9 +1498,9 @@
"license": "MIT"
},
"node_modules/@types/node": {
"version": "25.3.3",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.3.tgz",
"integrity": "sha512-DpzbrH7wIcBaJibpKo9nnSQL0MTRdnWttGyE5haGwK86xgMOkFLp7vEyfQPGLOJh5wNYiJ3V9PmUMDhV9u8kkQ==",
"version": "25.3.5",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.5.tgz",
"integrity": "sha512-oX8xrhvpiyRCQkG1MFchB09f+cXftgIXb3a7UUa4Y3wpmZPw5tyZGTLWhlESOLq1Rq6oDlc8npVU2/9xiCuXMA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -2049,18 +2074,18 @@
}
},
"node_modules/eslint": {
"version": "10.0.2",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-10.0.2.tgz",
"integrity": "sha512-uYixubwmqJZH+KLVYIVKY1JQt7tysXhtj21WSvjcSmU5SVNzMus1bgLe+pAt816yQ8opKfheVVoPLqvVMGejYw==",
"version": "10.0.3",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-10.0.3.tgz",
"integrity": "sha512-COV33RzXZkqhG9P2rZCFl9ZmJ7WL+gQSCRzE7RhkbclbQPtLAWReL7ysA0Sh4c8Im2U9ynybdR56PV0XcKvqaQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.2",
"@eslint/config-array": "^0.23.2",
"@eslint/config-array": "^0.23.3",
"@eslint/config-helpers": "^0.5.2",
"@eslint/core": "^1.1.0",
"@eslint/plugin-kit": "^0.6.0",
"@eslint/core": "^1.1.1",
"@eslint/plugin-kit": "^0.6.1",
"@humanfs/node": "^0.16.6",
"@humanwhocodes/module-importer": "^1.0.1",
"@humanwhocodes/retry": "^0.4.2",
@@ -2069,7 +2094,7 @@
"cross-spawn": "^7.0.6",
"debug": "^4.3.2",
"escape-string-regexp": "^4.0.0",
"eslint-scope": "^9.1.1",
"eslint-scope": "^9.1.2",
"eslint-visitor-keys": "^5.0.1",
"espree": "^11.1.1",
"esquery": "^1.7.0",
@@ -2082,7 +2107,7 @@
"imurmurhash": "^0.1.4",
"is-glob": "^4.0.0",
"json-stable-stringify-without-jsonify": "^1.0.1",
"minimatch": "^10.2.1",
"minimatch": "^10.2.4",
"natural-compare": "^1.4.0",
"optionator": "^0.9.3"
},
@@ -2121,9 +2146,9 @@
}
},
"node_modules/eslint-scope": {
"version": "9.1.1",
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.1.tgz",
"integrity": "sha512-GaUN0sWim5qc8KVErfPBWmc31LEsOkrUJbvJZV+xuL3u2phMUK4HIvXlWAakfC8W4nzlK+chPEAkYOYb5ZScIw==",
"version": "9.1.2",
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz",
"integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
@@ -2176,9 +2201,9 @@
}
},
"node_modules/espree": {
"version": "11.1.1",
"resolved": "https://registry.npmjs.org/espree/-/espree-11.1.1.tgz",
"integrity": "sha512-AVHPqQoZYc+RUM4/3Ly5udlZY/U4LS8pIG05jEjWM2lQMU/oaZ7qshzAl2YP1tfNmXfftH3ohurfwNAug+MnsQ==",
"version": "11.2.0",
"resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz",
"integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
@@ -2356,9 +2381,9 @@
}
},
"node_modules/flatted": {
"version": "3.3.4",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.4.tgz",
"integrity": "sha512-3+mMldrTAPdta5kjX2G2J7iX4zxtnwpdA8Tr2ZSjkyPSanvbZAcy6flmtnXbEybHrDcU9641lxrMfFuUxVz9vA==",
"version": "3.4.1",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.1.tgz",
"integrity": "sha512-IxfVbRFVlV8V/yRaGzk0UVIcsKKHMSfYw66T/u4nTwlWteQePsxe//LjudR1AMX4tZW3WFCh3Zqa/sjlqpbURQ==",
"dev": true,
"license": "ISC"
},

View File

@@ -3,7 +3,7 @@
"publishConfig": {
"access": "public"
},
"version": "0.20.2",
"version": "0.21.0",
"description": "An ACP-compatible coding agent powered by the Claude Agent SDK (TypeScript)",
"main": "dist/lib.js",
"types": "dist/lib.d.ts",
@@ -60,17 +60,18 @@
"author": "Zed Industries",
"license": "Apache-2.0",
"dependencies": {
"@agentclientprotocol/sdk": "0.14.1",
"@anthropic-ai/claude-agent-sdk": "0.2.68",
"@agentclientprotocol/sdk": "0.15.0",
"@anthropic-ai/claude-agent-sdk": "0.2.71",
"zod": "^3.25.0 || ^4.0.0"
},
"devDependencies": {
"@anthropic-ai/claude-code": "2.1.71",
"@anthropic-ai/sdk": "0.78.0",
"@eslint/js": "10.0.1",
"@types/node": "25.3.3",
"@types/node": "25.3.5",
"@typescript-eslint/eslint-plugin": "8.56.1",
"@typescript-eslint/parser": "8.56.1",
"eslint": "10.0.2",
"eslint": "10.0.3",
"eslint-config-prettier": "10.1.8",
"globals": "17.4.0",
"prettier": "3.8.1",

View File

@@ -60,7 +60,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,6 +109,7 @@ type Session = {
query: Query;
input: Pushable<SDKUserMessage>;
cancelled: boolean;
cwd: string;
permissionMode: PermissionMode;
settingsManager: SettingsManager;
accumulatedUsage: AccumulatedUsage;
@@ -148,23 +148,12 @@ 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;
};
};
};
/**
* Extra metadata for 'gateway' authentication requests.
*/
@@ -219,6 +208,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");
}
@@ -286,17 +281,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 +296,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",
},
};
@@ -362,8 +346,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] : []),
],
};
@@ -672,7 +657,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);
}
@@ -695,6 +683,10 @@ export class ClaudeAcpAgent implements Agent {
// the loop of the next prompt continues running
return { stopReason: "end_turn" };
}
if ("isReplay" in message && message.isReplay) {
// not pending or unrelated replay message
break;
}
}
// Store latest assistant usage (excluding subagents)
@@ -780,6 +772,7 @@ export class ClaudeAcpAgent implements Agent {
{
clientCapabilities: this.clientCapabilities,
parentToolUseId: message.parent_tool_use_id,
cwd: session.cwd,
},
)) {
await this.client.sessionUpdate(notification);
@@ -954,7 +947,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);
}
@@ -1001,6 +998,7 @@ export class ClaudeAcpAgent implements Agent {
...toolInfoFromToolUse(
{ name: toolName, input: toolInput, id: toolUseID },
supportsTerminalOutput,
session?.cwd,
),
},
});
@@ -1065,6 +1063,7 @@ export class ClaudeAcpAgent implements Agent {
...toolInfoFromToolUse(
{ name: toolName, input: toolInput, id: toolUseID },
supportsTerminalOutput,
session?.cwd,
),
},
});
@@ -1213,26 +1212,13 @@ 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 options: Options = {
systemPrompt,
@@ -1255,8 +1241,7 @@ 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()
@@ -1267,7 +1252,7 @@ export class ClaudeAcpAgent implements Agent {
"replay-user-messages": "",
},
disallowedTools: [...(userProvidedOptions?.disallowedTools || []), ...disallowedTools],
tools: { type: "preset", preset: "claude_code" },
tools,
hooks: {
...userProvidedOptions?.hooks,
PostToolUse: [
@@ -1378,6 +1363,7 @@ export class ClaudeAcpAgent implements Agent {
query: q,
input: input,
cancelled: false,
cwd: params.cwd,
permissionMode,
settingsManager,
accumulatedUsage: {
@@ -1693,6 +1679,7 @@ export function toAcpNotifications(
registerHooks?: boolean;
clientCapabilities?: ClientCapabilities;
parentToolUseId?: string | null;
cwd?: string;
},
): SessionNotification[] {
const registerHooks = options?.registerHooks !== false;
@@ -1821,7 +1808,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 +1826,7 @@ export function toAcpNotifications(
sessionUpdate: "tool_call",
rawInput,
status: "pending",
...toolInfoFromToolUse(chunk, supportsTerminalOutput),
...toolInfoFromToolUse(chunk, supportsTerminalOutput, options?.cwd),
};
}
}
@@ -1945,7 +1932,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 +1950,7 @@ export function streamEventToAcpNotifications(
{
clientCapabilities: options?.clientCapabilities,
parentToolUseId: message.parent_tool_use_id,
cwd: options?.cwd,
},
);
case "content_block_delta":
@@ -1973,6 +1964,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

@@ -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();
@@ -1279,6 +1315,7 @@ describe("stop reason propagation", () => {
query: gen as any,
input: new Pushable(),
cancelled: false,
cwd: "/test",
permissionMode: "default",
settingsManager: {} as any,
accumulatedUsage: {

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

@@ -1,3 +1,4 @@
import path from "node:path";
import {
ContentBlock,
PlanEntry,
@@ -74,7 +75,7 @@ import {
TodoWriteInput,
WebFetchInput,
WebSearchInput,
} from "@anthropic-ai/claude-agent-sdk/sdk-tools.js";
} from "@anthropic-ai/claude-code/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 }] : [],