Compare commits

...

7 Commits

Author SHA1 Message Date
Ben Brandt
73d308e4ad Update to claude-agent-sdk 0.1.55 (#182) 2025-11-27 14:35:53 +01:00
Ben Brandt
49b28a25eb Add CLAUDECODE environment to terminal invocations (#181) 2025-11-27 13:10:11 +00:00
Ben Brandt
8516df7188 fix: Access to undefined meta field (#180) 2025-11-27 14:06:47 +01:00
Guillaume Sabran
e31a16228a Allow user to pass Options to Claude Code in new session (#176)
When using Claude Code ACP as a direct dependency, this will allow the
user to configure Claude Code using the `Options` provided by the SDK.
2025-11-26 11:36:01 +01:00
Guillaume Sabran
c00782c103 Logging configuration (#177)
Allow to provide a logger to replace `console` (`console` remains the
default value)
2025-11-25 11:29:16 +01:00
Ben Brandt
4211f8cf05 Update to claude-agent-sdk 0.1.51 (#178)
Adds support for Opus 4.5
2025-11-24 23:24:34 +01:00
Ben Brandt
ac2119a23a More forgiving unreachable (#173)
Currently, if we get an unexpected message, we crash. While this is fine
in the world where we vendor CC (which we do by default) we also allow
users to override the executable for cases where their work needs a
wrapper.

This makes it so that we just log the event that we get, but don't crash
right away in case we can continue on.
2025-11-24 12:17:11 +00:00
9 changed files with 402 additions and 576 deletions

View File

@@ -1,5 +1,16 @@
# Changelog
## 0.10.9
- Update to @anthropic-ai/claude-agent-sdk@v0.1.55
- Allow defining a custom logger when used as a library
- Allow specifying custom options when used as a library
- Add `CLAUDECODE=1` to terminal invocations to match default Claude Code behavior
## 0.10.8
- Update to @anthropic-ai/claude-agent-sdk@v0.1.51 (adds support for Opus 4.5)
## 0.10.7
- Fix read/edit tool error handling so upstream errors surface

773
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -3,7 +3,7 @@
"publishConfig": {
"access": "public"
},
"version": "0.10.7",
"version": "0.10.9",
"description": "An ACP-compatible coding agent powered by the Claude Code SDK (TypeScript)",
"main": "dist/lib.js",
"bin": {
@@ -52,25 +52,24 @@
"license": "Apache-2.0",
"dependencies": {
"@agentclientprotocol/sdk": "0.5.1",
"@anthropic-ai/claude-agent-sdk": "0.1.50",
"@anthropic-ai/claude-code": "2.0.50",
"@modelcontextprotocol/sdk": "1.22.0",
"@anthropic-ai/claude-agent-sdk": "0.1.55",
"@anthropic-ai/claude-code": "2.0.55",
"@modelcontextprotocol/sdk": "1.23.0",
"diff": "8.0.2",
"uuid": "13.0.0"
},
"devDependencies": {
"@anthropic-ai/sdk": "0.70.1",
"@anthropic-ai/sdk": "0.71.0",
"@types/node": "24.10.1",
"@types/uuid": "10.0.0",
"@typescript-eslint/eslint-plugin": "8.47.0",
"@typescript-eslint/parser": "8.47.0",
"@vitest/ui": "4.0.13",
"@typescript-eslint/eslint-plugin": "8.48.0",
"@typescript-eslint/parser": "8.48.0",
"eslint": "9.39.1",
"eslint-config-prettier": "10.1.8",
"globals": "16.5.0",
"prettier": "3.6.2",
"prettier": "3.7.1",
"ts-node": "10.9.2",
"typescript": "5.9.3",
"vitest": "4.0.13"
"vitest": "4.0.14"
}
}

View File

@@ -48,12 +48,20 @@ import {
toolUpdateFromToolResult,
ClaudePlanEntry,
registerHookCallback,
postToolUseHook,
createPostToolUseHook,
} 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" };
/**
* Logger interface for customizing logging output
*/
export interface Logger {
log: (...args: any[]) => void;
error: (...args: any[]) => void;
}
type Session = {
query: Query;
input: Pushable<SDKUserMessage>;
@@ -73,13 +81,37 @@ type BackgroundTerminal =
};
/**
* Extra metadata that the agent provides for each tool_call / tool_update update, under the `_meta.claudeCode` key.
* Extra metadata that can be given to Claude Code when creating a new session.
*/
export type NewSessionMeta = {
claudeCode?: {
/**
* Options forwarded to Claude Code when starting a new session.
* Those parameters will be ignored and managed by ACP:
* - cwd
* - includePartialMessages
* - allowDangerouslySkipPermissions
* - permissionMode
* - canUseTool
* - executable
* Those parameters will be used and updated to work with ACP:
* - hooks (merged with ACP's hooks)
* - mcpServers (merged with ACP's mcpServers)
*/
options?: Options;
};
};
/**
* Extra metadata that the agent provides for each tool_call / tool_update update.
*/
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;
claudeCode?: {
/* The name of the tool that was used in Claude Code. */
toolName: string;
/* The structured output provided by Claude Code. */
toolResponse?: unknown;
};
};
type ToolUseCache = {
@@ -104,12 +136,14 @@ export class ClaudeAcpAgent implements Agent {
fileContentCache: { [key: string]: string };
backgroundTerminals: { [key: string]: BackgroundTerminal } = {};
clientCapabilities?: ClientCapabilities;
logger: Logger;
constructor(client: AgentSideConnection) {
constructor(client: AgentSideConnection, logger?: Logger) {
this.sessions = {};
this.client = client;
this.toolUseCache = {};
this.fileContentCache = {};
this.logger = logger ?? console;
}
async initialize(request: InitializeRequest): Promise<InitializeResponse> {
@@ -216,18 +250,23 @@ export class ClaudeAcpAgent implements Agent {
const permissionMode = "default";
// Extract options from _meta if provided
const userProvidedOptions = (params._meta as NewSessionMeta | undefined)?.claudeCode?.options;
const options: Options = {
cwd: params.cwd,
includePartialMessages: true,
mcpServers,
systemPrompt,
settingSources: ["user", "project", "local"],
stderr: (err) => this.logger.error(err),
...userProvidedOptions,
// Override certain fields that must be controlled by ACP
cwd: params.cwd,
includePartialMessages: true,
mcpServers: { ...(userProvidedOptions?.mcpServers || {}), ...mcpServers },
// 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,
@@ -235,9 +274,11 @@ export class ClaudeAcpAgent implements Agent {
pathToClaudeCodeExecutable: process.env.CLAUDE_CODE_EXECUTABLE,
}),
hooks: {
...userProvidedOptions?.hooks,
PostToolUse: [
...(userProvidedOptions?.hooks?.PostToolUse || []),
{
hooks: [postToolUseHook],
hooks: [createPostToolUseHook(this.logger)],
},
],
},
@@ -297,6 +338,12 @@ export class ClaudeAcpAgent implements Agent {
options.disallowedTools = disallowedTools;
}
// Handle abort controller from meta options
const abortController = userProvidedOptions?.abortController;
if (abortController?.signal.aborted) {
throw new Error("Cancelled");
}
const q = query({
prompt: input,
options,
@@ -392,7 +439,8 @@ export class ClaudeAcpAgent implements Agent {
// Todo: process via status api: https://docs.claude.com/en/docs/claude-code/hooks#hook-output
break;
default:
unreachable(message);
unreachable(message, this.logger);
break;
}
break;
case "result": {
@@ -429,7 +477,8 @@ export class ClaudeAcpAgent implements Agent {
}
return { stopReason: "max_turn_requests" };
default:
unreachable(message);
unreachable(message, this.logger);
break;
}
break;
}
@@ -440,6 +489,7 @@ export class ClaudeAcpAgent implements Agent {
this.toolUseCache,
this.fileContentCache,
this.client,
this.logger,
)) {
await this.client.sessionUpdate(notification);
}
@@ -457,7 +507,7 @@ export class ClaudeAcpAgent implements Agent {
typeof message.message.content === "string" &&
message.message.content.includes("<local-command-stdout>")
) {
console.log(message.message.content);
this.logger.log(message.message.content);
break;
}
@@ -465,7 +515,7 @@ export class ClaudeAcpAgent implements Agent {
typeof message.message.content === "string" &&
message.message.content.includes("<local-command-stderr>")
) {
console.error(message.message.content);
this.logger.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
@@ -503,6 +553,7 @@ export class ClaudeAcpAgent implements Agent {
this.toolUseCache,
this.fileContentCache,
this.client,
this.logger,
)) {
await this.client.sessionUpdate(notification);
}
@@ -514,6 +565,7 @@ export class ClaudeAcpAgent implements Agent {
break;
default:
unreachable(message);
break;
}
}
throw new Error("Session did not end in result");
@@ -599,8 +651,11 @@ export class ClaudeAcpAgent implements Agent {
toolCall: {
toolCallId: toolUseID,
rawInput: toolInput,
title: toolInfoFromToolUse({ name: toolName, input: toolInput }, this.fileContentCache)
.title,
title: toolInfoFromToolUse(
{ name: toolName, input: toolInput },
this.fileContentCache,
this.logger,
).title,
},
});
@@ -660,8 +715,11 @@ export class ClaudeAcpAgent implements Agent {
toolCall: {
toolCallId: toolUseID,
rawInput: toolInput,
title: toolInfoFromToolUse({ name: toolName, input: toolInput }, this.fileContentCache)
.title,
title: toolInfoFromToolUse(
{ name: toolName, input: toolInput },
this.fileContentCache,
this.logger,
).title,
},
});
if (
@@ -852,6 +910,7 @@ export function toAcpNotifications(
toolUseCache: ToolUseCache,
fileContentCache: { [key: string]: string },
client: AgentSideConnection,
logger: Logger,
): SessionNotification[] {
if (typeof content === "string") {
return [
@@ -927,8 +986,8 @@ export function toAcpNotifications(
claudeCode: {
toolResponse,
toolName: toolUse.name,
} satisfies ToolUpdateMeta,
},
},
} satisfies ToolUpdateMeta,
toolCallId: toolUseId,
sessionUpdate: "tool_call_update",
};
@@ -937,7 +996,7 @@ export function toAcpNotifications(
update,
});
} else {
console.error(
logger.error(
`[claude-code-acp] Got a tool response for tool use that wasn't tracked: ${toolUseId}`,
);
}
@@ -954,19 +1013,20 @@ export function toAcpNotifications(
_meta: {
claudeCode: {
toolName: chunk.name,
} satisfies ToolUpdateMeta,
},
},
} satisfies ToolUpdateMeta,
toolCallId: chunk.id,
sessionUpdate: "tool_call",
rawInput,
status: "pending",
...toolInfoFromToolUse(chunk, fileContentCache),
...toolInfoFromToolUse(chunk, fileContentCache, logger),
};
}
break;
}
case "tool_result":
case "tool_search_tool_result":
case "web_fetch_tool_result":
case "web_search_tool_result":
case "code_execution_tool_result":
@@ -975,7 +1035,7 @@ export function toAcpNotifications(
case "mcp_tool_result": {
const toolUse = toolUseCache[chunk.tool_use_id];
if (!toolUse) {
console.error(
logger.error(
`[claude-code-acp] Got a tool result for tool use that wasn't tracked: ${chunk.tool_use_id}`,
);
break;
@@ -986,8 +1046,8 @@ export function toAcpNotifications(
_meta: {
claudeCode: {
toolName: toolUse.name,
} satisfies ToolUpdateMeta,
},
},
} satisfies ToolUpdateMeta,
toolCallId: chunk.tool_use_id,
sessionUpdate: "tool_call_update",
status: "is_error" in chunk && chunk.is_error ? "failed" : "completed",
@@ -1007,7 +1067,8 @@ export function toAcpNotifications(
break;
default:
unreachable(chunk);
unreachable(chunk, logger);
break;
}
if (update) {
output.push({ sessionId, update });
@@ -1023,6 +1084,7 @@ export function streamEventToAcpNotifications(
toolUseCache: ToolUseCache,
fileContentCache: { [key: string]: string },
client: AgentSideConnection,
logger: Logger,
): SessionNotification[] {
const event = message.event;
switch (event.type) {
@@ -1034,6 +1096,7 @@ export function streamEventToAcpNotifications(
toolUseCache,
fileContentCache,
client,
logger,
);
case "content_block_delta":
return toAcpNotifications(
@@ -1043,6 +1106,7 @@ export function streamEventToAcpNotifications(
toolUseCache,
fileContentCache,
client,
logger,
);
// No content
case "message_start":
@@ -1052,7 +1116,8 @@ export function streamEventToAcpNotifications(
return [];
default:
unreachable(event);
unreachable(event, logger);
return [];
}
}

View File

@@ -5,6 +5,7 @@ export {
toAcpNotifications,
streamEventToAcpNotifications,
type ToolUpdateMeta,
type NewSessionMeta,
} from "./acp-agent.js";
export {
loadManagedSettings,

View File

@@ -377,6 +377,7 @@ Output: Create directory 'foo'`),
const handle = await agent.client.createTerminal({
command: input.command,
env: [{ name: "CLAUDECODE", value: "1" }],
sessionId,
outputByteLimit: 32_000,
});
@@ -582,7 +583,8 @@ In sessions with ${toolNames.killShell} always use it instead of KillShell.`,
content: [{ type: "text", text: "Command killed by timeout." }],
};
default: {
return unreachable(bgTerm);
unreachable(bgTerm);
throw new Error("Unexpected background terminal status");
}
}
},

View File

@@ -658,6 +658,7 @@ describe("tool conversions", () => {
{},
{},
{} as AgentSideConnection,
console,
),
).toStrictEqual([
{

View File

@@ -6,10 +6,12 @@ import {
BetaCodeExecutionToolResultBlockParam,
BetaRequestMCPToolResultBlockParam,
BetaTextEditorCodeExecutionToolResultBlockParam,
BetaToolSearchToolResultBlockParam,
BetaWebFetchToolResultBlockParam,
BetaWebSearchToolResultBlockParam,
} from "@anthropic-ai/sdk/resources/beta.mjs";
import { HookCallback } from "@anthropic-ai/claude-agent-sdk";
import { Logger } from "./acp-agent.js";
interface ToolInfo {
title: string;
@@ -27,6 +29,7 @@ interface ToolUpdate {
export function toolInfoFromToolUse(
toolUse: any,
cachedFileContent: { [key: string]: string },
logger: Logger = console,
): ToolInfo {
const name = toolUse.name;
const input = toolUse.input;
@@ -163,7 +166,7 @@ export function toolInfoFromToolUse(
newText = newContent.newContent;
affectedLines = newContent.lineNumbers;
} catch (e) {
console.error(e);
logger.error(e);
}
}
return {
@@ -404,7 +407,8 @@ export function toolUpdateFromToolResult(
| BetaCodeExecutionToolResultBlockParam
| BetaBashCodeExecutionToolResultBlockParam
| BetaTextEditorCodeExecutionToolResultBlockParam
| BetaRequestMCPToolResultBlockParam,
| BetaRequestMCPToolResultBlockParam
| BetaToolSearchToolResultBlockParam,
toolUse: any | undefined,
): ToolUpdate {
switch (toolUse?.name) {
@@ -570,19 +574,18 @@ export const registerHookCallback = (
};
/* 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];
export const createPostToolUseHook =
(logger: Logger = console): 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 {
logger.error(`No onPostToolUseHook found for tool use ID: ${toolUseID}`);
delete toolUseCallbacks[toolUseID];
}
}
}
return { continue: true };
};
return { continue: true };
};

View File

@@ -4,6 +4,7 @@ import { Readable, Writable } from "node:stream";
import { WritableStream, ReadableStream } from "node:stream/web";
import { readFileSync } from "node:fs";
import { platform } from "node:os";
import { Logger } from "./acp-agent.js";
// Useful for bridging push-based and async-iterator-based code.
export class Pushable<T> implements AsyncIterable<T> {
@@ -75,14 +76,14 @@ export function nodeToWebReadable(nodeStream: Readable): ReadableStream<Uint8Arr
});
}
export function unreachable(value: never): never {
export function unreachable(value: never, logger: Logger = console) {
let valueAsString;
try {
valueAsString = JSON.stringify(value);
} catch {
valueAsString = value;
}
throw new Error(`Unexpected case: ${valueAsString}`);
logger.error(`Unexpected case: ${valueAsString}`);
}
export function sleep(time: number): Promise<void> {