diff --git a/bun.lock b/bun.lock index 5e7c5ff201..9b42a1575c 100644 --- a/bun.lock +++ b/bun.lock @@ -43,6 +43,7 @@ "codebuff-tui": "./bin/codecane", }, "dependencies": { + "@agentclientprotocol/sdk": "^2.2.0", "@codebuff/sdk": "workspace:*", "@opentui/core": "0.3.4", "@opentui/react": "0.3.4", @@ -52,6 +53,7 @@ "jimp": "^1.6.0", "node-machine-id": "^1.1.12", "open": "^10.1.0", + "picocolors": "^1.1.1", "pino": "9.4.0", "react": "^19.0.0", "remark-breaks": "^4.0.0", @@ -183,6 +185,8 @@ "zod": "^4.2.1", }, "packages": { + "@agentclientprotocol/sdk": ["@agentclientprotocol/sdk@2.2.0", "", { "peerDependencies": { "@modelcontextprotocol/server": "^2.4.0", "zod": "^3.25.76 || ^4.0.0" }, "optionalPeers": ["@modelcontextprotocol/server"] }, "sha512-o1+eDQVb05yzPY+5v54awHyONU1oeLsbLVavkwrMQaZDuJDh/38pQiXJS4ix4d6QA3Y4bU0lHFRjwg99KKKSaQ=="], + "@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.50", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.18" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-21PaHfoLmouOXXNINTsZJsMw+wE5oLR2He/1kq/sKokTVKyq7ObGT1LDk6ahwxaz/GoaNaGankMh+EgVcdv2Cw=="], "@ai-sdk/gateway": ["@ai-sdk/gateway@4.0.132", "", { "dependencies": { "@ai-sdk/provider": "4.0.35", "@ai-sdk/provider-utils": "5.0.72", "@vercel/oidc": "3.2.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ppStDEz96g+JEuxGiVTP29OKBjPyYZXRxs38wnq3427U2QbZtxe6TukrgSAAq0n4yaHvSvVAWJY9kBlW8XudYg=="], @@ -1111,6 +1115,8 @@ "path-type": ["path-type@4.0.0", "", {}, "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw=="], + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + "picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], "pino": ["pino@9.4.0", "", { "dependencies": { "atomic-sleep": "^1.0.0", "fast-redact": "^3.1.1", "on-exit-leak-free": "^2.1.0", "pino-abstract-transport": "^1.2.0", "pino-std-serializers": "^7.0.0", "process-warning": "^4.0.0", "quick-format-unescaped": "^4.0.3", "real-require": "^0.2.0", "safe-stable-stringify": "^2.3.1", "sonic-boom": "^4.0.1", "thread-stream": "^3.0.0" }, "bin": { "pino": "bin.js" } }, "sha512-nbkQb5+9YPhQRz/BeQmrWpEknAaqjpAqRK8NwJpmrX/JHu7JuZC5G1CeAwJDJfGes4h+YihC6in3Q2nGb+Y09w=="], diff --git a/cli/package.json b/cli/package.json index f873cd35aa..ab2a198361 100644 --- a/cli/package.json +++ b/cli/package.json @@ -32,6 +32,7 @@ "bun": "1.3.14" }, "dependencies": { + "@agentclientprotocol/sdk": "^2.2.0", "@codebuff/sdk": "workspace:*", "@opentui/core": "0.3.4", "@opentui/react": "0.3.4", @@ -41,6 +42,7 @@ "jimp": "^1.6.0", "node-machine-id": "^1.1.12", "open": "^10.1.0", + "picocolors": "^1.1.1", "pino": "9.4.0", "react": "^19.0.0", "remark-breaks": "^4.0.0", diff --git a/cli/src/acp/__tests__/acp-agent.test.ts b/cli/src/acp/__tests__/acp-agent.test.ts new file mode 100644 index 0000000000..cea89291aa --- /dev/null +++ b/cli/src/acp/__tests__/acp-agent.test.ts @@ -0,0 +1,165 @@ +import { describe, expect, test } from 'bun:test' + +import { FreebuffAcpAgent } from '../acp-agent' + +import type { PrintModeEvent } from '@codebuff/common/types/print-mode' + +function agent(runTurn?: unknown) { + const backend = new FreebuffAcpAgent({ + runTurn: runTurn as never, + info: () => {}, + error: () => {}, + warn: () => {}, + debug: () => {}, + child: () => ({}) as never, + } as never) + const sessionId = backend.newSession('/tmp/project') + return { backend, sessionId } +} + +describe('ACP event mapping', () => { + test('text becomes an agent message chunk', () => { + const { backend, sessionId } = agent() + const mapped = backend.eventToUpdate(sessionId, { + type: 'text', + text: 'hello', + } as PrintModeEvent) + expect(mapped).toEqual({ + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'hello' }, + }, + }) + }) + + test('empty text is not reported', () => { + const { backend, sessionId } = agent() + expect( + backend.eventToUpdate(sessionId, { type: 'text', text: '' } as PrintModeEvent), + ).toBeNull() + }) + + test('reasoning becomes a thought chunk', () => { + const { backend, sessionId } = agent() + const mapped = backend.eventToUpdate(sessionId, { + type: 'reasoning_delta', + text: 'thinking', + ancestorRunIds: [], + runId: 'r1', + agentId: 'a1', + } as PrintModeEvent) + expect(mapped).toEqual({ + update: { + sessionUpdate: 'agent_thought_chunk', + content: { type: 'text', text: 'thinking' }, + }, + }) + }) + + test('a tool call becomes a pending tool_call with a kind', () => { + const { backend, sessionId } = agent() + const mapped = backend.eventToUpdate(sessionId, { + type: 'tool_call', + toolCallId: 'call_1', + toolName: 'read_files', + input: { paths: ['a.ts'] }, + } as PrintModeEvent) + expect(mapped).toEqual({ + update: { + sessionUpdate: 'tool_call', + toolCallId: 'call_1', + title: 'read_files', + name: 'read_files', + kind: 'read', + status: 'pending', + rawInput: { paths: ['a.ts'] }, + }, + }) + }) + + test('an unknown tool is reported as kind other', () => { + const { backend, sessionId } = agent() + const mapped = backend.eventToUpdate(sessionId, { + type: 'tool_call', + toolCallId: 'call_2', + toolName: 'some_new_tool', + input: {}, + } as PrintModeEvent) + expect((mapped as { update: { kind: string } }).update.kind).toBe('other') + }) + + test('a tool result completes the tool call with its output', () => { + const { backend, sessionId } = agent() + backend.eventToUpdate(sessionId, { + type: 'tool_call', + toolCallId: 'call_1', + toolName: 'read_files', + input: {}, + } as PrintModeEvent) + const mapped = backend.eventToUpdate(sessionId, { + type: 'tool_result', + toolCallId: 'call_1', + toolName: 'read_files', + output: [{ type: 'json', value: 'file contents' }], + } as PrintModeEvent) + expect(mapped).toEqual({ + update: { + sessionUpdate: 'tool_call_update', + toolCallId: 'call_1', + status: 'completed', + content: [ + { type: 'content', content: { type: 'text', text: 'file contents' } }, + ], + }, + }) + }) + + test('a media tool result is described rather than dropped', () => { + const { backend, sessionId } = agent() + const mapped = backend.eventToUpdate(sessionId, { + type: 'tool_result', + toolCallId: 'call_3', + toolName: 'screenshot', + output: [{ type: 'media', data: 'AAAA', mediaType: 'image/png' }], + } as PrintModeEvent) + const content = (mapped as { update: { content: Array<{ content: { text: string } }> } }) + .update.content + expect(content[0]!.content.text).toContain('image/png') + }) + + test('an error event becomes an error, not an update', () => { + const { backend, sessionId } = agent() + const mapped = backend.eventToUpdate(sessionId, { + type: 'error', + message: 'boom', + } as PrintModeEvent) + expect(mapped).toEqual({ error: 'boom' }) + }) + + test('lifecycle events carry nothing a client renders', () => { + const { backend, sessionId } = agent() + expect( + backend.eventToUpdate(sessionId, { + type: 'start', + messageHistoryLength: 0, + } as PrintModeEvent), + ).toBeNull() + expect( + backend.eventToUpdate(sessionId, { + type: 'finish', + totalCost: 0, + } as PrintModeEvent), + ).toBeNull() + }) +}) + +describe('ACP sessions', () => { + test('a new session has a unique id and is known', () => { + const backend = new FreebuffAcpAgent() + const first = backend.newSession('/tmp/a') + const second = backend.newSession('/tmp/b') + expect(first).not.toBe(second) + expect(backend.hasSession(first)).toBe(true) + expect(backend.hasSession('nope')).toBe(false) + }) +}) diff --git a/cli/src/acp/__tests__/acp-protocol.test.ts b/cli/src/acp/__tests__/acp-protocol.test.ts new file mode 100644 index 0000000000..0ef512e7ee --- /dev/null +++ b/cli/src/acp/__tests__/acp-protocol.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, test } from 'bun:test' +import * as acp from '@agentclientprotocol/sdk' + +import { FreebuffAcpAgent } from '../acp-agent' + +import type { PrintModeEvent } from '@codebuff/common/types/print-mode' + +/** + * Drives the ACP server the way a real client (Zed, and any other ACP + * implementation) does, over the protocol itself rather than by calling the + * agent's methods: initialize -> session/new -> session/prompt, and checks + * that the agent's `session/update` notifications carry the turn's content. + */ +function buildAgentApp(backend: FreebuffAcpAgent) { + return acp + .agent({ name: 'freebuff-test' }) + .onRequest(acp.methods.agent.initialize, () => ({ + protocolVersion: acp.PROTOCOL_VERSION, + agentCapabilities: { loadSession: false }, + })) + .onRequest(acp.methods.agent.session.new, ({ params }) => ({ + sessionId: backend.newSession(params.cwd), + })) + .onRequest(acp.methods.agent.session.prompt, async ({ params, client }) => { + const stop = await backend.prompt(params.sessionId, params.prompt, client) + return { stopReason: stop } + }) + .onNotification(acp.methods.agent.session.cancel, ({ params }) => { + backend.cancel(params.sessionId) + }) +} + +describe('ACP protocol round trip', () => { + test('a prompt turn streams agent text to the client as session updates', async () => { + // A stub turn: the SDK run path itself is covered by the CLI's own + // integration tests; here we verify the protocol surface. + const backend = new FreebuffAcpAgent({ + runTurn: async (request) => { + await request.onEvent({ type: 'text', text: 'hello ' }) + await request.onEvent({ type: 'text', text: 'world' }) + return { previousRun: null } + }, + }) + const agentApp = buildAgentApp(backend) + + const updates: Array> = [] + + const clientApp = acp + .client({ name: 'test-client' }) + .onNotification(acp.methods.client.session.update, ({ params }) => { + updates.push(params.update as Record) + }) + + await clientApp.connectWith(agentApp, async (agent) => { + const init = await agent.request(acp.methods.agent.initialize, { + protocolVersion: acp.PROTOCOL_VERSION, + }) + expect(init.protocolVersion).toBe(acp.PROTOCOL_VERSION) + + const session = await agent.request(acp.methods.agent.session.new, { + cwd: '/tmp/project', + mcpServers: [], + }) + expect(session.sessionId).toBeTruthy() + + const response = await agent.request(acp.methods.agent.session.prompt, { + sessionId: session.sessionId, + prompt: [{ type: 'text', text: 'say hi' }], + }) + expect(response.stopReason).toBe('end_turn') + }) + + const kinds = updates.map((u) => u.sessionUpdate) + expect(kinds).toEqual(['agent_message_chunk', 'agent_message_chunk']) + expect((updates[0]!.content as { text: string }).text).toBe('hello ') + expect((updates[1]!.content as { text: string }).text).toBe('world') + }) + + test('the event mapping is what the server sends for a text event', () => { + const backend = new FreebuffAcpAgent() + const sessionId = backend.newSession('/tmp/project') + const event: PrintModeEvent = { type: 'text', text: 'hi' } + const mapped = backend.eventToUpdate(sessionId, event) + expect(mapped).toEqual({ + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'hi' }, + }, + }) + }) +}) diff --git a/cli/src/acp/__tests__/acp-stdio.test.ts b/cli/src/acp/__tests__/acp-stdio.test.ts new file mode 100644 index 0000000000..9ca3d12fef --- /dev/null +++ b/cli/src/acp/__tests__/acp-stdio.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, test } from 'bun:test' +import { spawn } from 'node:child_process' +import path from 'node:path' + +/** + * End-to-end over the real transport: spawn the CLI with `--acp` and drive it + * with an ACP client over stdio, the way an editor does. Only the handshake + * and the session lifecycle are exercised (no backend needed). + */ +const repoRoot = path.resolve(import.meta.dir, '../../../..') + +type JsonRpcMessage = { + id?: number + method?: string + result?: Record + error?: { code: number; message: string } +} + +describe('freebuff --acp over stdio', () => { + test('a client can initialize and create a session', async () => { + const child = spawn( + 'bun', + ['run', path.join(repoRoot, 'cli/src/entry.ts'), '--acp'], + { + cwd: repoRoot, + env: { + ...process.env, + NEXT_PUBLIC_CB_ENVIRONMENT: 'prod', + NEXT_PUBLIC_CODEBUFF_APP_URL: 'https://www.codebuff.com', + NEXT_PUBLIC_SUPPORT_EMAIL: 'support@freebuff.com', + NEXT_PUBLIC_POSTHOG_API_KEY: 'phc_test', + NEXT_PUBLIC_POSTHOG_HOST_URL: 'https://us.i.posthog.com', + NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: 'pk_test', + NEXT_PUBLIC_STRIPE_CUSTOMER_PORTAL: + 'https://billing.stripe.com/p/login/test', + NEXT_PUBLIC_WEB_PORT: '3000', + FREEBUFF_MODE: 'true', + }, + stdio: ['pipe', 'pipe', 'pipe'], + }, + ) + + const lines: string[] = [] + let buffer = '' + child.stdout.on('data', (chunk: Buffer) => { + buffer += chunk.toString() + let index: number + while ((index = buffer.indexOf('\n')) !== -1) { + const line = buffer.slice(0, index).trim() + buffer = buffer.slice(index + 1) + if (line) lines.push(line) + } + }) + + const send = (message: Record) => { + child.stdin.write(`${JSON.stringify(message)}\n`) + } + + const readResponses = (): JsonRpcMessage[] => + lines + .map((line) => JSON.parse(line) as JsonRpcMessage) + .filter((message) => message.id !== undefined) + + // The protocol requires the initialize exchange to complete before any + // other request, so the session is created only after the response. + send({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { protocolVersion: 1 }, + }) + + const deadline = Date.now() + 30_000 + while (Date.now() < deadline) { + if (readResponses().some((message) => message.id === 1)) break + await new Promise((resolve) => setTimeout(resolve, 100)) + } + + send({ + jsonrpc: '2.0', + id: 2, + method: 'session/new', + params: { cwd: repoRoot, mcpServers: [] }, + }) + + while (Date.now() < deadline) { + if (readResponses().some((message) => message.id === 2)) break + await new Promise((resolve) => setTimeout(resolve, 100)) + } + child.kill() + + const responses = readResponses() + + const initialize = responses.find((message) => message.id === 1) + const session = responses.find((message) => message.id === 2) + expect(initialize?.error).toBeUndefined() + expect(initialize?.result?.protocolVersion).toBe(1) + expect(session?.error).toBeUndefined() + expect(typeof session?.result?.sessionId).toBe('string') + }) +}) diff --git a/cli/src/acp/acp-agent.ts b/cli/src/acp/acp-agent.ts new file mode 100644 index 0000000000..0f4d6a47f7 --- /dev/null +++ b/cli/src/acp/acp-agent.ts @@ -0,0 +1,310 @@ +/** + * Agent Client Protocol (ACP) support for the Freebuff CLI. + * + * `freebuff --acp` serves ACP v1 over stdio (JSON-RPC, newline-delimited), so + * editors and other ACP clients (Zed, and anything speaking the protocol — + * agentclientprotocol.com) can drive this CLI as an agent subprocess. This is + * the in-tree answer to issue #319; it reuses the exact run path the TUI + * uses — `getCodebuffClient()` — so free mode's session admission, metadata + * and device signing stay identical, and the server's client allowlist still + * sees an official Freebuff CLI. + * + * The mapping is PrintModeEvent (the SDK's `handleEvent` stream) -> ACP + * `session/update`: + * text -> agent_message_chunk + * reasoning_delta -> agent_thought_chunk + * tool_call -> tool_call (pending) + * tool_result -> tool_call_update (completed, with output content) + * error -> notice + * finish -> the `session/prompt` response resolves (end_turn) + * + * Nothing here touches auth, ads or the session store: the ACP surface is a + * second front end over the same conversation machinery, with sessions kept + * in-memory per connection and continued via the SDK's `previousRun` state. + */ +import { RequestError } from '@agentclientprotocol/sdk' + +import { logger } from '../utils/logger' +import { getCodebuffClient } from '../utils/codebuff-client' +import { loadAgentDefinitions } from '../utils/local-agent-registry' +import { AGENT_MODE_TO_COST_MODE, IS_FREEBUFF } from '../utils/constants' +import { freebuffSessionMetadata } from '../utils/freebuff-session-identity' +import { getFreebuffInstanceId } from '../hooks/use-freebuff-session' + +import type { AgentContext } from '@agentclientprotocol/sdk' +import type { Logger } from '@codebuff/common/types/contracts/logger' +import type { PrintModeEvent } from '@codebuff/common/types/print-mode' +import type { RunState } from '@codebuff/sdk' +import type { ContentBlock, ToolKind } from '@agentclientprotocol/sdk' + +/** Coarse ACP tool kind per Freebuff tool name. Unknown tools stay `other`. */ +const TOOL_KINDS: Record = { + read_files: 'read', + write_file: 'edit', + edit_file: 'edit', + apply_patch: 'edit', + find_files: 'search', + code_search: 'search', + web_search: 'search', + run_terminal_command: 'execute', + update_todo_list: 'think', +} + +/** The ACP update a PrintModeEvent maps to. */ +export type AcpUpdate = + | { sessionUpdate: 'agent_message_chunk'; content: ContentBlock } + | { sessionUpdate: 'agent_thought_chunk'; content: ContentBlock } + | { + sessionUpdate: 'tool_call' + toolCallId: string + title: string + name: string + kind: ToolKind + status: 'pending' + rawInput?: unknown + } + | { + sessionUpdate: 'tool_call_update' + toolCallId: string + status: 'completed' | 'failed' + content?: Array<{ type: 'content'; content: ContentBlock }> + } + | { sessionUpdate: 'notice'; notice: string } + +/** What one turn needs from the SDK run. Injected so the protocol surface can + * be tested without a backend, and so the run path stays swappable. */ +export type AcpTurnRequest = { + prompt: string + cwd: string + previousRun: RunState | null + signal: AbortSignal + onEvent: (event: PrintModeEvent) => void | Promise +} + +export type AcpTurnResult = { + previousRun: RunState | null +} + +type AcpSession = { + cwd: string + previousRun: RunState | null + /** The in-flight turn's abort, so `session/cancel` reaches the SDK run. */ + pendingPrompt: AbortController | null + /** Tool calls opened this turn that never reached a terminal status. */ + openToolCalls: Set +} + +export class FreebuffAcpAgent { + private readonly sessions = new Map() + private readonly logger: Logger + /** Runs one turn. Replaced in tests; the default is the SDK run. */ + private readonly runTurn: (request: AcpTurnRequest) => Promise + + constructor(options: { + logger?: Logger + runTurn?: (request: AcpTurnRequest) => Promise + } = {}) { + this.logger = options.logger ?? logger + this.runTurn = options.runTurn ?? defaultRunTurn + } + + newSession(cwd: string): string { + const sessionId = crypto.randomUUID() + this.sessions.set(sessionId, { + cwd, + previousRun: null, + pendingPrompt: null, + openToolCalls: new Set(), + }) + return sessionId + } + + hasSession(sessionId: string): boolean { + return this.sessions.has(sessionId) + } + + /** Human title for a tool call, from its input. Kept deliberately small: + * ACP clients render `rawInput` themselves when they want detail. */ + toolTitle(toolName: string, input: Record): string { + const path = (input as { path?: unknown }).path + const command = (input as { command?: unknown }).command + if (typeof path === 'string') return `${toolName}: ${path}` + if (typeof command === 'string') return `${toolName}: ${command.slice(0, 80)}` + return toolName + } + + /** PrintModeEvent -> ACP update, or null when the event carries nothing a + * client can show. Pure: no I/O, so it is directly unit-testable. */ + eventToUpdate( + sessionId: string, + event: PrintModeEvent, + ): { update: AcpUpdate } | { error: string } | null { + switch (event.type) { + case 'text': + if (!event.text) return null + return { + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: event.text }, + }, + } + case 'reasoning_delta': + if (!event.text) return null + return { + update: { + sessionUpdate: 'agent_thought_chunk', + content: { type: 'text', text: event.text }, + }, + } + case 'tool_call': { + this.sessions.get(sessionId)?.openToolCalls.add(event.toolCallId) + return { + update: { + sessionUpdate: 'tool_call', + toolCallId: event.toolCallId, + title: this.toolTitle(event.toolName, event.input), + name: event.toolName, + kind: TOOL_KINDS[event.toolName] ?? 'other', + status: 'pending', + rawInput: event.input, + }, + } + } + case 'tool_result': { + this.sessions.get(sessionId)?.openToolCalls.delete(event.toolCallId) + const content: ContentBlock[] = [] + for (const part of event.output) { + if (part.type === 'json') { + content.push({ + type: 'text', + text: + typeof part.value === 'string' + ? part.value + : JSON.stringify(part.value), + }) + } else if (part.type === 'media') { + content.push({ + type: 'text', + text: `[${part.mediaType} attachment, ${part.data.length} base64 chars]`, + }) + } + } + return { + update: { + sessionUpdate: 'tool_call_update', + toolCallId: event.toolCallId, + status: 'completed', + content: content.map((c) => ({ + type: 'content' as const, + content: c, + })), + }, + } + } + case 'error': + return { error: event.message } + default: + // start, finish, subagent_start/finish, download carry nothing an ACP + // client renders as an update. + return null + } + } + + async prompt( + sessionId: string, + promptBlocks: ContentBlock[], + cx: AgentContext, + ): Promise<'end_turn' | 'cancelled'> { + const session = this.sessions.get(sessionId) + if (!session) throw RequestError.sessionNotFound(sessionId) + + const prompt = promptBlocks + .map((b) => (b.type === 'text' ? b.text : '')) + .join('\n') + .trim() + if (!prompt) { + throw RequestError.invalidParams( + {}, + 'Prompt contained no text content', + ) + } + + const abort = new AbortController() + session.pendingPrompt = abort + session.openToolCalls.clear() + + const notify = async (update: AcpUpdate) => { + await cx.notify('session/update', { sessionId, update } as never) + } + + try { + const result = await this.runTurn({ + prompt, + cwd: session.cwd, + previousRun: session.previousRun, + signal: abort.signal, + onEvent: async (event: PrintModeEvent) => { + if (abort.signal.aborted) return + const mapped = this.eventToUpdate(sessionId, event) + if (!mapped) return + if ('error' in mapped) { + await notify({ sessionUpdate: 'notice', notice: mapped.error }) + return + } + await notify(mapped.update) + }, + }) + + // Close any tool call left open so clients never show a spinner forever. + for (const toolCallId of session.openToolCalls) { + await notify({ + sessionUpdate: 'tool_call_update', + toolCallId, + status: abort.signal.aborted ? 'failed' : 'completed', + }) + } + session.openToolCalls.clear() + + session.previousRun = result.previousRun + return abort.signal.aborted ? 'cancelled' : 'end_turn' + } finally { + session.pendingPrompt = null + } + } + + cancel(sessionId: string): void { + this.sessions.get(sessionId)?.pendingPrompt?.abort() + } +} + +/** The production turn: one SDK `run` on the shared CLI client, the same path + * the TUI's send-message flow takes. */ +const defaultRunTurn = async (request: AcpTurnRequest): Promise => { + const client = await getCodebuffClient() + if (!client) throw RequestError.authRequired({}) + + const agentDefinitions = loadAgentDefinitions() + const freebuffInstanceId = IS_FREEBUFF ? getFreebuffInstanceId() : undefined + + const result = (await client.run({ + agent: 'base2-free', + prompt: request.prompt, + agentDefinitions, + previousRun: request.previousRun ?? undefined, + cwd: request.cwd, + signal: request.signal, + costMode: AGENT_MODE_TO_COST_MODE.LITE, + ...(freebuffInstanceId + ? { extraCodebuffMetadata: freebuffSessionMetadata(freebuffInstanceId) } + : {}), + handleEvent: request.onEvent, + } as never)) as RunState + + if (result.output?.type === 'error' && !request.signal.aborted) { + logger.warn( + { error: result.output.message }, + '[acp] turn ended with an error', + ) + } + return { previousRun: result } +} diff --git a/cli/src/acp/serve.ts b/cli/src/acp/serve.ts new file mode 100644 index 0000000000..7456004c28 --- /dev/null +++ b/cli/src/acp/serve.ts @@ -0,0 +1,48 @@ +/** + * `freebuff --acp` entry point: serves the Agent Client Protocol (v1) over + * stdio using the official TypeScript SDK. See acp-agent.ts for the design. + */ +import * as acp from '@agentclientprotocol/sdk' +import { Readable, Writable } from 'node:stream' + +import { logger } from '../utils/logger' +import { FreebuffAcpAgent } from './acp-agent' + +import type { Logger } from '@codebuff/common/types/contracts/logger' + +export const ACP_PROTOCOL_VERSION = acp.PROTOCOL_VERSION + +export function serveAcp(options: { logger?: Logger } = {}): void { + const log: Logger = options.logger ?? logger + + const backend = new FreebuffAcpAgent(log) + + const app = acp + .agent({ name: 'freebuff' }) + .onRequest(acp.methods.agent.initialize, () => ({ + protocolVersion: acp.PROTOCOL_VERSION, + agentCapabilities: { + loadSession: false, + }, + authMethods: [], + agentInfo: { name: 'freebuff', title: 'Freebuff', version: '1.0.0' }, + })) + .onRequest(acp.methods.agent.session.new, ({ params }) => { + const sessionId = backend.newSession(params.cwd) + return { sessionId } + }) + .onRequest(acp.methods.agent.session.prompt, async ({ params, client }) => { + const stop = await backend.prompt(params.sessionId, params.prompt, client) + return { stopReason: stop } + }) + .onNotification(acp.methods.agent.session.cancel, ({ params }) => { + backend.cancel(params.sessionId) + }) + + const stream = acp.ndJsonStream( + Writable.toWeb(process.stdout) as unknown as WritableStream, + Readable.toWeb(process.stdin) as unknown as ReadableStream, + ) + app.connect(stream) + log.info({}, '[acp] serving Agent Client Protocol v1 over stdio') +} diff --git a/cli/src/cli-args.ts b/cli/src/cli-args.ts index 1ce73347cc..fe2330ff17 100644 --- a/cli/src/cli-args.ts +++ b/cli/src/cli-args.ts @@ -18,6 +18,8 @@ export type ParsedArgs = { initialMode?: AgentMode /** Load repository `.agents` content without the trust prompt (CI opt-in). */ trustAgents: boolean + /** Serve the Agent Client Protocol (ACP) over stdio instead of the TUI. */ + acp: boolean } export function loadPackageVersion(): string { @@ -67,6 +69,10 @@ export function parseArgs({ '--trust-agents', "Load this repository's .agents files and mcp.json without asking (for CI)", ) + .option( + '--acp', + 'Serve the Agent Client Protocol (ACP) over stdio (for editors and other ACP clients)', + ) .addArgument( new Argument('[command]', 'Command to run').choices(['login']), ) @@ -97,6 +103,10 @@ export function parseArgs({ '--trust-agents', "Load this repository's .agents files and mcp.json without asking (for CI)", ) + .option( + '--acp', + 'Serve the Agent Client Protocol (ACP) over stdio (for editors and other ACP clients)', + ) .option('--lite', 'Start in LITE mode') .option('--free', 'Start in LITE mode (deprecated alias)') .option('--max', 'Start in MAX mode') @@ -141,5 +151,6 @@ export function parseArgs({ cwd: options.cwd, initialMode, trustAgents: Boolean(options.trustAgents), + acp: Boolean(options.acp), } } diff --git a/cli/src/index.tsx b/cli/src/index.tsx index bda62297e7..49c35c9e65 100644 --- a/cli/src/index.tsx +++ b/cli/src/index.tsx @@ -265,6 +265,7 @@ async function main(): Promise { cwd, initialMode, trustAgents, + acp: serveAcpFlag, } = parseArgs() const isLoginCommand = command === 'login' @@ -276,6 +277,14 @@ async function main(): Promise { // Set the auth token for the API client setApiClientAuthToken(getAuthToken()) + // ACP mode: serve the Agent Client Protocol over stdio instead of the TUI. + // Must precede the renderer setup: stdio belongs to the protocol there. + if (serveAcpFlag) { + const { serveAcp } = await import('./acp/serve') + serveAcp() + return + } + captureCliFunnel( 'launch', 'started',