feat(acp): add experimental ACP support

Co-authored-by: Jonathan Taylor <visionik@pobox.com>
This commit is contained in:
Peter Steinberger
2026-01-18 08:02:42 +00:00
parent efaa73f543
commit de3b68740a
25 changed files with 1528 additions and 29 deletions

40
src/acp/commands.ts Normal file
View File

@@ -0,0 +1,40 @@
import type { AvailableCommand } from "@agentclientprotocol/sdk";
export function getAvailableCommands(): AvailableCommand[] {
return [
{ name: "help", description: "Show help and common commands." },
{ name: "commands", description: "List available commands." },
{ name: "status", description: "Show current status." },
{
name: "context",
description: "Explain context usage (list|detail|json).",
input: { hint: "list | detail | json" },
},
{ name: "whoami", description: "Show sender id (alias: /id)." },
{ name: "id", description: "Alias for /whoami." },
{ name: "subagents", description: "List or manage sub-agents." },
{ name: "config", description: "Read or write config (owner-only)." },
{ name: "debug", description: "Set runtime-only overrides (owner-only)." },
{ name: "usage", description: "Toggle usage footer (off|tokens|full)." },
{ name: "stop", description: "Stop the current run." },
{ name: "restart", description: "Restart the gateway (if enabled)." },
{ name: "dock-telegram", description: "Route replies to Telegram." },
{ name: "dock-discord", description: "Route replies to Discord." },
{ name: "dock-slack", description: "Route replies to Slack." },
{ name: "activation", description: "Set group activation (mention|always)." },
{ name: "send", description: "Set send mode (on|off|inherit)." },
{ name: "reset", description: "Reset the session (/new)." },
{ name: "new", description: "Reset the session (/reset)." },
{
name: "think",
description: "Set thinking level (off|minimal|low|medium|high|xhigh).",
},
{ name: "verbose", description: "Set verbose mode (on|full|off)." },
{ name: "reasoning", description: "Toggle reasoning output (on|off|stream)." },
{ name: "elevated", description: "Toggle elevated mode (on|off)." },
{ name: "model", description: "Select a model (list|status|<name>)." },
{ name: "queue", description: "Adjust queue mode and options." },
{ name: "bash", description: "Run a host command (if enabled)." },
{ name: "compact", description: "Compact the session history." },
];
}

View File

@@ -0,0 +1,34 @@
import { describe, expect, it } from "vitest";
import { extractAttachmentsFromPrompt, extractTextFromPrompt } from "./event-mapper.js";
describe("acp event mapper", () => {
it("extracts text and resource blocks into prompt text", () => {
const text = extractTextFromPrompt([
{ type: "text", text: "Hello" },
{ type: "resource", resource: { text: "File contents" } },
{ type: "resource_link", uri: "https://example.com", title: "Spec" },
{ type: "image", data: "abc", mimeType: "image/png" },
]);
expect(text).toBe(
"Hello\nFile contents\n[Resource link (Spec)] https://example.com",
);
});
it("extracts image blocks into gateway attachments", () => {
const attachments = extractAttachmentsFromPrompt([
{ type: "image", data: "abc", mimeType: "image/png" },
{ type: "image", data: "", mimeType: "image/png" },
{ type: "text", text: "ignored" },
]);
expect(attachments).toEqual([
{
type: "image",
mimeType: "image/png",
content: "abc",
},
]);
});
});

73
src/acp/event-mapper.ts Normal file
View File

@@ -0,0 +1,73 @@
import type { ContentBlock, ImageContent, ToolKind } from "@agentclientprotocol/sdk";
export type GatewayAttachment = {
type: string;
mimeType: string;
content: string;
};
export function extractTextFromPrompt(prompt: ContentBlock[]): string {
const parts: string[] = [];
for (const block of prompt) {
if (block.type === "text") {
parts.push(block.text);
continue;
}
if (block.type === "resource") {
const resource = block.resource as { text?: string } | undefined;
if (resource?.text) parts.push(resource.text);
continue;
}
if (block.type === "resource_link") {
const title = block.title ? ` (${block.title})` : "";
const uri = block.uri ?? "";
const line = uri ? `[Resource link${title}] ${uri}` : `[Resource link${title}]`;
parts.push(line);
}
}
return parts.join("\n");
}
export function extractAttachmentsFromPrompt(prompt: ContentBlock[]): GatewayAttachment[] {
const attachments: GatewayAttachment[] = [];
for (const block of prompt) {
if (block.type !== "image") continue;
const image = block as ImageContent;
if (!image.data || !image.mimeType) continue;
attachments.push({
type: "image",
mimeType: image.mimeType,
content: image.data,
});
}
return attachments;
}
export function formatToolTitle(
name: string | undefined,
args: Record<string, unknown> | undefined,
): string {
const base = name ?? "tool";
if (!args || Object.keys(args).length === 0) return base;
const parts = Object.entries(args).map(([key, value]) => {
const raw = typeof value === "string" ? value : JSON.stringify(value);
const safe = raw.length > 100 ? `${raw.slice(0, 100)}...` : raw;
return `${key}: ${safe}`;
});
return `${base}: ${parts.join(", ")}`;
}
export function inferToolKind(name?: string): ToolKind | undefined {
if (!name) return "other";
const normalized = name.toLowerCase();
if (normalized.includes("read")) return "read";
if (normalized.includes("write") || normalized.includes("edit")) return "edit";
if (normalized.includes("delete") || normalized.includes("remove")) return "delete";
if (normalized.includes("move") || normalized.includes("rename")) return "move";
if (normalized.includes("search") || normalized.includes("find")) return "search";
if (normalized.includes("exec") || normalized.includes("run") || normalized.includes("bash")) {
return "execute";
}
if (normalized.includes("fetch") || normalized.includes("http")) return "fetch";
return "other";
}

