Refactor the extension bridge (#7515)

This commit is contained in:
Chris Estreich 2025-08-29 00:28:38 -07:00 committed by Hannes Rudolph
parent af00c627b7
commit 2b7e259983
20 changed files with 506 additions and 2261 deletions

View file

@ -129,6 +129,7 @@ export class WebAuthService extends EventEmitter<AuthServiceEvents> 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<AuthServiceEvents> 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<AuthServiceEvents> 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<AuthServiceEvents> 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<AuthServiceEvents> 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"

View file

@ -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<TCommand = unknown, TEventName extends string = string, TEventData = unknown> {
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<TCommand = unknown, TEventName extends string
}
/**
* Handle incoming commands - template method that ensures common functionality
* is executed before subclass-specific logic.
*
* This method should be called by subclasses to handle commands.
* It will execute common functionality and then delegate to the abstract
* handleCommandImplementation method.
* Handle incoming commands - must be implemented by subclasses.
*/
public async handleCommand(command: TCommand): Promise<void> {
// 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<void>
public abstract handleCommand(command: TCommand): void
/**
* Handle connection-specific logic.

View file

@ -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<void> {
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<void> {
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<void> {
// 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<void> {

View file

@ -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<void> {
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<void> {
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<void> {
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<void> {
await this.connectionManager.reconnect()
// After a manual reconnect, we have a new socket instance
// so we need to set up listeners again.
this.setupSocketListeners()
}
}

View file

@ -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<RooCodeEventName, (...args: unknown[]) => 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<void> {
/**
* 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<void> {
const instance = await this.updateInstance()
const instance = this.updateInstance()
await this.publish(ExtensionSocketEvents.REGISTER, instance)
}
private async unregisterInstance(_socket: Socket): Promise<void> {
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<ExtensionInstance> {
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

View file

@ -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<void> {
this.socket = socket
await this.registerInstance(socket)
this.startHeartbeat(socket)
}
public onDisconnect(): void {
this.stopHeartbeat()
this.socket = null
}
public async onReconnect(socket: Socket): Promise<void> {
this.socket = socket
await this.registerInstance(socket)
this.startHeartbeat(socket)
}
public async cleanup(socket: Socket | null): Promise<void> {
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<void> {
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<void> {
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<boolean> {
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
}
}

View file

@ -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<string, unknown>
onConnect?: () => void | Promise<void>
onDisconnect?: (reason: string) => void
onReconnect?: (attemptNumber: number) => void | Promise<void>
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<RetryConfig>) {
this.options = options
if (retryConfig) {
this.retryConfig = { ...this.retryConfig, ...retryConfig }
}
}
public async connect(): Promise<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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()
}
}

View file

@ -7,7 +7,8 @@ export interface SocketTransportOptions {
socketOptions: Partial<ManagerOptions & SocketOptions>
onConnect?: () => void | Promise<void>
onDisconnect?: (reason: string) => void
onReconnect?: () => void | Promise<void>
onReconnect?: (attemptNumber: number) => void | Promise<void>
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<void> {
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<void> {
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<void> {
private async connectSocket(): Promise<void> {
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<void> {
console.log(`[SocketTransport#disconnect] Disconnecting...`)
private delay(ms: number): Promise<void> {
return new Promise((resolve) => {
this.retryTimeout = setTimeout(resolve, ms)
})
}
private clearRetryTimeouts() {
if (this.retryTimeout) {
clearTimeout(this.retryTimeout)
this.retryTimeout = null
}
}
public async disconnect(): Promise<void> {
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<void> {
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()
}

View file

@ -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<void>
@ -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<void> {
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<void> {
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)
}

View file

@ -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<void>
}[keyof TaskEvents]
const TASK_EVENT_MAPPING: Record<TaskBridgeEventName, keyof TaskEvents> = {
[TaskBridgeEventName.Message]: RooCodeEventName.Message,
[TaskBridgeEventName.TaskModeSwitched]: RooCodeEventName.TaskModeSwitched,
[TaskBridgeEventName.TaskInteractive]: RooCodeEventName.TaskInteractive,
}
export class TaskManager {
private subscribedTasks: Map<string, TaskLike> = new Map()
private pendingTasks: Map<string, TaskLike> = new Map()
private socket: Socket | null = null
private taskListeners: Map<string, Map<TaskBridgeEventName, TaskEventListener>> = new Map()
constructor() {}
public async onConnect(socket: Socket): Promise<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<TaskBridgeEventName, TaskEventListener>()
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<boolean> {
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
}
}
}

View file

@ -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<keyof TaskProviderEvents, Set<(...args: unknown[]) => 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) => {

View file

@ -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`)

View file

@ -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<typeof import("vscode") | undefined> {
// 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
}
}

View file

@ -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"

View file

@ -587,32 +587,49 @@ export type TaskBridgeCommand = z.infer<typeof taskBridgeCommandSchema>
* 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
}

View file

@ -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<TaskEvents> 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<TaskEvents> 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<TaskEvents> 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<TaskEvents> implements TaskLike {
taskNumber = -1,
onCreated,
initialTodos,
workspacePath,
}: TaskOptions) {
super()
@ -325,9 +302,6 @@ export class Task extends EventEmitter<TaskEvents> 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<TaskEvents> 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<TaskEvents> 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<TaskEvents> 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<TaskEvents> 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<TaskEvents> 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<TaskEvents> 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<TaskEvents> 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<TaskEvents> implements TaskLike {
this.handleWebviewAskResponse("noButtonClicked", text, images)
}
public async submitUserMessage(
text: string,
images?: string[],
mode?: string,
providerProfile?: string,
): Promise<void> {
public submitUserMessage(text: string, images?: string[]): void {
try {
text = (text ?? "").trim()
images = images ?? []
@ -943,16 +860,6 @@ export class Task extends EventEmitter<TaskEvents> 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<TaskEvents> implements TaskLike {
}
const { contextTokens: prevContextTokens } = this.getTokenUsage()
const {
messages,
summary,
@ -1175,16 +1081,19 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
return formatResponse.toolError(formatResponse.missingToolParameterError(paramName))
}
// Lifecycle
// Start / Resume / Abort / Dispose
// Start / Abort / Resume
private async startTask(task?: string, images?: string[]): Promise<void> {
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<TaskEvents> 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<TaskEvents> 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<TaskEvents> 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<TaskEvents> 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<TaskEvents> 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<void>((resolve) => {
this.pauseInterval = setInterval(() => {
if (!this.isPaused) {
@ -1637,35 +1539,6 @@ export class Task extends EventEmitter<TaskEvents> 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<void> {
@ -1752,7 +1625,7 @@ export class Task extends EventEmitter<TaskEvents> 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<TaskEvents> 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<TaskEvents> 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<TaskEvents> 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<TaskEvents> 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<TaskEvents> 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<TaskEvents> 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<TaskEvents> 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<TaskEvents> 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
}
}

View file

@ -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()
}
}

View file

@ -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 <K extends keyof GlobalState>(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<void> => {
// 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<void> => {
const currentCline = provider.getCurrentTask()
if (!currentCline) {
console.error("[handleDeleteMessageConfirm] No current cline available")
return
}
const handleDeleteMessageConfirm = async (messageTs: number): Promise<void> => {
// 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<number, any>()
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<void> => {
// 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<void> => {
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<number, any>()
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<ModelRecord> => {
@ -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
}
}
}

View file

@ -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)

View file

@ -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)
}