mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-10-27 04:30:59 +00:00
v0.1
This commit is contained in:
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
node_modules
|
||||
dist
|
||||
1724
package-lock.json
generated
Normal file
1724
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
36
package.json
Normal file
36
package.json
Normal file
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"name": "claude-code-acp-agent",
|
||||
"version": "0.1.0",
|
||||
"description": "An ACP-compatible coding agent powered by the Claude Code SDK (TypeScript)",
|
||||
"main": "dist/acp-agent.js",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"start": "node dist/acp-agent.js",
|
||||
"dev": "npm run build && npm run start",
|
||||
"lint": "eslint src --ext .ts"
|
||||
},
|
||||
"keywords": [
|
||||
"claude",
|
||||
"acp",
|
||||
"agent",
|
||||
"anthropic",
|
||||
"typescript",
|
||||
"sdk",
|
||||
"code"
|
||||
],
|
||||
"author": "Your Name",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/claude-code": "^1.0.92",
|
||||
"@zed-industries/agent-client-protocol": "0.0.32",
|
||||
"uuid": "11.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.11.30",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"eslint": "^8.56.0",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.4.0"
|
||||
}
|
||||
}
|
||||
306
src/acp-agent.ts
Normal file
306
src/acp-agent.ts
Normal file
@@ -0,0 +1,306 @@
|
||||
import {
|
||||
Agent,
|
||||
AgentSideConnection,
|
||||
AuthenticateRequest,
|
||||
CancelNotification,
|
||||
Client,
|
||||
InitializeRequest,
|
||||
InitializeResponse,
|
||||
LoadSessionRequest,
|
||||
NewSessionRequest,
|
||||
NewSessionResponse,
|
||||
PromptRequest,
|
||||
PromptResponse,
|
||||
} from "@zed-industries/agent-client-protocol";
|
||||
import {
|
||||
McpServerConfig,
|
||||
Query,
|
||||
query,
|
||||
SDKAssistantMessage,
|
||||
SDKMessage,
|
||||
SDKUserMessage,
|
||||
} from "@anthropic-ai/claude-code";
|
||||
import * as readline from "node:readline";
|
||||
import { v7 as uuidv7 } from "uuid";
|
||||
import {
|
||||
nodeToWebReadable,
|
||||
nodeToWebWritable,
|
||||
Pushable,
|
||||
unreachable,
|
||||
} from "./utils.js";
|
||||
import { ContentBlock } from "@zed-industries/agent-client-protocol";
|
||||
import { SessionNotification } from "@zed-industries/agent-client-protocol";
|
||||
|
||||
// Implement the ACP Agent interface
|
||||
class ClaudeAcpAgent implements Agent {
|
||||
sessions: {
|
||||
[key: string]: { query: Query; input: Pushable<SDKUserMessage> };
|
||||
};
|
||||
client: Client;
|
||||
|
||||
constructor(client: Client) {
|
||||
this.sessions = {};
|
||||
this.client = client;
|
||||
}
|
||||
async initialize(params: InitializeRequest): Promise<InitializeResponse> {
|
||||
return {
|
||||
protocolVersion: 1,
|
||||
// todo!()
|
||||
agentCapabilities: { promptCapabilities: { image: true } },
|
||||
};
|
||||
}
|
||||
async newSession(params: NewSessionRequest): Promise<NewSessionResponse> {
|
||||
let sessionId = uuidv7();
|
||||
let input = new Pushable<SDKUserMessage>();
|
||||
|
||||
const mcpServers: Record<string, McpServerConfig> = {};
|
||||
if (Array.isArray(params.mcpServers)) {
|
||||
for (const server of params.mcpServers) {
|
||||
mcpServers[server.name] = {
|
||||
type: "stdio",
|
||||
command: server.command,
|
||||
args: server.args,
|
||||
env: server.env
|
||||
? Object.fromEntries(server.env.map((e) => [e.name, e.value]))
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// todo!() auth, permissionPromptToolName
|
||||
|
||||
let q = query({
|
||||
prompt: input,
|
||||
options: { cwd: params.cwd, mcpServers },
|
||||
});
|
||||
this.sessions[sessionId] = { query: q, input: input };
|
||||
|
||||
return {
|
||||
sessionId,
|
||||
};
|
||||
}
|
||||
|
||||
async authenticate(params: AuthenticateRequest): Promise<void> {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
|
||||
async prompt(params: PromptRequest): Promise<PromptResponse> {
|
||||
if (!this.sessions[params.sessionId]) {
|
||||
throw new Error("Session not found");
|
||||
}
|
||||
|
||||
const { query, input } = this.sessions[params.sessionId];
|
||||
query.interrupt();
|
||||
input.push(promptToClaude(params));
|
||||
while (true) {
|
||||
let { value: message, done } = await query.next();
|
||||
if (done || !message) {
|
||||
break;
|
||||
}
|
||||
console.error(JSON.stringify(message, null, 4));
|
||||
switch (message.type) {
|
||||
case "system":
|
||||
break;
|
||||
case "result": {
|
||||
// todo!() how is rate-limiting handled?
|
||||
switch (message.subtype) {
|
||||
case "success":
|
||||
return { stopReason: "end_turn" };
|
||||
case "error_during_execution":
|
||||
return { stopReason: "refusal" };
|
||||
case "error_max_turns":
|
||||
return { stopReason: "max_turn_requests" };
|
||||
default:
|
||||
return { stopReason: "refusal" };
|
||||
}
|
||||
}
|
||||
case "user":
|
||||
case "assistant": {
|
||||
for (const notification of toAcpNotifications(
|
||||
message,
|
||||
params.sessionId,
|
||||
)) {
|
||||
this.client.sessionUpdate(notification);
|
||||
}
|
||||
break;
|
||||
}
|
||||
default:
|
||||
unreachable(message);
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error("Session did not end in result");
|
||||
}
|
||||
|
||||
async cancel(params: CancelNotification): Promise<void> {
|
||||
if (!this.sessions[params.sessionId]) {
|
||||
throw new Error("Session not found");
|
||||
}
|
||||
await this.sessions[params.sessionId].query.interrupt();
|
||||
delete this.sessions[params.sessionId];
|
||||
}
|
||||
}
|
||||
|
||||
function promptToClaude(prompt: PromptRequest): SDKUserMessage {
|
||||
const content: any[] = [];
|
||||
const context: any[] = [];
|
||||
|
||||
for (const chunk of prompt.prompt) {
|
||||
switch (chunk.type) {
|
||||
case "text":
|
||||
content.push({ type: "text", text: chunk.text });
|
||||
break;
|
||||
case "resource_link": {
|
||||
content.push({
|
||||
type: "text",
|
||||
text: chunk.uri,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "resource": {
|
||||
if ("text" in chunk.resource) {
|
||||
content.push({
|
||||
type: "text",
|
||||
text: `${chunk.resource.uri}`,
|
||||
});
|
||||
context.push({
|
||||
type: "text",
|
||||
text: `\n<context ref="${chunk.resource.uri}">\n${chunk.resource.text}\n</context>`,
|
||||
});
|
||||
}
|
||||
// Ignore blob resources (unsupported)
|
||||
break;
|
||||
}
|
||||
case "image":
|
||||
content.push({
|
||||
type: "image",
|
||||
source: {
|
||||
type: "base64",
|
||||
data: chunk.data,
|
||||
media_type: chunk.mimeType,
|
||||
},
|
||||
});
|
||||
break;
|
||||
// Ignore audio and other unsupported types
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
content.push(...context);
|
||||
|
||||
return {
|
||||
type: "user",
|
||||
message: {
|
||||
role: "user",
|
||||
content: content,
|
||||
},
|
||||
session_id: prompt.sessionId,
|
||||
parent_tool_use_id: null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert an SDKAssistantMessage (Claude) to a SessionNotification (ACP).
|
||||
* Only handles text, image, and thinking chunks for now.
|
||||
*/
|
||||
function toAcpNotifications(
|
||||
message: SDKAssistantMessage | SDKUserMessage,
|
||||
sessionId: string,
|
||||
): SessionNotification[] {
|
||||
let chunks = message.message.content as ContentChunk[];
|
||||
let output = [];
|
||||
// Only handle the first chunk for streaming; extend as needed for batching
|
||||
for (const chunk of chunks) {
|
||||
let update: SessionNotification["update"];
|
||||
switch (chunk.type) {
|
||||
case "text":
|
||||
update = {
|
||||
sessionUpdate: "agent_message_chunk",
|
||||
content: {
|
||||
type: "text",
|
||||
text: chunk.text,
|
||||
},
|
||||
};
|
||||
break;
|
||||
case "image":
|
||||
update = {
|
||||
sessionUpdate: "agent_message_chunk",
|
||||
content: {
|
||||
type: "image",
|
||||
data: chunk.source.type === "base64" ? chunk.source.data : "",
|
||||
mimeType:
|
||||
chunk.source.type === "base64" ? chunk.source.media_type : "",
|
||||
uri: chunk.source.type === "url" ? chunk.source.url : undefined,
|
||||
},
|
||||
};
|
||||
break;
|
||||
case "thinking":
|
||||
update = {
|
||||
sessionUpdate: "agent_thought_chunk",
|
||||
content: {
|
||||
type: "text",
|
||||
text: chunk.thinking,
|
||||
},
|
||||
};
|
||||
break;
|
||||
case "tool_use":
|
||||
update = {
|
||||
toolCallId: chunk.id,
|
||||
title: chunk.name,
|
||||
sessionUpdate: "tool_call",
|
||||
kind: "other", // todo!()
|
||||
rawInput: chunk.input,
|
||||
status: "pending",
|
||||
};
|
||||
break;
|
||||
|
||||
case "tool_result":
|
||||
update = {
|
||||
toolCallId: chunk.tool_use_id,
|
||||
sessionUpdate: "tool_call_update",
|
||||
content: [
|
||||
{ type: "content", content: { type: "text", text: chunk.content } },
|
||||
],
|
||||
status: chunk.is_error ? "failed" : "completed",
|
||||
};
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new Error("unhandled chunk type: " + chunk.type);
|
||||
}
|
||||
output.push({ sessionId: message.session_id, update });
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
new AgentSideConnection(
|
||||
(client) => new ClaudeAcpAgent(client),
|
||||
nodeToWebWritable(process.stdout),
|
||||
nodeToWebReadable(process.stdin),
|
||||
);
|
||||
|
||||
// Keep process alive
|
||||
process.stdin.resume();
|
||||
|
||||
type ContentChunk =
|
||||
| { type: "text"; text: string }
|
||||
| { type: "tool_use"; id: string; name: string; input: any } // input is serde_json::Value, so use any or unknown
|
||||
| {
|
||||
type: "tool_result";
|
||||
content: string;
|
||||
tool_use_id: string;
|
||||
is_error: boolean;
|
||||
} // content type depends on your Content definition
|
||||
| { type: "thinking"; thinking: string }
|
||||
| { type: "redacted_thinking" }
|
||||
| { type: "image"; source: ImageSource }
|
||||
| { type: "document" }
|
||||
| { type: "web_search_tool_result" }
|
||||
| { type: "untagged_text"; text: string };
|
||||
|
||||
// Example ImageSource type (adjust as needed)
|
||||
type ImageSource =
|
||||
| { type: "base64"; data: string; media_type: string }
|
||||
| { type: "url"; url: string };
|
||||
78
src/utils.ts
Normal file
78
src/utils.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
// A pushable async iterable: allows you to push items and consume them with for-await.
|
||||
|
||||
import { Readable, Writable } from "node:stream";
|
||||
|
||||
// Useful for bridging push-based and async-iterator-based code.
|
||||
export class Pushable<T> implements AsyncIterable<T> {
|
||||
private queue: T[] = [];
|
||||
private resolvers: ((value: IteratorResult<T>) => void)[] = [];
|
||||
private done = false;
|
||||
|
||||
push(item: T) {
|
||||
if (this.resolvers.length > 0) {
|
||||
const resolve = this.resolvers.shift()!;
|
||||
resolve({ value: item, done: false });
|
||||
} else {
|
||||
this.queue.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
end() {
|
||||
this.done = true;
|
||||
while (this.resolvers.length > 0) {
|
||||
const resolve = this.resolvers.shift()!;
|
||||
resolve({ value: undefined as any, done: true });
|
||||
}
|
||||
}
|
||||
|
||||
[Symbol.asyncIterator](): AsyncIterator<T> {
|
||||
return {
|
||||
next: (): Promise<IteratorResult<T>> => {
|
||||
if (this.queue.length > 0) {
|
||||
const value = this.queue.shift()!;
|
||||
return Promise.resolve({ value, done: false });
|
||||
}
|
||||
if (this.done) {
|
||||
return Promise.resolve({ value: undefined as any, done: true });
|
||||
}
|
||||
return new Promise<IteratorResult<T>>((resolve) => {
|
||||
this.resolvers.push(resolve);
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to convert Node.js streams to Web Streams
|
||||
export function nodeToWebWritable(
|
||||
nodeStream: Writable,
|
||||
): WritableStream<Uint8Array> {
|
||||
return new WritableStream<Uint8Array>({
|
||||
write(chunk) {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
nodeStream.write(Buffer.from(chunk), (err) => {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function nodeToWebReadable(
|
||||
nodeStream: Readable,
|
||||
): ReadableStream<Uint8Array> {
|
||||
return new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
nodeStream.on("data", (chunk: Buffer) => {
|
||||
controller.enqueue(new Uint8Array(chunk));
|
||||
});
|
||||
nodeStream.on("end", () => controller.close());
|
||||
nodeStream.on("error", (err) => controller.error(err));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function unreachable(value: never): never {
|
||||
throw new Error(`Unexpected case: ${value}`);
|
||||
}
|
||||
16
tsconfig.json
Normal file
16
tsconfig.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"lib": ["ES2020"],
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"skipLibCheck": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
Reference in New Issue
Block a user