Compare commits

..

6 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
Guillaume Sabran
c3e1418d89 Allow to resume previous session (#199)
When the ACP client persists the session id to support resuming, it has
no way to pass it back to the agent to resume an existing session as the
current implementation always creates a new id that is now shared with
Claude Code.

My client used to pass the value in the `--resume` within Claude Code
option, but `--resume` is not compatible with the new `--session-id`:

```
--session-id cannot be used with --continue or --resume.
```

Change:

When `--resume` is used, extract its value and use it as the session id.

---------

Co-authored-by: Ben Brandt <benjamin.j.brandt@gmail.com>
2025-12-11 12:18:38 +01:00
Bennet Bo Fenner
3b4cd4b2d5 Fix duplicate tool use IDs error (#198)
<img width="595" height="107" alt="image"
src="https://github.com/user-attachments/assets/3b9ae5e9-cbc8-425a-8cb6-1d201df3a812"
/>

---------

Co-authored-by: Ben Brandt <benjamin.j.brandt@gmail.com>
2025-12-09 22:00:38 +01:00
Ben Brandt
9320e05891 Update to claude-agent-sdk 0.1.61 (#195) 2025-12-08 12:17:49 +01:00
5 changed files with 145 additions and 65 deletions

View File

@@ -1,5 +1,19 @@
# 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
## 0.12.1
- Update to @anthropic-ai/claude-agent-sdk@v0.1.61
- Update to @agentclientprotocol/sdk@0.8.0
## 0.12.0
- Update to @anthropic-ai/claude-agent-sdk@v0.1.59

34
package-lock.json generated
View File

@@ -1,16 +1,16 @@
{
"name": "@zed-industries/claude-code-acp",
"version": "0.12.0",
"version": "0.12.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@zed-industries/claude-code-acp",
"version": "0.12.0",
"version": "0.12.3",
"license": "Apache-2.0",
"dependencies": {
"@agentclientprotocol/sdk": "0.7.0",
"@anthropic-ai/claude-agent-sdk": "0.1.59",
"@agentclientprotocol/sdk": "0.8.0",
"@anthropic-ai/claude-agent-sdk": "0.1.61",
"@modelcontextprotocol/sdk": "1.24.3",
"diff": "8.0.2"
},
@@ -18,7 +18,7 @@
"claude-code-acp": "dist/index.js"
},
"devDependencies": {
"@anthropic-ai/sdk": "0.71.1",
"@anthropic-ai/sdk": "0.71.2",
"@types/node": "24.10.1",
"@typescript-eslint/eslint-plugin": "8.48.1",
"@typescript-eslint/parser": "8.48.1",
@@ -32,18 +32,18 @@
}
},
"node_modules/@agentclientprotocol/sdk": {
"version": "0.7.0",
"resolved": "https://registry.npmjs.org/@agentclientprotocol/sdk/-/sdk-0.7.0.tgz",
"integrity": "sha512-lZrBwaP7qpDgwvhZOl61te9bpigpOXJfVTwLWqjMsnZI7oe5rgHdxGWfTHcqiRy4ITg9qbz3ApgYsxKoZpYMhg==",
"version": "0.8.0",
"resolved": "https://registry.npmjs.org/@agentclientprotocol/sdk/-/sdk-0.8.0.tgz",
"integrity": "sha512-RnYsrYqrGFNpPc/cgHgMPHjgn52F1tw5PWyr7UucQ2V1O72JWy3fCCkUgbbRgn7wbNVFsrEZMuBS186Mxgkhfg==",
"license": "Apache-2.0",
"peerDependencies": {
"zod": "^3.25.0 || ^4.0.0"
}
},
"node_modules/@anthropic-ai/claude-agent-sdk": {
"version": "0.1.59",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.1.59.tgz",
"integrity": "sha512-9TMxQCkIOd9W3c+owLtTW7d1ZgWeYoz1tbUwqz1TiKJTZmsmAFHUfXebQsJBZ+W15g6msqA6ln9yYxOKlNnlGw==",
"version": "0.1.61",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.1.61.tgz",
"integrity": "sha512-V0WlOMp56OetCeNoYMtvXSh6LmRYvoA69K16iaF7mXg8XYPZcrO2tuC8k8S8LWDUjoVjgg7EJYP1v/p0Ax2UPA==",
"license": "SEE LICENSE IN README.md",
"engines": {
"node": ">=18.0.0"
@@ -63,9 +63,9 @@
}
},
"node_modules/@anthropic-ai/sdk": {
"version": "0.71.1",
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.71.1.tgz",
"integrity": "sha512-ViVzuaWMxHygfB2G6/1OufWDjbJGRl3duR0N1CEx/D5O9nv1yLNtbO3Lri5sQdF+aDHP+0nL6WyOMwCJUDicRQ==",
"version": "0.71.2",
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.71.2.tgz",
"integrity": "sha512-TGNDEUuEstk/DKu0/TflXAEt+p+p/WhTlFzEnoosvbaDU2LTjm42igSdlL0VijrKpWejtOKxX0b8A7uc+XiSAQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -4238,9 +4238,9 @@
}
},
"node_modules/vite": {
"version": "7.2.6",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.2.6.tgz",
"integrity": "sha512-tI2l/nFHC5rLh7+5+o7QjKjSR04ivXDF4jcgV0f/bTQ+OJiITy5S6gaynVsEM+7RqzufMnVbIon6Sr5x1SDYaQ==",
"version": "7.2.7",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.2.7.tgz",
"integrity": "sha512-ITcnkFeR3+fI8P1wMgItjGrR10170d8auB4EpMLPqmx6uxElH3a/hHGQabSHKdqd4FXWO1nFIp9rRn7JQ34ACQ==",
"dev": true,
"license": "MIT",
"peer": true,

View File

@@ -3,7 +3,7 @@
"publishConfig": {
"access": "public"
},
"version": "0.12.0",
"version": "0.12.3",
"description": "An ACP-compatible coding agent powered by the Claude Code SDK (TypeScript)",
"main": "dist/lib.js",
"bin": {
@@ -51,13 +51,13 @@
"author": "Zed Industries",
"license": "Apache-2.0",
"dependencies": {
"@agentclientprotocol/sdk": "0.7.0",
"@anthropic-ai/claude-agent-sdk": "0.1.59",
"@agentclientprotocol/sdk": "0.8.0",
"@anthropic-ai/claude-agent-sdk": "0.1.61",
"@modelcontextprotocol/sdk": "1.24.3",
"diff": "8.0.2"
},
"devDependencies": {
"@anthropic-ai/sdk": "0.71.1",
"@anthropic-ai/sdk": "0.71.2",
"@types/node": "24.10.1",
"@typescript-eslint/eslint-plugin": "8.48.1",
"@typescript-eslint/parser": "8.48.1",

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
*/
@@ -197,7 +199,10 @@ export class ClaudeAcpAgent implements Agent {
throw RequestError.authRequired();
}
const sessionId = randomUUID();
// Extract options from _meta if provided
const userProvidedOptions = (params._meta as NewSessionMeta | undefined)?.claudeCode?.options;
const sessionId = userProvidedOptions?.resume || randomUUID();
const input = new Pushable<SDKUserMessage>();
const mcpServers: Record<string, McpServerConfig> = {};
@@ -250,8 +255,11 @@ export class ClaudeAcpAgent implements Agent {
const permissionMode = "default";
// Extract options from _meta if provided
const userProvidedOptions = (params._meta as NewSessionMeta | undefined)?.claudeCode?.options;
const extraArgs = { ...userProvidedOptions?.extraArgs };
if (userProvidedOptions?.resume === undefined) {
// Set our own session id if not resuming an existing session.
extraArgs["session-id"] = sessionId;
}
const options: Options = {
systemPrompt,
@@ -262,8 +270,7 @@ export class ClaudeAcpAgent implements Agent {
cwd: params.cwd,
includePartialMessages: true,
mcpServers: { ...(userProvidedOptions?.mcpServers || {}), ...mcpServers },
// Set our own session id
extraArgs: { ...userProvidedOptions?.extraArgs, "session-id": sessionId },
extraArgs,
// 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,
@@ -634,7 +641,7 @@ export class ClaudeAcpAgent implements Agent {
}
canUseTool(sessionId: string): CanUseTool {
return async (toolName, toolInput, { suggestions, toolUseID }) => {
return async (toolName, toolInput, { signal, suggestions, toolUseID }) => {
const session = this.sessions[sessionId];
if (!session) {
return {
@@ -667,6 +674,9 @@ export class ClaudeAcpAgent implements Agent {
},
});
if (signal.aborted || response.outcome?.outcome === "cancelled") {
throw new Error("Tool use aborted");
}
if (
response.outcome?.outcome === "selected" &&
(response.outcome.optionId === "default" || response.outcome.optionId === "acceptEdits")
@@ -730,6 +740,9 @@ export class ClaudeAcpAgent implements Agent {
).title,
},
});
if (signal.aborted || response.outcome?.outcome === "cancelled") {
throw new Error("Tool use aborted");
}
if (
response.outcome?.outcome === "selected" &&
(response.outcome.optionId === "allow" || response.outcome.optionId === "allow_always")

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) {