// The ACP agent surface — Privateer as an Agent Client Protocol server. // // A host (Buzz's `buzz-acp` harness, Zed, or any other ACP client) spawns this // process and speaks newline-delimited JSON-RPC over stdio. The host owns the // transport, the identity, and the UI; we own the agent. // // WHAT THIS IS NOT: the host does not get to bypass the moat. Privateer's // permission gate still classifies every action and still decides what needs a // human — ACP's `session/request_permission` is only how that question is DELIVERED // and rendered. `localAsk` stays a fail-closed deny, an unrecognized answer is a // deny, and the tool ceiling is ours. The host renders; the gate rules. // // The Pi session is injected as `createSession` so this file stays Pi-free and // unit-testable against a fake (see tests/acp.test.ts). import { randomUUID } from "node:crypto"; import { AsyncLocalStorage } from "node:async_hooks"; import { PROTOCOL_VERSION, type Agent, type AgentSideConnection, type InitializeRequest, type InitializeResponse, type NewSessionRequest, type NewSessionResponse, type ModelInfo, type SetSessionModelRequest, type SetSessionModelResponse, type PromptRequest, type PromptResponse, type CancelNotification, } from "@zed-industries/agent-client-protocol"; import type { PermissionRequest } from "../permissions/gate.ts"; import type { AskOutcome } from "../permissions/modeGate.ts"; import { promptText, permissionOptions, permissionTitle, outcomeToAsk, toolKindFor, canRemember } from "./protocol.ts"; /** * What a running turn reports back. Text is the answer; the tool callbacks are * what stop a long turn looking like a hang — a host renders them as live * progress, and without them a minute of tool work shows the user nothing at all. */ export interface TurnEvents { onText(delta: string): void; onToolStart(t: { id: string; name: string }): void; onToolEnd(t: { id: string; name: string; error?: string }): void; } /** One conversation, backed by a real Pi session. */ export interface AcpSession { /** Run a turn, reporting text and tool activity through `events`. */ prompt(text: string, events: TurnEvents, signal: AbortSignal): Promise<{ ok: boolean; error?: string }>; /** Switch model mid-conversation, preserving history. Absent → not supported. */ setModel?(modelId: string): Promise; dispose?(): void | Promise; } export interface AcpDeps { createSession(cwd: string, mcpServers: unknown[]): Promise; /** * The models this agent can offer, and which one is selected. * * Hosts render this as a picker on the session; without it the host shows no * model list at all (an empty dropdown is the visible symptom of returning no * `models` from session/new). Returning undefined means "no picker", which is a * legitimate choice — but it should be a choice, not an oversight. */ models?(): { available: ModelInfo[]; currentModelId: string } | undefined; onLog?(msg: string): void; } // Which prompt content we can faithfully represent. Text and resource_link are // mandatory for every agent; we add embeddedContext because a client inlining a // file saves a round-trip and our tools can read it straight out of the prompt. // Image/audio stay false until the model path actually carries them — advertising // a capability we'd render as "[image attached]" would be a lie the client plans // around. const PROMPT_CAPABILITIES = { image: false, audio: false, embeddedContext: true }; // Per-turn context, carried across Pi's async tool hooks so the shared gate knows // which ACP session to ask. Same mechanism as the channels runtime — several // sessions can be mid-turn at once and each approval must reach the right one. interface TurnContext { sessionId: string; conn: AgentSideConnection; /** Actions the user chose "allow always" for, scoped to THIS ACP session. */ remembered: Set; } const turnCtx = new AsyncLocalStorage(); /** Identity of a gated action for the purposes of "don't ask me again". Exact * match on tool + kind + detail: a different command is a different decision. */ function rememberKey(req: PermissionRequest): string { return `${req.tool}${req.kind}${req.detail}`; } /** * The gate's remote approver, routed over ACP. * * Wire this to `GateController.remoteAsk`. Fail-closed at every step: no turn * context, a transport error, a cancelled dialog, or an unrecognized option all * resolve to "deny". */ export async function askOverAcp(req: PermissionRequest, signal?: AbortSignal): Promise { const store = turnCtx.getStore(); if (!store) { // Denying here is correct but nearly invisible in production — the human just // sees "the agent refused" with no prompt ever shown. Say so on stderr, which // hosts capture as the agent log. process.stderr.write(`acp: gate asked for "${req.tool}" with no turn context — denied\n`); return "deny"; } if (signal?.aborted) { process.stderr.write(`acp: gate asked for "${req.tool}" on an aborted turn — denied\n`); return "deny"; } // "Allow always", honoured HERE rather than in the gate. // // ModeGate deliberately never remembers a remote decision (modeGate.ts:67-75 — // "we don't let a remote operator mutate local allowlist/mode"), which is right: // a host must not be able to widen this machine's standing allowlist or edit // mode. But it means the allow_always option we offer would otherwise be inert, // re-asking for the identical command every single time. // // So we remember it ourselves, in memory, scoped to one ACP session, never // written to disk and gone when the process exits. It cannot outlive the // conversation it was granted in, and it cannot affect the TUI or the harbor. // Destructive actions never get the option in the first place — see canRemember, // which covers alwaysAsk, protected AND dangerous shell commands (the last of which // is computed inside decideAuto and carries no field on the request, so checking the // fields alone silently let `curl … | sh` become standing permission). const key = rememberKey(req); if (store.remembered.has(key)) { process.stderr.write(`acp: "${req.tool}" pre-approved for this session — not asking again\n`); return "allow"; } process.stderr.write(`acp: asking host to approve "${req.tool}"\n`); try { const res = await store.conn.requestPermission({ sessionId: store.sessionId, toolCall: { toolCallId: `perm-${randomUUID()}`, title: permissionTitle(req), kind: toolKindFor(req), status: "pending", rawInput: { tool: req.tool, detail: req.detail, path: req.path }, }, options: permissionOptions(req), }); const outcome = outcomeToAsk(res); // Defence in depth: only remember when the option was actually offered. Same // predicate as permissionOptions, so a host returning "always" for an option we // never offered still can't cache a destructive action. if (outcome === "always" && canRemember(req)) store.remembered.add(key); return outcome; } catch { // A host that can't be asked is a host that can't consent. return "deny"; } } interface Entry { session: AcpSession; cwd: string; abort?: AbortController; cancelled?: boolean; /** "Allow always" grants, scoped to this session and this process only. */ remembered: Set; } export class PrivateerAcpAgent implements Agent { private readonly sessions = new Map(); constructor( private readonly conn: AgentSideConnection, private readonly deps: AcpDeps, ) {} async initialize(params: InitializeRequest): Promise { this.log(`initialize: client protocol v${params.protocolVersion}`); return { // Echo the client's version when we support it, else ours. We only speak v1, // and the client is expected to disconnect if that's too old for it. protocolVersion: Math.min(params.protocolVersion ?? PROTOCOL_VERSION, PROTOCOL_VERSION), agentCapabilities: { // Session history lives in the host (Buzz's relay is the durable log), so // there is nothing for us to reload. Advertising loadSession would promise // a resume we cannot honour. loadSession: false, promptCapabilities: PROMPT_CAPABILITIES, }, // No ACP-level auth: this process is spawned by a host that already has // whatever credentials it needs, and Privateer's own account session is // established out of band. authMethods: [], }; } // Required by the interface, but we advertise no authMethods — so a client that // follows the spec never calls this. Throwing beats a silent no-op: a client that // believes it authenticated when it didn't is worse than one that gets an error. async authenticate(): Promise { throw new Error("this agent advertises no authentication methods"); } async newSession(params: NewSessionRequest): Promise { const cwd = params.cwd; const session = await this.deps.createSession(cwd, params.mcpServers ?? []); const sessionId = randomUUID(); this.sessions.set(sessionId, { session, cwd, remembered: new Set() }); // Advertise the model picker. A host has no other way to learn what this agent // can run — omit `models` and the host's model dropdown is simply empty. const models = this.deps.models?.(); // "requested" is deliberate: the host's cwd is NOT necessarily where the session // runs. The confinement root is process-wide (see src/acp/run.ts), which logs a // line of its own when the two differ. Saying "created in " here read as a // guarantee this layer cannot make. this.log( `session ${sessionId.slice(0, 8)} created, host requested cwd ${cwd}` + (models ? ` — ${models.available.length} model(s), current ${models.currentModelId}` : " — no model picker"), ); return { sessionId, ...(models ? { models: { currentModelId: models.currentModelId, availableModels: models.available } } : {}), }; } async setSessionModel(params: SetSessionModelRequest): Promise { const entry = this.sessions.get(params.sessionId); if (!entry) throw new Error(`unknown session: ${params.sessionId}`); if (!entry.session.setModel) throw new Error("this agent does not support switching models"); // In-place on the Pi session, so the conversation so far is preserved rather // than silently restarted under the user. await entry.session.setModel(params.modelId); this.log(`session ${params.sessionId.slice(0, 8)} switched to ${params.modelId}`); return {}; } async prompt(params: PromptRequest): Promise { const entry = this.sessions.get(params.sessionId); if (!entry) throw new Error(`unknown session: ${params.sessionId}`); const text = promptText(params.prompt ?? []); // Log every turn's arrival. Without this a silent channel is ambiguous between // "the host never dispatched the message" (its own mention/owner gating) and // "the turn ran and produced nothing" — and those have completely different // fixes. Preview only, so the log doesn't become a transcript. const short = params.sessionId.slice(0, 8); this.log(`prompt ${short}: ${text.length} chars — ${JSON.stringify(text.slice(0, 60))}`); if (!text) { this.log(`prompt ${short}: empty after flattening — nothing to run`); return { stopReason: "end_turn" }; } // One turn at a time per session: Pi sessions are stateful and interleaving two // prompts would corrupt the transcript. A second prompt cancels nothing and // simply waits its turn is NOT what we do — the host is expected to serialize, // so an overlapping prompt is a host bug we surface loudly rather than mangle. if (entry.abort) throw new Error("a turn is already running for this session"); const abort = new AbortController(); entry.abort = abort; entry.cancelled = false; const sessionId = params.sessionId; let streamed = 0; // Every notification is fire-and-forget: a host that has gone away must not // wedge the turn, and the turn's own abort is what winds it down. const notify = (update: any) => void this.conn.sessionUpdate({ sessionId, update }).catch(() => {}); const onText = (delta: string) => { if (!delta) return; streamed += delta.length; notify({ sessionUpdate: "agent_message_chunk", content: { type: "text", text: delta } }); }; // Map Privateer's tool names onto ACP's icon categories so a host can render // the right affordance. Unknown tools fall back to "other" rather than being // hidden. const kindOf = (name: string): "read" | "edit" | "execute" | "fetch" | "search" | "other" => { if (name === "bash") return "execute"; if (name === "read" || name === "ls") return "read"; if (name === "write" || name === "edit") return "edit"; if (name === "grep" || name === "find") return "search"; if (name.startsWith("web_")) return "fetch"; return "other"; }; const events: TurnEvents = { onText, onToolStart: ({ id, name }) => notify({ sessionUpdate: "tool_call", toolCallId: id, title: name, kind: kindOf(name), status: "in_progress" }), onToolEnd: ({ id, name, error }) => notify({ sessionUpdate: "tool_call_update", toolCallId: id, title: name, status: error ? "failed" : "completed", ...(error ? { content: [{ type: "content", content: { type: "text", text: error.slice(0, 500) } }] } : {}), }), }; try { const result = await turnCtx.run({ sessionId, conn: this.conn, remembered: entry.remembered }, () => entry.session.prompt(text, events, abort.signal), ); if (entry.cancelled) { this.log(`prompt ${short}: cancelled after ${streamed} chars`); return { stopReason: "cancelled" }; } if (!result.ok && result.error) { // Surface the failure as visible text — a bare stopReason gives the human // in the channel nothing to act on. onText(`\n\n⚠️ ${result.error}`); this.log(`prompt ${short}: FAILED — ${result.error}`); } else { // A turn that streamed nothing is the shape of "the channel looks dead". this.log(`prompt ${short}: done, ${streamed} chars${streamed === 0 ? " (NO OUTPUT)" : ""}`); } return { stopReason: "end_turn" }; } catch (e) { if (entry.cancelled) return { stopReason: "cancelled" }; onText(`\n\n⚠️ ${e instanceof Error ? e.message : String(e)}`); return { stopReason: "end_turn" }; } finally { entry.abort = undefined; } } async cancel(params: CancelNotification): Promise { const entry = this.sessions.get(params.sessionId); if (!entry) return; entry.cancelled = true; entry.abort?.abort(); this.log(`session ${params.sessionId.slice(0, 8)} cancelled`); } /** Release every session. Called on stdin EOF — the host has gone. */ async shutdown(): Promise { for (const [, entry] of this.sessions) { entry.abort?.abort(); try { await entry.session.dispose?.(); } catch { /* best effort */ } } this.sessions.clear(); } private log(msg: string): void { this.deps.onLog?.(msg); } }