diff --git a/packages/cloud/src/__mocks__/vscode.ts b/packages/cloud/src/__mocks__/vscode.ts index 09384d195ff1..52585437863f 100644 --- a/packages/cloud/src/__mocks__/vscode.ts +++ b/packages/cloud/src/__mocks__/vscode.ts @@ -13,6 +13,10 @@ export const Uri = { parse: vi.fn((uri: string) => ({ toString: () => uri })), } +export const commands = { + executeCommand: vi.fn().mockResolvedValue(undefined), +} + export interface ExtensionContext { secrets: { get: (key: string) => Promise diff --git a/packages/cloud/src/bridge/BaseChannel.ts b/packages/cloud/src/bridge/BaseChannel.ts index 95db835d1f92..90d3ebbe7a1e 100644 --- a/packages/cloud/src/bridge/BaseChannel.ts +++ b/packages/cloud/src/bridge/BaseChannel.ts @@ -1,4 +1,13 @@ 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. @@ -11,9 +20,13 @@ import type { Socket } from "socket.io-client" export abstract class BaseChannel { protected socket: Socket | null = null protected readonly instanceId: string + protected readonly appProperties: StaticAppProperties + protected readonly gitProperties?: GitProperties - constructor(instanceId: string) { - this.instanceId = instanceId + constructor(options: BaseChannelOptions) { + this.instanceId = options.instanceId + this.appProperties = options.appProperties + this.gitProperties = options.gitProperties } /** @@ -81,9 +94,26 @@ 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. */ - public abstract handleCommand(command: TCommand): Promise + protected abstract handleCommandImplementation(command: TCommand): Promise /** * Handle connection-specific logic. diff --git a/packages/cloud/src/bridge/BridgeOrchestrator.ts b/packages/cloud/src/bridge/BridgeOrchestrator.ts index 69a6f5a57d02..15b5c65eb204 100644 --- a/packages/cloud/src/bridge/BridgeOrchestrator.ts +++ b/packages/cloud/src/bridge/BridgeOrchestrator.ts @@ -1,4 +1,5 @@ import crypto from "crypto" +import os from "os" import { type TaskProviderLike, @@ -6,6 +7,8 @@ import { type CloudUserInfo, type ExtensionBridgeCommand, type TaskBridgeCommand, + type StaticAppProperties, + type GitProperties, ConnectionState, ExtensionSocketEvents, TaskSocketEvents, @@ -39,6 +42,8 @@ export class BridgeOrchestrator { private readonly token: string private readonly provider: TaskProviderLike private readonly instanceId: string + private readonly appProperties: StaticAppProperties + private readonly gitProperties?: GitProperties // Components private socketTransport: SocketTransport @@ -61,66 +66,72 @@ export class BridgeOrchestrator { public static async connectOrDisconnect( userInfo: CloudUserInfo | null, remoteControlEnabled: boolean | undefined, - options?: BridgeOrchestratorOptions, + options: BridgeOrchestratorOptions, ): Promise { - const isEnabled = BridgeOrchestrator.isEnabled(userInfo, remoteControlEnabled) + if (BridgeOrchestrator.isEnabled(userInfo, remoteControlEnabled)) { + await BridgeOrchestrator.connect(options) + } else { + await BridgeOrchestrator.disconnect() + } + } + + public static async connect(options: BridgeOrchestratorOptions) { const instance = BridgeOrchestrator.instance - if (isEnabled) { - if (!instance) { - if (!options) { - console.error( - `[BridgeOrchestrator#connectOrDisconnect] Cannot connect: options are required for connection`, - ) - return - } - try { - console.log(`[BridgeOrchestrator#connectOrDisconnect] Connecting...`) - 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})`, - ) + if (!instance) { + try { + console.log(`[BridgeOrchestrator#connectOrDisconnect] Connecting...`) + // Populate telemetry properties before registering the instance. + await options.provider.getTelemetryProperties() - 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})`, - ) - } + 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) { - try { - console.log( - `[BridgeOrchestrator#connectOrDisconnect] Disconnecting... (state: ${instance.connectionState})`, - ) + if ( + instance.connectionState === ConnectionState.FAILED || + instance.connectionState === ConnectionState.DISCONNECTED + ) { + console.log( + `[BridgeOrchestrator#connectOrDisconnect] Re-connecting... (state: ${instance.connectionState})`, + ) - await instance.disconnect() - } catch (error) { + instance.reconnect().catch((error) => { console.error( - `[BridgeOrchestrator#connectOrDisconnect] disconnect() failed: ${error instanceof Error ? error.message : String(error)}`, + `[BridgeOrchestrator#connectOrDisconnect] reconnect() failed: ${error instanceof Error ? error.message : String(error)}`, ) - } finally { - BridgeOrchestrator.instance = null - } + }) } else { - console.log(`[BridgeOrchestrator#connectOrDisconnect] Already disconnected`) + console.log( + `[BridgeOrchestrator#connectOrDisconnect] Already connected or 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 } + } else { + console.log(`[BridgeOrchestrator#connectOrDisconnect] Already disconnected`) } } @@ -146,6 +157,8 @@ 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, @@ -166,8 +179,19 @@ export class BridgeOrchestrator { onReconnect: () => this.handleReconnect(), }) - this.extensionChannel = new ExtensionChannel(this.instanceId, this.userId, this.provider) - this.taskChannel = new TaskChannel(this.instanceId) + 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, + }) } private setupSocketListeners() { @@ -288,9 +312,6 @@ 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() } diff --git a/packages/cloud/src/bridge/ExtensionChannel.ts b/packages/cloud/src/bridge/ExtensionChannel.ts index 735d6c16b1f9..da98f9ac579b 100644 --- a/packages/cloud/src/bridge/ExtensionChannel.ts +++ b/packages/cloud/src/bridge/ExtensionChannel.ts @@ -14,7 +14,12 @@ import { HEARTBEAT_INTERVAL_MS, } from "@roo-code/types" -import { BaseChannel } from "./BaseChannel.js" +import { type BaseChannelOptions, BaseChannel } from "./BaseChannel.js" + +interface ExtensionChannelOptions extends BaseChannelOptions { + userId: string + provider: TaskProviderLike +} /** * Manages the extension-level communication channel. @@ -31,33 +36,36 @@ export class ExtensionChannel extends BaseChannel< private heartbeatInterval: NodeJS.Timeout | null = null private eventListeners: Map void> = new Map() - constructor(instanceId: string, userId: string, provider: TaskProviderLike) { - super(instanceId) - this.userId = userId - this.provider = provider + constructor(options: ExtensionChannelOptions) { + super({ + instanceId: options.instanceId, + appProperties: options.appProperties, + gitProperties: options.gitProperties, + }) + + this.userId = options.userId + this.provider = options.provider this.extensionInstance = { instanceId: this.instanceId, userId: this.userId, workspacePath: this.provider.cwd, - appProperties: this.provider.appProperties, - gitProperties: this.provider.gitProperties, + appProperties: this.appProperties, + gitProperties: this.gitProperties, lastHeartbeat: Date.now(), - task: { - taskId: "", - taskStatus: TaskStatus.None, - }, + task: { taskId: "", taskStatus: TaskStatus.None }, taskHistory: [], } this.setupListeners() } - public async handleCommand(command: ExtensionBridgeCommand): Promise { + protected async handleCommandImplementation(command: ExtensionBridgeCommand): Promise { if (command.instanceId !== this.instanceId) { console.log(`[ExtensionChannel] command -> instance id mismatch | ${this.instanceId}`, { messageInstanceId: command.instanceId, }) + return } @@ -217,8 +225,6 @@ export class ExtensionChannel extends BaseChannel< this.extensionInstance = { ...this.extensionInstance, - appProperties: this.extensionInstance.appProperties ?? this.provider.appProperties, - gitProperties: this.extensionInstance.gitProperties ?? this.provider.gitProperties, lastHeartbeat: Date.now(), task: task ? { diff --git a/packages/cloud/src/bridge/TaskChannel.ts b/packages/cloud/src/bridge/TaskChannel.ts index f974a3e559b9..433e740d4edb 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 { BaseChannel } from "./BaseChannel.js" +import { type BaseChannelOptions, BaseChannel } from "./BaseChannel.js" type TaskEventListener = { [K in keyof TaskEvents]: (...args: TaskEvents[K]) => void | Promise @@ -26,6 +26,9 @@ 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. @@ -69,11 +72,11 @@ export class TaskChannel extends BaseChannel< }, ] as const - constructor(instanceId: string) { - super(instanceId) + constructor(options: TaskChannelOptions) { + super(options) } - public async handleCommand(command: TaskBridgeCommand): Promise { + protected async handleCommandImplementation(command: TaskBridgeCommand): Promise { const task = this.subscribedTasks.get(command.taskId) if (!task) { diff --git a/packages/cloud/src/bridge/__tests__/ExtensionChannel.test.ts b/packages/cloud/src/bridge/__tests__/ExtensionChannel.test.ts index f4d09e51a5ee..99bd88969ab4 100644 --- a/packages/cloud/src/bridge/__tests__/ExtensionChannel.test.ts +++ b/packages/cloud/src/bridge/__tests__/ExtensionChannel.test.ts @@ -5,6 +5,7 @@ import type { Socket } from "socket.io-client" import { type TaskProviderLike, type TaskProviderEvents, + type StaticAppProperties, RooCodeEventName, ExtensionBridgeEventName, ExtensionSocketEvents, @@ -19,6 +20,15 @@ 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>>() @@ -80,7 +90,12 @@ describe("ExtensionChannel", () => { } as unknown as TaskProviderLike // Create extension channel instance - extensionChannel = new ExtensionChannel(instanceId, userId, mockProvider) + extensionChannel = new ExtensionChannel({ + instanceId, + appProperties, + userId, + provider: mockProvider, + }) }) afterEach(() => { @@ -155,7 +170,12 @@ describe("ExtensionChannel", () => { it("should not have duplicate listeners after multiple channel creations", () => { // Create a second channel with the same provider - const secondChannel = new ExtensionChannel("instance-2", userId, mockProvider) + const secondChannel = new ExtensionChannel({ + instanceId: "instance-2", + appProperties, + userId, + provider: mockProvider, + }) // Each event should have exactly 2 listeners (one from each channel) eventListeners.forEach((listeners) => { diff --git a/packages/cloud/src/bridge/__tests__/TaskChannel.test.ts b/packages/cloud/src/bridge/__tests__/TaskChannel.test.ts index 4a6aa724684a..1f13da966129 100644 --- a/packages/cloud/src/bridge/__tests__/TaskChannel.test.ts +++ b/packages/cloud/src/bridge/__tests__/TaskChannel.test.ts @@ -6,6 +6,7 @@ import type { Socket } from "socket.io-client" import { type TaskLike, type ClineMessage, + type StaticAppProperties, RooCodeEventName, TaskBridgeEventName, TaskBridgeCommandName, @@ -22,6 +23,15 @@ 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 = { @@ -75,7 +85,10 @@ describe("TaskChannel", () => { } // Create task channel instance - taskChannel = new TaskChannel(instanceId) + taskChannel = new TaskChannel({ + instanceId, + appProperties, + }) }) afterEach(() => { @@ -320,7 +333,7 @@ describe("TaskChannel", () => { channel.subscribedTasks.set(taskId, mockTask) }) - it("should handle Message command", () => { + it("should handle Message command", async () => { const command = { type: TaskBridgeCommandName.Message, taskId, @@ -331,7 +344,7 @@ describe("TaskChannel", () => { }, } - taskChannel.handleCommand(command) + await taskChannel.handleCommand(command) expect(mockTask.submitUserMessage).toHaveBeenCalledWith( command.payload.text, @@ -341,7 +354,7 @@ describe("TaskChannel", () => { ) }) - it("should handle ApproveAsk command", () => { + it("should handle ApproveAsk command", async () => { const command = { type: TaskBridgeCommandName.ApproveAsk, taskId, @@ -351,12 +364,12 @@ describe("TaskChannel", () => { }, } - taskChannel.handleCommand(command) + await taskChannel.handleCommand(command) expect(mockTask.approveAsk).toHaveBeenCalledWith(command.payload) }) - it("should handle DenyAsk command", () => { + it("should handle DenyAsk command", async () => { const command = { type: TaskBridgeCommandName.DenyAsk, taskId, @@ -366,12 +379,12 @@ describe("TaskChannel", () => { }, } - taskChannel.handleCommand(command) + await taskChannel.handleCommand(command) expect(mockTask.denyAsk).toHaveBeenCalledWith(command.payload) }) - it("should log error for unknown task", () => { + it("should log error for unknown task", async () => { const errorSpy = vi.spyOn(console, "error") const command = { @@ -383,7 +396,7 @@ describe("TaskChannel", () => { }, } - taskChannel.handleCommand(command) + await taskChannel.handleCommand(command) expect(errorSpy).toHaveBeenCalledWith(`[TaskChannel] Unable to find task unknown-task`) diff --git a/packages/types/npm/package.metadata.json b/packages/types/npm/package.metadata.json index 11da65550c62..af0d1bf2cdfa 100644 --- a/packages/types/npm/package.metadata.json +++ b/packages/types/npm/package.metadata.json @@ -1,6 +1,6 @@ { "name": "@roo-code/types", - "version": "1.69.0", + "version": "1.71.0", "description": "TypeScript type definitions for Roo Code.", "publishConfig": { "access": "public", diff --git a/packages/types/src/telemetry.ts b/packages/types/src/telemetry.ts index d872329b93c8..d64827102354 100644 --- a/packages/types/src/telemetry.ts +++ b/packages/types/src/telemetry.ts @@ -78,6 +78,7 @@ export const staticAppPropertiesSchema = z.object({ vscodeVersion: z.string(), platform: z.string(), editorName: z.string(), + hostname: z.string().optional(), }) export type StaticAppProperties = z.infer diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 15d5fa51a3af..bc29e67c0c13 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -2095,7 +2095,6 @@ export class ClineProvider public async remoteControlEnabled(enabled: boolean) { const userInfo = CloudService.instance.getUserInfo() - const config = await CloudService.instance.cloudAPI?.bridgeConfig().catch(() => undefined) if (!config) { diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index b9a30708dd57..a495489cc1d6 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -956,13 +956,21 @@ export const webviewMessageHandler = async ( break case "remoteControlEnabled": try { - await CloudService.instance.updateUserSettings({ - extensionBridgeEnabled: message.bool ?? false, - }) + 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(`Failed to update cloud settings for remote control: ${error}`) + provider.log( + `ClineProvider#remoteControlEnabled failed: ${error instanceof Error ? error.message : String(error)}`, + ) } - await provider.remoteControlEnabled(message.bool ?? false) + await provider.postStateToWebview() break case "refreshAllMcpServers": { diff --git a/src/extension.ts b/src/extension.ts index dec9b8a80dad..c1f8e0764e50 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -131,20 +131,13 @@ export async function activate(context: vscode.ExtensionContext) { authStateChangedHandler = async (data: { state: AuthState; previousState: AuthState }) => { postStateListener() - // Check if user has logged out if (data.state === "logged-out") { try { - // Disconnect the bridge when user logs out - // When userInfo is null and remoteControlEnabled is false, BridgeOrchestrator - // will disconnect. The options parameter is not needed for disconnection. - await BridgeOrchestrator.connectOrDisconnect(null, false) - + await BridgeOrchestrator.disconnect() cloudLogger("[CloudService] BridgeOrchestrator disconnected on logout") } catch (error) { cloudLogger( - `[CloudService] Failed to disconnect BridgeOrchestrator on logout: ${ - error instanceof Error ? error.message : String(error) - }`, + `[CloudService] Failed to disconnect BridgeOrchestrator on logout: ${error instanceof Error ? error.message : String(error)}`, ) } } @@ -152,6 +145,7 @@ export async function activate(context: vscode.ExtensionContext) { settingsUpdatedHandler = async () => { const userInfo = CloudService.instance.getUserInfo() + if (userInfo && CloudService.instance.cloudAPI) { try { const config = await CloudService.instance.cloudAPI.bridgeConfig() @@ -163,8 +157,6 @@ export async function activate(context: vscode.ExtensionContext) { ? true : (CloudService.instance.getUserSettings()?.settings?.extensionBridgeEnabled ?? false) - cloudLogger(`[CloudService] Settings updated - remoteControlEnabled = ${remoteControlEnabled}`) - await BridgeOrchestrator.connectOrDisconnect(userInfo, remoteControlEnabled, { ...config, provider, @@ -172,7 +164,7 @@ export async function activate(context: vscode.ExtensionContext) { }) } catch (error) { cloudLogger( - `[CloudService] Failed to update BridgeOrchestrator on settings change: ${error instanceof Error ? error.message : String(error)}`, + `[CloudService] BridgeOrchestrator#connectOrDisconnect failed on settings change: ${error instanceof Error ? error.message : String(error)}`, ) } } @@ -194,8 +186,6 @@ export async function activate(context: vscode.ExtensionContext) { const isCloudAgent = typeof process.env.ROO_CODE_CLOUD_TOKEN === "string" && process.env.ROO_CODE_CLOUD_TOKEN.length > 0 - cloudLogger(`[CloudService] isCloudAgent = ${isCloudAgent}, socketBridgeUrl = ${config.socketBridgeUrl}`) - const remoteControlEnabled = isCloudAgent ? true : (CloudService.instance.getUserSettings()?.settings?.extensionBridgeEnabled ?? false) @@ -207,7 +197,7 @@ export async function activate(context: vscode.ExtensionContext) { }) } catch (error) { cloudLogger( - `[CloudService] Failed to fetch bridgeConfig: ${error instanceof Error ? error.message : String(error)}`, + `[CloudService] BridgeOrchestrator#connectOrDisconnect failed on user change: ${error instanceof Error ? error.message : String(error)}`, ) } } diff --git a/src/extension/api.ts b/src/extension/api.ts index 86f2f47aa67f..1820ddee6ca8 100644 --- a/src/extension/api.ts +++ b/src/extension/api.ts @@ -1,9 +1,10 @@ import { EventEmitter } from "events" -import * as vscode from "vscode" import fs from "fs/promises" import * as path from "path" import * as os from "os" +import * as vscode from "vscode" + import { type RooCodeAPI, type RooCodeSettings, diff --git a/src/shared/package.ts b/src/shared/package.ts index 25588164ecd6..a5a61da5d227 100644 --- a/src/shared/package.ts +++ b/src/shared/package.ts @@ -1,7 +1,3 @@ -/** - * Package - */ - import { publisher, name, version } from "../package.json" // These ENV variables can be defined by ESBuild when building the extension