UNPKG

consortium

Version:

Remote control and session sharing CLI for AI coding agents

1,320 lines (1,316 loc) 53.1 kB
import { spawn } from 'node:child_process'; import { ndJsonStream, ClientSideConnection } from '@agentclientprotocol/sdk'; import { randomUUID } from 'node:crypto'; import { l as logger, b as packageJson, g as delay } from './types-DETLaopx.mjs'; import { s as scanToolResultForMedia } from './killSwitch-4ZtkkeeR.mjs'; const DEFAULT_TIMEOUTS = { /** Default initialization timeout: 60 seconds */ init: 6e4, /** Default tool call timeout: 2 minutes */ toolCall: 12e4, /** Think tool timeout: 30 seconds */ think: 3e4 }; class DefaultTransport { agentName; constructor(agentName = "generic-acp") { this.agentName = agentName; } /** * Default init timeout: 60 seconds */ getInitTimeout() { return DEFAULT_TIMEOUTS.init; } /** * Default: pass through all lines that are valid JSON objects/arrays */ filterStdoutLine(line) { const trimmed = line.trim(); if (!trimmed) { return null; } if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) { return null; } try { const parsed = JSON.parse(trimmed); if (typeof parsed !== "object" || parsed === null) { return null; } return line; } catch { return null; } } /** * Default: no special stderr handling */ handleStderr(_text, _context) { return { message: null }; } /** * Default: no special tool patterns */ getToolPatterns() { return []; } /** * Default: no investigation tools */ isInvestigationTool(_toolCallId, _toolKind) { return false; } /** * Default tool call timeout based on tool kind */ getToolCallTimeout(_toolCallId, toolKind) { if (toolKind === "think") { return DEFAULT_TIMEOUTS.think; } return DEFAULT_TIMEOUTS.toolCall; } /** * Default: no tool name extraction (return null) */ extractToolNameFromId(_toolCallId) { return null; } /** * Default: return original tool name (no special detection) */ determineToolName(toolName, _toolCallId, _input, _context) { return toolName; } } const TOOL_CALL_XML = /<tool_call>[\s\S]*?<\/tool_call>/gi; const TOOL_CALL_PARAM = /<parameter\s+name="[^"]*">[\s\S]*?<\/parameter>/gi; const TOOL_CALL_OPEN_TRUNCATED = /<tool_call>[\s\S]*$/i; const INPUT_OUTPUT_BLOCK = /(^|\n)INPUT\s*\n\{[\s\S]*?\n\}\s*\nOUTPUT\s*\n(?:\[[\s\S]*?\n\]|\{[\s\S]*?\n\})/g; const BARE_INPUT_OUTPUT_LINE = /^(INPUT|OUTPUT)\s*$/gm; const FENCED_INPUT_OUTPUT = /```(?:json|jsonc|js)?\s*\n?(?:INPUT|OUTPUT)[\s\S]*?```/gi; function scrubAgentMessageText(input) { if (!input || input.length < 6) return input; let text = input; text = text.replace(TOOL_CALL_XML, ""); text = text.replace(TOOL_CALL_PARAM, ""); text = text.replace(TOOL_CALL_OPEN_TRUNCATED, ""); text = text.replace(FENCED_INPUT_OUTPUT, ""); text = text.replace(INPUT_OUTPUT_BLOCK, ""); text = text.replace(BARE_INPUT_OUTPUT_LINE, ""); text = text.replace(/\n{3,}/g, "\n\n").trim(); return text; } const DEFAULT_IDLE_TIMEOUT_MS = 500; const DEFAULT_TOOL_CALL_TIMEOUT_MS = 12e4; function parseArgsFromContent(content) { if (Array.isArray(content)) { return { items: content }; } if (content && typeof content === "object" && content !== null) { return content; } return {}; } function extractErrorDetail(content) { if (!content) return void 0; if (typeof content === "string") { return content; } if (typeof content === "object" && content !== null && !Array.isArray(content)) { const obj = content; if (obj.error) { const error = obj.error; if (typeof error === "string") return error; if (error && typeof error === "object" && "message" in error) { const errObj = error; if (typeof errObj.message === "string") return errObj.message; } return JSON.stringify(error); } if (typeof obj.message === "string") return obj.message; const status = typeof obj.status === "string" ? obj.status : void 0; const reason = typeof obj.reason === "string" ? obj.reason : void 0; return status || reason || JSON.stringify(obj).substring(0, 500); } return void 0; } function formatDuration(startTime) { if (!startTime) return "unknown"; const duration = Date.now() - startTime; return `${(duration / 1e3).toFixed(2)}s`; } function formatDurationMinutes(startTime) { if (!startTime) return "unknown"; const duration = Date.now() - startTime; return (duration / 1e3 / 60).toFixed(2); } function extractInlineMediaBlocks(content) { const out = []; const visit = (block) => { if (!block || typeof block !== "object") return; const b = block; const t = b.type; if ((t === "image" || t === "audio") && typeof b.data === "string") { const mime = typeof b.mimeType === "string" ? b.mimeType : t === "image" ? "image/png" : "audio/mpeg"; out.push({ mime, base64: b.data }); return; } if (t === "resource" && b.resource && typeof b.resource === "object") { const r = b.resource; if (typeof r.blob === "string") { const mime = typeof r.mimeType === "string" ? r.mimeType : "application/octet-stream"; const name = typeof r.uri === "string" ? r.uri.split("/").pop() : void 0; out.push({ mime, base64: r.blob, name }); } return; } }; if (Array.isArray(content)) { for (const c of content) visit(c); } else if (content && typeof content === "object") { const c = content; if (Array.isArray(c.content)) { for (const x of c.content) visit(x); } else { visit(content); } } return out; } function handleAgentMessageChunk(update, ctx) { const content = update.content; for (const m of extractInlineMediaBlocks(content)) { ctx.emit({ type: "inline-media", mime: m.mime, base64: m.base64, name: m.name }); } if (!content || typeof content !== "object" || !("text" in content)) { return { handled: false }; } const rawText = content.text; if (typeof rawText !== "string") { return { handled: false }; } const isThinking = /^\*\*[^*]+\*\*\n/.test(rawText); if (isThinking) { ctx.emit({ type: "event", name: "thinking", payload: { text: rawText } }); } else { const text = scrubAgentMessageText(rawText); if (text.length === 0) { return { handled: true }; } logger.debug(`[AcpBackend] Received message chunk (length: ${text.length}, raw: ${rawText.length}): ${text.substring(0, 50)}...`); ctx.emit({ type: "model-output", textDelta: text }); ctx.clearIdleTimeout(); const idleTimeoutMs = ctx.transport.getIdleTimeout?.() ?? DEFAULT_IDLE_TIMEOUT_MS; ctx.setIdleTimeout(() => { if (ctx.activeToolCalls.size === 0) { logger.debug("[AcpBackend] No more chunks received, emitting idle status"); ctx.emitIdleStatus(); } else { logger.debug(`[AcpBackend] Delaying idle status - ${ctx.activeToolCalls.size} active tool calls`); } }, idleTimeoutMs); } return { handled: true }; } function handleAgentThoughtChunk(update, ctx) { const content = update.content; if (!content || typeof content !== "object" || !("text" in content)) { return { handled: false }; } const text = content.text; if (typeof text !== "string") { return { handled: false }; } if (ctx.activeToolCalls.size > 0) { const activeToolCallsList = Array.from(ctx.activeToolCalls); logger.debug(`[AcpBackend] \u{1F4AD} Thinking chunk received (${text.length} chars) during active tool calls: ${activeToolCallsList.join(", ")}`); } ctx.emit({ type: "event", name: "thinking", payload: { text } }); return { handled: true }; } function startToolCall(toolCallId, toolKind, update, ctx, source) { const startTime = Date.now(); const toolKindStr = typeof toolKind === "string" ? toolKind : void 0; const isInvestigation = ctx.transport.isInvestigationTool?.(toolCallId, toolKindStr) ?? false; const extractedName = ctx.transport.extractToolNameFromId?.(toolCallId); const realToolName = extractedName ?? (toolKindStr || "unknown"); ctx.toolCallIdToNameMap.set(toolCallId, realToolName); ctx.activeToolCalls.add(toolCallId); ctx.toolCallStartTimes.set(toolCallId, startTime); logger.debug(`[AcpBackend] \u23F1\uFE0F Set startTime for ${toolCallId} at ${new Date(startTime).toISOString()} (from ${source})`); logger.debug(`[AcpBackend] \u{1F527} Tool call START: ${toolCallId} (${toolKind} -> ${realToolName})${isInvestigation ? " [INVESTIGATION TOOL]" : ""}`); if (isInvestigation) { logger.debug(`[AcpBackend] \u{1F50D} Investigation tool detected - extended timeout (10min) will be used`); } const timeoutMs = ctx.transport.getToolCallTimeout?.(toolCallId, toolKindStr) ?? DEFAULT_TOOL_CALL_TIMEOUT_MS; if (!ctx.toolCallTimeouts.has(toolCallId)) { const timeout = setTimeout(() => { const duration = formatDuration(ctx.toolCallStartTimes.get(toolCallId)); logger.debug(`[AcpBackend] \u23F1\uFE0F Tool call TIMEOUT (from ${source}): ${toolCallId} (${toolKind}) after ${(timeoutMs / 1e3).toFixed(0)}s - Duration: ${duration}, removing from active set`); ctx.activeToolCalls.delete(toolCallId); ctx.toolCallStartTimes.delete(toolCallId); ctx.toolCallTimeouts.delete(toolCallId); if (ctx.activeToolCalls.size === 0) { logger.debug("[AcpBackend] No more active tool calls after timeout, emitting idle status"); ctx.emitIdleStatus(); } }, timeoutMs); ctx.toolCallTimeouts.set(toolCallId, timeout); logger.debug(`[AcpBackend] \u23F1\uFE0F Set timeout for ${toolCallId}: ${(timeoutMs / 1e3).toFixed(0)}s${isInvestigation ? " (investigation tool)" : ""}`); } else { logger.debug(`[AcpBackend] Timeout already set for ${toolCallId}, skipping`); } ctx.clearIdleTimeout(); ctx.emit({ type: "status", status: "running" }); const args = parseArgsFromContent(update.rawInput ?? update.content); if (update.locations && Array.isArray(update.locations)) { args.locations = update.locations; } if (isInvestigation && args.objective) { logger.debug(`[AcpBackend] \u{1F50D} Investigation tool objective: ${String(args.objective).substring(0, 100)}...`); } ctx.emit({ type: "tool-call", toolName: realToolName, args, callId: toolCallId }); } function completeToolCall(toolCallId, toolKind, content, ctx) { const startTime = ctx.toolCallStartTimes.get(toolCallId); const duration = formatDuration(startTime); const toolKindStr = ctx.toolCallIdToNameMap.get(toolCallId) ?? (typeof toolKind === "string" ? toolKind : "unknown"); ctx.activeToolCalls.delete(toolCallId); ctx.toolCallStartTimes.delete(toolCallId); const timeout = ctx.toolCallTimeouts.get(toolCallId); if (timeout) { clearTimeout(timeout); ctx.toolCallTimeouts.delete(toolCallId); } logger.debug(`[AcpBackend] \u2705 Tool call COMPLETED: ${toolCallId} (${toolKindStr}) - Duration: ${duration}. Active tool calls: ${ctx.activeToolCalls.size}`); for (const m of extractInlineMediaBlocks(content)) { ctx.emit({ type: "inline-media", mime: m.mime, base64: m.base64, name: m.name }); } void scanToolResultForMedia(content, (m) => { ctx.emit({ type: "inline-media", mime: m.mime, base64: Buffer.from(m.bytes).toString("base64"), name: m.name }); }).catch((err) => { logger.debug("[AcpBackend] scanToolResultForMedia failed:", err); }); ctx.emit({ type: "tool-result", toolName: toolKindStr, result: content, callId: toolCallId }); if (ctx.activeToolCalls.size === 0) { ctx.clearIdleTimeout(); logger.debug("[AcpBackend] All tool calls completed, emitting idle status"); ctx.emitIdleStatus(); } } function failToolCall(toolCallId, status, toolKind, content, ctx) { const startTime = ctx.toolCallStartTimes.get(toolCallId); const duration = startTime ? Date.now() - startTime : null; const toolKindStr = ctx.toolCallIdToNameMap.get(toolCallId) ?? (typeof toolKind === "string" ? toolKind : "unknown"); const isInvestigation = ctx.transport.isInvestigationTool?.(toolCallId, toolKindStr) ?? false; const hadTimeout = ctx.toolCallTimeouts.has(toolCallId); if (isInvestigation) { const durationStr2 = formatDuration(startTime); const durationMinutes = formatDurationMinutes(startTime); logger.debug(`[AcpBackend] \u{1F50D} Investigation tool ${status.toUpperCase()} after ${durationMinutes} minutes (${durationStr2})`); if (duration) { const threeMinutes = 3 * 60 * 1e3; const tolerance = 5e3; if (Math.abs(duration - threeMinutes) < tolerance) { logger.debug(`[AcpBackend] \u{1F50D} \u26A0\uFE0F Investigation tool failed at ~3 minutes - likely Gemini CLI timeout, not our timeout`); } } logger.debug(`[AcpBackend] \u{1F50D} Investigation tool FAILED - full content:`, JSON.stringify(content, null, 2)); logger.debug(`[AcpBackend] \u{1F50D} Investigation tool timeout status BEFORE cleanup: ${hadTimeout ? "timeout was set" : "no timeout was set"}`); logger.debug(`[AcpBackend] \u{1F50D} Investigation tool startTime status BEFORE cleanup: ${startTime ? `set at ${new Date(startTime).toISOString()}` : "not set"}`); } ctx.activeToolCalls.delete(toolCallId); ctx.toolCallStartTimes.delete(toolCallId); const timeout = ctx.toolCallTimeouts.get(toolCallId); if (timeout) { clearTimeout(timeout); ctx.toolCallTimeouts.delete(toolCallId); logger.debug(`[AcpBackend] Cleared timeout for ${toolCallId} (tool call ${status})`); } else { logger.debug(`[AcpBackend] No timeout found for ${toolCallId} (tool call ${status}) - timeout may not have been set`); } const durationStr = formatDuration(startTime); logger.debug(`[AcpBackend] \u274C Tool call ${status.toUpperCase()}: ${toolCallId} (${toolKindStr}) - Duration: ${durationStr}. Active tool calls: ${ctx.activeToolCalls.size}`); const errorDetail = extractErrorDetail(content); if (errorDetail) { logger.debug(`[AcpBackend] \u274C Tool call error details: ${errorDetail.substring(0, 500)}`); } else { logger.debug(`[AcpBackend] \u274C Tool call ${status} but no error details in content`); } ctx.emit({ type: "tool-result", toolName: toolKindStr, result: errorDetail ? { error: errorDetail, status } : { error: `Tool call ${status}`, status }, callId: toolCallId }); if (ctx.activeToolCalls.size === 0) { ctx.clearIdleTimeout(); logger.debug("[AcpBackend] All tool calls completed/failed, emitting idle status"); ctx.emitIdleStatus(); } } function handleToolCallUpdate(update, ctx) { const status = update.status; const toolCallId = update.toolCallId; if (!toolCallId) { logger.debug("[AcpBackend] Tool call update without toolCallId:", update); return { handled: false }; } const toolKind = update.kind || "unknown"; let toolCallCountSincePrompt = ctx.toolCallCountSincePrompt; if (status === "in_progress" || status === "pending") { if (!ctx.activeToolCalls.has(toolCallId)) { toolCallCountSincePrompt++; startToolCall(toolCallId, toolKind, update, ctx, "tool_call_update"); } else { logger.debug(`[AcpBackend] Tool call ${toolCallId} already tracked, status: ${status}`); } } else if (status === "completed") { completeToolCall(toolCallId, toolKind, update.content, ctx); } else if (status === "failed" || status === "cancelled") { failToolCall(toolCallId, status, toolKind, update.content, ctx); } return { handled: true, toolCallCountSincePrompt }; } function handleToolCall(update, ctx) { const toolCallId = update.toolCallId; const status = update.status; logger.debug(`[AcpBackend] Received tool_call: toolCallId=${toolCallId}, status=${status}, kind=${update.kind}`); const isInProgress = !status || status === "in_progress" || status === "pending"; if (!toolCallId || !isInProgress) { logger.debug(`[AcpBackend] Tool call ${toolCallId} not in progress (status: ${status}), skipping`); return { handled: false }; } if (ctx.activeToolCalls.has(toolCallId)) { logger.debug(`[AcpBackend] Tool call ${toolCallId} already in active set, skipping`); return { handled: true }; } startToolCall(toolCallId, update.kind, update, ctx, "tool_call"); return { handled: true }; } function handleLegacyMessageChunk(update, ctx) { if (!update.messageChunk) { return { handled: false }; } const chunk = update.messageChunk; if (chunk.textDelta) { ctx.emit({ type: "model-output", textDelta: chunk.textDelta }); return { handled: true }; } return { handled: false }; } function handlePlanUpdate(update, ctx) { if (!update.plan) { return { handled: false }; } ctx.emit({ type: "event", name: "plan", payload: update.plan }); return { handled: true }; } function handleThinkingUpdate(update, ctx) { if (!update.thinking) { return { handled: false }; } ctx.emit({ type: "event", name: "thinking", payload: update.thinking }); return { handled: true }; } const RETRY_CONFIG = { /** Maximum number of retry attempts for init/newSession */ maxAttempts: 3, /** Base delay between retries in ms */ baseDelayMs: 1e3, /** Maximum delay between retries in ms */ maxDelayMs: 5e3 }; function nodeToWebStreams(stdin, stdout) { const writable = new WritableStream({ write(chunk) { return new Promise((resolve, reject) => { const ok = stdin.write(chunk, (err) => { if (err) { logger.debug(`[AcpBackend] Error writing to stdin:`, err); reject(err); } }); if (ok) { resolve(); } else { stdin.once("drain", resolve); } }); }, close() { return new Promise((resolve) => { stdin.end(resolve); }); }, abort(reason) { stdin.destroy(reason instanceof Error ? reason : new Error(String(reason))); } }); const readable = new ReadableStream({ start(controller) { stdout.on("data", (chunk) => { controller.enqueue(new Uint8Array(chunk)); }); stdout.on("end", () => { controller.close(); }); stdout.on("error", (err) => { logger.debug(`[AcpBackend] Stdout error:`, err); controller.error(err); }); }, cancel() { stdout.destroy(); } }); return { writable, readable }; } async function withRetry(operation, options) { let lastError = null; for (let attempt = 1; attempt <= options.maxAttempts; attempt++) { try { return await operation(); } catch (error) { lastError = error instanceof Error ? error : new Error(String(error)); if (attempt < options.maxAttempts) { const delayMs = Math.min( options.baseDelayMs * Math.pow(2, attempt - 1), options.maxDelayMs ); logger.debug(`[AcpBackend] ${options.operationName} failed (attempt ${attempt}/${options.maxAttempts}): ${lastError.message}. Retrying in ${delayMs}ms...`); options.onRetry?.(attempt, lastError); await delay(delayMs); } } } throw lastError; } class AcpBackend { constructor(options) { this.options = options; this.transport = options.transportHandler ?? new DefaultTransport(options.agentName); } listeners = []; process = null; connection = null; acpSessionId = null; disposed = false; /** Track active tool calls to prevent duplicate events */ activeToolCalls = /* @__PURE__ */ new Set(); toolCallTimeouts = /* @__PURE__ */ new Map(); /** Track tool call start times for performance monitoring */ toolCallStartTimes = /* @__PURE__ */ new Map(); /** Pending permission requests that need response */ pendingPermissions = /* @__PURE__ */ new Map(); /** Map from permission request ID to real tool call ID for tracking */ permissionToToolCallMap = /* @__PURE__ */ new Map(); /** Map from real tool call ID to tool name for auto-approval */ toolCallIdToNameMap = /* @__PURE__ */ new Map(); /** Track if we just sent a prompt with change_title instruction */ recentPromptHadChangeTitle = false; /** Track tool calls count since last prompt (to identify first tool call) */ toolCallCountSincePrompt = 0; /** Timeout for emitting 'idle' status after last message chunk */ idleTimeout = null; /** Transport handler for agent-specific behavior */ transport; /** * When set, startSession() will issue an ACP `session/load` with this id * instead of `session/new`. Populated by loadSession() and consumed once by * the subsequent startSession() call. */ pendingResumeSessionId = null; onMessage(handler) { this.listeners.push(handler); } offMessage(handler) { const index = this.listeners.indexOf(handler); if (index !== -1) { this.listeners.splice(index, 1); } } emit(msg) { if (this.disposed) return; for (const listener of this.listeners) { try { listener(msg); } catch (error) { logger.warn("[AcpBackend] Error in message handler:", error); } } } async startSession(initialPrompt) { if (this.disposed) { throw new Error("Backend has been disposed"); } const sessionId = randomUUID(); this.emit({ type: "status", status: "starting" }); try { logger.debug(`[AcpBackend] Starting session: ${sessionId}`); const args = this.options.args || []; if (process.platform === "win32") { const fullCommand = [this.options.command, ...args].join(" "); this.process = spawn("cmd.exe", ["/c", fullCommand], { cwd: this.options.cwd, env: { ...process.env, ...this.options.env }, stdio: ["pipe", "pipe", "pipe"], windowsHide: true }); } else { this.process = spawn(this.options.command, args, { cwd: this.options.cwd, env: { ...process.env, ...this.options.env }, // Use 'pipe' for all stdio to capture output without printing to console // stdout and stderr will be handled by our event listeners stdio: ["pipe", "pipe", "pipe"] }); } if (this.process.stderr) { } if (!this.process.stdin || !this.process.stdout || !this.process.stderr) { throw new Error("Failed to create stdio pipes"); } this.process.stderr.on("data", (data) => { const text = data.toString(); if (!text.trim()) return; const hasActiveInvestigation = this.transport.isInvestigationTool ? Array.from(this.activeToolCalls).some((id) => this.transport.isInvestigationTool(id)) : false; const context = { activeToolCalls: this.activeToolCalls, hasActiveInvestigation }; if (hasActiveInvestigation) { logger.debug(`[AcpBackend] \u{1F50D} Agent stderr (during investigation): ${text.trim()}`); } else { logger.debug(`[AcpBackend] Agent stderr: ${text.trim()}`); } if (this.transport.handleStderr) { const result = this.transport.handleStderr(text, context); if (result.message) { this.emit(result.message); } } }); this.process.on("error", (err) => { logger.debug(`[AcpBackend] Process error:`, err); this.emit({ type: "status", status: "error", detail: err.message }); }); this.process.on("exit", (code, signal) => { if (!this.disposed && code !== 0 && code !== null) { logger.debug(`[AcpBackend] Process exited with code ${code}, signal ${signal}`); this.emit({ type: "status", status: "stopped", detail: `Exit code: ${code}` }); } }); const streams = nodeToWebStreams( this.process.stdin, this.process.stdout ); const writable = streams.writable; const readable = streams.readable; const transport = this.transport; const filteredReadable = new ReadableStream({ async start(controller) { const reader = readable.getReader(); const decoder = new TextDecoder(); const encoder = new TextEncoder(); let buffer = ""; let filteredCount = 0; try { while (true) { const { done, value } = await reader.read(); if (done) { if (buffer.trim()) { const filtered = transport.filterStdoutLine?.(buffer); if (filtered === void 0) { controller.enqueue(encoder.encode(buffer)); } else if (filtered !== null) { controller.enqueue(encoder.encode(filtered)); } else { filteredCount++; } } if (filteredCount > 0) { logger.debug(`[AcpBackend] Filtered out ${filteredCount} non-JSON lines from ${transport.agentName} stdout`); } controller.close(); break; } buffer += decoder.decode(value, { stream: true }); const lines = buffer.split("\n"); buffer = lines.pop() || ""; for (const line of lines) { if (!line.trim()) continue; const filtered = transport.filterStdoutLine?.(line); if (filtered === void 0) { controller.enqueue(encoder.encode(line + "\n")); } else if (filtered !== null) { controller.enqueue(encoder.encode(filtered + "\n")); } else { filteredCount++; } } } } catch (error) { logger.debug(`[AcpBackend] Error filtering stdout stream:`, error); controller.error(error); } finally { reader.releaseLock(); } } }); const stream = ndJsonStream(writable, filteredReadable); const client = { sessionUpdate: async (params) => { this.handleSessionUpdate(params); }, requestPermission: async (params) => { const extendedParams = params; const toolCall = extendedParams.toolCall; let toolName = toolCall?.kind || toolCall?.toolName || extendedParams.kind || "Unknown tool"; const toolCallId = toolCall?.id || randomUUID(); const permissionId = toolCallId; let input = {}; if (toolCall) { input = toolCall.input || toolCall.arguments || toolCall.content || {}; } else { input = extendedParams.input || extendedParams.arguments || extendedParams.content || {}; } const context = { recentPromptHadChangeTitle: this.recentPromptHadChangeTitle, toolCallCountSincePrompt: this.toolCallCountSincePrompt }; toolName = this.transport.determineToolName?.(toolName, toolCallId, input, context) ?? toolName; if (toolName !== (toolCall?.kind || toolCall?.toolName || extendedParams.kind || "Unknown tool")) { logger.debug(`[AcpBackend] Detected tool name: ${toolName} from toolCallId: ${toolCallId}`); } this.toolCallCountSincePrompt++; const options = extendedParams.options || []; logger.debug(`[AcpBackend] Permission request: tool=${toolName}, toolCallId=${toolCallId}, input=`, JSON.stringify(input)); logger.debug(`[AcpBackend] Permission request params structure:`, JSON.stringify({ hasToolCall: !!toolCall, toolCallKind: toolCall?.kind, toolCallId: toolCall?.id, paramsKind: extendedParams.kind, paramsKeys: Object.keys(params) }, null, 2)); this.emit({ type: "permission-request", id: permissionId, reason: toolName, payload: { ...params, permissionId, toolCallId, toolName, input, options: options.map((opt) => ({ id: opt.optionId, name: opt.name, kind: opt.kind })) } }); if (this.options.permissionHandler) { try { const result = await this.options.permissionHandler.handleToolCall( toolCallId, toolName, input ); let optionId = "cancel"; if (result.decision === "approved" || result.decision === "approved_for_session") { const proceedOnceOption2 = options.find( (opt) => opt.optionId === "proceed_once" || opt.name?.toLowerCase().includes("once") ); const proceedAlwaysOption = options.find( (opt) => opt.optionId === "proceed_always" || opt.name?.toLowerCase().includes("always") ); if (result.decision === "approved_for_session" && proceedAlwaysOption) { optionId = proceedAlwaysOption.optionId || "proceed_always"; } else if (proceedOnceOption2) { optionId = proceedOnceOption2.optionId || "proceed_once"; } else if (options.length > 0) { optionId = options[0].optionId || "proceed_once"; } this.emit({ type: "tool-result", toolName, result: { status: "approved", decision: result.decision }, callId: permissionId }); } else { const cancelOption = options.find( (opt) => opt.optionId === "cancel" || opt.name?.toLowerCase().includes("cancel") ); if (cancelOption) { optionId = cancelOption.optionId || "cancel"; } this.emit({ type: "tool-result", toolName, result: { status: "denied", decision: result.decision }, callId: permissionId }); } return { outcome: { outcome: "selected", optionId } }; } catch (error) { logger.debug("[AcpBackend] Error in permission handler:", error); return { outcome: { outcome: "selected", optionId: "cancel" } }; } } const proceedOnceOption = options.find( (opt) => opt.optionId === "proceed_once" || typeof opt.name === "string" && opt.name.toLowerCase().includes("once") ); const defaultOptionId = proceedOnceOption?.optionId || (options.length > 0 && options[0].optionId ? options[0].optionId : "proceed_once"); return { outcome: { outcome: "selected", optionId: defaultOptionId } }; } }; this.connection = new ClientSideConnection( (agent) => client, stream ); const initRequest = { protocolVersion: 1, clientCapabilities: { fs: { readTextFile: false, writeTextFile: false } }, clientInfo: { name: "consortium-cli", version: packageJson.version } }; const initTimeout = this.transport.getInitTimeout(); logger.debug(`[AcpBackend] Initializing connection (timeout: ${initTimeout}ms)...`); await withRetry( async () => { let timeoutHandle = null; try { const result = await Promise.race([ this.connection.initialize(initRequest).then((res) => { if (timeoutHandle) { clearTimeout(timeoutHandle); timeoutHandle = null; } return res; }), new Promise((_, reject) => { timeoutHandle = setTimeout(() => { reject(new Error(`Initialize timeout after ${initTimeout}ms - ${this.transport.agentName} did not respond`)); }, initTimeout); }) ]); return result; } finally { if (timeoutHandle) { clearTimeout(timeoutHandle); } } }, { operationName: "Initialize", maxAttempts: RETRY_CONFIG.maxAttempts, baseDelayMs: RETRY_CONFIG.baseDelayMs, maxDelayMs: RETRY_CONFIG.maxDelayMs } ); logger.debug(`[AcpBackend] Initialize completed`); const mcpServers = this.options.mcpServers ? Object.entries(this.options.mcpServers).map(([name, config]) => ({ name, command: config.command, args: config.args || [], env: config.env ? Object.entries(config.env).map(([envName, envValue]) => ({ name: envName, value: envValue })) : [] })) : []; const resumeSessionId = this.pendingResumeSessionId; this.pendingResumeSessionId = null; let resolvedAcpSessionId; if (resumeSessionId) { const loadSessionRequest = { cwd: this.options.cwd, mcpServers, sessionId: resumeSessionId }; logger.debug(`[AcpBackend] Loading existing session: ${resumeSessionId}`); await withRetry( async () => { let timeoutHandle = null; try { const result = await Promise.race([ this.connection.loadSession(loadSessionRequest).then((res) => { if (timeoutHandle) { clearTimeout(timeoutHandle); timeoutHandle = null; } return res; }), new Promise((_, reject) => { timeoutHandle = setTimeout(() => { reject(new Error(`Load session timeout after ${initTimeout}ms - ${this.transport.agentName} did not respond`)); }, initTimeout); }) ]); return result; } finally { if (timeoutHandle) { clearTimeout(timeoutHandle); } } }, { operationName: "LoadSession", maxAttempts: RETRY_CONFIG.maxAttempts, baseDelayMs: RETRY_CONFIG.baseDelayMs, maxDelayMs: RETRY_CONFIG.maxDelayMs } ); resolvedAcpSessionId = resumeSessionId; logger.debug(`[AcpBackend] Session loaded: ${resolvedAcpSessionId}`); } else { const newSessionRequest = { cwd: this.options.cwd, mcpServers }; logger.debug(`[AcpBackend] Creating new session...`); const sessionResponse = await withRetry( async () => { let timeoutHandle = null; try { const result = await Promise.race([ this.connection.newSession(newSessionRequest).then((res) => { if (timeoutHandle) { clearTimeout(timeoutHandle); timeoutHandle = null; } return res; }), new Promise((_, reject) => { timeoutHandle = setTimeout(() => { reject(new Error(`New session timeout after ${initTimeout}ms - ${this.transport.agentName} did not respond`)); }, initTimeout); }) ]); return result; } finally { if (timeoutHandle) { clearTimeout(timeoutHandle); } } }, { operationName: "NewSession", maxAttempts: RETRY_CONFIG.maxAttempts, baseDelayMs: RETRY_CONFIG.baseDelayMs, maxDelayMs: RETRY_CONFIG.maxDelayMs } ); resolvedAcpSessionId = sessionResponse.sessionId; logger.debug(`[AcpBackend] Session created: ${resolvedAcpSessionId}`); } this.acpSessionId = resolvedAcpSessionId; this.emitIdleStatus(); if (initialPrompt) { this.sendPrompt(sessionId, initialPrompt).catch((error) => { logger.debug("[AcpBackend] Error sending initial prompt:", error); this.emit({ type: "status", status: "error", detail: String(error) }); }); } return { sessionId: resolvedAcpSessionId }; } catch (error) { logger.debug("[AcpBackend] Error starting session:", error); this.emit({ type: "status", status: "error", detail: error instanceof Error ? error.message : String(error) }); throw error; } } /** * Resume an existing ACP session by id. * * Internally delegates to `startSession()` after staging the resume id so * the shared spawn/initialize flow runs unchanged. The first session RPC * issued during startSession() is then `session/load` instead of * `session/new` — see `pendingResumeSessionId` handling in startSession. */ async loadSession(sessionId) { if (this.disposed) { throw new Error("Backend has been disposed"); } this.pendingResumeSessionId = sessionId; try { return await this.startSession(); } catch (error) { this.pendingResumeSessionId = null; throw error; } } /** * Create handler context for session update processing */ createHandlerContext() { return { transport: this.transport, activeToolCalls: this.activeToolCalls, toolCallStartTimes: this.toolCallStartTimes, toolCallTimeouts: this.toolCallTimeouts, toolCallIdToNameMap: this.toolCallIdToNameMap, idleTimeout: this.idleTimeout, toolCallCountSincePrompt: this.toolCallCountSincePrompt, emit: (msg) => this.emit(msg), emitIdleStatus: () => this.emitIdleStatus(), clearIdleTimeout: () => { if (this.idleTimeout) { clearTimeout(this.idleTimeout); this.idleTimeout = null; } }, setIdleTimeout: (callback, ms) => { this.idleTimeout = setTimeout(() => { callback(); this.idleTimeout = null; }, ms); } }; } handleSessionUpdate(params) { const notification = params; const update = notification.update; if (!update) { logger.debug("[AcpBackend] Received session update without update field:", params); return; } const sessionUpdateType = update.sessionUpdate; if (sessionUpdateType !== "agent_message_chunk") { logger.debug(`[AcpBackend] Received session update: ${sessionUpdateType}`, JSON.stringify({ sessionUpdate: sessionUpdateType, toolCallId: update.toolCallId, status: update.status, kind: update.kind, hasContent: !!update.content, hasLocations: !!update.locations }, null, 2)); } const ctx = this.createHandlerContext(); if (sessionUpdateType === "agent_message_chunk") { handleAgentMessageChunk(update, ctx); return; } if (sessionUpdateType === "tool_call_update") { const result = handleToolCallUpdate(update, ctx); if (result.toolCallCountSincePrompt !== void 0) { this.toolCallCountSincePrompt = result.toolCallCountSincePrompt; } return; } if (sessionUpdateType === "agent_thought_chunk") { handleAgentThoughtChunk(update, ctx); return; } if (sessionUpdateType === "tool_call") { handleToolCall(update, ctx); return; } handleLegacyMessageChunk(update, ctx); handlePlanUpdate(update, ctx); handleThinkingUpdate(update, ctx); const updateTypeStr = sessionUpdateType; const handledTypes = ["agent_message_chunk", "tool_call_update", "agent_thought_chunk", "tool_call"]; if (updateTypeStr && !handledTypes.includes(updateTypeStr) && !update.messageChunk && !update.plan && !update.thinking) { logger.debug(`[AcpBackend] Unhandled session update type: ${updateTypeStr}`, JSON.stringify(update, null, 2)); } } // Promise resolver for waitForIdle - set when waiting for response to complete idleResolver = null; waitingForResponse = false; /** * Optional per-session scratch dir used to materialize non-image * attachment bytes onto disk before sending them to the agent as * `resource_link` blocks. Injected from outside (the runner owns the * session lifecycle and reuses one ScratchDir across prompts). */ setScratchDir(dir) { this.scratchDir = dir; } scratchDir = null; /** * Per-(agent, model) capability the conversion path consults to * decide whether to inline images or degrade-with-note. Defaults * to multimodal when unset (existing callers). */ setAttachmentCapability(cap) { this.attachmentCapability = cap; } attachmentCapability = null; /** * Build the `ContentBlock[]` that gets shipped over ACP for a single * prompt turn. Order: text first (if any), then each attachment in * the order it was supplied. * * Block mapping: * - drive_file + image MIME → inline `image` block (base64). * - drive_file + other MIME → write to ScratchDir, emit * `resource_link` with file:// URI. * - link / github_repo / external→ `resource_link` to original URI. * - drive_file w/o bytes (link) → resource_link to its sentinel URI. */ // Exposed for unit testing. async buildPromptBlocks(sessionId, text, attachments) { const blocks = []; if (text.length > 0) { blocks.push({ type: "text", text }); } const notes = []; for (const att of attachments) { try { const result = await this.attachmentToBlock(att); if (!result) continue; if (result.block) blocks.push(result.block); if (result.note) notes.push(result.note); } catch (err) { logger.debug(`[AcpBackend] Skipping attachment ${att.id}:`, err); notes.push(`[attachment ${att.name}: failed to attach]`); } } if (notes.length > 0) { const noteBlock = { type: "text", text: notes.join("\n") }; const insertAt = text.length > 0 ? 1 : 0; blocks.splice(insertAt, 0, noteBlock); } if (blocks.length === 0) { blocks.push({ type: "text", text: "" }); } return blocks; } /** * Map a single resolved attachment onto a content block + optional * text note. Returns `null` to skip entirely. The note is collected * and prepended as a single text block so the model is informed of * failures it can't see directly (e.g. an image sent to a text-only * model degrades to a "[image attached: foo.png — model cannot view * images]" note). */ async attachmentToBlock(att) { const status = att.status?.code ?? "ok"; if (status !== "ok") { const reason = att.status?.message ?? status; return { note: `[attachment ${att.name}: ${status} \u2014 ${reason}]` }; } const cap = this.attachmentCapability; const isImage = typeof att.mime === "string" && att.mime.startsWith("image/"); if (att.bytes && att.bytes.length > 0 && att.kind === "drive_file") { if (cap && att.bytes.length > cap.maxBytes) { return { note: `[attachment ${att.name}: too large (${att.bytes.length} bytes, limit ${cap.maxBytes})]` }; } if (isImage) { if (cap && !cap.images) { return { note: `[image attached: ${att.name} \u2014 selected model cannot view images]` }; } return { block: { type: "image", mimeType: att.mime, data: Buffer.from(att.bytes).toString("base64") } }; } if (!this.scratchDir) { return { note: `[attachment ${att.name}: scratch directory unavailable \u2014 file dropped]` }; } const path = await this.scratchDir.write(att.name, att.bytes); return { block: { type: "resource_link", uri: `file://${path}`, name: att.name, ...att.mime ? { mimeType: att.mime } : {} } }; } if (att.uri) { return { block: { type: "resource_link", uri: att.uri, name: att.name, ...att.mime ? { mimeType: att.mime } : {} } }; } return null; } async sendPrompt(sessionId, prompt) { const normalized = typeof prompt === "string" ? { text: prompt, attachments: [] } : { text: prompt.text, attachments: prompt.attachments ?? [] }; const promptText = normalized.text; const promptHasChangeTitle = this.options.hasChangeTitleInstruction?.(promptText) ?? false; this.toolCallCountSincePrompt = 0; this.recentPromptHadChangeTitle = promptHasChangeTitle; if (promptHasChangeTitle) { logger.debug('[AcpBackend] Prompt contains change_title instruction - will auto-approve first "other" tool call if it matches pattern'); } if (this.disposed) { throw new Error("Backend has been disposed"); } if (!this.connection || !this.acpSessionId) { throw new Error("Session not started"); } this.emit({ type: "status", status: "running" }); this.waitingForResponse = true; try { logger.debug(`[AcpBackend] Sending prompt (length: ${promptText.length}, attachments: ${normalized.attachments.length}): ${promptText.substring(0, 100)}...`); logger.debug(`[AcpBackend] Full prompt: ${promptText}`); const blocks = await this.buildPromptBlocks(sessionId, promptText, normalized.attachments); const promptRequest = { sessionId: this.acpSessionId, prompt: blocks }; logger.debug(`[AcpBackend] Prompt request:`, JSON.stringify(promptRequest, null, 2)); await this.connection.prompt(promptRequest); logger.debug("[AcpBackend] Prompt request sent to ACP connection"); } catch (error) { logger.debug("[AcpBackend] Error sending prompt:", error); this.waitingForResponse = false; let errorDetail; if (error instanceof Error) { errorDetail = error.message; } else if (typeof error === "object" && error !== null) { const errObj = error; const fallbackMessage = (typeof errObj.message === "string" ? errObj.message : void 0) || String(error); if (errObj.code !== void 0) { errorDetail = JSON.stringify({ code: errObj.code, message: fallbackMessage }); } else if (typeof errObj.message === "string") { errorDetail = errObj.message; } else { errorDetail = String(error); } } else { errorDetail = String(error); } this.emit({ type: "status", status: "error", detail: errorDetail }); throw error; } } /** * Wait for the response to complete (idle status after all chunks received) * Call this after sendPrompt to wait for Gemini to finish responding */ async waitForResponseComplete(timeoutMs = 12e4) { if (!this.waitingForResponse) { return; } return new Promise((resolve, reject) => { const timeout = setTimeout(() => { this.idleResolver = null; this.waitingForResponse = false; reject(new Error("Timeout waiting for response to complete")); }, timeoutMs); this.idleResolver = () => { clearTimeout(timeout); this.idleResolver = null; this.waitingForResponse = false; resolve(); }; }); } /** * Helper to emit idle status and resolve any waiting promises */ emitIdleStatus() { this.emit({ type: "status", status: "idle" }); if (this.idleResolver) { logger.debug("[AcpBackend] Resolving idle waiter"); this.idleResolver(); } } /** * Switch the active session's model in place via the binary's * `session/set_model` ACP RPC (exposed as `connection.setSessionModel` * in the official SDK). This preserves all existing conversation state * — no new ACP session is created — which is what makes in-session * model switching keep the transcript intact. * * Callers should pass a fully-qualified `<provider>/<model>` id; bare * ids are coerced to `opencode/<id>` to match the binary's expectation * (see `Provider.parseModel` in the opencode submodule). Returns * silently if there's no active session — the caller is then expected * to fall through to `startSession()` for the first user prompt. */ async setSessionModel(modelId) { if (!this.connection || !this.acpSessionId) { return; } const fullId = modelId.includes("/") ? modelId : `opencode/${modelId}`; logger.debug(`[AcpBackend] Switching session model in place: sessionId=${this.acpSessionId} modelId=${fullId}`); await this.connection.setSessionModel({ sessionId: this.acpSessionId, modelId: fullId }); } async cancel(sessionId) { if (!this.connection || !this.acpSessionId) { return; } try { await this.connection.cancel({ sessionId: this.acpSessionId }); this.emit({ type: "status", status: "stopped", detail: "Cancelled by user" }); } catch (error) { logger.debug("[AcpBackend] Error cancelling:", error); } } /** * Emit permission response event for UI/logging purposes. * * **IMPORTANT:** For ACP backends, this method does NOT send the actual permission * response to the agent. The ACP protocol requires synchronous permission handling, * which is done inside the `requestPermission` RPC handler via `this.options.permissionHandler`. * * This method only emits a `permission-response` event for: * - UI updates (e.g., closing permission dialogs) * - Logging and debugging * - Other parts of the CLI that need to react to permission decisions * * @param request