Compare commits

..

116 Commits

Author SHA1 Message Date
Ben Brandt
b1d09f2366 Fix history support (#356) 2026-02-27 12:06:47 +01:00
Ben Brandt
d24066bd9d Support windows arm builds and clean up artifact names (#355) 2026-02-27 10:46:23 +00:00
Ben Brandt
5ca63abc47 Update to claude-agent-sdk 0.2.62 (#354) 2026-02-27 10:14:30 +00:00
Steffen Deusch
001614b459 expose parent_tool_use_id for subagents (#341)
I'm not sure yet how a good protocol for subagents could look like, so
instead of opening an RFD, I opted to expose Claude's
`parent_tool_use_id` in `_meta.claudeCode.parentToolUseId`. This will
allow Tidewave to render tools from subagents in a better way.
2026-02-26 22:45:12 +01:00
Steffen Deusch
218661f97b ACP Session Usage (#345)
The AI in https://github.com/zed-industries/claude-agent-acp/pull/344
had one good idea: only use the usage from the last assistant message
for context size. Of course, `usage` on the AssistantMessage is not
documented in the Agent SDK (we need to do an ugly cast), but this seems
to work alright.

We don't get the exact new usage after compaction, so I set it to zero.
2026-02-26 22:44:20 +01:00
Ben Brandt
5ede2b7c4c Use history methods from the SDK (#352) 2026-02-26 21:37:15 +00:00
Ben Brandt
988a741d59 Update to claude-agent-sdk 0.2.59 (#351) 2026-02-26 22:29:45 +01:00
Ben Limmer
b57a429867 fix: merge user-provided disallowedTools instead of overwriting (#335)
The disallowedTools merge fix from #295 was regressed by #316 when the
options object was restructured. Restore the merge behavior and add
regression tests for all three documented merge fields (disallowedTools,
hooks, mcpServers).

Fixes #334
2026-02-20 18:55:16 +01:00
Steffen Deusch
101f09dbf8 Add single-file executable using bun (#332)
This adds a single-file executable using bun. The trick is to use a
separate flag for ACP `--acp` and spawn the regular `cli.js` bundled by
the Agent SDK if no flags are passed. This was, we can pass ourselves as
pathToClaudeCodeExecutable.
2026-02-20 12:23:04 +01:00
Ben Brandt
d36204f6da Prep 0.18.0 release (#331) 2026-02-18 20:20:21 +00:00
Ben Brandt
4de2f98a8e Make sure we don't override terminal content (#328)
- **Fix sending markdown content that replaces terminal content**
- **Make sure to always updated content to be safe**
2026-02-18 13:46:18 +00:00
Ben Brandt
f5a5c73607 Use edit tool response to construct a better diff (#327) 2026-02-18 10:35:54 +00:00
Ben Brandt
5bd0af27ec Support session config options (#326) 2026-02-18 10:25:29 +01:00
Ben Brandt
be618f5dcb Switch over to built-in Claude Code tools (#316)
Given a number of recent issues stemming from Claude Code using more
subagents that don't play nicely with our custom mcp server, plus some
slowly diverging behavior, we made the decision to just switch back over
to the built-in tools and do more processing on the tool input/output

There are some downsides, but also a lot of upsides, on the whole, it
feels like a win for now.

Closes #310, #279, #305, #236, #94

---------

Co-authored-by: Bennet Bo Fenner <bennetbo@gmx.de>
2026-02-18 09:52:16 +01:00
Ben Limmer
28b85aed41 fix: convert Read tool image content to ACP format (#322) (#323)
When the Read tool returns image content (e.g., reading a .png file),
the raw Claude SDK image block was passed through unconverted, causing
ACP clients to fail deserializing the tool call completion event. Route
non-text content through `toAcpContentBlock` to produce the correct ACP
image format.
2026-02-18 09:33:29 +01:00
Ben Brandt
44bb150b5d Update to claude-agent-sdk 0.2.45 to add access to Sonnet 4.6 (#324) 2026-02-17 22:08:43 +00:00
Ben Brandt
18bd9f2490 Fix bin path (#321) 2026-02-17 11:23:40 +00:00
Ben Brandt
b8e655d7ac Fix lockfile (#320) 2026-02-17 11:21:50 +00:00
Ben Brandt
0e77751692 Claude Code -> Claude Agent (#319)
Update based on
https://platform.claude.com/docs/en/agent-sdk/overview#branding-guidelines
2026-02-17 12:14:55 +01:00
Ben Brandt
e5b836820b Prep 0.16.2 release (#318) 2026-02-17 10:06:15 +00:00
Ben Brandt
c3ffcfa98f Update to claude-agent-sdk 0.2.44 (#317) 2026-02-17 10:00:54 +00:00
RAIT-09
4d232dafad fix: Replace all non-alphanumeric characters in encodeProjectPath (#307)
This PR fixes `encodeProjectPath` to replace all non-alphanumeric
characters with `-`, matching how Claude Code actually encodes project
paths.

The previous implementation only replaced path separators (`/` on Unix,
`\` on Windows), so characters like `_`, non-ASCII characters (e.g.
CJK), and other non-alphanumeric characters were left as-is. This caused
a mismatch with the paths encoded by Claude Code, resulting in session
lookups failing with "Session not found" for directories containing such
characters.

The fix also removes the Windows-specific code path, as the simple regex
`[^a-zA-Z0-9]` correctly handles all platforms.

Related to #300
2026-02-16 18:26:41 +01:00
Steffen Deusch
4f0ffa2735 don't include /login slash command in login command (#315)
Claude resubmits the slash command after authenticating, so it will ask
for authentication twice.
Since Claude Code prompts for setup and authentication anyway if not
signed in, we can just not include /login in the command.
2026-02-16 18:22:27 +01:00
dependabot[bot]
f069df5857 Bump qs from 6.14.1 to 6.14.2 in the npm_and_yarn group across 1 directory (#311)
Bumps the npm_and_yarn group with 1 update in the / directory:
[qs](https://github.com/ljharb/qs).

Updates `qs` from 6.14.1 to 6.14.2
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/ljharb/qs/blob/main/CHANGELOG.md">qs's
changelog</a>.</em></p>
<blockquote>
<h2><strong>6.14.2</strong></h2>
<ul>
<li>[Fix] <code>parse</code>: mark overflow objects for indexed notation
exceeding <code>arrayLimit</code> (<a
href="https://redirect.github.com/ljharb/qs/issues/546">#546</a>)</li>
<li>[Fix] <code>arrayLimit</code> means max count, not max index, in
<code>combine</code>/<code>merge</code>/<code>parseArrayValue</code></li>
<li>[Fix] <code>parse</code>: throw on <code>arrayLimit</code> exceeded
with indexed notation when <code>throwOnLimitExceeded</code> is true (<a
href="https://redirect.github.com/ljharb/qs/issues/529">#529</a>)</li>
<li>[Fix] <code>parse</code>: enforce <code>arrayLimit</code> on
<code>comma</code>-parsed values</li>
<li>[Fix] <code>parse</code>: fix error message to reflect arrayLimit as
max index; remove extraneous comments (<a
href="https://redirect.github.com/ljharb/qs/issues/545">#545</a>)</li>
<li>[Robustness] avoid <code>.push</code>, use <code>void</code></li>
<li>[readme] document that <code>addQueryPrefix</code> does not add
<code>?</code> to empty output (<a
href="https://redirect.github.com/ljharb/qs/issues/418">#418</a>)</li>
<li>[readme] clarify <code>parseArrays</code> and
<code>arrayLimit</code> documentation (<a
href="https://redirect.github.com/ljharb/qs/issues/543">#543</a>)</li>
<li>[readme] replace runkit CI badge with shields.io check-runs
badge</li>
<li>[meta] fix changelog typo (<code>arrayLength</code> →
<code>arrayLimit</code>)</li>
<li>[actions] fix rebase workflow permissions</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="bdcf0c7f82"><code>bdcf0c7</code></a>
v6.14.2</li>
<li><a
href="294db90c81"><code>294db90</code></a>
[readme] document that <code>addQueryPrefix</code> does not add
<code>?</code> to empty output</li>
<li><a
href="5c308e5516"><code>5c308e5</code></a>
[readme] clarify <code>parseArrays</code> and <code>arrayLimit</code>
documentation</li>
<li><a
href="6addf8cf73"><code>6addf8c</code></a>
[Fix] <code>parse</code>: mark overflow objects for indexed notation
exceeding <code>arrayLimit</code></li>
<li><a
href="cfc108f662"><code>cfc108f</code></a>
[Fix] <code>arrayLimit</code> means max count, not max index, in
<code>combine</code>/<code>merge</code>/`pars...</li>
<li><a
href="febb64442a"><code>febb644</code></a>
[Fix] <code>parse</code>: throw on <code>arrayLimit</code> exceeded with
indexed notation when `thr...</li>
<li><a
href="f6a7abff1f"><code>f6a7abf</code></a>
[Fix] <code>parse</code>: enforce <code>arrayLimit</code> on
<code>comma</code>-parsed values</li>
<li><a
href="fbc5206c25"><code>fbc5206</code></a>
[Fix] <code>parse</code>: fix error message to reflect arrayLimit as max
index; remove e...</li>
<li><a
href="1b9a8b4e78"><code>1b9a8b4</code></a>
[actions] fix rebase workflow permissions</li>
<li><a
href="2a35775614"><code>2a35775</code></a>
[meta] fix changelog typo (<code>arrayLength</code> →
<code>arrayLimit</code>)</li>
<li>Additional commits viewable in <a
href="https://github.com/ljharb/qs/compare/v6.14.1...v6.14.2">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=qs&package-manager=npm_and_yarn&previous-version=6.14.1&new-version=6.14.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-code-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-02-14 14:34:29 +01:00
Ben Brandt
e2a1ac9b9d Update to claude-agent-sdk 0.2.38 (#304) 2026-02-10 21:11:04 +00:00
Ben Brandt
673b0507d7 Make sure updating commands happens at the end of loading (#302) 2026-02-10 13:18:04 +00:00
Ben Brandt
ea00a4014a More permissive load session (#301)
Adds a lookup that checks the expected cwd path first, then
scans all project directories to locate the session JSONL file.
Ultimately matches the behavior we allow for resume
2026-02-10 12:36:02 +00:00
Ben Brandt
ebdd115b76 fix: Incorrect paths for session list (#298)
How Claude Code encodes the paths is lossy, so now we actually check the
cwd from the json files to make sure it is a valid path (otherwise
Claude Code just hangs)
2026-02-09 09:56:02 +00:00
Ben Limmer
7c87fe7a8c fix: merge user-provided disallowedTools instead of overwriting (#295)
## Summary

- User-provided `disallowedTools` from `_meta.claudeCode.options` were
silently overwritten by ACP's internal list
- Now merges both lists, following the same pattern used for `hooks` and
`mcpServers`
- Updated `NewSessionMeta` JSDoc to document the merge behavior

Fixes #294
2026-02-07 09:30:50 +00:00
Ben Brandt
8420b50523 Update to claude agent sdk 0.2.34 (#293)
Allows us to pass in session id via the SDK as well as use one promise
to get commands + models
2026-02-06 15:07:46 +01:00
Maximilian Krause
24339faf2b feat: implement loadSession by replaying JSONL session files (#285)
Based on the groundwork in #254, this adds support for `loadSession` to
replay messages and tool calls from resumed/loaded sessions through
Claude Code's persisted JSONL files.

- Advertise the `loadSession` capability
- Stream JSONL entries from
`~/.claude/projects/<cwd-encoded>/<sessionId>.jsonl`
- Correctly parse user/assistant messages and tool calls/results into
ACP session updates
- Add unit tests for the new functionality
2026-02-06 14:44:18 +01:00
Elle Najt
8360a21358 test: add tests for cross-tool rule isolation (#291)
## Summary
- Adds tests verifying that permission rules only apply to their
intended tool types
- Tests confirm the fix from #282 (commit d471ccf) works correctly

## Tests added
- Bash rules should not match Edit/Write/Read operations
- Edit rules should not match Bash operations
- Read rules should not match Bash operations

These tests catch the bug where `Bash(./**:*)` would incorrectly allow
file operations on matching paths.

## Verification
Confirmed tests fail on the commit before the fix (6b68ddf) and pass
after.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-06 00:09:44 +01:00
Ben Brandt
a9985d49e9 Update anthropic types (#292) 2026-02-05 23:08:09 +00:00
Agus Zubiaga
9dfd0a0c4f Bump version to 0.15.0 (#290)
Prepares release 0.15.0 with Opus 4.6 support.
2026-02-05 20:14:07 +00:00
Agus Zubiaga
fd4fd247bf Use fixed version for claude-agent-sdk (#289)
Removes caret prefix to pin the exact SDK version.
2026-02-05 20:11:38 +00:00
Agus Zubiaga
67926a9ac2 Upgrade claude-agent-sdk to 0.2.32 (#287)
Adds support for Opus 4.6 and the new 1M context Opus model.
2026-02-05 17:07:11 -03:00
dependabot[bot]
4d0cb9cbdd Bump @modelcontextprotocol/sdk from 1.25.3 to 1.26.0 in the npm_and_yarn group across 1 directory (#286)
Bumps the npm_and_yarn group with 1 update in the / directory:
[@modelcontextprotocol/sdk](https://github.com/modelcontextprotocol/typescript-sdk).

Updates `@modelcontextprotocol/sdk` from 1.25.3 to 1.26.0
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/modelcontextprotocol/typescript-sdk/releases"><code>@​modelcontextprotocol/sdk</code>'s
releases</a>.</em></p>
<blockquote>
<h2>v1.26.0</h2>
<p>Addresses &quot;Sharing server/transport instances can leak
cross-client response data&quot; in this GHSA <a
href="https://github.com/modelcontextprotocol/typescript-sdk/security/advisories/GHSA-345p-7cg4-v4c7">https://github.com/modelcontextprotocol/typescript-sdk/security/advisories/GHSA-345p-7cg4-v4c7</a></p>
<h2>What's Changed</h2>
<ul>
<li>chore: bump v1.25.3 for backport fixes by <a
href="https://github.com/pcarleton"><code>@​pcarleton</code></a> in <a
href="https://redirect.github.com/modelcontextprotocol/typescript-sdk/pull/1412">modelcontextprotocol/typescript-sdk#1412</a></li>
<li>fix(deps): resolve npm audit vulnerabilities and bump dependencies
(v1.x backport) by <a
href="https://github.com/samuv"><code>@​samuv</code></a> in <a
href="https://redirect.github.com/modelcontextprotocol/typescript-sdk/pull/1382">modelcontextprotocol/typescript-sdk#1382</a></li>
<li>Fix <a
href="https://redirect.github.com/modelcontextprotocol/typescript-sdk/issues/1430">#1430</a>:
Client Credentials providers scopes support (backported) by <a
href="https://github.com/NSeydoux"><code>@​NSeydoux</code></a> in <a
href="https://redirect.github.com/modelcontextprotocol/typescript-sdk/pull/1442">modelcontextprotocol/typescript-sdk#1442</a></li>
<li>chore: bump version to 1.26.0 by <a
href="https://github.com/pcarleton"><code>@​pcarleton</code></a> in <a
href="https://redirect.github.com/modelcontextprotocol/typescript-sdk/pull/1479">modelcontextprotocol/typescript-sdk#1479</a></li>
</ul>
<h2>New Contributors</h2>
<ul>
<li><a href="https://github.com/samuv"><code>@​samuv</code></a> made
their first contribution in <a
href="https://redirect.github.com/modelcontextprotocol/typescript-sdk/pull/1382">modelcontextprotocol/typescript-sdk#1382</a></li>
<li><a href="https://github.com/NSeydoux"><code>@​NSeydoux</code></a>
made their first contribution in <a
href="https://redirect.github.com/modelcontextprotocol/typescript-sdk/pull/1442">modelcontextprotocol/typescript-sdk#1442</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/modelcontextprotocol/typescript-sdk/compare/v1.25.3...v1.26.0">https://github.com/modelcontextprotocol/typescript-sdk/compare/v1.25.3...v1.26.0</a></p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="fe9c07b465"><code>fe9c07b</code></a>
chore: bump version to 1.26.0 (<a
href="https://redirect.github.com/modelcontextprotocol/typescript-sdk/issues/1479">#1479</a>)</li>
<li><a
href="4f01e7e070"><code>4f01e7e</code></a>
fix: add non-null assertions for optional setupServer fields in stateful
test</li>
<li><a
href="a05be176ca"><code>a05be17</code></a>
Merge commit from fork</li>
<li><a
href="50d9fa3cd1"><code>50d9fa3</code></a>
Fix <a
href="https://redirect.github.com/modelcontextprotocol/typescript-sdk/issues/1430">#1430</a>:
Client Credentials providers scopes support (backported) (<a
href="https://redirect.github.com/modelcontextprotocol/typescript-sdk/issues/1442">#1442</a>)</li>
<li><a
href="aa81a66556"><code>aa81a66</code></a>
fix(deps): resolve npm audit vulnerabilities and bump dependencies (v1.x
back...</li>
<li><a
href="6aba065965"><code>6aba065</code></a>
chore: bump v1.25.3 for backport fixes (<a
href="https://redirect.github.com/modelcontextprotocol/typescript-sdk/issues/1412">#1412</a>)</li>
<li>See full diff in <a
href="https://github.com/modelcontextprotocol/typescript-sdk/compare/v1.25.3...v1.26.0">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=@modelcontextprotocol/sdk&package-manager=npm_and_yarn&previous-version=1.25.3&new-version=1.26.0)](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-code-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-02-04 21:57:57 +01:00
Elle Najt
e0cac12239 fix: Bash permission rules should only match Bash tool calls (#282)
## Summary
Fixes a bug where `Bash(...)` permission rules would incorrectly match
against non-Bash tools like Edit and Write.

## The Bug
In `matchesRule`, this check was the problem:

```typescript
const ruleAppliesToTool =
  rule.toolName === "Bash" ||  // <-- Only checks the RULE type, not the TOOL type
  (rule.toolName === "Edit" && FILE_EDITING_TOOLS.includes(toolName)) ||
  (rule.toolName === "Read" && FILE_READING_TOOLS.includes(toolName));
```

Notice that Edit and Read rules correctly check BOTH the rule type AND
the tool type, but Bash only checked the rule type. This meant
`rule.toolName === "Bash"` returned true for ANY tool when the rule was
a Bash rule.

When a non-Bash tool (like `mcp__acp__Edit`) was invoked:
1. `rule.toolName === "Bash"` → `true`, so `ruleAppliesToTool = true`
2. The Bash-specific command matching was skipped (since `toolName !==
acpToolNames.bash`)
3. Code fell through to the glob matching at the end of `matchesRule`
4. The rule's argument was matched as a file glob against the file being
edited

## Evidence
With settings containing `Bash(./inbox:*)`, editing a file showed:

```
[PreToolUseHook] Tool: mcp__acp__Edit, Decision: allow, Rule: Bash(./inbox:*)
```

The Edit tool was being allowed by a Bash rule.

## The Fix
Add a check to ensure Bash rules only apply when the tool is actually
Bash:

```typescript
// Before (buggy)
rule.toolName === "Bash" ||

// After (fixed)  
(rule.toolName === "Bash" && toolName === acpToolNames.bash) ||
```

This makes the Bash check consistent with how Edit and Read rules work.

## Test plan
- [x] Verify `Bash(./script:*)` allows `mcp__acp__Bash` with command
`./script foo`
- [x] Verify `Bash(./script:*)` does NOT allow `mcp__acp__Edit` on file
`./script`

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 10:18:12 +01:00
dependabot[bot]
6b68ddf00e Bump @isaacs/brace-expansion from 5.0.0 to 5.0.1 in the npm_and_yarn group across 1 directory (#281)
Bumps the npm_and_yarn group with 1 update in the / directory:
@isaacs/brace-expansion.

Updates `@isaacs/brace-expansion` from 5.0.0 to 5.0.1


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=@isaacs/brace-expansion&package-manager=npm_and_yarn&previous-version=5.0.0&new-version=5.0.1)](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 merge` will merge this PR after your CI passes on it
- `@dependabot squash and merge` will squash and merge this PR after
your CI passes on it
- `@dependabot cancel merge` will cancel a previously requested merge
and block automerging
- `@dependabot reopen` will reopen this PR if it is closed
- `@dependabot close` will close this PR and stop Dependabot recreating
it. You can achieve the same result by closing it manually
- `@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-code-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-02-03 21:54:29 +01:00
José Valim
34a617e20f Do not list keybindings-help as a command (#280)
This is actually a skill that comes as part of Claude Code,
which shouldn't be exposed as a command over ACP.
2026-02-03 15:41:50 +01:00
Railly Hugo
9016cba978 fix: respect user model preference from settings.json (#267)
- Integrates model into existing settings infrastructure
- Respects precedence: user < project < local < enterprise
- Removes duplicate file reading logic

Fixes #225
2026-02-02 17:20:08 +01:00
Ben Brandt
325c513f1b Prep 0.14.0 release (#278) 2026-02-02 15:16:39 +00:00
Tommy D. Rossi
87fa0a23c0 feat: implement unstable_listSessions for session/list RFD (#254)
Adds support for the draft session/list RFD spec by reading Claude
Code's local session files.

- Implement `unstable_listSessions` method that parses JSONL session
files from `~/.claude/projects/`
- Advertise `list` capability in `sessionCapabilities`
- Support filtering by `cwd` parameter
- Implement cursor-based pagination (50 sessions per page)
- Extract title from first user message, use file mtime as `updatedAt`

Related: https://agentclientprotocol.com/rfds/session-list

What do you think @benbrandt? Is it ok to do the json files parsing
ourselves? I also opened [an
issue](https://github.com/anthropics/claude-agent-sdk-typescript/issues/147)
in the claude agent sdk repo but I doubt they will add that
functionality in the sdk npm package

---------

Co-authored-by: Ben Brandt <benjamin.j.brandt@gmail.com>
2026-02-02 15:14:08 +00:00
Ben Brandt
c5a49e7481 Notify when we enter plan mode (#277)
Closes #274
2026-02-02 14:37:02 +00:00
Ben Brandt
c597cfae85 Support IS_SANDBOX env var (#276)
Closes #271
2026-02-02 14:20:06 +00:00
José Valim
a31f4534dd Update CLAUDE_CONFIG_DIR environment variable usage (#269)
According to the docs, that's the new of the env var:
https://code.claude.com/docs/en/settings#environment-variables
2026-02-02 14:13:28 +00:00
Ben Brandt
2e9bb8e82b Update to claude-agent-sdk 0.2.29 (#275) 2026-02-02 14:12:21 +00:00
Pointless
cfdb6463d6 Fix glob matching for file permissions on Windows (#249)
8 tests in settings.test.ts fail on Windows because `minimatch` expects
forward slashes but `path.normalize()` returns backslashes.

Converts paths to forward slashes before matching.
2026-02-02 09:57:41 +01:00
Joshua Carver
adb495d5d3 fix: Map Claude Code tool results to valid ACP content blocks (#270)
This PR fixes an issue with `claude-code-acp` where incoming tool
results from the Anthropic SDK would, in several cases, get mapped to
invalid ACP `ContentBlock`s inside of the `toAcpContentUpdate` method.
This prevents ACP clients from being able to properly render tool calls.

Per the [ACP
spec](https://agentclientprotocol.com/protocol/schema#contentblock)
`ContentBlock` can only have a `type` field with a value of: `text |
image | audio | resource | resource_link`. However, several of Claude
Code's tools return results with different `type`'s .

This PR adds a `toAcpContentBlock` method that maps tool call results to
valid ACP `ContentBlock`. It attempts to be defensive and will map
unknown types to a text content block. This avoids breakages if
Anthropic changes the types upstream, or introduces new tools.
2026-01-29 15:51:34 +00:00
Steffen Deusch
11ca660dec allow using /context command (#268)
I experimented with https://agentclientprotocol.com/rfds/session-usage
(see
https://github.com/agentclientprotocol/agent-client-protocol/pull/415),
but the Claude Agent SDK does not seem to return the information we need
for an accurate context indicator. For example, one request to explore a
project returned 500k+ cached read tokens in Claude's `result` message,
while the agent reported 64k tokens used. I can open a PR with those
changes for reference once the Typescript SDK is updated.

To allow users to still see the current context usage, allowing to run
`/context` seems to be the second best option. This PR changes the code
to handle the `<local-command-stdout>` message that the SDK generates
when sending `/context` and includes `/context` as allowed command.

Screenshot from Tidewave with this change:

<img width="460" height="1079" alt="image"
src="https://github.com/user-attachments/assets/a3796e46-4014-4988-a993-e13d38d38d96"
/>
2026-01-29 16:34:39 +01:00
Charles Covey-Brandt
32898580a5 feat: add rawOutput to tool call updates (#261)
## Summary
- Pass the raw tool result content as `rawOutput` in `tool_call_update`
notifications
- Allows ACP clients to access the unprocessed tool output alongside the
formatted content
- Adds tests using SDK types to document exact shapes for different tool
result types

## Test plan
- [x] `npm run build` passes
- [x] `npm test` passes (86 tests)
- [x] `npm run lint` passes
- [x] `npm run format:check` passes

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-01-28 09:47:36 +01:00
Ben Brandt
a7794b5ffb Prep 0.13.2 release (#266) 2026-01-28 08:45:01 +00:00
Ben Brandt
8802888e39 Renable _meta.terminal-auth support (#265) 2026-01-28 08:42:21 +00:00
Ben Brandt
e4cebeca96 Update to claude-agent-sdk 0.2.22 (#264) 2026-01-28 08:41:29 +00:00
dependabot[bot]
45a539257b Bump hono from 4.11.5 to 4.11.7 in the npm_and_yarn group across 1 directory (#263)
Bumps the npm_and_yarn group with 1 update in the / directory:
[hono](https://github.com/honojs/hono).

Updates `hono` from 4.11.5 to 4.11.7
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/honojs/hono/releases">hono's
releases</a>.</em></p>
<blockquote>
<h2>v4.11.7</h2>
<h1>Security Release</h1>
<p>This release includes security fixes for multiple vulnerabilities in
Hono and related middleware. We recommend upgrading if you are using any
of the affected components.</p>
<h2>Components</h2>
<h3>IP Restriction Middleware</h3>
<p>Fixed an IPv4 address validation bypass that could allow IP-based
access control to be bypassed under certain configurations.</p>
<h3>Cache Middleware</h3>
<p>Fixed an issue where responses marked with <code>Cache-Control:
private</code> or <code>no-store</code> could be cached, potentially
leading to information disclosure on some runtimes.</p>
<h3>Serve Static Middleware (Cloudflare Workers adapter)</h3>
<p>Fixed an issue that could allow unintended access to internal asset
keys when serving static files with user-controlled paths.</p>
<h3>hono/jsx <code>ErrorBoundary</code></h3>
<p>Fixed a reflected Cross-Site Scripting (XSS) issue in the
<code>ErrorBoundary</code> component that could occur when untrusted
strings were rendered without proper escaping.</p>
<h2>Recommendation</h2>
<p>Users are encouraged to upgrade to this release, especially if
they:</p>
<ul>
<li>Use IP Restriction Middleware</li>
<li>Use Cache Middleware on Deno, Bun, or Node.js</li>
<li>Use Serve Static Middleware with user-controlled paths on Cloudflare
Workers</li>
<li>Render untrusted data inside <code>ErrorBoundary</code>
components</li>
</ul>
<h2>Security Advisories &amp; CVEs</h2>
<ul>
<li>
<p><strong>IP Restriction Middleware – IPv4 address validation
bypass</strong></p>
<ul>
<li>Advisory: <a
href="https://github.com/honojs/hono/security/advisories/GHSA-r354-f388-2fhh">https://github.com/honojs/hono/security/advisories/GHSA-r354-f388-2fhh</a></li>
<li>CVE: CVE-2026-24398</li>
</ul>
</li>
<li>
<p><strong>Cache Middleware ignores <code>Cache-Control:
private</code></strong></p>
<ul>
<li>Advisory: <a
href="https://github.com/honojs/hono/security/advisories/GHSA-6wqw-2p9w-4vw4">https://github.com/honojs/hono/security/advisories/GHSA-6wqw-2p9w-4vw4</a></li>
<li>CVE: CVE-2026-24472</li>
</ul>
</li>
<li>
<p><strong>Serve Static Middleware (Cloudflare Workers adapter) –
Arbitrary key read</strong></p>
<ul>
<li>Advisory: <a
href="https://github.com/honojs/hono/security/advisories/GHSA-w332-q679-j88p">https://github.com/honojs/hono/security/advisories/GHSA-w332-q679-j88p</a></li>
<li>CVE: CVE-2026-24473</li>
</ul>
</li>
<li>
<p><strong>hono/jsx <code>ErrorBoundary</code> – Cross-Site Scripting
(XSS)</strong></p>
<ul>
<li>Advisory: <a
href="https://github.com/honojs/hono/security/advisories/GHSA-9r54-q6cx-xmh5">https://github.com/honojs/hono/security/advisories/GHSA-9r54-q6cx-xmh5</a></li>
<li>CVE: Pending</li>
</ul>
</li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="f7d272abe1"><code>f7d272a</code></a>
4.11.7</li>
<li><a
href="2cf60046d7"><code>2cf6004</code></a>
Merge commit from fork</li>
<li><a
href="cf9a78db4d"><code>cf9a78d</code></a>
Merge commit from fork</li>
<li><a
href="edbf6eea8e"><code>edbf6ee</code></a>
Merge commit from fork</li>
<li><a
href="12c511745b"><code>12c5117</code></a>
Merge commit from fork</li>
<li><a
href="7343487e62"><code>7343487</code></a>
4.11.6</li>
<li><a
href="b6e5a97cd4"><code>b6e5a97</code></a>
feat(bun): export getBunServer (<a
href="https://redirect.github.com/honojs/hono/issues/4626">#4626</a>)</li>
<li><a
href="2a9cd953b6"><code>2a9cd95</code></a>
fix(sse): handle <code>\r</code> and <code>\r\n</code> line endings in
writeSSE (<a
href="https://redirect.github.com/honojs/hono/issues/4644">#4644</a>)</li>
<li><a
href="8078bbf759"><code>8078bbf</code></a>
docs: align CODE_OF_CONDUCT.md wording with Contributor Covenant (<a
href="https://redirect.github.com/honojs/hono/issues/4630">#4630</a>)</li>
<li><a
href="a5be555b31"><code>a5be555</code></a>
refactor: use <code>unique symbol</code> for more accurate typing. (<a
href="https://redirect.github.com/honojs/hono/issues/4651">#4651</a>)</li>
<li>See full diff in <a
href="https://github.com/honojs/hono/compare/v4.11.5...v4.11.7">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=hono&package-manager=npm_and_yarn&previous-version=4.11.5&new-version=4.11.7)](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 merge` will merge this PR after your CI passes on it
- `@dependabot squash and merge` will squash and merge this PR after
your CI passes on it
- `@dependabot cancel merge` will cancel a previously requested merge
and block automerging
- `@dependabot reopen` will reopen this PR if it is closed
- `@dependabot close` will close this PR and stop Dependabot recreating
it. You can achieve the same result by closing it manually
- `@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-code-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-01-27 22:53:21 +01:00
Ben Brandt
670fb18728 Update to claude-agent-sdk 0.2.19 (#260) 2026-01-26 10:15:54 +00:00
codeignus
36ea238d76 fix: return content from Write tool (#250)
## Problem
The Write tool was returning an empty content array, which causes errors
with some AI providers when using Claude Code through ACP.

Specifically, Z.AI GLM-4.7 returns error 1213 "The prompt parameter was
not received normally" when the Write tool returns empty content.
This error causes the API request to fail and halts the conversation
flow, even though the file write itself succeeds.

Note: The Claude Code CLI works fine - this issue is specific to the ACP
integration.

## Solution
Return a confirmation message from the Write tool, aligning it with
Read, Edit, and Bash tools which all return content.

## Changes
- Write tool now returns `content: [{type: "text", text: "Wrote file:
${input.file_path}"}]`

This change only adds a confirmation message - no existing functionality
is modified.

## Testing
Tested with Zed editor. Would appreciate others verifying it works as
expected.

Relates to zed-industries/zed#45704

---------

Co-authored-by: Ben Brandt <benjamin.j.brandt@gmail.com>
2026-01-19 15:27:32 +01:00
Charles Covey-Brandt
8366864daf Exclude tests from published package (#248)
## Summary
- Add `!dist/tests/` to package.json files array to prevent test files
from being included in npm package
- Add `.claude/*.local.*` to .gitignore for local config files

## Test plan
- [x] Verified with `npm pack --dry-run` that dist/tests/ is excluded
from package

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-01-16 17:33:43 +01:00
Ben Brandt
5917529f0d Prep 0.13.1 release (#246) 2026-01-15 21:10:52 +00:00
Ben Brandt
2597775ef0 Disable AskUserQuestion for now (#245)
This is a cool tool. But we don't have a great way to express it over
ACP at the moment.

Using RequestPermission _kind of_ works. But it conflates question
asking with a very security focused flow, which has problems as well.

Rather than have strange tool output, we'll just turn it off for now.
2026-01-15 22:03:09 +01:00
Charles Covey-Brandt
6572fcf978 Add TypeScript declaration files and integration tests (#237)
## Summary

- Enable TypeScript declaration file generation (`.d.ts` and
`.d.ts.map`)
- Add `types` field and proper `exports` configuration to package.json
- Add comprehensive integration test that verifies the full packaging
flow

## Changes

### Build Configuration
- **tsconfig.json**: Added `declaration: true` and `declarationMap:
true`
- **package.json**: Added `types` field and `exports` field with:
  - Main entry point with proper `types` and `import` conditions
- Wildcard `"./*": "./*"` to preserve backwards compatibility for deep
imports

### Integration Test
The new test at `src/tests/typescript-declarations.test.ts` validates:
- **Full packaging flow**: build → npm pack → install tarball →
type-check
- **Main exports**: All primary exports type-check correctly
- **Deep imports**: Backwards compatibility for `dist/*.js` imports
- **Type shapes**: Classes, interfaces, and function signatures match
expected types
- **Generic types**: Pushable<T> works correctly with type parameters
- **Invalid types**: TypeScript correctly rejects incorrect usage

The test creates temporary TypeScript projects, installs the actual
`.tgz` package, and runs `tsc --noEmit` to verify type-checking passes.

## Test plan
- [x] `npm run build` generates `.d.ts` and `.d.ts.map` files in `dist/`
- [x] All 8 integration tests pass: `npm run test:integration --
typescript-declarations`
- [x] Main exports type-check correctly
- [x] Deep imports continue to work (no breaking change)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-01-15 21:16:07 +01:00
Ben Brandt
5586ae0d2c Better error handling in custom MCP tools (#243)
Closes #229
2026-01-15 13:33:16 +01:00
Pointless
39ab0c248f Add MAX_THINKING_TOKENS env support and fix file-read-info format (#226)
PR for awareness and discussion.

1) claude-code-acp doesn't currently support configuring
"maxThinkingTokens"
(https://platform.claude.com/docs/en/agent-sdk/typescript) nor is it
configured in the query. Result, at least for me, is that agent thinking
chunks are never flowing through the chain. I added environment support
to my ACP host, added env variable to query init for maxThinkingTokens
following the pyton sdk env "MAX_THINKING_TOKENS". Result is now agent
thinking chunks DO flow. And I can see Claude reason. Before, no chunks
ever. This is perhaps based on configuration that Claude Agent SDK reads
but could be made more flexibel. Such as this suggestions.

2) Fix file-read-info message: was showing nonsensical range format
'Read lines 160-40', now shows 'Read 40 lines starting at line 160'. The
way this was written was confusing for myself and for Claude. If the
file-read-info is supposed to give information, then lines like "Read
lines 160-40" says absolutely nothing.

Reproduce:

Configure your ACP host to parse thinking tokens with no other external
configuration. Claude will not send any.
Enable maxThinkingTokens in ACP and set it to something like 8096. Try
your ACP implementation again, Claude now sends thinking chunks.

Bugs:

Anthropic has a meta issue. Don't talk about the "thinking" tag with
Claude. It will break output regardless of what client you use. (even on
Claude Code CLI)

---------

Co-authored-by: Ben Brandt <benjamin.j.brandt@gmail.com>
2026-01-15 12:21:35 +00:00
Ben Brandt
72a9cde888 Update to claude-agent-sdk 0.2.7 (#242) 2026-01-15 13:05:23 +01:00
Ben Brandt
c9d5d3bb9c Update to claude-agent-sdk 0.2.6 (#239) 2026-01-13 15:23:53 +01:00
dependabot[bot]
cee383c67c Bump @modelcontextprotocol/sdk from 1.25.1 to 1.25.2 in the npm_and_yarn group across 1 directory (#234)
Bumps the npm_and_yarn group with 1 update in the / directory:
[@modelcontextprotocol/sdk](https://github.com/modelcontextprotocol/typescript-sdk).

Updates `@modelcontextprotocol/sdk` from 1.25.1 to 1.25.2
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/modelcontextprotocol/typescript-sdk/releases"><code>@​modelcontextprotocol/sdk</code>'s
releases</a>.</em></p>
<blockquote>
<h2>v1.25.2</h2>
<h2>What's Changed</h2>
<ul>
<li>ci: trigger workflow on v1.x branch by <a
href="https://github.com/felixweinberger"><code>@​felixweinberger</code></a>
in <a
href="https://redirect.github.com/modelcontextprotocol/typescript-sdk/pull/1319">modelcontextprotocol/typescript-sdk#1319</a></li>
<li>fix: README badges links destinations by <a
href="https://github.com/antonpk1"><code>@​antonpk1</code></a> in <a
href="https://redirect.github.com/modelcontextprotocol/typescript-sdk/pull/907">modelcontextprotocol/typescript-sdk#907</a></li>
<li>fix: prevent ReDoS in UriTemplate regex patterns (v1.x backport) by
<a href="https://github.com/pcarleton"><code>@​pcarleton</code></a> in
<a
href="https://redirect.github.com/modelcontextprotocol/typescript-sdk/pull/1365">modelcontextprotocol/typescript-sdk#1365</a></li>
</ul>
<h2>New Contributors</h2>
<ul>
<li><a href="https://github.com/antonpk1"><code>@​antonpk1</code></a>
made their first contribution in <a
href="https://redirect.github.com/modelcontextprotocol/typescript-sdk/pull/907">modelcontextprotocol/typescript-sdk#907</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/modelcontextprotocol/typescript-sdk/compare/1.25.1...v1.25.2">https://github.com/modelcontextprotocol/typescript-sdk/compare/1.25.1...v1.25.2</a></p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="b392f02ffc"><code>b392f02</code></a>
fix: prevent ReDoS in UriTemplate regex patterns (v1.x backport) (<a
href="https://redirect.github.com/modelcontextprotocol/typescript-sdk/issues/1365">#1365</a>)</li>
<li><a
href="a0c9b13484"><code>a0c9b13</code></a>
fix: README badges links destinations (<a
href="https://redirect.github.com/modelcontextprotocol/typescript-sdk/issues/907">#907</a>)</li>
<li><a
href="6dd08ac608"><code>6dd08ac</code></a>
ci: trigger workflow on v1.x branch (<a
href="https://redirect.github.com/modelcontextprotocol/typescript-sdk/issues/1319">#1319</a>)</li>
<li>See full diff in <a
href="https://github.com/modelcontextprotocol/typescript-sdk/compare/1.25.1...v1.25.2">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=@modelcontextprotocol/sdk&package-manager=npm_and_yarn&previous-version=1.25.1&new-version=1.25.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 merge` will merge this PR after your CI passes on it
- `@dependabot squash and merge` will squash and merge this PR after
your CI passes on it
- `@dependabot cancel merge` will cancel a previously requested merge
and block automerging
- `@dependabot reopen` will reopen this PR if it is closed
- `@dependabot close` will close this PR and stop Dependabot recreating
it. You can achieve the same result by closing it manually
- `@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-code-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-01-07 18:03:25 +01:00
dependabot[bot]
041aa44d00 Bump qs from 6.14.0 to 6.14.1 in the npm_and_yarn group across 1 directory (#230)
Bumps the npm_and_yarn group with 1 update in the / directory:
[qs](https://github.com/ljharb/qs).

Updates `qs` from 6.14.0 to 6.14.1
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/ljharb/qs/blob/main/CHANGELOG.md">qs's
changelog</a>.</em></p>
<blockquote>
<h2><strong>6.14.1</strong></h2>
<ul>
<li>[Fix] ensure arrayLength applies to <code>[]</code> notation as
well</li>
<li>[Fix] <code>parse</code>: when a custom decoder returns
<code>null</code> for a key, ignore that key</li>
<li>[Refactor] <code>parse</code>: extract key segment splitting
helper</li>
<li>[meta] add threat model</li>
<li>[actions] add workflow permissions</li>
<li>[Tests] <code>stringify</code>: increase coverage</li>
<li>[Dev Deps] update <code>eslint</code>,
<code>@ljharb/eslint-config</code>, <code>npmignore</code>,
<code>es-value-fixtures</code>, <code>for-each</code>,
<code>object-inspect</code></li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="3fa11a5f64"><code>3fa11a5</code></a>
v6.14.1</li>
<li><a
href="a62670423c"><code>a626704</code></a>
[Dev Deps] update <code>npmignore</code></li>
<li><a
href="3086902ecf"><code>3086902</code></a>
[Fix] ensure arrayLength applies to <code>[]</code> notation as
well</li>
<li><a
href="fc7930e86c"><code>fc7930e</code></a>
[Dev Deps] update <code>eslint</code>,
<code>@ljharb/eslint-config</code></li>
<li><a
href="0b06aac566"><code>0b06aac</code></a>
[Dev Deps] update <code>@ljharb/eslint-config</code></li>
<li><a
href="64951f6200"><code>64951f6</code></a>
[Refactor] <code>parse</code>: extract key segment splitting helper</li>
<li><a
href="e1bd2599cd"><code>e1bd259</code></a>
[Dev Deps] update <code>@ljharb/eslint-config</code></li>
<li><a
href="f4b3d39709"><code>f4b3d39</code></a>
[eslint] add eslint 9 optional peer dep</li>
<li><a
href="6e94d9596c"><code>6e94d95</code></a>
[Dev Deps] update <code>eslint</code>,
<code>@ljharb/eslint-config</code>, <code>npmignore</code></li>
<li><a
href="973dc3c51c"><code>973dc3c</code></a>
[actions] add workflow permissions</li>
<li>Additional commits viewable in <a
href="https://github.com/ljharb/qs/compare/v6.14.0...v6.14.1">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=qs&package-manager=npm_and_yarn&previous-version=6.14.0&new-version=6.14.1)](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 merge` will merge this PR after your CI passes on it
- `@dependabot squash and merge` will squash and merge this PR after
your CI passes on it
- `@dependabot cancel merge` will cancel a previously requested merge
and block automerging
- `@dependabot reopen` will reopen this PR if it is closed
- `@dependabot close` will close this PR and stop Dependabot recreating
it. You can achieve the same result by closing it manually
- `@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-code-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-01-01 22:57:28 +01:00
Ben Brandt
3db76b2d20 Update clients list in readme (#228) 2025-12-30 21:51:22 +00:00
Forte Scarlet
db965a31aa Change npm install command to global installation in README (#224)
As mentioned in #216, npm installations should be global, but the
documentation does not reflect this
2025-12-30 21:39:07 +00:00
Steffen Deusch
5280246773 announce fork and ensure fork uses a new session id (#222)
Followup for https://github.com/zed-industries/claude-code-acp/pull/221
and https://github.com/zed-industries/claude-code-acp/pull/183.
2025-12-19 18:29:43 +01:00
Ben Brandt
9f3dd402ea Update to claude-agent-sdk 0.1.73 (#221)
Adds the ability for us to also define a session id when forking
2025-12-19 11:10:28 +01:00
Piet Brömmel
d19e399018 rename Claude Code SDK to Claude Agent SDK in the README und update the link (#220) 2025-12-18 22:27:09 +00:00
Ben Brandt
24d1050f7a Better Edit diffs (#217)
Removes the problematic file cache and instead builds the diff out of
the unified diff that gets returned from the edit tool already.

This should solve several error cases and provide a more reliable diff
2025-12-17 12:50:26 +01:00
Bennet Bo Fenner
a94598dd89 Prep v0.12.6 (#214) 2025-12-16 17:28:07 +01:00
Bennet Bo Fenner
8228c471c8 Prep release (#213) 2025-12-16 17:22:49 +01:00
Steffen Deusch
c2200eec80 fix unstable methods (#212) 2025-12-16 17:05:33 +01:00
Ben Brandt
3e5f0b4830 Prep release (#211) 2025-12-16 15:08:30 +00:00
Ben Brandt
172565f19d Update to claude-agent-sdk 0.1.70 (#210) 2025-12-16 13:43:55 +00:00
Steffen Deusch
09106474ea feat(unstable): add session/resume support (#207)
Verified in Tidewave!

---------

Co-authored-by: Ben Brandt <benjamin.j.brandt@gmail.com>
2025-12-12 15:41:43 +01:00
Ben Brandt
2b60bad254 Update to claude-agent-sdk 0.1.67 (#206) 2025-12-12 15:15:56 +01:00
Steffen Deusch
e81ecbd96e Implement session/fork (#183)
Work in progress! Uses a fork for now.

See
https://github.com/agentclientprotocol/agent-client-protocol/pull/277.

---------

Co-authored-by: Ben Brandt <benjamin.j.brandt@gmail.com>
2025-12-12 14:40:59 +01:00
Bennet Bo Fenner
e1f08905f7 Respect permissions specified in setting files (#197) 2025-12-11 17:12:35 +01:00
Ben Brandt
634f1ef6a4 Update to claude-agent-sdk 0.1.65 (#202) 2025-12-11 14:18:36 +01:00
Ben Brandt
86d333b8cc Prep release (#201) 2025-12-11 13:04:07 +00:00
Ben Brandt
9f6fef55dd Allow agent to write plans and todos to its config directory (#200)
- **Use official sdk types for tools**
- **Allow agent to write plans and todos to its config directory**
2025-12-11 14:02:16 +01:00
Guillaume Sabran
c3e1418d89 Allow to resume previous session (#199)
When the ACP client persists the session id to support resuming, it has
no way to pass it back to the agent to resume an existing session as the
current implementation always creates a new id that is now shared with
Claude Code.

My client used to pass the value in the `--resume` within Claude Code
option, but `--resume` is not compatible with the new `--session-id`:

```
--session-id cannot be used with --continue or --resume.
```

Change:

When `--resume` is used, extract its value and use it as the session id.

---------

Co-authored-by: Ben Brandt <benjamin.j.brandt@gmail.com>
2025-12-11 12:18:38 +01:00
Bennet Bo Fenner
3b4cd4b2d5 Fix duplicate tool use IDs error (#198)
<img width="595" height="107" alt="image"
src="https://github.com/user-attachments/assets/3b9ae5e9-cbc8-425a-8cb6-1d201df3a812"
/>

---------

Co-authored-by: Ben Brandt <benjamin.j.brandt@gmail.com>
2025-12-09 22:00:38 +01:00
Ben Brandt
9320e05891 Update to claude-agent-sdk 0.1.61 (#195) 2025-12-08 12:17:49 +01:00
Ben Brandt
3f44543019 Unify ACP and Claude Code session ids (#193)
There have been some issues for folks because the session id we returned
for ACP wasn't the same as the session id for Claude Code.

Now, we pass it through to the `--session-id` flag of the Claude Code
CLI, which means both ACP and Claude Code are always referring to the
same UUID. Yay!
2025-12-05 15:14:16 +00:00
Ben Brandt
4db3d855bf Update to claude-agent-sdk v0.1.59 (#192) 2025-12-05 16:06:31 +01:00
Ben Brandt
e57893f6d0 Update to claude-agent-sdk 0.1.57 (#191) 2025-12-03 11:27:01 +00:00
Ben Brandt
6a93d7e786 Update to ACP SDK v0.7.0 (#188) 2025-12-01 17:45:24 +01:00
RAIT-09
c3c0f4da36 Prevent invalid locations when Read tool call has no file_path (#186)
## Summary

Follow-up to #121. The same issue (invalid locations when `file_path` is
undefined) existed in the `Read` tool case as well.

## Problem

The ACP spec requires `path` as a required field in `ToolCallLocation`.
PR #121 fixed this for `mcp__acp__Read`, but the same issue remained for
the `Read` tool case.

When Claude generates a Read tool call without `file_path`, the current
code produces an invalid notification:

```json
{
  "locations": [
    {
      "line": 0
    }
  ]
}
```

This causes the following error in the client:

```
Error handling notification
{code: -32602, message: 'Invalid params', data: {…}}
```

## Solution

Return an empty `locations` array when `input.file_path` is undefined,
consistent with the fix in #121.
2025-12-01 13:22:06 +01:00
Ben Brandt
73d308e4ad Update to claude-agent-sdk 0.1.55 (#182) 2025-11-27 14:35:53 +01:00
Ben Brandt
49b28a25eb Add CLAUDECODE environment to terminal invocations (#181) 2025-11-27 13:10:11 +00:00
Ben Brandt
8516df7188 fix: Access to undefined meta field (#180) 2025-11-27 14:06:47 +01:00
Guillaume Sabran
e31a16228a Allow user to pass Options to Claude Code in new session (#176)
When using Claude Code ACP as a direct dependency, this will allow the
user to configure Claude Code using the `Options` provided by the SDK.
2025-11-26 11:36:01 +01:00
Guillaume Sabran
c00782c103 Logging configuration (#177)
Allow to provide a logger to replace `console` (`console` remains the
default value)
2025-11-25 11:29:16 +01:00
Ben Brandt
4211f8cf05 Update to claude-agent-sdk 0.1.51 (#178)
Adds support for Opus 4.5
2025-11-24 23:24:34 +01:00
Ben Brandt
ac2119a23a More forgiving unreachable (#173)
Currently, if we get an unexpected message, we crash. While this is fine
in the world where we vendor CC (which we do by default) we also allow
users to override the executable for cases where their work needs a
wrapper.

This makes it so that we just log the event that we get, but don't crash
right away in case we can continue on.
2025-11-24 12:17:11 +00:00
Ben Brandt
427117dce6 Update to claude-agent-sdk 0.1.50 (#171) 2025-11-24 12:05:38 +00:00
Paul Nelson
b5a7e0f28d Fix read/edit tool error handling so upstream errors surface (#163)
Fixes #162:
- guard both tool callbacks so they return the host-provided error
instead of crashing on missing content
- skip writeTextFile in the edit tool when the initial read fails,
mirroring the read tool behavior
- add regression tests

---------

Co-authored-by: Ben Brandt <benjamin.j.brandt@gmail.com>
2025-11-24 11:37:18 +01:00
Ben Brandt
8ef0b4e4c6 Update to claude-agent-sdk 0.1.46 (#168) 2025-11-19 08:42:32 +00:00
Ben Brandt
959cd6c8e2 Disable experimental terminal auth support for now (#167)
This was causing issues on Windows in the current form, and also has
some other issues that need looking into. Will revisit later.
2025-11-19 08:37:59 +00:00
Ben Brandt
ef209d9d12 Don't special-case "default" model id (#166)
A model of id "default" only shows up sometimes in certain plans. So
rather than special-case this one, we just take the first one in the
list.
2025-11-18 10:51:20 +00:00
Ben Brandt
91b00b71e3 Report errors from "status" messages (#165)
The "status" message may say "success" but can actually have an error
which we weren't surfacing.

We should now be returning a more helpful message to the user.

Closes #161 and possibly #146
2025-11-18 10:30:30 +00:00
Ben Brandt
101a527732 Update to claude-agent-sdk 0.1.44 (#164) 2025-11-18 09:59:41 +00:00
Amos Wenger
59c5a0a8b8 Add experimental support for disabling built-in tools via _meta flag (#158)
Add ability to disable built-in tools (Read, Write, Edit, Bash, etc.)
when `_meta.disableBuiltInTools` is set to true. This allows clients to
rely solely on MCP tools without the built-in tool implementations.

---

This is just my take on #157 — not necessarily the one that makes sense
/cc @benbrandt

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Ben Brandt <benjamin.j.brandt@gmail.com>
2025-11-17 10:31:59 +00:00
Ben Brandt
5133e2dec7 Update to claude-agent-sdk 0.1.42 (#160) 2025-11-17 10:24:02 +00:00
Tim Visher
ed035da9d8 Add title field to tool permission requests (#159)
🤖 Co-authored-by: Claude (Anthropic AI Assistant)

## Problem

Tool approval dialogs in ACP clients (like Emacs
[agent-shell](https://github.com/xenodium/agent-shell)) were showing up
empty because the `toolCall` object in `session/request_permission`
requests was missing the `title` field.

This caused dialogs to appear with just buttons and no description of
what tool is being approved, making it impossible for users to make
informed decisions.

## Solution

Add the `title` field to both `requestPermission` calls (lines 553 and
612) by using the existing `toolInfoFromToolUse()` function which
already generates appropriate titles for all tool types.

## Before
```
  ╭─
      ⚠ Tool Permission ⚠
      
      
       Allow (y)   Reject (n)   Always Allow (!)
  ╰─
```

## After
```
  ╭─
      ⚠ Tool Permission ⚠
      
      Write /path/to/file.txt
      
       Allow (y)   Reject (n)   Always Allow (!)
  ╰─
```

## References

- This agent-shell.el issue shows (as has been shown above) what the
expected interaction would look like and what this changeset supports:
https://github.com/xenodium/agent-shell/issues/123
- The ACP protocol already supports the `title` field and agent-shell
displays it correctly when present (confirmed with OpenCode Agent which
does send titles)

## Testing

- **Manual testing**: Confirmed with agent-shell that permission dialogs
now show descriptive titles like "Write /path/to/file.txt"
- **Unit tests**: Added test coverage that verifies the `title` field is
present in permission request structures for multiple tool types (Write,
Bash, Read)
- **Regression testing**: Verified the new test fails when the `title`
field is omitted, confirming it catches the bug this PR fixes
- **Existing tests**: All 61 existing unit tests pass

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Ben Brandt <benjamin.j.brandt@gmail.com>
2025-11-17 11:19:22 +01:00
Ben Brandt
8df7f10058 Use better file path resolution for experimental auth support (#156) 2025-11-14 12:21:49 +01:00
Ben Brandt
6a0d0b5522 Fix incorrect stop reason after tool call rejection (#153)
We were mapping tool call rejections to actual "refusals" which isn't
quite right in ACP.

Also filters out more of these status messages that Claude is returning.
2025-11-13 11:19:48 +01:00
Ben Brandt
7e0d2c9506 Update to claude-agent-sdk 0.1.37 (#152) 2025-11-13 09:56:19 +00:00
Guillaume Sabran
2835f030cf Add metadata to tool calls, including structured tool output (#128)
Use the `_meta` field for tool calls to forward:
- the original tool name (ie `Bash` etc)
- the structured output of the tool call

The output is received by using the `PostToolUse` hook from the SDK. The
`tool_call_update` for the tool result is now only sent when both the
hook callback and the original tool result (plain text) have been
received.

Claude Code describes [in its documentation
](https://docs.claude.com/en/api/agent-sdk/typescript#tool-output-types)the
expected structure of the output for each tools. Unfortunately those are
not part of the SDK, and [empirically I've found that there was some
differences](58c3100a36/local-server/src/server/endpoints/sendMessage/acp/clients/claudeCode/types.ts (L265)).
Still I've found that having this structured output was useful for my
client to provide a richer UX. Maybe Zed will too.

I was not sure how to add tests for this change, given the current setup
2025-11-13 10:42:21 +01:00
Ben Brandt
2710e146f4 Use vendored claude cli for experimental login method (#143) 2025-11-05 11:36:02 +01:00
Richard Feldman
bf396fea67 Use terminal-auth from _meta if supported (#141) 2025-11-04 21:34:16 -05:00
22 changed files with 5209 additions and 4141 deletions

View File

@@ -1,11 +1,84 @@
name: Publish Package to npmjs
name: Publish and Release
on:
push:
tags:
- "v*.*.*"
workflow_dispatch:
jobs:
publish-npm:
build-binaries:
name: Build (${{ matrix.target }})
runs-on: ubuntu-latest
permissions:
contents: read
timeout-minutes: 15
strategy:
fail-fast: true
matrix:
include:
- target: bun-linux-x64
artifact: claude-agent-acp
archive: claude-agent-acp-linux-x64.tar.gz
- target: bun-linux-arm64
artifact: claude-agent-acp
archive: claude-agent-acp-linux-arm64.tar.gz
- target: bun-linux-x64-musl
artifact: claude-agent-acp
archive: claude-agent-acp-linux-x64-musl.tar.gz
- target: bun-linux-arm64-musl
artifact: claude-agent-acp
archive: claude-agent-acp-linux-arm64-musl.tar.gz
- target: bun-darwin-x64
artifact: claude-agent-acp
archive: claude-agent-acp-darwin-x64.zip
- target: bun-darwin-arm64
artifact: claude-agent-acp
archive: claude-agent-acp-darwin-arm64.zip
- target: bun-windows-x64
artifact: claude-agent-acp.exe
archive: claude-agent-acp-windows-x64.zip
- target: bun-windows-arm64
artifact: claude-agent-acp.exe
archive: claude-agent-acp-windows-arm64.zip
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8
- uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3
with:
bun-version: latest
- name: Install dependencies
run: bun install
- name: Build binary
run: |
bun build src/static-entry.ts \
--compile \
--target ${{ matrix.target }} \
--define 'process.env.CLAUDE_AGENT_ACP_IS_SINGLE_FILE_BUN="true"' \
--outfile ${{ matrix.artifact }}
- name: Compress binary
run: |
if [[ "${{ matrix.archive }}" == *.zip ]]; then
zip ${{ matrix.archive }} ${{ matrix.artifact }}
else
tar czf ${{ matrix.archive }} ${{ matrix.artifact }}
fi
- name: Upload artifact
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f
with:
name: ${{ matrix.archive }}
path: ${{ matrix.archive }}
publish-npm:
name: Publish to npm
runs-on: ubuntu-latest
needs: [build-binaries]
if: startsWith(github.ref, 'refs/tags/')
environment: release # Optional: for enhanced security
permissions:
contents: read
@@ -22,15 +95,26 @@ jobs:
- run: npm ci
- run: npm run build
- run: npm publish
release:
name: Create Release
runs-on: ubuntu-latest
needs: [publish-npm]
needs: [build-binaries, publish-npm]
if: startsWith(github.ref, 'refs/tags/')
permissions:
contents: write
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8
- name: Download all artifacts
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131
with:
path: artifacts
merge-multiple: true
- name: Create Release
id: create_release
uses: softprops/action-gh-release@6da8fa9354ddfdc4aeace5fc48d7f679b5214090
with:
draft: true
generate_release_notes: true
files: artifacts/*

2
.gitignore vendored
View File

@@ -1,3 +1,5 @@
node_modules
dist
.DS_Store
.claude/*.local.*
CLAUDE.local.md

View File

@@ -1,5 +1,176 @@
# Changelog
## 0.19.2
- Fix for broken notifications when reloading session messages
## 0.19.1
- Support windows arm builds and clean up artifact files
## 0.19.0
- Update to @anthropic-ai/claude-agent-sdk@0.2.62
- Use SDK functions for listing and loading session history
- Build single-file executables using bun.
- Fix for overwritten disallowed tools.
## 0.18.0
- Switch over to built-in Claude tools. We no longer replicate specific ACP tools and just rely on sending updates based on Claude's internal tools. This means it won't use client capabilities for files or terminals, but also means there will be less difference and hopefully issues arising from the differences in behavior.
- Support ACP session config options: https://agentclientprotocol.com/protocol/session-config-options
- Fix for image output from tool calls.
## 0.17.1
- Update to @anthropic-ai/claude-agent-sdk@0.2.45 to add access to Sonnet 4.6
## 0.17.0
Rename from `@zed-industries/claude-code-acp` to `@zed-industries/claude-agent-acp` to align with the current [branding guidelines](https://platform.claude.com/docs/en/agent-sdk/overview#branding-guidelines)
## 0.16.2
- Update to @anthropic-ai/claude-agent-sdk@0.2.44
- fix: Replace all non-alphanumeric characters for session loading in encodeProjectPath (#307)
- don't include /login slash command in login command (#315)
## 0.16.1
- Update to @anthropic-ai/claude-agent-sdk@0.2.38
- Fix incorrect paths for session/list
- Fix available commands after loading a session
- Make loading session more permissive for finding events
- Fix overriding user-provided disallowedTools
## 0.16.0
- Update to @anthropic-ai/claude-agent-sdk@0.2.34
- Experimental support for session loading
## 0.15.0
- Update to @anthropic-ai/claude-agent-sdk@0.2.32 (adds support for Opus 4.6 and 1M context Opus)
## 0.14.0
- Update to @anthropic-ai/claude-agent-sdk@0.2.29
- Update to using the recommended `CLAUDE_CONFIG_DIR` env variable for setting where config files are kept
- Support /context command
- Fix incorrect context type mapping for tool calls
- Fix glob metching for file permissions on Windows
- Support the `IS_SANDBOX` env var for supporting bypass permissions in root mode
- Fix missing notification for entering plan mode
- Experimental unstable support for listing sessions
## 0.13.2
- Update to @anthropic-ai/claude-agent-sdk@0.2.22
- Fix: return content from ACP write tool to help with issues with alternate providers.
## 0.13.1
- Update to @anthropic-ai/claude-agent-sdk@0.2.7
- Add TypeScript declaration files for library users
- Fixed error handling in custom ACP focused MCP tools
## 0.13.0
- Update to @anthropic-ai/claude-agent-sdk@0.2.6
- Update to @agentclientprotocol/sdk@0.13.0
## 0.12.6
- Fix model selection
## 0.12.5
- Update to @anthropic-ai/claude-agent-sdk@v0.1.70
- Unstable implementation of resuming sessions
## 0.12.4
- Update to @anthropic-ai/claude-agent-sdk@v0.1.67
- Better respect permissions specified in settings files
- Unstable implementation of forking
## 0.12.3
- Update to @anthropic-ai/claude-agent-sdk@v0.1.65
- Update to @agentclientprotocol/sdk@0.9.0
- Allow agent to write plans and todos to its config directory
- Fix experimental resume ids
## 0.12.2
- Fix duplicate tool use IDs error
## 0.12.1
- Update to @anthropic-ai/claude-agent-sdk@v0.1.61
- Update to @agentclientprotocol/sdk@0.8.0
## 0.12.0
- Update to @anthropic-ai/claude-agent-sdk@v0.1.59
- Brings Opus to Claude Pro plans
- Support "Don't Ask" profile
- Unify ACP + Claude Code session ids
## 0.11.0
- Update to @anthropic-ai/claude-agent-sdk@v0.1.57
- Removed dependency on @anthropic-ai/claude-code since this is no longer needed
## 0.10.10
- Update to @agentclientprotocol/sdk@0.7.0
## 0.10.9
- Update to @anthropic-ai/claude-agent-sdk@v0.1.55
- Allow defining a custom logger when used as a library
- Allow specifying custom options when used as a library
- Add `CLAUDECODE=1` to terminal invocations to match default Claude Code behavior
## 0.10.8
- Update to @anthropic-ai/claude-agent-sdk@v0.1.51 (adds support for Opus 4.5)
## 0.10.7
- Fix read/edit tool error handling so upstream errors surface
- Update to @anthropic-ai/claude-agent-sdk@v0.1.50
## 0.10.6
- Disable experimental terminal auth support for now, as it was causing issues on Windows. Will revisit with a fix later.
- Update to @anthropic-ai/claude-agent-sdk@v0.1.46
## 0.10.5
- Better error messages at end of turn if there were any
- Add experimental support for disabling built-in tools via \_meta flag
- Update to @anthropic-ai/claude-agent-sdk@v0.1.44
## 0.10.4
- Fix tool call titles not appearing during approval in some cases
- Update to @anthropic-ai/claude-agent-sdk@v0.1.42
## 0.10.3
- Fix for experimental terminal auth support
## 0.10.2
- Fix incorrect stop reason for tool call refusals
## 0.10.1
- Add additional structured metadata to tool calls
- Update to @anthropic-ai/claude-agent-sdk@v0.1.37
## 0.10.0
- Update to @anthropic-ai/claude-agent-sdk@v0.1.30

View File

@@ -1,10 +1,10 @@
# ACP adapter for Claude Code
# ACP adapter for the Claude Agent SDK
[![npm](https://img.shields.io/npm/v/%40zed-industries%2Fclaude-code-acp)](https://www.npmjs.com/package/@zed-industries/claude-code-acp)
[![npm](https://img.shields.io/npm/v/%40zed-industries%2Fclaude-agent-acp)](https://www.npmjs.com/package/@zed-industries/claude-agent-acp)
Use [Claude Code](https://www.anthropic.com/claude-code) from [ACP-compatible](https://agentclientprotocol.com) clients such as [Zed](https://zed.dev)!
Use [Claude Agent SDK](https://platform.claude.com/docs/en/agent-sdk/overview#branding-guidelines) from [ACP-compatible](https://agentclientprotocol.com) clients such as [Zed](https://zed.dev)!
This tool implements an ACP agent by using the official [Claude Code SDK](https://docs.anthropic.com/en/docs/claude-code/sdk/sdk-overview), supporting:
This tool implements an ACP agent by using the official [Claude Agent SDK](https://platform.claude.com/docs/en/agent-sdk/overview), supporting:
- Context @-mentions
- Images
@@ -24,7 +24,7 @@ Learn more about the [Agent Client Protocol](https://agentclientprotocol.com/).
The latest version of Zed can already use this adapter out of the box.
To use Claude Code, open the Agent Panel and click "New Claude Code Thread" from the `+` button menu in the top-right:
To use Claude Agent, open the Agent Panel and click "New Claude Agent Thread" from the `+` button menu in the top-right:
https://github.com/user-attachments/assets/ddce66c7-79ac-47a3-ad59-4a6a3ca74903
@@ -32,28 +32,27 @@ Read the docs on [External Agent](https://zed.dev/docs/ai/external-agents) suppo
### Other clients
- Emacs via [agent-shell.el](https://github.com/xenodium/agent-shell)
- [marimo notebook](https://github.com/marimo-team/marimo)
- Neovim
- via [CodeCompanion.nvim](https://codecompanion.olimorris.dev/configuration/adapters#setup-claude-code-via-acp)
- via [yetone/avante.nvim](https://github.com/yetone/avante.nvim)
[Submit a PR](https://github.com/zed-industries/claude-code-acp/pulls) to add yours!
Or try it with any of the other [ACP compatible clients](https://agentclientprotocol.com/overview/clients)!
#### Installation
Install the adapter from `npm`:
```bash
npm install @zed-industries/claude-code-acp
npm install -g @zed-industries/claude-agent-acp
```
You can then use `claude-code-acp` as a regular ACP agent:
You can then use `claude-agent-acp` as a regular ACP agent:
```
ANTHROPIC_API_KEY=sk-... claude-code-acp
ANTHROPIC_API_KEY=sk-... claude-agent-acp
```
#### Single-file executable
Pre-built single-file binaries are available on the [Releases](https://github.com/zed-industries/claude-agent-acp/releases) page for Linux, macOS, and Windows.
These binaries bundle everything needed and don't require Node.js.
## License
Apache-2.0

3007
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,17 +1,26 @@
{
"name": "@zed-industries/claude-code-acp",
"name": "@zed-industries/claude-agent-acp",
"publishConfig": {
"access": "public"
},
"version": "0.10.0",
"description": "An ACP-compatible coding agent powered by the Claude Code SDK (TypeScript)",
"version": "0.19.2",
"description": "An ACP-compatible coding agent powered by the Claude Agent SDK (TypeScript)",
"main": "dist/lib.js",
"types": "dist/lib.d.ts",
"bin": {
"claude-code-acp": "./dist/index.js"
"claude-agent-acp": "dist/index.js"
},
"type": "module",
"exports": {
".": {
"types": "./dist/lib.d.ts",
"import": "./dist/lib.js"
},
"./*": "./*"
},
"files": [
"dist/",
"!dist/tests/",
"README.md",
"LICENSE",
"package.json"
@@ -40,37 +49,32 @@
"sdk",
"code"
],
"homepage": "https://github.com/zed-industries/claude-code-acp#readme",
"homepage": "https://github.com/zed-industries/claude-agent-acp#readme",
"bugs": {
"url": "https://github.com/zed-industries/claude-code-acp/issues"
"url": "https://github.com/zed-industries/claude-agent-acp/issues"
},
"repository": {
"type": "git",
"url": "git+https://github.com/zed-industries/claude-code-acp.git"
"url": "git+https://github.com/zed-industries/claude-agent-acp.git"
},
"author": "Zed Industries",
"license": "Apache-2.0",
"dependencies": {
"@agentclientprotocol/sdk": "0.5.1",
"@anthropic-ai/claude-agent-sdk": "0.1.30",
"@anthropic-ai/claude-code": "2.0.32",
"@modelcontextprotocol/sdk": "1.21.0",
"diff": "8.0.2",
"uuid": "13.0.0"
"@agentclientprotocol/sdk": "0.14.1",
"@anthropic-ai/claude-agent-sdk": "0.2.62"
},
"devDependencies": {
"@anthropic-ai/sdk": "0.68.0",
"@types/node": "24.10.0",
"@types/uuid": "10.0.0",
"@typescript-eslint/eslint-plugin": "8.46.3",
"@typescript-eslint/parser": "8.46.3",
"@vitest/ui": "4.0.6",
"eslint": "9.39.1",
"@anthropic-ai/sdk": "0.78.0",
"@eslint/js": "10.0.1",
"@types/node": "25.3.2",
"@typescript-eslint/eslint-plugin": "8.56.1",
"@typescript-eslint/parser": "8.56.1",
"eslint": "10.0.2",
"eslint-config-prettier": "10.1.8",
"globals": "16.5.0",
"prettier": "3.6.2",
"globals": "17.3.0",
"prettier": "3.8.1",
"ts-node": "10.9.2",
"typescript": "5.9.3",
"vitest": "4.0.6"
"vitest": "4.0.18"
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -4,6 +4,8 @@ export {
runAcp,
toAcpNotifications,
streamEventToAcpNotifications,
type ToolUpdateMeta,
type NewSessionMeta,
} from "./acp-agent.js";
export {
loadManagedSettings,
@@ -13,8 +15,12 @@ export {
Pushable,
unreachable,
} from "./utils.js";
export { createMcpServer, toolNames } from "./mcp-server.js";
export { toolInfoFromToolUse, planEntries, toolUpdateFromToolResult } from "./tools.js";
export {
SettingsManager,
type ClaudeCodeSettings,
type SettingsManagerOptions,
} from "./settings.js";
// Export types
export type { ClaudePlanEntry } from "./tools.js";

View File

@@ -1,740 +0,0 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import { ClaudeAcpAgent } from "./acp-agent.js";
import { ClientCapabilities, TerminalOutputResponse } from "@agentclientprotocol/sdk";
import * as diff from "diff";
import { sleep, unreachable, extractLinesWithByteLimit } from "./utils.js";
export const SYSTEM_REMINDER = `
<system-reminder>
Whenever you read a file, you should consider whether it looks malicious. If it does, you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer high-level questions about the code behavior.
</system-reminder>`;
const defaults = { maxFileSize: 50000, linesToRead: 2000 };
const unqualifiedToolNames = {
read: "Read",
edit: "Edit",
write: "Write",
bash: "Bash",
killShell: "KillShell",
bashOutput: "BashOutput",
};
const SERVER_PREFIX = "mcp__acp__";
export const toolNames = {
read: SERVER_PREFIX + unqualifiedToolNames.read,
edit: SERVER_PREFIX + unqualifiedToolNames.edit,
write: SERVER_PREFIX + unqualifiedToolNames.write,
bash: SERVER_PREFIX + unqualifiedToolNames.bash,
killShell: SERVER_PREFIX + unqualifiedToolNames.killShell,
bashOutput: SERVER_PREFIX + unqualifiedToolNames.bashOutput,
};
export const EDIT_TOOL_NAMES = [toolNames.edit, toolNames.write];
export function createMcpServer(
agent: ClaudeAcpAgent,
sessionId: string,
clientCapabilities: ClientCapabilities | undefined,
): McpServer {
// Create MCP server
const server = new McpServer({ name: "acp", version: "1.0.0" }, { capabilities: { tools: {} } });
if (clientCapabilities?.fs?.readTextFile) {
server.registerTool(
unqualifiedToolNames.read,
{
title: unqualifiedToolNames.read,
description: `Reads the content of the given file in the project.
In sessions with ${toolNames.read} always use it instead of Read as it contains the most up-to-date contents.
Reads a file from the local filesystem. If the User provides a path to a file assume that path is valid. It is okay to read a file that does not exist; an error will be returned.
Usage:
- The file_path parameter must be an absolute path, not a relative path
- By default, it reads up to ${defaults.linesToRead} lines starting from the beginning of the file
- You can optionally specify a line offset and limit (especially handy for long files), but it's recommended to read the whole file by not providing these parameters
- Any files larger than ${defaults.maxFileSize} bytes will be truncated
- This tool allows Claude Code to read images (eg PNG, JPG, etc). When reading an image file the contents are presented visually as Claude Code is a multimodal LLM.
- This tool can only read files, not directories. To read a directory, use an ls command via the ${toolNames.bash} tool.
- You have the capability to call multiple tools in a single response. It is always better to speculatively read multiple files as a batch that are potentially useful.`,
inputSchema: {
file_path: z.string().describe("The absolute path to the file to read"),
offset: z
.number()
.optional()
.default(1)
.describe(
"The line number to start reading from. Only provide if the file is too large to read at once",
),
limit: z
.number()
.optional()
.default(defaults.linesToRead)
.describe(
`The number of lines to read. Only provide if the file is too large to read at once.`,
),
},
annotations: {
title: "Read file",
readOnlyHint: true,
destructiveHint: false,
openWorldHint: false,
idempotentHint: false,
},
},
async (input) => {
try {
const session = agent.sessions[sessionId];
if (!session) {
return {
content: [
{
type: "text",
text: "The user has left the building",
},
],
};
}
const content = await agent.readTextFile({
sessionId,
path: input.file_path,
line: input.offset,
limit: input.limit,
});
// Extract lines with byte limit enforcement
const result = extractLinesWithByteLimit(content.content, defaults.maxFileSize);
// Construct informative message about what was read
let readInfo = "";
if (input.offset > 1 || result.wasLimited) {
readInfo = "\n\n<file-read-info>";
if (result.wasLimited) {
readInfo += `Read ${result.linesRead} lines (hit 50KB limit). `;
} else {
readInfo += `Read lines ${input.offset}-${result.linesRead}. `;
}
if (result.wasLimited) {
readInfo += `Continue with offset=${result.linesRead}.`;
}
readInfo += "</file-read-info>";
}
return {
content: [
{
type: "text",
text: result.content + readInfo + SYSTEM_REMINDER,
},
],
};
} catch (error: any) {
return {
content: [
{
type: "text",
text: "Reading file failed: " + error.message,
},
],
};
}
},
);
}
if (clientCapabilities?.fs?.writeTextFile) {
server.registerTool(
unqualifiedToolNames.write,
{
title: unqualifiedToolNames.write,
description: `Writes a file to the local filesystem..
In sessions with ${toolNames.write} always use it instead of Write as it will
allow the user to conveniently review changes.
Usage:
- This tool will overwrite the existing file if there is one at the provided path.
- If this is an existing file, you MUST use the ${toolNames.read} tool first to read the file's contents. This tool will fail if you did not read the file first.
- ALWAYS prefer editing existing files in the codebase. NEVER write new files unless explicitly required.
- NEVER proactively create documentation files (*.md) or README files. Only create documentation files if explicitly requested by the User.
- Only use emojis if the user explicitly requests it. Avoid writing emojis to files unless asked.`,
inputSchema: {
file_path: z
.string()
.describe("The absolute path to the file to write (must be absolute, not relative)"),
content: z.string().describe("The content to write to the file"),
},
annotations: {
title: "Write file",
readOnlyHint: false,
destructiveHint: false,
openWorldHint: false,
idempotentHint: false,
},
},
async (input) => {
try {
const session = agent.sessions[sessionId];
if (!session) {
return {
content: [
{
type: "text",
text: "The user has left the building",
},
],
};
}
await agent.writeTextFile({
sessionId,
path: input.file_path,
content: input.content,
});
return {
content: [],
};
} catch (error: any) {
return {
content: [
{
type: "text",
text: "Writing file failed: " + error.message,
},
],
};
}
},
);
server.registerTool(
unqualifiedToolNames.edit,
{
title: unqualifiedToolNames.edit,
description: `Performs exact string replacements in files.
In sessions with ${toolNames.edit} always use it instead of Edit as it will
allow the user to conveniently review changes.
Usage:
- You must use your \`${toolNames.read}\` tool at least once in the conversation before editing. This tool will error if you attempt an edit without reading the file.
- When editing text from Read tool output, ensure you preserve the exact indentation (tabs/spaces) as it appears.
- ALWAYS prefer editing existing files in the codebase. NEVER write new files unless explicitly required.
- Only use emojis if the user explicitly requests it. Avoid adding emojis to files unless asked.
- The edit will FAIL if \`old_string\` is not unique in the file. Either provide a larger string with more surrounding context to make it unique or use \`replace_all\` to change every instance of \`old_string\`.
- Use \`replace_all\` for replacing and renaming strings across the file. This parameter is useful if you want to rename a variable for instance.`,
inputSchema: {
file_path: z.string().describe("The absolute path to the file to modify"),
old_string: z.string().describe("The text to replace"),
new_string: z
.string()
.describe("The text to replace it with (must be different from old_string)"),
replace_all: z
.boolean()
.default(false)
.optional()
.describe("Replace all occurences of old_string (default false)"),
},
annotations: {
title: "Edit file",
readOnlyHint: false,
destructiveHint: false,
openWorldHint: false,
idempotentHint: false,
},
},
async (input) => {
const session = agent.sessions[sessionId];
if (!session) {
return {
content: [
{
type: "text",
text: "The user has left the building",
},
],
};
}
const { content } = await agent.readTextFile({
sessionId,
path: input.file_path,
});
const { newContent } = replaceAndCalculateLocation(content, [
{
oldText: input.old_string,
newText: input.new_string,
replaceAll: input.replace_all,
},
]);
const patch = diff.createPatch(input.file_path, content, newContent);
await agent.writeTextFile({
sessionId,
path: input.file_path,
content: newContent,
});
return {
content: [
{
type: "text",
text: patch,
},
],
};
},
);
}
if (agent.clientCapabilities?.terminal) {
server.registerTool(
unqualifiedToolNames.bash,
{
title: unqualifiedToolNames.bash,
description: `Executes a bash command
In sessions with ${toolNames.bash} always use it instead of Bash`,
inputSchema: {
command: z.string().describe("The command to execute"),
timeout: z
.number()
.default(2 * 60 * 1000)
.describe(`Optional timeout in milliseconds (max ${2 * 60 * 1000})`),
description: z.string().optional()
.describe(`Clear, concise description of what this command does in 5-10 words, in active voice. Examples:
Input: ls
Output: List files in current directory
Input: git status
Output: Show working tree status
Input: npm install
Output: Install package dependencies
Input: mkdir foo
Output: Create directory 'foo'`),
run_in_background: z
.boolean()
.default(false)
.describe(
`Set to true to run this command in the background. The tool returns an \`id\` that can be used with the \`${toolNames.bashOutput}\` tool to retrieve the current output, or the \`${toolNames.killShell}\` tool to stop it early.`,
),
},
},
async (input, extra) => {
const session = agent.sessions[sessionId];
if (!session) {
return {
content: [
{
type: "text",
text: "The user has left the building",
},
],
};
}
const toolCallId = extra._meta?.["claudecode/toolUseId"];
if (typeof toolCallId !== "string") {
throw new Error("No tool call ID found");
}
if (!agent.clientCapabilities?.terminal || !agent.client.createTerminal) {
throw new Error("unreachable");
}
const handle = await agent.client.createTerminal({
command: input.command,
sessionId,
outputByteLimit: 32_000,
});
await agent.client.sessionUpdate({
sessionId,
update: {
sessionUpdate: "tool_call_update",
toolCallId,
status: "in_progress",
title: input.description,
content: [{ type: "terminal", terminalId: handle.id }],
},
});
const abortPromise = new Promise((resolve) => {
if (extra.signal.aborted) {
resolve(null);
} else {
extra.signal.addEventListener("abort", () => {
resolve(null);
});
}
});
const statusPromise = Promise.race([
handle.waitForExit().then((exitStatus) => ({ status: "exited" as const, exitStatus })),
abortPromise.then(() => ({ status: "aborted" as const, exitStatus: null })),
sleep(input.timeout).then(async () => {
if (agent.backgroundTerminals[handle.id]?.status === "started") {
await handle.kill();
}
return { status: "timedOut" as const, exitStatus: null };
}),
]);
if (input.run_in_background) {
agent.backgroundTerminals[handle.id] = {
handle,
lastOutput: null,
status: "started",
};
statusPromise.then(async ({ status, exitStatus }) => {
const bgTerm = agent.backgroundTerminals[handle.id];
if (bgTerm.status !== "started") {
return;
}
const currentOutput = await handle.currentOutput();
agent.backgroundTerminals[handle.id] = {
status,
pendingOutput: {
...currentOutput,
output: stripCommonPrefix(bgTerm.lastOutput?.output ?? "", currentOutput.output),
exitStatus: exitStatus ?? currentOutput.exitStatus,
},
};
return handle.release();
});
return {
content: [
{
type: "text",
text: `Command started in background with id: ${handle.id}`,
},
],
};
}
await using terminal = handle;
const { status } = await statusPromise;
if (status === "aborted") {
return {
content: [{ type: "text", text: "Tool cancelled by user" }],
};
}
const output = await terminal.currentOutput();
return {
content: [{ type: "text", text: toolCommandOutput(status, output) }],
};
},
);
server.registerTool(
unqualifiedToolNames.bashOutput,
{
title: unqualifiedToolNames.bashOutput,
description: `- Retrieves output from a running or completed background bash shell
- Takes a shell_id parameter identifying the shell
- Always returns only new output since the last check
- Returns stdout and stderr output along with shell status
- Use this tool when you need to monitor or check the output of a long-running shell
In sessions with ${toolNames.bashOutput} always use it instead of BashOutput.`,
inputSchema: {
shell_id: z
.string()
.describe(`The id of the background bash command as returned by \`${toolNames.bash}\``),
},
},
async (input) => {
const bgTerm = agent.backgroundTerminals[input.shell_id];
if (!bgTerm) {
throw new Error(`Unknown shell ${input.shell_id}`);
}
if (bgTerm.status === "started") {
const newOutput = await bgTerm.handle.currentOutput();
const strippedOutput = stripCommonPrefix(
bgTerm.lastOutput?.output ?? "",
newOutput.output,
);
bgTerm.lastOutput = newOutput;
return {
content: [
{
type: "text",
text: toolCommandOutput(bgTerm.status, {
...newOutput,
output: strippedOutput,
}),
},
],
};
} else {
return {
content: [
{
type: "text",
text: toolCommandOutput(bgTerm.status, bgTerm.pendingOutput),
},
],
};
}
},
);
server.registerTool(
unqualifiedToolNames.killShell,
{
title: unqualifiedToolNames.killShell,
description: `- Kills a running background bash shell by its ID
- Takes a shell_id parameter identifying the shell to kill
- Returns a success or failure status
- Use this tool when you need to terminate a long-running shell
In sessions with ${toolNames.killShell} always use it instead of KillShell.`,
inputSchema: {
shell_id: z
.string()
.describe(`The id of the background bash command as returned by \`${toolNames.bash}\``),
},
},
async (input) => {
const bgTerm = agent.backgroundTerminals[input.shell_id];
if (!bgTerm) {
throw new Error(`Unknown shell ${input.shell_id}`);
}
switch (bgTerm.status) {
case "started": {
await bgTerm.handle.kill();
const currentOutput = await bgTerm.handle.currentOutput();
agent.backgroundTerminals[bgTerm.handle.id] = {
status: "killed",
pendingOutput: {
...currentOutput,
output: stripCommonPrefix(bgTerm.lastOutput?.output ?? "", currentOutput.output),
},
};
await bgTerm.handle.release();
return {
content: [{ type: "text", text: "Command killed successfully." }],
};
}
case "aborted":
return {
content: [{ type: "text", text: "Command aborted by user." }],
};
case "exited":
return {
content: [{ type: "text", text: "Command had already exited." }],
};
case "killed":
return {
content: [{ type: "text", text: "Command was already killed." }],
};
case "timedOut":
return {
content: [{ type: "text", text: "Command killed by timeout." }],
};
default: {
return unreachable(bgTerm);
}
}
},
);
}
return server;
}
function stripCommonPrefix(a: string, b: string): string {
let i = 0;
while (i < a.length && i < b.length && a[i] === b[i]) {
i++;
}
return b.slice(i);
}
function toolCommandOutput(
status: "started" | "aborted" | "exited" | "killed" | "timedOut",
output: TerminalOutputResponse,
): string {
const { exitStatus, output: commandOutput, truncated } = output;
let toolOutput = "";
switch (status) {
case "started":
case "exited": {
if (exitStatus && (exitStatus.exitCode ?? null) === null) {
toolOutput += `Interrupted by the user. `;
}
break;
}
case "killed":
toolOutput += `Killed. `;
break;
case "timedOut":
toolOutput += `Timed out. `;
break;
case "aborted":
break;
default: {
const unreachable: never = status;
return unreachable;
}
}
if (exitStatus) {
if (typeof exitStatus.exitCode === "number") {
toolOutput += `Exited with code ${exitStatus.exitCode}.`;
}
if (typeof exitStatus.signal === "string") {
toolOutput += `Signal \`${exitStatus.signal}\`. `;
}
toolOutput += "Final output:\n\n";
} else {
toolOutput += "New output:\n\n";
}
toolOutput += commandOutput;
if (truncated) {
toolOutput += `\n\nCommand output was too long, so it was truncated to ${commandOutput.length} bytes.`;
}
return toolOutput;
}
/**
* Replace text in a file and calculate the line numbers where the edits occurred.
*
* @param fileContent - The full file content
* @param edits - Array of edit operations to apply sequentially
* @returns the new content and the line numbers where replacements occurred in the final content
*/
export function replaceAndCalculateLocation(
fileContent: string,
edits: Array<{
oldText: string;
newText: string;
replaceAll?: boolean;
}>,
): { newContent: string; lineNumbers: number[] } {
let currentContent = fileContent;
// Use unique markers to track where replacements happen
const markerPrefix = `__REPLACE_MARKER_${Math.random().toString(36).substr(2, 9)}_`;
let markerCounter = 0;
const markers: string[] = [];
// Apply edits sequentially, inserting markers at replacement positions
for (const edit of edits) {
// Skip empty oldText
if (edit.oldText === "") {
throw new Error(`The provided \`old_string\` is empty.\n\nNo edits were applied.`);
}
if (edit.replaceAll) {
// Replace all occurrences with marker + newText
const parts: string[] = [];
let lastIndex = 0;
let searchIndex = 0;
while (true) {
const index = currentContent.indexOf(edit.oldText, searchIndex);
if (index === -1) {
if (searchIndex === 0) {
throw new Error(
`The provided \`old_string\` does not appear in the file: "${edit.oldText}".\n\nNo edits were applied.`,
);
}
break;
}
// Add content before the match
parts.push(currentContent.substring(lastIndex, index));
// Add marker and replacement
const marker = `${markerPrefix}${markerCounter++}__`;
markers.push(marker);
parts.push(marker + edit.newText);
lastIndex = index + edit.oldText.length;
searchIndex = lastIndex;
}
// Add remaining content
parts.push(currentContent.substring(lastIndex));
currentContent = parts.join("");
} else {
// Replace first occurrence only
const index = currentContent.indexOf(edit.oldText);
if (index === -1) {
throw new Error(
`The provided \`old_string\` does not appear in the file: "${edit.oldText}".\n\nNo edits were applied.`,
);
} else {
const marker = `${markerPrefix}${markerCounter++}__`;
markers.push(marker);
currentContent =
currentContent.substring(0, index) +
marker +
edit.newText +
currentContent.substring(index + edit.oldText.length);
}
}
}
// Find line numbers where markers appear in the content
const lineNumbers: number[] = [];
for (const marker of markers) {
const index = currentContent.indexOf(marker);
if (index !== -1) {
const lineNumber = Math.max(
0,
currentContent.substring(0, index).split(/\r\n|\r|\n/).length - 1,
);
lineNumbers.push(lineNumber);
}
}
// Remove all markers from the final content
let finalContent = currentContent;
for (const marker of markers) {
finalContent = finalContent.replace(marker, "");
}
// Dedupe and sort line numbers
const uniqueLineNumbers = [...new Set(lineNumbers)].sort();
return { newContent: finalContent, lineNumbers: uniqueLineNumbers };
}

269
src/settings.ts Normal file
View File

@@ -0,0 +1,269 @@
import * as fs from "node:fs";
import * as path from "node:path";
import { CLAUDE_CONFIG_DIR } from "./acp-agent.js";
/**
* Permission rule format examples:
* - "Read" - matches all Read tool calls
* - "Read(./.env)" - matches specific path
* - "Read(./.env.*)" - glob pattern
* - "Read(./secrets/**)" - recursive glob
* - "Bash(npm run lint)" - exact command prefix
* - "Bash(npm run:*)" - command prefix with wildcard
*
* Docs: https://code.claude.com/docs/en/iam#tool-specific-permission-rules
*/
export interface ClaudeCodeSettings {
env?: Record<string, string>;
model?: string;
}
/**
* Reads and parses a JSON settings file, returning an empty object if not found or invalid
*/
async function loadSettingsFile(filePath: string | null): Promise<ClaudeCodeSettings> {
if (!filePath) {
return {};
}
try {
const content = await fs.promises.readFile(filePath, "utf-8");
return JSON.parse(content) as ClaudeCodeSettings;
} catch {
return {};
}
}
/**
* Gets the enterprise settings path based on the current platform
*/
export function getManagedSettingsPath(): string {
switch (process.platform) {
case "darwin":
return "/Library/Application Support/ClaudeCode/managed-settings.json";
case "linux":
return "/etc/claude-code/managed-settings.json";
case "win32":
return "C:\\Program Files\\ClaudeCode\\managed-settings.json";
default:
return "/etc/claude-code/managed-settings.json";
}
}
export interface SettingsManagerOptions {
onChange?: () => void;
logger?: { log: (...args: any[]) => void; error: (...args: any[]) => void };
}
/**
* Manages Claude Code settings from multiple sources with proper precedence.
*
* Settings are loaded from (in order of increasing precedence):
* 1. User settings (~/.claude/settings.json)
* 2. Project settings (<cwd>/.claude/settings.json)
* 3. Local project settings (<cwd>/.claude/settings.local.json)
* 4. Enterprise managed settings (platform-specific path)
*
* The manager watches all settings files for changes and automatically reloads.
*/
export class SettingsManager {
private cwd: string;
private userSettings: ClaudeCodeSettings = {};
private projectSettings: ClaudeCodeSettings = {};
private localSettings: ClaudeCodeSettings = {};
private enterpriseSettings: ClaudeCodeSettings = {};
private mergedSettings: ClaudeCodeSettings = {};
private watchers: fs.FSWatcher[] = [];
private onChange?: () => void;
private logger: { log: (...args: any[]) => void; error: (...args: any[]) => void };
private initialized = false;
private debounceTimer: ReturnType<typeof setTimeout> | null = null;
constructor(cwd: string, options?: SettingsManagerOptions) {
this.cwd = cwd;
this.onChange = options?.onChange;
this.logger = options?.logger ?? console;
}
/**
* Initialize the settings manager by loading all settings and setting up file watchers
*/
async initialize(): Promise<void> {
if (this.initialized) {
return;
}
await this.loadAllSettings();
this.setupWatchers();
this.initialized = true;
}
/**
* Returns the path to the user settings file
*/
private getUserSettingsPath(): string {
return path.join(CLAUDE_CONFIG_DIR, "settings.json");
}
/**
* Returns the path to the project settings file
*/
private getProjectSettingsPath(): string {
return path.join(this.cwd, ".claude", "settings.json");
}
/**
* Returns the path to the local project settings file
*/
private getLocalSettingsPath(): string {
return path.join(this.cwd, ".claude", "settings.local.json");
}
/**
* Loads settings from all sources
*/
private async loadAllSettings(): Promise<void> {
const [userSettings, projectSettings, localSettings, enterpriseSettings] = await Promise.all([
loadSettingsFile(this.getUserSettingsPath()),
loadSettingsFile(this.getProjectSettingsPath()),
loadSettingsFile(this.getLocalSettingsPath()),
loadSettingsFile(getManagedSettingsPath()),
]);
this.userSettings = userSettings;
this.projectSettings = projectSettings;
this.localSettings = localSettings;
this.enterpriseSettings = enterpriseSettings;
this.mergeSettings();
}
/**
* Merges all settings sources with proper precedence.
* For permissions, rules from all sources are combined.
* Deny rules always take precedence during permission checks.
*/
private mergeSettings(): void {
const allSettings = [
this.userSettings,
this.projectSettings,
this.localSettings,
this.enterpriseSettings,
];
const merged: ClaudeCodeSettings = {};
for (const settings of allSettings) {
if (settings.env) {
merged.env = { ...merged.env, ...settings.env };
}
if (settings.model) {
merged.model = settings.model;
}
}
this.mergedSettings = merged;
}
/**
* Sets up file watchers for all settings files
*/
private setupWatchers(): void {
const paths = [
this.getUserSettingsPath(),
this.getProjectSettingsPath(),
this.getLocalSettingsPath(),
getManagedSettingsPath(),
];
for (const filePath of paths) {
if (!filePath) continue;
try {
const dir = path.dirname(filePath);
const filename = path.basename(filePath);
if (fs.existsSync(dir)) {
const watcher = fs.watch(dir, (eventType, changedFilename) => {
if (changedFilename === filename) {
this.handleSettingsChange();
}
});
watcher.on("error", (error) => {
this.logger.error(`Settings watcher error for ${filePath}:`, error);
});
this.watchers.push(watcher);
}
} catch (error) {
this.logger.error(`Failed to set up watcher for ${filePath}:`, error);
}
}
}
/**
* Handles settings file changes with debouncing to avoid rapid reloads
*/
private handleSettingsChange(): void {
if (this.debounceTimer) {
clearTimeout(this.debounceTimer);
}
this.debounceTimer = setTimeout(async () => {
this.debounceTimer = null;
try {
await this.loadAllSettings();
this.onChange?.();
} catch (error) {
this.logger.error("Failed to reload settings:", error);
}
}, 100);
}
/**
* Returns the current merged settings
*/
getSettings(): ClaudeCodeSettings {
return this.mergedSettings;
}
/**
* Returns the current working directory
*/
getCwd(): string {
return this.cwd;
}
/**
* Updates the working directory and reloads project-specific settings
*/
async setCwd(cwd: string): Promise<void> {
if (this.cwd === cwd) {
return;
}
this.dispose();
this.cwd = cwd;
this.initialized = false;
await this.initialize();
}
/**
* Disposes of file watchers and cleans up resources
*/
dispose(): void {
if (this.debounceTimer) {
clearTimeout(this.debounceTimer);
this.debounceTimer = null;
}
for (const watcher of this.watchers) {
watcher.close();
}
this.watchers = [];
this.initialized = false;
}
}

7
src/static-entry.ts Normal file
View File

@@ -0,0 +1,7 @@
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");
} else {
await import("./index.js");
}

View File

@@ -1,6 +1,6 @@
---
description: Say hello
argument-hint: [name]
argument-hint: name
---
Respond with "Hello $1" and nothing else.

View File

@@ -2,6 +2,7 @@ import { describe, it, expect, beforeAll, afterAll } from "vitest";
import { spawn, spawnSync } from "child_process";
import {
Agent,
AgentSideConnection,
AvailableCommand,
Client,
ClientSideConnection,
@@ -16,9 +17,22 @@ import {
WriteTextFileResponse,
} from "@agentclientprotocol/sdk";
import { nodeToWebWritable, nodeToWebReadable } from "../utils.js";
import { markdownEscape, toolInfoFromToolUse, toolUpdateFromToolResult } from "../tools.js";
import {
markdownEscape,
toolInfoFromToolUse,
toolUpdateFromToolResult,
toolUpdateFromEditToolResponse,
} from "../tools.js";
import { toAcpNotifications, promptToClaude } from "../acp-agent.js";
import { query, SDKAssistantMessage } from "@anthropic-ai/claude-agent-sdk";
import { randomUUID } from "crypto";
import type {
BetaToolResultBlockParam,
BetaToolSearchToolResultBlockParam,
BetaWebSearchToolResultBlockParam,
BetaWebFetchToolResultBlockParam,
BetaCodeExecutionToolResultBlockParam,
} from "@anthropic-ai/sdk/resources/beta.mjs";
describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)("ACP subprocess integration", () => {
let child: ReturnType<typeof spawn>;
@@ -164,7 +178,7 @@ describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)("ACP subprocess integration"
expect(commands).toContainEqual({
name: "say-hello",
description: "Say hello (project)",
input: { hint: "[name]" },
input: { hint: "name" },
});
await connection.prompt({
@@ -249,9 +263,9 @@ describe("tool conversions", () => {
},
};
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
kind: "execute",
title: "`rm README.md.rm`",
title: "rm README.md.rm",
content: [
{
content: {
@@ -274,7 +288,7 @@ describe("tool conversions", () => {
},
};
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
kind: "search",
title: "Find `*/**.ts`",
content: [],
@@ -295,7 +309,7 @@ describe("tool conversions", () => {
},
};
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
kind: "think",
title: "Handle user's work request",
content: [
@@ -310,24 +324,6 @@ describe("tool conversions", () => {
});
});
it("should handle LS tool calls", () => {
const tool_use = {
type: "tool_use",
id: "toolu_01EEqsX7Eb9hpx87KAHVPTey",
name: "LS",
input: {
path: "/Users/test/github/claude-code-acp",
},
};
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
kind: "search",
title: "List the `/Users/test/github/claude-code-acp` directory's contents",
content: [],
locations: [],
});
});
it("should handle Grep tool calls", () => {
const tool_use = {
type: "tool_use",
@@ -338,7 +334,7 @@ describe("tool conversions", () => {
},
};
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
kind: "search",
title: 'grep ".*"',
content: [],
@@ -356,7 +352,7 @@ describe("tool conversions", () => {
},
};
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
kind: "edit",
title: "Write /Users/test/project/example.txt",
content: [
@@ -371,18 +367,18 @@ describe("tool conversions", () => {
});
});
it("should handle mcp__acp__Write tool calls", () => {
it("should handle Write tool calls", () => {
const tool_use = {
type: "tool_use",
id: "toolu_01GHI789JKL456",
name: "mcp__acp__Write",
name: "Write",
input: {
file_path: "/Users/test/project/config.json",
content: '{"version": "1.0.0"}',
},
};
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
kind: "edit",
title: "Write /Users/test/project/config.json",
content: [
@@ -397,6 +393,81 @@ describe("tool conversions", () => {
});
});
it("should handle Edit tool calls", () => {
const tool_use = {
type: "tool_use",
id: "toolu_01EDIT123",
name: "Edit",
input: {
file_path: "/Users/test/project/test.txt",
old_string: "old text",
new_string: "new text",
},
};
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
kind: "edit",
title: "Edit /Users/test/project/test.txt",
content: [
{
type: "diff",
path: "/Users/test/project/test.txt",
oldText: "old text",
newText: "new text",
},
],
locations: [{ path: "/Users/test/project/test.txt" }],
});
});
it("should handle Edit tool calls with replace_all", () => {
const tool_use = {
type: "tool_use",
id: "toolu_01EDIT456",
name: "Edit",
input: {
replace_all: false,
file_path: "/Users/benbrandt/github/codex-acp/src/thread.rs",
old_string:
"struct PromptState {\n active_command: Option<ActiveCommand>,\n active_web_search: Option<String>,\n}",
new_string:
"struct PromptState {\n active_commands: HashMap<String, ActiveCommand>,\n active_web_search: Option<String>,\n}",
},
};
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
kind: "edit",
title: "Edit /Users/benbrandt/github/codex-acp/src/thread.rs",
content: [
{
type: "diff",
path: "/Users/benbrandt/github/codex-acp/src/thread.rs",
oldText:
"struct PromptState {\n active_command: Option<ActiveCommand>,\n active_web_search: Option<String>,\n}",
newText:
"struct PromptState {\n active_commands: HashMap<String, ActiveCommand>,\n active_web_search: Option<String>,\n}",
},
],
locations: [{ path: "/Users/benbrandt/github/codex-acp/src/thread.rs" }],
});
});
it("should handle Edit tool calls without file_path", () => {
const tool_use = {
type: "tool_use",
id: "toolu_01EDIT789",
name: "Edit",
input: {},
};
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
kind: "edit",
title: "Edit",
content: [],
locations: [],
});
});
it("should handle Read tool calls", () => {
const tool_use = {
type: "tool_use",
@@ -407,56 +478,56 @@ describe("tool conversions", () => {
},
};
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
kind: "read",
title: "Read File",
title: "Read /Users/test/project/readme.md",
content: [],
locations: [{ path: "/Users/test/project/readme.md", line: 0 }],
locations: [{ path: "/Users/test/project/readme.md", line: 1 }],
});
});
it("should handle mcp__acp__Read tool calls", () => {
it("should handle Read tool calls", () => {
const tool_use = {
type: "tool_use",
id: "toolu_01YZA789BCD123",
name: "mcp__acp__Read",
name: "Read",
input: {
file_path: "/Users/test/project/data.json",
},
};
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
kind: "read",
title: "Read /Users/test/project/data.json",
content: [],
locations: [{ path: "/Users/test/project/data.json", line: 0 }],
locations: [{ path: "/Users/test/project/data.json", line: 1 }],
});
});
it("should handle mcp__acp__Read with limit", () => {
it("should handle Read with limit", () => {
const tool_use = {
type: "tool_use",
id: "toolu_01EFG456HIJ789",
name: "mcp__acp__Read",
name: "Read",
input: {
file_path: "/Users/test/project/large.txt",
limit: 100,
},
};
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
kind: "read",
title: "Read /Users/test/project/large.txt (1 - 100)",
content: [],
locations: [{ path: "/Users/test/project/large.txt", line: 0 }],
locations: [{ path: "/Users/test/project/large.txt", line: 1 }],
});
});
it("should handle mcp__acp__Read with offset and limit", () => {
it("should handle Read with offset and limit", () => {
const tool_use = {
type: "tool_use",
id: "toolu_01KLM789NOP456",
name: "mcp__acp__Read",
name: "Read",
input: {
file_path: "/Users/test/project/large.txt",
offset: 50,
@@ -464,111 +535,33 @@ describe("tool conversions", () => {
},
};
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
kind: "read",
title: "Read /Users/test/project/large.txt (51 - 150)",
title: "Read /Users/test/project/large.txt (50 - 149)",
content: [],
locations: [{ path: "/Users/test/project/large.txt", line: 50 }],
});
});
it("should handle mcp__acp__Read with only offset", () => {
it("should handle Read with only offset", () => {
const tool_use = {
type: "tool_use",
id: "toolu_01QRS123TUV789",
name: "mcp__acp__Read",
name: "Read",
input: {
file_path: "/Users/test/project/large.txt",
offset: 200,
},
};
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
kind: "read",
title: "Read /Users/test/project/large.txt (from line 201)",
title: "Read /Users/test/project/large.txt (from line 200)",
content: [],
locations: [{ path: "/Users/test/project/large.txt", line: 200 }],
});
});
it("should handle WebFetch tool calls", () => {
const tool_use = {
type: "tool_use",
id: "toolu_01LxEjDn8ci9SAc3qG7LbbXV",
name: "WebFetch",
input: {
url: "https://agentclientprotocol.com",
prompt:
"Please provide a comprehensive summary of the content on this page, including what the Agent Client Protocol is, its main features, documentation links, and any other relevant information.",
},
};
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
kind: "fetch",
title: "Fetch https://agentclientprotocol.com",
content: [
{
content: {
text: "Please provide a comprehensive summary of the content on this page, including what the Agent Client Protocol is, its main features, documentation links, and any other relevant information.",
type: "text",
},
type: "content",
},
],
});
});
it("should handle WebSearch tool calls", () => {
const tool_use = {
type: "tool_use",
id: "toolu_01NYMwiZFbdoQFxYxuQDFZXQ",
name: "WebSearch",
input: {
query: "agentclientprotocol.com",
},
};
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
kind: "fetch",
title: '"agentclientprotocol.com"',
content: [],
});
});
it("should handle KillBash entries", () => {
const tool_use = {
type: "tool_use",
id: "toolu_01PhLms5fuvmdjy2bb6dfUKT",
name: "KillShell",
input: {
shell_id: "bash_1",
},
};
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
kind: "execute",
title: `Kill Process`,
content: [],
});
});
it("should handle BashOutput entries", () => {
const tool_use = {
type: "tool_use",
id: "toolu_01SJUWPtj1QspgANgtpqGPuN",
name: "BashOutput",
input: {
bash_id: "bash_1",
},
};
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
kind: "execute",
title: `Tail Logs`,
content: [],
});
});
it("should handle plan entries", () => {
const received: SDKAssistantMessage = {
type: "assistant",
@@ -642,6 +635,9 @@ describe("tool conversions", () => {
output_tokens: 1,
service_tier: "standard",
server_tool_use: null,
inference_geo: null,
iterations: null,
speed: null,
},
context_management: null,
},
@@ -650,7 +646,14 @@ describe("tool conversions", () => {
uuid: "b7c3330c-de8f-4bba-ac53-68c7f76ffeb5",
};
expect(
toAcpNotifications(received.message.content, received.message.role, "test", {}, {}),
toAcpNotifications(
received.message.content,
received.message.role,
"test",
{},
{} as AgentSideConnection,
console,
),
).toStrictEqual([
{
sessionId: "test",
@@ -707,7 +710,7 @@ describe("tool conversions", () => {
const toolUse = {
type: "tool_use",
id: "toolu_01MNO345",
name: "mcp__acp__Edit",
name: "Edit",
input: {
file_path: "/Users/test/project/test.txt",
old_string: "old",
@@ -737,7 +740,7 @@ describe("tool conversions", () => {
const toolUse = {
type: "tool_use",
id: "toolu_01MNO345",
name: "mcp__acp__Edit",
name: "Edit",
input: {
file_path: "/Users/test/project/test.txt",
old_string: "old",
@@ -769,6 +772,320 @@ describe("tool conversions", () => {
],
});
});
it("should transform tool_reference content to valid ACP content", () => {
const toolUse = {
type: "tool_use",
id: "toolu_01MNO345",
name: "ToolSearch",
input: { query: "test" },
};
const toolResult: BetaToolResultBlockParam = {
content: [
{
type: "tool_reference",
tool_name: "some_discovered_tool",
},
],
tool_use_id: "toolu_01MNO345",
is_error: false,
type: "tool_result",
};
const update = toolUpdateFromToolResult(toolResult, toolUse);
expect(update).toEqual({
content: [
{
type: "content",
content: { type: "text", text: "Tool: some_discovered_tool" },
},
],
});
});
it("should transform web_search_result content to valid ACP content", () => {
const toolUse = {
type: "tool_use",
id: "toolu_01MNO345",
name: "WebSearch",
input: { query: "test" },
};
const toolResult: BetaWebSearchToolResultBlockParam = {
content: [
{
type: "web_search_result",
title: "Test Result",
url: "https://example.com",
encrypted_content: "...",
page_age: null,
},
],
tool_use_id: "toolu_01MNO345",
type: "web_search_tool_result",
};
const update = toolUpdateFromToolResult(toolResult, toolUse);
expect(update).toEqual({
content: [
{
type: "content",
content: { type: "text", text: "Test Result (https://example.com)" },
},
],
});
});
it("should transform web_search_tool_result_error to valid ACP content", () => {
const toolUse = {
type: "tool_use",
id: "toolu_01MNO345",
name: "WebSearch",
input: { query: "test" },
};
const toolResult: BetaWebSearchToolResultBlockParam = {
content: {
type: "web_search_tool_result_error",
error_code: "unavailable",
},
tool_use_id: "toolu_01MNO345",
type: "web_search_tool_result",
};
const update = toolUpdateFromToolResult(toolResult, toolUse);
expect(update).toEqual({
content: [
{
type: "content",
content: { type: "text", text: "Error: unavailable" },
},
],
});
});
it("should transform code_execution_result content to valid ACP content", () => {
const toolUse = {
type: "tool_use",
id: "toolu_01MNO345",
name: "CodeExecution",
input: {},
};
const toolResult: BetaCodeExecutionToolResultBlockParam = {
content: {
type: "code_execution_result",
stdout: "Hello World",
stderr: "",
return_code: 0,
content: [],
},
tool_use_id: "toolu_01MNO345",
type: "code_execution_tool_result",
};
const update = toolUpdateFromToolResult(toolResult, toolUse);
expect(update).toEqual({
content: [
{
type: "content",
content: { type: "text", text: "Output: Hello World" },
},
],
});
});
it("should transform web_fetch_result content to valid ACP content", () => {
const toolUse = {
type: "tool_use",
id: "toolu_01MNO345",
name: "WebFetch",
input: { url: "https://example.com" },
};
const toolResult: BetaWebFetchToolResultBlockParam = {
content: {
type: "web_fetch_result",
url: "https://example.com",
content: {
type: "document",
citations: null,
title: null,
source: { type: "text", media_type: "text/plain", data: "Page content here" },
},
},
tool_use_id: "toolu_01MNO345",
type: "web_fetch_tool_result",
};
const update = toolUpdateFromToolResult(toolResult, toolUse);
expect(update).toEqual({
content: [
{
type: "content",
content: { type: "text", text: "Fetched: https://example.com" },
},
],
});
});
it("should transform tool_search_tool_search_result to valid ACP content", () => {
const toolUse = {
type: "tool_use",
id: "toolu_01MNO345",
name: "ToolSearch",
input: { query: "test" },
};
const toolResult: BetaToolSearchToolResultBlockParam = {
content: {
type: "tool_search_tool_search_result",
tool_references: [
{ type: "tool_reference", tool_name: "tool_a" },
{ type: "tool_reference", tool_name: "tool_b" },
],
},
tool_use_id: "toolu_01MNO345",
type: "tool_search_tool_result",
};
const update = toolUpdateFromToolResult(toolResult, toolUse);
expect(update).toEqual({
content: [
{
type: "content",
content: { type: "text", text: "Tools found: tool_a, tool_b" },
},
],
});
});
});
describe("toolUpdateFromEditToolResponse", () => {
it("should return empty for non-object input", () => {
expect(toolUpdateFromEditToolResponse(null)).toEqual({});
expect(toolUpdateFromEditToolResponse(undefined)).toEqual({});
expect(toolUpdateFromEditToolResponse("string")).toEqual({});
});
it("should return empty when filePath or structuredPatch is missing", () => {
expect(toolUpdateFromEditToolResponse({})).toEqual({});
expect(toolUpdateFromEditToolResponse({ filePath: "/foo.ts" })).toEqual({});
expect(toolUpdateFromEditToolResponse({ structuredPatch: [] })).toEqual({});
});
it("should build diff content from a single-hunk structuredPatch", () => {
const toolResponse = {
filePath: "/Users/test/project/test.txt",
structuredPatch: [
{
oldStart: 1,
oldLines: 3,
newStart: 1,
newLines: 3,
lines: [" context before", "-old line", "+new line", " context after"],
},
],
};
expect(toolUpdateFromEditToolResponse(toolResponse)).toEqual({
content: [
{
type: "diff",
path: "/Users/test/project/test.txt",
oldText: "context before\nold line\ncontext after",
newText: "context before\nnew line\ncontext after",
},
],
locations: [{ path: "/Users/test/project/test.txt", line: 1 }],
});
});
it("should build multiple diff content blocks for replaceAll with multiple hunks", () => {
const toolResponse = {
filePath: "/Users/test/project/file.ts",
structuredPatch: [
{
oldStart: 5,
oldLines: 1,
newStart: 5,
newLines: 1,
lines: ["-oldValue", "+newValue"],
},
{
oldStart: 20,
oldLines: 1,
newStart: 20,
newLines: 1,
lines: ["-oldValue", "+newValue"],
},
],
};
expect(toolUpdateFromEditToolResponse(toolResponse)).toEqual({
content: [
{
type: "diff",
path: "/Users/test/project/file.ts",
oldText: "oldValue",
newText: "newValue",
},
{
type: "diff",
path: "/Users/test/project/file.ts",
oldText: "oldValue",
newText: "newValue",
},
],
locations: [
{ path: "/Users/test/project/file.ts", line: 5 },
{ path: "/Users/test/project/file.ts", line: 20 },
],
});
});
it("should handle deletion (newText becomes empty string)", () => {
const toolResponse = {
filePath: "/Users/test/project/file.ts",
structuredPatch: [
{
oldStart: 10,
oldLines: 2,
newStart: 10,
newLines: 1,
lines: [" context", "-removed line"],
},
],
};
expect(toolUpdateFromEditToolResponse(toolResponse)).toEqual({
content: [
{
type: "diff",
path: "/Users/test/project/file.ts",
oldText: "context\nremoved line",
newText: "context",
},
],
locations: [{ path: "/Users/test/project/file.ts", line: 10 }],
});
});
it("should return empty for empty structuredPatch array", () => {
const toolResponse = {
filePath: "/Users/test/project/file.ts",
structuredPatch: [],
};
expect(toolUpdateFromEditToolResponse(toolResponse)).toEqual({});
});
});
describe("escape markdown", () => {
@@ -821,15 +1138,93 @@ describe("prompt conversion", () => {
});
});
describe("SDK behavior", () => {
it.skipIf(!process.env.RUN_INTEGRATION_TESTS)(
"query has a 'default' model",
async () => {
const q = query({ prompt: "hi" });
const models = await q.supportedModels();
const defaultModel = models.find((m) => m.value === "default");
expect(defaultModel).toBeDefined();
},
10000,
);
describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)("SDK behavior", () => {
it("query has a 'default' model", async () => {
const q = query({ prompt: "hi" });
const models = await q.supportedModels();
const defaultModel = models.find((m) => m.value === "default");
expect(defaultModel).toBeDefined();
}, 10000);
it("custom session id", async () => {
const sessionId = randomUUID();
const q = query({
prompt: "hi",
options: {
systemPrompt: { type: "preset", preset: "claude_code" },
sessionId,
settingSources: ["user", "project", "local"],
includePartialMessages: true,
},
});
const { value } = await q.next();
expect(value).toMatchObject({ type: "system", subtype: "init", session_id: sessionId });
}, 10000);
});
describe("permission requests", () => {
it("should include title field in tool permission request structure", () => {
// Test various tool types to ensure title is correctly generated
const testCases = [
{
toolUse: {
type: "tool_use" as const,
id: "test-1",
name: "Write",
input: { file_path: "/test/file.txt", content: "test" },
},
expectedTitlePart: "/test/file.txt",
},
{
toolUse: {
type: "tool_use" as const,
id: "test-2",
name: "Bash",
input: { command: "ls -la", description: "List files" },
},
expectedTitlePart: "ls -la",
},
{
toolUse: {
type: "tool_use" as const,
id: "test-3",
name: "Read",
input: { file_path: "/test/data.json" },
},
expectedTitlePart: "/test/data.json",
},
];
for (const testCase of testCases) {
// Get the tool info that would be used in requestPermission
const toolInfo = toolInfoFromToolUse(testCase.toolUse);
// Verify toolInfo has a title
expect(toolInfo.title).toBeDefined();
expect(toolInfo.title).toContain(testCase.expectedTitlePart);
// Verify the structure that our fix creates for requestPermission
// We now spread the full toolInfo (title, kind, content, locations)
const requestStructure = {
toolCall: {
toolCallId: testCase.toolUse.id,
rawInput: testCase.toolUse.input,
...toolInfo,
},
};
// Ensure the title field is present and populated
expect(requestStructure.toolCall.title).toBeDefined();
expect(requestStructure.toolCall.title).toContain(testCase.expectedTitlePart);
// Ensure kind is included so the client can render appropriate UI
expect(requestStructure.toolCall.kind).toBeDefined();
expect(typeof requestStructure.toolCall.kind).toBe("string");
// Ensure content is included so the client always has tool call details
expect(requestStructure.toolCall.content).toBeDefined();
expect(Array.isArray(requestStructure.toolCall.content)).toBe(true);
}
});
});

View File

@@ -0,0 +1,185 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { AgentSideConnection, SessionNotification } from "@agentclientprotocol/sdk";
import type { Options } from "@anthropic-ai/claude-agent-sdk";
import type { ClaudeAcpAgent as ClaudeAcpAgentType } from "../acp-agent.js";
let capturedOptions: Options | undefined;
vi.mock("@anthropic-ai/claude-agent-sdk", async () => {
const actual = await vi.importActual<typeof import("@anthropic-ai/claude-agent-sdk")>(
"@anthropic-ai/claude-agent-sdk",
);
return {
...actual,
query: (args: { prompt: unknown; options: Options }) => {
capturedOptions = args.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 () => {
const actual = await vi.importActual<typeof import("../tools.js")>("../tools.js");
return {
...actual,
registerHookCallback: vi.fn(),
};
});
describe("createSession options merging", () => {
let agent: ClaudeAcpAgentType;
let ClaudeAcpAgent: typeof ClaudeAcpAgentType;
function createMockClient(): AgentSideConnection {
return {
sessionUpdate: async (_notification: SessionNotification) => {},
requestPermission: async () => ({ outcome: { outcome: "cancelled" } }),
readTextFile: async () => ({ content: "" }),
writeTextFile: async () => ({}),
} as unknown as AgentSideConnection;
}
beforeEach(async () => {
capturedOptions = undefined;
vi.resetModules();
const acpAgent = await import("../acp-agent.js");
ClaudeAcpAgent = acpAgent.ClaudeAcpAgent;
agent = new ClaudeAcpAgent(createMockClient());
});
it("merges user-provided disallowedTools with ACP internal list", async () => {
await agent.newSession({
cwd: "/test",
mcpServers: [],
_meta: {
claudeCode: {
options: {
disallowedTools: ["WebSearch", "WebFetch"],
},
},
},
});
// User-provided tools should be present
expect(capturedOptions!.disallowedTools).toContain("WebSearch");
expect(capturedOptions!.disallowedTools).toContain("WebFetch");
// ACP's internal disallowed tool should also be present
expect(capturedOptions!.disallowedTools).toContain("AskUserQuestion");
});
it("works when user provides no disallowedTools", async () => {
await agent.newSession({
cwd: "/test",
mcpServers: [],
});
expect(capturedOptions!.disallowedTools).toContain("AskUserQuestion");
});
it("works when user provides empty disallowedTools", async () => {
await agent.newSession({
cwd: "/test",
mcpServers: [],
_meta: {
claudeCode: {
options: {
disallowedTools: [],
},
},
},
});
expect(capturedOptions!.disallowedTools).toContain("AskUserQuestion");
});
it("includes both user and built-in disallowed tools when disableBuiltInTools is true", async () => {
await agent.newSession({
cwd: "/test",
mcpServers: [],
_meta: {
disableBuiltInTools: true,
claudeCode: {
options: {
disallowedTools: ["CustomTool"],
},
},
},
});
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");
});
it("merges user-provided hooks with ACP hooks", async () => {
const userPreToolUseHook = { hooks: [{ command: "echo pre" }] };
await agent.newSession({
cwd: "/test",
mcpServers: [],
_meta: {
claudeCode: {
options: {
hooks: {
PreToolUse: [userPreToolUseHook],
PostToolUse: [{ hooks: [{ command: "echo user-post" }] }],
},
},
},
},
});
// User's PreToolUse hooks should be preserved
expect(capturedOptions!.hooks?.PreToolUse).toEqual([userPreToolUseHook]);
// PostToolUse should contain both user and ACP hooks
expect(capturedOptions!.hooks?.PostToolUse).toHaveLength(2);
});
it("merges user-provided mcpServers with ACP mcpServers", async () => {
await agent.newSession({
cwd: "/test",
mcpServers: [
{
name: "acp-server",
command: "node",
args: ["acp-server.js"],
env: [],
},
],
_meta: {
claudeCode: {
options: {
mcpServers: {
"user-server": {
type: "stdio",
command: "node",
args: ["server.js"],
},
},
},
},
},
});
// User-provided MCP server should be present
expect(capturedOptions!.mcpServers).toHaveProperty("user-server");
// ACP-provided MCP server should also be present
expect(capturedOptions!.mcpServers).toHaveProperty("acp-server");
});
});

View File

@@ -1,103 +0,0 @@
import { describe, it, expect } from "vitest";
import { extractLinesWithByteLimit } from "../utils.js";
describe("extractLinesWithByteLimit", () => {
const simpleContent = "Line 1\nLine 2\nLine 3\nLine 4\nLine 5";
it("should extract all lines when under byte limit", () => {
const result = extractLinesWithByteLimit(simpleContent, 1000);
expect(result.content).toBe(simpleContent);
expect(result.wasLimited).toBe(false);
expect(result.linesRead).toBe(5);
});
it("should limit output when exceeding byte limit", () => {
// Create content that will exceed byte limit
const longLine = "x".repeat(100);
const manyLines = Array(10).fill(longLine).join("\n");
const result = extractLinesWithByteLimit(manyLines, 250);
expect(result.wasLimited).toBe(true);
expect(result.linesRead).toBe(2);
});
it("should handle empty content", () => {
const result = extractLinesWithByteLimit("", 1000);
expect(result.content).toBe("");
expect(result.wasLimited).toBe(false);
expect(result.linesRead).toBe(1); // We read the one empty line
});
it("should handle single line file", () => {
const singleLine = "This is a single line without newline";
const result = extractLinesWithByteLimit(singleLine, 1000);
expect(result.content).toBe(singleLine);
expect(result.wasLimited).toBe(false);
expect(result.linesRead).toBe(1);
});
it("should correctly count bytes with multi-byte characters", () => {
const unicodeContent = "Hello 世界\n你好 World\nEmoji: 🌍\nNormal line";
const result = extractLinesWithByteLimit(unicodeContent, 1000);
expect(result.content).toBe(unicodeContent);
expect(result.linesRead).toBe(4);
});
it("should stop at byte limit even with one more line available", () => {
// Create lines where adding one more would exceed limit
const line1 = "a".repeat(40);
const line2 = "b".repeat(40);
const line3 = "c".repeat(40);
const content = `${line1}\n${line2}\n${line3}`;
const result = extractLinesWithByteLimit(content, 85);
expect(result.content).toBe(`${line1}\n${line2}\n`);
expect(result.wasLimited).toBe(true);
expect(result.linesRead).toBe(2);
});
it("should read exactly to limit when possible", () => {
const exactContent = "12345\n67890\n12345"; // 17 bytes total
const result = extractLinesWithByteLimit(exactContent, 17);
expect(result.content).toBe(exactContent);
expect(result.wasLimited).toBe(false);
expect(result.linesRead).toBe(3);
});
it("should handle Windows-style line endings", () => {
const windowsContent = "Line 1\r\nLine 2\r\nLine 3";
const result = extractLinesWithByteLimit(windowsContent, 1000);
// Note: split("\n") will keep the \r characters
expect(result.content).toBe("Line 1\r\nLine 2\r\nLine 3");
expect(result.linesRead).toBe(3);
});
it("should handle very large files efficiently", () => {
// Create a 100KB file
const largeLine = "x".repeat(1000);
const largeContent = Array(110).fill(largeLine).join("\n");
const result = extractLinesWithByteLimit(largeContent, 50000);
expect(result.wasLimited).toBe(true);
});
it("should allow at least one line even if it exceeds byte limit", () => {
const veryLongLine = "x".repeat(100000); // 100KB line
const result = extractLinesWithByteLimit(veryLongLine, 50000);
// Should return the line even though it exceeds the byte limit
// because we always allow at least one line if no lines have been added yet
expect(result.content).toBe(veryLongLine);
expect(result.linesRead).toBe(1);
expect(result.wasLimited).toBe(false);
});
});

View File

@@ -1,334 +0,0 @@
import { describe, it, expect } from "vitest";
import { replaceAndCalculateLocation } from "../mcp-server.js";
describe("replaceAndCalculateLocation", () => {
it("should replace first occurrence and return correct line number", () => {
const content = "line 1\nline 2 with text\nline 3 with text\nline 4";
const result = replaceAndCalculateLocation(content, [{ oldText: "text", newText: "replaced" }]);
expect(result.newContent).toBe("line 1\nline 2 with replaced\nline 3 with text\nline 4");
expect(result.lineNumbers).toEqual([1]); // Line 2 (0-based indexing)
});
it("should replace all occurrences when replaceAll is true", () => {
const content = "line 1\nline 2 with text\nline 3 with text\nline 4";
const result = replaceAndCalculateLocation(content, [
{ oldText: "text", newText: "replaced", replaceAll: true },
]);
expect(result.newContent).toBe("line 1\nline 2 with replaced\nline 3 with replaced\nline 4");
expect(result.lineNumbers).toEqual([1, 2]); // Lines 2 and 3 (0-based)
});
it("should return empty line numbers array when no match found", () => {
const content = "line 1\nline 2\nline 3";
expect(() => {
replaceAndCalculateLocation(content, [{ oldText: "notfound", newText: "replaced" }]);
}).toThrow('The provided `old_string` does not appear in the file: "notfound"');
});
it("should handle Windows line endings (CRLF)", () => {
const content = "line 1\r\nline 2 with text\r\nline 3 with text\r\nline 4";
const result = replaceAndCalculateLocation(content, [
{ oldText: "text", newText: "replaced", replaceAll: true },
]);
expect(result.newContent).toBe(
"line 1\r\nline 2 with replaced\r\nline 3 with replaced\r\nline 4",
);
expect(result.lineNumbers).toEqual([1, 2]);
});
it("should handle old Mac line endings (CR)", () => {
const content = "line 1\rline 2 with text\rline 3 with text\rline 4";
const result = replaceAndCalculateLocation(content, [
{ oldText: "text", newText: "replaced", replaceAll: true },
]);
expect(result.newContent).toBe("line 1\rline 2 with replaced\rline 3 with replaced\rline 4");
expect(result.lineNumbers).toEqual([1, 2]);
});
it("should handle mixed line endings", () => {
const content = "line 1\nline 2 with text\r\nline 3 with text\rline 4";
const result = replaceAndCalculateLocation(content, [
{ oldText: "text", newText: "replaced", replaceAll: true },
]);
expect(result.newContent).toBe("line 1\nline 2 with replaced\r\nline 3 with replaced\rline 4");
expect(result.lineNumbers).toEqual([1, 2]);
});
it("should handle text at the beginning of file", () => {
const content = "text at start\nline 2\nline 3";
const result = replaceAndCalculateLocation(content, [{ oldText: "text", newText: "replaced" }]);
expect(result.newContent).toBe("replaced at start\nline 2\nline 3");
expect(result.lineNumbers).toEqual([0]); // First line (0-based)
});
it("should handle text at the end of file", () => {
const content = "line 1\nline 2\nlast line with text";
const result = replaceAndCalculateLocation(content, [{ oldText: "text", newText: "replaced" }]);
expect(result.newContent).toBe("line 1\nline 2\nlast line with replaced");
expect(result.lineNumbers).toEqual([2]); // Last line (0-based)
});
it("should handle multiple occurrences on the same line", () => {
const content = "line 1\ntext text text\nline 3";
const result = replaceAndCalculateLocation(content, [
{ oldText: "text", newText: "replaced", replaceAll: true },
]);
expect(result.newContent).toBe("line 1\nreplaced replaced replaced\nline 3");
expect(result.lineNumbers).toEqual([1]); // All on line 2 (0-based), deduplicated
});
it("should handle empty file content", () => {
expect(() => {
replaceAndCalculateLocation("", [{ oldText: "text", newText: "replaced" }]);
}).toThrowError(
'The provided `old_string` does not appear in the file: "text".\n\nNo edits were applied.',
);
});
it("should handle empty search text", () => {
// Test with replaceAll false (default)
const content1 = "line 1\nline 2\nline 3";
expect(() => {
replaceAndCalculateLocation(content1, [{ oldText: "", newText: "replaced" }]);
}).toThrowError("The provided `old_string` is empty.\n\nNo edits were applied.");
// Test with replaceAll true on single line
const content2 = "abc";
expect(() => {
replaceAndCalculateLocation(content2, [{ oldText: "", newText: "X", replaceAll: true }]);
}).toThrowError("The provided `old_string` is empty.\n\nNo edits were applied.");
// Test with replaceAll true on multiline content
const content3 = "ab\ncd";
expect(() => {
replaceAndCalculateLocation(content3, [{ oldText: "", newText: "X", replaceAll: true }]);
}).toThrowError("The provided `old_string` is empty.\n\nNo edits were applied.");
});
it("should handle single line content", () => {
const content = "single line with text here";
const result = replaceAndCalculateLocation(content, [{ oldText: "text", newText: "replaced" }]);
expect(result.newContent).toBe("single line with replaced here");
expect(result.lineNumbers).toEqual([0]); // Line 1 (0-based)
});
it("should handle special characters in search text", () => {
const content = "line 1\nline with $special.chars*\nline 3";
const result = replaceAndCalculateLocation(content, [
{ oldText: "$special.chars*", newText: "replaced" },
]);
expect(result.newContent).toBe("line 1\nline with replaced\nline 3");
expect(result.lineNumbers).toEqual([1]);
});
it("should handle newlines in search text", () => {
const content = "line 1\nline 2\nline 3";
const result = replaceAndCalculateLocation(content, [
{ oldText: "1\nline", newText: "1\nmodified line" },
]);
expect(result.newContent).toBe("line 1\nmodified line 2\nline 3");
expect(result.lineNumbers).toEqual([0]); // Match starts at line 1 (0-based)
});
it("should handle newlines in replacement text", () => {
const content = "line 1\nline 2\nline 3";
const result = replaceAndCalculateLocation(content, [
{ oldText: "line 2", newText: "line 2a\nline 2b" },
]);
expect(result.newContent).toBe("line 1\nline 2a\nline 2b\nline 3");
expect(result.lineNumbers).toEqual([1]);
});
it("should handle very long lines", () => {
const longLine = "a".repeat(10000) + "text" + "b".repeat(10000);
const content = `line 1\n${longLine}\nline 3`;
const result = replaceAndCalculateLocation(content, [{ oldText: "text", newText: "replaced" }]);
expect(result.newContent).toBe(
`line 1\n${"a".repeat(10000)}replaced${"b".repeat(10000)}\nline 3`,
);
expect(result.lineNumbers).toEqual([1]);
});
it("should handle many lines", () => {
const lines = Array.from({ length: 1000 }, (_, i) => `line ${i}`);
lines[500] = "line with text";
const content = lines.join("\n");
const result = replaceAndCalculateLocation(content, [{ oldText: "text", newText: "replaced" }]);
expect(result.lineNumbers).toEqual([500]);
expect(result.newContent).includes("line with replaced");
});
it("should handle overlapping matches correctly", () => {
const content = "aaaa\nbbbb\ncccc";
const result = replaceAndCalculateLocation(content, [
{ oldText: "aa", newText: "xx", replaceAll: true },
]);
// Should replace non-overlapping occurrences
expect(result.newContent).toBe("xxxx\nbbbb\ncccc");
expect(result.lineNumbers).toEqual([0]); // Two matches on first line, deduplicated
});
it("should preserve content after replacement with different lengths", () => {
const content = "short\nmedium line\nlong line here";
// Replacing with longer text
const result1 = replaceAndCalculateLocation(content, [
{ oldText: "short", newText: "very very long text" },
]);
expect(result1.newContent).toBe("very very long text\nmedium line\nlong line here");
expect(result1.lineNumbers).toEqual([0]);
// Replacing with shorter text
const result2 = replaceAndCalculateLocation(content, [
{ oldText: "medium line", newText: "m" },
]);
expect(result2.newContent).toBe("short\nm\nlong line here");
expect(result2.lineNumbers).toEqual([1]);
});
it("should handle consecutive replacements", () => {
const content = "text1 text2 text3";
const result = replaceAndCalculateLocation(content, [
{ oldText: "text", newText: "replaced", replaceAll: true },
]);
expect(result.newContent).toBe("replaced1 replaced2 replaced3");
expect(result.lineNumbers).toEqual([0]); // All on first line, deduplicated
});
it("should handle case-sensitive replacements", () => {
const content = "Text TEXT text";
const result = replaceAndCalculateLocation(content, [
{ oldText: "text", newText: "replaced", replaceAll: true },
]);
// Should only replace exact matches
expect(result.newContent).toBe("Text TEXT replaced");
expect(result.lineNumbers).toEqual([0]); // Only lowercase "text" matched
});
it("should handle replacement at exact end of line", () => {
const content = "line ends with text\nnext line";
const result = replaceAndCalculateLocation(content, [{ oldText: "text", newText: "replaced" }]);
expect(result.newContent).toBe("line ends with replaced\nnext line");
expect(result.lineNumbers).toEqual([0]);
});
it("should handle replacement spanning multiple lines", () => {
const content = "line 1\nspan\nacross\nlines\nline 5";
const result = replaceAndCalculateLocation(content, [
{ oldText: "span\nacross\nlines", newText: "single" },
]);
expect(result.newContent).toBe("line 1\nsingle\nline 5");
expect(result.lineNumbers).toEqual([1]); // Match starts at line 2 (0-based)
});
it("should default replaceAll to false", () => {
const content = "match match match";
// Without passing replaceAll parameter
const result = replaceAndCalculateLocation(content, [
{ oldText: "match", newText: "replaced" },
]);
expect(result.newContent).toBe("replaced match match");
expect(result.lineNumbers).toEqual([0]); // Only first match
});
it("should handle multiple edits with correct line numbers", () => {
const content = "line 1\nfoo bar\nline 3\nfoo baz\nline 5\nbar foo\nline 7";
const result = replaceAndCalculateLocation(content, [
{ oldText: "foo", newText: "FOO", replaceAll: true },
{ oldText: "bar", newText: "BAR", replaceAll: false },
{ oldText: "line 3", newText: "LINE THREE" },
]);
// Should replace:
// - All "foo" occurrences (lines 1, 3, 5 in original)
// - First "bar" occurrence (line 1 after first edit)
// - "line 3" (line 2 after previous edits)
expect(result.newContent).toBe("line 1\nFOO BAR\nLINE THREE\nFOO baz\nline 5\nbar FOO\nline 7");
// Line numbers reflect position in final content, deduplicated and sorted
expect(result.lineNumbers).toEqual([
1, // FOO and BAR on line 1
2, // LINE THREE on line 2
3, // FOO on line 3
5, // FOO on line 5
]);
});
it("should handle multiple edits with overlapping text", () => {
const content = "hello world\nhello hello\nworld hello world";
const result = replaceAndCalculateLocation(content, [
{ oldText: "hello", newText: "hi", replaceAll: false },
{ oldText: "world", newText: "earth", replaceAll: true },
{ oldText: "hello", newText: "greetings", replaceAll: true },
]);
// First edit replaces first "hello" only
// Second edit replaces all "world" in the modified content
// Third edit replaces remaining "hello"s in the modified content
expect(result.newContent).toBe("hi earth\ngreetings greetings\nearth greetings earth");
// Line numbers reflect position in final content, deduplicated and sorted
expect(result.lineNumbers).toEqual([
0, // hi and earth on line 0
1, // greetings (multiple) on line 1
2, // earth and greetings on line 2
]);
});
it("should handle complex scenarios with marker-like text", () => {
// Test that our marker approach doesn't get confused by similar text
const content = "line 1\n__MARKER__text\nline 3\n__REPLACE_MARKER_abc__\nline 5";
const result = replaceAndCalculateLocation(content, [
{ oldText: "__MARKER__", newText: "REPLACED", replaceAll: true },
{ oldText: "text", newText: "content" },
{ oldText: "__REPLACE_MARKER_abc__", newText: "DONE" },
]);
expect(result.newContent).toBe("line 1\nREPLACEDcontent\nline 3\nDONE\nline 5");
// Line numbers should be correct even with marker-like text
expect(result.lineNumbers).toEqual([
1, // REPLACED on line 1
3, // DONE on line 3
]);
});
it("should handle edits where newText matches subsequent oldText", () => {
const content = "foo\nbar\nbaz";
const result = replaceAndCalculateLocation(content, [
{ oldText: "foo", newText: "bar" },
{ oldText: "bar", newText: "baz", replaceAll: true },
{ oldText: "baz", newText: "qux", replaceAll: true },
]);
// First edit: foo -> bar
// Second edit: both original bar and new bar -> baz
// Third edit: all baz instances -> qux
expect(result.newContent).toBe("qux\nqux\nqux");
// All replacements end up on their respective lines
expect(result.lineNumbers).toEqual([0, 1, 2]);
});
});

View File

@@ -0,0 +1,418 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { AgentSideConnection, SessionNotification } from "@agentclientprotocol/sdk";
import type { ClaudeAcpAgent as ClaudeAcpAgentType } from "../acp-agent.js";
const { registerHookCallbackSpy } = vi.hoisted(() => ({
registerHookCallbackSpy: vi.fn(),
}));
vi.mock("../tools.js", async () => {
const actual = await vi.importActual<typeof import("../tools.js")>("../tools.js");
return {
...actual,
registerHookCallback: registerHookCallbackSpy,
};
});
const SESSION_ID = "test-session-id";
const MOCK_MODES = {
currentModeId: "default",
availableModes: [
{ id: "default", name: "Default", description: "Standard behavior" },
{ id: "plan", name: "Plan Mode", description: "Planning mode" },
{ id: "acceptEdits", name: "Accept Edits", description: "Auto-accept edits" },
],
};
const MOCK_MODELS = {
currentModelId: "claude-opus-4-5",
availableModels: [
{ modelId: "claude-opus-4-5", name: "Claude Opus", description: "Most capable" },
{ modelId: "claude-sonnet-4-5", name: "Claude Sonnet", description: "Balanced" },
],
};
const MOCK_CONFIG_OPTIONS = [
{
id: "mode",
name: "Mode",
type: "select",
category: "mode",
currentValue: "default",
options: MOCK_MODES.availableModes.map((m) => ({
value: m.id,
name: m.name,
description: m.description,
})),
},
{
id: "model",
name: "Model",
type: "select",
category: "model",
currentValue: "claude-opus-4-5",
options: MOCK_MODELS.availableModels.map((m) => ({
value: m.modelId,
name: m.name,
description: m.description,
})),
},
];
describe("session config options", () => {
let agent: ClaudeAcpAgentType;
let ClaudeAcpAgent: typeof ClaudeAcpAgentType;
let sessionUpdates: SessionNotification[];
let createSessionSpy: ReturnType<typeof vi.fn>;
let setPermissionModeSpy: ReturnType<typeof vi.fn>;
let setModelSpy: ReturnType<typeof vi.fn>;
function createMockClient(): AgentSideConnection {
return {
sessionUpdate: async (notification: SessionNotification) => {
sessionUpdates.push(notification);
},
requestPermission: async () => ({ outcome: { outcome: "cancelled" } }),
readTextFile: async () => ({ content: "" }),
writeTextFile: async () => ({}),
} as unknown as AgentSideConnection;
}
function populateSession() {
setPermissionModeSpy = vi.fn();
setModelSpy = vi.fn();
(agent as unknown as { sessions: Record<string, unknown> }).sessions[SESSION_ID] = {
query: {
setPermissionMode: setPermissionModeSpy,
setModel: setModelSpy,
supportedCommands: async () => [],
},
input: null,
cancelled: false,
permissionMode: "default",
settingsManager: {},
configOptions: structuredClone(MOCK_CONFIG_OPTIONS),
};
}
beforeEach(async () => {
sessionUpdates = [];
registerHookCallbackSpy.mockClear();
vi.resetModules();
const acpAgent = await import("../acp-agent.js");
ClaudeAcpAgent = acpAgent.ClaudeAcpAgent;
agent = new ClaudeAcpAgent(createMockClient());
createSessionSpy = vi.fn(async () => ({
sessionId: SESSION_ID,
modes: MOCK_MODES,
models: MOCK_MODELS,
configOptions: MOCK_CONFIG_OPTIONS,
}));
(agent as unknown as { createSession: typeof createSessionSpy }).createSession =
createSessionSpy;
});
describe("newSession returns configOptions", () => {
it("includes configOptions in the response", async () => {
const response = await agent.newSession({ cwd: "/test", mcpServers: [] });
expect(response.configOptions).toBeDefined();
expect(response.configOptions).toEqual(MOCK_CONFIG_OPTIONS);
});
it("includes mode and model config options", async () => {
const response = await agent.newSession({ cwd: "/test", mcpServers: [] });
const modeOption = response.configOptions?.find((o) => o.id === "mode");
const modelOption = response.configOptions?.find((o) => o.id === "model");
expect(modeOption).toBeDefined();
expect(modelOption).toBeDefined();
});
});
describe("loadSession returns configOptions", () => {
it("includes configOptions from createSession", async () => {
// loadSession calls findSessionFile first - override the whole method
const loadSessionSpy = vi.fn(async () => ({
modes: MOCK_MODES,
models: MOCK_MODELS,
configOptions: MOCK_CONFIG_OPTIONS,
}));
(agent as unknown as { loadSession: typeof loadSessionSpy }).loadSession = loadSessionSpy;
const response = await agent.loadSession({
cwd: "/test",
sessionId: SESSION_ID,
mcpServers: [],
});
expect(response.configOptions).toEqual(MOCK_CONFIG_OPTIONS);
});
});
describe("setSessionConfigOption", () => {
beforeEach(() => {
populateSession();
});
it("throws when session not found", async () => {
await expect(
agent.setSessionConfigOption({
sessionId: "nonexistent",
configId: "mode",
value: "plan",
}),
).rejects.toThrow("Session not found");
});
it("throws when config option not found", async () => {
await expect(
agent.setSessionConfigOption({
sessionId: SESSION_ID,
configId: "unknown-option",
value: "some-value",
}),
).rejects.toThrow("Unknown config option: unknown-option");
});
it("throws when value is not valid for the option", async () => {
await expect(
agent.setSessionConfigOption({
sessionId: SESSION_ID,
configId: "mode",
value: "invalid-mode",
}),
).rejects.toThrow("Invalid value for config option mode: invalid-mode");
});
it("changes mode, sends current_mode_update but not config_option_update", async () => {
await agent.setSessionConfigOption({
sessionId: SESSION_ID,
configId: "mode",
value: "plan",
});
expect(setPermissionModeSpy).toHaveBeenCalledWith("plan");
const modeUpdate = sessionUpdates.find(
(n) => n.update.sessionUpdate === "current_mode_update",
);
expect(modeUpdate?.update).toMatchObject({
sessionUpdate: "current_mode_update",
currentModeId: "plan",
});
const configUpdate = sessionUpdates.find(
(n) => n.update.sessionUpdate === "config_option_update",
);
expect(configUpdate).toBeUndefined();
});
it("changes model and does not send a config_option_update notification", async () => {
await agent.setSessionConfigOption({
sessionId: SESSION_ID,
configId: "model",
value: "claude-sonnet-4-5",
});
expect(setModelSpy).toHaveBeenCalledWith("claude-sonnet-4-5");
const configUpdate = sessionUpdates.find(
(n) => n.update.sessionUpdate === "config_option_update",
);
expect(configUpdate).toBeUndefined();
});
it("returns full configOptions in the response", async () => {
const response = await agent.setSessionConfigOption({
sessionId: SESSION_ID,
configId: "mode",
value: "plan",
});
expect(response.configOptions).toHaveLength(MOCK_CONFIG_OPTIONS.length);
const modeOption = response.configOptions.find((o) => o.id === "mode");
expect(modeOption?.currentValue).toBe("plan");
});
it("other options are unchanged when one is updated", async () => {
const response = await agent.setSessionConfigOption({
sessionId: SESSION_ID,
configId: "mode",
value: "plan",
});
const modelOption = response.configOptions.find((o) => o.id === "model");
expect(modelOption?.currentValue).toBe("claude-opus-4-5");
});
});
describe("setSessionMode sends config_option_update", () => {
beforeEach(() => {
populateSession();
});
it("sends config_option_update when mode is changed via setSessionMode", async () => {
await agent.setSessionMode({ sessionId: SESSION_ID, modeId: "acceptEdits" });
const configUpdate = sessionUpdates.find(
(n) => n.update.sessionUpdate === "config_option_update",
);
expect(configUpdate).toBeDefined();
expect(configUpdate?.update).toMatchObject({
sessionUpdate: "config_option_update",
configOptions: expect.arrayContaining([
expect.objectContaining({ id: "mode", currentValue: "acceptEdits" }),
]),
});
});
it("updates stored configOptions currentValue when mode changes", async () => {
await agent.setSessionMode({ sessionId: SESSION_ID, modeId: "plan" });
const session = (
agent as unknown as {
sessions: Record<string, { configOptions: typeof MOCK_CONFIG_OPTIONS }>;
}
).sessions[SESSION_ID];
const modeOption = session.configOptions.find((o) => o.id === "mode");
expect(modeOption?.currentValue).toBe("plan");
});
it("does not send config_option_update for an invalid mode", async () => {
await expect(
agent.setSessionMode({ sessionId: SESSION_ID, modeId: "not-a-mode" as any }),
).rejects.toThrow("Invalid Mode");
const configUpdate = sessionUpdates.find(
(n) => n.update.sessionUpdate === "config_option_update",
);
expect(configUpdate).toBeUndefined();
});
});
describe("unstable_setSessionModel sends config_option_update", () => {
beforeEach(() => {
populateSession();
});
it("sends config_option_update when model is changed via setSessionModel", async () => {
await agent.unstable_setSessionModel({
sessionId: SESSION_ID,
modelId: "claude-sonnet-4-5",
});
const configUpdate = sessionUpdates.find(
(n) => n.update.sessionUpdate === "config_option_update",
);
expect(configUpdate).toBeDefined();
expect(configUpdate?.update).toMatchObject({
sessionUpdate: "config_option_update",
configOptions: expect.arrayContaining([
expect.objectContaining({ id: "model", currentValue: "claude-sonnet-4-5" }),
]),
});
});
it("updates stored configOptions currentValue when model changes", async () => {
await agent.unstable_setSessionModel({
sessionId: SESSION_ID,
modelId: "claude-sonnet-4-5",
});
const session = (
agent as unknown as {
sessions: Record<string, { configOptions: typeof MOCK_CONFIG_OPTIONS }>;
}
).sessions[SESSION_ID];
const modelOption = session.configOptions.find((o) => o.id === "model");
expect(modelOption?.currentValue).toBe("claude-sonnet-4-5");
});
});
describe("no config_option_update notification when using setSessionConfigOption", () => {
beforeEach(() => {
populateSession();
});
it("sends no config_option_update when setting mode via config option", async () => {
await agent.setSessionConfigOption({
sessionId: SESSION_ID,
configId: "mode",
value: "plan",
});
const configUpdates = sessionUpdates.filter(
(n) => n.update.sessionUpdate === "config_option_update",
);
expect(configUpdates).toHaveLength(0);
});
it("sends no config_option_update when setting model via config option", async () => {
await agent.setSessionConfigOption({
sessionId: SESSION_ID,
configId: "model",
value: "claude-sonnet-4-5",
});
const configUpdates = sessionUpdates.filter(
(n) => n.update.sessionUpdate === "config_option_update",
);
expect(configUpdates).toHaveLength(0);
});
});
describe("bidirectional consistency", () => {
beforeEach(() => {
populateSession();
});
it("setSessionConfigOption for mode also calls underlying setPermissionMode", async () => {
await agent.setSessionConfigOption({
sessionId: SESSION_ID,
configId: "mode",
value: "acceptEdits",
});
expect(setPermissionModeSpy).toHaveBeenCalledWith("acceptEdits");
});
it("setSessionConfigOption for model also calls underlying setModel", async () => {
await agent.setSessionConfigOption({
sessionId: SESSION_ID,
configId: "model",
value: "claude-sonnet-4-5",
});
expect(setModelSpy).toHaveBeenCalledWith("claude-sonnet-4-5");
});
it("setSessionMode also syncs configOptions", async () => {
await agent.setSessionMode({ sessionId: SESSION_ID, modeId: "plan" });
const session = (
agent as unknown as {
sessions: Record<string, { configOptions: typeof MOCK_CONFIG_OPTIONS }>;
}
).sessions[SESSION_ID];
expect(session.configOptions.find((o) => o.id === "mode")?.currentValue).toBe("plan");
});
it("setSessionModel also syncs configOptions", async () => {
await agent.unstable_setSessionModel({
sessionId: SESSION_ID,
modelId: "claude-sonnet-4-5",
});
const session = (
agent as unknown as {
sessions: Record<string, { configOptions: typeof MOCK_CONFIG_OPTIONS }>;
}
).sessions[SESSION_ID];
expect(session.configOptions.find((o) => o.id === "model")?.currentValue).toBe(
"claude-sonnet-4-5",
);
});
});
});

View File

@@ -0,0 +1,56 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { SettingsManager } from "../settings.js";
import * as fs from "node:fs";
import * as path from "node:path";
import * as os from "node:os";
describe("SettingsManager", () => {
let tempDir: string;
let settingsManager: SettingsManager;
beforeEach(async () => {
tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "settings-test-"));
});
afterEach(async () => {
settingsManager?.dispose();
await fs.promises.rm(tempDir, { recursive: true, force: true });
});
describe("settings merging", () => {
it("should merge model setting with later sources taking precedence", async () => {
const claudeDir = path.join(tempDir, ".claude");
await fs.promises.mkdir(claudeDir, { recursive: true });
// Project settings with one model
await fs.promises.writeFile(
path.join(claudeDir, "settings.json"),
JSON.stringify({
model: "claude-3-5-sonnet",
}),
);
settingsManager = new SettingsManager(tempDir);
await settingsManager.initialize();
let settings = settingsManager.getSettings();
expect(settings.model).toBe("claude-3-5-sonnet");
// Add local settings that override the model
await fs.promises.writeFile(
path.join(claudeDir, "settings.local.json"),
JSON.stringify({
model: "claude-3-5-haiku",
}),
);
// Re-initialize to pick up local settings
settingsManager.dispose();
settingsManager = new SettingsManager(tempDir);
await settingsManager.initialize();
settings = settingsManager.getSettings();
expect(settings.model).toBe("claude-3-5-haiku");
});
});
});

1237
src/tests/tools.test.ts Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,15 +1,81 @@
import { PlanEntry, ToolCallContent, ToolCallLocation, ToolKind } from "@agentclientprotocol/sdk";
import { replaceAndCalculateLocation, SYSTEM_REMINDER, toolNames } from "./mcp-server.js";
import { ToolResultBlockParam, WebSearchToolResultBlockParam } from "@anthropic-ai/sdk/resources";
import {
ContentBlock,
PlanEntry,
ToolCallContent,
ToolCallLocation,
ToolKind,
} from "@agentclientprotocol/sdk";
import {
ImageBlockParam,
TextBlockParam,
ToolResultBlockParam,
WebSearchResultBlock,
WebSearchToolResultBlockParam,
WebSearchToolResultError,
} from "@anthropic-ai/sdk/resources";
import {
BetaBashCodeExecutionToolResultBlockParam,
BetaBashCodeExecutionResultBlock,
BetaBashCodeExecutionToolResultError,
BetaCodeExecutionToolResultBlockParam,
BetaCodeExecutionResultBlock,
BetaCodeExecutionToolResultError,
BetaRequestMCPToolResultBlockParam,
BetaTextEditorCodeExecutionToolResultBlockParam,
BetaTextEditorCodeExecutionViewResultBlock,
BetaTextEditorCodeExecutionCreateResultBlock,
BetaTextEditorCodeExecutionStrReplaceResultBlock,
BetaTextEditorCodeExecutionToolResultError,
BetaToolResultBlockParam,
BetaToolSearchToolResultBlockParam,
BetaToolReferenceBlock,
BetaToolSearchToolSearchResultBlock,
BetaToolSearchToolResultError,
BetaWebFetchToolResultBlockParam,
BetaWebFetchBlock,
BetaWebFetchToolResultErrorBlock,
BetaWebSearchToolResultBlockParam,
BetaImageBlockParam,
} from "@anthropic-ai/sdk/resources/beta.mjs";
/**
* Union of all possible content types that can appear in tool results from the Anthropic SDK.
* These are transformed to valid ACP ContentBlock types by toValidAcpContent().
*/
type ToolResultContent =
| TextBlockParam
| ImageBlockParam
| BetaImageBlockParam
| BetaToolReferenceBlock
| BetaToolSearchToolSearchResultBlock
| BetaToolSearchToolResultError
| WebSearchResultBlock
| WebSearchToolResultError
| BetaWebFetchBlock
| BetaWebFetchToolResultErrorBlock
| BetaCodeExecutionResultBlock
| BetaCodeExecutionToolResultError
| BetaBashCodeExecutionResultBlock
| BetaBashCodeExecutionToolResultError
| BetaTextEditorCodeExecutionViewResultBlock
| 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;
kind: ToolKind;
@@ -21,22 +87,36 @@ interface ToolUpdate {
title?: string;
content?: ToolCallContent[];
locations?: ToolCallLocation[];
_meta?: {
terminal_info?: {
terminal_id: string;
};
terminal_output?: {
terminal_id: string;
data: string;
};
terminal_exit?: {
terminal_id: string;
exit_code: number;
signal: string | null;
};
};
}
export function toolInfoFromToolUse(
toolUse: any,
cachedFileContent: { [key: string]: string },
supportsTerminalOutput: boolean = false,
): ToolInfo {
const name = toolUse.name;
const input = toolUse.input;
switch (name) {
case "Task":
case "Task": {
const input = toolUse.input as AgentInput | BashInput;
return {
title: input?.description ? input.description : "Task",
kind: "think",
content:
input && input.prompt
input && "prompt" in input
? [
{
type: "content",
@@ -45,38 +125,16 @@ export function toolInfoFromToolUse(
]
: [],
};
}
case "NotebookRead":
case "Bash": {
const input = toolUse.input as BashInput;
return {
title: input?.notebook_path ? `Read Notebook ${input.notebook_path}` : "Read Notebook",
kind: "read",
content: [],
locations: input?.notebook_path ? [{ path: input.notebook_path }] : [],
};
case "NotebookEdit":
return {
title: input?.notebook_path ? `Edit Notebook ${input.notebook_path}` : "Edit Notebook",
kind: "edit",
content:
input && input.new_source
? [
{
type: "content",
content: { type: "text", text: input.new_source },
},
]
: [],
locations: input?.notebook_path ? [{ path: input.notebook_path }] : [],
};
case "Bash":
case toolNames.bash:
return {
title: input?.command ? "`" + input.command.replaceAll("`", "\\`") + "`" : "Terminal",
title: input?.command ? input.command : "Terminal",
kind: "execute",
content:
input && input.description
content: supportsTerminalOutput
? [{ type: "terminal" as const, terminalId: toolUse.id }]
: input && input.description
? [
{
type: "content",
@@ -85,30 +143,15 @@ export function toolInfoFromToolUse(
]
: [],
};
}
case "BashOutput":
case toolNames.bashOutput:
return {
title: "Tail Logs",
kind: "execute",
content: [],
};
case "KillShell":
case toolNames.killShell:
return {
title: "Kill Process",
kind: "execute",
content: [],
};
case toolNames.read: {
case "Read": {
const input = toolUse.input as FileReadInput;
let limit = "";
if (input.limit) {
limit =
" (" + ((input.offset ?? 0) + 1) + " - " + ((input.offset ?? 0) + input.limit) + ")";
if (input.limit && input.limit > 0) {
limit = " (" + (input.offset ?? 1) + " - " + ((input.offset ?? 1) + input.limit - 1) + ")";
} else if (input.offset) {
limit = " (from line " + (input.offset + 1) + ")";
limit = " (from line " + input.offset + ")";
}
return {
title: "Read " + (input.file_path ?? "File") + limit,
@@ -117,7 +160,7 @@ export function toolInfoFromToolUse(
? [
{
path: input.file_path,
line: input.offset ?? 0,
line: input.offset ?? 1,
},
]
: [],
@@ -125,69 +168,8 @@ export function toolInfoFromToolUse(
};
}
case "Read":
return {
title: "Read File",
kind: "read",
content: [],
locations: [{ path: input.file_path, line: input.offset ?? 0 }],
};
case "LS":
return {
title: `List the ${input?.path ? "`" + input.path + "`" : "current"} directory's contents`,
kind: "search",
content: [],
locations: [],
};
case toolNames.edit:
case "Edit": {
const path = input?.file_path ?? input?.file_path;
let oldText = input.old_string ?? null;
let newText = input.new_string ?? "";
let affectedLines: number[] = [];
if (path && oldText) {
try {
const oldContent = cachedFileContent[path] || "";
const newContent = replaceAndCalculateLocation(oldContent, [
{
oldText,
newText,
replaceAll: false,
},
]);
oldText = oldContent;
newText = newContent.newContent;
affectedLines = newContent.lineNumbers;
} catch (e) {
console.error(e);
}
}
return {
title: path ? `Edit \`${path}\`` : "Edit",
kind: "edit",
content:
input && path
? [
{
type: "diff",
path,
oldText,
newText,
},
]
: [],
locations: path
? affectedLines.length > 0
? affectedLines.map((line) => ({ line, path }))
: [{ path }]
: [],
};
}
case toolNames.write: {
case "Write": {
const input = toolUse.input as FileWriteInput;
let content: ToolCallContent[] = [];
if (input && input.file_path) {
content = [
@@ -214,25 +196,29 @@ export function toolInfoFromToolUse(
};
}
case "Write":
case "Edit": {
const input = toolUse.input as FileEditInput;
let content: ToolCallContent[] = [];
if (input && input.file_path && (input.old_string || input.new_string)) {
content = [
{
type: "diff",
path: input.file_path,
oldText: input.old_string || null,
newText: input.new_string ?? "",
},
];
}
return {
title: input?.file_path ? `Write ${input.file_path}` : "Write",
title: input?.file_path ? `Edit ${input.file_path}` : "Edit",
kind: "edit",
content:
input && input.file_path
? [
{
type: "diff",
path: input.file_path,
oldText: null,
newText: input.content,
},
]
: [],
content,
locations: input?.file_path ? [{ path: input.file_path }] : [],
};
}
case "Glob": {
const input = toolUse.input as GlobInput;
let label = "Find";
if (input.path) {
label += ` \`${input.path}\``;
@@ -249,6 +235,7 @@ export function toolInfoFromToolUse(
}
case "Grep": {
const input = toolUse.input as GrepInput;
let label = "grep";
if (input["-i"]) {
@@ -270,13 +257,13 @@ export function toolInfoFromToolUse(
if (input.output_mode) {
switch (input.output_mode) {
case "FilesWithMatches":
case "files_with_matches":
label += " -l";
break;
case "Count":
case "count":
label += " -c";
break;
case "Content":
case "content":
default:
break;
}
@@ -298,7 +285,9 @@ export function toolInfoFromToolUse(
label += " -P";
}
label += ` "${input.pattern}"`;
if (input.pattern) {
label += ` "${input.pattern}"`;
}
if (input.path) {
label += ` ${input.path}`;
@@ -311,7 +300,8 @@ export function toolInfoFromToolUse(
};
}
case "WebFetch":
case "WebFetch": {
const input = toolUse.input as WebFetchInput;
return {
title: input?.url ? `Fetch ${input.url}` : "Fetch",
kind: "fetch",
@@ -325,8 +315,10 @@ export function toolInfoFromToolUse(
]
: [],
};
}
case "WebSearch": {
const input = toolUse.input as WebSearchInput;
let label = `"${input.query}"`;
if (input.allowed_domains && input.allowed_domains.length > 0) {
@@ -344,7 +336,8 @@ export function toolInfoFromToolUse(
};
}
case "TodoWrite":
case "TodoWrite": {
const input = toolUse.input as TodoWriteInput;
return {
title: Array.isArray(input?.todos)
? `Update TODOs: ${input.todos.map((todo: any) => todo.content).join(", ")}`
@@ -352,18 +345,18 @@ export function toolInfoFromToolUse(
kind: "think",
content: [],
};
}
case "ExitPlanMode":
case "ExitPlanMode": {
return {
title: "Ready to code?",
kind: "switch_mode",
content:
input && input.plan
? [{ type: "content", content: { type: "text", text: input.plan } }]
: [],
content: [],
};
}
case "Other": {
const input = toolUse.input;
let output;
try {
output = JSON.stringify(input, null, 2);
@@ -397,18 +390,30 @@ export function toolInfoFromToolUse(
export function toolUpdateFromToolResult(
toolResult:
| ToolResultBlockParam
| BetaToolResultBlockParam
| BetaWebSearchToolResultBlockParam
| BetaWebFetchToolResultBlockParam
| WebSearchToolResultBlockParam
| BetaCodeExecutionToolResultBlockParam
| BetaBashCodeExecutionToolResultBlockParam
| BetaTextEditorCodeExecutionToolResultBlockParam
| BetaRequestMCPToolResultBlockParam,
| BetaRequestMCPToolResultBlockParam
| BetaToolSearchToolResultBlockParam,
toolUse: any | undefined,
supportsTerminalOutput: boolean = false,
): ToolUpdate {
if (
"is_error" in toolResult &&
toolResult.is_error &&
toolResult.content &&
toolResult.content.length > 0
) {
// Only return errors
return toAcpContentUpdate(toolResult.content, true);
}
switch (toolUse?.name) {
case "Read":
case toolNames.read:
if (Array.isArray(toolResult.content) && toolResult.content.length > 0) {
return {
content: toolResult.content.map((content: any) => ({
@@ -417,9 +422,9 @@ export function toolUpdateFromToolResult(
content.type === "text"
? {
type: "text",
text: markdownEscape(content.text.replace(SYSTEM_REMINDER, "")),
text: markdownEscape(content.text),
}
: content,
: toAcpContentBlock(content, false),
})),
};
} else if (typeof toolResult.content === "string" && toolResult.content.length > 0) {
@@ -429,7 +434,7 @@ export function toolUpdateFromToolResult(
type: "content",
content: {
type: "text",
text: markdownEscape(toolResult.content.replace(SYSTEM_REMINDER, "")),
text: markdownEscape(toolResult.content),
},
},
],
@@ -437,21 +442,76 @@ export function toolUpdateFromToolResult(
}
return {};
case toolNames.bash:
case "edit":
case "Edit":
case toolNames.edit:
case toolNames.write:
case "Write": {
case "Bash": {
const result = toolResult.content;
const terminalId = "tool_use_id" in toolResult ? String(toolResult.tool_use_id) : "";
const isError = "is_error" in toolResult && toolResult.is_error;
// Extract output and exit code from either format:
// 1. BetaBashCodeExecutionResultBlock: { type: "bash_code_execution_result", stdout, stderr, return_code }
// 2. Plain string content from a regular tool_result
// 3. Array content (e.g. [{ type: "text", text: "..." }])
let output = "";
let exitCode = isError ? 1 : 0;
if (
"is_error" in toolResult &&
toolResult.is_error &&
toolResult.content &&
toolResult.content.length > 0
result &&
typeof result === "object" &&
"type" in result &&
result.type === "bash_code_execution_result"
) {
// Only return errors
return toAcpContentUpdate(toolResult.content, true);
const bashResult = result as BetaBashCodeExecutionResultBlock;
output = bashResult.stdout || bashResult.stderr || "";
exitCode = bashResult.return_code;
} else if (typeof result === "string") {
output = result;
} else if (
Array.isArray(result) &&
result.length > 0 &&
"text" in result[0] &&
typeof result[0].text === "string"
) {
output = result.map((c: any) => c.text).join("\n");
}
if (supportsTerminalOutput) {
return {
content: [{ type: "terminal" as const, terminalId }],
_meta: {
terminal_info: {
terminal_id: terminalId,
},
terminal_output: {
terminal_id: terminalId,
data: output,
},
terminal_exit: {
terminal_id: terminalId,
exit_code: exitCode,
signal: null,
},
},
};
}
// Fallback: format output as a code block without terminal _meta
if (output.trim()) {
return {
content: [
{
type: "content",
content: {
type: "text",
text: `\`\`\`console\n${output.trimEnd()}\n\`\`\``,
},
},
],
};
}
return {};
}
case "Edit": // Edit is handled in hooks
case "Write": {
return {};
}
@@ -459,20 +519,6 @@ export function toolUpdateFromToolResult(
return { title: "Exited Plan Mode" };
}
case "Task":
case "NotebookEdit":
case "NotebookRead":
case "TodoWrite":
case "exit_plan_mode":
case "Bash":
case "BashOutput":
case "KillBash":
case "LS":
case "Glob":
case "Grep":
case "WebFetch":
case "WebSearch":
case "Other":
default: {
return toAcpContentUpdate(
toolResult.content,
@@ -488,17 +534,20 @@ function toAcpContentUpdate(
): { content?: ToolCallContent[] } {
if (Array.isArray(content) && content.length > 0) {
return {
content: content.map((content: any) => ({
type: "content",
content:
isError && content.type === "text"
? {
...content,
text: `\`\`\`\n${content.text}\n\`\`\``,
}
: content,
content: content.map((c: any) => ({
type: "content" as const,
content: toAcpContentBlock(c, isError),
})),
};
} else if (typeof content === "object" && content !== null && "type" in content) {
return {
content: [
{
type: "content" as const,
content: toAcpContentBlock(content, isError),
},
],
};
} else if (typeof content === "string" && content.length > 0) {
return {
content: [
@@ -515,6 +564,74 @@ function toAcpContentUpdate(
return {};
}
function toAcpContentBlock(content: ToolResultContent, isError: boolean): ContentBlock {
const wrapText = (text: string): ContentBlock => ({
type: "text" as const,
text: isError ? `\`\`\`\n${text}\n\`\`\`` : text,
});
switch (content.type) {
case "text":
return {
type: "text" as const,
text: isError ? `\`\`\`\n${content.text}\n\`\`\`` : content.text,
};
case "image":
if (content.source.type === "base64") {
return {
type: "image" as const,
data: content.source.data,
mimeType: content.source.media_type,
};
}
// URL and file-based images can't be converted to ACP format (requires data)
return wrapText(
content.source.type === "url"
? `[image: ${content.source.url}]`
: "[image: file reference]",
);
case "tool_reference":
return wrapText(`Tool: ${content.tool_name}`);
case "tool_search_tool_search_result":
return wrapText(
`Tools found: ${content.tool_references.map((r) => r.tool_name).join(", ") || "none"}`,
);
case "tool_search_tool_result_error":
return wrapText(
`Error: ${content.error_code}${content.error_message ? ` - ${content.error_message}` : ""}`,
);
case "web_search_result":
return wrapText(`${content.title} (${content.url})`);
case "web_search_tool_result_error":
return wrapText(`Error: ${content.error_code}`);
case "web_fetch_result":
return wrapText(`Fetched: ${content.url}`);
case "web_fetch_tool_result_error":
return wrapText(`Error: ${content.error_code}`);
case "code_execution_result":
return wrapText(`Output: ${content.stdout || content.stderr || ""}`);
case "bash_code_execution_result":
return wrapText(`Output: ${content.stdout || content.stderr || ""}`);
case "code_execution_tool_result_error":
case "bash_code_execution_tool_result_error":
return wrapText(`Error: ${content.error_code}`);
case "text_editor_code_execution_view_result":
return wrapText(content.content);
case "text_editor_code_execution_create_result":
return wrapText(content.is_file_update ? "File updated" : "File created");
case "text_editor_code_execution_str_replace_result":
return wrapText(content.lines?.join("\n") || "");
case "text_editor_code_execution_tool_result_error":
return wrapText(
`Error: ${content.error_code}${content.error_message ? ` - ${content.error_message}` : ""}`,
);
default:
return wrapText(JSON.stringify(content));
}
}
export type ClaudePlanEntry = {
content: string;
status: "pending" | "in_progress" | "completed";
@@ -538,3 +655,121 @@ export function markdownEscape(text: string): string {
}
return escape + "\n" + text + (text.endsWith("\n") ? "" : "\n") + escape;
}
interface EditToolResponseHunk {
oldStart: number;
oldLines: number;
newStart: number;
newLines: number;
lines: string[];
}
interface EditToolResponse {
filePath?: string;
structuredPatch?: EditToolResponseHunk[];
}
/**
* Builds diff ToolUpdate content from the structured Edit toolResponse provided
* by the PostToolUse hook. Unlike parsing the plain unified diff string, this uses
* the pre-parsed structuredPatch which supports multiple replacement sites (replaceAll)
* and always includes context lines for better readability.
*/
export function toolUpdateFromEditToolResponse(toolResponse: unknown): {
content?: ToolCallContent[];
locations?: ToolCallLocation[];
} {
if (!toolResponse || typeof toolResponse !== "object") return {};
const response = toolResponse as EditToolResponse;
if (!response.filePath || !Array.isArray(response.structuredPatch)) return {};
const content: ToolCallContent[] = [];
const locations: ToolCallLocation[] = [];
for (const { lines, newStart } of response.structuredPatch) {
const oldText: string[] = [];
const newText: string[] = [];
for (const line of lines) {
if (line.startsWith("-")) {
oldText.push(line.slice(1));
} else if (line.startsWith("+")) {
newText.push(line.slice(1));
} else {
oldText.push(line.slice(1));
newText.push(line.slice(1));
}
}
if (oldText.length > 0 || newText.length > 0) {
locations.push({ path: response.filePath, line: newStart });
content.push({
type: "diff",
path: response.filePath,
oldText: oldText.join("\n") || null,
newText: newText.join("\n"),
});
}
}
const result: { content?: ToolCallContent[]; locations?: ToolCallLocation[] } = {};
if (content.length > 0) result.content = content;
if (locations.length > 0) result.locations = locations;
return result;
}
/* A global variable to store callbacks that should be executed when receiving hooks from Claude Code */
const toolUseCallbacks: {
[toolUseId: string]: {
onPostToolUseHook?: (
toolUseID: string,
toolInput: unknown,
toolResponse: unknown,
) => Promise<void>;
};
} = {};
/* Setup callbacks that will be called when receiving hooks from Claude Code */
export const registerHookCallback = (
toolUseID: string,
{
onPostToolUseHook,
}: {
onPostToolUseHook?: (
toolUseID: string,
toolInput: unknown,
toolResponse: unknown,
) => Promise<void>;
},
) => {
toolUseCallbacks[toolUseID] = {
onPostToolUseHook,
};
};
/* A callback for Claude Code that is called when receiving a PostToolUse hook */
export const createPostToolUseHook =
(
logger: Logger = console,
options?: {
onEnterPlanMode?: () => Promise<void>;
},
): HookCallback =>
async (input: any, toolUseID: string | undefined): Promise<{ continue: boolean }> => {
if (input.hook_event_name === "PostToolUse") {
// Handle EnterPlanMode tool - notify client of mode change after successful execution
if (input.tool_name === "EnterPlanMode" && options?.onEnterPlanMode) {
await options.onEnterPlanMode();
}
if (toolUseID) {
const onPostToolUseHook = toolUseCallbacks[toolUseID]?.onPostToolUseHook;
if (onPostToolUseHook) {
await onPostToolUseHook(toolUseID, input.tool_input, input.tool_response);
delete toolUseCallbacks[toolUseID]; // Cleanup after execution
} else {
logger.error(`No onPostToolUseHook found for tool use ID: ${toolUseID}`);
delete toolUseCallbacks[toolUseID];
}
}
}
return { continue: true };
};

View File

@@ -3,7 +3,8 @@
import { Readable, Writable } from "node:stream";
import { WritableStream, ReadableStream } from "node:stream/web";
import { readFileSync } from "node:fs";
import { platform } from "node:os";
import { Logger } from "./acp-agent.js";
import { ClaudeCodeSettings, getManagedSettingsPath } from "./settings.js";
// Useful for bridging push-based and async-iterator-based code.
export class Pushable<T> implements AsyncIterable<T> {
@@ -75,120 +76,32 @@ export function nodeToWebReadable(nodeStream: Readable): ReadableStream<Uint8Arr
});
}
export function unreachable(value: never): never {
export function unreachable(value: never, logger: Logger = console) {
let valueAsString;
try {
valueAsString = JSON.stringify(value);
} catch {
valueAsString = value;
}
throw new Error(`Unexpected case: ${valueAsString}`);
logger.error(`Unexpected case: ${valueAsString}`);
}
export function sleep(time: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, time));
}
interface ManagedSettings {
permissions?: {
allow?: string[];
deny?: string[];
};
env?: Record<string, string>;
}
// Following the rules in https://docs.anthropic.com/en/docs/claude-code/settings#settings-files
// This can be removed once the SDK supports it natively.
function getManagedSettingsPath(): string {
const os = platform();
switch (os) {
case "darwin":
return "/Library/Application Support/ClaudeCode/managed-settings.json";
case "linux": // including WSL
return "/etc/claude-code/managed-settings.json";
case "win32":
return "C:\\ProgramData\\ClaudeCode\\managed-settings.json";
default:
return "/etc/claude-code/managed-settings.json";
}
}
export function loadManagedSettings(): ManagedSettings | null {
export function loadManagedSettings(): ClaudeCodeSettings | null {
try {
return JSON.parse(readFileSync(getManagedSettingsPath(), "utf8")) as ManagedSettings;
return JSON.parse(readFileSync(getManagedSettingsPath(), "utf8")) as ClaudeCodeSettings;
} catch {
return null;
}
}
export function applyEnvironmentSettings(settings: ManagedSettings): void {
export function applyEnvironmentSettings(settings: ClaudeCodeSettings): void {
if (settings.env) {
for (const [key, value] of Object.entries(settings.env)) {
process.env[key] = value;
}
}
}
export interface ExtractLinesResult {
content: string;
wasLimited: boolean;
linesRead: number;
}
/**
* Extracts lines from file content with byte limit enforcement.
*
* @param fullContent - The complete file content
* @param maxContentLength - Maximum number of UTF-16 Code Units to return
* @returns Object containing extracted content and metadata
*/
export function extractLinesWithByteLimit(
fullContent: string,
maxContentLength: number,
): ExtractLinesResult {
if (fullContent === "") {
return {
content: "",
wasLimited: false,
linesRead: 1,
};
}
let linesSeen = 0;
let index = 0;
linesSeen = 0;
let contentLength = 0;
let wasLimited = false;
while (true) {
const nextIndex = fullContent.indexOf("\n", index);
if (nextIndex < 0) {
// Last line in file (no trailing newline)
if (linesSeen > 0 && fullContent.length > maxContentLength) {
wasLimited = true;
break;
}
linesSeen += 1;
contentLength = fullContent.length;
break;
} else {
// Line with newline - include up to the newline
const newContentLength = nextIndex + 1;
if (linesSeen > 0 && newContentLength > maxContentLength) {
wasLimited = true;
break;
}
linesSeen += 1;
contentLength = newContentLength;
index = newContentLength;
}
}
return {
content: fullContent.slice(0, contentLength),
wasLimited,
linesRead: linesSeen,
};
}

View File

@@ -1,15 +1,17 @@
{
"compilerOptions": {
"target": "ES2020",
"target": "ES2021",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2020"],
"lib": ["ES2021"],
"strict": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true,
"outDir": "dist",
"rootDir": "src"
"rootDir": "src",
"declaration": true,
"declarationMap": true
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist"]