4
src/acp/index.ts Normal file
View File

@@ -0,0 +1,4 @@
export { serveAcpGateway } from "./server.js";
export { createInMemorySessionStore } from "./session.js";
export type { AcpSessionStore } from "./session.js";
export type { AcpServerOptions } from "./types.js";

35
src/acp/meta.ts Normal file
View File

@@ -0,0 +1,35 @@
export function readString(
meta: Record<string, unknown> | null | undefined,
keys: string[],
): string | undefined {
if (!meta) return undefined;
for (const key of keys) {
const value = meta[key];
if (typeof value === "string" && value.trim()) return value.trim();
}
return undefined;
}
export function readBool(
meta: Record<string, unknown> | null | undefined,
keys: string[],
): boolean | undefined {
if (!meta) return undefined;
for (const key of keys) {
const value = meta[key];
if (typeof value === "boolean") return value;
}
return undefined;
}
export function readNumber(
meta: Record<string, unknown> | null | undefined,
keys: string[],
): number | undefined {
if (!meta) return undefined;
for (const key of keys) {
const value = meta[key];
if (typeof value === "number" && Number.isFinite(value)) return value;
}
return undefined;
}

149
src/acp/server.ts Normal file
View File

@@ -0,0 +1,149 @@
#!/usr/bin/env node
import { Readable, Writable } from "node:stream";
import { fileURLToPath } from "node:url";
import { AgentSideConnection, ndJsonStream } from "@agentclientprotocol/sdk";
import { loadConfig } from "../config/config.js";
import { resolveGatewayAuth } from "../gateway/auth.js";
import { buildGatewayConnectionDetails } from "../gateway/call.js";
import { GatewayClient } from "../gateway/client.js";
import { isMainModule } from "../infra/is-main.js";
import {
GATEWAY_CLIENT_MODES,
GATEWAY_CLIENT_NAMES,
} from "../utils/message-channel.js";
import { AcpGatewayAgent } from "./translator.js";
import type { AcpServerOptions } from "./types.js";
export function serveAcpGateway(opts: AcpServerOptions = {}): void {
const cfg = loadConfig();
const connection = buildGatewayConnectionDetails({
config: cfg,
url: opts.gatewayUrl,
});
const isRemoteMode = cfg.gateway?.mode === "remote";
const remote = isRemoteMode ? cfg.gateway?.remote : undefined;
const auth = resolveGatewayAuth({ authConfig: cfg.gateway?.auth, env: process.env });
const token =
opts.gatewayToken ??
(isRemoteMode ? remote?.token?.trim() : undefined) ??
process.env.CLAWDBOT_GATEWAY_TOKEN ??
auth.token;
const password =
opts.gatewayPassword ??
(isRemoteMode ? remote?.password?.trim() : undefined) ??
process.env.CLAWDBOT_GATEWAY_PASSWORD ??
auth.password;
let agent: AcpGatewayAgent | null = null;
const gateway = new GatewayClient({
url: connection.url,
token: token || undefined,
password: password || undefined,
clientName: GATEWAY_CLIENT_NAMES.CLI,
clientDisplayName: "ACP",
clientVersion: "acp",
mode: GATEWAY_CLIENT_MODES.CLI,
onEvent: (evt) => {
void agent?.handleGatewayEvent(evt);
},
onHelloOk: () => {
agent?.handleGatewayReconnect();
},
onClose: (code, reason) => {
agent?.handleGatewayDisconnect(`${code}: ${reason}`);
},
});
const input = Writable.toWeb(process.stdout);
const output = Readable.toWeb(process.stdin) as ReadableStream<Uint8Array>;
const stream = ndJsonStream(input, output);
new AgentSideConnection((conn) => {
agent = new AcpGatewayAgent(conn, gateway, opts);
agent.start();
return agent;
}, stream);
gateway.start();
}
function parseArgs(args: string[]): AcpServerOptions {
const opts: AcpServerOptions = {};
for (let i = 0; i < args.length; i += 1) {
const arg = args[i];
if (arg === "--url" || arg === "--gateway-url") {
opts.gatewayUrl = args[i + 1];
i += 1;
continue;
}
if (arg === "--token" || arg === "--gateway-token") {
opts.gatewayToken = args[i + 1];
i += 1;
continue;
}
if (arg === "--password" || arg === "--gateway-password") {
opts.gatewayPassword = args[i + 1];
i += 1;
continue;
}
if (arg === "--session") {
opts.defaultSessionKey = args[i + 1];
i += 1;
continue;
}
if (arg === "--session-label") {
opts.defaultSessionLabel = args[i + 1];
i += 1;
continue;
}
if (arg === "--require-existing") {
opts.requireExistingSession = true;
continue;
}
if (arg === "--reset-session") {
opts.resetSession = true;
continue;
}
if (arg === "--no-prefix-cwd") {
opts.prefixCwd = false;
continue;
}
if (arg === "--verbose" || arg === "-v") {
opts.verbose = true;
continue;
}
if (arg === "--help" || arg === "-h") {
printHelp();
process.exit(0);
}
}
return opts;
}
function printHelp(): void {
console.log(`Usage: clawdbot acp [options]
Gateway-backed ACP server for IDE integration.
Options:
--url <url> Gateway WebSocket URL
--token <token> Gateway auth token
--password <password> Gateway auth password
--session <key> Default session key (e.g. "agent:main:main")
--session-label <label> Default session label to resolve
--require-existing Fail if the session key/label does not exist
--reset-session Reset the session key before first use
--no-prefix-cwd Do not prefix prompts with the working directory
--verbose, -v Verbose logging to stderr
--help, -h Show this help message
`);
}
if (isMainModule({ currentFile: fileURLToPath(import.meta.url) })) {
const opts = parseArgs(process.argv.slice(2));
serveAcpGateway(opts);
}

