mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-10-27 20:42:10 +00:00
Compare commits
76 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7e0d2c9506 | ||
|
|
2835f030cf | ||
|
|
2710e146f4 | ||
|
|
bf396fea67 | ||
|
|
84b5744a2f | ||
|
|
b213152a94 | ||
|
|
cd5aabdd74 | ||
|
|
9e18a4e6fd | ||
|
|
48df285bd0 | ||
|
|
b138c84cdb | ||
|
|
0735aca015 | ||
|
|
847d62dacb | ||
|
|
becf1d4f0a | ||
|
|
5491bb8dcc | ||
|
|
17aad5b7a1 | ||
|
|
dadc7f9f40 | ||
|
|
552f0a286d | ||
|
|
589e1b5fc9 | ||
|
|
0d97bb4753 | ||
|
|
e3119b81f9 | ||
|
|
cd3c9f7b13 | ||
|
|
8aea8fd0e1 | ||
|
|
81a541c644 | ||
|
|
b49154bef8 | ||
|
|
5fa453a33a | ||
|
|
c2ab2da5ca | ||
|
|
373d940eb8 | ||
|
|
2c16a41fa4 | ||
|
|
656cbe5921 | ||
|
|
2f811f8a85 | ||
|
|
6aa6537f67 | ||
|
|
1b72ac7c8a | ||
|
|
6ae2c05ddb | ||
|
|
8f0fc59a48 | ||
|
|
c25150523a | ||
|
|
ea796f3cbd | ||
|
|
7f5ebca3fb | ||
|
|
75b44e945e | ||
|
|
ad8611c399 | ||
|
|
c5abff0e43 | ||
|
|
5c98bd0404 | ||
|
|
71b660ae3b | ||
|
|
4ee25af3f9 | ||
|
|
1a5a9c16c6 | ||
|
|
5ca27369ef | ||
|
|
8068d894c1 | ||
|
|
71e745d5fb | ||
|
|
2aa181be27 | ||
|
|
600a2db82f | ||
|
|
f2a4b3d19b | ||
|
|
9135a0f1be | ||
|
|
92c5cd84e8 | ||
|
|
8311a7a222 | ||
|
|
ba513b190b | ||
|
|
a011363320 | ||
|
|
7737878dd2 | ||
|
|
2e53f1d3e4 | ||
|
|
aa7364d49b | ||
|
|
e22d1ed52c | ||
|
|
ecfd36afa6 | ||
|
|
7bd163818b | ||
|
|
491efe32e8 | ||
|
|
ee17e45030 | ||
|
|
468c3522a0 | ||
|
|
84d64b2762 | ||
|
|
9ff7919ea4 | ||
|
|
d58c2dd45d | ||
|
|
55532ecc57 | ||
|
|
f4711e96b0 | ||
|
|
f691024350 | ||
|
|
d6c91b1ec1 | ||
|
|
0289dd9de7 | ||
|
|
5cd3ac91e8 | ||
|
|
6b5873f005 | ||
|
|
539f94de45 | ||
|
|
70441b0aa3 |
@@ -1,6 +0,0 @@
|
||||
node_modules/
|
||||
dist/
|
||||
coverage/
|
||||
*.min.js
|
||||
*.config.js
|
||||
.github/
|
||||
@@ -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"]
|
||||
}
|
||||
23
.github/workflows/publish.yml
vendored
23
.github/workflows/publish.yml
vendored
@@ -4,8 +4,9 @@ on:
|
||||
tags:
|
||||
- "v*.*.*"
|
||||
jobs:
|
||||
build:
|
||||
publish-npm:
|
||||
runs-on: ubuntu-latest
|
||||
environment: release # Optional: for enhanced security
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
@@ -16,8 +17,20 @@ jobs:
|
||||
with:
|
||||
node-version: "lts/*"
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
- name: Update npm
|
||||
run: npm install -g npm@latest
|
||||
- run: npm ci
|
||||
# Add back provenance once repo is public
|
||||
- run: npm publish # --provenance --access public
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
- run: npm run build
|
||||
- run: npm publish
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [publish-npm]
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8
|
||||
- name: Create Release
|
||||
id: create_release
|
||||
uses: softprops/action-gh-release@6da8fa9354ddfdc4aeace5fc48d7f679b5214090
|
||||
with:
|
||||
draft: true
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,2 +1,3 @@
|
||||
node_modules
|
||||
dist
|
||||
.DS_Store
|
||||
|
||||
146
CHANGELOG.md
Normal file
146
CHANGELOG.md
Normal file
@@ -0,0 +1,146 @@
|
||||
# Changelog
|
||||
|
||||
## 0.10.1
|
||||
|
||||
- Add additional structured metadata to tool calls
|
||||
- Update to @anthropic-ai/claude-agent-sdk@v0.1.37
|
||||
|
||||
## 0.10.0
|
||||
|
||||
- Update to @anthropic-ai/claude-agent-sdk@v0.1.30
|
||||
- Use `canUseTool` callback instead of launching an HTTP MCP server for permission checks.
|
||||
|
||||
## 0.9.0
|
||||
|
||||
- Support slash commands coming from MCP servers (Prompts)
|
||||
|
||||
## 0.8.0
|
||||
|
||||
- Revert changes to filename for cli entrypoint
|
||||
- Provide library entrypoint via lib.ts
|
||||
|
||||
## 0.7.0
|
||||
|
||||
- Allow importing from this package as a library in addition to running it as a CLI. Allows for easier integration into existing node applications.
|
||||
- Update to @anthropic-ai/claude-agent-sdk@v0.1.27
|
||||
|
||||
## 0.6.10
|
||||
|
||||
- Provide `agentInfo` on initialization response.
|
||||
- Update to @agentclientprotocol/sdk@0.5.1
|
||||
- Fix crash when receiving a hook_response event
|
||||
- Fix for invalid locations when read call has no path
|
||||
|
||||
## 0.6.9
|
||||
|
||||
- Update to @anthropic-ai/claude-agent-sdk@v0.1.26
|
||||
- Update to @agentclientprotocol/sdk@0.5.0
|
||||
|
||||
## 0.6.8
|
||||
|
||||
- Fix for duplicate tokens appearing in thread with streaming enabled
|
||||
- Update to @anthropic-ai/claude-agent-sdk@v0.1.23
|
||||
- Update to @agentclientprotocol/sdk@0.4.9
|
||||
|
||||
## 0.6.7
|
||||
|
||||
- Fix for invalid plan input from the model introduced in latest agent-sdk
|
||||
|
||||
## 0.6.6
|
||||
|
||||
- Do not enable bypassPermissions mode if in root/sudo mode, because Claude Code will not start
|
||||
|
||||
## 0.6.5
|
||||
|
||||
- Fix for duplicated text content after streaming
|
||||
|
||||
## 0.6.4
|
||||
|
||||
- Support streaming partial messages!
|
||||
- Update to @anthropic-ai/claude-agent-sdk@v0.1.21
|
||||
|
||||
## 0.6.3
|
||||
|
||||
- Fix issue where slash commands were loaded before initialization was complete.
|
||||
|
||||
## 0.6.2
|
||||
|
||||
- Fix bug where mode selection would sometimes fire before initialization was complete.
|
||||
- Update to @anthropic-ai/claude-agent-sdk@v0.1.19
|
||||
|
||||
## 0.6.1
|
||||
|
||||
- Fix to allow bypassPermissions mode to be selected (it wasn't permitted previously)
|
||||
|
||||
## 0.6.0
|
||||
|
||||
- Provide a model selector. We use the "default" model by default, and the user can change it via the client.
|
||||
- Make sure writes require permissions when necessary: https://github.com/zed-industries/claude-code-acp/pull/92
|
||||
- Add support for appending or overriding the system prompt: https://github.com/zed-industries/claude-code-acp/pull/91
|
||||
- Update to @anthropic-ai/claude-agent-sdk@v0.1.15
|
||||
- Update to @agentclientprotocol/sdk@0.4.8
|
||||
|
||||
## 0.5.5
|
||||
|
||||
- Migrate to @agentclientprotocol/sdk@0.4.5
|
||||
- Update to @anthropic-ai/claude-agent-sdk@v0.1.13
|
||||
|
||||
## 0.5.4
|
||||
|
||||
- Update to @anthropic-ai/claude-agent-sdk@v0.1.11
|
||||
- Enable setting CLAUDE_CODE_EXECUTABLE to override the executable used by the SDK https://github.com/zed-industries/claude-code-acp/pull/86
|
||||
|
||||
## 0.5.3
|
||||
|
||||
- Update to @anthropic-ai/claude-agent-sdk@v0.1.8
|
||||
- Update to @zed-industries/agent-client-protocol@v0.4.5
|
||||
|
||||
## 0.5.2
|
||||
|
||||
- Add back @anthropic-ai/claude-code@2.0.1 as runtime dependency
|
||||
|
||||
## 0.5.1
|
||||
|
||||
- Update to @anthropic-ai/claude-agent-sdk@v0.1.1
|
||||
- Make improvements to ACP tools provided to the model
|
||||
|
||||
## 0.5.0
|
||||
|
||||
- Migrate to @anthropic-ai/claude-agent-sdk@v0.1.0
|
||||
|
||||
## v0.4.7
|
||||
|
||||
- More efficient file reads from the client.
|
||||
|
||||
## v0.4.6
|
||||
|
||||
- Update to @anthropic-ai/claude-code@v1.0.128
|
||||
|
||||
## v0.4.5
|
||||
|
||||
- Update to @anthropic-ai/claude-code@v1.0.124
|
||||
- Update to @zed-industries/agent-client-protocol@v0.4.3
|
||||
|
||||
## v0.4.4
|
||||
|
||||
- Update to @anthropic-ai/claude-code@v1.0.123
|
||||
- Update to @zed-industries/agent-client-protocol@v0.4.2
|
||||
|
||||
## v0.4.3
|
||||
|
||||
- Move ACP tools over MCP from an "http" MCP server to an "sdk" one so more tool calls can stay in-memory.
|
||||
- Update to @anthropic-ai/claude-code@v1.0.119
|
||||
- Update to @zed-industries/agent-client-protocol@v0.4.0
|
||||
|
||||
## v0.4.2
|
||||
|
||||
- Fix missing package.json metadata
|
||||
|
||||
## v0.4.1
|
||||
|
||||
- Add support for /compact command [ecfd36a](https://github.com/zed-industries/claude-code-acp/commit/ecfd36afa6c4e31f12e1daf9b8a2bdc12dda1794)
|
||||
- Add default limits to read tool [7bd1638](https://github.com/zed-industries/claude-code-acp/commit/7bd163818bb959b11fd2c933eff73ad83c57abb8)
|
||||
- Better rendering of Tool errors [491efe3](https://github.com/zed-industries/claude-code-acp/commit/491efe32e8547075842e448d873fc01b2ffabf3a)
|
||||
- Load managed-settings.json [f691024](https://github.com/zed-industries/claude-code-acp/commit/f691024350362858e00b97248ac68e356d2331c2)
|
||||
- Update to @anthropic-ai/claude-code@v1.0.113
|
||||
- Update to @zed-industries/agent-client-protocol@v0.3.1
|
||||
@@ -32,9 +32,13 @@ Read the docs on [External Agent](https://zed.dev/docs/ai/external-agents) suppo
|
||||
|
||||
### Other clients
|
||||
|
||||
- Neovim via [CodeCompanion.nvim](https://codecompanion.olimorris.dev/configuration/adapters#setup-claude-code-via-acp)
|
||||
- Emacs via [agent-shell.el](https://github.com/xenodium/agent-shell)
|
||||
- [marimo notebook](https://github.com/marimo-team/marimo)
|
||||
- Neovim
|
||||
- via [CodeCompanion.nvim](https://codecompanion.olimorris.dev/configuration/adapters#setup-claude-code-via-acp)
|
||||
- via [yetone/avante.nvim](https://github.com/yetone/avante.nvim)
|
||||
|
||||
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!
|
||||
[Submit a PR](https://github.com/zed-industries/claude-code-acp/pulls) to add yours!
|
||||
|
||||
#### Installation
|
||||
|
||||
|
||||
24
docs/RELEASES.md
Normal file
24
docs/RELEASES.md
Normal file
@@ -0,0 +1,24 @@
|
||||
## How to publish a new release
|
||||
|
||||
1. Create a new branch prep-vX.X.X
|
||||
|
||||
```sh
|
||||
git checkout -b prep-vX.X.X
|
||||
```
|
||||
|
||||
2. Bump the version in package.json
|
||||
|
||||
```sh
|
||||
npm version vX.X.X
|
||||
```
|
||||
|
||||
3. Add a new entry in CHANGELOG.md
|
||||
4. Create a PR for `prev-vX.X.X` & merge it
|
||||
5. Create tag and push it
|
||||
|
||||
```sh
|
||||
git tag vX.X.X
|
||||
git push origin vX.X.X
|
||||
```
|
||||
|
||||
6. Paste CHANGELOG into release notes and publish the version on GitHub
|
||||
48
eslint.config.js
Normal file
48
eslint.config.js
Normal file
@@ -0,0 +1,48 @@
|
||||
import js from "@eslint/js";
|
||||
import tseslint from "@typescript-eslint/eslint-plugin";
|
||||
import tsparser from "@typescript-eslint/parser";
|
||||
import prettierConfig from "eslint-config-prettier";
|
||||
import globals from "globals";
|
||||
|
||||
export default [
|
||||
{
|
||||
ignores: ["node_modules/", "dist/", "coverage/", "*.min.js", "*.config.js", ".github/"],
|
||||
},
|
||||
js.configs.recommended,
|
||||
{
|
||||
files: ["src/**/*.ts"],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
sourceType: "module",
|
||||
parser: tsparser,
|
||||
parserOptions: {
|
||||
project: "./tsconfig.json",
|
||||
},
|
||||
globals: {
|
||||
...globals.node,
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
"@typescript-eslint": tseslint,
|
||||
},
|
||||
rules: {
|
||||
...tseslint.configs.recommended.rules,
|
||||
"@typescript-eslint/explicit-function-return-type": "off",
|
||||
"@typescript-eslint/no-explicit-any": "off",
|
||||
"@typescript-eslint/no-unused-vars": [
|
||||
"error",
|
||||
{
|
||||
argsIgnorePattern: "^_",
|
||||
},
|
||||
],
|
||||
"no-console": "off",
|
||||
"no-constant-condition": "off",
|
||||
"default-case": "error",
|
||||
"prefer-const": "error",
|
||||
"no-var": "error",
|
||||
eqeqeq: ["error", "always"],
|
||||
curly: ["error", "all"],
|
||||
},
|
||||
},
|
||||
prettierConfig,
|
||||
];
|
||||
1948
package-lock.json
generated
1948
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
53
package.json
53
package.json
@@ -3,12 +3,12 @@
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"version": "0.10.1",
|
||||
"description": "An ACP-compatible coding agent powered by the Claude Code SDK (TypeScript)",
|
||||
"main": "dist/lib.js",
|
||||
"bin": {
|
||||
"claude-code-acp": "./dist/index.js"
|
||||
},
|
||||
"version": "0.2.6",
|
||||
"description": "An ACP-compatible coding agent powered by the Claude Code SDK (TypeScript)",
|
||||
"main": "dist/index.js",
|
||||
"type": "module",
|
||||
"files": [
|
||||
"dist/",
|
||||
@@ -40,30 +40,37 @@
|
||||
"sdk",
|
||||
"code"
|
||||
],
|
||||
"homepage": "https://github.com/zed-industries/claude-code-acp#readme",
|
||||
"bugs": {
|
||||
"url": "https://github.com/zed-industries/claude-code-acp/issues"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/zed-industries/claude-code-acp.git"
|
||||
},
|
||||
"author": "Zed Industries",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/claude-code": "^1.0.100",
|
||||
"@modelcontextprotocol/sdk": "^1.17.4",
|
||||
"@types/express": "^5.0.3",
|
||||
"@zed-industries/agent-client-protocol": "0.2.0-alpha.6",
|
||||
"diff": "^8.0.2",
|
||||
"express": "^5.1.0",
|
||||
"minimist": "^1.2.8",
|
||||
"uuid": "11.1.0"
|
||||
"@agentclientprotocol/sdk": "0.5.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.1.37",
|
||||
"@anthropic-ai/claude-code": "2.0.37",
|
||||
"@modelcontextprotocol/sdk": "1.21.1",
|
||||
"diff": "8.0.2",
|
||||
"uuid": "13.0.0"
|
||||
},
|
||||
"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",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.4.0",
|
||||
"vitest": "^3.2.4"
|
||||
"@anthropic-ai/sdk": "0.68.0",
|
||||
"@types/node": "24.10.1",
|
||||
"@types/uuid": "10.0.0",
|
||||
"@typescript-eslint/eslint-plugin": "8.46.4",
|
||||
"@typescript-eslint/parser": "8.46.4",
|
||||
"@vitest/ui": "4.0.8",
|
||||
"eslint": "9.39.1",
|
||||
"eslint-config-prettier": "10.1.8",
|
||||
"globals": "16.5.0",
|
||||
"prettier": "3.6.2",
|
||||
"ts-node": "10.9.2",
|
||||
"typescript": "5.9.3",
|
||||
"vitest": "4.0.8"
|
||||
}
|
||||
}
|
||||
|
||||
722
src/acp-agent.ts
722
src/acp-agent.ts
@@ -7,6 +7,7 @@ import {
|
||||
ClientCapabilities,
|
||||
InitializeRequest,
|
||||
InitializeResponse,
|
||||
ndJsonStream,
|
||||
NewSessionRequest,
|
||||
NewSessionResponse,
|
||||
PromptRequest,
|
||||
@@ -14,33 +15,50 @@ import {
|
||||
ReadTextFileRequest,
|
||||
ReadTextFileResponse,
|
||||
RequestError,
|
||||
SessionModelState,
|
||||
SetSessionModelRequest,
|
||||
SetSessionModelResponse,
|
||||
SetSessionModeRequest,
|
||||
SetSessionModeResponse,
|
||||
TerminalHandle,
|
||||
TerminalOutputResponse,
|
||||
WriteTextFileRequest,
|
||||
WriteTextFileResponse,
|
||||
} from "@zed-industries/agent-client-protocol";
|
||||
} from "@agentclientprotocol/sdk";
|
||||
import {
|
||||
CanUseTool,
|
||||
McpServerConfig,
|
||||
Options,
|
||||
PermissionMode,
|
||||
Query,
|
||||
query,
|
||||
SDKAssistantMessage,
|
||||
SDKPartialAssistantMessage,
|
||||
SDKUserMessage,
|
||||
} from "@anthropic-ai/claude-code";
|
||||
} from "@anthropic-ai/claude-agent-sdk";
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import * as os from "node:os";
|
||||
import { v7 as uuidv7 } from "uuid";
|
||||
import { nodeToWebReadable, nodeToWebWritable, Pushable, unreachable } from "./utils.js";
|
||||
import { SessionNotification } from "@zed-industries/agent-client-protocol";
|
||||
import { createMcpServer } from "./mcp-server.js";
|
||||
import { AddressInfo } from "node:net";
|
||||
import { toolInfoFromToolUse, planEntries, toolUpdateFromToolResult } from "./tools.js";
|
||||
import { SessionNotification } from "@agentclientprotocol/sdk";
|
||||
import { createMcpServer, EDIT_TOOL_NAMES, toolNames } from "./mcp-server.js";
|
||||
import {
|
||||
toolInfoFromToolUse,
|
||||
planEntries,
|
||||
toolUpdateFromToolResult,
|
||||
ClaudePlanEntry,
|
||||
registerHookCallback,
|
||||
postToolUseHook,
|
||||
} from "./tools.js";
|
||||
import { ContentBlockParam } from "@anthropic-ai/sdk/resources";
|
||||
import { BetaContentBlock, BetaRawContentBlockDelta } from "@anthropic-ai/sdk/resources/beta.mjs";
|
||||
import packageJson from "../package.json" with { type: "json" };
|
||||
|
||||
type Session = {
|
||||
query: Query;
|
||||
input: Pushable<SDKUserMessage>;
|
||||
cancelled: boolean;
|
||||
permissionMode: PermissionMode;
|
||||
};
|
||||
|
||||
type BackgroundTerminal =
|
||||
@@ -54,10 +72,30 @@ type BackgroundTerminal =
|
||||
pendingOutput: TerminalOutputResponse;
|
||||
};
|
||||
|
||||
type ToolUseCache = {
|
||||
[key: string]: { type: "tool_use"; id: string; name: string; input: any };
|
||||
/**
|
||||
* Extra metadata that the agent provides for each tool_call / tool_update update, under the `_meta.claudeCode` key.
|
||||
*/
|
||||
export type ToolUpdateMeta = {
|
||||
/* The name of the tool that was used in Claude Code. */
|
||||
toolName: string;
|
||||
/* The structured output provided by Claude Code. */
|
||||
toolResponse?: unknown;
|
||||
};
|
||||
|
||||
type ToolUseCache = {
|
||||
[key: string]: {
|
||||
type: "tool_use" | "server_tool_use" | "mcp_tool_use";
|
||||
id: string;
|
||||
name: string;
|
||||
input: any;
|
||||
};
|
||||
};
|
||||
|
||||
const DEFAULT_MODEL_ID = "default";
|
||||
|
||||
// Bypass Permissions doesn't work if we are a root/sudo user
|
||||
const IS_ROOT = (process.geteuid?.() ?? process.getuid?.()) === 0;
|
||||
|
||||
// Implement the ACP Agent interface
|
||||
export class ClaudeAcpAgent implements Agent {
|
||||
sessions: {
|
||||
@@ -78,22 +116,47 @@ export class ClaudeAcpAgent implements Agent {
|
||||
|
||||
async initialize(request: InitializeRequest): Promise<InitializeResponse> {
|
||||
this.clientCapabilities = request.clientCapabilities;
|
||||
|
||||
// Default authMethod
|
||||
const authMethod: any = {
|
||||
description: "Run `claude /login` in the terminal",
|
||||
name: "Log in with Claude Code",
|
||||
id: "claude-login",
|
||||
};
|
||||
|
||||
// If client supports terminal-auth capability, use that instead.
|
||||
if (request.clientCapabilities?._meta?.["terminal-auth"] === true) {
|
||||
const cliPath = import.meta
|
||||
.resolve("@anthropic-ai/claude-agent-sdk/cli.js")
|
||||
.replace("file://", "");
|
||||
|
||||
authMethod._meta = {
|
||||
"terminal-auth": {
|
||||
command: "node",
|
||||
args: [cliPath, "/login"],
|
||||
label: "Claude Code Login",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
protocolVersion: 1,
|
||||
// todo!()
|
||||
agentCapabilities: {
|
||||
promptCapabilities: {
|
||||
image: true,
|
||||
embeddedContext: true,
|
||||
},
|
||||
},
|
||||
authMethods: [
|
||||
{
|
||||
description: "Run `claude /login` in the terminal",
|
||||
name: "Log in with Claude Code",
|
||||
id: "claude-login",
|
||||
mcpCapabilities: {
|
||||
http: true,
|
||||
sse: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
agentInfo: {
|
||||
name: packageJson.name,
|
||||
title: "Claude Code",
|
||||
version: packageJson.version,
|
||||
},
|
||||
authMethods: [authMethod],
|
||||
};
|
||||
}
|
||||
async newSession(params: NewSessionRequest): Promise<NewSessionResponse> {
|
||||
@@ -110,50 +173,89 @@ export class ClaudeAcpAgent implements Agent {
|
||||
const mcpServers: Record<string, McpServerConfig> = {};
|
||||
if (Array.isArray(params.mcpServers)) {
|
||||
for (const server of params.mcpServers) {
|
||||
mcpServers[server.name] = {
|
||||
type: "stdio",
|
||||
command: server.command,
|
||||
args: server.args,
|
||||
env: server.env
|
||||
? Object.fromEntries(server.env.map((e) => [e.name, e.value]))
|
||||
: undefined,
|
||||
};
|
||||
if ("type" in server) {
|
||||
mcpServers[server.name] = {
|
||||
type: server.type,
|
||||
url: server.url,
|
||||
headers: server.headers
|
||||
? Object.fromEntries(server.headers.map((e) => [e.name, e.value]))
|
||||
: undefined,
|
||||
};
|
||||
} else {
|
||||
mcpServers[server.name] = {
|
||||
type: "stdio",
|
||||
command: server.command,
|
||||
args: server.args,
|
||||
env: server.env
|
||||
? Object.fromEntries(server.env.map((e) => [e.name, e.value]))
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const server = await createMcpServer(this, sessionId, this.clientCapabilities);
|
||||
const address = server.address() as AddressInfo;
|
||||
const server = createMcpServer(this, sessionId, this.clientCapabilities);
|
||||
mcpServers["acp"] = {
|
||||
type: "http",
|
||||
url: "http://127.0.0.1:" + address.port + "/mcp",
|
||||
headers: {
|
||||
"x-acp-proxy-session-id": sessionId,
|
||||
},
|
||||
type: "sdk",
|
||||
name: "acp",
|
||||
instance: server,
|
||||
};
|
||||
|
||||
let systemPrompt: Options["systemPrompt"] = { type: "preset", preset: "claude_code" };
|
||||
if (params._meta?.systemPrompt) {
|
||||
const customPrompt = params._meta.systemPrompt;
|
||||
if (typeof customPrompt === "string") {
|
||||
systemPrompt = customPrompt;
|
||||
} else if (
|
||||
typeof customPrompt === "object" &&
|
||||
"append" in customPrompt &&
|
||||
typeof customPrompt.append === "string"
|
||||
) {
|
||||
systemPrompt.append = customPrompt.append;
|
||||
}
|
||||
}
|
||||
|
||||
const permissionMode = "default";
|
||||
|
||||
const options: Options = {
|
||||
cwd: params.cwd,
|
||||
includePartialMessages: true,
|
||||
mcpServers,
|
||||
permissionPromptToolName: "mcp__acp__permission",
|
||||
systemPrompt,
|
||||
settingSources: ["user", "project", "local"],
|
||||
// If we want bypassPermissions to be an option, we have to allow it here.
|
||||
// But it doesn't work in root mode, so we only activate it if it will work.
|
||||
allowDangerouslySkipPermissions: !IS_ROOT,
|
||||
permissionMode,
|
||||
canUseTool: this.canUseTool(sessionId),
|
||||
stderr: (err) => console.error(err),
|
||||
// note: although not documented by the types, passing an absolute path
|
||||
// here works to find zed's managed node version.
|
||||
executable: process.execPath as any,
|
||||
...(process.env.CLAUDE_CODE_EXECUTABLE && {
|
||||
pathToClaudeCodeExecutable: process.env.CLAUDE_CODE_EXECUTABLE,
|
||||
}),
|
||||
hooks: {
|
||||
PostToolUse: [
|
||||
{
|
||||
hooks: [postToolUseHook],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const allowedTools = [];
|
||||
const disallowedTools = [];
|
||||
if (this.clientCapabilities?.fs?.readTextFile) {
|
||||
allowedTools.push("mcp__acp__read");
|
||||
allowedTools.push(toolNames.read);
|
||||
disallowedTools.push("Read");
|
||||
}
|
||||
if (this.clientCapabilities?.fs?.writeTextFile) {
|
||||
allowedTools.push("mcp__acp__write");
|
||||
disallowedTools.push("Write", "Edit", "MultiEdit");
|
||||
disallowedTools.push("Write", "Edit");
|
||||
}
|
||||
if (this.clientCapabilities?.terminal) {
|
||||
allowedTools.push("mcp__acp__BashOutput", "mcp__acp__KillBash");
|
||||
disallowedTools.push("Bash", "BashOutput", "KillBash");
|
||||
allowedTools.push(toolNames.bashOutput, toolNames.killShell);
|
||||
disallowedTools.push("Bash", "BashOutput", "KillShell");
|
||||
}
|
||||
|
||||
if (allowedTools.length > 0) {
|
||||
@@ -167,13 +269,19 @@ export class ClaudeAcpAgent implements Agent {
|
||||
prompt: input,
|
||||
options,
|
||||
});
|
||||
|
||||
this.sessions[sessionId] = {
|
||||
query: q,
|
||||
input: input,
|
||||
cancelled: false,
|
||||
permissionMode,
|
||||
};
|
||||
|
||||
getAvailableSlashCommands(q).then((availableCommands) => {
|
||||
const availableCommands = await getAvailableSlashCommands(q);
|
||||
const models = await getAvailableModels(q);
|
||||
|
||||
// Needs to happen after we return the session
|
||||
setTimeout(() => {
|
||||
this.client.sessionUpdate({
|
||||
sessionId,
|
||||
update: {
|
||||
@@ -181,10 +289,41 @@ export class ClaudeAcpAgent implements Agent {
|
||||
availableCommands,
|
||||
},
|
||||
});
|
||||
});
|
||||
}, 0);
|
||||
|
||||
const availableModes = [
|
||||
{
|
||||
id: "default",
|
||||
name: "Always Ask",
|
||||
description: "Prompts for permission on first use of each tool",
|
||||
},
|
||||
{
|
||||
id: "acceptEdits",
|
||||
name: "Accept Edits",
|
||||
description: "Automatically accepts file edit permissions for the session",
|
||||
},
|
||||
{
|
||||
id: "plan",
|
||||
name: "Plan Mode",
|
||||
description: "Claude can analyze but not modify files or execute commands",
|
||||
},
|
||||
];
|
||||
// Only works in non-root mode
|
||||
if (!IS_ROOT) {
|
||||
availableModes.push({
|
||||
id: "bypassPermissions",
|
||||
name: "Bypass Permissions",
|
||||
description: "Skips all permission prompts",
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
sessionId,
|
||||
models,
|
||||
modes: {
|
||||
currentModeId: permissionMode,
|
||||
availableModes,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -212,6 +351,17 @@ export class ClaudeAcpAgent implements Agent {
|
||||
}
|
||||
switch (message.type) {
|
||||
case "system":
|
||||
switch (message.subtype) {
|
||||
case "init":
|
||||
break;
|
||||
case "compact_boundary":
|
||||
case "hook_response":
|
||||
case "status":
|
||||
// Todo: process via status api: https://docs.claude.com/en/docs/claude-code/hooks#hook-output
|
||||
break;
|
||||
default:
|
||||
unreachable(message);
|
||||
}
|
||||
break;
|
||||
case "result": {
|
||||
if (this.sessions[params.sessionId].cancelled) {
|
||||
@@ -234,29 +384,79 @@ export class ClaudeAcpAgent implements Agent {
|
||||
return { stopReason: "refusal" };
|
||||
}
|
||||
}
|
||||
case "user":
|
||||
case "assistant": {
|
||||
if (this.sessions[params.sessionId].cancelled) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
message.message.model === "<synthetic>" &&
|
||||
message.message.content.length === 1 &&
|
||||
message.message.content[0].text.includes("Please run /login")
|
||||
) {
|
||||
throw RequestError.authRequired();
|
||||
}
|
||||
for (const notification of toAcpNotifications(
|
||||
case "stream_event": {
|
||||
for (const notification of streamEventToAcpNotifications(
|
||||
message,
|
||||
params.sessionId,
|
||||
this.toolUseCache,
|
||||
this.fileContentCache,
|
||||
this.client,
|
||||
)) {
|
||||
await this.client.sessionUpdate(notification);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "user":
|
||||
case "assistant": {
|
||||
if (this.sessions[params.sessionId].cancelled) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Slash commands like /compact can generate invalid output... doesn't match
|
||||
// their own docs: https://docs.anthropic.com/en/docs/claude-code/sdk/sdk-slash-commands#%2Fcompact-compact-conversation-history
|
||||
if (
|
||||
typeof message.message.content === "string" &&
|
||||
message.message.content.includes("<local-command-stdout>")
|
||||
) {
|
||||
console.log(message.message.content);
|
||||
break;
|
||||
}
|
||||
|
||||
if (
|
||||
typeof message.message.content === "string" &&
|
||||
message.message.content.includes("<local-command-stderr>")
|
||||
) {
|
||||
console.error(message.message.content);
|
||||
break;
|
||||
}
|
||||
// Skip these user messages for now, since they seem to just be messages we don't want in the feed
|
||||
if (message.type === "user" && typeof message.message.content === "string") {
|
||||
break;
|
||||
}
|
||||
|
||||
if (
|
||||
message.type === "assistant" &&
|
||||
message.message.model === "<synthetic>" &&
|
||||
Array.isArray(message.message.content) &&
|
||||
message.message.content.length === 1 &&
|
||||
message.message.content[0].type === "text" &&
|
||||
message.message.content[0].text.includes("Please run /login")
|
||||
) {
|
||||
throw RequestError.authRequired();
|
||||
}
|
||||
|
||||
const content =
|
||||
message.type === "assistant"
|
||||
? // Handled by stream events above
|
||||
message.message.content.filter((item) => !["text", "thinking"].includes(item.type))
|
||||
: message.message.content;
|
||||
|
||||
for (const notification of toAcpNotifications(
|
||||
content,
|
||||
message.message.role,
|
||||
params.sessionId,
|
||||
this.toolUseCache,
|
||||
this.fileContentCache,
|
||||
this.client,
|
||||
)) {
|
||||
await this.client.sessionUpdate(notification);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "tool_progress":
|
||||
break;
|
||||
case "auth_status":
|
||||
break;
|
||||
default:
|
||||
unreachable(message);
|
||||
}
|
||||
@@ -272,6 +472,38 @@ export class ClaudeAcpAgent implements Agent {
|
||||
await this.sessions[params.sessionId].query.interrupt();
|
||||
}
|
||||
|
||||
async setSessionModel(params: SetSessionModelRequest): Promise<SetSessionModelResponse | void> {
|
||||
if (!this.sessions[params.sessionId]) {
|
||||
throw new Error("Session not found");
|
||||
}
|
||||
await this.sessions[params.sessionId].query.setModel(params.modelId);
|
||||
}
|
||||
|
||||
async setSessionMode(params: SetSessionModeRequest): Promise<SetSessionModeResponse> {
|
||||
if (!this.sessions[params.sessionId]) {
|
||||
throw new Error("Session not found");
|
||||
}
|
||||
|
||||
switch (params.modeId) {
|
||||
case "default":
|
||||
case "acceptEdits":
|
||||
case "bypassPermissions":
|
||||
case "plan":
|
||||
this.sessions[params.sessionId].permissionMode = params.modeId;
|
||||
try {
|
||||
await this.sessions[params.sessionId].query.setPermissionMode(params.modeId);
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error && error.message ? error.message : "Invalid Mode";
|
||||
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
return {};
|
||||
default:
|
||||
throw new Error("Invalid Mode");
|
||||
}
|
||||
}
|
||||
|
||||
async readTextFile(params: ReadTextFileRequest): Promise<ReadTextFileResponse> {
|
||||
const response = await this.client.readTextFile(params);
|
||||
if (!params.limit && !params.line) {
|
||||
@@ -285,60 +517,174 @@ export class ClaudeAcpAgent implements Agent {
|
||||
this.fileContentCache[params.path] = params.content;
|
||||
return response;
|
||||
}
|
||||
|
||||
canUseTool(sessionId: string): CanUseTool {
|
||||
return async (toolName, toolInput, { suggestions, toolUseID }) => {
|
||||
const session = this.sessions[sessionId];
|
||||
if (!session) {
|
||||
return {
|
||||
behavior: "deny",
|
||||
message: "Session not found",
|
||||
interrupt: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (toolName === "ExitPlanMode") {
|
||||
const response = await this.client.requestPermission({
|
||||
options: [
|
||||
{
|
||||
kind: "allow_always",
|
||||
name: "Yes, and auto-accept edits",
|
||||
optionId: "acceptEdits",
|
||||
},
|
||||
{ kind: "allow_once", name: "Yes, and manually approve edits", optionId: "default" },
|
||||
{ kind: "reject_once", name: "No, keep planning", optionId: "plan" },
|
||||
],
|
||||
sessionId,
|
||||
toolCall: {
|
||||
toolCallId: toolUseID,
|
||||
rawInput: toolInput,
|
||||
},
|
||||
});
|
||||
|
||||
if (
|
||||
response.outcome?.outcome === "selected" &&
|
||||
(response.outcome.optionId === "default" || response.outcome.optionId === "acceptEdits")
|
||||
) {
|
||||
session.permissionMode = response.outcome.optionId;
|
||||
await this.client.sessionUpdate({
|
||||
sessionId,
|
||||
update: {
|
||||
sessionUpdate: "current_mode_update",
|
||||
currentModeId: response.outcome.optionId,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
behavior: "allow",
|
||||
updatedInput: toolInput,
|
||||
updatedPermissions: suggestions ?? [
|
||||
{ type: "setMode", mode: response.outcome.optionId, destination: "session" },
|
||||
],
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
behavior: "deny",
|
||||
message: "User rejected request to exit plan mode.",
|
||||
interrupt: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
session.permissionMode === "bypassPermissions" ||
|
||||
(session.permissionMode === "acceptEdits" && EDIT_TOOL_NAMES.includes(toolName))
|
||||
) {
|
||||
return {
|
||||
behavior: "allow",
|
||||
updatedInput: toolInput,
|
||||
updatedPermissions: suggestions ?? [
|
||||
{ type: "addRules", rules: [{ toolName }], behavior: "allow", destination: "session" },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const response = await this.client.requestPermission({
|
||||
options: [
|
||||
{
|
||||
kind: "allow_always",
|
||||
name: "Always Allow",
|
||||
optionId: "allow_always",
|
||||
},
|
||||
{ kind: "allow_once", name: "Allow", optionId: "allow" },
|
||||
{ kind: "reject_once", name: "Reject", optionId: "reject" },
|
||||
],
|
||||
sessionId,
|
||||
toolCall: {
|
||||
toolCallId: toolUseID,
|
||||
rawInput: toolInput,
|
||||
},
|
||||
});
|
||||
if (
|
||||
response.outcome?.outcome === "selected" &&
|
||||
(response.outcome.optionId === "allow" || response.outcome.optionId === "allow_always")
|
||||
) {
|
||||
// If Claude Code has suggestions, it will update their settings already
|
||||
if (response.outcome.optionId === "allow_always") {
|
||||
return {
|
||||
behavior: "allow",
|
||||
updatedInput: toolInput,
|
||||
updatedPermissions: suggestions ?? [
|
||||
{
|
||||
type: "addRules",
|
||||
rules: [{ toolName }],
|
||||
behavior: "allow",
|
||||
destination: "session",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
return {
|
||||
behavior: "allow",
|
||||
updatedInput: toolInput,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
behavior: "deny",
|
||||
message: "User refused permission to run tool",
|
||||
interrupt: true,
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function getAvailableModels(query: Query): Promise<SessionModelState> {
|
||||
const models = await query.supportedModels();
|
||||
|
||||
// Query doesn't give us access to the currently selected model, so we default to "default" if it is there,
|
||||
// otherwise we just choose the first model in the list.
|
||||
const currentModel = models.find((model) => model.value === DEFAULT_MODEL_ID) || models[0];
|
||||
await query.setModel(currentModel.value);
|
||||
|
||||
const availableModels = models.map((model) => ({
|
||||
modelId: model.value,
|
||||
name: model.displayName,
|
||||
description: model.description,
|
||||
}));
|
||||
|
||||
return {
|
||||
availableModels,
|
||||
currentModelId: currentModel.value,
|
||||
};
|
||||
}
|
||||
|
||||
async function getAvailableSlashCommands(query: Query): Promise<AvailableCommand[]> {
|
||||
const UNSUPPORTED_COMMANDS = [
|
||||
"add-dir",
|
||||
"agents", // Modal
|
||||
"bashes", // Modal
|
||||
"bug", // Modal
|
||||
"clear", // Escape Codes
|
||||
"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
|
||||
"context",
|
||||
"cost",
|
||||
"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
|
||||
"output-style:new",
|
||||
"release-notes",
|
||||
"todos",
|
||||
];
|
||||
//todo: Do not use `as any` once `supportedCommands` is exposed via the typescript interface
|
||||
const commands = await (query as any).supportedCommands();
|
||||
const commands = await query.supportedCommands();
|
||||
|
||||
return commands
|
||||
.map((command: { name: string; description: string; argumentHint: string }) => {
|
||||
.map((command) => {
|
||||
const input = command.argumentHint ? { hint: command.argumentHint } : null;
|
||||
let name = command.name;
|
||||
if (command.name.endsWith(" (MCP)")) {
|
||||
name = `mcp:${name.replace(" (MCP)", "")}`;
|
||||
}
|
||||
return {
|
||||
name: command.name,
|
||||
name,
|
||||
description: command.description || "",
|
||||
input,
|
||||
};
|
||||
})
|
||||
.filter(
|
||||
(command: AvailableCommand) =>
|
||||
!(command.name.match(/\(MCP\)/) || UNSUPPORTED_COMMANDS.includes(command.name)),
|
||||
);
|
||||
.filter((command: AvailableCommand) => !UNSUPPORTED_COMMANDS.includes(command.name));
|
||||
}
|
||||
|
||||
function formatUriAsLink(uri: string): string {
|
||||
@@ -358,15 +704,23 @@ function formatUriAsLink(uri: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
function promptToClaude(prompt: PromptRequest): SDKUserMessage {
|
||||
export function promptToClaude(prompt: PromptRequest): SDKUserMessage {
|
||||
const content: any[] = [];
|
||||
const context: any[] = [];
|
||||
|
||||
for (const chunk of prompt.prompt) {
|
||||
switch (chunk.type) {
|
||||
case "text":
|
||||
content.push({ type: "text", text: chunk.text });
|
||||
case "text": {
|
||||
let text = chunk.text;
|
||||
// change /mcp:server:command args -> /server:command (MCP) args
|
||||
const mcpMatch = text.match(/^\/mcp:([^:\s]+):(\S+)(\s+.*)?$/);
|
||||
if (mcpMatch) {
|
||||
const [, server, command, args] = mcpMatch;
|
||||
text = `/${server}:${command} (MCP)${args || ""}`;
|
||||
}
|
||||
content.push({ type: "text", text });
|
||||
break;
|
||||
}
|
||||
case "resource_link": {
|
||||
const formattedUri = formatUriAsLink(chunk.uri);
|
||||
content.push({
|
||||
@@ -434,20 +788,37 @@ function promptToClaude(prompt: PromptRequest): SDKUserMessage {
|
||||
* Only handles text, image, and thinking chunks for now.
|
||||
*/
|
||||
export function toAcpNotifications(
|
||||
message: SDKAssistantMessage | SDKUserMessage,
|
||||
content: string | ContentBlockParam[] | BetaContentBlock[] | BetaRawContentBlockDelta[],
|
||||
role: "assistant" | "user",
|
||||
sessionId: string,
|
||||
toolUseCache: ToolUseCache,
|
||||
fileContentCache: { [key: string]: string },
|
||||
client: AgentSideConnection,
|
||||
): SessionNotification[] {
|
||||
const chunks = message.message.content as ContentChunk[];
|
||||
if (typeof content === "string") {
|
||||
return [
|
||||
{
|
||||
sessionId,
|
||||
update: {
|
||||
sessionUpdate: role === "assistant" ? "agent_message_chunk" : "user_message_chunk",
|
||||
content: {
|
||||
type: "text",
|
||||
text: content,
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const output = [];
|
||||
// Only handle the first chunk for streaming; extend as needed for batching
|
||||
for (const chunk of chunks) {
|
||||
for (const chunk of content) {
|
||||
let update: SessionNotification["update"] | null = null;
|
||||
switch (chunk.type) {
|
||||
case "text":
|
||||
case "text_delta":
|
||||
update = {
|
||||
sessionUpdate: "agent_message_chunk",
|
||||
sessionUpdate: role === "assistant" ? "agent_message_chunk" : "user_message_chunk",
|
||||
content: {
|
||||
type: "text",
|
||||
text: chunk.text,
|
||||
@@ -456,7 +827,7 @@ export function toAcpNotifications(
|
||||
break;
|
||||
case "image":
|
||||
update = {
|
||||
sessionUpdate: "agent_message_chunk",
|
||||
sessionUpdate: role === "assistant" ? "agent_message_chunk" : "user_message_chunk",
|
||||
content: {
|
||||
type: "image",
|
||||
data: chunk.source.type === "base64" ? chunk.source.data : "",
|
||||
@@ -466,6 +837,7 @@ export function toAcpNotifications(
|
||||
};
|
||||
break;
|
||||
case "thinking":
|
||||
case "thinking_delta":
|
||||
update = {
|
||||
sessionUpdate: "agent_thought_chunk",
|
||||
content: {
|
||||
@@ -475,24 +847,74 @@ export function toAcpNotifications(
|
||||
};
|
||||
break;
|
||||
case "tool_use":
|
||||
case "server_tool_use":
|
||||
case "mcp_tool_use": {
|
||||
toolUseCache[chunk.id] = chunk;
|
||||
if (chunk.name === "TodoWrite") {
|
||||
update = {
|
||||
sessionUpdate: "plan",
|
||||
entries: planEntries(chunk.input),
|
||||
};
|
||||
// @ts-expect-error - sometimes input is empty object
|
||||
if (Array.isArray(chunk.input.todos)) {
|
||||
update = {
|
||||
sessionUpdate: "plan",
|
||||
entries: planEntries(chunk.input as { todos: ClaudePlanEntry[] }),
|
||||
};
|
||||
}
|
||||
} else {
|
||||
// Register hook callback to receive the structured output from the hook
|
||||
registerHookCallback(chunk.id, {
|
||||
onPostToolUseHook: async (toolUseId, toolInput, toolResponse) => {
|
||||
const toolUse = toolUseCache[toolUseId];
|
||||
if (toolUse) {
|
||||
const update: SessionNotification["update"] = {
|
||||
_meta: {
|
||||
claudeCode: {
|
||||
toolResponse,
|
||||
toolName: toolUse.name,
|
||||
} satisfies ToolUpdateMeta,
|
||||
},
|
||||
toolCallId: toolUseId,
|
||||
sessionUpdate: "tool_call_update",
|
||||
};
|
||||
await client.sessionUpdate({
|
||||
sessionId,
|
||||
update,
|
||||
});
|
||||
} else {
|
||||
console.error(
|
||||
`[claude-code-acp] Got a tool response for tool use that wasn't tracked: ${toolUseId}`,
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
let rawInput;
|
||||
try {
|
||||
rawInput = JSON.parse(JSON.stringify(chunk.input));
|
||||
} catch {
|
||||
// ignore if we can't turn it to JSON
|
||||
}
|
||||
update = {
|
||||
_meta: {
|
||||
claudeCode: {
|
||||
toolName: chunk.name,
|
||||
} satisfies ToolUpdateMeta,
|
||||
},
|
||||
toolCallId: chunk.id,
|
||||
sessionUpdate: "tool_call",
|
||||
rawInput: chunk.input,
|
||||
rawInput,
|
||||
status: "pending",
|
||||
...toolInfoFromToolUse(chunk, fileContentCache),
|
||||
};
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "tool_result": {
|
||||
case "tool_result":
|
||||
case "web_fetch_tool_result":
|
||||
case "web_search_tool_result":
|
||||
case "code_execution_tool_result":
|
||||
case "bash_code_execution_tool_result":
|
||||
case "text_editor_code_execution_tool_result":
|
||||
case "mcp_tool_result": {
|
||||
const toolUse = toolUseCache[chunk.tool_use_id];
|
||||
if (!toolUse) {
|
||||
console.error(
|
||||
@@ -503,17 +925,31 @@ export function toAcpNotifications(
|
||||
|
||||
if (toolUse.name !== "TodoWrite") {
|
||||
update = {
|
||||
_meta: {
|
||||
claudeCode: {
|
||||
toolName: toolUse.name,
|
||||
} satisfies ToolUpdateMeta,
|
||||
},
|
||||
toolCallId: chunk.tool_use_id,
|
||||
sessionUpdate: "tool_call_update",
|
||||
status: chunk.is_error ? "failed" : "completed",
|
||||
status: "is_error" in chunk && chunk.is_error ? "failed" : "completed",
|
||||
...toolUpdateFromToolResult(chunk, toolUseCache[chunk.tool_use_id]),
|
||||
};
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "document":
|
||||
case "search_result":
|
||||
case "redacted_thinking":
|
||||
case "input_json_delta":
|
||||
case "citations_delta":
|
||||
case "signature_delta":
|
||||
case "container_upload":
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new Error("unhandled chunk type: " + chunk.type);
|
||||
unreachable(chunk);
|
||||
}
|
||||
if (update) {
|
||||
output.push({ sessionId, update });
|
||||
@@ -523,31 +959,49 @@ export function toAcpNotifications(
|
||||
return output;
|
||||
}
|
||||
|
||||
export function runAcp() {
|
||||
new AgentSideConnection(
|
||||
(client) => new ClaudeAcpAgent(client),
|
||||
nodeToWebWritable(process.stdout),
|
||||
nodeToWebReadable(process.stdin),
|
||||
);
|
||||
export function streamEventToAcpNotifications(
|
||||
message: SDKPartialAssistantMessage,
|
||||
sessionId: string,
|
||||
toolUseCache: ToolUseCache,
|
||||
fileContentCache: { [key: string]: string },
|
||||
client: AgentSideConnection,
|
||||
): SessionNotification[] {
|
||||
const event = message.event;
|
||||
switch (event.type) {
|
||||
case "content_block_start":
|
||||
return toAcpNotifications(
|
||||
[event.content_block],
|
||||
"assistant",
|
||||
sessionId,
|
||||
toolUseCache,
|
||||
fileContentCache,
|
||||
client,
|
||||
);
|
||||
case "content_block_delta":
|
||||
return toAcpNotifications(
|
||||
[event.delta],
|
||||
"assistant",
|
||||
sessionId,
|
||||
toolUseCache,
|
||||
fileContentCache,
|
||||
client,
|
||||
);
|
||||
// No content
|
||||
case "message_start":
|
||||
case "message_delta":
|
||||
case "message_stop":
|
||||
case "content_block_stop":
|
||||
return [];
|
||||
|
||||
default:
|
||||
unreachable(event);
|
||||
}
|
||||
}
|
||||
|
||||
type ContentChunk =
|
||||
| { type: "text"; text: string }
|
||||
| { type: "tool_use"; id: string; name: string; input: any }
|
||||
| {
|
||||
type: "tool_result";
|
||||
content: string;
|
||||
tool_use_id: string;
|
||||
is_error: boolean;
|
||||
} // content type depends on your Content definition
|
||||
| { type: "thinking"; thinking: string }
|
||||
| { type: "redacted_thinking" }
|
||||
| { type: "image"; source: ImageSource }
|
||||
| { type: "document" }
|
||||
| { type: "web_search_tool_result" }
|
||||
| { type: "untagged_text"; text: string };
|
||||
export function runAcp() {
|
||||
const input = nodeToWebWritable(process.stdout);
|
||||
const output = nodeToWebReadable(process.stdin);
|
||||
|
||||
// Example ImageSource type (adjust as needed)
|
||||
type ImageSource =
|
||||
| { type: "base64"; data: string; media_type: string }
|
||||
| { type: "url"; url: string };
|
||||
const stream = ndJsonStream(input, output);
|
||||
new AgentSideConnection((client) => new ClaudeAcpAgent(client), stream);
|
||||
}
|
||||
|
||||
10
src/index.ts
10
src/index.ts
@@ -1,5 +1,13 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
// Load managed settings and apply environment variables
|
||||
import { loadManagedSettings, applyEnvironmentSettings } from "./utils.js";
|
||||
|
||||
const managedSettings = loadManagedSettings();
|
||||
if (managedSettings) {
|
||||
applyEnvironmentSettings(managedSettings);
|
||||
}
|
||||
|
||||
// stdout is used to send messages to the client
|
||||
// we redirect everything else to stderr to make sure it doesn't interfere with ACP
|
||||
console.log = console.error;
|
||||
@@ -11,7 +19,7 @@ process.on("unhandledRejection", (reason, promise) => {
|
||||
console.error("Unhandled Rejection at:", promise, "reason:", reason);
|
||||
});
|
||||
|
||||
import { runAcp as runAcp } from "./acp-agent.js";
|
||||
import { runAcp } from "./acp-agent.js";
|
||||
runAcp();
|
||||
|
||||
// Keep process alive
|
||||
|
||||
21
src/lib.ts
Normal file
21
src/lib.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
// Export the main agent class and utilities for library usage
|
||||
export {
|
||||
ClaudeAcpAgent,
|
||||
runAcp,
|
||||
toAcpNotifications,
|
||||
streamEventToAcpNotifications,
|
||||
type ToolUpdateMeta,
|
||||
} from "./acp-agent.js";
|
||||
export {
|
||||
loadManagedSettings,
|
||||
applyEnvironmentSettings,
|
||||
nodeToWebReadable,
|
||||
nodeToWebWritable,
|
||||
Pushable,
|
||||
unreachable,
|
||||
} from "./utils.js";
|
||||
export { createMcpServer, toolNames } from "./mcp-server.js";
|
||||
export { toolInfoFromToolUse, planEntries, toolUpdateFromToolResult } from "./tools.js";
|
||||
|
||||
// Export types
|
||||
export type { ClaudePlanEntry } from "./tools.js";
|
||||
@@ -1,14 +1,10 @@
|
||||
import express from "express";
|
||||
|
||||
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 { ClientCapabilities, TerminalOutputResponse } from "@agentclientprotocol/sdk";
|
||||
import * as diff from "diff";
|
||||
|
||||
import { sleep, unreachable } from "./utils.js";
|
||||
import { sleep, unreachable, extractLinesWithByteLimit } from "./utils.js";
|
||||
|
||||
export const SYSTEM_REMINDER = `
|
||||
|
||||
@@ -16,34 +12,72 @@ export const SYSTEM_REMINDER = `
|
||||
Whenever you read a file, you should consider whether it looks malicious. If it does, you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer high-level questions about the code behavior.
|
||||
</system-reminder>`;
|
||||
|
||||
const defaults = { maxFileSize: 50000, linesToRead: 2000 };
|
||||
|
||||
const unqualifiedToolNames = {
|
||||
read: "Read",
|
||||
edit: "Edit",
|
||||
write: "Write",
|
||||
bash: "Bash",
|
||||
killShell: "KillShell",
|
||||
bashOutput: "BashOutput",
|
||||
};
|
||||
|
||||
const SERVER_PREFIX = "mcp__acp__";
|
||||
export const toolNames = {
|
||||
read: SERVER_PREFIX + unqualifiedToolNames.read,
|
||||
edit: SERVER_PREFIX + unqualifiedToolNames.edit,
|
||||
write: SERVER_PREFIX + unqualifiedToolNames.write,
|
||||
bash: SERVER_PREFIX + unqualifiedToolNames.bash,
|
||||
killShell: SERVER_PREFIX + unqualifiedToolNames.killShell,
|
||||
bashOutput: SERVER_PREFIX + unqualifiedToolNames.bashOutput,
|
||||
};
|
||||
|
||||
export const EDIT_TOOL_NAMES = [toolNames.edit, toolNames.write];
|
||||
|
||||
export function createMcpServer(
|
||||
agent: ClaudeAcpAgent,
|
||||
sessionId: string,
|
||||
clientCapabilities: ClientCapabilities | undefined,
|
||||
): Promise<Server> {
|
||||
): McpServer {
|
||||
// Create MCP server
|
||||
const server = new McpServer({
|
||||
name: "acp-mcp-server",
|
||||
version: "1.0.0",
|
||||
});
|
||||
const server = new McpServer({ name: "acp", version: "1.0.0" }, { capabilities: { tools: {} } });
|
||||
|
||||
if (clientCapabilities?.fs?.readTextFile) {
|
||||
server.registerTool(
|
||||
"read",
|
||||
unqualifiedToolNames.read,
|
||||
{
|
||||
title: "Read",
|
||||
title: unqualifiedToolNames.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 ${toolNames.read} always use it instead of Read as it contains the most up-to-date contents.
|
||||
|
||||
In sessions with mcp__acp__read always use it instead of Read as it contains the most up-to-date contents.`,
|
||||
Reads a file from the local filesystem. If the User provides a path to a file assume that path is valid. It is okay to read a file that does not exist; an error will be returned.
|
||||
|
||||
Usage:
|
||||
- The file_path parameter must be an absolute path, not a relative path
|
||||
- By default, it reads up to ${defaults.linesToRead} lines starting from the beginning of the file
|
||||
- You can optionally specify a line offset and limit (especially handy for long files), but it's recommended to read the whole file by not providing these parameters
|
||||
- Any files larger than ${defaults.maxFileSize} bytes will be truncated
|
||||
- This tool allows Claude Code to read images (eg PNG, JPG, etc). When reading an image file the contents are presented visually as Claude Code is a multimodal LLM.
|
||||
- This tool can only read files, not directories. To read a directory, use an ls command via the ${toolNames.bash} tool.
|
||||
- You have the capability to call multiple tools in a single response. It is always better to speculatively read multiple files as a batch that are potentially useful.`,
|
||||
inputSchema: {
|
||||
abs_path: z.string().describe("The absolute path to the file to read."),
|
||||
file_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."),
|
||||
.default(1)
|
||||
.describe(
|
||||
"The line number to start reading from. Only provide if the file is too large to read at once",
|
||||
),
|
||||
limit: z
|
||||
.number()
|
||||
.optional()
|
||||
.default(defaults.linesToRead)
|
||||
.describe(
|
||||
`The number of lines to read. Only provide if the file is too large to read at once.`,
|
||||
),
|
||||
},
|
||||
annotations: {
|
||||
title: "Read file",
|
||||
@@ -66,18 +100,40 @@ In sessions with mcp__acp__read always use it instead of Read as it contains the
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const content = await agent.readTextFile({
|
||||
sessionId,
|
||||
path: input.abs_path,
|
||||
limit: input.limit,
|
||||
path: input.file_path,
|
||||
line: input.offset,
|
||||
limit: input.limit,
|
||||
});
|
||||
|
||||
// Extract lines with byte limit enforcement
|
||||
const result = extractLinesWithByteLimit(content.content, defaults.maxFileSize);
|
||||
|
||||
// Construct informative message about what was read
|
||||
let readInfo = "";
|
||||
if (input.offset > 1 || result.wasLimited) {
|
||||
readInfo = "\n\n<file-read-info>";
|
||||
|
||||
if (result.wasLimited) {
|
||||
readInfo += `Read ${result.linesRead} lines (hit 50KB limit). `;
|
||||
} else {
|
||||
readInfo += `Read lines ${input.offset}-${result.linesRead}. `;
|
||||
}
|
||||
|
||||
if (result.wasLimited) {
|
||||
readInfo += `Continue with offset=${result.linesRead}.`;
|
||||
}
|
||||
|
||||
readInfo += "</file-read-info>";
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: content.content + SYSTEM_REMINDER,
|
||||
text: result.content + readInfo + SYSTEM_REMINDER,
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -97,16 +153,25 @@ In sessions with mcp__acp__read always use it instead of Read as it contains the
|
||||
|
||||
if (clientCapabilities?.fs?.writeTextFile) {
|
||||
server.registerTool(
|
||||
"write",
|
||||
unqualifiedToolNames.write,
|
||||
{
|
||||
title: "Write",
|
||||
description: `Writes content to the specified file in the project.
|
||||
title: unqualifiedToolNames.write,
|
||||
description: `Writes a file to the local filesystem..
|
||||
|
||||
In sessions with mcp__acp__write always use it instead of Write as it will
|
||||
allow the user to conveniently review changes.`,
|
||||
In sessions with ${toolNames.write} always use it instead of Write as it will
|
||||
allow the user to conveniently review changes.
|
||||
|
||||
Usage:
|
||||
- This tool will overwrite the existing file if there is one at the provided path.
|
||||
- If this is an existing file, you MUST use the ${toolNames.read} tool first to read the file's contents. This tool will fail if you did not read the file first.
|
||||
- ALWAYS prefer editing existing files in the codebase. NEVER write new files unless explicitly required.
|
||||
- NEVER proactively create documentation files (*.md) or README files. Only create documentation files if explicitly requested by the User.
|
||||
- Only use emojis if the user explicitly requests it. Avoid writing emojis to files unless asked.`,
|
||||
inputSchema: {
|
||||
abs_path: z.string().describe("The absolute path to the file to write"),
|
||||
content: z.string().describe("The full content to write"),
|
||||
file_path: z
|
||||
.string()
|
||||
.describe("The absolute path to the file to write (must be absolute, not relative)"),
|
||||
content: z.string().describe("The content to write to the file"),
|
||||
},
|
||||
annotations: {
|
||||
title: "Write file",
|
||||
@@ -131,7 +196,7 @@ allow the user to conveniently review changes.`,
|
||||
}
|
||||
await agent.writeTextFile({
|
||||
sessionId,
|
||||
path: input.abs_path,
|
||||
path: input.file_path,
|
||||
content: input.content,
|
||||
});
|
||||
|
||||
@@ -152,28 +217,32 @@ allow the user to conveniently review changes.`,
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"edit",
|
||||
unqualifiedToolNames.edit,
|
||||
{
|
||||
title: "Edit",
|
||||
description: `Edit a file.
|
||||
title: unqualifiedToolNames.edit,
|
||||
description: `Performs exact string replacements in files.
|
||||
|
||||
In sessions with mcp__acp__edit always use it instead of Edit as it will
|
||||
In sessions with ${toolNames.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.
|
||||
- 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.
|
||||
- If the \`old_string\` value isn't found in the file, the edit won't be applied. The tool will fail and must be retried.`,
|
||||
Usage:
|
||||
- You must use your \`${toolNames.read}\` tool at least once in the conversation before editing. This tool will error if you attempt an edit without reading the file.
|
||||
- When editing text from Read tool output, ensure you preserve the exact indentation (tabs/spaces) as it appears.
|
||||
- ALWAYS prefer editing existing files in the codebase. NEVER write new files unless explicitly required.
|
||||
- Only use emojis if the user explicitly requests it. Avoid adding emojis to files unless asked.
|
||||
- The edit will FAIL if \`old_string\` is not unique in the file. Either provide a larger string with more surrounding context to make it unique or use \`replace_all\` to change every instance of \`old_string\`.
|
||||
- Use \`replace_all\` for replacing and renaming strings across the file. This parameter is useful if you want to rename a variable for instance.`,
|
||||
inputSchema: {
|
||||
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."),
|
||||
file_path: z.string().describe("The absolute path to the file to modify"),
|
||||
old_string: z.string().describe("The text to replace"),
|
||||
new_string: z
|
||||
.string()
|
||||
.describe("The text to replace it with (must be different from old_string)"),
|
||||
replace_all: z
|
||||
.boolean()
|
||||
.default(false)
|
||||
.optional()
|
||||
.describe("Replace all occurences of old_string (default false)"),
|
||||
},
|
||||
annotations: {
|
||||
title: "Edit file",
|
||||
@@ -198,105 +267,17 @@ File editing instructions:
|
||||
|
||||
const { content } = await agent.readTextFile({
|
||||
sessionId,
|
||||
path: input.abs_path,
|
||||
path: input.file_path,
|
||||
});
|
||||
|
||||
const { newContent } = replaceAndCalculateLocation(content, [
|
||||
{
|
||||
oldText: input.old_string,
|
||||
newText: input.new_string,
|
||||
replaceAll: false,
|
||||
replaceAll: input.replace_all,
|
||||
},
|
||||
]);
|
||||
|
||||
const patch = diff.createPatch(input.abs_path, content, newContent);
|
||||
|
||||
await agent.writeTextFile({
|
||||
sessionId,
|
||||
path: input.abs_path,
|
||||
content: newContent,
|
||||
});
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: patch,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"multi-edit",
|
||||
{
|
||||
title: "Multi Edit",
|
||||
description: `Edit a file with multiple sequential edits.
|
||||
|
||||
In sessions with mcp__acp__multi-edit always use it instead of MultiEdit 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.
|
||||
- Be minimal with replacements:
|
||||
- For unique lines, include only those lines.
|
||||
- For non-unique lines, include enough context to identify them, unless you're using \`replace_all\`
|
||||
- Do not escape quotes, newlines, or other characters.
|
||||
- If any of the provided \`old_string\` values aren't found in the file, no edits will be applied. The tool will fail and must be retried.`,
|
||||
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) => {
|
||||
const session = agent.sessions[sessionId];
|
||||
if (!session) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "The user has left the building",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const { content } = await agent.readTextFile({
|
||||
sessionId,
|
||||
path: input.file_path,
|
||||
});
|
||||
|
||||
const { newContent } = replaceAndCalculateLocation(
|
||||
content,
|
||||
input.edits.map((edit) => ({
|
||||
oldText: edit.old_string,
|
||||
newText: edit.new_string,
|
||||
replaceAll: edit.replace_all ?? false,
|
||||
})),
|
||||
);
|
||||
|
||||
const patch = diff.createPatch(input.file_path, content, newContent);
|
||||
|
||||
await agent.writeTextFile({
|
||||
@@ -319,21 +300,36 @@ File editing instructions:
|
||||
|
||||
if (agent.clientCapabilities?.terminal) {
|
||||
server.registerTool(
|
||||
"Bash",
|
||||
unqualifiedToolNames.bash,
|
||||
{
|
||||
title: "Bash",
|
||||
description: "Executes a bash command",
|
||||
title: unqualifiedToolNames.bash,
|
||||
description: `Executes a bash command
|
||||
|
||||
In sessions with ${toolNames.bash} always use it instead of Bash`,
|
||||
inputSchema: {
|
||||
command: z.string().describe("The bash command to execute as a one-liner"),
|
||||
timeout_ms: z
|
||||
command: z.string().describe("The command to execute"),
|
||||
timeout: z
|
||||
.number()
|
||||
.default(2 * 60 * 1000)
|
||||
.describe("Optional timeout in milliseconds"),
|
||||
.describe(`Optional timeout in milliseconds (max ${2 * 60 * 1000})`),
|
||||
description: z.string().optional()
|
||||
.describe(`Clear, concise description of what this command does in 5-10 words, in active voice. Examples:
|
||||
Input: ls
|
||||
Output: List files in current directory
|
||||
|
||||
Input: git status
|
||||
Output: Show working tree status
|
||||
|
||||
Input: npm install
|
||||
Output: Install package dependencies
|
||||
|
||||
Input: mkdir foo
|
||||
Output: Create directory 'foo'`),
|
||||
run_in_background: z
|
||||
.boolean()
|
||||
.default(false)
|
||||
.describe(
|
||||
"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.",
|
||||
`Set to true to run this command in the background. The tool returns an \`id\` that can be used with the \`${toolNames.bashOutput}\` tool to retrieve the current output, or the \`${toolNames.killShell}\` tool to stop it early.`,
|
||||
),
|
||||
},
|
||||
},
|
||||
@@ -372,6 +368,7 @@ File editing instructions:
|
||||
sessionUpdate: "tool_call_update",
|
||||
toolCallId,
|
||||
status: "in_progress",
|
||||
title: input.description,
|
||||
content: [{ type: "terminal", terminalId: handle.id }],
|
||||
},
|
||||
});
|
||||
@@ -389,7 +386,7 @@ File editing instructions:
|
||||
const statusPromise = Promise.race([
|
||||
handle.waitForExit().then((exitStatus) => ({ status: "exited" as const, exitStatus })),
|
||||
abortPromise.then(() => ({ status: "aborted" as const, exitStatus: null })),
|
||||
sleep(input.timeout_ms).then(async () => {
|
||||
sleep(input.timeout).then(async () => {
|
||||
if (agent.backgroundTerminals[handle.id]?.status === "started") {
|
||||
await handle.kill();
|
||||
}
|
||||
@@ -454,22 +451,27 @@ File editing instructions:
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"BashOutput",
|
||||
unqualifiedToolNames.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.",
|
||||
title: unqualifiedToolNames.bashOutput,
|
||||
description: `- Retrieves output from a running or completed background bash shell
|
||||
- Takes a shell_id parameter identifying the shell
|
||||
- Always returns only new output since the last check
|
||||
- Returns stdout and stderr output along with shell status
|
||||
- Use this tool when you need to monitor or check the output of a long-running shell
|
||||
|
||||
In sessions with ${toolNames.bashOutput} always use it instead of BashOutput.`,
|
||||
inputSchema: {
|
||||
id: z
|
||||
shell_id: z
|
||||
.string()
|
||||
.describe("The id of the background bash command as returned by `mcp__acp__Bash`"),
|
||||
.describe(`The id of the background bash command as returned by \`${toolNames.bash}\``),
|
||||
},
|
||||
},
|
||||
async (input) => {
|
||||
const bgTerm = agent.backgroundTerminals[input.id];
|
||||
const bgTerm = agent.backgroundTerminals[input.shell_id];
|
||||
|
||||
if (!bgTerm) {
|
||||
throw new Error(`Unknown shell ${input.id}`);
|
||||
throw new Error(`Unknown shell ${input.shell_id}`);
|
||||
}
|
||||
|
||||
if (bgTerm.status === "started") {
|
||||
@@ -505,21 +507,26 @@ File editing instructions:
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"KillBash",
|
||||
unqualifiedToolNames.killShell,
|
||||
{
|
||||
title: "KillBash",
|
||||
description: "Stops a background command by its id",
|
||||
title: unqualifiedToolNames.killShell,
|
||||
description: `- Kills a running background bash shell by its ID
|
||||
- Takes a shell_id parameter identifying the shell to kill
|
||||
- Returns a success or failure status
|
||||
- Use this tool when you need to terminate a long-running shell
|
||||
|
||||
In sessions with ${toolNames.killShell} always use it instead of KillShell.`,
|
||||
inputSchema: {
|
||||
id: z
|
||||
shell_id: z
|
||||
.string()
|
||||
.describe("The id of the background bash command as returned by `mcp__acp__Bash`"),
|
||||
.describe(`The id of the background bash command as returned by \`${toolNames.bash}\``),
|
||||
},
|
||||
},
|
||||
async (input) => {
|
||||
const bgTerm = agent.backgroundTerminals[input.id];
|
||||
const bgTerm = agent.backgroundTerminals[input.shell_id];
|
||||
|
||||
if (!bgTerm) {
|
||||
throw new Error(`Unknown shell ${input.id}`);
|
||||
throw new Error(`Unknown shell ${input.shell_id}`);
|
||||
}
|
||||
|
||||
switch (bgTerm.status) {
|
||||
@@ -563,133 +570,7 @@ File editing instructions:
|
||||
);
|
||||
}
|
||||
|
||||
const alwaysAllowedTools: { [key: string]: boolean } = {};
|
||||
server.registerTool(
|
||||
"permission",
|
||||
{
|
||||
title: "Permission Tool",
|
||||
description: "Used to request tool permissions",
|
||||
inputSchema: {
|
||||
tool_name: z.string(),
|
||||
input: z.any(),
|
||||
tool_use_id: z.string().optional(),
|
||||
},
|
||||
},
|
||||
async (input) => {
|
||||
const session = agent.sessions[sessionId];
|
||||
if (!session) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: JSON.stringify({
|
||||
behavior: "deny",
|
||||
message: "Session not found",
|
||||
}),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
if (alwaysAllowedTools[input.tool_name]) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: JSON.stringify({
|
||||
behavior: "allow",
|
||||
updatedInput: input.input,
|
||||
}),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
const response = await agent.client.requestPermission({
|
||||
options: [
|
||||
{
|
||||
kind: "allow_always",
|
||||
name: "Always Allow",
|
||||
optionId: "allow_always",
|
||||
},
|
||||
{ kind: "allow_once", name: "Allow", optionId: "allow" },
|
||||
{ kind: "reject_once", name: "Reject", optionId: "reject" },
|
||||
],
|
||||
sessionId,
|
||||
toolCall: {
|
||||
toolCallId: input.tool_use_id!,
|
||||
rawInput: input.input,
|
||||
},
|
||||
});
|
||||
if (
|
||||
response.outcome?.outcome === "selected" &&
|
||||
(response.outcome.optionId === "allow" || response.outcome.optionId === "allow_always")
|
||||
) {
|
||||
if (response.outcome.optionId === "allow_always") {
|
||||
alwaysAllowedTools[input.tool_name] = true;
|
||||
}
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: JSON.stringify({
|
||||
behavior: "allow",
|
||||
updatedInput: input.input,
|
||||
}),
|
||||
},
|
||||
],
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: JSON.stringify({
|
||||
behavior: "deny",
|
||||
message: "User refused permission to run tool",
|
||||
}),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
|
||||
app.post("/mcp", async (req, res) => {
|
||||
try {
|
||||
const transport: StreamableHTTPServerTransport = new StreamableHTTPServerTransport({
|
||||
sessionIdGenerator: undefined,
|
||||
});
|
||||
res.on("close", () => {
|
||||
transport.close();
|
||||
server.close();
|
||||
});
|
||||
await server.connect(transport);
|
||||
await transport.handleRequest(req, res, req.body);
|
||||
} catch (error) {
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({
|
||||
jsonrpc: "2.0",
|
||||
error: {
|
||||
code: -32603,
|
||||
message: `Internal server error: ${error}`,
|
||||
},
|
||||
id: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const listener = app.listen(0, "127.0.0.1", (error) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
resolve(listener);
|
||||
});
|
||||
});
|
||||
return server;
|
||||
}
|
||||
|
||||
function stripCommonPrefix(a: string, b: string): string {
|
||||
|
||||
@@ -2,9 +2,11 @@ import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
||||
import { spawn, spawnSync } from "child_process";
|
||||
import {
|
||||
Agent,
|
||||
AgentSideConnection,
|
||||
AvailableCommand,
|
||||
Client,
|
||||
ClientSideConnection,
|
||||
ndJsonStream,
|
||||
NewSessionResponse,
|
||||
ReadTextFileRequest,
|
||||
ReadTextFileResponse,
|
||||
@@ -13,11 +15,11 @@ import {
|
||||
SessionNotification,
|
||||
WriteTextFileRequest,
|
||||
WriteTextFileResponse,
|
||||
} from "@zed-industries/agent-client-protocol";
|
||||
} from "@agentclientprotocol/sdk";
|
||||
import { nodeToWebWritable, nodeToWebReadable } from "../utils.js";
|
||||
import { markdownEscape, toolInfoFromToolUse, toolUpdateFromToolResult } from "../tools.js";
|
||||
import { toAcpNotifications } from "../acp-agent.js";
|
||||
import { SDKAssistantMessage } from "@anthropic-ai/claude-code";
|
||||
import { toAcpNotifications, promptToClaude } from "../acp-agent.js";
|
||||
import { query, SDKAssistantMessage } from "@anthropic-ai/claude-agent-sdk";
|
||||
|
||||
describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)("ACP subprocess integration", () => {
|
||||
let child: ReturnType<typeof spawn>;
|
||||
@@ -91,7 +93,7 @@ describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)("ACP subprocess integration"
|
||||
|
||||
async writeTextFile(params: WriteTextFileRequest): Promise<WriteTextFileResponse> {
|
||||
this.files.set(params.path, params.content);
|
||||
return null;
|
||||
return {};
|
||||
}
|
||||
|
||||
async readTextFile(params: ReadTextFileRequest): Promise<ReadTextFileResponse> {
|
||||
@@ -108,14 +110,13 @@ describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)("ACP subprocess integration"
|
||||
newSessionResponse: NewSessionResponse;
|
||||
}> {
|
||||
let client;
|
||||
const connection = new ClientSideConnection(
|
||||
(agent) => {
|
||||
client = new TestClient(agent);
|
||||
return client;
|
||||
},
|
||||
nodeToWebWritable(child.stdin!),
|
||||
nodeToWebReadable(child.stdout!),
|
||||
);
|
||||
const input = nodeToWebWritable(child.stdin!);
|
||||
const output = nodeToWebReadable(child.stdout!);
|
||||
const stream = ndJsonStream(input, output);
|
||||
const connection = new ClientSideConnection((agent) => {
|
||||
client = new TestClient(agent);
|
||||
return client;
|
||||
}, stream);
|
||||
|
||||
await connection.initialize({
|
||||
protocolVersion: 1,
|
||||
@@ -191,6 +192,50 @@ describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)("ACP subprocess integration"
|
||||
|
||||
expect(client.takeReceivedText()).toContain("Hello GPT-5");
|
||||
}, 30000);
|
||||
|
||||
it("/compact works", async () => {
|
||||
const { client, connection, newSessionResponse } = await setupTestSession(__dirname);
|
||||
|
||||
const commands = await client.availableCommandsPromise;
|
||||
|
||||
expect(commands).toContainEqual({
|
||||
description:
|
||||
"Clear conversation history but keep a summary in context. Optional: /compact [instructions for summarization]",
|
||||
input: {
|
||||
hint: "<optional custom summarization instructions>",
|
||||
},
|
||||
name: "compact",
|
||||
});
|
||||
|
||||
// Error case (no previous message)
|
||||
await connection.prompt({
|
||||
prompt: [{ type: "text", text: "/compact" }],
|
||||
sessionId: newSessionResponse.sessionId,
|
||||
});
|
||||
|
||||
expect(client.takeReceivedText()).toBe("");
|
||||
|
||||
// Send something
|
||||
await connection.prompt({
|
||||
prompt: [{ type: "text", text: "Hi" }],
|
||||
sessionId: newSessionResponse.sessionId,
|
||||
});
|
||||
// Clear response
|
||||
client.takeReceivedText();
|
||||
|
||||
// Test with instruction
|
||||
await connection.prompt({
|
||||
prompt: [
|
||||
{
|
||||
type: "text",
|
||||
text: "/compact greeting",
|
||||
},
|
||||
],
|
||||
sessionId: newSessionResponse.sessionId,
|
||||
});
|
||||
|
||||
expect(client.takeReceivedText()).toContain("");
|
||||
}, 30000);
|
||||
});
|
||||
|
||||
describe("tool conversions", () => {
|
||||
@@ -327,13 +372,13 @@ describe("tool conversions", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle mcp__acp__write tool calls", () => {
|
||||
it("should handle mcp__acp__Write tool calls", () => {
|
||||
const tool_use = {
|
||||
type: "tool_use",
|
||||
id: "toolu_01GHI789JKL456",
|
||||
name: "mcp__acp__write",
|
||||
name: "mcp__acp__Write",
|
||||
input: {
|
||||
abs_path: "/Users/test/project/config.json",
|
||||
file_path: "/Users/test/project/config.json",
|
||||
content: '{"version": "1.0.0"}',
|
||||
},
|
||||
};
|
||||
@@ -371,13 +416,13 @@ describe("tool conversions", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle mcp__acp__read tool calls", () => {
|
||||
it("should handle mcp__acp__Read tool calls", () => {
|
||||
const tool_use = {
|
||||
type: "tool_use",
|
||||
id: "toolu_01YZA789BCD123",
|
||||
name: "mcp__acp__read",
|
||||
name: "mcp__acp__Read",
|
||||
input: {
|
||||
abs_path: "/Users/test/project/data.json",
|
||||
file_path: "/Users/test/project/data.json",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -389,13 +434,13 @@ describe("tool conversions", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle mcp__acp__read with limit", () => {
|
||||
it("should handle mcp__acp__Read with limit", () => {
|
||||
const tool_use = {
|
||||
type: "tool_use",
|
||||
id: "toolu_01EFG456HIJ789",
|
||||
name: "mcp__acp__read",
|
||||
name: "mcp__acp__Read",
|
||||
input: {
|
||||
abs_path: "/Users/test/project/large.txt",
|
||||
file_path: "/Users/test/project/large.txt",
|
||||
limit: 100,
|
||||
},
|
||||
};
|
||||
@@ -408,13 +453,13 @@ describe("tool conversions", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle mcp__acp__read with offset and limit", () => {
|
||||
it("should handle mcp__acp__Read with offset and limit", () => {
|
||||
const tool_use = {
|
||||
type: "tool_use",
|
||||
id: "toolu_01KLM789NOP456",
|
||||
name: "mcp__acp__read",
|
||||
name: "mcp__acp__Read",
|
||||
input: {
|
||||
abs_path: "/Users/test/project/large.txt",
|
||||
file_path: "/Users/test/project/large.txt",
|
||||
offset: 50,
|
||||
limit: 100,
|
||||
},
|
||||
@@ -428,13 +473,13 @@ describe("tool conversions", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle mcp__acp__read with only offset", () => {
|
||||
it("should handle mcp__acp__Read with only offset", () => {
|
||||
const tool_use = {
|
||||
type: "tool_use",
|
||||
id: "toolu_01QRS123TUV789",
|
||||
name: "mcp__acp__read",
|
||||
name: "mcp__acp__Read",
|
||||
input: {
|
||||
abs_path: "/Users/test/project/large.txt",
|
||||
file_path: "/Users/test/project/large.txt",
|
||||
offset: 200,
|
||||
},
|
||||
};
|
||||
@@ -495,7 +540,7 @@ describe("tool conversions", () => {
|
||||
const tool_use = {
|
||||
type: "tool_use",
|
||||
id: "toolu_01PhLms5fuvmdjy2bb6dfUKT",
|
||||
name: "KillBash",
|
||||
name: "KillShell",
|
||||
input: {
|
||||
shell_id: "bash_1",
|
||||
},
|
||||
@@ -532,6 +577,7 @@ describe("tool conversions", () => {
|
||||
id: "msg_017eNosJgww7F5qD4a8BcAcx",
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
container: null,
|
||||
model: "claude-sonnet-4-20250514",
|
||||
content: [
|
||||
{
|
||||
@@ -596,13 +642,24 @@ describe("tool conversions", () => {
|
||||
},
|
||||
output_tokens: 1,
|
||||
service_tier: "standard",
|
||||
server_tool_use: null,
|
||||
},
|
||||
context_management: null,
|
||||
},
|
||||
parent_tool_use_id: null,
|
||||
session_id: "d056596f-e328-41e9-badd-b07122ae5227",
|
||||
uuid: "b7c3330c-de8f-4bba-ac53-68c7f76ffeb5",
|
||||
};
|
||||
expect(toAcpNotifications(received, "test", {}, {})).toStrictEqual([
|
||||
expect(
|
||||
toAcpNotifications(
|
||||
received.message.content,
|
||||
received.message.role,
|
||||
"test",
|
||||
{},
|
||||
{},
|
||||
{} as AgentSideConnection,
|
||||
),
|
||||
).toStrictEqual([
|
||||
{
|
||||
sessionId: "test",
|
||||
update: {
|
||||
@@ -654,78 +711,13 @@ 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",
|
||||
line: 1,
|
||||
},
|
||||
{
|
||||
path: "/Users/test/project/config.json",
|
||||
line: 3,
|
||||
},
|
||||
],
|
||||
title: "Edit /Users/test/project/config.json",
|
||||
});
|
||||
});
|
||||
|
||||
it("should return empty update for successful edit result", () => {
|
||||
const toolUse = {
|
||||
type: "tool_use",
|
||||
id: "toolu_01MNO345",
|
||||
name: "mcp__acp__edit",
|
||||
name: "mcp__acp__Edit",
|
||||
input: {
|
||||
abs_path: "/Users/test/project/test.txt",
|
||||
file_path: "/Users/test/project/test.txt",
|
||||
old_string: "old",
|
||||
new_string: "new",
|
||||
},
|
||||
@@ -734,7 +726,7 @@ describe("tool conversions", () => {
|
||||
const toolResult = {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
type: "text" as const,
|
||||
text: "not valid json",
|
||||
},
|
||||
],
|
||||
@@ -753,9 +745,9 @@ describe("tool conversions", () => {
|
||||
const toolUse = {
|
||||
type: "tool_use",
|
||||
id: "toolu_01MNO345",
|
||||
name: "mcp__acp__edit",
|
||||
name: "mcp__acp__Edit",
|
||||
input: {
|
||||
abs_path: "/Users/test/project/test.txt",
|
||||
file_path: "/Users/test/project/test.txt",
|
||||
old_string: "old",
|
||||
new_string: "new",
|
||||
},
|
||||
@@ -764,7 +756,7 @@ describe("tool conversions", () => {
|
||||
const toolResult = {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
type: "text" as const,
|
||||
text: "Failed to find `old_string`",
|
||||
},
|
||||
],
|
||||
@@ -778,7 +770,10 @@ describe("tool conversions", () => {
|
||||
// Should return empty object when parsing fails
|
||||
expect(update).toEqual({
|
||||
content: [
|
||||
{ content: { type: "text", text: "Failed to find `old_string`" }, type: "content" },
|
||||
{
|
||||
content: { type: "text", text: "```\nFailed to find `old_string`\n```" },
|
||||
type: "content",
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
@@ -795,3 +790,54 @@ describe("escape markdown", () => {
|
||||
expect(escaped).toEqual("````\nfor example:\n```markdown\nHello *world*!\n```\n````");
|
||||
});
|
||||
});
|
||||
|
||||
describe("prompt conversion", () => {
|
||||
it("should not change built-in slash commands", () => {
|
||||
const message = promptToClaude({
|
||||
sessionId: "test",
|
||||
prompt: [
|
||||
{
|
||||
type: "text",
|
||||
text: "/compact args",
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(message.message.content).toEqual([
|
||||
{
|
||||
text: "/compact args",
|
||||
type: "text",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("should remove MCP prefix from MCP slash commands", () => {
|
||||
const message = promptToClaude({
|
||||
sessionId: "test",
|
||||
prompt: [
|
||||
{
|
||||
type: "text",
|
||||
text: "/mcp:server:name args",
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(message.message.content).toEqual([
|
||||
{
|
||||
text: "/server:name (MCP) args",
|
||||
type: "text",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SDK behavior", () => {
|
||||
it.skipIf(!process.env.RUN_INTEGRATION_TESTS)(
|
||||
"query has a 'default' model",
|
||||
async () => {
|
||||
const q = query({ prompt: "hi" });
|
||||
const models = await q.supportedModels();
|
||||
const defaultModel = models.find((m) => m.value === "default");
|
||||
expect(defaultModel).toBeDefined();
|
||||
},
|
||||
10000,
|
||||
);
|
||||
});
|
||||
|
||||
103
src/tests/extract-lines.test.ts
Normal file
103
src/tests/extract-lines.test.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { extractLinesWithByteLimit } from "../utils.js";
|
||||
|
||||
describe("extractLinesWithByteLimit", () => {
|
||||
const simpleContent = "Line 1\nLine 2\nLine 3\nLine 4\nLine 5";
|
||||
|
||||
it("should extract all lines when under byte limit", () => {
|
||||
const result = extractLinesWithByteLimit(simpleContent, 1000);
|
||||
|
||||
expect(result.content).toBe(simpleContent);
|
||||
expect(result.wasLimited).toBe(false);
|
||||
expect(result.linesRead).toBe(5);
|
||||
});
|
||||
|
||||
it("should limit output when exceeding byte limit", () => {
|
||||
// Create content that will exceed byte limit
|
||||
const longLine = "x".repeat(100);
|
||||
const manyLines = Array(10).fill(longLine).join("\n");
|
||||
|
||||
const result = extractLinesWithByteLimit(manyLines, 250);
|
||||
|
||||
expect(result.wasLimited).toBe(true);
|
||||
expect(result.linesRead).toBe(2);
|
||||
});
|
||||
|
||||
it("should handle empty content", () => {
|
||||
const result = extractLinesWithByteLimit("", 1000);
|
||||
|
||||
expect(result.content).toBe("");
|
||||
expect(result.wasLimited).toBe(false);
|
||||
expect(result.linesRead).toBe(1); // We read the one empty line
|
||||
});
|
||||
|
||||
it("should handle single line file", () => {
|
||||
const singleLine = "This is a single line without newline";
|
||||
const result = extractLinesWithByteLimit(singleLine, 1000);
|
||||
|
||||
expect(result.content).toBe(singleLine);
|
||||
expect(result.wasLimited).toBe(false);
|
||||
expect(result.linesRead).toBe(1);
|
||||
});
|
||||
|
||||
it("should correctly count bytes with multi-byte characters", () => {
|
||||
const unicodeContent = "Hello 世界\n你好 World\nEmoji: 🌍\nNormal line";
|
||||
const result = extractLinesWithByteLimit(unicodeContent, 1000);
|
||||
|
||||
expect(result.content).toBe(unicodeContent);
|
||||
expect(result.linesRead).toBe(4);
|
||||
});
|
||||
|
||||
it("should stop at byte limit even with one more line available", () => {
|
||||
// Create lines where adding one more would exceed limit
|
||||
const line1 = "a".repeat(40);
|
||||
const line2 = "b".repeat(40);
|
||||
const line3 = "c".repeat(40);
|
||||
const content = `${line1}\n${line2}\n${line3}`;
|
||||
|
||||
const result = extractLinesWithByteLimit(content, 85);
|
||||
|
||||
expect(result.content).toBe(`${line1}\n${line2}\n`);
|
||||
expect(result.wasLimited).toBe(true);
|
||||
expect(result.linesRead).toBe(2);
|
||||
});
|
||||
|
||||
it("should read exactly to limit when possible", () => {
|
||||
const exactContent = "12345\n67890\n12345"; // 17 bytes total
|
||||
const result = extractLinesWithByteLimit(exactContent, 17);
|
||||
|
||||
expect(result.content).toBe(exactContent);
|
||||
expect(result.wasLimited).toBe(false);
|
||||
expect(result.linesRead).toBe(3);
|
||||
});
|
||||
|
||||
it("should handle Windows-style line endings", () => {
|
||||
const windowsContent = "Line 1\r\nLine 2\r\nLine 3";
|
||||
const result = extractLinesWithByteLimit(windowsContent, 1000);
|
||||
|
||||
// Note: split("\n") will keep the \r characters
|
||||
expect(result.content).toBe("Line 1\r\nLine 2\r\nLine 3");
|
||||
expect(result.linesRead).toBe(3);
|
||||
});
|
||||
|
||||
it("should handle very large files efficiently", () => {
|
||||
// Create a 100KB file
|
||||
const largeLine = "x".repeat(1000);
|
||||
const largeContent = Array(110).fill(largeLine).join("\n");
|
||||
|
||||
const result = extractLinesWithByteLimit(largeContent, 50000);
|
||||
|
||||
expect(result.wasLimited).toBe(true);
|
||||
});
|
||||
|
||||
it("should allow at least one line even if it exceeds byte limit", () => {
|
||||
const veryLongLine = "x".repeat(100000); // 100KB line
|
||||
const result = extractLinesWithByteLimit(veryLongLine, 50000);
|
||||
|
||||
// Should return the line even though it exceeds the byte limit
|
||||
// because we always allow at least one line if no lines have been added yet
|
||||
expect(result.content).toBe(veryLongLine);
|
||||
expect(result.linesRead).toBe(1);
|
||||
expect(result.wasLimited).toBe(false);
|
||||
});
|
||||
});
|
||||
239
src/tools.ts
239
src/tools.ts
@@ -1,10 +1,15 @@
|
||||
import { PlanEntry, ToolCallContent, ToolCallLocation, ToolKind } from "@agentclientprotocol/sdk";
|
||||
import { replaceAndCalculateLocation, SYSTEM_REMINDER, toolNames } from "./mcp-server.js";
|
||||
import { ToolResultBlockParam, WebSearchToolResultBlockParam } from "@anthropic-ai/sdk/resources";
|
||||
import {
|
||||
PlanEntry,
|
||||
ToolCallContent,
|
||||
ToolCallLocation,
|
||||
ToolKind,
|
||||
} from "@zed-industries/agent-client-protocol";
|
||||
import { replaceAndCalculateLocation, SYSTEM_REMINDER } from "./mcp-server.js";
|
||||
BetaBashCodeExecutionToolResultBlockParam,
|
||||
BetaCodeExecutionToolResultBlockParam,
|
||||
BetaRequestMCPToolResultBlockParam,
|
||||
BetaTextEditorCodeExecutionToolResultBlockParam,
|
||||
BetaWebFetchToolResultBlockParam,
|
||||
BetaWebSearchToolResultBlockParam,
|
||||
} from "@anthropic-ai/sdk/resources/beta.mjs";
|
||||
import { HookCallback } from "@anthropic-ai/claude-agent-sdk";
|
||||
|
||||
interface ToolInfo {
|
||||
title: string;
|
||||
@@ -67,7 +72,7 @@ export function toolInfoFromToolUse(
|
||||
};
|
||||
|
||||
case "Bash":
|
||||
case "mcp__acp__Bash":
|
||||
case toolNames.bash:
|
||||
return {
|
||||
title: input?.command ? "`" + input.command.replaceAll("`", "\\`") + "`" : "Terminal",
|
||||
kind: "execute",
|
||||
@@ -83,22 +88,22 @@ export function toolInfoFromToolUse(
|
||||
};
|
||||
|
||||
case "BashOutput":
|
||||
case "mcp__acp__BashOutput":
|
||||
case toolNames.bashOutput:
|
||||
return {
|
||||
title: "Tail Logs",
|
||||
kind: "execute",
|
||||
content: [],
|
||||
};
|
||||
|
||||
case "KillBash":
|
||||
case "mcp__acp__KillBash":
|
||||
case "KillShell":
|
||||
case toolNames.killShell:
|
||||
return {
|
||||
title: "Kill Process",
|
||||
kind: "execute",
|
||||
content: [],
|
||||
};
|
||||
|
||||
case "mcp__acp__read": {
|
||||
case toolNames.read: {
|
||||
let limit = "";
|
||||
if (input.limit) {
|
||||
limit =
|
||||
@@ -107,14 +112,16 @@ export function toolInfoFromToolUse(
|
||||
limit = " (from line " + (input.offset + 1) + ")";
|
||||
}
|
||||
return {
|
||||
title: "Read " + (input.abs_path ?? "File") + limit,
|
||||
title: "Read " + (input.file_path ?? "File") + limit,
|
||||
kind: "read",
|
||||
locations: [
|
||||
{
|
||||
path: input.abs_path,
|
||||
line: input.offset ?? 0,
|
||||
},
|
||||
],
|
||||
locations: input.file_path
|
||||
? [
|
||||
{
|
||||
path: input.file_path,
|
||||
line: input.offset ?? 0,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
content: [],
|
||||
};
|
||||
}
|
||||
@@ -135,9 +142,9 @@ export function toolInfoFromToolUse(
|
||||
locations: [],
|
||||
};
|
||||
|
||||
case "mcp__acp__edit":
|
||||
case toolNames.edit:
|
||||
case "Edit": {
|
||||
const path = input?.abs_path ?? input?.file_path;
|
||||
const path = input?.file_path ?? input?.file_path;
|
||||
let oldText = input.old_string ?? null;
|
||||
let newText = input.new_string ?? "";
|
||||
let affectedLines: number[] = [];
|
||||
@@ -181,66 +188,13 @@ export function toolInfoFromToolUse(
|
||||
};
|
||||
}
|
||||
|
||||
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");
|
||||
let affectedLines: number[] = [];
|
||||
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;
|
||||
affectedLines = newContent.lineNumbers;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
// 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,
|
||||
},
|
||||
],
|
||||
locations: input?.file_path
|
||||
? affectedLines.length > 0
|
||||
? affectedLines.map((line) => ({ line, path: input.file_path }))
|
||||
: [{ path: input.file_path }]
|
||||
: [],
|
||||
};
|
||||
}
|
||||
case "mcp__acp__write": {
|
||||
case toolNames.write: {
|
||||
let content: ToolCallContent[] = [];
|
||||
if (input && input.abs_path) {
|
||||
if (input && input.file_path) {
|
||||
content = [
|
||||
{
|
||||
type: "diff",
|
||||
path: input.abs_path,
|
||||
path: input.file_path,
|
||||
oldText: null,
|
||||
newText: input.content,
|
||||
},
|
||||
@@ -254,10 +208,10 @@ export function toolInfoFromToolUse(
|
||||
];
|
||||
}
|
||||
return {
|
||||
title: input?.abs_path ? `Write ${input.abs_path}` : "Write",
|
||||
title: input?.file_path ? `Write ${input.file_path}` : "Write",
|
||||
kind: "edit",
|
||||
content,
|
||||
locations: input?.abs_path ? [{ path: input.abs_path }] : [],
|
||||
locations: input?.file_path ? [{ path: input.file_path }] : [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -393,24 +347,30 @@ export function toolInfoFromToolUse(
|
||||
|
||||
case "TodoWrite":
|
||||
return {
|
||||
title: input?.todos
|
||||
title: Array.isArray(input?.todos)
|
||||
? `Update TODOs: ${input.todos.map((todo: any) => todo.content).join(", ")}`
|
||||
: "Update TODOs",
|
||||
kind: "think",
|
||||
content: [],
|
||||
};
|
||||
|
||||
case "exit_plan_mode":
|
||||
case "ExitPlanMode":
|
||||
return {
|
||||
title: "Exit Plan Mode",
|
||||
kind: "think",
|
||||
title: "Ready to code?",
|
||||
kind: "switch_mode",
|
||||
content:
|
||||
input && input.plan
|
||||
? [{ type: "content", content: { type: "text", text: input.plan } }]
|
||||
: [],
|
||||
};
|
||||
|
||||
case "Other":
|
||||
case "Other": {
|
||||
let output;
|
||||
try {
|
||||
output = JSON.stringify(input, null, 2);
|
||||
} catch {
|
||||
output = typeof input === "string" ? input : "{}";
|
||||
}
|
||||
return {
|
||||
title: name || "Unknown Tool",
|
||||
kind: "other",
|
||||
@@ -419,11 +379,12 @@ export function toolInfoFromToolUse(
|
||||
type: "content",
|
||||
content: {
|
||||
type: "text",
|
||||
text: `\`\`\`json\n${JSON.stringify(input, null, 2) || "{}"}\`\`\``,
|
||||
text: `\`\`\`json\n${output}\`\`\``,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
default:
|
||||
return {
|
||||
@@ -434,20 +395,21 @@ export function toolInfoFromToolUse(
|
||||
}
|
||||
}
|
||||
|
||||
type ToolResult = {
|
||||
type: "tool_result";
|
||||
content: any;
|
||||
tool_use_id: string;
|
||||
is_error: boolean;
|
||||
};
|
||||
|
||||
export function toolUpdateFromToolResult(
|
||||
toolResult: ToolResult,
|
||||
toolResult:
|
||||
| ToolResultBlockParam
|
||||
| BetaWebSearchToolResultBlockParam
|
||||
| BetaWebFetchToolResultBlockParam
|
||||
| WebSearchToolResultBlockParam
|
||||
| BetaCodeExecutionToolResultBlockParam
|
||||
| BetaBashCodeExecutionToolResultBlockParam
|
||||
| BetaTextEditorCodeExecutionToolResultBlockParam
|
||||
| BetaRequestMCPToolResultBlockParam,
|
||||
toolUse: any | undefined,
|
||||
): ToolUpdate {
|
||||
switch (toolUse?.name) {
|
||||
case "Read":
|
||||
case "mcp__acp__read":
|
||||
case toolNames.read:
|
||||
if (Array.isArray(toolResult.content) && toolResult.content.length > 0) {
|
||||
return {
|
||||
content: toolResult.content.map((content: any) => ({
|
||||
@@ -476,22 +438,28 @@ export function toolUpdateFromToolResult(
|
||||
}
|
||||
return {};
|
||||
|
||||
case "mcp__acp__Bash":
|
||||
case "mcp__acp__edit":
|
||||
case toolNames.bash:
|
||||
case "edit":
|
||||
case "Edit":
|
||||
case "mcp__acp__multi-edit":
|
||||
case "multi-edit":
|
||||
case "MultiEdit":
|
||||
case "mcp__acp__write":
|
||||
case toolNames.edit:
|
||||
case toolNames.write:
|
||||
case "Write": {
|
||||
if (toolResult.is_error && toolResult.content?.length > 0) {
|
||||
if (
|
||||
"is_error" in toolResult &&
|
||||
toolResult.is_error &&
|
||||
toolResult.content &&
|
||||
toolResult.content.length > 0
|
||||
) {
|
||||
// Only return errors
|
||||
return toAcpContentUpdate(toolResult.content);
|
||||
return toAcpContentUpdate(toolResult.content, true);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
case "ExitPlanMode": {
|
||||
return { title: "Exited Plan Mode" };
|
||||
}
|
||||
|
||||
case "Task":
|
||||
case "NotebookEdit":
|
||||
case "NotebookRead":
|
||||
@@ -507,17 +475,29 @@ export function toolUpdateFromToolResult(
|
||||
case "WebSearch":
|
||||
case "Other":
|
||||
default: {
|
||||
return toAcpContentUpdate(toolResult.content);
|
||||
return toAcpContentUpdate(
|
||||
toolResult.content,
|
||||
"is_error" in toolResult ? toolResult.is_error : false,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function toAcpContentUpdate(content: any): { content?: ToolCallContent[] } {
|
||||
function toAcpContentUpdate(
|
||||
content: any,
|
||||
isError: boolean = false,
|
||||
): { content?: ToolCallContent[] } {
|
||||
if (Array.isArray(content) && content.length > 0) {
|
||||
return {
|
||||
content: content.map((content: any) => ({
|
||||
type: "content",
|
||||
content,
|
||||
content:
|
||||
isError && content.type === "text"
|
||||
? {
|
||||
...content,
|
||||
text: `\`\`\`\n${content.text}\n\`\`\``,
|
||||
}
|
||||
: content,
|
||||
})),
|
||||
};
|
||||
} else if (typeof content === "string" && content.length > 0) {
|
||||
@@ -527,7 +507,7 @@ function toAcpContentUpdate(content: any): { content?: ToolCallContent[] } {
|
||||
type: "content",
|
||||
content: {
|
||||
type: "text",
|
||||
text: content,
|
||||
text: isError ? `\`\`\`\n${content}\n\`\`\`` : content,
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -536,7 +516,7 @@ function toAcpContentUpdate(content: any): { content?: ToolCallContent[] } {
|
||||
return {};
|
||||
}
|
||||
|
||||
type ClaudePlanEntry = {
|
||||
export type ClaudePlanEntry = {
|
||||
content: string;
|
||||
status: "pending" | "in_progress" | "completed";
|
||||
activeForm: string;
|
||||
@@ -559,3 +539,50 @@ export function markdownEscape(text: string): string {
|
||||
}
|
||||
return escape + "\n" + text + (text.endsWith("\n") ? "" : "\n") + escape;
|
||||
}
|
||||
|
||||
/* A global variable to store callbacks that should be executed when receiving hooks from Claude Code */
|
||||
const toolUseCallbacks: {
|
||||
[toolUseId: string]: {
|
||||
onPostToolUseHook?: (
|
||||
toolUseID: string,
|
||||
toolInput: unknown,
|
||||
toolResponse: unknown,
|
||||
) => Promise<void>;
|
||||
};
|
||||
} = {};
|
||||
|
||||
/* Setup callbacks that will be called when receiving hooks from Claude Code */
|
||||
export const registerHookCallback = (
|
||||
toolUseID: string,
|
||||
{
|
||||
onPostToolUseHook,
|
||||
}: {
|
||||
onPostToolUseHook?: (
|
||||
toolUseID: string,
|
||||
toolInput: unknown,
|
||||
toolResponse: unknown,
|
||||
) => Promise<void>;
|
||||
},
|
||||
) => {
|
||||
toolUseCallbacks[toolUseID] = {
|
||||
onPostToolUseHook,
|
||||
};
|
||||
};
|
||||
|
||||
/* A callback for Claude Code that is called when receiving a PostToolUse hook */
|
||||
export const postToolUseHook: HookCallback = async (
|
||||
input: any,
|
||||
toolUseID: string | undefined,
|
||||
): Promise<{ continue: boolean }> => {
|
||||
if (input.hook_event_name === "PostToolUse" && toolUseID) {
|
||||
const onPostToolUseHook = toolUseCallbacks[toolUseID]?.onPostToolUseHook;
|
||||
if (onPostToolUseHook) {
|
||||
await onPostToolUseHook(toolUseID, input.tool_input, input.tool_response);
|
||||
delete toolUseCallbacks[toolUseID]; // Cleanup after execution
|
||||
} else {
|
||||
console.error(`No onPostToolUseHook found for tool use ID: ${toolUseID}`);
|
||||
delete toolUseCallbacks[toolUseID];
|
||||
}
|
||||
}
|
||||
return { continue: true };
|
||||
};
|
||||
|
||||
114
src/utils.ts
114
src/utils.ts
@@ -2,6 +2,8 @@
|
||||
|
||||
import { Readable, Writable } from "node:stream";
|
||||
import { WritableStream, ReadableStream } from "node:stream/web";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { platform } from "node:os";
|
||||
|
||||
// Useful for bridging push-based and async-iterator-based code.
|
||||
export class Pushable<T> implements AsyncIterable<T> {
|
||||
@@ -74,9 +76,119 @@ export function nodeToWebReadable(nodeStream: Readable): ReadableStream<Uint8Arr
|
||||
}
|
||||
|
||||
export function unreachable(value: never): never {
|
||||
throw new Error(`Unexpected case: ${value}`);
|
||||
let valueAsString;
|
||||
try {
|
||||
valueAsString = JSON.stringify(value);
|
||||
} catch {
|
||||
valueAsString = value;
|
||||
}
|
||||
throw new Error(`Unexpected case: ${valueAsString}`);
|
||||
}
|
||||
|
||||
export function sleep(time: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, time));
|
||||
}
|
||||
|
||||
interface ManagedSettings {
|
||||
permissions?: {
|
||||
allow?: string[];
|
||||
deny?: string[];
|
||||
};
|
||||
env?: Record<string, string>;
|
||||
}
|
||||
|
||||
// Following the rules in https://docs.anthropic.com/en/docs/claude-code/settings#settings-files
|
||||
// This can be removed once the SDK supports it natively.
|
||||
function getManagedSettingsPath(): string {
|
||||
const os = platform();
|
||||
switch (os) {
|
||||
case "darwin":
|
||||
return "/Library/Application Support/ClaudeCode/managed-settings.json";
|
||||
case "linux": // including WSL
|
||||
return "/etc/claude-code/managed-settings.json";
|
||||
case "win32":
|
||||
return "C:\\ProgramData\\ClaudeCode\\managed-settings.json";
|
||||
default:
|
||||
return "/etc/claude-code/managed-settings.json";
|
||||
}
|
||||
}
|
||||
|
||||
export function loadManagedSettings(): ManagedSettings | null {
|
||||
try {
|
||||
return JSON.parse(readFileSync(getManagedSettingsPath(), "utf8")) as ManagedSettings;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function applyEnvironmentSettings(settings: ManagedSettings): void {
|
||||
if (settings.env) {
|
||||
for (const [key, value] of Object.entries(settings.env)) {
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface ExtractLinesResult {
|
||||
content: string;
|
||||
wasLimited: boolean;
|
||||
linesRead: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts lines from file content with byte limit enforcement.
|
||||
*
|
||||
* @param fullContent - The complete file content
|
||||
* @param maxContentLength - Maximum number of UTF-16 Code Units to return
|
||||
* @returns Object containing extracted content and metadata
|
||||
*/
|
||||
export function extractLinesWithByteLimit(
|
||||
fullContent: string,
|
||||
maxContentLength: number,
|
||||
): ExtractLinesResult {
|
||||
if (fullContent === "") {
|
||||
return {
|
||||
content: "",
|
||||
wasLimited: false,
|
||||
linesRead: 1,
|
||||
};
|
||||
}
|
||||
|
||||
let linesSeen = 0;
|
||||
let index = 0;
|
||||
linesSeen = 0;
|
||||
|
||||
let contentLength = 0;
|
||||
let wasLimited = false;
|
||||
|
||||
while (true) {
|
||||
const nextIndex = fullContent.indexOf("\n", index);
|
||||
|
||||
if (nextIndex < 0) {
|
||||
// Last line in file (no trailing newline)
|
||||
if (linesSeen > 0 && fullContent.length > maxContentLength) {
|
||||
wasLimited = true;
|
||||
break;
|
||||
}
|
||||
linesSeen += 1;
|
||||
contentLength = fullContent.length;
|
||||
break;
|
||||
} else {
|
||||
// Line with newline - include up to the newline
|
||||
const newContentLength = nextIndex + 1;
|
||||
if (linesSeen > 0 && newContentLength > maxContentLength) {
|
||||
wasLimited = true;
|
||||
break;
|
||||
}
|
||||
linesSeen += 1;
|
||||
contentLength = newContentLength;
|
||||
index = newContentLength;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
content: fullContent.slice(0, contentLength),
|
||||
wasLimited,
|
||||
linesRead: linesSeen,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user