UNPKG

consortium

Version:

Remote control and session sharing CLI for AI coding agents

1,320 lines (1,309 loc) 63.6 kB
'use strict'; var ink = require('ink'); var React = require('react'); var node_crypto = require('node:crypto'); var path = require('node:path'); var persistence = require('./types-B_i6lpTn.cjs'); var createSessionMetadata = require('./createSessionMetadata-CVgp25Mn.cjs'); var index = require('./index-BMIckAk5.cjs'); var setupOfflineReconnection = require('./setupOfflineReconnection-CY4q78_S.cjs'); var capabilities = require('./capabilities-BsNjrlBG.cjs'); var config = require('./config-DAdIn-SN.cjs'); var GeminiDisplay = require('./GeminiDisplay-G90hcB70.cjs'); var BasePermissionHandler = require('./BasePermissionHandler-DM5JDRsB.cjs'); var BaseReasoningProcessor = require('./BaseReasoningProcessor-l1KZ6ISd.cjs'); var optionsParser = require('./optionsParser-eyp1ynVN.cjs'); require('axios'); require('chalk'); require('fs'); require('node:fs'); require('node:os'); require('node:events'); require('socket.io-client'); require('zod'); require('tweetnacl'); require('child_process'); require('util'); require('fs/promises'); require('crypto'); require('path'); require('url'); require('os'); require('node:child_process'); require('node:fs/promises'); require('node:module'); require('node:util'); require('expo-server-sdk'); require('node:readline'); require('node:url'); require('ps-list'); require('cross-spawn'); require('tmp'); require('qrcode-terminal'); require('open'); require('fastify'); require('fastify-type-provider-zod'); require('http'); require('@modelcontextprotocol/sdk/client/index.js'); require('@modelcontextprotocol/sdk/client/streamableHttp.js'); require('readline'); require('@modelcontextprotocol/sdk/server/mcp.js'); require('node:http'); require('@modelcontextprotocol/sdk/server/streamableHttp.js'); require('@agentclientprotocol/sdk'); require('./killSwitch-DwcqKgA9.cjs'); require('libsodium-wrappers'); 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) : []; persistence.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(); function createGeminiBackend(options) { const localConfig = config.readGeminiLocalConfig(); let apiKey = options.cloudToken || localConfig.token || process.env[config.GEMINI_API_KEY_ENV] || process.env[config.GOOGLE_API_KEY_ENV] || options.apiKey; if (!apiKey) { persistence.logger.warn(`[Gemini] No API key found. Run 'consortium connect gemini' to authenticate via Google OAuth, or set ${config.GEMINI_API_KEY_ENV} environment variable.`); } const geminiCommand = "gemini"; const model = config.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; persistence.logger.debug(`[Gemini] Using Google Cloud Project: ${googleCloudProject}${storedEmail ? ` (for ${storedEmail})` : " (global)"}`); } else { persistence.logger.debug(`[Gemini] Skipping stored Google Cloud Project (stored for ${storedEmail}, current user is ${currentEmail || "unknown"})`); } } const backendOptions = { agentName: "gemini", cwd: options.cwd, command: geminiCommand, args: geminiArgs, env: { ...options.env, ...apiKey ? { [config.GEMINI_API_KEY_ENV]: apiKey, [config.GOOGLE_API_KEY_ENV]: apiKey } : {}, // Pass model via env var - gemini CLI reads GEMINI_MODEL automatically [config.GEMINI_MODEL_ENV]: model, // Pass Google Cloud Project for Workspace accounts ...googleCloudProject ? { GOOGLE_CLOUD_PROJECT: googleCloudProject, GOOGLE_CLOUD_PROJECT_ID: googleCloudProject } : {}, // Suppress debug output from gemini CLI to avoid stdout pollution NODE_ENV: "production", DEBUG: "" }, mcpServers: options.mcpServers, permissionHandler: options.permissionHandler, transportHandler: geminiTransport, // Check if prompt instructs the agent to change title (for auto-approval of change_title tool) hasChangeTitleInstruction: (prompt) => { const lower = prompt.toLowerCase(); return lower.includes("change_title") || lower.includes("change title") || lower.includes("set title") || lower.includes("mcp__consortium__change_title"); } }; const modelSource = config.getGeminiModelSource(options.model, localConfig); persistence.logger.debug("[Gemini] Creating ACP SDK backend with options:", { cwd: backendOptions.cwd, command: backendOptions.command, args: backendOptions.args, hasApiKey: !!apiKey, model, modelSource, mcpServerCount: options.mcpServers ? Object.keys(options.mcpServers).length : 0 }); return { backend: new capabilities.AcpBackend(backendOptions), model, modelSource }; } class GeminiPermissionHandler extends BasePermissionHandler.BasePermissionHandler { currentPermissionMode = "default"; constructor(session) { super(session); } getLogPrefix() { return "[Gemini]"; } /** * Update session reference (override for type visibility) */ updateSession(newSession) { super.updateSession(newSession); } /** * Set the current permission mode * This affects how tool calls are automatically approved/denied */ setPermissionMode(mode) { this.currentPermissionMode = mode; persistence.logger.debug(`${this.getLogPrefix()} Permission mode set to: ${mode}`); } /** * Check if a tool should be auto-approved based on permission mode */ shouldAutoApprove(toolName, toolCallId, input) { const alwaysAutoApproveNames = ["change_title", "consortium__change_title", "GeminiReasoning", "CodexReasoning", "think", "save_memory"]; const alwaysAutoApproveIds = ["change_title", "save_memory"]; if (alwaysAutoApproveNames.some((name) => toolName.toLowerCase().includes(name.toLowerCase()))) { return true; } if (alwaysAutoApproveIds.some((id) => toolCallId.toLowerCase().includes(id.toLowerCase()))) { return true; } switch (this.currentPermissionMode) { case "yolo": return true; case "safe-yolo": return true; case "read-only": const writeTools = ["write", "edit", "create", "delete", "patch", "fs-edit"]; const isWriteTool = writeTools.some((wt) => toolName.toLowerCase().includes(wt)); return !isWriteTool; case "default": default: return false; } } /** * Handle a tool permission request * @param toolCallId - The unique ID of the tool call * @param toolName - The name of the tool being called * @param input - The input parameters for the tool * @returns Promise resolving to permission result */ async handleToolCall(toolCallId, toolName, input) { if (this.shouldAutoApprove(toolName, toolCallId, input)) { persistence.logger.debug(`${this.getLogPrefix()} Auto-approving tool ${toolName} (${toolCallId}) in ${this.currentPermissionMode} mode`); this.session.updateAgentState((currentState) => ({ ...currentState, completedRequests: { ...currentState.completedRequests, [toolCallId]: { tool: toolName, arguments: input, createdAt: Date.now(), completedAt: Date.now(), status: "approved", decision: this.currentPermissionMode === "yolo" ? "approved_for_session" : "approved" } } })); return { decision: this.currentPermissionMode === "yolo" ? "approved_for_session" : "approved" }; } return this.createPendingRequest(toolCallId, toolName, input); } } class GeminiReasoningProcessor extends BaseReasoningProcessor.BaseReasoningProcessor { getToolName() { return "GeminiReasoning"; } getLogPrefix() { return "[GeminiReasoningProcessor]"; } /** * Process a reasoning chunk from agent_thought_chunk. * Gemini sends reasoning as chunks, we accumulate them similar to Codex. */ processChunk(chunk) { this.processInput(chunk); } /** * Complete the reasoning section. * Called when reasoning is complete (e.g., when status changes to idle). * Returns true if reasoning was actually completed, false if there was nothing to complete. */ complete() { return this.completeReasoning(); } } class GeminiDiffProcessor { previousDiffs = /* @__PURE__ */ new Map(); // Track diffs per file path onMessage = null; constructor(onMessage) { this.onMessage = onMessage || null; } /** * Process an fs-edit event and check if it contains diff information */ processFsEdit(path, description, diff) { persistence.logger.debug(`[GeminiDiffProcessor] Processing fs-edit for path: ${path}`); if (diff) { this.processDiff(path, diff, description); } else { const simpleDiff = `File edited: ${path}${description ? ` - ${description}` : ""}`; this.processDiff(path, simpleDiff, description); } } /** * Process a tool result that may contain diff information */ processToolResult(toolName, result, callId) { if (result && typeof result === "object") { const diff = result.diff || result.unified_diff || result.patch; const path = result.path || result.file; if (diff && path) { persistence.logger.debug(`[GeminiDiffProcessor] Found diff in tool result: ${toolName} (${callId})`); this.processDiff(path, diff, result.description); } else if (result.changes && typeof result.changes === "object") { for (const [filePath, change] of Object.entries(result.changes)) { const changeDiff = change.diff || change.unified_diff || JSON.stringify(change); this.processDiff(filePath, changeDiff, change.description); } } } } /** * Process a unified diff and check if it has changed from the previous value */ processDiff(path, unifiedDiff, description) { const previousDiff = this.previousDiffs.get(path); if (previousDiff !== unifiedDiff) { persistence.logger.debug(`[GeminiDiffProcessor] Unified diff changed for ${path}, sending GeminiDiff tool call`); const callId = node_crypto.randomUUID(); const toolCall = { type: "tool-call", name: "GeminiDiff", callId, input: { unified_diff: unifiedDiff, path, description }, id: node_crypto.randomUUID() }; this.onMessage?.(toolCall); const toolResult = { type: "tool-call-result", callId, output: { status: "completed" }, id: node_crypto.randomUUID() }; this.onMessage?.(toolResult); } this.previousDiffs.set(path, unifiedDiff); persistence.logger.debug(`[GeminiDiffProcessor] Updated stored diff for ${path}`); } /** * Reset the processor state (called on task_complete or turn_aborted) */ reset() { persistence.logger.debug("[GeminiDiffProcessor] Resetting diff state"); this.previousDiffs.clear(); } /** * Set the message callback for sending messages directly */ setMessageCallback(callback) { this.onMessage = callback; } /** * Get the current diff value for a specific path */ getCurrentDiff(path) { return this.previousDiffs.get(path) || null; } /** * Get all tracked diffs */ getAllDiffs() { return new Map(this.previousDiffs); } } class ConversationHistory { messages = []; maxMessages; maxCharacters; currentModel; constructor(options = {}) { this.maxMessages = options.maxMessages ?? 20; this.maxCharacters = options.maxCharacters ?? 5e4; } /** * Set the current model being used */ setCurrentModel(model) { this.currentModel = model; } /** * Check if content is a duplicate of the last message with the same role. * Deduplication prevents inflating history when the same message is sent multiple times. */ isDuplicate(role, content) { if (this.messages.length === 0) return false; for (let i = this.messages.length - 1; i >= 0; i--) { const msg = this.messages[i]; if (msg.role === role) { const normalizedNew = content.trim().replace(/\s+/g, " "); const normalizedExisting = msg.content.replace(/\s+/g, " "); return normalizedNew === normalizedExisting; } } return false; } /** * Add a user message to history * Skips duplicate messages to prevent history inflation */ addUserMessage(content) { if (!content.trim()) return; const trimmedContent = content.trim(); if (this.isDuplicate("user", trimmedContent)) { persistence.logger.debug(`[ConversationHistory] Skipping duplicate user message (${trimmedContent.length} chars)`); return; } this.messages.push({ role: "user", content: trimmedContent, timestamp: Date.now() }); this.trimHistory(); persistence.logger.debug(`[ConversationHistory] Added user message (${trimmedContent.length} chars), total: ${this.messages.length}`); } /** * Add an assistant response to history * Skips duplicate messages to prevent history inflation */ addAssistantMessage(content) { if (!content.trim()) return; const trimmedContent = content.trim(); if (this.isDuplicate("assistant", trimmedContent)) { persistence.logger.debug(`[ConversationHistory] Skipping duplicate assistant message (${trimmedContent.length} chars)`); return; } this.messages.push({ role: "assistant", content: trimmedContent, timestamp: Date.now(), model: this.currentModel }); this.trimHistory(); persistence.logger.debug(`[ConversationHistory] Added assistant message (${trimmedContent.length} chars), total: ${this.messages.length}`); } /** * Get the number of messages in history */ size() { return this.messages.length; } /** * Check if there's any history to preserve */ hasHistory() { return this.messages.length > 0; } /** * Clear all history */ clear() { this.messages = []; persistence.logger.debug("[ConversationHistory] History cleared"); } /** * Get formatted context for injecting into a new session. * This is used when the model changes to preserve conversation context. * * @returns Formatted string with previous conversation context, or empty string if no history */ getContextForNewSession() { if (this.messages.length === 0) { return ""; } const formattedMessages = this.messages.map((msg) => { const role = msg.role === "user" ? "User" : "Assistant"; const content = msg.content.length > 2e3 ? msg.content.substring(0, 2e3) + "... [truncated]" : msg.content; return `${role}: ${content}`; }).join("\n\n"); return `[PREVIOUS CONVERSATION CONTEXT] The following is our previous conversation. Continue from where we left off: ${formattedMessages} [END OF PREVIOUS CONTEXT] `; } /** * Trim history to stay within limits */ trimHistory() { while (this.messages.length > this.maxMessages) { this.messages.shift(); } let totalChars = this.messages.reduce((sum, msg) => sum + msg.content.length, 0); while (totalChars > this.maxCharacters && this.messages.length > 1) { const removed = this.messages.shift(); if (removed) { totalChars -= removed.content.length; } } } /** * Get a summary of the conversation for logging/debugging */ getSummary() { const totalChars = this.messages.reduce((sum, msg) => sum + msg.content.length, 0); const userCount = this.messages.filter((m) => m.role === "user").length; const assistantCount = this.messages.filter((m) => m.role === "assistant").length; return `${this.messages.length} messages (${userCount} user, ${assistantCount} assistant), ${totalChars} chars`; } } async function runGemini(opts) { const sessionTag = node_crypto.randomUUID(); persistence.connectionState.setBackend("Gemini"); const api = await persistence.ApiClient.create(opts.credentials); const settings = await persistence.readSettings(); const machineId = settings?.machineId; if (!machineId) { console.error(`[START] No machine ID found in settings, which is unexpected since authAndSetupMachineIfNeeded should have created it. Please report this issue on https://github.com/ConsortiumAI/consortium-cli/issues`); process.exit(1); } persistence.logger.debug(`Using machineId: ${machineId}`); await api.getOrCreateMachine({ machineId, metadata: index.initialMachineMetadata }); let cloudToken = void 0; let currentUserEmail = void 0; try { const vendorToken = await api.getVendorToken("gemini"); if (vendorToken?.oauth?.access_token) { cloudToken = vendorToken.oauth.access_token; persistence.logger.debug("[Gemini] Using OAuth token from Consortium cloud"); if (vendorToken.oauth.id_token) { try { const parts = vendorToken.oauth.id_token.split("."); if (parts.length === 3) { const payload = JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8")); if (payload.email) { currentUserEmail = payload.email; persistence.logger.debug(`[Gemini] Current user email: ${currentUserEmail}`); } } } catch { persistence.logger.debug("[Gemini] Failed to decode id_token for email"); } } } } catch (error) { persistence.logger.debug("[Gemini] Failed to fetch cloud token:", error); } const { state, metadata } = createSessionMetadata.createSessionMetadata({ flavor: "gemini", machineId, startedBy: opts.startedBy }); const response = await api.getOrCreateSession({ tag: sessionTag, metadata, state }); let session; let permissionHandler; let isProcessingMessage = false; let pendingSessionSwap = null; const applyPendingSessionSwap = () => { if (pendingSessionSwap) { persistence.logger.debug("[gemini] Applying pending session swap"); session = pendingSessionSwap; if (permissionHandler) { permissionHandler.updateSession(pendingSessionSwap); } pendingSessionSwap = null; } }; const { session: initialSession, reconnectionHandle } = setupOfflineReconnection.setupOfflineReconnection({ api, sessionTag, metadata, state, response, onSessionSwap: (newSession) => { if (isProcessingMessage) { persistence.logger.debug("[gemini] Session swap requested during message processing - queueing"); pendingSessionSwap = newSession; } else { session = newSession; if (permissionHandler) { permissionHandler.updateSession(newSession); } } } }); session = initialSession; if (response) { try { persistence.logger.debug(`[START] Reporting session ${response.id} to daemon`); const result = await index.notifyDaemonSessionStarted(response.id, metadata); if (result.error) { persistence.logger.debug(`[START] Failed to report to daemon (may not be running):`, result.error); } else { persistence.logger.debug(`[START] Reported session ${response.id} to daemon`); } } catch (error) { persistence.logger.debug("[START] Failed to report to daemon (may not be running):", error); } } const attachments = GeminiDisplay.createRunnerAttachments({ credentials: opts.credentials, sessionId: response?.id ?? sessionTag, logTag: "gemini", agentId: "gemini", getCurrentModel: () => currentModel ?? null }); const messageQueue = new index.MessageQueue2((mode) => index.hashObject({ permissionMode: mode.permissionMode, model: mode.model })); const conversationHistory = new ConversationHistory({ maxMessages: 20, maxCharacters: 5e4 }); let currentPermissionMode = void 0; let currentModel = void 0; let hasStoredFirstMessage = false; session.onUserMessage((message) => { attachments.observeUserMessage(message); if (!hasStoredFirstMessage && message.content.text) { hasStoredFirstMessage = true; session.updateMetadata((m) => ({ ...m, firstMessage: message.content.text.substring(0, 100) })); } let messagePermissionMode = currentPermissionMode; if (message.meta?.permissionMode) { const validModes = ["default", "read-only", "safe-yolo", "yolo"]; if (validModes.includes(message.meta.permissionMode)) { messagePermissionMode = message.meta.permissionMode; currentPermissionMode = messagePermissionMode; updatePermissionMode(messagePermissionMode); persistence.logger.debug(`[Gemini] Permission mode updated from user message to: ${currentPermissionMode}`); } else { persistence.logger.debug(`[Gemini] Invalid permission mode received: ${message.meta.permissionMode}`); } } else { persistence.logger.debug(`[Gemini] User message received with no permission mode override, using current: ${currentPermissionMode ?? "default (effective)"}`); } if (currentPermissionMode === void 0) { currentPermissionMode = "default"; updatePermissionMode("default"); } let messageModel = currentModel; if (message.meta?.hasOwnProperty("model")) { if (message.meta.model === null) { messageModel = void 0; currentModel = void 0; } else if (message.meta.model) { const previousModel = currentModel; messageModel = message.meta.model; currentModel = messageModel; if (previousModel !== messageModel) { updateDisplayedModel(messageModel, true); messageBuffer.addMessage(`Model changed to: ${messageModel}`, "system"); persistence.logger.debug(`[Gemini] Model changed from ${previousModel} to ${messageModel}`); } } } const originalUserMessage = message.content.text; let fullPrompt = originalUserMessage; if (isFirstMessage && message.meta?.appendSystemPrompt) { fullPrompt = message.meta.appendSystemPrompt + "\n\n" + originalUserMessage; isFirstMessage = false; } const mode = { permissionMode: messagePermissionMode || "default", model: messageModel, originalUserMessage // Store original message separately }; messageQueue.push(fullPrompt, mode); conversationHistory.addUserMessage(originalUserMessage); }); let thinking = false; session.keepAlive(thinking, "remote"); const keepAliveInterval = setInterval(() => { session.keepAlive(thinking, "remote"); }, 2e3); let isFirstMessage = true; const sendReady = () => { session.sendSessionEvent({ type: "ready" }); try { api.push().sendToAllDevices( "It's ready!", "Gemini is waiting for your command", { sessionId: session.sessionId } ); } catch (pushError) { persistence.logger.debug("[Gemini] Failed to send ready push", pushError); } }; const emitReadyIfIdle = () => { if (shouldExit) { return false; } if (thinking) { return false; } if (isResponseInProgress) { return false; } if (messageQueue.size() > 0) { return false; } sendReady(); return true; }; let abortController = new AbortController(); let shouldExit = false; let geminiBackend = null; let acpSessionId = null; let wasSessionCreated = false; async function handleAbort() { persistence.logger.debug("[Gemini] Abort requested - stopping current task"); session.sendAgentMessage("gemini", { type: "turn_aborted", id: node_crypto.randomUUID() }); reasoningProcessor.abort(); diffProcessor.reset(); try { abortController.abort(); messageQueue.reset(); if (geminiBackend && acpSessionId) { await geminiBackend.cancel(acpSessionId); } persistence.logger.debug("[Gemini] Abort completed - session remains active"); } catch (error) { persistence.logger.debug("[Gemini] Error during abort:", error); } finally { abortController = new AbortController(); } } const handleKillSession = async () => { persistence.logger.debug("[Gemini] Kill session requested - terminating process"); await handleAbort(); persistence.logger.debug("[Gemini] Abort completed, proceeding with termination"); try { if (session) { session.updateMetadata((currentMetadata) => ({ ...currentMetadata, lifecycleState: "archived", lifecycleStateSince: Date.now(), archivedBy: "cli", archiveReason: "User terminated" })); session.sendSessionDeath(); await session.flush(); await session.close(); } index.stopCaffeinate(); consortiumServer.stop(); if (geminiBackend) { await geminiBackend.dispose(); } persistence.logger.debug("[Gemini] Session termination complete, exiting"); process.exit(0); } catch (error) { persistence.logger.debug("[Gemini] Error during session termination:", error); process.exit(1); } }; session.rpcHandlerManager.registerHandler("abort", handleAbort); index.registerKillSessionHandler(session.rpcHandlerManager, handleKillSession); const messageBuffer = new index.MessageBuffer(); const hasTTY = process.stdout.isTTY && process.stdin.isTTY; let inkInstance = null; let displayedModel = config.getInitialGeminiModel(); const localConfig = config.readGeminiLocalConfig(); persistence.logger.debug(`[gemini] Initial model setup: env[GEMINI_MODEL_ENV]=${process.env[config.GEMINI_MODEL_ENV] || "not set"}, localConfig=${localConfig.model || "not set"}, displayedModel=${displayedModel}`); const updateDisplayedModel = (model, saveToConfig = false) => { if (model === void 0) { persistence.logger.debug(`[gemini] updateDisplayedModel called with undefined, skipping update`); return; } const oldModel = displayedModel; displayedModel = model; persistence.logger.debug(`[gemini] updateDisplayedModel called: oldModel=${oldModel}, newModel=${model}, saveToConfig=${saveToConfig}`); if (saveToConfig) { config.saveGeminiModelToConfig(model); } if (hasTTY && oldModel !== model) { persistence.logger.debug(`[gemini] Adding model update message to buffer: [MODEL:${model}]`); messageBuffer.addMessage(`[MODEL:${model}]`, "system"); } else if (hasTTY) { persistence.logger.debug(`[gemini] Model unchanged, skipping update message`); } }; if (hasTTY) { console.clear(); const DisplayComponent = () => { const currentModelValue = displayedModel || "gemini-2.5-pro"; return React.createElement(GeminiDisplay.GeminiDisplay, { messageBuffer, logPath: process.env.DEBUG ? persistence.logger.getLogPath() : void 0, currentModel: currentModelValue, onExit: async () => { persistence.logger.debug("[gemini]: Exiting agent via Ctrl-C"); shouldExit = true; await handleAbort(); } }); }; inkInstance = ink.render(React.createElement(DisplayComponent), { exitOnCtrlC: false, patchConsole: false }); const initialModelName = displayedModel || "gemini-2.5-pro"; persistence.logger.debug(`[gemini] Sending initial model to UI: ${initialModelName}`); messageBuffer.addMessage(`[MODEL:${initialModelName}]`, "system"); } if (hasTTY) { process.stdin.resume(); if (process.stdin.isTTY) { process.stdin.setRawMode(true); } process.stdin.setEncoding("utf8"); } const consortiumServer = await index.startConsortiumServer(session, { sendArtifact: (msg) => session.sendAgentMessage("gemini", msg) }); const bridgeCommand = path.join(persistence.projectPath(), "bin", "consortium-mcp.mjs"); const mcpServers = { consortium: { command: bridgeCommand, args: ["--url", consortiumServer.url] } }; permissionHandler = new GeminiPermissionHandler(session); const reasoningProcessor = new GeminiReasoningProcessor((message) => { session.sendAgentMessage("gemini", message); }); const diffProcessor = new GeminiDiffProcessor((message) => { session.sendAgentMessage("gemini", message); }); const updatePermissionMode = (mode) => { permissionHandler.setPermissionMode(mode); }; let accumulatedResponse = ""; let isResponseInProgress = false; let sendPromptInFlight = false; let hadToolCallInTurn = false; let pendingChangeTitle = false; let changeTitleCompleted = false; let taskStartedSent = false; function setupGeminiMessageHandler(backend) { backend.onMessage((msg) => { switch (msg.type) { case "model-output": if (msg.textDelta) { if (!sendPromptInFlight) { session.sendAgentMessage("gemini", { type: "message", message: msg.textDelta }); break; } if (!isResponseInProgress) { messageBuffer.removeLastMessage("system"); messageBuffer.addMessage(msg.textDelta, "assistant"); isResponseInProgress = true; persistence.logger.debug(`[gemini] Started new response, first chunk length: ${msg.textDelta.length}`); } else { messageBuffer.updateLastMessage(msg.textDelta, "assistant"); persistence.logger.debug(`[gemini] Updated response, chunk length: ${msg.textDelta.length}, total accumulated: ${accumulatedResponse.length + msg.textDelta.length}`); } accumulatedResponse += msg.textDelta; } break; case "status": const statusDetail = msg.detail ? typeof msg.detail === "object" ? JSON.stringify(msg.detail) : String(msg.detail) : ""; persistence.logger.debug(`[gemini] Status changed: ${msg.status}${statusDetail ? ` - ${statusDetail}` : ""}`); if (msg.status === "error") { persistence.logger.debug(`[gemini] \u26A0\uFE0F Error status received: ${statusDetail || "Unknown error"}`); session.sendAgentMessage("gemini", { type: "turn_aborted", id: node_crypto.randomUUID() }); } if (msg.status === "running") { thinking = true; session.keepAlive(thinking, "remote"); if (!taskStartedSent) { session.sendAgentMessage("gemini", { type: "task_started", id: node_crypto.randomUUID() }); taskStartedSent = true; } messageBuffer.addMessage("Thinking...", "system"); } else if (msg.status === "idle" || msg.status === "stopped") { reasoningProcessor.complete(); } else if (msg.status === "error") { thinking = false; session.keepAlive(thinking, "remote"); accumulatedResponse = ""; isResponseInProgress = false; let errorMessage = "Unknown error"; if (msg.detail) { if (typeof msg.detail === "object") { const detailObj = msg.detail; errorMessage = detailObj.message || detailObj.details || JSON.stringify(detailObj); } else { errorMessage = String(msg.detail); } } if (errorMessage.includes("Authentication required")) { errorMessage = `Authentication required. For Google Workspace accounts, run: consortium gemini project set <project-id> Or use a different Google account: consortium connect gemini Guide: https://goo.gle/gemini-cli-auth-docs#workspace-gca`; } messageBuffer.addMessage(`Error: ${errorMessage}`, "status"); session.sendAgentMessage("gemini", { type: "message", message: `Error: ${errorMessage}` }); } break; case "tool-call": hadToolCallInTurn = true; const toolArgs = msg.args ? JSON.stringify(msg.args).substring(0, 100) : ""; const isInvestigationTool = msg.toolName === "codebase_investigator" || typeof msg.toolName === "string" && msg.toolName.includes("investigator"); persistence.logger.debug(`[gemini] \u{1F527} Tool call received: ${msg.toolName} (${msg.callId})${isInvestigationTool ? " [INVESTIGATION]" : ""}`); if (isInvestigationTool && msg.args && typeof msg.args === "object" && "objective" in msg.args) { persistence.logger.debug(`[gemini] \u{1F50D} Investigation objective: ${String(msg.args.objective).substring(0, 150)}...`); } messageBuffer.addMessage(`Executing: ${msg.toolName}${toolArgs ? ` ${toolArgs}${toolArgs.length >= 100 ? "..." : ""}` : ""}`, "tool"); session.sendAgentMessage("gemini", { type: "tool-call", name: msg.toolName, callId: msg.callId, input: msg.args, id: node_crypto.randomUUID() }); break; case "tool-result": if (msg.toolName === "change_title" || msg.callId?.includes("change_title") || msg.toolName === "consortium__change_title") { changeTitleCompleted = true; persistence.logger.debug("[gemini] change_title completed"); } const isError = msg.result && typeof msg.result === "object" && "error" in msg.result; const resultText = typeof msg.result === "string" ? msg.result.substring(0, 200) : JSON.stringify(msg.result).substring(0, 200); const truncatedResult = resultText + (typeof msg.result === "string" && msg.result.length > 200 ? "..." : ""); const resultSize = typeof msg.result === "string" ? msg.result.length : JSON.stringify(msg.result).length; persistence.logger.debug(`[gemini] ${isError ? "\u274C" : "\u2705"} Tool result received: ${msg.toolName} (${msg.callId}) - Size: ${resultSize} bytes${isError ? " [ERROR]" : ""}`); if (!isError) { diffProcessor.processToolResult(msg.toolName, msg.result, msg.callId); } if (isError) { const errorMsg = msg.result.error || "Tool call failed"; persistence.logger.debug(`[gemini] \u274C Tool call error: ${errorMsg.substring(0, 300)}`); messageBuffer.addMessage(`Error: ${errorMsg}`, "status"); } else { if (resultSize > 1e3) { persistence.logger.debug(`[gemini] \u2705 Large tool result (${resultSize} bytes) - first 200 chars: ${truncatedResult}`); } messageBuffer.addMessage(`Result: ${truncatedResult}`, "result"); } session.sendAgentMessage("gemini", { type: "tool-result", callId: msg.callId, output: msg.result, id: node_crypto.randomUUID() }); break; case "fs-edit": messageBuffer.addMessage(`File edit: ${msg.description}`, "tool"); diffProcessor.processFsEdit(msg.path || "", msg.description, msg.diff); session.sendAgentMessage("gemini", { type: "file-edit", description: msg.description, diff: msg.diff, filePath: msg.path || "unknown", id: node_crypto.randomUUID() }); break; default: if (msg.type === "token-count") { session.sendAgentMessage("gemini", { type: "token_count", ...msg, id: node_crypto.randomUUID() }); } break; case "terminal-output": messageBuffer.addMessage(msg.data, "result"); session.sendAgentMessage("gemini", { type: "terminal-output", data: msg.data, callId: msg.callId || node_crypto.randomUUID() }); break; case "permission-request": const payload = msg.payload || {}; session.sendAgentMessage("gemini", { type: "permission-request", permissionId: msg.id, toolName: payload.toolName || msg.reason || "unknown", description: msg.reason || payload.toolName || "", options: payload }); break; case "exec-approval-request": const execApprovalMsg = msg; const callId = execApprovalMsg.call_id || execApprovalMsg.callId || node_crypto.randomUUID(); const { call_id, type, ...inputs } = execApprovalMsg; persistence.logger.debug(`[gemini] Exec approval request received: ${callId}`); messageBuffer.addMessage(`Exec approval requested: ${callId}`, "tool"); session.sendAgentMessage("gemini", { type: "tool-call", name: "GeminiBash", // Similar to Codex's CodexBash callId, input: inputs, id: node_crypto.randomUUID() }); break; case "patch-apply-begin": const patchBeginMsg = msg; const patchCallId = patchBeginMsg.call_id || patchBeginMsg.callId || node_crypto.randomUUID(); const { call_id: patchCallIdVar, type: patchType, auto_approved, changes } = patchBeginMsg; const changeCount = changes ? Object.keys(changes).length : 0; const filesMsg = changeCount === 1 ? "1 file" : `${changeCount} files`; messageBuffer.addMessage(`Modifying ${filesMsg}...`, "tool"); persistence.logger.debug(`[gemini] Patch apply begin: ${patchCallId}, files: ${changeCount}`); session.sendAgentMessage("gemini", { type: "tool-call", name: "GeminiPatch", // Similar to Codex's CodexPatch callId: patchCallId, input: { auto_approved, changes }, id: node_crypto.randomUUID() }); break; case "patch-apply-end": const patchEndMsg = msg; const patchEndCallId = patchEndMsg.call_id || patchEndMsg.callId || node_crypto.randomUUID(); const { call_id: patchEndCallIdVar, type: patchEndType, stdout, stderr, success } = patchEndMsg; if (success) { const message = stdout || "Files modified successfully"; messageBuffer.addMessage(message.substring(0, 200), "result"); } else { const errorMsg = stderr || "Failed to modify files"; messageBuffer.addMessage(`Error: ${errorMsg.substring(0, 200)}`, "result"); } persistence.logger.debug(`[gemini] Patch apply end: ${patchEndCallId}, success: ${success}`); session.sendAgentMessage("gemini", { type: "tool-result", callId: patchEndCallId, output: { stdout, stderr, success }, id: node_crypto.randomUUID() }); break; case "inline-media": (async () => { try { const bytes = Uint8Array.from(Buffer.from(msg.base64, "base64")); const uploaded = await session.uploadArtifact({ bytes, mime: msg.mime }); session.sendAgentMessage("gemini", { type: "artifact", artifactId: uploaded.id, mime: uploaded.mime, name: msg.name, sizeBytes: uploaded.sizeBytes, id: node_crypto.randomUUID() }); } catch (err) { persistence.logger.debug("[gemini] inline-media upload failed:", err); } })(); break; case "event": if (msg.name === "thinking") { const thinkingPayload = msg.payload; const thinkingText = thinkingPayload && typeof thinkingPayload === "object" && "text" in thinkingPayload ? String(thinkingPayload.text || "") : ""; if (thinkingText) { reasoningProcessor.processChunk(thinkingText); persistence.logger.debug(`[gemini] \u{1F4AD} Thinking chunk received: ${thinkingText.length} chars - Preview: ${thinkingText.substring(0, 100)}...`); if (!thinkingText.startsWith("**")) { const thinkingPreview = thinkingText.substring(0, 100); messageBuffer.updateLastMessage(`[Thinking] ${thinkingPreview}...`, "system"); } } session.sendAgentMessage("gemini", { type: "thinking", text: thinkingText }); } break; } }); } let first = true; try { let currentModeHash = null; let pending = null; while (!shouldExit) { let message = pending; pending = null; if (!message) { persistence.logger.debug("[gemini] Main loop: waiting for messages from queue..."); const waitSignal = abortController.signal; const batch = await messageQueue.waitForMessagesAndGetAsString(waitSignal); if (!batch) { if (waitSignal.aborted && !shouldExit) { persistence.logger.debug("[gemini] Main loop: wait aborted, continuing..."); continue; } persistence.logger.debug("[gemini] Main loop: no batch received, breaking..."); break; } persistence.logger.debug(`[gemini] Main loop: received message from queue (length: ${batch.message.length})`); message = batch; } if (!message) { break; } let injectHistoryContext = false; if (wasSessionCreated && currentModeHash && message.hash !== currentModeHash) { persistence.logger.debug("[Gemini] Mode changed \u2013 restarting Gemini session"); messageBuffer.addMessage("\u2550".repeat(40), "status"); if (conversationHistory.hasHistory()) { messageBuffer.addMessage(`Switching model (preserving ${conversationHistory.size()} messages of context)...`, "status"); injectHistoryContext = true; persistence.logger.debug(`[Gemini] Will inject conversation history: ${conversationHistory.getSummary()}`); } else { messageBuffer.addMessage("Starting new Gemini session (mode changed)...", "status"); } permissionHandler.reset(); reasoningProcessor.abort(); if (geminiBackend) { await geminiBackend.dispose(); geminiBackend = null; } const modelToUse = message.mode?.model === void 0 ? void 0 : message.mode.model || null; const backendResult = createGeminiBackend({ cwd: process.cwd(), mcpServers, permissionHandler, cloudToken, currentUserEmail, // Pass model from message - if undefined, will use local config/env/default // If explicitly null, will skip local config and use env/default model: modelToUse }); geminiBackend = backendResult.backend; setupGeminiMessageHandler(geminiBackend); const actualMode