View File

@@ -0,0 +1,57 @@
import { describe, expect, it, vi } from "vitest";
import type { GatewayClient } from "../gateway/client.js";
import { parseSessionMeta, resolveSessionKey } from "./session-mapper.js";
function createGateway(resolveLabelKey = "agent:main:label"): {
gateway: GatewayClient;
request: ReturnType<typeof vi.fn>;
} {
const request = vi.fn(async (method: string, params: Record<string, unknown>) => {
if (method === "sessions.resolve" && "label" in params) {
return { ok: true, key: resolveLabelKey };
}
if (method === "sessions.resolve" && "key" in params) {
return { ok: true, key: params.key as string };
}
return { ok: true };
});
return {
gateway: { request } as unknown as GatewayClient,
request,
};
}
describe("acp session mapper", () => {
it("prefers explicit sessionLabel over sessionKey", async () => {
const { gateway, request } = createGateway();
const meta = parseSessionMeta({ sessionLabel: "support", sessionKey: "agent:main:main" });
const key = await resolveSessionKey({
meta,
fallbackKey: "acp:fallback",
gateway,
opts: {},
});
expect(key).toBe("agent:main:label");
expect(request).toHaveBeenCalledTimes(1);
expect(request).toHaveBeenCalledWith("sessions.resolve", { label: "support" });
});
it("lets meta sessionKey override default label", async () => {
const { gateway, request } = createGateway();
const meta = parseSessionMeta({ sessionKey: "agent:main:override" });
const key = await resolveSessionKey({
meta,
fallbackKey: "acp:fallback",
gateway,
opts: { defaultSessionLabel: "default-label" },
});
expect(key).toBe("agent:main:override");
expect(request).not.toHaveBeenCalled();
});
});

95
src/acp/session-mapper.ts Normal file
View File

@@ -0,0 +1,95 @@
import type { GatewayClient } from "../gateway/client.js";
import type { AcpServerOptions } from "./types.js";
import { readBool, readString } from "./meta.js";
export type AcpSessionMeta = {
sessionKey?: string;
sessionLabel?: string;
resetSession?: boolean;
requireExisting?: boolean;
prefixCwd?: boolean;
};
export function parseSessionMeta(meta: unknown): AcpSessionMeta {
if (!meta || typeof meta !== "object") return {};
const record = meta as Record<string, unknown>;
return {
sessionKey: readString(record, ["sessionKey", "session", "key"]),
sessionLabel: readString(record, ["sessionLabel", "label"]),
resetSession: readBool(record, ["resetSession", "reset"]),
requireExisting: readBool(record, ["requireExistingSession", "requireExisting"]),
prefixCwd: readBool(record, ["prefixCwd"]),
};
}
export async function resolveSessionKey(params: {
meta: AcpSessionMeta;
fallbackKey: string;
gateway: GatewayClient;
opts: AcpServerOptions;
}): Promise<string> {
const requestedLabel = params.meta.sessionLabel ?? params.opts.defaultSessionLabel;
const requestedKey = params.meta.sessionKey ?? params.opts.defaultSessionKey;
const requireExisting =
params.meta.requireExisting ?? params.opts.requireExistingSession ?? false;
if (params.meta.sessionLabel) {
const resolved = await params.gateway.request<{ ok: true; key: string }>(
"sessions.resolve",
{ label: params.meta.sessionLabel },
);
if (!resolved?.key) {
throw new Error(`Unable to resolve session label: ${params.meta.sessionLabel}`);
}
return resolved.key;
}
if (params.meta.sessionKey) {
if (!requireExisting) return params.meta.sessionKey;
const resolved = await params.gateway.request<{ ok: true; key: string }>(
"sessions.resolve",
{ key: params.meta.sessionKey },
);
if (!resolved?.key) {
throw new Error(`Session key not found: ${params.meta.sessionKey}`);
}
return resolved.key;
}
if (requestedLabel) {
const resolved = await params.gateway.request<{ ok: true; key: string }>(
"sessions.resolve",
{ label: requestedLabel },
);
if (!resolved?.key) {
throw new Error(`Unable to resolve session label: ${requestedLabel}`);
}
return resolved.key;
}
if (requestedKey) {
if (!requireExisting) return requestedKey;
const resolved = await params.gateway.request<{ ok: true; key: string }>(
"sessions.resolve",
{ key: requestedKey },
);
if (!resolved?.key) {
throw new Error(`Session key not found: ${requestedKey}`);
}
return resolved.key;
}
return params.fallbackKey;
}
export async function resetSessionIfNeeded(params: {
meta: AcpSessionMeta;
sessionKey: string;
gateway: GatewayClient;
opts: AcpServerOptions;
}): Promise<void> {
const resetSession = params.meta.resetSession ?? params.opts.resetSession ?? false;
if (!resetSession) return;
await params.gateway.request("sessions.reset", { key: params.sessionKey });
}

