diff --git a/src/agent/acp/AcpSdkBackend.ts b/src/agent/acp/AcpSdkBackend.ts index 8a88fd69..dae94f12 100644 --- a/src/agent/acp/AcpSdkBackend.ts +++ b/src/agent/acp/AcpSdkBackend.ts @@ -652,6 +652,9 @@ export class AcpSdkBackend implements AgentBackend { this.acpSessionId = sessionResponse.sessionId; logger.debug(`[AcpSdkBackend] Session created: ${this.acpSessionId}`); + // Emit session-created event so the launcher can track the session ID + this.emit({ type: 'event', name: 'session-created', payload: { sessionId: this.acpSessionId } }); + this.emit({ type: 'status', status: 'idle' }); // Send initial prompt if provided diff --git a/src/agent/acp/gemini.ts b/src/agent/acp/gemini.ts index 2b662ead..eb4578cd 100644 --- a/src/agent/acp/gemini.ts +++ b/src/agent/acp/gemini.ts @@ -30,20 +30,23 @@ import { export interface GeminiBackendOptions extends AgentFactoryOptions { /** API key for Gemini (defaults to GEMINI_API_KEY or GOOGLE_API_KEY env var) */ apiKey?: string; - + /** OAuth token from Happy cloud (via 'happy connect gemini') - highest priority */ cloudToken?: string; - + /** Model to use. If undefined, will use local config, env var, or default. * If explicitly set to null, will use default (skip local config). * (defaults to GEMINI_MODEL env var or 'gemini-2.5-pro') */ model?: string | null; - + /** MCP servers to make available to the agent */ mcpServers?: Record; - + /** Optional permission handler for tool approval */ permissionHandler?: AcpPermissionHandler; + + /** Session ID to resume (if available). Passed to Gemini CLI via --resume flag */ + sessionId?: string | null; } /** @@ -86,11 +89,18 @@ export function createGeminiBackend(options: GeminiBackendOptions): AgentBackend // If options.model is explicitly null, skip local config and use env/default const model = determineGeminiModel(options.model, localConfig); - // Build args - use only --experimental-acp flag + // Build args - start with --experimental-acp flag // Model is passed via GEMINI_MODEL env var (gemini CLI reads it automatically) // We don't use --model flag to avoid potential stdout conflicts with ACP protocol const geminiArgs = ['--experimental-acp']; + // Add --resume flag if session ID is provided + // This allows the Gemini CLI to resume an existing session, maintaining conversation history + if (options.sessionId) { + geminiArgs.push('--resume', options.sessionId); + logger.debug(`[Gemini] Resuming session: ${options.sessionId}`); + } + const backendOptions: AcpSdkBackendOptions = { agentName: 'gemini', cwd: options.cwd, diff --git a/src/api/apiSession.ts b/src/api/apiSession.ts index a2805c94..dd0bd290 100644 --- a/src/api/apiSession.ts +++ b/src/api/apiSession.ts @@ -226,26 +226,159 @@ export class ApiSessionClient extends EventEmitter { role: 'agent', content: { type: 'codex', - data: body // This wraps the entire Claude message + data: body // This wraps the entire Codex message }, meta: { sentFrom: 'cli' } }; const encrypted = encodeBase64(encrypt(this.encryptionKey, this.encryptionVariant, content)); - + // Check if socket is connected before sending if (!this.socket.connected) { logger.debug('[API] Socket not connected, cannot send message. Message will be lost:', { type: body.type }); // TODO: Consider implementing message queue or HTTP fallback for reliability } - + this.socket.emit('message', { sid: this.sessionId, message: encrypted }); } + /** + * Send Gemini message to mobile client + * Uses dedicated 'gemini' type instead of 'codex' for proper ACP alignment + * + * Configuration: GEMINI_DUAL_FORMAT + * - 'new': Send only gemini format + * - 'old': Send only legacy codex format (for rollback) + * - 'both': Send both formats for backward compatibility (default) + */ + sendGeminiMessage(body: any) { + const dualFormatMode = process.env.GEMINI_DUAL_FORMAT || 'both'; + + // Check if socket is connected before sending + if (!this.socket.connected) { + logger.debug('[API] Socket not connected, cannot send Gemini message. Message will be lost:', { type: body.type }); + return; + } + + // Send new gemini format + if (dualFormatMode === 'new' || dualFormatMode === 'both') { + let geminiContent = { + role: 'agent', + content: { + type: 'gemini', // Dedicated Gemini type + data: body + }, + meta: { + sentFrom: 'cli', + format: 'gemini' // Marker for debugging + } + }; + const geminiEncrypted = encodeBase64(encrypt(this.encryptionKey, this.encryptionVariant, geminiContent)); + + this.socket.emit('message', { + sid: this.sessionId, + message: geminiEncrypted + }); + + if (dualFormatMode === 'new') { + logger.debug('[API] Sent Gemini message (new format only):', { type: body.type }); + } + } + + // Send old codex format for backward compatibility + if (dualFormatMode === 'old' || dualFormatMode === 'both') { + // Convert new format to old codex format + let codexBody = this.convertGeminiToCodex(body); + if (codexBody) { + let codexContent = { + role: 'agent', + content: { + type: 'codex', + data: codexBody + }, + meta: { + sentFrom: 'cli', + format: 'codex-compat' // Marker for debugging + } + }; + const codexEncrypted = encodeBase64(encrypt(this.encryptionKey, this.encryptionVariant, codexContent)); + + this.socket.emit('message', { + sid: this.sessionId, + message: codexEncrypted + }); + + if (dualFormatMode === 'both') { + logger.debug('[API] Sent Gemini message (dual format - new + old):', { type: body.type }); + } + } + } + } + + /** + * Convert new Gemini message format to old Codex format for backward compatibility + */ + private convertGeminiToCodex(geminiBody: any): any { + const type = geminiBody.type; + + switch (type) { + case 'model-output': + return { + type: 'message', + message: geminiBody.textDelta || '', + id: geminiBody.id + }; + + case 'thinking': + return { + type: 'reasoning', + message: geminiBody.text, + id: geminiBody.id + }; + + case 'message': + return { + type: 'message', + message: geminiBody.message, + id: geminiBody.id + }; + + case 'tool-call': + return { + type: 'tool-call', + callId: geminiBody.callId, + name: geminiBody.toolName, + input: geminiBody.args, + id: geminiBody.id + }; + + case 'tool-result': + return { + type: 'tool-call-result', + callId: geminiBody.callId, + output: geminiBody.result, + id: geminiBody.id + }; + + case 'status': + case 'token-count': + case 'file-edit': + case 'terminal-output': + case 'permission-request': + // These don't exist in old format, skip + logger.debug('[API] Skipping Gemini message type in codex conversion:', type); + return null; + + default: + logger.debug('[API] Unknown Gemini message type for codex conversion:', type); + return null; + } + } + /** * Send a generic agent message to the session. * Works for any agent type (Gemini, Codex, Claude, etc.) diff --git a/src/api/types.ts b/src/api/types.ts index ae0147e5..7880ced3 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -271,12 +271,111 @@ export const UserMessageSchema = z.object({ export type UserMessage = z.infer +/** + * Gemini message data schema - aligned with ACP protocol + * Used for dedicated Gemini message type (separate from Codex) + */ +export const GeminiMessageDataSchema = z.discriminatedUnion('type', [ + // Text output from model + z.object({ + type: z.literal('model-output'), + textDelta: z.string().optional(), + id: z.string() + }), + + // Tool call + z.object({ + type: z.literal('tool-call'), + toolName: z.string(), + args: z.any(), + callId: z.string(), + id: z.string() + }), + + // Tool result + z.object({ + type: z.literal('tool-result'), + toolName: z.string(), + result: z.any(), + callId: z.string(), + isError: z.boolean().optional(), + id: z.string() + }), + + // Status update + z.object({ + type: z.literal('status'), + status: z.enum(['starting', 'running', 'idle', 'stopped', 'error']), + id: z.string() + }), + + // Token usage + z.object({ + type: z.literal('token-count'), + inputTokens: z.number(), + outputTokens: z.number(), + totalTokens: z.number().optional(), + id: z.string() + }), + + // Thinking/reasoning + z.object({ + type: z.literal('thinking'), + text: z.string(), + id: z.string() + }), + + // File edit + z.object({ + type: z.literal('file-edit'), + description: z.string(), + diff: z.string(), + path: z.string().optional(), + id: z.string() + }), + + // Terminal output + z.object({ + type: z.literal('terminal-output'), + data: z.string(), + id: z.string() + }), + + // Permission request + z.object({ + type: z.literal('permission-request'), + permissionId: z.string(), + reason: z.string(), + payload: z.any().optional(), + id: z.string() + }), + + // Generic message + z.object({ + type: z.literal('message'), + message: z.string(), + id: z.string() + }) +]); + +export type GeminiMessageData = z.infer + export const AgentMessageSchema = z.object({ role: z.literal('agent'), - content: z.object({ - type: z.literal('output'), - data: z.any() - }), + content: z.discriminatedUnion('type', [ + z.object({ + type: z.literal('output'), + data: z.any() // Claude messages + }), + z.object({ + type: z.literal('codex'), + data: z.any() // Codex/OpenAI messages + }), + z.object({ + type: z.literal('gemini'), // Gemini messages + data: GeminiMessageDataSchema + }) + ]), meta: MessageMetaSchema.optional() }) diff --git a/src/claude/claudeRemoteLauncher.ts b/src/claude/claudeRemoteLauncher.ts index b1888804..a8691c27 100644 --- a/src/claude/claudeRemoteLauncher.ts +++ b/src/claude/claudeRemoteLauncher.ts @@ -440,14 +440,34 @@ export async function claudeRemoteLauncher(session: Session): Promise<'switch' | // Clean up permission handler permissionHandler.reset(); - // Reset Terminal + // Reset Terminal - IMPORTANT: Order matters to prevent stdin listener competition bug! + // Must unmount Ink BEFORE changing terminal modes to ensure useInput listeners are removed + if (inkInstance) { + inkInstance.unmount(); + } + + // Remove explicit abort listener process.stdin.off('data', abort); + + // Reset terminal mode after Ink cleanup if (process.stdin.isTTY) { process.stdin.setRawMode(false); } - if (inkInstance) { - inkInstance.unmount(); + + // Additional cleanup: Ensure ALL stdin listeners are removed before switching to local mode + // This prevents parent and child from competing for stdin input (double character bug) + if (exitReason === 'switch') { + // Remove any remaining data listeners that might compete with the child process + const dataListeners = process.stdin.listeners('data'); + const keypressListeners = process.stdin.listeners('keypress'); + + if (dataListeners.length > 0 || keypressListeners.length > 0) { + logger.debug(`[remote]: Cleaning up remaining stdin listeners before switch (data: ${dataListeners.length}, keypress: ${keypressListeners.length})`); + process.stdin.removeAllListeners('data'); + process.stdin.removeAllListeners('keypress'); + } } + messageBuffer.clear(); // Resolve abort future diff --git a/src/claude/loop.ts b/src/claude/loop.ts index b3d1f7e0..dcab191c 100644 --- a/src/claude/loop.ts +++ b/src/claude/loop.ts @@ -91,6 +91,11 @@ export async function loop(opts: LoopOptions) { if (opts.onModeChange) { opts.onModeChange(mode); } + + // Small delay to ensure remote mode stdin cleanup fully completes + // before local mode spawns child (prevents stdin listener competition) + await new Promise(resolve => setTimeout(resolve, 50)); + continue; } } diff --git a/src/gemini/geminiLocal.ts b/src/gemini/geminiLocal.ts new file mode 100644 index 00000000..ae853f64 --- /dev/null +++ b/src/gemini/geminiLocal.ts @@ -0,0 +1,101 @@ +/** + * Gemini Local Mode - Spawn Gemini CLI Process + * + * This module spawns the Gemini CLI as a child process with inherited stdio, + * allowing the user to interact directly with Gemini's native interface. + * + * Similar to claudeLocal.ts but adapted for Gemini CLI's API. + */ + +import { spawn } from 'node:child_process'; +import { logger } from '@/ui/logger'; + +export async function geminiLocal(opts: { + abort: AbortSignal, + sessionId: string | null, + path: string, + onSessionFound: (id: string) => void, + model?: string, + approvalMode?: string, + allowedTools?: string[], +}): Promise { + logger.debug(`[GeminiLocal] Starting Gemini CLI in local mode`); + logger.debug(`[GeminiLocal] Working directory: ${opts.path}`); + logger.debug(`[GeminiLocal] Session ID: ${opts.sessionId || 'new'}`); + + // Build Gemini CLI arguments + const args: string[] = []; + + // Session management + if (opts.sessionId) { + args.push('--resume', opts.sessionId); + logger.debug(`[GeminiLocal] Resuming session: ${opts.sessionId}`); + } else { + logger.debug(`[GeminiLocal] Starting fresh session`); + } + + // Model selection + if (opts.model) { + args.push('--model', opts.model); + logger.debug(`[GeminiLocal] Using model: ${opts.model}`); + } + + // Permission/approval mode + if (opts.approvalMode) { + args.push('--approval-mode', opts.approvalMode); + logger.debug(`[GeminiLocal] Approval mode: ${opts.approvalMode}`); + } + + // Allowed tools (pre-approved tools that don't need confirmation) + if (opts.allowedTools && opts.allowedTools.length > 0) { + args.push('--allowed-tools', ...opts.allowedTools); + logger.debug(`[GeminiLocal] Allowed tools: ${opts.allowedTools.join(', ')}`); + } + + logger.debug(`[GeminiLocal] Spawning: gemini ${args.join(' ')}`); + + // Spawn the Gemini CLI process + try { + await new Promise((resolve, reject) => { + const child = spawn('gemini', args, { + stdio: ['inherit', 'inherit', 'inherit'], + signal: opts.abort, + cwd: opts.path, + env: { + ...process.env, + // Gemini CLI environment variables + GEMINI_PROJECT_DIR: opts.path, + } + }); + + child.on('error', (error) => { + logger.debug('[GeminiLocal] Process error:', error); + reject(error); + }); + + child.on('exit', (code, signal) => { + if (signal === 'SIGTERM' && opts.abort.aborted) { + // Normal termination due to abort signal + logger.debug('[GeminiLocal] Process aborted by signal'); + resolve(); + } else if (signal) { + logger.debug(`[GeminiLocal] Process terminated with signal: ${signal}`); + reject(new Error(`Gemini terminated with signal: ${signal}`)); + } else { + logger.debug(`[GeminiLocal] Process exited with code: ${code}`); + resolve(); + } + }); + }); + } catch (error: any) { + logger.debug('[GeminiLocal] Spawn failed:', error); + if (error.code === 'ENOENT') { + throw new Error('Gemini CLI not found. Please install it with: npm install -g @google/gemini-cli'); + } + throw error; + } finally { + logger.debug('[GeminiLocal] Cleanup complete'); + } + + return opts.sessionId; +} diff --git a/src/gemini/geminiLocalLauncher.ts b/src/gemini/geminiLocalLauncher.ts new file mode 100644 index 00000000..9942415d --- /dev/null +++ b/src/gemini/geminiLocalLauncher.ts @@ -0,0 +1,174 @@ +/** + * Gemini Local Mode Launcher + * + * Orchestrates local mode: spawns Gemini CLI, monitors session via hooks, + * and handles mode switching. + * + * Adapted from claudeLocalLauncher.ts for Gemini CLI. + */ + +import { logger } from '@/ui/logger'; +import { geminiLocal } from './geminiLocal'; +import { GeminiSession } from './session'; +import { Future } from '@/utils/future'; +import { createGeminiSessionScanner } from './utils/sessionScanner'; + +export async function geminiLocalLauncher(session: GeminiSession, opts: { + model?: string; + approvalMode?: string; + allowedTools?: string[]; +}): Promise<'switch' | 'exit'> { + logger.debug('[geminiLocalLauncher] Starting local launcher'); + + // Create scanner if we have a transcript path + let scanner: Awaited> | null = null; + + if (session.transcriptPath) { + scanner = await createGeminiSessionScanner({ + transcriptPath: session.transcriptPath, + onMessage: (message) => { + // Forward messages to Happy server + // TODO: Convert Gemini message format to Happy format + logger.debug(`[geminiLocalLauncher] Received message:`, message); + // session.client.sendGeminiSessionMessage(message); + } + }); + } + + // Register callbacks for when session/transcript are found via hook + const scannerSessionCallback = (transcriptPath: string) => { + logger.debug(`[geminiLocalLauncher] Transcript path callback: ${transcriptPath}`); + if (scanner) { + scanner.onNewSession(transcriptPath); + } + }; + + if (!session.transcriptPath) { + session.addTranscriptPathCallback(scannerSessionCallback); + } + + // Handle abort + let exitReason: 'switch' | 'exit' | null = null; + const processAbortController = new AbortController(); + let exitFuture = new Future(); + + try { + async function abort() { + // Send abort signal + if (!processAbortController.signal.aborted) { + processAbortController.abort(); + } + + // Await full exit + await exitFuture.promise; + } + + async function doAbort() { + logger.debug('[geminiLocal]: doAbort'); + + // Switching to remote mode + if (!exitReason) { + exitReason = 'switch'; + } + + // Reset sent messages + session.queue.reset(); + + // Abort + await abort(); + } + + async function doSwitch() { + logger.debug('[geminiLocal]: doSwitch'); + + // Switching to remote mode + if (!exitReason) { + exitReason = 'switch'; + } + + // Abort + await abort(); + } + + // Register RPC handlers for mode switching + session.client.rpcHandlerManager.registerHandler('abort', doAbort); + session.client.rpcHandlerManager.registerHandler('switch', doSwitch); + + // When any message is received, switch to remote mode + session.queue.setOnMessage((message: string, mode) => { + doSwitch(); + }); + + // If there are already messages in the queue, switch immediately + if (session.queue.size() > 0) { + logger.debug('[geminiLocal]: Messages in queue, switching to remote'); + return 'switch'; + } + + // Handle session start from hook + const handleSessionStart = (sessionId: string) => { + session.onSessionFound(sessionId); + }; + + // Run local mode + while (true) { + // If we already have an exit reason, return it + if (exitReason) { + return exitReason; + } + + // Launch Gemini CLI + logger.debug('[geminiLocal]: Launching Gemini CLI'); + try { + await geminiLocal({ + path: session.path, + sessionId: session.sessionId, + onSessionFound: handleSessionStart, + abort: processAbortController.signal, + model: opts.model, + approvalMode: opts.approvalMode, + allowedTools: opts.allowedTools, + }); + + // Normal exit + if (!exitReason) { + exitReason = 'exit'; + break; + } + } catch (e) { + logger.debug('[geminiLocal]: Launch error', e); + if (!exitReason) { + session.client.sendSessionEvent({ + type: 'message', + message: 'Gemini process exited unexpectedly' + }); + continue; + } else { + break; + } + } + logger.debug('[geminiLocal]: Launch done'); + } + } finally { + // Resolve future + exitFuture.resolve(undefined); + + // Set handlers to no-op + session.client.rpcHandlerManager.registerHandler('abort', async () => {}); + session.client.rpcHandlerManager.registerHandler('switch', async () => {}); + session.queue.setOnMessage(null); + + // Remove callbacks + if (!session.transcriptPath) { + session.removeTranscriptPathCallback(scannerSessionCallback); + } + + // Cleanup scanner + if (scanner) { + await scanner.cleanup(); + } + } + + // Return + return exitReason || 'exit'; +} diff --git a/src/gemini/geminiRemoteLauncher.ts b/src/gemini/geminiRemoteLauncher.ts new file mode 100644 index 00000000..d6031858 --- /dev/null +++ b/src/gemini/geminiRemoteLauncher.ts @@ -0,0 +1,891 @@ +/** + * Gemini Remote Mode Launcher + * + * Handles Gemini's remote mode with ACP backend and Ink UI. + * Extracted from runGemini.ts for better separation between local and remote modes. + */ + +import { render } from 'ink'; +import React from 'react'; +import { randomUUID } from 'node:crypto'; +import { join, resolve } from 'node:path'; + +import { ApiSessionClient } from '@/api/apiSession'; +import { ApiClient } from '@/api/api'; +import { logger } from '@/ui/logger'; +import { MessageQueue2 } from '@/utils/MessageQueue2'; +import { projectPath } from '@/projectPath'; +import { startHappyServer } from '@/claude/utils/startHappyServer'; +import { MessageBuffer } from '@/ui/ink/messageBuffer'; +import { registerKillSessionHandler } from '@/claude/registerKillSessionHandler'; + +import { createGeminiBackend } from '@/agent/acp/gemini'; +import type { AgentBackend, AgentMessage } from '@/agent/AgentBackend'; +import { GeminiDisplay } from '@/ui/ink/GeminiDisplay'; +import { GeminiPermissionHandler } from '@/gemini/utils/permissionHandler'; +import { GeminiReasoningProcessor } from '@/gemini/utils/reasoningProcessor'; +import { GeminiDiffProcessor } from '@/gemini/utils/diffProcessor'; +import type { PermissionMode, GeminiMode } from '@/gemini/types'; +import { GEMINI_MODEL_ENV, DEFAULT_GEMINI_MODEL, CHANGE_TITLE_INSTRUCTION } from '@/gemini/constants'; +import { + readGeminiLocalConfig, + determineGeminiModel, + saveGeminiModelToConfig, + getInitialGeminiModel +} from '@/gemini/utils/config'; +import { + parseOptionsFromText, + hasIncompleteOptions, + formatOptionsXml, +} from '@/gemini/utils/optionsParser'; + +export async function geminiRemoteLauncher(opts: { + session: ApiSessionClient; + api: ApiClient; + messageQueue: MessageQueue2; + model?: string; + approvalMode?: string; + allowedTools?: string[]; + cloudToken?: string; + sessionId?: string | null; + onSessionFound?: (sessionId: string) => void; +}): Promise<'switch' | 'exit'> { + logger.debug('[geminiRemoteLauncher] Starting remote mode'); + + const { session, api, messageQueue, cloudToken } = opts; + + // Track current overrides to apply per message (remote mode only) + let currentPermissionMode: PermissionMode | undefined = undefined; + let currentModel: string | undefined = opts.model; + + // Track if this is the first message to include system prompt only once + let isFirstMessage = true; + + session.onUserMessage((message) => { + // Resolve permission mode (validate) - same as Codex + let messagePermissionMode = currentPermissionMode; + if (message.meta?.permissionMode) { + const validModes: PermissionMode[] = ['default', 'read-only', 'safe-yolo', 'yolo']; + if (validModes.includes(message.meta.permissionMode as PermissionMode)) { + messagePermissionMode = message.meta.permissionMode as PermissionMode; + currentPermissionMode = messagePermissionMode; + // Update permission handler with new mode + updatePermissionMode(messagePermissionMode); + logger.debug(`[Gemini] Permission mode updated from user message to: ${currentPermissionMode}`); + } else { + logger.debug(`[Gemini] Invalid permission mode received: ${message.meta.permissionMode}`); + } + } else { + logger.debug(`[Gemini] User message received with no permission mode override, using current: ${currentPermissionMode ?? 'default (effective)'}`); + } + + // Initialize permission mode if not set yet + if (currentPermissionMode === undefined) { + currentPermissionMode = 'default'; + updatePermissionMode('default'); + } + + // Resolve model; explicit null resets to default (undefined) + let messageModel = currentModel; + if (message.meta?.hasOwnProperty('model')) { + if (message.meta.model === null) { + messageModel = undefined; + currentModel = undefined; + } else if (message.meta.model) { + messageModel = message.meta.model; + currentModel = messageModel; + updateDisplayedModel(messageModel, true); + messageBuffer.addMessage(`Model changed to: ${messageModel}`, 'system'); + } + } + + // Build the full prompt with appendSystemPrompt if provided + const originalUserMessage = message.content.text; + let fullPrompt = originalUserMessage; + if (isFirstMessage && message.meta?.appendSystemPrompt) { + fullPrompt = message.meta.appendSystemPrompt + '\n\n' + originalUserMessage + '\n\n' + CHANGE_TITLE_INSTRUCTION; + isFirstMessage = false; + } + + const mode: GeminiMode = { + permissionMode: messagePermissionMode || 'default', + model: messageModel, + originalUserMessage, + }; + messageQueue.push(fullPrompt, mode); + }); + + let thinking = false; + session.keepAlive(thinking, 'remote'); + const keepAliveInterval = setInterval(() => { + session.keepAlive(thinking, 'remote'); + }, 2000); + + const sendReady = () => { + session.sendSessionEvent({ type: 'ready' }); + try { + api.push().sendToAllDevices( + "It's ready!", + 'Gemini is waiting for your command', + { sessionId: session.sessionId } + ); + } catch (pushError) { + logger.debug('[Gemini] Failed to send ready push', pushError); + } + }; + + const emitReadyIfIdle = (): boolean => { + if (shouldExit) return false; + if (thinking) return false; + if (isResponseInProgress) return false; + if (messageQueue.size() > 0) return false; + + sendReady(); + return true; + }; + + // + // Abort handling + // + + let abortController = new AbortController(); + let shouldExit = false; + let switchToLocal = false; + let geminiBackend: AgentBackend | null = null; + let acpSessionId: string | null = opts.sessionId || null; // Start with provided session ID, will be updated when backend creates session + let wasSessionCreated = false; + + async function handleAbort() { + logger.debug('[Gemini] Abort requested - stopping current task'); + + session.sendGeminiMessage({ + type: 'message', + message: 'Turn aborted', + id: randomUUID(), + }); + + reasoningProcessor.abort(); + diffProcessor.reset(); + + try { + abortController.abort(); + messageQueue.reset(); + if (geminiBackend && acpSessionId) { + await geminiBackend.cancel(acpSessionId); + } + logger.debug('[Gemini] Abort completed - session remains active'); + } catch (error) { + logger.debug('[Gemini] Error during abort:', error); + } finally { + abortController = new AbortController(); + } + } + + const handleKillSession = async () => { + logger.debug('[Gemini] Kill session requested - terminating process'); + await handleAbort(); + shouldExit = true; + }; + + async function doSwitch() { + console.error('[geminiRemoteLauncher] Switch to local mode requested'); + logger.debug('[geminiRemoteLauncher] Switch to local mode requested'); + logger.debug(`[geminiRemoteLauncher] Current state - switchToLocal: ${switchToLocal}, shouldExit: ${shouldExit}`); + console.error(`[geminiRemoteLauncher] Setting flags - switchToLocal=true, shouldExit=true`); + switchToLocal = true; + shouldExit = true; + logger.debug('[geminiRemoteLauncher] State updated - switchToLocal: true, shouldExit: true'); + console.error('[geminiRemoteLauncher] Calling handleAbort()...'); + logger.debug('[geminiRemoteLauncher] Calling handleAbort()...'); + await handleAbort(); + console.error('[geminiRemoteLauncher] handleAbort() completed'); + logger.debug('[geminiRemoteLauncher] handleAbort() completed'); + } + + // Register RPC handlers for abort and mode switching + session.rpcHandlerManager.registerHandler('abort', handleAbort); + registerKillSessionHandler(session.rpcHandlerManager, handleKillSession); + session.rpcHandlerManager.registerHandler('switch', doSwitch); // When user wants to switch to local mode + + // + // Initialize Ink UI + // + + const messageBuffer = new MessageBuffer(); + const hasTTY = process.stdout.isTTY && process.stdin.isTTY; + let inkInstance: ReturnType | null = null; + + let displayedModel: string | undefined = opts.model || getInitialGeminiModel(); + + const localConfig = readGeminiLocalConfig(); + logger.debug(`[gemini] Initial model setup: env[GEMINI_MODEL_ENV]=${process.env[GEMINI_MODEL_ENV] || 'not set'}, localConfig=${localConfig.model || 'not set'}, displayedModel=${displayedModel}`); + + const updateDisplayedModel = (model: string | undefined, saveToConfig: boolean = false) => { + if (model === undefined) { + logger.debug(`[gemini] updateDisplayedModel called with undefined, skipping update`); + return; + } + + const oldModel = displayedModel; + displayedModel = model; + logger.debug(`[gemini] updateDisplayedModel called: oldModel=${oldModel}, newModel=${model}, saveToConfig=${saveToConfig}`); + + if (saveToConfig) { + saveGeminiModelToConfig(model); + } + + if (hasTTY && oldModel !== model) { + logger.debug(`[gemini] Adding model update message to buffer: [MODEL:${model}]`); + messageBuffer.addMessage(`[MODEL:${model}]`, 'system'); + } else if (hasTTY) { + logger.debug(`[gemini] Model unchanged, skipping update message`); + } + }; + + if (hasTTY) { + console.clear(); + + const switchCallback = () => { + logger.debug('[gemini]: Switching to local mode via spacebar'); + doSwitch(); + }; + + const exitCallback = async () => { + logger.debug('[gemini]: Exiting agent via Ctrl-C'); + shouldExit = true; + await handleAbort(); + }; + + inkInstance = render(React.createElement(GeminiDisplay, { + messageBuffer, + logPath: process.env.DEBUG ? logger.getLogPath() : undefined, + currentModel: displayedModel || 'gemini-2.5-pro', + onExit: exitCallback, + onSwitchToLocal: switchCallback + }), { + exitOnCtrlC: false, + patchConsole: false + }); + + const initialModelName = displayedModel || 'gemini-2.5-pro'; + 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'); + } + + // + // Start Happy MCP server and create Gemini backend + // + + const happyServer = await startHappyServer(session); + const bridgeCommand = join(projectPath(), 'bin', 'happy-mcp.mjs'); + const mcpServers = { + happy: { + command: bridgeCommand, + args: ['--url', happyServer.url] + } + }; + + const permissionHandler = new GeminiPermissionHandler(session); + const reasoningProcessor = new GeminiReasoningProcessor((message) => { + session.sendGeminiMessage(message); + }); + const diffProcessor = new GeminiDiffProcessor((message) => { + session.sendGeminiMessage(message); + }); + + const updatePermissionMode = (mode: PermissionMode) => { + permissionHandler.setPermissionMode(mode); + }; + + let accumulatedResponse = ''; + let isResponseInProgress = false; + let currentResponseMessageId: string | null = null; + + function setupGeminiMessageHandler(backend: AgentBackend): void { + backend.onMessage((msg: AgentMessage) => { + + switch (msg.type) { + case 'model-output': + if (msg.textDelta) { + if (!isResponseInProgress) { + messageBuffer.removeLastMessage('system'); + messageBuffer.addMessage(msg.textDelta, 'assistant'); + isResponseInProgress = true; + logger.debug(`[gemini] Started new response, first chunk length: ${msg.textDelta.length}`); + } else { + messageBuffer.updateLastMessage(msg.textDelta, 'assistant'); + logger.debug(`[gemini] Updated response, chunk length: ${msg.textDelta.length}, total accumulated: ${accumulatedResponse.length + msg.textDelta.length}`); + } + accumulatedResponse += msg.textDelta; + } + break; + + case 'status': + logger.debug(`[gemini] Status changed: ${msg.status}${msg.detail ? ` - ${msg.detail}` : ''}`); + + if (msg.status === 'error') { + logger.debug(`[gemini] ⚠️ Error status received: ${msg.detail || 'Unknown error'}`); + session.sendGeminiMessage({ + type: 'status', + status: 'error', + id: randomUUID(), + }); + } + + if (msg.status === 'running') { + thinking = true; + session.keepAlive(thinking, 'remote'); + session.sendGeminiMessage({ + type: 'status', + status: 'running', + id: randomUUID(), + }); + messageBuffer.addMessage('Thinking...', 'system'); + } else if (msg.status === 'idle' || msg.status === 'stopped') { + if (thinking) { + thinking = false; + } + thinking = false; + session.keepAlive(thinking, 'remote'); + + const reasoningCompleted = reasoningProcessor.complete(); + + if (reasoningCompleted || isResponseInProgress) { + session.sendGeminiMessage({ + type: 'status', + status: 'idle', + id: randomUUID(), + }); + } + + if (isResponseInProgress && accumulatedResponse.trim()) { + const { text: messageText, options } = parseOptionsFromText(accumulatedResponse); + + let finalMessageText = messageText; + if (options.length > 0) { + const optionsXml = formatOptionsXml(options); + finalMessageText = messageText + optionsXml; + logger.debug(`[gemini] Found ${options.length} options in response:`, options); + logger.debug(`[gemini] Keeping options in message text for mobile app parsing`); + } else if (hasIncompleteOptions(accumulatedResponse)) { + logger.debug(`[gemini] Warning: Incomplete options block detected but sending message anyway`); + } + + const messageId = randomUUID(); + + logger.debug(`[gemini] Sending complete message to mobile (length: ${finalMessageText.length}): ${finalMessageText.substring(0, 100)}...`); + session.sendGeminiMessage({ + type: 'model-output', + textDelta: finalMessageText, + id: messageId, + }); + accumulatedResponse = ''; + isResponseInProgress = false; + } + } else if (msg.status === 'error') { + thinking = false; + session.keepAlive(thinking, 'remote'); + accumulatedResponse = ''; + isResponseInProgress = false; + currentResponseMessageId = null; + + const errorMessage = msg.detail || 'Unknown error'; + messageBuffer.addMessage(`Error: ${errorMessage}`, 'status'); + + session.sendGeminiMessage({ + type: 'message', + message: `Error: ${errorMessage}`, + id: randomUUID(), + }); + } + break; + + case 'tool-call': + 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')); + + logger.debug(`[gemini] πŸ”§ Tool call received: ${msg.toolName} (${msg.callId})${isInvestigationTool ? ' [INVESTIGATION]' : ''}`); + if (isInvestigationTool && msg.args && typeof msg.args === 'object' && 'objective' in msg.args) { + logger.debug(`[gemini] πŸ” Investigation objective: ${String(msg.args.objective).substring(0, 150)}...`); + } + + messageBuffer.addMessage(`Executing: ${msg.toolName}${toolArgs ? ` ${toolArgs}${toolArgs.length >= 100 ? '...' : ''}` : ''}`, 'tool'); + session.sendGeminiMessage({ + type: 'tool-call', + toolName: msg.toolName, + args: msg.args, + callId: msg.callId, + id: randomUUID(), + }); + break; + + case 'tool-result': + 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; + + logger.debug(`[gemini] ${isError ? '❌' : 'βœ…'} 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 as any).error || 'Tool call failed'; + logger.debug(`[gemini] ❌ Tool call error: ${errorMsg.substring(0, 300)}`); + messageBuffer.addMessage(`Error: ${errorMsg}`, 'status'); + } else { + if (resultSize > 1000) { + logger.debug(`[gemini] βœ… Large tool result (${resultSize} bytes) - first 200 chars: ${truncatedResult}`); + } + messageBuffer.addMessage(`Result: ${truncatedResult}`, 'result'); + } + + session.sendGeminiMessage({ + type: 'tool-result', + toolName: msg.toolName, + result: msg.result, + callId: msg.callId, + isError: isError, + id: randomUUID(), + }); + break; + + case 'fs-edit': + messageBuffer.addMessage(`File edit: ${msg.description}`, 'tool'); + diffProcessor.processFsEdit(msg.path || '', msg.description, msg.diff); + + session.sendGeminiMessage({ + type: 'file-edit', + description: msg.description, + diff: msg.diff, + path: msg.path, + id: randomUUID(), + }); + break; + + case 'terminal-output': + messageBuffer.addMessage(msg.data, 'result'); + session.sendGeminiMessage({ + type: 'terminal-output', + data: msg.data, + id: randomUUID(), + }); + break; + + case 'permission-request': + session.sendGeminiMessage({ + type: 'permission-request', + permissionId: msg.id, + reason: msg.reason, + payload: msg.payload, + id: randomUUID(), + }); + break; + + case 'exec-approval-request': + const execApprovalMsg = msg as any; + const callId = execApprovalMsg.call_id || execApprovalMsg.callId || randomUUID(); + const { call_id, type, ...inputs } = execApprovalMsg; + + logger.debug(`[gemini] Exec approval request received: ${callId}`); + messageBuffer.addMessage(`Exec approval requested: ${callId}`, 'tool'); + + session.sendGeminiMessage({ + type: 'tool-call', + toolName: 'GeminiBash', + args: inputs, + callId: callId, + id: randomUUID(), + }); + break; + + case 'patch-apply-begin': + const patchBeginMsg = msg as any; + const patchCallId = patchBeginMsg.call_id || patchBeginMsg.callId || 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'); + logger.debug(`[gemini] Patch apply begin: ${patchCallId}, files: ${changeCount}`); + + session.sendGeminiMessage({ + type: 'tool-call', + toolName: 'GeminiPatch', + args: { + auto_approved, + changes + }, + callId: patchCallId, + id: randomUUID(), + }); + break; + + case 'patch-apply-end': + const patchEndMsg = msg as any; + const patchEndCallId = patchEndMsg.call_id || patchEndMsg.callId || 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'); + } + logger.debug(`[gemini] Patch apply end: ${patchEndCallId}, success: ${success}`); + + session.sendGeminiMessage({ + type: 'tool-result', + toolName: 'GeminiPatch', + result: { + stdout, + stderr, + success + }, + callId: patchEndCallId, + isError: !success, + id: randomUUID(), + }); + break; + + case 'event': + if (msg.name === 'session-created') { + // Capture the ACP session ID for future resumption + const sessionPayload = msg.payload as { sessionId?: string } | undefined; + if (sessionPayload && sessionPayload.sessionId) { + acpSessionId = sessionPayload.sessionId; + logger.debug(`[gemini] Session created/captured: ${acpSessionId}`); + + // Notify the loop so it can update the GeminiSession object + if (opts.onSessionFound) { + opts.onSessionFound(acpSessionId); + } + } + } else if (msg.name === 'thinking') { + const thinkingPayload = msg.payload as { text?: string } | undefined; + const thinkingText = (thinkingPayload && typeof thinkingPayload === 'object' && 'text' in thinkingPayload) + ? String(thinkingPayload.text || '') + : ''; + if (thinkingText) { + reasoningProcessor.processChunk(thinkingText); + logger.debug(`[gemini] πŸ’­ 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.sendGeminiMessage({ + type: 'thinking', + text: thinkingText, + id: randomUUID(), + }); + } + break; + + default: + if ((msg as any).type === 'token-count') { + const tokenMsg = msg as any; + session.sendGeminiMessage({ + type: 'token-count', + inputTokens: tokenMsg.inputTokens || 0, + outputTokens: tokenMsg.outputTokens || 0, + totalTokens: tokenMsg.totalTokens, + id: randomUUID(), + }); + } + break; + } + }); + } + + let first = true; + + try { + let currentModeHash: string | null = null; + let pending: { message: string; mode: GeminiMode; isolate: boolean; hash: string } | null = null; + + while (!shouldExit) { + logger.debug(`[gemini] Main loop iteration - shouldExit: ${shouldExit}, switchToLocal: ${switchToLocal}`); + let message: { message: string; mode: GeminiMode; isolate: boolean; hash: string } | null = pending; + pending = null; + + if (!message) { + 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) { + logger.debug('[gemini] Main loop: wait aborted but shouldExit is false, continuing...'); + continue; + } + logger.debug(`[gemini] Main loop: no batch received (aborted: ${waitSignal.aborted}, shouldExit: ${shouldExit}), breaking...`); + break; + } + logger.debug(`[gemini] Main loop: received message from queue (length: ${batch.message.length})`); + message = batch; + } + + if (!message) { + break; + } + + // Handle mode change + if (wasSessionCreated && currentModeHash && message.hash !== currentModeHash) { + logger.debug('[Gemini] Mode changed – restarting Gemini session'); + messageBuffer.addMessage('═'.repeat(40), 'status'); + 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 === undefined ? undefined : (message.mode.model || null); + geminiBackend = createGeminiBackend({ + cwd: process.cwd(), + mcpServers, + permissionHandler, + cloudToken, + model: modelToUse, + sessionId: acpSessionId, // Use captured session ID + }); + + setupGeminiMessageHandler(geminiBackend); + + const localConfigForModel = readGeminiLocalConfig(); + const actualModel = determineGeminiModel(modelToUse, localConfigForModel); + logger.debug(`[gemini] Model change - modelToUse=${modelToUse}, actualModel=${actualModel}`); + + logger.debug('[gemini] Starting new ACP session with model:', actualModel); + const { sessionId } = await geminiBackend.startSession(); + acpSessionId = sessionId; + logger.debug(`[gemini] New ACP session started: ${acpSessionId}`); + + logger.debug(`[gemini] Calling updateDisplayedModel with: ${actualModel}`); + updateDisplayedModel(actualModel, false); + + updatePermissionMode(message.mode.permissionMode); + + wasSessionCreated = true; + currentModeHash = message.hash; + first = false; + } + + currentModeHash = message.hash; + const userMessageToShow = message.mode?.originalUserMessage || message.message; + messageBuffer.addMessage(userMessageToShow, 'user'); + + try { + if (first || !wasSessionCreated) { + if (!geminiBackend) { + const modelToUse = message.mode?.model === undefined ? undefined : (message.mode.model || null); + geminiBackend = createGeminiBackend({ + cwd: process.cwd(), + mcpServers, + permissionHandler, + cloudToken, + model: modelToUse, + sessionId: acpSessionId, // Use captured session ID + }); + + setupGeminiMessageHandler(geminiBackend); + + const localConfigForModel = readGeminiLocalConfig(); + const actualModel = determineGeminiModel(modelToUse, localConfigForModel); + + const modelSource = modelToUse !== undefined + ? 'message' + : process.env[GEMINI_MODEL_ENV] + ? 'env-var' + : localConfigForModel.model + ? 'local-config' + : 'default'; + + logger.debug(`[gemini] Backend created, model will be: ${actualModel} (from ${modelSource})`); + logger.debug(`[gemini] Calling updateDisplayedModel with: ${actualModel}`); + updateDisplayedModel(actualModel, false); + } + + if (!acpSessionId) { + logger.debug('[gemini] Starting ACP session...'); + updatePermissionMode(message.mode.permissionMode); + const { sessionId } = await geminiBackend.startSession(); + acpSessionId = sessionId; + logger.debug(`[gemini] ACP session started: ${acpSessionId}`); + wasSessionCreated = true; + currentModeHash = message.hash; + + logger.debug(`[gemini] Displaying model in UI: ${displayedModel || 'gemini-2.5-pro'}, displayedModel: ${displayedModel}`); + } + } + + if (!acpSessionId) { + throw new Error('ACP session not started'); + } + + accumulatedResponse = ''; + isResponseInProgress = false; + + if (!geminiBackend || !acpSessionId) { + throw new Error('Gemini backend or session not initialized'); + } + + const promptToSend = message.message; + + logger.debug(`[gemini] Sending prompt to Gemini (length: ${promptToSend.length}): ${promptToSend.substring(0, 100)}...`); + logger.debug(`[gemini] Full prompt: ${promptToSend}`); + await geminiBackend.sendPrompt(acpSessionId, promptToSend); + logger.debug('[gemini] Prompt sent successfully'); + + if (first) { + first = false; + } + } catch (error) { + logger.debug('[gemini] Error in gemini session:', error); + const isAbortError = error instanceof Error && error.name === 'AbortError'; + + if (isAbortError) { + messageBuffer.addMessage('Aborted by user', 'status'); + session.sendSessionEvent({ type: 'message', message: 'Aborted by user' }); + } else { + let errorMsg = 'Process error occurred'; + + if (typeof error === 'object' && error !== null) { + const errObj = error as any; + + const errorDetails = errObj.data?.details || errObj.details || ''; + const errorCode = errObj.code || errObj.status || (errObj.response?.status); + const errorMessage = errObj.message || errObj.error?.message || ''; + const errorString = String(error); + + if (errorCode === 404 || errorDetails.includes('notFound') || errorDetails.includes('404') || + errorMessage.includes('not found') || errorMessage.includes('404')) { + const currentModel = displayedModel || 'gemini-2.5-pro'; + errorMsg = `Model "${currentModel}" not found. Available models: gemini-2.5-pro, gemini-2.5-flash, gemini-2.5-flash-lite`; + } + else if (errorCode === 429 || + errorDetails.includes('429') || errorMessage.includes('429') || errorString.includes('429') || + errorDetails.includes('rateLimitExceeded') || errorDetails.includes('RESOURCE_EXHAUSTED') || + errorMessage.includes('Rate limit exceeded') || errorMessage.includes('Resource exhausted') || + errorString.includes('rateLimitExceeded') || errorString.includes('RESOURCE_EXHAUSTED')) { + errorMsg = 'Gemini API rate limit exceeded. Please wait a moment and try again. The API will retry automatically.'; + } + else if (errorDetails.includes('quota') || errorMessage.includes('quota') || errorString.includes('quota')) { + errorMsg = 'Gemini API daily quota exceeded. Please wait until quota resets or use a paid API key.'; + } + else if (Object.keys(error).length === 0) { + errorMsg = 'Failed to start Gemini. Is "gemini" CLI installed? Run: npm install -g @google/gemini-cli'; + } + else if (errObj.message || errorMessage) { + errorMsg = errorDetails || errorMessage || errObj.message; + } + } else if (error instanceof Error) { + errorMsg = error.message; + } + + messageBuffer.addMessage(errorMsg, 'status'); + session.sendGeminiMessage({ + type: 'message', + message: errorMsg, + id: randomUUID(), + }); + } + } finally { + permissionHandler.reset(); + reasoningProcessor.abort(); + diffProcessor.reset(); + + thinking = false; + session.keepAlive(thinking, 'remote'); + + emitReadyIfIdle(); + + logger.debug(`[gemini] Main loop: turn completed, continuing to next iteration (queue size: ${messageQueue.size()})`); + } + } + + } finally { + // Clean up resources + logger.debug('[geminiRemoteLauncher]: Cleanup start'); + + // Note: RPC handlers are not unregistered as RpcHandlerManager doesn't provide unregisterHandler + // They will be cleaned up when the session is closed in runGemini.ts + + if (geminiBackend) { + await geminiBackend.dispose(); + } + + happyServer.stop(); + + // Clean up stdin with proper order (from bug fix) + if (inkInstance) { + inkInstance.unmount(); + } + + // Remove stdin listeners and reset terminal mode BEFORE clearing screen + // This ensures stdin is fully released before local mode starts + logger.debug('[geminiRemoteLauncher]: Starting stdin cleanup'); + + // Remove all stdin listeners (the old code with off() didn't work) + process.stdin.removeAllListeners('data'); + process.stdin.removeAllListeners('keypress'); + + const listenersRemaining = process.stdin.listeners('data').length + process.stdin.listeners('keypress').length; + logger.debug(`[geminiRemoteLauncher]: Stdin listeners removed. Remaining: ${listenersRemaining}`); + + // Reset terminal mode + if (process.stdin.isTTY) { + try { + process.stdin.setRawMode(false); + logger.debug('[geminiRemoteLauncher]: Stdin raw mode disabled'); + } catch (e) { + logger.debug('[geminiRemoteLauncher]: Failed to disable raw mode:', e); + } + } + + if (hasTTY) { + try { + process.stdin.pause(); + logger.debug('[geminiRemoteLauncher]: Stdin paused'); + } catch (e) { + logger.debug('[geminiRemoteLauncher]: Failed to pause stdin:', e); + } + } + + // Clear screen when switching to local mode (AFTER stdin cleanup) + if (switchToLocal && hasTTY) { + console.clear(); + logger.debug('[geminiRemoteLauncher]: Screen cleared for local mode'); + } + + clearInterval(keepAliveInterval); + messageBuffer.clear(); + + logger.debug('[geminiRemoteLauncher]: Cleanup completed'); + } + + const returnValue = switchToLocal ? 'switch' : 'exit'; + logger.debug(`[geminiRemoteLauncher]: Returning '${returnValue}' to geminiLoop`); + return returnValue; +} diff --git a/src/gemini/loop.ts b/src/gemini/loop.ts new file mode 100644 index 00000000..7659598d --- /dev/null +++ b/src/gemini/loop.ts @@ -0,0 +1,111 @@ +/** + * Gemini Mode Switching Loop + * + * Manages switching between local and remote modes for Gemini, + * similar to Claude's loop.ts + */ + +import { ApiSessionClient } from "@/api/apiSession"; +import { MessageQueue2 } from "@/utils/MessageQueue2"; +import { logger } from "@/ui/logger"; +import { GeminiSession } from "./session"; +import { geminiLocalLauncher } from "./geminiLocalLauncher"; +import { ApiClient } from "@/lib"; +import type { GeminiMode } from "./types"; + +interface GeminiLoopOptions { + path: string; + model?: string; + approvalMode?: string; + startingMode?: 'local' | 'remote'; + onModeChange: (mode: 'local' | 'remote') => void; + session: ApiSessionClient; + api: ApiClient; + messageQueue: MessageQueue2; + allowedTools?: string[]; + cloudToken?: string; + onSessionReady?: (session: GeminiSession) => void; +} + +export async function geminiLoop(opts: GeminiLoopOptions) { + // Get log path for debug display + const logPath = logger.logFilePath; + + // Create Gemini session + let session = new GeminiSession({ + client: opts.session, + path: opts.path, + sessionId: null, + logPath: logPath, + queue: opts.messageQueue, + }); + + // Notify that session is ready + if (opts.onSessionReady) { + opts.onSessionReady(session); + } + + let mode: 'local' | 'remote' = opts.startingMode ?? 'remote'; + + while (true) { + logger.debug(`[geminiLoop] Iteration with mode: ${mode}`); + + // Run local mode + if (mode === 'local') { + let reason = await geminiLocalLauncher(session, { + model: opts.model, + approvalMode: opts.approvalMode, + allowedTools: opts.allowedTools, + }); + + if (reason === 'exit') { + // Normal exit - Exit loop + return; + } + + // Non "exit" reason means we need to switch to remote mode + mode = 'remote'; + if (opts.onModeChange) { + opts.onModeChange(mode); + } + continue; + } + + // Run remote mode + if (mode === 'remote') { + const { geminiRemoteLauncher } = await import('./geminiRemoteLauncher'); + + let reason = await geminiRemoteLauncher({ + session: opts.session, + api: opts.api, + messageQueue: opts.messageQueue, + model: opts.model, + approvalMode: opts.approvalMode, + allowedTools: opts.allowedTools, + cloudToken: opts.cloudToken, + sessionId: session.sessionId, + onSessionFound: (sessionId: string) => { + logger.debug(`[geminiLoop] Session found in remote mode: ${sessionId}`); + session.onSessionFound(sessionId); + }, + }); + + if (reason === 'exit') { + // Normal exit - Exit loop + return; + } + + // Non "exit" reason means we need to switch to local mode + mode = 'local'; + if (opts.onModeChange) { + opts.onModeChange(mode); + } + + // Small delay to ensure remote mode stdin cleanup fully completes + // before local mode spawns child (prevents stdin listener competition) + await new Promise(resolve => setTimeout(resolve, 50)); + + continue; + } + } +} diff --git a/src/gemini/runGemini.ts b/src/gemini/runGemini.ts index b89554bf..1b50dca2 100644 --- a/src/gemini/runGemini.ts +++ b/src/gemini/runGemini.ts @@ -35,6 +35,7 @@ import { GeminiPermissionHandler } from '@/gemini/utils/permissionHandler'; import { GeminiReasoningProcessor } from '@/gemini/utils/reasoningProcessor'; import { GeminiDiffProcessor } from '@/gemini/utils/diffProcessor'; import type { PermissionMode, GeminiMode, CodexMessagePayload } from '@/gemini/types'; +import type { GeminiSession } from '@/gemini/session'; import { GEMINI_MODEL_ENV, DEFAULT_GEMINI_MODEL, CHANGE_TITLE_INSTRUCTION } from '@/gemini/constants'; import { readGeminiLocalConfig, @@ -55,6 +56,7 @@ import { export async function runGemini(opts: { credentials: Credentials; startedBy?: 'daemon' | 'terminal'; + startingMode?: 'local' | 'remote'; }): Promise { // // Define session @@ -140,963 +142,66 @@ export async function runGemini(opts: { model: mode.model, })); - // Track current overrides to apply per message - let currentPermissionMode: PermissionMode | undefined = undefined; - let currentModel: string | undefined = undefined; + // Variable to track current session instance (updated via onSessionReady callback) + // Used by hook server to notify GeminiSession when Gemini changes session ID + let currentSession: GeminiSession | null = null; - session.onUserMessage((message) => { - // Resolve permission mode (validate) - same as Codex - let messagePermissionMode = currentPermissionMode; - if (message.meta?.permissionMode) { - const validModes: PermissionMode[] = ['default', 'read-only', 'safe-yolo', 'yolo']; - if (validModes.includes(message.meta.permissionMode as PermissionMode)) { - messagePermissionMode = message.meta.permissionMode as PermissionMode; - currentPermissionMode = messagePermissionMode; - // Update permission handler with new mode - updatePermissionMode(messagePermissionMode); - logger.debug(`[Gemini] Permission mode updated from user message to: ${currentPermissionMode}`); - } else { - logger.debug(`[Gemini] Invalid permission mode received: ${message.meta.permissionMode}`); - } - } else { - logger.debug(`[Gemini] User message received with no permission mode override, using current: ${currentPermissionMode ?? 'default (effective)'}`); - } - - // Initialize permission mode if not set yet - if (currentPermissionMode === undefined) { - currentPermissionMode = 'default'; - updatePermissionMode('default'); - } - - // Resolve model; explicit null resets to default (undefined) - let messageModel = currentModel; - if (message.meta?.hasOwnProperty('model')) { - // If model is explicitly null, reset internal state but don't update displayed model - // If model is provided, use it and update displayed model - // Otherwise keep current model - if (message.meta.model === null) { - messageModel = undefined; // Explicitly reset - will use default/env/config - currentModel = undefined; - // Don't call updateDisplayedModel here - keep current displayed model - // The backend will use the correct model from env/config/default - } else if (message.meta.model) { - messageModel = message.meta.model; - currentModel = messageModel; - // Save model to config file so it persists across sessions - updateDisplayedModel(messageModel, true); // Update UI and save to config - // Show model change message in UI (this will trigger UI re-render) - messageBuffer.addMessage(`Model changed to: ${messageModel}`, 'system'); - } - // If message.meta.model is undefined, keep currentModel - } - - // Build the full prompt with appendSystemPrompt if provided - // Only include system prompt for the first message to avoid forcing tool usage on every message - const originalUserMessage = message.content.text; - let fullPrompt = originalUserMessage; - if (isFirstMessage && message.meta?.appendSystemPrompt) { - // Prepend system prompt to user message only for first message - // Also add change_title instruction (like Codex does) - // Use EXACT same format as Codex: add instruction AFTER user message - // This matches Codex's approach exactly - instruction comes after user message - // Codex format: system prompt + user message + change_title instruction - fullPrompt = message.meta.appendSystemPrompt + '\n\n' + originalUserMessage + '\n\n' + CHANGE_TITLE_INSTRUCTION; - isFirstMessage = false; - } - - const mode: GeminiMode = { - permissionMode: messagePermissionMode || 'default', - model: messageModel, - originalUserMessage, // Store original message separately - }; - messageQueue.push(fullPrompt, mode); - }); - - let thinking = false; - session.keepAlive(thinking, 'remote'); - const keepAliveInterval = setInterval(() => { - session.keepAlive(thinking, 'remote'); - }, 2000); - - // Track if this is the first message to include system prompt only once - 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) { - logger.debug('[Gemini] Failed to send ready push', pushError); - } - }; - - /** - * Check if we can emit ready event - * * Returns true when ready event was emitted - */ - const emitReadyIfIdle = (): boolean => { - if (shouldExit) { - return false; - } - if (thinking) { - return false; - } - if (isResponseInProgress) { - return false; - } - if (messageQueue.size() > 0) { - return false; - } - - sendReady(); - return true; - }; - - // - // Abort handling - // - - let abortController = new AbortController(); - let shouldExit = false; - let geminiBackend: AgentBackend | null = null; - let acpSessionId: string | null = null; - let wasSessionCreated = false; - - async function handleAbort() { - logger.debug('[Gemini] Abort requested - stopping current task'); - - // Send turn_aborted event (like Codex) when abort is requested - session.sendCodexMessage({ - type: 'turn_aborted', - id: randomUUID(), - }); - - // Abort reasoning processor and reset diff processor - reasoningProcessor.abort(); - diffProcessor.reset(); - - try { - abortController.abort(); - messageQueue.reset(); - if (geminiBackend && acpSessionId) { - await geminiBackend.cancel(acpSessionId); - } - logger.debug('[Gemini] Abort completed - session remains active'); - } catch (error) { - logger.debug('[Gemini] Error during abort:', error); - } finally { - abortController = new AbortController(); - } - } - - const handleKillSession = async () => { - logger.debug('[Gemini] Kill session requested - terminating process'); - await handleAbort(); - logger.debug('[Gemini] Abort completed, proceeding with termination'); + // Use geminiLoop for both local and remote modes + // Default to remote mode if no mode is specified + const startingMode = opts.startingMode || 'remote'; + logger.debug(`[Gemini] Starting in ${startingMode} mode with loop`); + const { geminiLoop } = await import('./loop'); + const { startHookServer } = await import('@/claude/utils/startHookServer'); + const { addGeminiHookToProject, removeGeminiHookFromProject } = await import('./utils/generateGeminiHookSettings'); - try { - if (session) { - session.updateMetadata((currentMetadata) => ({ - ...currentMetadata, - lifecycleState: 'archived', - lifecycleStateSince: Date.now(), - archivedBy: 'cli', - archiveReason: 'User terminated' - })); + // Start hook server for session tracking (needed for local mode) + const hookServer = await startHookServer({ + onSessionHook: (sessionId, data) => { + logger.debug(`[Gemini] SessionStart hook received: ${sessionId}`); + logger.debug(`[Gemini] Transcript path: ${data.transcript_path}`); - session.sendSessionDeath(); - await session.flush(); - await session.close(); - } - - stopCaffeinate(); - happyServer.stop(); - - if (geminiBackend) { - await geminiBackend.dispose(); - } - - logger.debug('[Gemini] Session termination complete, exiting'); - process.exit(0); - } catch (error) { - logger.debug('[Gemini] Error during session termination:', error); - process.exit(1); - } - }; - - session.rpcHandlerManager.registerHandler('abort', handleAbort); - registerKillSessionHandler(session.rpcHandlerManager, handleKillSession); - - // - // Initialize Ink UI - // - - const messageBuffer = new MessageBuffer(); - const hasTTY = process.stdout.isTTY && process.stdin.isTTY; - let inkInstance: ReturnType | null = null; - - // Track current model for UI display - // Initialize with env var or default to show correct model from start - let displayedModel: string | undefined = getInitialGeminiModel(); - - // Log initial values - const localConfig = readGeminiLocalConfig(); - logger.debug(`[gemini] Initial model setup: env[GEMINI_MODEL_ENV]=${process.env[GEMINI_MODEL_ENV] || 'not set'}, localConfig=${localConfig.model || 'not set'}, displayedModel=${displayedModel}`); - - // Function to update displayed model and notify UI - const updateDisplayedModel = (model: string | undefined, saveToConfig: boolean = false) => { - // Only update if model is actually provided (not undefined) - if (model === undefined) { - logger.debug(`[gemini] updateDisplayedModel called with undefined, skipping update`); - return; - } - - const oldModel = displayedModel; - displayedModel = model; - logger.debug(`[gemini] updateDisplayedModel called: oldModel=${oldModel}, newModel=${model}, saveToConfig=${saveToConfig}`); - - // Save to config file if requested (when user changes model via mobile app) - if (saveToConfig) { - saveGeminiModelToConfig(model); - } - - // Trigger UI update by adding a system message with model info - // The message will be parsed by UI to extract model name - if (hasTTY && oldModel !== model) { - // Add a system message that includes model info - UI will parse it - // Format: [MODEL:gemini-2.5-pro] to make it easy to extract - logger.debug(`[gemini] Adding model update message to buffer: [MODEL:${model}]`); - messageBuffer.addMessage(`[MODEL:${model}]`, 'system'); - } else if (hasTTY) { - logger.debug(`[gemini] Model unchanged, skipping update message`); - } - }; - - if (hasTTY) { - console.clear(); - // Create a React component that reads displayedModel from closure - // Model will update when UI re-renders (on messageBuffer updates) - // We use a function component that reads displayedModel on each render - const DisplayComponent = () => { - // Read displayedModel from closure - it will have latest value on each render - const currentModelValue = displayedModel || 'gemini-2.5-pro'; - // Don't log on every render to avoid spam - only log when model changes - return React.createElement(GeminiDisplay, { - messageBuffer, - logPath: process.env.DEBUG ? logger.getLogPath() : undefined, - currentModel: currentModelValue, - onExit: async () => { - logger.debug('[gemini]: Exiting agent via Ctrl-C'); - shouldExit = true; - await handleAbort(); + // Update session ID and transcript path in the GeminiSession instance + if (currentSession) { + const previousSessionId = currentSession.sessionId; + if (previousSessionId !== sessionId) { + logger.debug(`[Gemini] Session ID changed: ${previousSessionId} -> ${sessionId}`); + currentSession.onSessionFound(sessionId); } - }); - }; - - inkInstance = render(React.createElement(DisplayComponent), { - exitOnCtrlC: false, - patchConsole: false - }); - - // Send initial model to UI so it displays correctly from start - const initialModelName = displayedModel || 'gemini-2.5-pro'; - 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'); - } - - // - // Start Happy MCP server and create Gemini backend - // - const happyServer = await startHappyServer(session); - const bridgeCommand = join(projectPath(), 'bin', 'happy-mcp.mjs'); - const mcpServers = { - happy: { - command: bridgeCommand, - args: ['--url', happyServer.url] - } - }; - - // Create permission handler for tool approval - const permissionHandler = new GeminiPermissionHandler(session); - - // Create reasoning processor for handling thinking/reasoning chunks - const reasoningProcessor = new GeminiReasoningProcessor((message) => { - // Callback to send messages directly from the processor - session.sendCodexMessage(message); - }); - - // Create diff processor for handling file edit events and diff tracking - const diffProcessor = new GeminiDiffProcessor((message) => { - // Callback to send messages directly from the processor - session.sendCodexMessage(message); - }); - - // Update permission handler when permission mode changes - const updatePermissionMode = (mode: PermissionMode) => { - permissionHandler.setPermissionMode(mode); - }; - - // Accumulate Gemini response text for sending complete message to mobile - let accumulatedResponse = ''; - let isResponseInProgress = false; - let currentResponseMessageId: string | null = null; // Track the message ID for current response - - /** - * Set up message handler for Gemini backend - * This function is called when backend is created or recreated - */ - function setupGeminiMessageHandler(backend: AgentBackend): void { - backend.onMessage((msg: AgentMessage) => { - - switch (msg.type) { - case 'model-output': - if (msg.textDelta) { - // If this is the first delta of a new response, create a new message - // Otherwise, update the existing message for this response - if (!isResponseInProgress) { - // Start of new response - create new assistant message - // Remove "Thinking..." message if it exists (it will be replaced by actual response) - messageBuffer.removeLastMessage('system'); // Remove "Thinking..." if present - messageBuffer.addMessage(msg.textDelta, 'assistant'); - isResponseInProgress = true; - logger.debug(`[gemini] Started new response, first chunk length: ${msg.textDelta.length}`); - } else { - // Continue existing response - update last assistant message - messageBuffer.updateLastMessage(msg.textDelta, 'assistant'); - logger.debug(`[gemini] Updated response, chunk length: ${msg.textDelta.length}, total accumulated: ${accumulatedResponse.length + msg.textDelta.length}`); - } - accumulatedResponse += msg.textDelta; - } - break; - - case 'status': - // Log status changes for debugging - logger.debug(`[gemini] Status changed: ${msg.status}${msg.detail ? ` - ${msg.detail}` : ''}`); - - // Log error status with details - if (msg.status === 'error') { - logger.debug(`[gemini] ⚠️ Error status received: ${msg.detail || 'Unknown error'}`); - - // Send turn_aborted event (like Codex) when error occurs - session.sendCodexMessage({ - type: 'turn_aborted', - id: randomUUID(), - }); - } - - if (msg.status === 'running') { - thinking = true; - session.keepAlive(thinking, 'remote'); - - // Send task_started event (like Codex) when agent starts working - session.sendCodexMessage({ - type: 'task_started', - id: randomUUID(), - }); - - // Show thinking indicator in UI when agent starts working (like Codex) - // This will be updated with actual thinking text when agent_thought_chunk events arrive - // Always show thinking indicator when status becomes 'running' to give user feedback - // Even if response is in progress, we want to show thinking for new operations - messageBuffer.addMessage('Thinking...', 'system'); - - // Don't reset accumulator here - tool calls can happen during a response - // Accumulator will be reset when a new prompt is sent (in the main loop) - } else if (msg.status === 'idle' || msg.status === 'stopped') { - if (thinking) { - // Clear thinking indicator when agent finishes - thinking = false; - // Remove thinking message from UI when agent finishes (like Codex) - // The thinking messages will be replaced by actual response - } - thinking = false; - session.keepAlive(thinking, 'remote'); - - // Complete reasoning processor when status becomes idle (like Codex) - // Only complete if there's actually reasoning content to complete - // Skip if this is just the initial idle status after session creation - const reasoningCompleted = reasoningProcessor.complete(); - - // Send task_complete event (like Codex) when agent finishes - // Only send if this is a real task completion (not initial idle) - if (reasoningCompleted || isResponseInProgress) { - session.sendCodexMessage({ - type: 'task_complete', - id: randomUUID(), - }); - } - - // Send accumulated response to mobile app when response is complete - // Status 'idle' indicates task completion (similar to Codex's task_complete) - if (isResponseInProgress && accumulatedResponse.trim()) { - // Parse options from response text (for logging/debugging) - // But keep options IN the text - mobile app's parseMarkdown will extract them - const { text: messageText, options } = parseOptionsFromText(accumulatedResponse); - - // Mobile app parses options from text via parseMarkdown, so we need to keep them in the message - // Re-add options XML block to the message text if options were found - let finalMessageText = messageText; - if (options.length > 0) { - const optionsXml = formatOptionsXml(options); - finalMessageText = messageText + optionsXml; - logger.debug(`[gemini] Found ${options.length} options in response:`, options); - logger.debug(`[gemini] Keeping options in message text for mobile app parsing`); - } else if (hasIncompleteOptions(accumulatedResponse)) { - // If we have incomplete options block, still send the message - // The mobile app will handle incomplete blocks gracefully - logger.debug(`[gemini] Warning: Incomplete options block detected but sending message anyway`); - } - - const messageId = randomUUID(); - - const messagePayload: CodexMessagePayload = { - type: 'message', - message: finalMessageText, // Include options XML in text for mobile app - id: messageId, - ...(options.length > 0 && { options }), - }; - - logger.debug(`[gemini] Sending complete message to mobile (length: ${finalMessageText.length}): ${finalMessageText.substring(0, 100)}...`); - logger.debug(`[gemini] Full message payload:`, JSON.stringify(messagePayload, null, 2)); - // Use sendCodexMessage - mobile app parses options from message text via parseMarkdown - session.sendCodexMessage(messagePayload); - accumulatedResponse = ''; - isResponseInProgress = false; - } - // Note: sendReady() is called via emitReadyIfIdle() in the finally block after prompt completes - // Don't call it here to avoid duplicates - } else if (msg.status === 'error') { - thinking = false; - session.keepAlive(thinking, 'remote'); - accumulatedResponse = ''; - isResponseInProgress = false; - currentResponseMessageId = null; - - // Show error in CLI UI - const errorMessage = msg.detail || 'Unknown error'; - messageBuffer.addMessage(`Error: ${errorMessage}`, 'status'); - - // Use sendCodexMessage for consistency with codex format - session.sendCodexMessage({ - type: 'message', - message: `Error: ${errorMessage}`, - id: randomUUID(), - }); - } - break; - - case 'tool-call': - // Show tool call in UI like Codex does - 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')); - - logger.debug(`[gemini] πŸ”§ Tool call received: ${msg.toolName} (${msg.callId})${isInvestigationTool ? ' [INVESTIGATION]' : ''}`); - if (isInvestigationTool && msg.args && typeof msg.args === 'object' && 'objective' in msg.args) { - logger.debug(`[gemini] πŸ” Investigation objective: ${String(msg.args.objective).substring(0, 150)}...`); - } - - messageBuffer.addMessage(`Executing: ${msg.toolName}${toolArgs ? ` ${toolArgs}${toolArgs.length >= 100 ? '...' : ''}` : ''}`, 'tool'); - session.sendCodexMessage({ - type: 'tool-call', - name: msg.toolName, - callId: msg.callId, - input: msg.args, - id: randomUUID(), - }); - break; - - case 'tool-result': - // Show tool result in UI like Codex does - // Check if result contains error information - 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; - - logger.debug(`[gemini] ${isError ? '❌' : 'βœ…'} Tool result received: ${msg.toolName} (${msg.callId}) - Size: ${resultSize} bytes${isError ? ' [ERROR]' : ''}`); - - // Process tool result through diff processor to check for diff information (like Codex) - if (!isError) { - diffProcessor.processToolResult(msg.toolName, msg.result, msg.callId); - } - - if (isError) { - const errorMsg = (msg.result as any).error || 'Tool call failed'; - logger.debug(`[gemini] ❌ Tool call error: ${errorMsg.substring(0, 300)}`); - messageBuffer.addMessage(`Error: ${errorMsg}`, 'status'); - } else { - // Log summary for large results (like investigation tools) - if (resultSize > 1000) { - logger.debug(`[gemini] βœ… Large tool result (${resultSize} bytes) - first 200 chars: ${truncatedResult}`); - } - messageBuffer.addMessage(`Result: ${truncatedResult}`, 'result'); + if (data.transcript_path) { + logger.debug(`[Gemini] Transcript path: ${data.transcript_path}`); + currentSession.onTranscriptPathFound(data.transcript_path); } - - session.sendCodexMessage({ - type: 'tool-call-result', - callId: msg.callId, - output: msg.result, - id: randomUUID(), - }); - break; - - case 'fs-edit': - messageBuffer.addMessage(`File edit: ${msg.description}`, 'tool'); - - // Process fs-edit through diff processor (like Codex) - // msg.diff is optional (diff?: string), so it can be undefined - diffProcessor.processFsEdit(msg.path || '', msg.description, msg.diff); - - session.sendCodexMessage({ - type: 'file-edit', - description: msg.description, - diff: msg.diff, - path: msg.path, - id: randomUUID(), - }); - break; - - default: - // Handle token-count and other potential message types - if ((msg as any).type === 'token-count') { - // Forward token count to mobile app (like Codex) - // Note: Gemini ACP may not provide token_count events directly, - // but we handle them if they come from the backend - session.sendCodexMessage({ - type: 'token_count', - ...(msg as any), - id: randomUUID(), - }); - } - break; - - case 'terminal-output': - messageBuffer.addMessage(msg.data, 'result'); - session.sendCodexMessage({ - type: 'terminal-output', - data: msg.data, - id: randomUUID(), - }); - break; - - case 'permission-request': - // Forward permission request to mobile app - session.sendCodexMessage({ - type: 'permission-request', - permissionId: msg.id, - reason: msg.reason, - payload: msg.payload, - id: randomUUID(), - }); - break; - - case 'exec-approval-request': - // Handle exec approval request (like Codex exec_approval_request) - // Convert to tool call for mobile app compatibility - const execApprovalMsg = msg as any; - const callId = execApprovalMsg.call_id || execApprovalMsg.callId || randomUUID(); - const { call_id, type, ...inputs } = execApprovalMsg; - - logger.debug(`[gemini] Exec approval request received: ${callId}`); - messageBuffer.addMessage(`Exec approval requested: ${callId}`, 'tool'); - - session.sendCodexMessage({ - type: 'tool-call', - name: 'GeminiBash', // Similar to Codex's CodexBash - callId: callId, - input: inputs, - id: randomUUID(), - }); - break; - - case 'patch-apply-begin': - // Handle patch operation begin (like Codex patch_apply_begin) - const patchBeginMsg = msg as any; - const patchCallId = patchBeginMsg.call_id || patchBeginMsg.callId || randomUUID(); - const { call_id: patchCallIdVar, type: patchType, auto_approved, changes } = patchBeginMsg; - - // Add UI feedback for patch operation - const changeCount = changes ? Object.keys(changes).length : 0; - const filesMsg = changeCount === 1 ? '1 file' : `${changeCount} files`; - messageBuffer.addMessage(`Modifying ${filesMsg}...`, 'tool'); - logger.debug(`[gemini] Patch apply begin: ${patchCallId}, files: ${changeCount}`); - - session.sendCodexMessage({ - type: 'tool-call', - name: 'GeminiPatch', // Similar to Codex's CodexPatch - callId: patchCallId, - input: { - auto_approved, - changes - }, - id: randomUUID(), - }); - break; - - case 'patch-apply-end': - // Handle patch operation end (like Codex patch_apply_end) - const patchEndMsg = msg as any; - const patchEndCallId = patchEndMsg.call_id || patchEndMsg.callId || randomUUID(); - const { call_id: patchEndCallIdVar, type: patchEndType, stdout, stderr, success } = patchEndMsg; - - // Add UI feedback for completion - 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'); - } - logger.debug(`[gemini] Patch apply end: ${patchEndCallId}, success: ${success}`); - - session.sendCodexMessage({ - type: 'tool-call-result', - callId: patchEndCallId, - output: { - stdout, - stderr, - success - }, - id: randomUUID(), - }); - break; - - case 'event': - // Handle thinking events - process through ReasoningProcessor like Codex - if (msg.name === 'thinking') { - const thinkingPayload = msg.payload as { text?: string } | undefined; - const thinkingText = (thinkingPayload && typeof thinkingPayload === 'object' && 'text' in thinkingPayload) - ? String(thinkingPayload.text || '') - : ''; - if (thinkingText) { - // Process thinking chunk through reasoning processor - // This will identify titled reasoning sections (**Title**) and convert them to tool calls - reasoningProcessor.processChunk(thinkingText); - - // Log thinking chunks (especially useful for investigation tools) - logger.debug(`[gemini] πŸ’­ Thinking chunk received: ${thinkingText.length} chars - Preview: ${thinkingText.substring(0, 100)}...`); - - // Show thinking message in UI (truncated like Codex) - // For titled reasoning (starts with **), ReasoningProcessor will show it as tool call - // But we still show progress for long operations - if (!thinkingText.startsWith('**')) { - // Update existing "Thinking..." message or add new one for untitled reasoning - const thinkingPreview = thinkingText.substring(0, 100); - messageBuffer.updateLastMessage(`[Thinking] ${thinkingPreview}...`, 'system'); - } - // For titled reasoning, ReasoningProcessor will send tool call, but we keep "Thinking..." visible - // This ensures user sees progress during long reasoning operations - } - // Also forward to mobile for UI feedback - session.sendCodexMessage({ - type: 'thinking', - text: thinkingText, - id: randomUUID(), - }); - } - break; + } } - }); - } - - // Note: Backend will be created dynamically in the main loop based on model from first message - // This allows us to support model changes by recreating the backend - - let first = true; + }); try { - let currentModeHash: string | null = null; - let pending: { message: string; mode: GeminiMode; isolate: boolean; hash: string } | null = null; - - while (!shouldExit) { - let message: { message: string; mode: GeminiMode; isolate: boolean; hash: string } | null = pending; - pending = null; - - if (!message) { - 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) { - logger.debug('[gemini] Main loop: wait aborted, continuing...'); - continue; - } - logger.debug('[gemini] Main loop: no batch received, breaking...'); - break; - } - logger.debug(`[gemini] Main loop: received message from queue (length: ${batch.message.length})`); - message = batch; - } - - if (!message) { - break; - } - - // Handle mode change (like Codex) - restart session if permission mode or model changed - if (wasSessionCreated && currentModeHash && message.hash !== currentModeHash) { - logger.debug('[Gemini] Mode changed – restarting Gemini session'); - messageBuffer.addMessage('═'.repeat(40), 'status'); - messageBuffer.addMessage('Starting new Gemini session (mode changed)...', 'status'); - - // Reset permission handler and reasoning processor on mode change (like Codex) - permissionHandler.reset(); - reasoningProcessor.abort(); - - // Dispose old backend and create new one with new model - if (geminiBackend) { - await geminiBackend.dispose(); - geminiBackend = null; - } - - // Create new backend with new model - const modelToUse = message.mode?.model === undefined ? undefined : (message.mode.model || null); - geminiBackend = createGeminiBackend({ - cwd: process.cwd(), - mcpServers, - permissionHandler, - cloudToken, - // 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, - }); - - // Set up message handler again - setupGeminiMessageHandler(geminiBackend); - - // Start new session - // Determine actual model that will be used (from backend creation logic) - // Replicate backend logic: message model > env var > local config > default - const localConfigForModel = readGeminiLocalConfig(); - const actualModel = determineGeminiModel(modelToUse, localConfigForModel); - logger.debug(`[gemini] Model change - modelToUse=${modelToUse}, actualModel=${actualModel}`); - - logger.debug('[gemini] Starting new ACP session with model:', actualModel); - const { sessionId } = await geminiBackend.startSession(); - acpSessionId = sessionId; - logger.debug(`[gemini] New ACP session started: ${acpSessionId}`); - - // Update displayed model in UI (don't save to config - this is backend initialization) - logger.debug(`[gemini] Calling updateDisplayedModel with: ${actualModel}`); - updateDisplayedModel(actualModel, false); - // Don't add "Using model" message - model is shown in status bar - - // Update permission handler with current permission mode - updatePermissionMode(message.mode.permissionMode); - - wasSessionCreated = true; - currentModeHash = message.hash; - first = false; // Not first message anymore - } - - currentModeHash = message.hash; - // Show only original user message in UI, not the full prompt with system prompt - const userMessageToShow = message.mode?.originalUserMessage || message.message; - messageBuffer.addMessage(userMessageToShow, 'user'); - - try { - if (first || !wasSessionCreated) { - // First message or session not created yet - create backend and start session - if (!geminiBackend) { - const modelToUse = message.mode?.model === undefined ? undefined : (message.mode.model || null); - geminiBackend = createGeminiBackend({ - cwd: process.cwd(), - mcpServers, - permissionHandler, - cloudToken, - // 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, - }); - - // Set up message handler - setupGeminiMessageHandler(geminiBackend); - - // Determine actual model that will be used - // Backend will determine model from: message model > env var > local config > default - // We need to replicate this logic here to show correct model in UI - const localConfigForModel = readGeminiLocalConfig(); - const actualModel = determineGeminiModel(modelToUse, localConfigForModel); - - const modelSource = modelToUse !== undefined - ? 'message' - : process.env[GEMINI_MODEL_ENV] - ? 'env-var' - : localConfigForModel.model - ? 'local-config' - : 'default'; - - logger.debug(`[gemini] Backend created, model will be: ${actualModel} (from ${modelSource})`); - logger.debug(`[gemini] Calling updateDisplayedModel with: ${actualModel}`); - updateDisplayedModel(actualModel, false); // Don't save - this is backend initialization - } - - // Start session if not started - if (!acpSessionId) { - logger.debug('[gemini] Starting ACP session...'); - // Update permission handler with current permission mode before starting session - updatePermissionMode(message.mode.permissionMode); - const { sessionId } = await geminiBackend.startSession(); - acpSessionId = sessionId; - logger.debug(`[gemini] ACP session started: ${acpSessionId}`); - wasSessionCreated = true; - currentModeHash = message.hash; - - // Model info is already shown in status bar via updateDisplayedModel - logger.debug(`[gemini] Displaying model in UI: ${displayedModel || 'gemini-2.5-pro'}, displayedModel: ${displayedModel}`); - } - } - - if (!acpSessionId) { - throw new Error('ACP session not started'); - } - - // Reset accumulator when sending a new prompt (not when tool calls start) - // Reset accumulated response for new prompt - // This ensures a new assistant message will be created (not updating previous one) - accumulatedResponse = ''; - isResponseInProgress = false; - - if (!geminiBackend || !acpSessionId) { - throw new Error('Gemini backend or session not initialized'); - } - - // The prompt already includes system prompt and change_title instruction (added in onUserMessage handler) - // This is done in the message queue, so message.message already contains everything - const promptToSend = message.message; - - logger.debug(`[gemini] Sending prompt to Gemini (length: ${promptToSend.length}): ${promptToSend.substring(0, 100)}...`); - logger.debug(`[gemini] Full prompt: ${promptToSend}`); - await geminiBackend.sendPrompt(acpSessionId, promptToSend); - logger.debug('[gemini] Prompt sent successfully'); - - // Mark as not first message after sending prompt - if (first) { - first = false; - } - } catch (error) { - logger.debug('[gemini] Error in gemini session:', error); - const isAbortError = error instanceof Error && error.name === 'AbortError'; - - if (isAbortError) { - messageBuffer.addMessage('Aborted by user', 'status'); - session.sendSessionEvent({ type: 'message', message: 'Aborted by user' }); - } else { - // Parse error message - let errorMsg = 'Process error occurred'; - - if (typeof error === 'object' && error !== null) { - const errObj = error as any; - - // Extract error information from various possible formats - const errorDetails = errObj.data?.details || errObj.details || ''; - const errorCode = errObj.code || errObj.status || (errObj.response?.status); - const errorMessage = errObj.message || errObj.error?.message || ''; - const errorString = String(error); - - // Check for 404 error (model not found) - if (errorCode === 404 || errorDetails.includes('notFound') || errorDetails.includes('404') || - errorMessage.includes('not found') || errorMessage.includes('404')) { - const currentModel = displayedModel || 'gemini-2.5-pro'; - errorMsg = `Model "${currentModel}" not found. Available models: gemini-2.5-pro, gemini-2.5-flash, gemini-2.5-flash-lite`; - } - // Check for rate limit error (429) - multiple possible formats - else if (errorCode === 429 || - errorDetails.includes('429') || errorMessage.includes('429') || errorString.includes('429') || - errorDetails.includes('rateLimitExceeded') || errorDetails.includes('RESOURCE_EXHAUSTED') || - errorMessage.includes('Rate limit exceeded') || errorMessage.includes('Resource exhausted') || - errorString.includes('rateLimitExceeded') || errorString.includes('RESOURCE_EXHAUSTED')) { - errorMsg = 'Gemini API rate limit exceeded. Please wait a moment and try again. The API will retry automatically.'; - } - // Check for quota exceeded error - else if (errorDetails.includes('quota') || errorMessage.includes('quota') || errorString.includes('quota')) { - errorMsg = 'Gemini API daily quota exceeded. Please wait until quota resets or use a paid API key.'; - } - // Check for empty error (command not found) - else if (Object.keys(error).length === 0) { - errorMsg = 'Failed to start Gemini. Is "gemini" CLI installed? Run: npm install -g @google/gemini-cli'; - } - // Use message from error object - else if (errObj.message || errorMessage) { - errorMsg = errorDetails || errorMessage || errObj.message; - } - } else if (error instanceof Error) { - errorMsg = error.message; - } - - messageBuffer.addMessage(errorMsg, 'status'); - // Use sendCodexMessage for consistency with codex format - session.sendCodexMessage({ - type: 'message', - message: errorMsg, - id: randomUUID(), - }); - } - } finally { - // Reset permission handler, reasoning processor, and diff processor after turn (like Codex) - permissionHandler.reset(); - reasoningProcessor.abort(); // Use abort to properly finish any in-progress tool calls - diffProcessor.reset(); // Reset diff processor on turn completion - - thinking = false; - session.keepAlive(thinking, 'remote'); - - // Use same logic as Codex - emit ready if idle (no pending operations, no queue) - emitReadyIfIdle(); - - logger.debug(`[gemini] Main loop: turn completed, continuing to next iteration (queue size: ${messageQueue.size()})`); + // Add hook to project settings (for local mode) + addGeminiHookToProject(hookServer.port, metadata.path); + + // Run the loop + await geminiLoop({ + path: metadata.path, + model: undefined, // TODO: get from initial config + approvalMode: 'default', // TODO: map permission mode + startingMode: startingMode, + onModeChange: (mode) => { + logger.debug(`[Gemini] Mode changed to: ${mode}`); + }, + session, + api, + messageQueue, + cloudToken, + allowedTools: undefined, // TODO: get from config + onSessionReady: (sessionInstance) => { + // Store reference for hook server callback + currentSession = sessionInstance; } - } - + }); } finally { - // Clean up resources - logger.debug('[gemini]: Final cleanup start'); - try { - session.sendSessionDeath(); - await session.flush(); - await session.close(); - } catch (e) { - logger.debug('[gemini]: Error while closing session', e); - } - - if (geminiBackend) { - await geminiBackend.dispose(); - } - - happyServer.stop(); - - if (process.stdin.isTTY) { - try { process.stdin.setRawMode(false); } catch { /* ignore */ } - } - if (hasTTY) { - try { process.stdin.pause(); } catch { /* ignore */ } - } - - clearInterval(keepAliveInterval); - if (inkInstance) { - inkInstance.unmount(); - } - messageBuffer.clear(); - - logger.debug('[gemini]: Final cleanup completed'); + // Cleanup hook + removeGeminiHookFromProject(metadata.path); + hookServer.stop(); } } - diff --git a/src/gemini/session.ts b/src/gemini/session.ts new file mode 100644 index 00000000..c5b929b3 --- /dev/null +++ b/src/gemini/session.ts @@ -0,0 +1,117 @@ +/** + * Gemini Session Management + * + * Manages session state for Gemini local/remote modes, + * similar to Claude's Session class. + */ + +import { ApiSessionClient } from '@/api/apiSession'; +import { MessageQueue2 } from '@/utils/MessageQueue2'; +import { logger } from '@/ui/logger'; +import type { GeminiMode } from './types'; + +export class GeminiSession { + public sessionId: string | null; + public transcriptPath: string | null = null; + public readonly client: ApiSessionClient; + public readonly path: string; + public readonly logPath: string; + public readonly queue: MessageQueue2; + public readonly onThinkingChange?: (thinking: boolean) => void; + + // Session found callbacks + private sessionFoundCallbacks: Array<(sessionId: string) => void> = []; + + // Transcript path callbacks + private transcriptPathCallbacks: Array<(path: string) => void> = []; + + constructor(opts: { + client: ApiSessionClient; + path: string; + sessionId: string | null; + logPath: string; + queue: MessageQueue2; + onThinkingChange?: (thinking: boolean) => void; + }) { + this.client = opts.client; + this.path = opts.path; + this.sessionId = opts.sessionId; + this.logPath = opts.logPath; + this.queue = opts.queue; + this.onThinkingChange = opts.onThinkingChange; + } + + /** + * Called when a session ID is discovered (from SessionStart hook) + */ + onSessionFound(sessionId: string): void { + logger.debug(`[GeminiSession] Session found: ${sessionId}`); + + if (this.sessionId !== sessionId) { + this.sessionId = sessionId; + + // Notify all callbacks + for (const callback of this.sessionFoundCallbacks) { + try { + callback(sessionId); + } catch (error) { + logger.debug('[GeminiSession] Session found callback error:', error); + } + } + } + } + + /** + * Called when transcript path is discovered (from SessionStart hook) + */ + onTranscriptPathFound(transcriptPath: string): void { + logger.debug(`[GeminiSession] Transcript path found: ${transcriptPath}`); + + if (this.transcriptPath !== transcriptPath) { + this.transcriptPath = transcriptPath; + + // Notify all callbacks + for (const callback of this.transcriptPathCallbacks) { + try { + callback(transcriptPath); + } catch (error) { + logger.debug('[GeminiSession] Transcript path callback error:', error); + } + } + } + } + + /** + * Register a callback for when session ID is found + */ + addSessionFoundCallback(callback: (sessionId: string) => void): void { + this.sessionFoundCallbacks.push(callback); + } + + /** + * Remove a session found callback + */ + removeSessionFoundCallback(callback: (sessionId: string) => void): void { + const index = this.sessionFoundCallbacks.indexOf(callback); + if (index !== -1) { + this.sessionFoundCallbacks.splice(index, 1); + } + } + + /** + * Register a callback for when transcript path is found + */ + addTranscriptPathCallback(callback: (path: string) => void): void { + this.transcriptPathCallbacks.push(callback); + } + + /** + * Remove a transcript path callback + */ + removeTranscriptPathCallback(callback: (path: string) => void): void { + const index = this.transcriptPathCallbacks.indexOf(callback); + if (index !== -1) { + this.transcriptPathCallbacks.splice(index, 1); + } + } +} diff --git a/src/gemini/utils/generateGeminiHookSettings.ts b/src/gemini/utils/generateGeminiHookSettings.ts new file mode 100644 index 00000000..1778fc79 --- /dev/null +++ b/src/gemini/utils/generateGeminiHookSettings.ts @@ -0,0 +1,133 @@ +/** + * Generate Gemini hook settings for session tracking + * + * Unlike Claude which uses a temporary settings file passed via --settings flag, + * Gemini reads settings from .gemini/settings.json in the project directory. + * + * This module manages adding/removing the SessionStart hook to that file. + */ + +import { join } from 'node:path'; +import { writeFileSync, mkdirSync, existsSync, readFileSync } from 'node:fs'; +import { logger } from '@/ui/logger'; +import { projectPath } from '@/projectPath'; +import { resolve } from 'node:path'; + +/** + * Add SessionStart hook to project's .gemini/settings.json + * + * @param port - The port where Happy's hook server is listening + * @param projectDir - The project directory (cwd) + * @returns Path to the settings file + */ +export function addGeminiHookToProject(port: number, projectDir: string): string { + const geminiDir = join(projectDir, '.gemini'); + const settingsPath = join(geminiDir, 'settings.json'); + + // Ensure .gemini directory exists + mkdirSync(geminiDir, { recursive: true }); + + // Path to the hook forwarder script (reuse Claude's!) + const forwarderScript = resolve(projectPath(), 'scripts', 'session_hook_forwarder.cjs'); + const hookCommand = `node "${forwarderScript}" ${port}`; + + // Read existing settings or create new + let settings: any = {}; + if (existsSync(settingsPath)) { + try { + const content = readFileSync(settingsPath, 'utf-8'); + settings = JSON.parse(content); + logger.debug(`[generateGeminiHookSettings] Read existing settings from ${settingsPath}`); + } catch (error) { + logger.debug(`[generateGeminiHookSettings] Failed to parse existing settings, starting fresh:`, error); + settings = {}; + } + } + + // Preserve existing hooks, add our SessionStart hook + if (!settings.hooks) { + settings.hooks = {}; + } + + // Add SessionStart hook (will replace if exists) + // Match on "startup" and "resume" events + settings.hooks.SessionStart = [ + { + matcher: "startup|resume", + hooks: [ + { + name: "happy-session-tracker", + type: "command", + command: hookCommand, + description: "Happy CLI session tracking hook", + timeout: 5000 + } + ] + } + ]; + + // Write back to file + writeFileSync(settingsPath, JSON.stringify(settings, null, 2), 'utf-8'); + logger.debug(`[generateGeminiHookSettings] Wrote hook settings to ${settingsPath}`); + + return settingsPath; +} + +/** + * Remove Happy's SessionStart hook from project's .gemini/settings.json + * + * @param projectDir - The project directory (cwd) + */ +export function removeGeminiHookFromProject(projectDir: string): void { + const geminiDir = join(projectDir, '.gemini'); + const settingsPath = join(geminiDir, 'settings.json'); + + if (!existsSync(settingsPath)) { + logger.debug(`[generateGeminiHookSettings] No settings file to clean up`); + return; + } + + try { + const content = readFileSync(settingsPath, 'utf-8'); + const settings = JSON.parse(content); + + if (settings.hooks && settings.hooks.SessionStart) { + // Filter out our hook (by name) + const filtered = settings.hooks.SessionStart.map((matcher: any) => { + if (matcher.hooks) { + matcher.hooks = matcher.hooks.filter((hook: any) => + hook.name !== 'happy-session-tracker' + ); + } + return matcher; + }).filter((matcher: any) => matcher.hooks && matcher.hooks.length > 0); + + if (filtered.length > 0) { + settings.hooks.SessionStart = filtered; + } else { + delete settings.hooks.SessionStart; + } + + // If no hooks left, remove hooks object + if (Object.keys(settings.hooks).length === 0) { + delete settings.hooks; + } + + writeFileSync(settingsPath, JSON.stringify(settings, null, 2), 'utf-8'); + logger.debug(`[generateGeminiHookSettings] Removed hook from ${settingsPath}`); + } + } catch (error) { + logger.debug(`[generateGeminiHookSettings] Failed to cleanup hook:`, error); + } +} + +/** + * Get the path to Gemini's project directory + * + * Gemini uses a hash of the project path for organization, + * but we don't need to compute that - we just write hooks to + * the project's .gemini/ directory and Gemini will find it. + */ +export function getGeminiProjectDir(projectPath: string): string { + return projectPath; +} diff --git a/src/gemini/utils/path.ts b/src/gemini/utils/path.ts new file mode 100644 index 00000000..027ba8f0 --- /dev/null +++ b/src/gemini/utils/path.ts @@ -0,0 +1,37 @@ +/** + * Gemini path utilities + * + * Helper functions for Gemini-specific file paths + */ + +import { join } from 'node:path'; +import { homedir } from 'node:os'; + +/** + * Get the Gemini config directory for a project + * + * @param projectPath - The project working directory + * @returns Path to .gemini directory + */ +export function getGeminiProjectConfigDir(projectPath: string): string { + return join(projectPath, '.gemini'); +} + +/** + * Get the Gemini global config directory + * + * @returns Path to ~/.gemini + */ +export function getGeminiGlobalConfigDir(): string { + return join(homedir(), '.gemini'); +} + +/** + * Get the Gemini settings file path for a project + * + * @param projectPath - The project working directory + * @returns Path to .gemini/settings.json + */ +export function getGeminiSettingsPath(projectPath: string): string { + return join(getGeminiProjectConfigDir(projectPath), 'settings.json'); +} diff --git a/src/gemini/utils/sessionScanner.ts b/src/gemini/utils/sessionScanner.ts new file mode 100644 index 00000000..1f00b305 --- /dev/null +++ b/src/gemini/utils/sessionScanner.ts @@ -0,0 +1,171 @@ +/** + * Gemini Session Scanner + * + * Watches Gemini's transcript JSONL file for new messages and forwards them + * to Happy's session sync system. + * + * Similar to Claude's session scanner but adapted for Gemini's transcript format. + */ + +import { watch, FSWatcher } from 'node:fs'; +import { readFile } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import { logger } from '@/ui/logger'; + +/** + * Message from Gemini's transcript + * This is a simplified interface - adjust based on actual Gemini format + */ +export interface GeminiTranscriptMessage { + sessionId?: string; + messageId?: number; + type?: string; + message?: string; + timestamp?: string; + [key: string]: any; +} + +export interface GeminiSessionScanner { + cleanup: () => void; + onNewSession: (transcriptPath: string) => void; +} + +/** + * Create a scanner that watches Gemini's transcript JSONL file + * + * @param opts.transcriptPath - Path to the JSONL transcript file from SessionStart hook + * @param opts.onMessage - Callback when a new message is detected + */ +export async function createGeminiSessionScanner(opts: { + transcriptPath: string | null; + onMessage: (message: GeminiTranscriptMessage) => void; +}): Promise { + let currentTranscriptPath: string | null = opts.transcriptPath; + let watcher: FSWatcher | null = null; + let processedLines = new Set(); + let lastFileSize = 0; + + /** + * Read and process new lines from the transcript file + */ + async function processTranscript() { + if (!currentTranscriptPath) { + return; + } + + if (!existsSync(currentTranscriptPath)) { + logger.debug(`[GeminiScanner] Transcript file doesn't exist yet: ${currentTranscriptPath}`); + return; + } + + try { + const content = await readFile(currentTranscriptPath, 'utf-8'); + const currentSize = content.length; + + // Only process if file has grown + if (currentSize <= lastFileSize) { + return; + } + + lastFileSize = currentSize; + + const lines = content.split('\n'); + let newMessages = 0; + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed || processedLines.has(trimmed)) { + continue; + } + + try { + const message: GeminiTranscriptMessage = JSON.parse(trimmed); + processedLines.add(trimmed); + opts.onMessage(message); + newMessages++; + logger.debug(`[GeminiScanner] New message: type=${message.type}, messageId=${message.messageId}`); + } catch (parseError) { + logger.debug(`[GeminiScanner] Failed to parse line as JSON:`, parseError); + // Skip invalid JSON lines + } + } + + if (newMessages > 0) { + logger.debug(`[GeminiScanner] Processed ${newMessages} new messages`); + } + } catch (error) { + logger.debug(`[GeminiScanner] Error reading transcript:`, error); + } + } + + /** + * Start watching the transcript file + */ + function startWatching() { + if (!currentTranscriptPath) { + logger.debug(`[GeminiScanner] No transcript path, skipping watch`); + return; + } + + // Stop existing watcher if any + if (watcher) { + watcher.close(); + watcher = null; + } + + logger.debug(`[GeminiScanner] Starting watch on: ${currentTranscriptPath}`); + + // Initial scan + processTranscript().catch((error) => { + logger.debug(`[GeminiScanner] Initial scan error:`, error); + }); + + // Watch for changes + try { + watcher = watch(currentTranscriptPath, { persistent: false }, (eventType) => { + if (eventType === 'change') { + processTranscript().catch((error) => { + logger.debug(`[GeminiScanner] Watch callback error:`, error); + }); + } + }); + + watcher.on('error', (error) => { + logger.debug(`[GeminiScanner] Watcher error:`, error); + }); + } catch (error) { + logger.debug(`[GeminiScanner] Failed to start watch:`, error); + } + } + + // Start watching if we have a path + if (currentTranscriptPath) { + startWatching(); + } + + return { + /** + * Update to a new transcript path (when session changes) + */ + onNewSession: (newTranscriptPath: string) => { + logger.debug(`[GeminiScanner] Switching to new transcript: ${newTranscriptPath}`); + currentTranscriptPath = newTranscriptPath; + processedLines.clear(); + lastFileSize = 0; + startWatching(); + }, + + /** + * Stop watching and cleanup + */ + cleanup: () => { + logger.debug(`[GeminiScanner] Cleanup`); + if (watcher) { + watcher.close(); + watcher = null; + } + processedLines.clear(); + lastFileSize = 0; + } + }; +} diff --git a/src/index.ts b/src/index.ts index 94c01c0f..b3ea2493 100644 --- a/src/index.ts +++ b/src/index.ts @@ -202,16 +202,45 @@ import { execFileSync } from 'node:child_process' // Handle gemini command (ACP-based agent) try { + // Check for special flags that should bypass our infrastructure + const specialFlags = ['--help', '--version', '-h', '-v']; + const hasSpecialFlag = args.slice(1).some(arg => specialFlags.includes(arg)); + + if (hasSpecialFlag) { + // Just spawn gemini directly and exit + const { spawn } = await import('child_process'); + try { + const child = spawn('gemini', args.slice(1), { stdio: 'inherit' }); + await new Promise((resolve, reject) => { + child.on('close', () => resolve()); + child.on('error', (err) => reject(err)); + }); + } catch (error: any) { + if (error.code === 'ENOENT') { + console.error(chalk.red('Error: Gemini CLI not found. Please install it with: npm install -g @google/gemini-cli')); + process.exit(1); + } + throw error; + } + return; + } + const { runGemini } = await import('@/gemini/runGemini'); - - // Parse startedBy argument + + // Parse startedBy and startingMode arguments let startedBy: 'daemon' | 'terminal' | undefined = undefined; + let startingMode: 'local' | 'remote' | undefined = undefined; + for (let i = 1; i < args.length; i++) { if (args[i] === '--started-by') { startedBy = args[++i] as 'daemon' | 'terminal'; + } else if (args[i] === '--local') { + startingMode = 'local'; + } else if (args[i] === '--remote') { + startingMode = 'remote'; } } - + const { credentials } = await authAndSetupMachineIfNeeded(); @@ -229,7 +258,7 @@ import { execFileSync } from 'node:child_process' await new Promise(resolve => setTimeout(resolve, 200)); } - await runGemini({credentials, startedBy}); + await runGemini({credentials, startedBy, startingMode}); } catch (error) { console.error(chalk.red('Error:'), error instanceof Error ? error.message : 'Unknown error') if (process.env.DEBUG) { diff --git a/src/ui/ink/GeminiDisplay.tsx b/src/ui/ink/GeminiDisplay.tsx index cd8e65f5..2e9fda81 100644 --- a/src/ui/ink/GeminiDisplay.tsx +++ b/src/ui/ink/GeminiDisplay.tsx @@ -14,12 +14,13 @@ interface GeminiDisplayProps { logPath?: string; currentModel?: string; onExit?: () => void; + onSwitchToLocal?: () => void; } -export const GeminiDisplay: React.FC = ({ messageBuffer, logPath, currentModel, onExit }) => { +export const GeminiDisplay: React.FC = ({ messageBuffer, logPath, currentModel, onExit, onSwitchToLocal }) => { const [messages, setMessages] = useState([]); - const [confirmationMode, setConfirmationMode] = useState(false); - const [actionInProgress, setActionInProgress] = useState(false); + const [confirmationMode, setConfirmationMode] = useState<'exit' | 'switch' | null>(null); + const [actionInProgress, setActionInProgress] = useState<'exiting' | 'switching' | null>(null); const [model, setModel] = useState(currentModel); const confirmationTimeoutRef = useRef(null); const { stdout } = useStdout(); @@ -68,15 +69,15 @@ export const GeminiDisplay: React.FC = ({ messageBuffer, log }, [messageBuffer]); const resetConfirmation = useCallback(() => { - setConfirmationMode(false); + setConfirmationMode(null); if (confirmationTimeoutRef.current) { clearTimeout(confirmationTimeoutRef.current); confirmationTimeoutRef.current = null; } }, []); - const setConfirmationWithTimeout = useCallback(() => { - setConfirmationMode(true); + const setConfirmationWithTimeout = useCallback((mode: 'exit' | 'switch') => { + setConfirmationMode(mode); if (confirmationTimeoutRef.current) { clearTimeout(confirmationTimeoutRef.current); } @@ -86,19 +87,45 @@ export const GeminiDisplay: React.FC = ({ messageBuffer, log }, [resetConfirmation]); useInput(useCallback(async (input, key) => { - if (actionInProgress) return; + // Debug: Log every key press to verify useInput is working + console.error(`[GeminiDisplay] useInput fired: input="${input}", key=${JSON.stringify(key)}, confirmationMode=${confirmationMode}, actionInProgress=${actionInProgress}`); + + if (actionInProgress) { + console.error(`[GeminiDisplay] Ignoring input - action already in progress: ${actionInProgress}`); + return; + } // Handle Ctrl-C if (key.ctrl && input === 'c') { - if (confirmationMode) { + if (confirmationMode === 'exit') { // Second Ctrl-C, exit resetConfirmation(); - setActionInProgress(true); + setActionInProgress('exiting'); await new Promise(resolve => setTimeout(resolve, 100)); - onExit?.(); + try { + await onExit?.(); + console.error(`[GeminiDisplay] onExit callback completed successfully`); + } catch (error) { + console.error(`[GeminiDisplay] onExit callback threw error:`, error); + } } else { // First Ctrl-C, show confirmation - setConfirmationWithTimeout(); + setConfirmationWithTimeout('exit'); + } + return; + } + + // Handle double space for mode switching + if (input === ' ') { + if (confirmationMode === 'switch') { + // Second space, switch to local + resetConfirmation(); + setActionInProgress('switching'); + await new Promise(resolve => setTimeout(resolve, 100)); + onSwitchToLocal?.(); + } else { + // First space, show confirmation + setConfirmationWithTimeout('switch'); } return; } @@ -107,7 +134,7 @@ export const GeminiDisplay: React.FC = ({ messageBuffer, log if (confirmationMode) { resetConfirmation(); } - }, [confirmationMode, actionInProgress, onExit, setConfirmationWithTimeout, resetConfirmation])); + }, [confirmationMode, actionInProgress, onExit, onSwitchToLocal, setConfirmationWithTimeout, resetConfirmation])); const getMessageColor = (type: BufferedMessage['type']): string => { switch (type) { @@ -147,7 +174,7 @@ export const GeminiDisplay: React.FC = ({ messageBuffer, log overflow="hidden" > - ✨ Gemini Agent Messages + πŸ“‘ Remote Mode - Gemini Messages {'─'.repeat(Math.min(terminalWidth - 4, 60))} @@ -200,18 +227,26 @@ export const GeminiDisplay: React.FC = ({ messageBuffer, log flexDirection="column" > - {actionInProgress ? ( + {actionInProgress === 'exiting' ? ( Exiting agent... - ) : confirmationMode ? ( + ) : actionInProgress === 'switching' ? ( + + Switching to local mode... + + ) : confirmationMode === 'exit' ? ( ⚠️ Press Ctrl-C again to exit the agent + ) : confirmationMode === 'switch' ? ( + + ⏸️ Press space again to switch to local mode + ) : ( <> - ✨ Gemini Agent Running β€’ Ctrl-C to exit + πŸ“± Press space to switch to local mode β€’ Ctrl-C to exit {model && (