mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-08-28 05:27:24 +00:00
586 lines
18 KiB
TypeScript
586 lines
18 KiB
TypeScript
/**
|
|
* 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 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"
|
|
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
|
|
/**
|
|
* 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): 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,
|
|
commandExecutionTimeout: 30,
|
|
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
|
|
}
|
|
}
|
|
}
|
|
|
|
// ==========================================================================
|
|
// 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
|
|
// ==========================================================================
|
|
|
|
public async runTask(prompt: string, taskId?: string): Promise<void> {
|
|
this.sendToExtension({ type: "newTask", text: prompt, taskId })
|
|
|
|
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 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
|
|
}
|
|
}
|
|
}
|