26
src/acp/session.test.ts Normal file
View File

@@ -0,0 +1,26 @@
import { describe, expect, it, afterEach } from "vitest";
import { createInMemorySessionStore } from "./session.js";
describe("acp session manager", () => {
const store = createInMemorySessionStore();
afterEach(() => {
store.clearAllSessionsForTest();
});
it("tracks active runs and clears on cancel", () => {
const session = store.createSession({
sessionKey: "acp:test",
cwd: "/tmp",
});
const controller = new AbortController();
store.setActiveRun(session.sessionId, "run-1", controller);
expect(store.getSessionByRunId("run-1")?.sessionId).toBe(session.sessionId);
const cancelled = store.cancelActiveRun(session.sessionId);
expect(cancelled).toBe(true);
expect(store.getSessionByRunId("run-1")).toBeUndefined();
});
});

93
src/acp/session.ts Normal file
View File

@@ -0,0 +1,93 @@
import { randomUUID } from "node:crypto";
import type { AcpSession } from "./types.js";
export type AcpSessionStore = {
createSession: (params: {
sessionKey: string;
cwd: string;
sessionId?: string;
}) => AcpSession;
getSession: (sessionId: string) => AcpSession | undefined;
getSessionByRunId: (runId: string) => AcpSession | undefined;
setActiveRun: (sessionId: string, runId: string, abortController: AbortController) => void;
clearActiveRun: (sessionId: string) => void;
cancelActiveRun: (sessionId: string) => boolean;
clearAllSessionsForTest: () => void;
};
export function createInMemorySessionStore(): AcpSessionStore {
const sessions = new Map<string, AcpSession>();
const runIdToSessionId = new Map<string, string>();
const createSession: AcpSessionStore["createSession"] = (params) => {
const sessionId = params.sessionId ?? randomUUID();
const session: AcpSession = {
sessionId,
sessionKey: params.sessionKey,
cwd: params.cwd,
createdAt: Date.now(),
abortController: null,
activeRunId: null,
};
sessions.set(sessionId, session);
return session;
};
const getSession: AcpSessionStore["getSession"] = (sessionId) => sessions.get(sessionId);
const getSessionByRunId: AcpSessionStore["getSessionByRunId"] = (runId) => {
const sessionId = runIdToSessionId.get(runId);
return sessionId ? sessions.get(sessionId) : undefined;
};
const setActiveRun: AcpSessionStore["setActiveRun"] = (
sessionId,
runId,
abortController,
) => {
const session = sessions.get(sessionId);
if (!session) return;
session.activeRunId = runId;
session.abortController = abortController;
runIdToSessionId.set(runId, sessionId);
};
const clearActiveRun: AcpSessionStore["clearActiveRun"] = (sessionId) => {
const session = sessions.get(sessionId);
if (!session) return;
if (session.activeRunId) runIdToSessionId.delete(session.activeRunId);
session.activeRunId = null;
session.abortController = null;
};
const cancelActiveRun: AcpSessionStore["cancelActiveRun"] = (sessionId) => {
const session = sessions.get(sessionId);
if (!session?.abortController) return false;
session.abortController.abort();
if (session.activeRunId) runIdToSessionId.delete(session.activeRunId);
session.abortController = null;
session.activeRunId = null;
return true;
};
const clearAllSessionsForTest: AcpSessionStore["clearAllSessionsForTest"] = () => {
for (const session of sessions.values()) {
session.abortController?.abort();
}
sessions.clear();
runIdToSessionId.clear();
};
return {
createSession,
getSession,
getSessionByRunId,
setActiveRun,
clearActiveRun,
cancelActiveRun,
clearAllSessionsForTest,
};
}
export const defaultAcpSessionStore = createInMemorySessionStore();

433
src/acp/translator.ts Normal file
View File

