import { EventEmitter } from "events" import fs from "fs/promises" import * as path from "path" import * as os from "os" import * as vscode from "vscode" import pWaitFor from "p-wait-for" import { type RooCodeAPI, type RooCodeSettings, type RooCodeEvents, type ProviderSettings, type ProviderSettingsEntry, type TaskEvent, type TaskStartResponse, type CreateTaskOptions, type TaskApiConversationHistorySequence, type WebviewThemeFixture, RooCodeEventName, TaskCommandName, isSecretStateKey, IpcOrigin, IpcMessageType, TASK_START_FAILURE_ERROR_CODE, TASK_START_FAILURE_ERROR_MESSAGE, } from "@roo-code/types" import { IpcServer } from "@roo-code/ipc" import { Package } from "../shared/package" import type { Mode } from "../shared/modes" import type { ClineProvider } from "../core/webview/ClineProvider" import type { ClineProviderFactory } from "../core/webview/ClineProviderFactory" import { withoutInvalidDisplaySettings } from "../core/config/displaySettings" import { readTaskMessages } from "../core/task-persistence/taskMessages" import { Terminal } from "../integrations/terminal/Terminal" import { TerminalRegistry } from "../integrations/terminal/TerminalRegistry" import { getCommands } from "../services/command/commands" export class API extends EventEmitter implements RooCodeAPI { private readonly outputChannel: vscode.OutputChannel private readonly sidebarProvider: ClineProvider private readonly ipc?: IpcServer private readonly log: (...args: unknown[]) => void private logfile?: string constructor( outputChannel: vscode.OutputChannel, provider: ClineProvider, private readonly providerFactory: ClineProviderFactory, socketPath?: string, enableLogging = false, ) { super() this.outputChannel = outputChannel this.sidebarProvider = provider if (enableLogging) { this.log = (...args: unknown[]) => { this.outputChannelLog(...args) console.log(args) } this.logfile = path.join(os.tmpdir(), "roo-code-messages.log") } else { this.log = () => {} } this.registerListeners(this.sidebarProvider) if (socketPath) { const ipc = (this.ipc = new IpcServer(socketPath, this.log)) ipc.listen() this.log(`[API] ipc server started: socketPath=${socketPath}, pid=${process.pid}, ppid=${process.ppid}`) ipc.on(IpcMessageType.TaskCommand, async (clientId, command) => { const sendResponse = (eventName: RooCodeEventName, payload: unknown[]) => { ipc.send(clientId, { type: IpcMessageType.TaskEvent, origin: IpcOrigin.Server, data: { eventName, payload } as TaskEvent, }) } switch (command.commandName) { case TaskCommandName.StartNewTask: { // Neither prompt nor settings values enter the log. this.log("[API] StartNewTask", { promptLength: command.data.text.length, imageCount: command.data.images?.length ?? 0, newTab: command.data.newTab === true, requestId: command.data.requestId, }) const { requestId } = command.data if (requestId === undefined) { await this.startNewTask(command.data, { focusSidebar: false }) break } // Exactly one sanitized, client-scoped response per // correlated start. The reply never echoes the failure. // Await actual startup, including configuration writes. // The smoke controller owns its timeout and terminates // its isolated host; shared-profile writes are not raced. let response: TaskStartResponse try { const taskId = await this.startNewTask(command.data, { focusSidebar: false, }) response = { requestId, success: true, taskId } } catch { this.log("[API] StartNewTask failed", { requestId }) response = { requestId, success: false, errorCode: TASK_START_FAILURE_ERROR_CODE, errorMessage: TASK_START_FAILURE_ERROR_MESSAGE, } } sendResponse(RooCodeEventName.TaskStartResponse, [response]) break } case TaskCommandName.CancelTask: this.log(`[API] CancelTask`) await this.cancelCurrentTask() break case TaskCommandName.CloseTask: this.log(`[API] CloseTask`) await vscode.commands.executeCommand("workbench.action.files.saveFiles") await vscode.commands.executeCommand("workbench.action.closeWindow") break case TaskCommandName.ResumeTask: this.log(`[API] ResumeTask -> ${command.data}`) try { await this.resumeTask(command.data) } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error) this.log(`[API] ResumeTask failed for taskId ${command.data}: ${errorMessage}`) // Don't rethrow - we want to prevent IPC server crashes. // The error is logged for debugging purposes. } break case TaskCommandName.SendMessage: this.log(`[API] SendMessage -> ${command.data.text}`) await this.sendMessage(command.data.text, command.data.images) break case TaskCommandName.GetCommands: try { const commands = await getCommands(this.sidebarProvider.cwd) sendResponse(RooCodeEventName.CommandsResponse, [ commands.map((cmd) => ({ name: cmd.name, source: cmd.source, filePath: cmd.filePath, description: cmd.description, argumentHint: cmd.argumentHint, })), ]) } catch { sendResponse(RooCodeEventName.CommandsResponse, [[]]) } break case TaskCommandName.GetModes: try { const modes = await this.sidebarProvider.getModes() sendResponse(RooCodeEventName.ModesResponse, [modes]) } catch { sendResponse(RooCodeEventName.ModesResponse, [[]]) } break case TaskCommandName.GetModels: try { sendResponse(RooCodeEventName.ModelsResponse, [{}]) } catch { sendResponse(RooCodeEventName.ModelsResponse, [{}]) } break case TaskCommandName.DeleteQueuedMessage: this.log(`[API] DeleteQueuedMessage -> ${command.data}`) try { this.deleteQueuedMessage(command.data) } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error) this.log(`[API] DeleteQueuedMessage failed for messageId ${command.data}: ${errorMessage}`) } break } }) } } public override emit( eventName: K, ...args: K extends keyof RooCodeEvents ? RooCodeEvents[K] : never ) { const data = { eventName: eventName as RooCodeEventName, payload: args } as TaskEvent this.ipc?.broadcast({ type: IpcMessageType.TaskEvent, origin: IpcOrigin.Server, data }) return super.emit(eventName, ...args) } public async startNewTask( { configuration, text, images, newTab, }: { configuration: RooCodeSettings text?: string images?: string[] newTab?: boolean }, startOptions?: { focusSidebar?: boolean }, ) { let provider: ClineProvider if (newTab) { await vscode.commands.executeCommand("workbench.action.files.revert") await vscode.commands.executeCommand("workbench.action.closeAllEditors") provider = await this.providerFactory.createInNewTab() this.registerListeners(provider) } else { // IPC callers omit focus: executeCommand cannot be cancelled and a // stalled webview launch would block the whole start. if (startOptions?.focusSidebar ?? true) { await vscode.commands.executeCommand(`${Package.name}.SidebarProvider.focus`) } provider = this.sidebarProvider } await provider.evictCurrentTask() await provider.postStateToWebview() await provider.postMessageToWebview({ type: "action", action: "chatButtonClicked" }) await provider.postMessageToWebview({ type: "invoke", invoke: "newChat", text, images }) // API-driven tasks never stop to ask the user, so the hard stop is // effectively unlimited. The soft repetition tier is disabled too: // without a reachable hard stop it would reject every identical call // with no escalation. const options: CreateTaskOptions = { consecutiveMistakeLimit: Number.MAX_SAFE_INTEGER, toolRepetitionSoftLimit: 0, } const task = await provider.createTask(text, images, undefined, options, configuration) if (!task) { throw new Error("Failed to create task due to policy restrictions") } return task.taskId } public async resumeTask(taskId: string): Promise { await vscode.commands.executeCommand(`${Package.name}.SidebarProvider.focus`) await this.waitForWebviewLaunch(5_000) const { historyItem } = await this.sidebarProvider.getTaskWithId(taskId) await this.sidebarProvider.createTaskWithHistoryItem(historyItem) if (this.sidebarProvider.viewLaunched) { await this.sidebarProvider.postMessageToWebview({ type: "action", action: "chatButtonClicked" }) } else { this.log( `[API#resumeTask] webview not launched after resume for task ${taskId}; continuing in headless mode`, ) } } public async isTaskInHistory(taskId: string): Promise { try { await this.sidebarProvider.getTaskWithId(taskId) return true } catch { return false } } public async getTaskHistoryItem(taskId: string) { const item = this.sidebarProvider.taskHistoryStore.get(taskId) return item ? structuredClone(item) : undefined } public async getTaskApiConversationHistoryLength(taskId: string): Promise { try { const { apiConversationHistory } = await this.sidebarProvider.getTaskWithId(taskId) return apiConversationHistory.length } catch { return 0 } } /** Checks persisted turn ordering without exposing conversation contents to tests. */ public async hasTaskApiConversationHistorySequence( taskId: string, sequence: TaskApiConversationHistorySequence, ): Promise { let apiConversationHistory: Awaited>["apiConversationHistory"] try { const task = await this.sidebarProvider.getTaskWithId(taskId) apiConversationHistory = task.apiConversationHistory } catch { return false } const userTurnIndex = apiConversationHistory.findIndex( (message) => message.role === "user" && Array.isArray(message.content) && message.content.some((block) => block.type === "text" && block.text.includes(sequence.userText)), ) if (userTurnIndex < 0) return false // Search all assistant turns that belong to the same generation: between // this user turn and the next genuine user text turn (or end of history). // tool_result messages also use role:"user", so we must skip them — // stopping at a tool_result would cut the search short before a later // attempt_completion in the same generation. const nextUserIndex = apiConversationHistory.findIndex( (m, i) => i > userTurnIndex && m.role === "user" && Array.isArray(m.content) && m.content.some((block) => block.type === "text"), ) const generationSlice = apiConversationHistory.slice( userTurnIndex + 1, nextUserIndex < 0 ? undefined : nextUserIndex, ) return generationSlice.some( (message) => message.role === "assistant" && Array.isArray(message.content) && message.content.some( (block) => block.type === "tool_use" && block.name === sequence.assistantToolName && JSON.stringify(block.input).includes(sequence.assistantToolInputText), ), ) } /** Checks persisted ui_messages.json contents without exposing messages to tests. */ public async hasTaskUiMessageText(taskId: string, text: string): Promise { let uiMessages: Awaited> try { // Reject unknown ids first: readTaskMessages creates the task directory. await this.sidebarProvider.getTaskWithId(taskId) uiMessages = await readTaskMessages({ taskId, globalStoragePath: this.sidebarProvider.contextProxy.globalStorageUri.fsPath, }) } catch { return false } return uiMessages.some((message) => message.text?.includes(text)) } public getCurrentTaskStack() { return this.sidebarProvider.getCurrentTaskStack() } public async clearCurrentTask(_lastMessage?: string) { // Legacy finishSubTask removed; clear current by closing active task instance. await this.sidebarProvider.evictCurrentTask() await this.sidebarProvider.postStateToWebview() } public async cancelCurrentTask() { await this.sidebarProvider.cancelTask() } public async abandonSubtask(childTaskId: string): Promise { return this.sidebarProvider.abandonSubtask(childTaskId) } public async sendMessage(text?: string, images?: string[]) { const currentTask = this.sidebarProvider.getCurrentTask() // In headless/sandbox flows the webview may not be launched, so routing // through invoke=sendMessage drops the message. Deliver directly to the // task ask-response channel instead. if (!this.sidebarProvider.viewLaunched) { if (!currentTask) { this.log("[API#sendMessage] no current task in headless mode; message dropped") return } await currentTask.submitUserMessage(text ?? "", images) return } await this.sidebarProvider.postMessageToWebview({ type: "invoke", invoke: "sendMessage", text, images }) } public deleteQueuedMessage(messageId: string) { const currentTask = this.sidebarProvider.getCurrentTask() if (!currentTask) { this.log(`[API#deleteQueuedMessage] no current task; ignoring delete for messageId ${messageId}`) return } currentTask.messageQueueService.removeMessage(messageId) } public async pressPrimaryButton() { await this.sidebarProvider.postMessageToWebview({ type: "invoke", invoke: "primaryButtonClick" }) } public async pressSecondaryButton() { await this.sidebarProvider.postMessageToWebview({ type: "invoke", invoke: "secondaryButtonClick" }) } public async approveCurrentAsk() { this.sidebarProvider.getCurrentTask()?.approveAsk() } public isReady() { return this.sidebarProvider.viewLaunched } public captureWebviewThemeFixture(): Promise { return this.sidebarProvider.requestWebviewThemeFixture() } public getLatestAnnouncementId(): string { return this.sidebarProvider.latestAnnouncementId } private async waitForWebviewLaunch(timeoutMs: number): Promise { try { await pWaitFor(() => this.sidebarProvider.viewLaunched, { timeout: timeoutMs, interval: 50, }) return true } catch { this.log(`[API#waitForWebviewLaunch] webview did not launch within ${timeoutMs}ms`) return false } } private registerListeners(provider: ClineProvider) { provider.on(RooCodeEventName.TaskCompleted, async (taskId, tokenUsage, toolUsage) => { const historyItem = provider.taskHistoryStore.get(taskId) this.emit(RooCodeEventName.TaskCompleted, taskId, tokenUsage, toolUsage, { isSubtask: !!historyItem?.parentTaskId, }) await this.fileLog( `[${new Date().toISOString()}] taskCompleted -> ${taskId} | ${JSON.stringify(tokenUsage, null, 2)} | ${JSON.stringify(toolUsage, null, 2)}\n`, ) }) provider.on(RooCodeEventName.TaskCreated, (task) => { // Task Lifecycle task.on(RooCodeEventName.TaskStarted, async () => { this.emit(RooCodeEventName.TaskStarted, task.taskId) await this.fileLog(`[${new Date().toISOString()}] taskStarted -> ${task.taskId}\n`) }) task.on(RooCodeEventName.TaskAborted, () => { this.emit(RooCodeEventName.TaskAborted, task.taskId) }) task.on(RooCodeEventName.TaskFocused, () => { this.emit(RooCodeEventName.TaskFocused, task.taskId) }) task.on(RooCodeEventName.TaskUnfocused, () => { this.emit(RooCodeEventName.TaskUnfocused, task.taskId) }) task.on(RooCodeEventName.TaskActive, () => { this.emit(RooCodeEventName.TaskActive, task.taskId) }) task.on(RooCodeEventName.TaskInteractive, () => { this.emit(RooCodeEventName.TaskInteractive, task.taskId) }) task.on(RooCodeEventName.TaskResumable, () => { this.emit(RooCodeEventName.TaskResumable, task.taskId) }) task.on(RooCodeEventName.TaskIdle, () => { this.emit(RooCodeEventName.TaskIdle, task.taskId) }) // Subtask Lifecycle task.on(RooCodeEventName.TaskPaused, () => { this.emit(RooCodeEventName.TaskPaused, task.taskId) }) task.on(RooCodeEventName.TaskUnpaused, () => { this.emit(RooCodeEventName.TaskUnpaused, task.taskId) }) task.on(RooCodeEventName.TaskSpawned, (childTaskId) => { this.emit(RooCodeEventName.TaskSpawned, task.taskId, childTaskId) }) task.on(RooCodeEventName.TaskDelegated, (childTaskId: string) => { this.emit(RooCodeEventName.TaskDelegated, task.taskId, childTaskId) }) task.on(RooCodeEventName.TaskDelegationCompleted, (childTaskId: string, summary: string) => { this.emit(RooCodeEventName.TaskDelegationCompleted, task.taskId, childTaskId, summary) }) task.on(RooCodeEventName.TaskDelegationResumed, (childTaskId: string) => { this.emit(RooCodeEventName.TaskDelegationResumed, task.taskId, childTaskId) }) // Task Execution task.on(RooCodeEventName.Message, async (message) => { this.emit(RooCodeEventName.Message, { taskId: task.taskId, ...message }) if (message.message.partial !== true) { await this.fileLog(`[${new Date().toISOString()}] ${JSON.stringify(message.message, null, 2)}\n`) } }) task.on(RooCodeEventName.TaskModeSwitched, (taskId, mode) => { this.emit(RooCodeEventName.TaskModeSwitched, taskId, mode) }) task.on(RooCodeEventName.TaskAskResponded, () => { this.emit(RooCodeEventName.TaskAskResponded, task.taskId) }) task.on(RooCodeEventName.QueuedMessagesUpdated, (taskId, messages) => { this.emit(RooCodeEventName.QueuedMessagesUpdated, taskId, messages) }) // Task Analytics task.on(RooCodeEventName.TaskToolFailed, (taskId, tool, error) => { this.emit(RooCodeEventName.TaskToolFailed, taskId, tool, error) }) task.on(RooCodeEventName.TaskTokenUsageUpdated, (_, tokenUsage, toolUsage) => { this.emit(RooCodeEventName.TaskTokenUsageUpdated, task.taskId, tokenUsage, toolUsage) }) // Let's go! this.emit(RooCodeEventName.TaskCreated, task.taskId) }) // Delegation events are emitted by the provider, not by individual task instances. provider.on(RooCodeEventName.TaskDelegated, (parentTaskId, childTaskId) => { this.emit(RooCodeEventName.TaskDelegated, parentTaskId, childTaskId) }) provider.on(RooCodeEventName.TaskDelegationCompleted, (parentTaskId, childTaskId, summary) => { this.emit(RooCodeEventName.TaskDelegationCompleted, parentTaskId, childTaskId, summary) }) provider.on(RooCodeEventName.TaskDelegationResumed, (parentTaskId, childTaskId) => { this.emit(RooCodeEventName.TaskDelegationResumed, parentTaskId, childTaskId) }) } // Logging private outputChannelLog(...args: unknown[]) { for (const arg of args) { if (arg === null) { this.outputChannel.appendLine("null") } else if (arg === undefined) { this.outputChannel.appendLine("undefined") } else if (typeof arg === "string") { this.outputChannel.appendLine(arg) } else if (arg instanceof Error) { this.outputChannel.appendLine(`Error: ${arg.message}\n${arg.stack || ""}`) } else { try { this.outputChannel.appendLine( JSON.stringify( arg, (_key, value) => { if (typeof value === "bigint") return `BigInt(${value})` if (typeof value === "function") return `Function: ${value.name || "anonymous"}` if (typeof value === "symbol") return value.toString() return value }, 2, ), ) } catch { this.outputChannel.appendLine(`[Non-serializable object: ${Object.prototype.toString.call(arg)}]`) } } } } private async fileLog(message: string) { if (!this.logfile) { return } try { await fs.appendFile(this.logfile, message, "utf8") } catch (_) { this.logfile = undefined } } // Global Settings Management public getConfiguration(): RooCodeSettings { return Object.fromEntries( Object.entries(this.sidebarProvider.getValues()).filter(([key]) => !isSecretStateKey(key)), ) } /** * Stores the settings, then the API profile and the mode profiles. The settings are written with * `ContextProxy.setValues`, which is not a transaction (see there): if a setting cannot be stored, this rejects * with a `SettingsWriteError` that lists the keys that were stored and the keys that were not (the cache of the * failed keys has been read back from storage), the stored settings stay, and nothing after the settings is * written. Invalid values of the display settings are dropped and logged first. */ public async setConfiguration(allValues: RooCodeSettings) { const values = withoutInvalidDisplaySettings(allValues, (key) => this.sidebarProvider.log(`[setConfiguration] Ignored an invalid value for ${key}`), ) await this.sidebarProvider.contextProxy.setValues(values) await this.sidebarProvider.providerSettingsManager.saveConfig(values.currentApiConfigName || "default", values) if (values.modeApiConfigs) { await Promise.all( Object.entries(values.modeApiConfigs).map(([mode, configId]) => this.sidebarProvider.providerSettingsManager.setModeConfig(mode as Mode, configId), ), ) } await this.sidebarProvider.postStateToWebview() } public setTerminalProfile(name: string | undefined): void { const previousProfile = Terminal.getTerminalProfile() Terminal.setTerminalProfile(name) if (Terminal.getTerminalProfile() !== previousProfile) { TerminalRegistry.closeIdleTerminals() } } // Provider Profile Management public getProfiles(): string[] { return this.sidebarProvider.getProviderProfileEntries().map(({ name }) => name) } public getProfileEntry(name: string): ProviderSettingsEntry | undefined { return this.sidebarProvider.getProviderProfileEntry(name) } public async createProfile(name: string, profile?: ProviderSettings, activate: boolean = true) { const entry = this.getProfileEntry(name) if (entry) { throw new Error(`Profile with name "${name}" already exists`) } const id = await this.sidebarProvider.upsertProviderProfile(name, profile ?? {}, activate) if (!id) { throw new Error(`Failed to create profile with name "${name}"`) } return id } public async updateProfile( name: string, profile: ProviderSettings, activate: boolean = true, ): Promise { const entry = this.getProfileEntry(name) if (!entry) { throw new Error(`Profile with name "${name}" does not exist`) } const id = await this.sidebarProvider.upsertProviderProfile(name, profile, activate) if (!id) { throw new Error(`Failed to update profile with name "${name}"`) } return id } public async upsertProfile( name: string, profile: ProviderSettings, activate: boolean = true, ): Promise { const id = await this.sidebarProvider.upsertProviderProfile(name, profile, activate) if (!id) { throw new Error(`Failed to upsert profile with name "${name}"`) } return id } public async deleteProfile(name: string): Promise { const entry = this.getProfileEntry(name) if (!entry) { throw new Error(`Profile with name "${name}" does not exist`) } await this.sidebarProvider.deleteProviderProfile(entry) } public getActiveProfile(): string | undefined { return this.getConfiguration().currentApiConfigName } public async setActiveProfile(name: string): Promise { const entry = this.getProfileEntry(name) if (!entry) { throw new Error(`Profile with name "${name}" does not exist`) } await this.sidebarProvider.activateProviderProfile({ name }) return this.getActiveProfile() } }