Compare commits

...

10 Commits

Author SHA1 Message Date
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
Ben Brandt
427117dce6 Update to claude-agent-sdk 0.1.50 (#171) 2025-11-24 12:05:38 +00:00
Paul Nelson
b5a7e0f28d Fix read/edit tool error handling so upstream errors surface (#163)
Fixes #162:
- guard both tool callbacks so they return the host-provided error
instead of crashing on missing content
- skip writeTextFile in the edit tool when the initial read fails,
mirroring the read tool behavior
- add regression tests

---------

Co-authored-by: Ben Brandt <benjamin.j.brandt@gmail.com>
2025-11-24 11:37:18 +01:00
Ben Brandt
8ef0b4e4c6 Update to claude-agent-sdk 0.1.46 (#168) 2025-11-19 08:42:32 +00:00
Ben Brandt
959cd6c8e2 Disable experimental terminal auth support for now (#167)
This was causing issues on Windows in the current form, and also has
some other issues that need looking into. Will revisit later.
2025-11-19 08:37:59 +00:00
Ben Brandt
ef209d9d12 Don't special-case "default" model id (#166)
A model of id "default" only shows up sometimes in certain plans. So
rather than special-case this one, we just take the first one in the
list.
2025-11-18 10:51:20 +00:00
Ben Brandt
91b00b71e3 Report errors from "status" messages (#165)
The "status" message may say "success" but can actually have an error
which we weren't surfacing.

We should now be returning a more helpful message to the user.

Closes #161 and possibly #146
2025-11-18 10:30:30 +00:00
Ben Brandt
101a527732 Update to claude-agent-sdk 0.1.44 (#164) 2025-11-18 09:59:41 +00:00
Amos Wenger
59c5a0a8b8 Add experimental support for disabling built-in tools via _meta flag (#158)
Add ability to disable built-in tools (Read, Write, Edit, Bash, etc.)
when `_meta.disableBuiltInTools` is set to true. This allows clients to
rely solely on MCP tools without the built-in tool implementations.

---

This is just my take on #157 — not necessarily the one that makes sense
/cc @benbrandt

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Ben Brandt <benjamin.j.brandt@gmail.com>
2025-11-17 10:31:59 +00:00
8 changed files with 546 additions and 668 deletions

View File

@@ -1,5 +1,25 @@
# Changelog
## 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
- Update to @anthropic-ai/claude-agent-sdk@v0.1.50
## 0.10.6
- Disable experimental terminal auth support for now, as it was causing issues on Windows. Will revisit with a fix later.
- Update to @anthropic-ai/claude-agent-sdk@v0.1.46
## 0.10.5
- Better error messages at end of turn if there were any
- Add experimental support for disabling built-in tools via \_meta flag
- Update to @anthropic-ai/claude-agent-sdk@v0.1.44
## 0.10.4
- Fix tool call titles not appearing during approval in some cases

887
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.4",
"version": "0.10.8",
"description": "An ACP-compatible coding agent powered by the Claude Code SDK (TypeScript)",
"main": "dist/lib.js",
"bin": {
@@ -52,25 +52,25 @@
"license": "Apache-2.0",
"dependencies": {
"@agentclientprotocol/sdk": "0.5.1",
"@anthropic-ai/claude-agent-sdk": "0.1.42",
"@anthropic-ai/claude-code": "2.0.42",
"@anthropic-ai/claude-agent-sdk": "0.1.51",
"@anthropic-ai/claude-code": "2.0.51",
"@modelcontextprotocol/sdk": "1.22.0",
"diff": "8.0.2",
"uuid": "13.0.0"
},
"devDependencies": {
"@anthropic-ai/sdk": "0.69.0",
"@anthropic-ai/sdk": "0.71.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.9",
"@typescript-eslint/eslint-plugin": "8.48.0",
"@typescript-eslint/parser": "8.48.0",
"@vitest/ui": "4.0.13",
"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.9"
"vitest": "4.0.13"
}
}

View File

@@ -53,7 +53,6 @@ import {
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" };
import { fileURLToPath } from "node:url";
type Session = {
query: Query;
@@ -92,8 +91,6 @@ type ToolUseCache = {
};
};
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;
@@ -126,17 +123,17 @@ export class ClaudeAcpAgent implements Agent {
};
// If client supports terminal-auth capability, use that instead.
if (request.clientCapabilities?._meta?.["terminal-auth"] === true) {
const cliPath = fileURLToPath(import.meta.resolve("@anthropic-ai/claude-agent-sdk/cli.js"));
// if (request.clientCapabilities?._meta?.["terminal-auth"] === true) {
// const cliPath = fileURLToPath(import.meta.resolve("@anthropic-ai/claude-agent-sdk/cli.js"));
authMethod._meta = {
"terminal-auth": {
command: cliPath,
args: ["/login"],
label: "Claude Code Login",
},
};
}
// authMethod._meta = {
// "terminal-auth": {
// command: "node",
// args: [cliPath, "/login"],
// label: "Claude Code Login",
// },
// };
// }
return {
protocolVersion: 1,
@@ -193,12 +190,15 @@ export class ClaudeAcpAgent implements Agent {
}
}
const server = createMcpServer(this, sessionId, this.clientCapabilities);
mcpServers["acp"] = {
type: "sdk",
name: "acp",
instance: server,
};
// Only add the acp MCP server if built-in tools are not disabled
if (!params._meta?.disableBuiltInTools) {
const server = createMcpServer(this, sessionId, this.clientCapabilities);
mcpServers["acp"] = {
type: "sdk",
name: "acp",
instance: server,
};
}
let systemPrompt: Options["systemPrompt"] = { type: "preset", preset: "claude_code" };
if (params._meta?.systemPrompt) {
@@ -245,16 +245,49 @@ export class ClaudeAcpAgent implements Agent {
const allowedTools = [];
const disallowedTools = [];
if (this.clientCapabilities?.fs?.readTextFile) {
allowedTools.push(toolNames.read);
disallowedTools.push("Read");
}
if (this.clientCapabilities?.fs?.writeTextFile) {
disallowedTools.push("Write", "Edit");
}
if (this.clientCapabilities?.terminal) {
allowedTools.push(toolNames.bashOutput, toolNames.killShell);
disallowedTools.push("Bash", "BashOutput", "KillShell");
// Check if built-in tools should be disabled
const disableBuiltInTools = params._meta?.disableBuiltInTools === true;
if (!disableBuiltInTools) {
if (this.clientCapabilities?.fs?.readTextFile) {
allowedTools.push(toolNames.read);
disallowedTools.push("Read");
}
if (this.clientCapabilities?.fs?.writeTextFile) {
disallowedTools.push("Write", "Edit");
}
if (this.clientCapabilities?.terminal) {
allowedTools.push(toolNames.bashOutput, toolNames.killShell);
disallowedTools.push("Bash", "BashOutput", "KillShell");
}
} else {
// When built-in tools are disabled, explicitly disallow all of them
disallowedTools.push(
toolNames.read,
toolNames.write,
toolNames.edit,
toolNames.bash,
toolNames.bashOutput,
toolNames.killShell,
"Read",
"Write",
"Edit",
"Bash",
"BashOutput",
"KillShell",
"Glob",
"Grep",
"Task",
"TodoWrite",
"ExitPlanMode",
"WebSearch",
"WebFetch",
"AskUserQuestion",
"SlashCommand",
"Skill",
"NotebookEdit",
);
}
if (allowedTools.length > 0) {
@@ -360,6 +393,7 @@ export class ClaudeAcpAgent implements Agent {
break;
default:
unreachable(message);
break;
}
break;
case "result": {
@@ -367,21 +401,37 @@ export class ClaudeAcpAgent implements Agent {
return { stopReason: "cancelled" };
}
// todo!() how is rate-limiting handled?
switch (message.subtype) {
case "success": {
if (message.result.includes("Please run /login")) {
throw RequestError.authRequired();
}
if (message.is_error) {
throw RequestError.internalError(undefined, message.result);
}
return { stopReason: "end_turn" };
}
case "error_during_execution":
if (message.is_error) {
throw RequestError.internalError(
undefined,
message.errors.join(", ") || message.subtype,
);
}
return { stopReason: "end_turn" };
case "error_max_budget_usd":
case "error_max_turns":
case "error_max_structured_output_retries":
if (message.is_error) {
throw RequestError.internalError(
undefined,
message.errors.join(", ") || message.subtype,
);
}
return { stopReason: "max_turn_requests" };
default:
unreachable(message);
break;
}
break;
}
@@ -466,6 +516,7 @@ export class ClaudeAcpAgent implements Agent {
break;
default:
unreachable(message);
break;
}
}
throw new Error("Session did not end in result");
@@ -653,9 +704,8 @@ export class ClaudeAcpAgent implements Agent {
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];
// Query doesn't give us access to the currently selected model, so we just choose the first model in the list.
const currentModel = models[0];
await query.setModel(currentModel.value);
const availableModels = models.map((model) => ({
@@ -920,6 +970,7 @@ export function toAcpNotifications(
}
case "tool_result":
case "tool_search_tool_result":
case "web_fetch_tool_result":
case "web_search_tool_result":
case "code_execution_tool_result":
@@ -961,6 +1012,7 @@ export function toAcpNotifications(
default:
unreachable(chunk);
break;
}
if (update) {
output.push({ sessionId, update });
@@ -1006,6 +1058,7 @@ export function streamEventToAcpNotifications(
default:
unreachable(event);
return [];
}
}

View File

@@ -101,15 +101,19 @@ Usage:
};
}
const content = await agent.readTextFile({
const readResponse = await agent.readTextFile({
sessionId,
path: input.file_path,
line: input.offset,
limit: input.limit,
});
if (typeof readResponse?.content !== "string") {
throw new Error(`No file contents for ${input.file_path}.`);
}
// Extract lines with byte limit enforcement
const result = extractLinesWithByteLimit(content.content, defaults.maxFileSize);
const result = extractLinesWithByteLimit(readResponse.content, defaults.maxFileSize);
// Construct informative message about what was read
let readInfo = "";
@@ -253,47 +257,62 @@ Usage:
},
},
async (input) => {
const session = agent.sessions[sessionId];
if (!session) {
try {
const session = agent.sessions[sessionId];
if (!session) {
return {
content: [
{
type: "text",
text: "The user has left the building",
},
],
};
}
const readResponse = await agent.readTextFile({
sessionId,
path: input.file_path,
});
if (typeof readResponse?.content !== "string") {
throw new Error(`No file contents for ${input.file_path}.`);
}
const { newContent } = replaceAndCalculateLocation(readResponse.content, [
{
oldText: input.old_string,
newText: input.new_string,
replaceAll: input.replace_all,
},
]);
const patch = diff.createPatch(input.file_path, readResponse.content, newContent);
await agent.writeTextFile({
sessionId,
path: input.file_path,
content: newContent,
});
return {
content: [
{
type: "text",
text: "The user has left the building",
text: patch,
},
],
};
} catch (error: any) {
return {
content: [
{
type: "text",
text: "Editing file failed: " + (error?.message ?? String(error)),
},
],
};
}
const { content } = await agent.readTextFile({
sessionId,
path: input.file_path,
});
const { newContent } = replaceAndCalculateLocation(content, [
{
oldText: input.old_string,
newText: input.new_string,
replaceAll: input.replace_all,
},
]);
const patch = diff.createPatch(input.file_path, content, newContent);
await agent.writeTextFile({
sessionId,
path: input.file_path,
content: newContent,
});
return {
content: [
{
type: "text",
text: patch,
},
],
};
},
);
}
@@ -563,7 +582,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

@@ -0,0 +1,72 @@
import { describe, expect, it, vi } from "vitest";
import { createMcpServer } from "../mcp-server.js";
const SESSION_ID = "session-id";
function getTool(server: any, name: string) {
const tools = server?._registeredTools;
if (!tools || !tools[name]) {
throw new Error(`Tool ${name} not registered`);
}
return tools[name];
}
function createAgent(overrides: Record<string, unknown> = {}) {
return {
sessions: {
[SESSION_ID]: {},
},
readTextFile: vi.fn(),
writeTextFile: vi.fn(),
backgroundTerminals: {},
clientCapabilities: {},
client: {
sessionUpdate: vi.fn(),
createTerminal: vi.fn(),
requestPermission: vi.fn(),
},
...overrides,
};
}
describe("MCP server read-related tools", () => {
it("reports missing file contents instead of crashing the read tool", async () => {
const agent = createAgent();
agent.readTextFile.mockResolvedValue({});
const server = createMcpServer(agent as any, SESSION_ID, {
fs: { readTextFile: true },
});
const readTool = getTool(server, "Read");
const result = await readTool.callback({
file_path: "/tmp/file.txt",
offset: 1,
limit: 2000,
});
expect(result.content[0].text).toBe("Reading file failed: No file contents for /tmp/file.txt.");
});
it("reports missing contents inside the edit tool and skips writes", async () => {
const agent = createAgent();
agent.readTextFile.mockResolvedValue({});
const server = createMcpServer(agent as any, SESSION_ID, {
fs: { readTextFile: true, writeTextFile: true },
});
const editTool = getTool(server, "Edit");
const result = await editTool.callback({
file_path: "/tmp/file.txt",
old_string: "foo",
new_string: "bar",
replace_all: false,
});
expect(result.content[0].text).toBe("Editing file failed: No file contents for /tmp/file.txt.");
expect(agent.writeTextFile).not.toHaveBeenCalled();
});
});

View File

@@ -6,6 +6,7 @@ import {
BetaCodeExecutionToolResultBlockParam,
BetaRequestMCPToolResultBlockParam,
BetaTextEditorCodeExecutionToolResultBlockParam,
BetaToolSearchToolResultBlockParam,
BetaWebFetchToolResultBlockParam,
BetaWebSearchToolResultBlockParam,
} from "@anthropic-ai/sdk/resources/beta.mjs";
@@ -404,7 +405,8 @@ export function toolUpdateFromToolResult(
| BetaCodeExecutionToolResultBlockParam
| BetaBashCodeExecutionToolResultBlockParam
| BetaTextEditorCodeExecutionToolResultBlockParam
| BetaRequestMCPToolResultBlockParam,
| BetaRequestMCPToolResultBlockParam
| BetaToolSearchToolResultBlockParam,
toolUse: any | undefined,
): ToolUpdate {
switch (toolUse?.name) {

View File

@@ -75,14 +75,14 @@ export function nodeToWebReadable(nodeStream: Readable): ReadableStream<Uint8Arr
});
}
export function unreachable(value: never): never {
export function unreachable(value: never) {
let valueAsString;
try {
valueAsString = JSON.stringify(value);
} catch {
valueAsString = value;
}
throw new Error(`Unexpected case: ${valueAsString}`);
console.error(`Unexpected case: ${valueAsString}`);
}
export function sleep(time: number): Promise<void> {