/** * ExtensionHost - Loads and runs the Roo Code extension in CLI mode * * This class is responsible for: * 1. Creating the vscode-shim mock * 2. Loading the extension bundle via require() * 3. Activating the extension * 4. Managing bidirectional message flow between CLI and extension */ import { EventEmitter } from "events" import { createRequire } from "module" import path from "path" import { fileURLToPath } from "url" import fs from "fs" import readline from "readline" import { createVSCodeAPI, setRuntimeConfigValues } from "@roo-code/vscode-shim" import { ProviderName, ReasoningEffortExtended, RooCodeSettings } from "@roo-code/types" // Get the CLI package root directory (for finding node_modules/@vscode/ripgrep) // When bundled, import.meta.url points to dist/index.js, so go up to package root const __dirname = path.dirname(fileURLToPath(import.meta.url)) const CLI_PACKAGE_ROOT = path.resolve(__dirname, "..") export interface ExtensionHostOptions { mode: string reasoningEffort?: ReasoningEffortExtended | "disabled" apiProvider: ProviderName apiKey?: string model: string workspacePath: string extensionPath: string verbose?: boolean quiet?: boolean nonInteractive?: boolean } interface ExtensionModule { activate: (context: unknown) => Promise deactivate?: () => Promise } /** * Local interface for webview provider (matches VSCode API) */ interface WebviewViewProvider { resolveWebviewView?(webviewView: unknown, context: unknown, token: unknown): void | Promise } export class ExtensionHost extends EventEmitter { private vscode: ReturnType | null = null private extensionModule: ExtensionModule | null = null private extensionAPI: unknown = null private webviewProviders: Map = new Map() private options: ExtensionHostOptions private isWebviewReady = false private pendingMessages: unknown[] = [] private messageListener: ((message: unknown) => void) | null = null private originalConsole: { log: typeof console.log warn: typeof console.warn error: typeof console.error debug: typeof console.debug info: typeof console.info } | null = null private originalProcessEmitWarning: typeof process.emitWarning | null = null // Track pending asks that need a response (by ts) private pendingAsks: Set = new Set() // Readline interface for interactive prompts private rl: readline.Interface | null = null // Track displayed messages by ts to avoid duplicates and show updates private displayedMessages: Map = new Map() // Track streamed content by ts for delta computation private streamedContent: Map = new Map() // Track message processing for verbose debug output private processedMessageCount = 0 // Track if we're currently streaming a message (to manage newlines) private currentlyStreamingTs: number | null = null constructor(options: ExtensionHostOptions) { super() this.options = options } private log(...args: unknown[]): void { if (this.options.verbose) { // Use original console if available to avoid quiet mode suppression const logFn = this.originalConsole?.log || console.log logFn("[ExtensionHost]", ...args) } } /** * Suppress Node.js warnings (like MaxListenersExceededWarning) * This is called regardless of quiet mode to prevent warnings from interrupting output */ private suppressNodeWarnings(): void { // Suppress process warnings (like MaxListenersExceededWarning) this.originalProcessEmitWarning = process.emitWarning process.emitWarning = () => {} // Also suppress via the warning event handler process.on("warning", () => {}) } /** * Suppress console output from the extension when quiet mode is enabled. * This intercepts console.log, console.warn, console.info, console.debug * but allows console.error through for critical errors. */ private setupQuietMode(): void { if (!this.options.quiet) { return } // Save original console methods this.originalConsole = { log: console.log, warn: console.warn, error: console.error, debug: console.debug, info: console.info, } // Replace with no-op functions (except error) console.log = () => {} console.warn = () => {} console.debug = () => {} console.info = () => {} // Keep console.error for critical errors } /** * Restore original console methods and process.emitWarning */ private restoreConsole(): void { if (this.originalConsole) { console.log = this.originalConsole.log console.warn = this.originalConsole.warn console.error = this.originalConsole.error console.debug = this.originalConsole.debug console.info = this.originalConsole.info this.originalConsole = null } if (this.originalProcessEmitWarning) { process.emitWarning = this.originalProcessEmitWarning this.originalProcessEmitWarning = null } } async activate(): Promise { this.log("Activating extension...") // Suppress Node.js warnings (like MaxListenersExceededWarning) before anything else this.suppressNodeWarnings() // Set up quiet mode before loading extension this.setupQuietMode() // Verify extension path exists const bundlePath = path.join(this.options.extensionPath, "extension.js") if (!fs.existsSync(bundlePath)) { this.restoreConsole() throw new Error(`Extension bundle not found at: ${bundlePath}`) } // 1. Create VSCode API mock this.log("Creating VSCode API mock...") this.log("Using appRoot:", CLI_PACKAGE_ROOT) this.vscode = createVSCodeAPI( this.options.extensionPath, this.options.workspacePath, undefined, // identity { appRoot: CLI_PACKAGE_ROOT }, // options - point appRoot to CLI package for ripgrep ) // 2. Set global vscode reference for the extension ;(global as Record).vscode = this.vscode // 3. Set up __extensionHost global for webview registration // This is used by WindowAPI.registerWebviewViewProvider ;(global as Record).__extensionHost = this // 4. Set up module resolution to intercept require('vscode') const require = createRequire(import.meta.url) const Module = require("module") const originalResolve = Module._resolveFilename Module._resolveFilename = function (request: string, parent: unknown, isMain: boolean, options: unknown) { if (request === "vscode") { return "vscode-mock" } return originalResolve.call(this, request, parent, isMain, options) } // Add the mock to require.cache // Use 'as unknown as' to satisfy TypeScript's Module type requirements require.cache["vscode-mock"] = { id: "vscode-mock", filename: "vscode-mock", loaded: true, exports: this.vscode, children: [], paths: [], path: "", isPreloading: false, parent: null, require: require, } as unknown as NodeJS.Module this.log("Loading extension bundle from:", bundlePath) // 5. Load extension bundle try { this.extensionModule = require(bundlePath) as ExtensionModule } catch (error) { // Restore module resolution before throwing Module._resolveFilename = originalResolve throw new Error( `Failed to load extension bundle: ${error instanceof Error ? error.message : String(error)}`, ) } // 6. Restore module resolution Module._resolveFilename = originalResolve this.log("Activating extension...") // 7. Activate extension try { this.extensionAPI = await this.extensionModule.activate(this.vscode.context) this.log("Extension activated successfully") } catch (error) { throw new Error(`Failed to activate extension: ${error instanceof Error ? error.message : String(error)}`) } } /** * Called by WindowAPI.registerWebviewViewProvider * This is triggered when the extension registers its sidebar webview provider */ registerWebviewProvider(viewId: string, provider: WebviewViewProvider): void { this.log(`Webview provider registered: ${viewId}`) this.webviewProviders.set(viewId, provider) // The WindowAPI will call resolveWebviewView automatically // We don't need to do anything here } /** * Called when a webview provider is disposed */ unregisterWebviewProvider(viewId: string): void { this.log(`Webview provider unregistered: ${viewId}`) this.webviewProviders.delete(viewId) } /** * Returns true during initial extension setup * Used to prevent the extension from aborting tasks during initialization */ isInInitialSetup(): boolean { return !this.isWebviewReady } /** * Called by WindowAPI after resolveWebviewView completes * This indicates the webview is ready to receive messages */ markWebviewReady(): void { this.log("Webview marked as ready") this.isWebviewReady = true this.emit("webviewReady") // Flush any pending messages this.flushPendingMessages() } /** * Send any messages that were queued before the webview was ready */ private flushPendingMessages(): void { if (this.pendingMessages.length > 0) { this.log(`Flushing ${this.pendingMessages.length} pending messages`) for (const message of this.pendingMessages) { this.emit("webviewMessage", message) } this.pendingMessages = [] } } /** * Send a message to the extension (simulating webview -> extension communication). */ sendToExtension(message: unknown): void { if (!this.isWebviewReady) { this.log("Queueing message (webview not ready):", message) this.pendingMessages.push(message) return } this.log("Sending message to extension:", message) this.emit("webviewMessage", message) } private applyRuntimeSettings(settings: RooCodeSettings): void { if (this.options.mode) { settings.mode = this.options.mode } if (this.options.reasoningEffort) { if (this.options.reasoningEffort === "disabled") { settings.enableReasoningEffort = false } else { settings.enableReasoningEffort = true settings.reasoningEffort = this.options.reasoningEffort } } // Update vscode-shim runtime configuration so // vscode.workspace.getConfiguration() returns correct values. setRuntimeConfigValues("roo-cline", settings as Record) } /** * Build the provider-specific API configuration * Each provider uses different field names for API key and model */ private buildApiConfiguration(): RooCodeSettings { const provider = this.options.apiProvider || "anthropic" const apiKey = this.options.apiKey const model = this.options.model // Base config with provider. const config: RooCodeSettings = { apiProvider: provider } // Map provider to the correct API key and model field names. switch (provider) { case "anthropic": if (apiKey) config.apiKey = apiKey if (model) config.apiModelId = model break case "openrouter": if (apiKey) config.openRouterApiKey = apiKey if (model) config.openRouterModelId = model break case "gemini": if (apiKey) config.geminiApiKey = apiKey if (model) config.apiModelId = model break case "openai-native": if (apiKey) config.openAiNativeApiKey = apiKey if (model) config.apiModelId = model break case "openai": if (apiKey) config.openAiApiKey = apiKey if (model) config.openAiModelId = model break case "mistral": if (apiKey) config.mistralApiKey = apiKey if (model) config.apiModelId = model break case "deepseek": if (apiKey) config.deepSeekApiKey = apiKey if (model) config.apiModelId = model break case "xai": if (apiKey) config.xaiApiKey = apiKey if (model) config.apiModelId = model break case "groq": if (apiKey) config.groqApiKey = apiKey if (model) config.apiModelId = model break case "fireworks": if (apiKey) config.fireworksApiKey = apiKey if (model) config.apiModelId = model break case "cerebras": if (apiKey) config.cerebrasApiKey = apiKey if (model) config.apiModelId = model break case "sambanova": if (apiKey) config.sambaNovaApiKey = apiKey if (model) config.apiModelId = model break case "ollama": if (apiKey) config.ollamaApiKey = apiKey if (model) config.ollamaModelId = model break case "lmstudio": if (model) config.lmStudioModelId = model break case "litellm": if (apiKey) config.litellmApiKey = apiKey if (model) config.litellmModelId = model break case "huggingface": if (apiKey) config.huggingFaceApiKey = apiKey if (model) config.huggingFaceModelId = model break case "chutes": if (apiKey) config.chutesApiKey = apiKey if (model) config.apiModelId = model break case "featherless": if (apiKey) config.featherlessApiKey = apiKey if (model) config.apiModelId = model break case "unbound": if (apiKey) config.unboundApiKey = apiKey if (model) config.unboundModelId = model break case "requesty": if (apiKey) config.requestyApiKey = apiKey if (model) config.requestyModelId = model break case "deepinfra": if (apiKey) config.deepInfraApiKey = apiKey if (model) config.deepInfraModelId = model break case "vercel-ai-gateway": if (apiKey) config.vercelAiGatewayApiKey = apiKey if (model) config.vercelAiGatewayModelId = model break case "zai": if (apiKey) config.zaiApiKey = apiKey if (model) config.apiModelId = model break case "baseten": if (apiKey) config.basetenApiKey = apiKey if (model) config.apiModelId = model break case "doubao": if (apiKey) config.doubaoApiKey = apiKey if (model) config.apiModelId = model break case "moonshot": if (apiKey) config.moonshotApiKey = apiKey if (model) config.apiModelId = model break case "minimax": if (apiKey) config.minimaxApiKey = apiKey if (model) config.apiModelId = model break case "io-intelligence": if (apiKey) config.ioIntelligenceApiKey = apiKey if (model) config.ioIntelligenceModelId = model break default: // Default to apiKey and apiModelId for unknown providers. if (apiKey) config.apiKey = apiKey if (model) config.apiModelId = model } return config } /** * Run a task with the given prompt */ async runTask(prompt: string): Promise { this.log("Running task:", prompt) // Wait for webview to be ready if (!this.isWebviewReady) { this.log("Waiting for webview to be ready...") await new Promise((resolve) => { this.once("webviewReady", resolve) }) } // Set up message listener for extension responses this.setupMessageListener() // Configure approval settings based on mode // In non-interactive mode (-y flag), enable auto-approval for everything // In interactive mode (default), we'll prompt the user for each action if (this.options.nonInteractive) { this.log("Non-interactive mode: enabling auto-approval settings...") const settings: RooCodeSettings = { autoApprovalEnabled: true, alwaysAllowReadOnly: true, alwaysAllowReadOnlyOutsideWorkspace: true, alwaysAllowWrite: true, alwaysAllowWriteOutsideWorkspace: true, alwaysAllowWriteProtected: false, // Keep protected files safe. alwaysAllowBrowser: true, alwaysAllowMcp: true, alwaysAllowModeSwitch: true, alwaysAllowSubtasks: true, alwaysAllowExecute: true, alwaysAllowFollowupQuestions: true, // Allow all commands with wildcard (required for command auto-approval). allowedCommands: ["*"], commandExecutionTimeout: 20, } this.applyRuntimeSettings(settings) this.sendToExtension({ type: "updateSettings", updatedSettings: settings }) await new Promise((resolve) => setTimeout(resolve, 100)) } else { this.log("Interactive mode: user will be prompted for approvals...") const settings: RooCodeSettings = { autoApprovalEnabled: false } this.applyRuntimeSettings(settings) this.sendToExtension({ type: "updateSettings", updatedSettings: settings }) await new Promise((resolve) => setTimeout(resolve, 100)) } if (this.options.apiKey) { this.sendToExtension({ type: "updateSettings", updatedSettings: this.buildApiConfiguration() }) await new Promise((resolve) => setTimeout(resolve, 100)) } this.sendToExtension({ type: "newTask", text: prompt }) await this.waitForCompletion() } /** * Set up listener for messages from the extension */ private setupMessageListener(): void { this.messageListener = (message: unknown) => { this.handleExtensionMessage(message) } this.on("extensionWebviewMessage", this.messageListener) } /** * Handle messages from the extension */ private handleExtensionMessage(message: unknown): void { const msg = message as Record if (this.options.verbose) { this.log("Received message from extension:", JSON.stringify(msg, null, 2)) } // Handle different message types switch (msg.type) { case "state": this.handleStateMessage(msg) break case "messageUpdated": // This is the streaming update - handle individual message updates this.handleMessageUpdated(msg) break case "action": this.handleActionMessage(msg) break case "invoke": this.handleInvokeMessage(msg) break default: // Log unknown message types in verbose mode if (this.options.verbose) { this.log("Unknown message type:", msg.type) } } } /** * Output a message to the user (bypasses quiet mode) * Use this for all user-facing output instead of console.log */ private output(...args: unknown[]): void { const text = args.map((arg) => (typeof arg === "string" ? arg : JSON.stringify(arg))).join(" ") process.stdout.write(text + "\n") } /** * Output an error message to the user (bypasses quiet mode) * Use this for all user-facing errors instead of console.error */ private outputError(...args: unknown[]): void { const text = args.map((arg) => (typeof arg === "string" ? arg : JSON.stringify(arg))).join(" ") process.stderr.write(text + "\n") } /** * Handle state update messages from the extension */ private handleStateMessage(msg: Record): void { const state = msg.state as Record | undefined if (!state) return const clineMessages = state.clineMessages as Array> | undefined if (clineMessages && clineMessages.length > 0) { // Track message processing for verbose debug output this.processedMessageCount++ // Verbose: log state update summary if (this.options.verbose) { this.log(`State update #${this.processedMessageCount}: ${clineMessages.length} messages`) } // Process all messages to find new or updated ones for (const message of clineMessages) { if (!message) continue const ts = message.ts as number | undefined const isPartial = message.partial as boolean | undefined const text = message.text as string const type = message.type as string const say = message.say as string | undefined const ask = message.ask as string | undefined if (!ts) continue // Handle "say" type messages if (type === "say" && say) { this.handleSayMessage(ts, say, text, isPartial) } // Handle "ask" type messages else if (type === "ask" && ask) { this.handleAskMessage(ts, ask, text, isPartial) } } } } /** * Handle messageUpdated - individual streaming updates for a single message * This is where real-time streaming happens! */ private handleMessageUpdated(msg: Record): void { const clineMessage = msg.clineMessage as Record | undefined if (!clineMessage) return const ts = clineMessage.ts as number | undefined const isPartial = clineMessage.partial as boolean | undefined const text = clineMessage.text as string const type = clineMessage.type as string const say = clineMessage.say as string | undefined const ask = clineMessage.ask as string | undefined if (!ts) return // Handle "say" type messages if (type === "say" && say) { this.handleSayMessage(ts, say, text, isPartial) } // Handle "ask" type messages else if (type === "ask" && ask) { this.handleAskMessage(ts, ask, text, isPartial) } } /** * Write streaming output directly to stdout (bypassing quiet mode if needed) */ private writeStream(text: string): void { process.stdout.write(text) } /** * Stream content with delta computation - only output new characters */ private streamContent(ts: number, text: string, header: string): void { const previous = this.streamedContent.get(ts) if (!previous) { // First time seeing this message - output header and initial text this.writeStream(`\n${header} `) this.writeStream(text) this.streamedContent.set(ts, { text, headerShown: true }) this.currentlyStreamingTs = ts } else if (text.length > previous.text.length && text.startsWith(previous.text)) { // Text has grown - output delta const delta = text.slice(previous.text.length) this.writeStream(delta) this.streamedContent.set(ts, { text, headerShown: true }) } } /** * Finish streaming a message (add newline) */ private finishStream(ts: number): void { if (this.currentlyStreamingTs === ts) { this.writeStream("\n") this.currentlyStreamingTs = null } } /** * Handle "say" type messages */ private handleSayMessage(ts: number, say: string, text: string, isPartial: boolean | undefined): void { const previousDisplay = this.displayedMessages.get(ts) const alreadyDisplayedComplete = previousDisplay && !previousDisplay.partial switch (say) { case "text": // Skip the initial user prompt echo (first message with no prior messages) if (this.displayedMessages.size === 0 && !previousDisplay) { this.displayedMessages.set(ts, { text, partial: !!isPartial }) break } if (isPartial && text) { // Stream partial content this.streamContent(ts, text, "[assistant]") this.displayedMessages.set(ts, { text, partial: true }) } else if (!isPartial && text && !alreadyDisplayedComplete) { // Message complete - ensure all content is output const streamed = this.streamedContent.get(ts) if (streamed) { // We were streaming - output any remaining delta and finish if (text.length > streamed.text.length && text.startsWith(streamed.text)) { const delta = text.slice(streamed.text.length) this.writeStream(delta) } this.finishStream(ts) } else { // Not streamed yet - output complete message this.output("\n[assistant]", text) } this.displayedMessages.set(ts, { text, partial: false }) this.streamedContent.set(ts, { text, headerShown: true }) } break case "thinking": case "reasoning": // Stream reasoning content in real-time. this.log(`Received ${say} message: partial=${isPartial}, textLength=${text?.length ?? 0}`) if (isPartial && text) { this.streamContent(ts, text, "[reasoning]") this.displayedMessages.set(ts, { text, partial: true }) } else if (!isPartial && text && !alreadyDisplayedComplete) { // Reasoning complete - finish the stream. const streamed = this.streamedContent.get(ts) if (streamed) { if (text.length > streamed.text.length && text.startsWith(streamed.text)) { const delta = text.slice(streamed.text.length) this.writeStream(delta) } this.finishStream(ts) } else { this.output("\n[reasoning]", text) } this.displayedMessages.set(ts, { text, partial: false }) } break case "command_output": // Stream command output in real-time. if (isPartial && text) { this.streamContent(ts, text, "[command output]") this.displayedMessages.set(ts, { text, partial: true }) } else if (!isPartial && text && !alreadyDisplayedComplete) { // Command output complete - finish the stream. const streamed = this.streamedContent.get(ts) if (streamed) { if (text.length > streamed.text.length && text.startsWith(streamed.text)) { const delta = text.slice(streamed.text.length) this.writeStream(delta) } this.finishStream(ts) } else { this.writeStream("\n[command output] ") this.writeStream(text) this.writeStream("\n") } this.displayedMessages.set(ts, { text, partial: false }) } break case "completion_result": // Only process when message is complete (not partial) if (!isPartial && !alreadyDisplayedComplete) { this.output("\n[task complete]", text || "") this.displayedMessages.set(ts, { text: text || "", partial: false }) this.emit("taskComplete") } else if (isPartial) { // Track partial messages but don't output yet - wait for complete message this.displayedMessages.set(ts, { text: text || "", partial: true }) } break case "error": // Display errors to the user but don't terminate the task // Errors like command timeouts are informational - the agent should decide what to do next if (!alreadyDisplayedComplete) { this.outputError("\n[error]", text || "Unknown error") this.displayedMessages.set(ts, { text: text || "", partial: false }) } break case "tool": // Tool usage - show when complete if (text && !alreadyDisplayedComplete) { this.output("\n[tool]", text) this.displayedMessages.set(ts, { text, partial: false }) } break case "api_req_started": // API request started - log in verbose mode if (this.options.verbose) { this.log(`API request started: ts=${ts}`) } break default: // Other say types - show in verbose mode if (this.options.verbose) { this.log(`Unknown say type: ${say}, text length: ${text?.length ?? 0}, partial: ${isPartial}`) if (text && !alreadyDisplayedComplete) { this.output(`\n[${say}]`, text || "") this.displayedMessages.set(ts, { text: text || "", partial: false }) } } } } /** * Handle "ask" type messages - these require user responses * In interactive mode: prompt user for input * In non-interactive mode: auto-approve (handled by extension settings) */ private handleAskMessage(ts: number, ask: string, text: string, isPartial: boolean | undefined): void { // Special handling for command_output - stream it in real-time // This needs to happen before the isPartial skip if (ask === "command_output") { this.handleCommandOutputAsk(ts, text, isPartial) return } // Skip partial messages - wait for the complete ask if (isPartial) { return } // Check if we already handled this ask if (this.pendingAsks.has(ts)) { return } // In non-interactive mode, the extension's auto-approval settings handle everything // We just need to display the action being taken if (this.options.nonInteractive) { this.handleAskMessageNonInteractive(ts, ask, text) return } // Interactive mode - prompt user for input this.handleAskMessageInteractive(ts, ask, text) } /** * Handle ask messages in non-interactive mode * For followup questions: show prompt with 10s timeout, auto-select first option if no input * For everything else: auto-approval handles responses */ private handleAskMessageNonInteractive(ts: number, ask: string, text: string): void { const previousDisplay = this.displayedMessages.get(ts) const alreadyDisplayed = !!previousDisplay switch (ask) { case "followup": if (!alreadyDisplayed) { // In non-interactive mode, still prompt the user but with a 10s timeout // that auto-selects the first option if no input is received this.pendingAsks.add(ts) this.handleFollowupQuestionWithTimeout(ts, text) this.displayedMessages.set(ts, { text, partial: false }) } break case "command": if (!alreadyDisplayed) { this.output("\n[command]", text || "") this.displayedMessages.set(ts, { text: text || "", partial: false }) } break // Note: command_output is handled separately in handleCommandOutputAsk case "tool": if (!alreadyDisplayed && text) { try { const toolInfo = JSON.parse(text) const toolName = toolInfo.tool || "unknown" this.output(`\n[tool] ${toolName}`) // Display all tool parameters (excluding 'tool' which is the name) for (const [key, value] of Object.entries(toolInfo)) { if (key === "tool") continue // Format the value - truncate long strings let displayValue: string if (typeof value === "string") { displayValue = value.length > 200 ? value.substring(0, 200) + "..." : value } else if (typeof value === "object" && value !== null) { const json = JSON.stringify(value) displayValue = json.length > 200 ? json.substring(0, 200) + "..." : json } else { displayValue = String(value) } this.output(` ${key}: ${displayValue}`) } } catch { this.output("\n[tool]", text) } this.displayedMessages.set(ts, { text, partial: false }) } break case "browser_action_launch": if (!alreadyDisplayed) { this.output("\n[browser action]", text || "") this.displayedMessages.set(ts, { text: text || "", partial: false }) } break case "use_mcp_server": if (!alreadyDisplayed) { try { const mcpInfo = JSON.parse(text) this.output(`\n[mcp] ${mcpInfo.server_name || "unknown"}`) } catch { this.output("\n[mcp]", text || "") } this.displayedMessages.set(ts, { text: text || "", partial: false }) } break case "api_req_failed": if (!alreadyDisplayed) { this.output("\n[retrying api Request]") this.displayedMessages.set(ts, { text: text || "", partial: false }) } break case "resume_task": case "resume_completed_task": if (!alreadyDisplayed) { this.output("\n[continuing task]") this.displayedMessages.set(ts, { text: text || "", partial: false }) } break case "completion_result": // Task completion - no action needed break default: if (!alreadyDisplayed && text) { this.output(`\n[${ask}]`, text) this.displayedMessages.set(ts, { text, partial: false }) } } } /** * Handle ask messages in interactive mode - prompt user for input */ private handleAskMessageInteractive(ts: number, ask: string, text: string): void { // Mark this ask as pending so we don't handle it again this.pendingAsks.add(ts) switch (ask) { case "followup": this.handleFollowupQuestion(ts, text) break case "command": this.handleCommandApproval(ts, text) break // Note: command_output is handled separately in handleCommandOutputAsk case "tool": this.handleToolApproval(ts, text) break case "browser_action_launch": this.handleBrowserApproval(ts, text) break case "use_mcp_server": this.handleMcpApproval(ts, text) break case "api_req_failed": this.handleApiFailedRetry(ts, text) break case "resume_task": case "resume_completed_task": this.handleResumeTask(ts, ask, text) break case "completion_result": // Task completion - handled by say message, no response needed this.pendingAsks.delete(ts) break default: // Unknown ask type - try to handle as yes/no this.handleGenericApproval(ts, ask, text) } } /** * Handle followup questions - prompt for text input with suggestions */ private async handleFollowupQuestion(ts: number, text: string): Promise { let question = text // Suggestions are objects with { answer: string, mode?: string } let suggestions: Array<{ answer: string; mode?: string | null }> = [] // Parse the followup question JSON // Format: { question: "...", suggest: [{ answer: "text", mode: "code" }, ...] } try { const data = JSON.parse(text) question = data.question || text suggestions = Array.isArray(data.suggest) ? data.suggest : [] } catch { // Use raw text if not JSON } this.output("\n[question]", question) // Show numbered suggestions if (suggestions.length > 0) { this.output("\nSuggested answers:") suggestions.forEach((suggestion, index) => { const suggestionText = suggestion.answer || String(suggestion) const modeHint = suggestion.mode ? ` (mode: ${suggestion.mode})` : "" this.output(` ${index + 1}. ${suggestionText}${modeHint}`) }) this.output("") } try { const answer = await this.promptForInput( suggestions.length > 0 ? "Enter number (1-" + suggestions.length + ") or type your answer: " : "Your answer: ", ) let responseText = answer.trim() // Check if user entered a number corresponding to a suggestion const num = parseInt(responseText, 10) if (!isNaN(num) && num >= 1 && num <= suggestions.length) { const selectedSuggestion = suggestions[num - 1] if (selectedSuggestion) { responseText = selectedSuggestion.answer || String(selectedSuggestion) this.output(`Selected: ${responseText}`) } } this.sendFollowupResponse(responseText) // Don't delete from pendingAsks - keep it to prevent re-processing // if the extension sends another state update before processing our response } catch { // If prompt fails (e.g., stdin closed), use first suggestion answer or empty const firstSuggestion = suggestions.length > 0 ? suggestions[0] : null const fallback = firstSuggestion?.answer ?? "" this.output(`[Using default: ${fallback || "(empty)"}]`) this.sendFollowupResponse(fallback) } // Note: We intentionally don't delete from pendingAsks here. // The ts stays in the set to prevent duplicate handling if the extension // sends another state update before it processes our response. // The set is cleared when the task completes or the host is disposed. } /** * Handle followup questions with a timeout (for non-interactive mode) * Shows the prompt but auto-selects the first option after 10 seconds * if the user doesn't type anything. Cancels the timeout on any keypress. */ private async handleFollowupQuestionWithTimeout(ts: number, text: string): Promise { let question = text // Suggestions are objects with { answer: string, mode?: string } let suggestions: Array<{ answer: string; mode?: string | null }> = [] // Parse the followup question JSON try { const data = JSON.parse(text) question = data.question || text suggestions = Array.isArray(data.suggest) ? data.suggest : [] } catch { // Use raw text if not JSON } this.output("\n[question]", question) // Show numbered suggestions if (suggestions.length > 0) { this.output("\nSuggested answers:") suggestions.forEach((suggestion, index) => { const suggestionText = suggestion.answer || String(suggestion) const modeHint = suggestion.mode ? ` (mode: ${suggestion.mode})` : "" this.output(` ${index + 1}. ${suggestionText}${modeHint}`) }) this.output("") } // Default to first suggestion or empty string const firstSuggestion = suggestions.length > 0 ? suggestions[0] : null const defaultAnswer = firstSuggestion?.answer ?? "" try { const answer = await this.promptForInputWithTimeout( suggestions.length > 0 ? `Enter number (1-${suggestions.length}) or type your answer (auto-select in 10s): ` : "Your answer (auto-select in 10s): ", 10000, // 10 second timeout defaultAnswer, ) let responseText = answer.trim() // Check if user entered a number corresponding to a suggestion const num = parseInt(responseText, 10) if (!isNaN(num) && num >= 1 && num <= suggestions.length) { const selectedSuggestion = suggestions[num - 1] if (selectedSuggestion) { responseText = selectedSuggestion.answer || String(selectedSuggestion) this.output(`Selected: ${responseText}`) } } this.sendFollowupResponse(responseText) } catch { // If prompt fails, use default this.output(`[Using default: ${defaultAnswer || "(empty)"}]`) this.sendFollowupResponse(defaultAnswer) } } /** * Prompt user for text input with a timeout * Returns defaultValue if timeout expires before any input * Cancels timeout as soon as any character is typed */ private promptForInputWithTimeout(prompt: string, timeoutMs: number, defaultValue: string): Promise { return new Promise((resolve) => { // Temporarily restore console for interactive prompts const wasQuiet = this.options.quiet if (wasQuiet) { this.restoreConsole() } // Put stdin in raw mode to detect individual keypresses const wasRaw = process.stdin.isRaw if (process.stdin.isTTY) { process.stdin.setRawMode(true) } process.stdin.resume() let inputBuffer = "" let timeoutCancelled = false let resolved = false // Set up the timeout const timeout = setTimeout(() => { if (!resolved) { resolved = true cleanup() this.output(`\n[Timeout - using default: ${defaultValue || "(empty)"}]`) resolve(defaultValue) } }, timeoutMs) // Show the prompt process.stdout.write(prompt) // Cleanup function const cleanup = () => { clearTimeout(timeout) process.stdin.removeListener("data", onData) if (process.stdin.isTTY && wasRaw !== undefined) { process.stdin.setRawMode(wasRaw) } process.stdin.pause() if (wasQuiet) { this.setupQuietMode() } } // Handle keypress data const onData = (data: Buffer) => { const char = data.toString() // Check for Ctrl+C if (char === "\x03") { cleanup() resolved = true this.output("\n[cancelled]") resolve(defaultValue) return } // Cancel timeout on first character if (!timeoutCancelled) { timeoutCancelled = true clearTimeout(timeout) } // Handle Enter key if (char === "\r" || char === "\n") { if (!resolved) { resolved = true cleanup() process.stdout.write("\n") resolve(inputBuffer) } return } // Handle Backspace if (char === "\x7f" || char === "\b") { if (inputBuffer.length > 0) { inputBuffer = inputBuffer.slice(0, -1) // Erase character on screen: move back, write space, move back process.stdout.write("\b \b") } return } // Regular character - add to buffer and echo inputBuffer += char process.stdout.write(char) } process.stdin.on("data", onData) }) } /** * Handle command execution approval */ private async handleCommandApproval(ts: number, text: string): Promise { this.output("\n[command request]") this.output(` Command: ${text || "(no command specified)"}`) try { const approved = await this.promptForYesNo("Execute this command? (y/n): ") this.sendApprovalResponse(approved) } catch { this.output("[Defaulting to: no]") this.sendApprovalResponse(false) } // Note: Don't delete from pendingAsks - see handleFollowupQuestion comment } /** * Handle tool execution approval */ private async handleToolApproval(ts: number, text: string): Promise { let toolName = "unknown" let toolInfo: Record = {} try { toolInfo = JSON.parse(text) as Record toolName = (toolInfo.tool as string) || "unknown" } catch { // Use raw text if not JSON } this.output(`\n[Tool Request] ${toolName}`) // Display all tool parameters (excluding 'tool' which is the name) for (const [key, value] of Object.entries(toolInfo)) { if (key === "tool") continue // Format the value - truncate long strings let displayValue: string if (typeof value === "string") { displayValue = value.length > 200 ? value.substring(0, 200) + "..." : value } else if (typeof value === "object" && value !== null) { const json = JSON.stringify(value) displayValue = json.length > 200 ? json.substring(0, 200) + "..." : json } else { displayValue = String(value) } this.output(` ${key}: ${displayValue}`) } try { const approved = await this.promptForYesNo("Approve this action? (y/n): ") this.sendApprovalResponse(approved) } catch { this.output("[Defaulting to: no]") this.sendApprovalResponse(false) } // Note: Don't delete from pendingAsks - see handleFollowupQuestion comment } /** * Handle browser action approval */ private async handleBrowserApproval(ts: number, text: string): Promise { this.output("\n[browser action request]") if (text) this.output(` Action: ${text}`) try { const approved = await this.promptForYesNo("Allow browser action? (y/n): ") this.sendApprovalResponse(approved) } catch { this.output("[Defaulting to: no]") this.sendApprovalResponse(false) } // Note: Don't delete from pendingAsks - see handleFollowupQuestion comment } /** * Handle MCP server access approval */ private async handleMcpApproval(ts: number, text: string): Promise { let serverName = "unknown" let toolName = "" let resourceUri = "" try { const mcpInfo = JSON.parse(text) serverName = mcpInfo.server_name || "unknown" if (mcpInfo.type === "use_mcp_tool") { toolName = mcpInfo.tool_name || "" } else if (mcpInfo.type === "access_mcp_resource") { resourceUri = mcpInfo.uri || "" } } catch { // Use raw text if not JSON } this.output("\n[mcp request]") this.output(` Server: ${serverName}`) if (toolName) this.output(` Tool: ${toolName}`) if (resourceUri) this.output(` Resource: ${resourceUri}`) try { const approved = await this.promptForYesNo("Allow MCP access? (y/n): ") this.sendApprovalResponse(approved) } catch { this.output("[Defaulting to: no]") this.sendApprovalResponse(false) } // Note: Don't delete from pendingAsks - see handleFollowupQuestion comment } /** * Handle API request failed - retry prompt */ private async handleApiFailedRetry(ts: number, text: string): Promise { this.output("\n[api request failed]") this.output(` Error: ${text || "Unknown error"}`) try { const retry = await this.promptForYesNo("Retry the request? (y/n): ") this.sendApprovalResponse(retry) } catch { this.output("[Defaulting to: no]") this.sendApprovalResponse(false) } // Note: Don't delete from pendingAsks - see handleFollowupQuestion comment } /** * Handle task resume prompt */ private async handleResumeTask(ts: number, ask: string, text: string): Promise { const isCompleted = ask === "resume_completed_task" this.output(`\n[Resume ${isCompleted ? "Completed " : ""}Task]`) if (text) this.output(` ${text}`) try { const resume = await this.promptForYesNo("Continue with this task? (y/n): ") this.sendApprovalResponse(resume) } catch { this.output("[Defaulting to: no]") this.sendApprovalResponse(false) } // Note: Don't delete from pendingAsks - see handleFollowupQuestion comment } /** * Handle generic approval prompts for unknown ask types */ private async handleGenericApproval(ts: number, ask: string, text: string): Promise { this.output(`\n[${ask}]`) if (text) this.output(` ${text}`) try { const approved = await this.promptForYesNo("Approve? (y/n): ") this.sendApprovalResponse(approved) } catch { this.output("[Defaulting to: no]") this.sendApprovalResponse(false) } // Note: Don't delete from pendingAsks - see handleFollowupQuestion comment } /** * Handle command_output ask messages - stream the output in real-time * This is called for both partial (streaming) and complete messages */ private handleCommandOutputAsk(ts: number, text: string, isPartial: boolean | undefined): void { const previousDisplay = this.displayedMessages.get(ts) const alreadyDisplayedComplete = previousDisplay && !previousDisplay.partial // Stream partial content if (isPartial && text) { this.streamContent(ts, text, "[command output]") this.displayedMessages.set(ts, { text, partial: true }) } else if (!isPartial) { // Message complete - output any remaining content and send approval if (text && !alreadyDisplayedComplete) { const streamed = this.streamedContent.get(ts) if (streamed) { // We were streaming - output any remaining delta and finish. if (text.length > streamed.text.length && text.startsWith(streamed.text)) { const delta = text.slice(streamed.text.length) this.writeStream(delta) } this.finishStream(ts) } else { this.writeStream("\n[command output] ") this.writeStream(text) this.writeStream("\n") } this.displayedMessages.set(ts, { text, partial: false }) this.streamedContent.set(ts, { text, headerShown: true }) } // Send approval response (only once per ts). if (!this.pendingAsks.has(ts)) { this.pendingAsks.add(ts) this.sendApprovalResponse(true) } } } /** * Prompt user for text input via readline */ private promptForInput(prompt: string): Promise { return new Promise((resolve, reject) => { // Temporarily restore console for interactive prompts const wasQuiet = this.options.quiet if (wasQuiet) { this.restoreConsole() } const rl = readline.createInterface({ input: process.stdin, output: process.stdout, }) rl.question(prompt, (answer) => { rl.close() // Restore quiet mode if it was enabled if (wasQuiet) { this.setupQuietMode() } resolve(answer) }) // Handle stdin close (e.g., piped input ended) rl.on("close", () => { if (wasQuiet) { this.setupQuietMode() } }) // Handle errors rl.on("error", (err) => { rl.close() if (wasQuiet) { this.setupQuietMode() } reject(err) }) }) } /** * Prompt user for yes/no input */ private async promptForYesNo(prompt: string): Promise { const answer = await this.promptForInput(prompt) const normalized = answer.trim().toLowerCase() // Accept y, yes, Y, Yes, YES, etc. return normalized === "y" || normalized === "yes" } /** * Send a followup response (text answer) to the extension */ private sendFollowupResponse(text: string): void { this.sendToExtension({ type: "askResponse", askResponse: "messageResponse", text, }) } /** * Send an approval response (yes/no) to the extension */ private sendApprovalResponse(approved: boolean): void { this.sendToExtension({ type: "askResponse", askResponse: approved ? "yesButtonClicked" : "noButtonClicked", }) } /** * Handle action messages */ private handleActionMessage(msg: Record): void { const action = msg.action as string if (this.options.verbose) { this.log("Action:", action) } } /** * Handle invoke messages */ private handleInvokeMessage(msg: Record): void { const invoke = msg.invoke as string if (this.options.verbose) { this.log("Invoke:", invoke) } } /** * Wait for the task to complete */ private waitForCompletion(): Promise { return new Promise((resolve, reject) => { const completeHandler = () => { cleanup() resolve() } const errorHandler = (error: string) => { cleanup() reject(new Error(error)) } const cleanup = () => { this.off("taskComplete", completeHandler) this.off("taskError", errorHandler) } this.once("taskComplete", completeHandler) this.once("taskError", errorHandler) // Set a timeout (10 minutes by default) const timeout = setTimeout( () => { cleanup() reject(new Error("Task timed out")) }, 10 * 60 * 1000, ) // Clear timeout on completion this.once("taskComplete", () => clearTimeout(timeout)) this.once("taskError", () => clearTimeout(timeout)) }) } /** * Clean up resources */ async dispose(): Promise { this.log("Disposing extension host...") // Clear pending asks this.pendingAsks.clear() // Close readline interface if open if (this.rl) { this.rl.close() this.rl = null } // Remove message listener if (this.messageListener) { this.off("extensionWebviewMessage", this.messageListener) this.messageListener = null } // Deactivate extension if it has a deactivate function if (this.extensionModule?.deactivate) { try { await this.extensionModule.deactivate() } catch (error) { this.log("Error deactivating extension:", error) } } // Clear references this.vscode = null this.extensionModule = null this.extensionAPI = null this.webviewProviders.clear() // Clear globals delete (global as Record).vscode delete (global as Record).__extensionHost // Restore console if it was suppressed this.restoreConsole() this.log("Extension host disposed") } }