@@ -0,0 +1,433 @@
import { randomUUID } from "node:crypto";
import type {
Agent,
AgentSideConnection,
AuthenticateRequest,
AuthenticateResponse,
CancelNotification,
InitializeRequest,
InitializeResponse,
ListSessionsRequest,
ListSessionsResponse,
LoadSessionRequest,
LoadSessionResponse,
NewSessionRequest,
NewSessionResponse,
PromptRequest,
PromptResponse,
SetSessionModeRequest,
SetSessionModeResponse,
StopReason,
} from "@agentclientprotocol/sdk";
import { PROTOCOL_VERSION } from "@agentclientprotocol/sdk";
import type { GatewayClient } from "../gateway/client.js";
import type { EventFrame } from "../gateway/protocol/index.js";
import type { SessionsListResult } from "../gateway/session-utils.js";
import { getAvailableCommands } from "./commands.js";
import { readBool, readNumber, readString } from "./meta.js";
import {
extractAttachmentsFromPrompt,
extractTextFromPrompt,
formatToolTitle,
inferToolKind,
} from "./event-mapper.js";
import { parseSessionMeta, resetSessionIfNeeded, resolveSessionKey } from "./session-mapper.js";
import { ACP_AGENT_INFO, type AcpServerOptions } from "./types.js";
import { defaultAcpSessionStore, type AcpSessionStore } from "./session.js";
type PendingPrompt = {
sessionId: string;
sessionKey: string;
idempotencyKey: string;
resolve: (response: PromptResponse) => void;
reject: (err: Error) => void;
sentTextLength?: number;
sentText?: string;
toolCalls?: Set<string>;
};
type AcpGatewayAgentOptions = AcpServerOptions & {
sessionStore?: AcpSessionStore;
};
export class AcpGatewayAgent implements Agent {
private connection: AgentSideConnection;
private gateway: GatewayClient;
private opts: AcpGatewayAgentOptions;
private log: (msg: string) => void;
private sessionStore: AcpSessionStore;
private pendingPrompts = new Map<string, PendingPrompt>();
constructor(
connection: AgentSideConnection,
gateway: GatewayClient,
opts: AcpGatewayAgentOptions = {},
) {
this.connection = connection;
this.gateway = gateway;
this.opts = opts;
this.log = opts.verbose
? (msg: string) => process.stderr.write(`[acp] ${msg}\n`)
: () => {};
this.sessionStore = opts.sessionStore ?? defaultAcpSessionStore;
}
start(): void {
this.log("ready");
}
handleGatewayReconnect(): void {
this.log("gateway reconnected");
}
handleGatewayDisconnect(reason: string): void {
this.log(`gateway disconnected: ${reason}`);
for (const pending of this.pendingPrompts.values()) {
pending.reject(new Error(`Gateway disconnected: ${reason}`));
this.sessionStore.clearActiveRun(pending.sessionId);
}
this.pendingPrompts.clear();
}
async handleGatewayEvent(evt: EventFrame): Promise<void> {
if (evt.event === "chat") {
await this.handleChatEvent(evt);
return;
}
if (evt.event === "agent") {
await this.handleAgentEvent(evt);
}
}
async initialize(_params: InitializeRequest): Promise<InitializeResponse> {
return {
protocolVersion: PROTOCOL_VERSION,
agentCapabilities: {
loadSession: true,
promptCapabilities: {
image: true,
audio: false,
embeddedContext: true,
},
mcpCapabilities: {
http: false,
sse: false,
},
sessionCapabilities: {
list: {},
},
},
agentInfo: ACP_AGENT_INFO,
authMethods: [],
};
}
async newSession(params: NewSessionRequest): Promise<NewSessionResponse> {
if (params.mcpServers.length > 0) {
this.log(`ignoring ${params.mcpServers.length} MCP servers`);
}
const sessionId = randomUUID();
const meta = parseSessionMeta(params._meta);
const sessionKey = await resolveSessionKey({
meta,
fallbackKey: `acp:${sessionId}`,
gateway: this.gateway,
opts: this.opts,
});
await resetSessionIfNeeded({
meta,
sessionKey,
gateway: this.gateway,
opts: this.opts,
});
const session = this.sessionStore.createSession({
sessionId,
sessionKey,
cwd: params.cwd,
});
this.log(`newSession: ${session.sessionId} -> ${session.sessionKey}`);
await this.sendAvailableCommands(session.sessionId);
return { sessionId: session.sessionId };
}
async loadSession(params: LoadSessionRequest): Promise<LoadSessionResponse> {
if (params.mcpServers.length > 0) {
this.log(`ignoring ${params.mcpServers.length} MCP servers`);
}
const meta = parseSessionMeta(params._meta);
const sessionKey = await resolveSessionKey({
meta,
fallbackKey: params.sessionId,
gateway: this.gateway,
opts: this.opts,
});
await resetSessionIfNeeded({
meta,
sessionKey,
gateway: this.gateway,
opts: this.opts,
});
const session = this.sessionStore.createSession({
sessionId: params.sessionId,
sessionKey,
cwd: params.cwd,
});
this.log(`loadSession: ${session.sessionId} -> ${session.sessionKey}`);
await this.sendAvailableCommands(session.sessionId);
return {};
}
async unstable_listSessions(params: ListSessionsRequest): Promise<ListSessionsResponse> {
const limit = readNumber(params._meta, ["limit"]) ?? 100;
const result = await this.gateway.request<SessionsListResult>("sessions.list", { limit });
const cwd = params.cwd ?? process.cwd();
return {
sessions: result.sessions.map((session) => ({
sessionId: session.key,
cwd,
title: session.displayName ?? session.label ?? session.key,
updatedAt: session.updatedAt ? new Date(session.updatedAt).toISOString() : undefined,
_meta: {
sessionKey: session.key,
kind: session.kind,
channel: session.channel,
},
})),
nextCursor: null,
};
}
async authenticate(_params: AuthenticateRequest): Promise<AuthenticateResponse> {
return {};
}
async setSessionMode(
params: SetSessionModeRequest,
): Promise<SetSessionModeResponse | void> {
const session = this.sessionStore.getSession(params.sessionId);
if (!session) {
throw new Error(`Session ${params.sessionId} not found`);
}
if (!params.modeId) return {};
try {
await this.gateway.request("sessions.patch", {
key: session.sessionKey,
thinkingLevel: params.modeId,
});
this.log(`setSessionMode: ${session.sessionId} -> ${params.modeId}`);
} catch (err) {
this.log(`setSessionMode error: ${String(err)}`);
}
return {};
}
async prompt(params: PromptRequest): Promise<PromptResponse> {
const session = this.sessionStore.getSession(params.sessionId);
if (!session) {
throw new Error(`Session ${params.sessionId} not found`);
}
if (session.abortController) {
this.sessionStore.cancelActiveRun(params.sessionId);
}
const abortController = new AbortController();
const runId = randomUUID();
this.sessionStore.setActiveRun(params.sessionId, runId, abortController);
const meta = parseSessionMeta(params._meta);
const userText = extractTextFromPrompt(params.prompt);
const attachments = extractAttachmentsFromPrompt(params.prompt);
const prefixCwd = meta.prefixCwd ?? this.opts.prefixCwd ?? true;
const message = prefixCwd ? `[Working directory: ${session.cwd}]\n\n${userText}` : userText;
return new Promise<PromptResponse>((resolve, reject) => {
this.pendingPrompts.set(params.sessionId, {
sessionId: params.sessionId,
sessionKey: session.sessionKey,
idempotencyKey: runId,
resolve,
reject,
});
this.gateway
.request(
"chat.send",
{
sessionKey: session.sessionKey,
message,
attachments: attachments.length > 0 ? attachments : undefined,
idempotencyKey: runId,
thinking: readString(params._meta, ["thinking", "thinkingLevel"]),
deliver: readBool(params._meta, ["deliver"]),
timeoutMs: readNumber(params._meta, ["timeoutMs"]),
},
{ expectFinal: true },
)
.catch((err) => {
this.pendingPrompts.delete(params.sessionId);
this.sessionStore.clearActiveRun(params.sessionId);
reject(err instanceof Error ? err : new Error(String(err)));
});
});
}
async cancel(params: CancelNotification): Promise<void> {
const session = this.sessionStore.getSession(params.sessionId);
if (!session) return;
this.sessionStore.cancelActiveRun(params.sessionId);
try {
await this.gateway.request("chat.abort", { sessionKey: session.sessionKey });
} catch (err) {
this.log(`cancel error: ${String(err)}`);
}
const pending = this.pendingPrompts.get(params.sessionId);
if (pending) {
this.pendingPrompts.delete(params.sessionId);
pending.resolve({ stopReason: "cancelled" });
}
}
private async handleAgentEvent(evt: EventFrame): Promise<void> {
const payload = evt.payload as Record<string, unknown> | undefined;
if (!payload) return;
const stream = payload.stream as string | undefined;
const data = payload.data as Record<string, unknown> | undefined;
const sessionKey = payload.sessionKey as string | undefined;
if (!stream || !data || !sessionKey) return;
if (stream !== "tool") return;
const phase = data.phase as string | undefined;
const name = data.name as string | undefined;
const toolCallId = data.toolCallId as string | undefined;
if (!toolCallId) return;
const pending = this.findPendingBySessionKey(sessionKey);
if (!pending) return;
if (phase === "start") {
if (!pending.toolCalls) pending.toolCalls = new Set();
if (pending.toolCalls.has(toolCallId)) return;
pending.toolCalls.add(toolCallId);
const args = data.args as Record<string, unknown> | undefined;
await this.connection.sessionUpdate({
sessionId: pending.sessionId,
update: {
sessionUpdate: "tool_call",
toolCallId,
title: formatToolTitle(name, args),
status: "in_progress",
rawInput: args,
kind: inferToolKind(name),
},
});
return;
}
if (phase === "result") {
const isError = Boolean(data.isError);
await this.connection.sessionUpdate({
sessionId: pending.sessionId,
update: {
sessionUpdate: "tool_call_update",
toolCallId,
status: isError ? "failed" : "completed",
rawOutput: data.result,
},
});
}
}
private async handleChatEvent(evt: EventFrame): Promise<void> {
const payload = evt.payload as Record<string, unknown> | undefined;
if (!payload) return;
const sessionKey = payload.sessionKey as string | undefined;
const state = payload.state as string | undefined;
const runId = payload.runId as string | undefined;
const messageData = payload.message as Record<string, unknown> | undefined;
if (!sessionKey || !state) return;
const pending = this.findPendingBySessionKey(sessionKey);
if (!pending) return;
if (runId && pending.idempotencyKey !== runId) return;
if (state === "delta" && messageData) {
await this.handleDeltaEvent(pending.sessionId, messageData);
return;
}
if (state === "final") {
this.finishPrompt(pending.sessionId, pending, "end_turn");
return;
}
if (state === "aborted") {
this.finishPrompt(pending.sessionId, pending, "cancelled");
return;
}
if (state === "error") {
this.finishPrompt(pending.sessionId, pending, "refusal");
}
}
private async handleDeltaEvent(
sessionId: string,
messageData: Record<string, unknown>,
): Promise<void> {
const content = messageData.content as Array<{ type: string; text?: string }> | undefined;
const fullText = content?.find((c) => c.type === "text")?.text ?? "";
const pending = this.pendingPrompts.get(sessionId);
if (!pending) return;
const sentSoFar = pending.sentTextLength ?? 0;
if (fullText.length <= sentSoFar) return;
const newText = fullText.slice(sentSoFar);
pending.sentTextLength = fullText.length;
pending.sentText = fullText;
await this.connection.sessionUpdate({
sessionId,
update: {
sessionUpdate: "agent_message_chunk",
content: { type: "text", text: newText },
},
});
}
private finishPrompt(
sessionId: string,
pending: PendingPrompt,
stopReason: StopReason,
): void {
this.pendingPrompts.delete(sessionId);
this.sessionStore.clearActiveRun(sessionId);
pending.resolve({ stopReason });
}
private findPendingBySessionKey(sessionKey: string): PendingPrompt | undefined {
for (const pending of this.pendingPrompts.values()) {
if (pending.sessionKey === sessionKey) return pending;
}
return undefined;
}
private async sendAvailableCommands(sessionId: string): Promise<void> {
await this.connection.sessionUpdate({
sessionId,
update: {
sessionUpdate: "available_commands_update",
availableCommands: getAvailableCommands(),
},
});
}
}

