| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614 |
- /**
- * ExtensionHost - Loads and runs the Roo Code extension in CLI mode
- *
- * This class is a thin coordination layer responsible for:
- * 1. Creating the vscode-shim mock
- * 2. Loading the extension bundle via require()
- * 3. Activating the extension
- * 4. Wiring up managers for output, prompting, and ask handling
- */
- import { createRequire } from "module"
- import path from "path"
- import { fileURLToPath } from "url"
- import fs from "fs"
- import { EventEmitter } from "events"
- import pWaitFor from "p-wait-for"
- import type {
- ClineMessage,
- ExtensionMessage,
- ReasoningEffortExtended,
- RooCodeSettings,
- WebviewMessage,
- } from "@roo-code/types"
- import { createVSCodeAPI, IExtensionHost, ExtensionHostEventMap, setRuntimeConfigValues } from "@roo-code/vscode-shim"
- import { DebugLogger, setDebugLogEnabled } from "@roo-code/core/cli"
- import { DEFAULT_FLAGS, type SupportedProvider } from "@/types/index.js"
- import type { User } from "@/lib/sdk/index.js"
- import { getProviderSettings } from "@/lib/utils/provider.js"
- import { createEphemeralStorageDir } from "@/lib/storage/index.js"
- import type { WaitingForInputEvent, TaskCompletedEvent } from "./events.js"
- import type { AgentStateInfo } from "./agent-state.js"
- import { ExtensionClient } from "./extension-client.js"
- import { OutputManager } from "./output-manager.js"
- import { PromptManager } from "./prompt-manager.js"
- import { AskDispatcher } from "./ask-dispatcher.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 running from a release tarball, ROO_CLI_ROOT is set by the wrapper script.
- // In development, we fall back to finding the CLI package root by walking up to package.json.
- // This works whether running from dist/ (bundled) or src/agent/ (tsx dev).
- const __dirname = path.dirname(fileURLToPath(import.meta.url))
- function findCliPackageRoot(): string {
- let dir = __dirname
- while (dir !== path.dirname(dir)) {
- if (fs.existsSync(path.join(dir, "package.json"))) {
- return dir
- }
- dir = path.dirname(dir)
- }
- return path.resolve(__dirname, "..")
- }
- const CLI_PACKAGE_ROOT = process.env.ROO_CLI_ROOT || findCliPackageRoot()
- export interface ExtensionHostOptions {
- mode: string
- reasoningEffort?: ReasoningEffortExtended | "unspecified" | "disabled"
- consecutiveMistakeLimit?: number
- user: User | null
- provider: SupportedProvider
- apiKey?: string
- model: string
- workspacePath: string
- extensionPath: string
- nonInteractive?: boolean
- /**
- * When true, uses a temporary storage directory that is cleaned up on exit.
- */
- ephemeral: boolean
- debug: boolean
- exitOnComplete: boolean
- terminalShell?: string
- /**
- * When true, exit the process on API request errors instead of retrying.
- */
- exitOnError?: 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, don't suppress node warnings and console output since we're
- * running in an integration test and we want to see the output.
- */
- integrationTest?: boolean
- }
- interface ExtensionModule {
- activate: (context: unknown) => Promise<unknown>
- deactivate?: () => Promise<void>
- }
- interface WebviewViewProvider {
- resolveWebviewView?(webviewView: unknown, context: unknown, token: unknown): void | Promise<void>
- }
- export interface ExtensionHostInterface extends IExtensionHost<ExtensionHostEventMap> {
- client: ExtensionClient
- activate(): Promise<void>
- runTask(prompt: string, taskId?: string, configuration?: RooCodeSettings, images?: string[]): Promise<void>
- resumeTask(taskId: string): Promise<void>
- sendToExtension(message: WebviewMessage): void
- dispose(): Promise<void>
- }
- export class ExtensionHost extends EventEmitter implements ExtensionHostInterface {
- // Extension lifecycle.
- private vscode: ReturnType<typeof createVSCodeAPI> | null = null
- private extensionModule: ExtensionModule | null = null
- private extensionAPI: unknown = null
- private options: ExtensionHostOptions
- private isReady = false
- private messageListener: ((message: ExtensionMessage) => void) | null = null
- private initialSettings: RooCodeSettings
- // Console suppression.
- 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
- // Ephemeral storage.
- private ephemeralStorageDir: string | null = null
- private previousCliRuntimeEnv: string | undefined
- // ==========================================================================
- // Managers - These do all the heavy lifting
- // ==========================================================================
- /**
- * ExtensionClient: Single source of truth for agent loop state.
- * Handles message processing and state detection.
- */
- public readonly client: ExtensionClient
- /**
- * OutputManager: Handles all CLI output and streaming.
- * Uses Observable pattern internally for stream tracking.
- */
- private outputManager: OutputManager
- /**
- * PromptManager: Handles all user input collection.
- * Provides readline, yes/no, and timed prompts.
- */
- private promptManager: PromptManager
- /**
- * AskDispatcher: Routes asks to appropriate handlers.
- * Uses type guards (isIdleAsk, isInteractiveAsk, etc.) from client module.
- */
- private askDispatcher: AskDispatcher
- // ==========================================================================
- // Constructor
- // ==========================================================================
- constructor(options: ExtensionHostOptions) {
- super()
- this.options = options
- // Mark this process as CLI runtime so extension code can apply
- // CLI-specific behavior without affecting VS Code desktop usage.
- this.previousCliRuntimeEnv = process.env.ROO_CLI_RUNTIME
- process.env.ROO_CLI_RUNTIME = "1"
- // Enable file-based debug logging only when --debug is passed.
- if (options.debug) {
- setDebugLogEnabled(true)
- }
- // Set up quiet mode early, before any extension code runs.
- // This suppresses console output from the extension during load.
- this.setupQuietMode()
- // Initialize client - single source of truth for agent state (including mode).
- this.client = new ExtensionClient({
- sendMessage: (msg) => this.sendToExtension(msg),
- debug: options.debug, // Enable debug logging in the client.
- })
- // Initialize output manager.
- this.outputManager = new OutputManager({ disabled: options.disableOutput })
- // Initialize prompt manager with console mode callbacks.
- this.promptManager = new PromptManager({
- onBeforePrompt: () => this.restoreConsole(),
- onAfterPrompt: () => this.setupQuietMode(),
- })
- // Initialize ask dispatcher.
- this.askDispatcher = new AskDispatcher({
- outputManager: this.outputManager,
- promptManager: this.promptManager,
- sendMessage: (msg) => this.sendToExtension(msg),
- nonInteractive: options.nonInteractive,
- exitOnError: options.exitOnError,
- disabled: options.disableOutput, // TUI mode handles asks directly.
- })
- // Wire up client events.
- this.setupClientEventHandlers()
- // Populate initial settings.
- const baseSettings: RooCodeSettings = {
- mode: this.options.mode,
- consecutiveMistakeLimit: this.options.consecutiveMistakeLimit ?? DEFAULT_FLAGS.consecutiveMistakeLimit,
- commandExecutionTimeout: 300,
- enableCheckpoints: false,
- experiments: {
- customTools: true,
- },
- ...getProviderSettings(this.options.provider, this.options.apiKey, this.options.model),
- }
- this.initialSettings = this.options.nonInteractive
- ? {
- autoApprovalEnabled: true,
- alwaysAllowReadOnly: true,
- alwaysAllowReadOnlyOutsideWorkspace: true,
- alwaysAllowWrite: true,
- alwaysAllowWriteOutsideWorkspace: true,
- alwaysAllowWriteProtected: true,
- alwaysAllowMcp: true,
- alwaysAllowModeSwitch: true,
- alwaysAllowSubtasks: true,
- alwaysAllowExecute: true,
- allowedCommands: ["*"],
- ...baseSettings,
- }
- : {
- autoApprovalEnabled: false,
- ...baseSettings,
- }
- if (this.options.reasoningEffort && this.options.reasoningEffort !== "unspecified") {
- if (this.options.reasoningEffort === "disabled") {
- this.initialSettings.enableReasoningEffort = false
- } else {
- this.initialSettings.enableReasoningEffort = true
- this.initialSettings.reasoningEffort = this.options.reasoningEffort
- }
- }
- if (this.options.terminalShell) {
- this.initialSettings.terminalShellIntegrationDisabled = true
- this.initialSettings.execaShellPath = this.options.terminalShell
- }
- }
- // ==========================================================================
- // Client Event Handlers
- // ==========================================================================
- /**
- * Wire up client events to managers.
- * The client emits events, managers handle them.
- */
- private setupClientEventHandlers(): void {
- // Handle new messages - delegate to OutputManager.
- this.client.on("message", (msg: ClineMessage) => {
- this.logMessageDebug(msg, "new")
- this.outputManager.outputMessage(msg)
- })
- // Handle message updates - delegate to OutputManager.
- this.client.on("messageUpdated", (msg: ClineMessage) => {
- this.logMessageDebug(msg, "updated")
- this.outputManager.outputMessage(msg)
- })
- // Handle waiting for input - delegate to AskDispatcher.
- this.client.on("waitingForInput", (event: WaitingForInputEvent) => {
- this.askDispatcher.handleAsk(event.message)
- })
- // Handle task completion.
- this.client.on("taskCompleted", (event: TaskCompletedEvent) => {
- // Output completion message via OutputManager.
- // Note: completion_result is an "ask" type, not a "say" type.
- if (event.message && event.message.type === "ask" && event.message.ask === "completion_result") {
- this.outputManager.outputCompletionResult(event.message.ts, event.message.text || "")
- }
- })
- }
- // ==========================================================================
- // Logging + Console Suppression
- // ==========================================================================
- private setupQuietMode(): void {
- // Skip if already set up or if integrationTest mode
- if (this.originalConsole || this.options.integrationTest) {
- return
- }
- // Suppress node warnings.
- this.originalProcessEmitWarning = process.emitWarning
- process.emitWarning = () => {}
- process.on("warning", () => {})
- // Suppress console output.
- this.originalConsole = {
- log: console.log,
- warn: console.warn,
- error: console.error,
- debug: console.debug,
- info: console.info,
- }
- console.log = () => {}
- console.warn = () => {}
- console.debug = () => {}
- console.info = () => {}
- }
- private restoreConsole(): void {
- if (!this.originalConsole) {
- return
- }
- 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
- }
- }
- private logMessageDebug(msg: ClineMessage, type: "new" | "updated"): void {
- if (msg.partial) {
- if (!this.outputManager.hasLoggedFirstPartial(msg.ts)) {
- this.outputManager.setLoggedFirstPartial(msg.ts)
- cliLogger.debug("message:start", { ts: msg.ts, type: msg.say || msg.ask })
- }
- } else {
- cliLogger.debug(`message:${type === "new" ? "new" : "complete"}`, { ts: msg.ts, type: msg.say || msg.ask })
- this.outputManager.clearLoggedFirstPartial(msg.ts)
- }
- }
- // ==========================================================================
- // Extension Lifecycle
- // ==========================================================================
- public async activate(): Promise<void> {
- const bundlePath = path.join(this.options.extensionPath, "extension.js")
- if (!fs.existsSync(bundlePath)) {
- this.restoreConsole()
- throw new Error(`Extension bundle not found at: ${bundlePath}`)
- }
- let storageDir: string | undefined
- if (this.options.ephemeral) {
- this.ephemeralStorageDir = await createEphemeralStorageDir()
- storageDir = this.ephemeralStorageDir
- }
- // Create VSCode API mock.
- this.vscode = createVSCodeAPI(this.options.extensionPath, this.options.workspacePath, undefined, {
- appRoot: CLI_PACKAGE_ROOT,
- storageDir,
- })
- ;(global as Record<string, unknown>).vscode = this.vscode
- ;(global as Record<string, unknown>).__extensionHost = this
- // Set up module resolution.
- 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)
- }
- 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
- try {
- this.extensionModule = require(bundlePath) as ExtensionModule
- } catch (error) {
- Module._resolveFilename = originalResolve
- throw new Error(
- `Failed to load extension bundle: ${error instanceof Error ? error.message : String(error)}`,
- )
- }
- Module._resolveFilename = originalResolve
- 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)}`)
- }
- // Set up message listener - forward all messages to client.
- this.messageListener = (message: ExtensionMessage) => this.client.handleMessage(message)
- this.on("extensionWebviewMessage", this.messageListener)
- await pWaitFor(() => this.isReady, { interval: 100, timeout: 10_000 })
- }
- public registerWebviewProvider(_viewId: string, _provider: WebviewViewProvider): void {}
- public unregisterWebviewProvider(_viewId: string): void {}
- public markWebviewReady(): void {
- this.isReady = true
- // Apply CLI settings to the runtime config and context proxy BEFORE
- // sending webviewDidLaunch. This prevents a race condition where the
- // webviewDidLaunch handler's first-time init sync reads default state
- // (apiProvider: "anthropic") instead of the CLI-provided settings.
- setRuntimeConfigValues("roo-cline", this.initialSettings as Record<string, unknown>)
- this.sendToExtension({ type: "updateSettings", updatedSettings: this.initialSettings })
- // Now trigger extension initialization. The context proxy should already
- // have CLI-provided values when the webviewDidLaunch handler runs.
- this.sendToExtension({ type: "webviewDidLaunch" })
- }
- public isInInitialSetup(): boolean {
- return !this.isReady
- }
- // ==========================================================================
- // Message Handling
- // ==========================================================================
- public sendToExtension(message: WebviewMessage): void {
- if (!this.isReady) {
- throw new Error("You cannot send messages to the extension before it is ready")
- }
- this.emit("webviewMessage", message)
- }
- // ==========================================================================
- // Task Management
- // ==========================================================================
- private waitForTaskCompletion(): Promise<void> {
- return new Promise((resolve, reject) => {
- const completeHandler = () => {
- cleanup()
- resolve()
- }
- const errorHandler = (error: Error) => {
- cleanup()
- reject(error)
- }
- const cleanup = () => {
- this.client.off("taskCompleted", completeHandler)
- this.client.off("error", errorHandler)
- if (messageHandler) {
- this.client.off("message", messageHandler)
- }
- }
- // When exitOnError is enabled, listen for api_req_retry_delayed messages
- // (sent by Task.ts during auto-approval retry backoff) and exit immediately.
- let messageHandler: ((msg: ClineMessage) => void) | null = null
- if (this.options.exitOnError) {
- messageHandler = (msg: ClineMessage) => {
- if (msg.type === "say" && msg.say === "api_req_retry_delayed") {
- cleanup()
- reject(new Error(msg.text?.split("\n")[0] || "API request failed"))
- }
- }
- this.client.on("message", messageHandler)
- }
- this.client.once("taskCompleted", completeHandler)
- this.client.once("error", errorHandler)
- })
- }
- public async runTask(
- prompt: string,
- taskId?: string,
- configuration?: RooCodeSettings,
- images?: string[],
- ): Promise<void> {
- this.sendToExtension({
- type: "newTask",
- text: prompt,
- taskId,
- taskConfiguration: configuration,
- ...(images !== undefined ? { images } : {}),
- })
- return this.waitForTaskCompletion()
- }
- public async resumeTask(taskId: string): Promise<void> {
- this.sendToExtension({ type: "showTaskWithId", text: taskId })
- return this.waitForTaskCompletion()
- }
- // ==========================================================================
- // Public Agent State API
- // ==========================================================================
- /**
- * Get the current agent loop state.
- */
- public getAgentState(): AgentStateInfo {
- return this.client.getAgentState()
- }
- /**
- * Check if the agent is currently waiting for user input.
- */
- public isWaitingForInput(): boolean {
- return this.client.getAgentState().isWaitingForInput
- }
- // ==========================================================================
- // Cleanup
- // ==========================================================================
- async dispose(): Promise<void> {
- // Clear managers.
- this.outputManager.clear()
- this.askDispatcher.clear()
- // Remove message listener.
- if (this.messageListener) {
- this.off("extensionWebviewMessage", this.messageListener)
- this.messageListener = null
- }
- // Reset client.
- this.client.reset()
- // Deactivate extension.
- if (this.extensionModule?.deactivate) {
- try {
- await this.extensionModule.deactivate()
- } catch {
- // NO-OP
- }
- }
- // Clear references.
- this.vscode = null
- this.extensionModule = null
- this.extensionAPI = null
- // Clear globals.
- delete (global as Record<string, unknown>).vscode
- delete (global as Record<string, unknown>).__extensionHost
- // Restore console.
- this.restoreConsole()
- // Clean up ephemeral storage.
- if (this.ephemeralStorageDir) {
- try {
- await fs.promises.rm(this.ephemeralStorageDir, { recursive: true, force: true })
- this.ephemeralStorageDir = null
- } catch {
- // NO-OP
- }
- }
- // Restore previous CLI runtime marker for process hygiene in tests.
- if (this.previousCliRuntimeEnv === undefined) {
- delete process.env.ROO_CLI_RUNTIME
- } else {
- process.env.ROO_CLI_RUNTIME = this.previousCliRuntimeEnv
- }
- }
- }
|