consortium
Version:
Remote control and session sharing CLI for AI coding agents
1,320 lines (1,309 loc) • 63.6 kB
JavaScript
'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