30
src/acp/types.ts Normal file
View File

@@ -0,0 +1,30 @@
import type { SessionId } from "@agentclientprotocol/sdk";
import { VERSION } from "../version.js";
export type AcpSession = {
sessionId: SessionId;
sessionKey: string;
cwd: string;
createdAt: number;
abortController: AbortController | null;
activeRunId: string | null;
};
export type AcpServerOptions = {
gatewayUrl?: string;
gatewayToken?: string;
gatewayPassword?: string;
defaultSessionKey?: string;
defaultSessionLabel?: string;
requireExistingSession?: boolean;
resetSession?: boolean;
prefixCwd?: boolean;
verbose?: boolean;
};
export const ACP_AGENT_INFO = {
name: "clawdbot-acp",
title: "Clawdbot ACP Gateway",
version: VERSION,
};

43
src/cli/acp-cli.ts Normal file
View File

@@ -0,0 +1,43 @@
import type { Command } from "commander";
import { serveAcpGateway } from "../acp/server.js";
import { defaultRuntime } from "../runtime.js";
import { formatDocsLink } from "../terminal/links.js";
import { theme } from "../terminal/theme.js";
export function registerAcpCli(program: Command) {
program
.command("acp")
.description("Run an ACP bridge backed by the Gateway")
.option("--url <url>", "Gateway WebSocket URL (defaults to gateway.remote.url when configured)")
.option("--token <token>", "Gateway token (if required)")
.option("--password <password>", "Gateway password (if required)")
.option("--session <key>", "Default session key (e.g. agent:main:main)")
.option("--session-label <label>", "Default session label to resolve")
.option("--require-existing", "Fail if the session key/label does not exist", false)
.option("--reset-session", "Reset the session key before first use", false)
.option("--no-prefix-cwd", "Do not prefix prompts with the working directory", false)
.option("--verbose, -v", "Verbose logging to stderr", false)
.addHelpText(
"after",
() => `\n${theme.muted("Docs:")} ${formatDocsLink("/cli/acp", "docs.clawd.bot/cli/acp")}\n`,
)
.action((opts) => {
try {
serveAcpGateway({
gatewayUrl: opts.url as string | undefined,
gatewayToken: opts.token as string | undefined,
gatewayPassword: opts.password as string | undefined,
defaultSessionKey: opts.session as string | undefined,
defaultSessionLabel: opts.sessionLabel as string | undefined,
requireExistingSession: Boolean(opts.requireExisting),
resetSession: Boolean(opts.resetSession),
prefixCwd: !opts.noPrefixCwd,
verbose: Boolean(opts.verbose),
});
} catch (err) {
defaultRuntime.error(String(err));
defaultRuntime.exit(1);
}
});
}

