consortium
Version:
Remote control and session sharing CLI for AI coding agents
1,356 lines (1,351 loc) • 108 kB
JavaScript
import { useStdout, useInput, Box, Text, render } from 'ink';
import React, { useState, useRef, useEffect, useCallback } from 'react';
import { randomUUID } from 'node:crypto';
import { join } from 'node:path';
import { l as logger, b as packageJson, f as delay, d as connectionState, A as ApiClient, p as projectPath } from './types-WAGIe5yd.mjs';
import { readSettings } from './persistence-CJJbK-4u.mjs';
import { B as BasePermissionHandler, a as BaseReasoningProcessor, c as createSessionMetadata, s as setupOfflineReconnection } from './setupOfflineReconnection-D6eubEb9.mjs';
import { i as initialMachineMetadata, n as notifyDaemonSessionStarted, M as MessageQueue2, h as hashObject, a as MessageBuffer, r as registerKillSessionHandler, s as startConsortiumServer, b as stopCaffeinate } from './index-Bw3Z0DV3.mjs';
import { spawn } from 'node:child_process';
import { ndJsonStream, ClientSideConnection } from '@agentclientprotocol/sdk';
import { a as GEMINI_API_KEY_ENV, b as GOOGLE_API_KEY_ENV, G as GEMINI_MODEL_ENV, C as CHANGE_TITLE_INSTRUCTION } from './constants-Ccz6B_M-.mjs';
import { readGeminiLocalConfig, determineGeminiModel, getGeminiModelSource, getInitialGeminiModel, saveGeminiModelToConfig } from './config-9iaJES8u.mjs';
import 'axios';
import 'chalk';
import 'fs';
import 'node:fs';
import 'node:os';
import 'node:events';
import 'socket.io-client';
import 'zod';
import 'tweetnacl';
import 'child_process';
import 'util';
import 'fs/promises';
import 'crypto';
import 'path';
import 'url';
import 'os';
import 'expo-server-sdk';
import 'node:fs/promises';
import 'node:readline';
import 'node:url';
import 'node:util';
import 'ps-list';
import 'cross-spawn';
import 'tmp';
import 'qrcode-terminal';
import 'open';
import 'fastify';
import 'fastify-type-provider-zod';
import '@modelcontextprotocol/sdk/server/mcp.js';
import 'node:http';
import '@modelcontextprotocol/sdk/server/streamableHttp.js';
import 'http';
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 GEMINI_TIMEOUTS = {
/** Gemini CLI can be slow on first start (downloading models, etc.) */
init: 12e4,
/** Standard tool call timeout */
toolCall: 12e4,
/** Investigation tools (codebase_investigator) can run for a long time */
investigation: 6e5,
/** Think tools are usually quick */
think: 3e4,
/** Idle detection after last message chunk */
idle: 500
};
const GEMINI_TOOL_PATTERNS = [
{
name: "change_title",
patterns: ["change_title", "change-title", "consortium__change_title", "mcp__consortium__change_title"],
inputFields: ["title"],
emptyInputDefault: true
// change_title often has empty input (title extracted from context)
},
{
name: "save_memory",
patterns: ["save_memory", "save-memory"],
inputFields: ["memory", "content"]
},
{
name: "think",
patterns: ["think"],
inputFields: ["thought", "thinking"]
}
];
const AVAILABLE_MODELS = [
"gemini-2.5-pro",
"gemini-2.5-flash",
"gemini-2.5-flash-lite"
];
class GeminiTransport {
agentName = "gemini";
/**
* Gemini CLI needs 2 minutes for first start (model download, warm-up)
*/
getInitTimeout() {
return GEMINI_TIMEOUTS.init;
}
/**
* Filter Gemini CLI debug output from stdout.
*
* Gemini CLI outputs various debug info (experiments, flags, etc.) to stdout
* that breaks ACP JSON-RPC parsing. We only keep valid JSON lines.
*/
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;
}
}
/**
* Handle Gemini CLI stderr output.
*
* Detects:
* - Rate limit errors (429) - logged but not shown (CLI handles retries)
* - Model not found (404) - emit error with available models
* - Other errors during investigation - logged for debugging
*/
handleStderr(text, context) {
const trimmed = text.trim();
if (!trimmed) {
return { message: null, suppress: true };
}
if (trimmed.includes("status 429") || trimmed.includes('code":429') || trimmed.includes("rateLimitExceeded") || trimmed.includes("RESOURCE_EXHAUSTED")) {
return {
message: null,
suppress: false
// Log for debugging but don't show to user
};
}
if (trimmed.includes("status 404") || trimmed.includes('code":404')) {
const errorMessage = {
type: "status",
status: "error",
detail: `Model not found. Available models: ${AVAILABLE_MODELS.join(", ")}`
};
return { message: errorMessage };
}
if (context.hasActiveInvestigation) {
const hasError = trimmed.includes("timeout") || trimmed.includes("Timeout") || trimmed.includes("failed") || trimmed.includes("Failed") || trimmed.includes("error") || trimmed.includes("Error");
if (hasError) {
return { message: null, suppress: false };
}
}
return { message: null };
}
/**
* Gemini-specific tool patterns
*/
getToolPatterns() {
return GEMINI_TOOL_PATTERNS;
}
/**
* Check if tool is an investigation tool (needs longer timeout)
*/
isInvestigationTool(toolCallId, toolKind) {
const lowerId = toolCallId.toLowerCase();
return lowerId.includes("codebase_investigator") || lowerId.includes("investigator") || typeof toolKind === "string" && toolKind.includes("investigator");
}
/**
* Get timeout for a tool call
*/
getToolCallTimeout(toolCallId, toolKind) {
if (this.isInvestigationTool(toolCallId, toolKind)) {
return GEMINI_TIMEOUTS.investigation;
}
if (toolKind === "think") {
return GEMINI_TIMEOUTS.think;
}
return GEMINI_TIMEOUTS.toolCall;
}
/**
* Get idle detection timeout
*/
getIdleTimeout() {
return GEMINI_TIMEOUTS.idle;
}
/**
* Extract tool name from toolCallId using Gemini patterns.
*
* Tool IDs often contain the tool name as a prefix (e.g., "change_title-1765385846663" -> "change_title")
*/
extractToolNameFromId(toolCallId) {
const lowerId = toolCallId.toLowerCase();
for (const toolPattern of GEMINI_TOOL_PATTERNS) {
for (const pattern of toolPattern.patterns) {
if (lowerId.includes(pattern.toLowerCase())) {
return toolPattern.name;
}
}
}
return null;
}
/**
* Check if input is effectively empty
*/
isEmptyInput(input) {
if (!input) return true;
if (Array.isArray(input)) return input.length === 0;
if (typeof input === "object") return Object.keys(input).length === 0;
return false;
}
/**
* Determine the real tool name from various sources.
*
* When Gemini sends "other" or "Unknown tool", tries to determine the real name from:
* 1. toolCallId patterns (most reliable - tool name often embedded in ID)
* 2. Input field signatures (specific fields indicate specific tools)
* 3. Empty input default (some tools like change_title have empty input)
*
* Context-based heuristics were removed as they were fragile and the above
* methods cover all known cases.
*/
determineToolName(toolName, toolCallId, input, _context) {
if (toolName !== "other" && toolName !== "Unknown tool") {
return toolName;
}
const idToolName = this.extractToolNameFromId(toolCallId);
if (idToolName) {
return idToolName;
}
if (input && typeof input === "object" && !Array.isArray(input)) {
const inputKeys = Object.keys(input);
for (const toolPattern of GEMINI_TOOL_PATTERNS) {
if (toolPattern.inputFields) {
const hasMatchingField = toolPattern.inputFields.some(
(field) => inputKeys.some((key) => key.toLowerCase() === field.toLowerCase())
);
if (hasMatchingField) {
return toolPattern.name;
}
}
}
}
if (this.isEmptyInput(input) && toolName === "other") {
const defaultTool = GEMINI_TOOL_PATTERNS.find((p) => p.emptyInputDefault);
if (defaultTool) {
return defaultTool.name;
}
}
if (toolName === "other" || toolName === "Unknown tool") {
const inputKeys = input && typeof input === "object" ? Object.keys(input) : [];
logger.debug(
`[GeminiTransport] Unknown tool pattern - toolCallId: "${toolCallId}", toolName: "${toolName}", inputKeys: [${inputKeys.join(", ")}]. Consider adding a new pattern to GEMINI_TOOL_PATTERNS if this tool appears frequently.`
);
}
return toolName;
}
}
const geminiTransport = new GeminiTransport();
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 handleAgentMessageChunk(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 };
}
const isThinking = /^\*\*[^*]+\*\*\n/.test(text);
if (isThinking) {
ctx.emit({
type: "event",
name: "thinking",
payload: { text }
});
} else {
logger.debug(`[AcpBackend] Received message chunk (length: ${text.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.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: toolKindStr || "unknown",
args,
callId: toolCallId
});
}
function completeToolCall(toolCallId, toolKind, content, ctx) {
const startTime = ctx.toolCallStartTimes.get(toolCallId);
const duration = formatDuration(startTime);
const toolKindStr = 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}`);
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 = 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;
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 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
}
);
this.acpSessionId = sessionResponse.sessionId;
logger.debug(`[AcpBackend] Session created: ${this.acpSessionId}`);
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 };
} 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;
}
}
/**
* 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;
async sendPrompt(sessionId, prompt) {
const promptHasChangeTitle = this.options.hasChangeTitleInstruction?.(prompt) ?? 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: ${prompt.length}): ${prompt.substring(0, 100)}...`);
logger.debug(`[AcpBackend] Full prompt: ${prompt}`);
const contentBlock = {
type: "text",
text: prompt
};
const promptRequest = {
sessionId: this.acpSessionId,
prompt: [contentBlock]
};
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();
}
}
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 requestId - The ID of the permission request
* @param approved - Whether the permission was granted
*/
async respondToPermission(requestId, approved) {
logger.debug(`[AcpBackend] Permission response event (UI only): ${requestId} = ${approved}`);
this.emit({ type: "permission-response", id: requestId, approved });
}
async dispose() {
if (this.disposed) return;
logger.debug("[AcpBackend] Disposing backend");
this.disposed = true;
if (this.connection && this.acpSessionId) {
try {
await Promise.race([
this.connection.cancel({ sessionId: this.acpSessionId }),
new Promise((resolve) => setTimeout(resolve, 2e3))
// 2s timeout for graceful shutdown
]);
} catch (error) {
logger.debug("[AcpBackend] Error during graceful shutdown:", error);
}
}
if (this.process) {
this.process.kill("SIGTERM");
await new Promise((resolve) => {
const timeout = setTimeout(() => {
if (this.process) {
logger.debug("[AcpBackend] Force killing process");
this.process.kill("SIGKILL");
}
resolve();
}, 1e3);
this.process?.once("exit", () => {
clearTimeout(timeout);
resolve();
});
});
this.process = null;
}
if (this.idleTimeout) {
clearTimeout(this.idleTimeout);
this.idleTimeout = null;
}
this.listeners = [];
this.connection = null;
this.acpSessionId = null;
this.activeToolCalls.clear();
for (const timeout of this.toolCallTimeouts.values()) {
clearTimeout(timeout);
}
this.toolCallTimeouts.clear();
this.toolCallStartTimes.clear();
this.pendingPermissions.clear();
}
}
function createGeminiBackend(options) {
const localConfig = readGeminiLocalConfig();
let apiKey = options.cloudToken || localConfig.token || process.env[GEMINI_API_KEY_ENV] || process.env[GOOGLE_API_KEY_ENV] || options.apiKey;
if (!apiKey) {
logger.warn(`[Gemini] No API key found. Run 'consortium connect gemini' to authenticate via Google OAuth, or set ${GEMINI_API_KEY_ENV} environment variable.`);
}
const geminiCommand = "gemini";
const model = determineGeminiModel(options.model, localConfig);
const geminiArgs = ["--experimental-acp"];
let googleCloudProject = null;
if (localConfig.googleCloudProject) {
const storedEmail = localConfig.googleCloudProjectEmail;
const currentEmail = options.currentUserEmail;
if (!storedEmail || storedEmail === currentEmail) {
googleCloudProject = localConfig.googleCloudProject;
logger.debug(`[Gemini] Using Google Cloud Project: ${googleCloudProject}${storedEmail ? ` (for ${storedEmail})` : " (global)"}`);
} else