From 9b26911af8510ed70c4f368bc7afae4469c5180a Mon Sep 17 00:00:00 2001 From: halfaipg Date: Wed, 29 Jul 2026 14:58:44 -0400 Subject: [PATCH] Add ACP harness support for Buzz --- README.md | 22 ++ package-lock.json | 14 +- package.json | 3 +- src/acp/server.test.ts | 138 ++++++++++++ src/acp/server.ts | 468 +++++++++++++++++++++++++++++++++++++++++ src/agent/agent.ts | 6 + src/cli.tsx | 32 ++- src/mcp/manager.ts | 10 + 8 files changed, 689 insertions(+), 4 deletions(-) create mode 100644 src/acp/server.test.ts create mode 100644 src/acp/server.ts diff --git a/README.md b/README.md index 7651519..a67414d 100644 --- a/README.md +++ b/README.md @@ -86,6 +86,27 @@ codebase project build --wait "build a launch waitlist page" OAuth uses Codebase Auto by default (`codebase/d4f`, in-house DeepSeek V4 Flash). Swap models live with `/model`. Set reasoning depth with `/effort`. `project build` hands a prompt to the web builder and prints the session, status, event stream, and preview URL when you pass `--wait`. +## Use Codebase from an ACP client + +Codebase speaks the [Agent Client Protocol](https://agentclientprotocol.com/) over stdio: + +```sh +codebase acp +``` + +For a Buzz custom harness, use: + +```text +Name: Codebase +Command: codebase +Arguments: acp +``` + +Buzz can then discover the models available to your Codebase account. Each ACP +session gets its own working directory and conversation, accepts client-supplied +MCP servers, streams assistant and tool progress, supports cancellation, and +routes write/shell approvals through the client's permission UI. + ## What makes it good - **🏁 Tournaments.** `/tournament ` races several agents on the same change in isolated worktrees, a judge ranks them, you merge the winner. `--models opus,sonnet,haiku` pits models head-to-head on *your* code. @@ -93,6 +114,7 @@ OAuth uses Codebase Auto by default (`codebase/d4f`, in-house DeepSeek V4 Flash) - **↺ Rewind anything.** `/rewind` rolls the conversation *and* the files back to before any earlier prompt — a bad turn fully un-happens. Every edit is checkpointed. - **🧠 Remembers across sessions.** Pulls durable facts (your prefs, project decisions, the rules you set) out of a session, then recalls matching notes with file/source/session/last-used/staleness labels. `#note` to add one by hand; `/memory list|show|forget` to inspect or clean them up. - **🔌 MCP.** Connect external tool servers (filesystem, Postgres, git, fetch, …) over stdio or remote HTTP, OAuth and all. Their tools splice straight into the agent. +- **ACP.** Run `codebase acp` to use Codebase from Buzz and other ACP clients, with model discovery, streamed tool activity, cancellation, permissions, and client-supplied MCP servers. - **🤖 Subagents.** Fan out read-only researchers or write-capable workers that keep their tool-noise out of your main context — each can run in its own git worktree, on its own model and reasoning level. - **🪝 Hooks.** Shell commands on lifecycle events (pre/post tool, edit, prompt, session start/end) — run a formatter on save, block secrets, commit on exit. - **🌐 SSH.** Run commands on enrolled remote hosts by name, behind the same safety validator as the local shell. diff --git a/package-lock.json b/package-lock.json index a558807..012b24c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,14 +1,15 @@ { "name": "codebase-cli", - "version": "2.0.0-pre.80", + "version": "2.0.0-pre.81", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "codebase-cli", - "version": "2.0.0-pre.80", + "version": "2.0.0-pre.81", "license": "MIT", "dependencies": { + "@agentclientprotocol/sdk": "^1.3.0", "@earendil-works/pi-agent-core": "0.74.0", "@earendil-works/pi-ai": "0.74.0", "@earendil-works/pi-tui": "^0.74.0", @@ -38,6 +39,15 @@ "node": ">=20.0.0" } }, + "node_modules/@agentclientprotocol/sdk": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@agentclientprotocol/sdk/-/sdk-1.3.0.tgz", + "integrity": "sha512-i3h/efaeuMUFAO1HSfo97QZQnnvMd7wWBYtBsdL6UMZg3a78sk3Ffya5Xu7C7tYsXomXoDXJBAzQF2PcFKAhIQ==", + "license": "Apache-2.0", + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + }, "node_modules/@alcalzone/ansi-tokenize": { "version": "0.1.3", "resolved": "https://registry.npmjs.org/@alcalzone/ansi-tokenize/-/ansi-tokenize-0.1.3.tgz", diff --git a/package.json b/package.json index d321480..125d185 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codebase-cli", - "version": "2.0.0-pre.80", + "version": "2.0.0-pre.81", "description": "Codebase CLI — a TypeScript coding agent on the pi-mono runtime. OAuth-aware, any LLM provider, single install.", "keywords": [ "ai", @@ -63,6 +63,7 @@ "prepack": "npm run build" }, "dependencies": { + "@agentclientprotocol/sdk": "^1.3.0", "@earendil-works/pi-agent-core": "0.74.0", "@earendil-works/pi-ai": "0.74.0", "@earendil-works/pi-tui": "^0.74.0", diff --git a/src/acp/server.test.ts b/src/acp/server.test.ts new file mode 100644 index 0000000..9295ce0 --- /dev/null +++ b/src/acp/server.test.ts @@ -0,0 +1,138 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { PassThrough, Readable, Writable } from "node:stream"; +import { fileURLToPath } from "node:url"; +import * as acp from "@agentclientprotocol/sdk"; +import type { Model } from "@earendil-works/pi-ai"; +import { fauxAssistantMessage, fauxToolCall, registerFauxProvider } from "@earendil-works/pi-ai"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { runAcpServer } from "./server.js"; + +const MOCK_MCP = fileURLToPath(new URL("../mcp/__test__/mock-server.mjs", import.meta.url)); + +describe("runAcpServer", () => { + let faux: ReturnType; + let model: Model; + let home: string; + let cwd: string; + let previousHome: string | undefined; + + beforeEach(() => { + previousHome = process.env.HOME; + home = mkdtempSync(join(tmpdir(), "acp-home-")); + cwd = mkdtempSync(join(tmpdir(), "acp-cwd-")); + process.env.HOME = home; + process.env.CODEBASE_NO_AUTO_MEMORY = "1"; + faux = registerFauxProvider({ + models: [ + { + id: "test-model", + name: "Test Model", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 100_000, + maxTokens: 4096, + }, + ], + tokenSize: { min: 1, max: 2 }, + }); + model = faux.models[0] as Model; + }); + + afterEach(() => { + faux.unregister(); + rmSync(home, { recursive: true, force: true }); + rmSync(cwd, { recursive: true, force: true }); + if (previousHome === undefined) delete process.env.HOME; + else process.env.HOME = previousHome; + delete process.env.CODEBASE_NO_AUTO_MEMORY; + }); + + it("runs a Buzz-compatible ACP turn with injected MCP tools and streamed updates", async () => { + faux.setResponses([ + fauxAssistantMessage([fauxToolCall("mcp__buzz__echo", { text: "from Buzz" })], { + stopReason: "toolUse", + }), + fauxAssistantMessage("posted through Buzz"), + ]); + + const toAgent = new PassThrough(); + const fromAgent = new PassThrough(); + const serverDone = runAcpServer({ + stdin: toAgent, + stdout: fromAgent, + configOverride: { model, apiKey: "faux-key", source: "byok" }, + }); + const updates: acp.SessionNotification[] = []; + const permissionRequests: acp.RequestPermissionRequest[] = []; + const stream = acp.ndJsonStream(Writable.toWeb(toAgent), Readable.toWeb(fromAgent) as ReadableStream); + + const result = await acp + .client({ name: "buzz-test" }) + .onNotification(acp.methods.client.session.update, (ctx) => { + updates.push(ctx.params); + }) + .onRequest(acp.methods.client.session.requestPermission, (ctx) => { + permissionRequests.push(ctx.params); + const allow = ctx.params.options.find((option) => option.kind === "allow_once"); + if (!allow) return { outcome: { outcome: "cancelled" as const } }; + return { outcome: { outcome: "selected" as const, optionId: allow.optionId } }; + }) + .connectWith(stream, async (client) => { + const initialized = await client.request(acp.methods.agent.initialize, { + protocolVersion: acp.PROTOCOL_VERSION, + clientInfo: { name: "buzz-acp", version: "test" }, + }); + expect(initialized.protocolVersion).toBe(acp.PROTOCOL_VERSION); + expect(initialized.agentInfo?.name).toBe("Codebase"); + + const session = await client.request(acp.methods.agent.session.new, { + cwd, + mcpServers: [ + { + name: "buzz", + command: process.execPath, + args: [MOCK_MCP], + env: [], + }, + ], + }); + expect(session.configOptions?.[0]).toMatchObject({ + id: "model", + category: "model", + currentValue: "test-model", + }); + await client.request(acp.methods.agent.session.setConfigOption, { + sessionId: session.sessionId, + configId: "model", + value: "test-model", + }); + return client.request(acp.methods.agent.session.prompt, { + sessionId: session.sessionId, + prompt: [{ type: "text", text: "Reply through the Buzz tool." }], + }); + }); + + expect(result.stopReason).toBe("end_turn"); + expect(permissionRequests).toHaveLength(1); + expect(permissionRequests[0]?.toolCall.name).toBe("mcp__buzz__echo"); + expect(updates.some((event) => event.update.sessionUpdate === "tool_call")).toBe(true); + expect(updates.some((event) => event.update.sessionUpdate === "tool_call_update")).toBe(true); + const text = updates + .filter( + ( + event, + ): event is acp.SessionNotification & { + update: { sessionUpdate: "agent_message_chunk"; content: { type: "text"; text: string } }; + } => event.update.sessionUpdate === "agent_message_chunk" && event.update.content.type === "text", + ) + .map((event) => event.update.content.text) + .join(""); + expect(text).toContain("posted through Buzz"); + + toAgent.end(); + await serverDone; + }); +}); diff --git a/src/acp/server.ts b/src/acp/server.ts new file mode 100644 index 0000000..6f45772 --- /dev/null +++ b/src/acp/server.ts @@ -0,0 +1,468 @@ +import { randomUUID } from "node:crypto"; +import { isAbsolute, resolve } from "node:path"; +import type { Readable, Writable } from "node:stream"; +import { Readable as NodeReadable, Writable as NodeWritable } from "node:stream"; +import * as acp from "@agentclientprotocol/sdk"; +import type { AgentEvent } from "@earendil-works/pi-agent-core"; +import type { ImageContent } from "@earendil-works/pi-ai"; +import { type AgentBundle, type CreateAgentOptions, createAgent } from "../agent/agent.js"; +import type { ModelOption } from "../agent/model-list.js"; +import type { NamedServer } from "../mcp/config.js"; +import type { PermissionRequest, ResponseChoice } from "../permissions/store.js"; +import { VERSION } from "../version.js"; + +interface AcpSession { + id: string; + bundle: AgentBundle; + promptInFlight: boolean; + cancelled: boolean; + notificationQueue: Promise; + notificationError?: unknown; + assistantText: string; + cwd: string; + mcpServers: acp.McpServer[]; + modelOptions: ModelOption[]; +} + +export interface AcpServerOptions { + stdin?: Readable; + stdout?: Writable; + stderr?: Writable; + configOverride?: CreateAgentOptions["configOverride"]; +} + +export async function runAcpServer(options: AcpServerOptions = {}): Promise { + const stdin = options.stdin ?? process.stdin; + const stdout = options.stdout ?? process.stdout; + const sessions = new Map(); + + const stream = acp.ndJsonStream(NodeWritable.toWeb(stdout), NodeReadable.toWeb(stdin) as ReadableStream); + + const app = acp + .agent({ name: "codebase-cli" }) + .onRequest("initialize", (ctx) => initialize(ctx.params)) + .onRequest("session/new", (ctx) => newSession(ctx.params)) + .onRequest("session/set_config_option", (ctx) => setSessionConfigOption(ctx.params)) + .onRequest("session/prompt", (ctx) => prompt(ctx.params, ctx.client)) + .onNotification("session/cancel", (ctx) => cancel(ctx.params)); + + const connection = app.connect(stream); + await connection.closed; + for (const session of sessions.values()) disposeSession(session); + return 0; + + function initialize(params: acp.InitializeRequest): acp.InitializeResponse { + return { + protocolVersion: Math.min(params.protocolVersion, acp.PROTOCOL_VERSION), + agentInfo: { name: "Codebase", version: VERSION }, + agentCapabilities: { + loadSession: false, + promptCapabilities: { + image: true, + embeddedContext: true, + }, + mcpCapabilities: { + http: true, + }, + }, + }; + } + + async function newSession(params: acp.NewSessionRequest): Promise { + if (!isAbsolute(params.cwd)) throw new Error("ACP session cwd must be an absolute path"); + + const bundle = createAgent({ + cwd: params.cwd, + autoApprove: false, + persistSession: false, + configOverride: options.configOverride, + }); + + try { + await connectMcp(bundle, params.mcpServers, params.cwd); + } catch (error) { + disposeBundle(bundle); + throw error; + } + + const id = randomUUID(); + const session: AcpSession = { + id, + bundle, + promptInFlight: false, + cancelled: false, + notificationQueue: Promise.resolve(), + assistantText: "", + cwd: params.cwd, + mcpServers: [...params.mcpServers], + modelOptions: await discoverModels(bundle), + }; + sessions.set(id, session); + return { sessionId: id, configOptions: [modelConfig(session)] }; + } + + async function setSessionConfigOption( + params: acp.SetSessionConfigOptionRequest, + ): Promise { + const session = sessions.get(params.sessionId); + if (!session) throw new Error(`ACP session not found: ${params.sessionId}`); + if (session.promptInFlight) throw new Error("cannot change the model while a prompt is in flight"); + if (params.configId !== "model" || typeof params.value !== "string") { + throw new Error(`unsupported ACP session config option: ${params.configId}`); + } + const selected = session.modelOptions.find((option) => option.id === params.value); + if (!selected) throw new Error(`unknown model: ${params.value}`); + if (session.bundle.model.id === selected.id) return { configOptions: [modelConfig(session)] }; + + const previous = session.bundle; + const next = createAgent({ + cwd: session.cwd, + autoApprove: false, + persistSession: false, + initialMessages: [...previous.agent.state.messages], + taskListId: previous.sessions.id, + modelOverride: { provider: selected.provider, modelId: selected.id }, + configOverride: options.configOverride, + }); + try { + await connectMcp(next, session.mcpServers, session.cwd); + } catch (error) { + disposeBundle(next); + throw error; + } + session.bundle = next; + disposeBundle(previous); + return { configOptions: [modelConfig(session)] }; + } + + async function prompt(params: acp.PromptRequest, client: acp.AgentContext): Promise { + const session = sessions.get(params.sessionId); + if (!session) throw new Error(`ACP session not found: ${params.sessionId}`); + if (session.promptInFlight) throw new Error("a prompt is already in flight for this session"); + + session.promptInFlight = true; + session.cancelled = false; + session.notificationError = undefined; + session.assistantText = ""; + + const { text, images } = promptContent(params.prompt); + const permissionTasks = new Set>(); + const unsubscribeEvents = session.bundle.subscribe((event) => translateEvent(session, event, client)); + const unsubscribePermissions = session.bundle.permissions.subscribe((request) => { + if (!request) return; + const task = requestPermission(session, request, client).finally(() => permissionTasks.delete(task)); + permissionTasks.add(task); + }); + + try { + const result = await session.bundle.submitUserPrompt(text, images); + await Promise.all(permissionTasks); + await session.notificationQueue; + if (session.notificationError) throw session.notificationError; + if (session.cancelled) return { stopReason: "cancelled" }; + if (!result.submitted) { + await notifyText(session, client, result.reason ?? "Prompt blocked by Codebase policy."); + await session.notificationQueue; + return { stopReason: "refusal" }; + } + if (result.error) throw new Error(result.error); + return { stopReason: "end_turn" }; + } finally { + unsubscribeEvents(); + unsubscribePermissions(); + session.promptInFlight = false; + } + } + + function cancel(params: acp.CancelNotification): void { + const session = sessions.get(params.sessionId); + if (!session) return; + session.cancelled = true; + session.bundle.agent.abort(); + } + + function translateEvent(session: AcpSession, event: AgentEvent, client: acp.AgentContext): void { + switch (event.type) { + case "message_start": + if (event.message.role === "assistant") session.assistantText = ""; + break; + case "message_update": + case "message_end": { + if (event.message.role !== "assistant") break; + const current = assistantMessageText(event.message); + const delta = current.startsWith(session.assistantText) + ? current.slice(session.assistantText.length) + : current; + session.assistantText = current; + if (delta) void notifyText(session, client, delta); + break; + } + case "tool_execution_start": + queueNotification(session, client, { + sessionUpdate: "tool_call", + toolCallId: event.toolCallId, + title: toolTitle(event.toolName, event.args), + name: event.toolName, + kind: toolKind(event.toolName), + status: "in_progress", + locations: toolLocations(event.args, session.bundle.toolContext.cwd), + rawInput: event.args, + }); + break; + case "tool_execution_update": + queueNotification(session, client, { + sessionUpdate: "tool_call_update", + toolCallId: event.toolCallId, + status: "in_progress", + rawOutput: boundedValue(event.partialResult), + }); + break; + case "tool_execution_end": + queueNotification(session, client, { + sessionUpdate: "tool_call_update", + toolCallId: event.toolCallId, + status: event.isError ? "failed" : "completed", + rawOutput: boundedValue(event.result), + }); + break; + } + } + + async function requestPermission( + session: AcpSession, + request: PermissionRequest, + client: acp.AgentContext, + ): Promise { + try { + const response = await client.request(acp.methods.client.session.requestPermission, { + sessionId: session.id, + toolCall: { + toolCallId: request.id, + title: request.summary, + name: request.tool, + kind: toolKind(request.tool), + status: "pending", + rawInput: { + reason: request.reason, + detail: request.detail, + risk: request.risk, + }, + }, + options: [ + { optionId: "allow-once", name: "Allow once", kind: "allow_once" }, + { + optionId: "trust-tool", + name: request.trustScope ? `Always allow ${request.trustScope}` : "Always allow this tool", + kind: "allow_always", + }, + { optionId: "deny", name: "Deny", kind: "reject_once" }, + ], + }); + const choice: ResponseChoice = + response.outcome.outcome === "selected" + ? response.outcome.optionId === "allow-once" + ? "allow-once" + : response.outcome.optionId === "trust-tool" + ? "trust-tool" + : "deny" + : "deny"; + session.bundle.permissions.respond(request.id, choice); + } catch (error) { + session.bundle.permissions.respond(request.id, "deny"); + throw error; + } + } + + function queueNotification(session: AcpSession, client: acp.AgentContext, update: acp.SessionUpdate): void { + session.notificationQueue = session.notificationQueue + .then(() => + client.notify(acp.methods.client.session.update, { + sessionId: session.id, + update, + }), + ) + .catch((error: unknown) => { + session.notificationError = error; + }); + } + + async function notifyText(session: AcpSession, client: acp.AgentContext, text: string): Promise { + queueNotification(session, client, { + sessionUpdate: "agent_message_chunk", + messageId: `assistant-${session.id}`, + content: { type: "text", text }, + }); + } + + function disposeSession(session: AcpSession): void { + session.bundle.agent.abort(); + disposeBundle(session.bundle); + } + + function disposeBundle(bundle: AgentBundle): void { + bundle.mcp.dispose(); + bundle.checkpoints.dispose(); + bundle.toolContext.tasks.dispose(); + } + + async function connectMcp(bundle: AgentBundle, servers: readonly acp.McpServer[], cwd: string): Promise { + await bundle.connectMcp(); + const supplied = toNamedServers(servers, cwd); + const existingToolCount = bundle.mcp.tools().length; + await bundle.mcp.connectServers(supplied); + const suppliedNames = new Set(supplied.map((server) => server.name)); + const failed = bundle.mcp + .status() + .filter((status) => suppliedNames.has(status.name) && !status.connected) + .map((status) => `${status.name}: ${status.error ?? "connection failed"}`); + if (failed.length > 0) throw new Error(`ACP-provided MCP server failed: ${failed.join("; ")}`); + const suppliedTools = bundle.mcp.tools().slice(existingToolCount); + if (suppliedTools.length > 0) { + bundle.agent.state.tools = [...bundle.agent.state.tools, ...suppliedTools]; + } + } + + async function discoverModels(bundle: AgentBundle): Promise { + const current = { + id: bundle.model.id, + name: bundle.model.name, + provider: String(bundle.model.provider), + }; + try { + const discovered = await bundle.availableModels(AbortSignal.timeout(5_000)); + const byId = new Map(discovered.map((option) => [option.id, option])); + if (!byId.has(current.id)) byId.set(current.id, current); + return [...byId.values()]; + } catch { + return [current]; + } + } + + function modelConfig(session: AcpSession): acp.SessionConfigOption { + return { + type: "select", + id: "model", + name: "Model", + category: "model", + currentValue: session.bundle.model.id, + options: session.modelOptions.map((option) => ({ + value: option.id, + name: option.name, + description: option.provider, + })), + }; + } +} + +function toNamedServers(servers: readonly acp.McpServer[], cwd: string): NamedServer[] { + return servers.map((server) => { + if ("type" in server) { + if (server.type === "http") { + return { + name: server.name, + transport: "http", + spec: { + url: server.url, + headers: Object.fromEntries(server.headers.map((header) => [header.name, header.value])), + }, + }; + } + throw new Error(`unsupported ACP MCP transport "${server.type}" for server "${server.name}"`); + } + return { + name: server.name, + transport: "stdio", + spec: { + command: server.command, + args: server.args, + env: Object.fromEntries(server.env.map((entry) => [entry.name, entry.value])), + cwd, + }, + }; + }); +} + +function promptContent(blocks: readonly acp.ContentBlock[]): { text: string; images?: ImageContent[] } { + const text: string[] = []; + const images: ImageContent[] = []; + for (const block of blocks) { + switch (block.type) { + case "text": + text.push(block.text); + break; + case "image": + images.push({ type: "image", data: block.data, mimeType: block.mimeType }); + break; + case "resource_link": + text.push(`[Reference: ${block.name}](${block.uri})`); + break; + case "resource": + if ("text" in block.resource) { + text.push(`Reference ${block.resource.uri}:\n${block.resource.text}`); + } else { + text.push(`[Binary reference: ${block.resource.uri}]`); + } + break; + case "audio": + break; + } + } + return { text: text.join("\n\n").trim(), images: images.length > 0 ? images : undefined }; +} + +function assistantMessageText(message: unknown): string { + if (!message || typeof message !== "object") return ""; + const content = (message as { content?: unknown }).content; + if (typeof content === "string") return content; + if (!Array.isArray(content)) return ""; + return content + .filter( + (block): block is { type: "text"; text: string } => + typeof block === "object" && + block !== null && + (block as { type?: unknown }).type === "text" && + typeof (block as { text?: unknown }).text === "string", + ) + .map((block) => block.text) + .join(""); +} + +function toolKind(name: string): acp.ToolKind { + if (/^(read_file|list_files|glob|grep|git_status|git_diff|git_log|code_navigation)$/.test(name)) return "read"; + if (/^(write_file|edit_file|multi_edit|notebook_edit|apply_patch)$/.test(name)) return "edit"; + if (/^(web_fetch|web_search)$/.test(name)) return "fetch"; + if (/^(shell|dispatch_agent)$/.test(name)) return "execute"; + if (/^(save_memory|update_task|create_task)$/.test(name)) return "think"; + return "other"; +} + +function toolTitle(name: string, args: unknown): string { + const target = firstString(args, ["path", "file_path", "command", "query", "url"]); + const label = name.replaceAll("_", " "); + return target ? `${label}: ${target.slice(0, 100)}` : label; +} + +function toolLocations(args: unknown, cwd: string): acp.ToolCallLocation[] | undefined { + const path = firstString(args, ["path", "file_path"]); + if (!path) return undefined; + return [{ path: isAbsolute(path) ? path : resolve(cwd, path) }]; +} + +function firstString(value: unknown, keys: readonly string[]): string | undefined { + if (!value || typeof value !== "object") return undefined; + const record = value as Record; + for (const key of keys) { + if (typeof record[key] === "string" && record[key]) return record[key]; + } + return undefined; +} + +function boundedValue(value: unknown): unknown { + if (typeof value === "string") return value.length > 100_000 ? `${value.slice(0, 100_000)}\n[truncated]` : value; + try { + const json = JSON.stringify(value); + return json.length > 100_000 ? `${json.slice(0, 100_000)}\n[truncated]` : value; + } catch { + return String(value); + } +} diff --git a/src/agent/agent.ts b/src/agent/agent.ts index ca1e3ee..28ef5ab 100644 --- a/src/agent/agent.ts +++ b/src/agent/agent.ts @@ -32,6 +32,7 @@ import type { ToolContext } from "../tools/types.js"; import { UserQueryStore } from "../user-queries/store.js"; import { type ResolvedConfig, resolveConfig } from "./config.js"; import { type Effort, resolveEffort } from "./effort.js"; +import { fetchAvailableModels, type ModelOption } from "./model-list.js"; import { buildProjectFilesAddendum } from "./project-files.js"; import { streamProxySafely } from "./safe-stream.js"; import { buildSystemPrompt } from "./system-prompt.js"; @@ -168,6 +169,8 @@ export interface AgentBundle { * mcp.json exists. */ connectMcp: () => Promise; + /** Discover models available through the resolved provider or Codebase proxy. */ + availableModels: (signal?: AbortSignal) => Promise; } export function createAgent(opts: CreateAgentOptions = {}): AgentBundle { @@ -597,6 +600,8 @@ export function createAgent(opts: CreateAgentOptions = {}): AgentBundle { } return mcp.status(); }; + const availableModels = async (signal?: AbortSignal): Promise => + fetchAvailableModels(model, await getApiKey(), signal); void agentRef; return { @@ -625,6 +630,7 @@ export function createAgent(opts: CreateAgentOptions = {}): AgentBundle { checkpoints, mcp, connectMcp, + availableModels, }; } diff --git a/src/cli.tsx b/src/cli.tsx index 532c867..ca4d12a 100755 --- a/src/cli.tsx +++ b/src/cli.tsx @@ -4,6 +4,7 @@ import { existsSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { render } from "ink"; +import { runAcpServer } from "./acp/server.js"; import { runAppServer } from "./app-server/server.js"; import { runAuthSubcommand } from "./auth/cli.js"; import { ensureFreshCredentials } from "./auth/ensure-fresh.js"; @@ -152,6 +153,13 @@ if (argv[0] === "--version" || argv[0] === "-v") { // of a working server. await ensureFreshCredentials(); runAppServer({ autoApprove: !noAutoApprove, resume }).then((code) => process.exit(code)); +} else if (argv[0] === "acp") { + if (argv.slice(1).some((a) => a === "--help" || a === "-h")) { + printAcpHelp(); + process.exit(0); + } + await ensureFreshCredentials(); + runAcpServer().then((code) => process.exit(code)); } else if (argv[0] === "run") { if (argv.slice(1).some((a) => a === "--help" || a === "-h")) { printRunHelp(); @@ -361,6 +369,7 @@ function printHelp(): void { " codebase skills show skill help (TUI: /skills)", " codebase tournament show tournament help (TUI: /tournament)", " codebase director list manage trained directors (hire, status, fire)", + " codebase acp Agent Client Protocol server on stdio", " codebase app-server JSON-RPC server on stdio (for IDE extensions)", " codebase --version print version and exit", " codebase --help show this message", @@ -403,7 +412,7 @@ function printHelpTopics(): void { process.stdout.write( [ "Help topics:", - " auth, run, auto, project, web-build, ssh, usage, doctor, mcp, receipt, bench, director, app-server", + " auth, run, auto, project, web-build, ssh, usage, doctor, mcp, receipt, bench, director, acp, app-server", " memory, permissions, agents, skills, tournament, context, model, effort, rewind", "", "Examples:", @@ -436,6 +445,9 @@ function printTopicHelp(rawTopic: string): boolean { case "app-server": printAppServerHelp(); return true; + case "acp": + printAcpHelp(); + return true; case "web-build": printWebBuildHelp(); return true; @@ -551,6 +563,24 @@ function printAppServerHelp(): void { ); } +function printAcpHelp(): void { + process.stdout.write( + [ + "usage: codebase acp", + "", + "Run Codebase as an Agent Client Protocol (ACP) agent over stdio.", + "", + "ACP clients can create isolated sessions, stream assistant and tool updates,", + "cancel turns, answer permission requests, and inject stdio or HTTP MCP servers.", + "", + "Buzz custom harness:", + " command: codebase", + " args: acp", + "", + ].join("\n"), + ); +} + function printProjectHelp(): void { process.stdout.write( [ diff --git a/src/mcp/manager.ts b/src/mcp/manager.ts index 142ed42..5336e4d 100644 --- a/src/mcp/manager.ts +++ b/src/mcp/manager.ts @@ -72,9 +72,19 @@ export class McpManager { */ async connectAll(options: LoadMcpConfigOptions = {}): Promise { const servers = loadMcpServers(options); + await this.connectServers(servers); + } + + /** + * Connect an explicit server list supplied by an embedding protocol such + * as ACP. Existing configured servers and supplied servers share the same + * lifecycle and tool bridge. + */ + async connectServers(servers: readonly NamedServer[]): Promise { await Promise.all( servers.map(async (server) => { const { name } = server; + if (this.clientsByName.has(name)) return; const client = this.makeClient(server); try { await client.connect();