View File

@@ -1,6 +1,7 @@
import type { Command } from "commander";
import { loadConfig } from "../../config/config.js";
import { registerPluginCliCommands } from "../../plugins/cli.js";
import { registerAcpCli } from "../acp-cli.js";
import { registerChannelsCli } from "../channels-cli.js";
import { registerCronCli } from "../cron-cli.js";
import { registerDaemonCli } from "../daemon-cli.js";
@@ -23,6 +24,7 @@ import { registerTuiCli } from "../tui-cli.js";
import { registerUpdateCli } from "../update-cli.js";
export function registerSubCliCommands(program: Command) {
registerAcpCli(program);
registerDaemonCli(program);
registerGatewayCli(program);
registerLogsCli(program);

View File

@@ -6,6 +6,7 @@ import { resolveAgentTimeoutMs } from "../agents/timeout.js";
import { agentCommand } from "../commands/agent.js";
import { mergeSessionEntry, updateSessionStore } from "../config/sessions.js";
import { registerAgentRunContext } from "../infra/agent-events.js";
import { isAcpSessionKey } from "../routing/session-key.js";
import { defaultRuntime } from "../runtime.js";
import {
abortChatRunById,
@@ -385,6 +386,7 @@ export const handleChatBridgeMethods: BridgeMethodHandler = async (ctx, nodeId,
runId: clientRunId,
status: "started" as const,
};
const lane = isAcpSessionKey(p.sessionKey) ? p.sessionKey : undefined;
void agentCommand(
{
message: parsedMessage,
@@ -397,6 +399,7 @@ export const handleChatBridgeMethods: BridgeMethodHandler = async (ctx, nodeId,
timeout: Math.ceil(timeoutMs / 1000).toString(),
messageChannel: `node(${nodeId})`,
abortSignal: abortController.signal,
lane,
},
defaultRuntime,
ctx.deps,

View File

@@ -7,6 +7,7 @@ import { resolveAgentTimeoutMs } from "../../agents/timeout.js";
import { agentCommand } from "../../commands/agent.js";
import { mergeSessionEntry, updateSessionStore } from "../../config/sessions.js";
import { registerAgentRunContext } from "../../infra/agent-events.js";
import { isAcpSessionKey } from "../../routing/session-key.js";
import { defaultRuntime } from "../../runtime.js";
import { resolveSendPolicy } from "../../sessions/send-policy.js";
import { INTERNAL_MESSAGE_CHANNEL } from "../../utils/message-channel.js";
@@ -299,6 +300,7 @@ export const chatHandlers: GatewayRequestHandlers = {
};
respond(true, ackPayload, undefined, { runId: clientRunId });
const lane = isAcpSessionKey(p.sessionKey) ? p.sessionKey : undefined;
void agentCommand(
{
message: parsedMessage,
@@ -311,6 +313,7 @@ export const chatHandlers: GatewayRequestHandlers = {
timeout: Math.ceil(timeoutMs / 1000).toString(),
messageChannel: INTERNAL_MESSAGE_CHANNEL,
abortSignal: abortController.signal,
lane,
},
defaultRuntime,
context.deps,

View File

@@ -1,3 +1,15 @@
import {
parseAgentSessionKey,
type ParsedAgentSessionKey,
} from "../sessions/session-key-utils.js";
export {
isAcpSessionKey,
isSubagentSessionKey,
parseAgentSessionKey,
type ParsedAgentSessionKey,
} from "../sessions/session-key-utils.js";
export const DEFAULT_AGENT_ID = "main";
export const DEFAULT_MAIN_KEY = "main";
export const DEFAULT_ACCOUNT_ID = "default";
@@ -11,11 +23,6 @@ export function normalizeMainKey(value: string | undefined | null): string {
return trimmed ? trimmed : DEFAULT_MAIN_KEY;
}
export type ParsedAgentSessionKey = {
agentId: string;
rest: string;
};
export function toAgentRequestSessionKey(storeKey: string | undefined | null): string | undefined {
const raw = (storeKey ?? "").trim();
if (!raw) return undefined;
@@ -70,28 +77,6 @@ export function normalizeAccountId(value: string | undefined | null): string {
);
}
export function parseAgentSessionKey(
sessionKey: string | undefined | null,
): ParsedAgentSessionKey | null {
const raw = (sessionKey ?? "").trim();
if (!raw) return null;
const parts = raw.split(":").filter(Boolean);
if (parts.length < 3) return null;
if (parts[0] !== "agent") return null;
const agentId = parts[1]?.trim();
const rest = parts.slice(2).join(":");
if (!agentId || !rest) return null;
return { agentId, rest };
}
export function isSubagentSessionKey(sessionKey: string | undefined | null): boolean {
const raw = (sessionKey ?? "").trim();
if (!raw) return false;
if (raw.toLowerCase().startsWith("subagent:")) return true;
const parsed = parseAgentSessionKey(raw);
return Boolean((parsed?.rest ?? "").toLowerCase().startsWith("subagent:"));
}
export function buildAgentMainSessionKey(params: {
agentId: string;
mainKey?: string | undefined;

View File

@@ -0,0 +1,35 @@
export type ParsedAgentSessionKey = {
agentId: string;
rest: string;
};
export function parseAgentSessionKey(
sessionKey: string | undefined | null,
): ParsedAgentSessionKey | null {
const raw = (sessionKey ?? "").trim();
if (!raw) return null;
const parts = raw.split(":").filter(Boolean);
if (parts.length < 3) return null;
if (parts[0] !== "agent") return null;
const agentId = parts[1]?.trim();
const rest = parts.slice(2).join(":");
if (!agentId || !rest) return null;
return { agentId, rest };
}
export function isSubagentSessionKey(sessionKey: string | undefined | null): boolean {
const raw = (sessionKey ?? "").trim();
if (!raw) return false;
if (raw.toLowerCase().startsWith("subagent:")) return true;
const parsed = parseAgentSessionKey(raw);
return Boolean((parsed?.rest ?? "").toLowerCase().startsWith("subagent:"));
}
export function isAcpSessionKey(sessionKey: string | undefined | null): boolean {
const raw = (sessionKey ?? "").trim();
if (!raw) return false;
const normalized = raw.toLowerCase();
if (normalized.startsWith("acp:")) return true;
const parsed = parseAgentSessionKey(raw);
return Boolean((parsed?.rest ?? "").toLowerCase().startsWith("acp:"));
}