mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-08-28 05:27:24 +00:00
* Allow selecting a specific shell Add --terminal-shell CLI flag to specify which shell ExecaTerminalProcess uses for inline command execution. The shell path is validated at the CLI layer and passed through the standard settings mechanism (BaseTerminal static getter/setter), matching how all other CLI terminal settings flow through the system. * test(cli): make shell path access test cross-platform
614 lines
19 KiB
TypeScript
614 lines
19 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 { 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
|
|
}
|
|
}
|
|
}
|