Roo-Code/apps/cli/src/extension-host.ts

1829 lines
52 KiB
TypeScript

/**
* 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 os from "os"
import readline from "readline"
import {
ProviderName,
ReasoningEffortExtended,
RooCodeSettings,
ExtensionMessage,
WebviewMessage,
} from "@roo-code/types"
import { createVSCodeAPI, setRuntimeConfigValues } from "@roo-code/vscode-shim"
import { debugLog, DebugLogger } from "@roo-code/core/debug-log"
import { FOLLOWUP_TIMEOUT_SECONDS } from "./constants.js"
import { toolInspectorLog, clearToolInspectorLog } from "./utils/toolInspectorLogger.js"
// Pre-configured logger for CLI message activity debugging
const cliLogger = new DebugLogger("CLI")
// 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
/**
* When true, completely disables all direct stdout/stderr output.
* Use this when running in TUI mode where Ink controls the terminal.
*/
disableOutput?: boolean
/**
* When true, uses a temporary storage directory that is cleaned up on exit.
* No state persists between runs - all configuration from CLI flags/environment.
*/
ephemeral?: boolean
}
interface ExtensionModule {
activate: (context: unknown) => Promise<unknown>
deactivate?: () => Promise<void>
}
/**
* Local interface for webview provider (matches VSCode API)
*/
interface WebviewViewProvider {
resolveWebviewView?(webviewView: unknown, context: unknown, token: unknown): void | Promise<void>
}
export class ExtensionHost extends EventEmitter {
private vscode: ReturnType<typeof createVSCodeAPI> | null = null
private extensionModule: ExtensionModule | null = null
private extensionAPI: unknown = null
private webviewProviders: Map<string, WebviewViewProvider> = new Map()
private options: ExtensionHostOptions
private isWebviewReady = false
private pendingMessages: unknown[] = []
private messageListener: ((message: ExtensionMessage) => 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<number> = 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<number, { text: string; partial: boolean }> = new Map()
// Track streamed content by ts for delta computation
private streamedContent: Map<number, { text: string; headerShown: boolean }> = new Map()
// Track if we're currently streaming a message (to manage newlines)
private currentlyStreamingTs: number | null = null
// Track the current mode to detect mode changes
private currentMode: string | null = null
// Track ephemeral storage directory for cleanup
private ephemeralStorageDir: string | null = null
constructor(options: ExtensionHostOptions) {
super()
this.options = options
this.currentMode = options.mode || null
}
/**
* Write debug log entry to ~/.roo/cli-debug.log
* This avoids console output which breaks the TUI.
*/
private log(message: string, data?: unknown): void {
debugLog(message, data)
}
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
}
}
/**
* Create a unique ephemeral storage directory in the system temp folder.
* This directory will be cleaned up when dispose() is called.
*/
private async createEphemeralStorageDir(): Promise<string> {
const uniqueId = `${Date.now()}-${Math.random().toString(36).slice(2, 11)}`
const tmpDir = path.join(os.tmpdir(), `roo-cli-${uniqueId}`)
await fs.promises.mkdir(tmpDir, { recursive: true })
return tmpDir
}
async activate(): Promise<void> {
// 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}`)
}
// Create ephemeral storage directory if ephemeral mode is enabled
let storageDir: string | undefined
if (this.options.ephemeral) {
storageDir = await this.createEphemeralStorageDir()
this.ephemeralStorageDir = storageDir
}
// 1. Create VSCode API mock
this.vscode = createVSCodeAPI(
this.options.extensionPath,
this.options.workspacePath,
undefined, // identity
{ appRoot: CLI_PACKAGE_ROOT, storageDir }, // options - point appRoot to CLI package for ripgrep, custom storageDir for ephemeral mode
)
// 2. Set global vscode reference for the extension
;(global as Record<string, unknown>).vscode = this.vscode
// 3. Set up __extensionHost global for webview registration
// This is used by WindowAPI.registerWebviewViewProvider
;(global as Record<string, unknown>).__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
// 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
// 7. Activate extension
try {
this.extensionAPI = await this.extensionModule.activate(this.vscode.context)
} catch (error) {
throw new Error(`Failed to activate extension: ${error instanceof Error ? error.message : String(error)}`)
}
// 8. Set up message listener to capture state updates (including mode changes)
// This needs to happen early so mode switches before a task runs are captured
this.messageListener = (message: ExtensionMessage) => this.handleExtensionMessage(message)
this.on("extensionWebviewMessage", this.messageListener)
}
/**
* Called by WindowAPI.registerWebviewViewProvider
* This is triggered when the extension registers its sidebar webview provider
*/
registerWebviewProvider(viewId: string, provider: WebviewViewProvider): void {
this.webviewProviders.set(viewId, provider)
}
/**
* Called when a webview provider is disposed
*/
unregisterWebviewProvider(viewId: string): void {
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.isWebviewReady = true
this.emit("webviewReady")
this.flushPendingMessages()
}
/**
* Send any messages that were queued before the webview was ready
*/
private flushPendingMessages(): void {
if (this.pendingMessages.length > 0) {
for (const message of this.pendingMessages) {
this.emit("webviewMessage", message)
}
this.pendingMessages = []
}
}
/**
* Send a message to the extension (simulating webview -> extension communication).
*/
sendToExtension(message: WebviewMessage): void {
if (!this.isWebviewReady) {
this.pendingMessages.push(message)
return
}
this.emit("webviewMessage", message)
}
private applyRuntimeSettings(settings: RooCodeSettings): void {
// Use currentMode (updated by state messages from mode switches) if set,
// otherwise fall back to initial mode from CLI options
const activeMode = this.currentMode || this.options.mode
if (activeMode) {
settings.mode = activeMode
}
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<string, unknown>)
}
/**
* Get API key from environment variable based on provider
*/
private getApiKeyFromEnv(provider: string): string | undefined {
const envVarMap: Record<string, string> = {
anthropic: "ANTHROPIC_API_KEY",
openai: "OPENAI_API_KEY",
"openai-native": "OPENAI_API_KEY",
openrouter: "OPENROUTER_API_KEY",
google: "GOOGLE_API_KEY",
gemini: "GOOGLE_API_KEY",
bedrock: "AWS_ACCESS_KEY_ID",
ollama: "OLLAMA_API_KEY",
mistral: "MISTRAL_API_KEY",
deepseek: "DEEPSEEK_API_KEY",
xai: "XAI_API_KEY",
groq: "GROQ_API_KEY",
}
const envVar = envVarMap[provider.toLowerCase()] || `${provider.toUpperCase().replace(/-/g, "_")}_API_KEY`
return process.env[envVar]
}
/**
* Build the provider-specific API configuration
* Each provider uses different field names for API key and model
* Falls back to environment variables for API keys when not explicitly passed
*/
private buildApiConfiguration(): RooCodeSettings {
const provider = this.options.apiProvider || "anthropic"
// Try explicit API key first, then fall back to environment variable
const apiKey = this.options.apiKey || this.getApiKeyFromEnv(provider)
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
}
async runTask(prompt: string): Promise<void> {
// Clear tool inspector log for fresh session (non-TUI mode)
clearToolInspectorLog()
toolInspectorLog("session:start:non-tui", {
timestamp: new Date().toISOString(),
mode: this.options.mode,
nonInteractive: this.options.nonInteractive,
})
cliLogger.debug("runTask:start", {
promptPreview: prompt?.substring(0, 100),
isWebviewReady: this.isWebviewReady,
})
if (!this.isWebviewReady) {
await new Promise<void>((resolve) => {
this.once("webviewReady", resolve)
})
}
// Message listener is now set up in activate() to capture mode changes before first task
const baseSettings: RooCodeSettings = {
commandExecutionTimeout: 30,
enableCheckpoints: false, // Checkpoints disabled until CLI UI is implemented.
...this.buildApiConfiguration(),
}
const settings: RooCodeSettings = this.options.nonInteractive
? {
autoApprovalEnabled: true,
alwaysAllowReadOnly: true,
alwaysAllowReadOnlyOutsideWorkspace: true,
alwaysAllowWrite: true,
alwaysAllowWriteOutsideWorkspace: true,
alwaysAllowWriteProtected: false,
alwaysAllowBrowser: true,
alwaysAllowMcp: true,
alwaysAllowModeSwitch: true,
alwaysAllowSubtasks: true,
alwaysAllowExecute: true,
allowedCommands: ["*"],
...baseSettings,
}
: {
autoApprovalEnabled: false,
...baseSettings,
}
this.applyRuntimeSettings(settings)
this.sendToExtension({ type: "updateSettings", updatedSettings: settings })
await new Promise<void>((resolve) => setTimeout(resolve, 100))
this.sendToExtension({ type: "newTask", text: prompt })
await this.waitForCompletion()
}
private handleExtensionMessage(msg: ExtensionMessage): void {
// Log all incoming extension messages for debugging
cliLogger.debug("handleExtensionMessage", {
type: msg.type,
hasClineMessage: msg.clineMessage?.ts ? true : false,
hasState: msg.state ? true : false,
})
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 "modes":
// Forward modes list to the TUI via a dedicated event.
// DO NOT emit to "extensionWebviewMessage" - that would create an infinite loop
// since messageListener listens on that event and calls this function.
this.emit("modesUpdated", msg)
break
}
}
/**
* 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 {
// In TUI mode, don't write directly to stdout - let Ink handle rendering
if (this.options.disableOutput) {
return
}
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 {
// In TUI mode, don't write directly to stderr - let Ink handle rendering
if (this.options.disableOutput) {
return
}
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: ExtensionMessage): void {
const state = msg.state
if (!state) {
return
}
// Track current mode for mode switch detection (in tool execution).
const newMode = state.mode
if (newMode) {
this.currentMode = newMode
}
// Log state summary for debugging
const clineMessages = state.clineMessages
cliLogger.debug("handleStateMessage", {
mode: newMode,
messageCount: clineMessages?.length ?? 0,
pendingAsksCount: this.pendingAsks.size,
pendingAsks: Array.from(this.pendingAsks),
isWebviewReady: this.isWebviewReady,
})
if (clineMessages && clineMessages.length > 0) {
// Log summary of recent messages
const recentMessages = clineMessages.slice(-5).map((m) => ({
ts: m?.ts,
type: m?.type,
say: m?.say,
ask: m?.ask,
partial: m?.partial,
textPreview: m?.text?.substring(0, 100),
}))
cliLogger.debug("handleStateMessage:recentMessages", recentMessages)
for (const message of clineMessages) {
if (!message) {
continue
}
const ts = message.ts
const isPartial = message.partial
const text = message.text
const type = message.type
const say = message.say
const ask = message.ask
if (!ts) {
continue
}
if (type === "say" && say && typeof text === "string") {
this.handleSayMessage(ts, say, text, isPartial)
} else if (type === "ask" && ask && typeof text === "string") {
this.handleAskMessage(ts, ask, text, isPartial)
}
}
}
}
/**
* Handle messageUpdated - individual streaming updates for a single message
* This is where real-time streaming happens!
*/
private handleMessageUpdated({ clineMessage: msg }: ExtensionMessage): void {
// Log message updates for debugging stuck states
if (msg?.ask) {
cliLogger.debug("messageUpdated:ask", {
ts: msg.ts,
ask: msg.ask,
partial: msg.partial,
textPreview: msg.text?.substring(0, 100),
pendingAsksCount: this.pendingAsks.size,
hasPendingForTs: this.pendingAsks.has(msg.ts),
})
} else if (msg?.say) {
cliLogger.debug("messageUpdated:say", {
ts: msg.ts,
say: msg.say,
partial: msg.partial,
textPreview: msg.text?.substring(0, 100),
})
} else if (msg) {
cliLogger.debug("messageUpdated:other", {
ts: msg.ts,
type: msg.type,
textPreview: msg.text?.substring(0, 100),
})
}
if (msg?.type === "say" && msg.say && typeof msg.text === "string") {
this.handleSayMessage(msg.ts, msg.say, msg.text, msg.partial)
} else if (msg?.type === "ask" && msg.ask && typeof msg.text === "string") {
this.handleAskMessage(msg.ts, msg.ask, msg.text, msg.partial)
}
}
/**
* Write streaming output directly to stdout (bypassing quiet mode if needed)
*/
private writeStream(text: string): void {
// In TUI mode, don't write directly to stdout - let Ink handle rendering
if (this.options.disableOutput) {
return
}
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.
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":
if (text && !alreadyDisplayedComplete) {
this.output("\n[tool]", text)
this.displayedMessages.set(ts, { text, partial: false })
}
break
case "api_req_started":
break
default:
// NO-OP
}
}
/**
* 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)) {
cliLogger.debug("handleAskMessage:skipped", {
ts,
ask,
reason: "alreadyPending",
pendingAsksCount: this.pendingAsks.size,
})
return
}
// IMPORTANT: Check disableOutput FIRST, before nonInteractive!
// In TUI mode (disableOutput=true), don't handle asks here - let the TUI handle them.
// The TUI listens to the same extensionWebviewMessage events and renders its own UI.
// If we don't check this first, the non-interactive handler would capture stdin input
// that's meant for the TUI (e.g., arrow keys show as "[B[B[B[B" escape codes).
if (this.options.disableOutput) {
cliLogger.debug("handleAskMessage:skipped", {
ts,
ask,
reason: "disableOutput (TUI mode)",
})
return
}
// Log that we're processing a new ask
cliLogger.debug("handleAskMessage:processing", {
ts,
ask,
textPreview: text?.substring(0, 100),
mode: this.options.nonInteractive ? "nonInteractive" : "interactive",
pendingAsksCount: this.pendingAsks.size,
})
// In non-interactive mode (without TUI), the extension's auto-approval settings handle
// most things, but followup questions need special handling with timeout.
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)
cliLogger.debug("pendingAsks:added (nonInteractive)", {
ts,
ask: "followup",
pendingAsksCount: this.pendingAsks.size,
waitingForUserInput: true,
})
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) {
let toolInfo
let toolName
try {
toolInfo = JSON.parse(text)
toolName = toolInfo.tool || "unknown"
// Log tool payload for inspection (non-interactive plaintext mode)
toolInspectorLog("ask:tool:plaintext:nonInteractive", {
ts,
rawText: text,
parsedToolInfo: toolInfo,
})
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)
cliLogger.debug("pendingAsks:added", {
ts,
ask,
pendingAsksCount: this.pendingAsks.size,
allPendingAsks: Array.from(this.pendingAsks),
waitingForUserInput: true,
})
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<void> {
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 the timeout
* if the user doesn't type anything. Cancels the timeout on any keypress.
* Default timeout is 60 seconds, configurable via followupAutoApproveTimeoutMs.
*/
private async handleFollowupQuestionWithTimeout(ts: number, text: string): Promise<void> {
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 ?? ""
const timeoutMs = FOLLOWUP_TIMEOUT_SECONDS * 1000
try {
const answer = await this.promptForInputWithTimeout(
suggestions.length > 0
? `Enter number (1-${suggestions.length}) or type your answer (auto-select in ${Math.round(timeoutMs / 1000)}s): `
: `Your answer (auto-select in ${Math.round(timeoutMs / 1000)}s): `,
timeoutMs,
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 (_error) {
// 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<string> {
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<void> {
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<void> {
let toolName = "unknown"
let toolInfo: Record<string, unknown> = {}
try {
toolInfo = JSON.parse(text) as Record<string, unknown>
toolName = (toolInfo.tool as string) || "unknown"
// Log tool payload for inspection (interactive plaintext mode)
toolInspectorLog("ask:tool:plaintext:interactive", {
ts,
rawText: text,
parsedToolInfo: toolInfo,
})
} 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)
// Note: Mode switch detection and API config re-application is handled in handleStateMessage.
// This works for both interactive and non-interactive (auto-approved) modes.
} 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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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)
cliLogger.debug("pendingAsks:added (command_output)", {
ts,
ask: "command_output",
pendingAsksCount: this.pendingAsks.size,
autoApproved: true,
})
this.sendApprovalResponse(true)
}
}
}
/**
* Prompt user for text input via readline
*/
private promptForInput(prompt: string): Promise<string> {
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<boolean> {
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 {
cliLogger.debug("sendFollowupResponse", {
textPreview: text?.substring(0, 100),
pendingAsksCount: this.pendingAsks.size,
})
this.sendToExtension({ type: "askResponse", askResponse: "messageResponse", text })
}
/**
* Send an approval response (yes/no) to the extension
*/
private sendApprovalResponse(approved: boolean): void {
cliLogger.debug("sendApprovalResponse", {
approved,
pendingAsksCount: this.pendingAsks.size,
})
this.sendToExtension({
type: "askResponse",
askResponse: approved ? "yesButtonClicked" : "noButtonClicked",
})
}
/**
* Wait for the task to complete
*/
private waitForCompletion(): Promise<void> {
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)
})
}
/**
* Clean up resources
*/
async dispose(): Promise<void> {
// 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) {
// NO-OP
}
}
// Clear references.
this.vscode = null
this.extensionModule = null
this.extensionAPI = null
this.webviewProviders.clear()
// Clear globals.
delete (global as Record<string, unknown>).vscode
delete (global as Record<string, unknown>).__extensionHost
// Restore console if it was suppressed.
this.restoreConsole()
// Clean up ephemeral storage directory if it exists.
if (this.ephemeralStorageDir) {
try {
await fs.promises.rm(this.ephemeralStorageDir, { recursive: true, force: true })
this.ephemeralStorageDir = null
} catch (_error) {
// NO-OP
}
}
}
}