diff --git a/packages/cloud/src/WebAuthService.ts b/packages/cloud/src/WebAuthService.ts index cb0e087547..e7c886ddcd 100644 --- a/packages/cloud/src/WebAuthService.ts +++ b/packages/cloud/src/WebAuthService.ts @@ -129,6 +129,7 @@ export class WebAuthService extends EventEmitter implements A private changeState(newState: AuthState): void { const previousState = this.state this.state = newState + this.log(`[auth] changeState: ${previousState} -> ${newState}`) this.emit("auth-state-changed", { state: newState, previousState }) } @@ -162,8 +163,6 @@ export class WebAuthService extends EventEmitter implements A this.userInfo = null this.changeState("logged-out") - - this.log("[auth] Transitioned to logged-out state") } private transitionToAttemptingSession(credentials: AuthCredentials): void { @@ -176,8 +175,6 @@ export class WebAuthService extends EventEmitter implements A this.changeState("attempting-session") this.timer.start() - - this.log("[auth] Transitioned to attempting-session state") } private transitionToInactiveSession(): void { @@ -185,8 +182,6 @@ export class WebAuthService extends EventEmitter implements A this.userInfo = null this.changeState("inactive-session") - - this.log("[auth] Transitioned to inactive-session state") } /** @@ -422,7 +417,6 @@ export class WebAuthService extends EventEmitter implements A if (previousState !== "active-session") { this.changeState("active-session") - this.log("[auth] Transitioned to active-session state") this.fetchUserInfo() } else { this.state = "active-session" diff --git a/packages/cloud/src/bridge/BaseChannel.ts b/packages/cloud/src/bridge/BaseChannel.ts index 90d3ebbe7a..45b9b525f6 100644 --- a/packages/cloud/src/bridge/BaseChannel.ts +++ b/packages/cloud/src/bridge/BaseChannel.ts @@ -1,13 +1,4 @@ import type { Socket } from "socket.io-client" -import * as vscode from "vscode" - -import type { StaticAppProperties, GitProperties } from "@roo-code/types" - -export interface BaseChannelOptions { - instanceId: string - appProperties: StaticAppProperties - gitProperties?: GitProperties -} /** * Abstract base class for communication channels in the bridge system. @@ -20,13 +11,9 @@ export interface BaseChannelOptions { export abstract class BaseChannel { protected socket: Socket | null = null protected readonly instanceId: string - protected readonly appProperties: StaticAppProperties - protected readonly gitProperties?: GitProperties - constructor(options: BaseChannelOptions) { - this.instanceId = options.instanceId - this.appProperties = options.appProperties - this.gitProperties = options.gitProperties + constructor(instanceId: string) { + this.instanceId = instanceId } /** @@ -94,26 +81,9 @@ export abstract class BaseChannel { - // Common functionality: focus the sidebar. - await vscode.commands.executeCommand(`${this.appProperties.appName}.SidebarProvider.focus`) - - // Delegate to subclass-specific implementation. - await this.handleCommandImplementation(command) - } - - /** - * Handle command-specific logic - must be implemented by subclasses. - * This method is called after common functionality has been executed. - */ - protected abstract handleCommandImplementation(command: TCommand): Promise + public abstract handleCommand(command: TCommand): void /** * Handle connection-specific logic. diff --git a/packages/cloud/src/bridge/BridgeOrchestrator.ts b/packages/cloud/src/bridge/BridgeOrchestrator.ts index 15b5c65eb2..73b757e5c8 100644 --- a/packages/cloud/src/bridge/BridgeOrchestrator.ts +++ b/packages/cloud/src/bridge/BridgeOrchestrator.ts @@ -1,5 +1,4 @@ import crypto from "crypto" -import os from "os" import { type TaskProviderLike, @@ -7,8 +6,6 @@ import { type CloudUserInfo, type ExtensionBridgeCommand, type TaskBridgeCommand, - type StaticAppProperties, - type GitProperties, ConnectionState, ExtensionSocketEvents, TaskSocketEvents, @@ -34,16 +31,12 @@ export interface BridgeOrchestratorOptions { export class BridgeOrchestrator { private static instance: BridgeOrchestrator | null = null - private static pendingTask: TaskLike | null = null - // Core private readonly userId: string private readonly socketBridgeUrl: string private readonly token: string private readonly provider: TaskProviderLike private readonly instanceId: string - private readonly appProperties: StaticAppProperties - private readonly gitProperties?: GitProperties // Components private socketTransport: SocketTransport @@ -68,86 +61,58 @@ export class BridgeOrchestrator { remoteControlEnabled: boolean | undefined, options: BridgeOrchestratorOptions, ): Promise { - if (BridgeOrchestrator.isEnabled(userInfo, remoteControlEnabled)) { - await BridgeOrchestrator.connect(options) - } else { - await BridgeOrchestrator.disconnect() - } - } - - public static async connect(options: BridgeOrchestratorOptions) { + const isEnabled = BridgeOrchestrator.isEnabled(userInfo, remoteControlEnabled) const instance = BridgeOrchestrator.instance - if (!instance) { - try { - console.log(`[BridgeOrchestrator#connectOrDisconnect] Connecting...`) - // Populate telemetry properties before registering the instance. - await options.provider.getTelemetryProperties() - - BridgeOrchestrator.instance = new BridgeOrchestrator(options) - await BridgeOrchestrator.instance.connect() - } catch (error) { - console.error( - `[BridgeOrchestrator#connectOrDisconnect] connect() failed: ${error instanceof Error ? error.message : String(error)}`, - ) - } - } else { - if ( - instance.connectionState === ConnectionState.FAILED || - instance.connectionState === ConnectionState.DISCONNECTED - ) { - console.log( - `[BridgeOrchestrator#connectOrDisconnect] Re-connecting... (state: ${instance.connectionState})`, - ) - - instance.reconnect().catch((error) => { + if (isEnabled) { + if (!instance) { + try { + console.log(`[BridgeOrchestrator#connectOrDisconnect] Connecting...`) + BridgeOrchestrator.instance = new BridgeOrchestrator(options) + await BridgeOrchestrator.instance.connect() + } catch (error) { console.error( - `[BridgeOrchestrator#connectOrDisconnect] reconnect() failed: ${error instanceof Error ? error.message : String(error)}`, + `[BridgeOrchestrator#connectOrDisconnect] connect() failed: ${error instanceof Error ? error.message : String(error)}`, ) - }) + } } else { - console.log( - `[BridgeOrchestrator#connectOrDisconnect] Already connected or connecting (state: ${instance.connectionState})`, - ) - } - } - } + if ( + instance.connectionState === ConnectionState.FAILED || + instance.connectionState === ConnectionState.DISCONNECTED + ) { + console.log( + `[BridgeOrchestrator#connectOrDisconnect] Re-connecting... (state: ${instance.connectionState})`, + ) - public static async disconnect() { - const instance = BridgeOrchestrator.instance - - if (instance) { - try { - console.log( - `[BridgeOrchestrator#connectOrDisconnect] Disconnecting... (state: ${instance.connectionState})`, - ) - - await instance.disconnect() - } catch (error) { - console.error( - `[BridgeOrchestrator#connectOrDisconnect] disconnect() failed: ${error instanceof Error ? error.message : String(error)}`, - ) - } finally { - BridgeOrchestrator.instance = null + instance.reconnect().catch((error) => { + console.error( + `[BridgeOrchestrator#connectOrDisconnect] reconnect() failed: ${error instanceof Error ? error.message : String(error)}`, + ) + }) + } else { + console.log( + `[BridgeOrchestrator#connectOrDisconnect] Already connected or connecting (state: ${instance.connectionState})`, + ) + } } } else { - console.log(`[BridgeOrchestrator#connectOrDisconnect] Already disconnected`) - } - } + if (instance) { + try { + console.log( + `[BridgeOrchestrator#connectOrDisconnect] Disconnecting... (state: ${instance.connectionState})`, + ) - /** - * @TODO: What if subtasks also get spawned? We'd probably want deferred - * subscriptions for those too. - */ - public static async subscribeToTask(task: TaskLike): Promise { - const instance = BridgeOrchestrator.instance - - if (instance && instance.socketTransport.isConnected()) { - console.log(`[BridgeOrchestrator#subscribeToTask] Subscribing to task ${task.taskId}`) - await instance.subscribeToTask(task) - } else { - console.log(`[BridgeOrchestrator#subscribeToTask] Deferring subscription for task ${task.taskId}`) - BridgeOrchestrator.pendingTask = task + await instance.disconnect() + } catch (error) { + console.error( + `[BridgeOrchestrator#connectOrDisconnect] disconnect() failed: ${error instanceof Error ? error.message : String(error)}`, + ) + } finally { + BridgeOrchestrator.instance = null + } + } else { + console.log(`[BridgeOrchestrator#connectOrDisconnect] Already disconnected`) + } } } @@ -157,8 +122,6 @@ export class BridgeOrchestrator { this.token = options.token this.provider = options.provider this.instanceId = options.sessionId || crypto.randomUUID() - this.appProperties = { ...options.provider.appProperties, hostname: os.hostname() } - this.gitProperties = options.provider.gitProperties this.socketTransport = new SocketTransport({ url: this.socketBridgeUrl, @@ -179,19 +142,8 @@ export class BridgeOrchestrator { onReconnect: () => this.handleReconnect(), }) - this.extensionChannel = new ExtensionChannel({ - instanceId: this.instanceId, - appProperties: this.appProperties, - gitProperties: this.gitProperties, - userId: this.userId, - provider: this.provider, - }) - - this.taskChannel = new TaskChannel({ - instanceId: this.instanceId, - appProperties: this.appProperties, - gitProperties: this.gitProperties, - }) + this.extensionChannel = new ExtensionChannel(this.instanceId, this.userId, this.provider) + this.taskChannel = new TaskChannel(this.instanceId) } private setupSocketListeners() { @@ -228,27 +180,12 @@ export class BridgeOrchestrator { const socket = this.socketTransport.getSocket() if (!socket) { - console.error("[BridgeOrchestrator#handleConnect] Socket not available") + console.error("[BridgeOrchestrator] Socket not available after connect") return } await this.extensionChannel.onConnect(socket) await this.taskChannel.onConnect(socket) - - if (BridgeOrchestrator.pendingTask) { - console.log( - `[BridgeOrchestrator#handleConnect] Subscribing to task ${BridgeOrchestrator.pendingTask.taskId}`, - ) - - try { - await this.subscribeToTask(BridgeOrchestrator.pendingTask) - BridgeOrchestrator.pendingTask = null - } catch (error) { - console.error( - `[BridgeOrchestrator#handleConnect] subscribeToTask() failed: ${error instanceof Error ? error.message : String(error)}`, - ) - } - } } private handleDisconnect() { @@ -312,6 +249,9 @@ export class BridgeOrchestrator { } private async connect(): Promise { + // Populate the app and git properties before registering the instance. + await this.provider.getTelemetryProperties() + await this.socketTransport.connect() this.setupSocketListeners() } @@ -321,7 +261,6 @@ export class BridgeOrchestrator { await this.taskChannel.cleanup(this.socketTransport.getSocket()) await this.socketTransport.disconnect() BridgeOrchestrator.instance = null - BridgeOrchestrator.pendingTask = null } public async reconnect(): Promise { diff --git a/packages/cloud/src/bridge/ExtensionBridgeService.ts b/packages/cloud/src/bridge/ExtensionBridgeService.ts deleted file mode 100644 index 0ab7e304f2..0000000000 --- a/packages/cloud/src/bridge/ExtensionBridgeService.ts +++ /dev/null @@ -1,290 +0,0 @@ -import crypto from "crypto" - -import { - type TaskProviderLike, - type TaskLike, - type CloudUserInfo, - type ExtensionBridgeCommand, - type TaskBridgeCommand, - ConnectionState, - ExtensionSocketEvents, - TaskSocketEvents, -} from "@roo-code/types" - -import { SocketConnectionManager } from "./SocketConnectionManager.js" -import { ExtensionManager } from "./ExtensionManager.js" -import { TaskManager } from "./TaskManager.js" - -export interface ExtensionBridgeServiceOptions { - userId: string - socketBridgeUrl: string - token: string - provider: TaskProviderLike - sessionId?: string -} - -export class ExtensionBridgeService { - private static instance: ExtensionBridgeService | null = null - - // Core - private readonly userId: string - private readonly socketBridgeUrl: string - private readonly token: string - private readonly provider: TaskProviderLike - private readonly instanceId: string - - // Managers - private connectionManager: SocketConnectionManager - private extensionManager: ExtensionManager - private taskManager: TaskManager - - // Reconnection - private readonly MAX_RECONNECT_ATTEMPTS = Infinity - private readonly RECONNECT_DELAY = 1_000 - private readonly RECONNECT_DELAY_MAX = 30_000 - - public static getInstance(): ExtensionBridgeService | null { - return ExtensionBridgeService.instance - } - - public static async createInstance(options: ExtensionBridgeServiceOptions) { - console.log("[ExtensionBridgeService] createInstance") - ExtensionBridgeService.instance = new ExtensionBridgeService(options) - await ExtensionBridgeService.instance.initialize() - return ExtensionBridgeService.instance - } - - public static resetInstance() { - if (ExtensionBridgeService.instance) { - console.log("[ExtensionBridgeService] resetInstance") - ExtensionBridgeService.instance.disconnect().catch(() => {}) - ExtensionBridgeService.instance = null - } - } - - public static async handleRemoteControlState( - userInfo: CloudUserInfo | null, - remoteControlEnabled: boolean | undefined, - options: ExtensionBridgeServiceOptions, - logger?: (message: string) => void, - ) { - if (userInfo?.extensionBridgeEnabled && remoteControlEnabled) { - const existingService = ExtensionBridgeService.getInstance() - - if (!existingService) { - try { - const service = await ExtensionBridgeService.createInstance(options) - const state = service.getConnectionState() - - logger?.(`[ExtensionBridgeService#handleRemoteControlState] Instance created (state: ${state})`) - - if (state !== ConnectionState.CONNECTED) { - logger?.( - `[ExtensionBridgeService#handleRemoteControlState] Service is not connected yet, will retry in background`, - ) - } - } catch (error) { - const message = `[ExtensionBridgeService#handleRemoteControlState] Failed to create instance: ${ - error instanceof Error ? error.message : String(error) - }` - - logger?.(message) - console.error(message) - } - } else { - const state = existingService.getConnectionState() - - if (state === ConnectionState.FAILED || state === ConnectionState.DISCONNECTED) { - logger?.( - `[ExtensionBridgeService#handleRemoteControlState] Existing service is ${state}, attempting reconnection`, - ) - - existingService.reconnect().catch((error) => { - const message = `[ExtensionBridgeService#handleRemoteControlState] Reconnection failed: ${ - error instanceof Error ? error.message : String(error) - }` - - logger?.(message) - console.error(message) - }) - } - } - } else { - const existingService = ExtensionBridgeService.getInstance() - - if (existingService) { - try { - await existingService.disconnect() - ExtensionBridgeService.resetInstance() - - logger?.(`[ExtensionBridgeService#handleRemoteControlState] Service disconnected and reset`) - } catch (error) { - const message = `[ExtensionBridgeService#handleRemoteControlState] Failed to disconnect and reset instance: ${ - error instanceof Error ? error.message : String(error) - }` - - logger?.(message) - console.error(message) - } - } - } - } - - private constructor(options: ExtensionBridgeServiceOptions) { - this.userId = options.userId - this.socketBridgeUrl = options.socketBridgeUrl - this.token = options.token - this.provider = options.provider - this.instanceId = options.sessionId || crypto.randomUUID() - - this.connectionManager = new SocketConnectionManager({ - url: this.socketBridgeUrl, - socketOptions: { - query: { - token: this.token, - clientType: "extension", - instanceId: this.instanceId, - }, - transports: ["websocket", "polling"], - reconnection: true, - reconnectionAttempts: this.MAX_RECONNECT_ATTEMPTS, - reconnectionDelay: this.RECONNECT_DELAY, - reconnectionDelayMax: this.RECONNECT_DELAY_MAX, - }, - onConnect: () => this.handleConnect(), - onDisconnect: () => this.handleDisconnect(), - onReconnect: () => this.handleReconnect(), - }) - - this.extensionManager = new ExtensionManager(this.instanceId, this.userId, this.provider) - - this.taskManager = new TaskManager() - } - - private async initialize() { - // Populate the app and git properties before registering the instance. - await this.provider.getTelemetryProperties() - - await this.connectionManager.connect() - this.setupSocketListeners() - } - - private setupSocketListeners() { - const socket = this.connectionManager.getSocket() - - if (!socket) { - console.error("[ExtensionBridgeService] Socket not available") - return - } - - // Remove any existing listeners first to prevent duplicates. - socket.off(ExtensionSocketEvents.RELAYED_COMMAND) - socket.off(TaskSocketEvents.RELAYED_COMMAND) - socket.off("connected") - - socket.on(ExtensionSocketEvents.RELAYED_COMMAND, (message: ExtensionBridgeCommand) => { - console.log( - `[ExtensionBridgeService] on(${ExtensionSocketEvents.RELAYED_COMMAND}) -> ${message.type} for ${message.instanceId}`, - ) - - this.extensionManager?.handleExtensionCommand(message) - }) - - socket.on(TaskSocketEvents.RELAYED_COMMAND, (message: TaskBridgeCommand) => { - console.log( - `[ExtensionBridgeService] on(${TaskSocketEvents.RELAYED_COMMAND}) -> ${message.type} for ${message.taskId}`, - ) - - this.taskManager.handleTaskCommand(message) - }) - } - - private async handleConnect() { - const socket = this.connectionManager.getSocket() - - if (!socket) { - console.error("[ExtensionBridgeService] Socket not available after connect") - - return - } - - await this.extensionManager.onConnect(socket) - await this.taskManager.onConnect(socket) - } - - private handleDisconnect() { - this.extensionManager.onDisconnect() - this.taskManager.onDisconnect() - } - - private async handleReconnect() { - const socket = this.connectionManager.getSocket() - - if (!socket) { - console.error("[ExtensionBridgeService] Socket not available after reconnect") - - return - } - - // Re-setup socket listeners to ensure they're properly configured - // after automatic reconnection (Socket.IO's built-in reconnection) - // The socket.off() calls in setupSocketListeners prevent duplicates - this.setupSocketListeners() - - await this.extensionManager.onReconnect(socket) - await this.taskManager.onReconnect(socket) - } - - // Task API - - public async subscribeToTask(task: TaskLike): Promise { - const socket = this.connectionManager.getSocket() - - if (!socket || !this.connectionManager.isConnected()) { - console.warn("[ExtensionBridgeService] Cannot subscribe to task: not connected. Will retry when connected.") - - this.taskManager.addPendingTask(task) - - const state = this.connectionManager.getConnectionState() - - if (state === ConnectionState.DISCONNECTED || state === ConnectionState.FAILED) { - this.initialize() - } - - return - } - - await this.taskManager.subscribeToTask(task, socket) - } - - public async unsubscribeFromTask(taskId: string): Promise { - const socket = this.connectionManager.getSocket() - - if (!socket) { - return - } - - await this.taskManager.unsubscribeFromTask(taskId, socket) - } - - // Shared API - - public getConnectionState(): ConnectionState { - return this.connectionManager.getConnectionState() - } - - public async disconnect(): Promise { - await this.extensionManager.cleanup(this.connectionManager.getSocket()) - await this.taskManager.cleanup(this.connectionManager.getSocket()) - await this.connectionManager.disconnect() - ExtensionBridgeService.instance = null - } - - public async reconnect(): Promise { - await this.connectionManager.reconnect() - - // After a manual reconnect, we have a new socket instance - // so we need to set up listeners again. - this.setupSocketListeners() - } -} diff --git a/packages/cloud/src/bridge/ExtensionChannel.ts b/packages/cloud/src/bridge/ExtensionChannel.ts index 72f62ffd92..99649f76f4 100644 --- a/packages/cloud/src/bridge/ExtensionChannel.ts +++ b/packages/cloud/src/bridge/ExtensionChannel.ts @@ -14,12 +14,7 @@ import { HEARTBEAT_INTERVAL_MS, } from "@roo-code/types" -import { type BaseChannelOptions, BaseChannel } from "./BaseChannel.js" - -interface ExtensionChannelOptions extends BaseChannelOptions { - userId: string - provider: TaskProviderLike -} +import { BaseChannel } from "./BaseChannel.js" /** * Manages the extension-level communication channel. @@ -36,36 +31,36 @@ export class ExtensionChannel extends BaseChannel< private heartbeatInterval: NodeJS.Timeout | null = null private eventListeners: Map void> = new Map() - constructor(options: ExtensionChannelOptions) { - super({ - instanceId: options.instanceId, - appProperties: options.appProperties, - gitProperties: options.gitProperties, - }) - - this.userId = options.userId - this.provider = options.provider + constructor(instanceId: string, userId: string, provider: TaskProviderLike) { + super(instanceId) + this.userId = userId + this.provider = provider this.extensionInstance = { instanceId: this.instanceId, userId: this.userId, workspacePath: this.provider.cwd, - appProperties: this.appProperties, - gitProperties: this.gitProperties, + appProperties: this.provider.appProperties, + gitProperties: this.provider.gitProperties, lastHeartbeat: Date.now(), - task: { taskId: "", taskStatus: TaskStatus.None }, + task: { + taskId: "", + taskStatus: TaskStatus.None, + }, taskHistory: [], } this.setupListeners() } - protected async handleCommandImplementation(command: ExtensionBridgeCommand): Promise { + /** + * Handle extension-specific commands from the web app + */ + public handleCommand(command: ExtensionBridgeCommand): void { if (command.instanceId !== this.instanceId) { console.log(`[ExtensionChannel] command -> instance id mismatch | ${this.instanceId}`, { messageInstanceId: command.instanceId, }) - return } @@ -74,22 +69,13 @@ export class ExtensionChannel extends BaseChannel< console.log(`[ExtensionChannel] command -> createTask() | ${command.instanceId}`, { text: command.payload.text?.substring(0, 100) + "...", hasImages: !!command.payload.images, - mode: command.payload.mode, - providerProfile: command.payload.providerProfile, }) - this.provider.createTask( - command.payload.text, - command.payload.images, - undefined, // parentTask - undefined, // options - { mode: command.payload.mode, currentApiConfigName: command.payload.providerProfile }, - ) - + this.provider.createTask(command.payload.text, command.payload.images) break } case ExtensionBridgeCommandName.StopTask: { - const instance = await this.updateInstance() + const instance = this.updateInstance() if (instance.task.taskStatus === TaskStatus.Running) { console.log(`[ExtensionChannel] command -> cancelTask() | ${command.instanceId}`) @@ -100,7 +86,6 @@ export class ExtensionChannel extends BaseChannel< this.provider.clearTask() this.provider.postStateToWebview() } - break } case ExtensionBridgeCommandName.ResumeTask: { @@ -108,6 +93,7 @@ export class ExtensionChannel extends BaseChannel< taskId: command.payload.taskId, }) + // Resume the task from history by taskId this.provider.resumeTask(command.payload.taskId) this.provider.postStateToWebview() break @@ -136,12 +122,12 @@ export class ExtensionChannel extends BaseChannel< } private async registerInstance(_socket: Socket): Promise { - const instance = await this.updateInstance() + const instance = this.updateInstance() await this.publish(ExtensionSocketEvents.REGISTER, instance) } private async unregisterInstance(_socket: Socket): Promise { - const instance = await this.updateInstance() + const instance = this.updateInstance() await this.publish(ExtensionSocketEvents.UNREGISTER, instance) } @@ -149,7 +135,7 @@ export class ExtensionChannel extends BaseChannel< this.stopHeartbeat() this.heartbeatInterval = setInterval(async () => { - const instance = await this.updateInstance() + const instance = this.updateInstance() try { socket.emit(ExtensionSocketEvents.HEARTBEAT, instance) @@ -183,19 +169,14 @@ export class ExtensionChannel extends BaseChannel< { from: RooCodeEventName.TaskInteractive, to: ExtensionBridgeEventName.TaskInteractive }, { from: RooCodeEventName.TaskResumable, to: ExtensionBridgeEventName.TaskResumable }, { from: RooCodeEventName.TaskIdle, to: ExtensionBridgeEventName.TaskIdle }, - { from: RooCodeEventName.TaskPaused, to: ExtensionBridgeEventName.TaskPaused }, - { from: RooCodeEventName.TaskUnpaused, to: ExtensionBridgeEventName.TaskUnpaused }, - { from: RooCodeEventName.TaskSpawned, to: ExtensionBridgeEventName.TaskSpawned }, - { from: RooCodeEventName.TaskUserMessage, to: ExtensionBridgeEventName.TaskUserMessage }, - { from: RooCodeEventName.TaskTokenUsageUpdated, to: ExtensionBridgeEventName.TaskTokenUsageUpdated }, ] as const eventMapping.forEach(({ from, to }) => { - // Create and store the listener function for cleanup. - const listener = async (..._args: unknown[]) => { + // Create and store the listener function for cleanup/ + const listener = (..._args: unknown[]) => { this.publish(ExtensionSocketEvents.EVENT, { type: to, - instance: await this.updateInstance(), + instance: this.updateInstance(), timestamp: Date.now(), }) } @@ -214,37 +195,24 @@ export class ExtensionChannel extends BaseChannel< this.eventListeners.clear() } - private async updateInstance(): Promise { + private updateInstance(): ExtensionInstance { const task = this.provider?.getCurrentTask() const taskHistory = this.provider?.getRecentTasks() ?? [] - const mode = await this.provider?.getMode() - const modes = (await this.provider?.getModes()) ?? [] - - const providerProfile = await this.provider?.getProviderProfile() - const providerProfiles = (await this.provider?.getProviderProfiles()) ?? [] - this.extensionInstance = { ...this.extensionInstance, + appProperties: this.extensionInstance.appProperties ?? this.provider.appProperties, + gitProperties: this.extensionInstance.gitProperties ?? this.provider.gitProperties, lastHeartbeat: Date.now(), task: task ? { taskId: task.taskId, - parentTaskId: task.parentTaskId, - childTaskId: task.childTaskId, taskStatus: task.taskStatus, - taskAsk: task?.taskAsk, - queuedMessages: task.queuedMessages, - tokenUsage: task.tokenUsage, ...task.metadata, } : { taskId: "", taskStatus: TaskStatus.None }, taskAsk: task?.taskAsk, taskHistory, - mode, - providerProfile, - modes, - providerProfiles, } return this.extensionInstance diff --git a/packages/cloud/src/bridge/ExtensionManager.ts b/packages/cloud/src/bridge/ExtensionManager.ts deleted file mode 100644 index 335245e24c..0000000000 --- a/packages/cloud/src/bridge/ExtensionManager.ts +++ /dev/null @@ -1,297 +0,0 @@ -import type { Socket } from "socket.io-client" - -import { - type TaskProviderLike, - type ExtensionInstance, - type ExtensionBridgeCommand, - type ExtensionBridgeEvent, - RooCodeEventName, - TaskStatus, - ExtensionBridgeCommandName, - ExtensionBridgeEventName, - ExtensionSocketEvents, - HEARTBEAT_INTERVAL_MS, -} from "@roo-code/types" - -export class ExtensionManager { - private instanceId: string - private userId: string - private provider: TaskProviderLike - private extensionInstance: ExtensionInstance - private heartbeatInterval: NodeJS.Timeout | null = null - private socket: Socket | null = null - - constructor(instanceId: string, userId: string, provider: TaskProviderLike) { - this.instanceId = instanceId - this.userId = userId - this.provider = provider - - this.extensionInstance = { - instanceId: this.instanceId, - userId: this.userId, - workspacePath: this.provider.cwd, - appProperties: this.provider.appProperties, - gitProperties: this.provider.gitProperties, - lastHeartbeat: Date.now(), - task: { - taskId: "", - taskStatus: TaskStatus.None, - }, - taskHistory: [], - } - - this.setupListeners() - } - - public async onConnect(socket: Socket): Promise { - this.socket = socket - await this.registerInstance(socket) - this.startHeartbeat(socket) - } - - public onDisconnect(): void { - this.stopHeartbeat() - this.socket = null - } - - public async onReconnect(socket: Socket): Promise { - this.socket = socket - await this.registerInstance(socket) - this.startHeartbeat(socket) - } - - public async cleanup(socket: Socket | null): Promise { - this.stopHeartbeat() - - if (socket) { - await this.unregisterInstance(socket) - } - - this.socket = null - } - - public handleExtensionCommand(message: ExtensionBridgeCommand): void { - if (message.instanceId !== this.instanceId) { - console.log(`[ExtensionManager] command -> instance id mismatch | ${this.instanceId}`, { - messageInstanceId: message.instanceId, - }) - - return - } - - switch (message.type) { - case ExtensionBridgeCommandName.StartTask: { - console.log(`[ExtensionManager] command -> createTask() | ${message.instanceId}`, { - text: message.payload.text?.substring(0, 100) + "...", - hasImages: !!message.payload.images, - }) - - this.provider.createTask(message.payload.text, message.payload.images) - - break - } - case ExtensionBridgeCommandName.StopTask: { - const instance = this.updateInstance() - - if (instance.task.taskStatus === TaskStatus.Running) { - console.log(`[ExtensionManager] command -> cancelTask() | ${message.instanceId}`) - - this.provider.cancelTask() - this.provider.postStateToWebview() - } else if (instance.task.taskId) { - console.log(`[ExtensionManager] command -> clearTask() | ${message.instanceId}`) - - this.provider.clearTask() - this.provider.postStateToWebview() - } - - break - } - case ExtensionBridgeCommandName.ResumeTask: { - console.log(`[ExtensionManager] command -> resumeTask() | ${message.instanceId}`, { - taskId: message.payload.taskId, - }) - - // Resume the task from history by taskId - this.provider.resumeTask(message.payload.taskId) - - this.provider.postStateToWebview() - - break - } - } - } - - private async registerInstance(socket: Socket): Promise { - const instance = this.updateInstance() - - try { - socket.emit(ExtensionSocketEvents.REGISTER, instance) - - console.log( - `[ExtensionManager] emit() -> ${ExtensionSocketEvents.REGISTER}`, - // instance, - ) - } catch (error) { - console.error( - `[ExtensionManager] emit() failed -> ${ExtensionSocketEvents.REGISTER}: ${ - error instanceof Error ? error.message : String(error) - }`, - ) - - return - } - } - - private async unregisterInstance(socket: Socket): Promise { - const instance = this.updateInstance() - - try { - socket.emit(ExtensionSocketEvents.UNREGISTER, instance) - - console.log( - `[ExtensionManager] emit() -> ${ExtensionSocketEvents.UNREGISTER}`, - // instance, - ) - } catch (error) { - console.error( - `[ExtensionManager] emit() failed -> ${ExtensionSocketEvents.UNREGISTER}: ${ - error instanceof Error ? error.message : String(error) - }`, - ) - } - } - - private startHeartbeat(socket: Socket): void { - this.stopHeartbeat() - - this.heartbeatInterval = setInterval(async () => { - const instance = this.updateInstance() - - try { - socket.emit(ExtensionSocketEvents.HEARTBEAT, instance) - - // console.log( - // `[ExtensionManager] emit() -> ${ExtensionSocketEvents.HEARTBEAT}`, - // instance, - // ); - } catch (error) { - console.error( - `[ExtensionManager] emit() failed -> ${ExtensionSocketEvents.HEARTBEAT}: ${ - error instanceof Error ? error.message : String(error) - }`, - ) - } - }, HEARTBEAT_INTERVAL_MS) - } - - private stopHeartbeat(): void { - if (this.heartbeatInterval) { - clearInterval(this.heartbeatInterval) - this.heartbeatInterval = null - } - } - - private setupListeners(): void { - const eventMapping = [ - { - from: RooCodeEventName.TaskCreated, - to: ExtensionBridgeEventName.TaskCreated, - }, - { - from: RooCodeEventName.TaskStarted, - to: ExtensionBridgeEventName.TaskStarted, - }, - { - from: RooCodeEventName.TaskCompleted, - to: ExtensionBridgeEventName.TaskCompleted, - }, - { - from: RooCodeEventName.TaskAborted, - to: ExtensionBridgeEventName.TaskAborted, - }, - { - from: RooCodeEventName.TaskFocused, - to: ExtensionBridgeEventName.TaskFocused, - }, - { - from: RooCodeEventName.TaskUnfocused, - to: ExtensionBridgeEventName.TaskUnfocused, - }, - { - from: RooCodeEventName.TaskActive, - to: ExtensionBridgeEventName.TaskActive, - }, - { - from: RooCodeEventName.TaskInteractive, - to: ExtensionBridgeEventName.TaskInteractive, - }, - { - from: RooCodeEventName.TaskResumable, - to: ExtensionBridgeEventName.TaskResumable, - }, - { - from: RooCodeEventName.TaskIdle, - to: ExtensionBridgeEventName.TaskIdle, - }, - ] as const - - const addListener = - (type: ExtensionBridgeEventName) => - async (..._args: unknown[]) => { - this.publishEvent({ - type, - instance: this.updateInstance(), - timestamp: Date.now(), - }) - } - - eventMapping.forEach(({ from, to }) => this.provider.on(from, addListener(to))) - } - - private async publishEvent(message: ExtensionBridgeEvent): Promise { - if (!this.socket) { - console.error("[ExtensionManager] publishEvent -> socket not available") - return false - } - - try { - this.socket.emit(ExtensionSocketEvents.EVENT, message) - - console.log(`[ExtensionManager] emit() -> ${ExtensionSocketEvents.EVENT} ${message.type}`, message) - - return true - } catch (error) { - console.error( - `[ExtensionManager] emit() failed -> ${ExtensionSocketEvents.EVENT}: ${ - error instanceof Error ? error.message : String(error) - }`, - ) - - return false - } - } - - private updateInstance(): ExtensionInstance { - const task = this.provider?.getCurrentTask() - const taskHistory = this.provider?.getRecentTasks() ?? [] - - this.extensionInstance = { - ...this.extensionInstance, - appProperties: this.extensionInstance.appProperties ?? this.provider.appProperties, - gitProperties: this.extensionInstance.gitProperties ?? this.provider.gitProperties, - lastHeartbeat: Date.now(), - task: task - ? { - taskId: task.taskId, - taskStatus: task.taskStatus, - ...task.metadata, - } - : { taskId: "", taskStatus: TaskStatus.None }, - taskAsk: task?.taskAsk, - taskHistory, - } - - return this.extensionInstance - } -} diff --git a/packages/cloud/src/bridge/SocketConnectionManager.ts b/packages/cloud/src/bridge/SocketConnectionManager.ts deleted file mode 100644 index 3ba9631fec..0000000000 --- a/packages/cloud/src/bridge/SocketConnectionManager.ts +++ /dev/null @@ -1,289 +0,0 @@ -import { io, type Socket } from "socket.io-client" - -import { ConnectionState, type RetryConfig } from "@roo-code/types" - -export interface SocketConnectionOptions { - url: string - socketOptions: Record - onConnect?: () => void | Promise - onDisconnect?: (reason: string) => void - onReconnect?: (attemptNumber: number) => void | Promise - onError?: (error: Error) => void - logger?: { - log: (message: string, ...args: unknown[]) => void - error: (message: string, ...args: unknown[]) => void - warn: (message: string, ...args: unknown[]) => void - } -} - -export class SocketConnectionManager { - private socket: Socket | null = null - private connectionState: ConnectionState = ConnectionState.DISCONNECTED - private retryAttempt: number = 0 - private retryTimeout: NodeJS.Timeout | null = null - private hasConnectedOnce: boolean = false - - private readonly retryConfig: RetryConfig = { - maxInitialAttempts: 10, - initialDelay: 1_000, - maxDelay: 15_000, - backoffMultiplier: 2, - } - - private readonly CONNECTION_TIMEOUT = 2_000 - private readonly options: SocketConnectionOptions - - constructor(options: SocketConnectionOptions, retryConfig?: Partial) { - this.options = options - - if (retryConfig) { - this.retryConfig = { ...this.retryConfig, ...retryConfig } - } - } - - public async connect(): Promise { - if (this.connectionState === ConnectionState.CONNECTED) { - console.log(`[SocketConnectionManager] Already connected`) - return - } - - if (this.connectionState === ConnectionState.CONNECTING || this.connectionState === ConnectionState.RETRYING) { - console.log(`[SocketConnectionManager] Connection attempt already in progress`) - - return - } - - // Start connection attempt without blocking. - this.startConnectionAttempt() - } - - private async startConnectionAttempt() { - this.retryAttempt = 0 - - try { - await this.connectWithRetry() - } catch (error) { - console.error(`[SocketConnectionManager] Initial connection attempts failed:`, error) - - // If we've never connected successfully, we've exhausted our retry attempts - // The user will need to manually retry or fix the issue - this.connectionState = ConnectionState.FAILED - } - } - - private async connectWithRetry(): Promise { - let delay = this.retryConfig.initialDelay - - while (this.retryAttempt < this.retryConfig.maxInitialAttempts) { - try { - this.connectionState = this.retryAttempt === 0 ? ConnectionState.CONNECTING : ConnectionState.RETRYING - - console.log( - `[SocketConnectionManager] Connection attempt ${this.retryAttempt + 1} / ${this.retryConfig.maxInitialAttempts}`, - ) - - await this.connectSocket() - - console.log(`[SocketConnectionManager] Connected to ${this.options.url}`) - - this.connectionState = ConnectionState.CONNECTED - this.retryAttempt = 0 - - this.clearRetryTimeouts() - - if (this.options.onConnect) { - await this.options.onConnect() - } - - return - } catch (error) { - this.retryAttempt++ - - console.error(`[SocketConnectionManager] Connection attempt ${this.retryAttempt} failed:`, error) - - if (this.socket) { - this.socket.disconnect() - this.socket = null - } - - if (this.retryAttempt >= this.retryConfig.maxInitialAttempts) { - this.connectionState = ConnectionState.FAILED - - throw new Error(`Failed to connect after ${this.retryConfig.maxInitialAttempts} attempts`) - } - - console.log(`[SocketConnectionManager] Waiting ${delay}ms before retry...`) - - await this.delay(delay) - - delay = Math.min(delay * this.retryConfig.backoffMultiplier, this.retryConfig.maxDelay) - } - } - } - - private async connectSocket(): Promise { - return new Promise((resolve, reject) => { - this.socket = io(this.options.url, this.options.socketOptions) - - const connectionTimeout = setTimeout(() => { - console.error(`[SocketConnectionManager] Connection timeout`) - - if (this.connectionState !== ConnectionState.CONNECTED) { - this.socket?.disconnect() - reject(new Error("Connection timeout")) - } - }, this.CONNECTION_TIMEOUT) - - this.socket.on("connect", async () => { - clearTimeout(connectionTimeout) - - const isReconnection = this.hasConnectedOnce - - // If this is a reconnection (not the first connect), treat it as a - // reconnect. - // This handles server restarts where 'reconnect' event might not fire. - if (isReconnection) { - console.log( - `[SocketConnectionManager] Treating connect as reconnection (server may have restarted)`, - ) - - this.connectionState = ConnectionState.CONNECTED - - if (this.options.onReconnect) { - // Call onReconnect to re-register instance. - await this.options.onReconnect(0) - } - } - - this.hasConnectedOnce = true - resolve() - }) - - this.socket.on("disconnect", (reason: string) => { - console.log(`[SocketConnectionManager] Disconnected (reason: ${reason})`) - - this.connectionState = ConnectionState.DISCONNECTED - - if (this.options.onDisconnect) { - this.options.onDisconnect(reason) - } - - // Don't attempt to reconnect if we're manually disconnecting. - const isManualDisconnect = reason === "io client disconnect" - - if (!isManualDisconnect && this.hasConnectedOnce) { - // After successful initial connection, rely entirely on Socket.IO's - // reconnection. - console.log(`[SocketConnectionManager] Socket.IO will handle reconnection (reason: ${reason})`) - } - }) - - // Listen for reconnection attempts. - this.socket.on("reconnect_attempt", (attemptNumber: number) => { - console.log(`[SocketConnectionManager] Socket.IO reconnect attempt:`, { - attemptNumber, - }) - }) - - this.socket.on("reconnect", (attemptNumber: number) => { - console.log(`[SocketConnectionManager] Socket reconnected (attempt: ${attemptNumber})`) - - this.connectionState = ConnectionState.CONNECTED - - if (this.options.onReconnect) { - this.options.onReconnect(attemptNumber) - } - }) - - this.socket.on("reconnect_error", (error: Error) => { - console.error(`[SocketConnectionManager] Socket.IO reconnect error:`, error) - }) - - this.socket.on("reconnect_failed", () => { - console.error(`[SocketConnectionManager] Socket.IO reconnection failed after all attempts`) - - this.connectionState = ConnectionState.FAILED - - // Socket.IO has exhausted its reconnection attempts - // The connection is now permanently failed until manual intervention - }) - - this.socket.on("error", (error) => { - console.error(`[SocketConnectionManager] Socket error:`, error) - - if (this.connectionState !== ConnectionState.CONNECTED) { - clearTimeout(connectionTimeout) - reject(error) - } - - if (this.options.onError) { - this.options.onError(error) - } - }) - - this.socket.on("auth_error", (error) => { - console.error(`[SocketConnectionManager] Authentication error:`, error) - clearTimeout(connectionTimeout) - reject(new Error(error.message || "Authentication failed")) - }) - }) - } - - private delay(ms: number): Promise { - return new Promise((resolve) => { - this.retryTimeout = setTimeout(resolve, ms) - }) - } - - // 1. Custom retry for initial connection attempts. - // 2. Socket.IO's built-in reconnection after successful initial connection. - - private clearRetryTimeouts() { - if (this.retryTimeout) { - clearTimeout(this.retryTimeout) - this.retryTimeout = null - } - } - - public async disconnect(): Promise { - console.log(`[SocketConnectionManager] Disconnecting...`) - - this.clearRetryTimeouts() - - if (this.socket) { - this.socket.removeAllListeners() - this.socket.disconnect() - this.socket = null - } - - this.connectionState = ConnectionState.DISCONNECTED - - console.log(`[SocketConnectionManager] Disconnected`) - } - - public getSocket(): Socket | null { - return this.socket - } - - public getConnectionState(): ConnectionState { - return this.connectionState - } - - public isConnected(): boolean { - return this.connectionState === ConnectionState.CONNECTED && this.socket?.connected === true - } - - public async reconnect(): Promise { - if (this.connectionState === ConnectionState.CONNECTED) { - console.log(`[SocketConnectionManager] Already connected`) - return - } - - console.log(`[SocketConnectionManager] Manual reconnection requested`) - - this.hasConnectedOnce = false - - await this.disconnect() - await this.connect() - } -} diff --git a/packages/cloud/src/bridge/SocketTransport.ts b/packages/cloud/src/bridge/SocketTransport.ts index 2df3cf95eb..5fb40e989c 100644 --- a/packages/cloud/src/bridge/SocketTransport.ts +++ b/packages/cloud/src/bridge/SocketTransport.ts @@ -7,7 +7,8 @@ export interface SocketTransportOptions { socketOptions: Partial onConnect?: () => void | Promise onDisconnect?: (reason: string) => void - onReconnect?: () => void | Promise + onReconnect?: (attemptNumber: number) => void | Promise + onError?: (error: Error) => void logger?: { log: (message: string, ...args: unknown[]) => void error: (message: string, ...args: unknown[]) => void @@ -22,11 +23,12 @@ export interface SocketTransportOptions { export class SocketTransport { private socket: Socket | null = null private connectionState: ConnectionState = ConnectionState.DISCONNECTED + private retryAttempt: number = 0 private retryTimeout: NodeJS.Timeout | null = null - private isPreviouslyConnected: boolean = false + private hasConnectedOnce: boolean = false private readonly retryConfig: RetryConfig = { - maxInitialAttempts: Infinity, + maxInitialAttempts: 10, initialDelay: 1_000, maxDelay: 15_000, backoffMultiplier: 2, @@ -43,68 +45,93 @@ export class SocketTransport { } } - // This is the initial connnect attempt. We need to implement our own - // infinite retry mechanism since Socket.io's automatic reconnection only - // kicks in after a successful initial connection. public async connect(): Promise { if (this.connectionState === ConnectionState.CONNECTED) { - console.log(`[SocketTransport#connect] Already connected`) + console.log(`[SocketTransport] Already connected`) return } if (this.connectionState === ConnectionState.CONNECTING || this.connectionState === ConnectionState.RETRYING) { - console.log(`[SocketTransport#connect] Already in progress`) + console.log(`[SocketTransport] Connection attempt already in progress`) return } - let attempt = 0 + // Start connection attempt without blocking. + this.startConnectionAttempt() + } + + private async startConnectionAttempt() { + this.retryAttempt = 0 + + try { + await this.connectWithRetry() + } catch (error) { + console.error( + `[SocketTransport] Initial connection attempts failed: ${error instanceof Error ? error.message : String(error)}`, + ) + + // If we've never connected successfully, we've exhausted our retry attempts + // The user will need to manually retry or fix the issue + this.connectionState = ConnectionState.FAILED + } + } + + private async connectWithRetry(): Promise { let delay = this.retryConfig.initialDelay - while (attempt < this.retryConfig.maxInitialAttempts) { - console.log(`[SocketTransport#connect] attempt = ${attempt + 1}, delay = ${delay}ms`) - this.connectionState = attempt === 0 ? ConnectionState.CONNECTING : ConnectionState.RETRYING - + while (this.retryAttempt < this.retryConfig.maxInitialAttempts) { try { - await this._connect() - break - } catch (_error) { - attempt++ + this.connectionState = this.retryAttempt === 0 ? ConnectionState.CONNECTING : ConnectionState.RETRYING + + console.log( + `[SocketTransport] Connection attempt ${this.retryAttempt + 1} / ${this.retryConfig.maxInitialAttempts}`, + ) + + await this.connectSocket() + + console.log(`[SocketTransport] Connected to ${this.options.url}`) + + this.connectionState = ConnectionState.CONNECTED + this.retryAttempt = 0 + + this.clearRetryTimeouts() + + if (this.options.onConnect) { + await this.options.onConnect() + } + + return + } catch (error) { + this.retryAttempt++ + + console.error(`[SocketTransport] Connection attempt ${this.retryAttempt} failed:`, error) if (this.socket) { this.socket.disconnect() this.socket = null } - const promise = new Promise((resolve) => { - this.retryTimeout = setTimeout(resolve, delay) - }) + if (this.retryAttempt >= this.retryConfig.maxInitialAttempts) { + this.connectionState = ConnectionState.FAILED - await promise + throw new Error(`Failed to connect after ${this.retryConfig.maxInitialAttempts} attempts`) + } + + console.log(`[SocketTransport] Waiting ${delay}ms before retry...`) + + await this.delay(delay) delay = Math.min(delay * this.retryConfig.backoffMultiplier, this.retryConfig.maxDelay) } } - - if (this.retryTimeout) { - clearTimeout(this.retryTimeout) - this.retryTimeout = null - } - - if (this.socket?.connected) { - console.log(`[SocketTransport#connect] connected - ${this.options.url}`) - } else { - // Since we have infinite retries this should never happen. - this.connectionState = ConnectionState.FAILED - console.error(`[SocketTransport#connect] Giving up`) - } } - private async _connect(): Promise { + private async connectSocket(): Promise { return new Promise((resolve, reject) => { this.socket = io(this.options.url, this.options.socketOptions) - let connectionTimeout: NodeJS.Timeout | null = setTimeout(() => { - console.error(`[SocketTransport#_connect] failed to connect after ${this.CONNECTION_TIMEOUT}ms`) + const connectionTimeout = setTimeout(() => { + console.error(`[SocketTransport] Connection timeout`) if (this.connectionState !== ConnectionState.CONNECTED) { this.socket?.disconnect() @@ -112,48 +139,31 @@ export class SocketTransport { } }, this.CONNECTION_TIMEOUT) - // https://socket.io/docs/v4/client-api/#event-connect this.socket.on("connect", async () => { - console.log( - `[SocketTransport#_connect] on(connect): isPreviouslyConnected = ${this.isPreviouslyConnected}`, - ) + clearTimeout(connectionTimeout) - if (connectionTimeout) { - clearTimeout(connectionTimeout) - connectionTimeout = null - } + const isReconnection = this.hasConnectedOnce - this.connectionState = ConnectionState.CONNECTED + // If this is a reconnection (not the first connect), treat it as a + // reconnect. This handles server restarts where 'reconnect' event might not fire. + if (isReconnection) { + console.log(`[SocketTransport] Treating connect as reconnection (server may have restarted)`) + + this.connectionState = ConnectionState.CONNECTED - if (this.isPreviouslyConnected) { if (this.options.onReconnect) { - await this.options.onReconnect() - } - } else { - if (this.options.onConnect) { - await this.options.onConnect() + // Call onReconnect to re-register instance. + await this.options.onReconnect(0) } } - this.isPreviouslyConnected = true + this.hasConnectedOnce = true resolve() }) - // https://socket.io/docs/v4/client-api/#event-connect_error - this.socket.on("connect_error", (error) => { - if (connectionTimeout && this.connectionState !== ConnectionState.CONNECTED) { - console.error(`[SocketTransport] on(connect_error): ${error.message}`) - clearTimeout(connectionTimeout) - connectionTimeout = null - reject(error) - } - }) + this.socket.on("disconnect", (reason: string) => { + console.log(`[SocketTransport] Disconnected (reason: ${reason})`) - // https://socket.io/docs/v4/client-api/#event-disconnect - this.socket.on("disconnect", (reason, details) => { - console.log( - `[SocketTransport#_connect] on(disconnect) (reason: ${reason}, details: ${JSON.stringify(details)})`, - ) this.connectionState = ConnectionState.DISCONNECTED if (this.options.onDisconnect) { @@ -163,95 +173,91 @@ export class SocketTransport { // Don't attempt to reconnect if we're manually disconnecting. const isManualDisconnect = reason === "io client disconnect" - if (!isManualDisconnect && this.isPreviouslyConnected) { - // After successful initial connection, rely entirely on - // Socket.IO's reconnection logic. - console.log("[SocketTransport#_connect] will attempt to reconnect") - } else { - console.log("[SocketTransport#_connect] will *NOT* attempt to reconnect") + if (!isManualDisconnect && this.hasConnectedOnce) { + // After successful initial connection, rely entirely on Socket.IO's + // reconnection. + console.log(`[SocketTransport] Socket.IO will handle reconnection (reason: ${reason})`) } }) - // https://socket.io/docs/v4/client-api/#event-error - // Fired upon a connection error. - this.socket.io.on("error", (error) => { - // Connection error. - if (connectionTimeout && this.connectionState !== ConnectionState.CONNECTED) { - console.error(`[SocketTransport#_connect] on(error): ${error.message}`) - clearTimeout(connectionTimeout) - connectionTimeout = null - reject(error) - } - - // Post-connection error. - if (this.connectionState === ConnectionState.CONNECTED) { - console.error(`[SocketTransport#_connect] on(error): ${error.message}`) - } + // Listen for reconnection attempts. + this.socket.on("reconnect_attempt", (attemptNumber: number) => { + console.log(`[SocketTransport] Socket.IO reconnect attempt:`, { + attemptNumber, + }) }) - // https://socket.io/docs/v4/client-api/#event-reconnect - // Fired upon a successful reconnection. - this.socket.io.on("reconnect", (attempt) => { - console.log(`[SocketTransport#_connect] on(reconnect) - ${attempt}`) + this.socket.on("reconnect", (attemptNumber: number) => { + console.log(`[SocketTransport] Socket reconnected (attempt: ${attemptNumber})`) + this.connectionState = ConnectionState.CONNECTED if (this.options.onReconnect) { - this.options.onReconnect() + this.options.onReconnect(attemptNumber) } }) - // https://socket.io/docs/v4/client-api/#event-reconnect_attempt - // Fired upon an attempt to reconnect. - this.socket.io.on("reconnect_attempt", (attempt) => { - console.log(`[SocketTransport#_connect] on(reconnect_attempt) - ${attempt}`) + this.socket.on("reconnect_error", (error: Error) => { + console.error(`[SocketTransport] Socket.IO reconnect error:`, error) }) - // https://socket.io/docs/v4/client-api/#event-reconnect_error - // Fired upon a reconnection attempt error. - this.socket.io.on("reconnect_error", (error) => { - console.error(`[SocketTransport#_connect] on(reconnect_error): ${error.message}`) - }) + this.socket.on("reconnect_failed", () => { + console.error(`[SocketTransport] Socket.IO reconnection failed after all attempts`) - // https://socket.io/docs/v4/client-api/#event-reconnect_failed - // Fired when couldn't reconnect within `reconnectionAttempts`. - // Since we use infinite retries, this should never fire. - this.socket.io.on("reconnect_failed", () => { - console.error(`[SocketTransport#_connect] on(reconnect_failed) - giving up`) this.connectionState = ConnectionState.FAILED + + // Socket.IO has exhausted its reconnection attempts + // The connection is now permanently failed until manual intervention }) - // This is a custom event fired by the server. - this.socket.on("auth_error", (error) => { - console.error( - `[SocketTransport#_connect] on(auth_error): ${error instanceof Error ? error.message : String(error)}`, - ) + this.socket.on("error", (error) => { + console.error(`[SocketTransport] Socket error:`, error) - if (connectionTimeout && this.connectionState !== ConnectionState.CONNECTED) { + if (this.connectionState !== ConnectionState.CONNECTED) { clearTimeout(connectionTimeout) - connectionTimeout = null - reject(new Error(error.message || "Authentication failed")) + reject(error) } + + if (this.options.onError) { + this.options.onError(error) + } + }) + + this.socket.on("auth_error", (error) => { + console.error(`[SocketTransport] Authentication error:`, error) + clearTimeout(connectionTimeout) + reject(new Error(error.message || "Authentication failed")) }) }) } - public async disconnect(): Promise { - console.log(`[SocketTransport#disconnect] Disconnecting...`) + private delay(ms: number): Promise { + return new Promise((resolve) => { + this.retryTimeout = setTimeout(resolve, ms) + }) + } + private clearRetryTimeouts() { if (this.retryTimeout) { clearTimeout(this.retryTimeout) this.retryTimeout = null } + } + + public async disconnect(): Promise { + console.log(`[SocketTransport] Disconnecting...`) + + this.clearRetryTimeouts() if (this.socket) { this.socket.removeAllListeners() - this.socket.io.removeAllListeners() this.socket.disconnect() this.socket = null } this.connectionState = ConnectionState.DISCONNECTED - console.log(`[SocketTransport#disconnect] Disconnected`) + + console.log(`[SocketTransport] Disconnected`) } public getSocket(): Socket | null { @@ -267,14 +273,15 @@ export class SocketTransport { } public async reconnect(): Promise { - console.log(`[SocketTransport#reconnect] Manually reconnecting...`) - if (this.connectionState === ConnectionState.CONNECTED) { - console.log(`[SocketTransport#reconnect] Already connected`) + console.log(`[SocketTransport] Already connected`) return } - this.isPreviouslyConnected = false + console.log(`[SocketTransport] Manual reconnection requested`) + + this.hasConnectedOnce = false + await this.disconnect() await this.connect() } diff --git a/packages/cloud/src/bridge/TaskChannel.ts b/packages/cloud/src/bridge/TaskChannel.ts index 433e740d4e..f4656dc6d2 100644 --- a/packages/cloud/src/bridge/TaskChannel.ts +++ b/packages/cloud/src/bridge/TaskChannel.ts @@ -14,7 +14,7 @@ import { TaskSocketEvents, } from "@roo-code/types" -import { type BaseChannelOptions, BaseChannel } from "./BaseChannel.js" +import { BaseChannel } from "./BaseChannel.js" type TaskEventListener = { [K in keyof TaskEvents]: (...args: TaskEvents[K]) => void | Promise @@ -26,9 +26,6 @@ type TaskEventMapping = { createPayload: (task: TaskLike, ...args: any[]) => any // eslint-disable-line @typescript-eslint/no-explicit-any } -// eslint-disable-next-line @typescript-eslint/no-empty-object-type -interface TaskChannelOptions extends BaseChannelOptions {} - /** * Manages task-level communication channels. * Handles task subscriptions, messaging, and task-specific commands. @@ -72,11 +69,11 @@ export class TaskChannel extends BaseChannel< }, ] as const - constructor(options: TaskChannelOptions) { - super(options) + constructor(instanceId: string) { + super(instanceId) } - protected async handleCommandImplementation(command: TaskBridgeCommand): Promise { + public handleCommand(command: TaskBridgeCommand): void { const task = this.subscribedTasks.get(command.taskId) if (!task) { @@ -90,14 +87,7 @@ export class TaskChannel extends BaseChannel< `[TaskChannel] ${TaskBridgeCommandName.Message} ${command.taskId} -> submitUserMessage()`, command, ) - - await task.submitUserMessage( - command.payload.text, - command.payload.images, - command.payload.mode, - command.payload.providerProfile, - ) - + task.submitUserMessage(command.payload.text, command.payload.images) break case TaskBridgeCommandName.ApproveAsk: @@ -105,7 +95,6 @@ export class TaskChannel extends BaseChannel< `[TaskChannel] ${TaskBridgeCommandName.ApproveAsk} ${command.taskId} -> approveAsk()`, command, ) - task.approveAsk(command.payload) break @@ -174,27 +163,25 @@ export class TaskChannel extends BaseChannel< public async unsubscribeFromTask(taskId: string, _socket: Socket): Promise { const task = this.subscribedTasks.get(taskId) - if (!task) { - return - } - await this.publish(TaskSocketEvents.LEAVE, { taskId }, (response: LeaveResponse) => { if (response.success) { - console.log(`[TaskChannel#unsubscribeFromTask] unsubscribed from ${taskId}`) + console.log(`[TaskChannel#unsubscribeFromTask] unsubscribed from ${taskId}`, response) } else { console.error(`[TaskChannel#unsubscribeFromTask] failed to unsubscribe from ${taskId}`) } // If we failed to unsubscribe then something is probably wrong and // we should still discard this task from `subscribedTasks`. - this.removeTaskListeners(task) - this.subscribedTasks.delete(taskId) + if (task) { + this.removeTaskListeners(task) + this.subscribedTasks.delete(taskId) + } }) } private setupTaskListeners(task: TaskLike): void { if (this.taskListeners.has(task.taskId)) { - console.warn(`[TaskChannel] Listeners already exist for task, removing old listeners for ${task.taskId}`) + console.warn("[TaskChannel] Listeners already exist for task, removing old listeners:", task.taskId) this.removeTaskListeners(task) } diff --git a/packages/cloud/src/bridge/TaskManager.ts b/packages/cloud/src/bridge/TaskManager.ts deleted file mode 100644 index 3940d59f25..0000000000 --- a/packages/cloud/src/bridge/TaskManager.ts +++ /dev/null @@ -1,279 +0,0 @@ -import type { Socket } from "socket.io-client" - -import { - type ClineMessage, - type TaskEvents, - type TaskLike, - type TaskBridgeCommand, - type TaskBridgeEvent, - RooCodeEventName, - TaskBridgeEventName, - TaskBridgeCommandName, - TaskSocketEvents, -} from "@roo-code/types" - -type TaskEventListener = { - [K in keyof TaskEvents]: (...args: TaskEvents[K]) => void | Promise -}[keyof TaskEvents] - -const TASK_EVENT_MAPPING: Record = { - [TaskBridgeEventName.Message]: RooCodeEventName.Message, - [TaskBridgeEventName.TaskModeSwitched]: RooCodeEventName.TaskModeSwitched, - [TaskBridgeEventName.TaskInteractive]: RooCodeEventName.TaskInteractive, -} - -export class TaskManager { - private subscribedTasks: Map = new Map() - private pendingTasks: Map = new Map() - private socket: Socket | null = null - - private taskListeners: Map> = new Map() - - constructor() {} - - public async onConnect(socket: Socket): Promise { - this.socket = socket - - // Rejoin all subscribed tasks. - for (const taskId of this.subscribedTasks.keys()) { - try { - socket.emit(TaskSocketEvents.JOIN, { taskId }) - - console.log(`[TaskManager] emit() -> ${TaskSocketEvents.JOIN} ${taskId}`) - } catch (error) { - console.error( - `[TaskManager] emit() failed -> ${TaskSocketEvents.JOIN}: ${ - error instanceof Error ? error.message : String(error) - }`, - ) - } - } - - // Subscribe to any pending tasks. - for (const task of this.pendingTasks.values()) { - await this.subscribeToTask(task, socket) - } - - this.pendingTasks.clear() - } - - public onDisconnect(): void { - this.socket = null - } - - public async onReconnect(socket: Socket): Promise { - this.socket = socket - - // Rejoin all subscribed tasks. - for (const taskId of this.subscribedTasks.keys()) { - try { - socket.emit(TaskSocketEvents.JOIN, { taskId }) - - console.log(`[TaskManager] emit() -> ${TaskSocketEvents.JOIN} ${taskId}`) - } catch (error) { - console.error( - `[TaskManager] emit() failed -> ${TaskSocketEvents.JOIN}: ${ - error instanceof Error ? error.message : String(error) - }`, - ) - } - } - } - - public async cleanup(socket: Socket | null): Promise { - if (!socket) { - return - } - - const unsubscribePromises = [] - - for (const taskId of this.subscribedTasks.keys()) { - unsubscribePromises.push(this.unsubscribeFromTask(taskId, socket)) - } - - await Promise.allSettled(unsubscribePromises) - this.subscribedTasks.clear() - this.taskListeners.clear() - this.pendingTasks.clear() - this.socket = null - } - - public addPendingTask(task: TaskLike): void { - this.pendingTasks.set(task.taskId, task) - } - - public async subscribeToTask(task: TaskLike, socket: Socket): Promise { - const taskId = task.taskId - this.subscribedTasks.set(taskId, task) - this.setupListeners(task) - - try { - socket.emit(TaskSocketEvents.JOIN, { taskId }) - console.log(`[TaskManager] emit() -> ${TaskSocketEvents.JOIN} ${taskId}`) - } catch (error) { - console.error( - `[TaskManager] emit() failed -> ${TaskSocketEvents.JOIN}: ${ - error instanceof Error ? error.message : String(error) - }`, - ) - } - } - - public async unsubscribeFromTask(taskId: string, socket: Socket): Promise { - const task = this.subscribedTasks.get(taskId) - - if (task) { - this.removeListeners(task) - this.subscribedTasks.delete(taskId) - } - - try { - socket.emit(TaskSocketEvents.LEAVE, { taskId }) - - console.log(`[TaskManager] emit() -> ${TaskSocketEvents.LEAVE} ${taskId}`) - } catch (error) { - console.error( - `[TaskManager] emit() failed -> ${TaskSocketEvents.LEAVE}: ${ - error instanceof Error ? error.message : String(error) - }`, - ) - } - } - - public handleTaskCommand(message: TaskBridgeCommand): void { - const task = this.subscribedTasks.get(message.taskId) - - if (!task) { - console.error(`[TaskManager#handleTaskCommand] Unable to find task ${message.taskId}`) - - return - } - - switch (message.type) { - case TaskBridgeCommandName.Message: - console.log( - `[TaskManager#handleTaskCommand] ${TaskBridgeCommandName.Message} ${message.taskId} -> submitUserMessage()`, - message, - ) - - task.submitUserMessage(message.payload.text, message.payload.images) - break - case TaskBridgeCommandName.ApproveAsk: - console.log( - `[TaskManager#handleTaskCommand] ${TaskBridgeCommandName.ApproveAsk} ${message.taskId} -> approveAsk()`, - message, - ) - - task.approveAsk(message.payload) - break - case TaskBridgeCommandName.DenyAsk: - console.log( - `[TaskManager#handleTaskCommand] ${TaskBridgeCommandName.DenyAsk} ${message.taskId} -> denyAsk()`, - message, - ) - - task.denyAsk(message.payload) - break - } - } - - private setupListeners(task: TaskLike): void { - if (this.taskListeners.has(task.taskId)) { - console.warn("[TaskManager] Listeners already exist for task, removing old listeners:", task.taskId) - - this.removeListeners(task) - } - - const listeners = new Map() - - const onMessage = ({ action, message }: { action: string; message: ClineMessage }) => { - this.publishEvent({ - type: TaskBridgeEventName.Message, - taskId: task.taskId, - action, - message, - }) - } - - task.on(RooCodeEventName.Message, onMessage) - listeners.set(TaskBridgeEventName.Message, onMessage) - - const onTaskModeSwitched = (mode: string) => { - this.publishEvent({ - type: TaskBridgeEventName.TaskModeSwitched, - taskId: task.taskId, - mode, - }) - } - - task.on(RooCodeEventName.TaskModeSwitched, onTaskModeSwitched) - listeners.set(TaskBridgeEventName.TaskModeSwitched, onTaskModeSwitched) - - const onTaskInteractive = (_taskId: string) => { - this.publishEvent({ - type: TaskBridgeEventName.TaskInteractive, - taskId: task.taskId, - }) - } - - task.on(RooCodeEventName.TaskInteractive, onTaskInteractive) - - listeners.set(TaskBridgeEventName.TaskInteractive, onTaskInteractive) - - this.taskListeners.set(task.taskId, listeners) - - console.log("[TaskManager] Task listeners setup complete for:", task.taskId) - } - - private removeListeners(task: TaskLike): void { - const listeners = this.taskListeners.get(task.taskId) - - if (!listeners) { - return - } - - console.log("[TaskManager] Removing task listeners for:", task.taskId) - - listeners.forEach((listener, eventName) => { - try { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - task.off(TASK_EVENT_MAPPING[eventName], listener as any) - } catch (error) { - console.error( - `[TaskManager] Error removing listener for ${String(eventName)} on task ${task.taskId}:`, - error, - ) - } - }) - - this.taskListeners.delete(task.taskId) - } - - private async publishEvent(message: TaskBridgeEvent): Promise { - if (!this.socket) { - console.error("[TaskManager] publishEvent -> socket not available") - return false - } - - try { - this.socket.emit(TaskSocketEvents.EVENT, message) - - if (message.type !== TaskBridgeEventName.Message) { - console.log( - `[TaskManager] emit() -> ${TaskSocketEvents.EVENT} ${message.taskId} ${message.type}`, - message, - ) - } - - return true - } catch (error) { - console.error( - `[TaskManager] emit() failed -> ${TaskSocketEvents.EVENT}: ${ - error instanceof Error ? error.message : String(error) - }`, - ) - - return false - } - } -} diff --git a/packages/cloud/src/bridge/__tests__/ExtensionChannel.test.ts b/packages/cloud/src/bridge/__tests__/ExtensionChannel.test.ts index 7afd16516e..89979c9a66 100644 --- a/packages/cloud/src/bridge/__tests__/ExtensionChannel.test.ts +++ b/packages/cloud/src/bridge/__tests__/ExtensionChannel.test.ts @@ -5,7 +5,6 @@ import type { Socket } from "socket.io-client" import { type TaskProviderLike, type TaskProviderEvents, - type StaticAppProperties, RooCodeEventName, ExtensionBridgeEventName, ExtensionSocketEvents, @@ -20,15 +19,6 @@ describe("ExtensionChannel", () => { const instanceId = "test-instance-123" const userId = "test-user-456" - const appProperties: StaticAppProperties = { - appName: "roo-code", - appVersion: "1.0.0", - vscodeVersion: "1.0.0", - platform: "darwin", - editorName: "Roo Code", - hostname: "test-host", - } - // Track registered event listeners const eventListeners = new Map unknown>>() @@ -63,13 +53,6 @@ describe("ExtensionChannel", () => { postStateToWebview: vi.fn(), postMessageToWebview: vi.fn(), getTelemetryProperties: vi.fn(), - getMode: vi.fn().mockResolvedValue("code"), - getModes: vi.fn().mockResolvedValue([ - { slug: "code", name: "Code", description: "Code mode" }, - { slug: "architect", name: "Architect", description: "Architect mode" }, - ]), - getProviderProfile: vi.fn().mockResolvedValue("default"), - getProviderProfiles: vi.fn().mockResolvedValue([{ name: "default", description: "Default profile" }]), on: vi.fn((event: keyof TaskProviderEvents, listener: (...args: unknown[]) => unknown) => { if (!eventListeners.has(event)) { eventListeners.set(event, new Set()) @@ -90,12 +73,7 @@ describe("ExtensionChannel", () => { } as unknown as TaskProviderLike // Create extension channel instance - extensionChannel = new ExtensionChannel({ - instanceId, - appProperties, - userId, - provider: mockProvider, - }) + extensionChannel = new ExtensionChannel(instanceId, userId, mockProvider) }) afterEach(() => { @@ -116,11 +94,6 @@ describe("ExtensionChannel", () => { RooCodeEventName.TaskInteractive, RooCodeEventName.TaskResumable, RooCodeEventName.TaskIdle, - RooCodeEventName.TaskPaused, - RooCodeEventName.TaskUnpaused, - RooCodeEventName.TaskSpawned, - RooCodeEventName.TaskUserMessage, - RooCodeEventName.TaskTokenUsageUpdated, ] // Check that on() was called for each event @@ -171,12 +144,7 @@ describe("ExtensionChannel", () => { it("should not have duplicate listeners after multiple channel creations", () => { // Create a second channel with the same provider - const secondChannel = new ExtensionChannel({ - instanceId: "instance-2", - appProperties, - userId, - provider: mockProvider, - }) + const secondChannel = new ExtensionChannel("instance-2", userId, mockProvider) // Each event should have exactly 2 listeners (one from each channel) eventListeners.forEach((listeners) => { @@ -216,9 +184,6 @@ describe("ExtensionChannel", () => { // Connect the socket to enable publishing await extensionChannel.onConnect(mockSocket) - // Clear the mock calls from the connection (which emits a register event) - ;(mockSocket.emit as any).mockClear() - // Get a listener that was registered for TaskStarted const taskStartedListeners = eventListeners.get(RooCodeEventName.TaskStarted) expect(taskStartedListeners).toBeDefined() @@ -227,7 +192,7 @@ describe("ExtensionChannel", () => { // Trigger the listener const listener = Array.from(taskStartedListeners!)[0] if (listener) { - await listener("test-task-id") + listener("test-task-id") } // Verify the event was published to the socket @@ -255,7 +220,8 @@ describe("ExtensionChannel", () => { } // Listeners should still be the same count (not accumulated) - expect(eventListeners.size).toBe(15) + const expectedEventCount = 10 // Number of events we listen to + expect(eventListeners.size).toBe(expectedEventCount) // Each event should have exactly 1 listener eventListeners.forEach((listeners) => { diff --git a/packages/cloud/src/bridge/__tests__/TaskChannel.test.ts b/packages/cloud/src/bridge/__tests__/TaskChannel.test.ts index 1f13da9661..2809ca78f8 100644 --- a/packages/cloud/src/bridge/__tests__/TaskChannel.test.ts +++ b/packages/cloud/src/bridge/__tests__/TaskChannel.test.ts @@ -6,7 +6,6 @@ import type { Socket } from "socket.io-client" import { type TaskLike, type ClineMessage, - type StaticAppProperties, RooCodeEventName, TaskBridgeEventName, TaskBridgeCommandName, @@ -23,15 +22,6 @@ describe("TaskChannel", () => { const instanceId = "test-instance-123" const taskId = "test-task-456" - const appProperties: StaticAppProperties = { - appName: "roo-code", - appVersion: "1.0.0", - vscodeVersion: "1.0.0", - platform: "darwin", - editorName: "Roo Code", - hostname: "test-host", - } - beforeEach(() => { // Create mock socket mockSocket = { @@ -85,10 +75,7 @@ describe("TaskChannel", () => { } // Create task channel instance - taskChannel = new TaskChannel({ - instanceId, - appProperties, - }) + taskChannel = new TaskChannel(instanceId) }) afterEach(() => { @@ -312,7 +299,8 @@ describe("TaskChannel", () => { // Verify warning was logged expect(warnSpy).toHaveBeenCalledWith( - `[TaskChannel] Listeners already exist for task, removing old listeners for ${taskId}`, + "[TaskChannel] Listeners already exist for task, removing old listeners:", + taskId, ) // Verify only one set of listeners exists @@ -333,7 +321,7 @@ describe("TaskChannel", () => { channel.subscribedTasks.set(taskId, mockTask) }) - it("should handle Message command", async () => { + it("should handle Message command", () => { const command = { type: TaskBridgeCommandName.Message, taskId, @@ -344,17 +332,12 @@ describe("TaskChannel", () => { }, } - await taskChannel.handleCommand(command) + taskChannel.handleCommand(command) - expect(mockTask.submitUserMessage).toHaveBeenCalledWith( - command.payload.text, - command.payload.images, - undefined, - undefined, - ) + expect(mockTask.submitUserMessage).toHaveBeenCalledWith(command.payload.text, command.payload.images) }) - it("should handle ApproveAsk command", async () => { + it("should handle ApproveAsk command", () => { const command = { type: TaskBridgeCommandName.ApproveAsk, taskId, @@ -364,12 +347,12 @@ describe("TaskChannel", () => { }, } - await taskChannel.handleCommand(command) + taskChannel.handleCommand(command) expect(mockTask.approveAsk).toHaveBeenCalledWith(command.payload) }) - it("should handle DenyAsk command", async () => { + it("should handle DenyAsk command", () => { const command = { type: TaskBridgeCommandName.DenyAsk, taskId, @@ -379,12 +362,12 @@ describe("TaskChannel", () => { }, } - await taskChannel.handleCommand(command) + taskChannel.handleCommand(command) expect(mockTask.denyAsk).toHaveBeenCalledWith(command.payload) }) - it("should log error for unknown task", async () => { + it("should log error for unknown task", () => { const errorSpy = vi.spyOn(console, "error") const command = { @@ -396,7 +379,7 @@ describe("TaskChannel", () => { }, } - await taskChannel.handleCommand(command) + taskChannel.handleCommand(command) expect(errorSpy).toHaveBeenCalledWith(`[TaskChannel] Unable to find task unknown-task`) diff --git a/packages/cloud/src/importVscode.ts b/packages/cloud/src/importVscode.ts index b3c3c94150..f389555afa 100644 --- a/packages/cloud/src/importVscode.ts +++ b/packages/cloud/src/importVscode.ts @@ -7,43 +7,38 @@ let vscodeModule: typeof import("vscode") | undefined /** - * Attempts to dynamically import the VS Code module. - * Returns undefined if not running in a VS Code/Cursor extension context. + * Attempts to dynamically import the `vscode` module. + * Returns undefined if not running in a VSCode extension context. */ export async function importVscode(): Promise { - // Check if already loaded if (vscodeModule) { return vscodeModule } try { - // Method 1: Check if vscode is available in global scope (common in extension hosts). - if (typeof globalThis !== "undefined" && "acquireVsCodeApi" in globalThis) { - // We're in a webview context, vscode module won't be available. - return undefined - } - - // Method 2: Try to require the module (works in most extension contexts). if (typeof require !== "undefined") { try { // eslint-disable-next-line @typescript-eslint/no-require-imports vscodeModule = require("vscode") if (vscodeModule) { + console.log("VS Code module loaded from require") return vscodeModule } } catch (error) { - console.error("Error loading VS Code module:", error) + console.error(`Error loading VS Code module: ${error instanceof Error ? error.message : String(error)}`) // Fall through to dynamic import. } } - // Method 3: Dynamic import (original approach, works in VSCode). vscodeModule = await import("vscode") + console.log("VS Code module loaded from dynamic import") return vscodeModule } catch (error) { - // Log the original error for debugging. - console.warn("VS Code module not available in this environment:", error) + console.warn( + `VS Code module not available in this environment: ${error instanceof Error ? error.message : String(error)}`, + ) + return undefined } } diff --git a/packages/cloud/src/index.ts b/packages/cloud/src/index.ts index 6ba2d3e61e..dd40e6fc52 100644 --- a/packages/cloud/src/index.ts +++ b/packages/cloud/src/index.ts @@ -1,5 +1,5 @@ export * from "./config.js" -export * from "./CloudAPI.js" -export * from "./CloudService.js" -export * from "./bridge/ExtensionBridgeService.js" +export { CloudService } from "./CloudService.js" + +export { BridgeOrchestrator } from "./bridge/index.js" diff --git a/packages/types/src/cloud.ts b/packages/types/src/cloud.ts index b80c562fa3..dbf79b6bfa 100644 --- a/packages/types/src/cloud.ts +++ b/packages/types/src/cloud.ts @@ -587,32 +587,49 @@ export type TaskBridgeCommand = z.infer * ExtensionSocketEvents */ -export const ExtensionSocketEvents = { - CONNECTED: "extension:connected", +export enum ExtensionSocketEvents { + CONNECTED = "extension:connected", - REGISTER: "extension:register", - UNREGISTER: "extension:unregister", + REGISTER = "extension:register", + UNREGISTER = "extension:unregister", - HEARTBEAT: "extension:heartbeat", + HEARTBEAT = "extension:heartbeat", - EVENT: "extension:event", // event from extension instance - RELAYED_EVENT: "extension:relayed_event", // relay from server + EVENT = "extension:event", // event from extension instance + RELAYED_EVENT = "extension:relayed_event", // relay from server - COMMAND: "extension:command", // command from user - RELAYED_COMMAND: "extension:relayed_command", // relay from server -} as const + COMMAND = "extension:command", // command from user + RELAYED_COMMAND = "extension:relayed_command", // relay from server +} /** * TaskSocketEvents */ -export const TaskSocketEvents = { - JOIN: "task:join", - LEAVE: "task:leave", +export enum TaskSocketEvents { + JOIN = "task:join", + LEAVE = "task:leave", - EVENT: "task:event", // event from extension task - RELAYED_EVENT: "task:relayed_event", // relay from server + EVENT = "task:event", // event from extension task + RELAYED_EVENT = "task:relayed_event", // relay from server - COMMAND: "task:command", // command from user - RELAYED_COMMAND: "task:relayed_command", // relay from server -} as const + COMMAND = "task:command", // command from user + RELAYED_COMMAND = "task:relayed_command", // relay from server +} + +/** + * `emit()` Response Types + */ + +export type JoinResponse = { + success: boolean + error?: string + taskId?: string + timestamp?: string +} + +export type LeaveResponse = { + success: boolean + taskId?: string + timestamp?: string +} diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 2cd74d8157..c1797f90e4 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -23,7 +23,6 @@ import { type ClineAsk, type ToolProgressStatus, type HistoryItem, - type CreateTaskOptions, RooCodeEventName, TelemetryEventName, TaskStatus, @@ -34,15 +33,13 @@ import { isIdleAsk, isInteractiveAsk, isResumableAsk, - QueuedMessage, } from "@roo-code/types" import { TelemetryService } from "@roo-code/telemetry" import { CloudService, BridgeOrchestrator } from "@roo-code/cloud" // api import { ApiHandler, ApiHandlerCreateMessageMetadata, buildApiHandler } from "../../api" -import { ApiStream, GroundingSource } from "../../api/transform/stream" -import { maybeRemoveImageBlocks } from "../../api/transform/image-cleaning" +import { ApiStream } from "../../api/transform/stream" // shared import { findLastIndex } from "../../shared/array" @@ -50,7 +47,7 @@ import { combineApiRequests } from "../../shared/combineApiRequests" import { combineCommandSequences } from "../../shared/combineCommandSequences" import { t } from "../../i18n" import { ClineApiReqCancelReason, ClineApiReqInfo } from "../../shared/ExtensionMessage" -import { getApiMetrics, hasTokenUsageChanged } from "../../shared/getApiMetrics" +import { getApiMetrics } from "../../shared/getApiMetrics" import { ClineAskResponse } from "../../shared/WebviewMessage" import { defaultModeSlug } from "../../shared/modes" import { DiffStrategy } from "../../shared/tools" @@ -81,7 +78,6 @@ import { SYSTEM_PROMPT } from "../prompts/system" // core modules import { ToolRepetitionDetector } from "../tools/ToolRepetitionDetector" -import { restoreTodoListForTask } from "../tools/updateTodoListTool" import { FileContextTracker } from "../context-tracking/FileContextTracker" import { RooIgnoreController } from "../ignore/RooIgnoreController" import { RooProtectedController } from "../protect/RooProtectedController" @@ -91,14 +87,7 @@ import { truncateConversationIfNeeded } from "../sliding-window" import { ClineProvider } from "../webview/ClineProvider" import { MultiSearchReplaceDiffStrategy } from "../diff/strategies/multi-search-replace" import { MultiFileSearchReplaceDiffStrategy } from "../diff/strategies/multi-file-search-replace" -import { - type ApiMessage, - readApiMessages, - saveApiMessages, - readTaskMessages, - saveTaskMessages, - taskMetadata, -} from "../task-persistence" +import { readApiMessages, saveApiMessages, readTaskMessages, saveTaskMessages, taskMetadata } from "../task-persistence" import { getEnvironmentDetails } from "../environment/getEnvironmentDetails" import { checkContextWindowExceededError } from "../context/context-management/context-error-handling" import { @@ -110,18 +99,19 @@ import { checkpointDiff, } from "../checkpoints" import { processUserContentMentions } from "../mentions/processUserContentMentions" +import { ApiMessage } from "../task-persistence/apiMessages" import { getMessagesSinceLastSummary, summarizeConversation } from "../condense" -import { Gpt5Metadata, ClineMessageWithMetadata } from "./types" -import { MessageQueueService } from "../message-queue/MessageQueueService" - +import { maybeRemoveImageBlocks } from "../../api/transform/image-cleaning" +import { restoreTodoListForTask } from "../tools/updateTodoListTool" import { AutoApprovalHandler } from "./AutoApprovalHandler" +import { Gpt5Metadata, ClineMessageWithMetadata } from "./types" const MAX_EXPONENTIAL_BACKOFF_SECONDS = 600 // 10 minutes const DEFAULT_USAGE_COLLECTION_TIMEOUT_MS = 5000 // 5 seconds const FORCED_CONTEXT_REDUCTION_PERCENT = 75 // Keep 75% of context (remove 25%) on context window errors const MAX_CONTEXT_WINDOW_RETRIES = 3 // Maximum retries for context window errors -export interface TaskOptions extends CreateTaskOptions { +export type TaskOptions = { provider: ClineProvider apiConfiguration: ProviderSettings enableDiff?: boolean @@ -139,15 +129,10 @@ export interface TaskOptions extends CreateTaskOptions { taskNumber?: number onCreated?: (task: Task) => void initialTodos?: TodoItem[] - workspacePath?: string } export class Task extends EventEmitter implements TaskLike { readonly taskId: string - readonly rootTaskId?: string - readonly parentTaskId?: string - childTaskId?: string - readonly instanceId: string readonly metadata: TaskMetadata @@ -273,10 +258,7 @@ export class Task extends EventEmitter implements TaskLike { // Task Bridge enableBridge: boolean - - // Message Queue Service - public readonly messageQueueService: MessageQueueService - private messageQueueStateChangedHandler: (() => void) | undefined + bridge: BridgeOrchestrator | null = null // Streaming isWaitingForFirstChunk = false @@ -295,10 +277,6 @@ export class Task extends EventEmitter implements TaskLike { private lastUsedInstructions?: string private skipPrevResponseIdOnce: boolean = false - // Token Usage Cache - private tokenUsageSnapshot?: TokenUsage - private tokenUsageSnapshotAt?: number - constructor({ provider, apiConfiguration, @@ -316,7 +294,6 @@ export class Task extends EventEmitter implements TaskLike { taskNumber = -1, onCreated, initialTodos, - workspacePath, }: TaskOptions) { super() @@ -325,9 +302,6 @@ export class Task extends EventEmitter implements TaskLike { } this.taskId = historyItem ? historyItem.id : crypto.randomUUID() - this.rootTaskId = historyItem ? historyItem.rootTaskId : rootTask?.taskId - this.parentTaskId = historyItem ? historyItem.parentTaskId : parentTask?.taskId - this.childTaskId = undefined this.metadata = { task: historyItem ? historyItem.task : task, @@ -337,7 +311,7 @@ export class Task extends EventEmitter implements TaskLike { // Normal use-case is usually retry similar history task with new workspace. this.workspacePath = parentTask ? parentTask.workspacePath - : (workspacePath ?? getWorkspacePath(path.join(os.homedir(), "Desktop"))) + : getWorkspacePath(path.join(os.homedir(), "Desktop")) this.instanceId = crypto.randomUUID().slice(0, 8) this.taskNumber = -1 @@ -365,6 +339,7 @@ export class Task extends EventEmitter implements TaskLike { this.enableCheckpoints = enableCheckpoints this.enableBridge = enableBridge + this.rootTask = rootTask this.parentTask = parentTask this.taskNumber = taskNumber @@ -382,18 +357,9 @@ export class Task extends EventEmitter implements TaskLike { TelemetryService.instance.captureTaskCreated(this.taskId) } - // Initialize the assistant message parser. + // Initialize the assistant message parser this.assistantMessageParser = new AssistantMessageParser() - this.messageQueueService = new MessageQueueService() - - this.messageQueueStateChangedHandler = () => { - this.emit(RooCodeEventName.TaskUserMessage, this.taskId) - this.providerRef.deref()?.postStateToWebview() - } - - this.messageQueueService.on("stateChanged", this.messageQueueStateChangedHandler) - // Only set up diff strategy if diff is enabled. if (this.diffEnabled) { // Default to old strategy, will be updated if experiment is enabled. @@ -667,21 +633,15 @@ export class Task extends EventEmitter implements TaskLike { }) const { historyItem, tokenUsage } = await taskMetadata({ - taskId: this.taskId, - rootTaskId: this.rootTaskId, - parentTaskId: this.parentTaskId, - taskNumber: this.taskNumber, messages: this.clineMessages, + taskId: this.taskId, + taskNumber: this.taskNumber, globalStoragePath: this.globalStoragePath, workspace: this.cwd, - mode: this._taskMode || defaultModeSlug, // Use the task's own mode, not the current provider mode. + mode: this._taskMode || defaultModeSlug, // Use the task's own mode, not the current provider mode }) - if (hasTokenUsageChanged(tokenUsage, this.tokenUsageSnapshot)) { - this.emit(RooCodeEventName.TaskTokenUsageUpdated, this.taskId, tokenUsage) - this.tokenUsageSnapshot = undefined - this.tokenUsageSnapshotAt = undefined - } + this.emit(RooCodeEventName.TaskTokenUsageUpdated, this.taskId, tokenUsage) await this.providerRef.deref()?.updateTaskHistory(historyItem) } catch (error) { @@ -800,13 +760,10 @@ export class Task extends EventEmitter implements TaskLike { // The state is mutable if the message is complete and the task will // block (via the `pWaitFor`). const isBlocking = !(this.askResponse !== undefined || this.lastMessageTs !== askTs) - const isMessageQueued = !this.messageQueueService.isEmpty() - const isStatusMutable = !partial && isBlocking && !isMessageQueued + const isStatusMutable = !partial && isBlocking let statusMutationTimeouts: NodeJS.Timeout[] = [] if (isStatusMutable) { - console.log(`Task#ask will block -> type: ${type}`) - if (isInteractiveAsk(type)) { statusMutationTimeouts.push( setTimeout(() => { @@ -841,19 +798,9 @@ export class Task extends EventEmitter implements TaskLike { }, 1_000), ) } - } else if (isMessageQueued) { - console.log("Task#ask will process message queue") - - const message = this.messageQueueService.dequeueMessage() - - if (message) { - setTimeout(async () => { - await this.submitUserMessage(message.text, message.images) - }, 0) - } } - // Wait for askResponse to be set. + // Wait for askResponse to be set await pWaitFor(() => this.askResponse !== undefined || this.lastMessageTs !== askTs, { interval: 100 }) if (this.lastMessageTs !== askTs) { @@ -891,31 +838,6 @@ export class Task extends EventEmitter implements TaskLike { this.askResponse = askResponse this.askResponseText = text this.askResponseImages = images - - // Create a checkpoint whenever the user sends a message. - // Use allowEmpty=true to ensure a checkpoint is recorded even if there are no file changes. - // Suppress the checkpoint_saved chat row for this particular checkpoint to keep the timeline clean. - if (askResponse === "messageResponse") { - void this.checkpointSave(false, true) - } - - // Mark the last follow-up question as answered - if (askResponse === "messageResponse" || askResponse === "yesButtonClicked") { - // Find the last unanswered follow-up message using findLastIndex - const lastFollowUpIndex = findLastIndex( - this.clineMessages, - (msg) => msg.type === "ask" && msg.ask === "followup" && !msg.isAnswered, - ) - - if (lastFollowUpIndex !== -1) { - // Mark this follow-up as answered - this.clineMessages[lastFollowUpIndex].isAnswered = true - // Save the updated messages - this.saveClineMessages().catch((error) => { - console.error("Failed to save answered follow-up state:", error) - }) - } - } } public approveAsk({ text, images }: { text?: string; images?: string[] } = {}) { @@ -926,12 +848,7 @@ export class Task extends EventEmitter implements TaskLike { this.handleWebviewAskResponse("noButtonClicked", text, images) } - public async submitUserMessage( - text: string, - images?: string[], - mode?: string, - providerProfile?: string, - ): Promise { + public submitUserMessage(text: string, images?: string[]): void { try { text = (text ?? "").trim() images = images ?? [] @@ -943,16 +860,6 @@ export class Task extends EventEmitter implements TaskLike { const provider = this.providerRef.deref() if (provider) { - if (mode) { - await provider.setMode(mode) - } - - if (providerProfile) { - await provider.setProviderProfile(providerProfile) - } - - this.emit(RooCodeEventName.TaskUserMessage, this.taskId) - provider.postMessageToWebview({ type: "invoke", invoke: "sendMessage", text, images }) } else { console.error("[Task#submitUserMessage] Provider reference lost") @@ -997,7 +904,6 @@ export class Task extends EventEmitter implements TaskLike { } const { contextTokens: prevContextTokens } = this.getTokenUsage() - const { messages, summary, @@ -1175,16 +1081,19 @@ export class Task extends EventEmitter implements TaskLike { return formatResponse.toolError(formatResponse.missingToolParameterError(paramName)) } - // Lifecycle - // Start / Resume / Abort / Dispose + // Start / Abort / Resume private async startTask(task?: string, images?: string[]): Promise { if (this.enableBridge) { try { - await BridgeOrchestrator.subscribeToTask(this) + this.bridge = this.bridge || BridgeOrchestrator.getInstance() + + if (this.bridge) { + await this.bridge.subscribeToTask(this) + } } catch (error) { console.error( - `[Task#startTask] BridgeOrchestrator.subscribeToTask() failed: ${error instanceof Error ? error.message : String(error)}`, + `[Task#startTask] subscribeToTask failed - ${error instanceof Error ? error.message : String(error)}`, ) } } @@ -1219,34 +1128,63 @@ export class Task extends EventEmitter implements TaskLike { ]) } + public async resumePausedTask(lastMessage: string) { + this.isPaused = false + this.emit(RooCodeEventName.TaskUnpaused) + + // Fake an answer from the subtask that it has completed running and + // this is the result of what it has done add the message to the chat + // history and to the webview ui. + try { + await this.say("subtask_result", lastMessage) + + await this.addToApiConversationHistory({ + role: "user", + content: [{ type: "text", text: `[new_task completed] Result: ${lastMessage}` }], + }) + + // Set skipPrevResponseIdOnce to ensure the next API call sends the full conversation + // including the subtask result, not just from before the subtask was created + this.skipPrevResponseIdOnce = true + } catch (error) { + this.providerRef + .deref() + ?.log(`Error failed to add reply from subtask into conversation of parent task, error: ${error}`) + + throw error + } + } + private async resumeTaskFromHistory() { if (this.enableBridge) { try { - await BridgeOrchestrator.subscribeToTask(this) + this.bridge = this.bridge || BridgeOrchestrator.getInstance() + + if (this.bridge) { + await this.bridge.subscribeToTask(this) + } } catch (error) { console.error( - `[Task#resumeTaskFromHistory] BridgeOrchestrator.subscribeToTask() failed: ${error instanceof Error ? error.message : String(error)}`, + `[Task#resumeTaskFromHistory] subscribeToTask failed - ${error instanceof Error ? error.message : String(error)}`, ) } } const modifiedClineMessages = await this.getSavedClineMessages() - // Check for any stored GPT-5 response IDs in the message history. + // Check for any stored GPT-5 response IDs in the message history const gpt5Messages = modifiedClineMessages.filter( (m): m is ClineMessage & ClineMessageWithMetadata => m.type === "say" && m.say === "text" && !!(m as ClineMessageWithMetadata).metadata?.gpt5?.previous_response_id, ) - if (gpt5Messages.length > 0) { const lastGpt5Message = gpt5Messages[gpt5Messages.length - 1] - // The lastGpt5Message contains the previous_response_id that can be - // used for continuity. + // The lastGpt5Message contains the previous_response_id that can be used for continuity } - // Remove any resume messages that may have been added before. + // Remove any resume messages that may have been added before const lastRelevantMessageIndex = findLastIndex( modifiedClineMessages, (m) => !(m.ask === "resume_task" || m.ask === "resume_completed_task"), @@ -1464,8 +1402,8 @@ export class Task extends EventEmitter implements TaskLike { newUserContent.push(...formatResponse.imageBlocks(responseImages)) } - // Ensure we have at least some content to send to the API. - // If newUserContent is empty, add a minimal resumption message. + // Ensure we have at least some content to send to the API + // If newUserContent is empty, add a minimal resumption message if (newUserContent.length === 0) { newUserContent.push({ type: "text", @@ -1475,52 +1413,16 @@ export class Task extends EventEmitter implements TaskLike { await this.overwriteApiConversationHistory(modifiedApiConversationHistory) - // Task resuming from history item. + // Task resuming from history item + await this.initiateTaskLoop(newUserContent) } - public async abortTask(isAbandoned = false) { - // Aborting task - - // Will stop any autonomously running promises. - if (isAbandoned) { - this.abandoned = true - } - - this.abort = true - this.emit(RooCodeEventName.TaskAborted) - - try { - this.dispose() // Call the centralized dispose method - } catch (error) { - console.error(`Error during task ${this.taskId}.${this.instanceId} disposal:`, error) - // Don't rethrow - we want abort to always succeed - } - // Save the countdown message in the automatic retry or other content. - try { - // Save the countdown message in the automatic retry or other content. - await this.saveClineMessages() - } catch (error) { - console.error(`Error saving messages during abort for task ${this.taskId}.${this.instanceId}:`, error) - } - } - public dispose(): void { - console.log(`[Task#dispose] disposing task ${this.taskId}.${this.instanceId}`) + // Disposing task + console.log(`[Task] disposing task ${this.taskId}.${this.instanceId}`) - // Dispose message queue and remove event listeners. - try { - if (this.messageQueueStateChangedHandler) { - this.messageQueueService.removeListener("stateChanged", this.messageQueueStateChangedHandler) - this.messageQueueStateChangedHandler = undefined - } - - this.messageQueueService.dispose() - } catch (error) { - console.error("Error disposing message queue:", error) - } - - // Remove all event listeners to prevent memory leaks. + // Remove all event listeners to prevent memory leaks try { this.removeAllListeners() } catch (error) { @@ -1540,14 +1442,13 @@ export class Task extends EventEmitter implements TaskLike { this.pauseInterval = undefined } - if (this.enableBridge) { - BridgeOrchestrator.getInstance() - ?.unsubscribeFromTask(this.taskId) - .catch((error) => - console.error( - `[Task#dispose] BridgeOrchestrator#unsubscribeFromTask() failed: ${error instanceof Error ? error.message : String(error)}`, - ), - ) + // Unsubscribe from TaskBridge service. + if (this.bridge) { + this.bridge + .unsubscribeFromTask(this.taskId) + .catch((error: unknown) => console.error("Error unsubscribing from task bridge:", error)) + + this.bridge = null } // Release any terminals associated with this task. @@ -1596,36 +1497,37 @@ export class Task extends EventEmitter implements TaskLike { } } - // Subtasks - // Spawn / Wait / Complete + public async abortTask(isAbandoned = false) { + // Aborting task - public async startSubtask(message: string, initialTodos: TodoItem[], mode: string) { - const provider = this.providerRef.deref() - - if (!provider) { - throw new Error("Provider not available") + // Will stop any autonomously running promises. + if (isAbandoned) { + this.abandoned = true } - const newTask = await provider.createTask(message, undefined, this, { initialTodos }) + this.abort = true + this.emit(RooCodeEventName.TaskAborted) - if (newTask) { - this.isPaused = true // Pause parent. - this.childTaskId = newTask.taskId - - await provider.handleModeSwitch(mode) // Set child's mode. - await delay(500) // Allow mode change to take effect. - - this.emit(RooCodeEventName.TaskPaused, this.taskId) - this.emit(RooCodeEventName.TaskSpawned, newTask.taskId) + try { + this.dispose() // Call the centralized dispose method + } catch (error) { + console.error(`Error during task ${this.taskId}.${this.instanceId} disposal:`, error) + // Don't rethrow - we want abort to always succeed + } + // Save the countdown message in the automatic retry or other content. + try { + // Save the countdown message in the automatic retry or other content. + await this.saveClineMessages() + } catch (error) { + console.error(`Error saving messages during abort for task ${this.taskId}.${this.instanceId}:`, error) } - - return newTask } // Used when a sub-task is launched and the parent task is waiting for it to // finish. - // TBD: Add a timeout to prevent infinite waiting. - public async waitForSubtask() { + // TBD: The 1s should be added to the settings, also should add a timeout to + // prevent infinite waiting. + public async waitForResume() { await new Promise((resolve) => { this.pauseInterval = setInterval(() => { if (!this.isPaused) { @@ -1637,35 +1539,6 @@ export class Task extends EventEmitter implements TaskLike { }) } - public async completeSubtask(lastMessage: string) { - this.isPaused = false - this.childTaskId = undefined - - this.emit(RooCodeEventName.TaskUnpaused, this.taskId) - - // Fake an answer from the subtask that it has completed running and - // this is the result of what it has done add the message to the chat - // history and to the webview ui. - try { - await this.say("subtask_result", lastMessage) - - await this.addToApiConversationHistory({ - role: "user", - content: [{ type: "text", text: `[new_task completed] Result: ${lastMessage}` }], - }) - - // Set skipPrevResponseIdOnce to ensure the next API call sends the full conversation - // including the subtask result, not just from before the subtask was created - this.skipPrevResponseIdOnce = true - } catch (error) { - this.providerRef - .deref() - ?.log(`Error failed to add reply from subtask into conversation of parent task, error: ${error}`) - - throw error - } - } - // Task Loop private async initiateTaskLoop(userContent: Anthropic.Messages.ContentBlockParam[]): Promise { @@ -1752,7 +1625,7 @@ export class Task extends EventEmitter implements TaskLike { if (this.isPaused && provider) { provider.log(`[subtasks] paused ${this.taskId}.${this.instanceId}`) - await this.waitForSubtask() + await this.waitForResume() provider.log(`[subtasks] resumed ${this.taskId}.${this.instanceId}`) const currentMode = (await provider.getState())?.mode ?? defaultModeSlug @@ -1913,7 +1786,7 @@ export class Task extends EventEmitter implements TaskLike { this.didFinishAbortingStream = true } - // Reset streaming state for each new API request + // Reset streaming state. this.currentStreamingContentIndex = 0 this.currentStreamingDidCheckpoint = false this.assistantMessageContent = [] @@ -1934,7 +1807,6 @@ export class Task extends EventEmitter implements TaskLike { const stream = this.attemptApiRequest() let assistantMessage = "" let reasoningMessage = "" - let pendingGroundingSources: GroundingSource[] = [] this.isStreaming = true try { @@ -1961,13 +1833,6 @@ export class Task extends EventEmitter implements TaskLike { cacheReadTokens += chunk.cacheReadTokens ?? 0 totalCost = chunk.totalCost break - case "grounding": - // Handle grounding sources separately from regular content - // to prevent state persistence issues - store them separately - if (chunk.sources && chunk.sources.length > 0) { - pendingGroundingSources.push(...chunk.sources) - } - break case "text": { assistantMessage += chunk.text @@ -2261,16 +2126,6 @@ export class Task extends EventEmitter implements TaskLike { let didEndLoop = false if (assistantMessage.length > 0) { - // Display grounding sources to the user if they exist - if (pendingGroundingSources.length > 0) { - const citationLinks = pendingGroundingSources.map((source, i) => `[${i + 1}](${source.url})`) - const sourcesText = `${t("common:gemini.sources")} ${citationLinks.join(", ")}` - - await this.say("text", sourcesText, undefined, false, undefined, undefined, { - isNonInteractive: true, - }) - } - await this.addToApiConversationHistory({ role: "assistant", content: [{ type: "text", text: assistantMessage }], @@ -2441,13 +2296,11 @@ export class Task extends EventEmitter implements TaskLike { const { contextTokens } = this.getTokenUsage() const modelInfo = this.api.getModel().info - const maxTokens = getModelMaxOutputTokens({ modelId: this.api.getModel().id, model: modelInfo, settings: this.apiConfiguration, }) - const contextWindow = modelInfo.contextWindow // Get the current profile ID using the helper method @@ -2790,8 +2643,8 @@ export class Task extends EventEmitter implements TaskLike { // Checkpoints - public async checkpointSave(force: boolean = false, suppressMessage: boolean = false) { - return checkpointSave(this, force, undefined, suppressMessage) + public async checkpointSave(force: boolean = false) { + return checkpointSave(this, force) } public async checkpointRestore(options: CheckpointRestoreOptions) { @@ -2869,6 +2722,10 @@ export class Task extends EventEmitter implements TaskLike { // Getters + public get cwd() { + return this.workspacePath + } + public get taskStatus(): TaskStatus { if (this.interactiveAsk) { return TaskStatus.Interactive @@ -2888,23 +2745,4 @@ export class Task extends EventEmitter implements TaskLike { public get taskAsk(): ClineMessage | undefined { return this.idleAsk || this.resumableAsk || this.interactiveAsk } - - public get queuedMessages(): QueuedMessage[] { - return this.messageQueueService.messages - } - - public get tokenUsage(): TokenUsage | undefined { - if (this.tokenUsageSnapshot && this.tokenUsageSnapshotAt) { - return this.tokenUsageSnapshot - } - - this.tokenUsageSnapshot = this.getTokenUsage() - this.tokenUsageSnapshotAt = this.clineMessages.at(-1)?.ts - - return this.tokenUsageSnapshot - } - - public get cwd() { - return this.workspacePath - } } diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 320a9ff024..0cc03ab3a9 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -39,7 +39,7 @@ import { ORGANIZATION_ALLOW_ALL, } from "@roo-code/types" import { TelemetryService } from "@roo-code/telemetry" -import { CloudService, getRooCodeApiUrl } from "@roo-code/cloud" +import { CloudService, BridgeOrchestrator, getRooCodeApiUrl } from "@roo-code/cloud" import { Package } from "../../shared/package" import { findLast } from "../../shared/array" @@ -70,7 +70,6 @@ import { fileExistsAtPath } from "../../utils/fs" import { setTtsEnabled, setTtsSpeed } from "../../utils/tts" import { getWorkspaceGitInfo } from "../../utils/git" import { getWorkspacePath } from "../../utils/path" -import { isRemoteControlEnabled } from "../../utils/remoteControl" import { setPanel } from "../../activate/registerCommands" @@ -136,7 +135,6 @@ export class ClineProvider ) { super() - this.log("ClineProvider instantiated") ClineProvider.activeInstances.add(this) this.mdmService = mdmService @@ -302,11 +300,11 @@ export class ClineProvider // Adds a new Task instance to clineStack, marking the start of a new task. // The instance is pushed to the top of the stack (LIFO order). - // When the task is completed, the top instance is removed, reactivating the previous task. + // When the task is completed, the top instance is removed, reactivating the + // previous task. async addClineToStack(task: Task) { - console.log(`[subtasks] adding task ${task.taskId}.${task.instanceId} to stack`) - - // Add this cline instance into the stack that represents the order of all the called tasks. + // Add this cline instance into the stack that represents the order of + // all the called tasks. this.clineStack.push(task) task.emit(RooCodeEventName.TaskFocused) @@ -350,15 +348,13 @@ export class ClineProvider let task = this.clineStack.pop() if (task) { - console.log(`[subtasks] removing task ${task.taskId}.${task.instanceId} from stack`) - try { // Abort the running task and set isAbandoned to true so // all running promises will exit as well. await task.abortTask(true) } catch (e) { this.log( - `[subtasks] encountered error while aborting task ${task.taskId}.${task.instanceId}: ${e.message}`, + `[removeClineFromStack] encountered error while aborting task ${task.taskId}.${task.instanceId}: ${e.message}`, ) } @@ -384,6 +380,7 @@ export class ClineProvider if (this.clineStack.length === 0) { return undefined } + return this.clineStack[this.clineStack.length - 1] } @@ -396,19 +393,22 @@ export class ClineProvider return this.clineStack.map((cline) => cline.taskId) } - // remove the current task/cline instance (at the top of the stack), so this task is finished - // and resume the previous task/cline instance (if it exists) - // this is used when a sub task is finished and the parent task needs to be resumed + // Remove the current task/cline instance (at the top of the stack), so this + // task is finished and resume the previous task/cline instance (if it + // exists). + // This is used when a subtask is finished and the parent task needs to be + // resumed. async finishSubTask(lastMessage: string) { - console.log(`[subtasks] finishing subtask ${lastMessage}`) - // remove the last cline instance from the stack (this is the finished sub task) + // Remove the last cline instance from the stack (this is the finished + // subtask). await this.removeClineFromStack() - // resume the last cline instance in the stack (if it exists - this is the 'parent' calling task) + // Resume the last cline instance in the stack (if it exists - this is + // the 'parent' calling task). await this.getCurrentTask()?.resumePausedTask(lastMessage) } - // Clear the current task without treating it as a subtask - // This is used when the user cancels a task that is not a subtask + // Clear the current task without treating it as a subtask. + // This is used when the user cancels a task that is not a subtask. async clearTask() { await this.removeClineFromStack() } @@ -625,8 +625,6 @@ export class ClineProvider } async resolveWebviewView(webviewView: vscode.WebviewView | vscode.WebviewPanel) { - this.log("Resolving webview view") - this.view = webviewView const inTabMode = "onDidChangeViewState" in webviewView @@ -745,8 +743,6 @@ export class ClineProvider // If the extension is starting a new session, clear previous task state. await this.removeClineFromStack() - - this.log("Webview view resolved") } // When initializing a new task, (not from history but from a tool command @@ -800,7 +796,7 @@ export class ClineProvider parentTask, taskNumber: this.clineStack.length + 1, onCreated: this.taskCreationCallback, - enableTaskBridge: isRemoteControlEnabled(cloudUserInfo, remoteControlEnabled), + enableBridge: BridgeOrchestrator.isEnabled(cloudUserInfo, remoteControlEnabled), initialTodos: options.initialTodos, ...options, }) @@ -808,7 +804,7 @@ export class ClineProvider await this.addClineToStack(task) this.log( - `[subtasks] ${task.parentTask ? "child" : "parent"} task ${task.taskId}.${task.instanceId} instantiated`, + `[createTask] ${task.parentTask ? "child" : "parent"} task ${task.taskId}.${task.instanceId} instantiated`, ) return task @@ -872,9 +868,6 @@ export class ClineProvider remoteControlEnabled, } = await this.getState() - // Determine if TaskBridge should be enabled - const enableTaskBridge = isRemoteControlEnabled(cloudUserInfo, remoteControlEnabled) - const task = new Task({ provider: this, apiConfiguration, @@ -888,13 +881,13 @@ export class ClineProvider parentTask: historyItem.parentTask, taskNumber: historyItem.number, onCreated: this.taskCreationCallback, - enableTaskBridge, + enableBridge: BridgeOrchestrator.isEnabled(cloudUserInfo, remoteControlEnabled), }) await this.addClineToStack(task) this.log( - `[subtasks] ${task.parentTask ? "child" : "parent"} task ${task.taskId}.${task.instanceId} instantiated`, + `[createTaskWithHistoryItem] ${task.parentTask ? "child" : "parent"} task ${task.taskId}.${task.instanceId} instantiated`, ) // Restore preserved FCO state if provided (from task abort/cancel) @@ -1324,7 +1317,7 @@ export class ClineProvider return } - console.log(`[subtasks] cancelling task ${cline.taskId}.${cline.instanceId}`) + console.log(`[cancelTask] cancelling task ${cline.taskId}.${cline.instanceId}`) const { historyItem } = await this.getTaskWithId(cline.taskId) // Preserve parent and root task information for history item. @@ -2258,56 +2251,50 @@ export class ClineProvider return true } - public async handleRemoteControlToggle(enabled: boolean) { - const { CloudService: CloudServiceImport, ExtensionBridgeService } = await import("@roo-code/cloud") + public async remoteControlEnabled(enabled: boolean) { + const userInfo = CloudService.instance.getUserInfo() - const userInfo = CloudServiceImport.instance.getUserInfo() + const config = await CloudService.instance.cloudAPI?.bridgeConfig().catch(() => undefined) - const bridgeConfig = await CloudServiceImport.instance.cloudAPI?.bridgeConfig().catch(() => undefined) - - if (!bridgeConfig) { - this.log("[ClineProvider#handleRemoteControlToggle] Failed to get bridge config") + if (!config) { + this.log("[ClineProvider#remoteControlEnabled] Failed to get bridge config") return } - await ExtensionBridgeService.handleRemoteControlState( - userInfo, - enabled, - { ...bridgeConfig, provider: this, sessionId: vscode.env.sessionId }, - (message: string) => this.log(message), - ) + await BridgeOrchestrator.connectOrDisconnect(userInfo, enabled, { + ...config, + provider: this, + sessionId: vscode.env.sessionId, + }) - if (isRemoteControlEnabled(userInfo, enabled)) { + const bridge = BridgeOrchestrator.getInstance() + + if (bridge) { const currentTask = this.getCurrentTask() - if (currentTask && !currentTask.bridgeService) { + if (currentTask && !currentTask.bridge) { try { - currentTask.bridgeService = ExtensionBridgeService.getInstance() - - if (currentTask.bridgeService) { - await currentTask.bridgeService.subscribeToTask(currentTask) - } + currentTask.bridge = bridge + await currentTask.bridge.subscribeToTask(currentTask) } catch (error) { - const message = `[ClineProvider#handleRemoteControlToggle] subscribeToTask failed - ${error instanceof Error ? error.message : String(error)}` + const message = `[ClineProvider#remoteControlEnabled] subscribeToTask failed - ${error instanceof Error ? error.message : String(error)}` this.log(message) console.error(message) } } } else { for (const task of this.clineStack) { - if (task.bridgeService) { + if (task.bridge) { try { - await task.bridgeService.unsubscribeFromTask(task.taskId) - task.bridgeService = null + await task.bridge.unsubscribeFromTask(task.taskId) + task.bridge = null } catch (error) { - const message = `[ClineProvider#handleRemoteControlToggle] unsubscribeFromTask failed - ${error instanceof Error ? error.message : String(error)}` + const message = `[ClineProvider#remoteControlEnabled] unsubscribeFromTask failed - ${error instanceof Error ? error.message : String(error)}` this.log(message) console.error(message) } } } - - ExtensionBridgeService.resetInstance() } } diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index dce4d48776..54d64cd618 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -16,21 +16,14 @@ import { CloudService } from "@roo-code/cloud" import { TelemetryService } from "@roo-code/telemetry" import { type ApiMessage } from "../task-persistence/apiMessages" -import { saveTaskMessages } from "../task-persistence" import { ClineProvider } from "./ClineProvider" -import { handleCheckpointRestoreOperation } from "./checkpointRestoreHandler" import { changeLanguage, t } from "../../i18n" import { Package } from "../../shared/package" import { RouterName, toRouterName, ModelRecord } from "../../shared/api" import { MessageEnhancer } from "./messageEnhancer" -import { - type WebviewMessage, - type EditQueuedMessagePayload, - checkoutDiffPayloadSchema, - checkoutRestorePayloadSchema, -} from "../../shared/WebviewMessage" +import { checkoutDiffPayloadSchema, checkoutRestorePayloadSchema, WebviewMessage } from "../../shared/WebviewMessage" import { checkExistKey } from "../../shared/checkExistApiConfig" import { experimentDefault } from "../../shared/experiments" import { Terminal } from "../../integrations/terminal/Terminal" @@ -69,17 +62,14 @@ export const webviewMessageHandler = async ( const updateGlobalState = async (key: K, value: GlobalState[K]) => await provider.contextProxy.setValue(key, value) - const getCurrentCwd = () => { - return provider.getCurrentTask()?.cwd || provider.cwd - } /** * Shared utility to find message indices based on timestamp */ const findMessageIndices = (messageTs: number, currentCline: any) => { - // Find the exact message by timestamp, not the first one after a cutoff - const messageIndex = currentCline.clineMessages.findIndex((msg: ClineMessage) => msg.ts === messageTs) + const timeCutoff = messageTs - 1000 // 1 second buffer before the message + const messageIndex = currentCline.clineMessages.findIndex((msg: ClineMessage) => msg.ts && msg.ts >= timeCutoff) const apiConversationHistoryIndex = currentCline.apiConversationHistory.findIndex( - (msg: ApiMessage) => msg.ts === messageTs, + (msg: ApiMessage) => msg.ts && msg.ts >= timeCutoff, ) return { messageIndex, apiConversationHistoryIndex } } @@ -106,110 +96,38 @@ export const webviewMessageHandler = async ( * Handles message deletion operations with user confirmation */ const handleDeleteOperation = async (messageTs: number): Promise => { - // Check if there's a checkpoint before this message - const currentCline = provider.getCurrentTask() - let hasCheckpoint = false - if (currentCline) { - const { messageIndex } = findMessageIndices(messageTs, currentCline) - if (messageIndex !== -1) { - // Find the last checkpoint before this message - const checkpoints = currentCline.clineMessages.filter( - (msg) => msg.say === "checkpoint_saved" && msg.ts > messageTs, - ) - - hasCheckpoint = checkpoints.length > 0 - } else { - console.log("[webviewMessageHandler] Message not found! Looking for ts:", messageTs) - } - } - // Send message to webview to show delete confirmation dialog await provider.postMessageToWebview({ type: "showDeleteMessageDialog", messageTs, - hasCheckpoint, }) } /** * Handles confirmed message deletion from webview dialog */ - const handleDeleteMessageConfirm = async (messageTs: number, restoreCheckpoint?: boolean): Promise => { - const currentCline = provider.getCurrentTask() - if (!currentCline) { - console.error("[handleDeleteMessageConfirm] No current cline available") - return - } + const handleDeleteMessageConfirm = async (messageTs: number): Promise => { + // Only proceed if we have a current task. + if (provider.getCurrentTask()) { + const currentCline = provider.getCurrentTask()! + const { messageIndex, apiConversationHistoryIndex } = findMessageIndices(messageTs, currentCline) - const { messageIndex, apiConversationHistoryIndex } = findMessageIndices(messageTs, currentCline) + if (messageIndex !== -1) { + try { + const { historyItem } = await provider.getTaskWithId(currentCline.taskId) - if (messageIndex === -1) { - const errorMessage = `Message with timestamp ${messageTs} not found` - console.error("[handleDeleteMessageConfirm]", errorMessage) - await vscode.window.showErrorMessage(errorMessage) - return - } + // Delete this message and all subsequent messages + await removeMessagesThisAndSubsequent(currentCline, messageIndex, apiConversationHistoryIndex) - try { - const targetMessage = currentCline.clineMessages[messageIndex] - - // If checkpoint restoration is requested, find and restore to the last checkpoint before this message - if (restoreCheckpoint) { - // Find the last checkpoint before this message - const checkpoints = currentCline.clineMessages.filter( - (msg) => msg.say === "checkpoint_saved" && msg.ts > messageTs, - ) - - const nextCheckpoint = checkpoints[0] - - if (nextCheckpoint && nextCheckpoint.text) { - await handleCheckpointRestoreOperation({ - provider, - currentCline, - messageTs: targetMessage.ts!, - messageIndex, - checkpoint: { hash: nextCheckpoint.text }, - operation: "delete", - }) - } else { - // No checkpoint found before this message - console.log("[handleDeleteMessageConfirm] No checkpoint found before message") - vscode.window.showWarningMessage("No checkpoint found before this message") + // Initialize with history item after deletion + await provider.createTaskWithHistoryItem(historyItem) + } catch (error) { + console.error("Error in delete message:", error) + vscode.window.showErrorMessage( + `Error deleting message: ${error instanceof Error ? error.message : String(error)}`, + ) } - } else { - // For non-checkpoint deletes, preserve checkpoint associations for remaining messages - // Store checkpoints from messages that will be preserved - const preservedCheckpoints = new Map() - for (let i = 0; i < messageIndex; i++) { - const msg = currentCline.clineMessages[i] - if (msg?.checkpoint && msg.ts) { - preservedCheckpoints.set(msg.ts, msg.checkpoint) - } - } - - // Delete this message and all subsequent messages - await removeMessagesThisAndSubsequent(currentCline, messageIndex, apiConversationHistoryIndex) - - // Restore checkpoint associations for preserved messages - for (const [ts, checkpoint] of preservedCheckpoints) { - const msgIndex = currentCline.clineMessages.findIndex((msg) => msg.ts === ts) - if (msgIndex !== -1) { - currentCline.clineMessages[msgIndex].checkpoint = checkpoint - } - } - - // Save the updated messages with restored checkpoints - await saveTaskMessages({ - messages: currentCline.clineMessages, - taskId: currentCline.taskId, - globalStoragePath: provider.contextProxy.globalStorageUri.fsPath, - }) } - } catch (error) { - console.error("Error in delete message:", error) - vscode.window.showErrorMessage( - `Error deleting message: ${error instanceof Error ? error.message : String(error)}`, - ) } } @@ -217,31 +135,11 @@ export const webviewMessageHandler = async ( * Handles message editing operations with user confirmation */ const handleEditOperation = async (messageTs: number, editedContent: string, images?: string[]): Promise => { - // Check if there's a checkpoint before this message - const currentCline = provider.getCurrentTask() - let hasCheckpoint = false - if (currentCline) { - const { messageIndex } = findMessageIndices(messageTs, currentCline) - if (messageIndex !== -1) { - // Find the last checkpoint before this message - const checkpoints = currentCline.clineMessages.filter( - (msg) => msg.say === "checkpoint_saved" && msg.ts > messageTs, - ) - - hasCheckpoint = checkpoints.length > 0 - } else { - console.log("[webviewMessageHandler] Edit - Message not found in clineMessages!") - } - } else { - console.log("[webviewMessageHandler] Edit - No currentCline available!") - } - // Send message to webview to show edit confirmation dialog await provider.postMessageToWebview({ type: "showEditMessageDialog", messageTs, text: editedContent, - hasCheckpoint, images, }) } @@ -252,105 +150,38 @@ export const webviewMessageHandler = async ( const handleEditMessageConfirm = async ( messageTs: number, editedContent: string, - restoreCheckpoint?: boolean, images?: string[], ): Promise => { - const currentCline = provider.getCurrentTask() - if (!currentCline) { - console.error("[handleEditMessageConfirm] No current cline available") - return - } + // Only proceed if we have a current task. + if (provider.getCurrentTask()) { + const currentCline = provider.getCurrentTask()! - // Use findMessageIndices to find messages based on timestamp - const { messageIndex, apiConversationHistoryIndex } = findMessageIndices(messageTs, currentCline) + // Use findMessageIndices to find messages based on timestamp + const { messageIndex, apiConversationHistoryIndex } = findMessageIndices(messageTs, currentCline) - if (messageIndex === -1) { - const errorMessage = `Message with timestamp ${messageTs} not found` - console.error("[handleEditMessageConfirm]", errorMessage) - await vscode.window.showErrorMessage(errorMessage) - return - } + if (messageIndex !== -1) { + try { + // Edit this message and delete subsequent + await removeMessagesThisAndSubsequent(currentCline, messageIndex, apiConversationHistoryIndex) - try { - const targetMessage = currentCline.clineMessages[messageIndex] - - // If checkpoint restoration is requested, find and restore to the last checkpoint before this message - if (restoreCheckpoint) { - // Find the last checkpoint before this message - const checkpoints = currentCline.clineMessages.filter( - (msg) => msg.say === "checkpoint_saved" && msg.ts > messageTs, - ) - - const nextCheckpoint = checkpoints[0] - - if (nextCheckpoint && nextCheckpoint.text) { - await handleCheckpointRestoreOperation({ - provider, - currentCline, - messageTs: targetMessage.ts!, - messageIndex, - checkpoint: { hash: nextCheckpoint.text }, - operation: "edit", - editData: { - editedContent, - images, - apiConversationHistoryIndex, - }, + // Process the edited message as a regular user message + // This will add it to the conversation and trigger an AI response + webviewMessageHandler(provider, { + type: "askResponse", + askResponse: "messageResponse", + text: editedContent, + images, }) - // The task will be cancelled and reinitialized by checkpointRestore - // The pending edit will be processed in the reinitialized task - return - } else { - // No checkpoint found before this message - console.log("[handleEditMessageConfirm] No checkpoint found before message") - vscode.window.showWarningMessage("No checkpoint found before this message") - // Continue with non-checkpoint edit + + // Don't initialize with history item for edit operations + // The webviewMessageHandler will handle the conversation state + } catch (error) { + console.error("Error in edit message:", error) + vscode.window.showErrorMessage( + `Error editing message: ${error instanceof Error ? error.message : String(error)}`, + ) } } - - // For non-checkpoint edits, preserve checkpoint associations for remaining messages - // Store checkpoints from messages that will be preserved - const preservedCheckpoints = new Map() - for (let i = 0; i < messageIndex; i++) { - const msg = currentCline.clineMessages[i] - if (msg?.checkpoint && msg.ts) { - preservedCheckpoints.set(msg.ts, msg.checkpoint) - } - } - - // Edit this message and delete subsequent - await removeMessagesThisAndSubsequent(currentCline, messageIndex, apiConversationHistoryIndex) - - // Restore checkpoint associations for preserved messages - for (const [ts, checkpoint] of preservedCheckpoints) { - const msgIndex = currentCline.clineMessages.findIndex((msg) => msg.ts === ts) - if (msgIndex !== -1) { - currentCline.clineMessages[msgIndex].checkpoint = checkpoint - } - } - - // Save the updated messages with restored checkpoints - await saveTaskMessages({ - messages: currentCline.clineMessages, - taskId: currentCline.taskId, - globalStoragePath: provider.contextProxy.globalStorageUri.fsPath, - }) - - // Process the edited message as a regular user message - webviewMessageHandler(provider, { - type: "askResponse", - askResponse: "messageResponse", - text: editedContent, - images, - }) - - // Don't initialize with history item for edit operations - // The webviewMessageHandler will handle the conversation state - } catch (error) { - console.error("Error in edit message:", error) - vscode.window.showErrorMessage( - `Error editing message: ${error instanceof Error ? error.message : String(error)}`, - ) } } @@ -714,7 +545,6 @@ export const webviewMessageHandler = async ( litellm: {}, ollama: {}, lmstudio: {}, - deepinfra: {}, } const safeGetModels = async (options: GetModelsOptions): Promise => { @@ -742,14 +572,6 @@ export const webviewMessageHandler = async ( { key: "glama", options: { provider: "glama" } }, { key: "unbound", options: { provider: "unbound", apiKey: apiConfiguration.unboundApiKey } }, { key: "vercel-ai-gateway", options: { provider: "vercel-ai-gateway" } }, - { - key: "deepinfra", - options: { - provider: "deepinfra", - apiKey: apiConfiguration.deepInfraApiKey, - baseUrl: apiConfiguration.deepInfraBaseUrl, - }, - }, ] // Add IO Intelligence if API key is provided @@ -915,14 +737,10 @@ export const webviewMessageHandler = async ( saveImage(message.dataUri!) break case "openFile": - let filePath: string = message.text! - if (!path.isAbsolute(filePath)) { - filePath = path.join(getCurrentCwd(), filePath) - } - openFile(filePath, message.values as { create?: boolean; content?: string; line?: number }) + openFile(message.text!, message.values as { create?: boolean; content?: string; line?: number }) break case "openMention": - openMention(getCurrentCwd(), message.text) + openMention(message.text) break case "openExternal": if (message.url) { @@ -1017,8 +835,8 @@ export const webviewMessageHandler = async ( return } - const workspaceFolder = getCurrentCwd() - const rooDir = path.join(workspaceFolder, ".roo") + const workspaceFolder = vscode.workspace.workspaceFolders[0] + const rooDir = path.join(workspaceFolder.uri.fsPath, ".roo") const mcpPath = path.join(rooDir, "mcp.json") try { @@ -1132,22 +950,8 @@ export const webviewMessageHandler = async ( await provider.postStateToWebview() break case "remoteControlEnabled": - try { - await CloudService.instance.updateUserSettings({ extensionBridgeEnabled: message.bool ?? false }) - } catch (error) { - provider.log( - `CloudService#updateUserSettings failed: ${error instanceof Error ? error.message : String(error)}`, - ) - } - - try { - await provider.remoteControlEnabled(message.bool ?? false) - } catch (error) { - provider.log( - `ClineProvider#remoteControlEnabled failed: ${error instanceof Error ? error.message : String(error)}`, - ) - } - + await updateGlobalState("remoteControlEnabled", message.bool ?? false) + await provider.remoteControlEnabled(message.bool ?? false) await provider.postStateToWebview() break case "refreshAllMcpServers": { @@ -1504,14 +1308,6 @@ export const webviewMessageHandler = async ( await updateGlobalState("language", message.text as Language) await provider.postStateToWebview() break - case "openRouterImageApiKey": - await provider.contextProxy.setValue("openRouterImageApiKey", message.text) - await provider.postStateToWebview() - break - case "openRouterImageGenerationSelectedModel": - await provider.contextProxy.setValue("openRouterImageGenerationSelectedModel", message.text) - await provider.postStateToWebview() - break case "showRooIgnoredFiles": await updateGlobalState("showRooIgnoredFiles", message.bool ?? false) await provider.postStateToWebview() @@ -1603,7 +1399,7 @@ export const webviewMessageHandler = async ( const { apiConfiguration, customSupportPrompts, - listApiConfigMeta = [], + listApiConfigMeta, enhancementApiConfigId, includeTaskHistoryInEnhance, } = state @@ -1668,7 +1464,7 @@ export const webviewMessageHandler = async ( } break case "searchCommits": { - const cwd = getCurrentCwd() + const cwd = provider.cwd if (cwd) { try { const commits = await searchCommits(message.query || "", cwd) @@ -1686,7 +1482,7 @@ export const webviewMessageHandler = async ( break } case "searchFiles": { - const workspacePath = getCurrentCwd() + const workspacePath = getWorkspacePath() if (!workspacePath) { // Handle case where workspace path is not available @@ -1848,17 +1644,12 @@ export const webviewMessageHandler = async ( break case "deleteMessageConfirm": if (message.messageTs) { - await handleDeleteMessageConfirm(message.messageTs, message.restoreCheckpoint) + await handleDeleteMessageConfirm(message.messageTs) } break case "editMessageConfirm": if (message.messageTs && message.text) { - await handleEditMessageConfirm( - message.messageTs, - message.text, - message.restoreCheckpoint, - message.images, - ) + await handleEditMessageConfirm(message.messageTs, message.text, message.images) } break case "getListApiConfiguration": @@ -2229,9 +2020,9 @@ export const webviewMessageHandler = async ( await provider.postStateToWebview() break } - case "cloudButtonClicked": { - // Navigate to the cloud tab. - provider.postMessageToWebview({ type: "action", action: "cloudButtonClicked" }) + case "accountButtonClicked": { + // Navigate to the account tab. + provider.postMessageToWebview({ type: "action", action: "accountButtonClicked" }) break } case "rooCloudSignIn": { @@ -2675,7 +2466,7 @@ export const webviewMessageHandler = async ( case "requestCommands": { try { const { getCommands } = await import("../../services/command/commands") - const commands = await getCommands(getCurrentCwd()) + const commands = await getCommands(provider.cwd || "") // Convert to the format expected by the frontend const commandList = commands.map((command) => ({ @@ -2704,7 +2495,7 @@ export const webviewMessageHandler = async ( try { if (message.text) { const { getCommand } = await import("../../services/command/commands") - const command = await getCommand(getCurrentCwd(), message.text) + const command = await getCommand(provider.cwd || "", message.text) if (command && command.filePath) { openFile(command.filePath) @@ -2724,7 +2515,7 @@ export const webviewMessageHandler = async ( try { if (message.text && message.values?.source) { const { getCommand } = await import("../../services/command/commands") - const command = await getCommand(getCurrentCwd(), message.text) + const command = await getCommand(provider.cwd || "", message.text) if (command && command.filePath) { // Delete the command file @@ -2756,12 +2547,8 @@ export const webviewMessageHandler = async ( const globalConfigDir = path.join(os.homedir(), ".roo") commandsDir = path.join(globalConfigDir, "commands") } else { - if (!vscode.workspace.workspaceFolders?.length) { - vscode.window.showErrorMessage(t("common:errors.no_workspace")) - return - } // Project commands - const workspaceRoot = getCurrentCwd() + const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath if (!workspaceRoot) { vscode.window.showErrorMessage(t("common:errors.no_workspace_for_project_command")) break @@ -2841,7 +2628,7 @@ export const webviewMessageHandler = async ( // Refresh commands list const { getCommands } = await import("../../services/command/commands") - const commands = await getCommands(getCurrentCwd() || "") + const commands = await getCommands(provider.cwd || "") const commandList = commands.map((command) => ({ name: command.name, source: command.source, @@ -2876,26 +2663,5 @@ export const webviewMessageHandler = async ( vscode.window.showWarningMessage(t("common:mdm.info.organization_requires_auth")) break } - - /** - * Chat Message Queue - */ - - case "queueMessage": { - provider.getCurrentTask()?.messageQueueService.addMessage(message.text ?? "", message.images) - break - } - case "removeQueuedMessage": { - provider.getCurrentTask()?.messageQueueService.removeMessage(message.text ?? "") - break - } - case "editQueuedMessage": { - if (message.payload) { - const { id, text, images } = message.payload as EditQueuedMessagePayload - provider.getCurrentTask()?.messageQueueService.updateMessage(id, text, images) - } - - break - } } } diff --git a/src/extension.ts b/src/extension.ts index 6060bb341f..4aa5ff1113 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -13,7 +13,7 @@ try { } import type { CloudUserInfo } from "@roo-code/types" -import { CloudService, ExtensionBridgeService } from "@roo-code/cloud" +import { CloudService, BridgeOrchestrator } from "@roo-code/cloud" import { TelemetryService, PostHogTelemetryClient } from "@roo-code/telemetry" import "./utils/path" // Necessary to have access to String.prototype.toPosix. @@ -30,7 +30,6 @@ import { CodeIndexManager } from "./services/code-index/manager" import { MdmService } from "./services/mdm/MdmService" import { migrateSettings } from "./utils/migrateSettings" import { autoImportSettings } from "./utils/autoImportSettings" -import { isRemoteControlEnabled } from "./utils/remoteControl" import { API } from "./extension/api" import { @@ -147,15 +146,10 @@ export async function activate(context: vscode.ExtensionContext) { cloudLogger(`[CloudService] isCloudAgent = ${isCloudAgent}, socketBridgeUrl = ${config.socketBridgeUrl}`) - ExtensionBridgeService.handleRemoteControlState( + await BridgeOrchestrator.connectOrDisconnect( userInfo, isCloudAgent ? true : contextProxy.getValue("remoteControlEnabled"), - { - ...config, - provider, - sessionId: vscode.env.sessionId, - }, - cloudLogger, + { ...config, provider, sessionId: vscode.env.sessionId }, ) } catch (error) { cloudLogger( @@ -333,10 +327,10 @@ export async function deactivate() { } } - const bridgeService = ExtensionBridgeService.getInstance() + const bridge = BridgeOrchestrator.getInstance() - if (bridgeService) { - await bridgeService.disconnect() + if (bridge) { + await bridge.disconnect() } await McpServerManager.cleanup(extensionContext) diff --git a/src/utils/remoteControl.ts b/src/utils/remoteControl.ts deleted file mode 100644 index f003b522d1..0000000000 --- a/src/utils/remoteControl.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type { CloudUserInfo } from "@roo-code/types" - -/** - * Determines if remote control features should be enabled - * @param cloudUserInfo - User information from cloud service - * @param remoteControlEnabled - User's remote control setting - * @returns true if remote control should be enabled - */ -export function isRemoteControlEnabled(cloudUserInfo?: CloudUserInfo | null, remoteControlEnabled?: boolean): boolean { - return !!(cloudUserInfo?.id && cloudUserInfo.extensionBridgeEnabled && remoteControlEnabled) -}