2
0

extension-host.ts 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614
  1. /**
  2. * ExtensionHost - Loads and runs the Roo Code extension in CLI mode
  3. *
  4. * This class is a thin coordination layer responsible for:
  5. * 1. Creating the vscode-shim mock
  6. * 2. Loading the extension bundle via require()
  7. * 3. Activating the extension
  8. * 4. Wiring up managers for output, prompting, and ask handling
  9. */
  10. import { createRequire } from "module"
  11. import path from "path"
  12. import { fileURLToPath } from "url"
  13. import fs from "fs"
  14. import { EventEmitter } from "events"
  15. import pWaitFor from "p-wait-for"
  16. import type {
  17. ClineMessage,
  18. ExtensionMessage,
  19. ReasoningEffortExtended,
  20. RooCodeSettings,
  21. WebviewMessage,
  22. } from "@roo-code/types"
  23. import { createVSCodeAPI, IExtensionHost, ExtensionHostEventMap, setRuntimeConfigValues } from "@roo-code/vscode-shim"
  24. import { DebugLogger, setDebugLogEnabled } from "@roo-code/core/cli"
  25. import { DEFAULT_FLAGS, type SupportedProvider } from "@/types/index.js"
  26. import type { User } from "@/lib/sdk/index.js"
  27. import { getProviderSettings } from "@/lib/utils/provider.js"
  28. import { createEphemeralStorageDir } from "@/lib/storage/index.js"
  29. import type { WaitingForInputEvent, TaskCompletedEvent } from "./events.js"
  30. import type { AgentStateInfo } from "./agent-state.js"
  31. import { ExtensionClient } from "./extension-client.js"
  32. import { OutputManager } from "./output-manager.js"
  33. import { PromptManager } from "./prompt-manager.js"
  34. import { AskDispatcher } from "./ask-dispatcher.js"
  35. // Pre-configured logger for CLI message activity debugging.
  36. const cliLogger = new DebugLogger("CLI")
  37. // Get the CLI package root directory (for finding node_modules/@vscode/ripgrep)
  38. // When running from a release tarball, ROO_CLI_ROOT is set by the wrapper script.
  39. // In development, we fall back to finding the CLI package root by walking up to package.json.
  40. // This works whether running from dist/ (bundled) or src/agent/ (tsx dev).
  41. const __dirname = path.dirname(fileURLToPath(import.meta.url))
  42. function findCliPackageRoot(): string {
  43. let dir = __dirname
  44. while (dir !== path.dirname(dir)) {
  45. if (fs.existsSync(path.join(dir, "package.json"))) {
  46. return dir
  47. }
  48. dir = path.dirname(dir)
  49. }
  50. return path.resolve(__dirname, "..")
  51. }
  52. const CLI_PACKAGE_ROOT = process.env.ROO_CLI_ROOT || findCliPackageRoot()
  53. export interface ExtensionHostOptions {
  54. mode: string
  55. reasoningEffort?: ReasoningEffortExtended | "unspecified" | "disabled"
  56. consecutiveMistakeLimit?: number
  57. user: User | null
  58. provider: SupportedProvider
  59. apiKey?: string
  60. model: string
  61. workspacePath: string
  62. extensionPath: string
  63. nonInteractive?: boolean
  64. /**
  65. * When true, uses a temporary storage directory that is cleaned up on exit.
  66. */
  67. ephemeral: boolean
  68. debug: boolean
  69. exitOnComplete: boolean
  70. terminalShell?: string
  71. /**
  72. * When true, exit the process on API request errors instead of retrying.
  73. */
  74. exitOnError?: boolean
  75. /**
  76. * When true, completely disables all direct stdout/stderr output.
  77. * Use this when running in TUI mode where Ink controls the terminal.
  78. */
  79. disableOutput?: boolean
  80. /**
  81. * When true, don't suppress node warnings and console output since we're
  82. * running in an integration test and we want to see the output.
  83. */
  84. integrationTest?: boolean
  85. }
  86. interface ExtensionModule {
  87. activate: (context: unknown) => Promise<unknown>
  88. deactivate?: () => Promise<void>
  89. }
  90. interface WebviewViewProvider {
  91. resolveWebviewView?(webviewView: unknown, context: unknown, token: unknown): void | Promise<void>
  92. }
  93. export interface ExtensionHostInterface extends IExtensionHost<ExtensionHostEventMap> {
  94. client: ExtensionClient
  95. activate(): Promise<void>
  96. runTask(prompt: string, taskId?: string, configuration?: RooCodeSettings, images?: string[]): Promise<void>
  97. resumeTask(taskId: string): Promise<void>
  98. sendToExtension(message: WebviewMessage): void
  99. dispose(): Promise<void>
  100. }
  101. export class ExtensionHost extends EventEmitter implements ExtensionHostInterface {
  102. // Extension lifecycle.
  103. private vscode: ReturnType<typeof createVSCodeAPI> | null = null
  104. private extensionModule: ExtensionModule | null = null
  105. private extensionAPI: unknown = null
  106. private options: ExtensionHostOptions
  107. private isReady = false
  108. private messageListener: ((message: ExtensionMessage) => void) | null = null
  109. private initialSettings: RooCodeSettings
  110. // Console suppression.
  111. private originalConsole: {
  112. log: typeof console.log
  113. warn: typeof console.warn
  114. error: typeof console.error
  115. debug: typeof console.debug
  116. info: typeof console.info
  117. } | null = null
  118. private originalProcessEmitWarning: typeof process.emitWarning | null = null
  119. // Ephemeral storage.
  120. private ephemeralStorageDir: string | null = null
  121. private previousCliRuntimeEnv: string | undefined
  122. // ==========================================================================
  123. // Managers - These do all the heavy lifting
  124. // ==========================================================================
  125. /**
  126. * ExtensionClient: Single source of truth for agent loop state.
  127. * Handles message processing and state detection.
  128. */
  129. public readonly client: ExtensionClient
  130. /**
  131. * OutputManager: Handles all CLI output and streaming.
  132. * Uses Observable pattern internally for stream tracking.
  133. */
  134. private outputManager: OutputManager
  135. /**
  136. * PromptManager: Handles all user input collection.
  137. * Provides readline, yes/no, and timed prompts.
  138. */
  139. private promptManager: PromptManager
  140. /**
  141. * AskDispatcher: Routes asks to appropriate handlers.
  142. * Uses type guards (isIdleAsk, isInteractiveAsk, etc.) from client module.
  143. */
  144. private askDispatcher: AskDispatcher
  145. // ==========================================================================
  146. // Constructor
  147. // ==========================================================================
  148. constructor(options: ExtensionHostOptions) {
  149. super()
  150. this.options = options
  151. // Mark this process as CLI runtime so extension code can apply
  152. // CLI-specific behavior without affecting VS Code desktop usage.
  153. this.previousCliRuntimeEnv = process.env.ROO_CLI_RUNTIME
  154. process.env.ROO_CLI_RUNTIME = "1"
  155. // Enable file-based debug logging only when --debug is passed.
  156. if (options.debug) {
  157. setDebugLogEnabled(true)
  158. }
  159. // Set up quiet mode early, before any extension code runs.
  160. // This suppresses console output from the extension during load.
  161. this.setupQuietMode()
  162. // Initialize client - single source of truth for agent state (including mode).
  163. this.client = new ExtensionClient({
  164. sendMessage: (msg) => this.sendToExtension(msg),
  165. debug: options.debug, // Enable debug logging in the client.
  166. })
  167. // Initialize output manager.
  168. this.outputManager = new OutputManager({ disabled: options.disableOutput })
  169. // Initialize prompt manager with console mode callbacks.
  170. this.promptManager = new PromptManager({
  171. onBeforePrompt: () => this.restoreConsole(),
  172. onAfterPrompt: () => this.setupQuietMode(),
  173. })
  174. // Initialize ask dispatcher.
  175. this.askDispatcher = new AskDispatcher({
  176. outputManager: this.outputManager,
  177. promptManager: this.promptManager,
  178. sendMessage: (msg) => this.sendToExtension(msg),
  179. nonInteractive: options.nonInteractive,
  180. exitOnError: options.exitOnError,
  181. disabled: options.disableOutput, // TUI mode handles asks directly.
  182. })
  183. // Wire up client events.
  184. this.setupClientEventHandlers()
  185. // Populate initial settings.
  186. const baseSettings: RooCodeSettings = {
  187. mode: this.options.mode,
  188. consecutiveMistakeLimit: this.options.consecutiveMistakeLimit ?? DEFAULT_FLAGS.consecutiveMistakeLimit,
  189. commandExecutionTimeout: 300,
  190. enableCheckpoints: false,
  191. experiments: {
  192. customTools: true,
  193. },
  194. ...getProviderSettings(this.options.provider, this.options.apiKey, this.options.model),
  195. }
  196. this.initialSettings = this.options.nonInteractive
  197. ? {
  198. autoApprovalEnabled: true,
  199. alwaysAllowReadOnly: true,
  200. alwaysAllowReadOnlyOutsideWorkspace: true,
  201. alwaysAllowWrite: true,
  202. alwaysAllowWriteOutsideWorkspace: true,
  203. alwaysAllowWriteProtected: true,
  204. alwaysAllowMcp: true,
  205. alwaysAllowModeSwitch: true,
  206. alwaysAllowSubtasks: true,
  207. alwaysAllowExecute: true,
  208. allowedCommands: ["*"],
  209. ...baseSettings,
  210. }
  211. : {
  212. autoApprovalEnabled: false,
  213. ...baseSettings,
  214. }
  215. if (this.options.reasoningEffort && this.options.reasoningEffort !== "unspecified") {
  216. if (this.options.reasoningEffort === "disabled") {
  217. this.initialSettings.enableReasoningEffort = false
  218. } else {
  219. this.initialSettings.enableReasoningEffort = true
  220. this.initialSettings.reasoningEffort = this.options.reasoningEffort
  221. }
  222. }
  223. if (this.options.terminalShell) {
  224. this.initialSettings.terminalShellIntegrationDisabled = true
  225. this.initialSettings.execaShellPath = this.options.terminalShell
  226. }
  227. }
  228. // ==========================================================================
  229. // Client Event Handlers
  230. // ==========================================================================
  231. /**
  232. * Wire up client events to managers.
  233. * The client emits events, managers handle them.
  234. */
  235. private setupClientEventHandlers(): void {
  236. // Handle new messages - delegate to OutputManager.
  237. this.client.on("message", (msg: ClineMessage) => {
  238. this.logMessageDebug(msg, "new")
  239. this.outputManager.outputMessage(msg)
  240. })
  241. // Handle message updates - delegate to OutputManager.
  242. this.client.on("messageUpdated", (msg: ClineMessage) => {
  243. this.logMessageDebug(msg, "updated")
  244. this.outputManager.outputMessage(msg)
  245. })
  246. // Handle waiting for input - delegate to AskDispatcher.
  247. this.client.on("waitingForInput", (event: WaitingForInputEvent) => {
  248. this.askDispatcher.handleAsk(event.message)
  249. })
  250. // Handle task completion.
  251. this.client.on("taskCompleted", (event: TaskCompletedEvent) => {
  252. // Output completion message via OutputManager.
  253. // Note: completion_result is an "ask" type, not a "say" type.
  254. if (event.message && event.message.type === "ask" && event.message.ask === "completion_result") {
  255. this.outputManager.outputCompletionResult(event.message.ts, event.message.text || "")
  256. }
  257. })
  258. }
  259. // ==========================================================================
  260. // Logging + Console Suppression
  261. // ==========================================================================
  262. private setupQuietMode(): void {
  263. // Skip if already set up or if integrationTest mode
  264. if (this.originalConsole || this.options.integrationTest) {
  265. return
  266. }
  267. // Suppress node warnings.
  268. this.originalProcessEmitWarning = process.emitWarning
  269. process.emitWarning = () => {}
  270. process.on("warning", () => {})
  271. // Suppress console output.
  272. this.originalConsole = {
  273. log: console.log,
  274. warn: console.warn,
  275. error: console.error,
  276. debug: console.debug,
  277. info: console.info,
  278. }
  279. console.log = () => {}
  280. console.warn = () => {}
  281. console.debug = () => {}
  282. console.info = () => {}
  283. }
  284. private restoreConsole(): void {
  285. if (!this.originalConsole) {
  286. return
  287. }
  288. console.log = this.originalConsole.log
  289. console.warn = this.originalConsole.warn
  290. console.error = this.originalConsole.error
  291. console.debug = this.originalConsole.debug
  292. console.info = this.originalConsole.info
  293. this.originalConsole = null
  294. if (this.originalProcessEmitWarning) {
  295. process.emitWarning = this.originalProcessEmitWarning
  296. this.originalProcessEmitWarning = null
  297. }
  298. }
  299. private logMessageDebug(msg: ClineMessage, type: "new" | "updated"): void {
  300. if (msg.partial) {
  301. if (!this.outputManager.hasLoggedFirstPartial(msg.ts)) {
  302. this.outputManager.setLoggedFirstPartial(msg.ts)
  303. cliLogger.debug("message:start", { ts: msg.ts, type: msg.say || msg.ask })
  304. }
  305. } else {
  306. cliLogger.debug(`message:${type === "new" ? "new" : "complete"}`, { ts: msg.ts, type: msg.say || msg.ask })
  307. this.outputManager.clearLoggedFirstPartial(msg.ts)
  308. }
  309. }
  310. // ==========================================================================
  311. // Extension Lifecycle
  312. // ==========================================================================
  313. public async activate(): Promise<void> {
  314. const bundlePath = path.join(this.options.extensionPath, "extension.js")
  315. if (!fs.existsSync(bundlePath)) {
  316. this.restoreConsole()
  317. throw new Error(`Extension bundle not found at: ${bundlePath}`)
  318. }
  319. let storageDir: string | undefined
  320. if (this.options.ephemeral) {
  321. this.ephemeralStorageDir = await createEphemeralStorageDir()
  322. storageDir = this.ephemeralStorageDir
  323. }
  324. // Create VSCode API mock.
  325. this.vscode = createVSCodeAPI(this.options.extensionPath, this.options.workspacePath, undefined, {
  326. appRoot: CLI_PACKAGE_ROOT,
  327. storageDir,
  328. })
  329. ;(global as Record<string, unknown>).vscode = this.vscode
  330. ;(global as Record<string, unknown>).__extensionHost = this
  331. // Set up module resolution.
  332. const require = createRequire(import.meta.url)
  333. const Module = require("module")
  334. const originalResolve = Module._resolveFilename
  335. Module._resolveFilename = function (request: string, parent: unknown, isMain: boolean, options: unknown) {
  336. if (request === "vscode") return "vscode-mock"
  337. return originalResolve.call(this, request, parent, isMain, options)
  338. }
  339. require.cache["vscode-mock"] = {
  340. id: "vscode-mock",
  341. filename: "vscode-mock",
  342. loaded: true,
  343. exports: this.vscode,
  344. children: [],
  345. paths: [],
  346. path: "",
  347. isPreloading: false,
  348. parent: null,
  349. require: require,
  350. } as unknown as NodeJS.Module
  351. try {
  352. this.extensionModule = require(bundlePath) as ExtensionModule
  353. } catch (error) {
  354. Module._resolveFilename = originalResolve
  355. throw new Error(
  356. `Failed to load extension bundle: ${error instanceof Error ? error.message : String(error)}`,
  357. )
  358. }
  359. Module._resolveFilename = originalResolve
  360. try {
  361. this.extensionAPI = await this.extensionModule.activate(this.vscode.context)
  362. } catch (error) {
  363. throw new Error(`Failed to activate extension: ${error instanceof Error ? error.message : String(error)}`)
  364. }
  365. // Set up message listener - forward all messages to client.
  366. this.messageListener = (message: ExtensionMessage) => this.client.handleMessage(message)
  367. this.on("extensionWebviewMessage", this.messageListener)
  368. await pWaitFor(() => this.isReady, { interval: 100, timeout: 10_000 })
  369. }
  370. public registerWebviewProvider(_viewId: string, _provider: WebviewViewProvider): void {}
  371. public unregisterWebviewProvider(_viewId: string): void {}
  372. public markWebviewReady(): void {
  373. this.isReady = true
  374. // Apply CLI settings to the runtime config and context proxy BEFORE
  375. // sending webviewDidLaunch. This prevents a race condition where the
  376. // webviewDidLaunch handler's first-time init sync reads default state
  377. // (apiProvider: "anthropic") instead of the CLI-provided settings.
  378. setRuntimeConfigValues("roo-cline", this.initialSettings as Record<string, unknown>)
  379. this.sendToExtension({ type: "updateSettings", updatedSettings: this.initialSettings })
  380. // Now trigger extension initialization. The context proxy should already
  381. // have CLI-provided values when the webviewDidLaunch handler runs.
  382. this.sendToExtension({ type: "webviewDidLaunch" })
  383. }
  384. public isInInitialSetup(): boolean {
  385. return !this.isReady
  386. }
  387. // ==========================================================================
  388. // Message Handling
  389. // ==========================================================================
  390. public sendToExtension(message: WebviewMessage): void {
  391. if (!this.isReady) {
  392. throw new Error("You cannot send messages to the extension before it is ready")
  393. }
  394. this.emit("webviewMessage", message)
  395. }
  396. // ==========================================================================
  397. // Task Management
  398. // ==========================================================================
  399. private waitForTaskCompletion(): Promise<void> {
  400. return new Promise((resolve, reject) => {
  401. const completeHandler = () => {
  402. cleanup()
  403. resolve()
  404. }
  405. const errorHandler = (error: Error) => {
  406. cleanup()
  407. reject(error)
  408. }
  409. const cleanup = () => {
  410. this.client.off("taskCompleted", completeHandler)
  411. this.client.off("error", errorHandler)
  412. if (messageHandler) {
  413. this.client.off("message", messageHandler)
  414. }
  415. }
  416. // When exitOnError is enabled, listen for api_req_retry_delayed messages
  417. // (sent by Task.ts during auto-approval retry backoff) and exit immediately.
  418. let messageHandler: ((msg: ClineMessage) => void) | null = null
  419. if (this.options.exitOnError) {
  420. messageHandler = (msg: ClineMessage) => {
  421. if (msg.type === "say" && msg.say === "api_req_retry_delayed") {
  422. cleanup()
  423. reject(new Error(msg.text?.split("\n")[0] || "API request failed"))
  424. }
  425. }
  426. this.client.on("message", messageHandler)
  427. }
  428. this.client.once("taskCompleted", completeHandler)
  429. this.client.once("error", errorHandler)
  430. })
  431. }
  432. public async runTask(
  433. prompt: string,
  434. taskId?: string,
  435. configuration?: RooCodeSettings,
  436. images?: string[],
  437. ): Promise<void> {
  438. this.sendToExtension({
  439. type: "newTask",
  440. text: prompt,
  441. taskId,
  442. taskConfiguration: configuration,
  443. ...(images !== undefined ? { images } : {}),
  444. })
  445. return this.waitForTaskCompletion()
  446. }
  447. public async resumeTask(taskId: string): Promise<void> {
  448. this.sendToExtension({ type: "showTaskWithId", text: taskId })
  449. return this.waitForTaskCompletion()
  450. }
  451. // ==========================================================================
  452. // Public Agent State API
  453. // ==========================================================================
  454. /**
  455. * Get the current agent loop state.
  456. */
  457. public getAgentState(): AgentStateInfo {
  458. return this.client.getAgentState()
  459. }
  460. /**
  461. * Check if the agent is currently waiting for user input.
  462. */
  463. public isWaitingForInput(): boolean {
  464. return this.client.getAgentState().isWaitingForInput
  465. }
  466. // ==========================================================================
  467. // Cleanup
  468. // ==========================================================================
  469. async dispose(): Promise<void> {
  470. // Clear managers.
  471. this.outputManager.clear()
  472. this.askDispatcher.clear()
  473. // Remove message listener.
  474. if (this.messageListener) {
  475. this.off("extensionWebviewMessage", this.messageListener)
  476. this.messageListener = null
  477. }
  478. // Reset client.
  479. this.client.reset()
  480. // Deactivate extension.
  481. if (this.extensionModule?.deactivate) {
  482. try {
  483. await this.extensionModule.deactivate()
  484. } catch {
  485. // NO-OP
  486. }
  487. }
  488. // Clear references.
  489. this.vscode = null
  490. this.extensionModule = null
  491. this.extensionAPI = null
  492. // Clear globals.
  493. delete (global as Record<string, unknown>).vscode
  494. delete (global as Record<string, unknown>).__extensionHost
  495. // Restore console.
  496. this.restoreConsole()
  497. // Clean up ephemeral storage.
  498. if (this.ephemeralStorageDir) {
  499. try {
  500. await fs.promises.rm(this.ephemeralStorageDir, { recursive: true, force: true })
  501. this.ephemeralStorageDir = null
  502. } catch {
  503. // NO-OP
  504. }
  505. }
  506. // Restore previous CLI runtime marker for process hygiene in tests.
  507. if (this.previousCliRuntimeEnv === undefined) {
  508. delete process.env.ROO_CLI_RUNTIME
  509. } else {
  510. process.env.ROO_CLI_RUNTIME = this.previousCliRuntimeEnv
  511. }
  512. }
  513. }