mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-10-27 12:40:32 +00:00
Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aa7364d49b | ||
|
|
e22d1ed52c | ||
|
|
ecfd36afa6 | ||
|
|
7bd163818b | ||
|
|
491efe32e8 | ||
|
|
ee17e45030 | ||
|
|
468c3522a0 | ||
|
|
84d64b2762 | ||
|
|
9ff7919ea4 | ||
|
|
d58c2dd45d | ||
|
|
55532ecc57 | ||
|
|
f4711e96b0 | ||
|
|
f691024350 | ||
|
|
d6c91b1ec1 | ||
|
|
0289dd9de7 | ||
|
|
5cd3ac91e8 |
@@ -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"]
|
||||
}
|
||||
9
.github/workflows/publish.yml
vendored
9
.github/workflows/publish.yml
vendored
@@ -6,6 +6,7 @@ on:
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
environment: release # Optional: for enhanced security
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
@@ -16,8 +17,8 @@ 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
|
||||
|
||||
14
CHANGELOG.md
Normal file
14
CHANGELOG.md
Normal file
@@ -0,0 +1,14 @@
|
||||
# Changelog
|
||||
|
||||
## 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
|
||||
@@ -33,8 +33,9 @@ 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)
|
||||
- [marimo notebook](https://github.com/marimo-team/marimo)
|
||||
|
||||
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
|
||||
|
||||
|
||||
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,
|
||||
];
|
||||
1096
package-lock.json
generated
1096
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
51
package.json
51
package.json
@@ -6,7 +6,7 @@
|
||||
"bin": {
|
||||
"claude-code-acp": "./dist/index.js"
|
||||
},
|
||||
"version": "0.3.0",
|
||||
"version": "0.4.2",
|
||||
"description": "An ACP-compatible coding agent powered by the Claude Code SDK (TypeScript)",
|
||||
"main": "dist/index.js",
|
||||
"type": "module",
|
||||
@@ -40,30 +40,39 @@
|
||||
"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",
|
||||
"@zed-industries/agent-client-protocol": "0.2.0-alpha.7",
|
||||
"diff": "^8.0.2",
|
||||
"express": "^5.1.0",
|
||||
"minimist": "^1.2.8",
|
||||
"uuid": "11.1.0"
|
||||
"@anthropic-ai/claude-code": "1.0.113",
|
||||
"@modelcontextprotocol/sdk": "1.18.0",
|
||||
"@zed-industries/agent-client-protocol": "0.3.1",
|
||||
"diff": "8.0.2",
|
||||
"express": "5.1.0",
|
||||
"minimist": "1.2.8",
|
||||
"uuid": "13.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^5.0.3",
|
||||
"@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"
|
||||
"@types/express": "5.0.3",
|
||||
"@types/minimist": "1.2.5",
|
||||
"@types/node": "24.4.0",
|
||||
"@types/uuid": "10.0.0",
|
||||
"@typescript-eslint/eslint-plugin": "8.43.0",
|
||||
"@typescript-eslint/parser": "8.43.0",
|
||||
"@vitest/ui": "3.2.4",
|
||||
"eslint": "9.35.0",
|
||||
"eslint-config-prettier": "10.1.8",
|
||||
"globals": "16.4.0",
|
||||
"prettier": "3.6.2",
|
||||
"ts-node": "10.9.2",
|
||||
"typescript": "5.9.2",
|
||||
"vitest": "3.2.4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,8 @@ import {
|
||||
ReadTextFileRequest,
|
||||
ReadTextFileResponse,
|
||||
RequestError,
|
||||
SetSessionModeRequest,
|
||||
SetSessionModeResponse,
|
||||
TerminalHandle,
|
||||
TerminalOutputResponse,
|
||||
WriteTextFileRequest,
|
||||
@@ -22,6 +24,7 @@ import {
|
||||
import {
|
||||
McpServerConfig,
|
||||
Options,
|
||||
PermissionMode,
|
||||
Query,
|
||||
query,
|
||||
SDKAssistantMessage,
|
||||
@@ -33,7 +36,7 @@ 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 { createMcpServer, toolNames } from "./mcp-server.js";
|
||||
import { AddressInfo } from "node:net";
|
||||
import { toolInfoFromToolUse, planEntries, toolUpdateFromToolResult } from "./tools.js";
|
||||
|
||||
@@ -41,6 +44,7 @@ type Session = {
|
||||
query: Query;
|
||||
input: Pushable<SDKUserMessage>;
|
||||
cancelled: boolean;
|
||||
permissionMode: PermissionMode;
|
||||
};
|
||||
|
||||
type BackgroundTerminal =
|
||||
@@ -148,7 +152,7 @@ export class ClaudeAcpAgent implements Agent {
|
||||
const options: Options = {
|
||||
cwd: params.cwd,
|
||||
mcpServers,
|
||||
permissionPromptToolName: "mcp__acp__permission",
|
||||
permissionPromptToolName: toolNames.permission,
|
||||
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.
|
||||
@@ -158,15 +162,15 @@ export class ClaudeAcpAgent implements Agent {
|
||||
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");
|
||||
allowedTools.push(toolNames.write);
|
||||
disallowedTools.push("Write", "Edit", "MultiEdit");
|
||||
}
|
||||
if (this.clientCapabilities?.terminal) {
|
||||
allowedTools.push("mcp__acp__BashOutput", "mcp__acp__KillBash");
|
||||
allowedTools.push(toolNames.bashOutput, toolNames.killBash);
|
||||
disallowedTools.push("Bash", "BashOutput", "KillBash");
|
||||
}
|
||||
|
||||
@@ -185,6 +189,7 @@ export class ClaudeAcpAgent implements Agent {
|
||||
query: q,
|
||||
input: input,
|
||||
cancelled: false,
|
||||
permissionMode: "default",
|
||||
};
|
||||
|
||||
getAvailableSlashCommands(q).then((availableCommands) => {
|
||||
@@ -199,6 +204,31 @@ export class ClaudeAcpAgent implements Agent {
|
||||
|
||||
return {
|
||||
sessionId,
|
||||
modes: {
|
||||
currentModeId: "default",
|
||||
availableModes: [
|
||||
{
|
||||
id: "default",
|
||||
name: "Always Ask",
|
||||
description: "Prompts for permission on first use of each tool",
|
||||
},
|
||||
{
|
||||
id: "acceptEdits",
|
||||
name: "Accept Edits",
|
||||
description: "Automatically accepts file edit permissions for the session",
|
||||
},
|
||||
{
|
||||
id: "bypassPermissions",
|
||||
name: "Bypass Permissions",
|
||||
description: "Skips all permission prompts",
|
||||
},
|
||||
{
|
||||
id: "plan",
|
||||
name: "Plan Mode",
|
||||
description: "Claude can analyze but not modify files or execute commands",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -254,6 +284,24 @@ export class ClaudeAcpAgent implements Agent {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
if (
|
||||
message.message.model === "<synthetic>" &&
|
||||
message.message.content.length === 1 &&
|
||||
@@ -272,7 +320,7 @@ export class ClaudeAcpAgent implements Agent {
|
||||
break;
|
||||
}
|
||||
default:
|
||||
unreachable(message);
|
||||
unreachable(message as never);
|
||||
}
|
||||
}
|
||||
throw new Error("Session did not end in result");
|
||||
@@ -286,6 +334,29 @@ export class ClaudeAcpAgent implements Agent {
|
||||
await this.sessions[params.sessionId].query.interrupt();
|
||||
}
|
||||
|
||||
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 "plan":
|
||||
this.sessions[params.sessionId].permissionMode = params.modeId;
|
||||
await this.sessions[params.sessionId].query.setPermissionMode(params.modeId);
|
||||
return {};
|
||||
case "bypassPermissions":
|
||||
// For some reason, the SDK doesn't support setting the mode to `bypassPermissions`
|
||||
// so we do it ourselves
|
||||
this.sessions[params.sessionId].permissionMode = "bypassPermissions";
|
||||
await this.sessions[params.sessionId].query.setPermissionMode("acceptEdits");
|
||||
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) {
|
||||
@@ -308,7 +379,6 @@ async function getAvailableSlashCommands(query: Query): Promise<AvailableCommand
|
||||
"bashes", // Modal
|
||||
"bug", // Modal
|
||||
"clear", // Escape Codes
|
||||
"compact", // Not supported via SDK?
|
||||
"config", // Modal
|
||||
"context", // Escape Codes
|
||||
"cost", // Escape Codes
|
||||
@@ -337,14 +407,14 @@ async function getAvailableSlashCommands(query: Query): Promise<AvailableCommand
|
||||
"todos", // Escape Codes
|
||||
"vim", // Not needed
|
||||
];
|
||||
//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;
|
||||
return {
|
||||
name: command.name,
|
||||
// @ts-expect-error type in the ts interface
|
||||
description: command.description || "",
|
||||
input,
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -8,7 +8,8 @@ import { ClaudeAcpAgent } from "./acp-agent.js";
|
||||
import { ClientCapabilities, TerminalOutputResponse } from "@zed-industries/agent-client-protocol";
|
||||
import * as diff from "diff";
|
||||
|
||||
import { sleep, unreachable } from "./utils.js";
|
||||
import { sleep, unreachable, extractLinesWithByteLimit } from "./utils.js";
|
||||
import { PermissionResult } from "@anthropic-ai/claude-code";
|
||||
|
||||
export const SYSTEM_REMINDER = `
|
||||
|
||||
@@ -16,6 +17,33 @@ 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: 1000 };
|
||||
|
||||
const unqualifiedToolNames = {
|
||||
read: "read",
|
||||
edit: "edit",
|
||||
write: "write",
|
||||
multiEdit: "multi-edit",
|
||||
bash: "Bash",
|
||||
killBash: "KillBash",
|
||||
bashOutput: "BashOutput",
|
||||
permission: "permission",
|
||||
};
|
||||
|
||||
const SERVER_PREFIX = "mcp__acp__";
|
||||
export const toolNames = {
|
||||
read: SERVER_PREFIX + unqualifiedToolNames.read,
|
||||
edit: SERVER_PREFIX + unqualifiedToolNames.edit,
|
||||
write: SERVER_PREFIX + unqualifiedToolNames.write,
|
||||
multiEdit: SERVER_PREFIX + unqualifiedToolNames.multiEdit,
|
||||
bash: SERVER_PREFIX + unqualifiedToolNames.bash,
|
||||
killBash: SERVER_PREFIX + unqualifiedToolNames.killBash,
|
||||
bashOutput: SERVER_PREFIX + unqualifiedToolNames.bashOutput,
|
||||
permission: SERVER_PREFIX + unqualifiedToolNames.permission,
|
||||
};
|
||||
|
||||
const editToolNames = [toolNames.edit, toolNames.multiEdit, toolNames.write];
|
||||
|
||||
export function createMcpServer(
|
||||
agent: ClaudeAcpAgent,
|
||||
sessionId: string,
|
||||
@@ -29,21 +57,30 @@ export function createMcpServer(
|
||||
|
||||
if (clientCapabilities?.fs?.readTextFile) {
|
||||
server.registerTool(
|
||||
"read",
|
||||
unqualifiedToolNames.read,
|
||||
{
|
||||
title: "Read",
|
||||
description: `Reads the content of the given file in the project.
|
||||
|
||||
Never attempt to read a path that hasn't been previously mentioned.
|
||||
|
||||
In sessions with mcp__acp__read always use it instead of Read as it contains the most up-to-date contents.`,
|
||||
In sessions with ${toolNames.read} always use it instead of Read as it contains the most up-to-date contents.`,
|
||||
inputSchema: {
|
||||
abs_path: z.string().describe("The absolute path to the file to read."),
|
||||
offset: z
|
||||
.number()
|
||||
.optional()
|
||||
.describe("Which line to start reading from. Omit to start from the beginning."),
|
||||
limit: z.number().optional().describe("How many lines to read. Omit for the whole file."),
|
||||
.default(0)
|
||||
.describe(
|
||||
"Which line to start reading from (0-based). Omit to start from the beginning.",
|
||||
),
|
||||
linesToRead: z
|
||||
.number()
|
||||
.optional()
|
||||
.default(defaults.linesToRead)
|
||||
.describe(
|
||||
`How many lines to read. Omit for ${defaults.linesToRead}. (Reading files that are too large can fill up the context window unnecessarily, so be thoughtful about how much you read at a time.)`,
|
||||
),
|
||||
},
|
||||
annotations: {
|
||||
title: "Read file",
|
||||
@@ -66,18 +103,43 @@ 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,
|
||||
line: input.offset,
|
||||
});
|
||||
|
||||
// Extract lines with byte limit enforcement
|
||||
const result = extractLinesWithByteLimit(
|
||||
content.content,
|
||||
input.offset ?? 0,
|
||||
input.linesToRead,
|
||||
defaults.maxFileSize,
|
||||
);
|
||||
|
||||
// Construct informative message about what was read
|
||||
let readInfo = "";
|
||||
if (input.offset > 0 || 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 ?? 0) + 1}-${result.actualEndLine}. `;
|
||||
}
|
||||
|
||||
if (result.wasLimited) {
|
||||
readInfo += `Continue with offset=${result.actualEndLine}.`;
|
||||
}
|
||||
|
||||
readInfo += "</file-read-info>";
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: content.content + SYSTEM_REMINDER,
|
||||
text: result.content + readInfo + SYSTEM_REMINDER,
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -97,12 +159,12 @@ 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.
|
||||
|
||||
In sessions with mcp__acp__write always use it instead of Write as it will
|
||||
In sessions with ${toolNames.write} always use it instead of Write as it will
|
||||
allow the user to conveniently review changes.`,
|
||||
inputSchema: {
|
||||
abs_path: z.string().describe("The absolute path to the file to write"),
|
||||
@@ -152,12 +214,12 @@ allow the user to conveniently review changes.`,
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"edit",
|
||||
unqualifiedToolNames.edit,
|
||||
{
|
||||
title: "Edit",
|
||||
description: `Edit a file.
|
||||
|
||||
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:
|
||||
@@ -229,12 +291,12 @@ File editing instructions:
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"multi-edit",
|
||||
unqualifiedToolNames.multiEdit,
|
||||
{
|
||||
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
|
||||
In sessions with ${toolNames.multiEdit} always use it instead of MultiEdit as it will
|
||||
allow the user to conveniently review changes.
|
||||
|
||||
File editing instructions:
|
||||
@@ -319,7 +381,7 @@ File editing instructions:
|
||||
|
||||
if (agent.clientCapabilities?.terminal) {
|
||||
server.registerTool(
|
||||
"Bash",
|
||||
unqualifiedToolNames.bash,
|
||||
{
|
||||
title: "Bash",
|
||||
description: "Executes a bash command",
|
||||
@@ -333,7 +395,7 @@ File editing instructions:
|
||||
.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.",
|
||||
`When set to true, the command is started 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.killBash}\` tool to stop it early.`,
|
||||
),
|
||||
},
|
||||
},
|
||||
@@ -454,7 +516,7 @@ File editing instructions:
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"BashOutput",
|
||||
unqualifiedToolNames.bashOutput,
|
||||
{
|
||||
title: "BashOutput",
|
||||
description:
|
||||
@@ -462,7 +524,7 @@ File editing instructions:
|
||||
inputSchema: {
|
||||
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) => {
|
||||
@@ -505,14 +567,14 @@ File editing instructions:
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"KillBash",
|
||||
unqualifiedToolNames.killBash,
|
||||
{
|
||||
title: "KillBash",
|
||||
description: "Stops a background command by its id",
|
||||
inputSchema: {
|
||||
id: z
|
||||
.string()
|
||||
.describe("The id of the background bash command as returned by `mcp__acp__Bash`"),
|
||||
.describe(`The id of the background bash command as returned by \`${toolNames.bash}\``),
|
||||
},
|
||||
},
|
||||
async (input) => {
|
||||
@@ -565,7 +627,7 @@ File editing instructions:
|
||||
|
||||
const alwaysAllowedTools: { [key: string]: boolean } = {};
|
||||
server.registerTool(
|
||||
"permission",
|
||||
unqualifiedToolNames.permission,
|
||||
{
|
||||
title: "Permission Tool",
|
||||
description: "Used to request tool permissions",
|
||||
@@ -576,42 +638,37 @@ File editing instructions:
|
||||
},
|
||||
},
|
||||
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 result = await canUseTool(input);
|
||||
|
||||
return {
|
||||
content: [{ type: "text", text: JSON.stringify(result) }],
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
async function canUseTool(input: {
|
||||
tool_use_id?: string;
|
||||
tool_name: string;
|
||||
input?: any;
|
||||
}): Promise<PermissionResult> {
|
||||
const session = agent.sessions[sessionId];
|
||||
if (!session) {
|
||||
return {
|
||||
behavior: "deny",
|
||||
message: "Session not found",
|
||||
};
|
||||
}
|
||||
|
||||
if (input.tool_name === "ExitPlanMode") {
|
||||
const response = await agent.client.requestPermission({
|
||||
options: [
|
||||
{
|
||||
kind: "allow_always",
|
||||
name: "Always Allow",
|
||||
optionId: "allow_always",
|
||||
name: "Yes, and auto-accept edits",
|
||||
optionId: "acceptEdits",
|
||||
},
|
||||
{ kind: "allow_once", name: "Allow", optionId: "allow" },
|
||||
{ kind: "reject_once", name: "Reject", optionId: "reject" },
|
||||
{ kind: "allow_once", name: "Yes, and manually approve edits", optionId: "default" },
|
||||
{ kind: "reject_once", name: "No, keep planning", optionId: "plan" },
|
||||
],
|
||||
sessionId,
|
||||
toolCall: {
|
||||
@@ -619,39 +676,80 @@ File editing instructions:
|
||||
rawInput: input.input,
|
||||
},
|
||||
});
|
||||
|
||||
if (
|
||||
response.outcome?.outcome === "selected" &&
|
||||
(response.outcome.optionId === "allow" || response.outcome.optionId === "allow_always")
|
||||
(response.outcome.optionId === "default" || response.outcome.optionId === "acceptEdits")
|
||||
) {
|
||||
if (response.outcome.optionId === "allow_always") {
|
||||
alwaysAllowedTools[input.tool_name] = true;
|
||||
}
|
||||
session.permissionMode = response.outcome.optionId;
|
||||
await agent.client.sessionUpdate({
|
||||
sessionId,
|
||||
update: {
|
||||
sessionUpdate: "current_mode_update",
|
||||
currentModeId: response.outcome.optionId,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: JSON.stringify({
|
||||
behavior: "allow",
|
||||
updatedInput: input.input,
|
||||
}),
|
||||
},
|
||||
behavior: "allow",
|
||||
updatedInput: input.input,
|
||||
updatedPermissions: [
|
||||
{ type: "setMode", mode: response.outcome.optionId, destination: "session" },
|
||||
],
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: JSON.stringify({
|
||||
behavior: "deny",
|
||||
message: "User refused permission to run tool",
|
||||
}),
|
||||
},
|
||||
],
|
||||
behavior: "deny",
|
||||
message: "User rejected request to exit plan mode.",
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
session.permissionMode === "bypassPermissions" ||
|
||||
(session.permissionMode === "acceptEdits" && editToolNames.includes(input.tool_name)) ||
|
||||
alwaysAllowedTools[input.tool_name]
|
||||
) {
|
||||
return {
|
||||
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 {
|
||||
behavior: "allow",
|
||||
updatedInput: input.input,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
behavior: "deny",
|
||||
message: "User refused permission to run tool",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
|
||||
@@ -91,7 +91,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> {
|
||||
@@ -191,6 +191,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", () => {
|
||||
@@ -778,7 +822,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",
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
174
src/tests/extract-lines.test.ts
Normal file
174
src/tests/extract-lines.test.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
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, 0, 5, 1000);
|
||||
|
||||
expect(result.content).toBe(simpleContent);
|
||||
expect(result.actualEndLine).toBe(4);
|
||||
expect(result.wasLimited).toBe(false);
|
||||
expect(result.linesRead).toBe(5);
|
||||
});
|
||||
|
||||
it("should extract partial lines with offset", () => {
|
||||
const result = extractLinesWithByteLimit(simpleContent, 2, 2, 1000);
|
||||
|
||||
expect(result.content).toBe("Line 3\nLine 4");
|
||||
expect(result.actualEndLine).toBe(3);
|
||||
expect(result.wasLimited).toBe(false);
|
||||
expect(result.linesRead).toBe(2);
|
||||
});
|
||||
|
||||
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, 0, 10, 250);
|
||||
|
||||
expect(result.wasLimited).toBe(true);
|
||||
expect(result.actualEndLine).toBe(1); // Should only get 2 lines
|
||||
expect(result.linesRead).toBe(2);
|
||||
});
|
||||
|
||||
it("should handle offset beyond file length", () => {
|
||||
const result = extractLinesWithByteLimit(simpleContent, 10, 5, 1000);
|
||||
|
||||
expect(result.content).toBe("");
|
||||
expect(result.actualEndLine).toBe(10);
|
||||
expect(result.wasLimited).toBe(false);
|
||||
expect(result.linesRead).toBe(0);
|
||||
});
|
||||
|
||||
it("should handle empty content", () => {
|
||||
const result = extractLinesWithByteLimit("", 0, 10, 1000);
|
||||
|
||||
expect(result.content).toBe("");
|
||||
expect(result.actualEndLine).toBe(0); // Empty string splits to [""] and we process that one empty line
|
||||
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, 0, 10, 1000);
|
||||
|
||||
expect(result.content).toBe(singleLine);
|
||||
expect(result.actualEndLine).toBe(0);
|
||||
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, 0, 10, 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, 0, 3, 85);
|
||||
|
||||
expect(result.content).toBe(`${line1}\n${line2}`);
|
||||
expect(result.actualEndLine).toBe(1);
|
||||
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, 0, 3, 17);
|
||||
|
||||
expect(result.content).toBe(exactContent);
|
||||
expect(result.actualEndLine).toBe(2);
|
||||
expect(result.wasLimited).toBe(false);
|
||||
expect(result.linesRead).toBe(3);
|
||||
});
|
||||
|
||||
it("should handle reading from middle to end", () => {
|
||||
const result = extractLinesWithByteLimit(simpleContent, 3, 100, 1000);
|
||||
|
||||
expect(result.content).toBe("Line 4\nLine 5");
|
||||
expect(result.actualEndLine).toBe(4);
|
||||
expect(result.wasLimited).toBe(false);
|
||||
expect(result.linesRead).toBe(2);
|
||||
});
|
||||
|
||||
it("should handle large content with typical parameters", () => {
|
||||
const longContent = Array(2000).fill("Line content here").join("\n");
|
||||
const result = extractLinesWithByteLimit(longContent, 0, 1000, 50000);
|
||||
|
||||
expect(result.actualEndLine).toBeLessThanOrEqual(1000); // Should be limited by line count
|
||||
});
|
||||
|
||||
it("should provide correct data for partial read at start", () => {
|
||||
const result = extractLinesWithByteLimit(simpleContent, 0, 2, 1000);
|
||||
|
||||
expect(result.actualEndLine).toBe(1);
|
||||
expect(result.linesRead).toBe(2);
|
||||
});
|
||||
|
||||
it("should provide correct data for partial read with offset", () => {
|
||||
const result = extractLinesWithByteLimit(simpleContent, 1, 2, 1000);
|
||||
|
||||
expect(result.actualEndLine).toBe(2);
|
||||
expect(result.linesRead).toBe(2);
|
||||
});
|
||||
|
||||
it("should not add newline after last line", () => {
|
||||
const result = extractLinesWithByteLimit("Line 1\nLine 2\nLine 3", 2, 1, 1000);
|
||||
|
||||
expect(result.content).toBe("Line 3"); // No trailing newline
|
||||
});
|
||||
|
||||
it("should handle Windows-style line endings", () => {
|
||||
const windowsContent = "Line 1\r\nLine 2\r\nLine 3";
|
||||
const result = extractLinesWithByteLimit(windowsContent, 0, 3, 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, 0, 200, 50000);
|
||||
|
||||
expect(result.wasLimited).toBe(true);
|
||||
expect(result.actualEndLine).toBeLessThan(50); // Should stop well before 200 lines
|
||||
});
|
||||
|
||||
it("should handle line limit of 0", () => {
|
||||
const result = extractLinesWithByteLimit(simpleContent, 0, 0, 1000);
|
||||
|
||||
expect(result.content).toBe("");
|
||||
expect(result.actualEndLine).toBe(0);
|
||||
expect(result.linesRead).toBe(0);
|
||||
expect(result.wasLimited).toBe(false);
|
||||
});
|
||||
|
||||
it("should allow at least one line even if it exceeds byte limit", () => {
|
||||
const veryLongLine = "x".repeat(100000); // 100KB line
|
||||
const result = extractLinesWithByteLimit(veryLongLine, 0, 1, 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.actualEndLine).toBe(0);
|
||||
expect(result.linesRead).toBe(1);
|
||||
expect(result.wasLimited).toBe(false);
|
||||
});
|
||||
});
|
||||
55
src/tools.ts
55
src/tools.ts
@@ -4,7 +4,7 @@ import {
|
||||
ToolCallLocation,
|
||||
ToolKind,
|
||||
} from "@zed-industries/agent-client-protocol";
|
||||
import { replaceAndCalculateLocation, SYSTEM_REMINDER } from "./mcp-server.js";
|
||||
import { replaceAndCalculateLocation, SYSTEM_REMINDER, toolNames } from "./mcp-server.js";
|
||||
|
||||
interface ToolInfo {
|
||||
title: string;
|
||||
@@ -67,7 +67,7 @@ export function toolInfoFromToolUse(
|
||||
};
|
||||
|
||||
case "Bash":
|
||||
case "mcp__acp__Bash":
|
||||
case toolNames.bash:
|
||||
return {
|
||||
title: input?.command ? "`" + input.command.replaceAll("`", "\\`") + "`" : "Terminal",
|
||||
kind: "execute",
|
||||
@@ -83,7 +83,7 @@ export function toolInfoFromToolUse(
|
||||
};
|
||||
|
||||
case "BashOutput":
|
||||
case "mcp__acp__BashOutput":
|
||||
case toolNames.bashOutput:
|
||||
return {
|
||||
title: "Tail Logs",
|
||||
kind: "execute",
|
||||
@@ -91,14 +91,14 @@ export function toolInfoFromToolUse(
|
||||
};
|
||||
|
||||
case "KillBash":
|
||||
case "mcp__acp__KillBash":
|
||||
case toolNames.killBash:
|
||||
return {
|
||||
title: "Kill Process",
|
||||
kind: "execute",
|
||||
content: [],
|
||||
};
|
||||
|
||||
case "mcp__acp__read": {
|
||||
case toolNames.read: {
|
||||
let limit = "";
|
||||
if (input.limit) {
|
||||
limit =
|
||||
@@ -135,7 +135,7 @@ export function toolInfoFromToolUse(
|
||||
locations: [],
|
||||
};
|
||||
|
||||
case "mcp__acp__edit":
|
||||
case toolNames.edit:
|
||||
case "Edit": {
|
||||
const path = input?.abs_path ?? input?.file_path;
|
||||
let oldText = input.old_string ?? null;
|
||||
@@ -181,7 +181,7 @@ export function toolInfoFromToolUse(
|
||||
};
|
||||
}
|
||||
|
||||
case "mcp__acp__multi-edit":
|
||||
case toolNames.multiEdit:
|
||||
case "MultiEdit": {
|
||||
const multiInput = input as {
|
||||
file_path: string;
|
||||
@@ -234,7 +234,7 @@ export function toolInfoFromToolUse(
|
||||
: [],
|
||||
};
|
||||
}
|
||||
case "mcp__acp__write": {
|
||||
case toolNames.write: {
|
||||
let content: ToolCallContent[] = [];
|
||||
if (input && input.abs_path) {
|
||||
content = [
|
||||
@@ -400,10 +400,10 @@ export function toolInfoFromToolUse(
|
||||
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 } }]
|
||||
@@ -447,7 +447,7 @@ export function toolUpdateFromToolResult(
|
||||
): 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 +476,26 @@ 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 toolNames.edit:
|
||||
case toolNames.multiEdit:
|
||||
case "multi-edit":
|
||||
case "MultiEdit":
|
||||
case "mcp__acp__write":
|
||||
case toolNames.write:
|
||||
case "Write": {
|
||||
if (toolResult.is_error && 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 +511,26 @@ export function toolUpdateFromToolResult(
|
||||
case "WebSearch":
|
||||
case "Other":
|
||||
default: {
|
||||
return toAcpContentUpdate(toolResult.content);
|
||||
return toAcpContentUpdate(toolResult.content, toolResult.is_error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 +540,7 @@ function toAcpContentUpdate(content: any): { content?: ToolCallContent[] } {
|
||||
type: "content",
|
||||
content: {
|
||||
type: "text",
|
||||
text: content,
|
||||
text: isError ? `\`\`\`\n${content}\n\`\`\`` : content,
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
152
src/utils.ts
152
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> {
|
||||
@@ -80,3 +82,153 @@ export function unreachable(value: never): never {
|
||||
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;
|
||||
actualEndLine: number;
|
||||
wasLimited: boolean;
|
||||
linesRead: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts lines from file content with byte limit enforcement.
|
||||
*
|
||||
* @param fullContent - The complete file content
|
||||
* @param linesToSkip - Starting line number (0-based)
|
||||
* @param linesToRead - Maximum number of lines to read
|
||||
* @param maxContentLength - Maximum number of UTF-16 Code Units to return
|
||||
* @returns Object containing extracted content and metadata
|
||||
*/
|
||||
export function extractLinesWithByteLimit(
|
||||
fullContent: string,
|
||||
linesToSkip: number,
|
||||
linesToRead: number,
|
||||
maxContentLength: number,
|
||||
): ExtractLinesResult {
|
||||
if (fullContent === "" || linesToRead === 0) {
|
||||
if (linesToSkip === 0 && linesToRead > 0) {
|
||||
return {
|
||||
content: "",
|
||||
actualEndLine: 0,
|
||||
wasLimited: false,
|
||||
linesRead: 1,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
content: "",
|
||||
actualEndLine: linesToSkip,
|
||||
wasLimited: false,
|
||||
linesRead: 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
let linesSeen = 0;
|
||||
let index = 0;
|
||||
|
||||
while (linesSeen < linesToSkip) {
|
||||
const nextIndex = fullContent.indexOf("\n", index);
|
||||
|
||||
// There were not enough lines to skip.
|
||||
if (nextIndex < 0) {
|
||||
return {
|
||||
content: "",
|
||||
actualEndLine: linesToSkip,
|
||||
wasLimited: false,
|
||||
linesRead: 0,
|
||||
};
|
||||
}
|
||||
|
||||
linesSeen += 1;
|
||||
index = nextIndex + 1;
|
||||
}
|
||||
|
||||
// We've successfully skipped over all the lines we were supposed to.
|
||||
// Now we can actually start reading!
|
||||
const startIndex = index;
|
||||
linesSeen = 0;
|
||||
|
||||
let contentLength = 0;
|
||||
let wasLimited = false;
|
||||
|
||||
while (linesSeen < linesToRead) {
|
||||
const nextIndex = fullContent.indexOf("\n", index);
|
||||
|
||||
if (nextIndex < 0) {
|
||||
// Last line in file (no trailing newline)
|
||||
const newContentLength = fullContent.length - startIndex;
|
||||
if (linesSeen > 0 && newContentLength > maxContentLength) {
|
||||
wasLimited = true;
|
||||
break;
|
||||
}
|
||||
linesSeen += 1;
|
||||
contentLength = newContentLength;
|
||||
break;
|
||||
} else {
|
||||
// Line with newline - include up to the newline
|
||||
const newContentLength = nextIndex + 1 - startIndex;
|
||||
if (linesSeen > 0 && newContentLength > maxContentLength) {
|
||||
wasLimited = true;
|
||||
break;
|
||||
}
|
||||
linesSeen += 1;
|
||||
contentLength = newContentLength;
|
||||
index = nextIndex + 1;
|
||||
}
|
||||
}
|
||||
|
||||
// If we ended with a newline and we stopped due to byte limit or line limit (not end of file), remove the trailing newline
|
||||
if (contentLength > 0 && fullContent[startIndex + contentLength - 1] === "\n") {
|
||||
// Check if there's more content after our current position that we didn't read
|
||||
if (startIndex + contentLength < fullContent.length) {
|
||||
contentLength -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
content: fullContent.slice(startIndex, startIndex + contentLength),
|
||||
actualEndLine: linesSeen > 0 ? linesToSkip + linesSeen - 1 : linesToSkip,
|
||||
wasLimited,
|
||||
linesRead: linesSeen,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user