diff --git a/package.json b/package.json index 557cf9b8..94110199 100644 --- a/package.json +++ b/package.json @@ -70,6 +70,7 @@ "postinstall": "node scripts/unpack-tools.cjs" }, "dependencies": { + "@agentclientprotocol/sdk": "^0.8.0", "@modelcontextprotocol/sdk": "^1.22.0", "@stablelib/base64": "^2.0.1", "@stablelib/hex": "^2.0.1", @@ -79,6 +80,7 @@ "@types/qrcode-terminal": "^0.12.2", "@types/react": "^19.2.7", "@types/tmp": "^0.2.6", + "ai": "^5.0.107", "axios": "^1.13.2", "chalk": "^5.6.2", "cross-spawn": "^7.0.6", diff --git a/src/agent/AgentBackend.ts b/src/agent/AgentBackend.ts new file mode 100644 index 00000000..33a32814 --- /dev/null +++ b/src/agent/AgentBackend.ts @@ -0,0 +1,154 @@ +/** + * AgentBackend - Universal interface for AI agent backends + * + * This module defines the core abstraction for different agent backends + * (Claude, Codex, Gemini, OpenCode, etc.) that can be controlled through + * the Happy CLI and mobile app. + * + * The AgentBackend interface provides a unified way to: + * - Start and manage agent sessions + * - Send prompts and receive responses + * - Handle tool calls and permissions + * - Stream model output and events + */ + +/** Unique identifier for an agent session */ +export type SessionId = string; + +/** Unique identifier for a tool call */ +export type ToolCallId = string; + +/** + * Messages emitted by an agent backend during a session. + * These messages are forwarded to the Happy server and mobile app. + */ +export type AgentMessage = + | { type: 'model-output'; textDelta?: string; fullText?: string } + | { type: 'status'; status: 'starting' | 'running' | 'idle' | 'stopped' | 'error'; detail?: string } + | { type: 'tool-call'; toolName: string; args: Record; callId: ToolCallId } + | { type: 'tool-result'; toolName: string; result: unknown; callId: ToolCallId } + | { type: 'permission-request'; id: string; reason: string; payload: unknown } + | { type: 'permission-response'; id: string; approved: boolean } + | { type: 'fs-edit'; description: string; diff?: string; path?: string } + | { type: 'terminal-output'; data: string } + | { type: 'event'; name: string; payload: unknown } + | { type: 'token-count'; [key: string]: unknown } // Token count information (format may vary) + | { type: 'exec-approval-request'; call_id: string; [key: string]: unknown } // Exec approval request (like Codex exec_approval_request) + | { type: 'patch-apply-begin'; call_id: string; auto_approved?: boolean; changes: Record } // Patch operation begin (like Codex patch_apply_begin) + | { type: 'patch-apply-end'; call_id: string; stdout?: string; stderr?: string; success: boolean } // Patch operation end (like Codex patch_apply_end) + +/** MCP server configuration for tools */ +export interface McpServerConfig { + command: string; + args?: string[]; + env?: Record; +} + +/** Transport type for agent communication */ +export type AgentTransport = 'native-claude' | 'mcp-codex' | 'acp'; + +/** Agent identifier */ +export type AgentId = 'claude' | 'codex' | 'gemini' | 'opencode' | 'claude-acp' | 'codex-acp'; + +/** + * Configuration for creating an agent backend + */ +export interface AgentBackendConfig { + /** Working directory for the agent */ + cwd: string; + + /** Name of the agent */ + agentName: AgentId; + + /** Transport protocol to use */ + transport: AgentTransport; + + /** Environment variables to pass to the agent */ + env?: Record; + + /** MCP servers to make available to the agent */ + mcpServers?: Record; +} + +/** + * Configuration specific to ACP-based agents + */ +export interface AcpAgentConfig extends AgentBackendConfig { + transport: 'acp'; + + /** Command to spawn the ACP agent */ + command: string; + + /** Arguments for the agent command */ + args?: string[]; +} + +/** + * Result of starting a session + */ +export interface StartSessionResult { + sessionId: SessionId; +} + +/** + * Handler function type for agent messages + */ +export type AgentMessageHandler = (msg: AgentMessage) => void; + +/** + * Universal interface for agent backends. + * + * All agent implementations (Claude, Codex, Gemini, etc.) should implement + * this interface to be usable through the Happy CLI and mobile app. + */ +export interface AgentBackend { + /** + * Start a new agent session. + * + * @param initialPrompt - Optional initial prompt to send to the agent + * @returns Promise resolving to session information + */ + startSession(initialPrompt?: string): Promise; + + /** + * Send a prompt to an existing session. + * + * @param sessionId - The session to send the prompt to + * @param prompt - The user's prompt text + */ + sendPrompt(sessionId: SessionId, prompt: string): Promise; + + /** + * Cancel the current operation in a session. + * + * @param sessionId - The session to cancel + */ + cancel(sessionId: SessionId): Promise; + + /** + * Register a handler for agent messages. + * + * @param handler - Function to call when messages are received + */ + onMessage(handler: AgentMessageHandler): void; + + /** + * Remove a previously registered message handler. + * + * @param handler - The handler to remove + */ + offMessage?(handler: AgentMessageHandler): void; + + /** + * Respond to a permission request. + * + * @param requestId - The ID of the permission request + * @param approved - Whether the permission was granted + */ + respondToPermission?(requestId: string, approved: boolean): Promise; + + /** + * Clean up resources and close the backend. + */ + dispose(): Promise; +} diff --git a/src/agent/AgentRegistry.ts b/src/agent/AgentRegistry.ts new file mode 100644 index 00000000..4a67ce88 --- /dev/null +++ b/src/agent/AgentRegistry.ts @@ -0,0 +1,89 @@ +/** + * AgentRegistry - Registry for agent backend factories + * + * This module provides a central registry for creating agent backends. + * It allows registering factory functions for different agent types + * and creating instances of those backends. + */ + +import type { AgentBackend, AgentId } from './AgentBackend'; + +/** Options passed to agent factory functions */ +export interface AgentFactoryOptions { + /** Working directory for the agent */ + cwd: string; + + /** Environment variables to pass to the agent */ + env?: Record; +} + +/** Factory function type for creating agent backends */ +export type AgentFactory = (opts: AgentFactoryOptions) => AgentBackend; + +/** + * Registry for agent backend factories. + * + * Use this to register and create agent backends by their identifier. + * + * @example + * ```ts + * const registry = new AgentRegistry(); + * registry.register('gemini', createGeminiBackend); + * + * const backend = registry.create('gemini', { cwd: process.cwd() }); + * await backend.startSession('Hello!'); + * ``` + */ +export class AgentRegistry { + private factories = new Map(); + + /** + * Register a factory function for an agent type. + * + * @param id - The agent identifier + * @param factory - Factory function to create the backend + */ + register(id: AgentId, factory: AgentFactory): void { + this.factories.set(id, factory); + } + + /** + * Check if an agent type is registered. + * + * @param id - The agent identifier to check + * @returns true if the agent is registered + */ + has(id: AgentId): boolean { + return this.factories.has(id); + } + + /** + * Get the list of registered agent identifiers. + * + * @returns Array of registered agent IDs + */ + list(): AgentId[] { + return Array.from(this.factories.keys()); + } + + /** + * Create an agent backend instance. + * + * @param id - The agent identifier + * @param opts - Options for creating the backend + * @returns The created agent backend + * @throws Error if the agent type is not registered + */ + create(id: AgentId, opts: AgentFactoryOptions): AgentBackend { + const factory = this.factories.get(id); + if (!factory) { + const available = this.list().join(', ') || 'none'; + throw new Error(`Unknown agent: ${id}. Available agents: ${available}`); + } + return factory(opts); + } +} + +/** Global agent registry instance */ +export const agentRegistry = new AgentRegistry(); + diff --git a/src/agent/acp/AcpSdkBackend.ts b/src/agent/acp/AcpSdkBackend.ts new file mode 100644 index 00000000..8a88fd69 --- /dev/null +++ b/src/agent/acp/AcpSdkBackend.ts @@ -0,0 +1,1301 @@ +/** + * AcpSdkBackend - Agent Client Protocol backend using official SDK + * + * This module provides a backend implementation using the official + * @agentclientprotocol/sdk for direct control over the ACP protocol. + */ + +import { spawn, type ChildProcess } from 'node:child_process'; +import { Readable, Writable } from 'node:stream'; +import { + ClientSideConnection, + ndJsonStream, + type Client, + type Agent, + type SessionNotification, + type RequestPermissionRequest, + type RequestPermissionResponse, + type InitializeRequest, + type NewSessionRequest, + type PromptRequest, + type ContentBlock, +} from '@agentclientprotocol/sdk'; +import { randomUUID } from 'node:crypto'; +import type { + AgentBackend, + AgentMessage, + AgentMessageHandler, + SessionId, + StartSessionResult, + McpServerConfig, +} from '../AgentBackend'; +import { logger } from '@/ui/logger'; +import packageJson from '../../../package.json'; +import { + isInvestigationTool, + determineToolName, + getRealToolName, + getToolCallTimeout, +} from './utils'; +import { hasChangeTitleInstruction } from '@/gemini/utils/promptUtils'; + +/** + * Extended RequestPermissionRequest with additional fields that may be present + */ +type ExtendedRequestPermissionRequest = RequestPermissionRequest & { + toolCall?: { + id?: string; + kind?: string; + toolName?: string; + input?: Record; + arguments?: Record; + content?: Record; + }; + kind?: string; + input?: Record; + arguments?: Record; + content?: Record; + options?: Array<{ + optionId?: string; + name?: string; + kind?: string; + }>; +}; + +/** + * Extended SessionNotification with additional fields + */ +type ExtendedSessionNotification = SessionNotification & { + update?: { + sessionUpdate?: string; + toolCallId?: string; + status?: string; + kind?: string | unknown; + content?: { + text?: string; + error?: string | { message?: string }; + [key: string]: unknown; + } | string | unknown; + locations?: unknown[]; + messageChunk?: { + textDelta?: string; + }; + plan?: unknown; + thinking?: unknown; + [key: string]: unknown; + }; +} + +/** Timeout for ACP initialization in milliseconds (2 minutes - Gemini CLI can be slow on first start) */ +const ACP_INIT_TIMEOUT_MS = 120000; + +/** + * Permission handler interface for ACP backends + */ +export interface AcpPermissionHandler { + /** + * Handle a tool permission request + * @param toolCallId - The unique ID of the tool call + * @param toolName - The name of the tool being called + * @param input - The input parameters for the tool + * @returns Promise resolving to permission result with decision + */ + handleToolCall( + toolCallId: string, + toolName: string, + input: unknown + ): Promise<{ decision: 'approved' | 'approved_for_session' | 'denied' | 'abort' }>; +} + +/** + * Configuration for AcpSdkBackend + */ +export interface AcpSdkBackendOptions { + /** Agent name for identification */ + agentName: string; + + /** Working directory for the agent */ + cwd: string; + + /** Command to spawn the ACP agent */ + command: string; + + /** Arguments for the agent command */ + args?: string[]; + + /** Environment variables to pass to the agent */ + env?: Record; + + /** MCP servers to make available to the agent */ + mcpServers?: Record; + + /** Optional permission handler for tool approval */ + permissionHandler?: AcpPermissionHandler; +} + +/** + * Convert Node.js streams to Web Streams for ACP SDK + * + * NOTE: This function registers event handlers on stdout. If you also register + * handlers directly on stdout (e.g., for logging), both will fire. + */ +function nodeToWebStreams( + stdin: Writable, + stdout: Readable +): { writable: WritableStream; readable: ReadableStream } { + // Convert Node writable to Web WritableStream + const writable = new WritableStream({ + write(chunk) { + return new Promise((resolve, reject) => { + const ok = stdin.write(chunk, (err) => { + if (err) { + logger.debug(`[AcpSdkBackend] Error writing to stdin:`, err); + reject(err); + } + }); + if (ok) { + resolve(); + } else { + stdin.once('drain', resolve); + } + }); + }, + close() { + return new Promise((resolve) => { + stdin.end(resolve); + }); + }, + abort(reason) { + stdin.destroy(reason instanceof Error ? reason : new Error(String(reason))); + } + }); + + // Convert Node readable to Web ReadableStream + // Filter out non-JSON debug output from gemini CLI (experiments, flags, etc.) + const readable = new ReadableStream({ + start(controller) { + stdout.on('data', (chunk: Buffer) => { + controller.enqueue(new Uint8Array(chunk)); + }); + stdout.on('end', () => { + controller.close(); + }); + stdout.on('error', (err) => { + logger.debug(`[AcpSdkBackend] Stdout error:`, err); + controller.error(err); + }); + }, + cancel() { + stdout.destroy(); + } + }); + + return { writable, readable }; +} + +/** + * ACP backend using the official @agentclientprotocol/sdk + */ +export class AcpSdkBackend implements AgentBackend { + private listeners: AgentMessageHandler[] = []; + private process: ChildProcess | null = null; + private connection: ClientSideConnection | null = null; + private acpSessionId: string | null = null; + private disposed = false; + /** Track active tool calls to prevent duplicate events */ + private activeToolCalls = new Set(); + private toolCallTimeouts = new Map(); + /** Track tool call start times for performance monitoring */ + private toolCallStartTimes = new Map(); + /** Pending permission requests that need response */ + private pendingPermissions = new Map void>(); + + /** Map from permission request ID to real tool call ID for tracking */ + private permissionToToolCallMap = new Map(); + + /** Map from real tool call ID to tool name for auto-approval */ + private toolCallIdToNameMap = new Map(); + + /** Track if we just sent a prompt with change_title instruction */ + private recentPromptHadChangeTitle = false; + + /** Track tool calls count since last prompt (to identify first tool call) */ + private toolCallCountSincePrompt = 0; + /** Timeout for emitting 'idle' status after last message chunk */ + private idleTimeout: NodeJS.Timeout | null = null; + + constructor(private options: AcpSdkBackendOptions) { + } + + onMessage(handler: AgentMessageHandler): void { + this.listeners.push(handler); + } + + offMessage(handler: AgentMessageHandler): void { + const index = this.listeners.indexOf(handler); + if (index !== -1) { + this.listeners.splice(index, 1); + } + } + + private emit(msg: AgentMessage): void { + if (this.disposed) return; + for (const listener of this.listeners) { + try { + listener(msg); + } catch (error) { + logger.warn('[AcpSdkBackend] Error in message handler:', error); + } + } + } + + async startSession(initialPrompt?: string): Promise { + if (this.disposed) { + throw new Error('Backend has been disposed'); + } + + const sessionId = randomUUID(); + this.emit({ type: 'status', status: 'starting' }); + + try { + logger.debug(`[AcpSdkBackend] Starting session: ${sessionId}`); + // Spawn the ACP agent process + const args = this.options.args || []; + + // On Windows, spawn via cmd.exe to handle .cmd files and PATH resolution + // This ensures proper stdio piping without shell buffering + if (process.platform === 'win32') { + const fullCommand = [this.options.command, ...args].join(' '); + this.process = spawn('cmd.exe', ['/c', fullCommand], { + cwd: this.options.cwd, + env: { ...process.env, ...this.options.env }, + stdio: ['pipe', 'pipe', 'pipe'], + windowsHide: true, + }); + } else { + this.process = spawn(this.options.command, args, { + cwd: this.options.cwd, + env: { ...process.env, ...this.options.env }, + // Use 'pipe' for all stdio to capture output without printing to console + // stdout and stderr will be handled by our event listeners + stdio: ['pipe', 'pipe', 'pipe'], + }); + } + + // Ensure stderr doesn't leak to console - redirect to logger only + // This prevents gemini CLI debug output from appearing in user's console + if (this.process.stderr) { + // stderr is already handled by the event listener below + // but we ensure it doesn't go to parent's stderr + } + + if (!this.process.stdin || !this.process.stdout || !this.process.stderr) { + throw new Error('Failed to create stdio pipes'); + } + + // Suppress stderr output - redirect to logger only (not console) + // Gemini CLI may output debug info to stderr, but we don't want it in console + // However, we need to detect API errors (like 429 rate limit) and notify the user + this.process.stderr.on('data', (data: Buffer) => { + const text = data.toString(); + // Only log to file, never to console + if (text.trim()) { + // Check if we have active investigation tools - log stderr more verbosely for them + const hasActiveInvestigation = Array.from(this.activeToolCalls).some(id => + isInvestigationTool(id) + ); + + if (hasActiveInvestigation) { + logger.debug(`[AcpSdkBackend] 🔍 Agent stderr (during investigation): ${text.trim()}`); + } else { + logger.debug(`[AcpSdkBackend] Agent stderr: ${text.trim()}`); + } + + // Detect API errors in stderr (429 rate limit, 404 not found, etc.) + // These errors come from gemini-cli when API calls fail + // Note: gemini-cli handles retries internally, we don't need to track or show these + if (text.includes('status 429') || text.includes('code":429') || + text.includes('rateLimitExceeded') || text.includes('RESOURCE_EXHAUSTED')) { + // Rate limit error - log for debugging but don't show to user + // Gemini CLI will handle retries internally + logger.debug('[AcpSdkBackend] ⚠️ Detected rate limit error (429) in stderr - gemini-cli will handle retry'); + } else if (text.includes('status 404') || text.includes('code":404')) { + // Not found error - emit error status + logger.debug('[AcpSdkBackend] ⚠️ Detected 404 error in stderr'); + this.emit({ + type: 'status', + status: 'error', + detail: 'Model not found. Available models: gemini-2.5-pro, gemini-2.5-flash, gemini-2.5-flash-lite' + }); + } else if (hasActiveInvestigation && ( + text.includes('timeout') || text.includes('Timeout') || + text.includes('failed') || text.includes('Failed') || + text.includes('error') || text.includes('Error') + )) { + // Log any errors/timeouts during investigation tools for debugging + logger.debug(`[AcpSdkBackend] 🔍 Investigation tool stderr error/timeout: ${text.trim()}`); + } + + } + }); + + this.process.on('error', (err) => { + // Log to file only, not console + logger.debug(`[AcpSdkBackend] Process error:`, err); + this.emit({ type: 'status', status: 'error', detail: err.message }); + }); + + this.process.on('exit', (code, signal) => { + if (!this.disposed && code !== 0 && code !== null) { + logger.debug(`[AcpSdkBackend] Process exited with code ${code}, signal ${signal}`); + this.emit({ type: 'status', status: 'stopped', detail: `Exit code: ${code}` }); + } + }); + + // Create Web Streams from Node streams + const streams = nodeToWebStreams( + this.process.stdin, + this.process.stdout + ); + const writable = streams.writable; + const readable = streams.readable; + + // Filter out non-JSON data before it reaches ndJsonStream + // Gemini CLI outputs debug info (experiments, flags, etc.) to stdout + // which breaks ACP protocol parsing. We filter it silently. + const filteredReadable = new ReadableStream({ + async start(controller) { + const reader = readable.getReader(); + const decoder = new TextDecoder(); + const encoder = new TextEncoder(); + let buffer = ''; + let filteredCount = 0; + + // Helper function to check if a string is valid JSON + const isValidJSON = (str: string): boolean => { + const trimmed = str.trim(); + if (!trimmed || (!trimmed.startsWith('{') && !trimmed.startsWith('['))) { + return false; + } + try { + JSON.parse(trimmed); + return true; + } catch { + return false; + } + }; + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) { + // Flush any remaining buffer + if (buffer.trim()) { + if (isValidJSON(buffer)) { + controller.enqueue(encoder.encode(buffer)); + } else { + filteredCount++; + } + } + if (filteredCount > 0) { + logger.debug(`[AcpSdkBackend] Filtered out ${filteredCount} non-JSON lines from gemini CLI stdout`); + } + controller.close(); + break; + } + + // Decode and accumulate data + buffer += decoder.decode(value, { stream: true }); + + // Process line by line (ndJSON is line-delimited) + const lines = buffer.split('\n'); + buffer = lines.pop() || ''; // Keep last incomplete line in buffer + + for (const line of lines) { + const trimmed = line.trim(); + + // Skip empty lines + if (!trimmed) { + continue; + } + + // Validate JSON before passing through + if (isValidJSON(trimmed)) { + // Valid JSON line - pass it through + controller.enqueue(encoder.encode(line + '\n')); + } else { + // Non-JSON or invalid JSON - silently skip it + filteredCount++; + } + } + } + } catch (error) { + logger.debug(`[AcpSdkBackend] Error filtering stdout stream:`, error); + controller.error(error); + } finally { + reader.releaseLock(); + } + } + }); + + // Create ndJSON stream for ACP + const stream = ndJsonStream(writable, filteredReadable); + + // Create Client implementation + const client: Client = { + sessionUpdate: async (params: SessionNotification) => { + this.handleSessionUpdate(params); + }, + requestPermission: async (params: RequestPermissionRequest): Promise => { + + const permissionId = randomUUID(); + const extendedParams = params as ExtendedRequestPermissionRequest; + const toolCall = extendedParams.toolCall; + let toolName = toolCall?.kind || toolCall?.toolName || extendedParams.kind || 'Unknown tool'; + const toolCallId = toolCall?.id || permissionId; + + // Extract input/arguments from various possible locations FIRST (before checking toolName) + let input: Record = {}; + if (toolCall) { + input = toolCall.input || toolCall.arguments || toolCall.content || {}; + } else { + // If no toolCall, try to extract from params directly + input = extendedParams.input || extendedParams.arguments || extendedParams.content || {}; + } + + // If toolName is "other" or "Unknown tool", try to determine real tool name + toolName = determineToolName( + toolName, + toolCallId, + input, + params, + { + recentPromptHadChangeTitle: this.recentPromptHadChangeTitle, + toolCallCountSincePrompt: this.toolCallCountSincePrompt, + } + ); + + if (toolName !== (toolCall?.kind || toolCall?.toolName || extendedParams.kind || 'Unknown tool')) { + logger.debug(`[AcpSdkBackend] Detected tool name: ${toolName} from toolCallId: ${toolCallId}`); + } + + // Increment tool call counter for context tracking + this.toolCallCountSincePrompt++; + + const options = extendedParams.options || []; + + // Log permission request for debugging (include full params to understand structure) + logger.debug(`[AcpSdkBackend] Permission request: tool=${toolName}, toolCallId=${toolCallId}, input=`, JSON.stringify(input)); + logger.debug(`[AcpSdkBackend] Permission request params structure:`, JSON.stringify({ + hasToolCall: !!toolCall, + toolCallKind: toolCall?.kind, + toolCallId: toolCall?.id, + paramsKind: extendedParams.kind, + paramsKeys: Object.keys(params), + }, null, 2)); + + // Emit permission request event for UI/mobile handling + this.emit({ + type: 'permission-request', + id: permissionId, + reason: toolName, + payload: { + ...params, + permissionId, + toolCallId, + toolName, + input, + options: options.map((opt) => ({ + id: opt.optionId, + name: opt.name, + kind: opt.kind, + })), + }, + }); + + // Use permission handler if provided, otherwise auto-approve + if (this.options.permissionHandler) { + try { + const result = await this.options.permissionHandler.handleToolCall( + toolCallId, + toolName, + input + ); + + // Map permission decision to ACP response + // ACP uses optionId from the request options + let optionId = 'cancel'; // Default to cancel/deny + + if (result.decision === 'approved' || result.decision === 'approved_for_session') { + // Find the appropriate optionId from the request options + // Look for 'proceed_once' or 'proceed_always' in options + const proceedOnceOption = options.find((opt: any) => + opt.optionId === 'proceed_once' || opt.name?.toLowerCase().includes('once') + ); + const proceedAlwaysOption = options.find((opt: any) => + opt.optionId === 'proceed_always' || opt.name?.toLowerCase().includes('always') + ); + + if (result.decision === 'approved_for_session' && proceedAlwaysOption) { + optionId = proceedAlwaysOption.optionId || 'proceed_always'; + } else if (proceedOnceOption) { + optionId = proceedOnceOption.optionId || 'proceed_once'; + } else if (options.length > 0) { + // Fallback to first option if no specific match + optionId = options[0].optionId || 'proceed_once'; + } + } else { + // Denied or aborted - find cancel option + const cancelOption = options.find((opt: any) => + opt.optionId === 'cancel' || opt.name?.toLowerCase().includes('cancel') + ); + if (cancelOption) { + optionId = cancelOption.optionId || 'cancel'; + } + } + + return { outcome: { outcome: 'selected', optionId } }; + } catch (error) { + // Log to file only, not console + logger.debug('[AcpSdkBackend] Error in permission handler:', error); + // Fallback to deny on error + return { outcome: { outcome: 'selected', optionId: 'cancel' } }; + } + } + + // Auto-approve with 'proceed_once' if no permission handler + // optionId must match one from the request options (e.g., 'proceed_once', 'proceed_always', 'cancel') + const proceedOnceOption = options.find((opt) => + opt.optionId === 'proceed_once' || (typeof opt.name === 'string' && opt.name.toLowerCase().includes('once')) + ); + const defaultOptionId = proceedOnceOption?.optionId || (options.length > 0 && options[0].optionId ? options[0].optionId : 'proceed_once'); + return { outcome: { outcome: 'selected', optionId: defaultOptionId } }; + }, + }; + + // Create ClientSideConnection + this.connection = new ClientSideConnection( + (agent: Agent) => client, + stream + ); + + // Initialize the connection with timeout + const initRequest: InitializeRequest = { + protocolVersion: 1, + clientCapabilities: { + fs: { + readTextFile: false, + writeTextFile: false, + }, + }, + clientInfo: { + name: 'happy-cli', + version: packageJson.version, + }, + }; + + logger.debug(`[AcpSdkBackend] Initializing connection...`); + let initTimeout: NodeJS.Timeout | null = null; + const initResponse = await Promise.race([ + this.connection.initialize(initRequest).then((result) => { + // Clear timeout if initialization succeeds + if (initTimeout) { + clearTimeout(initTimeout); + initTimeout = null; + } + return result; + }), + new Promise((_, reject) => { + initTimeout = setTimeout(() => { + logger.debug(`[AcpSdkBackend] Initialize timeout after ${ACP_INIT_TIMEOUT_MS}ms`); + reject(new Error(`Initialize timeout after ${ACP_INIT_TIMEOUT_MS}ms - Gemini CLI did not respond`)); + }, ACP_INIT_TIMEOUT_MS); + }), + ]); + logger.debug(`[AcpSdkBackend] Initialize completed`); + + // Create a new session + const mcpServers = this.options.mcpServers + ? Object.entries(this.options.mcpServers).map(([name, config]) => ({ + name, + command: config.command, + args: config.args || [], + env: config.env + ? Object.entries(config.env).map(([envName, envValue]) => ({ name: envName, value: envValue })) + : [], + })) + : []; + + const newSessionRequest: NewSessionRequest = { + cwd: this.options.cwd, + mcpServers: mcpServers as unknown as NewSessionRequest['mcpServers'], + }; + + logger.debug(`[AcpSdkBackend] Creating new session...`); + let newSessionTimeout: NodeJS.Timeout | null = null; + const sessionResponse = await Promise.race([ + this.connection.newSession(newSessionRequest).then((result) => { + // Clear timeout if session creation succeeds + if (newSessionTimeout) { + clearTimeout(newSessionTimeout); + newSessionTimeout = null; + } + return result; + }), + new Promise((_, reject) => { + newSessionTimeout = setTimeout(() => { + logger.debug(`[AcpSdkBackend] NewSession timeout after ${ACP_INIT_TIMEOUT_MS}ms`); + reject(new Error('New session timeout')); + }, ACP_INIT_TIMEOUT_MS); + }), + ]); + this.acpSessionId = sessionResponse.sessionId; + logger.debug(`[AcpSdkBackend] Session created: ${this.acpSessionId}`); + + this.emit({ type: 'status', status: 'idle' }); + + // Send initial prompt if provided + if (initialPrompt) { + this.sendPrompt(sessionId, initialPrompt).catch((error) => { + // Log to file only, not console + logger.debug('[AcpSdkBackend] Error sending initial prompt:', error); + this.emit({ type: 'status', status: 'error', detail: String(error) }); + }); + } + + return { sessionId }; + + } catch (error) { + // Log to file only, not console + logger.debug('[AcpSdkBackend] Error starting session:', error); + this.emit({ + type: 'status', + status: 'error', + detail: error instanceof Error ? error.message : String(error) + }); + throw error; + } + } + + private handleSessionUpdate(params: SessionNotification): void { + // SessionNotification structure: { sessionId, update: { sessionUpdate, content, ... } } + const notification = params as ExtendedSessionNotification; + const update = notification.update; + + if (!update) { + logger.debug('[AcpSdkBackend] Received session update without update field:', params); + return; + } + + const sessionUpdateType = update.sessionUpdate; + + // Log session updates for debugging (but not every chunk to avoid log spam) + if (sessionUpdateType !== 'agent_message_chunk') { + logger.debug(`[AcpSdkBackend] Received session update: ${sessionUpdateType}`, JSON.stringify({ + sessionUpdate: sessionUpdateType, + toolCallId: update.toolCallId, + status: update.status, + kind: update.kind, + hasContent: !!update.content, + hasLocations: !!update.locations, + }, null, 2)); + } + + // Handle agent message chunks (text output from Gemini) + if (sessionUpdateType === 'agent_message_chunk') { + + const content = update.content; + if (content && typeof content === 'object' && 'text' in content && typeof content.text === 'string') { + const text = content.text; + + // Filter out "thinking" messages (start with **...**) + // These are internal reasoning, not user-facing output + const isThinking = /^\*\*[^*]+\*\*\n/.test(text); + + if (isThinking) { + // Emit as thinking event instead of model output + this.emit({ + type: 'event', + name: 'thinking', + payload: { text }, + }); + } else { + logger.debug(`[AcpSdkBackend] Received message chunk (length: ${text.length}): ${text.substring(0, 50)}...`); + this.emit({ + type: 'model-output', + textDelta: text, + }); + + // Reset idle timeout - more chunks are coming + if (this.idleTimeout) { + clearTimeout(this.idleTimeout); + this.idleTimeout = null; + } + + // Set timeout to emit 'idle' after a short delay when no more chunks arrive + // This delay ensures all chunks (especially options blocks) are received before marking as idle + this.idleTimeout = setTimeout(() => { + // Only emit idle if no active tool calls + if (this.activeToolCalls.size === 0) { + logger.debug('[AcpSdkBackend] No more chunks received, emitting idle status'); + this.emit({ type: 'status', status: 'idle' }); + } else { + logger.debug(`[AcpSdkBackend] Delaying idle status - ${this.activeToolCalls.size} active tool calls`); + } + this.idleTimeout = null; + }, 500); // 500ms delay to batch chunks (reduced from 500ms, but still enough for options) + } + } + } + + // Handle tool call updates + if (sessionUpdateType === 'tool_call_update') { + const status = update.status; + const toolCallId = update.toolCallId; + + if (!toolCallId) { + logger.debug('[AcpSdkBackend] Tool call update without toolCallId:', update); + return; + } + + if (status === 'in_progress' || status === 'pending') { + // Only emit tool-call if we haven't seen this toolCallId before + if (!this.activeToolCalls.has(toolCallId)) { + const startTime = Date.now(); + const toolKind = update.kind || 'unknown'; + const isInvestigation = isInvestigationTool(toolCallId, toolKind); + + // Determine real tool name from toolCallId (e.g., "change_title-1765385846663" -> "change_title") + const realToolName = getRealToolName(toolCallId, toolKind); + + // Store mapping for permission requests + this.toolCallIdToNameMap.set(toolCallId, realToolName); + + this.activeToolCalls.add(toolCallId); + this.toolCallStartTimes.set(toolCallId, startTime); + logger.debug(`[AcpSdkBackend] ⏱️ Set startTime for ${toolCallId} at ${new Date(startTime).toISOString()} (from tool_call_update)`); + + // Increment tool call counter for context tracking + this.toolCallCountSincePrompt++; + + logger.debug(`[AcpSdkBackend] 🔧 Tool call START: ${toolCallId} (${toolKind} -> ${realToolName})${isInvestigation ? ' [INVESTIGATION TOOL]' : ''}`); + if (isInvestigation) { + logger.debug(`[AcpSdkBackend] 🔍 Investigation tool detected (by toolCallId) - extended timeout (10min) will be used`); + } + + // Set timeout for tool call completion (especially important for investigation tools) + // This ensures timeout is set even if tool_call event doesn't arrive + const timeoutMs = getToolCallTimeout(toolCallId, toolKind); + + // Only set timeout if not already set (from tool_call event) + if (!this.toolCallTimeouts.has(toolCallId)) { + const timeout = setTimeout(() => { + const startTime = this.toolCallStartTimes.get(toolCallId); + const duration = startTime ? Date.now() - startTime : null; + const durationStr = duration ? `${(duration / 1000).toFixed(2)}s` : 'unknown'; + + logger.debug(`[AcpSdkBackend] ⏱️ Tool call TIMEOUT (from tool_call_update): ${toolCallId} (${toolKind}) after ${(timeoutMs / 1000).toFixed(0)}s - Duration: ${durationStr}, removing from active set`); + this.activeToolCalls.delete(toolCallId); + this.toolCallStartTimes.delete(toolCallId); + this.toolCallTimeouts.delete(toolCallId); + + // Check if we should emit idle status + if (this.activeToolCalls.size === 0) { + logger.debug('[AcpSdkBackend] No more active tool calls after timeout, emitting idle status'); + this.emit({ type: 'status', status: 'idle' }); + } + }, timeoutMs); + + this.toolCallTimeouts.set(toolCallId, timeout); + logger.debug(`[AcpSdkBackend] ⏱️ Set timeout for ${toolCallId}: ${(timeoutMs / 1000).toFixed(0)}s${isInvestigation ? ' (investigation tool)' : ''}`); + } else { + logger.debug(`[AcpSdkBackend] Timeout already set for ${toolCallId}, skipping`); + } + + // Clear idle timeout - tool call is starting, agent is working + if (this.idleTimeout) { + clearTimeout(this.idleTimeout); + this.idleTimeout = null; + } + + // Emit running status when tool call starts + this.emit({ type: 'status', status: 'running' }); + + // Parse args from content (can be array or object) + let args: Record = {}; + if (Array.isArray(update.content)) { + // Convert array content to object if needed + args = { items: update.content }; + } else if (update.content && typeof update.content === 'object' && update.content !== null) { + args = update.content as Record; + } + + // Log tool call details for investigation tools + if (isInvestigation && args.objective) { + logger.debug(`[AcpSdkBackend] 🔍 Investigation tool objective: ${String(args.objective).substring(0, 100)}...`); + } + + this.emit({ + type: 'tool-call', + toolName: typeof toolKind === 'string' ? toolKind : 'unknown', + args, + callId: toolCallId, + }); + } else { + // Tool call already tracked - might be an update + logger.debug(`[AcpSdkBackend] Tool call ${toolCallId} already tracked, status: ${status}`); + } + } else if (status === 'completed') { + // Tool call finished - remove from active set and clear timeout + const startTime = this.toolCallStartTimes.get(toolCallId); + const duration = startTime ? Date.now() - startTime : null; + const toolKind = update.kind || 'unknown'; + + this.activeToolCalls.delete(toolCallId); + this.toolCallStartTimes.delete(toolCallId); + + const timeout = this.toolCallTimeouts.get(toolCallId); + if (timeout) { + clearTimeout(timeout); + this.toolCallTimeouts.delete(toolCallId); + } + + const durationStr = duration ? `${(duration / 1000).toFixed(2)}s` : 'unknown'; + logger.debug(`[AcpSdkBackend] ✅ Tool call COMPLETED: ${toolCallId} (${toolKind}) - Duration: ${durationStr}. Active tool calls: ${this.activeToolCalls.size}`); + + this.emit({ + type: 'tool-result', + toolName: typeof toolKind === 'string' ? toolKind : 'unknown', + result: update.content, + callId: toolCallId, + }); + + // If no more active tool calls, emit 'idle' immediately (like Codex's task_complete) + // No timeout needed - when all tool calls complete, task is done + if (this.activeToolCalls.size === 0) { + if (this.idleTimeout) { + clearTimeout(this.idleTimeout); + this.idleTimeout = null; + } + logger.debug('[AcpSdkBackend] All tool calls completed, emitting idle status'); + this.emit({ type: 'status', status: 'idle' }); + } + } else if (status === 'failed' || status === 'cancelled') { + // Tool call failed or was cancelled - remove from active set and clear timeout + // IMPORTANT: Save values BEFORE deleting them for logging + const startTime = this.toolCallStartTimes.get(toolCallId); + const duration = startTime ? Date.now() - startTime : null; + const toolKind = update.kind || 'unknown'; + const isInvestigation = isInvestigationTool(toolCallId, toolKind); + const hadTimeout = this.toolCallTimeouts.has(toolCallId); + + // Log detailed timing information for investigation tools BEFORE cleanup + if (isInvestigation) { + const durationStr = duration ? `${(duration / 1000).toFixed(2)}s` : 'unknown'; + const durationMinutes = duration ? (duration / 1000 / 60).toFixed(2) : 'unknown'; + logger.debug(`[AcpSdkBackend] 🔍 Investigation tool ${status.toUpperCase()} after ${durationMinutes} minutes (${durationStr})`); + + // Check if this matches a 3-minute timeout pattern + if (duration) { + const threeMinutes = 3 * 60 * 1000; + const tolerance = 5000; // 5 second tolerance + if (Math.abs(duration - threeMinutes) < tolerance) { + logger.debug(`[AcpSdkBackend] 🔍 ⚠️ Investigation tool failed at ~3 minutes - likely Gemini CLI timeout, not our timeout`); + } + } + + logger.debug(`[AcpSdkBackend] 🔍 Investigation tool FAILED - full update.content:`, JSON.stringify(update.content, null, 2)); + logger.debug(`[AcpSdkBackend] 🔍 Investigation tool timeout status BEFORE cleanup: ${hadTimeout ? 'timeout was set' : 'no timeout was set'}`); + logger.debug(`[AcpSdkBackend] 🔍 Investigation tool startTime status BEFORE cleanup: ${startTime ? `set at ${new Date(startTime).toISOString()}` : 'not set'}`); + } + + // Now cleanup - remove from active set and clear timeout + this.activeToolCalls.delete(toolCallId); + this.toolCallStartTimes.delete(toolCallId); + + const timeout = this.toolCallTimeouts.get(toolCallId); + if (timeout) { + clearTimeout(timeout); + this.toolCallTimeouts.delete(toolCallId); + logger.debug(`[AcpSdkBackend] Cleared timeout for ${toolCallId} (tool call ${status})`); + } else { + logger.debug(`[AcpSdkBackend] No timeout found for ${toolCallId} (tool call ${status}) - timeout may not have been set`); + } + + const durationStr = duration ? `${(duration / 1000).toFixed(2)}s` : 'unknown'; + logger.debug(`[AcpSdkBackend] ❌ Tool call ${status.toUpperCase()}: ${toolCallId} (${toolKind}) - Duration: ${durationStr}. Active tool calls: ${this.activeToolCalls.size}`); + + // Extract error information from update.content if available + let errorDetail: string | undefined; + + if (update.content) { + if (typeof update.content === 'string') { + errorDetail = update.content; + } else if (typeof update.content === 'object' && update.content !== null && !Array.isArray(update.content)) { + const content = update.content as unknown as Record; + if (content.error) { + const error = content.error; + errorDetail = typeof error === 'string' + ? error + : (error && typeof error === 'object' && 'message' in error && typeof error.message === 'string') + ? error.message + : JSON.stringify(error); + } else if (typeof content.message === 'string') { + errorDetail = content.message; + } else { + // Try to extract any error-like fields + const status = typeof content.status === 'string' ? content.status : undefined; + const reason = typeof content.reason === 'string' ? content.reason : undefined; + errorDetail = status || reason || JSON.stringify(content).substring(0, 500); + } + } + } + + if (errorDetail) { + logger.debug(`[AcpSdkBackend] ❌ Tool call error details: ${errorDetail.substring(0, 500)}`); + } else { + logger.debug(`[AcpSdkBackend] ❌ Tool call ${status} but no error details in update.content`); + } + + // Emit tool-result with error information so user can see what went wrong + this.emit({ + type: 'tool-result', + toolName: typeof toolKind === 'string' ? toolKind : 'unknown', + result: errorDetail + ? { error: errorDetail, status: status } + : { error: `Tool call ${status}`, status: status }, + callId: toolCallId, + }); + + // If no more active tool calls, emit 'idle' immediately (like Codex's task_complete) + if (this.activeToolCalls.size === 0) { + if (this.idleTimeout) { + clearTimeout(this.idleTimeout); + this.idleTimeout = null; + } + logger.debug('[AcpSdkBackend] All tool calls completed/failed, emitting idle status'); + this.emit({ type: 'status', status: 'idle' }); + } + } + } + + // Legacy format support (in case some agents use old format) + if (update.messageChunk) { + const chunk = update.messageChunk; + if (chunk.textDelta) { + this.emit({ + type: 'model-output', + textDelta: chunk.textDelta, + }); + } + } + + // Handle plan updates + if (update.plan) { + this.emit({ + type: 'event', + name: 'plan', + payload: update.plan, + }); + } + + // Handle agent_thought_chunk (Gemini's thinking/reasoning chunks) + if (sessionUpdateType === 'agent_thought_chunk') { + + const content = update.content; + if (content && typeof content === 'object' && 'text' in content && typeof content.text === 'string') { + const text = content.text; + // Log thinking chunks for investigation tools (they can be long) + const hasActiveInvestigation = Array.from(this.activeToolCalls).some(() => { + // We can't directly check tool kind here, but we log for correlation + return true; // Log all thinking chunks when tool calls are active + }); + + if (hasActiveInvestigation && this.activeToolCalls.size > 0) { + const activeToolCallsList = Array.from(this.activeToolCalls); + logger.debug(`[AcpSdkBackend] 💭 Thinking chunk received (${text.length} chars) during active tool calls: ${activeToolCallsList.join(', ')}`); + } + + // Emit as thinking event - don't show as regular message + this.emit({ + type: 'event', + name: 'thinking', + payload: { text }, + }); + } + } + + // Handle tool_call (direct tool call, not just tool_call_update) + if (sessionUpdateType === 'tool_call') { + const toolCallId = update.toolCallId; + const status = update.status; + + logger.debug(`[AcpSdkBackend] Received tool_call: toolCallId=${toolCallId}, status=${status}, kind=${update.kind}`); + + // tool_call can come without explicit status, assume 'in_progress' if status is missing + const isInProgress = !status || status === 'in_progress' || status === 'pending'; + + if (toolCallId && isInProgress) { + + // Only emit tool-call if we haven't seen this toolCallId before + if (!this.activeToolCalls.has(toolCallId)) { + const startTime = Date.now(); + this.activeToolCalls.add(toolCallId); + this.toolCallStartTimes.set(toolCallId, startTime); + logger.debug(`[AcpSdkBackend] Added tool call ${toolCallId} to active set. Total active: ${this.activeToolCalls.size}`); + logger.debug(`[AcpSdkBackend] ⏱️ Set startTime for ${toolCallId} at ${new Date(startTime).toISOString()}`); + + // Clear idle timeout - tool call is starting, agent is working + if (this.idleTimeout) { + clearTimeout(this.idleTimeout); + this.idleTimeout = null; + } + + // Set timeout for tool call completion (especially for "think" tools that may not send completion updates) + // Think tools typically complete quickly, but we set a longer timeout for other tools + // codebase_investigator and similar investigation tools can take 5+ minutes, so we use a much longer timeout + // NOTE: update.kind may be "think" even for codebase_investigator, so we check toolCallId instead + const isInvestigation = isInvestigationTool(toolCallId, update.kind); + + if (isInvestigation) { + logger.debug(`[AcpSdkBackend] 🔍 Investigation tool detected (toolCallId: ${toolCallId}, kind: ${update.kind}) - using extended timeout (10min)`); + } + + const timeoutMs = getToolCallTimeout(toolCallId, update.kind); + + // Only set timeout if not already set (from tool_call_update) + if (!this.toolCallTimeouts.has(toolCallId)) { + const timeout = setTimeout(() => { + const startTime = this.toolCallStartTimes.get(toolCallId); + const duration = startTime ? Date.now() - startTime : null; + const durationStr = duration ? `${(duration / 1000).toFixed(2)}s` : 'unknown'; + + logger.debug(`[AcpSdkBackend] ⏱️ Tool call TIMEOUT (from tool_call): ${toolCallId} (${update.kind}) after ${(timeoutMs / 1000).toFixed(0)}s - Duration: ${durationStr}, removing from active set`); + this.activeToolCalls.delete(toolCallId); + this.toolCallStartTimes.delete(toolCallId); + this.toolCallTimeouts.delete(toolCallId); + + // Check if we should emit idle status + if (this.activeToolCalls.size === 0) { + logger.debug('[AcpSdkBackend] No more active tool calls after timeout, emitting idle status'); + this.emit({ type: 'status', status: 'idle' }); + } + }, timeoutMs); + + this.toolCallTimeouts.set(toolCallId, timeout); + logger.debug(`[AcpSdkBackend] ⏱️ Set timeout for ${toolCallId}: ${(timeoutMs / 1000).toFixed(0)}s${isInvestigation ? ' (investigation tool)' : ''}`); + } else { + logger.debug(`[AcpSdkBackend] Timeout already set for ${toolCallId}, skipping`); + } + + // Emit running status when tool call starts + this.emit({ type: 'status', status: 'running' }); + + // Parse args from content (can be array or object) + let args: Record = {}; + if (Array.isArray(update.content)) { + args = { items: update.content }; + } else if (update.content && typeof update.content === 'object') { + args = update.content; + } + + // Extract locations if present (for file operations) + if (update.locations && Array.isArray(update.locations)) { + args.locations = update.locations; + } + + logger.debug(`[AcpSdkBackend] Emitting tool-call event: toolName=${update.kind}, toolCallId=${toolCallId}, args=`, JSON.stringify(args)); + + this.emit({ + type: 'tool-call', + toolName: update.kind || 'unknown', + args, + callId: toolCallId, + }); + } else { + logger.debug(`[AcpSdkBackend] Tool call ${toolCallId} already in active set, skipping`); + } + } else { + logger.debug(`[AcpSdkBackend] Tool call ${toolCallId} not in progress (status: ${status}), skipping`); + } + } + + // Handle thinking/reasoning (explicit thinking field) + if (update.thinking) { + + this.emit({ + type: 'event', + name: 'thinking', + payload: update.thinking, + }); + } + + // Log unhandled session update types for debugging + if (sessionUpdateType && + sessionUpdateType !== 'agent_message_chunk' && + sessionUpdateType !== 'tool_call_update' && + sessionUpdateType !== 'agent_thought_chunk' && + sessionUpdateType !== 'tool_call' && + !update.messageChunk && + !update.plan && + !update.thinking) { + logger.debug(`[AcpSdkBackend] Unhandled session update type: ${sessionUpdateType}`, JSON.stringify(update, null, 2)); + } + } + + async sendPrompt(sessionId: SessionId, prompt: string): Promise { + // Check if prompt contains change_title instruction + const promptHasChangeTitle = hasChangeTitleInstruction(prompt); + + // Reset tool call counter and set flag + this.toolCallCountSincePrompt = 0; + this.recentPromptHadChangeTitle = promptHasChangeTitle; + + if (promptHasChangeTitle) { + logger.debug('[AcpSdkBackend] Prompt contains change_title instruction - will auto-approve first "other" tool call if it matches pattern'); + } + if (this.disposed) { + throw new Error('Backend has been disposed'); + } + + if (!this.connection || !this.acpSessionId) { + throw new Error('Session not started'); + } + + this.emit({ type: 'status', status: 'running' }); + + try { + logger.debug(`[AcpSdkBackend] Sending prompt (length: ${prompt.length}): ${prompt.substring(0, 100)}...`); + logger.debug(`[AcpSdkBackend] Full prompt: ${prompt}`); + + const contentBlock: ContentBlock = { + type: 'text', + text: prompt, + }; + + const promptRequest: PromptRequest = { + sessionId: this.acpSessionId, + prompt: [contentBlock], + }; + + logger.debug(`[AcpSdkBackend] Prompt request:`, JSON.stringify(promptRequest, null, 2)); + await this.connection.prompt(promptRequest); + logger.debug('[AcpSdkBackend] Prompt request sent to ACP connection'); + + // Don't emit 'idle' here - it will be emitted after all message chunks are received + // The idle timeout in handleSessionUpdate will emit 'idle' after the last chunk + + } catch (error) { + logger.debug('[AcpSdkBackend] Error sending prompt:', error); + + // Extract error details for better error handling + let errorDetail: string; + if (error instanceof Error) { + errorDetail = error.message; + } else if (typeof error === 'object' && error !== null) { + const errObj = error as Record; + // Try to extract structured error information + const fallbackMessage = (typeof errObj.message === 'string' ? errObj.message : undefined) || String(error); + if (errObj.code !== undefined) { + errorDetail = JSON.stringify({ code: errObj.code, message: fallbackMessage }); + } else if (typeof errObj.message === 'string') { + errorDetail = errObj.message; + } else { + errorDetail = String(error); + } + } else { + errorDetail = String(error); + } + + this.emit({ + type: 'status', + status: 'error', + detail: errorDetail + }); + throw error; + } + } + + async cancel(sessionId: SessionId): Promise { + if (!this.connection || !this.acpSessionId) { + return; + } + + try { + await this.connection.cancel({ sessionId: this.acpSessionId }); + this.emit({ type: 'status', status: 'stopped', detail: 'Cancelled by user' }); + } catch (error) { + // Log to file only, not console + logger.debug('[AcpSdkBackend] Error cancelling:', error); + } + } + + async respondToPermission(requestId: string, approved: boolean): Promise { + logger.debug(`[AcpSdkBackend] Permission response: ${requestId} = ${approved}`); + this.emit({ type: 'permission-response', id: requestId, approved }); + // TODO: Implement actual permission response when needed + } + + async dispose(): Promise { + if (this.disposed) return; + + logger.debug('[AcpSdkBackend] Disposing backend'); + this.disposed = true; + + // Try graceful shutdown first + if (this.connection && this.acpSessionId) { + try { + // Send cancel to stop any ongoing work + await Promise.race([ + this.connection.cancel({ sessionId: this.acpSessionId }), + new Promise((resolve) => setTimeout(resolve, 2000)), // 2s timeout for graceful shutdown + ]); + } catch (error) { + logger.debug('[AcpSdkBackend] Error during graceful shutdown:', error); + } + } + + // Kill the process + if (this.process) { + // Try SIGTERM first, then SIGKILL after timeout + this.process.kill('SIGTERM'); + + // Give process 1 second to terminate gracefully + await new Promise((resolve) => { + const timeout = setTimeout(() => { + if (this.process) { + logger.debug('[AcpSdkBackend] Force killing process'); + this.process.kill('SIGKILL'); + } + resolve(); + }, 1000); + + this.process?.once('exit', () => { + clearTimeout(timeout); + resolve(); + }); + }); + + this.process = null; + } + + // Clear timeouts + if (this.idleTimeout) { + clearTimeout(this.idleTimeout); + this.idleTimeout = null; + } + + // Clear state + this.listeners = []; + this.connection = null; + this.acpSessionId = null; + this.activeToolCalls.clear(); + // Clear all tool call timeouts + for (const timeout of this.toolCallTimeouts.values()) { + clearTimeout(timeout); + } + this.toolCallTimeouts.clear(); + this.toolCallStartTimes.clear(); + this.pendingPermissions.clear(); + } +} diff --git a/src/agent/acp/gemini.ts b/src/agent/acp/gemini.ts new file mode 100644 index 00000000..2b662ead --- /dev/null +++ b/src/agent/acp/gemini.ts @@ -0,0 +1,138 @@ +/** + * Gemini ACP Backend - Gemini CLI agent via ACP + * + * This module provides a factory function for creating a Gemini backend + * that communicates using the Agent Client Protocol (ACP). + * + * Gemini CLI is a reference ACP implementation from Google that supports + * the --experimental-acp flag for ACP mode. + */ + +import { AcpSdkBackend, type AcpSdkBackendOptions, type AcpPermissionHandler } from './AcpSdkBackend'; +import type { AgentBackend, McpServerConfig } from '../AgentBackend'; +import { agentRegistry, type AgentFactoryOptions } from '../AgentRegistry'; +import { logger } from '@/ui/logger'; +import { + GEMINI_API_KEY_ENV, + GOOGLE_API_KEY_ENV, + GEMINI_MODEL_ENV, + DEFAULT_GEMINI_MODEL +} from '@/gemini/constants'; +import { + readGeminiLocalConfig, + determineGeminiModel, + getGeminiModelSource +} from '@/gemini/utils/config'; + +/** + * Options for creating a Gemini ACP backend + */ +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; +} + +/** + * Create a Gemini backend using ACP (official SDK). + * + * The Gemini CLI must be installed and available in PATH. + * Uses the --experimental-acp flag to enable ACP mode. + * + * @param options - Configuration options + * @returns AgentBackend instance for Gemini + */ + +export function createGeminiBackend(options: GeminiBackendOptions): AgentBackend { + + // Resolve API key from multiple sources (in priority order): + // 1. Happy cloud OAuth token (via 'happy connect gemini') - highest priority + // 2. Local Gemini CLI config files (~/.gemini/) + // 3. GEMINI_API_KEY environment variable + // 4. GOOGLE_API_KEY environment variable - lowest priority + + // Try reading from local Gemini CLI config (token and model) + const localConfig = readGeminiLocalConfig(); + + let apiKey = options.cloudToken // 1. Happy cloud token (passed from runGemini) + || localConfig.token // 2. Local config (~/.gemini/) + || process.env[GEMINI_API_KEY_ENV] // 3. GEMINI_API_KEY env var + || process.env[GOOGLE_API_KEY_ENV] // 4. GOOGLE_API_KEY env var + || options.apiKey; // 5. Explicit apiKey option (fallback) + + if (!apiKey) { + logger.warn(`[Gemini] No API key found. Run 'happy connect gemini' to authenticate via Google OAuth, or set ${GEMINI_API_KEY_ENV} environment variable.`); + } + + // Command to run gemini + const geminiCommand = 'gemini'; + + // Get model from options, local config, system environment, or use default + // Priority: options.model (if provided) > local config > env var > default + // If options.model is undefined, check local config, then env, then use default + // 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 + // 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']; + + const backendOptions: AcpSdkBackendOptions = { + agentName: 'gemini', + cwd: options.cwd, + command: geminiCommand, + args: geminiArgs, + env: { + ...options.env, + ...(apiKey ? { [GEMINI_API_KEY_ENV]: apiKey, [GOOGLE_API_KEY_ENV]: apiKey } : {}), + // Pass model via env var - gemini CLI reads GEMINI_MODEL automatically + [GEMINI_MODEL_ENV]: model, + // Suppress debug output from gemini CLI to avoid stdout pollution + NODE_ENV: 'production', + DEBUG: '', + }, + mcpServers: options.mcpServers, + permissionHandler: options.permissionHandler, + }; + + // Determine model source for logging + const modelSource = getGeminiModelSource(options.model, localConfig); + + logger.debug('[Gemini] Creating ACP SDK backend with options:', { + cwd: backendOptions.cwd, + command: backendOptions.command, + args: backendOptions.args, + hasApiKey: !!apiKey, + model: model, + modelSource: modelSource, + mcpServerCount: options.mcpServers ? Object.keys(options.mcpServers).length : 0, + }); + + return new AcpSdkBackend(backendOptions); +} + +/** + * Register Gemini backend with the global agent registry. + * + * This function should be called during application initialization + * to make the Gemini agent available for use. + */ +export function registerGeminiAgent(): void { + agentRegistry.register('gemini', (opts) => createGeminiBackend(opts)); + logger.debug('[Gemini] Registered with agent registry'); +} + diff --git a/src/agent/acp/index.ts b/src/agent/acp/index.ts new file mode 100644 index 00000000..1b44cd26 --- /dev/null +++ b/src/agent/acp/index.ts @@ -0,0 +1,12 @@ +/** + * ACP Module - Agent Client Protocol implementations + * + * This module exports all ACP-related functionality including + * the base AcpSdkBackend and agent-specific implementations. + * + * Uses the official @agentclientprotocol/sdk from Zed Industries. + */ + +export { AcpSdkBackend, type AcpSdkBackendOptions } from './AcpSdkBackend'; +export { createGeminiBackend, registerGeminiAgent, type GeminiBackendOptions } from './gemini'; + diff --git a/src/agent/acp/utils.ts b/src/agent/acp/utils.ts new file mode 100644 index 00000000..aa466e3c --- /dev/null +++ b/src/agent/acp/utils.ts @@ -0,0 +1,160 @@ +/** + * ACP Backend Utilities + * + * Utility functions for working with ACP tool calls, tool names, and timeouts. + */ + +/** + * Known tool name patterns that can be extracted from toolCallId + */ +const KNOWN_TOOL_PATTERNS = { + change_title: ['change_title', 'change-title', 'happy__change_title'], + save_memory: ['save_memory', 'save-memory'], + think: ['think'], +} as const; + +/** + * Check if a tool is an investigation tool based on toolCallId and toolKind + * + * @param toolCallId - The tool call ID + * @param toolKind - The tool kind/type + * @returns true if this is an investigation tool + */ +export function isInvestigationTool(toolCallId: string, toolKind?: string | unknown): boolean { + return toolCallId.includes('codebase_investigator') || + toolCallId.includes('investigator') || + (typeof toolKind === 'string' && toolKind.includes('investigator')); +} + +/** + * Extract tool name from toolCallId + * + * Tool IDs often contain the tool name as a prefix (e.g., "change_title-1765385846663") + * + * @param toolCallId - The tool call ID + * @returns The extracted tool name, or null if not found + */ +export function extractToolNameFromId(toolCallId: string): string | null { + const lowerId = toolCallId.toLowerCase(); + + for (const [toolName, patterns] of Object.entries(KNOWN_TOOL_PATTERNS)) { + for (const pattern of patterns) { + if (lowerId.includes(pattern.toLowerCase())) { + return toolName; + } + } + } + + return null; +} + +/** + * Determine the real tool name from various sources + * + * When ACP sends "other" or "Unknown tool", we try to determine the real name from: + * 1. toolCallId (most reliable) + * 2. input parameters + * 3. params structure + * 4. Context (first tool call after change_title instruction) + * + * @param toolName - The initial tool name (may be "other" or "Unknown tool") + * @param toolCallId - The tool call ID + * @param input - The input parameters + * @param params - The full params object + * @param context - Context information (recent prompt had change_title, tool call count) + * @returns The determined tool name + */ +export function determineToolName( + toolName: string, + toolCallId: string, + input: Record, + params: unknown, + context?: { + recentPromptHadChangeTitle?: boolean; + toolCallCountSincePrompt?: number; + } +): string { + // If tool name is already known, return it + if (toolName !== 'other' && toolName !== 'Unknown tool') { + return toolName; + } + + // 1. Check toolCallId for known tool names (most reliable) + const idToolName = extractToolNameFromId(toolCallId); + if (idToolName) { + return idToolName; + } + + // 2. Check input for function names or tool identifiers + if (input && typeof input === 'object') { + const inputStr = JSON.stringify(input).toLowerCase(); + for (const [toolName, patterns] of Object.entries(KNOWN_TOOL_PATTERNS)) { + for (const pattern of patterns) { + if (inputStr.includes(pattern.toLowerCase())) { + return toolName; + } + } + } + } + + // 3. Check params for additional clues + const paramsStr = JSON.stringify(params).toLowerCase(); + for (const [toolName, patterns] of Object.entries(KNOWN_TOOL_PATTERNS)) { + for (const pattern of patterns) { + if (paramsStr.includes(pattern.toLowerCase())) { + return toolName; + } + } + } + + // 4. Context-based heuristic: if this is the first tool call after a prompt with change_title instruction + // AND input is empty/minimal, it's likely change_title + if (context?.recentPromptHadChangeTitle && context.toolCallCountSincePrompt === 0) { + const isEmptyInput = !input || + (Array.isArray(input) && input.length === 0) || + (typeof input === 'object' && Object.keys(input).length === 0); + + if (isEmptyInput && toolName === 'other') { + return 'change_title'; + } + } + + // Return original tool name if we couldn't determine it + return toolName; +} + +/** + * Get the real tool name from toolCallId, falling back to toolKind + * + * @param toolCallId - The tool call ID + * @param toolKind - The tool kind/type + * @returns The real tool name + */ +export function getRealToolName(toolCallId: string, toolKind: string | unknown): string { + const extracted = extractToolNameFromId(toolCallId); + if (extracted) { + return extracted; + } + return typeof toolKind === 'string' ? toolKind : 'unknown'; +} + +/** + * Get timeout for a tool call based on its type + * + * @param toolCallId - The tool call ID + * @param toolKind - The tool kind/type + * @returns Timeout in milliseconds + */ +export function getToolCallTimeout(toolCallId: string, toolKind: string | unknown): number { + const isInvestigation = isInvestigationTool(toolCallId, toolKind); + const isThinkTool = toolKind === 'think'; + + if (isInvestigation) { + return 600000; // 10 minutes for investigation tools (like codebase_investigator) + } else if (isThinkTool) { + return 30000; // 30s for regular think tools + } else { + return 120000; // 2min for other tools + } +} + diff --git a/src/agent/index.ts b/src/agent/index.ts new file mode 100644 index 00000000..d3f3f9bc --- /dev/null +++ b/src/agent/index.ts @@ -0,0 +1,40 @@ +/** + * Agent Module - Universal agent backend abstraction + * + * This module provides the core abstraction layer for different AI agents + * (Claude, Codex, Gemini, OpenCode, etc.) that can be controlled through + * the Happy CLI and mobile app. + */ + +// Core types and interfaces +export type { + AgentMessage, + AgentMessageHandler, + AgentBackend, + AgentBackendConfig, + AcpAgentConfig, + McpServerConfig, + AgentTransport, + AgentId, + SessionId, + ToolCallId, + StartSessionResult, +} from './AgentBackend'; + +// Registry +export { AgentRegistry, agentRegistry, type AgentFactory, type AgentFactoryOptions } from './AgentRegistry'; + +// ACP implementations +export * from './acp'; + +/** + * Initialize all agent backends and register them with the global registry. + * + * Call this function during application startup to make all agents available. + */ +export function initializeAgents(): void { + // Import and register agents + const { registerGeminiAgent } = require('./acp/gemini'); + registerGeminiAgent(); +} + diff --git a/src/api/api.ts b/src/api/api.ts index 8d1c0208..7de458e3 100644 --- a/src/api/api.ts +++ b/src/api/api.ts @@ -202,4 +202,87 @@ export class ApiClient { throw new Error(`Failed to register vendor token: ${error instanceof Error ? error.message : 'Unknown error'}`); } } + + /** + * Get vendor API token from the server + * Returns the token if it exists, null otherwise + */ + async getVendorToken(vendor: 'openai' | 'anthropic' | 'gemini'): Promise { + try { + const response = await axios.get( + `${configuration.serverUrl}/v1/connect/${vendor}/token`, + { + headers: { + 'Authorization': `Bearer ${this.credential.token}`, + 'Content-Type': 'application/json' + }, + timeout: 5000 + } + ); + + if (response.status === 404) { + logger.debug(`[API] No vendor token found for ${vendor}`); + return null; + } + + if (response.status !== 200) { + throw new Error(`Server returned status ${response.status}`); + } + + // Log raw response for debugging + logger.debug(`[API] Raw vendor token response:`, { + status: response.status, + dataKeys: Object.keys(response.data || {}), + hasToken: 'token' in (response.data || {}), + tokenType: typeof response.data?.token, + }); + + // Token is returned as JSON string, parse it + let tokenData: any = null; + if (response.data?.token) { + if (typeof response.data.token === 'string') { + try { + tokenData = JSON.parse(response.data.token); + } catch (parseError) { + logger.debug(`[API] Failed to parse token as JSON, using as string:`, parseError); + tokenData = response.data.token; + } + } else if (response.data.token !== null) { + // Token exists and is not null + tokenData = response.data.token; + } else { + // Token is explicitly null - treat as not found + logger.debug(`[API] Token is null for ${vendor}, treating as not found`); + return null; + } + } else if (response.data && typeof response.data === 'object') { + // Maybe the token is directly in response.data + // But check if it's { token: null } - treat as not found + if (response.data.token === null && Object.keys(response.data).length === 1) { + logger.debug(`[API] Response contains only null token for ${vendor}, treating as not found`); + return null; + } + tokenData = response.data; + } + + // Final check: if tokenData is null or { token: null }, return null + if (tokenData === null || (tokenData && typeof tokenData === 'object' && tokenData.token === null && Object.keys(tokenData).length === 1)) { + logger.debug(`[API] Token data is null for ${vendor}`); + return null; + } + + logger.debug(`[API] Vendor token for ${vendor} retrieved successfully`, { + tokenDataType: typeof tokenData, + tokenDataKeys: tokenData && typeof tokenData === 'object' ? Object.keys(tokenData) : 'not an object', + }); + return tokenData; + } catch (error: any) { + if (error.response?.status === 404) { + logger.debug(`[API] No vendor token found for ${vendor}`); + return null; + } + logger.debug(`[API] [ERROR] Failed to get vendor token:`, error); + return null; + } + } } diff --git a/src/api/apiSession.ts b/src/api/apiSession.ts index 1f87dfe8..a2805c94 100644 --- a/src/api/apiSession.ts +++ b/src/api/apiSession.ts @@ -232,6 +232,41 @@ export class ApiSessionClient extends EventEmitter { 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 a generic agent message to the session. + * Works for any agent type (Gemini, Codex, Claude, etc.) + * + * @param agentType - The type of agent sending the message (e.g., 'gemini', 'codex', 'claude') + * @param body - The message payload + */ + sendAgentMessage(agentType: 'gemini' | 'codex' | 'claude' | 'opencode', body: any) { + let content = { + role: 'agent', + content: { + type: agentType, + data: body + }, + meta: { + sentFrom: 'cli' + } + }; + + logger.debug(`[SOCKET] Sending ${agentType} message:`, { type: body.type, hasMessage: !!body.message }); + const encrypted = encodeBase64(encrypt(this.encryptionKey, this.encryptionVariant, content)); this.socket.emit('message', { sid: this.sessionId, diff --git a/src/claude/runClaude.ts b/src/claude/runClaude.ts index cd75cf7f..907e1ab3 100644 --- a/src/claude/runClaude.ts +++ b/src/claude/runClaude.ts @@ -34,6 +34,9 @@ export interface StartOptions { } export async function runClaude(credentials: Credentials, options: StartOptions = {}): Promise { + logger.debug(`[CLAUDE] ===== CLAUDE MODE STARTING =====`); + logger.debug(`[CLAUDE] This is the Claude agent, NOT Gemini`); + const workingDirectory = process.cwd(); const sessionTag = randomUUID(); diff --git a/src/claude/utils/path.test.ts b/src/claude/utils/path.test.ts index 3e0e594c..86433824 100644 --- a/src/claude/utils/path.test.ts +++ b/src/claude/utils/path.test.ts @@ -1,89 +1,39 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, vi } from 'vitest'; import { getProjectPath } from './path'; +import * as os from 'node:os'; import { join } from 'node:path'; -vi.mock('node:os', () => ({ - homedir: vi.fn(() => '/home/user') -})); - -// Store original env -const originalEnv = process.env; +vi.mock('node:os'); describe('getProjectPath', () => { - beforeEach(() => { - // Reset process.env to a clean state - process.env = { ...originalEnv }; - delete process.env.CLAUDE_CONFIG_DIR; - }); - - afterEach(() => { - // Restore original env - process.env = originalEnv; - }); - it('should replace slashes with hyphens in the project path', () => { - const workingDir = '/Users/steve/projects/my-app'; - const result = getProjectPath(workingDir); - expect(result).toBe(join('/home/user', '.claude', 'projects', '-Users-steve-projects-my-app')); - }); - - it('should replace dots with hyphens in the project path', () => { - const workingDir = '/Users/steve/projects/app.test.js'; - const result = getProjectPath(workingDir); - expect(result).toBe(join('/home/user', '.claude', 'projects', '-Users-steve-projects-app-test-js')); - }); - - it('should handle paths with both slashes and dots', () => { - const workingDir = '/var/www/my.site.com/public'; - const result = getProjectPath(workingDir); - expect(result).toBe(join('/home/user', '.claude', 'projects', '-var-www-my-site-com-public')); - }); - - it('should handle relative paths by resolving them first', () => { - const workingDir = './my-project'; - const result = getProjectPath(workingDir); - expect(result).toContain(join('/home/user', '.claude', 'projects')); - expect(result).toContain('my-project'); - }); - - it('should handle empty directory path', () => { - const workingDir = ''; - const result = getProjectPath(workingDir); - expect(result).toContain(join('/home/user', '.claude', 'projects')); - }); - - describe('CLAUDE_CONFIG_DIR support', () => { - it('should use default .claude directory when CLAUDE_CONFIG_DIR is not set', () => { - const workingDir = '/Users/steve/projects/my-app'; - const result = getProjectPath(workingDir); - expect(result).toBe(join('/home/user', '.claude', 'projects', '-Users-steve-projects-my-app')); - }); - - it('should use CLAUDE_CONFIG_DIR when set', () => { - process.env.CLAUDE_CONFIG_DIR = '/custom/claude/config'; - const workingDir = '/Users/steve/projects/my-app'; - const result = getProjectPath(workingDir); - expect(result).toBe(join('/custom/claude/config', 'projects', '-Users-steve-projects-my-app')); - }); - - it('should handle relative CLAUDE_CONFIG_DIR path', () => { - process.env.CLAUDE_CONFIG_DIR = './config/claude'; - const workingDir = '/Users/steve/projects/my-app'; - const result = getProjectPath(workingDir); - expect(result).toBe(join('./config/claude', 'projects', '-Users-steve-projects-my-app')); - }); - - it('should fallback to default when CLAUDE_CONFIG_DIR is empty string', () => { - process.env.CLAUDE_CONFIG_DIR = ''; - const workingDir = '/Users/steve/projects/my-app'; - const result = getProjectPath(workingDir); - expect(result).toBe(join('/home/user', '.claude', 'projects', '-Users-steve-projects-my-app')); - }); - - it('should handle CLAUDE_CONFIG_DIR with trailing slash', () => { - process.env.CLAUDE_CONFIG_DIR = '/custom/claude/config/'; - const workingDir = '/Users/steve/projects/my-app'; - const result = getProjectPath(workingDir); - expect(result).toBe(join('/custom/claude/config/', 'projects', '-Users-steve-projects-my-app')); - }); - }); -}); \ No newline at end of file + it('should return the default project path when CLAUDE_CONFIG_DIR is not set', () => { + vi.spyOn(os, 'homedir').mockReturnValue('/home/user'); + const workingDirectory = '/path/to/project'; + const expectedPath = join('/home/user', '.claude', 'projects', '-path-to-project'); + expect(getProjectPath(workingDirectory)).toBe(expectedPath); + }); + + it('should return the project path based on CLAUDE_CONFIG_DIR when it is set', () => { + process.env.CLAUDE_CONFIG_DIR = '/custom/claude/config'; + const workingDirectory = '/path/to/project'; + const expectedPath = join('/custom/claude/config', 'projects', '-path-to-project'); + expect(getProjectPath(workingDirectory)).toBe(expectedPath); + delete process.env.CLAUDE_CONFIG_DIR; + }); + + it('should handle windows paths correctly', () => { + vi.spyOn(os, 'homedir').mockReturnValue('C:\\Users\\user'); + const workingDirectory = 'C:\\path\\to\\project'; + const expectedPath = 'C:\\Users\\user\\.claude\\projects\\C-path-to-project'; + expect(getProjectPath(workingDirectory)).toBe(expectedPath); + }); + + it('should handle relative paths', () => { + vi.spyOn(os, 'homedir').mockReturnValue('/home/user'); + const workingDirectory = 'relative/path'; + const resolvedWorkingDirectory = join(process.cwd(), workingDirectory); + const projectId = resolvedWorkingDirectory.replace(/[\\\/.:]/g, '-'); + const expectedPath = join('/home/user', '.claude', 'projects', projectId); + expect(getProjectPath(workingDirectory)).toBe(expectedPath); + }); +}); diff --git a/src/codex/runCodex.ts b/src/codex/runCodex.ts index 62b4b5fd..d789bb19 100644 --- a/src/codex/runCodex.ts +++ b/src/codex/runCodex.ts @@ -23,6 +23,7 @@ import { MessageBuffer } from "@/ui/ink/messageBuffer"; import { CodexDisplay } from "@/ui/ink/CodexDisplay"; import { trimIdent } from "@/utils/trimIdent"; import type { CodexSessionConfig } from './types'; +import { CHANGE_TITLE_INSTRUCTION } from '@/gemini/constants'; import { notifyDaemonSessionStarted } from "@/daemon/controlClient"; import { registerKillSessionHandler } from "@/claude/registerKillSessionHandler"; import { delay } from "@/utils/time"; @@ -636,7 +637,7 @@ export async function runCodex(opts: { if (!wasCreated) { const startConfig: CodexSessionConfig = { - prompt: first ? message.message + '\n\n' + trimIdent(`Based on this message, call functions.happy__change_title to change chat session title that would represent the current task. If chat idea would change dramatically - call this function again to update the title.`) : message.message, + prompt: first ? message.message + '\n\n' + CHANGE_TITLE_INSTRUCTION : message.message, sandbox, 'approval-policy': approvalPolicy, config: { mcp_servers: mcpServers } diff --git a/src/gemini/constants.ts b/src/gemini/constants.ts new file mode 100644 index 00000000..4903b234 --- /dev/null +++ b/src/gemini/constants.ts @@ -0,0 +1,29 @@ +/** + * Gemini Constants + * + * Centralized constants for Gemini integration including environment variable names + * and default values. + */ + +import { trimIdent } from '@/utils/trimIdent'; + +/** Environment variable name for Gemini API key */ +export const GEMINI_API_KEY_ENV = 'GEMINI_API_KEY'; + +/** Environment variable name for Google API key (alternative) */ +export const GOOGLE_API_KEY_ENV = 'GOOGLE_API_KEY'; + +/** Environment variable name for Gemini model selection */ +export const GEMINI_MODEL_ENV = 'GEMINI_MODEL'; + +/** Default Gemini model */ +export const DEFAULT_GEMINI_MODEL = 'gemini-2.5-pro'; + +/** + * Instruction for changing chat title + * Used in system prompts to instruct agents to call change_title function + */ +export const CHANGE_TITLE_INSTRUCTION = trimIdent( + `Based on this message, call functions.happy__change_title to change chat session title that would represent the current task. If chat idea would change dramatically - call this function again to update the title.` +); + diff --git a/src/gemini/runGemini.ts b/src/gemini/runGemini.ts new file mode 100644 index 00000000..b89554bf --- /dev/null +++ b/src/gemini/runGemini.ts @@ -0,0 +1,1102 @@ +/** + * Gemini CLI Entry Point + * + * This module provides the main entry point for running the Gemini agent + * through Happy CLI. It manages the agent lifecycle, session state, and + * communication with the Happy server and mobile app. + */ + +import { render } from 'ink'; +import React from 'react'; +import { randomUUID } from 'node:crypto'; +import os from 'node:os'; +import { join, resolve } from 'node:path'; + +import { ApiClient } from '@/api/api'; +import { logger } from '@/ui/logger'; +import { Credentials, readSettings } from '@/persistence'; +import { AgentState, Metadata } from '@/api/types'; +import { initialMachineMetadata } from '@/daemon/run'; +import { configuration } from '@/configuration'; +import packageJson from '../../package.json'; +import { MessageQueue2 } from '@/utils/MessageQueue2'; +import { hashObject } from '@/utils/deterministicJson'; +import { projectPath } from '@/projectPath'; +import { startHappyServer } from '@/claude/utils/startHappyServer'; +import { MessageBuffer } from '@/ui/ink/messageBuffer'; +import { notifyDaemonSessionStarted } from '@/daemon/controlClient'; +import { registerKillSessionHandler } from '@/claude/registerKillSessionHandler'; +import { stopCaffeinate } from '@/utils/caffeinate'; + +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, CodexMessagePayload } 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'; + + +/** + * Main entry point for the gemini command with ink UI + */ +export async function runGemini(opts: { + credentials: Credentials; + startedBy?: 'daemon' | 'terminal'; +}): Promise { + // + // Define session + // + + + const sessionTag = randomUUID(); + const api = await ApiClient.create(opts.credentials); + + + // + // Machine + // + + const settings = await readSettings(); + const machineId = settings?.machineId; + if (!machineId) { + console.error(`[START] No machine ID found in settings, which is unexpected since authAndSetupMachineIfNeeded should have created it. Please report this issue on https://github.com/slopus/happy-cli/issues`); + process.exit(1); + } + logger.debug(`Using machineId: ${machineId}`); + await api.getOrCreateMachine({ + machineId, + metadata: initialMachineMetadata + }); + + // + // Fetch Gemini cloud token (from 'happy connect gemini') + // + let cloudToken: string | undefined = undefined; + try { + const vendorToken = await api.getVendorToken('gemini'); + if (vendorToken?.oauth?.access_token) { + cloudToken = vendorToken.oauth.access_token; + logger.debug('[Gemini] Using OAuth token from Happy cloud'); + } + } catch (error) { + logger.debug('[Gemini] Failed to fetch cloud token:', error); + } + + // + // Create session + // + + const state: AgentState = { + controlledByUser: false, + }; + const metadata: Metadata = { + path: process.cwd(), + host: os.hostname(), + version: packageJson.version, + os: os.platform(), + machineId: machineId, + homeDir: os.homedir(), + happyHomeDir: configuration.happyHomeDir, + happyLibDir: projectPath(), + happyToolsDir: resolve(projectPath(), 'tools', 'unpacked'), + startedFromDaemon: opts.startedBy === 'daemon', + hostPid: process.pid, + startedBy: opts.startedBy || 'terminal', + lifecycleState: 'running', + lifecycleStateSince: Date.now(), + flavor: 'gemini' + }; + const response = await api.getOrCreateSession({ tag: sessionTag, metadata, state }); + const session = api.sessionSyncClient(response); + + // Report to daemon + try { + logger.debug(`[START] Reporting session ${response.id} to daemon`); + const result = await notifyDaemonSessionStarted(response.id, metadata); + if (result.error) { + logger.debug(`[START] Failed to report to daemon (may not be running):`, result.error); + } else { + logger.debug(`[START] Reported session ${response.id} to daemon`); + } + } catch (error) { + logger.debug('[START] Failed to report to daemon (may not be running):', error); + } + + const messageQueue = new MessageQueue2((mode) => hashObject({ + permissionMode: mode.permissionMode, + model: mode.model, + })); + + // Track current overrides to apply per message + let currentPermissionMode: PermissionMode | undefined = undefined; + let currentModel: string | undefined = undefined; + + 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'); + + try { + if (session) { + session.updateMetadata((currentMetadata) => ({ + ...currentMetadata, + lifecycleState: 'archived', + lifecycleStateSince: Date.now(), + archivedBy: 'cli', + archiveReason: 'User terminated' + })); + + session.sendSessionDeath(); + await session.flush(); + await session.close(); + } + + 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(); + } + }); + }; + + 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'); + } + + 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()})`); + } + } + + } 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'); + } +} + diff --git a/src/gemini/types.ts b/src/gemini/types.ts new file mode 100644 index 00000000..c557563d --- /dev/null +++ b/src/gemini/types.ts @@ -0,0 +1,29 @@ +/** + * Gemini Types + * + * Centralized type definitions for Gemini integration. + */ + +/** + * Permission mode for tool approval + */ +export type PermissionMode = 'default' | 'read-only' | 'safe-yolo' | 'yolo'; + +/** + * Mode configuration for Gemini messages + */ +export interface GeminiMode { + permissionMode: PermissionMode; + model?: string; + originalUserMessage?: string; // Original user message without system prompt +} + +/** + * Codex message payload for sending messages to mobile app + */ +export interface CodexMessagePayload { + type: 'message'; + message: string; + id: string; + options?: string[]; +} diff --git a/src/gemini/utils/config.ts b/src/gemini/utils/config.ts new file mode 100644 index 00000000..c1e5344c --- /dev/null +++ b/src/gemini/utils/config.ts @@ -0,0 +1,196 @@ +/** + * Gemini Configuration Utilities + * + * Utilities for reading and writing Gemini CLI configuration files, + * including API keys, tokens, and model settings. + */ + +import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs'; +import { join } from 'path'; +import { homedir } from 'os'; +import { execSync } from 'child_process'; +import { logger } from '@/ui/logger'; +import { GEMINI_MODEL_ENV, DEFAULT_GEMINI_MODEL } from '../constants'; + +/** + * Result of reading Gemini local configuration + */ +export interface GeminiLocalConfig { + token: string | null; + model: string | null; +} + +/** + * Try to read Gemini config (auth token and model) from local Gemini CLI config + * Gemini CLI stores tokens in ~/.gemini/ or uses gcloud Application Default Credentials + */ +export function readGeminiLocalConfig(): GeminiLocalConfig { + let token: string | null = null; + let model: string | null = null; + + // Try common Gemini CLI config locations + // Gemini CLI stores OAuth tokens in ~/.gemini/oauth_creds.json after 'gemini auth' + const possiblePaths = [ + join(homedir(), '.gemini', 'oauth_creds.json'), // Main OAuth credentials file + join(homedir(), '.gemini', 'config.json'), + join(homedir(), '.config', 'gemini', 'config.json'), + join(homedir(), '.gemini', 'auth.json'), + join(homedir(), '.config', 'gemini', 'auth.json'), + ]; + + for (const configPath of possiblePaths) { + if (existsSync(configPath)) { + try { + const config = JSON.parse(readFileSync(configPath, 'utf-8')); + + // Try different possible token field names + // oauth_creds.json has access_token field + if (!token) { + const foundToken = config.access_token || config.token || config.apiKey || config.GEMINI_API_KEY; + if (foundToken && typeof foundToken === 'string') { + token = foundToken; + logger.debug(`[Gemini] Found token in ${configPath}`); + } + } + + // Try to read model from config + if (!model) { + const foundModel = config.model || config.GEMINI_MODEL; + if (foundModel && typeof foundModel === 'string') { + model = foundModel; + logger.debug(`[Gemini] Found model in ${configPath}: ${model}`); + } + } + } catch (error) { + logger.debug(`[Gemini] Failed to read config from ${configPath}:`, error); + } + } + } + + // Try gcloud Application Default Credentials + // Gemini CLI might use gcloud auth application-default print-access-token + if (!token) { + try { + const gcloudToken = execSync('gcloud auth application-default print-access-token', { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + timeout: 5000 + }).trim(); + if (gcloudToken && gcloudToken.length > 0) { + token = gcloudToken; + logger.debug('[Gemini] Found token via gcloud Application Default Credentials'); + } + } catch (error) { + // gcloud not available or not authenticated - this is fine + logger.debug('[Gemini] gcloud Application Default Credentials not available'); + } + } + + return { token, model }; +} + +/** + * Determine the model to use based on priority: + * 1. Explicit model parameter (if provided) + * 2. Environment variable (GEMINI_MODEL) + * 3. Local config file + * 4. Default model + * + * @param explicitModel - Model explicitly provided (undefined = check sources, null = skip config) + * @param localConfig - Local config result from readGeminiLocalConfig() + * @returns The model string to use + */ +export function determineGeminiModel( + explicitModel: string | null | undefined, + localConfig: GeminiLocalConfig +): string { + if (explicitModel !== undefined) { + if (explicitModel === null) { + // Explicitly null - use env or default, skip local config + return process.env[GEMINI_MODEL_ENV] || DEFAULT_GEMINI_MODEL; + } else { + // Model explicitly provided - use it + return explicitModel; + } + } else { + // No explicit model - check env var first (user override), then local config, then default + // This allows users to override config via environment variable + const envModel = process.env[GEMINI_MODEL_ENV]; + logger.debug(`[Gemini] Model selection: env[GEMINI_MODEL_ENV]=${envModel}, localConfig.model=${localConfig.model}, DEFAULT=${DEFAULT_GEMINI_MODEL}`); + const model = envModel || localConfig.model || DEFAULT_GEMINI_MODEL; + logger.debug(`[Gemini] Selected model: ${model}`); + return model; + } +} + +/** + * Save model to Gemini config file + * + * @param model - The model name to save + */ +export function saveGeminiModelToConfig(model: string): void { + try { + const configDir = join(homedir(), '.gemini'); + const configPath = join(configDir, 'config.json'); + + // Create directory if it doesn't exist + if (!existsSync(configDir)) { + mkdirSync(configDir, { recursive: true }); + } + + // Read existing config or create new one + let config: any = {}; + if (existsSync(configPath)) { + try { + config = JSON.parse(readFileSync(configPath, 'utf-8')); + } catch (error) { + logger.debug(`[Gemini] Failed to read existing config, creating new one`); + config = {}; + } + } + + // Update model in config + config.model = model; + + // Write config back + writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8'); + logger.debug(`[Gemini] Saved model "${model}" to ${configPath}`); + } catch (error) { + logger.debug(`[Gemini] Failed to save model to config:`, error); + // Don't throw - this is not critical + } +} + +/** + * Get the initial model value for UI display + * Priority: env var > local config > default + * + * @returns The initial model string + */ +export function getInitialGeminiModel(): string { + const localConfig = readGeminiLocalConfig(); + return process.env[GEMINI_MODEL_ENV] || localConfig.model || DEFAULT_GEMINI_MODEL; +} + +/** + * Determine the source of the model for logging purposes + * + * @param explicitModel - Model explicitly provided (undefined = check sources, null = skip config) + * @param localConfig - Local config result from readGeminiLocalConfig() + * @returns Source identifier: 'explicit' | 'env-var' | 'local-config' | 'default' + */ +export function getGeminiModelSource( + explicitModel: string | null | undefined, + localConfig: GeminiLocalConfig +): 'explicit' | 'env-var' | 'local-config' | 'default' { + if (explicitModel !== undefined && explicitModel !== null) { + return 'explicit'; + } else if (process.env[GEMINI_MODEL_ENV]) { + return 'env-var'; + } else if (localConfig.model) { + return 'local-config'; + } else { + return 'default'; + } +} + diff --git a/src/gemini/utils/diffProcessor.ts b/src/gemini/utils/diffProcessor.ts new file mode 100644 index 00000000..751d45b6 --- /dev/null +++ b/src/gemini/utils/diffProcessor.ts @@ -0,0 +1,158 @@ +/** + * Diff Processor for Gemini - Handles file edit events and tracks unified_diff changes + * + * This processor tracks changes from fs-edit events and tool_call results that contain + * file modification information, converting them to GeminiDiff tool calls similar to Codex. + * + * Note: Gemini ACP doesn't have direct turn_diff events like Codex, so we track + * file changes through fs-edit events and tool results that may contain diff information. + */ + +import { randomUUID } from 'node:crypto'; +import { logger } from '@/ui/logger'; + +export interface DiffToolCall { + type: 'tool-call'; + name: 'GeminiDiff'; + callId: string; + input: { + unified_diff?: string; + path?: string; + description?: string; + }; + id: string; +} + +export interface DiffToolResult { + type: 'tool-call-result'; + callId: string; + output: { + status: 'completed'; + }; + id: string; +} + +export class GeminiDiffProcessor { + private previousDiffs = new Map(); // Track diffs per file path + private onMessage: ((message: any) => void) | null = null; + + constructor(onMessage?: (message: any) => void) { + this.onMessage = onMessage || null; + } + + /** + * Process an fs-edit event and check if it contains diff information + */ + processFsEdit(path: string, description?: string, diff?: string): void { + logger.debug(`[GeminiDiffProcessor] Processing fs-edit for path: ${path}`); + + // If we have a diff, process it + if (diff) { + this.processDiff(path, diff, description); + } else { + // Even without diff, we can track that a file was edited + // Generate a simple diff representation + const simpleDiff = `File edited: ${path}${description ? ` - ${description}` : ''}`; + this.processDiff(path, simpleDiff, description); + } + } + + /** + * Process a tool result that may contain diff information + */ + processToolResult(toolName: string, result: any, callId: string): void { + // Check if result contains diff information + if (result && typeof result === 'object') { + // Look for common diff fields + const diff = result.diff || result.unified_diff || result.patch; + const path = result.path || result.file; + + if (diff && path) { + logger.debug(`[GeminiDiffProcessor] Found diff in tool result: ${toolName} (${callId})`); + this.processDiff(path, diff, result.description); + } else if (result.changes && typeof result.changes === 'object') { + // Handle multiple file changes (like patch operations) + for (const [filePath, change] of Object.entries(result.changes)) { + const changeDiff = (change as any).diff || (change as any).unified_diff || + JSON.stringify(change); + this.processDiff(filePath, changeDiff, (change as any).description); + } + } + } + } + + /** + * Process a unified diff and check if it has changed from the previous value + */ + private processDiff(path: string, unifiedDiff: string, description?: string): void { + const previousDiff = this.previousDiffs.get(path); + + // Check if the diff has changed from the previous value + if (previousDiff !== unifiedDiff) { + logger.debug(`[GeminiDiffProcessor] Unified diff changed for ${path}, sending GeminiDiff tool call`); + + // Generate a unique call ID for this diff + const callId = randomUUID(); + + // Send tool call for the diff change + const toolCall: DiffToolCall = { + type: 'tool-call', + name: 'GeminiDiff', + callId: callId, + input: { + unified_diff: unifiedDiff, + path: path, + description: description + }, + id: randomUUID() + }; + + this.onMessage?.(toolCall); + + // Immediately send the tool result to mark it as completed + const toolResult: DiffToolResult = { + type: 'tool-call-result', + callId: callId, + output: { + status: 'completed' + }, + id: randomUUID() + }; + + this.onMessage?.(toolResult); + } + + // Update the stored diff value + this.previousDiffs.set(path, unifiedDiff); + logger.debug(`[GeminiDiffProcessor] Updated stored diff for ${path}`); + } + + /** + * Reset the processor state (called on task_complete or turn_aborted) + */ + reset(): void { + logger.debug('[GeminiDiffProcessor] Resetting diff state'); + this.previousDiffs.clear(); + } + + /** + * Set the message callback for sending messages directly + */ + setMessageCallback(callback: (message: any) => void): void { + this.onMessage = callback; + } + + /** + * Get the current diff value for a specific path + */ + getCurrentDiff(path: string): string | null { + return this.previousDiffs.get(path) || null; + } + + /** + * Get all tracked diffs + */ + getAllDiffs(): Map { + return new Map(this.previousDiffs); + } +} diff --git a/src/gemini/utils/optionsParser.ts b/src/gemini/utils/optionsParser.ts new file mode 100644 index 00000000..bf105dd1 --- /dev/null +++ b/src/gemini/utils/optionsParser.ts @@ -0,0 +1,70 @@ +/** + * Options Parser Utilities + * + * Utilities for parsing and formatting XML options blocks from agent responses. + * Used for extracting and formatting blocks. + */ + +/** + * Check if text has an incomplete options block (opening tag but no closing tag) + * + * @param text - The text to check + * @returns true if there's an opening tag without a closing tag + */ +export function hasIncompleteOptions(text: string): boolean { + const hasOpeningTag = //i.test(text); + const hasClosingTag = /<\/options>/i.test(text); + return hasOpeningTag && !hasClosingTag; +} + +/** + * Parse XML options from text + * Extracts blocks and returns + * the text without options and the parsed options array + * + * @param text - The text containing options XML + * @returns Object with text (without options) and options array + */ +export function parseOptionsFromText(text: string): { text: string; options: string[] } { + // Match ... block (multiline, non-greedy) + const optionsRegex = /\s*([\s\S]*?)\s*<\/options>/i; + const match = text.match(optionsRegex); + + if (!match) { + return { text: text.trim(), options: [] }; + } + + // Extract options block content + const optionsBlock = match[1]; + + // Parse individual