Compare commits

...

14 Commits

Author SHA1 Message Date
Ben Brandt
becf1d4f0a Update to claude agent sdk 0.1.27 (#125)
Also simplifies the permission mode workaround so we don't have to
change the profile immediately, which could have raced with client
changes anwyay.
2025-10-27 12:34:43 +01:00
Guillaume Sabran
5491bb8dcc Allow for the package to be used as a dependency, while preserving existing behavior when run (#124)
This change will allow for this package to be used directly, without
launching a subprocess and communicating over stdio.

---------

Co-authored-by: Ben Brandt <benjamin.j.brandt@gmail.com>
2025-10-27 11:10:41 +00:00
Ben Brandt
17aad5b7a1 Provide agentInfo on initialization response (#123)
- **Provide agentInfo on initialization**
- **Prep 0.6.10**
2025-10-24 17:02:07 +00:00
Steffen Deusch
dadc7f9f40 prevent invalid locations when read call has no path (#121)
The ACP spec has the path as a required field for tool call locations,
but I've seen Claude generate read calls without an input path, which
would generate:

```json
{
  "content": [],
  "kind": "read",
  "locations": [
    {
      "line": 0
    }
  ],
  "rawInput": {},
  "sessionUpdate": "tool_call",
  "status": "pending",
  "title": "Read File",
  "toolCallId": "toolu_012PzeR5whUjWN4ij2HBubJw"
}
```

And then fail to parse as SessionNotification in the SDK.
2025-10-24 12:49:43 +00:00
Ben Brandt
552f0a286d Fix job name 2025-10-24 13:05:27 +02:00
Ben Brandt
589e1b5fc9 Update claude-agent-sdk to 0.1.26 (#120) 2025-10-24 11:03:18 +00:00
Ben Brandt
0d97bb4753 Fix type holes in switch statements (#119)
We were relying on unreachable to catch stuff for us, but it wasn't
erroring because we were manually casting a few of the types to never,
leading to a bug when a hook response was encountered.

We'll revisit showing these hook responses to the user once ACP supports
multiple types of status messages.
2025-10-24 10:19:41 +00:00
Ben Brandt
e3119b81f9 Prep v0.6.8 (#114) 2025-10-21 09:19:37 +00:00
Ben Brandt
cd3c9f7b13 Update to claude-agent-sdk v0.1.23 (#113)
- **Don't emit content from message start**
- **Update to claude-agent-sdk v0.1.23**
2025-10-21 11:16:35 +02:00
Ben Brandt
8aea8fd0e1 Remove extra console.log 2025-10-17 13:35:36 +02:00
Ben Brandt
81a541c644 Fix for invalid plan input in the model (#110)
Seems to have been introduced in the latest claude agent sdk.

Closes #109
2025-10-17 11:33:46 +00:00
Ben Brandt
b49154bef8 Don't allow bypassPermissions to be enabled if in root/sudo mode, as Claude Code won't start (#108)
Closes #104

Turns out this was the root cause, not the order of messages after all.
2025-10-17 10:58:38 +00:00
Ben Brandt
5fa453a33a Reorganize order of loading config from the query. (#107) 2025-10-17 12:36:58 +02:00
Ben Brandt
c2ab2da5ca Remove duplicated text content after streaming (#106) 2025-10-17 10:12:58 +00:00
8 changed files with 511 additions and 526 deletions

View File

@@ -4,7 +4,7 @@ on:
tags:
- "v*.*.*"
jobs:
build:
publish-npm:
runs-on: ubuntu-latest
environment: release # Optional: for enhanced security
permissions:
@@ -22,3 +22,15 @@ jobs:
- run: npm ci
- run: npm run build
- run: npm publish
release:
runs-on: ubuntu-latest
needs: [publish-npm]
permissions:
contents: write
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8
- name: Create Release
id: create_release
uses: softprops/action-gh-release@6da8fa9354ddfdc4aeace5fc48d7f679b5214090
with:
draft: true

View File

@@ -1,5 +1,40 @@
# Changelog
## 0.7.0
- Allow importing from this package as a library in addition to running it as a CLI. Allows for easier integration into existing node applications.
- Update to @anthropic-ai/claude-agent-sdk@v0.1.27
## 0.6.10
- Provide `agentInfo` on initialization response.
- Update to @agentclientprotocol/sdk@0.5.1
- Fix crash when receiving a hook_response event
- Fix for invalid locations when read call has no path
## 0.6.9
- Update to @anthropic-ai/claude-agent-sdk@v0.1.26
- Update to @agentclientprotocol/sdk@0.5.0
## 0.6.8
- Fix for duplicate tokens appearing in thread with streaming enabled
- Update to @anthropic-ai/claude-agent-sdk@v0.1.23
- Update to @agentclientprotocol/sdk@0.4.9
## 0.6.7
- Fix for invalid plan input from the model introduced in latest agent-sdk
## 0.6.6
- Do not enable bypassPermissions mode if in root/sudo mode, because Claude Code will not start
## 0.6.5
- Fix for duplicated text content after streaming
## 0.6.4
- Support streaming partial messages!

733
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -4,9 +4,9 @@
"access": "public"
},
"bin": {
"claude-code-acp": "./dist/index.js"
"claude-code-acp": "./dist/cli.js"
},
"version": "0.6.4",
"version": "0.7.0",
"description": "An ACP-compatible coding agent powered by the Claude Code SDK (TypeScript)",
"main": "dist/index.js",
"type": "module",
@@ -18,7 +18,7 @@
],
"scripts": {
"build": "tsc",
"start": "node dist/index.js",
"start": "node dist/cli.js",
"dev": "npm run build && npm run start",
"lint": "eslint src --ext .ts",
"lint:fix": "eslint src --ext .ts --fix",
@@ -51,30 +51,30 @@
"author": "Zed Industries",
"license": "Apache-2.0",
"dependencies": {
"@agentclientprotocol/sdk": "0.4.8",
"@anthropic-ai/claude-agent-sdk": "0.1.21",
"@anthropic-ai/claude-code": "2.0.21",
"@modelcontextprotocol/sdk": "1.20.1",
"@agentclientprotocol/sdk": "0.5.1",
"@anthropic-ai/claude-agent-sdk": "0.1.27",
"@anthropic-ai/claude-code": "2.0.27",
"@modelcontextprotocol/sdk": "1.20.2",
"diff": "8.0.2",
"express": "5.1.0",
"minimist": "1.2.8",
"uuid": "13.0.0"
},
"devDependencies": {
"@anthropic-ai/sdk": "^0.67.0",
"@types/express": "5.0.3",
"@anthropic-ai/sdk": "0.67.0",
"@types/express": "5.0.4",
"@types/minimist": "1.2.5",
"@types/node": "24.8.1",
"@types/node": "24.9.1",
"@types/uuid": "10.0.0",
"@typescript-eslint/eslint-plugin": "8.46.1",
"@typescript-eslint/parser": "8.46.1",
"@vitest/ui": "3.2.4",
"eslint": "9.37.0",
"@typescript-eslint/eslint-plugin": "8.46.2",
"@typescript-eslint/parser": "8.46.2",
"@vitest/ui": "4.0.3",
"eslint": "9.38.0",
"eslint-config-prettier": "10.1.8",
"globals": "16.4.0",
"prettier": "3.6.2",
"ts-node": "10.9.2",
"typescript": "5.9.3",
"vitest": "3.2.4"
"vitest": "4.0.3"
}
}

View File

@@ -55,6 +55,7 @@ import {
} 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" };
type Session = {
query: Query;
@@ -85,6 +86,9 @@ 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;
// Implement the ACP Agent interface
export class ClaudeAcpAgent implements Agent {
sessions: {
@@ -107,7 +111,6 @@ export class ClaudeAcpAgent implements Agent {
this.clientCapabilities = request.clientCapabilities;
return {
protocolVersion: 1,
// todo!()
agentCapabilities: {
promptCapabilities: {
image: true,
@@ -118,6 +121,11 @@ export class ClaudeAcpAgent implements Agent {
sse: true,
},
},
agentInfo: {
name: packageJson.name,
title: "Claude Code",
version: packageJson.version,
},
authMethods: [
{
description: "Run `claude /login` in the terminal",
@@ -194,15 +202,18 @@ export class ClaudeAcpAgent implements Agent {
}
}
const permissionMode = "default";
const options: Options = {
cwd: params.cwd,
includePartialMessages: true,
mcpServers,
systemPrompt,
settingSources: ["user", "project", "local"],
// If we want bypassPermissions to be an option, we have to start the session with it on.
// We change it immediately back to default below before returning the session.
permissionMode: "bypassPermissions",
// 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,
permissionPromptToolName: PERMISSION_TOOL_NAME,
stderr: (err) => console.error(err),
// note: although not documented by the types, passing an absolute path
@@ -238,10 +249,7 @@ export class ClaudeAcpAgent implements Agent {
prompt: input,
options,
});
const models = await getAvailableModels(q);
const permissionMode = "default";
// Change it back to default
await q.setPermissionMode(permissionMode);
this.sessions[sessionId] = {
query: q,
input: input,
@@ -249,45 +257,52 @@ export class ClaudeAcpAgent implements Agent {
permissionMode,
};
getAvailableSlashCommands(q).then((availableCommands) => {
setTimeout(() => {
this.client.sessionUpdate({
sessionId,
update: {
sessionUpdate: "available_commands_update",
availableCommands,
},
});
}, 0);
});
const availableCommands = await getAvailableSlashCommands(q);
const models = await getAvailableModels(q);
// Needs to happen after we return the session
setTimeout(() => {
this.client.sessionUpdate({
sessionId,
update: {
sessionUpdate: "available_commands_update",
availableCommands,
},
});
}, 0);
const 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: "plan",
name: "Plan Mode",
description: "Claude can analyze but not modify files or execute commands",
},
];
// Only works in non-root mode
if (!IS_ROOT) {
availableModes.push({
id: "bypassPermissions",
name: "Bypass Permissions",
description: "Skips all permission prompts",
});
}
return {
sessionId,
models,
modes: {
currentModeId: permissionMode,
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",
},
],
availableModes,
},
};
}
@@ -321,8 +336,11 @@ export class ClaudeAcpAgent implements Agent {
break;
case "compact_boundary":
break;
case "hook_response":
// Todo: process via status api: https://docs.claude.com/en/docs/claude-code/hooks#hook-output
break;
default:
unreachable(message as never);
unreachable(message);
}
break;
case "result": {
@@ -396,8 +414,14 @@ export class ClaudeAcpAgent implements Agent {
throw RequestError.authRequired();
}
const content =
message.type === "assistant"
? // Handled by stream events above
message.message.content.filter((item) => !["text", "thinking"].includes(item.type))
: message.message.content;
for (const notification of toAcpNotifications(
message.message.content,
content,
message.message.role,
params.sessionId,
this.toolUseCache,
@@ -408,7 +432,7 @@ export class ClaudeAcpAgent implements Agent {
break;
}
default:
unreachable(message as never);
unreachable(message);
}
}
throw new Error("Session did not end in result");
@@ -696,10 +720,13 @@ export function toAcpNotifications(
case "mcp_tool_use": {
toolUseCache[chunk.id] = chunk;
if (chunk.name === "TodoWrite") {
update = {
sessionUpdate: "plan",
entries: planEntries(chunk.input as { todos: ClaudePlanEntry[] }),
};
// @ts-expect-error - sometimes input is empty object
if (Array.isArray(chunk.input.todos)) {
update = {
sessionUpdate: "plan",
entries: planEntries(chunk.input as { todos: ClaudePlanEntry[] }),
};
}
} else {
let rawInput;
try {
@@ -772,14 +799,6 @@ export function streamEventToAcpNotifications(
): SessionNotification[] {
const event = message.event;
switch (event.type) {
case "message_start":
return toAcpNotifications(
event.message.content,
event.message.role,
sessionId,
toolUseCache,
fileContentCache,
);
case "content_block_start":
return toAcpNotifications(
[event.content_block],
@@ -796,10 +815,11 @@ export function streamEventToAcpNotifications(
toolUseCache,
fileContentCache,
);
// No content
case "message_start":
case "message_delta":
case "message_stop":
case "content_block_stop":
// do we need stop reason?
return [];
default:

26
src/cli.ts Normal file
View File

@@ -0,0 +1,26 @@
#!/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;
console.info = console.error;
console.warn = console.error;
console.debug = console.error;
process.on("unhandledRejection", (reason, promise) => {
console.error("Unhandled Rejection at:", promise, "reason:", reason);
});
import { runAcp } from "./acp-agent.js";
runAcp();
// Keep process alive
process.stdin.resume();

View File

@@ -1,26 +1,25 @@
#!/usr/bin/env node
// Export the main agent class and utilities for library usage
export {
ClaudeAcpAgent,
runAcp,
toAcpNotifications,
streamEventToAcpNotifications,
} from "./acp-agent.js";
export {
loadManagedSettings,
applyEnvironmentSettings,
nodeToWebReadable,
nodeToWebWritable,
Pushable,
unreachable,
} from "./utils.js";
export {
createMcpServer,
createPermissionMcpServer,
PERMISSION_TOOL_NAME,
toolNames,
} from "./mcp-server.js";
export { toolInfoFromToolUse, planEntries, toolUpdateFromToolResult } from "./tools.js";
// 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;
console.info = console.error;
console.warn = console.error;
console.debug = console.error;
process.on("unhandledRejection", (reason, promise) => {
console.error("Unhandled Rejection at:", promise, "reason:", reason);
});
import { runAcp as runAcp } from "./acp-agent.js";
runAcp();
// Keep process alive
process.stdin.resume();
// Export types
export type { ClaudePlanEntry } from "./tools.js";

View File

@@ -113,12 +113,14 @@ export function toolInfoFromToolUse(
return {
title: "Read " + (input.file_path ?? "File") + limit,
kind: "read",
locations: [
{
path: input.file_path,
line: input.offset ?? 0,
},
],
locations: input.file_path
? [
{
path: input.file_path,
line: input.offset ?? 0,
},
]
: [],
content: [],
};
}
@@ -344,7 +346,7 @@ export function toolInfoFromToolUse(
case "TodoWrite":
return {
title: input?.todos
title: Array.isArray(input?.todos)
? `Update TODOs: ${input.todos.map((todo: any) => todo.content).join(", ")}`
: "Update TODOs",
kind: "think",