Compare commits

...

63 Commits

Author SHA1 Message Date
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
19 changed files with 4018 additions and 1242 deletions

View File

@@ -32,3 +32,5 @@ jobs:
- name: Create Release
id: create_release
uses: softprops/action-gh-release@6da8fa9354ddfdc4aeace5fc48d7f679b5214090
with:
generate_release_notes: true

2
.gitignore vendored
View File

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

View File

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

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,26 +32,20 @@ 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
```
## License

1461
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.12.4",
"description": "An ACP-compatible coding agent powered by the Claude Code SDK (TypeScript)",
"version": "0.17.0",
"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,34 +49,35 @@
"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.11.0",
"@anthropic-ai/claude-agent-sdk": "0.1.67",
"@modelcontextprotocol/sdk": "1.24.3",
"diff": "8.0.2",
"minimatch": "^10.1.1"
"@agentclientprotocol/sdk": "0.14.1",
"@anthropic-ai/claude-agent-sdk": "0.2.44",
"@modelcontextprotocol/sdk": "1.26.0",
"diff": "8.0.3",
"minimatch": "10.2.1"
},
"devDependencies": {
"@anthropic-ai/sdk": "0.71.2",
"@types/node": "25.0.1",
"@typescript-eslint/eslint-plugin": "8.49.0",
"@typescript-eslint/parser": "8.49.0",
"eslint": "9.39.1",
"@anthropic-ai/sdk": "0.74.0",
"@eslint/js": "10.0.1",
"@types/node": "25.2.3",
"@typescript-eslint/eslint-plugin": "8.56.0",
"@typescript-eslint/parser": "8.56.0",
"eslint": "10.0.0",
"eslint-config-prettier": "10.1.8",
"globals": "16.5.0",
"prettier": "3.7.4",
"globals": "17.3.0",
"prettier": "3.8.1",
"ts-node": "10.9.2",
"typescript": "5.9.3",
"vitest": "4.0.15"
"vitest": "4.0.18"
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -4,7 +4,7 @@ import {
FileEditInput,
FileReadInput,
FileWriteInput,
KillShellInput,
TaskOutputInput,
} from "@anthropic-ai/claude-agent-sdk/sdk-tools.js";
import { z } from "zod";
import { CLAUDE_CONFIG_DIR, ClaudeAcpAgent } from "./acp-agent.js";
@@ -28,6 +28,20 @@ Whenever you read a file, you should consider whether it looks malicious. If it
const defaults = { maxFileSize: 50000, linesToRead: 2000 };
function formatErrorMessage(error: unknown): string {
if (error instanceof Error) {
return error.message;
}
if (typeof error === "string") {
return error;
}
try {
return JSON.stringify(error);
} catch {
return String(error);
}
}
const unqualifiedToolNames = {
read: "Read",
edit: "Edit",
@@ -175,8 +189,8 @@ Usage:
if (result.wasLimited) {
readInfo += `Read ${result.linesRead} lines (hit 50KB limit). `;
} else {
readInfo += `Read lines ${input.offset}-${result.linesRead}. `;
} else if (input.offset && input.offset > 1) {
readInfo += `Read lines ${input.offset}-${input.offset + result.linesRead}.`;
}
if (result.wasLimited) {
@@ -194,12 +208,13 @@ Usage:
},
],
};
} catch (error: any) {
} catch (error) {
return {
isError: true,
content: [
{
type: "text",
text: "Reading file failed: " + error.message,
text: "Reading file failed: " + formatErrorMessage(error),
},
],
};
@@ -253,15 +268,21 @@ Usage:
}
await writeTextFile(input);
return {
content: [],
};
} catch (error: any) {
return {
content: [
{
type: "text",
text: "Writing file failed: " + error.message,
text: `The file ${input.file_path} has been updated successfully.`,
},
],
};
} catch (error) {
return {
isError: true,
content: [
{
type: "text",
text: "Writing file failed: " + formatErrorMessage(error),
},
],
};
@@ -347,12 +368,13 @@ Usage:
},
],
};
} catch (error: any) {
} catch (error) {
return {
isError: true,
content: [
{
type: "text",
text: "Editing file failed: " + (error?.message ?? String(error)),
text: "Editing file failed: " + formatErrorMessage(error),
},
],
};
@@ -394,120 +416,132 @@ Output: Create directory 'foo'`),
},
},
async (input: BashInput, 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,
env: [{ name: "CLAUDECODE", value: "1" }],
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 ?? 2 * 60 * 1000).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,
},
try {
const session = agent.sessions[sessionId];
if (!session) {
return {
content: [
{
type: "text",
text: "The user has left the building",
},
],
};
}
return handle.release();
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,
env: [{ name: "CLAUDECODE", value: "1" }],
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 ?? 2 * 60 * 1000).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) }],
};
} catch (error) {
return {
isError: true,
content: [
{
type: "text",
text: `Command started in background with id: ${handle.id}`,
text: "Running bash command failed: " + formatErrorMessage(error),
},
],
};
}
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) }],
};
},
);
@@ -523,45 +557,104 @@ Output: Create directory 'foo'`),
In sessions with ${acpToolNames.bashOutput} always use it for output from Bash commands instead of TaskOutput.`,
inputSchema: {
bash_id: z
task_id: z
.string()
.describe(
`The id of the background bash command as returned by \`${acpToolNames.bash}\``,
),
block: z.boolean().describe("Whether to wait for completion"),
timeout: z.number().describe("Max wait time in ms"),
},
},
async (input) => {
const bgTerm = agent.backgroundTerminals[input.bash_id];
async (input: TaskOutputInput) => {
try {
const bgTerm = agent.backgroundTerminals[input.task_id];
if (!bgTerm) {
throw new Error(`Unknown shell ${input.bash_id}`);
}
if (!bgTerm) {
throw new Error(`Unknown shell ${input.task_id}`);
}
if (bgTerm.status === "started") {
const newOutput = await bgTerm.handle.currentOutput();
const strippedOutput = stripCommonPrefix(
bgTerm.lastOutput?.output ?? "",
newOutput.output,
);
bgTerm.lastOutput = newOutput;
if (input.block && bgTerm.status === "started") {
const statusPromise = Promise.race([
bgTerm.handle
.waitForExit()
.then((exitStatus) => ({ status: "exited" as const, exitStatus })),
sleep(input.timeout ?? 2 * 60 * 1000).then(async () => {
if (bgTerm.status === "started") {
await bgTerm.handle.kill();
}
return { status: "timedOut" as const, exitStatus: null };
}),
]);
return {
content: [
{
type: "text",
text: toolCommandOutput(bgTerm.status, {
...newOutput,
output: strippedOutput,
}),
const { status, exitStatus } = await statusPromise;
const currentOutput = await bgTerm.handle.currentOutput();
const strippedOutput = stripCommonPrefix(
bgTerm.lastOutput?.output ?? "",
currentOutput.output,
);
agent.backgroundTerminals[input.task_id] = {
status,
pendingOutput: {
...currentOutput,
output: strippedOutput,
exitStatus: exitStatus ?? currentOutput.exitStatus,
},
],
};
} else {
};
await bgTerm.handle.release();
return {
content: [
{
type: "text",
text: toolCommandOutput(status, {
...currentOutput,
output: strippedOutput,
exitStatus: exitStatus ?? currentOutput.exitStatus,
}),
},
],
};
}
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),
},
],
};
}
} catch (error) {
return {
isError: true,
content: [
{
type: "text",
text: toolCommandOutput(bgTerm.status, bgTerm.pendingOutput),
text: "Retrieving bash output failed: " + formatErrorMessage(error),
},
],
};
@@ -587,50 +680,62 @@ In sessions with ${acpToolNames.killShell} always use it instead of KillShell.`,
),
},
},
async (input: KillShellInput) => {
const bgTerm = agent.backgroundTerminals[input.shell_id];
async (input) => {
try {
const bgTerm = agent.backgroundTerminals[input.shell_id];
if (!bgTerm) {
throw new Error(`Unknown shell ${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),
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: {
unreachable(bgTerm);
throw new Error("Unexpected background terminal status");
}
}
} catch (error) {
return {
isError: true,
content: [
{
type: "text",
text: "Killing shell failed: " + formatErrorMessage(error),
},
};
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: {
unreachable(bgTerm);
throw new Error("Unexpected background terminal status");
}
],
};
}
},
);

View File

