/** * Shared ACP `Client` builder — the callback closures every ACP connection needs * (worktree-scoped FS bridge + permission/elicitation routing + session updates). * * Extracted (v2.7 audit reshape) from the byte-identical `buildClient` closures in * `acp-dispatch.ts` (one-shot) and `backends/warm-acp.ts` (warm). The two differed * only in WHERE the per-turn context comes from (a fixed dispatch vs. the warm * backend's `activeTurn`) and a trivially-equivalent permission gate — both are now * supplied via the `resolveTurn` callback, so the FS/permission/elicitation wiring * lives once. Behavior is preserved exactly: * - `sessionUpdate` drops when `resolveTurn()` returns null (between turns). * - permission/elicitation route to the UI only when BOTH a taskId AND sessionId * are present (warm always has a sessionId, so this matches its prior * `turn?.taskId` gate); otherwise the same auto-select-first / decline fallback. */ import type { Client, SessionNotification, RequestPermissionRequest, RequestPermissionResponse, ReadTextFileRequest, ReadTextFileResponse, WriteTextFileRequest, WriteTextFileResponse, CreateTerminalRequest, CreateTerminalResponse, CreateElicitationRequest, CreateElicitationResponse, } from '@agentclientprotocol/sdk'; import { readWorktreeTextFile, writeWorktreeTextFile } from './acp-client-fs.js'; import { waitForPermissionResponse, waitForElicitationResponse } from './permission-waiter.js'; /** The per-turn context an ACP `Client` closure needs, resolved lazily per call. */ export interface AcpTurnContext { /** Per-turn task id, for routing permission/elicitation prompts back to the UI. */ taskId: string | undefined; /** BooCode session id (for permission-waiter's broker frames). */ sessionId: string | undefined; /** Per-turn mode id (autonomous-mode gate in permission-waiter). */ modeId: string | undefined; /** The agent name (for permission-waiter routing). */ agent: string; /** Forward a session/update notification to the turn's event sink. */ onSessionUpdate: (params: SessionNotification) => void | Promise; } /** * Build the ACP `Client` callbacks once per connection. `resolveTurn` is called at * the moment each callback fires and returns the live turn context (or null when no * turn is active — `sessionUpdate` then drops, matching the warm backend's * between-turns behavior). The FS bridge is scoped to `worktreePath`. */ export function buildAcpClient(worktreePath: string, resolveTurn: () => AcpTurnContext | null): Client { return { sessionUpdate: async (params: SessionNotification): Promise => { const turn = resolveTurn(); if (!turn) return; // between turns — drop (no orphan settles a future turn) await turn.onSessionUpdate(params); }, requestPermission: async (params: RequestPermissionRequest): Promise => { const turn = resolveTurn(); if (turn && turn.taskId && turn.sessionId) { return waitForPermissionResponse(turn.taskId, turn.sessionId, turn.agent, turn.modeId, params); } const firstOption = params.options[0]; if (firstOption) return { outcome: { outcome: 'selected', optionId: firstOption.optionId } }; return { outcome: { outcome: 'cancelled' } }; }, readTextFile: async (params: ReadTextFileRequest): Promise => { const content = await readWorktreeTextFile(worktreePath, params.path, params.line, params.limit); return { content }; }, writeTextFile: async (params: WriteTextFileRequest): Promise => { await writeWorktreeTextFile(worktreePath, params.path, params.content); return {}; }, createTerminal: async (_params: CreateTerminalRequest): Promise => { return { terminalId: 'noop' }; }, unstable_createElicitation: async (params: CreateElicitationRequest): Promise => { const turn = resolveTurn(); if (turn && turn.taskId && turn.sessionId) { return waitForElicitationResponse(turn.taskId, turn.sessionId, turn.agent, turn.modeId, params); } return { action: 'decline' }; }, }; }