Compare commits

...

3 Commits

Author SHA1 Message Date
Ben Brandt
fda22f227c prep release 2025-12-11 14:02:33 +01:00
Ben Brandt
7643799805 Allow agent to write plans and todos to its config directory 2025-12-11 14:00:24 +01:00
Ben Brandt
ce4ae06993 Use official sdk types for tools 2025-12-11 13:19:22 +01:00
5 changed files with 101 additions and 41 deletions

View File

@@ -1,5 +1,10 @@
# Changelog
## 0.12.3
- Allow agent to write plans and todos to its config directory
- Fix experimental resume ids
## 0.12.2
- Fix duplicate tool use IDs error

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "@zed-industries/claude-code-acp",
"version": "0.12.2",
"version": "0.12.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@zed-industries/claude-code-acp",
"version": "0.12.2",
"version": "0.12.3",
"license": "Apache-2.0",
"dependencies": {
"@agentclientprotocol/sdk": "0.8.0",

View File

@@ -3,7 +3,7 @@
"publishConfig": {
"access": "public"
},
"version": "0.12.2",
"version": "0.12.3",
"description": "An ACP-compatible coding agent powered by the Claude Code SDK (TypeScript)",
"main": "dist/lib.js",
"bin": {

View File

@@ -54,6 +54,8 @@ import { BetaContentBlock, BetaRawContentBlockDelta } from "@anthropic-ai/sdk/re
import packageJson from "../package.json" with { type: "json" };
import { randomUUID } from "node:crypto";
export const CLAUDE_CONFIG_DIR = process.env.CLAUDE ?? path.join(os.homedir(), ".claude");
/**
* Logger interface for customizing logging output
*/

View File

@@ -1,8 +1,22 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import {
BashInput,
BashOutputInput,
FileEditInput,
FileReadInput,
FileWriteInput,
KillShellInput,
} from "@anthropic-ai/claude-agent-sdk/sdk-tools.js";
import { z } from "zod";
import { ClaudeAcpAgent } from "./acp-agent.js";
import { ClientCapabilities, TerminalOutputResponse } from "@agentclientprotocol/sdk";
import { CLAUDE_CONFIG_DIR, ClaudeAcpAgent } from "./acp-agent.js";
import {
ClientCapabilities,
ReadTextFileResponse,
TerminalOutputResponse,
} from "@agentclientprotocol/sdk";
import * as diff from "diff";
import * as path from "node:path";
import * as fs from "node:fs/promises";
import { sleep, unreachable, extractLinesWithByteLimit } from "./utils.js";
@@ -40,6 +54,62 @@ export function createMcpServer(
sessionId: string,
clientCapabilities: ClientCapabilities | undefined,
): McpServer {
/**
* This checks if a given path is related to internal agent persistence and if the agent should be allowed to read/write from here.
* We let the agent do normal fs operations on these paths so that it can persist its state.
* However, we block access to settings files for security reasons.
*/
function internalPath(file_path: string) {
return (
file_path.startsWith(CLAUDE_CONFIG_DIR) &&
!file_path.startsWith(path.join(CLAUDE_CONFIG_DIR, "settings.json")) &&
!file_path.startsWith(path.join(CLAUDE_CONFIG_DIR, "session-env"))
);
}
async function readTextFile(input: FileReadInput): Promise<ReadTextFileResponse> {
if (internalPath(input.file_path)) {
const content = await fs.readFile(input.file_path, "utf8");
// eslint-disable-next-line eqeqeq
if (input.offset != null || input.limit != null) {
const lines = content.split("\n");
// Apply offset and limit if provided
const offset = input.offset ?? 1;
const limit = input.limit ?? lines.length;
// Extract the requested lines (offset is 1-based)
const startIndex = Math.max(0, offset - 1);
const endIndex = Math.min(lines.length, startIndex + limit);
const selectedLines = lines.slice(startIndex, endIndex);
return { content: selectedLines.join("\n") };
} else {
return { content };
}
}
return agent.readTextFile({
sessionId,
path: input.file_path,
line: input.offset,
limit: input.limit,
});
}
async function writeTextFile(input: FileWriteInput): Promise<void> {
if (internalPath(input.file_path)) {
await fs.writeFile(input.file_path, input.content, "utf8");
} else {
await agent.writeTextFile({
sessionId,
path: input.file_path,
content: input.content,
});
}
}
// Create MCP server
const server = new McpServer({ name: "acp", version: "1.0.0" }, { capabilities: { tools: {} } });
@@ -87,7 +157,7 @@ Usage:
idempotentHint: false,
},
},
async (input) => {
async (input: FileReadInput) => {
try {
const session = agent.sessions[sessionId];
if (!session) {
@@ -101,12 +171,7 @@ Usage:
};
}
const readResponse = await agent.readTextFile({
sessionId,
path: input.file_path,
line: input.offset,
limit: input.limit,
});
const readResponse = await readTextFile(input);
if (typeof readResponse?.content !== "string") {
throw new Error(`No file contents for ${input.file_path}.`);
@@ -117,7 +182,7 @@ Usage:
// Construct informative message about what was read
let readInfo = "";
if (input.offset > 1 || result.wasLimited) {
if ((input.offset && input.offset > 1) || result.wasLimited) {
readInfo = "\n\n<file-read-info>";
if (result.wasLimited) {
@@ -185,7 +250,7 @@ Usage:
idempotentHint: false,
},
},
async (input) => {
async (input: FileWriteInput) => {
try {
const session = agent.sessions[sessionId];
if (!session) {
@@ -198,11 +263,7 @@ Usage:
],
};
}
await agent.writeTextFile({
sessionId,
path: input.file_path,
content: input.content,
});
await writeTextFile(input);
return {
content: [],
@@ -246,7 +307,7 @@ Usage:
.boolean()
.default(false)
.optional()
.describe("Replace all occurences of old_string (default false)"),
.describe("Replace all occurrences of old_string (default false)"),
},
annotations: {
title: "Edit file",
@@ -256,7 +317,7 @@ Usage:
idempotentHint: false,
},
},
async (input) => {
async (input: FileEditInput) => {
try {
const session = agent.sessions[sessionId];
if (!session) {
@@ -270,9 +331,8 @@ Usage:
};
}
const readResponse = await agent.readTextFile({
sessionId,
path: input.file_path,
const readResponse = await readTextFile({
file_path: input.file_path,
});
if (typeof readResponse?.content !== "string") {
@@ -289,11 +349,7 @@ Usage:
const patch = diff.createPatch(input.file_path, readResponse.content, newContent);
await agent.writeTextFile({
sessionId,
path: input.file_path,
content: newContent,
});
await writeTextFile({ file_path: input.file_path, content: newContent });
return {
content: [
@@ -327,10 +383,7 @@ Usage:
In sessions with ${toolNames.bash} always use it instead of Bash`,
inputSchema: {
command: z.string().describe("The command to execute"),
timeout: z
.number()
.default(2 * 60 * 1000)
.describe(`Optional timeout in milliseconds (max ${2 * 60 * 1000})`),
timeout: z.number().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
@@ -352,7 +405,7 @@ Output: Create directory 'foo'`),
),
},
},
async (input, extra) => {
async (input: BashInput, extra) => {
const session = agent.sessions[sessionId];
if (!session) {
return {
@@ -406,7 +459,7 @@ Output: Create directory 'foo'`),
const statusPromise = Promise.race([
handle.waitForExit().then((exitStatus) => ({ status: "exited" as const, exitStatus })),
abortPromise.then(() => ({ status: "aborted" as const, exitStatus: null })),
sleep(input.timeout).then(async () => {
sleep(input.timeout ?? 2 * 60 * 1000).then(async () => {
if (agent.backgroundTerminals[handle.id]?.status === "started") {
await handle.kill();
}
@@ -475,23 +528,23 @@ Output: Create directory 'foo'`),
{
title: unqualifiedToolNames.bashOutput,
description: `- Retrieves output from a running or completed background bash shell
- Takes a shell_id parameter identifying the shell
- Takes a bash_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: {
shell_id: z
bash_id: z
.string()
.describe(`The id of the background bash command as returned by \`${toolNames.bash}\``),
},
},
async (input) => {
const bgTerm = agent.backgroundTerminals[input.shell_id];
async (input: BashOutputInput) => {
const bgTerm = agent.backgroundTerminals[input.bash_id];
if (!bgTerm) {
throw new Error(`Unknown shell ${input.shell_id}`);
throw new Error(`Unknown shell ${input.bash_id}`);
}
if (bgTerm.status === "started") {
@@ -542,7 +595,7 @@ In sessions with ${toolNames.killShell} always use it instead of KillShell.`,
.describe(`The id of the background bash command as returned by \`${toolNames.bash}\``),
},
},
async (input) => {
async (input: KillShellInput) => {
const bgTerm = agent.backgroundTerminals[input.shell_id];
if (!bgTerm) {