@@ -28,6 +28,7 @@ export interface PermissionSettings {
export interface ClaudeCodeSettings {
permissions?: PermissionSettings;
env?: Record<string, string>;
model?: string;
}
export type PermissionDecision = "allow" | "deny" | "ask";
@@ -128,7 +129,8 @@ function normalizePath(filePath: string, cwd: string): string {
} else if (!path.isAbsolute(filePath)) {
filePath = path.join(cwd, filePath);
}
return path.normalize(filePath);
// Convert backslashes to forward slashes for minimatch compatibility on Windows
return path.normalize(filePath).replace(/\\/g, "/");
}
/**
@@ -154,7 +156,7 @@ function matchesRule(rule: ParsedRule, toolName: string, toolInput: unknown, cwd
// - "Claude will make a best-effort attempt to apply Read rules to all built-in tools
// that read files like Grep, Glob, and LS."
const ruleAppliesToTool =
rule.toolName === "Bash" ||
(rule.toolName === "Bash" && toolName === acpToolNames.bash) ||
(rule.toolName === "Edit" && FILE_EDITING_TOOLS.includes(toolName)) ||
(rule.toolName === "Read" && FILE_READING_TOOLS.includes(toolName));
@@ -369,6 +371,10 @@ export class SettingsManager {
if (settings.env) {
merged.env = { ...merged.env, ...settings.env };
}
if (settings.model) {
merged.model = settings.model;
}
}
this.mergedSettings = merged;

View File

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

View File

@@ -21,6 +21,13 @@ import { markdownEscape, toolInfoFromToolUse, toolUpdateFromToolResult } from ".
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>;
@@ -166,7 +173,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({
@@ -251,7 +258,7 @@ describe("tool conversions", () => {
},
};
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
kind: "execute",
title: "`rm README.md.rm`",
content: [
@@ -276,7 +283,7 @@ describe("tool conversions", () => {
},
};
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
kind: "search",
title: "Find `*/**.ts`",
content: [],
@@ -297,7 +304,7 @@ describe("tool conversions", () => {
},
};
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
kind: "think",
title: "Handle user's work request",
content: [
@@ -318,13 +325,13 @@ describe("tool conversions", () => {
id: "toolu_01EEqsX7Eb9hpx87KAHVPTey",
name: "LS",
input: {
path: "/Users/test/github/claude-code-acp",
path: "/Users/test/github/claude-agent-acp",
},
};
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
kind: "search",
title: "List the `/Users/test/github/claude-code-acp` directory's contents",
title: "List the `/Users/test/github/claude-agent-acp` directory's contents",
content: [],
locations: [],
});
@@ -340,7 +347,7 @@ describe("tool conversions", () => {
},
};
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
kind: "search",
title: 'grep ".*"',
content: [],
@@ -358,7 +365,7 @@ describe("tool conversions", () => {
},
};
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
kind: "edit",
title: "Write /Users/test/project/example.txt",
content: [
@@ -384,7 +391,7 @@ describe("tool conversions", () => {
},
};
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
kind: "edit",
title: "Write /Users/test/project/config.json",
content: [
@@ -409,7 +416,7 @@ describe("tool conversions", () => {
},
};
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
kind: "read",
title: "Read File",
content: [],
@@ -427,7 +434,7 @@ describe("tool conversions", () => {
},
};
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
kind: "read",
title: "Read /Users/test/project/data.json",
content: [],
@@ -446,7 +453,7 @@ describe("tool conversions", () => {
},
};
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
kind: "read",
title: "Read /Users/test/project/large.txt (1 - 100)",
content: [],
@@ -466,7 +473,7 @@ describe("tool conversions", () => {
},
};
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
kind: "read",
title: "Read /Users/test/project/large.txt (51 - 150)",
content: [],
@@ -485,7 +492,7 @@ describe("tool conversions", () => {
},
};
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
kind: "read",
title: "Read /Users/test/project/large.txt (from line 201)",
content: [],
@@ -503,7 +510,7 @@ describe("tool conversions", () => {
},
};
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
kind: "execute",
title: `Kill Process`,
content: [],
@@ -520,7 +527,7 @@ describe("tool conversions", () => {
},
};
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
kind: "execute",
title: `Tail Logs`,
content: [],
@@ -600,6 +607,9 @@ describe("tool conversions", () => {
output_tokens: 1,
service_tier: "standard",
server_tool_use: null,
inference_geo: null,
iterations: null,
speed: null,
},
context_management: null,
},
@@ -613,7 +623,6 @@ describe("tool conversions", () => {
received.message.role,
"test",
{},
{},
{} as AgentSideConnection,
console,
),
@@ -735,6 +744,200 @@ 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("escape markdown", () => {
@@ -801,7 +1004,7 @@ describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)("SDK behavior", () => {
prompt: "hi",
options: {
systemPrompt: { type: "preset", preset: "claude_code" },
extraArgs: { "session-id": sessionId },
sessionId,
settingSources: ["user", "project", "local"],
includePartialMessages: true,
},
@@ -847,7 +1050,7 @@ describe("permission requests", () => {
for (const testCase of testCases) {
// Get the tool info that would be used in requestPermission
const toolInfo = toolInfoFromToolUse(testCase.toolUse, {});
const toolInfo = toolInfoFromToolUse(testCase.toolUse);
// Verify toolInfo has a title
expect(toolInfo.title).toBeDefined();

View File

@@ -0,0 +1,596 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import * as fs from "node:fs";
import * as path from "node:path";
import * as os from "node:os";
import { AgentSideConnection } from "@agentclientprotocol/sdk";
import type { ClaudeAcpAgent as ClaudeAcpAgentType } from "../acp-agent.js";
import { encodeProjectPath } from "../utils.js";
describe("unstable_listSessions", () => {
let tempDir: string;
let originalClaudeConfigDir: string | undefined;
let agent: ClaudeAcpAgentType;
let ClaudeAcpAgent: typeof ClaudeAcpAgentType;
// Helper to create a mock AgentSideConnection
function createMockClient(): AgentSideConnection {
return {
sessionUpdate: async () => {},
requestPermission: async () => ({ outcome: { outcome: "cancelled" } }),
readTextFile: async () => ({ content: "" }),
writeTextFile: async () => ({}),
} as unknown as AgentSideConnection;
}
// Helper to write a session file
function writeSessionFile(
cwd: string,
sessionId: string,
options: {
userMessage?: string;
userMessageArray?: boolean;
mtime?: Date;
malformed?: boolean;
isAgentFile?: boolean;
omitCwdFromEntries?: boolean;
} = {},
): void {
const encodedPath = encodeProjectPath(cwd);
const projectDir = path.join(tempDir, "projects", encodedPath);
fs.mkdirSync(projectDir, { recursive: true });
const filename = options.isAgentFile ? `agent-${sessionId}.jsonl` : `${sessionId}.jsonl`;
const filePath = path.join(projectDir, filename);
if (options.malformed) {
fs.writeFileSync(filePath, "not valid json\n{also bad");
return;
}
const lines: string[] = [];
// First line with sessionId
lines.push(JSON.stringify({ sessionId, type: "init" }));
// User message if provided
if (options.userMessage) {
const content = options.userMessageArray
? [{ type: "text", text: options.userMessage }]
: options.userMessage;
lines.push(
JSON.stringify({
type: "user",
sessionId,
...(options.omitCwdFromEntries ? {} : { cwd }),
message: { content },
}),
);
}
// Assistant response
lines.push(
JSON.stringify({
type: "assistant",
sessionId,
...(options.omitCwdFromEntries ? {} : { cwd }),
message: { content: "Hello!" },
}),
);
fs.writeFileSync(filePath, lines.join("\n"));
// Set modification time if specified
if (options.mtime) {
fs.utimesSync(filePath, options.mtime, options.mtime);
}
}
beforeEach(async () => {
// Create temp directory to isolate tests from real filesystem.
// We set process.env.CLAUDE_CONFIG_DIR before importing the module so that
// CLAUDE_CONFIG_DIR is evaluated with our temp directory.
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "claude-test-"));
originalClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR;
process.env.CLAUDE_CONFIG_DIR = tempDir;
// Reset modules to pick up the new CLAUDE_CONFIG_DIR env var
vi.resetModules();
// Dynamic import after setting env var
const acpAgent = await import("../acp-agent.js");
ClaudeAcpAgent = acpAgent.ClaudeAcpAgent;
// Create agent instance
agent = new ClaudeAcpAgent(createMockClient());
});
afterEach(() => {
// Restore env and cleanup
if (originalClaudeConfigDir !== undefined) {
process.env.CLAUDE_CONFIG_DIR = originalClaudeConfigDir;
} else {
delete process.env.CLAUDE_CONFIG_DIR;
}
fs.rmSync(tempDir, { recursive: true, force: true });
});
it("returns empty array when projects directory does not exist", async () => {
const result = await agent.unstable_listSessions({});
expect(result.sessions).toEqual([]);
expect(result.nextCursor).toBeUndefined();
});
it("returns empty array when projects directory is empty", async () => {
fs.mkdirSync(path.join(tempDir, "projects"), { recursive: true });
const result = await agent.unstable_listSessions({});
expect(result.sessions).toEqual([]);
});
it("parses session files and returns correct metadata", async () => {
const cwd = "/Users/test/myproject";
const sessionId = "sess-123";
const mtime = new Date("2025-01-15T10:30:00Z");
writeSessionFile(cwd, sessionId, {
userMessage: "Hello, can you help me?",
mtime,
});
const result = await agent.unstable_listSessions({});
expect(result.sessions).toHaveLength(1);
expect(result.sessions[0]).toMatchObject({
sessionId: "sess-123",
cwd: "/Users/test/myproject",
title: "Hello, can you help me?",
});
expect(result.sessions[0]!.updatedAt).toBeDefined();
});
it("extracts title from array-style user message content", async () => {
writeSessionFile("/Users/test/project", "sess-456", {
userMessage: "Fix the bug in auth",
userMessageArray: true,
});
const result = await agent.unstable_listSessions({});
expect(result.sessions[0]!.title).toBe("Fix the bug in auth");
});
it("truncates long titles to 128 characters with ellipsis", async () => {
const longMessage = "A".repeat(150);
writeSessionFile("/Users/test/project", "sess-789", {
userMessage: longMessage,
});
const result = await agent.unstable_listSessions({});
expect(result.sessions[0]!.title).toBe("A".repeat(127) + "…");
});
it("replaces newlines with spaces in titles", async () => {
const messageWithNewlines = "First line\nSecond line\r\nThird line";
writeSessionFile("/Users/test/project", "sess-newlines", {
userMessage: messageWithNewlines,
});
const result = await agent.unstable_listSessions({});
expect(result.sessions[0]!.title).toBe("First line Second line Third line");
});
it("collapses multiple whitespace characters in titles", async () => {
const messageWithWhitespace = "Hello world \n\n test";
writeSessionFile("/Users/test/project", "sess-whitespace", {
userMessage: messageWithWhitespace,
});
const result = await agent.unstable_listSessions({});
expect(result.sessions[0]!.title).toBe("Hello world test");
});
it("filters sessions by cwd parameter", async () => {
writeSessionFile("/Users/test/projectone", "sess-a", { userMessage: "Project A" });
writeSessionFile("/Users/test/projecttwo", "sess-b", { userMessage: "Project B" });
const result = await agent.unstable_listSessions({ cwd: "/Users/test/projectone" });
expect(result.sessions).toHaveLength(1);
expect(result.sessions[0]!.sessionId).toBe("sess-a");
});
it("uses per-entry cwd when filtering even if encoded folder collides", async () => {
const encodedPath = encodeProjectPath("/Users/test/a-b");
const projectDir = path.join(tempDir, "projects", encodedPath);
fs.mkdirSync(projectDir, { recursive: true });
fs.writeFileSync(
path.join(projectDir, "sess-a-b.jsonl"),
[
JSON.stringify({ sessionId: "sess-a-b", type: "init" }),
JSON.stringify({
type: "user",
sessionId: "sess-a-b",
cwd: "/Users/test/a-b",
message: { content: "A-B" },
}),
].join("\n"),
);
fs.writeFileSync(
path.join(projectDir, "sess-a-slash-b.jsonl"),
[
JSON.stringify({ sessionId: "sess-a-slash-b", type: "init" }),
JSON.stringify({
type: "user",
sessionId: "sess-a-slash-b",
cwd: "/Users/test/a/b",
message: { content: "A/B" },
}),
].join("\n"),
);
const result = await agent.unstable_listSessions({ cwd: "/Users/test/a-b" });
expect(result.sessions).toHaveLength(1);
expect(result.sessions[0]!.sessionId).toBe("sess-a-b");
expect(result.sessions[0]!.cwd).toBe("/Users/test/a-b");
});
it("returns the true cwd for paths containing hyphens", async () => {
const cwd = "/Users/test/claude-agent-acp";
writeSessionFile(cwd, "hyphen-session", { userMessage: "Hyphen test" });
const result = await agent.unstable_listSessions({});
expect(result.sessions).toHaveLength(1);
expect(result.sessions[0]!.cwd).toBe(cwd);
expect(result.sessions[0]!.sessionId).toBe("hyphen-session");
});
it("filters hyphenated paths by cwd correctly", async () => {
writeSessionFile("/Users/test/claude-agent-acp", "hyphen-a", { userMessage: "A" });
writeSessionFile("/Users/test/other-project", "hyphen-b", { userMessage: "B" });
const result = await agent.unstable_listSessions({ cwd: "/Users/test/claude-agent-acp" });
expect(result.sessions).toHaveLength(1);
expect(result.sessions[0]!.sessionId).toBe("hyphen-a");
expect(result.sessions[0]!.cwd).toBe("/Users/test/claude-agent-acp");
});
it("returns all sessions when cwd is not specified", async () => {
writeSessionFile("/Users/test/projectone", "sess-a", { userMessage: "A" });
writeSessionFile("/Users/test/projecttwo", "sess-b", { userMessage: "B" });
const result = await agent.unstable_listSessions({});
expect(result.sessions).toHaveLength(2);
});
it("sorts sessions by updatedAt descending (most recent first)", async () => {
writeSessionFile("/Users/test/project", "old-session", {
userMessage: "Old",
mtime: new Date("2025-01-01T00:00:00Z"),
});
writeSessionFile("/Users/test/project", "new-session", {
userMessage: "New",
mtime: new Date("2025-01-15T00:00:00Z"),
});
const result = await agent.unstable_listSessions({});
expect(result.sessions[0]!.sessionId).toBe("new-session");
expect(result.sessions[1]!.sessionId).toBe("old-session");
});
it("skips files starting with agent-", async () => {
writeSessionFile("/Users/test/project", "regular-session", { userMessage: "Regular" });
writeSessionFile("/Users/test/project", "hidden-session", {
userMessage: "Hidden",
isAgentFile: true,
});
const result = await agent.unstable_listSessions({});
expect(result.sessions).toHaveLength(1);
expect(result.sessions[0]!.sessionId).toBe("regular-session");
});
it("skips malformed session files gracefully", async () => {
writeSessionFile("/Users/test/project", "good-session", { userMessage: "Good" });
writeSessionFile("/Users/test/project", "bad-session", { malformed: true });
const result = await agent.unstable_listSessions({});
expect(result.sessions).toHaveLength(1);
expect(result.sessions[0]!.sessionId).toBe("good-session");
});
it("uses filename as sessionId when not in file content", async () => {
const cwd = "/Users/test/project";
const encodedPath = encodeProjectPath(cwd);
const projectDir = path.join(tempDir, "projects", encodedPath);
fs.mkdirSync(projectDir, { recursive: true });
// Write file without sessionId in content (uses filename fallback), but with cwd.
const filePath = path.join(projectDir, "fallback-id.jsonl");
fs.writeFileSync(
filePath,
[
JSON.stringify({ type: "init" }),
JSON.stringify({ type: "assistant", cwd, message: { content: "Hi" } }),
].join("\n"),
);
const result = await agent.unstable_listSessions({});
expect(result.sessions[0]!.sessionId).toBe("fallback-id");
});
it("keeps sessionId stable from filename when later entries have different sessionId", async () => {
const cwd = "/Users/test/project";
const encodedPath = encodeProjectPath(cwd);
const projectDir = path.join(tempDir, "projects", encodedPath);
fs.mkdirSync(projectDir, { recursive: true });
const filePath = path.join(projectDir, "stable-id.jsonl");
fs.writeFileSync(
filePath,
[
JSON.stringify({ type: "init", sessionId: "stable-id" }),
JSON.stringify({
type: "user",
sessionId: "stable-id",
cwd,
message: { content: "Hello" },
}),
JSON.stringify({
type: "assistant",
sessionId: "other-id",
cwd,
message: { content: "Wrong session id from sidechain" },
}),
].join("\n"),
);
const result = await agent.unstable_listSessions({});
expect(result.sessions).toHaveLength(1);
expect(result.sessions[0]!.sessionId).toBe("stable-id");
});
it("derives cwd from entries for the filename sessionId only", async () => {
const cwd = "/Users/test/project";
const encodedPath = encodeProjectPath(cwd);
const projectDir = path.join(tempDir, "projects", encodedPath);
fs.mkdirSync(projectDir, { recursive: true });
const filePath = path.join(projectDir, "target-session.jsonl");
fs.writeFileSync(
filePath,
[
JSON.stringify({
type: "user",
sessionId: "target-session",
cwd,
message: { content: "Correct cwd entry" },
}),
].join("\n"),
);
const result = await agent.unstable_listSessions({});
expect(result.sessions).toHaveLength(1);
expect(result.sessions[0]!.sessionId).toBe("target-session");
expect(result.sessions[0]!.cwd).toBe(cwd);
});
it("keeps scanning after title to find later cwd in same session", async () => {
const cwd = "/Users/test/project";
const encodedPath = encodeProjectPath(cwd);
const projectDir = path.join(tempDir, "projects", encodedPath);
fs.mkdirSync(projectDir, { recursive: true });
const filePath = path.join(projectDir, "late-cwd.jsonl");
fs.writeFileSync(
filePath,
[
JSON.stringify({ type: "init", sessionId: "late-cwd" }),
JSON.stringify({
type: "user",
sessionId: "late-cwd",
message: { content: "Title from first user message" },
}),
JSON.stringify({
type: "assistant",
sessionId: "late-cwd",
cwd,
message: { content: "Assistant entry carries cwd" },
}),
].join("\n"),
);
const result = await agent.unstable_listSessions({});
expect(result.sessions).toHaveLength(1);
expect(result.sessions[0]!.sessionId).toBe("late-cwd");
expect(result.sessions[0]!.cwd).toBe(cwd);
expect(result.sessions[0]!.title).toBe("Title from first user message");
});
it("ignores sidechain entries when deriving cwd and title", async () => {
const cwd = "/Users/test/project";
const encodedPath = encodeProjectPath(cwd);
const projectDir = path.join(tempDir, "projects", encodedPath);
fs.mkdirSync(projectDir, { recursive: true });
const filePath = path.join(projectDir, "sidechain-metadata.jsonl");
fs.writeFileSync(
filePath,
[
JSON.stringify({ type: "init", sessionId: "sidechain-metadata" }),
JSON.stringify({
type: "user",
sessionId: "sidechain-metadata",
isSidechain: true,
cwd: "/Users/test/wrong-project",
message: { content: "Wrong sidechain title" },
}),
JSON.stringify({
type: "user",
sessionId: "sidechain-metadata",
cwd,
message: { content: "Correct primary title" },
}),
].join("\n"),
);
const result = await agent.unstable_listSessions({ cwd });
expect(result.sessions).toHaveLength(1);
expect(result.sessions[0]!.sessionId).toBe("sidechain-metadata");
expect(result.sessions[0]!.cwd).toBe(cwd);
expect(result.sessions[0]!.title).toBe("Correct primary title");
});
it("skips entries without cwd when cwd filter is not provided", async () => {
const cwd = "/Users/test/project";
const encodedPath = encodeProjectPath(cwd);
const projectDir = path.join(tempDir, "projects", encodedPath);
fs.mkdirSync(projectDir, { recursive: true });
const filePath = path.join(projectDir, "no-cwd.jsonl");
fs.writeFileSync(filePath, JSON.stringify({ sessionId: "no-cwd", type: "summary" }) + "\n");
const result = await agent.unstable_listSessions({});
expect(result.sessions).toHaveLength(0);
});
it("returns null title when no user message exists", async () => {
const cwd = "/Users/test/project";
const encodedPath = encodeProjectPath(cwd);
const projectDir = path.join(tempDir, "projects", encodedPath);
fs.mkdirSync(projectDir, { recursive: true });
// Write file with only init and assistant message
const filePath = path.join(projectDir, "no-user.jsonl");
fs.writeFileSync(
filePath,
[
JSON.stringify({ sessionId: "no-user", type: "init" }),
JSON.stringify({ type: "assistant", cwd, message: { content: "Hi" } }),
].join("\n"),
);
const result = await agent.unstable_listSessions({});
expect(result.sessions[0]!.title).toBeNull();
});
describe("pagination", () => {
it("returns nextCursor when there are more results", async () => {
// Create 60 sessions (more than PAGE_SIZE of 50)
for (let i = 0; i < 60; i++) {
writeSessionFile("/Users/test/project", `sess-${i.toString().padStart(3, "0")}`, {
userMessage: `Session ${i}`,
mtime: new Date(Date.now() - i * 1000), // Each older by 1 second
});
}
const result = await agent.unstable_listSessions({});
expect(result.sessions).toHaveLength(50);
expect(result.nextCursor).toBeDefined();
});
it("does not return nextCursor when all results fit in one page", async () => {
for (let i = 0; i < 10; i++) {
writeSessionFile("/Users/test/project", `sess-${i}`, { userMessage: `Session ${i}` });
}
const result = await agent.unstable_listSessions({});
expect(result.sessions).toHaveLength(10);
expect(result.nextCursor).toBeUndefined();
});
it("returns next page when cursor is provided", async () => {
// Create 60 sessions
for (let i = 0; i < 60; i++) {
writeSessionFile("/Users/test/project", `sess-${i.toString().padStart(3, "0")}`, {
userMessage: `Session ${i}`,
mtime: new Date(Date.now() - i * 1000),
});
}
// Get first page
const firstPage = await agent.unstable_listSessions({});
expect(firstPage.sessions).toHaveLength(50);
expect(firstPage.nextCursor).toBeDefined();
// Get second page
const secondPage = await agent.unstable_listSessions({ cursor: firstPage.nextCursor });
expect(secondPage.sessions).toHaveLength(10);
expect(secondPage.nextCursor).toBeUndefined();
});
it("handles invalid cursor gracefully (starts from beginning)", async () => {
writeSessionFile("/Users/test/project", "sess-1", { userMessage: "Test" });
const result = await agent.unstable_listSessions({ cursor: "invalid-base64!" });
expect(result.sessions).toHaveLength(1);
});
it("handles malformed cursor JSON gracefully", async () => {
writeSessionFile("/Users/test/project", "sess-1", { userMessage: "Test" });
// Valid base64 but not valid JSON
const badCursor = Buffer.from("not json").toString("base64");
const result = await agent.unstable_listSessions({ cursor: badCursor });
expect(result.sessions).toHaveLength(1);
});
});
describe("Windows path support", () => {
it("decodes Windows-style paths correctly", async () => {
// Simulate a Windows path encoded as "C-Users-test-project"
const windowsCwd = "C:\\Users\\test\\project";
writeSessionFile(windowsCwd, "win-session", { userMessage: "Windows test" });
const result = await agent.unstable_listSessions({});
expect(result.sessions).toHaveLength(1);
expect(result.sessions[0]!.cwd).toBe("C:\\Users\\test\\project");
expect(result.sessions[0]!.sessionId).toBe("win-session");
});
it("filters Windows paths by cwd correctly", async () => {
writeSessionFile("C:\\Users\\test\\projectone", "win-a", { userMessage: "A" });
writeSessionFile("C:\\Users\\test\\projecttwo", "win-b", { userMessage: "B" });
const result = await agent.unstable_listSessions({ cwd: "C:\\Users\\test\\projectone" });
expect(result.sessions).toHaveLength(1);
expect(result.sessions[0]!.sessionId).toBe("win-a");
});
it("handles mixed Unix and Windows paths", async () => {
writeSessionFile("/Users/test/unixproject", "unix-session", { userMessage: "Unix" });
writeSessionFile("C:\\Users\\test\\winproject", "win-session", { userMessage: "Windows" });
const result = await agent.unstable_listSessions({});
expect(result.sessions).toHaveLength(2);
const cwds = result.sessions.map((s) => s.cwd);
expect(cwds).toContain("/Users/test/unixproject");
expect(cwds).toContain("C:\\Users\\test\\winproject");
});
});
});

View File

@@ -0,0 +1,320 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import * as fs from "node:fs";
import * as path from "node:path";
import * as os from "node:os";
import { AgentSideConnection, SessionNotification } from "@agentclientprotocol/sdk";
import type { ClaudeAcpAgent as ClaudeAcpAgentType } from "../acp-agent.js";
import { encodeProjectPath } from "../utils.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,
};
});
describe("loadSession", () => {
let tempDir: string;
let originalClaudeConfigDir: string | undefined;
let agent: ClaudeAcpAgentType;
let ClaudeAcpAgent: typeof ClaudeAcpAgentType;
let sessionUpdates: SessionNotification[];
let createSessionSpy: ReturnType<typeof vi.fn>;
// Helper to create a mock AgentSideConnection
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 writeSessionFile(
cwd: string,
sessionId: string,
entries: Array<Record<string, unknown> | string>,
): void {
const encodedPath = encodeProjectPath(cwd);
const projectDir = path.join(tempDir, "projects", encodedPath);
fs.mkdirSync(projectDir, { recursive: true });
const filePath = path.join(projectDir, `${sessionId}.jsonl`);
const lines = entries.map((entry) =>
typeof entry === "string" ? entry : JSON.stringify(entry),
);
fs.writeFileSync(filePath, lines.join("\n"));
}
beforeEach(async () => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "claude-load-session-"));
originalClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR;
process.env.CLAUDE_CONFIG_DIR = tempDir;
sessionUpdates = [];
registerHookCallbackSpy.mockClear();
vi.resetModules();
const acpAgent = await import("../acp-agent.js");
ClaudeAcpAgent = acpAgent.ClaudeAcpAgent;
agent = new ClaudeAcpAgent(createMockClient());
createSessionSpy = vi.fn(async () => ({ modes: null, models: null }));
(agent as unknown as { createSession: typeof createSessionSpy }).createSession =
createSessionSpy;
});
afterEach(() => {
if (originalClaudeConfigDir !== undefined) {
process.env.CLAUDE_CONFIG_DIR = originalClaudeConfigDir;
} else {
delete process.env.CLAUDE_CONFIG_DIR;
}
fs.rmSync(tempDir, { recursive: true, force: true });
});
it("throws when the session file does not exist", async () => {
await expect(
agent.loadSession({
cwd: "/Users/test/project",
sessionId: "missing-session",
mcpServers: [],
}),
).rejects.toThrow("Session not found");
expect(createSessionSpy).not.toHaveBeenCalled();
});
it("replays history and returns modes/models from createSession", async () => {
const cwd = "/Users/test/project";
const sessionId = "session-123";
writeSessionFile(cwd, sessionId, [
{ type: "summary", summary: "ignored" },
{
type: "user",
sessionId,
message: { role: "user", content: "Hello" },
},
{
type: "assistant",
sessionId,
message: {
role: "assistant",
content: [{ type: "thinking", thinking: "Thinking" }],
},
},
{
type: "assistant",
sessionId,
message: {
role: "assistant",
content: [{ type: "text", text: "Hi" }],
},
},
{
type: "assistant",
sessionId,
message: {
role: "assistant",
content: [
{
type: "tool_use",
id: "tool-1",
name: "Read",
input: { file_path: "/tmp/example.txt" },
},
],
},
},
{
type: "user",
sessionId,
message: {
role: "user",
content: [{ type: "tool_result", tool_use_id: "tool-1", content: "ok" }],
},
},
]);
const result = await agent.loadSession({ cwd, sessionId, mcpServers: [] });
expect(result).toEqual({ modes: null, models: null });
expect(createSessionSpy).toHaveBeenCalledWith(
expect.objectContaining({ cwd, mcpServers: [] }),
{ resume: sessionId },
);
expect(registerHookCallbackSpy).not.toHaveBeenCalled();
expect(sessionUpdates.map((notification) => notification.update.sessionUpdate)).toEqual([
"user_message_chunk",
"agent_thought_chunk",
"agent_message_chunk",
"tool_call",
"tool_call_update",
]);
expect(sessionUpdates[0]?.update).toMatchObject({
sessionUpdate: "user_message_chunk",
content: { type: "text", text: "Hello" },
});
expect(sessionUpdates[1]?.update).toMatchObject({
sessionUpdate: "agent_thought_chunk",
content: { type: "text", text: "Thinking" },
});
expect(sessionUpdates[2]?.update).toMatchObject({
sessionUpdate: "agent_message_chunk",
content: { type: "text", text: "Hi" },
});
expect(sessionUpdates[3]?.update).toMatchObject({
sessionUpdate: "tool_call",
toolCallId: "tool-1",
status: "pending",
});
expect(sessionUpdates[4]?.update).toMatchObject({
sessionUpdate: "tool_call_update",
toolCallId: "tool-1",
status: "completed",
});
});
it("skips sidechain, mismatched sessionId, invalid, and unsupported entries", async () => {
const cwd = "/Users/test/project";
const sessionId = "session-456";
writeSessionFile(cwd, sessionId, [
"{not valid json}",
{ type: "summary", summary: "ignore" },
{
type: "user",
sessionId,
isSidechain: true,
message: { role: "user", content: "Sidechain" },
},
{
type: "assistant",
sessionId: "other-session",
message: { role: "assistant", content: "Wrong session" },
},
{
type: "assistant",
sessionId,
message: { role: "system", content: "Unsupported role" },
},
{
type: "user",
sessionId,
message: { role: "user", content: 123 },
},
{
type: "user",
sessionId,
message: { role: "user", content: "Keep me" },
},
]);
await agent.loadSession({ cwd, sessionId, mcpServers: [] });
expect(sessionUpdates).toHaveLength(1);
expect(sessionUpdates[0]?.update).toMatchObject({
sessionUpdate: "user_message_chunk",
content: { type: "text", text: "Keep me" },
});
});
it("finds session file when cwd differs from the original session cwd", async () => {
const originalCwd = "/Users/test/original-project";
const differentCwd = "/Users/test/different-project";
const sessionId = "cross-cwd-session";
// Session was created in originalCwd
writeSessionFile(originalCwd, sessionId, [
{
type: "user",
sessionId,
cwd: originalCwd,
message: { role: "user", content: "Hello from original" },
},
]);
// Load with a different cwd
await agent.loadSession({ cwd: differentCwd, sessionId, mcpServers: [] });
expect(sessionUpdates).toHaveLength(1);
expect(sessionUpdates[0]?.update).toMatchObject({
sessionUpdate: "user_message_chunk",
content: { type: "text", text: "Hello from original" },
});
expect(createSessionSpy).toHaveBeenCalledWith(expect.objectContaining({ cwd: differentCwd }), {
resume: sessionId,
});
});
it("sends available_commands_update after history replay, not during", async () => {
const cwd = "/Users/test/project";
const sessionId = "session-commands";
// Mock createSession to populate the sessions map with a mock query
createSessionSpy.mockImplementationOnce(async () => {
(agent as unknown as { sessions: Record<string, unknown> }).sessions[sessionId] = {
query: {
supportedCommands: async () => [{ name: "help", description: "Get help" }],
},
};
return { modes: null, models: null };
});
writeSessionFile(cwd, sessionId, [
{
type: "user",
sessionId,
message: { role: "user", content: "Hello" },
},
{
type: "assistant",
sessionId,
message: { role: "assistant", content: [{ type: "text", text: "Hi" }] },
},
]);
await agent.loadSession({ cwd, sessionId, mcpServers: [] });
// Flush the setTimeout + async work inside sendAvailableCommandsUpdate
await new Promise((resolve) => setTimeout(resolve, 10));
const updateTypes = sessionUpdates.map((n) => n.update.sessionUpdate);
// available_commands_update should come AFTER all replay notifications
expect(updateTypes).toEqual([
"user_message_chunk",
"agent_message_chunk",
"available_commands_update",
]);
});
it("handles Windows-style paths", async () => {
const cwd = "C:\\Users\\test\\project";
const sessionId = "win-session";
writeSessionFile(cwd, sessionId, [
{
type: "user",
sessionId,
message: { role: "user", content: "Hello" },
},
]);
await agent.loadSession({ cwd, sessionId, mcpServers: [] });
expect(sessionUpdates).toHaveLength(1);
expect(sessionUpdates[0]?.sessionId).toBe(sessionId);
});
});

View File

@@ -465,6 +465,41 @@ describe("SettingsManager", () => {
});
expect(envResult.decision).toBe("deny");
});
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");
});
});
describe("ask rules", () => {
@@ -614,4 +649,119 @@ describe("SettingsManager", () => {
await fs.promises.rm(tempDir2, { recursive: true, force: true });
});
});
describe("cross-tool rule isolation", () => {
// These tests verify the fix in commit d471ccf:
// Permission rules should only match their intended tool type.
// A Bash rule should not match Edit/Write/Read operations, and vice versa.
it("should not apply Bash rules to Edit tool calls", async () => {
const claudeDir = path.join(tempDir, ".claude");
await fs.promises.mkdir(claudeDir, { recursive: true });
await fs.promises.writeFile(
path.join(claudeDir, "settings.json"),
JSON.stringify({
permissions: {
// This Bash rule has an argument that looks like a glob pattern.
// Before the fix, it would incorrectly match Edit operations
// because the argument would be interpreted as a file glob.
allow: ["Bash(./**:*)"],
},
}),
);
settingsManager = new SettingsManager(tempDir);
await settingsManager.initialize();
// Bash rule should NOT allow Edit operations
const result = settingsManager.checkPermission("mcp__acp__Edit", {
file_path: path.join(tempDir, "any-file.txt"),
});
expect(result.decision).toBe("ask");
});
it("should not apply Bash rules to Write tool calls", async () => {
const claudeDir = path.join(tempDir, ".claude");
await fs.promises.mkdir(claudeDir, { recursive: true });
await fs.promises.writeFile(
path.join(claudeDir, "settings.json"),
JSON.stringify({
permissions: {
allow: ["Bash(./**:*)"],
},
}),
);
settingsManager = new SettingsManager(tempDir);
await settingsManager.initialize();
const result = settingsManager.checkPermission("mcp__acp__Write", {
file_path: path.join(tempDir, "any-file.txt"),
});
expect(result.decision).toBe("ask");
});
it("should not apply Bash rules to Read tool calls", async () => {
const claudeDir = path.join(tempDir, ".claude");
await fs.promises.mkdir(claudeDir, { recursive: true });
await fs.promises.writeFile(
path.join(claudeDir, "settings.json"),
JSON.stringify({
permissions: {
allow: ["Bash(./**:*)"],
},
}),
);
settingsManager = new SettingsManager(tempDir);
await settingsManager.initialize();
const result = settingsManager.checkPermission("mcp__acp__Read", {
file_path: path.join(tempDir, "any-file.txt"),
});
expect(result.decision).toBe("ask");
});
it("should not apply Edit rules to Bash tool calls", async () => {
const claudeDir = path.join(tempDir, ".claude");
await fs.promises.mkdir(claudeDir, { recursive: true });
await fs.promises.writeFile(
path.join(claudeDir, "settings.json"),
JSON.stringify({
permissions: {
allow: ["Edit"],
},
}),
);
settingsManager = new SettingsManager(tempDir);
await settingsManager.initialize();
const result = settingsManager.checkPermission("mcp__acp__Bash", {
command: "echo hello",
});
expect(result.decision).toBe("ask");
});
it("should not apply Read rules to Bash tool calls", async () => {
const claudeDir = path.join(tempDir, ".claude");
await fs.promises.mkdir(claudeDir, { recursive: true });
await fs.promises.writeFile(
path.join(claudeDir, "settings.json"),
JSON.stringify({
permissions: {
allow: ["Read"],
},
}),
);
settingsManager = new SettingsManager(tempDir);
await settingsManager.initialize();
const result = settingsManager.checkPermission("mcp__acp__Bash", {
command: "ls -la",
});
expect(result.decision).toBe("ask");
});
});
});

318
src/tests/tools.test.ts Normal file
View File

@@ -0,0 +1,318 @@
import { describe, it, expect } from "vitest";
import { AgentSideConnection } from "@agentclientprotocol/sdk";
import { ToolResultBlockParam } from "@anthropic-ai/sdk/resources";
import {
BetaMCPToolResultBlock,
BetaTextBlock,
BetaWebSearchResultBlock,
BetaWebSearchToolResultBlock,
BetaBashCodeExecutionToolResultBlock,
BetaBashCodeExecutionResultBlock,
} from "@anthropic-ai/sdk/resources/beta.mjs";
import { toAcpNotifications, ToolUseCache, Logger } from "../acp-agent.js";
describe("rawOutput in tool call updates", () => {
const mockClient = {} as AgentSideConnection;
const mockLogger: Logger = { log: () => {}, error: () => {} };
it("should include rawOutput with string content for tool_result", () => {
const toolUseCache: ToolUseCache = {
toolu_123: {
type: "tool_use",
id: "toolu_123",
name: "Bash",
input: { command: "echo hello" },
},
};
const toolResult: ToolResultBlockParam = {
type: "tool_result",
tool_use_id: "toolu_123",
content: "hello\n",
is_error: false,
};
const notifications = toAcpNotifications(
[toolResult],
"assistant",
"test-session",
toolUseCache,
mockClient,
mockLogger,
);
expect(notifications).toHaveLength(1);
expect(notifications[0].update).toMatchObject({
sessionUpdate: "tool_call_update",
toolCallId: "toolu_123",
status: "completed",
rawOutput: "hello\n",
});
});
it("should include rawOutput with array content for tool_result", () => {
const toolUseCache: ToolUseCache = {
toolu_456: {
type: "tool_use",
id: "toolu_456",
name: "Read",
input: { file_path: "/test/file.txt" },
},
};
// ToolResultBlockParam content can be string or array of TextBlockParam
const toolResult: ToolResultBlockParam = {
type: "tool_result",
tool_use_id: "toolu_456",
content: [{ type: "text", text: "Line 1\nLine 2\nLine 3" }],
is_error: false,
};
const notifications = toAcpNotifications(
[toolResult],
"assistant",
"test-session",
toolUseCache,
mockClient,
mockLogger,
);
expect(notifications).toHaveLength(1);
expect(notifications[0].update).toMatchObject({
sessionUpdate: "tool_call_update",
toolCallId: "toolu_456",
status: "completed",
rawOutput: [{ type: "text", text: "Line 1\nLine 2\nLine 3" }],
});
});
it("should include rawOutput for mcp_tool_result with string content", () => {
const toolUseCache: ToolUseCache = {
toolu_789: {
type: "tool_use",
id: "toolu_789",
name: "mcp__server__tool",
input: { query: "test" },
},
};
// BetaMCPToolResultBlock content can be string or Array<BetaTextBlock>
const toolResult: BetaMCPToolResultBlock = {
type: "mcp_tool_result",
tool_use_id: "toolu_789",
content: '{"result": "success", "data": [1, 2, 3]}',
is_error: false,
};
const notifications = toAcpNotifications(
[toolResult],
"assistant",
"test-session",
toolUseCache,
mockClient,
mockLogger,
);
expect(notifications).toHaveLength(1);
expect(notifications[0].update).toMatchObject({
sessionUpdate: "tool_call_update",
toolCallId: "toolu_789",
status: "completed",
rawOutput: '{"result": "success", "data": [1, 2, 3]}',
});
});
it("should include rawOutput for mcp_tool_result with array content", () => {
const toolUseCache: ToolUseCache = {
toolu_abc: {
type: "tool_use",
id: "toolu_abc",
name: "mcp__server__search",
input: { term: "test" },
},
};
// BetaTextBlock requires citations field
const arrayContent: BetaTextBlock[] = [
{ type: "text", text: "Result 1", citations: null },
{ type: "text", text: "Result 2", citations: null },
];
const toolResult: BetaMCPToolResultBlock = {
type: "mcp_tool_result",
tool_use_id: "toolu_abc",
content: arrayContent,
is_error: false,
};
const notifications = toAcpNotifications(
[toolResult],
"assistant",
"test-session",
toolUseCache,
mockClient,
mockLogger,
);
expect(notifications).toHaveLength(1);
expect(notifications[0].update).toMatchObject({
sessionUpdate: "tool_call_update",
toolCallId: "toolu_abc",
status: "completed",
rawOutput: arrayContent,
});
});
it("should include rawOutput for web_search_tool_result", () => {
const toolUseCache: ToolUseCache = {
toolu_web: {
type: "tool_use",
id: "toolu_web",
name: "WebSearch",
input: { query: "test search" },
},
};
// BetaWebSearchResultBlock from SDK
const searchResults: BetaWebSearchResultBlock[] = [
{
type: "web_search_result",
url: "https://example.com",
title: "Example",
encrypted_content: "encrypted content here",
page_age: "2 days ago",
},
];
const toolResult: BetaWebSearchToolResultBlock = {
type: "web_search_tool_result",
tool_use_id: "toolu_web",
content: searchResults,
};
const notifications = toAcpNotifications(
[toolResult],
"assistant",
"test-session",
toolUseCache,
mockClient,
mockLogger,
);
expect(notifications).toHaveLength(1);
expect(notifications[0].update).toMatchObject({
sessionUpdate: "tool_call_update",
toolCallId: "toolu_web",
status: "completed",
rawOutput: searchResults,
});
});
it("should include rawOutput for bash_code_execution_tool_result", () => {
const toolUseCache: ToolUseCache = {
toolu_bash: {
type: "tool_use",
id: "toolu_bash",
name: "Bash",
input: { command: "ls -la" },
},
};
// BetaBashCodeExecutionResultBlock from SDK
const bashResult: BetaBashCodeExecutionResultBlock = {
type: "bash_code_execution_result",
stdout: "file1.txt\nfile2.txt",
stderr: "",
return_code: 0,
content: [],
};
const toolResult: BetaBashCodeExecutionToolResultBlock = {
type: "bash_code_execution_tool_result",
tool_use_id: "toolu_bash",
content: bashResult,
};
const notifications = toAcpNotifications(
[toolResult],
"assistant",
"test-session",
toolUseCache,
mockClient,
mockLogger,
);
expect(notifications).toHaveLength(1);
expect(notifications[0].update).toMatchObject({
sessionUpdate: "tool_call_update",
toolCallId: "toolu_bash",
status: "completed",
rawOutput: bashResult,
});
});
it("should set status to failed when is_error is true", () => {
const toolUseCache: ToolUseCache = {
toolu_err: {
type: "tool_use",
id: "toolu_err",
name: "Bash",
input: { command: "invalid_command" },
},
};
const toolResult: ToolResultBlockParam = {
type: "tool_result",
tool_use_id: "toolu_err",
content: "command not found: invalid_command",
is_error: true,
};
const notifications = toAcpNotifications(
[toolResult],
"assistant",
"test-session",
toolUseCache,
mockClient,
mockLogger,
);
expect(notifications).toHaveLength(1);
expect(notifications[0].update).toMatchObject({
sessionUpdate: "tool_call_update",
toolCallId: "toolu_err",
status: "failed",
rawOutput: "command not found: invalid_command",
});
});
it("should not emit tool_call_update for TodoWrite (emits plan instead)", () => {
const toolUseCache: ToolUseCache = {
toolu_todo: {
type: "tool_use",
id: "toolu_todo",
name: "TodoWrite",
input: { todos: [{ content: "Test task", status: "pending" }] },
},
};
const toolResult: ToolResultBlockParam = {
type: "tool_result",
tool_use_id: "toolu_todo",
content: "Todos updated successfully",
is_error: false,
};
const notifications = toAcpNotifications(
[toolResult],
"assistant",
"test-session",
toolUseCache,
mockClient,
mockLogger,
);
// TodoWrite should not emit tool_call_update - it emits plan updates instead
expect(notifications).toHaveLength(0);
});
});

View File

@@ -0,0 +1,558 @@
import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from "vitest";
import * as fs from "node:fs";
import * as path from "node:path";
import * as os from "node:os";
import { spawnSync } from "child_process";
describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)(
"TypeScript declaration files integration",
() => {
let tempDir: string;
let tarballPath: string;
const projectRoot = path.resolve(__dirname, "../..");
// Base configuration templates
const basePackageJson = {
name: "ts-declaration-test",
version: "1.0.0",
type: "module",
dependencies: {},
devDependencies: {
typescript: "5.9.3",
"@types/node": "25.0.3",
},
};
const baseTsConfig = {
compilerOptions: {
target: "ES2020",
module: "NodeNext",
moduleResolution: "NodeNext",
lib: ["ES2020"],
strict: true,
esModuleInterop: true,
skipLibCheck: true, // Skip checking dependency internals, focus on our types
noEmit: true,
declaration: false,
},
include: ["*.ts"],
};
// Build and pack the package once for all tests
beforeAll(async () => {
// Step 1: Clean dist folder to ensure fresh build
const distPath = path.join(projectRoot, "dist");
await fs.promises.rm(distPath, { recursive: true, force: true });
console.log("Cleaned dist folder");
console.log("Building package...");
// Step 2: Build the package
const buildResult = spawnSync("npm", ["run", "build"], {
cwd: projectRoot,
stdio: "pipe",
encoding: "utf-8",
});
if (buildResult.status !== 0) {
throw new Error(`Build failed: ${buildResult.stderr || buildResult.stdout}`);
}
console.log("Packing package...");
// Step 3: Pack to create tarball
const packResult = spawnSync("npm", ["pack", "--pack-destination", os.tmpdir()], {
cwd: projectRoot,
stdio: "pipe",
encoding: "utf-8",
});
if (packResult.status !== 0) {
throw new Error(`Pack failed: ${packResult.stderr || packResult.stdout}`);
}
// Get the tarball filename from stdout (npm pack outputs the filename)
const tarballName = packResult.stdout.trim().split("\n").pop();
if (!tarballName) {
throw new Error("Failed to get tarball name from npm pack output");
}
tarballPath = path.join(os.tmpdir(), tarballName);
console.log(`Tarball created at: ${tarballPath}`);
}, 60000); // 60 second timeout for build
// Clean up the tarball after all tests
afterAll(async () => {
if (tarballPath && fs.existsSync(tarballPath)) {
await fs.promises.unlink(tarballPath);
console.log("Cleaned up tarball");
}
});
// Create fresh temp directory for each test
beforeEach(async () => {
tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "ts-declaration-test-"));
});
// Clean up temp directory after each test
afterEach(async () => {
if (tempDir) {
await fs.promises.rm(tempDir, { recursive: true, force: true });
}
});
// Helper function to set up a test TypeScript project
async function setupTestProject(
packageJson: object = basePackageJson,
tsconfig: object = baseTsConfig,
): Promise<void> {
// Write package.json
await fs.promises.writeFile(
path.join(tempDir, "package.json"),
JSON.stringify(packageJson, null, 2),
);
// Write tsconfig.json
await fs.promises.writeFile(
path.join(tempDir, "tsconfig.json"),
JSON.stringify(tsconfig, null, 2),
);
// Install all dependencies (TypeScript, @types/node, and the tarball)
console.log(`Installing dependencies in ${tempDir}...`);
const installResult = spawnSync("npm", ["install", tarballPath], {
cwd: tempDir,
stdio: "pipe",
encoding: "utf-8",
timeout: 60000, // 60 second timeout
});
if (installResult.status !== 0) {
throw new Error(`npm install failed: ${installResult.stderr || installResult.stdout}`);
}
}
// Helper function to run TypeScript type checking
function runTypeCheck(srcDir: string = tempDir): {
success: boolean;
output: string;
} {
const result = spawnSync("npx", ["tsc", "--noEmit"], {
cwd: srcDir,
stdio: "pipe",
encoding: "utf-8",
timeout: 30000, // 30 second timeout
});
return {
success: result.status === 0,
output: result.stdout + result.stderr,
};
}
// Helper function to write a test TypeScript file
async function writeTestFile(filename: string, content: string): Promise<void> {
await fs.promises.writeFile(path.join(tempDir, filename), content);
}
// Test 1: Main exports import verification
it("should successfully type-check main exports", async () => {
await setupTestProject();
await writeTestFile(
"test-main-exports.ts",
`
import {
ClaudeAcpAgent,
runAcp,
toAcpNotifications,
streamEventToAcpNotifications,
SettingsManager,
createMcpServer,
loadManagedSettings,
applyEnvironmentSettings,
nodeToWebReadable,
nodeToWebWritable,
Pushable,
unreachable,
toolInfoFromToolUse,
planEntries,
toolUpdateFromToolResult,
createPreToolUseHook,
toolNames,
} from "@zed-industries/claude-agent-acp";
// Type-only imports
import type {
ToolUpdateMeta,
NewSessionMeta,
ClaudeCodeSettings,
PermissionSettings,
PermissionDecision,
PermissionCheckResult,
SettingsManagerOptions,
ClaudePlanEntry,
} from "@zed-industries/claude-agent-acp";
// Verify exports exist and have expected types
const _runAcp: typeof runAcp = runAcp;
const _createMcpServer: typeof createMcpServer = createMcpServer;
const _toolNames: typeof toolNames = toolNames;
`,
);
const result = runTypeCheck();
if (!result.success) {
console.error("TypeScript errors:", result.output);
}
expect(result.success).toBe(true);
}, 120000);
// Test 2: Deep imports verification (backwards compatibility)
it("should successfully type-check deep imports", async () => {
await setupTestProject();
await writeTestFile(
"test-deep-imports.ts",
`
// Deep import from dist/tools.js
import {
acpToolNames,
EDIT_TOOL_NAMES,
ACP_TOOL_NAME_PREFIX,
toolInfoFromToolUse,
planEntries,
} from "@zed-industries/claude-agent-acp/dist/tools.js";
// Deep import from dist/settings.js
import {
SettingsManager,
getManagedSettingsPath,
} from "@zed-industries/claude-agent-acp/dist/settings.js";
// Deep import from dist/utils.js
import {
Pushable,
nodeToWebReadable,
nodeToWebWritable,
loadManagedSettings,
} from "@zed-industries/claude-agent-acp/dist/utils.js";
// Verify types work
const prefix: string = ACP_TOOL_NAME_PREFIX;
const editTools: readonly string[] = EDIT_TOOL_NAMES;
`,
);
const result = runTypeCheck();
if (!result.success) {
console.error("TypeScript errors:", result.output);
}
expect(result.success).toBe(true);
}, 120000);
// Test 3: SettingsManager type shape verification
it("should verify SettingsManager has correct type shape", async () => {
await setupTestProject();
await writeTestFile(
"test-settings-manager.ts",
`
import {
SettingsManager,
ClaudeCodeSettings,
PermissionCheckResult,
SettingsManagerOptions
} from "@zed-industries/claude-agent-acp";
// Test constructor signature
const options: SettingsManagerOptions = {
onChange: () => {},
logger: { log: console.log, error: console.error },
};
declare const cwd: string;
const manager = new SettingsManager(cwd, options);
// Test method signatures
async function testMethods() {
// initialize returns Promise<void>
await manager.initialize();
// checkPermission returns PermissionCheckResult
const result: PermissionCheckResult = manager.checkPermission(
"mcp__acp__Read",
{ file_path: "/some/path" }
);
// Verify decision type
const decision: "allow" | "deny" | "ask" = result.decision;
const rule: string | undefined = result.rule;
const source: "allow" | "deny" | "ask" | undefined = result.source;
// getSettings returns ClaudeCodeSettings
const settings: ClaudeCodeSettings = manager.getSettings();
// getCwd returns string
const currentCwd: string = manager.getCwd();
// setCwd returns Promise<void>
await manager.setCwd("/new/path");
// dispose returns void
manager.dispose();
}
`,
);
const result = runTypeCheck();
if (!result.success) {
console.error("TypeScript errors:", result.output);
}
expect(result.success).toBe(true);
}, 120000);
// Test 4: ClaudeAcpAgent instantiation and type verification
it("should verify ClaudeAcpAgent has correct type shape", async () => {
await setupTestProject();
await writeTestFile(
"test-claude-acp-agent.ts",
`
import { ClaudeAcpAgent } from "@zed-industries/claude-agent-acp";
import type { AgentSideConnection } from "@agentclientprotocol/sdk";
// ClaudeAcpAgent should be a class that can be instantiated
declare const mockConnection: AgentSideConnection;
declare const mockLogger: { log: (...args: any[]) => void; error: (...args: any[]) => void };
// Test constructor - accepts AgentSideConnection and optional logger
const agent = new ClaudeAcpAgent(mockConnection, mockLogger);
// Verify it has sessions property
const sessions: Record<string, any> = agent.sessions;
// Verify it has client property
const client: AgentSideConnection = agent.client;
// Verify it has logger property
const logger = agent.logger;
`,
);
const result = runTypeCheck();
if (!result.success) {
console.error("TypeScript errors:", result.output);
}
expect(result.success).toBe(true);
}, 120000);
// Test 5: Type-only exports work correctly
it("should verify type-only exports are usable", async () => {
await setupTestProject();
await writeTestFile(
"test-type-exports.ts",
`
import type {
ClaudeCodeSettings,
PermissionSettings,
PermissionDecision,
PermissionCheckResult,
SettingsManagerOptions,
ClaudePlanEntry,
ToolUpdateMeta,
NewSessionMeta,
} from "@zed-industries/claude-agent-acp";
// Test ClaudeCodeSettings shape
const settings: ClaudeCodeSettings = {
permissions: {
allow: ["Read"],
deny: ["Read(./.env)"],
ask: ["Bash"],
additionalDirectories: ["/extra"],
defaultMode: "default",
},
env: {
API_KEY: "secret",
},
};
// Test PermissionSettings shape
const perms: PermissionSettings = {
allow: ["Read"],
deny: ["Write"],
};
// Test PermissionDecision
const decisions: PermissionDecision[] = ["allow", "deny", "ask"];
// Test ClaudePlanEntry shape
const planEntry: ClaudePlanEntry = {
content: "Do something",
status: "pending",
activeForm: "Doing something",
};
// Test valid status values
const validStatuses: ClaudePlanEntry["status"][] = [
"pending",
"in_progress",
"completed",
];
// Test ToolUpdateMeta shape
const toolMeta: ToolUpdateMeta = {
claudeCode: {
toolName: "Read",
toolResponse: { success: true },
},
};
// Test NewSessionMeta shape
const sessionMeta: NewSessionMeta = {
claudeCode: {
options: {},
},
};
`,
);
const result = runTypeCheck();
if (!result.success) {
console.error("TypeScript errors:", result.output);
}
expect(result.success).toBe(true);
}, 120000);
// Test 6: Function signatures verification
it("should verify function signatures are correct", async () => {
await setupTestProject();
await writeTestFile(
"test-function-signatures.ts",
`
import {
runAcp,
createMcpServer,
toolInfoFromToolUse,
planEntries,
createPreToolUseHook,
loadManagedSettings,
applyEnvironmentSettings,
SettingsManager,
} from "@zed-industries/claude-agent-acp";
import type { ClaudeCodeSettings } from "@zed-industries/claude-agent-acp";
// runAcp should be a function with no parameters that returns void
const runAcpType: () => void = runAcp;
// toolInfoFromToolUse should accept any and return object with title and kind
const info = toolInfoFromToolUse({ name: "Read", input: {} });
const title: string = info.title;
const kind: string = info.kind;
// planEntries should accept todos array and return array
const entries = planEntries({
todos: [
{ content: "test", status: "pending", activeForm: "testing" }
]
});
// entries should be an array
const entriesArray: any[] = entries;
// createPreToolUseHook should accept SettingsManager and optional logger
declare const settingsManager: SettingsManager;
const hook = createPreToolUseHook(settingsManager, console);
// loadManagedSettings should return ClaudeCodeSettings | null
const managedSettings: ClaudeCodeSettings | null = loadManagedSettings();
// applyEnvironmentSettings should accept ClaudeCodeSettings and return void
const applyResult: void = applyEnvironmentSettings({ permissions: {} });
`,
);
const result = runTypeCheck();
if (!result.success) {
console.error("TypeScript errors:", result.output);
}
expect(result.success).toBe(true);
}, 120000);
// Test 7: Pushable generic class verification
it("should verify Pushable class works correctly", async () => {
await setupTestProject();
await writeTestFile(
"test-pushable.ts",
`
import { Pushable } from "@zed-industries/claude-agent-acp";
// Pushable should be a generic class
const pushable = new Pushable<string>();
// Should have push method
pushable.push("test");
// Should have end method
pushable.end();
// Should implement AsyncIterable
async function consume() {
for await (const item of pushable) {
const str: string = item;
}
}
// Generic type parameter should work
const numPushable = new Pushable<number>();
numPushable.push(42);
interface MyType { id: number; name: string; }
const customPushable = new Pushable<MyType>();
customPushable.push({ id: 1, name: "test" });
`,
);
const result = runTypeCheck();
if (!result.success) {
console.error("TypeScript errors:", result.output);
}
expect(result.success).toBe(true);
}, 120000);
// Test 8: Verify incorrect types fail
it("should fail type-check with incorrect types", async () => {
await setupTestProject();
await writeTestFile(
"test-invalid-types.ts",
`
import { SettingsManager, ClaudeCodeSettings } from "@zed-industries/claude-agent-acp";
// This should fail - SettingsManager constructor requires string cwd
// @ts-expect-error - Testing that wrong argument type fails
const badManager = new SettingsManager(123);
// This should fail - ClaudeCodeSettings permissions must be an object
const badSettings: ClaudeCodeSettings = {
// @ts-expect-error - Testing that wrong type fails
permissions: "not-an-object",
};
`,
);
// This test should PASS because we expect tsc to catch these errors
// with @ts-expect-error directives
const result = runTypeCheck();
if (!result.success) {
// If it fails, it means @ts-expect-error didn't catch the error
// which could mean the types are too permissive
console.error("TypeScript errors (expected @ts-expect-error to catch):", result.output);
}
expect(result.success).toBe(true);
}, 120000);
},
);

View File

@@ -1,6 +1,44 @@
import { PlanEntry, ToolCallContent, ToolCallLocation, ToolKind } from "@agentclientprotocol/sdk";
import { replaceAndCalculateLocation, SYSTEM_REMINDER } from "./mcp-server.js";
import { ToolResultBlockParam, WebSearchToolResultBlockParam } from "@anthropic-ai/sdk/resources";
import {
ContentBlock,
PlanEntry,
ToolCallContent,
ToolCallLocation,
ToolKind,
} from "@agentclientprotocol/sdk";
import { SYSTEM_REMINDER } from "./mcp-server.js";
import * as diff from "diff";
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";
const acpUnqualifiedToolNames = {
read: "Read",
@@ -23,15 +61,29 @@ export const acpToolNames = {
export const EDIT_TOOL_NAMES = [acpToolNames.edit, acpToolNames.write];
import {
BetaBashCodeExecutionToolResultBlockParam,
BetaCodeExecutionToolResultBlockParam,
BetaRequestMCPToolResultBlockParam,
BetaTextEditorCodeExecutionToolResultBlockParam,
BetaToolSearchToolResultBlockParam,
BetaWebFetchToolResultBlockParam,
BetaWebSearchToolResultBlockParam,
} 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 { SettingsManager } from "./settings.js";
@@ -49,11 +101,7 @@ interface ToolUpdate {
locations?: ToolCallLocation[];
}
export function toolInfoFromToolUse(
toolUse: any,
cachedFileContent: { [key: string]: string },
logger: Logger = console,
): ToolInfo {
export function toolInfoFromToolUse(toolUse: any): ToolInfo {
const name = toolUse.name;
const input = toolUse.input;
@@ -178,27 +226,7 @@ export function toolInfoFromToolUse(
case acpToolNames.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) {
logger.error(e);
}
}
return {
title: path ? `Edit \`${path}\`` : "Edit",
kind: "edit",
@@ -208,16 +236,12 @@ export function toolInfoFromToolUse(
{
type: "diff",
path,
oldText,
newText,
oldText: input.old_string ?? null,
newText: input.new_string ?? "",
},
]
: [],
locations: path
? affectedLines.length > 0
? affectedLines.map((line) => ({ line, path }))
: [{ path }]
: [],
locations: path ? [{ path }] : undefined,
};
}
@@ -332,7 +356,9 @@ export function toolInfoFromToolUse(
label += " -P";
}
label += ` "${input.pattern}"`;
if (input.pattern) {
label += ` "${input.pattern}"`;
}
if (input.path) {
label += ` ${input.path}`;
@@ -431,6 +457,7 @@ export function toolInfoFromToolUse(
export function toolUpdateFromToolResult(
toolResult:
| ToolResultBlockParam
| BetaToolResultBlockParam
| BetaWebSearchToolResultBlockParam
| BetaWebFetchToolResultBlockParam
| WebSearchToolResultBlockParam
@@ -441,6 +468,16 @@ export function toolUpdateFromToolResult(
| BetaToolSearchToolResultBlockParam,
toolUse: any | undefined,
): 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 acpToolNames.read:
@@ -472,21 +509,60 @@ export function toolUpdateFromToolResult(
}
return {};
case acpToolNames.edit: {
const content: ToolCallContent[] = [];
const locations: ToolCallLocation[] = [];
if (
Array.isArray(toolResult.content) &&
toolResult.content.length > 0 &&
"text" in toolResult.content[0] &&
typeof toolResult.content[0].text === "string"
) {
const patches = diff.parsePatch(toolResult.content[0].text);
console.error(JSON.stringify(patches));
for (const { oldFileName, newFileName, hunks } of patches) {
for (const { lines, newStart } of hunks) {
const oldText = [];
const newText = [];
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: newFileName || oldFileName, line: newStart });
content.push({
type: "diff",
path: newFileName || oldFileName,
oldText: oldText.join("\n") || null,
newText: newText.join("\n"),
});
}
}
}
}
const result: ToolUpdate = {};
if (content.length > 0) {
result.content = content;
}
if (locations.length > 0) {
result.locations = locations;
}
return result;
}
case acpToolNames.bash:
case "edit":
case "Edit":
case acpToolNames.edit:
case acpToolNames.write:
case "Write": {
if (
"is_error" in toolResult &&
toolResult.is_error &&
toolResult.content &&
toolResult.content.length > 0
) {
// Only return errors
return toAcpContentUpdate(toolResult.content, true);
}
return {};
}
@@ -523,17 +599,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: [
@@ -550,6 +629,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";
@@ -605,16 +752,28 @@ export const registerHookCallback = (
/* A callback for Claude Code that is called when receiving a PostToolUse hook */
export const createPostToolUseHook =
(logger: Logger = console): HookCallback =>
(
logger: Logger = console,
options?: {
onEnterPlanMode?: () => Promise<void>;
},
): HookCallback =>
async (input: any, toolUseID: string | undefined): Promise<{ continue: boolean }> => {
if (input.hook_event_name === "PostToolUse" && 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];
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

@@ -133,7 +133,6 @@ export function extractLinesWithByteLimit(
let linesSeen = 0;
let index = 0;
linesSeen = 0;
let contentLength = 0;
let wasLimited = false;
@@ -169,3 +168,10 @@ export function extractLinesWithByteLimit(
linesRead: linesSeen,
};
}
// Helper to encode a path like Claude does:
// - Unix: "/Users/test" -> "-Users-test"
// - Windows: "C:\Users\test" -> "C--Users-test"
export function encodeProjectPath(cwd: string): string {
return cwd.replace(/[^a-zA-Z0-9]/g, "-");
}

View File

@@ -9,7 +9,9 @@
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true,
"outDir": "dist",
"rootDir": "src"
"rootDir": "src",
"declaration": true,
"declarationMap": true
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist"]