|
|
@@ -1,1663 +0,0 @@
|
|
|
-/**
|
|
|
- * 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<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: 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<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 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<void> {
|
|
|
- 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<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
|
|
|
-
|
|
|
- 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<string, unknown>)
|
|
|
- }
|
|
|
-
|
|
|
- /**
|
|
|
- * 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<void> {
|
|
|
- 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<void>((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<void>((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<void>((resolve) => setTimeout(resolve, 100))
|
|
|
- }
|
|
|
-
|
|
|
- if (this.options.apiKey) {
|
|
|
- this.sendToExtension({ type: "updateSettings", updatedSettings: this.buildApiConfiguration() })
|
|
|
- await new Promise<void>((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<string, unknown>
|
|
|
-
|
|
|
- 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<string, unknown>): void {
|
|
|
- const state = msg.state as Record<string, unknown> | undefined
|
|
|
- if (!state) return
|
|
|
-
|
|
|
- const clineMessages = state.clineMessages as Array<Record<string, unknown>> | 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<string, unknown>): void {
|
|
|
- const clineMessage = msg.clineMessage as Record<string, unknown> | 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<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 10 seconds
|
|
|
- * if the user doesn't type anything. Cancels the timeout on any keypress.
|
|
|
- */
|
|
|
- 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 ?? ""
|
|
|
-
|
|
|
- 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<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"
|
|
|
- } 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<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)
|
|
|
- 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 {
|
|
|
- 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<string, unknown>): void {
|
|
|
- const action = msg.action as string
|
|
|
-
|
|
|
- if (this.options.verbose) {
|
|
|
- this.log("Action:", action)
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- /**
|
|
|
- * Handle invoke messages
|
|
|
- */
|
|
|
- private handleInvokeMessage(msg: Record<string, unknown>): void {
|
|
|
- const invoke = msg.invoke as string
|
|
|
-
|
|
|
- if (this.options.verbose) {
|
|
|
- this.log("Invoke:", invoke)
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- /**
|
|
|
- * 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)
|
|
|
-
|
|
|
- // 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<void> {
|
|
|
- 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<string, unknown>).vscode
|
|
|
- delete (global as Record<string, unknown>).__extensionHost
|
|
|
-
|
|
|
- // Restore console if it was suppressed
|
|
|
- this.restoreConsole()
|
|
|
-
|
|
|
- this.log("Extension host disposed")
|
|
|
- }
|
|
|
-}
|