Compare commits

..

1 Commits

Author SHA1 Message Date
Cole Miller
abae47de80 read output in a code block 2025-08-28 21:08:26 -04:00
17 changed files with 604 additions and 1919 deletions

View File

@@ -1,6 +0,0 @@
node_modules/
dist/
coverage/
*.min.js
*.config.js
.github/

View File

@@ -1,33 +0,0 @@
{
"root": true,
"parser": "@typescript-eslint/parser",
"parserOptions": {
"ecmaVersion": 2020,
"sourceType": "module",
"project": "./tsconfig.json"
},
"plugins": ["@typescript-eslint"],
"extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended", "prettier"],
"env": {
"node": true,
"es2020": true
},
"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"]
},
"ignorePatterns": ["dist", "node_modules", "*.js"]
}

View File

@@ -1,45 +0,0 @@
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

View File

@@ -1,23 +0,0 @@
name: Publish Package to npmjs
on:
push:
tags:
- "v*.*.*"
jobs:
build:
runs-on: ubuntu-latest
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"
- run: npm ci
# Add back provenance once repo is public
- run: npm publish # --provenance --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

View File

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

View File

@@ -1,51 +1,3 @@
# ACP adapter for Claude Code
An ACP-compatible coding agent powered by the Claude Code SDK
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
- 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](https://zed.dev/docs/ai/external-agents) on External Agent support.
### Other clients
Setup instructions for other clients are coming soon. Feel free to [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
This wrapper lets you use the real version of Claude Code from the IDE of your choice.

571
package-lock.json generated
View File

@@ -1,44 +1,37 @@
{
"name": "@zed-industries/claude-code-acp",
"version": "0.1.9",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@zed-industries/claude-code-acp",
"version": "0.1.9",
"version": "0.1.0",
"license": "Apache-2.0",
"dependencies": {
"@anthropic-ai/claude-code": "^1.0.100",
"@anthropic-ai/claude-code": "^1.0.96",
"@modelcontextprotocol/sdk": "^1.17.4",
"@types/express": "^5.0.3",
"@zed-industries/agent-client-protocol": "0.2.0-alpha.4",
"@zed-industries/agent-client-protocol": "0.1.2",
"express": "^5.1.0",
"minimist": "^1.2.8",
"uuid": "11.1.0"
},
"bin": {
"claude-code-acp": "dist/index.js"
},
"devDependencies": {
"@types/minimist": "^1.2.5",
"@types/node": "^24.3.0",
"@types/uuid": "^10.0.0",
"@typescript-eslint/eslint-plugin": "^8.42.0",
"@typescript-eslint/parser": "^8.42.0",
"@vitest/ui": "^3.2.4",
"eslint": "^8.57.0",
"eslint-config-prettier": "^9.1.0",
"prettier": "^3.6.2",
"eslint": "^8.56.0",
"ts-node": "^10.9.2",
"typescript": "^5.4.0",
"vitest": "^3.2.4"
}
},
"node_modules/@anthropic-ai/claude-code": {
"version": "1.0.100",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-code/-/claude-code-1.0.100.tgz",
"integrity": "sha512-b4FRo3t46kPawzE8pT7nxtDc9NdYPTFaPoGGns/ZNqsqRu2jKMiSjjt5DiFvfaBZA8Tnt2qXbv4nd1vqR9Ru8Q==",
"version": "1.0.96",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-code/-/claude-code-1.0.96.tgz",
"integrity": "sha512-xnxhYzuh6PYlMcw56REMQiGMW20WaLLOvG8L8TObq70zhNKs3dro7nhYwHRe1c2ubTr20oIJK0aSkyD2BpO8nA==",
"license": "SEE LICENSE IN README.md",
"bin": {
"claude": "cli.js"
@@ -563,30 +556,6 @@
"url": "https://opencollective.com/eslint"
}
},
"node_modules/@eslint/eslintrc/node_modules/brace-expansion": {
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
}
},
"node_modules/@eslint/eslintrc/node_modules/minimatch": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^1.1.7"
},
"engines": {
"node": "*"
}
},
"node_modules/@eslint/js": {
"version": "8.57.1",
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz",
@@ -613,30 +582,6 @@
"node": ">=10.10.0"
}
},
"node_modules/@humanwhocodes/config-array/node_modules/brace-expansion": {
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
}
},
"node_modules/@humanwhocodes/config-array/node_modules/minimatch": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^1.1.7"
},
"engines": {
"node": "*"
}
},
"node_modules/@humanwhocodes/module-importer": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz",
@@ -1406,251 +1351,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.42.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.42.0.tgz",
"integrity": "sha512-Aq2dPqsQkxHOLfb2OPv43RnIvfj05nw8v/6n3B2NABIPpHnjQnaLo9QGMTvml+tv4korl/Cjfrb/BYhoL8UUTQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/regexpp": "^4.10.0",
"@typescript-eslint/scope-manager": "8.42.0",
"@typescript-eslint/type-utils": "8.42.0",
"@typescript-eslint/utils": "8.42.0",
"@typescript-eslint/visitor-keys": "8.42.0",
"graphemer": "^1.4.0",
"ignore": "^7.0.0",
"natural-compare": "^1.4.0",
"ts-api-utils": "^2.1.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"@typescript-eslint/parser": "^8.42.0",
"eslint": "^8.57.0 || ^9.0.0",
"typescript": ">=4.8.4 <6.0.0"
}
},
"node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": {
"version": "7.0.5",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz",
"integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 4"
}
},
"node_modules/@typescript-eslint/parser": {
"version": "8.42.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.42.0.tgz",
"integrity": "sha512-r1XG74QgShUgXph1BYseJ+KZd17bKQib/yF3SR+demvytiRXrwd12Blnz5eYGm8tXaeRdd4x88MlfwldHoudGg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/scope-manager": "8.42.0",
"@typescript-eslint/types": "8.42.0",
"@typescript-eslint/typescript-estree": "8.42.0",
"@typescript-eslint/visitor-keys": "8.42.0",
"debug": "^4.3.4"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"eslint": "^8.57.0 || ^9.0.0",
"typescript": ">=4.8.4 <6.0.0"
}
},
"node_modules/@typescript-eslint/project-service": {
"version": "8.42.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.42.0.tgz",
"integrity": "sha512-vfVpLHAhbPjilrabtOSNcUDmBboQNrJUiNAGoImkZKnMjs2TIcWG33s4Ds0wY3/50aZmTMqJa6PiwkwezaAklg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/tsconfig-utils": "^8.42.0",
"@typescript-eslint/types": "^8.42.0",
"debug": "^4.3.4"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"typescript": ">=4.8.4 <6.0.0"
}
},
"node_modules/@typescript-eslint/scope-manager": {
"version": "8.42.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.42.0.tgz",
"integrity": "sha512-51+x9o78NBAVgQzOPd17DkNTnIzJ8T/O2dmMBLoK9qbY0Gm52XJcdJcCl18ExBMiHo6jPMErUQWUv5RLE51zJw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.42.0",
"@typescript-eslint/visitor-keys": "8.42.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/@typescript-eslint/tsconfig-utils": {
"version": "8.42.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.42.0.tgz",
"integrity": "sha512-kHeFUOdwAJfUmYKjR3CLgZSglGHjbNTi1H8sTYRYV2xX6eNz4RyJ2LIgsDLKf8Yi0/GL1WZAC/DgZBeBft8QAQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"typescript": ">=4.8.4 <6.0.0"
}
},
"node_modules/@typescript-eslint/type-utils": {
"version": "8.42.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.42.0.tgz",
"integrity": "sha512-9KChw92sbPTYVFw3JLRH1ockhyR3zqqn9lQXol3/YbI6jVxzWoGcT3AsAW0mu1MY0gYtsXnUGV/AKpkAj5tVlQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.42.0",
"@typescript-eslint/typescript-estree": "8.42.0",
"@typescript-eslint/utils": "8.42.0",
"debug": "^4.3.4",
"ts-api-utils": "^2.1.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"eslint": "^8.57.0 || ^9.0.0",
"typescript": ">=4.8.4 <6.0.0"
}
},
"node_modules/@typescript-eslint/types": {
"version": "8.42.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.42.0.tgz",
"integrity": "sha512-LdtAWMiFmbRLNP7JNeY0SqEtJvGMYSzfiWBSmx+VSZ1CH+1zyl8Mmw1TT39OrtsRvIYShjJWzTDMPWZJCpwBlw==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/@typescript-eslint/typescript-estree": {
"version": "8.42.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.42.0.tgz",
"integrity": "sha512-ku/uYtT4QXY8sl9EDJETD27o3Ewdi72hcXg1ah/kkUgBvAYHLwj2ofswFFNXS+FL5G+AGkxBtvGt8pFBHKlHsQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/project-service": "8.42.0",
"@typescript-eslint/tsconfig-utils": "8.42.0",
"@typescript-eslint/types": "8.42.0",
"@typescript-eslint/visitor-keys": "8.42.0",
"debug": "^4.3.4",
"fast-glob": "^3.3.2",
"is-glob": "^4.0.3",
"minimatch": "^9.0.4",
"semver": "^7.6.0",
"ts-api-utils": "^2.1.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"typescript": ">=4.8.4 <6.0.0"
}
},
"node_modules/@typescript-eslint/utils": {
"version": "8.42.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.42.0.tgz",
"integrity": "sha512-JnIzu7H3RH5BrKC4NoZqRfmjqCIS1u3hGZltDYJgkVdqAezl4L9d1ZLw+36huCujtSBSAirGINF/S4UxOcR+/g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/eslint-utils": "^4.7.0",
"@typescript-eslint/scope-manager": "8.42.0",
"@typescript-eslint/types": "8.42.0",
"@typescript-eslint/typescript-estree": "8.42.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"eslint": "^8.57.0 || ^9.0.0",
"typescript": ">=4.8.4 <6.0.0"
}
},
"node_modules/@typescript-eslint/visitor-keys": {
"version": "8.42.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.42.0.tgz",
"integrity": "sha512-3WbiuzoEowaEn8RSnhJBrxSwX8ULYE9CXaPepS2C2W3NSA5NNIvBaslpBSBElPq0UGr0xVJlXFWOAKIkyylydQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.42.0",
"eslint-visitor-keys": "^4.2.1"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz",
"integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"url": "https://opencollective.com/eslint"
}
},
"node_modules/@ungap/structured-clone": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz",
@@ -1796,9 +1496,9 @@
}
},
"node_modules/@zed-industries/agent-client-protocol": {
"version": "0.2.0-alpha.4",
"resolved": "https://registry.npmjs.org/@zed-industries/agent-client-protocol/-/agent-client-protocol-0.2.0-alpha.4.tgz",
"integrity": "sha512-l/MdK0MrvEb55/U3wAtAN/MC04YK+N6vNyakNgLsK2JTcmG54n45gXa0OZ08lYUmEvLe0shdu1dDtzlOV/Luxw==",
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/@zed-industries/agent-client-protocol/-/agent-client-protocol-0.1.2.tgz",
"integrity": "sha512-A1RCN7Uf09vngbhBVLiZENenKYOYD2OGiILLGXPaSBloDbem8xmLaDmrKQuYl0kjLJ1DcWmVaHUv97n1l5vJ6w==",
"license": "Apache-2.0",
"dependencies": {
"zod": "^3.0.0"
@@ -1947,26 +1647,14 @@
}
},
"node_modules/brace-expansion": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0"
}
},
"node_modules/braces": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
"dev": true,
"license": "MIT",
"dependencies": {
"fill-range": "^7.1.1"
},
"engines": {
"node": ">=8"
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
}
},
"node_modules/bytes": {
@@ -2421,19 +2109,6 @@
"url": "https://opencollective.com/eslint"
}
},
"node_modules/eslint-config-prettier": {
"version": "9.1.2",
"resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-9.1.2.tgz",
"integrity": "sha512-iI1f+D2ViGn+uvv5HuHVUamg8ll4tN+JRHGc6IJi4TP9Kl976C57fzPXgseXNs8v0iA8aSJpHsTWjDb9QJamGQ==",
"dev": true,
"license": "MIT",
"bin": {
"eslint-config-prettier": "bin/cli.js"
},
"peerDependencies": {
"eslint": ">=7.0.0"
}
},
"node_modules/eslint-scope": {
"version": "7.2.2",
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz",
@@ -2464,30 +2139,6 @@
"url": "https://opencollective.com/eslint"
}
},
"node_modules/eslint/node_modules/brace-expansion": {
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
}
},
"node_modules/eslint/node_modules/minimatch": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^1.1.7"
},
"engines": {
"node": "*"
}
},
"node_modules/espree": {
"version": "9.6.1",
"resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz",
@@ -2584,12 +2235,12 @@
}
},
"node_modules/eventsource-parser": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz",
"integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==",
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.5.tgz",
"integrity": "sha512-bSRG85ZrMdmWtm7qkF9He9TNRzc/Bm99gEJMaQoHJ9E6Kv9QBbsldh2oMj7iXmYNEAVvNgvv5vPorG6W+XtBhQ==",
"license": "MIT",
"engines": {
"node": ">=18.0.0"
"node": ">=20.0.0"
}
},
"node_modules/expect-type": {
@@ -2665,36 +2316,6 @@
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"license": "MIT"
},
"node_modules/fast-glob": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
"integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@nodelib/fs.stat": "^2.0.2",
"@nodelib/fs.walk": "^1.2.3",
"glob-parent": "^5.1.2",
"merge2": "^1.3.0",
"micromatch": "^4.0.8"
},
"engines": {
"node": ">=8.6.0"
}
},
"node_modules/fast-glob/node_modules/glob-parent": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
"dev": true,
"license": "ISC",
"dependencies": {
"is-glob": "^4.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/fast-json-stable-stringify": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
@@ -2756,19 +2377,6 @@
"node": "^10.12.0 || >=12.0.0"
}
},
"node_modules/fill-range": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
"dev": true,
"license": "MIT",
"dependencies": {
"to-regex-range": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/finalhandler": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.0.tgz",
@@ -2946,30 +2554,6 @@
"node": ">=10.13.0"
}
},
"node_modules/glob/node_modules/brace-expansion": {
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
}
},
"node_modules/glob/node_modules/minimatch": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^1.1.7"
},
"engines": {
"node": "*"
}
},
"node_modules/globals": {
"version": "13.24.0",
"resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz",
@@ -3163,16 +2747,6 @@
"node": ">=0.10.0"
}
},
"node_modules/is-number": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.12.0"
}
},
"node_modules/is-path-inside": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz",
@@ -3336,43 +2910,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/merge2": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
"integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 8"
}
},
"node_modules/micromatch": {
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
"integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
"dev": true,
"license": "MIT",
"dependencies": {
"braces": "^3.0.3",
"picomatch": "^2.3.1"
},
"engines": {
"node": ">=8.6"
}
},
"node_modules/micromatch/node_modules/picomatch": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8.6"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/mime-db": {
"version": "1.54.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
@@ -3395,19 +2932,16 @@
}
},
"node_modules/minimatch": {
"version": "9.0.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
"integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.1"
"brace-expansion": "^1.1.7"
},
"engines": {
"node": ">=16 || 14 >=14.17"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
"node": "*"
}
},
"node_modules/minimist": {
@@ -3707,22 +3241,6 @@
"node": ">= 0.8.0"
}
},
"node_modules/prettier": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz",
"integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
"dev": true,
"license": "MIT",
"bin": {
"prettier": "bin/prettier.cjs"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/prettier/prettier?sponsor=1"
}
},
"node_modules/proxy-addr": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
@@ -3949,19 +3467,6 @@
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/semver": {
"version": "7.7.2",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz",
"integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==",
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/send": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/send/-/send-1.2.0.tgz",
@@ -4273,19 +3778,6 @@
"node": ">=14.0.0"
}
},
"node_modules/to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"is-number": "^7.0.0"
},
"engines": {
"node": ">=8.0"
}
},
"node_modules/toidentifier": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
@@ -4305,19 +3797,6 @@
"node": ">=6"
}
},
"node_modules/ts-api-utils": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz",
"integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18.12"
},
"peerDependencies": {
"typescript": ">=4.8.4"
}
},
"node_modules/ts-node": {
"version": "10.9.2",
"resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz",

View File

@@ -3,33 +3,19 @@
"publishConfig": {
"access": "public"
},
"bin": {
"claude-code-acp": "./dist/index.js"
},
"version": "0.1.9",
"version": "0.1.0",
"description": "An ACP-compatible coding agent powered by the Claude Code SDK (TypeScript)",
"main": "dist/index.js",
"main": "dist/acp-agent.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",
"prepublishOnly": "npm run build"
"test:coverage": "vitest run --coverage"
},
"keywords": [
"claude",
@@ -43,10 +29,10 @@
"author": "Zed Industries",
"license": "Apache-2.0",
"dependencies": {
"@anthropic-ai/claude-code": "^1.0.100",
"@anthropic-ai/claude-code": "^1.0.96",
"@modelcontextprotocol/sdk": "^1.17.4",
"@types/express": "^5.0.3",
"@zed-industries/agent-client-protocol": "0.2.0-alpha.4",
"@zed-industries/agent-client-protocol": "0.1.2",
"express": "^5.1.0",
"minimist": "^1.2.8",
"uuid": "11.1.0"
@@ -55,12 +41,8 @@
"@types/minimist": "^1.2.5",
"@types/node": "^24.3.0",
"@types/uuid": "^10.0.0",
"@typescript-eslint/eslint-plugin": "^8.42.0",
"@typescript-eslint/parser": "^8.42.0",
"@vitest/ui": "^3.2.4",
"eslint": "^8.57.0",
"eslint-config-prettier": "^9.1.0",
"prettier": "^3.6.2",
"eslint": "^8.56.0",
"ts-node": "^10.9.2",
"typescript": "^5.4.0",
"vitest": "^3.2.4"

View File

@@ -2,40 +2,44 @@ import {
Agent,
AgentSideConnection,
AuthenticateRequest,
AvailableCommand,
CancelNotification,
ClientCapabilities,
Client,
InitializeRequest,
InitializeResponse,
LoadSessionRequest,
NewSessionRequest,
NewSessionResponse,
PromptRequest,
PromptResponse,
ReadTextFileRequest,
ReadTextFileResponse,
RequestError,
TerminalHandle,
TerminalOutputResponse,
WriteTextFileRequest,
WriteTextFileResponse,
ToolCallContent,
ToolKind,
} from "@zed-industries/agent-client-protocol";
import {
McpServerConfig,
Options,
Query,
query,
SDKAssistantMessage,
SDKMessage,
SDKUserMessage,
} from "@anthropic-ai/claude-code";
import * as fs from "node:fs";
import * as path from "node:path";
import * as os from "node:os";
import * as readline from "node:readline";
import { v7 as uuidv7 } from "uuid";
import { nodeToWebReadable, nodeToWebWritable, Pushable, sleep, unreachable } from "./utils.js";
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";
import { AddressInfo } from "node:net";
import { toolInfoFromToolUse, planEntries, toolUpdateFromToolResult } from "./tools.js";
import {
toolInfoFromToolUse,
planEntries,
toolUpdateFromToolResult,
} from "./tools.js";
type Session = {
query: Query;
@@ -43,49 +47,25 @@ type Session = {
cancelled: boolean;
};
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 };
};
// Implement the ACP Agent interface
export class ClaudeAcpAgent implements Agent {
sessions: {
[key: string]: Session;
};
client: AgentSideConnection;
toolUseCache: ToolUseCache;
fileContentCache: { [key: string]: string };
backgroundTerminals: { [key: string]: BackgroundTerminal } = {};
clientCapabilities?: ClientCapabilities;
client: Client;
toolUseCache: { [key: string]: any };
constructor(client: AgentSideConnection) {
constructor(client: Client) {
this.sessions = {};
this.client = client;
this.toolUseCache = {};
this.fileContentCache = {};
}
async initialize(request: InitializeRequest): Promise<InitializeResponse> {
this.clientCapabilities = request.clientCapabilities;
async initialize(params: InitializeRequest): Promise<InitializeResponse> {
return {
protocolVersion: 1,
// todo!()
agentCapabilities: {
promptCapabilities: {
image: true,
embeddedContext: true,
},
promptCapabilities: { image: true, embeddedContext: true },
},
authMethods: [
{
@@ -102,8 +82,8 @@ export class ClaudeAcpAgent implements Agent {
};
}
async newSession(params: NewSessionRequest): Promise<NewSessionResponse> {
const sessionId = uuidv7();
const input = new Pushable<SDKUserMessage>();
let sessionId = uuidv7();
let input = new Pushable<SDKUserMessage>();
const mcpServers: Record<string, McpServerConfig> = {};
if (Array.isArray(params.mcpServers)) {
@@ -119,8 +99,9 @@ export class ClaudeAcpAgent implements Agent {
}
}
const server = await createMcpServer(this, sessionId, this.clientCapabilities);
const address = server.address() as AddressInfo;
// todo!() auth
let server = await createMcpServer(this, sessionId);
let address = server.address() as AddressInfo;
mcpServers["acp"] = {
type: "http",
url: "http://127.0.0.1:" + address.port + "/mcp",
@@ -129,38 +110,17 @@ export class ClaudeAcpAgent implements Agent {
},
};
const options: Options = {
cwd: params.cwd,
mcpServers,
permissionPromptToolName: "mcp__acp__permission",
stderr: (err) => console.error(err),
};
const allowedTools = [];
const disallowedTools = [];
if (this.clientCapabilities?.fs?.readTextFile) {
allowedTools.push("mcp__acp__read");
disallowedTools.push("Read");
}
if (this.clientCapabilities?.fs?.writeTextFile) {
allowedTools.push("mcp__acp__write");
disallowedTools.push("Write", "Edit", "MultiEdit");
}
if (this.clientCapabilities?.terminal) {
allowedTools.push("mcp__acp__BashOutput", "mcp__acp__KillBash");
disallowedTools.push("Bash", "BashOutput", "KillBash");
}
if (allowedTools.length > 0) {
options.allowedTools = allowedTools;
}
if (disallowedTools.length > 0) {
options.disallowedTools = disallowedTools;
}
const q = query({
let q = query({
prompt: input,
options,
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),
},
});
this.sessions[sessionId] = {
query: q,
@@ -168,14 +128,12 @@ export class ClaudeAcpAgent implements Agent {
cancelled: false,
};
const availableCommands = await availableSlashCommands(q);
return {
sessionId,
availableCommands,
};
}
async authenticate(_params: AuthenticateRequest): Promise<void> {
async authenticate(params: AuthenticateRequest): Promise<void> {
throw new Error("Method not implemented.");
}
@@ -190,7 +148,7 @@ export class ClaudeAcpAgent implements Agent {
input.push(promptToClaude(params));
while (true) {
const { value: message, done } = await query.next();
let { value: message, done } = await query.next();
if (done || !message) {
if (this.sessions[params.sessionId].cancelled) {
return { stopReason: "cancelled" };
@@ -228,8 +186,8 @@ export class ClaudeAcpAgent implements Agent {
}
if (
message.message.model === "<synthetic>" &&
message.message.content.length === 1 &&
message.message.model == "<synthetic>" &&
message.message.content.length == 1 &&
message.message.content[0].text.includes("Please run /login")
) {
throw RequestError.authRequired();
@@ -238,7 +196,6 @@ export class ClaudeAcpAgent implements Agent {
message,
params.sessionId,
this.toolUseCache,
this.fileContentCache,
)) {
await this.client.sessionUpdate(notification);
}
@@ -258,86 +215,6 @@ export class ClaudeAcpAgent implements Agent {
this.sessions[params.sessionId].cancelled = true;
await this.sessions[params.sessionId].query.interrupt();
}
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 availableSlashCommands(query: Query): Promise<AvailableCommand[]> {
const UNSUPPORTED_COMMANDS = [
"add-dir",
"agents", // Modal
"bashes", // Modal
"bug", // Modal
"clear", // Escape Codes
"compact", // Not supported via SDK?
"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 Promise.race([
//todo: Do not use `as any` once `supportedCommands` is exposed via the typescript interface
(query as any).supportedCommands(),
sleep(5000).then(() => {
if (
fs.existsSync(path.resolve(os.homedir(), ".claude.json.backup")) &&
!fs.existsSync(path.resolve(os.homedir(), ".claude.json"))
) {
throw RequestError.authRequired();
}
throw new Error("can't load supported slash commands");
}),
]);
return commands
.map((command: { name: string; description: string; argumentHint: string }) => {
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 {
@@ -435,14 +312,13 @@ function promptToClaude(prompt: PromptRequest): SDKUserMessage {
export function toAcpNotifications(
message: SDKAssistantMessage | SDKUserMessage,
sessionId: string,
toolUseCache: ToolUseCache,
fileContentCache: { [key: string]: string },
toolUseCache: { [key: string]: any },
): SessionNotification[] {
const chunks = message.message.content as ContentChunk[];
const output = [];
let chunks = message.message.content as ContentChunk[];
let output = [];
// Only handle the first chunk for streaming; extend as needed for batching
for (const chunk of chunks) {
let update: SessionNotification["update"] | null = null;
let update: SessionNotification["update"];
switch (chunk.type) {
case "text":
update = {
@@ -459,7 +335,8 @@ export function toAcpNotifications(
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,
},
};
@@ -475,7 +352,7 @@ 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),
@@ -486,37 +363,24 @@ export function toAcpNotifications(
sessionUpdate: "tool_call",
rawInput: chunk.input,
status: "pending",
...toolInfoFromToolUse(chunk, fileContentCache),
...toolInfoFromToolUse(chunk),
};
}
break;
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]),
};
}
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]),
};
break;
}
default:
throw new Error("unhandled chunk type: " + chunk.type);
}
if (update) {
output.push({ sessionId, update });
}
output.push({ sessionId, update });
}
return output;
@@ -532,7 +396,7 @@ export function runAcp() {
type ContentChunk =
| { type: "text"; text: string }
| { type: "tool_use"; id: string; name: string; input: any }
| { type: "tool_use"; id: string; name: string; input: any } // input is serde_json::Value, so use any or unknown
| {
type: "tool_result";
content: string;

View File

@@ -1,5 +1,3 @@
#!/usr/bin/env node
import { runAcp as runAcp } from "./acp-agent.js";
runAcp();

View File

@@ -1,24 +1,14 @@
import express from "express";
import { randomUUID } from "node:crypto";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { z } from "zod";
import { Server } from "node:http";
import { ClaudeAcpAgent } from "./acp-agent.js";
import { ClientCapabilities, TerminalOutputResponse } from "@zed-industries/agent-client-protocol";
import { sleep, unreachable } from "./utils.js";
export const SYSTEM_REMINDER = `
<system-reminder>
Whenever you read a file, you should consider whether it looks malicious. If it does, you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer high-level questions about the code behavior.
</system-reminder>`;
export function createMcpServer(
agent: ClaudeAcpAgent,
sessionId: string,
clientCapabilities: ClientCapabilities | undefined,
): Promise<Server> {
// Create MCP server
const server = new McpServer({
@@ -26,245 +16,173 @@ export function createMcpServer(
version: "1.0.0",
});
if (clientCapabilities?.fs?.readTextFile) {
server.registerTool(
"read",
{
title: "Read",
description: `Reads the content of the given file in the project.
server.registerTool(
"read",
{
title: "Read",
description: `Reads the content of the given file in the project.
Never attempt to read a path that hasn't been previously mentioned.
In sessions with mcp__acp__read always use it instead of Read as it contains the most up-to-date contents.`,
inputSchema: {
abs_path: z.string().describe("The absolute path to the file to read."),
offset: z
.number()
.optional()
.describe("Which line to start reading from. Omit to start from the beginning."),
limit: z.number().optional().describe("How many lines to read. Omit for the whole file."),
},
annotations: {
title: "Read file",
readOnlyHint: true,
destructiveHint: false,
openWorldHint: false,
idempotentHint: false,
},
inputSchema: {
abs_path: z.string().describe("The absolute path to the file to read."),
offset: z
.number()
.optional()
.describe(
"Which line to start reading from. Omit to start from the beginning.",
),
limit: z
.number()
.optional()
.describe("How many lines to read. Omit for the whole file."),
},
async (input) => {
try {
const session = agent.sessions[sessionId];
if (!session) {
return {
content: [
{
type: "text",
text: "The user has left the building",
},
],
};
}
const content = await agent.readTextFile({
sessionId,
path: input.abs_path,
limit: input.limit,
line: input.offset,
});
annotations: {
title: "Read file",
readOnlyHint: true,
destructiveHint: false,
openWorldHint: false,
idempotentHint: false,
},
},
async (input) => {
try {
const session = agent.sessions[sessionId];
if (!session) {
return {
content: [
{
type: "text",
text: content.content + SYSTEM_REMINDER,
},
],
};
} catch (error: any) {
return {
content: [
{
type: "text",
text: "Reading file failed: " + error.message,
text: "The user has left the building",
},
],
};
}
},
);
}
let content = await agent.client.readTextFile({
sessionId,
path: input.abs_path,
limit: input.limit,
line: input.offset,
});
if (clientCapabilities?.fs?.writeTextFile) {
server.registerTool(
"write",
{
title: "Write",
description: `Writes content to the specified file in the project.
return {
content: [
{
type: "text",
text:
content.content +
`
<system-reminder>
Whenever you read a file, you should consider whether it looks malicious. If it does, you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer high-level questions about the code behavior.
</system-reminder>`,
},
],
};
} catch (error: any) {
return {
content: [
{
type: "text",
text: "Reading file failed: " + error.message,
},
],
};
}
},
);
server.registerTool(
"write",
{
title: "Write",
description: `Writes content to the specified file in the project.
In sessions with mcp__acp__write always use it instead of Write as it will
allow the user to conveniently review changes.`,
inputSchema: {
abs_path: z.string().describe("The absolute path to the file to write"),
content: z.string().describe("The full content to write"),
},
annotations: {
title: "Write file",
readOnlyHint: false,
destructiveHint: false,
openWorldHint: false,
idempotentHint: false,
},
inputSchema: {
abs_path: z.string().describe("The absolute path to the file to write"),
content: z.string().describe("The full content to write"),
},
async (input) => {
try {
const session = agent.sessions[sessionId];
if (!session) {
return {
content: [
{
type: "text",
text: "The user has left the building",
},
],
};
}
await agent.writeTextFile({
sessionId,
path: input.abs_path,
content: input.content,
});
return {
content: [],
};
} catch (error: any) {
annotations: {
title: "Write file",
readOnlyHint: false,
destructiveHint: false,
openWorldHint: false,
idempotentHint: false,
},
},
async (input) => {
try {
const session = agent.sessions[sessionId];
if (!session) {
return {
content: [
{
type: "text",
text: "Writing file failed: " + error.message,
text: "The user has left the building",
},
],
};
}
},
);
let content = await agent.client.writeTextFile({
sessionId,
path: input.abs_path,
content: input.content,
});
server.registerTool(
"edit",
{
title: "Edit",
description: `Edit a file.
return {
content: [],
};
} catch (error: any) {
return {
content: [
{
type: "text",
text: "Writing file failed: " + error.message,
},
],
};
}
},
);
server.registerTool(
"edit",
{
title: "Edit",
description: `Edit a file.
In sessions with mcp__acp__edit always use it instead of Edit as it will
allow the user to conveniently review changes.
File editing instructions:
- The \`old_string\` param must match existing file content, including indentation.
- The \`old_string\` param must come from the actual file, not an outline.
- The \`old_string\` section must not be empty.
- The \`old_text\` param must match existing file content, including indentation.
- The \`old_text\` param must come from the actual file, not an outline.
- The \`old_text\` section must not be empty.
- Be minimal with replacements:
- For unique lines, include only those lines.
- For non-unique lines, include enough context to identify them.
- Do not escape quotes, newlines, or other characters.
- Only edit the specified file.`,
inputSchema: {
abs_path: z.string().describe("The absolute path to the file to read."),
old_string: z.string().describe("The old text to replace (must be unique in the file)"),
new_string: z.string().describe("The new text."),
},
annotations: {
title: "Edit file",
readOnlyHint: false,
destructiveHint: false,
openWorldHint: false,
idempotentHint: false,
},
inputSchema: {
abs_path: z.string().describe("The absolute path to the file to read."),
old_text: z
.string()
.describe("The old text to replace (must be unique in the file)"),
new_text: z.string().describe("The new text."),
},
async (input) => {
try {
const session = agent.sessions[sessionId];
if (!session) {
return {
content: [
{
type: "text",
text: "The user has left the building",
},
],
};
}
const { content } = await agent.readTextFile({
sessionId,
path: input.abs_path,
});
const { newContent, lineNumbers } = replaceAndCalculateLocation(content, [
{
oldText: input.old_string,
newText: input.new_string,
replaceAll: false,
},
]);
await agent.writeTextFile({
sessionId,
path: input.abs_path,
content: newContent,
});
return {
content: [
{
type: "text",
text: JSON.stringify({ lineNumbers }),
},
],
};
} catch (error: any) {
return {
content: [
{
type: "text",
text: "Editing file failed: " + error.message,
},
],
};
}
annotations: {
title: "Edit file",
readOnlyHint: false,
destructiveHint: false,
openWorldHint: false,
idempotentHint: false,
},
);
server.registerTool(
"multi-edit",
{
title: "Multi Edit",
description: `Edit a file with multiple sequential edits.`,
inputSchema: {
file_path: z.string().describe("The absolute path to the file to modify"),
edits: z
.array(
z.object({
old_string: z.string().describe("The text to replace"),
new_string: z.string().describe("The text to replace it with"),
replace_all: z
.boolean()
.optional()
.describe("Replace all occurrences of old_string (default false)"),
}),
)
.min(1)
.describe("Array of edit operations to perform sequentially on the file"),
},
annotations: {
title: "Multi Edit file",
readOnlyHint: false,
destructiveHint: false,
openWorldHint: false,
idempotentHint: false,
},
},
async (input) => {
},
async (input) => {
try {
const session = agent.sessions[sessionId];
if (!session) {
return {
@@ -277,23 +195,25 @@ File editing instructions:
};
}
const { content } = await agent.readTextFile({
let { content } = await agent.client.readTextFile({
sessionId,
path: input.file_path,
path: input.abs_path,
});
const { newContent, lineNumbers } = replaceAndCalculateLocation(
content,
input.edits.map((edit) => ({
oldText: edit.old_string,
newText: edit.new_string,
replaceAll: edit.replace_all ?? false,
})),
[
{
oldText: input.old_text,
newText: input.new_text,
replaceAll: false,
},
],
);
await agent.writeTextFile({
await agent.client.writeTextFile({
sessionId,
path: input.file_path,
path: input.abs_path,
content: newContent,
});
@@ -305,256 +225,99 @@ File editing instructions:
},
],
};
},
);
}
if (agent.clientCapabilities?.terminal) {
server.registerTool(
"Bash",
{
title: "Bash",
description: "Executes a bash command",
inputSchema: {
command: z.string().describe("The bash command to execute as a one-liner"),
timeout_ms: z
.number()
.default(2 * 60 * 1000)
.describe("Optional timeout in milliseconds"),
run_in_background: z
.boolean()
.default(false)
.describe(
"When set to true, the command is started in the background. The tool returns an `id` that can be used with the `mcp__acp__BashOutput` tool to retrieve the current output, or the `mcp__acp__KillBash` tool to stop it early.",
),
},
},
async (input, extra) => {
const session = agent.sessions[sessionId];
if (!session) {
return {
content: [
{
type: "text",
text: "The user has left the building",
},
],
};
}
const toolCallId = extra._meta?.["claudecode/toolUseId"];
if (typeof toolCallId !== "string") {
throw new Error("No tool call ID found");
}
if (!agent.clientCapabilities?.terminal || !agent.client.createTerminal) {
throw new Error("unreachable");
}
const handle = await agent.client.createTerminal({
command: input.command,
sessionId,
outputByteLimit: 32_000,
});
await agent.client.sessionUpdate({
sessionId,
update: {
sessionUpdate: "tool_call_update",
toolCallId,
status: "in_progress",
content: [{ type: "terminal", terminalId: handle.id }],
},
});
const abortPromise = new Promise((resolve) => {
if (extra.signal.aborted) {
resolve(null);
} else {
extra.signal.addEventListener("abort", () => {
resolve(null);
});
}
});
const statusPromise = Promise.race([
handle.waitForExit().then(() => ({ status: "exited" as const })),
abortPromise.then(() => ({ status: "aborted" as const })),
sleep(input.timeout_ms).then(async () => {
if (agent.backgroundTerminals[handle.id]?.status === "started") {
await handle.kill();
}
return { status: "timedOut" as const };
}),
]);
if (input.run_in_background) {
agent.backgroundTerminals[handle.id] = {
handle,
lastOutput: null,
status: "started",
};
statusPromise.then(async ({ status }) => {
const bgTerm = agent.backgroundTerminals[handle.id];
if (bgTerm.status !== "started") {
return;
}
const currentOutput = await handle.currentOutput();
agent.backgroundTerminals[handle.id] = {
status,
pendingOutput: {
...currentOutput,
output: stripCommonPrefix(bgTerm.lastOutput?.output ?? "", currentOutput.output),
},
};
return handle.release();
});
return {
content: [
{
type: "text",
text: `Command started in background with id: ${handle.id}`,
},
],
};
}
await using terminal = handle;
const { status } = await statusPromise;
if (status === "aborted") {
return {
content: [{ type: "text", text: "Tool cancelled by user" }],
};
}
const output = await terminal.currentOutput();
} catch (error: any) {
return {
content: [{ type: "text", text: toolCommandOutput(status, output) }],
content: [
{
type: "text",
text: "Editing file failed: " + error.message,
},
],
};
}
},
);
server.registerTool(
"multi-edit",
{
title: "Multi Edit",
description: `Edit a file with multiple sequential edits.`,
inputSchema: {
file_path: z
.string()
.describe("The absolute path to the file to modify"),
edits: z
.array(
z.object({
old_string: z.string().describe("The text to replace"),
new_string: z.string().describe("The text to replace it with"),
replace_all: z
.boolean()
.optional()
.describe(
"Replace all occurrences of old_string (default false)",
),
}),
)
.min(1)
.describe(
"Array of edit operations to perform sequentially on the file",
),
},
);
server.registerTool(
"BashOutput",
{
title: "BashOutput",
description:
"Returns the current output and exit status of a background bash command by its id. Includes only new output since last invocation.",
inputSchema: {
id: z
.string()
.describe("The id of the background bash command as returned by `mcp__acp__Bash`"),
},
annotations: {
title: "Multi Edit file",
readOnlyHint: false,
destructiveHint: false,
openWorldHint: false,
idempotentHint: false,
},
async (input) => {
const bgTerm = agent.backgroundTerminals[input.id];
},
async (input) => {
const session = agent.sessions[sessionId];
if (!session) {
return {
content: [
{
type: "text",
text: "The user has left the building",
},
],
};
}
if (!bgTerm) {
throw new Error(`Unknown shell ${input.id}`);
}
let { content } = await agent.client.readTextFile({
sessionId,
path: input.file_path,
});
if (bgTerm.status === "started") {
const newOutput = await bgTerm.handle.currentOutput();
const strippedOutput = stripCommonPrefix(
bgTerm.lastOutput?.output ?? "",
newOutput.output,
);
bgTerm.lastOutput = newOutput;
const { newContent, lineNumbers } = replaceAndCalculateLocation(
content,
input.edits.map((edit) => ({
oldText: edit.old_string,
newText: edit.new_string,
replaceAll: edit.replace_all ?? false,
})),
);
return {
content: [
{
type: "text",
text: toolCommandOutput(bgTerm.status, {
...newOutput,
output: strippedOutput,
}),
},
],
};
} else {
return {
content: [
{
type: "text",
text: toolCommandOutput(bgTerm.status, bgTerm.pendingOutput),
},
],
};
}
},
);
await agent.client.writeTextFile({
sessionId,
path: input.file_path,
content: newContent,
});
server.registerTool(
"KillBash",
{
title: "KillBash",
description: "Stops a background command by its id",
inputSchema: {
id: z
.string()
.describe("The id of the background bash command as returned by `mcp__acp__Bash`"),
},
},
async (input) => {
const bgTerm = agent.backgroundTerminals[input.id];
return {
content: [
{
type: "text",
text: JSON.stringify({ lineNumbers }),
},
],
};
},
);
if (!bgTerm) {
throw new Error(`Unknown shell ${input.id}`);
}
switch (bgTerm.status) {
case "started": {
await bgTerm.handle.kill();
const currentOutput = await bgTerm.handle.currentOutput();
agent.backgroundTerminals[bgTerm.handle.id] = {
status: "killed",
pendingOutput: {
...currentOutput,
output: stripCommonPrefix(bgTerm.lastOutput?.output ?? "", currentOutput.output),
},
};
await bgTerm.handle.release();
return {
content: [{ type: "text", text: "Command killed successfully." }],
};
}
case "aborted":
return {
content: [{ type: "text", text: "Command aborted by user." }],
};
case "exited":
return {
content: [{ type: "text", text: "Command had already exited." }],
};
case "killed":
return {
content: [{ type: "text", text: "Command was already killed." }],
};
case "timedOut":
return {
content: [{ type: "text", text: "Command killed by timeout." }],
};
default: {
return unreachable(bgTerm);
}
}
},
);
}
const alwaysAllowedTools: { [key: string]: boolean } = {};
let alwaysAllowedTools: { [key: string]: boolean } = {};
server.registerTool(
"permission",
{
@@ -594,7 +357,7 @@ File editing instructions:
],
};
}
const response = await agent.client.requestPermission({
let response = await agent.client.requestPermission({
options: [
{
kind: "allow_always",
@@ -611,10 +374,11 @@ File editing instructions:
},
});
if (
response.outcome?.outcome === "selected" &&
(response.outcome.optionId === "allow" || response.outcome.optionId === "allow_always")
response.outcome?.outcome == "selected" &&
(response.outcome.optionId == "allow" ||
response.outcome.optionId == "allow_always")
) {
if (response.outcome.optionId === "allow_always") {
if (response.outcome.optionId == "allow_always") {
alwaysAllowedTools[input.tool_name] = true;
}
return {
@@ -649,9 +413,10 @@ File editing instructions:
app.post("/mcp", async (req, res) => {
try {
const transport: StreamableHTTPServerTransport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined,
});
const transport: StreamableHTTPServerTransport =
new StreamableHTTPServerTransport({
sessionIdGenerator: undefined,
});
res.on("close", () => {
transport.close();
server.close();
@@ -664,7 +429,7 @@ File editing instructions:
jsonrpc: "2.0",
error: {
code: -32603,
message: `Internal server error: ${error}`,
message: "Internal server error",
},
id: null,
});
@@ -683,64 +448,6 @@ File editing instructions:
});
}
function stripCommonPrefix(a: string, b: string): string {
let i = 0;
while (i < a.length && i < b.length && a[i] === b[i]) {
i++;
}
return b.slice(i);
}
function toolCommandOutput(
status: "started" | "aborted" | "exited" | "killed" | "timedOut",
output: TerminalOutputResponse,
): string {
const { exitStatus, output: commandOutput, truncated } = output;
let toolOutput = "";
switch (status) {
case "started": {
if (exitStatus && (exitStatus.exitCode ?? null) === null) {
toolOutput += `Interrupted. `;
}
break;
}
case "killed":
toolOutput += `Killed. `;
break;
case "timedOut":
toolOutput += `Timed out. `;
break;
case "exited":
case "aborted":
break;
default: {
const unreachable: never = status;
return unreachable;
}
}
if (exitStatus) {
if (typeof exitStatus.exitCode === "number" && exitStatus.exitCode !== 0) {
toolOutput += `Failed with exit code ${exitStatus.exitCode}.`;
}
if (typeof exitStatus.signal === "string") {
toolOutput += `Signal \`${exitStatus.signal}\`. `;
}
}
toolOutput += "Output:\n\n";
toolOutput += commandOutput;
if (truncated) {
toolOutput += `\n\nCommand output was too long, so it was truncated to ${commandOutput.length} bytes.`;
}
return toolOutput;
}
/**
* Replace text in a file and calculate the line numbers where the edits occurred.
*
@@ -778,9 +485,7 @@ export function replaceAndCalculateLocation(
while (true) {
const index = currentContent.indexOf(edit.oldText, searchIndex);
if (index === -1) {
break;
}
if (index === -1) break;
// Add content before the match
parts.push(currentContent.substring(lastIndex, index));

View File

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

View File

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

View File

@@ -4,7 +4,6 @@ import {
Agent,
Client,
ClientSideConnection,
NewSessionResponse,
ReadTextFileRequest,
ReadTextFileResponse,
RequestPermissionRequest,
@@ -14,168 +13,89 @@ import {
WriteTextFileResponse,
} from "@zed-industries/agent-client-protocol";
import { nodeToWebWritable, nodeToWebReadable } from "../utils.js";
import { markdownEscape, toolInfoFromToolUse, toolUpdateFromToolResult } from "../tools.js";
import { toolInfoFromToolUse, toolUpdateFromToolResult } from "../tools.js";
import { toAcpNotifications } from "../acp-agent.js";
import { SDKAssistantMessage } from "@anthropic-ai/claude-code";
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 () => {
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,
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);
});
});
child.on("error", (error) => {
console.error("Error starting subprocess:", error);
afterAll(() => {
child.kill();
});
child.on("exit", (exit) => {
console.error("Exited with", exit);
});
});
afterAll(() => {
child.kill();
});
class TestClient implements Client {
agent: Agent;
class TestClient implements Client {
agent: Agent;
files: Map<string, string> = new Map();
receivedText: string = "";
constructor(agent: Agent) {
this.agent = agent;
}
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));
if (
params.update.sessionUpdate === "agent_message_chunk" &&
params.update.content.type === "text"
) {
this.receivedText += params.update.content.text;
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.",
};
}
}
async writeTextFile(params: WriteTextFileRequest): Promise<WriteTextFileResponse> {
this.files.set(params.path, params.content);
return null;
}
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 connection = new ClientSideConnection(
(agent) => {
client = new TestClient(agent);
return client;
},
nodeToWebWritable(child.stdin!),
nodeToWebReadable(child.stdout!),
);
await connection.initialize({
protocolVersion: 1,
clientCapabilities: {
fs: {
readTextFile: true,
writeTextFile: true,
it("should connect to the ACP subprocess", async () => {
const connection = new ClientSideConnection(
(agent) => {
return new TestClient(agent);
},
},
});
nodeToWebWritable(child.stdin!),
nodeToWebReadable(child.stdout!),
);
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);
expect(newSessionResponse.availableCommands).toContainEqual({
name: "quick-math",
description: "10 * 3 = 30 (project)",
input: null,
});
expect(newSessionResponse.availableCommands).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);
});
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);
},
);
describe("tool conversions", () => {
it("should handle Bash nicely", () => {
@@ -189,7 +109,7 @@ describe("tool conversions", () => {
},
};
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
kind: "execute",
title: "`rm README.md.rm`",
content: [
@@ -214,7 +134,7 @@ describe("tool conversions", () => {
},
};
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
kind: "search",
title: "Find */**.ts",
content: [],
@@ -235,7 +155,7 @@ describe("tool conversions", () => {
},
};
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
kind: "think",
title: "Handle user's work request",
content: [
@@ -256,15 +176,15 @@ describe("tool conversions", () => {
id: "toolu_01EEqsX7Eb9hpx87KAHVPTey",
name: "LS",
input: {
path: "/Users/test/github/claude-code-acp",
path: "/Users/benbrandt/github/claude-code-acp",
},
};
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
kind: "search",
title: "List the `/Users/test/github/claude-code-acp` directory's contents",
title: "/Users/benbrandt/github/claude-code-acp",
content: [],
locations: [],
locations: [{ path: "/Users/benbrandt/github/claude-code-acp" }],
});
});
@@ -278,7 +198,7 @@ describe("tool conversions", () => {
},
};
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
kind: "search",
title: 'grep ".*"',
content: [],
@@ -291,12 +211,12 @@ describe("tool conversions", () => {
id: "toolu_01ABC123XYZ789",
name: "Write",
input: {
file_path: "/Users/test/project/example.txt",
abs_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: [
@@ -322,7 +242,7 @@ describe("tool conversions", () => {
},
};
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
kind: "edit",
title: "Write /Users/test/project/config.json",
content: [
@@ -343,14 +263,19 @@ describe("tool conversions", () => {
id: "toolu_01MNO456PQR789",
name: "Read",
input: {
file_path: "/Users/test/project/readme.md",
abs_path: "/Users/test/project/readme.md",
},
};
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
kind: "read",
title: "Read File",
content: [],
content: [
{
type: "content",
content: { type: "text", text: "/Users/test/project/readme.md" },
},
],
locations: [{ path: "/Users/test/project/readme.md", line: 0 }],
});
});
@@ -365,7 +290,7 @@ describe("tool conversions", () => {
},
};
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
kind: "read",
title: "Read /Users/test/project/data.json",
content: [],
@@ -384,7 +309,7 @@ describe("tool conversions", () => {
},
};
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
kind: "read",
title: "Read /Users/test/project/large.txt (1 - 100)",
content: [],
@@ -404,7 +329,7 @@ describe("tool conversions", () => {
},
};
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
kind: "read",
title: "Read /Users/test/project/large.txt (51 - 150)",
content: [],
@@ -423,7 +348,7 @@ describe("tool conversions", () => {
},
};
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
kind: "read",
title: "Read /Users/test/project/large.txt (from line 201)",
content: [],
@@ -443,7 +368,7 @@ describe("tool conversions", () => {
},
};
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
kind: "fetch",
title: "Fetch https://agentclientprotocol.com",
content: [
@@ -468,7 +393,7 @@ describe("tool conversions", () => {
},
};
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
kind: "fetch",
title: '"agentclientprotocol.com"',
content: [],
@@ -476,7 +401,7 @@ describe("tool conversions", () => {
});
it("should handle KillBash entries", () => {
const tool_use = {
let tool_use = {
type: "tool_use",
id: "toolu_01PhLms5fuvmdjy2bb6dfUKT",
name: "KillBash",
@@ -485,7 +410,7 @@ describe("tool conversions", () => {
},
};
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
kind: "execute",
title: `Kill Process`,
content: [],
@@ -493,7 +418,7 @@ describe("tool conversions", () => {
});
it("should handle BashOutput entries", () => {
const tool_use = {
let tool_use = {
type: "tool_use",
id: "toolu_01SJUWPtj1QspgANgtpqGPuN",
name: "BashOutput",
@@ -502,7 +427,7 @@ describe("tool conversions", () => {
},
};
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
kind: "execute",
title: `Tail Logs`,
content: [],
@@ -550,7 +475,8 @@ 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",
},
@@ -586,7 +512,7 @@ describe("tool conversions", () => {
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: {
@@ -645,8 +571,8 @@ describe("tool conversions", () => {
name: "edit",
input: {
abs_path: "/Users/test/project/example.txt",
old_string: "old content",
new_string: "new content",
old_text: "old content",
new_text: "new content",
},
};
@@ -668,66 +594,6 @@ describe("tool conversions", () => {
]);
});
it("should show full diff for multi edit tool", () => {
const toolUse = {
type: "tool_use",
id: "toolu_01DEF456",
name: "mcp__acp__multi-edit",
input: {
file_path: "/Users/test/project/config.json",
edits: [
{
old_string: 'version": 1',
new_string: 'version": 2',
replace_all: false,
},
{
old_string: 'enabled": false',
new_string: 'enabled": true',
replace_all: true,
},
],
},
};
const fileCache: { [key: string]: string } = {
"/Users/test/project/config.json": JSON.stringify(
{
version: 1,
filler: "filler",
enabled: false,
},
null,
4,
),
};
expect(toolInfoFromToolUse(toolUse, fileCache)).toEqual({
content: [
{
newText: `{
"version": 2,
"filler": "filler",
"enabled": true
}`,
oldText: `{
"version": 1,
"filler": "filler",
"enabled": false
}`,
path: "/Users/test/project/config.json",
type: "diff",
},
],
kind: "edit",
locations: [
{
path: "/Users/test/project/config.json",
},
],
title: "Edit /Users/test/project/config.json",
});
});
it("should extract locations from multi-edit tool results", () => {
const toolUse = {
type: "tool_use",
@@ -776,8 +642,8 @@ describe("tool conversions", () => {
name: "mcp__acp__edit",
input: {
abs_path: "/Users/test/project/main.py",
old_string: "def hello():",
new_string: "def hello_world():",
old_text: "def hello():",
new_text: "def hello_world():",
},
};
@@ -792,7 +658,9 @@ describe("tool conversions", () => {
const update = toolUpdateFromToolResult(toolResult, toolUse);
expect(update.locations).toEqual([{ path: "/Users/test/project/main.py", line: 42 }]);
expect(update.locations).toEqual([
{ path: "/Users/test/project/main.py", line: 42 },
]);
});
it("should handle empty locations from edit tools", () => {
@@ -802,8 +670,8 @@ describe("tool conversions", () => {
name: "edit",
input: {
abs_path: "/Users/test/project/notfound.txt",
old_string: "nonexistent",
new_string: "replacement",
old_text: "nonexistent",
new_text: "replacement",
},
};
@@ -828,8 +696,8 @@ describe("tool conversions", () => {
name: "edit",
input: {
abs_path: "/Users/test/project/test.txt",
old_string: "old",
new_string: "new",
old_text: "old",
new_text: "new",
},
};
@@ -848,15 +716,3 @@ describe("tool conversions", () => {
expect(update).toEqual({});
});
});
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````");
});
});

View File

@@ -4,9 +4,13 @@ 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)
});
@@ -16,7 +20,9 @@ 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)
});
@@ -48,7 +54,9 @@ 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]);
});
@@ -58,13 +66,17 @@ 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)
@@ -72,7 +84,9 @@ 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)
@@ -84,13 +98,17 @@ 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" }]);
const result = replaceAndCalculateLocation(content, [
{ oldText: "text", newText: "replaced" },
]);
expect(result.newContent).toBe("");
expect(result.lineNumbers).toEqual([]);
@@ -99,7 +117,9 @@ describe("replaceAndCalculateLocation", () => {
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" }]);
const result1 = replaceAndCalculateLocation(content1, [
{ oldText: "", newText: "replaced" },
]);
expect(result1.newContent).toBe(content1);
expect(result1.lineNumbers).toEqual([]);
@@ -122,7 +142,9 @@ describe("replaceAndCalculateLocation", () => {
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)
@@ -161,7 +183,9 @@ 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`,
@@ -174,7 +198,9 @@ 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");
@@ -198,7 +224,9 @@ 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
@@ -232,7 +260,9 @@ 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]);
@@ -272,7 +302,9 @@ 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([
@@ -294,7 +326,9 @@ 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([
@@ -306,14 +340,17 @@ 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,10 @@
import { ContentBlock } from "@modelcontextprotocol/sdk/types.js";
import {
PlanEntry,
ToolCallContent,
ToolCallLocation,
ToolKind,
} from "@zed-industries/agent-client-protocol";
import { replaceAndCalculateLocation, SYSTEM_REMINDER } from "./mcp-server.js";
interface ToolInfo {
title: string;
@@ -19,10 +19,7 @@ interface ToolUpdate {
locations?: ToolCallLocation[];
}
export function toolInfoFromToolUse(
toolUse: any,
cachedFileContent: { [key: string]: string },
): ToolInfo {
export function toolInfoFromToolUse(toolUse: any): ToolInfo {
const name = toolUse.name;
const input = toolUse.input;
@@ -44,15 +41,30 @@ export function toolInfoFromToolUse(
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: [],
content:
input && input.notebook_path
? [
{
type: "content",
content: {
type: "text",
text: input.notebook_path.toString(),
},
},
]
: [],
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
@@ -67,12 +79,13 @@ export function toolInfoFromToolUse(
};
case "Bash":
case "mcp__acp__Bash":
return {
title: input?.command ? "`" + input.command.replaceAll("`", "\\`") + "`" : "Terminal",
title: input?.command
? "`" + input.command.replaceAll("`", "\\`") + "`"
: "Terminal",
kind: "execute",
content:
input && input.description
input && input.command
? [
{
type: "content",
@@ -83,7 +96,6 @@ export function toolInfoFromToolUse(
};
case "BashOutput":
case "mcp__acp__BashOutput":
return {
title: "Tail Logs",
kind: "execute",
@@ -91,7 +103,6 @@ export function toolInfoFromToolUse(
};
case "KillBash":
case "mcp__acp__KillBash":
return {
title: "Kill Process",
kind: "execute",
@@ -102,7 +113,11 @@ export function toolInfoFromToolUse(
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) + ")";
}
@@ -123,108 +138,65 @@ export function toolInfoFromToolUse(
return {
title: "Read File",
kind: "read",
content: [],
locations: [{ path: input.file_path, line: input.offset ?? 0 }],
content:
input && input.abs_path
? [
{
type: "content",
content: { type: "text", text: input.abs_path.toString() },
},
]
: [],
locations: [{ path: input.abs_path, line: input.offset ?? 0 }],
};
case "LS":
return {
title: `List the ${input?.path ? "`" + input.path + "`" : "current"} directory's contents`,
title: input?.path ? input.path : "Current Directory",
kind: "search",
content: [],
locations: [],
locations: input?.path ? [{ path: input.path }] : [],
};
case "mcp__acp__edit":
case "Edit": {
const path = input?.abs_path ?? input?.file_path;
let oldText = input.old_string ?? null;
let newText = input.new_string ?? "";
let line = null;
if (path && input.old_text) {
try {
const oldContent = cachedFileContent[path] || "";
const newContent = replaceAndCalculateLocation(oldContent, [
{
oldText: input.old_string,
newText: input.new_string,
replaceAll: false,
},
]);
oldText = oldContent;
newText = newContent.newContent;
line = newContent.lineNumbers[0];
} catch (e) {
console.error(e);
}
}
case "Edit":
return {
title: path ? `Edit ${path}` : "Edit",
title: input?.abs_path ? `Edit ${input.abs_path}` : "Edit",
kind: "edit",
content:
input && path
input && input.abs_path
? [
{
type: "diff",
path,
oldText,
newText,
path: input.abs_path,
oldText: input.old_text || null,
newText: input.new_text,
},
]
: [],
locations: path ? [{ path, line }] : [],
locations: input?.abs_path ? [{ path: input.abs_path }] : [],
};
}
case "mcp__acp__multi-edit":
case "MultiEdit": {
const multiInput = input as {
file_path: string;
edits: {
old_string: string;
new_string: string;
replace_all?: boolean;
}[];
};
let oldTextMulti = multiInput.edits.map((edit: any) => edit.old_string).join("\n");
let newTextMulti = multiInput.edits.map((edit: any) => edit.new_string).join("\n");
try {
if (multiInput.edits && multiInput.file_path) {
const oldContent =
cachedFileContent[multiInput.file_path] ||
multiInput.edits.map((edit: any) => edit.oldText).join("\n");
const newContent = replaceAndCalculateLocation(
oldContent,
multiInput.edits.map((edit) => ({
oldText: edit.old_string,
newText: edit.new_string,
replaceAll: edit.replace_all,
})),
);
oldTextMulti = oldContent;
newTextMulti = newContent.newContent;
}
} catch (e) {
console.error(e);
}
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: [
{
type: "diff" as const,
path: input.file_path,
oldText: oldTextMulti,
newText: newTextMulti,
},
],
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 "mcp__acp__write":
let content: ToolCallContent[] = [];
if (input && input.abs_path) {
content = [
@@ -249,24 +221,23 @@ export function toolInfoFromToolUse(
content,
locations: input?.abs_path ? [{ path: input.abs_path }] : [],
};
}
case "Write":
return {
title: input?.file_path ? `Write ${input.file_path}` : "Write",
title: input?.abs_path ? `Write ${input.abs_path}` : "Write",
kind: "edit",
content:
input && input.file_path
input && input.abs_path
? [
{
type: "diff",
path: input.file_path,
path: input.abs_path,
oldText: null,
newText: input.content,
},
]
: [],
locations: input?.file_path ? [{ path: input.file_path }] : [],
locations: input?.abs_path ? [{ path: input.abs_path }] : [],
};
case "Glob": {
@@ -314,7 +285,6 @@ export function toolInfoFromToolUse(
label += " -c";
break;
case "Content":
default:
break;
}
}
@@ -424,7 +394,10 @@ export function toolInfoFromToolUse(
}
}
export function toolUpdateFromToolResult(toolResult: any, toolUse: any | undefined): ToolUpdate {
export function toolUpdateFromToolResult(
toolResult: any,
toolUse: any | undefined,
): ToolUpdate {
switch (toolUse?.name) {
case "mcp__acp__edit":
case "edit":
@@ -435,14 +408,15 @@ export function toolUpdateFromToolResult(toolResult: any, toolUse: any | undefin
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,
}));
const locations: ToolCallLocation[] = data.lineNumbers.map(
(line: number) => ({
path: toolUse?.input?.abs_path || toolUse?.input?.file_path,
line: line,
}),
);
return { locations };
}
} catch (error) {
console.error(`Error parsing edit tool result: ${error}`);
} catch (e) {
// If parsing fails, return empty object
return {};
}
@@ -458,63 +432,26 @@ export function toolUpdateFromToolResult(toolResult: any, toolUse: any | undefin
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,
}));
const locations: ToolCallLocation[] = data.lineNumbers.map(
(line: number) => ({
path: toolUse?.input?.file_path || toolUse?.input?.abs_path,
line: line,
}),
);
return { locations };
}
} catch (error) {
console.error(`Error parsing edit tool result: ${error}`);
} catch (e) {
// If parsing fails, return empty object
return {};
}
}
return {};
}
case "mcp__acp__write":
case "Write":
return {};
case "Read":
case "mcp__acp__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 "mcp__acp__Bash":
// The bash tool already embeds a terminal
return {};
case "Task":
case "NotebookEdit":
case "NotebookRead":
case "mcp__acp__write":
case "Write":
case "TodoWrite":
case "exit_plan_mode":
case "Bash":
@@ -526,23 +463,33 @@ export function toolUpdateFromToolResult(toolResult: any, toolUse: any | undefin
case "WebFetch":
case "WebSearch":
case "Other":
case "mcp__acp__read":
if (Array.isArray(toolResult.content)) {
return {
content: toolResult.content.map((content: any) => ({
type: "content",
content: {
type: "text",
text: `\`\`\`${toolUse.input?.abs_path ?? ""}\n${content.text ?? ""}\n\`\`\``,
},
})),
};
}
return {};
default: {
if (Array.isArray(toolResult.content) && toolResult.content.length > 0) {
if (Array.isArray(toolResult.content)) {
return {
content: toolResult.content.map((content: any) => ({
type: "content",
content,
})),
};
} else if (typeof toolResult.content === "string" && toolResult.content.length > 0) {
} else if (typeof toolResult.content === "string") {
return {
content: [
{
type: "content",
content: {
type: "text",
text: toolResult.content,
},
content: { type: "text", text: toolResult.content },
},
],
};
@@ -565,13 +512,3 @@ 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

@@ -45,23 +45,24 @@ 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) => {
@@ -76,7 +77,3 @@ export function nodeToWebReadable(nodeStream: Readable): ReadableStream<Uint8Arr
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));
}