Compare commits

...

107 Commits

Author SHA1 Message Date
Ben Brandt
2f811f8a85 Update to claude-agent-sdk 0.1.19 (#100) 2025-10-16 12:22:31 +00:00
Ben Brandt
6aa6537f67 Fix initialization error (#99)
Closes #98

It turns out we can't always call this right away, as on some machines
the process hasn't initialized yet.
Since available models waits for the process to start, as a workaround
we wait for that first, and then set the permission mode again once we
know that it is ready.
2025-10-16 12:17:32 +00:00
Ben Brandt
1b72ac7c8a Fix for allowing bypassPermissions to work (#97)
It seems that unless you start the session with bypassPermissions, you
can't change to it later.

So we start with it on, and immediately change it back to default. This
allows people to change it later however.

Closes https://github.com/zed-industries/zed/issues/39392
2025-10-15 13:23:26 +00:00
Ben Brandt
6ae2c05ddb Prep v0.6.0 (#96) 2025-10-15 10:25:43 +00:00
Bennet Fenner
8f0fc59a48 Add model selection support (#93)
This part of ACP is not yet stabilized, so marking as Draft for now

---------

Co-authored-by: Ben Brandt <benjamin.j.brandt@gmail.com>
Co-authored-by: Smit Barmase <heysmitbarmase@gmail.com>
2025-10-15 12:21:30 +02:00
Ben Brandt
c25150523a Update claude-agent-sdk to 0.1.15 (#95)
- **Update claude-agent-sdk to 0.1.15**
- **v0.5.6**
2025-10-15 09:05:16 +00:00
WhiskeyJack96
ea796f3cbd Add support for appending or overriding the system prompt (#91)
resolves #90 

Tested both the `append` and `override` flows by sending:
```javascript
const resp = await this.connection.newSession({
			cwd: basePath,
			_meta: {
				systemPrompt: "you are an obsidian assistant agent, you should be helpful to the user when working with their notes"
			},
			mcpServers: []
		});
```

```javascript
const resp = await this.connection.newSession({
			cwd: basePath,
			_meta: {
				systemPrompt: {
					append: "always speak in spanish"
				}
			},
			mcpServers: []
		});
```

and confirmed that in both cases those prompts are what gets sent to the
agent!

---------

Co-authored-by: Ben Brandt <benjamin.j.brandt@gmail.com>
2025-10-15 10:48:06 +02:00
WhiskeyJack96
7f5ebca3fb Make write and edit permissions consistent (#92)
Currently edit requires a permissions call and write does not. This
means that if clients want to allow users to approve `Write` calls they
need to add handling to `HandleFileWrite` but if they do that then edits
get _two_ permissions checks.

Alternatively we could remove the write tool from approved tools (which
would make more sense to me) but I'm assuming its there for a good
reason, if not happy to change to remove it instead!

---------

Co-authored-by: Ben Brandt <benjamin.j.brandt@gmail.com>
2025-10-15 10:34:49 +02:00
Ben Brandt
75b44e945e Update to claude-agent-sdk 0.1.13 (#89) 2025-10-10 14:43:44 +00:00
Ben Brandt
ad8611c399 Migrate to @agentclientprotocol/sdk (#88) 2025-10-10 16:17:37 +02:00
ozer
c5abff0e43 Improve setSessionMode error handling for SDK messages (#85)
When `default`, `acceptEdits`, or `plan` modes fail, users see the real
error from the SDK instead of a generic message.

`Bypass permissions` gets special treatment - it shows both the SDK
error AND explains it needs proper configuration. I couldn't test SDK
thats why I added special treatment for bypass permissions. If you can
test it we can talk further.

Related to: https://github.com/zed-industries/zed/pull/39524 @benbrandt
2025-10-09 12:49:44 +02:00
Ben Brandt
5c98bd0404 Update to claude-agent-sdk@1.0.11 (#87)
Signed-off-by: Ben Brandt <benjamin.j.brandt@gmail.com>
2025-10-09 08:30:56 +00:00
Alfredo Luque
71b660ae3b Enable setting CLAUDE_CODE_EXECUTABLE to override the claude code CLI used (#86)
## Description
Closes #84 by allowing users to set `CLAUDE_CODE_EXECUTABLE` to override
the binary that's used by the claude code SDK. This is key in enterprise
environments where claude CLI has been shimmed in some way.
2025-10-07 22:54:50 +02:00
Ben Brandt
4ee25af3f9 Update to claude-agent-sdk 0.1.8 (#83) 2025-10-06 09:01:40 +00:00
Ben Brandt
1a5a9c16c6 Add back @anthropic-ai/claude-code as runtime dependency (#79)
Some clients (i.e. Zed) still relied on this package being there at
runtime.
Adding it as a dependency for now.
2025-09-30 16:46:04 +02:00
Ben Brandt
5ca27369ef Prep v0.5.1 (#78) 2025-09-30 09:19:37 +00:00
Ben Brandt
8068d894c1 Align ACP tools with latest Claude Code tools (#77)
Signed-off-by: Ben Brandt <benjamin.j.brandt@gmail.com>
2025-09-30 11:08:24 +02:00
Ben Brandt
71e745d5fb Use default behavior for bypassPermissions mode (#75)
They updated the types to allow us to just pass this through now. So we
shouldn't have to have the special casing here.
2025-09-29 23:13:12 +02:00
Ben Brandt
2aa181be27 Migrate to Claude Agent SDK (#74)
- **Migrate to Claude Agent SDK**
- **Prep v0.5**
2025-09-29 21:58:38 +02:00
Ben Brandt
600a2db82f More efficient file reads (#73)
Allows the client to handle line offsets, rather than loading the entire
file and then slicing it on the agent side.

Signed-off-by: Ben Brandt <benjamin.j.brandt@gmail.com>

Signed-off-by: Ben Brandt <benjamin.j.brandt@gmail.com>
2025-09-29 13:54:30 +02:00
Ben Brandt
f2a4b3d19b claude 1.0.128 (#72)
- **Update to 1.0.128 + use anthropic sdk types**
- **Fix compacting output**
- **Handle JSON errors**

---------

Signed-off-by: Ben Brandt <benjamin.j.brandt@gmail.com>
2025-09-29 12:59:59 +02:00
Ben Brandt
9135a0f1be Update to claude-code-sdk v1.0.124 (#68) 2025-09-25 15:05:47 +02:00
Ben Brandt
92c5cd84e8 Update clients list (#67) 2025-09-25 12:26:40 +00:00
Christian Tietze
8311a7a222 add Emacs client to README (#66) 2025-09-25 12:21:43 +00:00
Ben Brandt
ba513b190b Claude Code SDK 1.0.123 (#65)
- **Bump to claude code sdk v1.0.123**
- **Prep release**

---------

Signed-off-by: Ben Brandt <benjamin.j.brandt@gmail.com>
2025-09-24 10:36:14 +02:00
Ben Brandt
a011363320 v0.4.3 (#63) 2025-09-19 12:16:16 +02:00
Ben Brandt
7737878dd2 Pull permissions into own server (#50)
This allows us to use the SDK version of an MCP server for all other
tool calls. Ideally the permission server goes away eventually.

Signed-off-by: Ben Brandt <benjamin.j.brandt@gmail.com>

---------

Signed-off-by: Ben Brandt <benjamin.j.brandt@gmail.com>
2025-09-19 12:05:46 +02:00
Ben Brandt
2e53f1d3e4 Update to ACP TS library v0.4 (#59)
Signed-off-by: Ben Brandt <benjamin.j.brandt@gmail.com>
2025-09-18 10:04:30 +00:00
Ben Brandt
aa7364d49b v0.4.2 (#55) 2025-09-15 18:48:44 +02:00
Ben Brandt
e22d1ed52c v0.4.1 (#54)
Signed-off-by: Ben Brandt <benjamin.j.brandt@gmail.com>
2025-09-15 18:42:07 +02:00
Ben Brandt
ecfd36afa6 Support /compact (#51)
It would be nice to also support sending status via hooks when the
compact action is happening (and maybe show something in the UI to show
where compacting happened in the thread) but for now this at least
allows it.

---------

Signed-off-by: Ben Brandt <benjamin.j.brandt@gmail.com>
2025-09-15 18:27:43 +02:00
Richard Feldman
7bd163818b Add default limits to read tool (#52)
Now it won't read files over 50K code units by default, and instead
insist on breaking them up by line number.
2025-09-15 11:03:01 -04:00
Richard Feldman
491efe32e8 Tool error backticks (#53)
This looks more reasonable now, because the code is now rendered in a
monospace font:

<img width="475" height="450" alt="Screenshot 2025-09-15 at 10 58 20 AM"
src="https://github.com/user-attachments/assets/9870436b-ee8f-44cd-b814-b22ad9261ebe"
/>

That said, this definitely would benefit from a horizontal scroll bar on
the code blocks. You can't really read what it's saying without copying
the text out of the block.

Of course, in this particular case the relevant info is all in the
beginning of the error message (namely, that it failed to find the
`old_string`), and this does look overall better than before (where it
was just this big jumble of non-monospaced code), so I think it's worth
merging as-is and then revisiting horizontal scrolling later.
2025-09-15 11:02:47 -04:00
Ben Brandt
ee17e45030 Use Trusted Publishing for npm release (#49) 2025-09-15 10:59:16 +02:00
Ben Brandt
468c3522a0 Update deps to latest versions (#48)
Signed-off-by: Ben Brandt <benjamin.j.brandt@gmail.com>
2025-09-15 10:46:05 +02:00
Ben Brandt
84d64b2762 Add build step to release 2025-09-15 10:35:21 +02:00
Myles Scolnick
9ff7919ea4 readme: Add marimo notebook (#47) 2025-09-15 10:11:14 +02:00
Agus Zubiaga
d58c2dd45d Update to acp 0.2.1 (#45) 2025-09-12 14:07:00 -03:00
Richard Feldman
55532ecc57 Re-upgrade to SDK version 1.0.111 (#44)
There's apparently a RCE on 1.0.100, so we shouldn't stay on that
version.
2025-09-11 12:50:19 -04:00
Richard Feldman
f4711e96b0 Pin npm dependencies (#43)
We discussed this and decided we'll manually upgrade on our own cadence.
Don't want surprises like
https://github.com/zed-industries/zed/issues/37976
2025-09-11 12:27:52 -04:00
Richard Feldman
f691024350 Load managed-settings.json (#42)
The SDK doesn't currently load [Claude Code's
`managed-settings.json`](https://docs.anthropic.com/en/docs/claude-code/settings#settings-files)
- now the adapter does (following [the rules in the
docs](https://docs.anthropic.com/en/docs/claude-code/settings#settings-files)),
but we can remove this once the SDK supports it.
2025-09-11 08:47:33 -04:00
Agus Zubiaga
d6c91b1ec1 0.4.0 2025-09-09 11:49:37 -03:00
Agus Zubiaga
0289dd9de7 Support plan mode and all other permission modes (#40)
Exposes all Claude Code permission modes as [ACP Session
modes](https://github.com/zed-industries/agent-client-protocol/pull/67).
This allows ACP clients to build mode selectors and to be notified when
the mode changes as a result of `ExitPlanMode`.

See a demo in the [Zed
PR](https://github.com/zed-industries/zed/pull/37632).

Closes #31

---------

Co-authored-by: Bennet Bo Fenner <bennetbo@gmx.de>
Co-authored-by: Richard Feldman <oss@rtfeldman.com>
Co-authored-by: Bennet <bennet@zed.dev>
2025-09-09 11:48:26 -03:00
Agus Zubiaga
5cd3ac91e8 Removing "Coming soon" 2025-09-08 13:18:28 -03:00
Agus Zubiaga
6b5873f005 0.3.0 2025-09-08 13:13:25 -03:00
Agus Zubiaga
539f94de45 Support HTTP/SSE MCP servers (#38)
HTTP/SSE MCP servers can be now included in `session/new`
2025-09-08 13:12:19 -03:00
Jie Wang
70441b0aa3 Move @types/express to dev dependencies (#34) 2025-09-05 06:19:09 +02:00
Agus Zubiaga
de514862f8 0.2.6 2025-09-04 18:01:30 -03:00
Ben Brandt
9ebdfffd48 Remove option for using an API Key, just support /login
Co-authored-by: Agus Zubiaga <agus@zed.dev>
2025-09-04 08:41:45 -07:00
Agus Zubiaga
e6bf33cd30 0.2.5 2025-09-03 22:02:40 -03:00
Xiangyi Li
96261a5b50 Remove dollar signs from CLI commands in installation section (#22)
Makes it easier to copy and paste the installation commands without
having to manually remove the dollar signs. This is a common convention
in documentation to improve user experience when following installation
instructions.
2025-09-03 22:01:24 -03:00
Agus Zubiaga
003ae41d18 Send available commands over notifications (#20)
See https://github.com/zed-industries/agent-client-protocol/pull/62

---------

Co-authored-by: Cole Miller <cole@zed.dev>
2025-09-03 22:00:54 -03:00
Danilo Leal
0ba0ec1017 Update Claude Code auth button label (#16)
We were using "Claude CLI" which is not the correct product name.
2025-09-03 16:37:50 -04:00
Oli
f70730f1ab Update README for CodeCompanion.nvim (#17)
Added CodeCompanion.nvim to the README, linking to my own setup
instructions.
2025-09-03 12:53:40 -07:00
Agus Zubiaga
13cd9214d7 0.2.4 2025-09-03 15:15:13 -03:00
Agus Zubiaga
78975d75f5 Provide more helpful message on init error 2025-09-03 15:15:03 -03:00
Agus Zubiaga
bbd0774ae5 0.2.3 2025-09-03 15:07:22 -03:00
Agus Zubiaga
76331017b2 Pass node executable path 2025-09-03 15:07:13 -03:00
Agus Zubiaga
90f1447e99 0.2.2 2025-09-03 14:04:10 -03:00
Agus Zubiaga
dae4c9956e Raise timeout 2025-09-03 14:03:47 -03:00
Agus Zubiaga
eb282abb50 Fix readme format 2025-09-03 11:39:33 -03:00
Agus Zubiaga
74c0be057b Add npm badge 2025-09-03 11:36:39 -03:00
Agus Zubiaga
e66091f723 Add permission request note 2025-09-03 11:02:33 -03:00
Agus Zubiaga
1b99ab96a0 Fix test 2025-09-03 09:28:11 -03:00
Agus Zubiaga
8d14d9dd1d 0.2.1 2025-09-03 09:25:44 -03:00
Agus Zubiaga
98b6cd5c07 Fix edit location reporting (#15)
When reporting the edit tool uses, we were accessing a field with a typo
(we should start using Zod, btw), and that caused locations not to be
reported correctly. We also now report all line numbers.

Co-authored-by: Bennet Bo Fenner <bennetbo@gmx.de>
2025-09-03 09:25:15 -03:00
Agus Zubiaga
a4c9ba3c7a 0.2.0 2025-09-03 09:05:24 -03:00
Agus Zubiaga
a08ce88f30 Improve edit tool quality (#14)
- Return a unified diff after applying changes
- Fail if the `old_string` isn't found
- Fail if the `old_string` is empty
- Throw instead of returning succesful content on error

---------

Co-authored-by: Bennet Bo Fenner <bennetbo@gmx.de>
2025-09-03 09:04:06 -03:00
Agus Zubiaga
f717175cdc 0.1.11 2025-09-02 23:16:46 -03:00
Agus Zubiaga
d1fd794ecb Add backticks around glob pattern (#13)
Looks nicer and prevents potential markdown issues
2025-09-02 23:13:27 -03:00
Agus Zubiaga
f54da50bf5 Update README.md 2025-09-02 22:35:45 -03:00
Agus Zubiaga
55c61986e8 0.1.10 2025-09-02 20:03:22 -03:00
Agus Zubiaga
46f643b9f4 Improve background terminal output 2025-09-02 20:03:14 -03:00
Agus Zubiaga
07316e2eb0 Redirect console.log et al to stderr 2025-09-02 19:46:30 -03:00
Agus Zubiaga
9dc15bd190 0.1.9 2025-09-02 19:26:40 -03:00
Agus Zubiaga
df04c41d5a Only include relevant files 2025-09-02 19:14:05 -03:00
Agus Zubiaga
a3038d383f Setup linter (#12)
Fixes basic issues. For now, we're still allowing `any`, but it'd be
nice to move away from it.
2025-09-02 19:06:32 -03:00
Agus Zubiaga
28f36d7474 Format README 2025-09-02 18:24:51 -03:00
Agus Zubiaga
a07f132a9a Improve "Other clients" section 2025-09-02 18:23:05 -03:00
Agus Zubiaga
7d969fd9ff Update README.md 2025-09-02 18:17:17 -03:00
Conrad Irwin
a4e8ba0b14 Remove more unsupported /slash commands 2025-09-02 13:18:13 -07:00
Conrad Irwin
532daf0e7e v0.1.8 2025-09-02 12:33:40 -07:00
Conrad Irwin
d6e5910631 Fix load errors when SDK is busted 2025-09-02 12:33:11 -07:00
Agus Zubiaga
0d5e78f1ac Fix info for builtin tools (#11) 2025-09-02 15:50:21 -03:00
Agus Zubiaga
02fac67f52 Do not send tool_call_update for TodoWrite (#10)
When we receive a `TodoWrite` tool use, we send a plan update instead of
a tool call. However, we were still trying to send a tool call update
when we received the `TodoWrite` tool result, which caused an ACP error.

Co-authored-by: Bennet Bo Fenner <bennetbo@gmx.de>
2025-09-02 11:53:54 -03:00
Agus Zubiaga
1ef3ba327d Unpin SDK now that entrypoint is fixed (#9)
Co-authored-by: Bennet <bennet@zed.dev>
2025-09-02 11:38:45 -03:00
Agus Zubiaga
617233d693 0.1.7 2025-09-02 11:25:56 -03:00
Bennet Bo Fenner
0dc5a4b366 Add support for slash commands (#7)
Co-authored-by: Antonio Scandurra <me@as-cii.com>
Co-authored-by: Agus Zubiaga <agus@zed.dev>
2025-09-02 10:29:45 +02:00
Agus Zubiaga
6d1711cfa4 Use ACP terminal support when available (#8)
When the `terminal` client capability is available, we'll now replace
Claude Code's bash tools with our own that go over ACP. This includes
support for background terminals.

---------

Co-authored-by: Bennet Bo Fenner <bennetbo@gmx.de>
Co-authored-by: Bennet <bennet@zed.dev>
2025-09-01 12:01:50 -03:00
Ben Brandt
f79a121c9c Remove provenance flag 2025-08-29 12:18:27 -07:00
Ben Brandt
ace8b0b5e5 0.1.6 2025-08-29 12:13:56 -07:00
Ben Brandt
1d14d35779 Add publish step on tags (#6) 2025-08-29 21:13:27 +02:00
Ben Brandt
d39c2adb21 Add CI workflow and Prettier formatting (#5) 2025-08-29 20:35:31 +02:00
Conrad Irwin
48a31df673 v0.1.5 2025-08-29 10:01:57 -06:00
Conrad Irwin
63d243deef Add a bin field 2025-08-29 10:01:45 -06:00
Conrad Irwin
048d5c562c Add bin 2025-08-29 09:57:27 -06:00
Bennet Bo Fenner
d6b805a77b Update tests to reflect new behavior 2025-08-29 10:36:42 +02:00
Conrad Irwin
b83c250f17 Only enable read/write tools if supported by the client 2025-08-28 23:02:59 -06:00
Conrad Irwin
d7511ac542 v0.1.4 2025-08-28 22:42:48 -06:00
Conrad Irwin
f18bfeaf8d Render read files in code blocks 2025-08-28 22:34:56 -06:00
Cole Miller
c4674e901a Change the format of LS tool's title to match Zed agent (#4) 2025-08-29 00:20:51 -04:00
Ben Brandt
dd088b39e3 Cleanup tool updates a bit 2025-08-28 20:01:34 -07:00
Conrad Irwin
a066b838a2 v0.1.3 2025-08-28 20:59:09 -06:00
Conrad Irwin
b875e24ad5 Fix showing of diffs in permission prompts 2025-08-28 20:58:45 -06:00
Conrad Irwin
77f667c619 Fix publishing 2025-08-28 19:33:14 -06:00
Conrad Irwin
1f2953a4ce v0.1.1 2025-08-28 18:20:21 -06:00
Ben Brandt
ce0e1a30ac Only overwrite content if we have something 2025-08-28 16:48:30 -07:00
18 changed files with 3463 additions and 1485 deletions

45
.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,45 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:
permissions:
contents: read
concurrency:
group: "ci-${{ github.ref }}"
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
build:
name: Build
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8
- name: Use Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
with:
node-version: latest
cache: "npm"
- name: Install dependencies
run: npm ci
- name: Check formatting
run: npm run format:check
- name: Run linter
run: npm run lint
- name: Build
run: npm run build
- name: Run tests
run: npm run test:run

24
.github/workflows/publish.yml vendored Normal file
View File

@@ -0,0 +1,24 @@
name: Publish Package to npmjs
on:
push:
tags:
- "v*.*.*"
jobs:
build:
runs-on: ubuntu-latest
environment: release # Optional: for enhanced security
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8
# Setup .npmrc file to publish to npm
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
with:
node-version: "lts/*"
registry-url: "https://registry.npmjs.org"
- name: Update npm
run: npm install -g npm@latest
- run: npm ci
- run: npm run build
- run: npm publish

4
.prettierrc.json Normal file
View File

@@ -0,0 +1,4 @@
{
"printWidth": 100,
"tabWidth": 2
}

83
CHANGELOG.md Normal file
View File

@@ -0,0 +1,83 @@
# Changelog
## 0.6.2
- Fix bug where mode selection would sometimes fire before initialization was complete.
- Update to @anthropic-ai/claude-agent-sdk@v0.1.19
## 0.6.1
- Fix to allow bypassPermissions mode to be selected (it wasn't permitted previously)
## 0.6.0
- Provide a model selector. We use the "default" model by default, and the user can change it via the client.
- Make sure writes require permissions when necessary: https://github.com/zed-industries/claude-code-acp/pull/92
- Add support for appending or overriding the system prompt: https://github.com/zed-industries/claude-code-acp/pull/91
- Update to @anthropic-ai/claude-agent-sdk@v0.1.15
- Update to @agentclientprotocol/sdk@0.4.8
## 0.5.5
- Migrate to @agentclientprotocol/sdk@0.4.5
- Update to @anthropic-ai/claude-agent-sdk@v0.1.13
## 0.5.4
- Update to @anthropic-ai/claude-agent-sdk@v0.1.11
- Enable setting CLAUDE_CODE_EXECUTABLE to override the executable used by the SDK https://github.com/zed-industries/claude-code-acp/pull/86
## 0.5.3
- Update to @anthropic-ai/claude-agent-sdk@v0.1.8
- Update to @zed-industries/agent-client-protocol@v0.4.5
## 0.5.2
- Add back @anthropic-ai/claude-code@2.0.1 as runtime dependency
## 0.5.1
- Update to @anthropic-ai/claude-agent-sdk@v0.1.1
- Make improvements to ACP tools provided to the model
## 0.5.0
- Migrate to @anthropic-ai/claude-agent-sdk@v0.1.0
## v0.4.7
- More efficient file reads from the client.
## v0.4.6
- Update to @anthropic-ai/claude-code@v1.0.128
## v0.4.5
- Update to @anthropic-ai/claude-code@v1.0.124
- Update to @zed-industries/agent-client-protocol@v0.4.3
## v0.4.4
- Update to @anthropic-ai/claude-code@v1.0.123
- Update to @zed-industries/agent-client-protocol@v0.4.2
## v0.4.3
- Move ACP tools over MCP from an "http" MCP server to an "sdk" one so more tool calls can stay in-memory.
- Update to @anthropic-ai/claude-code@v1.0.119
- Update to @zed-industries/agent-client-protocol@v0.4.0
## v0.4.2
- Fix missing package.json metadata
## v0.4.1
- Add support for /compact command [ecfd36a](https://github.com/zed-industries/claude-code-acp/commit/ecfd36afa6c4e31f12e1daf9b8a2bdc12dda1794)
- Add default limits to read tool [7bd1638](https://github.com/zed-industries/claude-code-acp/commit/7bd163818bb959b11fd2c933eff73ad83c57abb8)
- Better rendering of Tool errors [491efe3](https://github.com/zed-industries/claude-code-acp/commit/491efe32e8547075842e448d873fc01b2ffabf3a)
- Load managed-settings.json [f691024](https://github.com/zed-industries/claude-code-acp/commit/f691024350362858e00b97248ac68e356d2331c2)
- Update to @anthropic-ai/claude-code@v1.0.113
- Update to @zed-industries/agent-client-protocol@v0.3.1

View File

@@ -1,3 +1,59 @@
An ACP-compatible coding agent powered by the Claude Code SDK
# ACP adapter for Claude Code
This wrapper lets you use the real version of Claude Code from the IDE of your choice.
[![npm](https://img.shields.io/npm/v/%40zed-industries%2Fclaude-code-acp)](https://www.npmjs.com/package/@zed-industries/claude-code-acp)
Use [Claude Code](https://www.anthropic.com/claude-code) 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:
- Context @-mentions
- Images
- Tool calls (with permission requests)
- Following
- Edit review
- TODO lists
- Interactive (and background) terminals
- Custom [Slash commands](https://docs.anthropic.com/en/docs/claude-code/slash-commands)
- Client MCP servers
Learn more about the [Agent Client Protocol](https://agentclientprotocol.com/).
## How to use
### Zed
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:
https://github.com/user-attachments/assets/ddce66c7-79ac-47a3-ad59-4a6a3ca74903
Read the docs on [External Agent](https://zed.dev/docs/ai/external-agents) support.
### 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!
#### Installation
Install the adapter from `npm`:
```bash
npm install @zed-industries/claude-code-acp
```
You can then use `claude-code-acp` as a regular ACP agent:
```
ANTHROPIC_API_KEY=sk-... claude-code-acp
```
## License
Apache-2.0

48
eslint.config.js Normal file
View File

@@ -0,0 +1,48 @@
import js from "@eslint/js";
import tseslint from "@typescript-eslint/eslint-plugin";
import tsparser from "@typescript-eslint/parser";
import prettierConfig from "eslint-config-prettier";
import globals from "globals";
export default [
{
ignores: ["node_modules/", "dist/", "coverage/", "*.min.js", "*.config.js", ".github/"],
},
js.configs.recommended,
{
files: ["src/**/*.ts"],
languageOptions: {
ecmaVersion: 2020,
sourceType: "module",
parser: tsparser,
parserOptions: {
project: "./tsconfig.json",
},
globals: {
...globals.node,
},
},
plugins: {
"@typescript-eslint": tseslint,
},
rules: {
...tseslint.configs.recommended.rules,
"@typescript-eslint/explicit-function-return-type": "off",
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-unused-vars": [
"error",
{
argsIgnorePattern: "^_",
},
],
"no-console": "off",
"no-constant-condition": "off",
"default-case": "error",
"prefer-const": "error",
"no-var": "error",
eqeqeq: ["error", "always"],
curly: ["error", "all"],
},
},
prettierConfig,
];

1789
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -3,19 +3,33 @@
"publishConfig": {
"access": "public"
},
"version": "0.1.0",
"bin": {
"claude-code-acp": "./dist/index.js"
},
"version": "0.6.2",
"description": "An ACP-compatible coding agent powered by the Claude Code SDK (TypeScript)",
"main": "dist/acp-agent.js",
"main": "dist/index.js",
"type": "module",
"files": [
"dist/",
"README.md",
"LICENSE",
"package.json"
],
"scripts": {
"build": "tsc",
"start": "node dist/index.js",
"dev": "npm run build && npm run start",
"lint": "eslint src --ext .ts",
"lint:fix": "eslint src --ext .ts --fix",
"format": "prettier --write .",
"format:check": "prettier --check .",
"check": "npm run lint && npm run format:check",
"test": "vitest",
"test:integration": "RUN_INTEGRATION_TESTS=true vitest run",
"test:run": "vitest run",
"test:coverage": "vitest run --coverage"
"test:coverage": "vitest run --coverage",
"prepublishOnly": "npm run build"
},
"keywords": [
"claude",
@@ -26,25 +40,41 @@
"sdk",
"code"
],
"homepage": "https://github.com/zed-industries/claude-code-acp#readme",
"bugs": {
"url": "https://github.com/zed-industries/claude-code-acp/issues"
},
"repository": {
"type": "git",
"url": "git+https://github.com/zed-industries/claude-code-acp.git"
},
"author": "Zed Industries",
"license": "Apache-2.0",
"dependencies": {
"@anthropic-ai/claude-code": "^1.0.96",
"@modelcontextprotocol/sdk": "^1.17.4",
"@types/express": "^5.0.3",
"@zed-industries/agent-client-protocol": "0.1.2",
"express": "^5.1.0",
"minimist": "^1.2.8",
"uuid": "11.1.0"
"@agentclientprotocol/sdk": "0.4.8",
"@anthropic-ai/claude-agent-sdk": "0.1.19",
"@anthropic-ai/claude-code": "2.0.19",
"@modelcontextprotocol/sdk": "1.20.0",
"diff": "8.0.2",
"express": "5.1.0",
"minimist": "1.2.8",
"uuid": "13.0.0"
},
"devDependencies": {
"@types/minimist": "^1.2.5",
"@types/node": "^24.3.0",
"@types/uuid": "^10.0.0",
"@vitest/ui": "^3.2.4",
"eslint": "^8.56.0",
"ts-node": "^10.9.2",
"typescript": "^5.4.0",
"vitest": "^3.2.4"
"@anthropic-ai/sdk": "^0.66.0",
"@types/express": "5.0.3",
"@types/minimist": "1.2.5",
"@types/node": "24.8.0",
"@types/uuid": "10.0.0",
"@typescript-eslint/eslint-plugin": "8.46.1",
"@typescript-eslint/parser": "8.46.1",
"@vitest/ui": "3.2.4",
"eslint": "9.37.0",
"eslint-config-prettier": "10.1.8",
"globals": "16.4.0",
"prettier": "3.6.2",
"ts-node": "10.9.2",
"typescript": "5.9.3",
"vitest": "3.2.4"
}
}

View File

@@ -2,107 +2,171 @@ import {
Agent,
AgentSideConnection,
AuthenticateRequest,
AvailableCommand,
CancelNotification,
Client,
ClientCapabilities,
InitializeRequest,
InitializeResponse,
LoadSessionRequest,
ndJsonStream,
NewSessionRequest,
NewSessionResponse,
PromptRequest,
PromptResponse,
ReadTextFileRequest,
ReadTextFileResponse,
RequestError,
ToolCallContent,
ToolKind,
} from "@zed-industries/agent-client-protocol";
SessionModelState,
SetSessionModelRequest,
SetSessionModelResponse,
SetSessionModeRequest,
SetSessionModeResponse,
TerminalHandle,
TerminalOutputResponse,
WriteTextFileRequest,
WriteTextFileResponse,
} from "@agentclientprotocol/sdk";
import {
McpServerConfig,
Options,
PermissionMode,
Query,
query,
SDKAssistantMessage,
SDKMessage,
SDKUserMessage,
} from "@anthropic-ai/claude-code";
import * as readline from "node:readline";
SDKUserMessageReplay,
} from "@anthropic-ai/claude-agent-sdk";
import * as fs from "node:fs";
import * as path from "node:path";
import * as os from "node:os";
import { v7 as uuidv7 } from "uuid";
import { nodeToWebReadable, nodeToWebWritable, Pushable, unreachable } from "./utils.js";
import { SessionNotification } from "@agentclientprotocol/sdk";
import {
nodeToWebReadable,
nodeToWebWritable,
Pushable,
unreachable,
} from "./utils.js";
import { ContentBlock } from "@zed-industries/agent-client-protocol";
import { SessionNotification } from "@zed-industries/agent-client-protocol";
import { createMcpServer } from "./mcp-server.js";
createMcpServer,
createPermissionMcpServer,
PERMISSION_TOOL_NAME,
toolNames,
} from "./mcp-server.js";
import { AddressInfo } from "node:net";
import {
toolInfoFromToolUse,
planEntries,
toolUpdateFromToolResult,
ClaudePlanEntry,
} from "./tools.js";
type Session = {
query: Query;
input: Pushable<SDKUserMessage>;
cancelled: boolean;
permissionMode: PermissionMode;
};
type BackgroundTerminal =
| {
handle: TerminalHandle;
status: "started";
lastOutput: TerminalOutputResponse | null;
}
| {
status: "aborted" | "exited" | "killed" | "timedOut";
pendingOutput: TerminalOutputResponse;
};
type ToolUseCache = {
[key: string]: { type: "tool_use"; id: string; name: string; input: any };
};
const DEFAULT_MODEL_ID = "default";
// Implement the ACP Agent interface
export class ClaudeAcpAgent implements Agent {
sessions: {
[key: string]: Session;
};
client: Client;
toolUseCache: { [key: string]: any };
client: AgentSideConnection;
toolUseCache: ToolUseCache;
fileContentCache: { [key: string]: string };
backgroundTerminals: { [key: string]: BackgroundTerminal } = {};
clientCapabilities?: ClientCapabilities;
constructor(client: Client) {
constructor(client: AgentSideConnection) {
this.sessions = {};
this.client = client;
this.toolUseCache = {};
this.fileContentCache = {};
}
async initialize(params: InitializeRequest): Promise<InitializeResponse> {
async initialize(request: InitializeRequest): Promise<InitializeResponse> {
this.clientCapabilities = request.clientCapabilities;
return {
protocolVersion: 1,
// todo!()
agentCapabilities: {
promptCapabilities: { image: true, embeddedContext: true },
promptCapabilities: {
image: true,
embeddedContext: true,
},
mcpCapabilities: {
http: true,
sse: true,
},
},
authMethods: [
{
description: "Run `claude /login` in the terminal",
name: "Login with Claude CLI",
name: "Log in with Claude Code",
id: "claude-login",
},
{
description: "Anthropic API KEY",
name: "Use Anthropic API Key",
id: "anthropic-api-key",
},
],
};
}
async newSession(params: NewSessionRequest): Promise<NewSessionResponse> {
let sessionId = uuidv7();
let input = new Pushable<SDKUserMessage>();
if (
fs.existsSync(path.resolve(os.homedir(), ".claude.json.backup")) &&
!fs.existsSync(path.resolve(os.homedir(), ".claude.json"))
) {
throw RequestError.authRequired();
}
const sessionId = uuidv7();
const input = new Pushable<SDKUserMessage>();
const mcpServers: Record<string, McpServerConfig> = {};
if (Array.isArray(params.mcpServers)) {
for (const server of params.mcpServers) {
mcpServers[server.name] = {
type: "stdio",
command: server.command,
args: server.args,
env: server.env
? Object.fromEntries(server.env.map((e) => [e.name, e.value]))
: undefined,
};
if ("type" in server) {
mcpServers[server.name] = {
type: server.type,
url: server.url,
headers: server.headers
? Object.fromEntries(server.headers.map((e) => [e.name, e.value]))
: undefined,
};
} else {
mcpServers[server.name] = {
type: "stdio",
command: server.command,
args: server.args,
env: server.env
? Object.fromEntries(server.env.map((e) => [e.name, e.value]))
: undefined,
};
}
}
}
// todo!() auth
let server = await createMcpServer(this, sessionId);
let address = server.address() as AddressInfo;
const server = createMcpServer(this, sessionId, this.clientCapabilities);
mcpServers["acp"] = {
type: "sdk",
name: "acp",
instance: server,
};
// Ideally replace with `canUseTool`
const permissionServer = await createPermissionMcpServer(this, sessionId);
const address = permissionServer.address() as AddressInfo;
mcpServers["acpPermission"] = {
type: "http",
url: "http://127.0.0.1:" + address.port + "/mcp",
headers: {
@@ -110,30 +174,116 @@ export class ClaudeAcpAgent implements Agent {
},
};
let q = query({
let systemPrompt: Options["systemPrompt"] = { type: "preset", preset: "claude_code" };
if (params._meta?.systemPrompt) {
const customPrompt = params._meta.systemPrompt;
if (typeof customPrompt === "string") {
systemPrompt = customPrompt;
} else if (
typeof customPrompt === "object" &&
"append" in customPrompt &&
typeof customPrompt.append === "string"
) {
systemPrompt.append = customPrompt.append;
}
}
const options: Options = {
cwd: params.cwd,
mcpServers,
systemPrompt,
settingSources: ["user", "project", "local"],
// If we want bypassPermissions to be an option, we have to start the session with it on.
// We change it immediately back to default below before returning the session.
permissionMode: "bypassPermissions",
permissionPromptToolName: PERMISSION_TOOL_NAME,
stderr: (err) => console.error(err),
// note: although not documented by the types, passing an absolute path
// here works to find zed's managed node version.
executable: process.execPath as any,
...(process.env.CLAUDE_CODE_EXECUTABLE && {
pathToClaudeCodeExecutable: process.env.CLAUDE_CODE_EXECUTABLE,
}),
};
const allowedTools = [];
const disallowedTools = [];
if (this.clientCapabilities?.fs?.readTextFile) {
allowedTools.push(toolNames.read);
disallowedTools.push("Read");
}
if (this.clientCapabilities?.fs?.writeTextFile) {
disallowedTools.push("Write", "Edit");
}
if (this.clientCapabilities?.terminal) {
allowedTools.push(toolNames.bashOutput, toolNames.killShell);
disallowedTools.push("Bash", "BashOutput", "KillShell");
}
if (allowedTools.length > 0) {
options.allowedTools = allowedTools;
}
if (disallowedTools.length > 0) {
options.disallowedTools = disallowedTools;
}
const q = query({
prompt: input,
options: {
cwd: params.cwd,
mcpServers,
allowedTools: ["mcp__acp__read"],
disallowedTools: ["Read", "Write", "Edit", "MultiEdit"],
strictMcpConfig: true,
permissionPromptToolName: "mcp__acp__permission",
stderr: (err) => console.error(err),
},
options,
});
const models = await getAvailableModels(q);
const permissionMode = "default";
// Change it back to default
await q.setPermissionMode(permissionMode);
this.sessions[sessionId] = {
query: q,
input: input,
cancelled: false,
permissionMode,
};
getAvailableSlashCommands(q).then((availableCommands) => {
this.client.sessionUpdate({
sessionId,
update: {
sessionUpdate: "available_commands_update",
availableCommands,
},
});
});
return {
sessionId,
models,
modes: {
currentModeId: permissionMode,
availableModes: [
{
id: "default",
name: "Always Ask",
description: "Prompts for permission on first use of each tool",
},
{
id: "acceptEdits",
name: "Accept Edits",
description: "Automatically accepts file edit permissions for the session",
},
{
id: "bypassPermissions",
name: "Bypass Permissions",
description: "Skips all permission prompts",
},
{
id: "plan",
name: "Plan Mode",
description: "Claude can analyze but not modify files or execute commands",
},
],
},
};
}
async authenticate(params: AuthenticateRequest): Promise<void> {
async authenticate(_params: AuthenticateRequest): Promise<void> {
throw new Error("Method not implemented.");
}
@@ -148,7 +298,7 @@ export class ClaudeAcpAgent implements Agent {
input.push(promptToClaude(params));
while (true) {
let { value: message, done } = await query.next();
const { value: message, done } = await query.next();
if (done || !message) {
if (this.sessions[params.sessionId].cancelled) {
return { stopReason: "cancelled" };
@@ -157,6 +307,14 @@ export class ClaudeAcpAgent implements Agent {
}
switch (message.type) {
case "system":
switch (message.subtype) {
case "init":
break;
case "compact_boundary":
break;
default:
unreachable(message as never);
}
break;
case "result": {
if (this.sessions[params.sessionId].cancelled) {
@@ -182,27 +340,54 @@ export class ClaudeAcpAgent implements Agent {
case "user":
case "assistant": {
if (this.sessions[params.sessionId].cancelled) {
continue;
break;
}
// Slash commands like /compact can generate invalid output... doesn't match
// their own docs: https://docs.anthropic.com/en/docs/claude-code/sdk/sdk-slash-commands#%2Fcompact-compact-conversation-history
if (
typeof message.message.content === "string" &&
message.message.content.includes("<local-command-stdout>")
) {
console.log(message.message.content);
break;
}
if (
message.message.model == "<synthetic>" &&
message.message.content.length == 1 &&
typeof message.message.content === "string" &&
message.message.content.includes("<local-command-stderr>")
) {
console.error(message.message.content);
break;
}
// Skip these user messages for now, since they seem to just be messages we don't want in the feed
if (message.type === "user" && typeof message.message.content === "string") {
break;
}
if (
message.type === "assistant" &&
message.message.model === "<synthetic>" &&
Array.isArray(message.message.content) &&
message.message.content.length === 1 &&
message.message.content[0].type === "text" &&
message.message.content[0].text.includes("Please run /login")
) {
throw RequestError.authRequired();
}
for (const notification of toAcpNotifications(
message,
params.sessionId,
this.toolUseCache,
this.fileContentCache,
)) {
await this.client.sessionUpdate(notification);
}
break;
}
default:
unreachable(message);
unreachable(message as never);
}
}
throw new Error("Session did not end in result");
@@ -215,6 +400,124 @@ export class ClaudeAcpAgent implements Agent {
this.sessions[params.sessionId].cancelled = true;
await this.sessions[params.sessionId].query.interrupt();
}
async setSessionModel(params: SetSessionModelRequest): Promise<SetSessionModelResponse | void> {
if (!this.sessions[params.sessionId]) {
throw new Error("Session not found");
}
await this.sessions[params.sessionId].query.setModel(params.modelId);
}
async setSessionMode(params: SetSessionModeRequest): Promise<SetSessionModeResponse> {
if (!this.sessions[params.sessionId]) {
throw new Error("Session not found");
}
switch (params.modeId) {
case "default":
case "acceptEdits":
case "bypassPermissions":
case "plan":
this.sessions[params.sessionId].permissionMode = params.modeId;
try {
await this.sessions[params.sessionId].query.setPermissionMode(params.modeId);
} catch (error) {
const errorMessage =
error instanceof Error && error.message ? error.message : "Invalid Mode";
throw new Error(errorMessage);
}
return {};
default:
throw new Error("Invalid Mode");
}
}
async readTextFile(params: ReadTextFileRequest): Promise<ReadTextFileResponse> {
const response = await this.client.readTextFile(params);
if (!params.limit && !params.line) {
this.fileContentCache[params.path] = response.content;
}
return response;
}
async writeTextFile(params: WriteTextFileRequest): Promise<WriteTextFileResponse> {
const response = await this.client.writeTextFile(params);
this.fileContentCache[params.path] = params.content;
return response;
}
}
async function getAvailableModels(query: Query): Promise<SessionModelState> {
const models = await query.supportedModels();
// Query doesn't give us access to the currently selected model, so we default to "default" if it is there,
// otherwise we just choose the first model in the list.
const currentModel = models.find((model) => model.value === DEFAULT_MODEL_ID) || models[0];
await query.setModel(currentModel.value);
const availableModels = models.map((model) => ({
modelId: model.value,
name: model.displayName,
description: model.description,
}));
return {
availableModels,
currentModelId: currentModel.value,
};
}
async function getAvailableSlashCommands(query: Query): Promise<AvailableCommand[]> {
const UNSUPPORTED_COMMANDS = [
"add-dir",
"agents", // Modal
"bashes", // Modal
"bug", // Modal
"clear", // Escape Codes
"config", // Modal
"context", // Escape Codes
"cost", // Escape Codes
"doctor", // Escape Codes
"exit",
"export", // Modal
"help", // Modal
"hooks", // Modal
"ide", // Modal
"install-github-app", // Modal
"login",
"logout",
"memory",
"mcp",
"migrate-installer", // Modal
"model", // Not supported via SDK?
"output-style", // Modal
"output-style:new", // Modal
"permissions", // Modal
"privacy-settings",
"release-notes", // Escape Codes
"resume",
"status", // Not supported via SDK?
"statusline", // Not needed
"terminal-setup", // Not needed
"todos", // Escape Codes
"vim", // Not needed
];
const commands = await query.supportedCommands();
return commands
.map((command) => {
const input = command.argumentHint ? { hint: command.argumentHint } : null;
return {
name: command.name,
description: command.description || "",
input,
};
})
.filter(
(command: AvailableCommand) =>
!(command.name.match(/\(MCP\)/) || UNSUPPORTED_COMMANDS.includes(command.name)),
);
}
function formatUriAsLink(uri: string): string {
@@ -310,19 +613,38 @@ function promptToClaude(prompt: PromptRequest): SDKUserMessage {
* Only handles text, image, and thinking chunks for now.
*/
export function toAcpNotifications(
message: SDKAssistantMessage | SDKUserMessage,
message: SDKAssistantMessage | SDKUserMessage | SDKUserMessageReplay,
sessionId: string,
toolUseCache: { [key: string]: any },
toolUseCache: ToolUseCache,
fileContentCache: { [key: string]: string },
): SessionNotification[] {
let chunks = message.message.content as ContentChunk[];
let output = [];
const content = message.message.content;
if (typeof content === "string") {
return [
{
sessionId,
update: {
sessionUpdate:
message.type === "assistant" ? "agent_message_chunk" : "user_message_chunk",
content: {
type: "text",
text: content,
},
},
},
];
}
const output = [];
// Only handle the first chunk for streaming; extend as needed for batching
for (const chunk of chunks) {
let update: SessionNotification["update"];
for (const chunk of content) {
let update: SessionNotification["update"] | null = null;
switch (chunk.type) {
case "text":
update = {
sessionUpdate: "agent_message_chunk",
sessionUpdate:
message.type === "assistant" ? "agent_message_chunk" : "user_message_chunk",
content: {
type: "text",
text: chunk.text,
@@ -331,12 +653,12 @@ export function toAcpNotifications(
break;
case "image":
update = {
sessionUpdate: "agent_message_chunk",
sessionUpdate:
message.type === "assistant" ? "agent_message_chunk" : "user_message_chunk",
content: {
type: "image",
data: chunk.source.type === "base64" ? chunk.source.data : "",
mimeType:
chunk.source.type === "base64" ? chunk.source.media_type : "",
mimeType: chunk.source.type === "base64" ? chunk.source.media_type : "",
uri: chunk.source.type === "url" ? chunk.source.url : undefined,
},
};
@@ -352,65 +674,63 @@ export function toAcpNotifications(
break;
case "tool_use":
toolUseCache[chunk.id] = chunk;
if (chunk.name == "TodoWrite") {
if (chunk.name === "TodoWrite") {
update = {
sessionUpdate: "plan",
entries: planEntries(chunk.input),
entries: planEntries(chunk.input as { todos: ClaudePlanEntry[] }),
};
} else {
let rawInput;
try {
rawInput = JSON.parse(JSON.stringify(chunk.input));
} catch {
// ignore if we can't turn it to JSON
}
update = {
toolCallId: chunk.id,
sessionUpdate: "tool_call",
rawInput: chunk.input,
rawInput,
status: "pending",
...toolInfoFromToolUse(chunk),
...toolInfoFromToolUse(chunk, fileContentCache),
};
}
break;
case "tool_result":
update = {
toolCallId: chunk.tool_use_id,
sessionUpdate: "tool_call_update",
status: chunk.is_error ? "failed" : "completed",
...toolUpdateFromToolResult(chunk, toolUseCache[chunk.tool_use_id]),
};
case "tool_result": {
const toolUse = toolUseCache[chunk.tool_use_id];
if (!toolUse) {
console.error(
`[claude-code-acp] Got a tool result for tool use that wasn't tracked: ${chunk.tool_use_id}`,
);
break;
}
if (toolUse.name !== "TodoWrite") {
update = {
toolCallId: chunk.tool_use_id,
sessionUpdate: "tool_call_update",
status: chunk.is_error ? "failed" : "completed",
...toolUpdateFromToolResult(chunk, toolUseCache[chunk.tool_use_id]),
};
}
break;
}
default:
throw new Error("unhandled chunk type: " + chunk.type);
}
output.push({ sessionId, update });
if (update) {
output.push({ sessionId, update });
}
}
return output;
}
export function runAcp() {
new AgentSideConnection(
(client) => new ClaudeAcpAgent(client),
nodeToWebWritable(process.stdout),
nodeToWebReadable(process.stdin),
);
const input = nodeToWebWritable(process.stdout);
const output = nodeToWebReadable(process.stdin);
const stream = ndJsonStream(input, output);
new AgentSideConnection((client) => new ClaudeAcpAgent(client), stream);
}
type ContentChunk =
| { type: "text"; text: string }
| { type: "tool_use"; id: string; name: string; input: any } // input is serde_json::Value, so use any or unknown
| {
type: "tool_result";
content: string;
tool_use_id: string;
is_error: boolean;
} // content type depends on your Content definition
| { type: "thinking"; thinking: string }
| { type: "redacted_thinking" }
| { type: "image"; source: ImageSource }
| { type: "document" }
| { type: "web_search_tool_result" }
| { type: "untagged_text"; text: string };
// Example ImageSource type (adjust as needed)
type ImageSource =
| { type: "base64"; data: string; media_type: string }
| { type: "url"; url: string };

View File

@@ -1,3 +1,24 @@
#!/usr/bin/env node
// Load managed settings and apply environment variables
import { loadManagedSettings, applyEnvironmentSettings } from "./utils.js";
const managedSettings = loadManagedSettings();
if (managedSettings) {
applyEnvironmentSettings(managedSettings);
}
// stdout is used to send messages to the client
// we redirect everything else to stderr to make sure it doesn't interfere with ACP
console.log = console.error;
console.info = console.error;
console.warn = console.error;
console.debug = console.error;
process.on("unhandledRejection", (reason, promise) => {
console.error("Unhandled Rejection at:", promise, "reason:", reason);
});
import { runAcp as runAcp } from "./acp-agent.js";
runAcp();

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,5 @@
---
description: 10 * 3 = 30
---
What is 10 \* 3. Only respond with the number.

View File

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

View File

@@ -2,8 +2,11 @@ import { describe, it, expect, beforeAll, afterAll } from "vitest";
import { spawn, spawnSync } from "child_process";
import {
Agent,
AvailableCommand,
Client,
ClientSideConnection,
ndJsonStream,
NewSessionResponse,
ReadTextFileRequest,
ReadTextFileResponse,
RequestPermissionRequest,
@@ -11,91 +14,228 @@ import {
SessionNotification,
WriteTextFileRequest,
WriteTextFileResponse,
} from "@zed-industries/agent-client-protocol";
} from "@agentclientprotocol/sdk";
import { nodeToWebWritable, nodeToWebReadable } from "../utils.js";
import { toolInfoFromToolUse, toolUpdateFromToolResult } from "../tools.js";
import { markdownEscape, toolInfoFromToolUse, toolUpdateFromToolResult } from "../tools.js";
import { toAcpNotifications } from "../acp-agent.js";
import { SDKAssistantMessage } from "@anthropic-ai/claude-code";
import { query, SDKAssistantMessage } from "@anthropic-ai/claude-agent-sdk";
describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)(
"ACP subprocess integration",
() => {
let child: ReturnType<typeof spawn>;
describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)("ACP subprocess integration", () => {
let child: ReturnType<typeof spawn>;
beforeAll(async () => {
let valid = spawnSync("tsc", { stdio: "inherit" });
if (valid.status) {
throw new Error("failed to compile");
}
// Start the subprocess
child = spawn("npm", ["run", "--silent", "dev"], {
stdio: ["pipe", "pipe", "inherit"],
env: process.env,
});
child.on("error", (error) => {
console.error("Error starting subprocess:", error);
});
child.on("exit", (exit) => {
console.error("Exited with", exit);
});
beforeAll(async () => {
const valid = spawnSync("tsc", { stdio: "inherit" });
if (valid.status) {
throw new Error("failed to compile");
}
// Start the subprocess
child = spawn("npm", ["run", "--silent", "dev"], {
stdio: ["pipe", "pipe", "inherit"],
env: process.env,
});
afterAll(() => {
child.kill();
child.on("error", (error) => {
console.error("Error starting subprocess:", error);
});
child.on("exit", (exit) => {
console.error("Exited with", exit);
});
});
class TestClient implements Client {
agent: Agent;
afterAll(() => {
child.kill();
});
constructor(agent: Agent) {
this.agent = agent;
}
requestPermission(
params: RequestPermissionRequest,
): Promise<RequestPermissionResponse> {
throw new Error("Method not implemented.");
}
async sessionUpdate(params: SessionNotification): Promise<void> {
console.error("RECEVIED", params);
}
writeTextFile(
params: WriteTextFileRequest,
): Promise<WriteTextFileResponse> {
throw new Error("Method not implemented.");
}
async readTextFile(
params: ReadTextFileRequest,
): Promise<ReadTextFileResponse> {
return {
content:
"This project attempts to take over the world by solving the Goldbach conjecture.",
};
class TestClient implements Client {
agent: Agent;
files: Map<string, string> = new Map();
receivedText: string = "";
resolveAvailableCommands: (commands: AvailableCommand[]) => void;
availableCommandsPromise: Promise<AvailableCommand[]>;
constructor(agent: Agent) {
this.agent = agent;
this.resolveAvailableCommands = () => {};
this.availableCommandsPromise = new Promise((resolve) => {
this.resolveAvailableCommands = resolve;
});
}
takeReceivedText() {
const text = this.receivedText;
this.receivedText = "";
return text;
}
async requestPermission(params: RequestPermissionRequest): Promise<RequestPermissionResponse> {
const optionId = params.options.find((p) => p.kind === "allow_once")!.optionId;
return { outcome: { outcome: "selected", optionId } };
}
async sessionUpdate(params: SessionNotification): Promise<void> {
console.error("RECEIVED", JSON.stringify(params, null, 4));
switch (params.update.sessionUpdate) {
case "agent_message_chunk": {
if (params.update.content.type === "text") {
this.receivedText += params.update.content.text;
}
break;
}
case "available_commands_update":
this.resolveAvailableCommands(params.update.availableCommands);
break;
default:
break;
}
}
it("should connect to the ACP subprocess", async () => {
const connection = new ClientSideConnection(
(agent) => {
return new TestClient(agent);
},
nodeToWebWritable(child.stdin!),
nodeToWebReadable(child.stdout!),
);
async writeTextFile(params: WriteTextFileRequest): Promise<WriteTextFileResponse> {
this.files.set(params.path, params.content);
return {};
}
let session = await connection.newSession({ cwd: "./", mcpServers: [] });
await connection.prompt({
prompt: [
{
type: "text",
text: "What does this project do? Read the README.md file.",
},
],
sessionId: session.sessionId,
});
console.log("DONE AWAITING");
}, 20000);
},
);
async readTextFile(params: ReadTextFileRequest): Promise<ReadTextFileResponse> {
const content = this.files.get(params.path) ?? "";
return {
content,
};
}
}
async function setupTestSession(cwd: string): Promise<{
client: TestClient;
connection: ClientSideConnection;
newSessionResponse: NewSessionResponse;
}> {
let client;
const input = nodeToWebWritable(child.stdin!);
const output = nodeToWebReadable(child.stdout!);
const stream = ndJsonStream(input, output);
const connection = new ClientSideConnection((agent) => {
client = new TestClient(agent);
return client;
}, stream);
await connection.initialize({
protocolVersion: 1,
clientCapabilities: {
fs: {
readTextFile: true,
writeTextFile: true,
},
},
});
const newSessionResponse = await connection.newSession({
cwd,
mcpServers: [],
});
return { client: client!, connection, newSessionResponse };
}
it("should connect to the ACP subprocess", async () => {
const { client, connection, newSessionResponse } = await setupTestSession("./");
await connection.prompt({
prompt: [
{
type: "text",
text: "Hello",
},
],
sessionId: newSessionResponse.sessionId,
});
expect(client.takeReceivedText()).not.toEqual("");
}, 30000);
it("should include available commands", async () => {
const { client, connection, newSessionResponse } = await setupTestSession(__dirname);
const commands = await client.availableCommandsPromise;
expect(commands).toContainEqual({
name: "quick-math",
description: "10 * 3 = 30 (project)",
input: null,
});
expect(commands).toContainEqual({
name: "say-hello",
description: "Say hello (project)",
input: { hint: "[name]" },
});
await connection.prompt({
prompt: [
{
type: "text",
text: "/quick-math",
},
],
sessionId: newSessionResponse.sessionId,
});
expect(client.takeReceivedText()).toContain("30");
await connection.prompt({
prompt: [
{
type: "text",
text: "/say-hello GPT-5",
},
],
sessionId: newSessionResponse.sessionId,
});
expect(client.takeReceivedText()).toContain("Hello GPT-5");
}, 30000);
it("/compact works", async () => {
const { client, connection, newSessionResponse } = await setupTestSession(__dirname);
const commands = await client.availableCommandsPromise;
expect(commands).toContainEqual({
description:
"Clear conversation history but keep a summary in context. Optional: /compact [instructions for summarization]",
input: {
hint: "<optional custom summarization instructions>",
},
name: "compact",
});
// Error case (no previous message)
await connection.prompt({
prompt: [{ type: "text", text: "/compact" }],
sessionId: newSessionResponse.sessionId,
});
expect(client.takeReceivedText()).toBe("");
// Send something
await connection.prompt({
prompt: [{ type: "text", text: "Hi" }],
sessionId: newSessionResponse.sessionId,
});
// Clear response
client.takeReceivedText();
// Test with instruction
await connection.prompt({
prompt: [
{
type: "text",
text: "/compact greeting",
},
],
sessionId: newSessionResponse.sessionId,
});
expect(client.takeReceivedText()).toContain("");
}, 30000);
});
describe("tool conversions", () => {
it("should handle Bash nicely", () => {
@@ -109,7 +249,7 @@ describe("tool conversions", () => {
},
};
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
kind: "execute",
title: "`rm README.md.rm`",
content: [
@@ -134,9 +274,9 @@ describe("tool conversions", () => {
},
};
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
kind: "search",
title: "Find */**.ts",
title: "Find `*/**.ts`",
content: [],
locations: [],
});
@@ -155,7 +295,7 @@ describe("tool conversions", () => {
},
};
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
kind: "think",
title: "Handle user's work request",
content: [
@@ -176,15 +316,15 @@ describe("tool conversions", () => {
id: "toolu_01EEqsX7Eb9hpx87KAHVPTey",
name: "LS",
input: {
path: "/Users/benbrandt/github/claude-code-acp",
path: "/Users/test/github/claude-code-acp",
},
};
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
kind: "search",
title: "/Users/benbrandt/github/claude-code-acp",
title: "List the `/Users/test/github/claude-code-acp` directory's contents",
content: [],
locations: [{ path: "/Users/benbrandt/github/claude-code-acp" }],
locations: [],
});
});
@@ -198,7 +338,7 @@ describe("tool conversions", () => {
},
};
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
kind: "search",
title: 'grep ".*"',
content: [],
@@ -211,12 +351,12 @@ describe("tool conversions", () => {
id: "toolu_01ABC123XYZ789",
name: "Write",
input: {
abs_path: "/Users/test/project/example.txt",
file_path: "/Users/test/project/example.txt",
content: "Hello, World!\nThis is test content.",
},
};
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
kind: "edit",
title: "Write /Users/test/project/example.txt",
content: [
@@ -231,18 +371,18 @@ describe("tool conversions", () => {
});
});
it("should handle mcp__acp__write tool calls", () => {
it("should handle mcp__acp__Write tool calls", () => {
const tool_use = {
type: "tool_use",
id: "toolu_01GHI789JKL456",
name: "mcp__acp__write",
name: "mcp__acp__Write",
input: {
abs_path: "/Users/test/project/config.json",
file_path: "/Users/test/project/config.json",
content: '{"version": "1.0.0"}',
},
};
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
kind: "edit",
title: "Write /Users/test/project/config.json",
content: [
@@ -263,34 +403,29 @@ describe("tool conversions", () => {
id: "toolu_01MNO456PQR789",
name: "Read",
input: {
abs_path: "/Users/test/project/readme.md",
file_path: "/Users/test/project/readme.md",
},
};
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
kind: "read",
title: "Read File",
content: [
{
type: "content",
content: { type: "text", text: "/Users/test/project/readme.md" },
},
],
content: [],
locations: [{ path: "/Users/test/project/readme.md", line: 0 }],
});
});
it("should handle mcp__acp__read tool calls", () => {
it("should handle mcp__acp__Read tool calls", () => {
const tool_use = {
type: "tool_use",
id: "toolu_01YZA789BCD123",
name: "mcp__acp__read",
name: "mcp__acp__Read",
input: {
abs_path: "/Users/test/project/data.json",
file_path: "/Users/test/project/data.json",
},
};
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
kind: "read",
title: "Read /Users/test/project/data.json",
content: [],
@@ -298,18 +433,18 @@ describe("tool conversions", () => {
});
});
it("should handle mcp__acp__read with limit", () => {
it("should handle mcp__acp__Read with limit", () => {
const tool_use = {
type: "tool_use",
id: "toolu_01EFG456HIJ789",
name: "mcp__acp__read",
name: "mcp__acp__Read",
input: {
abs_path: "/Users/test/project/large.txt",
file_path: "/Users/test/project/large.txt",
limit: 100,
},
};
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
kind: "read",
title: "Read /Users/test/project/large.txt (1 - 100)",
content: [],
@@ -317,19 +452,19 @@ describe("tool conversions", () => {
});
});
it("should handle mcp__acp__read with offset and limit", () => {
it("should handle mcp__acp__Read with offset and limit", () => {
const tool_use = {
type: "tool_use",
id: "toolu_01KLM789NOP456",
name: "mcp__acp__read",
name: "mcp__acp__Read",
input: {
abs_path: "/Users/test/project/large.txt",
file_path: "/Users/test/project/large.txt",
offset: 50,
limit: 100,
},
};
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
kind: "read",
title: "Read /Users/test/project/large.txt (51 - 150)",
content: [],
@@ -337,18 +472,18 @@ describe("tool conversions", () => {
});
});
it("should handle mcp__acp__read with only offset", () => {
it("should handle mcp__acp__Read with only offset", () => {
const tool_use = {
type: "tool_use",
id: "toolu_01QRS123TUV789",
name: "mcp__acp__read",
name: "mcp__acp__Read",
input: {
abs_path: "/Users/test/project/large.txt",
file_path: "/Users/test/project/large.txt",
offset: 200,
},
};
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
kind: "read",
title: "Read /Users/test/project/large.txt (from line 201)",
content: [],
@@ -368,7 +503,7 @@ describe("tool conversions", () => {
},
};
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
kind: "fetch",
title: "Fetch https://agentclientprotocol.com",
content: [
@@ -393,7 +528,7 @@ describe("tool conversions", () => {
},
};
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
kind: "fetch",
title: '"agentclientprotocol.com"',
content: [],
@@ -401,16 +536,16 @@ describe("tool conversions", () => {
});
it("should handle KillBash entries", () => {
let tool_use = {
const tool_use = {
type: "tool_use",
id: "toolu_01PhLms5fuvmdjy2bb6dfUKT",
name: "KillBash",
name: "KillShell",
input: {
shell_id: "bash_1",
},
};
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
kind: "execute",
title: `Kill Process`,
content: [],
@@ -418,7 +553,7 @@ describe("tool conversions", () => {
});
it("should handle BashOutput entries", () => {
let tool_use = {
const tool_use = {
type: "tool_use",
id: "toolu_01SJUWPtj1QspgANgtpqGPuN",
name: "BashOutput",
@@ -427,7 +562,7 @@ describe("tool conversions", () => {
},
};
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
kind: "execute",
title: `Tail Logs`,
content: [],
@@ -441,6 +576,7 @@ describe("tool conversions", () => {
id: "msg_017eNosJgww7F5qD4a8BcAcx",
type: "message",
role: "assistant",
container: null,
model: "claude-sonnet-4-20250514",
content: [
{
@@ -475,8 +611,7 @@ describe("tool conversions", () => {
activeForm: "Adding concurrent access tests",
},
{
content:
"Add tests for Each function with various data types",
content: "Add tests for Each function with various data types",
status: "pending",
activeForm: "Adding Each function tests",
},
@@ -506,13 +641,15 @@ describe("tool conversions", () => {
},
output_tokens: 1,
service_tier: "standard",
server_tool_use: null,
},
context_management: null,
},
parent_tool_use_id: null,
session_id: "d056596f-e328-41e9-badd-b07122ae5227",
uuid: "b7c3330c-de8f-4bba-ac53-68c7f76ffeb5",
};
expect(toAcpNotifications(received, "test", {})).toStrictEqual([
expect(toAcpNotifications(received, "test", {}, {})).toStrictEqual([
{
sessionId: "test",
update: {
@@ -564,150 +701,28 @@ describe("tool conversions", () => {
]);
});
it("should extract locations from edit tool results", () => {
const toolUse = {
type: "tool_use",
id: "toolu_01ABC123",
name: "edit",
input: {
abs_path: "/Users/test/project/example.txt",
old_text: "old content",
new_text: "new content",
},
};
const toolResult = {
content: [
{
type: "text",
text: JSON.stringify({ lineNumbers: [5, 10, 15] }),
},
],
};
const update = toolUpdateFromToolResult(toolResult, toolUse);
expect(update.locations).toEqual([
{ path: "/Users/test/project/example.txt", line: 5 },
{ path: "/Users/test/project/example.txt", line: 10 },
{ path: "/Users/test/project/example.txt", line: 15 },
]);
});
it("should extract locations from multi-edit tool results", () => {
const toolUse = {
type: "tool_use",
id: "toolu_01DEF456",
name: "multi-edit",
input: {
file_path: "/Users/test/project/config.json",
edits: [
{
old_string: "version: 1.0",
new_string: "version: 2.0",
replace_all: false,
},
{
old_string: "enabled: false",
new_string: "enabled: true",
replace_all: true,
},
],
},
};
const toolResult = {
content: [
{
type: "text",
text: JSON.stringify({ lineNumbers: [2, 8, 12, 20] }),
},
],
};
const update = toolUpdateFromToolResult(toolResult, toolUse);
expect(update.locations).toEqual([
{ path: "/Users/test/project/config.json", line: 2 },
{ path: "/Users/test/project/config.json", line: 8 },
{ path: "/Users/test/project/config.json", line: 12 },
{ path: "/Users/test/project/config.json", line: 20 },
]);
});
it("should handle mcp__acp__edit tool results", () => {
const toolUse = {
type: "tool_use",
id: "toolu_01GHI789",
name: "mcp__acp__edit",
input: {
abs_path: "/Users/test/project/main.py",
old_text: "def hello():",
new_text: "def hello_world():",
},
};
const toolResult = {
content: [
{
type: "text",
text: JSON.stringify({ lineNumbers: [42] }),
},
],
};
const update = toolUpdateFromToolResult(toolResult, toolUse);
expect(update.locations).toEqual([
{ path: "/Users/test/project/main.py", line: 42 },
]);
});
it("should handle empty locations from edit tools", () => {
const toolUse = {
type: "tool_use",
id: "toolu_01JKL012",
name: "edit",
input: {
abs_path: "/Users/test/project/notfound.txt",
old_text: "nonexistent",
new_text: "replacement",
},
};
const toolResult = {
content: [
{
type: "text",
text: JSON.stringify({ lineNumbers: [] }),
},
],
};
const update = toolUpdateFromToolResult(toolResult, toolUse);
expect(update.locations).toEqual([]);
});
it("should handle malformed tool results gracefully", () => {
it("should return empty update for successful edit result", () => {
const toolUse = {
type: "tool_use",
id: "toolu_01MNO345",
name: "edit",
name: "mcp__acp__Edit",
input: {
abs_path: "/Users/test/project/test.txt",
old_text: "old",
new_text: "new",
file_path: "/Users/test/project/test.txt",
old_string: "old",
new_string: "new",
},
};
const toolResult = {
content: [
{
type: "text",
type: "text" as const,
text: "not valid json",
},
],
tool_use_id: "test",
is_error: false,
type: "tool_result" as const,
};
const update = toolUpdateFromToolResult(toolResult, toolUse);
@@ -715,4 +730,66 @@ describe("tool conversions", () => {
// Should return empty object when parsing fails
expect(update).toEqual({});
});
it("should return content update for edit failure", () => {
const toolUse = {
type: "tool_use",
id: "toolu_01MNO345",
name: "mcp__acp__Edit",
input: {
file_path: "/Users/test/project/test.txt",
old_string: "old",
new_string: "new",
},
};
const toolResult = {
content: [
{
type: "text" as const,
text: "Failed to find `old_string`",
},
],
tool_use_id: "test",
is_error: true,
type: "tool_result" as const,
};
const update = toolUpdateFromToolResult(toolResult, toolUse);
// Should return empty object when parsing fails
expect(update).toEqual({
content: [
{
content: { type: "text", text: "```\nFailed to find `old_string`\n```" },
type: "content",
},
],
});
});
});
describe("escape markdown", () => {
it("should escape markdown characters", () => {
let text = "Hello *world*!";
let escaped = markdownEscape(text);
expect(escaped).toEqual("```\nHello *world*!\n```");
text = "for example:\n```markdown\nHello *world*!\n```\n";
escaped = markdownEscape(text);
expect(escaped).toEqual("````\nfor example:\n```markdown\nHello *world*!\n```\n````");
});
});
describe("SDK behavior", () => {
it.skipIf(!process.env.RUN_INTEGRATION_TESTS)(
"query has a 'default' model",
async () => {
const q = query({ prompt: "hi" });
const models = await q.supportedModels();
const defaultModel = models.find((m) => m.value === "default");
expect(defaultModel).toBeDefined();
},
10000,
);
});

View File

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

View File

@@ -4,13 +4,9 @@ import { replaceAndCalculateLocation } from "../mcp-server.js";
describe("replaceAndCalculateLocation", () => {
it("should replace first occurrence and return correct line number", () => {
const content = "line 1\nline 2 with text\nline 3 with text\nline 4";
const result = replaceAndCalculateLocation(content, [
{ oldText: "text", newText: "replaced" },
]);
const result = replaceAndCalculateLocation(content, [{ oldText: "text", newText: "replaced" }]);
expect(result.newContent).toBe(
"line 1\nline 2 with replaced\nline 3 with text\nline 4",
);
expect(result.newContent).toBe("line 1\nline 2 with replaced\nline 3 with text\nline 4");
expect(result.lineNumbers).toEqual([1]); // Line 2 (0-based indexing)
});
@@ -20,20 +16,15 @@ describe("replaceAndCalculateLocation", () => {
{ oldText: "text", newText: "replaced", replaceAll: true },
]);
expect(result.newContent).toBe(
"line 1\nline 2 with replaced\nline 3 with replaced\nline 4",
);
expect(result.newContent).toBe("line 1\nline 2 with replaced\nline 3 with replaced\nline 4");
expect(result.lineNumbers).toEqual([1, 2]); // Lines 2 and 3 (0-based)
});
it("should return empty line numbers array when no match found", () => {
const content = "line 1\nline 2\nline 3";
const result = replaceAndCalculateLocation(content, [
{ oldText: "notfound", newText: "replaced" },
]);
expect(result.newContent).toBe(content);
expect(result.lineNumbers).toEqual([]);
expect(() => {
replaceAndCalculateLocation(content, [{ oldText: "notfound", newText: "replaced" }]);
}).toThrow('The provided `old_string` does not appear in the file: "notfound"');
});
it("should handle Windows line endings (CRLF)", () => {
@@ -54,9 +45,7 @@ describe("replaceAndCalculateLocation", () => {
{ oldText: "text", newText: "replaced", replaceAll: true },
]);
expect(result.newContent).toBe(
"line 1\rline 2 with replaced\rline 3 with replaced\rline 4",
);
expect(result.newContent).toBe("line 1\rline 2 with replaced\rline 3 with replaced\rline 4");
expect(result.lineNumbers).toEqual([1, 2]);
});
@@ -66,17 +55,13 @@ describe("replaceAndCalculateLocation", () => {
{ oldText: "text", newText: "replaced", replaceAll: true },
]);
expect(result.newContent).toBe(
"line 1\nline 2 with replaced\r\nline 3 with replaced\rline 4",
);
expect(result.newContent).toBe("line 1\nline 2 with replaced\r\nline 3 with replaced\rline 4");
expect(result.lineNumbers).toEqual([1, 2]);
});
it("should handle text at the beginning of file", () => {
const content = "text at start\nline 2\nline 3";
const result = replaceAndCalculateLocation(content, [
{ oldText: "text", newText: "replaced" },
]);
const result = replaceAndCalculateLocation(content, [{ oldText: "text", newText: "replaced" }]);
expect(result.newContent).toBe("replaced at start\nline 2\nline 3");
expect(result.lineNumbers).toEqual([0]); // First line (0-based)
@@ -84,9 +69,7 @@ describe("replaceAndCalculateLocation", () => {
it("should handle text at the end of file", () => {
const content = "line 1\nline 2\nlast line with text";
const result = replaceAndCalculateLocation(content, [
{ oldText: "text", newText: "replaced" },
]);
const result = replaceAndCalculateLocation(content, [{ oldText: "text", newText: "replaced" }]);
expect(result.newContent).toBe("line 1\nline 2\nlast line with replaced");
expect(result.lineNumbers).toEqual([2]); // Last line (0-based)
@@ -98,53 +81,41 @@ describe("replaceAndCalculateLocation", () => {
{ oldText: "text", newText: "replaced", replaceAll: true },
]);
expect(result.newContent).toBe(
"line 1\nreplaced replaced replaced\nline 3",
);
expect(result.newContent).toBe("line 1\nreplaced replaced replaced\nline 3");
expect(result.lineNumbers).toEqual([1]); // All on line 2 (0-based), deduplicated
});
it("should handle empty file content", () => {
const content = "";
const result = replaceAndCalculateLocation(content, [
{ oldText: "text", newText: "replaced" },
]);
expect(result.newContent).toBe("");
expect(result.lineNumbers).toEqual([]);
expect(() => {
replaceAndCalculateLocation("", [{ oldText: "text", newText: "replaced" }]);
}).toThrowError(
'The provided `old_string` does not appear in the file: "text".\n\nNo edits were applied.',
);
});
it("should handle empty search text", () => {
// Test with replaceAll false (default)
const content1 = "line 1\nline 2\nline 3";
const result1 = replaceAndCalculateLocation(content1, [
{ oldText: "", newText: "replaced" },
]);
expect(result1.newContent).toBe(content1);
expect(result1.lineNumbers).toEqual([]);
expect(() => {
replaceAndCalculateLocation(content1, [{ oldText: "", newText: "replaced" }]);
}).toThrowError("The provided `old_string` is empty.\n\nNo edits were applied.");
// Test with replaceAll true on single line
const content2 = "abc";
const result2 = replaceAndCalculateLocation(content2, [
{ oldText: "", newText: "X", replaceAll: true },
]);
expect(result2.newContent).toBe(content2);
expect(result2.lineNumbers).toEqual([]);
expect(() => {
replaceAndCalculateLocation(content2, [{ oldText: "", newText: "X", replaceAll: true }]);
}).toThrowError("The provided `old_string` is empty.\n\nNo edits were applied.");
// Test with replaceAll true on multiline content
const content3 = "ab\ncd";
const result3 = replaceAndCalculateLocation(content3, [
{ oldText: "", newText: "X", replaceAll: true },
]);
expect(result3.newContent).toBe(content3);
expect(result3.lineNumbers).toEqual([]);
expect(() => {
replaceAndCalculateLocation(content3, [{ oldText: "", newText: "X", replaceAll: true }]);
}).toThrowError("The provided `old_string` is empty.\n\nNo edits were applied.");
});
it("should handle single line content", () => {
const content = "single line with text here";
const result = replaceAndCalculateLocation(content, [
{ oldText: "text", newText: "replaced" },
]);
const result = replaceAndCalculateLocation(content, [{ oldText: "text", newText: "replaced" }]);
expect(result.newContent).toBe("single line with replaced here");
expect(result.lineNumbers).toEqual([0]); // Line 1 (0-based)
@@ -183,9 +154,7 @@ describe("replaceAndCalculateLocation", () => {
it("should handle very long lines", () => {
const longLine = "a".repeat(10000) + "text" + "b".repeat(10000);
const content = `line 1\n${longLine}\nline 3`;
const result = replaceAndCalculateLocation(content, [
{ oldText: "text", newText: "replaced" },
]);
const result = replaceAndCalculateLocation(content, [{ oldText: "text", newText: "replaced" }]);
expect(result.newContent).toBe(
`line 1\n${"a".repeat(10000)}replaced${"b".repeat(10000)}\nline 3`,
@@ -198,9 +167,7 @@ describe("replaceAndCalculateLocation", () => {
lines[500] = "line with text";
const content = lines.join("\n");
const result = replaceAndCalculateLocation(content, [
{ oldText: "text", newText: "replaced" },
]);
const result = replaceAndCalculateLocation(content, [{ oldText: "text", newText: "replaced" }]);
expect(result.lineNumbers).toEqual([500]);
expect(result.newContent).includes("line with replaced");
@@ -224,9 +191,7 @@ describe("replaceAndCalculateLocation", () => {
const result1 = replaceAndCalculateLocation(content, [
{ oldText: "short", newText: "very very long text" },
]);
expect(result1.newContent).toBe(
"very very long text\nmedium line\nlong line here",
);
expect(result1.newContent).toBe("very very long text\nmedium line\nlong line here");
expect(result1.lineNumbers).toEqual([0]);
// Replacing with shorter text
@@ -260,9 +225,7 @@ describe("replaceAndCalculateLocation", () => {
it("should handle replacement at exact end of line", () => {
const content = "line ends with text\nnext line";
const result = replaceAndCalculateLocation(content, [
{ oldText: "text", newText: "replaced" },
]);
const result = replaceAndCalculateLocation(content, [{ oldText: "text", newText: "replaced" }]);
expect(result.newContent).toBe("line ends with replaced\nnext line");
expect(result.lineNumbers).toEqual([0]);
@@ -302,9 +265,7 @@ describe("replaceAndCalculateLocation", () => {
// - All "foo" occurrences (lines 1, 3, 5 in original)
// - First "bar" occurrence (line 1 after first edit)
// - "line 3" (line 2 after previous edits)
expect(result.newContent).toBe(
"line 1\nFOO BAR\nLINE THREE\nFOO baz\nline 5\nbar FOO\nline 7",
);
expect(result.newContent).toBe("line 1\nFOO BAR\nLINE THREE\nFOO baz\nline 5\nbar FOO\nline 7");
// Line numbers reflect position in final content, deduplicated and sorted
expect(result.lineNumbers).toEqual([
@@ -326,9 +287,7 @@ describe("replaceAndCalculateLocation", () => {
// First edit replaces first "hello" only
// Second edit replaces all "world" in the modified content
// Third edit replaces remaining "hello"s in the modified content
expect(result.newContent).toBe(
"hi earth\ngreetings greetings\nearth greetings earth",
);
expect(result.newContent).toBe("hi earth\ngreetings greetings\nearth greetings earth");
// Line numbers reflect position in final content, deduplicated and sorted
expect(result.lineNumbers).toEqual([
@@ -340,17 +299,14 @@ describe("replaceAndCalculateLocation", () => {
it("should handle complex scenarios with marker-like text", () => {
// Test that our marker approach doesn't get confused by similar text
const content =
"line 1\n__MARKER__text\nline 3\n__REPLACE_MARKER_abc__\nline 5";
const content = "line 1\n__MARKER__text\nline 3\n__REPLACE_MARKER_abc__\nline 5";
const result = replaceAndCalculateLocation(content, [
{ oldText: "__MARKER__", newText: "REPLACED", replaceAll: true },
{ oldText: "text", newText: "content" },
{ oldText: "__REPLACE_MARKER_abc__", newText: "DONE" },
]);
expect(result.newContent).toBe(
"line 1\nREPLACEDcontent\nline 3\nDONE\nline 5",
);
expect(result.newContent).toBe("line 1\nREPLACEDcontent\nline 3\nDONE\nline 5");
// Line numbers should be correct even with marker-like text
expect(result.lineNumbers).toEqual([

View File

@@ -1,10 +1,6 @@
import { ContentBlock } from "@modelcontextprotocol/sdk/types.js";
import {
PlanEntry,
ToolCallContent,
ToolCallLocation,
ToolKind,
} from "@zed-industries/agent-client-protocol";
import { PlanEntry, ToolCallContent, ToolCallLocation, ToolKind } from "@agentclientprotocol/sdk";
import { replaceAndCalculateLocation, SYSTEM_REMINDER, toolNames } from "./mcp-server.js";
import { ToolResultBlockParam } from "@anthropic-ai/sdk/resources";
interface ToolInfo {
title: string;
@@ -19,7 +15,10 @@ interface ToolUpdate {
locations?: ToolCallLocation[];
}
export function toolInfoFromToolUse(toolUse: any): ToolInfo {
export function toolInfoFromToolUse(
toolUse: any,
cachedFileContent: { [key: string]: string },
): ToolInfo {
const name = toolUse.name;
const input = toolUse.input;
@@ -41,30 +40,15 @@ export function toolInfoFromToolUse(toolUse: any): ToolInfo {
case "NotebookRead":
return {
title: input?.notebook_path
? `Read Notebook ${input.notebook_path}`
: "Read Notebook",
title: input?.notebook_path ? `Read Notebook ${input.notebook_path}` : "Read Notebook",
kind: "read",
content:
input && input.notebook_path
? [
{
type: "content",
content: {
type: "text",
text: input.notebook_path.toString(),
},
},
]
: [],
content: [],
locations: input?.notebook_path ? [{ path: input.notebook_path }] : [],
};
case "NotebookEdit":
return {
title: input?.notebook_path
? `Edit Notebook ${input.notebook_path}`
: "Edit Notebook",
title: input?.notebook_path ? `Edit Notebook ${input.notebook_path}` : "Edit Notebook",
kind: "edit",
content:
input && input.new_source
@@ -79,13 +63,12 @@ export function toolInfoFromToolUse(toolUse: any): ToolInfo {
};
case "Bash":
case toolNames.bash:
return {
title: input?.command
? "`" + input.command.replaceAll("`", "\\`") + "`"
: "Terminal",
title: input?.command ? "`" + input.command.replaceAll("`", "\\`") + "`" : "Terminal",
kind: "execute",
content:
input && input.command
input && input.description
? [
{
type: "content",
@@ -96,37 +79,35 @@ export function toolInfoFromToolUse(toolUse: any): ToolInfo {
};
case "BashOutput":
case toolNames.bashOutput:
return {
title: "Tail Logs",
kind: "execute",
content: [],
};
case "KillBash":
case "KillShell":
case toolNames.killShell:
return {
title: "Kill Process",
kind: "execute",
content: [],
};
case "mcp__acp__read": {
case toolNames.read: {
let limit = "";
if (input.limit) {
limit =
" (" +
((input.offset ?? 0) + 1) +
" - " +
((input.offset ?? 0) + input.limit) +
")";
" (" + ((input.offset ?? 0) + 1) + " - " + ((input.offset ?? 0) + input.limit) + ")";
} else if (input.offset) {
limit = " (from line " + (input.offset + 1) + ")";
}
return {
title: "Read " + (input.abs_path ?? "File") + limit,
title: "Read " + (input.file_path ?? "File") + limit,
kind: "read",
locations: [
{
path: input.abs_path,
path: input.file_path,
line: input.offset ?? 0,
},
],
@@ -138,71 +119,71 @@ export function toolInfoFromToolUse(toolUse: any): ToolInfo {
return {
title: "Read File",
kind: "read",
content:
input && input.abs_path
? [
{
type: "content",
content: { type: "text", text: input.abs_path.toString() },
},
]
: [],
locations: [{ path: input.abs_path, line: input.offset ?? 0 }],
content: [],
locations: [{ path: input.file_path, line: input.offset ?? 0 }],
};
case "LS":
return {
title: input?.path ? input.path : "Current Directory",
title: `List the ${input?.path ? "`" + input.path + "`" : "current"} directory's contents`,
kind: "search",
content: [],
locations: input?.path ? [{ path: input.path }] : [],
locations: [],
};
case "mcp__acp__edit":
case "Edit":
case toolNames.edit:
case "Edit": {
const path = input?.file_path ?? input?.file_path;
let oldText = input.old_string ?? null;
let newText = input.new_string ?? "";
let affectedLines: number[] = [];
if (path && oldText) {
try {
const oldContent = cachedFileContent[path] || "";
const newContent = replaceAndCalculateLocation(oldContent, [
{
oldText,
newText,
replaceAll: false,
},
]);
oldText = oldContent;
newText = newContent.newContent;
affectedLines = newContent.lineNumbers;
} catch (e) {
console.error(e);
}
}
return {
title: input?.abs_path ? `Edit ${input.abs_path}` : "Edit",
title: path ? `Edit \`${path}\`` : "Edit",
kind: "edit",
content:
input && input.abs_path
input && path
? [
{
type: "diff",
path: input.abs_path,
oldText: input.old_text || null,
newText: input.new_text,
path,
oldText,
newText,
},
]
: [],
locations: input?.abs_path ? [{ path: input.abs_path }] : [],
locations: path
? affectedLines.length > 0
? affectedLines.map((line) => ({ line, path }))
: [{ path }]
: [],
};
}
case "mcp__acp__multi-edit":
case "MultiEdit":
// Display it as a normal edit, because end users don't care about
// the distinction between edits and multi-edits.
return {
title: input?.file_path ? `Edit ${input.file_path}` : "Edit",
kind: "edit",
content:
input && input.edits && input.edits.length > 0
? input.edits.map((edit: any) => ({
type: "diff" as const,
path: input.file_path,
oldText: edit.old_string || null,
newText: edit.new_string,
}))
: [],
locations: input?.file_path ? [{ path: input.file_path }] : [],
};
case "mcp__acp__write":
case toolNames.write: {
let content: ToolCallContent[] = [];
if (input && input.abs_path) {
if (input && input.file_path) {
content = [
{
type: "diff",
path: input.abs_path,
path: input.file_path,
oldText: null,
newText: input.content,
},
@@ -216,37 +197,38 @@ export function toolInfoFromToolUse(toolUse: any): ToolInfo {
];
}
return {
title: input?.abs_path ? `Write ${input.abs_path}` : "Write",
title: input?.file_path ? `Write ${input.file_path}` : "Write",
kind: "edit",
content,
locations: input?.abs_path ? [{ path: input.abs_path }] : [],
locations: input?.file_path ? [{ path: input.file_path }] : [],
};
}
case "Write":
return {
title: input?.abs_path ? `Write ${input.abs_path}` : "Write",
title: input?.file_path ? `Write ${input.file_path}` : "Write",
kind: "edit",
content:
input && input.abs_path
input && input.file_path
? [
{
type: "diff",
path: input.abs_path,
path: input.file_path,
oldText: null,
newText: input.content,
},
]
: [],
locations: input?.abs_path ? [{ path: input.abs_path }] : [],
locations: input?.file_path ? [{ path: input.file_path }] : [],
};
case "Glob": {
let label = "Find";
if (input.path) {
label += ` ${input.path}`;
label += ` \`${input.path}\``;
}
if (input.pattern) {
label += ` ${input.pattern}`;
label += ` \`${input.pattern}\``;
}
return {
title: label,
@@ -285,6 +267,7 @@ export function toolInfoFromToolUse(toolUse: any): ToolInfo {
label += " -c";
break;
case "Content":
default:
break;
}
}
@@ -360,17 +343,23 @@ export function toolInfoFromToolUse(toolUse: any): ToolInfo {
content: [],
};
case "exit_plan_mode":
case "ExitPlanMode":
return {
title: "Exit Plan Mode",
kind: "think",
title: "Ready to code?",
kind: "switch_mode",
content:
input && input.plan
? [{ type: "content", content: { type: "text", text: input.plan } }]
: [],
};
case "Other":
case "Other": {
let output;
try {
output = JSON.stringify(input, null, 2);
} catch {
output = typeof input === "string" ? input : "{}";
}
return {
title: name || "Unknown Tool",
kind: "other",
@@ -379,11 +368,12 @@ export function toolInfoFromToolUse(toolUse: any): ToolInfo {
type: "content",
content: {
type: "text",
text: `\`\`\`json\n${JSON.stringify(input, null, 2) || "{}"}\`\`\``,
text: `\`\`\`json\n${output}\`\`\``,
},
},
],
};
}
default:
return {
@@ -395,63 +385,60 @@ export function toolInfoFromToolUse(toolUse: any): ToolInfo {
}
export function toolUpdateFromToolResult(
toolResult: any,
toolResult: ToolResultBlockParam,
toolUse: any | undefined,
): ToolUpdate {
switch (toolUse?.name) {
case "mcp__acp__edit":
case "Read":
case toolNames.read:
if (Array.isArray(toolResult.content) && toolResult.content.length > 0) {
return {
content: toolResult.content.map((content: any) => ({
type: "content",
content:
content.type === "text"
? {
type: "text",
text: markdownEscape(content.text.replace(SYSTEM_REMINDER, "")),
}
: content,
})),
};
} else if (typeof toolResult.content === "string" && toolResult.content.length > 0) {
return {
content: [
{
type: "content",
content: {
type: "text",
text: markdownEscape(toolResult.content.replace(SYSTEM_REMINDER, "")),
},
},
],
};
}
return {};
case toolNames.bash:
case "edit":
case "Edit": {
// Parse the line numbers from the tool result
if (toolResult.content && toolResult.content.length > 0) {
try {
const firstContent = toolResult.content[0];
const data = JSON.parse(firstContent.text || firstContent);
if (data.lineNumbers && Array.isArray(data.lineNumbers)) {
const locations: ToolCallLocation[] = data.lineNumbers.map(
(line: number) => ({
path: toolUse?.input?.abs_path || toolUse?.input?.file_path,
line: line,
}),
);
return { locations };
}
} catch (e) {
// If parsing fails, return empty object
return {};
}
case "Edit":
case toolNames.edit:
case toolNames.write:
case "Write": {
if (toolResult.is_error && toolResult.content && toolResult.content.length > 0) {
// Only return errors
return toAcpContentUpdate(toolResult.content, true);
}
return {};
}
case "mcp__acp__multi-edit":
case "multi-edit":
case "MultiEdit": {
// Parse the line numbers from the tool result
if (toolResult.content && toolResult.content.length > 0) {
try {
const firstContent = toolResult.content[0];
const data = JSON.parse(firstContent.text || firstContent);
if (data.lineNumbers && Array.isArray(data.lineNumbers)) {
const locations: ToolCallLocation[] = data.lineNumbers.map(
(line: number) => ({
path: toolUse?.input?.file_path || toolUse?.input?.abs_path,
line: line,
}),
);
return { locations };
}
} catch (e) {
// If parsing fails, return empty object
return {};
}
}
return {};
case "ExitPlanMode": {
return { title: "Exited Plan Mode" };
}
case "Task":
case "NotebookEdit":
case "NotebookRead":
case "mcp__acp__write":
case "Write":
case "TodoWrite":
case "exit_plan_mode":
case "Bash":
@@ -464,31 +451,45 @@ export function toolUpdateFromToolResult(
case "WebSearch":
case "Other":
default: {
// This happens for the mcp__acp__read tool,
// but may also for others...
if (Array.isArray(toolResult.content)) {
return {
content: toolResult.content.map((content: any) => ({
type: "content",
content,
})),
};
} else if (typeof toolResult.content === "string") {
return {
content: [
{
type: "content",
content: { type: "text", text: toolResult.content },
},
],
};
}
return {};
return toAcpContentUpdate(toolResult.content, toolResult.is_error);
}
}
}
type ClaudePlanEntry = {
function toAcpContentUpdate(
content: any,
isError: boolean = false,
): { 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,
})),
};
} else if (typeof content === "string" && content.length > 0) {
return {
content: [
{
type: "content",
content: {
type: "text",
text: isError ? `\`\`\`\n${content}\n\`\`\`` : content,
},
},
],
};
}
return {};
}
export type ClaudePlanEntry = {
content: string;
status: "pending" | "in_progress" | "completed";
activeForm: string;
@@ -501,3 +502,13 @@ export function planEntries(input: { todos: ClaudePlanEntry[] }): PlanEntry[] {
priority: "medium",
}));
}
export function markdownEscape(text: string): string {
let escape = "```";
for (const [m] of text.matchAll(/^```+/gm)) {
while (m.length >= escape.length) {
escape += "`";
}
}
return escape + "\n" + text + (text.endsWith("\n") ? "" : "\n") + escape;
}

View File

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