mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-08-28 05:27:24 +00:00
3415 lines
120 KiB
TypeScript
3415 lines
120 KiB
TypeScript
import os from "os"
|
|
import * as path from "path"
|
|
import fs from "fs/promises"
|
|
import EventEmitter from "events"
|
|
|
|
import { Anthropic } from "@anthropic-ai/sdk"
|
|
import delay from "delay"
|
|
import axios from "axios"
|
|
import pWaitFor from "p-wait-for"
|
|
import * as vscode from "vscode"
|
|
|
|
import {
|
|
type TaskProviderLike,
|
|
type TaskProviderEvents,
|
|
type GlobalState,
|
|
type ProviderName,
|
|
type ProviderSettings,
|
|
type RooCodeSettings,
|
|
type ProviderSettingsEntry,
|
|
type StaticAppProperties,
|
|
type DynamicAppProperties,
|
|
type CloudAppProperties,
|
|
type TaskProperties,
|
|
type GitProperties,
|
|
type TelemetryProperties,
|
|
type TelemetryPropertiesProvider,
|
|
type CodeActionId,
|
|
type CodeActionName,
|
|
type TerminalActionId,
|
|
type TerminalActionPromptType,
|
|
type HistoryItem,
|
|
type CloudUserInfo,
|
|
type CloudOrganizationMembership,
|
|
type CreateTaskOptions,
|
|
type TokenUsage,
|
|
type ToolUsage,
|
|
type ExtensionMessage,
|
|
type ExtensionState,
|
|
type MarketplaceInstalledMetadata,
|
|
RooCodeEventName,
|
|
requestyDefaultModelId,
|
|
openRouterDefaultModelId,
|
|
DEFAULT_TERMINAL_OUTPUT_CHARACTER_LIMIT,
|
|
DEFAULT_WRITE_DELAY_MS,
|
|
ORGANIZATION_ALLOW_ALL,
|
|
DEFAULT_MODES,
|
|
DEFAULT_CHECKPOINT_TIMEOUT_SECONDS,
|
|
getModelId,
|
|
} from "@roo-code/types"
|
|
import { TelemetryService } from "@roo-code/telemetry"
|
|
import { CloudService, BridgeOrchestrator, getRooCodeApiUrl } from "@roo-code/cloud"
|
|
|
|
import { Package } from "../../shared/package"
|
|
import { findLast } from "../../shared/array"
|
|
import { supportPrompt } from "../../shared/support-prompt"
|
|
import { GlobalFileNames } from "../../shared/globalFileNames"
|
|
import { Mode, defaultModeSlug, getModeBySlug } from "../../shared/modes"
|
|
import { experimentDefault } from "../../shared/experiments"
|
|
import { formatLanguage } from "../../shared/language"
|
|
import { WebviewMessage } from "../../shared/WebviewMessage"
|
|
import { EMBEDDING_MODEL_PROFILES } from "../../shared/embeddingModels"
|
|
import { ProfileValidator } from "../../shared/ProfileValidator"
|
|
|
|
import { Terminal } from "../../integrations/terminal/Terminal"
|
|
import { downloadTask } from "../../integrations/misc/export-markdown"
|
|
import { getTheme } from "../../integrations/theme/getTheme"
|
|
import WorkspaceTracker from "../../integrations/workspace/WorkspaceTracker"
|
|
|
|
import { McpHub } from "../../services/mcp/McpHub"
|
|
import { McpServerManager } from "../../services/mcp/McpServerManager"
|
|
import { MarketplaceManager } from "../../services/marketplace"
|
|
import { ShadowCheckpointService } from "../../services/checkpoints/ShadowCheckpointService"
|
|
import { CodeIndexManager } from "../../services/code-index/manager"
|
|
import type { IndexProgressUpdate } from "../../services/code-index/interfaces/manager"
|
|
import { MdmService } from "../../services/mdm/MdmService"
|
|
import { SkillsManager } from "../../services/skills/SkillsManager"
|
|
|
|
import { fileExistsAtPath } from "../../utils/fs"
|
|
import { setTtsEnabled, setTtsSpeed } from "../../utils/tts"
|
|
import { getWorkspaceGitInfo } from "../../utils/git"
|
|
import { getWorkspacePath } from "../../utils/path"
|
|
import { OrganizationAllowListViolationError } from "../../utils/errors"
|
|
|
|
import { setPanel } from "../../activate/registerCommands"
|
|
|
|
import { t } from "../../i18n"
|
|
|
|
import { buildApiHandler } from "../../api"
|
|
import { forceFullModelDetailsLoad, hasLoadedFullDetails } from "../../api/providers/fetchers/lmstudio"
|
|
|
|
import { ContextProxy } from "../config/ContextProxy"
|
|
import { ProviderSettingsManager } from "../config/ProviderSettingsManager"
|
|
import { CustomModesManager } from "../config/CustomModesManager"
|
|
import { Task } from "../task/Task"
|
|
import { getSystemPromptFilePath } from "../prompts/sections/custom-system-prompt"
|
|
|
|
import { webviewMessageHandler } from "./webviewMessageHandler"
|
|
import type { ClineMessage, TodoItem } from "@roo-code/types"
|
|
import { readApiMessages, saveApiMessages, saveTaskMessages } from "../task-persistence"
|
|
import { readTaskMessages } from "../task-persistence/taskMessages"
|
|
import { getNonce } from "./getNonce"
|
|
import { getUri } from "./getUri"
|
|
import { REQUESTY_BASE_URL } from "../../shared/utils/requesty"
|
|
import { validateAndFixToolResultIds } from "../task/validateToolResultIds"
|
|
|
|
/**
|
|
* https://github.com/microsoft/vscode-webview-ui-toolkit-samples/blob/main/default/weather-webview/src/providers/WeatherViewProvider.ts
|
|
* https://github.com/KumarVariable/vscode-extension-sidebar-html/blob/master/src/customSidebarViewProvider.ts
|
|
*/
|
|
|
|
export type ClineProviderEvents = {
|
|
clineCreated: [cline: Task]
|
|
}
|
|
|
|
interface PendingEditOperation {
|
|
messageTs: number
|
|
editedContent: string
|
|
images?: string[]
|
|
messageIndex: number
|
|
apiConversationHistoryIndex: number
|
|
timeoutId: NodeJS.Timeout
|
|
createdAt: number
|
|
}
|
|
|
|
export class ClineProvider
|
|
extends EventEmitter<TaskProviderEvents>
|
|
implements vscode.WebviewViewProvider, TelemetryPropertiesProvider, TaskProviderLike
|
|
{
|
|
// Used in package.json as the view's id. This value cannot be changed due
|
|
// to how VSCode caches views based on their id, and updating the id would
|
|
// break existing instances of the extension.
|
|
public static readonly sideBarId = `${Package.name}.SidebarProvider`
|
|
public static readonly tabPanelId = `${Package.name}.TabPanelProvider`
|
|
private static activeInstances: Set<ClineProvider> = new Set()
|
|
private disposables: vscode.Disposable[] = []
|
|
private webviewDisposables: vscode.Disposable[] = []
|
|
private view?: vscode.WebviewView | vscode.WebviewPanel
|
|
private clineStack: Task[] = []
|
|
private codeIndexStatusSubscription?: vscode.Disposable
|
|
private codeIndexManager?: CodeIndexManager
|
|
private _workspaceTracker?: WorkspaceTracker // workSpaceTracker read-only for access outside this class
|
|
protected mcpHub?: McpHub // Change from private to protected
|
|
protected skillsManager?: SkillsManager
|
|
private marketplaceManager: MarketplaceManager
|
|
private mdmService?: MdmService
|
|
private taskCreationCallback: (task: Task) => void
|
|
private taskEventListeners: WeakMap<Task, Array<() => void>> = new WeakMap()
|
|
private currentWorkspacePath: string | undefined
|
|
|
|
private recentTasksCache?: string[]
|
|
private pendingOperations: Map<string, PendingEditOperation> = new Map()
|
|
private static readonly PENDING_OPERATION_TIMEOUT_MS = 30000 // 30 seconds
|
|
|
|
private cloudOrganizationsCache: CloudOrganizationMembership[] | null = null
|
|
private cloudOrganizationsCacheTimestamp: number | null = null
|
|
private static readonly CLOUD_ORGANIZATIONS_CACHE_DURATION_MS = 5 * 1000 // 5 seconds
|
|
|
|
public isViewLaunched = false
|
|
public settingsImportedAt?: number
|
|
public readonly latestAnnouncementId = "jan-2026-v3.40.0-settings-search-stop-button-api-fixes" // v3.40.0 Settings Search, Stop Button UX, API Fixes
|
|
public readonly providerSettingsManager: ProviderSettingsManager
|
|
public readonly customModesManager: CustomModesManager
|
|
|
|
constructor(
|
|
readonly context: vscode.ExtensionContext,
|
|
private readonly outputChannel: vscode.OutputChannel,
|
|
private readonly renderContext: "sidebar" | "editor" = "sidebar",
|
|
public readonly contextProxy: ContextProxy,
|
|
mdmService?: MdmService,
|
|
) {
|
|
super()
|
|
this.currentWorkspacePath = getWorkspacePath()
|
|
|
|
ClineProvider.activeInstances.add(this)
|
|
|
|
this.mdmService = mdmService
|
|
this.updateGlobalState("codebaseIndexModels", EMBEDDING_MODEL_PROFILES)
|
|
|
|
// Start configuration loading (which might trigger indexing) in the background.
|
|
// Don't await, allowing activation to continue immediately.
|
|
|
|
// Register this provider with the telemetry service to enable it to add
|
|
// properties like mode and provider.
|
|
TelemetryService.instance.setProvider(this)
|
|
|
|
this._workspaceTracker = new WorkspaceTracker(this)
|
|
|
|
this.providerSettingsManager = new ProviderSettingsManager(this.context)
|
|
|
|
this.customModesManager = new CustomModesManager(this.context, async () => {
|
|
await this.postStateToWebview()
|
|
})
|
|
|
|
// Initialize MCP Hub through the singleton manager
|
|
McpServerManager.getInstance(this.context, this)
|
|
.then((hub) => {
|
|
this.mcpHub = hub
|
|
this.mcpHub.registerClient()
|
|
})
|
|
.catch((error) => {
|
|
this.log(`Failed to initialize MCP Hub: ${error}`)
|
|
})
|
|
|
|
// Initialize Skills Manager for skill discovery
|
|
this.skillsManager = new SkillsManager(this)
|
|
this.skillsManager.initialize().catch((error) => {
|
|
this.log(`Failed to initialize Skills Manager: ${error}`)
|
|
})
|
|
|
|
this.marketplaceManager = new MarketplaceManager(this.context, this.customModesManager)
|
|
|
|
// Forward <most> task events to the provider.
|
|
// We do something fairly similar for the IPC-based API.
|
|
this.taskCreationCallback = (instance: Task) => {
|
|
this.emit(RooCodeEventName.TaskCreated, instance)
|
|
|
|
// Create named listener functions so we can remove them later.
|
|
const onTaskStarted = () => this.emit(RooCodeEventName.TaskStarted, instance.taskId)
|
|
const onTaskCompleted = (taskId: string, tokenUsage: TokenUsage, toolUsage: ToolUsage) =>
|
|
this.emit(RooCodeEventName.TaskCompleted, taskId, tokenUsage, toolUsage)
|
|
const onTaskAborted = async () => {
|
|
this.emit(RooCodeEventName.TaskAborted, instance.taskId)
|
|
|
|
try {
|
|
// Only rehydrate on genuine streaming failures.
|
|
// User-initiated cancels are handled by cancelTask().
|
|
if (instance.abortReason === "streaming_failed") {
|
|
// Defensive safeguard: if another path already replaced this instance, skip
|
|
const current = this.getCurrentTask()
|
|
if (current && current.instanceId !== instance.instanceId) {
|
|
this.log(
|
|
`[onTaskAborted] Skipping rehydrate: current instance ${current.instanceId} != aborted ${instance.instanceId}`,
|
|
)
|
|
return
|
|
}
|
|
|
|
const { historyItem } = await this.getTaskWithId(instance.taskId)
|
|
const rootTask = instance.rootTask
|
|
const parentTask = instance.parentTask
|
|
await this.createTaskWithHistoryItem({ ...historyItem, rootTask, parentTask })
|
|
}
|
|
} catch (error) {
|
|
this.log(
|
|
`[onTaskAborted] Failed to rehydrate after streaming failure: ${
|
|
error instanceof Error ? error.message : String(error)
|
|
}`,
|
|
)
|
|
}
|
|
}
|
|
const onTaskFocused = () => this.emit(RooCodeEventName.TaskFocused, instance.taskId)
|
|
const onTaskUnfocused = () => this.emit(RooCodeEventName.TaskUnfocused, instance.taskId)
|
|
const onTaskActive = (taskId: string) => this.emit(RooCodeEventName.TaskActive, taskId)
|
|
const onTaskInteractive = (taskId: string) => this.emit(RooCodeEventName.TaskInteractive, taskId)
|
|
const onTaskResumable = (taskId: string) => this.emit(RooCodeEventName.TaskResumable, taskId)
|
|
const onTaskIdle = (taskId: string) => this.emit(RooCodeEventName.TaskIdle, taskId)
|
|
const onTaskPaused = (taskId: string) => this.emit(RooCodeEventName.TaskPaused, taskId)
|
|
const onTaskUnpaused = (taskId: string) => this.emit(RooCodeEventName.TaskUnpaused, taskId)
|
|
const onTaskSpawned = (taskId: string) => this.emit(RooCodeEventName.TaskSpawned, taskId)
|
|
const onTaskUserMessage = (taskId: string) => this.emit(RooCodeEventName.TaskUserMessage, taskId)
|
|
const onTaskTokenUsageUpdated = (taskId: string, tokenUsage: TokenUsage, toolUsage: ToolUsage) =>
|
|
this.emit(RooCodeEventName.TaskTokenUsageUpdated, taskId, tokenUsage, toolUsage)
|
|
|
|
// Attach the listeners.
|
|
instance.on(RooCodeEventName.TaskStarted, onTaskStarted)
|
|
instance.on(RooCodeEventName.TaskCompleted, onTaskCompleted)
|
|
instance.on(RooCodeEventName.TaskAborted, onTaskAborted)
|
|
instance.on(RooCodeEventName.TaskFocused, onTaskFocused)
|
|
instance.on(RooCodeEventName.TaskUnfocused, onTaskUnfocused)
|
|
instance.on(RooCodeEventName.TaskActive, onTaskActive)
|
|
instance.on(RooCodeEventName.TaskInteractive, onTaskInteractive)
|
|
instance.on(RooCodeEventName.TaskResumable, onTaskResumable)
|
|
instance.on(RooCodeEventName.TaskIdle, onTaskIdle)
|
|
instance.on(RooCodeEventName.TaskPaused, onTaskPaused)
|
|
instance.on(RooCodeEventName.TaskUnpaused, onTaskUnpaused)
|
|
instance.on(RooCodeEventName.TaskSpawned, onTaskSpawned)
|
|
instance.on(RooCodeEventName.TaskUserMessage, onTaskUserMessage)
|
|
instance.on(RooCodeEventName.TaskTokenUsageUpdated, onTaskTokenUsageUpdated)
|
|
|
|
// Store the cleanup functions for later removal.
|
|
this.taskEventListeners.set(instance, [
|
|
() => instance.off(RooCodeEventName.TaskStarted, onTaskStarted),
|
|
() => instance.off(RooCodeEventName.TaskCompleted, onTaskCompleted),
|
|
() => instance.off(RooCodeEventName.TaskAborted, onTaskAborted),
|
|
() => instance.off(RooCodeEventName.TaskFocused, onTaskFocused),
|
|
() => instance.off(RooCodeEventName.TaskUnfocused, onTaskUnfocused),
|
|
() => instance.off(RooCodeEventName.TaskActive, onTaskActive),
|
|
() => instance.off(RooCodeEventName.TaskInteractive, onTaskInteractive),
|
|
() => instance.off(RooCodeEventName.TaskResumable, onTaskResumable),
|
|
() => instance.off(RooCodeEventName.TaskIdle, onTaskIdle),
|
|
() => instance.off(RooCodeEventName.TaskUserMessage, onTaskUserMessage),
|
|
() => instance.off(RooCodeEventName.TaskPaused, onTaskPaused),
|
|
() => instance.off(RooCodeEventName.TaskUnpaused, onTaskUnpaused),
|
|
() => instance.off(RooCodeEventName.TaskSpawned, onTaskSpawned),
|
|
() => instance.off(RooCodeEventName.TaskTokenUsageUpdated, onTaskTokenUsageUpdated),
|
|
])
|
|
}
|
|
|
|
// Initialize Roo Code Cloud profile sync.
|
|
if (CloudService.hasInstance()) {
|
|
this.initializeCloudProfileSync().catch((error) => {
|
|
this.log(`Failed to initialize cloud profile sync: ${error}`)
|
|
})
|
|
} else {
|
|
this.log("CloudService not ready, deferring cloud profile sync")
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Override EventEmitter's on method to match TaskProviderLike interface
|
|
*/
|
|
override on<K extends keyof TaskProviderEvents>(
|
|
event: K,
|
|
listener: (...args: TaskProviderEvents[K]) => void | Promise<void>,
|
|
): this {
|
|
return super.on(event, listener as any)
|
|
}
|
|
|
|
/**
|
|
* Override EventEmitter's off method to match TaskProviderLike interface
|
|
*/
|
|
override off<K extends keyof TaskProviderEvents>(
|
|
event: K,
|
|
listener: (...args: TaskProviderEvents[K]) => void | Promise<void>,
|
|
): this {
|
|
return super.off(event, listener as any)
|
|
}
|
|
|
|
/**
|
|
* Initialize cloud profile synchronization
|
|
*/
|
|
private async initializeCloudProfileSync() {
|
|
try {
|
|
// Check if authenticated and sync profiles
|
|
if (CloudService.hasInstance() && CloudService.instance.isAuthenticated()) {
|
|
await this.syncCloudProfiles()
|
|
}
|
|
|
|
// Set up listener for future updates
|
|
if (CloudService.hasInstance()) {
|
|
CloudService.instance.on("settings-updated", this.handleCloudSettingsUpdate)
|
|
}
|
|
} catch (error) {
|
|
this.log(`Error in initializeCloudProfileSync: ${error}`)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle cloud settings updates
|
|
*/
|
|
private handleCloudSettingsUpdate = async () => {
|
|
try {
|
|
await this.syncCloudProfiles()
|
|
} catch (error) {
|
|
this.log(`Error handling cloud settings update: ${error}`)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Synchronize cloud profiles with local profiles.
|
|
*/
|
|
private async syncCloudProfiles() {
|
|
try {
|
|
const settings = CloudService.instance.getOrganizationSettings()
|
|
|
|
if (!settings?.providerProfiles) {
|
|
return
|
|
}
|
|
|
|
const currentApiConfigName = this.getGlobalState("currentApiConfigName")
|
|
|
|
const result = await this.providerSettingsManager.syncCloudProfiles(
|
|
settings.providerProfiles,
|
|
currentApiConfigName,
|
|
)
|
|
|
|
if (result.hasChanges) {
|
|
// Update list.
|
|
await this.updateGlobalState("listApiConfigMeta", await this.providerSettingsManager.listConfig())
|
|
|
|
if (result.activeProfileChanged && result.activeProfileId) {
|
|
// Reload full settings for new active profile.
|
|
const profile = await this.providerSettingsManager.getProfile({
|
|
id: result.activeProfileId,
|
|
})
|
|
await this.activateProviderProfile({ name: profile.name })
|
|
}
|
|
|
|
await this.postStateToWebview()
|
|
}
|
|
} catch (error) {
|
|
this.log(`Error syncing cloud profiles: ${error}`)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Initialize cloud profile synchronization when CloudService is ready
|
|
* This method is called externally after CloudService has been initialized
|
|
*/
|
|
public async initializeCloudProfileSyncWhenReady(): Promise<void> {
|
|
try {
|
|
if (CloudService.hasInstance() && CloudService.instance.isAuthenticated()) {
|
|
await this.syncCloudProfiles()
|
|
}
|
|
|
|
if (CloudService.hasInstance()) {
|
|
CloudService.instance.off("settings-updated", this.handleCloudSettingsUpdate)
|
|
CloudService.instance.on("settings-updated", this.handleCloudSettingsUpdate)
|
|
}
|
|
} catch (error) {
|
|
this.log(`Failed to initialize cloud profile sync when ready: ${error}`)
|
|
}
|
|
}
|
|
|
|
// 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.
|
|
async addClineToStack(task: Task) {
|
|
// Add this cline instance into the stack that represents the order of
|
|
// all the called tasks.
|
|
this.clineStack.push(task)
|
|
task.emit(RooCodeEventName.TaskFocused)
|
|
|
|
// Perform special setup provider specific tasks.
|
|
await this.performPreparationTasks(task)
|
|
|
|
// Ensure getState() resolves correctly.
|
|
const state = await this.getState()
|
|
|
|
if (!state || typeof state.mode !== "string") {
|
|
throw new Error(t("common:errors.retrieve_current_mode"))
|
|
}
|
|
}
|
|
|
|
async performPreparationTasks(cline: Task) {
|
|
// LMStudio: We need to force model loading in order to read its context
|
|
// size; we do it now since we're starting a task with that model selected.
|
|
if (cline.apiConfiguration && cline.apiConfiguration.apiProvider === "lmstudio") {
|
|
try {
|
|
if (!hasLoadedFullDetails(cline.apiConfiguration.lmStudioModelId!)) {
|
|
await forceFullModelDetailsLoad(
|
|
cline.apiConfiguration.lmStudioBaseUrl ?? "http://localhost:1234",
|
|
cline.apiConfiguration.lmStudioModelId!,
|
|
)
|
|
}
|
|
} catch (error) {
|
|
this.log(`Failed to load full model details for LM Studio: ${error}`)
|
|
vscode.window.showErrorMessage(error.message)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Removes and destroys the top Cline instance (the current finished task),
|
|
// activating the previous one (resuming the parent task).
|
|
async removeClineFromStack() {
|
|
if (this.clineStack.length === 0) {
|
|
return
|
|
}
|
|
|
|
// Pop the top Cline instance from the stack.
|
|
let task = this.clineStack.pop()
|
|
|
|
if (task) {
|
|
task.emit(RooCodeEventName.TaskUnfocused)
|
|
|
|
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(
|
|
`[ClineProvider#removeClineFromStack] abortTask() failed ${task.taskId}.${task.instanceId}: ${e.message}`,
|
|
)
|
|
}
|
|
|
|
// Remove event listeners before clearing the reference.
|
|
const cleanupFunctions = this.taskEventListeners.get(task)
|
|
|
|
if (cleanupFunctions) {
|
|
cleanupFunctions.forEach((cleanup) => cleanup())
|
|
this.taskEventListeners.delete(task)
|
|
}
|
|
|
|
// Make sure no reference kept, once promises end it will be
|
|
// garbage collected.
|
|
task = undefined
|
|
}
|
|
}
|
|
|
|
getTaskStackSize(): number {
|
|
return this.clineStack.length
|
|
}
|
|
|
|
public getCurrentTaskStack(): string[] {
|
|
return this.clineStack.map((cline) => cline.taskId)
|
|
}
|
|
|
|
// Pending Edit Operations Management
|
|
|
|
/**
|
|
* Sets a pending edit operation with automatic timeout cleanup
|
|
*/
|
|
public setPendingEditOperation(
|
|
operationId: string,
|
|
editData: {
|
|
messageTs: number
|
|
editedContent: string
|
|
images?: string[]
|
|
messageIndex: number
|
|
apiConversationHistoryIndex: number
|
|
},
|
|
): void {
|
|
// Clear any existing operation with the same ID
|
|
this.clearPendingEditOperation(operationId)
|
|
|
|
// Create timeout for automatic cleanup
|
|
const timeoutId = setTimeout(() => {
|
|
this.clearPendingEditOperation(operationId)
|
|
this.log(`[setPendingEditOperation] Automatically cleared stale pending operation: ${operationId}`)
|
|
}, ClineProvider.PENDING_OPERATION_TIMEOUT_MS)
|
|
|
|
// Store the operation
|
|
this.pendingOperations.set(operationId, {
|
|
...editData,
|
|
timeoutId,
|
|
createdAt: Date.now(),
|
|
})
|
|
|
|
this.log(`[setPendingEditOperation] Set pending operation: ${operationId}`)
|
|
}
|
|
|
|
/**
|
|
* Gets a pending edit operation by ID
|
|
*/
|
|
private getPendingEditOperation(operationId: string): PendingEditOperation | undefined {
|
|
return this.pendingOperations.get(operationId)
|
|
}
|
|
|
|
/**
|
|
* Clears a specific pending edit operation
|
|
*/
|
|
private clearPendingEditOperation(operationId: string): boolean {
|
|
const operation = this.pendingOperations.get(operationId)
|
|
if (operation) {
|
|
clearTimeout(operation.timeoutId)
|
|
this.pendingOperations.delete(operationId)
|
|
this.log(`[clearPendingEditOperation] Cleared pending operation: ${operationId}`)
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
/**
|
|
* Clears all pending edit operations
|
|
*/
|
|
private clearAllPendingEditOperations(): void {
|
|
for (const [operationId, operation] of this.pendingOperations) {
|
|
clearTimeout(operation.timeoutId)
|
|
}
|
|
this.pendingOperations.clear()
|
|
this.log(`[clearAllPendingEditOperations] Cleared all pending operations`)
|
|
}
|
|
|
|
/*
|
|
VSCode extensions use the disposable pattern to clean up resources when the sidebar/editor tab is closed by the user or system. This applies to event listening, commands, interacting with the UI, etc.
|
|
- https://vscode-docs.readthedocs.io/en/stable/extensions/patterns-and-principles/
|
|
- https://github.com/microsoft/vscode-extension-samples/blob/main/webview-sample/src/extension.ts
|
|
*/
|
|
private clearWebviewResources() {
|
|
while (this.webviewDisposables.length) {
|
|
const x = this.webviewDisposables.pop()
|
|
if (x) {
|
|
x.dispose()
|
|
}
|
|
}
|
|
}
|
|
|
|
async dispose() {
|
|
this.log("Disposing ClineProvider...")
|
|
|
|
// Clear all tasks from the stack.
|
|
while (this.clineStack.length > 0) {
|
|
await this.removeClineFromStack()
|
|
}
|
|
|
|
this.log("Cleared all tasks")
|
|
|
|
// Clear all pending edit operations to prevent memory leaks
|
|
this.clearAllPendingEditOperations()
|
|
this.log("Cleared pending operations")
|
|
|
|
if (this.view && "dispose" in this.view) {
|
|
this.view.dispose()
|
|
this.log("Disposed webview")
|
|
}
|
|
|
|
this.clearWebviewResources()
|
|
|
|
// Clean up cloud service event listener
|
|
if (CloudService.hasInstance()) {
|
|
CloudService.instance.off("settings-updated", this.handleCloudSettingsUpdate)
|
|
}
|
|
|
|
while (this.disposables.length) {
|
|
const x = this.disposables.pop()
|
|
|
|
if (x) {
|
|
x.dispose()
|
|
}
|
|
}
|
|
|
|
this._workspaceTracker?.dispose()
|
|
this._workspaceTracker = undefined
|
|
await this.mcpHub?.unregisterClient()
|
|
this.mcpHub = undefined
|
|
await this.skillsManager?.dispose()
|
|
this.skillsManager = undefined
|
|
this.marketplaceManager?.cleanup()
|
|
this.customModesManager?.dispose()
|
|
this.log("Disposed all disposables")
|
|
ClineProvider.activeInstances.delete(this)
|
|
|
|
// Clean up any event listeners attached to this provider
|
|
this.removeAllListeners()
|
|
|
|
McpServerManager.unregisterProvider(this)
|
|
}
|
|
|
|
public static getVisibleInstance(): ClineProvider | undefined {
|
|
return findLast(Array.from(this.activeInstances), (instance) => instance.view?.visible === true)
|
|
}
|
|
|
|
public static async getInstance(): Promise<ClineProvider | undefined> {
|
|
let visibleProvider = ClineProvider.getVisibleInstance()
|
|
|
|
// If no visible provider, try to show the sidebar view
|
|
if (!visibleProvider) {
|
|
await vscode.commands.executeCommand(`${Package.name}.SidebarProvider.focus`)
|
|
// Wait briefly for the view to become visible
|
|
await delay(100)
|
|
visibleProvider = ClineProvider.getVisibleInstance()
|
|
}
|
|
|
|
// If still no visible provider, return
|
|
if (!visibleProvider) {
|
|
return
|
|
}
|
|
|
|
return visibleProvider
|
|
}
|
|
|
|
public static async isActiveTask(): Promise<boolean> {
|
|
const visibleProvider = await ClineProvider.getInstance()
|
|
|
|
if (!visibleProvider) {
|
|
return false
|
|
}
|
|
|
|
// Check if there is a cline instance in the stack (if this provider has an active task)
|
|
if (visibleProvider.getCurrentTask()) {
|
|
return true
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
public static async handleCodeAction(
|
|
command: CodeActionId,
|
|
promptType: CodeActionName,
|
|
params: Record<string, string | any[]>,
|
|
): Promise<void> {
|
|
// Capture telemetry for code action usage
|
|
TelemetryService.instance.captureCodeActionUsed(promptType)
|
|
|
|
const visibleProvider = await ClineProvider.getInstance()
|
|
|
|
if (!visibleProvider) {
|
|
return
|
|
}
|
|
|
|
const { customSupportPrompts } = await visibleProvider.getState()
|
|
|
|
// TODO: Improve type safety for promptType.
|
|
const prompt = supportPrompt.create(promptType, params, customSupportPrompts)
|
|
|
|
if (command === "addToContext") {
|
|
await visibleProvider.postMessageToWebview({
|
|
type: "invoke",
|
|
invoke: "setChatBoxMessage",
|
|
text: `${prompt}\n\n`,
|
|
})
|
|
await visibleProvider.postMessageToWebview({ type: "action", action: "focusInput" })
|
|
return
|
|
}
|
|
|
|
await visibleProvider.createTask(prompt)
|
|
}
|
|
|
|
public static async handleTerminalAction(
|
|
command: TerminalActionId,
|
|
promptType: TerminalActionPromptType,
|
|
params: Record<string, string | any[]>,
|
|
): Promise<void> {
|
|
TelemetryService.instance.captureCodeActionUsed(promptType)
|
|
|
|
const visibleProvider = await ClineProvider.getInstance()
|
|
|
|
if (!visibleProvider) {
|
|
return
|
|
}
|
|
|
|
const { customSupportPrompts } = await visibleProvider.getState()
|
|
const prompt = supportPrompt.create(promptType, params, customSupportPrompts)
|
|
|
|
if (command === "terminalAddToContext") {
|
|
await visibleProvider.postMessageToWebview({
|
|
type: "invoke",
|
|
invoke: "setChatBoxMessage",
|
|
text: `${prompt}\n\n`,
|
|
})
|
|
await visibleProvider.postMessageToWebview({ type: "action", action: "focusInput" })
|
|
return
|
|
}
|
|
|
|
try {
|
|
await visibleProvider.createTask(prompt)
|
|
} catch (error) {
|
|
if (error instanceof OrganizationAllowListViolationError) {
|
|
// Errors from terminal commands seem to get swallowed / ignored.
|
|
vscode.window.showErrorMessage(error.message)
|
|
}
|
|
|
|
throw error
|
|
}
|
|
}
|
|
|
|
async resolveWebviewView(webviewView: vscode.WebviewView | vscode.WebviewPanel) {
|
|
this.view = webviewView
|
|
const inTabMode = "onDidChangeViewState" in webviewView
|
|
|
|
if (inTabMode) {
|
|
setPanel(webviewView, "tab")
|
|
} else if ("onDidChangeVisibility" in webviewView) {
|
|
setPanel(webviewView, "sidebar")
|
|
}
|
|
|
|
// Initialize out-of-scope variables that need to receive persistent
|
|
// global state values.
|
|
this.getState().then(
|
|
({
|
|
terminalShellIntegrationTimeout = Terminal.defaultShellIntegrationTimeout,
|
|
terminalShellIntegrationDisabled = false,
|
|
terminalCommandDelay = 0,
|
|
terminalZshClearEolMark = true,
|
|
terminalZshOhMy = false,
|
|
terminalZshP10k = false,
|
|
terminalPowershellCounter = false,
|
|
terminalZdotdir = false,
|
|
}) => {
|
|
Terminal.setShellIntegrationTimeout(terminalShellIntegrationTimeout)
|
|
Terminal.setShellIntegrationDisabled(terminalShellIntegrationDisabled)
|
|
Terminal.setCommandDelay(terminalCommandDelay)
|
|
Terminal.setTerminalZshClearEolMark(terminalZshClearEolMark)
|
|
Terminal.setTerminalZshOhMy(terminalZshOhMy)
|
|
Terminal.setTerminalZshP10k(terminalZshP10k)
|
|
Terminal.setPowershellCounter(terminalPowershellCounter)
|
|
Terminal.setTerminalZdotdir(terminalZdotdir)
|
|
},
|
|
)
|
|
|
|
this.getState().then(({ ttsEnabled }) => {
|
|
setTtsEnabled(ttsEnabled ?? false)
|
|
})
|
|
|
|
this.getState().then(({ ttsSpeed }) => {
|
|
setTtsSpeed(ttsSpeed ?? 1)
|
|
})
|
|
|
|
// Set up webview options with proper resource roots
|
|
const resourceRoots = [this.contextProxy.extensionUri]
|
|
|
|
// Add workspace folders to allow access to workspace files
|
|
if (vscode.workspace.workspaceFolders) {
|
|
resourceRoots.push(...vscode.workspace.workspaceFolders.map((folder) => folder.uri))
|
|
}
|
|
|
|
webviewView.webview.options = {
|
|
enableScripts: true,
|
|
localResourceRoots: resourceRoots,
|
|
}
|
|
|
|
webviewView.webview.html =
|
|
this.contextProxy.extensionMode === vscode.ExtensionMode.Development
|
|
? await this.getHMRHtmlContent(webviewView.webview)
|
|
: await this.getHtmlContent(webviewView.webview)
|
|
|
|
// Sets up an event listener to listen for messages passed from the webview view context
|
|
// and executes code based on the message that is received.
|
|
this.setWebviewMessageListener(webviewView.webview)
|
|
|
|
// Initialize code index status subscription for the current workspace.
|
|
this.updateCodeIndexStatusSubscription()
|
|
|
|
// Listen for active editor changes to update code index status for the
|
|
// current workspace.
|
|
const activeEditorSubscription = vscode.window.onDidChangeActiveTextEditor(() => {
|
|
// Update subscription when workspace might have changed.
|
|
this.updateCodeIndexStatusSubscription()
|
|
})
|
|
this.webviewDisposables.push(activeEditorSubscription)
|
|
|
|
// Listen for when the panel becomes visible.
|
|
// https://github.com/microsoft/vscode-discussions/discussions/840
|
|
if ("onDidChangeViewState" in webviewView) {
|
|
// WebviewView and WebviewPanel have all the same properties except
|
|
// for this visibility listener panel.
|
|
const viewStateDisposable = webviewView.onDidChangeViewState(() => {
|
|
if (this.view?.visible) {
|
|
this.postMessageToWebview({ type: "action", action: "didBecomeVisible" })
|
|
}
|
|
})
|
|
|
|
this.webviewDisposables.push(viewStateDisposable)
|
|
} else if ("onDidChangeVisibility" in webviewView) {
|
|
// sidebar
|
|
const visibilityDisposable = webviewView.onDidChangeVisibility(() => {
|
|
if (this.view?.visible) {
|
|
this.postMessageToWebview({ type: "action", action: "didBecomeVisible" })
|
|
}
|
|
})
|
|
|
|
this.webviewDisposables.push(visibilityDisposable)
|
|
}
|
|
|
|
// Listen for when the view is disposed
|
|
// This happens when the user closes the view or when the view is closed programmatically
|
|
webviewView.onDidDispose(
|
|
async () => {
|
|
if (inTabMode) {
|
|
this.log("Disposing ClineProvider instance for tab view")
|
|
await this.dispose()
|
|
} else {
|
|
this.log("Clearing webview resources for sidebar view")
|
|
this.clearWebviewResources()
|
|
// Reset current workspace manager reference when view is disposed
|
|
this.codeIndexManager = undefined
|
|
}
|
|
},
|
|
null,
|
|
this.disposables,
|
|
)
|
|
|
|
// Listen for when color changes
|
|
const configDisposable = vscode.workspace.onDidChangeConfiguration(async (e) => {
|
|
if (e && e.affectsConfiguration("workbench.colorTheme")) {
|
|
// Sends latest theme name to webview
|
|
await this.postMessageToWebview({ type: "theme", text: JSON.stringify(await getTheme()) })
|
|
}
|
|
})
|
|
this.webviewDisposables.push(configDisposable)
|
|
|
|
// If the extension is starting a new session, clear previous task state.
|
|
await this.removeClineFromStack()
|
|
}
|
|
|
|
public async createTaskWithHistoryItem(
|
|
historyItem: HistoryItem & { rootTask?: Task; parentTask?: Task },
|
|
options?: { startTask?: boolean },
|
|
) {
|
|
// Check if we're rehydrating the current task to avoid flicker
|
|
const currentTask = this.getCurrentTask()
|
|
const isRehydratingCurrentTask = currentTask && currentTask.taskId === historyItem.id
|
|
|
|
if (!isRehydratingCurrentTask) {
|
|
await this.removeClineFromStack()
|
|
}
|
|
|
|
// If the history item has a saved mode, restore it and its associated API configuration.
|
|
if (historyItem.mode) {
|
|
// Validate that the mode still exists
|
|
const customModes = await this.customModesManager.getCustomModes()
|
|
const modeExists = getModeBySlug(historyItem.mode, customModes) !== undefined
|
|
|
|
if (!modeExists) {
|
|
// Mode no longer exists, fall back to default mode.
|
|
this.log(
|
|
`Mode '${historyItem.mode}' from history no longer exists. Falling back to default mode '${defaultModeSlug}'.`,
|
|
)
|
|
historyItem.mode = defaultModeSlug
|
|
}
|
|
|
|
await this.updateGlobalState("mode", historyItem.mode)
|
|
|
|
// Load the saved API config for the restored mode if it exists.
|
|
// Skip mode-based profile activation if historyItem.apiConfigName exists,
|
|
// since the task's specific provider profile will override it anyway.
|
|
if (!historyItem.apiConfigName) {
|
|
const savedConfigId = await this.providerSettingsManager.getModeConfigId(historyItem.mode)
|
|
const listApiConfig = await this.providerSettingsManager.listConfig()
|
|
|
|
// Update listApiConfigMeta first to ensure UI has latest data.
|
|
await this.updateGlobalState("listApiConfigMeta", listApiConfig)
|
|
|
|
// If this mode has a saved config, use it.
|
|
if (savedConfigId) {
|
|
const profile = listApiConfig.find(({ id }) => id === savedConfigId)
|
|
|
|
if (profile?.name) {
|
|
try {
|
|
// Check if the profile has actual API configuration (not just an id).
|
|
// In CLI mode, the ProviderSettingsManager may return empty default profiles
|
|
// that only contain 'id' and 'name' fields. Activating such a profile would
|
|
// overwrite the CLI's working API configuration with empty settings.
|
|
const fullProfile = await this.providerSettingsManager.getProfile({ name: profile.name })
|
|
const hasActualSettings = !!fullProfile.apiProvider
|
|
|
|
if (hasActualSettings) {
|
|
await this.activateProviderProfile({ name: profile.name })
|
|
} else {
|
|
// The task will continue with the current/default configuration.
|
|
}
|
|
} catch (error) {
|
|
// Log the error but continue with task restoration.
|
|
this.log(
|
|
`Failed to restore API configuration for mode '${historyItem.mode}': ${
|
|
error instanceof Error ? error.message : String(error)
|
|
}. Continuing with default configuration.`,
|
|
)
|
|
// The task will continue with the current/default configuration.
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// If the history item has a saved API config name (provider profile), restore it.
|
|
// This overrides any mode-based config restoration above, because the task's
|
|
// specific provider profile takes precedence over mode defaults.
|
|
if (historyItem.apiConfigName) {
|
|
const listApiConfig = await this.providerSettingsManager.listConfig()
|
|
// Keep global state/UI in sync with latest profiles for parity with mode restoration above.
|
|
await this.updateGlobalState("listApiConfigMeta", listApiConfig)
|
|
const profile = listApiConfig.find(({ name }) => name === historyItem.apiConfigName)
|
|
|
|
if (profile?.name) {
|
|
try {
|
|
await this.activateProviderProfile(
|
|
{ name: profile.name },
|
|
{ persistModeConfig: false, persistTaskHistory: false },
|
|
)
|
|
} catch (error) {
|
|
// Log the error but continue with task restoration.
|
|
this.log(
|
|
`Failed to restore API configuration '${historyItem.apiConfigName}' for task: ${
|
|
error instanceof Error ? error.message : String(error)
|
|
}. Continuing with current configuration.`,
|
|
)
|
|
}
|
|
} else {
|
|
// Profile no longer exists, log warning but continue
|
|
this.log(
|
|
`Provider profile '${historyItem.apiConfigName}' from history no longer exists. Using current configuration.`,
|
|
)
|
|
}
|
|
}
|
|
|
|
const {
|
|
apiConfiguration,
|
|
diffEnabled: enableDiff,
|
|
enableCheckpoints,
|
|
checkpointTimeout,
|
|
fuzzyMatchThreshold,
|
|
experiments,
|
|
cloudUserInfo,
|
|
taskSyncEnabled,
|
|
} = await this.getState()
|
|
|
|
const task = new Task({
|
|
provider: this,
|
|
apiConfiguration,
|
|
enableDiff,
|
|
enableCheckpoints,
|
|
checkpointTimeout,
|
|
fuzzyMatchThreshold,
|
|
consecutiveMistakeLimit: apiConfiguration.consecutiveMistakeLimit,
|
|
historyItem,
|
|
experiments,
|
|
rootTask: historyItem.rootTask,
|
|
parentTask: historyItem.parentTask,
|
|
taskNumber: historyItem.number,
|
|
workspacePath: historyItem.workspace,
|
|
onCreated: this.taskCreationCallback,
|
|
startTask: options?.startTask ?? true,
|
|
enableBridge: BridgeOrchestrator.isEnabled(cloudUserInfo, taskSyncEnabled),
|
|
// Preserve the status from the history item to avoid overwriting it when the task saves messages
|
|
initialStatus: historyItem.status,
|
|
})
|
|
|
|
if (isRehydratingCurrentTask) {
|
|
// Replace the current task in-place to avoid UI flicker
|
|
const stackIndex = this.clineStack.length - 1
|
|
|
|
// Properly dispose of the old task to ensure garbage collection
|
|
const oldTask = this.clineStack[stackIndex]
|
|
|
|
// Abort the old task to stop running processes and mark as abandoned
|
|
try {
|
|
await oldTask.abortTask(true)
|
|
} catch (e) {
|
|
this.log(
|
|
`[createTaskWithHistoryItem] abortTask() failed for old task ${oldTask.taskId}.${oldTask.instanceId}: ${e.message}`,
|
|
)
|
|
}
|
|
|
|
// Remove event listeners from the old task
|
|
const cleanupFunctions = this.taskEventListeners.get(oldTask)
|
|
if (cleanupFunctions) {
|
|
cleanupFunctions.forEach((cleanup) => cleanup())
|
|
this.taskEventListeners.delete(oldTask)
|
|
}
|
|
|
|
// Replace the task in the stack
|
|
this.clineStack[stackIndex] = task
|
|
task.emit(RooCodeEventName.TaskFocused)
|
|
|
|
// Perform preparation tasks and set up event listeners
|
|
await this.performPreparationTasks(task)
|
|
|
|
this.log(
|
|
`[createTaskWithHistoryItem] rehydrated task ${task.taskId}.${task.instanceId} in-place (flicker-free)`,
|
|
)
|
|
} else {
|
|
await this.addClineToStack(task)
|
|
|
|
this.log(
|
|
`[createTaskWithHistoryItem] ${task.parentTask ? "child" : "parent"} task ${task.taskId}.${task.instanceId} instantiated`,
|
|
)
|
|
}
|
|
|
|
// Check if there's a pending edit after checkpoint restoration
|
|
const operationId = `task-${task.taskId}`
|
|
const pendingEdit = this.getPendingEditOperation(operationId)
|
|
if (pendingEdit) {
|
|
this.clearPendingEditOperation(operationId) // Clear the pending edit
|
|
|
|
this.log(`[createTaskWithHistoryItem] Processing pending edit after checkpoint restoration`)
|
|
|
|
// Process the pending edit after a short delay to ensure the task is fully initialized
|
|
setTimeout(async () => {
|
|
try {
|
|
// Find the message index in the restored state
|
|
const { messageIndex, apiConversationHistoryIndex } = (() => {
|
|
const messageIndex = task.clineMessages.findIndex((msg) => msg.ts === pendingEdit.messageTs)
|
|
const apiConversationHistoryIndex = task.apiConversationHistory.findIndex(
|
|
(msg) => msg.ts === pendingEdit.messageTs,
|
|
)
|
|
return { messageIndex, apiConversationHistoryIndex }
|
|
})()
|
|
|
|
if (messageIndex !== -1) {
|
|
// Remove the target message and all subsequent messages
|
|
await task.overwriteClineMessages(task.clineMessages.slice(0, messageIndex))
|
|
|
|
if (apiConversationHistoryIndex !== -1) {
|
|
await task.overwriteApiConversationHistory(
|
|
task.apiConversationHistory.slice(0, apiConversationHistoryIndex),
|
|
)
|
|
}
|
|
|
|
// Process the edited message
|
|
await task.handleWebviewAskResponse(
|
|
"messageResponse",
|
|
pendingEdit.editedContent,
|
|
pendingEdit.images,
|
|
)
|
|
}
|
|
} catch (error) {
|
|
this.log(`[createTaskWithHistoryItem] Error processing pending edit: ${error}`)
|
|
}
|
|
}, 100) // Small delay to ensure task is fully ready
|
|
}
|
|
|
|
return task
|
|
}
|
|
|
|
public async postMessageToWebview(message: ExtensionMessage) {
|
|
await this.view?.webview.postMessage(message)
|
|
}
|
|
|
|
private async getHMRHtmlContent(webview: vscode.Webview): Promise<string> {
|
|
let localPort = "5173"
|
|
|
|
try {
|
|
const fs = require("fs")
|
|
const path = require("path")
|
|
const portFilePath = path.resolve(__dirname, "../../.vite-port")
|
|
|
|
if (fs.existsSync(portFilePath)) {
|
|
localPort = fs.readFileSync(portFilePath, "utf8").trim()
|
|
console.log(`[ClineProvider:Vite] Using Vite server port from ${portFilePath}: ${localPort}`)
|
|
} else {
|
|
console.log(
|
|
`[ClineProvider:Vite] Port file not found at ${portFilePath}, using default port: ${localPort}`,
|
|
)
|
|
}
|
|
} catch (err) {
|
|
console.error("[ClineProvider:Vite] Failed to read Vite port file:", err)
|
|
}
|
|
|
|
const localServerUrl = `localhost:${localPort}`
|
|
|
|
// Check if local dev server is running.
|
|
try {
|
|
await axios.get(`http://${localServerUrl}`)
|
|
} catch (error) {
|
|
vscode.window.showErrorMessage(t("common:errors.hmr_not_running"))
|
|
return this.getHtmlContent(webview)
|
|
}
|
|
|
|
const nonce = getNonce()
|
|
|
|
// Get the OpenRouter base URL from configuration
|
|
const { apiConfiguration } = await this.getState()
|
|
const openRouterBaseUrl = apiConfiguration.openRouterBaseUrl || "https://openrouter.ai"
|
|
// Extract the domain for CSP
|
|
const openRouterDomain = openRouterBaseUrl.match(/^(https?:\/\/[^\/]+)/)?.[1] || "https://openrouter.ai"
|
|
|
|
const stylesUri = getUri(webview, this.contextProxy.extensionUri, [
|
|
"webview-ui",
|
|
"build",
|
|
"assets",
|
|
"index.css",
|
|
])
|
|
|
|
const codiconsUri = getUri(webview, this.contextProxy.extensionUri, ["assets", "codicons", "codicon.css"])
|
|
const materialIconsUri = getUri(webview, this.contextProxy.extensionUri, [
|
|
"assets",
|
|
"vscode-material-icons",
|
|
"icons",
|
|
])
|
|
const imagesUri = getUri(webview, this.contextProxy.extensionUri, ["assets", "images"])
|
|
const audioUri = getUri(webview, this.contextProxy.extensionUri, ["webview-ui", "audio"])
|
|
|
|
const file = "src/index.tsx"
|
|
const scriptUri = `http://${localServerUrl}/${file}`
|
|
|
|
const reactRefresh = /*html*/ `
|
|
<script nonce="${nonce}" type="module">
|
|
import RefreshRuntime from "http://localhost:${localPort}/@react-refresh"
|
|
RefreshRuntime.injectIntoGlobalHook(window)
|
|
window.$RefreshReg$ = () => {}
|
|
window.$RefreshSig$ = () => (type) => type
|
|
window.__vite_plugin_react_preamble_installed__ = true
|
|
</script>
|
|
`
|
|
|
|
const csp = [
|
|
"default-src 'none'",
|
|
`font-src ${webview.cspSource} data:`,
|
|
`style-src ${webview.cspSource} 'unsafe-inline' https://* http://${localServerUrl} http://0.0.0.0:${localPort}`,
|
|
`img-src ${webview.cspSource} https://storage.googleapis.com https://img.clerk.com data:`,
|
|
`media-src ${webview.cspSource}`,
|
|
`script-src 'unsafe-eval' ${webview.cspSource} https://* https://*.posthog.com http://${localServerUrl} http://0.0.0.0:${localPort} 'nonce-${nonce}'`,
|
|
`connect-src ${webview.cspSource} ${openRouterDomain} https://* https://*.posthog.com ws://${localServerUrl} ws://0.0.0.0:${localPort} http://${localServerUrl} http://0.0.0.0:${localPort}`,
|
|
]
|
|
|
|
return /*html*/ `
|
|
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width,initial-scale=1,shrink-to-fit=no">
|
|
<meta http-equiv="Content-Security-Policy" content="${csp.join("; ")}">
|
|
<link rel="stylesheet" type="text/css" href="${stylesUri}">
|
|
<link href="${codiconsUri}" rel="stylesheet" />
|
|
<script nonce="${nonce}">
|
|
window.IMAGES_BASE_URI = "${imagesUri}"
|
|
window.AUDIO_BASE_URI = "${audioUri}"
|
|
window.MATERIAL_ICONS_BASE_URI = "${materialIconsUri}"
|
|
</script>
|
|
<title>Roo Code</title>
|
|
</head>
|
|
<body>
|
|
<div id="root"></div>
|
|
${reactRefresh}
|
|
<script type="module" src="${scriptUri}"></script>
|
|
</body>
|
|
</html>
|
|
`
|
|
}
|
|
|
|
/**
|
|
* Defines and returns the HTML that should be rendered within the webview panel.
|
|
*
|
|
* @remarks This is also the place where references to the React webview build files
|
|
* are created and inserted into the webview HTML.
|
|
*
|
|
* @param webview A reference to the extension webview
|
|
* @param extensionUri The URI of the directory containing the extension
|
|
* @returns A template string literal containing the HTML that should be
|
|
* rendered within the webview panel
|
|
*/
|
|
private async getHtmlContent(webview: vscode.Webview): Promise<string> {
|
|
// Get the local path to main script run in the webview,
|
|
// then convert it to a uri we can use in the webview.
|
|
|
|
// The CSS file from the React build output
|
|
const stylesUri = getUri(webview, this.contextProxy.extensionUri, [
|
|
"webview-ui",
|
|
"build",
|
|
"assets",
|
|
"index.css",
|
|
])
|
|
|
|
const scriptUri = getUri(webview, this.contextProxy.extensionUri, ["webview-ui", "build", "assets", "index.js"])
|
|
const codiconsUri = getUri(webview, this.contextProxy.extensionUri, ["assets", "codicons", "codicon.css"])
|
|
const materialIconsUri = getUri(webview, this.contextProxy.extensionUri, [
|
|
"assets",
|
|
"vscode-material-icons",
|
|
"icons",
|
|
])
|
|
const imagesUri = getUri(webview, this.contextProxy.extensionUri, ["assets", "images"])
|
|
const audioUri = getUri(webview, this.contextProxy.extensionUri, ["webview-ui", "audio"])
|
|
|
|
// Use a nonce to only allow a specific script to be run.
|
|
/*
|
|
content security policy of your webview to only allow scripts that have a specific nonce
|
|
create a content security policy meta tag so that only loading scripts with a nonce is allowed
|
|
As your extension grows you will likely want to add custom styles, fonts, and/or images to your webview. If you do, you will need to update the content security policy meta tag to explicitly allow for these resources. E.g.
|
|
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src ${webview.cspSource}; font-src ${webview.cspSource}; img-src ${webview.cspSource} https:; script-src 'nonce-${nonce}';">
|
|
- 'unsafe-inline' is required for styles due to vscode-webview-toolkit's dynamic style injection
|
|
- since we pass base64 images to the webview, we need to specify img-src ${webview.cspSource} data:;
|
|
|
|
in meta tag we add nonce attribute: A cryptographic nonce (only used once) to allow scripts. The server must generate a unique nonce value each time it transmits a policy. It is critical to provide a nonce that cannot be guessed as bypassing a resource's policy is otherwise trivial.
|
|
*/
|
|
const nonce = getNonce()
|
|
|
|
// Get the OpenRouter base URL from configuration
|
|
const { apiConfiguration } = await this.getState()
|
|
const openRouterBaseUrl = apiConfiguration.openRouterBaseUrl || "https://openrouter.ai"
|
|
// Extract the domain for CSP
|
|
const openRouterDomain = openRouterBaseUrl.match(/^(https?:\/\/[^\/]+)/)?.[1] || "https://openrouter.ai"
|
|
|
|
// Tip: Install the es6-string-html VS Code extension to enable code highlighting below
|
|
return /*html*/ `
|
|
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width,initial-scale=1,shrink-to-fit=no">
|
|
<meta name="theme-color" content="#000000">
|
|
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; font-src ${webview.cspSource} data:; style-src ${webview.cspSource} 'unsafe-inline'; img-src ${webview.cspSource} https://storage.googleapis.com https://img.clerk.com data:; media-src ${webview.cspSource}; script-src ${webview.cspSource} 'wasm-unsafe-eval' 'nonce-${nonce}' https://ph.roocode.com 'strict-dynamic'; connect-src ${webview.cspSource} ${openRouterDomain} https://api.requesty.ai https://ph.roocode.com;">
|
|
<link rel="stylesheet" type="text/css" href="${stylesUri}">
|
|
<link href="${codiconsUri}" rel="stylesheet" />
|
|
<script nonce="${nonce}">
|
|
window.IMAGES_BASE_URI = "${imagesUri}"
|
|
window.AUDIO_BASE_URI = "${audioUri}"
|
|
window.MATERIAL_ICONS_BASE_URI = "${materialIconsUri}"
|
|
</script>
|
|
<title>Roo Code</title>
|
|
</head>
|
|
<body>
|
|
<noscript>You need to enable JavaScript to run this app.</noscript>
|
|
<div id="root"></div>
|
|
<script nonce="${nonce}" type="module" src="${scriptUri}"></script>
|
|
</body>
|
|
</html>
|
|
`
|
|
}
|
|
|
|
/**
|
|
* Sets up an event listener to listen for messages passed from the webview context and
|
|
* executes code based on the message that is received.
|
|
*
|
|
* @param webview A reference to the extension webview
|
|
*/
|
|
private setWebviewMessageListener(webview: vscode.Webview) {
|
|
const onReceiveMessage = async (message: WebviewMessage) =>
|
|
webviewMessageHandler(this, message, this.marketplaceManager)
|
|
|
|
const messageDisposable = webview.onDidReceiveMessage(onReceiveMessage)
|
|
this.webviewDisposables.push(messageDisposable)
|
|
}
|
|
|
|
/**
|
|
* Handle switching to a new mode, including updating the associated API configuration
|
|
* @param newMode The mode to switch to
|
|
*/
|
|
public async handleModeSwitch(newMode: Mode) {
|
|
const task = this.getCurrentTask()
|
|
|
|
if (task) {
|
|
TelemetryService.instance.captureModeSwitch(task.taskId, newMode)
|
|
task.emit(RooCodeEventName.TaskModeSwitched, task.taskId, newMode)
|
|
|
|
try {
|
|
// Update the task history with the new mode first.
|
|
const history = this.getGlobalState("taskHistory") ?? []
|
|
const taskHistoryItem = history.find((item) => item.id === task.taskId)
|
|
|
|
if (taskHistoryItem) {
|
|
taskHistoryItem.mode = newMode
|
|
await this.updateTaskHistory(taskHistoryItem)
|
|
}
|
|
|
|
// Only update the task's mode after successful persistence.
|
|
;(task as any)._taskMode = newMode
|
|
} catch (error) {
|
|
// If persistence fails, log the error but don't update the in-memory state.
|
|
this.log(
|
|
`Failed to persist mode switch for task ${task.taskId}: ${error instanceof Error ? error.message : String(error)}`,
|
|
)
|
|
|
|
// Optionally, we could emit an event to notify about the failure.
|
|
// This ensures the in-memory state remains consistent with persisted state.
|
|
throw error
|
|
}
|
|
}
|
|
|
|
await this.updateGlobalState("mode", newMode)
|
|
|
|
this.emit(RooCodeEventName.ModeChanged, newMode)
|
|
|
|
// Load the saved API config for the new mode if it exists.
|
|
const savedConfigId = await this.providerSettingsManager.getModeConfigId(newMode)
|
|
const listApiConfig = await this.providerSettingsManager.listConfig()
|
|
|
|
// Update listApiConfigMeta first to ensure UI has latest data.
|
|
await this.updateGlobalState("listApiConfigMeta", listApiConfig)
|
|
|
|
// If this mode has a saved config, use it.
|
|
if (savedConfigId) {
|
|
const profile = listApiConfig.find(({ id }) => id === savedConfigId)
|
|
|
|
if (profile?.name) {
|
|
// Check if the profile has actual API configuration (not just an id).
|
|
// In CLI mode, the ProviderSettingsManager may return empty default profiles
|
|
// that only contain 'id' and 'name' fields. Activating such a profile would
|
|
// overwrite the CLI's working API configuration with empty settings.
|
|
// Skip activation if the profile has no apiProvider set - this indicates
|
|
// an unconfigured/empty profile.
|
|
const fullProfile = await this.providerSettingsManager.getProfile({ name: profile.name })
|
|
const hasActualSettings = !!fullProfile.apiProvider
|
|
|
|
if (hasActualSettings) {
|
|
await this.activateProviderProfile({ name: profile.name })
|
|
} else {
|
|
// The task will continue with the current/default configuration.
|
|
}
|
|
} else {
|
|
// The task will continue with the current/default configuration.
|
|
}
|
|
} else {
|
|
// If no saved config for this mode, save current config as default.
|
|
const currentApiConfigNameAfter = this.getGlobalState("currentApiConfigName")
|
|
|
|
if (currentApiConfigNameAfter) {
|
|
const config = listApiConfig.find((c) => c.name === currentApiConfigNameAfter)
|
|
|
|
if (config?.id) {
|
|
await this.providerSettingsManager.setModeConfig(newMode, config.id)
|
|
}
|
|
}
|
|
}
|
|
|
|
await this.postStateToWebview()
|
|
}
|
|
|
|
// Provider Profile Management
|
|
|
|
/**
|
|
* Updates the current task's API handler.
|
|
* Rebuilds when:
|
|
* - provider or model changes, OR
|
|
* - explicitly forced (e.g., user-initiated profile switch/save to apply changed settings like headers/baseUrl/tier).
|
|
* Always synchronizes task.apiConfiguration with latest provider settings.
|
|
* @param providerSettings The new provider settings to apply
|
|
* @param options.forceRebuild Force rebuilding the API handler regardless of provider/model equality
|
|
*/
|
|
private updateTaskApiHandlerIfNeeded(
|
|
providerSettings: ProviderSettings,
|
|
options: { forceRebuild?: boolean } = {},
|
|
): void {
|
|
const task = this.getCurrentTask()
|
|
if (!task) return
|
|
|
|
const { forceRebuild = false } = options
|
|
|
|
// Determine if we need to rebuild using the previous configuration snapshot
|
|
const prevConfig = task.apiConfiguration
|
|
const prevProvider = prevConfig?.apiProvider
|
|
const prevModelId = prevConfig ? getModelId(prevConfig) : undefined
|
|
const prevToolProtocol = prevConfig?.toolProtocol
|
|
const newProvider = providerSettings.apiProvider
|
|
const newModelId = getModelId(providerSettings)
|
|
const newToolProtocol = providerSettings.toolProtocol
|
|
|
|
const needsRebuild =
|
|
forceRebuild ||
|
|
prevProvider !== newProvider ||
|
|
prevModelId !== newModelId ||
|
|
prevToolProtocol !== newToolProtocol
|
|
|
|
if (needsRebuild) {
|
|
// Use updateApiConfiguration which handles both API handler rebuild and parser sync.
|
|
// This is important when toolProtocol changes - the assistantMessageParser needs to be
|
|
// created/destroyed to match the new protocol (XML vs native).
|
|
// Note: updateApiConfiguration is declared async but has no actual async operations,
|
|
// so we can safely call it without awaiting.
|
|
task.updateApiConfiguration(providerSettings)
|
|
} else {
|
|
// No rebuild needed, just sync apiConfiguration
|
|
;(task as any).apiConfiguration = providerSettings
|
|
}
|
|
}
|
|
|
|
getProviderProfileEntries(): ProviderSettingsEntry[] {
|
|
return this.contextProxy.getValues().listApiConfigMeta || []
|
|
}
|
|
|
|
getProviderProfileEntry(name: string): ProviderSettingsEntry | undefined {
|
|
return this.getProviderProfileEntries().find((profile) => profile.name === name)
|
|
}
|
|
|
|
public hasProviderProfileEntry(name: string): boolean {
|
|
return !!this.getProviderProfileEntry(name)
|
|
}
|
|
|
|
async upsertProviderProfile(
|
|
name: string,
|
|
providerSettings: ProviderSettings,
|
|
activate: boolean = true,
|
|
): Promise<string | undefined> {
|
|
try {
|
|
// TODO: Do we need to be calling `activateProfile`? It's not
|
|
// clear to me what the source of truth should be; in some cases
|
|
// we rely on the `ContextProxy`'s data store and in other cases
|
|
// we rely on the `ProviderSettingsManager`'s data store. It might
|
|
// be simpler to unify these two.
|
|
const id = await this.providerSettingsManager.saveConfig(name, providerSettings)
|
|
|
|
if (activate) {
|
|
const { mode } = await this.getState()
|
|
|
|
// These promises do the following:
|
|
// 1. Adds or updates the list of provider profiles.
|
|
// 2. Sets the current provider profile.
|
|
// 3. Sets the current mode's provider profile.
|
|
// 4. Copies the provider settings to the context.
|
|
//
|
|
// Note: 1, 2, and 4 can be done in one `ContextProxy` call:
|
|
// this.contextProxy.setValues({ ...providerSettings, listApiConfigMeta: ..., currentApiConfigName: ... })
|
|
// We should probably switch to that and verify that it works.
|
|
// I left the original implementation in just to be safe.
|
|
await Promise.all([
|
|
this.updateGlobalState("listApiConfigMeta", await this.providerSettingsManager.listConfig()),
|
|
this.updateGlobalState("currentApiConfigName", name),
|
|
this.providerSettingsManager.setModeConfig(mode, id),
|
|
this.contextProxy.setProviderSettings(providerSettings),
|
|
])
|
|
|
|
// Change the provider for the current task.
|
|
// TODO: We should rename `buildApiHandler` for clarity (e.g. `getProviderClient`).
|
|
this.updateTaskApiHandlerIfNeeded(providerSettings, { forceRebuild: true })
|
|
|
|
// Keep the current task's sticky provider profile in sync with the newly-activated profile.
|
|
await this.persistStickyProviderProfileToCurrentTask(name)
|
|
} else {
|
|
await this.updateGlobalState("listApiConfigMeta", await this.providerSettingsManager.listConfig())
|
|
}
|
|
|
|
await this.postStateToWebview()
|
|
return id
|
|
} catch (error) {
|
|
this.log(
|
|
`Error create new api configuration: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`,
|
|
)
|
|
|
|
vscode.window.showErrorMessage(t("common:errors.create_api_config"))
|
|
return undefined
|
|
}
|
|
}
|
|
|
|
async deleteProviderProfile(profileToDelete: ProviderSettingsEntry) {
|
|
const globalSettings = this.contextProxy.getValues()
|
|
let profileToActivate: string | undefined = globalSettings.currentApiConfigName
|
|
|
|
if (profileToDelete.name === profileToActivate) {
|
|
profileToActivate = this.getProviderProfileEntries().find(({ name }) => name !== profileToDelete.name)?.name
|
|
}
|
|
|
|
if (!profileToActivate) {
|
|
throw new Error("You cannot delete the last profile")
|
|
}
|
|
|
|
const entries = this.getProviderProfileEntries().filter(({ name }) => name !== profileToDelete.name)
|
|
|
|
await this.contextProxy.setValues({
|
|
...globalSettings,
|
|
currentApiConfigName: profileToActivate,
|
|
listApiConfigMeta: entries,
|
|
})
|
|
|
|
await this.postStateToWebview()
|
|
}
|
|
|
|
private async persistStickyProviderProfileToCurrentTask(apiConfigName: string): Promise<void> {
|
|
const task = this.getCurrentTask()
|
|
if (!task) {
|
|
return
|
|
}
|
|
|
|
try {
|
|
// Update in-memory state immediately so sticky behavior works even before the task has
|
|
// been persisted into taskHistory (it will be captured on the next save).
|
|
task.setTaskApiConfigName(apiConfigName)
|
|
|
|
const history = this.getGlobalState("taskHistory") ?? []
|
|
const taskHistoryItem = history.find((item) => item.id === task.taskId)
|
|
|
|
if (taskHistoryItem) {
|
|
await this.updateTaskHistory({ ...taskHistoryItem, apiConfigName })
|
|
}
|
|
} catch (error) {
|
|
// If persistence fails, log the error but don't fail the profile switch.
|
|
this.log(
|
|
`Failed to persist provider profile switch for task ${task.taskId}: ${
|
|
error instanceof Error ? error.message : String(error)
|
|
}`,
|
|
)
|
|
}
|
|
}
|
|
|
|
async activateProviderProfile(
|
|
args: { name: string } | { id: string },
|
|
options?: { persistModeConfig?: boolean; persistTaskHistory?: boolean },
|
|
) {
|
|
const { name, id, ...providerSettings } = await this.providerSettingsManager.activateProfile(args)
|
|
|
|
const persistModeConfig = options?.persistModeConfig ?? true
|
|
const persistTaskHistory = options?.persistTaskHistory ?? true
|
|
|
|
// See `upsertProviderProfile` for a description of what this is doing.
|
|
await Promise.all([
|
|
this.contextProxy.setValue("listApiConfigMeta", await this.providerSettingsManager.listConfig()),
|
|
this.contextProxy.setValue("currentApiConfigName", name),
|
|
this.contextProxy.setProviderSettings(providerSettings),
|
|
])
|
|
|
|
const { mode } = await this.getState()
|
|
|
|
if (id && persistModeConfig) {
|
|
await this.providerSettingsManager.setModeConfig(mode, id)
|
|
}
|
|
|
|
// Change the provider for the current task.
|
|
this.updateTaskApiHandlerIfNeeded(providerSettings, { forceRebuild: true })
|
|
|
|
// Update the current task's sticky provider profile, unless this activation is
|
|
// being used purely as a non-persisting restoration (e.g., reopening a task from history).
|
|
if (persistTaskHistory) {
|
|
await this.persistStickyProviderProfileToCurrentTask(name)
|
|
}
|
|
|
|
await this.postStateToWebview()
|
|
|
|
if (providerSettings.apiProvider) {
|
|
this.emit(RooCodeEventName.ProviderProfileChanged, { name, provider: providerSettings.apiProvider })
|
|
}
|
|
}
|
|
|
|
async updateCustomInstructions(instructions?: string) {
|
|
// User may be clearing the field.
|
|
await this.updateGlobalState("customInstructions", instructions || undefined)
|
|
await this.postStateToWebview()
|
|
}
|
|
|
|
// MCP
|
|
|
|
async ensureMcpServersDirectoryExists(): Promise<string> {
|
|
// Get platform-specific application data directory
|
|
let mcpServersDir: string
|
|
if (process.platform === "win32") {
|
|
// Windows: %APPDATA%\Roo-Code\MCP
|
|
mcpServersDir = path.join(os.homedir(), "AppData", "Roaming", "Roo-Code", "MCP")
|
|
} else if (process.platform === "darwin") {
|
|
// macOS: ~/Documents/Cline/MCP
|
|
mcpServersDir = path.join(os.homedir(), "Documents", "Cline", "MCP")
|
|
} else {
|
|
// Linux: ~/.local/share/Cline/MCP
|
|
mcpServersDir = path.join(os.homedir(), ".local", "share", "Roo-Code", "MCP")
|
|
}
|
|
|
|
try {
|
|
await fs.mkdir(mcpServersDir, { recursive: true })
|
|
} catch (error) {
|
|
// Fallback to a relative path if directory creation fails
|
|
return path.join(os.homedir(), ".roo-code", "mcp")
|
|
}
|
|
return mcpServersDir
|
|
}
|
|
|
|
async ensureSettingsDirectoryExists(): Promise<string> {
|
|
const { getSettingsDirectoryPath } = await import("../../utils/storage")
|
|
const globalStoragePath = this.contextProxy.globalStorageUri.fsPath
|
|
return getSettingsDirectoryPath(globalStoragePath)
|
|
}
|
|
|
|
// OpenRouter
|
|
|
|
async handleOpenRouterCallback(code: string) {
|
|
let { apiConfiguration, currentApiConfigName = "default" } = await this.getState()
|
|
|
|
let apiKey: string
|
|
|
|
try {
|
|
const baseUrl = apiConfiguration.openRouterBaseUrl || "https://openrouter.ai/api/v1"
|
|
// Extract the base domain for the auth endpoint.
|
|
const baseUrlDomain = baseUrl.match(/^(https?:\/\/[^\/]+)/)?.[1] || "https://openrouter.ai"
|
|
const response = await axios.post(`${baseUrlDomain}/api/v1/auth/keys`, { code })
|
|
|
|
if (response.data && response.data.key) {
|
|
apiKey = response.data.key
|
|
} else {
|
|
throw new Error("Invalid response from OpenRouter API")
|
|
}
|
|
} catch (error) {
|
|
this.log(
|
|
`Error exchanging code for API key: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`,
|
|
)
|
|
|
|
throw error
|
|
}
|
|
|
|
const newConfiguration: ProviderSettings = {
|
|
...apiConfiguration,
|
|
apiProvider: "openrouter",
|
|
openRouterApiKey: apiKey,
|
|
openRouterModelId: apiConfiguration?.openRouterModelId || openRouterDefaultModelId,
|
|
}
|
|
|
|
await this.upsertProviderProfile(currentApiConfigName, newConfiguration)
|
|
}
|
|
|
|
// Requesty
|
|
|
|
async handleRequestyCallback(code: string, baseUrl: string | null) {
|
|
let { apiConfiguration } = await this.getState()
|
|
|
|
const newConfiguration: ProviderSettings = {
|
|
...apiConfiguration,
|
|
apiProvider: "requesty",
|
|
requestyApiKey: code,
|
|
requestyModelId: apiConfiguration?.requestyModelId || requestyDefaultModelId,
|
|
}
|
|
|
|
// set baseUrl as undefined if we don't provide one
|
|
// or if it is the default requesty url
|
|
if (!baseUrl || baseUrl === REQUESTY_BASE_URL) {
|
|
newConfiguration.requestyBaseUrl = undefined
|
|
} else {
|
|
newConfiguration.requestyBaseUrl = baseUrl
|
|
}
|
|
|
|
const profileName = `Requesty (${new Date().toLocaleString()})`
|
|
await this.upsertProviderProfile(profileName, newConfiguration)
|
|
}
|
|
|
|
// Task history
|
|
|
|
async getTaskWithId(id: string): Promise<{
|
|
historyItem: HistoryItem
|
|
taskDirPath: string
|
|
apiConversationHistoryFilePath: string
|
|
uiMessagesFilePath: string
|
|
apiConversationHistory: Anthropic.MessageParam[]
|
|
}> {
|
|
const history = this.getGlobalState("taskHistory") ?? []
|
|
const historyItem = history.find((item) => item.id === id)
|
|
|
|
if (historyItem) {
|
|
const { getTaskDirectoryPath } = await import("../../utils/storage")
|
|
const globalStoragePath = this.contextProxy.globalStorageUri.fsPath
|
|
const taskDirPath = await getTaskDirectoryPath(globalStoragePath, id)
|
|
const apiConversationHistoryFilePath = path.join(taskDirPath, GlobalFileNames.apiConversationHistory)
|
|
const uiMessagesFilePath = path.join(taskDirPath, GlobalFileNames.uiMessages)
|
|
const fileExists = await fileExistsAtPath(apiConversationHistoryFilePath)
|
|
|
|
if (fileExists) {
|
|
const apiConversationHistory = JSON.parse(await fs.readFile(apiConversationHistoryFilePath, "utf8"))
|
|
|
|
return {
|
|
historyItem,
|
|
taskDirPath,
|
|
apiConversationHistoryFilePath,
|
|
uiMessagesFilePath,
|
|
apiConversationHistory,
|
|
}
|
|
}
|
|
}
|
|
|
|
// if we tried to get a task that doesn't exist, remove it from state
|
|
// FIXME: this seems to happen sometimes when the json file doesnt save to disk for some reason
|
|
await this.deleteTaskFromState(id)
|
|
throw new Error("Task not found")
|
|
}
|
|
|
|
async showTaskWithId(id: string) {
|
|
if (id !== this.getCurrentTask()?.taskId) {
|
|
// Non-current task.
|
|
const { historyItem } = await this.getTaskWithId(id)
|
|
await this.createTaskWithHistoryItem(historyItem) // Clears existing task.
|
|
}
|
|
|
|
await this.postMessageToWebview({ type: "action", action: "chatButtonClicked" })
|
|
}
|
|
|
|
async exportTaskWithId(id: string) {
|
|
const { historyItem, apiConversationHistory } = await this.getTaskWithId(id)
|
|
await downloadTask(historyItem.ts, apiConversationHistory)
|
|
}
|
|
|
|
/* Condenses a task's message history to use fewer tokens. */
|
|
async condenseTaskContext(taskId: string) {
|
|
let task: Task | undefined
|
|
for (let i = this.clineStack.length - 1; i >= 0; i--) {
|
|
if (this.clineStack[i].taskId === taskId) {
|
|
task = this.clineStack[i]
|
|
break
|
|
}
|
|
}
|
|
if (!task) {
|
|
throw new Error(`Task with id ${taskId} not found in stack`)
|
|
}
|
|
await task.condenseContext()
|
|
await this.postMessageToWebview({ type: "condenseTaskContextResponse", text: taskId })
|
|
}
|
|
|
|
// this function deletes a task from task hidtory, and deletes it's checkpoints and delete the task folder
|
|
async deleteTaskWithId(id: string) {
|
|
try {
|
|
// get the task directory full path
|
|
const { taskDirPath } = await this.getTaskWithId(id)
|
|
|
|
// remove task from stack if it's the current task
|
|
if (id === this.getCurrentTask()?.taskId) {
|
|
// Close the current task instance; delegation flows will be handled via metadata if applicable.
|
|
await this.removeClineFromStack()
|
|
}
|
|
|
|
// delete task from the task history state
|
|
await this.deleteTaskFromState(id)
|
|
|
|
// Delete associated shadow repository or branch.
|
|
// TODO: Store `workspaceDir` in the `HistoryItem` object.
|
|
const globalStorageDir = this.contextProxy.globalStorageUri.fsPath
|
|
const workspaceDir = this.cwd
|
|
|
|
try {
|
|
await ShadowCheckpointService.deleteTask({ taskId: id, globalStorageDir, workspaceDir })
|
|
} catch (error) {
|
|
console.error(
|
|
`[deleteTaskWithId${id}] failed to delete associated shadow repository or branch: ${error instanceof Error ? error.message : String(error)}`,
|
|
)
|
|
}
|
|
|
|
// delete the entire task directory including checkpoints and all content
|
|
try {
|
|
await fs.rm(taskDirPath, { recursive: true, force: true })
|
|
console.log(`[deleteTaskWithId${id}] removed task directory`)
|
|
} catch (error) {
|
|
console.error(
|
|
`[deleteTaskWithId${id}] failed to remove task directory: ${error instanceof Error ? error.message : String(error)}`,
|
|
)
|
|
}
|
|
} catch (error) {
|
|
// If task is not found, just remove it from state
|
|
if (error instanceof Error && error.message === "Task not found") {
|
|
await this.deleteTaskFromState(id)
|
|
return
|
|
}
|
|
throw error
|
|
}
|
|
}
|
|
|
|
async deleteTaskFromState(id: string) {
|
|
const taskHistory = this.getGlobalState("taskHistory") ?? []
|
|
const updatedTaskHistory = taskHistory.filter((task) => task.id !== id)
|
|
await this.updateGlobalState("taskHistory", updatedTaskHistory)
|
|
this.recentTasksCache = undefined
|
|
await this.postStateToWebview()
|
|
}
|
|
|
|
async refreshWorkspace() {
|
|
this.currentWorkspacePath = getWorkspacePath()
|
|
await this.postStateToWebview()
|
|
}
|
|
|
|
async postStateToWebview() {
|
|
const state = await this.getStateToPostToWebview()
|
|
this.postMessageToWebview({ type: "state", state })
|
|
|
|
// Check MDM compliance and send user to account tab if not compliant
|
|
// Only redirect if there's an actual MDM policy requiring authentication
|
|
if (this.mdmService?.requiresCloudAuth() && !this.checkMdmCompliance()) {
|
|
await this.postMessageToWebview({ type: "action", action: "cloudButtonClicked" })
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Fetches marketplace data on demand to avoid blocking main state updates
|
|
*/
|
|
async fetchMarketplaceData() {
|
|
try {
|
|
const [marketplaceResult, marketplaceInstalledMetadata] = await Promise.all([
|
|
this.marketplaceManager.getMarketplaceItems().catch((error) => {
|
|
console.error("Failed to fetch marketplace items:", error)
|
|
return { organizationMcps: [], marketplaceItems: [], errors: [error.message] }
|
|
}),
|
|
this.marketplaceManager.getInstallationMetadata().catch((error) => {
|
|
console.error("Failed to fetch installation metadata:", error)
|
|
return { project: {}, global: {} } as MarketplaceInstalledMetadata
|
|
}),
|
|
])
|
|
|
|
// Send marketplace data separately
|
|
this.postMessageToWebview({
|
|
type: "marketplaceData",
|
|
organizationMcps: marketplaceResult.organizationMcps || [],
|
|
marketplaceItems: marketplaceResult.marketplaceItems || [],
|
|
marketplaceInstalledMetadata: marketplaceInstalledMetadata || { project: {}, global: {} },
|
|
errors: marketplaceResult.errors,
|
|
})
|
|
} catch (error) {
|
|
console.error("Failed to fetch marketplace data:", error)
|
|
|
|
// Send empty data on error to prevent UI from hanging
|
|
this.postMessageToWebview({
|
|
type: "marketplaceData",
|
|
organizationMcps: [],
|
|
marketplaceItems: [],
|
|
marketplaceInstalledMetadata: { project: {}, global: {} },
|
|
errors: [error instanceof Error ? error.message : String(error)],
|
|
})
|
|
|
|
// Show user-friendly error notification for network issues
|
|
if (error instanceof Error && error.message.includes("timeout")) {
|
|
vscode.window.showWarningMessage(
|
|
"Marketplace data could not be loaded due to network restrictions. Core functionality remains available.",
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Checks if there is a file-based system prompt override for the given mode
|
|
*/
|
|
async hasFileBasedSystemPromptOverride(mode: Mode): Promise<boolean> {
|
|
const promptFilePath = getSystemPromptFilePath(this.cwd, mode)
|
|
return await fileExistsAtPath(promptFilePath)
|
|
}
|
|
|
|
/**
|
|
* Merges allowed commands from global state and workspace configuration
|
|
* with proper validation and deduplication
|
|
*/
|
|
private mergeAllowedCommands(globalStateCommands?: string[]): string[] {
|
|
return this.mergeCommandLists("allowedCommands", "allowed", globalStateCommands)
|
|
}
|
|
|
|
/**
|
|
* Merges denied commands from global state and workspace configuration
|
|
* with proper validation and deduplication
|
|
*/
|
|
private mergeDeniedCommands(globalStateCommands?: string[]): string[] {
|
|
return this.mergeCommandLists("deniedCommands", "denied", globalStateCommands)
|
|
}
|
|
|
|
/**
|
|
* Common utility for merging command lists from global state and workspace configuration.
|
|
* Implements the Command Denylist feature's merging strategy with proper validation.
|
|
*
|
|
* @param configKey - VSCode workspace configuration key
|
|
* @param commandType - Type of commands for error logging
|
|
* @param globalStateCommands - Commands from global state
|
|
* @returns Merged and deduplicated command list
|
|
*/
|
|
private mergeCommandLists(
|
|
configKey: "allowedCommands" | "deniedCommands",
|
|
commandType: "allowed" | "denied",
|
|
globalStateCommands?: string[],
|
|
): string[] {
|
|
try {
|
|
// Validate and sanitize global state commands
|
|
const validGlobalCommands = Array.isArray(globalStateCommands)
|
|
? globalStateCommands.filter((cmd) => typeof cmd === "string" && cmd.trim().length > 0)
|
|
: []
|
|
|
|
// Get workspace configuration commands
|
|
const workspaceCommands = vscode.workspace.getConfiguration(Package.name).get<string[]>(configKey) || []
|
|
|
|
// Validate and sanitize workspace commands
|
|
const validWorkspaceCommands = Array.isArray(workspaceCommands)
|
|
? workspaceCommands.filter((cmd) => typeof cmd === "string" && cmd.trim().length > 0)
|
|
: []
|
|
|
|
// Combine and deduplicate commands
|
|
// Global state takes precedence over workspace configuration
|
|
const mergedCommands = [...new Set([...validGlobalCommands, ...validWorkspaceCommands])]
|
|
|
|
return mergedCommands
|
|
} catch (error) {
|
|
console.error(`Error merging ${commandType} commands:`, error)
|
|
// Return empty array as fallback to prevent crashes
|
|
return []
|
|
}
|
|
}
|
|
|
|
async getStateToPostToWebview(): Promise<ExtensionState> {
|
|
const {
|
|
apiConfiguration,
|
|
lastShownAnnouncementId,
|
|
customInstructions,
|
|
alwaysAllowReadOnly,
|
|
alwaysAllowReadOnlyOutsideWorkspace,
|
|
alwaysAllowWrite,
|
|
alwaysAllowWriteOutsideWorkspace,
|
|
alwaysAllowWriteProtected,
|
|
alwaysAllowExecute,
|
|
allowedCommands,
|
|
deniedCommands,
|
|
alwaysAllowBrowser,
|
|
alwaysAllowMcp,
|
|
alwaysAllowModeSwitch,
|
|
alwaysAllowSubtasks,
|
|
allowedMaxRequests,
|
|
allowedMaxCost,
|
|
autoCondenseContext,
|
|
autoCondenseContextPercent,
|
|
soundEnabled,
|
|
ttsEnabled,
|
|
ttsSpeed,
|
|
diffEnabled,
|
|
enableCheckpoints,
|
|
checkpointTimeout,
|
|
taskHistory,
|
|
soundVolume,
|
|
browserViewportSize,
|
|
screenshotQuality,
|
|
remoteBrowserHost,
|
|
remoteBrowserEnabled,
|
|
cachedChromeHostUrl,
|
|
writeDelayMs,
|
|
terminalOutputLineLimit,
|
|
terminalOutputCharacterLimit,
|
|
terminalShellIntegrationTimeout,
|
|
terminalShellIntegrationDisabled,
|
|
terminalCommandDelay,
|
|
terminalPowershellCounter,
|
|
terminalZshClearEolMark,
|
|
terminalZshOhMy,
|
|
terminalZshP10k,
|
|
terminalZdotdir,
|
|
fuzzyMatchThreshold,
|
|
mcpEnabled,
|
|
enableMcpServerCreation,
|
|
currentApiConfigName,
|
|
listApiConfigMeta,
|
|
pinnedApiConfigs,
|
|
mode,
|
|
customModePrompts,
|
|
customSupportPrompts,
|
|
enhancementApiConfigId,
|
|
autoApprovalEnabled,
|
|
customModes,
|
|
experiments,
|
|
maxOpenTabsContext,
|
|
maxWorkspaceFiles,
|
|
browserToolEnabled,
|
|
telemetrySetting,
|
|
showRooIgnoredFiles,
|
|
enableSubfolderRules,
|
|
language,
|
|
maxReadFileLine,
|
|
maxImageFileSize,
|
|
maxTotalImageSize,
|
|
terminalCompressProgressBar,
|
|
historyPreviewCollapsed,
|
|
reasoningBlockCollapsed,
|
|
enterBehavior,
|
|
cloudUserInfo,
|
|
cloudIsAuthenticated,
|
|
sharingEnabled,
|
|
publicSharingEnabled,
|
|
organizationAllowList,
|
|
organizationSettingsVersion,
|
|
maxConcurrentFileReads,
|
|
condensingApiConfigId,
|
|
customCondensingPrompt,
|
|
codebaseIndexConfig,
|
|
codebaseIndexModels,
|
|
profileThresholds,
|
|
alwaysAllowFollowupQuestions,
|
|
followupAutoApproveTimeoutMs,
|
|
includeDiagnosticMessages,
|
|
maxDiagnosticMessages,
|
|
includeTaskHistoryInEnhance,
|
|
includeCurrentTime,
|
|
includeCurrentCost,
|
|
maxGitStatusFiles,
|
|
taskSyncEnabled,
|
|
remoteControlEnabled,
|
|
imageGenerationProvider,
|
|
openRouterImageApiKey,
|
|
openRouterImageGenerationSelectedModel,
|
|
featureRoomoteControlEnabled,
|
|
isBrowserSessionActive,
|
|
} = await this.getState()
|
|
|
|
let cloudOrganizations: CloudOrganizationMembership[] = []
|
|
|
|
try {
|
|
if (!CloudService.instance.isCloudAgent) {
|
|
const now = Date.now()
|
|
|
|
if (
|
|
this.cloudOrganizationsCache !== null &&
|
|
this.cloudOrganizationsCacheTimestamp !== null &&
|
|
now - this.cloudOrganizationsCacheTimestamp < ClineProvider.CLOUD_ORGANIZATIONS_CACHE_DURATION_MS
|
|
) {
|
|
cloudOrganizations = this.cloudOrganizationsCache!
|
|
} else {
|
|
cloudOrganizations = await CloudService.instance.getOrganizationMemberships()
|
|
this.cloudOrganizationsCache = cloudOrganizations
|
|
this.cloudOrganizationsCacheTimestamp = now
|
|
}
|
|
}
|
|
} catch (error) {
|
|
// Ignore this error.
|
|
}
|
|
|
|
const telemetryKey = process.env.POSTHOG_API_KEY
|
|
const machineId = vscode.env.machineId
|
|
const mergedAllowedCommands = this.mergeAllowedCommands(allowedCommands)
|
|
const mergedDeniedCommands = this.mergeDeniedCommands(deniedCommands)
|
|
const cwd = this.cwd
|
|
|
|
// Check if there's a system prompt override for the current mode
|
|
const currentMode = mode ?? defaultModeSlug
|
|
const hasSystemPromptOverride = await this.hasFileBasedSystemPromptOverride(currentMode)
|
|
|
|
return {
|
|
version: this.context.extension?.packageJSON?.version ?? "",
|
|
apiConfiguration,
|
|
customInstructions,
|
|
alwaysAllowReadOnly: alwaysAllowReadOnly ?? false,
|
|
alwaysAllowReadOnlyOutsideWorkspace: alwaysAllowReadOnlyOutsideWorkspace ?? false,
|
|
alwaysAllowWrite: alwaysAllowWrite ?? false,
|
|
alwaysAllowWriteOutsideWorkspace: alwaysAllowWriteOutsideWorkspace ?? false,
|
|
alwaysAllowWriteProtected: alwaysAllowWriteProtected ?? false,
|
|
alwaysAllowExecute: alwaysAllowExecute ?? false,
|
|
alwaysAllowBrowser: alwaysAllowBrowser ?? false,
|
|
alwaysAllowMcp: alwaysAllowMcp ?? false,
|
|
alwaysAllowModeSwitch: alwaysAllowModeSwitch ?? false,
|
|
alwaysAllowSubtasks: alwaysAllowSubtasks ?? false,
|
|
isBrowserSessionActive,
|
|
allowedMaxRequests,
|
|
allowedMaxCost,
|
|
autoCondenseContext: autoCondenseContext ?? true,
|
|
autoCondenseContextPercent: autoCondenseContextPercent ?? 100,
|
|
uriScheme: vscode.env.uriScheme,
|
|
currentTaskItem: this.getCurrentTask()?.taskId
|
|
? (taskHistory || []).find((item: HistoryItem) => item.id === this.getCurrentTask()?.taskId)
|
|
: undefined,
|
|
clineMessages: this.getCurrentTask()?.clineMessages || [],
|
|
currentTaskTodos: this.getCurrentTask()?.todoList || [],
|
|
messageQueue: this.getCurrentTask()?.messageQueueService?.messages,
|
|
taskHistory: (taskHistory || [])
|
|
.filter((item: HistoryItem) => item.ts && item.task)
|
|
.sort((a: HistoryItem, b: HistoryItem) => b.ts - a.ts),
|
|
soundEnabled: soundEnabled ?? false,
|
|
ttsEnabled: ttsEnabled ?? false,
|
|
ttsSpeed: ttsSpeed ?? 1.0,
|
|
diffEnabled: diffEnabled ?? true,
|
|
enableCheckpoints: enableCheckpoints ?? true,
|
|
checkpointTimeout: checkpointTimeout ?? DEFAULT_CHECKPOINT_TIMEOUT_SECONDS,
|
|
shouldShowAnnouncement:
|
|
telemetrySetting !== "unset" && lastShownAnnouncementId !== this.latestAnnouncementId,
|
|
allowedCommands: mergedAllowedCommands,
|
|
deniedCommands: mergedDeniedCommands,
|
|
soundVolume: soundVolume ?? 0.5,
|
|
browserViewportSize: browserViewportSize ?? "900x600",
|
|
screenshotQuality: screenshotQuality ?? 75,
|
|
remoteBrowserHost,
|
|
remoteBrowserEnabled: remoteBrowserEnabled ?? false,
|
|
cachedChromeHostUrl: cachedChromeHostUrl,
|
|
writeDelayMs: writeDelayMs ?? DEFAULT_WRITE_DELAY_MS,
|
|
terminalOutputLineLimit: terminalOutputLineLimit ?? 500,
|
|
terminalOutputCharacterLimit: terminalOutputCharacterLimit ?? DEFAULT_TERMINAL_OUTPUT_CHARACTER_LIMIT,
|
|
terminalShellIntegrationTimeout: terminalShellIntegrationTimeout ?? Terminal.defaultShellIntegrationTimeout,
|
|
terminalShellIntegrationDisabled: terminalShellIntegrationDisabled ?? true,
|
|
terminalCommandDelay: terminalCommandDelay ?? 0,
|
|
terminalPowershellCounter: terminalPowershellCounter ?? false,
|
|
terminalZshClearEolMark: terminalZshClearEolMark ?? true,
|
|
terminalZshOhMy: terminalZshOhMy ?? false,
|
|
terminalZshP10k: terminalZshP10k ?? false,
|
|
terminalZdotdir: terminalZdotdir ?? false,
|
|
fuzzyMatchThreshold: fuzzyMatchThreshold ?? 1.0,
|
|
mcpEnabled: mcpEnabled ?? true,
|
|
enableMcpServerCreation: enableMcpServerCreation ?? true,
|
|
currentApiConfigName: currentApiConfigName ?? "default",
|
|
listApiConfigMeta: listApiConfigMeta ?? [],
|
|
pinnedApiConfigs: pinnedApiConfigs ?? {},
|
|
mode: mode ?? defaultModeSlug,
|
|
customModePrompts: customModePrompts ?? {},
|
|
customSupportPrompts: customSupportPrompts ?? {},
|
|
enhancementApiConfigId,
|
|
autoApprovalEnabled: autoApprovalEnabled ?? false,
|
|
customModes,
|
|
experiments: experiments ?? experimentDefault,
|
|
mcpServers: this.mcpHub?.getAllServers() ?? [],
|
|
maxOpenTabsContext: maxOpenTabsContext ?? 20,
|
|
maxWorkspaceFiles: maxWorkspaceFiles ?? 200,
|
|
cwd,
|
|
browserToolEnabled: browserToolEnabled ?? true,
|
|
telemetrySetting,
|
|
telemetryKey,
|
|
machineId,
|
|
showRooIgnoredFiles: showRooIgnoredFiles ?? false,
|
|
enableSubfolderRules: enableSubfolderRules ?? false,
|
|
language: language ?? formatLanguage(vscode.env.language),
|
|
renderContext: this.renderContext,
|
|
maxReadFileLine: maxReadFileLine ?? -1,
|
|
maxImageFileSize: maxImageFileSize ?? 5,
|
|
maxTotalImageSize: maxTotalImageSize ?? 20,
|
|
maxConcurrentFileReads: maxConcurrentFileReads ?? 5,
|
|
settingsImportedAt: this.settingsImportedAt,
|
|
terminalCompressProgressBar: terminalCompressProgressBar ?? true,
|
|
hasSystemPromptOverride,
|
|
historyPreviewCollapsed: historyPreviewCollapsed ?? false,
|
|
reasoningBlockCollapsed: reasoningBlockCollapsed ?? true,
|
|
enterBehavior: enterBehavior ?? "send",
|
|
cloudUserInfo,
|
|
cloudIsAuthenticated: cloudIsAuthenticated ?? false,
|
|
cloudAuthSkipModel: this.context.globalState.get<boolean>("roo-auth-skip-model") ?? false,
|
|
cloudOrganizations,
|
|
sharingEnabled: sharingEnabled ?? false,
|
|
publicSharingEnabled: publicSharingEnabled ?? false,
|
|
organizationAllowList,
|
|
organizationSettingsVersion,
|
|
condensingApiConfigId,
|
|
customCondensingPrompt,
|
|
codebaseIndexModels: codebaseIndexModels ?? EMBEDDING_MODEL_PROFILES,
|
|
codebaseIndexConfig: {
|
|
codebaseIndexEnabled: codebaseIndexConfig?.codebaseIndexEnabled ?? false,
|
|
codebaseIndexQdrantUrl: codebaseIndexConfig?.codebaseIndexQdrantUrl ?? "http://localhost:6333",
|
|
codebaseIndexEmbedderProvider: codebaseIndexConfig?.codebaseIndexEmbedderProvider ?? "openai",
|
|
codebaseIndexEmbedderBaseUrl: codebaseIndexConfig?.codebaseIndexEmbedderBaseUrl ?? "",
|
|
codebaseIndexEmbedderModelId: codebaseIndexConfig?.codebaseIndexEmbedderModelId ?? "",
|
|
codebaseIndexEmbedderModelDimension: codebaseIndexConfig?.codebaseIndexEmbedderModelDimension ?? 1536,
|
|
codebaseIndexOpenAiCompatibleBaseUrl: codebaseIndexConfig?.codebaseIndexOpenAiCompatibleBaseUrl,
|
|
codebaseIndexSearchMaxResults: codebaseIndexConfig?.codebaseIndexSearchMaxResults,
|
|
codebaseIndexSearchMinScore: codebaseIndexConfig?.codebaseIndexSearchMinScore,
|
|
codebaseIndexBedrockRegion: codebaseIndexConfig?.codebaseIndexBedrockRegion,
|
|
codebaseIndexBedrockProfile: codebaseIndexConfig?.codebaseIndexBedrockProfile,
|
|
codebaseIndexOpenRouterSpecificProvider: codebaseIndexConfig?.codebaseIndexOpenRouterSpecificProvider,
|
|
},
|
|
// Only set mdmCompliant if there's an actual MDM policy
|
|
// undefined means no MDM policy, true means compliant, false means non-compliant
|
|
mdmCompliant: this.mdmService?.requiresCloudAuth() ? this.checkMdmCompliance() : undefined,
|
|
profileThresholds: profileThresholds ?? {},
|
|
cloudApiUrl: getRooCodeApiUrl(),
|
|
hasOpenedModeSelector: this.getGlobalState("hasOpenedModeSelector") ?? false,
|
|
alwaysAllowFollowupQuestions: alwaysAllowFollowupQuestions ?? false,
|
|
followupAutoApproveTimeoutMs: followupAutoApproveTimeoutMs ?? 60000,
|
|
includeDiagnosticMessages: includeDiagnosticMessages ?? true,
|
|
maxDiagnosticMessages: maxDiagnosticMessages ?? 50,
|
|
includeTaskHistoryInEnhance: includeTaskHistoryInEnhance ?? true,
|
|
includeCurrentTime: includeCurrentTime ?? true,
|
|
includeCurrentCost: includeCurrentCost ?? true,
|
|
maxGitStatusFiles: maxGitStatusFiles ?? 0,
|
|
taskSyncEnabled,
|
|
remoteControlEnabled,
|
|
imageGenerationProvider,
|
|
openRouterImageApiKey,
|
|
openRouterImageGenerationSelectedModel,
|
|
featureRoomoteControlEnabled,
|
|
claudeCodeIsAuthenticated: await (async () => {
|
|
try {
|
|
const { claudeCodeOAuthManager } = await import("../../integrations/claude-code/oauth")
|
|
return await claudeCodeOAuthManager.isAuthenticated()
|
|
} catch {
|
|
return false
|
|
}
|
|
})(),
|
|
debug: vscode.workspace.getConfiguration(Package.name).get<boolean>("debug", false),
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Storage
|
|
* https://dev.to/kompotkot/how-to-use-secretstorage-in-your-vscode-extensions-2hco
|
|
* https://www.eliostruyf.com/devhack-code-extension-storage-options/
|
|
*/
|
|
|
|
async getState(): Promise<
|
|
Omit<
|
|
ExtensionState,
|
|
| "clineMessages"
|
|
| "renderContext"
|
|
| "hasOpenedModeSelector"
|
|
| "version"
|
|
| "shouldShowAnnouncement"
|
|
| "hasSystemPromptOverride"
|
|
>
|
|
> {
|
|
const stateValues = this.contextProxy.getValues()
|
|
const customModes = await this.customModesManager.getCustomModes()
|
|
|
|
// Determine apiProvider with the same logic as before.
|
|
const apiProvider: ProviderName = stateValues.apiProvider ? stateValues.apiProvider : "anthropic"
|
|
|
|
// Build the apiConfiguration object combining state values and secrets.
|
|
const providerSettings = this.contextProxy.getProviderSettings()
|
|
|
|
// Ensure apiProvider is set properly if not already in state
|
|
if (!providerSettings.apiProvider) {
|
|
providerSettings.apiProvider = apiProvider
|
|
}
|
|
|
|
let organizationAllowList = ORGANIZATION_ALLOW_ALL
|
|
|
|
try {
|
|
organizationAllowList = await CloudService.instance.getAllowList()
|
|
} catch (error) {
|
|
console.error(
|
|
`[getState] failed to get organization allow list: ${error instanceof Error ? error.message : String(error)}`,
|
|
)
|
|
}
|
|
|
|
let cloudUserInfo: CloudUserInfo | null = null
|
|
|
|
try {
|
|
cloudUserInfo = CloudService.instance.getUserInfo()
|
|
} catch (error) {
|
|
console.error(
|
|
`[getState] failed to get cloud user info: ${error instanceof Error ? error.message : String(error)}`,
|
|
)
|
|
}
|
|
|
|
let cloudIsAuthenticated: boolean = false
|
|
|
|
try {
|
|
cloudIsAuthenticated = CloudService.instance.isAuthenticated()
|
|
} catch (error) {
|
|
console.error(
|
|
`[getState] failed to get cloud authentication state: ${error instanceof Error ? error.message : String(error)}`,
|
|
)
|
|
}
|
|
|
|
let sharingEnabled: boolean = false
|
|
|
|
try {
|
|
sharingEnabled = await CloudService.instance.canShareTask()
|
|
} catch (error) {
|
|
console.error(
|
|
`[getState] failed to get sharing enabled state: ${error instanceof Error ? error.message : String(error)}`,
|
|
)
|
|
}
|
|
|
|
let publicSharingEnabled: boolean = false
|
|
|
|
try {
|
|
publicSharingEnabled = await CloudService.instance.canSharePublicly()
|
|
} catch (error) {
|
|
console.error(
|
|
`[getState] failed to get public sharing enabled state: ${error instanceof Error ? error.message : String(error)}`,
|
|
)
|
|
}
|
|
|
|
let organizationSettingsVersion: number = -1
|
|
|
|
try {
|
|
if (CloudService.hasInstance()) {
|
|
const settings = CloudService.instance.getOrganizationSettings()
|
|
organizationSettingsVersion = settings?.version ?? -1
|
|
}
|
|
} catch (error) {
|
|
console.error(
|
|
`[getState] failed to get organization settings version: ${error instanceof Error ? error.message : String(error)}`,
|
|
)
|
|
}
|
|
|
|
let taskSyncEnabled: boolean = false
|
|
|
|
try {
|
|
taskSyncEnabled = CloudService.instance.isTaskSyncEnabled()
|
|
} catch (error) {
|
|
console.error(
|
|
`[getState] failed to get task sync enabled state: ${error instanceof Error ? error.message : String(error)}`,
|
|
)
|
|
}
|
|
|
|
// Get actual browser session state
|
|
const isBrowserSessionActive = this.getCurrentTask()?.browserSession?.isSessionActive() ?? false
|
|
|
|
// Return the same structure as before.
|
|
return {
|
|
apiConfiguration: providerSettings,
|
|
lastShownAnnouncementId: stateValues.lastShownAnnouncementId,
|
|
customInstructions: stateValues.customInstructions,
|
|
apiModelId: stateValues.apiModelId,
|
|
alwaysAllowReadOnly: stateValues.alwaysAllowReadOnly ?? false,
|
|
alwaysAllowReadOnlyOutsideWorkspace: stateValues.alwaysAllowReadOnlyOutsideWorkspace ?? false,
|
|
alwaysAllowWrite: stateValues.alwaysAllowWrite ?? false,
|
|
alwaysAllowWriteOutsideWorkspace: stateValues.alwaysAllowWriteOutsideWorkspace ?? false,
|
|
alwaysAllowWriteProtected: stateValues.alwaysAllowWriteProtected ?? false,
|
|
alwaysAllowExecute: stateValues.alwaysAllowExecute ?? false,
|
|
alwaysAllowBrowser: stateValues.alwaysAllowBrowser ?? false,
|
|
alwaysAllowMcp: stateValues.alwaysAllowMcp ?? false,
|
|
alwaysAllowModeSwitch: stateValues.alwaysAllowModeSwitch ?? false,
|
|
alwaysAllowSubtasks: stateValues.alwaysAllowSubtasks ?? false,
|
|
alwaysAllowFollowupQuestions: stateValues.alwaysAllowFollowupQuestions ?? false,
|
|
isBrowserSessionActive,
|
|
followupAutoApproveTimeoutMs: stateValues.followupAutoApproveTimeoutMs ?? 60000,
|
|
diagnosticsEnabled: stateValues.diagnosticsEnabled ?? true,
|
|
allowedMaxRequests: stateValues.allowedMaxRequests,
|
|
allowedMaxCost: stateValues.allowedMaxCost,
|
|
autoCondenseContext: stateValues.autoCondenseContext ?? true,
|
|
autoCondenseContextPercent: stateValues.autoCondenseContextPercent ?? 100,
|
|
taskHistory: stateValues.taskHistory ?? [],
|
|
allowedCommands: stateValues.allowedCommands,
|
|
deniedCommands: stateValues.deniedCommands,
|
|
soundEnabled: stateValues.soundEnabled ?? false,
|
|
ttsEnabled: stateValues.ttsEnabled ?? false,
|
|
ttsSpeed: stateValues.ttsSpeed ?? 1.0,
|
|
diffEnabled: stateValues.diffEnabled ?? true,
|
|
enableCheckpoints: stateValues.enableCheckpoints ?? true,
|
|
checkpointTimeout: stateValues.checkpointTimeout ?? DEFAULT_CHECKPOINT_TIMEOUT_SECONDS,
|
|
soundVolume: stateValues.soundVolume,
|
|
browserViewportSize: stateValues.browserViewportSize ?? "900x600",
|
|
screenshotQuality: stateValues.screenshotQuality ?? 75,
|
|
remoteBrowserHost: stateValues.remoteBrowserHost,
|
|
remoteBrowserEnabled: stateValues.remoteBrowserEnabled ?? false,
|
|
cachedChromeHostUrl: stateValues.cachedChromeHostUrl as string | undefined,
|
|
fuzzyMatchThreshold: stateValues.fuzzyMatchThreshold ?? 1.0,
|
|
writeDelayMs: stateValues.writeDelayMs ?? DEFAULT_WRITE_DELAY_MS,
|
|
terminalOutputLineLimit: stateValues.terminalOutputLineLimit ?? 500,
|
|
terminalOutputCharacterLimit:
|
|
stateValues.terminalOutputCharacterLimit ?? DEFAULT_TERMINAL_OUTPUT_CHARACTER_LIMIT,
|
|
terminalShellIntegrationTimeout:
|
|
stateValues.terminalShellIntegrationTimeout ?? Terminal.defaultShellIntegrationTimeout,
|
|
terminalShellIntegrationDisabled: stateValues.terminalShellIntegrationDisabled ?? true,
|
|
terminalCommandDelay: stateValues.terminalCommandDelay ?? 0,
|
|
terminalPowershellCounter: stateValues.terminalPowershellCounter ?? false,
|
|
terminalZshClearEolMark: stateValues.terminalZshClearEolMark ?? true,
|
|
terminalZshOhMy: stateValues.terminalZshOhMy ?? false,
|
|
terminalZshP10k: stateValues.terminalZshP10k ?? false,
|
|
terminalZdotdir: stateValues.terminalZdotdir ?? false,
|
|
terminalCompressProgressBar: stateValues.terminalCompressProgressBar ?? true,
|
|
mode: stateValues.mode ?? defaultModeSlug,
|
|
language: stateValues.language ?? formatLanguage(vscode.env.language),
|
|
mcpEnabled: stateValues.mcpEnabled ?? true,
|
|
enableMcpServerCreation: stateValues.enableMcpServerCreation ?? true,
|
|
mcpServers: this.mcpHub?.getAllServers() ?? [],
|
|
currentApiConfigName: stateValues.currentApiConfigName ?? "default",
|
|
listApiConfigMeta: stateValues.listApiConfigMeta ?? [],
|
|
pinnedApiConfigs: stateValues.pinnedApiConfigs ?? {},
|
|
modeApiConfigs: stateValues.modeApiConfigs ?? ({} as Record<Mode, string>),
|
|
customModePrompts: stateValues.customModePrompts ?? {},
|
|
customSupportPrompts: stateValues.customSupportPrompts ?? {},
|
|
enhancementApiConfigId: stateValues.enhancementApiConfigId,
|
|
experiments: stateValues.experiments ?? experimentDefault,
|
|
autoApprovalEnabled: stateValues.autoApprovalEnabled ?? false,
|
|
customModes,
|
|
maxOpenTabsContext: stateValues.maxOpenTabsContext ?? 20,
|
|
maxWorkspaceFiles: stateValues.maxWorkspaceFiles ?? 200,
|
|
browserToolEnabled: stateValues.browserToolEnabled ?? true,
|
|
telemetrySetting: stateValues.telemetrySetting || "unset",
|
|
showRooIgnoredFiles: stateValues.showRooIgnoredFiles ?? false,
|
|
enableSubfolderRules: stateValues.enableSubfolderRules ?? false,
|
|
maxReadFileLine: stateValues.maxReadFileLine ?? -1,
|
|
maxImageFileSize: stateValues.maxImageFileSize ?? 5,
|
|
maxTotalImageSize: stateValues.maxTotalImageSize ?? 20,
|
|
maxConcurrentFileReads: stateValues.maxConcurrentFileReads ?? 5,
|
|
historyPreviewCollapsed: stateValues.historyPreviewCollapsed ?? false,
|
|
reasoningBlockCollapsed: stateValues.reasoningBlockCollapsed ?? true,
|
|
enterBehavior: stateValues.enterBehavior ?? "send",
|
|
cloudUserInfo,
|
|
cloudIsAuthenticated,
|
|
sharingEnabled,
|
|
publicSharingEnabled,
|
|
organizationAllowList,
|
|
organizationSettingsVersion,
|
|
condensingApiConfigId: stateValues.condensingApiConfigId,
|
|
customCondensingPrompt: stateValues.customCondensingPrompt,
|
|
codebaseIndexModels: stateValues.codebaseIndexModels ?? EMBEDDING_MODEL_PROFILES,
|
|
codebaseIndexConfig: {
|
|
codebaseIndexEnabled: stateValues.codebaseIndexConfig?.codebaseIndexEnabled ?? false,
|
|
codebaseIndexQdrantUrl:
|
|
stateValues.codebaseIndexConfig?.codebaseIndexQdrantUrl ?? "http://localhost:6333",
|
|
codebaseIndexEmbedderProvider:
|
|
stateValues.codebaseIndexConfig?.codebaseIndexEmbedderProvider ?? "openai",
|
|
codebaseIndexEmbedderBaseUrl: stateValues.codebaseIndexConfig?.codebaseIndexEmbedderBaseUrl ?? "",
|
|
codebaseIndexEmbedderModelId: stateValues.codebaseIndexConfig?.codebaseIndexEmbedderModelId ?? "",
|
|
codebaseIndexEmbedderModelDimension:
|
|
stateValues.codebaseIndexConfig?.codebaseIndexEmbedderModelDimension,
|
|
codebaseIndexOpenAiCompatibleBaseUrl:
|
|
stateValues.codebaseIndexConfig?.codebaseIndexOpenAiCompatibleBaseUrl,
|
|
codebaseIndexSearchMaxResults: stateValues.codebaseIndexConfig?.codebaseIndexSearchMaxResults,
|
|
codebaseIndexSearchMinScore: stateValues.codebaseIndexConfig?.codebaseIndexSearchMinScore,
|
|
codebaseIndexBedrockRegion: stateValues.codebaseIndexConfig?.codebaseIndexBedrockRegion,
|
|
codebaseIndexBedrockProfile: stateValues.codebaseIndexConfig?.codebaseIndexBedrockProfile,
|
|
codebaseIndexOpenRouterSpecificProvider:
|
|
stateValues.codebaseIndexConfig?.codebaseIndexOpenRouterSpecificProvider,
|
|
},
|
|
profileThresholds: stateValues.profileThresholds ?? {},
|
|
includeDiagnosticMessages: stateValues.includeDiagnosticMessages ?? true,
|
|
maxDiagnosticMessages: stateValues.maxDiagnosticMessages ?? 50,
|
|
includeTaskHistoryInEnhance: stateValues.includeTaskHistoryInEnhance ?? true,
|
|
includeCurrentTime: stateValues.includeCurrentTime ?? true,
|
|
includeCurrentCost: stateValues.includeCurrentCost ?? true,
|
|
maxGitStatusFiles: stateValues.maxGitStatusFiles ?? 0,
|
|
taskSyncEnabled,
|
|
remoteControlEnabled: (() => {
|
|
try {
|
|
const cloudSettings = CloudService.instance.getUserSettings()
|
|
return cloudSettings?.settings?.extensionBridgeEnabled ?? false
|
|
} catch (error) {
|
|
console.error(
|
|
`[getState] failed to get remote control setting from cloud: ${error instanceof Error ? error.message : String(error)}`,
|
|
)
|
|
return false
|
|
}
|
|
})(),
|
|
imageGenerationProvider: stateValues.imageGenerationProvider,
|
|
openRouterImageApiKey: stateValues.openRouterImageApiKey,
|
|
openRouterImageGenerationSelectedModel: stateValues.openRouterImageGenerationSelectedModel,
|
|
featureRoomoteControlEnabled: (() => {
|
|
try {
|
|
const userSettings = CloudService.instance.getUserSettings()
|
|
const hasOrganization = cloudUserInfo?.organizationId != null
|
|
return hasOrganization || (userSettings?.features?.roomoteControlEnabled ?? false)
|
|
} catch (error) {
|
|
console.error(
|
|
`[getState] failed to get featureRoomoteControlEnabled: ${error instanceof Error ? error.message : String(error)}`,
|
|
)
|
|
return false
|
|
}
|
|
})(),
|
|
}
|
|
}
|
|
|
|
async updateTaskHistory(item: HistoryItem): Promise<HistoryItem[]> {
|
|
const history = (this.getGlobalState("taskHistory") as HistoryItem[] | undefined) || []
|
|
const existingItemIndex = history.findIndex((h) => h.id === item.id)
|
|
|
|
if (existingItemIndex !== -1) {
|
|
// Preserve existing metadata (e.g., delegation fields) unless explicitly overwritten.
|
|
// This prevents loss of status/awaitingChildId/delegatedToId when tasks are reopened,
|
|
// terminated, or when routine message persistence occurs.
|
|
history[existingItemIndex] = {
|
|
...history[existingItemIndex],
|
|
...item,
|
|
}
|
|
} else {
|
|
history.push(item)
|
|
}
|
|
|
|
await this.updateGlobalState("taskHistory", history)
|
|
this.recentTasksCache = undefined
|
|
|
|
return history
|
|
}
|
|
|
|
// ContextProxy
|
|
|
|
// @deprecated - Use `ContextProxy#setValue` instead.
|
|
private async updateGlobalState<K extends keyof GlobalState>(key: K, value: GlobalState[K]) {
|
|
await this.contextProxy.setValue(key, value)
|
|
}
|
|
|
|
// @deprecated - Use `ContextProxy#getValue` instead.
|
|
private getGlobalState<K extends keyof GlobalState>(key: K) {
|
|
return this.contextProxy.getValue(key)
|
|
}
|
|
|
|
public async setValue<K extends keyof RooCodeSettings>(key: K, value: RooCodeSettings[K]) {
|
|
await this.contextProxy.setValue(key, value)
|
|
}
|
|
|
|
public getValue<K extends keyof RooCodeSettings>(key: K) {
|
|
return this.contextProxy.getValue(key)
|
|
}
|
|
|
|
public getValues() {
|
|
return this.contextProxy.getValues()
|
|
}
|
|
|
|
public async setValues(values: RooCodeSettings) {
|
|
await this.contextProxy.setValues(values)
|
|
}
|
|
|
|
// dev
|
|
|
|
async resetState() {
|
|
const answer = await vscode.window.showInformationMessage(
|
|
t("common:confirmation.reset_state"),
|
|
{ modal: true },
|
|
t("common:answers.yes"),
|
|
)
|
|
|
|
if (answer !== t("common:answers.yes")) {
|
|
return
|
|
}
|
|
|
|
// Log out from cloud if authenticated
|
|
if (CloudService.hasInstance()) {
|
|
try {
|
|
await CloudService.instance.logout()
|
|
} catch (error) {
|
|
this.log(
|
|
`Failed to logout from cloud during reset: ${error instanceof Error ? error.message : String(error)}`,
|
|
)
|
|
// Continue with reset even if logout fails
|
|
}
|
|
}
|
|
|
|
await this.contextProxy.resetAllState()
|
|
await this.providerSettingsManager.resetAllConfigs()
|
|
await this.customModesManager.resetCustomModes()
|
|
await this.removeClineFromStack()
|
|
await this.postStateToWebview()
|
|
await this.postMessageToWebview({ type: "action", action: "chatButtonClicked" })
|
|
}
|
|
|
|
// logging
|
|
|
|
public log(message: string) {
|
|
this.outputChannel.appendLine(message)
|
|
console.log(message)
|
|
}
|
|
|
|
// getters
|
|
|
|
public get workspaceTracker(): WorkspaceTracker | undefined {
|
|
return this._workspaceTracker
|
|
}
|
|
|
|
get viewLaunched() {
|
|
return this.isViewLaunched
|
|
}
|
|
|
|
get messages() {
|
|
return this.getCurrentTask()?.clineMessages || []
|
|
}
|
|
|
|
public getMcpHub(): McpHub | undefined {
|
|
return this.mcpHub
|
|
}
|
|
|
|
public getSkillsManager(): SkillsManager | undefined {
|
|
return this.skillsManager
|
|
}
|
|
|
|
/**
|
|
* Check if the current state is compliant with MDM policy
|
|
* @returns true if compliant or no MDM policy exists, false if MDM policy exists and user is non-compliant
|
|
*/
|
|
public checkMdmCompliance(): boolean {
|
|
if (!this.mdmService) {
|
|
return true // No MDM service, allow operation
|
|
}
|
|
|
|
const compliance = this.mdmService.isCompliant()
|
|
|
|
if (!compliance.compliant) {
|
|
return false
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
public async remoteControlEnabled(enabled: boolean) {
|
|
if (!enabled) {
|
|
await BridgeOrchestrator.disconnect()
|
|
return
|
|
}
|
|
|
|
const userInfo = CloudService.instance.getUserInfo()
|
|
|
|
if (!userInfo) {
|
|
this.log("[ClineProvider#remoteControlEnabled] Failed to get user info, disconnecting")
|
|
await BridgeOrchestrator.disconnect()
|
|
return
|
|
}
|
|
|
|
const config = await CloudService.instance.cloudAPI?.bridgeConfig().catch(() => undefined)
|
|
|
|
if (!config) {
|
|
this.log("[ClineProvider#remoteControlEnabled] Failed to get bridge config")
|
|
return
|
|
}
|
|
|
|
await BridgeOrchestrator.connectOrDisconnect(userInfo, enabled, {
|
|
...config,
|
|
provider: this,
|
|
sessionId: vscode.env.sessionId,
|
|
isCloudAgent: CloudService.instance.isCloudAgent,
|
|
})
|
|
|
|
const bridge = BridgeOrchestrator.getInstance()
|
|
|
|
if (bridge) {
|
|
const currentTask = this.getCurrentTask()
|
|
|
|
if (currentTask && !currentTask.enableBridge) {
|
|
try {
|
|
currentTask.enableBridge = true
|
|
await BridgeOrchestrator.subscribeToTask(currentTask)
|
|
} catch (error) {
|
|
const message = `[ClineProvider#remoteControlEnabled] BridgeOrchestrator.subscribeToTask() failed: ${error instanceof Error ? error.message : String(error)}`
|
|
this.log(message)
|
|
console.error(message)
|
|
}
|
|
}
|
|
} else {
|
|
for (const task of this.clineStack) {
|
|
if (task.enableBridge) {
|
|
try {
|
|
await BridgeOrchestrator.getInstance()?.unsubscribeFromTask(task.taskId)
|
|
} catch (error) {
|
|
const message = `[ClineProvider#remoteControlEnabled] BridgeOrchestrator#unsubscribeFromTask() failed: ${error instanceof Error ? error.message : String(error)}`
|
|
this.log(message)
|
|
console.error(message)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Gets the CodeIndexManager for the current active workspace
|
|
* @returns CodeIndexManager instance for the current workspace or the default one
|
|
*/
|
|
public getCurrentWorkspaceCodeIndexManager(): CodeIndexManager | undefined {
|
|
return CodeIndexManager.getInstance(this.context)
|
|
}
|
|
|
|
/**
|
|
* Updates the code index status subscription to listen to the current workspace manager
|
|
*/
|
|
private updateCodeIndexStatusSubscription(): void {
|
|
// Get the current workspace manager
|
|
const currentManager = this.getCurrentWorkspaceCodeIndexManager()
|
|
|
|
// If the manager hasn't changed, no need to update subscription
|
|
if (currentManager === this.codeIndexManager) {
|
|
return
|
|
}
|
|
|
|
// Dispose the old subscription if it exists
|
|
if (this.codeIndexStatusSubscription) {
|
|
this.codeIndexStatusSubscription.dispose()
|
|
this.codeIndexStatusSubscription = undefined
|
|
}
|
|
|
|
// Update the current workspace manager reference
|
|
this.codeIndexManager = currentManager
|
|
|
|
// Subscribe to the new manager's progress updates if it exists
|
|
if (currentManager) {
|
|
this.codeIndexStatusSubscription = currentManager.onProgressUpdate((update: IndexProgressUpdate) => {
|
|
// Only send updates if this manager is still the current one
|
|
if (currentManager === this.getCurrentWorkspaceCodeIndexManager()) {
|
|
// Get the full status from the manager to ensure we have all fields correctly formatted
|
|
const fullStatus = currentManager.getCurrentStatus()
|
|
this.postMessageToWebview({
|
|
type: "indexingStatusUpdate",
|
|
values: fullStatus,
|
|
})
|
|
}
|
|
})
|
|
|
|
if (this.view) {
|
|
this.webviewDisposables.push(this.codeIndexStatusSubscription)
|
|
}
|
|
|
|
// Send initial status for the current workspace
|
|
this.postMessageToWebview({
|
|
type: "indexingStatusUpdate",
|
|
values: currentManager.getCurrentStatus(),
|
|
})
|
|
}
|
|
}
|
|
|
|
/**
|
|
* TaskProviderLike, TelemetryPropertiesProvider
|
|
*/
|
|
|
|
public getCurrentTask(): Task | undefined {
|
|
if (this.clineStack.length === 0) {
|
|
return undefined
|
|
}
|
|
|
|
return this.clineStack[this.clineStack.length - 1]
|
|
}
|
|
|
|
public getRecentTasks(): string[] {
|
|
if (this.recentTasksCache) {
|
|
return this.recentTasksCache
|
|
}
|
|
|
|
const history = this.getGlobalState("taskHistory") ?? []
|
|
const workspaceTasks: HistoryItem[] = []
|
|
|
|
for (const item of history) {
|
|
if (!item.ts || !item.task || item.workspace !== this.cwd) {
|
|
continue
|
|
}
|
|
|
|
workspaceTasks.push(item)
|
|
}
|
|
|
|
if (workspaceTasks.length === 0) {
|
|
this.recentTasksCache = []
|
|
return this.recentTasksCache
|
|
}
|
|
|
|
workspaceTasks.sort((a, b) => b.ts - a.ts)
|
|
let recentTaskIds: string[] = []
|
|
|
|
if (workspaceTasks.length >= 100) {
|
|
// If we have at least 100 tasks, return tasks from the last 7 days.
|
|
const sevenDaysAgo = Date.now() - 7 * 24 * 60 * 60 * 1000
|
|
|
|
for (const item of workspaceTasks) {
|
|
// Stop when we hit tasks older than 7 days.
|
|
if (item.ts < sevenDaysAgo) {
|
|
break
|
|
}
|
|
|
|
recentTaskIds.push(item.id)
|
|
}
|
|
} else {
|
|
// Otherwise, return the most recent 100 tasks (or all if less than 100).
|
|
recentTaskIds = workspaceTasks.slice(0, Math.min(100, workspaceTasks.length)).map((item) => item.id)
|
|
}
|
|
|
|
this.recentTasksCache = recentTaskIds
|
|
return this.recentTasksCache
|
|
}
|
|
|
|
// When initializing a new task, (not from history but from a tool command
|
|
// new_task) there is no need to remove the previous task since the new
|
|
// task is a subtask of the previous one, and when it finishes it is removed
|
|
// from the stack and the caller is resumed in this way we can have a chain
|
|
// of tasks, each one being a sub task of the previous one until the main
|
|
// task is finished.
|
|
public async createTask(
|
|
text?: string,
|
|
images?: string[],
|
|
parentTask?: Task,
|
|
options: CreateTaskOptions = {},
|
|
configuration: RooCodeSettings = {},
|
|
): Promise<Task> {
|
|
if (configuration) {
|
|
await this.setValues(configuration)
|
|
|
|
if (configuration.allowedCommands) {
|
|
await vscode.workspace
|
|
.getConfiguration(Package.name)
|
|
.update("allowedCommands", configuration.allowedCommands, vscode.ConfigurationTarget.Global)
|
|
}
|
|
|
|
if (configuration.deniedCommands) {
|
|
await vscode.workspace
|
|
.getConfiguration(Package.name)
|
|
.update("deniedCommands", configuration.deniedCommands, vscode.ConfigurationTarget.Global)
|
|
}
|
|
|
|
if (configuration.commandExecutionTimeout !== undefined) {
|
|
await vscode.workspace
|
|
.getConfiguration(Package.name)
|
|
.update(
|
|
"commandExecutionTimeout",
|
|
configuration.commandExecutionTimeout,
|
|
vscode.ConfigurationTarget.Global,
|
|
)
|
|
}
|
|
|
|
if (configuration.currentApiConfigName) {
|
|
await this.setProviderProfile(configuration.currentApiConfigName)
|
|
}
|
|
}
|
|
|
|
const {
|
|
apiConfiguration,
|
|
organizationAllowList,
|
|
diffEnabled: enableDiff,
|
|
enableCheckpoints,
|
|
checkpointTimeout,
|
|
fuzzyMatchThreshold,
|
|
experiments,
|
|
cloudUserInfo,
|
|
remoteControlEnabled,
|
|
} = await this.getState()
|
|
|
|
// Single-open-task invariant: always enforce for user-initiated top-level tasks
|
|
if (!parentTask) {
|
|
try {
|
|
await this.removeClineFromStack()
|
|
} catch {
|
|
// Non-fatal
|
|
}
|
|
}
|
|
|
|
if (!ProfileValidator.isProfileAllowed(apiConfiguration, organizationAllowList)) {
|
|
throw new OrganizationAllowListViolationError(t("common:errors.violated_organization_allowlist"))
|
|
}
|
|
|
|
const task = new Task({
|
|
provider: this,
|
|
apiConfiguration,
|
|
enableDiff,
|
|
enableCheckpoints,
|
|
checkpointTimeout,
|
|
fuzzyMatchThreshold,
|
|
consecutiveMistakeLimit: apiConfiguration.consecutiveMistakeLimit,
|
|
task: text,
|
|
images,
|
|
experiments,
|
|
rootTask: this.clineStack.length > 0 ? this.clineStack[0] : undefined,
|
|
parentTask,
|
|
taskNumber: this.clineStack.length + 1,
|
|
onCreated: this.taskCreationCallback,
|
|
enableBridge: BridgeOrchestrator.isEnabled(cloudUserInfo, remoteControlEnabled),
|
|
initialTodos: options.initialTodos,
|
|
...options,
|
|
})
|
|
|
|
await this.addClineToStack(task)
|
|
|
|
this.log(
|
|
`[createTask] ${task.parentTask ? "child" : "parent"} task ${task.taskId}.${task.instanceId} instantiated`,
|
|
)
|
|
|
|
return task
|
|
}
|
|
|
|
public async cancelTask(): Promise<void> {
|
|
const task = this.getCurrentTask()
|
|
|
|
if (!task) {
|
|
return
|
|
}
|
|
|
|
console.log(`[cancelTask] cancelling task ${task.taskId}.${task.instanceId}`)
|
|
|
|
const { historyItem, uiMessagesFilePath } = await this.getTaskWithId(task.taskId)
|
|
|
|
// Preserve parent and root task information for history item.
|
|
const rootTask = task.rootTask
|
|
const parentTask = task.parentTask
|
|
|
|
// Mark this as a user-initiated cancellation so provider-only rehydration can occur
|
|
task.abortReason = "user_cancelled"
|
|
|
|
// Capture the current instance to detect if rehydrate already occurred elsewhere
|
|
const originalInstanceId = task.instanceId
|
|
|
|
// Immediately cancel the underlying HTTP request if one is in progress
|
|
// This ensures the stream fails quickly rather than waiting for network timeout
|
|
task.cancelCurrentRequest()
|
|
|
|
// Begin abort (non-blocking)
|
|
task.abortTask()
|
|
|
|
// Immediately mark the original instance as abandoned to prevent any residual activity
|
|
task.abandoned = true
|
|
|
|
await pWaitFor(
|
|
() =>
|
|
this.getCurrentTask()! === undefined ||
|
|
this.getCurrentTask()!.isStreaming === false ||
|
|
this.getCurrentTask()!.didFinishAbortingStream ||
|
|
// If only the first chunk is processed, then there's no
|
|
// need to wait for graceful abort (closes edits, browser,
|
|
// etc).
|
|
this.getCurrentTask()!.isWaitingForFirstChunk,
|
|
{
|
|
timeout: 3_000,
|
|
},
|
|
).catch(() => {
|
|
console.error("Failed to abort task")
|
|
})
|
|
|
|
// Defensive safeguard: if current instance already changed, skip rehydrate
|
|
const current = this.getCurrentTask()
|
|
if (current && current.instanceId !== originalInstanceId) {
|
|
this.log(
|
|
`[cancelTask] Skipping rehydrate: current instance ${current.instanceId} != original ${originalInstanceId}`,
|
|
)
|
|
return
|
|
}
|
|
|
|
// Final race check before rehydrate to avoid duplicate rehydration
|
|
{
|
|
const currentAfterCheck = this.getCurrentTask()
|
|
if (currentAfterCheck && currentAfterCheck.instanceId !== originalInstanceId) {
|
|
this.log(
|
|
`[cancelTask] Skipping rehydrate after final check: current instance ${currentAfterCheck.instanceId} != original ${originalInstanceId}`,
|
|
)
|
|
return
|
|
}
|
|
}
|
|
|
|
// Clears task again, so we need to abortTask manually above.
|
|
await this.createTaskWithHistoryItem({ ...historyItem, rootTask, parentTask })
|
|
}
|
|
|
|
// Clear the current task without treating it as a subtask.
|
|
// This is used when the user cancels a task that is not a subtask.
|
|
public async clearTask(): Promise<void> {
|
|
if (this.clineStack.length > 0) {
|
|
const task = this.clineStack[this.clineStack.length - 1]
|
|
console.log(`[clearTask] clearing task ${task.taskId}.${task.instanceId}`)
|
|
await this.removeClineFromStack()
|
|
}
|
|
}
|
|
|
|
public resumeTask(taskId: string): void {
|
|
// Use the existing showTaskWithId method which handles both current and
|
|
// historical tasks.
|
|
this.showTaskWithId(taskId).catch((error) => {
|
|
this.log(`Failed to resume task ${taskId}: ${error.message}`)
|
|
})
|
|
}
|
|
|
|
// Modes
|
|
|
|
public async getModes(): Promise<{ slug: string; name: string }[]> {
|
|
try {
|
|
const customModes = await this.customModesManager.getCustomModes()
|
|
return [...DEFAULT_MODES, ...customModes].map(({ slug, name }) => ({ slug, name }))
|
|
} catch (error) {
|
|
return DEFAULT_MODES.map(({ slug, name }) => ({ slug, name }))
|
|
}
|
|
}
|
|
|
|
public async getMode(): Promise<string> {
|
|
const { mode } = await this.getState()
|
|
return mode
|
|
}
|
|
|
|
public async setMode(mode: string): Promise<void> {
|
|
await this.setValues({ mode })
|
|
}
|
|
|
|
// Provider Profiles
|
|
|
|
public async getProviderProfiles(): Promise<{ name: string; provider?: string }[]> {
|
|
const { listApiConfigMeta = [] } = await this.getState()
|
|
return listApiConfigMeta.map((profile) => ({ name: profile.name, provider: profile.apiProvider }))
|
|
}
|
|
|
|
public async getProviderProfile(): Promise<string> {
|
|
const { currentApiConfigName = "default" } = await this.getState()
|
|
return currentApiConfigName
|
|
}
|
|
|
|
public async setProviderProfile(name: string): Promise<void> {
|
|
await this.activateProviderProfile({ name })
|
|
}
|
|
|
|
// Telemetry
|
|
|
|
private _appProperties?: StaticAppProperties
|
|
private _gitProperties?: GitProperties
|
|
|
|
private getAppProperties(): StaticAppProperties {
|
|
if (!this._appProperties) {
|
|
const packageJSON = this.context.extension?.packageJSON
|
|
|
|
this._appProperties = {
|
|
appName: packageJSON?.name ?? Package.name,
|
|
appVersion: packageJSON?.version ?? Package.version,
|
|
vscodeVersion: vscode.version,
|
|
platform: process.platform,
|
|
editorName: vscode.env.appName,
|
|
}
|
|
}
|
|
|
|
return this._appProperties
|
|
}
|
|
|
|
public get appProperties(): StaticAppProperties {
|
|
return this._appProperties ?? this.getAppProperties()
|
|
}
|
|
|
|
private getCloudProperties(): CloudAppProperties {
|
|
let cloudIsAuthenticated: boolean | undefined
|
|
|
|
try {
|
|
if (CloudService.hasInstance()) {
|
|
cloudIsAuthenticated = CloudService.instance.isAuthenticated()
|
|
}
|
|
} catch (error) {
|
|
// Silently handle errors to avoid breaking telemetry collection.
|
|
this.log(`[getTelemetryProperties] Failed to get cloud auth state: ${error}`)
|
|
}
|
|
|
|
return {
|
|
cloudIsAuthenticated,
|
|
}
|
|
}
|
|
|
|
private async getTaskProperties(): Promise<DynamicAppProperties & TaskProperties> {
|
|
const { language = "en", mode, apiConfiguration } = await this.getState()
|
|
|
|
const task = this.getCurrentTask()
|
|
const todoList = task?.todoList
|
|
let todos: { total: number; completed: number; inProgress: number; pending: number } | undefined
|
|
|
|
if (todoList && todoList.length > 0) {
|
|
todos = {
|
|
total: todoList.length,
|
|
completed: todoList.filter((todo) => todo.status === "completed").length,
|
|
inProgress: todoList.filter((todo) => todo.status === "in_progress").length,
|
|
pending: todoList.filter((todo) => todo.status === "pending").length,
|
|
}
|
|
}
|
|
|
|
return {
|
|
language,
|
|
mode,
|
|
taskId: task?.taskId,
|
|
parentTaskId: task?.parentTaskId,
|
|
apiProvider: apiConfiguration?.apiProvider,
|
|
modelId: task?.api?.getModel().id,
|
|
diffStrategy: task?.diffStrategy?.getName(),
|
|
isSubtask: task ? !!task.parentTaskId : undefined,
|
|
...(todos && { todos }),
|
|
}
|
|
}
|
|
|
|
private async getGitProperties(): Promise<GitProperties> {
|
|
if (!this._gitProperties) {
|
|
this._gitProperties = await getWorkspaceGitInfo()
|
|
}
|
|
|
|
return this._gitProperties
|
|
}
|
|
|
|
public get gitProperties(): GitProperties | undefined {
|
|
return this._gitProperties
|
|
}
|
|
|
|
public async getTelemetryProperties(): Promise<TelemetryProperties> {
|
|
return {
|
|
...this.getAppProperties(),
|
|
...this.getCloudProperties(),
|
|
...(await this.getTaskProperties()),
|
|
...(await this.getGitProperties()),
|
|
}
|
|
}
|
|
|
|
public get cwd() {
|
|
return this.currentWorkspacePath || getWorkspacePath()
|
|
}
|
|
|
|
/**
|
|
* Delegate parent task and open child task.
|
|
*
|
|
* - Enforce single-open invariant
|
|
* - Persist parent delegation metadata
|
|
* - Emit TaskDelegated (task-level; API forwards to provider/bridge)
|
|
* - Create child as sole active and switch mode to child's mode
|
|
*/
|
|
public async delegateParentAndOpenChild(params: {
|
|
parentTaskId: string
|
|
message: string
|
|
initialTodos: TodoItem[]
|
|
mode: string
|
|
}): Promise<Task> {
|
|
const { parentTaskId, message, initialTodos, mode } = params
|
|
|
|
// Metadata-driven delegation is always enabled
|
|
|
|
// 1) Get parent (must be current task)
|
|
const parent = this.getCurrentTask()
|
|
if (!parent) {
|
|
throw new Error("[delegateParentAndOpenChild] No current task")
|
|
}
|
|
if (parent.taskId !== parentTaskId) {
|
|
throw new Error(
|
|
`[delegateParentAndOpenChild] Parent mismatch: expected ${parentTaskId}, current ${parent.taskId}`,
|
|
)
|
|
}
|
|
// 2) Flush pending tool results to API history BEFORE disposing the parent.
|
|
// This is critical for native tool protocol: when tools are called before new_task,
|
|
// their tool_result blocks are in userMessageContent but not yet saved to API history.
|
|
// If we don't flush them, the parent's API conversation will be incomplete and
|
|
// cause 400 errors when resumed (missing tool_result for tool_use blocks).
|
|
//
|
|
// NOTE: We do NOT pass the assistant message here because the assistant message
|
|
// is already added to apiConversationHistory by the normal flow in
|
|
// recursivelyMakeClineRequests BEFORE tools start executing. We only need to
|
|
// flush the pending user message with tool_results.
|
|
try {
|
|
await parent.flushPendingToolResultsToHistory()
|
|
} catch (error) {
|
|
this.log(
|
|
`[delegateParentAndOpenChild] Error flushing pending tool results (non-fatal): ${
|
|
error instanceof Error ? error.message : String(error)
|
|
}`,
|
|
)
|
|
}
|
|
|
|
// 3) Enforce single-open invariant by closing/disposing the parent first
|
|
// This ensures we never have >1 tasks open at any time during delegation.
|
|
// Await abort completion to ensure clean disposal and prevent unhandled rejections.
|
|
try {
|
|
await this.removeClineFromStack()
|
|
} catch (error) {
|
|
this.log(
|
|
`[delegateParentAndOpenChild] Error during parent disposal (non-fatal): ${
|
|
error instanceof Error ? error.message : String(error)
|
|
}`,
|
|
)
|
|
// Non-fatal: proceed with child creation even if parent cleanup had issues
|
|
}
|
|
|
|
// 3) Switch provider mode to child's requested mode BEFORE creating the child task
|
|
// This ensures the child's system prompt and configuration are based on the correct mode.
|
|
// The mode switch must happen before createTask() because the Task constructor
|
|
// initializes its mode from provider.getState() during initializeTaskMode().
|
|
try {
|
|
await this.handleModeSwitch(mode as any)
|
|
} catch (e) {
|
|
this.log(
|
|
`[delegateParentAndOpenChild] handleModeSwitch failed for mode '${mode}': ${
|
|
(e as Error)?.message ?? String(e)
|
|
}`,
|
|
)
|
|
}
|
|
|
|
// 4) Create child as sole active (parent reference preserved for lineage)
|
|
// Pass initialStatus: "active" to ensure the child task's historyItem is created
|
|
// with status from the start, avoiding race conditions where the task might
|
|
// call attempt_completion before status is persisted separately.
|
|
const child = await this.createTask(message, undefined, parent as any, {
|
|
initialTodos,
|
|
initialStatus: "active",
|
|
})
|
|
|
|
// 5) Persist parent delegation metadata
|
|
try {
|
|
const { historyItem } = await this.getTaskWithId(parentTaskId)
|
|
const childIds = Array.from(new Set([...(historyItem.childIds ?? []), child.taskId]))
|
|
const updatedHistory: typeof historyItem = {
|
|
...historyItem,
|
|
status: "delegated",
|
|
delegatedToId: child.taskId,
|
|
awaitingChildId: child.taskId,
|
|
childIds,
|
|
}
|
|
await this.updateTaskHistory(updatedHistory)
|
|
} catch (err) {
|
|
this.log(
|
|
`[delegateParentAndOpenChild] Failed to persist parent metadata for ${parentTaskId} -> ${child.taskId}: ${
|
|
(err as Error)?.message ?? String(err)
|
|
}`,
|
|
)
|
|
}
|
|
|
|
// 6) Emit TaskDelegated (provider-level)
|
|
try {
|
|
this.emit(RooCodeEventName.TaskDelegated, parentTaskId, child.taskId)
|
|
} catch {
|
|
// non-fatal
|
|
}
|
|
|
|
return child
|
|
}
|
|
|
|
/**
|
|
* Reopen parent task from delegation with write-back and events.
|
|
*/
|
|
public async reopenParentFromDelegation(params: {
|
|
parentTaskId: string
|
|
childTaskId: string
|
|
completionResultSummary: string
|
|
}): Promise<void> {
|
|
const { parentTaskId, childTaskId, completionResultSummary } = params
|
|
const globalStoragePath = this.contextProxy.globalStorageUri.fsPath
|
|
|
|
// 1) Load parent from history and current persisted messages
|
|
const { historyItem } = await this.getTaskWithId(parentTaskId)
|
|
|
|
let parentClineMessages: ClineMessage[] = []
|
|
try {
|
|
parentClineMessages = await readTaskMessages({
|
|
taskId: parentTaskId,
|
|
globalStoragePath,
|
|
})
|
|
} catch {
|
|
parentClineMessages = []
|
|
}
|
|
|
|
let parentApiMessages: any[] = []
|
|
try {
|
|
parentApiMessages = (await readApiMessages({
|
|
taskId: parentTaskId,
|
|
globalStoragePath,
|
|
})) as any[]
|
|
} catch {
|
|
parentApiMessages = []
|
|
}
|
|
|
|
// 2) Inject synthetic records: UI subtask_result and update API tool_result
|
|
const ts = Date.now()
|
|
|
|
// Defensive: ensure arrays
|
|
if (!Array.isArray(parentClineMessages)) parentClineMessages = []
|
|
if (!Array.isArray(parentApiMessages)) parentApiMessages = []
|
|
|
|
const subtaskUiMessage: ClineMessage = {
|
|
type: "say",
|
|
say: "subtask_result",
|
|
text: completionResultSummary,
|
|
ts,
|
|
}
|
|
parentClineMessages.push(subtaskUiMessage)
|
|
await saveTaskMessages({ messages: parentClineMessages, taskId: parentTaskId, globalStoragePath })
|
|
|
|
// Find the tool_use_id from the last assistant message's new_task tool_use
|
|
let toolUseId: string | undefined
|
|
for (let i = parentApiMessages.length - 1; i >= 0; i--) {
|
|
const msg = parentApiMessages[i]
|
|
if (msg.role === "assistant" && Array.isArray(msg.content)) {
|
|
for (const block of msg.content) {
|
|
if (block.type === "tool_use" && block.name === "new_task") {
|
|
toolUseId = block.id
|
|
break
|
|
}
|
|
}
|
|
if (toolUseId) break
|
|
}
|
|
}
|
|
|
|
// The API expects: user → assistant (with tool_use) → user (with tool_result)
|
|
// We need to add a NEW user message with the tool_result AFTER the assistant's tool_use
|
|
// NOT add it to an existing user message
|
|
if (toolUseId) {
|
|
// Check if the last message is already a user message with a tool_result for this tool_use_id
|
|
// (in case this is a retry or the history was already updated)
|
|
const lastMsg = parentApiMessages[parentApiMessages.length - 1]
|
|
let alreadyHasToolResult = false
|
|
if (lastMsg?.role === "user" && Array.isArray(lastMsg.content)) {
|
|
for (const block of lastMsg.content) {
|
|
if (block.type === "tool_result" && block.tool_use_id === toolUseId) {
|
|
// Update the existing tool_result content
|
|
block.content = `Subtask ${childTaskId} completed.\n\nResult:\n${completionResultSummary}`
|
|
alreadyHasToolResult = true
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
// If no existing tool_result found, create a NEW user message with the tool_result
|
|
if (!alreadyHasToolResult) {
|
|
parentApiMessages.push({
|
|
role: "user",
|
|
content: [
|
|
{
|
|
type: "tool_result" as const,
|
|
tool_use_id: toolUseId,
|
|
content: `Subtask ${childTaskId} completed.\n\nResult:\n${completionResultSummary}`,
|
|
},
|
|
],
|
|
ts,
|
|
})
|
|
}
|
|
} else {
|
|
// Fallback for XML protocol or when toolUseId couldn't be found:
|
|
// Add a text block (not ideal but maintains backward compatibility)
|
|
parentApiMessages.push({
|
|
role: "user",
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: `Subtask ${childTaskId} completed.\n\nResult:\n${completionResultSummary}`,
|
|
},
|
|
],
|
|
ts,
|
|
})
|
|
}
|
|
|
|
// Validate the newly injected tool_result against the preceding assistant message.
|
|
// This ensures the tool_result's tool_use_id matches a tool_use in the immediately
|
|
// preceding assistant message (Anthropic API requirement).
|
|
const lastMessage = parentApiMessages[parentApiMessages.length - 1]
|
|
if (lastMessage?.role === "user") {
|
|
const validatedMessage = validateAndFixToolResultIds(lastMessage, parentApiMessages.slice(0, -1))
|
|
parentApiMessages[parentApiMessages.length - 1] = validatedMessage
|
|
}
|
|
|
|
await saveApiMessages({ messages: parentApiMessages as any, taskId: parentTaskId, globalStoragePath })
|
|
|
|
// 3) Update child metadata to "completed" status
|
|
try {
|
|
const { historyItem: childHistory } = await this.getTaskWithId(childTaskId)
|
|
await this.updateTaskHistory({
|
|
...childHistory,
|
|
status: "completed",
|
|
})
|
|
} catch (err) {
|
|
this.log(
|
|
`[reopenParentFromDelegation] Failed to persist child completed status for ${childTaskId}: ${
|
|
(err as Error)?.message ?? String(err)
|
|
}`,
|
|
)
|
|
}
|
|
|
|
// 4) Update parent metadata and persist BEFORE emitting completion event
|
|
const childIds = Array.from(new Set([...(historyItem.childIds ?? []), childTaskId]))
|
|
const updatedHistory: typeof historyItem = {
|
|
...historyItem,
|
|
status: "active",
|
|
completedByChildId: childTaskId,
|
|
completionResultSummary,
|
|
awaitingChildId: undefined,
|
|
childIds,
|
|
}
|
|
await this.updateTaskHistory(updatedHistory)
|
|
|
|
// 5) Emit TaskDelegationCompleted (provider-level)
|
|
try {
|
|
this.emit(RooCodeEventName.TaskDelegationCompleted, parentTaskId, childTaskId, completionResultSummary)
|
|
} catch {
|
|
// non-fatal
|
|
}
|
|
|
|
// 6) Close child instance if still open (single-open-task invariant)
|
|
const current = this.getCurrentTask()
|
|
if (current?.taskId === childTaskId) {
|
|
await this.removeClineFromStack()
|
|
}
|
|
|
|
// 7) Reopen the parent from history as the sole active task (restores saved mode)
|
|
// IMPORTANT: startTask=false to suppress resume-from-history ask scheduling
|
|
const parentInstance = await this.createTaskWithHistoryItem(updatedHistory, { startTask: false })
|
|
|
|
// 8) Inject restored histories into the in-memory instance before resuming
|
|
if (parentInstance) {
|
|
try {
|
|
await parentInstance.overwriteClineMessages(parentClineMessages)
|
|
} catch {
|
|
// non-fatal
|
|
}
|
|
try {
|
|
await parentInstance.overwriteApiConversationHistory(parentApiMessages as any)
|
|
} catch {
|
|
// non-fatal
|
|
}
|
|
|
|
// Auto-resume parent without ask("resume_task")
|
|
await parentInstance.resumeAfterDelegation()
|
|
}
|
|
|
|
// 9) Emit TaskDelegationResumed (provider-level)
|
|
try {
|
|
this.emit(RooCodeEventName.TaskDelegationResumed, parentTaskId, childTaskId)
|
|
} catch {
|
|
// non-fatal
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Convert a file path to a webview-accessible URI
|
|
* This method safely converts file paths to URIs that can be loaded in the webview
|
|
*
|
|
* @param filePath - The absolute file path to convert
|
|
* @returns The webview URI string, or the original file URI if conversion fails
|
|
* @throws {Error} When webview is not available
|
|
* @throws {TypeError} When file path is invalid
|
|
*/
|
|
public convertToWebviewUri(filePath: string): string {
|
|
try {
|
|
const fileUri = vscode.Uri.file(filePath)
|
|
|
|
// Check if we have a webview available
|
|
if (this.view?.webview) {
|
|
const webviewUri = this.view.webview.asWebviewUri(fileUri)
|
|
return webviewUri.toString()
|
|
}
|
|
|
|
// Specific error for no webview available
|
|
const error = new Error("No webview available for URI conversion")
|
|
console.error(error.message)
|
|
// Fallback to file URI if no webview available
|
|
return fileUri.toString()
|
|
} catch (error) {
|
|
// More specific error handling
|
|
if (error instanceof TypeError) {
|
|
console.error("Invalid file path provided for URI conversion:", error)
|
|
} else {
|
|
console.error("Failed to convert to webview URI:", error)
|
|
}
|
|
// Return file URI as fallback
|
|
return vscode.Uri.file(filePath).toString()
|
|
}
|
|
}
|
|
}
|