import os from "os" import * as path from "path" import fs from "fs/promises" import EventEmitter from "events" import crypto from "crypto" import { Anthropic } from "@anthropic-ai/sdk" import delay from "delay" import axios from "axios" import debounce from "lodash.debounce" import pWaitFor from "p-wait-for" import * as vscode from "vscode" import { WEBVIEW_RECOVERY_LIMITS, preservesRecoveryIncident, resolutionFailureAction, canCommitRecovery, nextRecoveryAttempt, releaseRecoveryOwner, pendingSidebarAfterCommandFailure, recordRecoveryFailure, recoveryHealth, recoveryWarningAction, isRecoveryGenerationCurrent, beginExplicitRecovery, } from "./webviewRecoveryPolicy" 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 PendingTaskAction, type CloudUserInfo, type CloudOrganizationMembership, type CreateTaskOptions, type TokenUsage, type ToolUsage, type ExtensionMessage, type ExtensionState, type WebviewThemeFixture, type WebviewDiagnosticsSnapshot, type MarketplaceInstalledMetadata, RooCodeEventName, requestyDefaultModelId, openRouterDefaultModelId, DEFAULT_WRITE_DELAY_MS, DEFAULT_DIFF_FUZZY_THRESHOLD, DEFAULT_DESTRUCTIVE_COMMAND_GUARD_ENABLED, DEFAULT_ALWAYS_DENY_UNAPPROVED_COMMANDS, DEFAULT_COLLAPSE_COMMAND_OUTPUT_BY_DEFAULT, DEFAULT_AUTO_CLOSE_ZOO_OPENED_FILES, DEFAULT_AUTO_CLOSE_ZOO_OPENED_FILES_AFTER_USER_EDITED, DEFAULT_AUTO_CLOSE_ZOO_OPENED_NEW_FILES, ORGANIZATION_ALLOW_ALL, DEFAULT_MODES, DEFAULT_CHECKPOINT_TIMEOUT_SECONDS, getModelId, isRetiredProvider, modelIdKeys, providerIdentifiers, } from "@roo-code/types" import { RateLimitClock, createRateLimitClock } from "../task/RateLimitClock" import { TaskRegistry } from "../task/TaskRegistry" import { TaskScheduler } from "../task/TaskScheduler" import { getEffectiveTaskApiConfiguration, selectHandoffExecutionContext, type TaskExecutionContext, } from "../task/providerHandoff" import { aggregateTaskCostsRecursive, type AggregatedCosts } from "./aggregateTaskCosts" import { TelemetryService } from "@roo-code/telemetry" import { CloudService, 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, getTaskFileName } from "../../integrations/misc/export-markdown" import { resolveDefaultSaveUri, saveLastExportPath } from "../../utils/export" 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 { CodeIndexManagerRegistry } from "../../services/code-index/code-index-manager-registry" import type { CodeIndexWorkspaceScope } from "../../services/code-index/code-index-workspace-scope" import { MdmService } from "../../services/mdm/MdmService" import { SkillsManager } from "../../services/skills/SkillsManager" import { DiagnosticsRecorder, DiagnosticsRequestBroker, type DiagnosticsProviderSourceSnapshot, } from "../../services/diagnostics" 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 { getPanel, 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 { withoutInvalidDisplaySettings } from "../config/displaySettings" import { ProviderSettingsManager } from "../config/ProviderSettingsManager" import { CustomModesManager } from "../config/CustomModesManager" import { PendingActionSettlementError, Task } from "../task/Task" import { abortProviderQuotaRequests, webviewMessageHandler } from "./webviewMessageHandler" import type { WebviewFocusTracker } from "./WebviewFocusTracker" import type { ClineMessage, TodoItem } from "@roo-code/types" import { type ApiMessage, readApiMessages, saveApiMessages, saveTaskMessages, TaskHistoryStore, abandonDelegatedChild, completeDelegatedChild, delegateTaskToChild, interruptDelegatedChild, LifecycleTransitionError, } from "../task-persistence" import { readTaskMessages } from "../task-persistence/taskMessages" import { getNonce } from "./getNonce" import { getUri } from "./getUri" import { omitOriginalContentFromExtensionMessage } from "./stripOriginalContent" import { REQUESTY_BASE_URL } from "../../shared/utils/requesty" import { validateAndFixToolResultIds } from "../task/validateToolResultIds" import { PendingEditOperationStore, type PendingEditOperationInput } from "./PendingEditOperationStore" /** * 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 */ type DelegatedChildContext = TaskExecutionContext function runDelegationTransition( locks: Map>, parentTaskId: string, fn: () => Promise, ): Promise { const previous = locks.get(parentTaskId) ?? Promise.resolve() // Fail-forward: run fn even if the previous transition rejected. A failed // cancelTask must not permanently block a subsequent reopenParentFromDelegation. // The cancelledDelegationChildIds guard inside each fn is the safety net. const current = previous.then(fn, fn) const tail = current.then( () => {}, () => {}, ) locks.set(parentTaskId, tail) void tail.finally(() => { if (locks.get(parentTaskId) === tail) { locks.delete(parentTaskId) } }) return current } function scheduleTask( scheduler: TaskScheduler, task: Task, source: string, run: () => Promise = () => task.run(), onError?: (error: unknown) => void | Promise, ): void { void scheduler.schedule(task, run).catch(async (error) => { console.error(`[${source}] taskScheduler.schedule failed:`, error) try { await onError?.(error) } catch (cleanupError) { console.error(`[${source}] task failure cleanup failed:`, cleanupError) } }) } // Keys a model-selector patch may touch: model IDs plus settings reset on model change. const PROFILE_MODEL_ID_PATCH_KEYS: ReadonlySet = new Set(modelIdKeys) const BEDROCK_CUSTOM_ARN_MODEL_ID = "custom-arn" // These keys can only be cleared. Setting `awsCustomArn` would let a patch invoke a Bedrock // resource that the organization allow-list never checked, because the allow-list sees `apiModelId`. const PROFILE_MODEL_RESET_ONLY_KEYS: ReadonlySet = new Set([ "reasoningEffort", "modelMaxTokens", "modelMaxThinkingTokens", "awsCustomArn", "awsCustomArnBaseModelId", "awsModelContextWindow", ]) type GetStateOptions = { includeTaskHistory?: boolean } function removeTaskEventListeners(listeners: WeakMap void>>, task: Task): void { const cleanupFunctions = listeners.get(task) if (cleanupFunctions) { cleanupFunctions.forEach((cleanup) => cleanup()) listeners.delete(task) } } export class ClineProvider extends EventEmitter 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 = new Set() private webviewDisposables: vscode.Disposable[] = [] private pendingThemeFixtureProbes = new Map< string, { resolve: (fixture: WebviewThemeFixture) => void reject: (error: Error) => void timeout: ReturnType } >() private nextThemeFixtureProbeId = 0 private view?: vscode.WebviewView | vscode.WebviewPanel private taskRegistry = new TaskRegistry() private taskScheduler = new TaskScheduler() private static readonly delegationTransitionLocks = new Map>() private cancelledDelegationChildIds = new Set() 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 void>> = new WeakMap() private currentWorkspacePath: string | undefined private _disposed = false private lastWebviewHeartbeatAt = 0 // Bumped on every heartbeat or grace reset, including events in the same // millisecond; pending recovery must yield to either sign of renewed grace. private webviewHeartbeatRevision = 0 private webviewRecoveryAttempts = 0 private webviewRecoveryConfirmation: ReturnType | undefined private webviewHostRecoveryInProgress = false // VS Code may resolve the focused sidebar after our bounded wait expires. // Keep that repair distinct from a user-opened view until it arrives. private pendingSidebarRecovery = false private webviewRecoveryFailureReported = false private webviewRecoveryStopLogged = false private lastReceivedWebviewHeartbeatAt: number | undefined private webviewHeartbeatCount = 0 private webviewHealthySince: number | undefined private static readonly WEBVIEW_RECOVERY_CONFIRM_MS = 10_000 private static readonly WEBVIEW_RECOVERY_ATTEMPT_LIMIT = WEBVIEW_RECOVERY_LIMITS.attempts private webviewWatchdogInterval: ReturnType | null = null // Bumped to invalidate an in-flight recovery reload when the provider or // the watched view is disposed; the reload must not reassign webview.html // afterwards. private webviewRecoveryEpoch = 0 private webviewRecoveryRequestGeneration = 0 // Epoch that owns an in-flight recovery reload, if any. Only a recovery // from the same epoch is blocked, so disposing/replacing the view (which // bumps webviewRecoveryEpoch) never makes the replacement wait on the // stale recovery's completion. private webviewRecoveryInFlightEpoch: number | undefined = undefined // Wall-clock reading of the previous watchdog tick; a tick arriving far // later than scheduled means the host slept, so the grace window restarts // instead of counting the suspended interval against a live renderer. private lastWebviewWatchdogTickAt = 0 private static readonly WEBVIEW_WATCHDOG_TICK_MS = 60_000 private static readonly WEBVIEW_HEARTBEAT_STALE_MS = WEBVIEW_RECOVERY_LIMITS.staleMs private readonly _postStateToWebviewThrottled = debounce( async () => { try { await this.postStateToWebviewWithoutTaskHistory() } catch (error) { this.log( `[ClineProvider#postStateToWebviewThrottled] Failed to post state: ${ error instanceof Error ? error.message : String(error) }`, ) } }, 500, { leading: true, trailing: true, maxWait: 1000 }, ) private readonly diagnosticsRecorder = new DiagnosticsRecorder(200) private readonly diagnosticsRequestBroker = new DiagnosticsRequestBroker() private readonly rateLimitClock: RateLimitClock = createRateLimitClock() private recentTasksCache?: string[] public readonly taskHistoryStore: TaskHistoryStore private taskHistoryStoreInitialized = false public static readonly PENDING_OPERATION_TIMEOUT_MS = 30000 // 30 seconds private providerProfileMutationQueue = Promise.resolve() private historyTaskCreationQueue = Promise.resolve() private runDelegationTransition(parentTaskId: string, fn: () => Promise): Promise { return runDelegationTransition(ClineProvider.delegationTransitionLocks, parentTaskId, fn) } /** * With `runToCompletion`, a timeout only rejects the caller: the mutation is not aborted and the * queue advances once it settles. Use it for multi-write mutations that must not stop halfway * (storage saved but not activated) or overlap with the next mutation. */ private enqueueProviderProfileMutation( fn: (signal: AbortSignal) => Promise, options: { runToCompletion?: boolean } = {}, ): Promise { const controller = new AbortController() // Run fn after either outcome so a rejected mutation never poisons the queue. const run = this.providerProfileMutationQueue.then( () => fn(controller.signal), () => fn(controller.signal), ) const callerResult = this.withProviderProfileMutationTimeout(run, () => { if (options.runToCompletion) { this.log("Provider profile mutation timed out; waiting for it to finish before the next mutation") return } controller.abort() this.log("Provider profile mutation timed out; aborting in-flight mutation") }) void run.then( () => { if (controller.signal.aborted) { this.log("Provider profile mutation completed after cancellation") } }, (error) => { if (controller.signal.aborted) { this.log( `Provider profile mutation errored after cancellation: ${ error instanceof Error ? error.message : String(error) }`, ) } }, ) // Advance from the timeout-bounded result. Each fn checks its AbortSignal before // writing state, so advancing the queue on timeout cannot produce stale overwrites. this.providerProfileMutationQueue = (options.runToCompletion ? run : callerResult).then( () => undefined, () => undefined, ) return callerResult } private withProviderProfileMutationTimeout(operation: Promise, onTimeout: () => void): Promise { let timeoutId: ReturnType | undefined const timeout = new Promise((_, reject) => { timeoutId = setTimeout(() => { onTimeout() reject(new Error("Provider profile mutation timed out")) }, ClineProvider.PENDING_OPERATION_TIMEOUT_MS) }) return Promise.race([operation, timeout]).finally(() => { if (timeoutId) { clearTimeout(timeoutId) } }) } private readonly pendingEditOperations: PendingEditOperationStore private cloudOrganizationsCache: CloudOrganizationMembership[] | null = null private cloudOrganizationsCacheTimestamp: number | null = null private static readonly CLOUD_ORGANIZATIONS_CACHE_DURATION_MS = 5 * 1000 // 5 seconds /** * Monotonically increasing sequence number for clineMessages state pushes. * Used by the frontend to reject stale state that arrives out-of-order. */ private clineMessagesSeq = 0 public isViewLaunched = false public settingsImportedAt?: number public readonly latestAnnouncementId = "oct-2026-v3.94.0-models-oauth-chat" // v3.94.0 model support, MCP OAuth, and chat reliability 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, public readonly webviewFocusTracker: WebviewFocusTracker, mdmService?: MdmService, ) { super() this.currentWorkspacePath = getWorkspacePath() this.pendingEditOperations = new PendingEditOperationStore( ClineProvider.PENDING_OPERATION_TIMEOUT_MS, (message) => this.log(message), ) ClineProvider.activeInstances.add(this) this.diagnosticsRecorder.record({ boundary: "provider", phase: "success", type: "created" }) this.mdmService = mdmService void this.updateGlobalState("codebaseIndexModels", EMBEDDING_MODEL_PROFILES) // Initialize the authoritative per-task file-based history store. this.taskHistoryStore = new TaskHistoryStore(this.contextProxy.globalStorageUri.fsPath) this.initializeTaskHistoryStore().catch((error) => { this.log(`Failed to initialize TaskHistoryStore: ${error}`) }) // 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.postStateToWebviewWithoutClineMessages() }) // 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 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 = async (taskId: string, tokenUsage: TokenUsage, toolUsage: ToolUsage) => { // Explicitly transition the task to "completed" so that any prior terminal // status (e.g. "interrupted" from a previous cancel) is correctly overwritten. // saveClineMessages() omits the status field for top-level tasks, which causes // the store's merge to preserve a stale "interrupted" status after completion. // interrupted → completed is a valid VALID_TRANSITIONS path. try { const existing = this.taskHistoryStore.get(taskId) if (existing && existing.status !== "completed") { await this.updateTaskHistory({ ...existing, status: "completed" }) } } catch (err) { this.log( `[onTaskCompleted] Failed to write completed status for ${taskId}: ${err instanceof Error ? err.message : String(err)}`, ) } this.emit(RooCodeEventName.TaskCompleted, taskId, tokenUsage, toolUsage) } const onTaskAborted = () => this.emit(RooCodeEventName.TaskAborted, instance.taskId) 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 the TaskHistoryStore and migrate from globalState if needed. */ private async initializeTaskHistoryStore(): Promise { const startedAt = Date.now() this.diagnosticsRecorder.record({ boundary: "task-history", phase: "start", type: "initialize" }) try { await this.taskHistoryStore.initialize() // Migration: backfill per-task files from globalState on first run const migrationKey = "taskHistoryMigratedToFiles" const alreadyMigrated = this.context.globalState.get(migrationKey) if (!alreadyMigrated) { const legacyHistory = this.context.globalState.get("taskHistory") ?? [] if (legacyHistory.length > 0) { this.log(`[initializeTaskHistoryStore] Migrating ${legacyHistory.length} entries from globalState`) await this.taskHistoryStore.migrateFromGlobalState(legacyHistory) } await this.context.globalState.update(migrationKey, true) this.log("[initializeTaskHistoryStore] Migration complete") } this.taskHistoryStoreInitialized = true this.diagnosticsRecorder.record({ boundary: "task-history", phase: "success", type: "initialize", elapsedMs: Date.now() - startedAt, }) } catch (error) { this.diagnosticsRecorder.record({ boundary: "task-history", phase: "failure", type: "initialize", elapsedMs: Date.now() - startedAt, }) this.log(`[initializeTaskHistoryStore] Error: ${error instanceof Error ? error.message : String(error)}`) } } /** * Override EventEmitter's on method to match TaskProviderLike interface */ override on( event: K, listener: (...args: TaskProviderEvents[K]) => void | Promise, ): this { return super.on(event, listener as any) } /** * Override EventEmitter's off method to match TaskProviderLike interface */ override off( event: K, listener: (...args: TaskProviderEvents[K]) => void | Promise, ): this { return super.off(event, listener as any) } /** * Handle cloud settings updates */ private handleCloudSettingsUpdate = async () => { this.log("Ignoring cloud settings update because cloud profile synchronization is disabled") } /** * Initialize cloud profile synchronization when CloudService is ready * This method is called externally after CloudService has been initialized */ public async initializeCloudProfileSyncWhenReady(): Promise { this.log("Cloud profile synchronization is disabled in compatibility mode") } // Adds a new Task instance to the registry, 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.taskRegistry.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 === providerIdentifiers.lmstudio) { try { if (!hasLoadedFullDetails(cline.apiConfiguration.lmStudioModelId!)) { await forceFullModelDetailsLoad( cline.apiConfiguration.lmStudioBaseUrl ?? "http://localhost:1234", cline.apiConfiguration.lmStudioModelId!, buildApiHandler({ ...cline.apiConfiguration, apiProvider: providerIdentifiers.lmstudio }), ) } } 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.taskRegistry.length === 0) { return } // Remove the focused Cline instance from the stack. let task = this.taskRegistry.current if (task) { task = this.taskRegistry.remove(task.taskId) } 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 } } private async cleanupFailedHistoryTask(task: Task, error: unknown): Promise { if (!(error instanceof PendingActionSettlementError)) { return } if (this.taskRegistry.getById(task.taskId) !== task) { return } this.taskRegistry.remove(task.taskId) task.emit(RooCodeEventName.TaskUnfocused) removeTaskEventListeners(this.taskEventListeners, task) try { await task.dispose() } catch (error) { this.log( `[cleanupFailedHistoryTask] dispose() failed for ${task.taskId}.${task.instanceId}: ${error instanceof Error ? error.message : String(error)}`, ) } } /** * Evicts the current task from the stack and, if it was an active delegated child, * marks it interrupted so the parent stays delegated (rather than silently losing the link). * * Use this in place of bare removeClineFromStack() at any call site that is not itself * part of a delegation transition (i.e. everywhere except delegateParentAndOpenChild, * createTask with a parentTask, and reopenParentFromDelegation). */ public async evictCurrentTask(): Promise { const current = this.getCurrentTask() const storedHistory = current ? this.taskHistoryStore.get(current.taskId) : undefined await this.removeClineFromStack() if (storedHistory?.status === "active" && storedHistory.parentTaskId) { await this.markDelegatedChildInterrupted({ childTaskId: storedHistory.id, parentTaskId: storedHistory.parentTaskId, }) } } /** * Marks a live delegated child as "interrupted" when it is evicted without completing * (e.g. user hits + for a new task, or navigates away while the child is still active). * * This preserves the delegation link — the parent stays "delegated" with awaitingChildId * intact — so the user can later resume or abandon the interrupted child. It is the live- * eviction counterpart to cancelTask()'s interruption path and to reopenParentFromDelegation() * (which handles normal child completion). * * Must be called AFTER removeClineFromStack() so the live Task's final saveClineMessages() * does not reattach the child's parentTaskId/rootTaskId over the interrupted status. */ private async markDelegatedChildInterrupted({ childTaskId, parentTaskId, }: { childTaskId: string parentTaskId: string }): Promise { // Fast path: already interrupted (cancelTask beat us to it), nothing to do. if (this.taskHistoryStore.get(childTaskId)?.status === "interrupted") { this.log(`[markDelegatedChildInterrupted] Child ${childTaskId} already interrupted — skipping`) return } try { await this.runDelegationTransition(parentTaskId, async () => { const { historyItem: parentHistory } = await this.getTaskWithId(parentTaskId) if (parentHistory?.status !== "delegated" || parentHistory?.awaitingChildId !== childTaskId) { this.log( `[markDelegatedChildInterrupted] Parent ${parentTaskId} no longer delegated to child ${childTaskId} — skipping`, ) return } // Prefer the in-memory store entry: it is written by delegateParentAndOpenChild // with the correct parentTaskId before the child saves its first message. // getTaskWithId reads from disk and may return an incomplete record (missing // parentTaskId) if the child was evicted before its first saveClineMessages(). const childHistory = this.taskHistoryStore.get(childTaskId) ?? (await this.getTaskWithId(childTaskId)).historyItem // Re-check inside the lock to close the TOCTOU window with cancelTask() or // a concurrent completion. Only proceed when the child is still "active"; // any other terminal status (interrupted, completed) must not be overwritten. if (childHistory?.status !== "active") { this.log( `[markDelegatedChildInterrupted] Child ${childTaskId} is no longer active (status=${childHistory?.status}) — skipping`, ) return } const interruptedChild = interruptDelegatedChild(parentHistory, childHistory) await this.updateTaskHistory(interruptedChild) await this.postMessageToWebview({ type: "taskHistoryItemUpdated", taskHistoryItem: interruptedChild }) await this.postMessageToWebview({ type: "taskHistoryItemUpdated", taskHistoryItem: parentHistory }) this.log( `[markDelegatedChildInterrupted] Marked child ${childTaskId} interrupted; parent ${parentTaskId} stays delegated`, ) }) } catch (err) { this.log( `[markDelegatedChildInterrupted] Failed for child ${childTaskId}: ${err instanceof Error ? err.message : String(err)}`, ) } } getTaskStackSize(): number { return this.taskRegistry.length } public getCurrentTaskStack(): string[] { return this.taskRegistry.taskIds } public async setPendingTaskAction(taskId: string, pendingAction: PendingTaskAction): Promise { await this.taskHistoryStore.atomicReadAndUpdate(taskId, (historyItem) => ({ ...historyItem, pendingAction, })) this.recentTasksCache = undefined } public async clearPendingTaskAction(taskId: string, actionId: string): Promise { let cleared = false try { await this.taskHistoryStore.atomicReadAndUpdate(taskId, (historyItem) => { if (historyItem.pendingAction?.actionId !== actionId) { return historyItem } cleared = true return { ...historyItem, pendingAction: undefined } }) } catch (error) { if ( error instanceof Error && error.message === `[TaskHistoryStore] atomicReadAndUpdate: task ${taskId} not found in cache` ) { return false } throw error } if (cleared) { this.recentTasksCache = undefined } return cleared } // Pending Edit Operations Management /** * Sets a pending edit operation with automatic timeout cleanup */ public setPendingEditOperation(operationId: string, editData: PendingEditOperationInput): object { return this.pendingEditOperations.set(operationId, editData) } /** * Gets a pending edit operation by ID */ private getPendingEditOperation(operationId: string) { return this.pendingEditOperations.get(operationId) } /** * Clears a specific pending edit operation */ public clearPendingEditOperation(operationId: string, owner?: object): boolean { return this.pendingEditOperations.clear(operationId, owner) } /** * Clears all pending edit operations */ private clearAllPendingEditOperations(): void { this.pendingEditOperations.clearAll() } /* 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() { // The view that asked for provider limits is gone or replaced: stop fetching them. A replaced view may not get // to send its cancel message (a crashed one never does), and the new view asks again by itself. abortProviderQuotaRequests(this) this.rejectPendingThemeFixtureProbes(new Error("Webview was disposed before the theme fixture probe completed")) this.stopWebviewWatchdog() // Invalidate any recovery reload still awaiting its HTML so it cannot // reassign webview.html on the disposed view. this.webviewRecoveryEpoch++ // User-opened replacements get a fresh budget; recreating a failed host // must preserve its incident budget so crash loops remain bounded. if (!preservesRecoveryIncident(this.webviewHostRecoveryInProgress, this.pendingSidebarRecovery)) { this.webviewRecoveryAttempts = 0 this.webviewRecoveryFailureReported = false this.webviewRecoveryStopLogged = false } this.lastReceivedWebviewHeartbeatAt = undefined this.webviewHealthySince = undefined while (this.webviewDisposables.length) { const x = this.webviewDisposables.pop() if (x) { x.dispose() } } } /** * True when an in-flight resolve no longer belongs to the provider's current * view because the view was disposed or replaced (or the provider itself was * disposed) while the resolve was awaiting HTML or state hydration. */ private isStaleViewResolve(webviewView: vscode.WebviewView | vscode.WebviewPanel, epoch: number): boolean { return this._disposed || this.view !== webviewView || epoch !== this.webviewRecoveryEpoch } /** Drain one task's memoized cleanup without preventing the remaining provider shutdown work. */ private async drainTaskDisposal(task: Task): Promise { try { await task.dispose() } catch (error) { this.log( `[ClineProvider#dispose] Task cleanup failed for ${task.taskId}.${task.instanceId}: ${error instanceof Error ? error.message : String(error)}`, ) } } async dispose() { if (this._disposed) { return } this._disposed = true this._postStateToWebviewThrottled.cancel() this.stopWebviewWatchdog() this.diagnosticsRecorder.record({ boundary: "provider", phase: "start", type: "dispose" }) this.log("Disposing ClineProvider...") // Reject any tasks still waiting for a scheduler permit so they don't // hold the event loop after the provider is torn down. this.taskScheduler.cancelQueued() // Provider-limit requests still running would keep fetching for a view that is gone. abortProviderQuotaRequests(this) // Clear all tasks from the stack. The first pop goes through evictCurrentTask() // so an active delegated child is marked interrupted before the extension shuts down, // rather than being left persisted as "active" across the reload. if (this.taskRegistry.length > 0) { const task = this.taskRegistry.current! await this.evictCurrentTask() await this.drainTaskDisposal(task) } while (this.taskRegistry.length > 0) { const task = this.taskRegistry.current! await this.removeClineFromStack() await this.drainTaskDisposal(task) } 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) } this._workspaceTracker?.dispose() this._workspaceTracker = undefined await this.mcpHub?.unregisterClient() this.mcpHub = undefined await this.skillsManager?.dispose() this.skillsManager = undefined await this.marketplaceManager?.cleanup() this.customModesManager?.dispose() this.taskHistoryStore.dispose() this.log("Disposed all disposables") ClineProvider.activeInstances.delete(this) this.diagnosticsRequestBroker.dispose() // Clean up any event listeners attached to this provider this.removeAllListeners() McpServerManager.unregisterProvider(this) } public get isViewVisible(): boolean { return this.view?.visible === true } public static getVisibleInstance(): ClineProvider | undefined { return findLast(Array.from(this.activeInstances), (instance) => instance.isViewVisible) } public static getAllInstances(): ClineProvider[] { return Array.from(this.activeInstances) } public static async getInstance(): Promise { 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 { 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 async handleCodeAction( command: CodeActionId, promptType: CodeActionName, params: Record, ): Promise { // Capture telemetry for code action usage TelemetryService.instance.captureCodeActionUsed(promptType) const { customSupportPrompts } = await this.getState() // TODO: Improve type safety for promptType. const prompt = supportPrompt.create(promptType, params, customSupportPrompts) if (command === "addToContext") { await this.postMessageToWebview({ type: "invoke", invoke: "setChatBoxMessage", text: `${prompt}\n\n`, }) await this.postMessageToWebview({ type: "action", action: "focusInput" }) return } await this.createTask(prompt) } public static async handleTerminalAction( command: TerminalActionId, promptType: TerminalActionPromptType, params: Record, ): Promise { 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) { const generation = this.webviewRecoveryRequestGeneration // Capture intent before yielding: host recovery can time out while this // resolve is still generating HTML or hydrating state. const recoveringHost = preservesRecoveryIncident( this.webviewHostRecoveryInProgress, this.pendingSidebarRecovery, ) try { await this.initializeWebviewView(webviewView, recoveringHost) } catch (error) { if (!isRecoveryGenerationCurrent(generation, this.webviewRecoveryRequestGeneration)) return const action = resolutionFailureAction({ recoveringHost, disposed: this._disposed, isCurrent: this.view === webviewView, }) if (action === "propagate") throw error if (action === "ignore") return // VS Code invokes sidebar resolution itself, outside the focus command's // promise. Handle both host types here so failed initialization retains // its task, disposal subscription, and remaining recovery attempts. const host = "onDidChangeViewState" in webviewView ? "tab" : "sidebar" this.reportWebviewRecoveryFailure(`Failed to initialize replacement ${host}: ${String(error)}`, false) this.startWebviewWatchdog() } } private async initializeWebviewView( webviewView: vscode.WebviewView | vscode.WebviewPanel, recoveringHost: boolean, ): Promise { // A different view replaces the current one: dispose its subscriptions so // the replaced view can no longer dispatch messages or refresh provider state. if (this.view !== webviewView) { this.clearWebviewResources() } const epoch = this.webviewRecoveryEpoch this.pendingSidebarRecovery = false this.view = webviewView const inTabMode = "onDidChangeViewState" in webviewView this.diagnosticsRecorder.record({ boundary: "provider", phase: "success", type: "view-resolved", }) // Register disposal before awaiting HTML or state so closing the view during // either load invalidates this resolve and prevents later listener installation. webviewView.onDidDispose( async () => { // A view that is no longer current must not clear the current // view's resources or dispose the provider. if (webviewView !== this.view) { return } if (inTabMode) { if (getPanel() === webviewView) setPanel(undefined, "tab") this.log("Disposing ClineProvider instance for tab view") await this.dispose() } else { this.log("Clearing webview resources for sidebar view") this.view = undefined this.clearWebviewResources() } }, null, // View-scoped, like the other view subscriptions: disposed both when // the view is disposed and when a different view replaces it. this.webviewDisposables, ) if (inTabMode) { setPanel(webviewView, "tab") } else if ("onDidChangeVisibility" in webviewView) { setPanel(webviewView, "sidebar") } // 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, } const html = await this.getWebviewHtml(webviewView.webview) // The HTML load above can finish after the view was disposed or replaced. // A stale resolve must not assign HTML to a view the provider no longer owns. if (this.isStaleViewResolve(webviewView, epoch)) { return } webviewView.webview.html = html // Initialize out-of-scope variables that need to receive persistent // global state values. await this.getState().then( ({ terminalShellIntegrationTimeout = Terminal.defaultShellIntegrationTimeout, terminalShellIntegrationDisabled = false, terminalCommandDelay = 0, terminalZshClearEolMark = true, terminalZshOhMy = false, terminalZshP10k = false, terminalPowershellCounter = false, terminalZdotdir = false, terminalProfile, ttsEnabled, ttsSpeed, }) => { if (this.isStaleViewResolve(webviewView, epoch)) return Terminal.setShellIntegrationTimeout(terminalShellIntegrationTimeout) Terminal.setShellIntegrationDisabled(terminalShellIntegrationDisabled) Terminal.setCommandDelay(terminalCommandDelay) Terminal.setTerminalZshClearEolMark(terminalZshClearEolMark) Terminal.setTerminalZshOhMy(terminalZshOhMy) Terminal.setTerminalZshP10k(terminalZshP10k) Terminal.setPowershellCounter(terminalPowershellCounter) Terminal.setTerminalZdotdir(terminalZdotdir) Terminal.setTerminalProfile(terminalProfile) setTtsEnabled(ttsEnabled ?? false) setTtsSpeed(ttsSpeed ?? 1) }, ) // State hydration can also finish after the view was disposed or replaced. // A stale resolve must not install listeners on a view the provider no longer owns. if (this.isStaleViewResolve(webviewView, epoch)) { return } // 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) this.webviewDisposables.push(this.webviewFocusTracker.init(this, webviewView)) // Detect a dead webview renderer process (gray screen) via heartbeat timeout. this.startWebviewWatchdog() // 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(() => { // Ignore events from a view that is no longer the current view. if (webviewView !== this.view) { return } this.diagnosticsRecorder.record({ boundary: "provider", phase: "success", type: this.view?.visible ? "view-visible" : "view-hidden", }) if (this.view?.visible) { // Hidden webviews throttle timers, so grant a fresh grace // window instead of counting throttled heartbeats as a crash. this.resetWebviewHeartbeatGrace() void this.postMessageToWebview({ type: "action", action: "didBecomeVisible" }) } else { this.logWebviewHiddenDiagnostics() } }) this.webviewDisposables.push(viewStateDisposable) } else if ("onDidChangeVisibility" in webviewView) { // sidebar const visibilityDisposable = webviewView.onDidChangeVisibility(() => { // Ignore events from a view that is no longer the current view. if (webviewView !== this.view) { return } this.diagnosticsRecorder.record({ boundary: "provider", phase: "success", type: this.view?.visible ? "view-visible" : "view-hidden", }) if (this.view?.visible) { // Hidden webviews throttle timers, so grant a fresh grace // window instead of counting throttled heartbeats as a crash. this.resetWebviewHeartbeatGrace() void this.postMessageToWebview({ type: "action", action: "didBecomeVisible" }) } else { this.logWebviewHiddenDiagnostics() } }) this.webviewDisposables.push(visibilityDisposable) } // Listen for when color changes const configDisposable = vscode.workspace.onDidChangeConfiguration(async (e) => { // Ignore configuration refreshes scoped to a view that is no longer current. if (webviewView !== this.view) { return } 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. // But don't clear if there's already an active task (e.g., resumed via IPC/bridge). const currentTask = this.getCurrentTask() // Host recreation is a UI repair, including when the displayed task is // interrupted. It must not evict or resume that task as a side effect. if (!recoveringHost && (!currentTask || currentTask.abandoned || currentTask.abort)) { await this.removeClineFromStack() } // Ensure zoo-gateway profile is seeded for users who signed in before this feature existed. // Without this, users with a valid cached token but no zoo-gateway profile would need to // re-authenticate to use Zoo Gateway. Fire-and-forget to avoid blocking webview init. void this.ensureZooGatewayProfileSeeded().catch((err) => { this.log(`[ensureZooGatewayProfileSeeded] Error: ${err instanceof Error ? err.message : String(err)}`) }) } /** * Seeds the zoo-gateway provider profile for users who have a cached auth token * but no profile (e.g., users who signed in before Zoo Gateway was added), or * who have an empty/imported profile without a token. * Called once per webview init; handleZooCodeCallback is idempotent so repeated calls are safe. */ private async ensureZooGatewayProfileSeeded(): Promise { const { getCachedZooCodeToken, getZooCodeBaseUrl } = await import("../../services/zoo-code-auth") const token = getCachedZooCodeToken() if (!token) return const expectedGatewayBaseUrl = `${getZooCodeBaseUrl()}/api/gateway/v1` // Check ALL zoo-gateway profiles — only skip seeding if every profile has the current token. // Using .find() would miss stale tokens in duplicate/renamed profiles since handleZooCodeCallback // uses .filter() and updates all of them — the early-return guard must match. const allProfiles = await this.providerSettingsManager.listConfig() const zooGatewayProfiles = allProfiles.filter((p) => p.apiProvider === providerIdentifiers.zooGateway) if (zooGatewayProfiles.length === 0) { this.log("[ensureZooGatewayProfileSeeded] No zoo-gateway profile found, creating one") } else { let allUpToDate = true for (const entry of zooGatewayProfiles) { try { const fullProfile = await this.providerSettingsManager.getProfile({ name: entry.name }) if ( fullProfile.zooSessionToken !== token || fullProfile.zooGatewayBaseUrl !== expectedGatewayBaseUrl ) { allUpToDate = false this.log("[ensureZooGatewayProfileSeeded] Existing zoo-gateway profile is stale, updating") break } } catch { allUpToDate = false this.log("[ensureZooGatewayProfileSeeded] Failed to read existing profile, will re-seed") break } } if (allUpToDate) { const { postZooGatewayCredentialsReady } = await import("../../services/zoo-gateway-credentials-sync") postZooGatewayCredentialsReady((message) => this.postMessageToWebview(message)) return } } // User has token but either no profile, some profiles without token, or stale tokens — seed all await this.handleZooCodeCallback(token) } public createTaskWithHistoryItem( historyItem: HistoryItem & { rootTask?: Task; parentTask?: Task }, options?: { startTask?: boolean }, ): Promise { // History navigation can arrive concurrently (for example, two rapid // showTaskWithId messages). Serialize the full eviction/installation // transition so both callers cannot observe the same previous registry // state and schedule distinct Task instances for one history item. // Fail forward so one rejected restoration does not poison the queue. return ClineProvider.prototype.enqueueHistoryTaskTransition.call< ClineProvider, [() => Promise], Promise >(this, () => ClineProvider.prototype.createTaskWithHistoryItemUnlocked.call(this, historyItem, options)) } /** * Rehydrates `task` from history only if it is still current when the queued * step runs, so a navigation queued earlier is never evicted. */ public restartTaskIfCurrent(task: Task): Promise { return ClineProvider.prototype.enqueueHistoryTaskTransition.call< ClineProvider, [() => Promise], Promise >(this, async () => { if (this.getCurrentTask() !== task) { return false } const { historyItem } = await this.getTaskWithId(task.taskId) if (this.getCurrentTask() !== task) { return false } await ClineProvider.prototype.createTaskWithHistoryItemUnlocked.call(this, historyItem) return true }) } private enqueueHistoryTaskTransition(transition: () => Promise): Promise { const previous = this.historyTaskCreationQueue ?? Promise.resolve() const run = previous.then(transition, transition) this.historyTaskCreationQueue = run.then( () => {}, () => {}, ) return run } private async createTaskWithHistoryItemUnlocked( historyItem: HistoryItem & { rootTask?: Task; parentTask?: Task }, options?: { startTask?: boolean }, ): Promise { const isCliRuntime = process.env.ROO_CLI_RUNTIME === "1" // CLI injects runtime provider settings from command flags/env at startup. // Restoring provider profiles from task history can overwrite those // runtime settings with stale/incomplete persisted profiles. const skipProfileRestoreFromHistory = isCliRuntime // 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.evictCurrentTask() } // 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. const lockApiConfigAcrossModes = this.context.workspaceState.get("lockApiConfigAcrossModes", false) if (!historyItem.apiConfigName && !lockApiConfigAcrossModes && !skipProfileRestoreFromHistory) { 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 }, { persistModeConfig: false, expectedCurrentTaskId: historyItem.id }, ) } 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 && !skipProfileRestoreFromHistory) { 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 { if (profile.apiProvider) { await this.activateProviderProfile( { name: profile.name }, { persistModeConfig: false, persistTaskHistory: false, expectedCurrentTaskId: historyItem.id, }, ) } } 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.`, ) } } else if (historyItem.apiConfigName && skipProfileRestoreFromHistory) { this.log( `Skipping restore of provider profile '${historyItem.apiConfigName}' for task ${historyItem.id} in CLI runtime.`, ) } const { apiConfiguration, enableCheckpoints, checkpointTimeout, experiments, diffFuzzyThreshold } = await this.getState() const task = new Task({ provider: this, apiConfiguration, enableCheckpoints, checkpointTimeout, consecutiveMistakeLimit: apiConfiguration.consecutiveMistakeLimit, toolRepetitionSoftLimit: apiConfiguration.toolRepetitionSoftLimit, historyItem, experiments, rootTask: historyItem.rootTask, parentTask: historyItem.parentTask, taskNumber: historyItem.number, workspacePath: historyItem.workspace, onCreated: this.taskCreationCallback, startTask: false, // Preserve the status from the history item to avoid overwriting it when the task saves messages initialStatus: historyItem.status, rateLimitClock: this.rateLimitClock, diffFuzzyThreshold, }) if (isRehydratingCurrentTask) { // Replace the current task in-place to avoid UI flicker const oldTask = this.taskRegistry.current // The current task may have changed or been removed during the awaits above; never touch it. if (oldTask !== currentTask) { removeTaskEventListeners(this.taskEventListeners, task) await task.abortTask(true).catch(() => {}) return task } if (oldTask) { // 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}`, ) } removeTaskEventListeners(this.taskEventListeners, oldTask) // Replace in-place: preserves stack index and current pointer this.taskRegistry.replace(oldTask.taskId, 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)`, ) if (options?.startTask !== false) { scheduleTask(this.taskScheduler, task, "createTaskWithHistoryItem", undefined, (error) => this.cleanupFailedHistoryTask(task, error), ) } } else { await this.addClineToStack(task) this.log( `[createTaskWithHistoryItem] ${task.parentTask ? "child" : "parent"} task ${task.taskId}.${task.instanceId} instantiated`, ) if (options?.startTask !== false) { scheduleTask(this.taskScheduler, task, "createTaskWithHistoryItem", undefined, (error) => this.cleanupFailedHistoryTask(task, error), ) } } // 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) { const startedAt = Date.now() const taskId = this.getCurrentTask()?.taskId this.diagnosticsRecorder.record({ boundary: "webview-out", phase: "start", type: message.type, action: message.action, stateSequence: message.type === "state" ? message.state?.clineMessagesSeq : undefined, taskId, }) if (this._disposed) { this.diagnosticsRecorder.record({ boundary: "webview-out", phase: "failure", type: message.type, action: message.action, elapsedMs: Date.now() - startedAt, taskId, }) return } try { const delivered = await this.view?.webview.postMessage(omitOriginalContentFromExtensionMessage(message)) this.diagnosticsRecorder.record({ boundary: "webview-out", phase: delivered ? "success" : "failure", type: message.type, action: message.action, elapsedMs: Date.now() - startedAt, stateSequence: message.type === "state" ? message.state?.clineMessagesSeq : undefined, taskId, }) } catch { this.diagnosticsRecorder.record({ boundary: "webview-out", phase: "failure", type: message.type, action: message.action, elapsedMs: Date.now() - startedAt, taskId, }) // View disposed, drop message silently } } public requestWebviewThemeFixture(timeoutMs = 5_000): Promise { if (process.env.ROO_CODE_THEME_FIXTURE_PROBE !== "1") { return Promise.reject(new Error("Theme fixture probing is disabled")) } const requestId = `theme-fixture-${++this.nextThemeFixtureProbeId}` return new Promise((resolve, reject) => { const timeout = setTimeout(() => { this.pendingThemeFixtureProbes.delete(requestId) reject(new Error(`Theme fixture probe timed out after ${timeoutMs}ms`)) }, timeoutMs) this.pendingThemeFixtureProbes.set(requestId, { resolve, reject, timeout }) void this.postMessageToWebview({ type: "themeFixtureProbeRequest", requestId }) }) } public resolveWebviewThemeFixtureProbe(requestId: string, fixture: WebviewThemeFixture): void { const pending = this.pendingThemeFixtureProbes.get(requestId) if (!pending) { return } clearTimeout(pending.timeout) this.pendingThemeFixtureProbes.delete(requestId) pending.resolve(fixture) } private rejectPendingThemeFixtureProbes(error: Error): void { for (const pending of this.pendingThemeFixtureProbes.values()) { clearTimeout(pending.timeout) pending.reject(error) } this.pendingThemeFixtureProbes.clear() } public getDiagnosticsSnapshot(): DiagnosticsProviderSourceSnapshot { const currentTask = this.getCurrentTask() const events = this.diagnosticsRecorder.snapshot(100) return { renderContext: this.renderContext, disposed: this._disposed, viewPresent: Boolean(this.view), visible: this.view?.visible === true, launched: this.isViewLaunched, taskHistoryInitialized: this.taskHistoryStoreInitialized, taskCount: this.taskRegistry.length, currentTaskId: currentTask?.taskId, currentMessageCount: currentTask?.clineMessages.length ?? 0, currentTodoCount: currentTask?.todoList?.length ?? 0, history: this.taskHistoryStore.getAll(), events: events.events, eventsTruncated: events.truncated, } } public requestWebviewDiagnostics(timeoutMs = 1_000): Promise { if (this._disposed || !this.view) return Promise.resolve(undefined) return this.diagnosticsRequestBroker.request( (requestId) => this.postMessageToWebview({ type: "diagnosticsRequest", requestId }), timeoutMs, ) } private async getHMRHtmlContent(webview: vscode.Webview): Promise { 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 = `127.0.0.1:${localPort}` // Check if local dev server is running. try { await axios.get(`http://${localServerUrl}`, { timeout: 5_000 }) } catch (error) { const timedOut = axios.isAxiosError(error) && (error.code === "ECONNABORTED" || error.code === "ETIMEDOUT") void vscode.window.showErrorMessage( t(timedOut ? "common:errors.hmr_probe_timeout" : "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*/ ` ` 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 https://avatars.githubusercontent.com https://lh3.googleusercontent.com data:`, `media-src ${webview.cspSource}`, `script-src 'unsafe-eval' ${webview.cspSource} 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*/ ` Zoo Code
${reactRefresh} ` } /** * 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 { // 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. - '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*/ ` Zoo Code
` } /** * 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 webviewView The view whose webview messages are listened to */ private setWebviewMessageListener(webviewView: vscode.WebviewView | vscode.WebviewPanel) { const onReceiveMessage = async (message: WebviewMessage) => { // Ignore messages from a view that is no longer the current view. if (webviewView !== this.view) { return } const startedAt = Date.now() this.diagnosticsRecorder.record({ boundary: "webview-in", phase: "start", type: message.type }) try { if (message.type === "diagnosticsResponse" && message.requestId) { this.diagnosticsRequestBroker.resolve(message.requestId, message.diagnostics) } else { await webviewMessageHandler(this, message, this.marketplaceManager) } this.diagnosticsRecorder.record({ boundary: "webview-in", phase: "success", type: message.type, elapsedMs: Date.now() - startedAt, }) } catch (error) { this.diagnosticsRecorder.record({ boundary: "webview-in", phase: "failure", type: message.type, elapsedMs: Date.now() - startedAt, }) throw error } } const messageDisposable = webviewView.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 * @param targetTask The task whose in-memory mode should be updated. Defaults to the * current task. Pass null to apply only global mode/profile effects for a pending child. */ public async handleModeSwitch(newMode: Mode, targetTask: Task | null | undefined = this.getCurrentTask()) { return this.enqueueProviderProfileMutation((signal) => this.handleModeSwitchUnlocked(newMode, targetTask, signal), ) } private async handleModeSwitchUnlocked( newMode: Mode, targetTask: Task | null | undefined, signal?: AbortSignal, ): Promise { const task = targetTask 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 taskHistoryItem = this.getTaskHistoryItem(task.taskId) if (taskHistoryItem) { await this.updateTaskHistory({ ...taskHistoryItem, mode: newMode }) } // 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) // If workspace lock is on, keep the current API config — don't load mode-specific config const lockApiConfigAcrossModes = this.context.workspaceState.get("lockApiConfigAcrossModes", false) if (lockApiConfigAcrossModes) { if (targetTask !== null) { await this.postStateToWebview() } return } if (signal?.aborted) return // 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() if (signal?.aborted) return // 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.activateProviderProfileUnlocked( { name: profile.name }, targetTask === null ? { skipCurrentTaskRebuild: true } : undefined, signal, ) } 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) } } } if (targetTask !== null) { 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; skipCurrentTaskRebuild?: boolean; expectedCurrentTaskId?: string } = {}, ): void { if (options.skipCurrentTaskRebuild) return const task = this.getCurrentTask() if (!task || (options.expectedCurrentTaskId && task.taskId !== options.expectedCurrentTaskId)) 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 newProvider = providerSettings.apiProvider const newModelId = getModelId(providerSettings) const needsRebuild = forceRebuild || prevProvider !== newProvider || prevModelId !== newModelId if (needsRebuild) { // Use updateApiConfiguration which handles both API handler rebuild and parser sync. // 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 { try { return await this.enqueueProviderProfileMutation( (signal) => this.persistProviderProfile(name, providerSettings, activate, signal), { runToCompletion: true }, ) } 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 } } /** * Applies a model change to the latest stored profile inside the mutation queue, * so a stale webview snapshot cannot overwrite newer profile settings. * A `null` patch value clears the key. */ async updateProfileModel(name: string, expectedProvider: string, patch: Record): Promise { let partialState = false try { await this.enqueueProviderProfileMutation( async (signal) => { const { name: _name, ...stored } = await this.providerSettingsManager.getProfile({ name }) // Unset provider (e.g. the seeded default profile) resolves to anthropic, matching getState(). const storedProvider = stored.apiProvider ?? providerIdentifiers.anthropic if (storedProvider !== expectedProvider) { throw new Error( `Profile "${name}" provider changed from "${expectedProvider}" to "${storedProvider}"`, ) } const merged: Record = { ...stored, apiProvider: storedProvider } for (const [key, value] of Object.entries(patch)) { if (PROFILE_MODEL_ID_PATCH_KEYS.has(key)) { if (typeof value !== "string" || value.length === 0) { throw new Error(`Key "${key}" must be a non-empty string in a model patch`) } merged[key] = value } else if (PROFILE_MODEL_RESET_ONLY_KEYS.has(key)) { if (value !== null && value !== undefined) { throw new Error(`Key "${key}" can only be cleared in a model patch`) } delete merged[key] } else { throw new Error(`Key "${key}" is not allowed in a model patch`) } } if (storedProvider === providerIdentifiers.bedrock) { if (merged.apiModelId === BEDROCK_CUSTOM_ARN_MODEL_ID) { // The allow-list only sees apiModelId, so a patch must not select an ARN it cannot supply. if (!merged.awsCustomArn) { throw new Error( `Model "${BEDROCK_CUSTOM_ARN_MODEL_ID}" requires an existing custom ARN`, ) } } else { // A retained ARN would override the allow-listed model, so drop it with its context fields. for (const key of PROFILE_MODEL_RESET_ONLY_KEYS) { if (key.startsWith("aws")) delete merged[key] } } } const { organizationAllowList } = await this.getState() if (!ProfileValidator.isProfileAllowed(merged as ProviderSettings, organizationAllowList)) { throw new OrganizationAllowListViolationError( t("common:errors.violated_organization_allowlist"), ) } if (signal.aborted) return try { await this.persistProviderProfile(name, merged as ProviderSettings, true, signal) } catch (persistError) { // Saving and activating are separate writes. Put the previous profile back so // storage, context settings, and the task do not disagree about the model. try { await this.persistProviderProfile(name, stored as ProviderSettings, true, signal) } catch (restoreError) { partialState = true this.log( `Error restoring profile after failed model update: ${JSON.stringify(restoreError, Object.getOwnPropertyNames(restoreError), 2)}`, ) } throw persistError } }, { runToCompletion: true }, ) } catch (error) { this.log(`Error update profile model: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`) vscode.window.showErrorMessage( partialState ? t("common:errors.update_profile_model_partial") : error instanceof OrganizationAllowListViolationError ? error.message : t("common:errors.create_api_config"), ) } } private async persistProviderProfile( name: string, providerSettings: ProviderSettings, activate: boolean, signal: AbortSignal, ): Promise { // 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 (signal.aborted) return id if (activate) { const { mode } = await this.getState() if (signal.aborted) return id // 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. // `allSettled` so a caller that rolls back after a failure never races a write still in flight. const activationResults = await Promise.allSettled([ this.updateGlobalState("listApiConfigMeta", await this.providerSettingsManager.listConfig()), this.updateGlobalState("currentApiConfigName", name), this.providerSettingsManager.setModeConfig(mode, id), this.contextProxy.setProviderSettings(providerSettings), ]) const failedActivation = activationResults.find( (result): result is PromiseRejectedResult => result.status === "rejected", ) if (failedActivation) throw failedActivation.reason if (signal.aborted) return id // 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()) } if (signal.aborted) return id await this.postStateToWebview() return id } 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, options: { skipCurrentTaskRebuild?: boolean; expectedCurrentTaskId?: string } = {}, ): Promise { if (options.skipCurrentTaskRebuild) return const task = this.getCurrentTask() if (!task || (options.expectedCurrentTaskId && task.taskId !== options.expectedCurrentTaskId)) { 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 taskHistoryItem = this.getTaskHistoryItem(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 skipCurrentTaskRebuild?: boolean /** Rebuild and sticky-write only when this task is still current. */ expectedCurrentTaskId?: string }, ) { return this.enqueueProviderProfileMutation((signal) => this.activateProviderProfileUnlocked(args, options, signal), ) } private async activateProviderProfileUnlocked( args: { name: string } | { id: string }, options?: { persistModeConfig?: boolean persistTaskHistory?: boolean skipCurrentTaskRebuild?: boolean expectedCurrentTaskId?: string }, signal?: AbortSignal, ): Promise { const { name, id, ...providerSettings } = await this.providerSettingsManager.activateProfile(args) if (signal?.aborted) return const persistModeConfig = options?.persistModeConfig ?? true const persistTaskHistory = options?.persistTaskHistory ?? true const skipCurrentTaskRebuild = options?.skipCurrentTaskRebuild ?? false if (!skipCurrentTaskRebuild) { // 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. const expectedCurrentTaskId = options?.expectedCurrentTaskId this.updateTaskApiHandlerIfNeeded(providerSettings, { forceRebuild: true, skipCurrentTaskRebuild, expectedCurrentTaskId, }) // 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, { skipCurrentTaskRebuild, expectedCurrentTaskId, }) } if (!skipCurrentTaskRebuild) { await this.postStateToWebview() } if (providerSettings.apiProvider && !skipCurrentTaskRebuild) { 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 { // 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 { // Fallback to a relative path if directory creation fails return path.join(os.homedir(), ".roo-code", "mcp") } return mcpServersDir } async ensureSettingsDirectoryExists(): Promise { const { getSettingsDirectoryPath } = await import("../../utils/storage") const globalStoragePath = this.contextProxy.globalStorageUri.fsPath return getSettingsDirectoryPath(globalStoragePath) } // OpenRouter async handleOpenRouterCallback(code: string) { const { 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: providerIdentifiers.openrouter, openRouterApiKey: apiKey, openRouterModelId: apiConfiguration?.openRouterModelId || openRouterDefaultModelId, } await this.upsertProviderProfile(currentApiConfigName, newConfiguration) } // Zoo Code Auth async handleZooCodeCallback(token: string) { // Auth mutation (token storage, subscription check, success toast) was already // performed by handleAuthCallback() in handleUri.ts before this method was called. // Save the zoo-gateway provider profile with the session token so that // ZooGatewayHandler can authenticate without any manual user input. // // activate: true ONLY if Zoo Gateway is already the active profile — this pushes // the new token to the in-memory handler so the current task picks it up immediately. // Otherwise activate: false — do NOT switch providers mid-conversation. The user // must explicitly select Zoo Gateway in settings if they want to use it. try { const { apiConfiguration } = await this.getState() const currentSettings = this.contextProxy.getProviderSettings() const currentApiConfigName = this.contextProxy.getValues().currentApiConfigName // Derive the gateway base URL from ZOO_CODE_BASE_URL so that non-prod environments // (staging, local dev) route completions to the correct backend instead of always // hard-coding production. An already-set value in the profile is NOT preserved here — // it must always align with the auth server the user just authenticated against. const { getZooCodeBaseUrl } = await import("../../services/zoo-code-auth") const derivedGatewayBaseUrl = `${getZooCodeBaseUrl()}/api/gateway/v1` // Check if Zoo Gateway is the currently active profile by apiProvider identity, // not by profile name (profile names are user-renameable). const isZooGatewayActive = currentSettings.apiProvider === providerIdentifiers.zooGateway // Always scan ALL profiles and update every zoo-gateway profile with the new token. // This ensures renamed profiles, duplicate profiles, and inactive profiles all stay // in sync. The model lookup in requestRouterModels uses .find() which returns the // first zoo-gateway profile it finds — if that profile has a stale token, requests fail. const allProfiles = await this.providerSettingsManager.listConfig() const zooProfiles = allProfiles.filter((p) => p.apiProvider === providerIdentifiers.zooGateway) if (zooProfiles.length === 0) { // No existing zoo-gateway profile — create the canonical default. const newConfiguration: ProviderSettings = { apiProvider: providerIdentifiers.zooGateway, zooSessionToken: token, zooGatewayModelId: apiConfiguration.zooGatewayModelId, zooGatewayBaseUrl: derivedGatewayBaseUrl, } // Activate only if zoo-gateway was the active provider (shouldn't happen if // no profiles exist, but defensive). await this.upsertProviderProfile("Zoo Gateway", newConfiguration, isZooGatewayActive) } else { // Update every existing zoo-gateway profile with the new token and the // derived base URL so that environment-specific routing stays consistent. for (const entry of zooProfiles) { const isActiveProfile = isZooGatewayActive && entry.name === currentApiConfigName const existing = await this.providerSettingsManager.getProfile({ name: entry.name }) const updated: ProviderSettings = { ...existing, zooSessionToken: token, zooGatewayBaseUrl: derivedGatewayBaseUrl, } if (isActiveProfile) { // Use upsertProviderProfile with activate: true so the in-memory handler // picks up the new token immediately for the current task. await this.upsertProviderProfile(entry.name, updated, true) } else { // Non-active profiles just need the token saved to disk. await this.providerSettingsManager.saveConfig(entry.name, updated) } } } } catch (error) { this.log( `[handleZooCodeCallback] Failed to save zoo-gateway profile: ${ error instanceof Error ? error.message : String(error) }`, ) } await this.postStateToWebview() const { postZooGatewayCredentialsReady } = await import("../../services/zoo-gateway-credentials-sync") postZooGatewayCredentialsReady((message) => this.postMessageToWebview(message)) } // Requesty async handleRequestyCallback(code: string, baseUrl: string | null) { const { apiConfiguration } = await this.getState() const newConfiguration: ProviderSettings = { ...apiConfiguration, apiProvider: providerIdentifiers.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 private getTaskHistoryItem(id: string): HistoryItem | undefined { const historyItem = this.taskHistoryStore.get(id) // Once initialization and migration succeed, the file-backed store is authoritative. // Legacy global state is only a fallback while startup is incomplete or has failed. if (historyItem || this.taskHistoryStoreInitialized) { return historyItem } return (this.getGlobalState("taskHistory") ?? []).find((item) => item.id === id) } async getTaskWithId(id: string): Promise<{ historyItem: HistoryItem taskDirPath: string apiConversationHistoryFilePath: string uiMessagesFilePath: string apiConversationHistory: Anthropic.MessageParam[] }> { const historyItem = this.getTaskHistoryItem(id) if (!historyItem) { throw new Error("Task not found") } 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) let apiConversationHistory: Anthropic.MessageParam[] = [] if (fileExists) { try { apiConversationHistory = JSON.parse(await fs.readFile(apiConversationHistoryFilePath, "utf8")) } catch (error) { console.warn( `[getTaskWithId] api_conversation_history.json corrupted for task ${id}, returning empty history: ${error instanceof Error ? error.message : String(error)}`, ) } } else { console.warn( `[getTaskWithId] api_conversation_history.json missing for task ${id}, returning empty history`, ) } return { historyItem, taskDirPath, apiConversationHistoryFilePath, uiMessagesFilePath, apiConversationHistory, } } async getTaskWithAggregatedCosts(taskId: string): Promise<{ historyItem: HistoryItem aggregatedCosts: AggregatedCosts }> { const { historyItem } = await this.getTaskWithId(taskId) const aggregatedCosts = await aggregateTaskCostsRecursive(taskId, async (id: string) => { const result = await this.getTaskWithId(id) return result.historyItem }) return { historyItem, aggregatedCosts } } async showTaskWithId(id: string) { const startedAt = Date.now() this.diagnosticsRecorder.record({ boundary: "task-navigation", phase: "start", type: "show-task", taskId: id, }) try { 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" }) this.diagnosticsRecorder.record({ boundary: "task-navigation", phase: "success", type: "show-task", taskId: id, elapsedMs: Date.now() - startedAt, messageCount: this.getCurrentTask()?.clineMessages.length ?? 0, }) } catch (error) { this.diagnosticsRecorder.record({ boundary: "task-navigation", phase: "failure", type: "show-task", taskId: id, elapsedMs: Date.now() - startedAt, }) throw error } } async exportTaskWithId(id: string) { const { historyItem, apiConversationHistory } = await this.getTaskWithId(id) const fileName = getTaskFileName(historyItem.ts) const defaultUri = await resolveDefaultSaveUri(this.contextProxy, "lastTaskExportPath", fileName, { useWorkspace: false, fallbackDir: path.join(os.homedir(), "Downloads"), }) const saveUri = await downloadTask(apiConversationHistory, defaultUri) if (saveUri) { await saveLastExportPath(this.contextProxy, "lastTaskExportPath", saveUri) } } /* * Sets a user-assigned display title for a history item. A blank title * clears it so the history falls back to the original prompt. */ async renameTaskWithId(id: string, title: string | undefined) { if (!this.taskHistoryStore.get(id)) { this.log(`[renameTaskWithId] task ${id} not found in history`) return } // Read and write under the store lock so a queued task save or delegation // transition cannot be overwritten by a stale snapshot of the item. const trimmed = title?.trim() await this.taskHistoryStore.atomicReadAndUpdate(id, (historyItem) => ({ ...historyItem, title: trimmed ? trimmed : undefined, })) this.recentTasksCache = undefined if (this.isViewLaunched) { const updatedItem = this.taskHistoryStore.get(id) if (updatedItem) { await this.postMessageToWebview({ type: "taskHistoryItemUpdated", taskHistoryItem: updatedItem }) } } } /* Condenses a task's message history to use fewer tokens. */ async condenseTaskContext(taskId: string) { const task = this.taskRegistry.getById(taskId) 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 history, and deletes its checkpoints and delete the task folder // If the task has subtasks (childIds), they will also be deleted recursively async deleteTaskWithId(id: string, cascadeSubtasks: boolean = true) { try { await this.getTaskWithId(id) // Collect all task IDs to delete (parent + all subtasks) const allIdsToDelete: string[] = [id] if (cascadeSubtasks) { // Recursively collect all child IDs const collectChildIds = async (taskId: string): Promise => { try { const { historyItem: item } = await this.getTaskWithId(taskId) if (item.childIds && item.childIds.length > 0) { for (const childId of item.childIds) { allIdsToDelete.push(childId) await collectChildIds(childId) } } } catch { // Child task may already be deleted or not found, continue console.log(`[deleteTaskWithId] child task ${taskId} not found, skipping`) } } await collectChildIds(id) } // Remove from stack if any of the tasks to delete are in the current task stack for (const taskId of allIdsToDelete) { if (taskId === this.getCurrentTask()?.taskId) { // Close the current task instance; delegation flows will be handled via metadata if applicable. await this.removeClineFromStack() break } } // Delete all tasks from state in one batch await this.taskHistoryStore.deleteMany(allIdsToDelete) this.recentTasksCache = undefined // Delete associated shadow repositories or branches and task directories const globalStorageDir = this.contextProxy.globalStorageUri.fsPath const workspaceDir = this.cwd const { getTaskDirectoryPath } = await import("../../utils/storage") const globalStoragePath = this.contextProxy.globalStorageUri.fsPath for (const taskId of allIdsToDelete) { try { await ShadowCheckpointService.deleteTask({ taskId, globalStorageDir, workspaceDir }) } catch (error) { console.error( `[deleteTaskWithId${taskId}] failed to delete associated shadow repository or branch: ${error instanceof Error ? error.message : String(error)}`, ) } // Delete the task directory try { const dirPath = await getTaskDirectoryPath(globalStoragePath, taskId) await fs.rm(dirPath, { recursive: true, force: true }) console.log(`[deleteTaskWithId${taskId}] removed task directory`) } catch (error) { console.error( `[deleteTaskWithId${taskId}] failed to remove task directory: ${error instanceof Error ? error.message : String(error)}`, ) } } await this.postStateToWebview() } 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) { await this.taskHistoryStore.delete(id) this.recentTasksCache = undefined await this.postStateToWebview() } async refreshWorkspace() { this.currentWorkspacePath = getWorkspacePath() await this.postStateToWebview() } async postStateToWebview() { const clineMessagesSeq = ++this.clineMessagesSeq const state = await this.getStateToPostToWebview() state.clineMessagesSeq = clineMessagesSeq await this.postMessageToWebview({ type: "state", state }) } /** * Like postStateToWebview but intentionally omits taskHistory. * * Rationale: * - taskHistory can be large and was being resent on every chat message update. * - The webview maintains taskHistory in-memory and receives updates via * `taskHistoryUpdated` / `taskHistoryItemUpdated`. */ async postStateToWebviewWithoutTaskHistory(): Promise { const clineMessagesSeq = ++this.clineMessagesSeq const state = await this.getStateToPostToWebview({ includeTaskHistory: false }) state.clineMessagesSeq = clineMessagesSeq const { taskHistory: _omit, ...rest } = state await this.postMessageToWebview({ type: "state", state: rest }) } /** * Schedules a debounced state-post attempt. A call made while the debounce timer is active returns * the result of the most recent invocation, so awaiting this method does not wait for the trailing * invocation scheduled by that call. Use `flushPostStateToWebviewThrottled()` to force and await any * pending trailing invocation before continuing. */ async postStateToWebviewThrottled(): Promise { if (this._disposed) { return } await this._postStateToWebviewThrottled() } async flushPostStateToWebviewThrottled(): Promise { if (this._disposed) { return } await this._postStateToWebviewThrottled.flush() } /** * Like postStateToWebview but intentionally omits both clineMessages and taskHistory. * * Rationale: * - Cloud event handlers (auth, settings, user-info) and mode changes trigger state pushes * that have nothing to do with chat messages. Including clineMessages in these pushes * creates race conditions where a stale snapshot of clineMessages (captured during async * getStateToPostToWebview) overwrites newer messages the task has streamed in the meantime. * - This method ensures cloud/mode events only push the state fields they actually affect * (cloud auth, org settings, profiles, etc.) without interfering with task message streaming. */ async postStateToWebviewWithoutClineMessages(): Promise { const state = await this.getStateToPostToWebview({ includeTaskHistory: false }) const { clineMessages: _omitMessages, taskHistory: _omitHistory, ...rest } = state await this.postMessageToWebview({ type: "state", state: rest }) } /** * 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 await 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 await 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.", ) } } } /** * 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(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({ includeTaskHistory = true }: GetStateOptions = {}): Promise { // Ensure the store is initialized before reading task history await this.taskHistoryStore.initialized const { apiConfiguration, lastShownAnnouncementId, customInstructions, alwaysAllowReadOnly, alwaysAllowReadOnlyOutsideWorkspace, allowedReadFiles, alwaysAllowWrite, alwaysAllowWriteOutsideWorkspace, alwaysAllowWriteProtected, allowedWriteFiles, alwaysAllowExecute, destructiveCommandGuardEnabled, alwaysDenyUnapprovedCommands, allowedCommands, deniedCommands, alwaysAllowMcp, alwaysAllowModeSwitch, alwaysAllowSubtasks, allowedMaxRequests, allowedMaxCost, autoCondenseContext, autoCondenseContextPercent, soundEnabled, ttsEnabled, ttsSpeed, enableCheckpoints, checkpointTimeout, soundVolume, writeDelayMs, diffFuzzyThreshold, terminalShellIntegrationTimeout, terminalShellIntegrationDisabled, collapseCommandOutputByDefault, terminalCommandDelay, terminalPowershellCounter, terminalZshClearEolMark, terminalZshOhMy, terminalZshP10k, terminalZdotdir, terminalProfile, mcpEnabled, currentApiConfigName, listApiConfigMeta, pinnedApiConfigs, mode, customModePrompts, customSupportPrompts, enhancementApiConfigId, autoApprovalEnabled, customModes, experiments, maxOpenTabsContext, maxWorkspaceFiles, disabledTools, telemetrySetting, showRooIgnoredFiles, enableSubfolderRules, language, maxImageFileSize, maxTotalImageSize, historyPreviewCollapsed, reasoningBlockCollapsed, chatFontSize, timeFormat, dateFormat, showRelativeTimes, enterBehavior, cloudUserInfo, cloudIsAuthenticated, sharingEnabled, publicSharingEnabled, organizationAllowList, organizationSettingsVersion, customCondensingPrompt, codebaseIndexConfig, codebaseIndexModels, profileThresholds, alwaysAllowFollowupQuestions, followupAutoApproveTimeoutMs, includeDiagnosticMessages, maxDiagnosticMessages, includeTaskHistoryInEnhance, includeCurrentTime, includeCurrentCost, maxGitStatusFiles, taskSyncEnabled, imageGenerationProvider, openRouterImageApiKey, openRouterImageGenerationSelectedModel, lockApiConfigAcrossModes, autoCloseZooOpenedFiles, autoCloseZooOpenedFilesAfterUserEdited, autoCloseZooOpenedNewFiles, } = await this.getState({ includeTaskHistory: false }) 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 { // Ignore this error. } const telemetryKey = process.env.POSTHOG_API_KEY const machineId = vscode.env.machineId const vscodeTelemetryEnabled = vscode.env.isTelemetryEnabled const mergedAllowedCommands = this.mergeAllowedCommands(allowedCommands) const mergedDeniedCommands = this.mergeDeniedCommands(deniedCommands) const cwd = this.cwd const currentTask = this.getCurrentTask() let currentTaskMode: string | undefined try { currentTaskMode = currentTask?.taskMode } catch { // A just-created task may still be initializing its mode; retain the persisted projection for this post. } const currentTaskApiConfigName = currentTask?.taskApiConfigName const currentTaskApiConfiguration = currentTask?.apiConfiguration let zooCodeState: { zooCodeIsAuthenticated: boolean zooCodeUserName: string | undefined zooCodeUserEmail: string | undefined zooCodeUserImage: string | undefined zooCodeBaseUrl: string deviceName: string } = { zooCodeIsAuthenticated: false, zooCodeUserName: undefined, zooCodeUserEmail: undefined, zooCodeUserImage: undefined, zooCodeBaseUrl: "https://www.zoocode.dev", deviceName: os.hostname(), } try { const { isZooCodeAuthenticated, getCachedZooCodeUserInfo, getZooCodeBaseUrl } = await import("../../services/zoo-code-auth") const userInfo = getCachedZooCodeUserInfo() zooCodeState = { zooCodeIsAuthenticated: await isZooCodeAuthenticated(), zooCodeUserName: userInfo.name, zooCodeUserEmail: userInfo.email, zooCodeUserImage: userInfo.image, zooCodeBaseUrl: getZooCodeBaseUrl(), deviceName: os.hostname(), } } catch { // Keep the default unauthenticated state if the optional Zoo Code auth service is unavailable. } return { version: this.context.extension?.packageJSON?.version ?? "", apiConfiguration: currentTaskApiConfiguration ?? apiConfiguration, customInstructions, alwaysAllowReadOnly: alwaysAllowReadOnly ?? false, alwaysAllowReadOnlyOutsideWorkspace: alwaysAllowReadOnlyOutsideWorkspace ?? false, allowedReadFiles: allowedReadFiles ?? [], alwaysAllowWrite: alwaysAllowWrite ?? false, alwaysAllowWriteOutsideWorkspace: alwaysAllowWriteOutsideWorkspace ?? false, alwaysAllowWriteProtected: alwaysAllowWriteProtected ?? false, allowedWriteFiles: allowedWriteFiles ?? [], alwaysAllowExecute: alwaysAllowExecute ?? false, destructiveCommandGuardEnabled, alwaysDenyUnapprovedCommands: alwaysDenyUnapprovedCommands ?? false, alwaysAllowMcp: alwaysAllowMcp ?? false, alwaysAllowModeSwitch: alwaysAllowModeSwitch ?? false, alwaysAllowSubtasks: alwaysAllowSubtasks ?? false, allowedMaxRequests, allowedMaxCost, autoCondenseContext: autoCondenseContext ?? true, autoCondenseContextPercent: autoCondenseContextPercent ?? 100, uriScheme: vscode.env.uriScheme, currentTaskId: currentTask?.taskId, currentTaskItem: currentTask?.taskId ? this.taskHistoryStore.get(currentTask.taskId) : undefined, clineMessages: currentTask?.clineMessages || [], currentTaskTodos: currentTask?.todoList || [], messageQueue: currentTask?.messageQueueService?.messages, taskHistory: includeTaskHistory ? this.taskHistoryStore.getAll().filter((item: HistoryItem) => item.ts && item.task) : [], soundEnabled: soundEnabled ?? false, ttsEnabled: ttsEnabled ?? false, ttsSpeed: ttsSpeed ?? 1.0, enableCheckpoints: enableCheckpoints ?? true, checkpointTimeout: checkpointTimeout ?? DEFAULT_CHECKPOINT_TIMEOUT_SECONDS, shouldShowAnnouncement: telemetrySetting !== "unset" && lastShownAnnouncementId !== this.latestAnnouncementId, allowedCommands: mergedAllowedCommands, deniedCommands: mergedDeniedCommands, soundVolume: soundVolume ?? 0.5, writeDelayMs: writeDelayMs ?? DEFAULT_WRITE_DELAY_MS, diffFuzzyThreshold: diffFuzzyThreshold ?? DEFAULT_DIFF_FUZZY_THRESHOLD, terminalShellIntegrationTimeout: terminalShellIntegrationTimeout ?? Terminal.defaultShellIntegrationTimeout, terminalShellIntegrationDisabled: terminalShellIntegrationDisabled ?? true, collapseCommandOutputByDefault: collapseCommandOutputByDefault ?? DEFAULT_COLLAPSE_COMMAND_OUTPUT_BY_DEFAULT, terminalCommandDelay: terminalCommandDelay ?? 0, terminalPowershellCounter: terminalPowershellCounter ?? false, terminalZshClearEolMark: terminalZshClearEolMark ?? true, terminalZshOhMy: terminalZshOhMy ?? false, terminalZshP10k: terminalZshP10k ?? false, terminalZdotdir: terminalZdotdir ?? false, terminalProfile, mcpEnabled: mcpEnabled ?? true, currentApiConfigName: currentTask ? currentTaskApiConfigName : currentApiConfigName, listApiConfigMeta: listApiConfigMeta ?? [], pinnedApiConfigs: pinnedApiConfigs ?? {}, mode: currentTaskMode ?? 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, disabledTools, telemetrySetting, telemetryKey, machineId, vscodeTelemetryEnabled, showRooIgnoredFiles: showRooIgnoredFiles ?? false, enableSubfolderRules: enableSubfolderRules ?? false, language: language ?? formatLanguage(vscode.env.language), renderContext: this.renderContext, maxImageFileSize: maxImageFileSize ?? 5, maxTotalImageSize: maxTotalImageSize ?? 20, settingsImportedAt: this.settingsImportedAt, historyPreviewCollapsed: historyPreviewCollapsed ?? false, reasoningBlockCollapsed: reasoningBlockCollapsed ?? true, chatFontSize, timeFormat: timeFormat ?? "auto", dateFormat: dateFormat ?? "auto", showRelativeTimes: showRelativeTimes ?? true, enterBehavior: enterBehavior ?? "send", cloudUserInfo, cloudIsAuthenticated: cloudIsAuthenticated ?? false, cloudAuthSkipModel: this.context.globalState.get("roo-auth-skip-model") ?? false, cloudOrganizations, sharingEnabled: sharingEnabled ?? false, publicSharingEnabled: publicSharingEnabled ?? false, organizationAllowList, organizationSettingsVersion, customCondensingPrompt, codebaseIndexModels: codebaseIndexModels ?? EMBEDDING_MODEL_PROFILES, codebaseIndexConfig: { codebaseIndexEnabled: codebaseIndexConfig?.codebaseIndexEnabled ?? false, codebaseIndexQdrantUrl: codebaseIndexConfig?.codebaseIndexQdrantUrl ?? "http://localhost:6333", codebaseIndexEmbedderProvider: codebaseIndexConfig?.codebaseIndexEmbedderProvider ?? providerIdentifiers.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, }, // Phase 1 cloud removal: do not let Cloud-auth MDM enforcement force login-only UI flows. mdmCompliant: undefined, profileThresholds: profileThresholds ?? {}, cloudApiUrl: getRooCodeApiUrl(), hasOpenedModeSelector: this.getGlobalState("hasOpenedModeSelector") ?? false, lockApiConfigAcrossModes: lockApiConfigAcrossModes ?? 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, imageGenerationProvider, openRouterImageApiKey, openRouterImageGenerationSelectedModel, autoCloseZooOpenedFiles: autoCloseZooOpenedFiles ?? DEFAULT_AUTO_CLOSE_ZOO_OPENED_FILES, autoCloseZooOpenedFilesAfterUserEdited: autoCloseZooOpenedFilesAfterUserEdited ?? DEFAULT_AUTO_CLOSE_ZOO_OPENED_FILES_AFTER_USER_EDITED, autoCloseZooOpenedNewFiles: autoCloseZooOpenedNewFiles ?? DEFAULT_AUTO_CLOSE_ZOO_OPENED_NEW_FILES, openAiCodexIsAuthenticated: await (async () => { try { const { openAiCodexOAuthManager } = await import("../../integrations/openai-codex/oauth") return await openAiCodexOAuthManager.isAuthenticated() } catch { return false } })(), kimiCodeIsAuthenticated: await (async () => { try { const { kimiCodeOAuthManager } = await import("../../integrations/kimi-code/oauth") return await kimiCodeOAuthManager.isAuthenticated() } catch { return false } })(), kimiCodeOAuthState: await (async () => { try { const { kimiCodeOAuthManager } = await import("../../integrations/kimi-code/oauth") return kimiCodeOAuthManager.getState() } catch { return undefined } })(), ...zooCodeState, platform: process.platform, arch: process.arch, debug: vscode.workspace.getConfiguration(Package.name).get("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({ includeTaskHistory = true }: GetStateOptions = {}): Promise< Omit< ExtensionState, "clineMessages" | "renderContext" | "hasOpenedModeSelector" | "version" | "shouldShowAnnouncement" > > { const stateValues = this.contextProxy.getValues() const customModes = await this.customModesManager.getCustomModes() // Determine apiProvider with the same logic as before, while filtering retired providers. const apiProvider: ProviderName = stateValues.apiProvider && !isRetiredProvider(stateValues.apiProvider) ? stateValues.apiProvider : providerIdentifiers.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)}`, ) } const sharingEnabled: boolean = false const publicSharingEnabled: boolean = false 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)}`, ) } const taskSyncEnabled: boolean = 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, allowedReadFiles: stateValues.allowedReadFiles ?? [], alwaysAllowWrite: stateValues.alwaysAllowWrite ?? false, alwaysAllowWriteOutsideWorkspace: stateValues.alwaysAllowWriteOutsideWorkspace ?? false, alwaysAllowWriteProtected: stateValues.alwaysAllowWriteProtected ?? false, allowedWriteFiles: stateValues.allowedWriteFiles ?? [], alwaysAllowExecute: stateValues.alwaysAllowExecute ?? false, destructiveCommandGuardEnabled: stateValues.destructiveCommandGuardEnabled ?? DEFAULT_DESTRUCTIVE_COMMAND_GUARD_ENABLED, alwaysDenyUnapprovedCommands: stateValues.alwaysDenyUnapprovedCommands ?? DEFAULT_ALWAYS_DENY_UNAPPROVED_COMMANDS, alwaysAllowMcp: stateValues.alwaysAllowMcp ?? false, alwaysAllowModeSwitch: stateValues.alwaysAllowModeSwitch ?? false, alwaysAllowSubtasks: stateValues.alwaysAllowSubtasks ?? false, alwaysAllowFollowupQuestions: stateValues.alwaysAllowFollowupQuestions ?? false, followupAutoApproveTimeoutMs: stateValues.followupAutoApproveTimeoutMs ?? 60000, diagnosticsEnabled: stateValues.diagnosticsEnabled ?? true, allowedMaxRequests: stateValues.allowedMaxRequests, allowedMaxCost: stateValues.allowedMaxCost, autoCondenseContext: stateValues.autoCondenseContext ?? true, autoCondenseContextPercent: stateValues.autoCondenseContextPercent ?? 100, taskHistory: includeTaskHistory ? this.taskHistoryStore.getAll() : [], allowedCommands: stateValues.allowedCommands, deniedCommands: stateValues.deniedCommands, soundEnabled: stateValues.soundEnabled ?? false, ttsEnabled: stateValues.ttsEnabled ?? false, ttsSpeed: stateValues.ttsSpeed ?? 1.0, enableCheckpoints: stateValues.enableCheckpoints ?? true, checkpointTimeout: stateValues.checkpointTimeout ?? DEFAULT_CHECKPOINT_TIMEOUT_SECONDS, soundVolume: stateValues.soundVolume, writeDelayMs: stateValues.writeDelayMs ?? DEFAULT_WRITE_DELAY_MS, diffFuzzyThreshold: stateValues.diffFuzzyThreshold ?? DEFAULT_DIFF_FUZZY_THRESHOLD, terminalShellIntegrationTimeout: stateValues.terminalShellIntegrationTimeout ?? Terminal.defaultShellIntegrationTimeout, terminalShellIntegrationDisabled: stateValues.terminalShellIntegrationDisabled ?? true, collapseCommandOutputByDefault: stateValues.collapseCommandOutputByDefault ?? DEFAULT_COLLAPSE_COMMAND_OUTPUT_BY_DEFAULT, terminalCommandDelay: stateValues.terminalCommandDelay ?? 0, terminalPowershellCounter: stateValues.terminalPowershellCounter ?? false, terminalZshClearEolMark: stateValues.terminalZshClearEolMark ?? true, terminalZshOhMy: stateValues.terminalZshOhMy ?? false, terminalZshP10k: stateValues.terminalZshP10k ?? false, terminalZdotdir: stateValues.terminalZdotdir ?? false, terminalProfile: stateValues.terminalProfile, mode: stateValues.mode ?? defaultModeSlug, language: stateValues.language ?? formatLanguage(vscode.env.language), mcpEnabled: stateValues.mcpEnabled ?? true, mcpServers: this.mcpHub?.getAllServers() ?? [], currentApiConfigName: stateValues.currentApiConfigName ?? "default", listApiConfigMeta: stateValues.listApiConfigMeta ?? [], pinnedApiConfigs: stateValues.pinnedApiConfigs ?? {}, modeApiConfigs: stateValues.modeApiConfigs ?? ({} as Record), 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, disabledTools: stateValues.disabledTools, telemetrySetting: stateValues.telemetrySetting || "unset", showRooIgnoredFiles: stateValues.showRooIgnoredFiles ?? false, enableSubfolderRules: stateValues.enableSubfolderRules ?? false, maxImageFileSize: stateValues.maxImageFileSize ?? 5, maxTotalImageSize: stateValues.maxTotalImageSize ?? 20, historyPreviewCollapsed: stateValues.historyPreviewCollapsed ?? false, reasoningBlockCollapsed: stateValues.reasoningBlockCollapsed ?? true, chatFontSize: stateValues.chatFontSize, timeFormat: stateValues.timeFormat ?? "auto", dateFormat: stateValues.dateFormat ?? "auto", showRelativeTimes: stateValues.showRelativeTimes ?? true, enterBehavior: stateValues.enterBehavior ?? "send", cloudUserInfo, cloudIsAuthenticated, sharingEnabled, publicSharingEnabled, organizationAllowList, organizationSettingsVersion, 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 ?? providerIdentifiers.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 ?? {}, lockApiConfigAcrossModes: this.context.workspaceState.get("lockApiConfigAcrossModes", false), 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, imageGenerationProvider: stateValues.imageGenerationProvider, openRouterImageApiKey: stateValues.openRouterImageApiKey, openRouterImageGenerationSelectedModel: stateValues.openRouterImageGenerationSelectedModel, autoCloseZooOpenedFiles: stateValues.autoCloseZooOpenedFiles, autoCloseZooOpenedFilesAfterUserEdited: stateValues.autoCloseZooOpenedFilesAfterUserEdited, autoCloseZooOpenedNewFiles: stateValues.autoCloseZooOpenedNewFiles, } } /** * Updates a task in the task history and optionally broadcasts the updated history to the webview. * Now delegates to TaskHistoryStore for per-task file persistence. * * This writer never owns the `title` field: `title` is written only by the rename path * (`renameTaskWithId` via `taskHistoryStore.atomicReadAndUpdate`). Callers here build items * from snapshots read before taking the store lock, so any title they carry can be stale. * * @param item The history item to update or add * @param options.broadcast Whether to broadcast the updated history to the webview (default: true) * @returns The updated task history array */ async updateTaskHistory(item: HistoryItem, options: { broadcast?: boolean } = {}): Promise { const { broadcast = true } = options // Drop the snapshot's title so upsertCore preserves the value held under the store lock // instead of treating a stale title as a changed field and reverting a concurrent rename. const { title: _title, ...itemWithoutTitle } = item const history = await this.taskHistoryStore.upsert(itemWithoutTitle) this.recentTasksCache = undefined // Broadcast the updated history to the webview if requested. // Prefer per-item updates to avoid repeatedly cloning/sending the full history. if (broadcast && this.isViewLaunched) { const updatedItem = this.taskHistoryStore.get(item.id) ?? itemWithoutTitle await this.postMessageToWebview({ type: "taskHistoryItemUpdated", taskHistoryItem: updatedItem }) } return history } /** * Broadcasts a task history update to the webview. * This sends a lightweight message with just the task history, rather than the full state. * @param history The task history to broadcast (if not provided, reads from the store) */ public async broadcastTaskHistoryUpdate(history?: HistoryItem[]): Promise { if (!this.isViewLaunched) { return } const taskHistory = history ?? this.taskHistoryStore.getAll() // Sort and filter the history the same way as getStateToPostToWebview const sortedHistory = taskHistory .filter((item: HistoryItem) => item.ts && item.task) .sort((a: HistoryItem, b: HistoryItem) => b.ts - a.ts) await this.postMessageToWebview({ type: "taskHistoryUpdated", taskHistory: sortedHistory, }) } // ContextProxy // @deprecated - Use `ContextProxy#setValue` instead. private async updateGlobalState(key: K, value: GlobalState[K]) { await this.contextProxy.setValue(key, value) } // @deprecated - Use `ContextProxy#getValue` instead. private getGlobalState(key: K) { return this.contextProxy.getValue(key) } public async setValue(key: K, value: RooCodeSettings[K]) { await this.contextProxy.setValue(key, value) } public getValue(key: K) { return this.contextProxy.getValue(key) } public getValues() { return this.contextProxy.getValues() } public async setValues(values: RooCodeSettings) { await this.contextProxy.setValues( withoutInvalidDisplaySettings(values, (key) => this.log(`[setValues] Ignored an invalid value for ${key}`)), ) } // 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 getCurrentWorkspaceCodeIndexScope(): CodeIndexWorkspaceScope | undefined { return CodeIndexManagerRegistry.getOrCreateScope(this.context) } /** * TaskProviderLike, TelemetryPropertiesProvider */ public getCurrentTask(): Task | undefined { return this.taskRegistry.current } private logWebviewHiddenDiagnostics(): void { const task = this.getCurrentTask() if (!task || task.abort || task.abandoned) { return } this.log( `[Zoo Code] Webview hidden during active task.\n` + ` taskId: ${task.taskId}\n` + ` messageCount: ${task.clineMessages.length}\n` + ` stackDepth: ${this.taskRegistry.length}\n` + ` timestamp: ${new Date().toISOString()}\n` + `If the panel appears gray after this, share this log with support@zoocode.dev`, ) } /** A heartbeat confirms liveness; only sustained health replenishes recovery attempts. */ public updateWebviewHeartbeat(): void { const now = Date.now() const health = recoveryHealth( { attempts: this.webviewRecoveryAttempts, lastHeartbeatAt: this.lastReceivedWebviewHeartbeatAt, healthySince: this.webviewHealthySince, }, now, ) this.webviewHealthySince = health.healthySince this.lastReceivedWebviewHeartbeatAt = now this.webviewHeartbeatCount++ // A new heartbeat invalidates the old warning action. Allow a fresh warning // if this renderer stalls again, without replenishing its retry budget. this.webviewRecoveryFailureReported = false this.webviewRecoveryStopLogged = false if (this.webviewRecoveryConfirmation) { this.log("[Zoo Code] Webview heartbeat returned after recovery") } this.resetWebviewHeartbeatGrace() if (health.attempts === 0 && this.webviewRecoveryAttempts > 0) { this.log("[Zoo Code] Webview recovery confirmed by sustained heartbeats") } this.webviewRecoveryAttempts = health.attempts } /** Restarts the grace window and invalidates pending recovery without recording a heartbeat. */ private resetWebviewHeartbeatGrace(): void { this.clearWebviewRecoveryConfirmation() this.lastWebviewHeartbeatAt = Date.now() this.webviewHeartbeatRevision++ } /** * Starts (or restarts) the watchdog that detects a dead webview renderer * process. Hidden webviews throttle timers, so becoming visible resets the * grace window instead of counting throttled heartbeats as a crash. */ private startWebviewWatchdog(): void { this.resetWebviewHeartbeatGrace() this.lastWebviewWatchdogTickAt = Date.now() if (this.webviewWatchdogInterval) { clearInterval(this.webviewWatchdogInterval) } this.webviewWatchdogInterval = setInterval(() => { const now = Date.now() const tickGap = now - this.lastWebviewWatchdogTickAt this.lastWebviewWatchdogTickAt = now // A tick far later than scheduled means the host was suspended or // blocked; the wall clock jumped while both timers were paused. // Grant a fresh grace window instead of reloading a live renderer. if (tickGap > ClineProvider.WEBVIEW_WATCHDOG_TICK_MS * 1.5) { this.resetWebviewHeartbeatGrace() return } if (this.view?.visible !== true) { return } if (now - this.lastWebviewHeartbeatAt <= ClineProvider.WEBVIEW_HEARTBEAT_STALE_MS) return if (this.webviewRecoveryAttempts >= ClineProvider.WEBVIEW_RECOVERY_ATTEMPT_LIMIT) { // Let the final attempt finish generating HTML before declaring recovery stopped. if (this.webviewRecoveryInFlightEpoch !== this.webviewRecoveryEpoch) { this.reportWebviewRecoveryFailure("Recovery attempt limit reached without sustained heartbeats") } return } this.log("[Zoo Code] Webview heartbeat stale while visible; attempting recovery") void this.reloadWebviewForRecovery() }, ClineProvider.WEBVIEW_WATCHDOG_TICK_MS) } /** Stops the renderer heartbeat watchdog; the webview it watches is gone. */ private stopWebviewWatchdog(): void { this.clearWebviewRecoveryConfirmation() if (this.webviewWatchdogInterval) { clearInterval(this.webviewWatchdogInterval) this.webviewWatchdogInterval = null } } /** * First attempt: regenerate this view's HTML. A missing heartbeat escalates * to host recreation because HTML delivery cannot restart a dead renderer. */ private async reloadWebviewForRecovery(): Promise { const view = this.view if (!view?.webview || this.webviewRecoveryAttempts >= ClineProvider.WEBVIEW_RECOVERY_ATTEMPT_LIMIT) { return } // Capture the epoch so a disposal or view replacement can invalidate // this operation while the HTML is being generated. const epoch = this.webviewRecoveryEpoch // A recovery already awaiting its HTML generation owns this view; a // second one would only pile another forced reload onto it. Ownership // is scoped to the epoch so a stale recovery (its view disposed or // replaced, epoch bumped) never blocks the replacement view's recovery. const attempt = nextRecoveryAttempt(this.webviewRecoveryAttempts, this.webviewRecoveryInFlightEpoch === epoch) if (attempt === undefined) return this.webviewRecoveryInFlightEpoch = epoch // Count the whole attempt, including HTML-generation failures. this.webviewRecoveryAttempts = attempt this.lastReceivedWebviewHeartbeatAt = undefined this.webviewHealthySince = undefined // A heartbeat that arrives while the HTML is being generated means the // renderer is alive again; comparing revisions (not timestamps) lets the // post-await check catch heartbeats that land in the same millisecond. const heartbeatRevision = this.webviewHeartbeatRevision try { const html = await this.getWebviewHtml(view.webview) // The await yields; assigning html now that the provider is disposed, // the watched view was disposed/replaced, the renderer heartbeat // recovered, or the view hid again would either touch a dead or // unrelated webview or reload one that no longer needs recovery. if ( !canCommitRecovery({ disposed: this._disposed, isCurrent: this.view === view, epochMatches: this.webviewRecoveryEpoch === epoch, heartbeatMatches: this.webviewHeartbeatRevision === heartbeatRevision, visible: view.visible === true, }) ) { return } view.webview.html = html this.resetWebviewHeartbeatGrace() const revision = this.webviewHeartbeatRevision const requestedAt = Date.now() this.webviewRecoveryConfirmation = setTimeout(() => { this.webviewRecoveryConfirmation = undefined // A late confirmation timer can be a host suspend, just like a // late watchdog tick. Let renderer timers resume before escalating. if (Date.now() - requestedAt > ClineProvider.WEBVIEW_RECOVERY_CONFIRM_MS * 1.5) { this.resetWebviewHeartbeatGrace() return } if ( this._disposed || this.view !== view || this.webviewRecoveryEpoch !== epoch || this.webviewHeartbeatRevision !== revision || !view.visible ) return void this.recreateWebviewHostForRecovery(view).catch((error: unknown) => { this.reportWebviewRecoveryFailure( `Host recreation failed: ${error instanceof Error ? error.message : String(error)}`, ) }) }, ClineProvider.WEBVIEW_RECOVERY_CONFIRM_MS) } catch (error) { this.log(`[Zoo Code] Failed to reload webview: ${error instanceof Error ? error.message : String(error)}`) } finally { // Clear ownership only while this completion still holds it; a // stale recovery must not release the replacement's in-flight state. this.webviewRecoveryInFlightEpoch = releaseRecoveryOwner(this.webviewRecoveryInFlightEpoch, epoch) } } private clearWebviewRecoveryConfirmation(): void { if (this.webviewRecoveryConfirmation) clearTimeout(this.webviewRecoveryConfirmation) this.webviewRecoveryConfirmation = undefined } /** Recreate the host/transport: sending more HTML cannot revive its dead renderer. */ private async recreateWebviewHostForRecovery(view: vscode.WebviewView | vscode.WebviewPanel): Promise { if (this.webviewHostRecoveryInProgress || this._disposed || this.view !== view || !view.visible) return const revision = this.webviewHeartbeatRevision const generation = this.webviewRecoveryRequestGeneration let started = false let sidebarFocused = false try { let commands: string[] | undefined if (!("onDidChangeViewState" in view)) { // Generated VS Code view commands are not an extension API contract. // Check availability and cover them against the pinned real host in E2E. commands = await vscode.commands.getCommands(true) } if ( !isRecoveryGenerationCurrent(generation, this.webviewRecoveryRequestGeneration) || this.webviewHostRecoveryInProgress || this._disposed || this.view !== view || !view.visible || this.webviewHeartbeatRevision !== revision ) return if ( commands && (!commands.includes(`${ClineProvider.sideBarId}.removeView`) || !commands.includes(`${ClineProvider.sideBarId}.focus`)) ) { throw new Error("VS Code does not provide the scoped sidebar recreation commands") } this.webviewHostRecoveryInProgress = true started = true this.log("[Zoo Code] No heartbeat after HTML reload; recreating this webview host") if ("onDidChangeViewState" in view) { const replacement = vscode.window.createWebviewPanel( ClineProvider.tabPanelId, view.title, { viewColumn: view.viewColumn ?? vscode.ViewColumn.Active, preserveFocus: true }, { ...view.options, ...view.webview.options }, ) replacement.iconPath = view.iconPath // Resolve publishes the replacement before yielding. The old view's // disposal must see it no longer owns this provider and its tasks. const resolved = this.resolveWebviewView(replacement) if (this.view === replacement) view.dispose() else replacement.dispose() await resolved } else { this.pendingSidebarRecovery = true await vscode.commands.executeCommand(`${ClineProvider.sideBarId}.removeView`) if (this._disposed || !isRecoveryGenerationCurrent(generation, this.webviewRecoveryRequestGeneration)) return await vscode.commands.executeCommand(`${ClineProvider.sideBarId}.focus`, { preserveFocus: true }) sidebarFocused = true if (!isRecoveryGenerationCurrent(generation, this.webviewRecoveryRequestGeneration)) return await this.waitForRecoveryView(view, generation) } } catch (error) { if (!isRecoveryGenerationCurrent(generation, this.webviewRecoveryRequestGeneration)) return // Only a successful focus can leave a delayed replacement pending. // Command failures must not mark a later manual open as recovery. this.pendingSidebarRecovery = pendingSidebarAfterCommandFailure({ started, focusSucceeded: sidebarFocused, pending: this.pendingSidebarRecovery, }) // A late discovery failure must not stop a recovered/replaced view. // Once recreation starts, disposal of the old view is expected. if ( this._disposed || (!started && (this.view !== view || this.webviewHeartbeatRevision !== revision || !view.visible)) ) return if (started && this.lastReceivedWebviewHeartbeatAt !== undefined) return // A published replacement may still be initializing after the wait // expires. Its resolver will restore the watchdog on failure; keep the // remaining attempts available instead of turning this into a dead end. const replacementPublished = this.view !== undefined && this.view !== view this.reportWebviewRecoveryFailure( `Host recreation failed: ${error instanceof Error ? error.message : String(error)}`, !replacementPublished, ) } finally { if (started && isRecoveryGenerationCurrent(generation, this.webviewRecoveryRequestGeneration)) this.webviewHostRecoveryInProgress = false } } private async waitForRecoveryView( previous: vscode.WebviewView | vscode.WebviewPanel | undefined, generation: number, ): Promise { await pWaitFor( () => this._disposed || !isRecoveryGenerationCurrent(generation, this.webviewRecoveryRequestGeneration) || (this.view !== undefined && this.view !== previous && this.webviewWatchdogInterval !== null), { interval: 50, timeout: 10_000 }, ) } /** An explicit user action, unlike an ambiguous VS Code resolve callback. */ public async retryWebviewRecovery(expectedGeneration = this.webviewRecoveryRequestGeneration): Promise { if (this._disposed || !isRecoveryGenerationCurrent(expectedGeneration, this.webviewRecoveryRequestGeneration)) return const fresh = beginExplicitRecovery(this.webviewRecoveryRequestGeneration) const generation = fresh.generation this.webviewRecoveryRequestGeneration = generation const view = this.view if (!view || !("onDidChangeViewState" in view)) this.pendingSidebarRecovery = true this.webviewRecoveryEpoch++ this.webviewRecoveryInFlightEpoch = undefined this.webviewHostRecoveryInProgress = false // Count this user-requested attempt in its fresh, bounded incident. this.webviewRecoveryAttempts = fresh.attempts this.webviewRecoveryFailureReported = fresh.reported this.webviewRecoveryStopLogged = fresh.stopLogged this.lastReceivedWebviewHeartbeatAt = undefined this.webviewHealthySince = undefined this.resetWebviewHeartbeatGrace() let focused = false try { if (view) { if ("onDidChangeViewState" in view) view.reveal(view.viewColumn ?? vscode.ViewColumn.Active, true) else view.show(true) await pWaitFor( () => this._disposed || !isRecoveryGenerationCurrent(generation, this.webviewRecoveryRequestGeneration) || this.view !== view || view.visible, { interval: 50, timeout: 10_000 }, ) if ( this._disposed || !isRecoveryGenerationCurrent(generation, this.webviewRecoveryRequestGeneration) || this.view !== view ) return await this.recreateWebviewHostForRecovery(view) } else { // The earlier timeout may have removed the only sidebar. The next // resolve, whatever triggered it, repairs this explicitly renewed incident. this.pendingSidebarRecovery = true this.webviewHostRecoveryInProgress = true await vscode.commands.executeCommand(`${ClineProvider.sideBarId}.focus`, { preserveFocus: true }) focused = true if (!isRecoveryGenerationCurrent(generation, this.webviewRecoveryRequestGeneration)) return await this.waitForRecoveryView(undefined, generation) } } catch (error) { if (this._disposed || !isRecoveryGenerationCurrent(generation, this.webviewRecoveryRequestGeneration)) return this.pendingSidebarRecovery = pendingSidebarAfterCommandFailure({ started: true, focusSucceeded: focused, pending: this.pendingSidebarRecovery, }) const replacementPublished = this.view !== undefined && this.view !== view this.reportWebviewRecoveryFailure( `Explicit interface recovery failed: ${String(error)}`, !replacementPublished, ) } finally { if (isRecoveryGenerationCurrent(generation, this.webviewRecoveryRequestGeneration)) this.webviewHostRecoveryInProgress = false } } private reportWebviewRecoveryFailure(reason: string, stopRecovery = true): void { if (this._disposed) return // A terminal failure still stops retries if an earlier retryable failure // already showed the notification for this incident. const failure = recordRecoveryFailure( { attempts: this.webviewRecoveryAttempts, reported: this.webviewRecoveryFailureReported, stopLogged: this.webviewRecoveryStopLogged, }, stopRecovery, ) this.webviewRecoveryAttempts = failure.attempts this.webviewRecoveryFailureReported = failure.reported this.webviewRecoveryStopLogged = failure.stopLogged if (failure.log) this.log(`[Zoo Code] Webview recovery ${stopRecovery ? "stopped" : "will retry"}: ${reason}`) if (!failure.notify) return const heartbeatCount = this.webviewHeartbeatCount const generation = this.webviewRecoveryRequestGeneration void Promise.resolve( vscode.window.showWarningMessage( "Zoo Code's interface is not responding. Retry interface recovery without restarting running tasks. Reloading the VS Code window will restart extensions and may interrupt running tasks.", "Retry Interface Recovery", "Reload Window", "Show Logs", ), ) .then(async (choice) => { const action = recoveryWarningAction( choice, this._disposed, this.webviewHeartbeatCount === heartbeatCount, isRecoveryGenerationCurrent(generation, this.webviewRecoveryRequestGeneration), ) if (action === "retry") await this.retryWebviewRecovery(generation) if (action === "reload") { await vscode.commands.executeCommand("workbench.action.reloadWindow") } if (action === "logs") this.outputChannel.show() }) .then(undefined, (error: unknown) => { this.log(`[Zoo Code] Recovery notification failed: ${String(error)}`) }) } /** Builds the webview HTML using the same path as resolveWebviewView (HMR in development). */ private async getWebviewHtml(webview: vscode.Webview): Promise { return this.contextProxy.extensionMode === vscode.ExtensionMode.Development && process.env.ROO_CODE_THEME_FIXTURE_PROBE !== "1" ? await this.getHMRHtmlContent(webview) : await this.getHtmlContent(webview) } public getRecentTasks(): string[] { if (this.recentTasksCache) { return this.recentTasksCache } const history = this.taskHistoryStore.getAll() 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 & { handoffExecutionContext?: DelegatedChildContext } = {}, configuration: RooCodeSettings = {}, ): Promise { 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) } // Register custom modes so the CustomModesManager knows about them. // setValues writes to global state, but the manager overwrites that // when it merges .roomodes + global settings on refresh. Persisting // via updateCustomMode ensures modes survive the merge cycle. if (configuration.customModes?.length) { for (const mode of configuration.customModes) { await this.customModesManager.updateCustomMode(mode.slug, mode) } } } const { apiConfiguration, enableCheckpoints, checkpointTimeout, experiments, organizationAllowList, diffFuzzyThreshold, } = await this.getState() const effectiveApiConfiguration = getEffectiveTaskApiConfiguration( apiConfiguration, options.handoffExecutionContext, ) // Single-open-task invariant: always enforce for user-initiated top-level tasks. if (!parentTask) { await this.evictCurrentTask().catch(() => { // Non-fatal }) } if (!ProfileValidator.isProfileAllowed(effectiveApiConfiguration, organizationAllowList)) { throw new OrganizationAllowListViolationError(t("common:errors.violated_organization_allowlist")) } const task = new Task({ provider: this, apiConfiguration, enableCheckpoints, checkpointTimeout, consecutiveMistakeLimit: effectiveApiConfiguration.consecutiveMistakeLimit, toolRepetitionSoftLimit: effectiveApiConfiguration.toolRepetitionSoftLimit, task: text, images, experiments, rootTask: this.taskRegistry.getAll()[0], parentTask, taskNumber: this.taskRegistry.length + 1, onCreated: this.taskCreationCallback, initialTodos: options.initialTodos, // Ensure this task is present in the registry before startTask() emits // its initial state update, so state.currentTaskId is available ASAP. startTask: false, diffFuzzyThreshold, ...options, rateLimitClock: this.rateLimitClock, }) await this.addClineToStack(task) if (options.startTask !== false) { scheduleTask(this.taskScheduler, task, "createTask") } this.log( `[createTask] ${task.parentTask ? "child" : "parent"} task ${task.taskId}.${task.instanceId} instantiated`, ) return task } public async cancelTask(): Promise { const task = this.getCurrentTask() if (!task) { return } console.log(`[cancelTask] cancelling task ${task.taskId}.${task.instanceId}`) await this.cancelTaskInternal(task) } private async cancelTaskInternal(task: Task): Promise { let historyItem: HistoryItem | undefined try { const history = await this.getTaskWithId(task.taskId) historyItem = history.historyItem } catch (error) { // During task startup there is a short window where currentTask exists // but task history has not been persisted yet. Cancelling should still // abort safely; we just skip post-cancel rehydration in that case. if (error instanceof Error && error.message === "Task not found") { this.log(`[cancelTask] task history missing for ${task.taskId}; skipping rehydrate`) } else { throw error } } // Preserve parent and root task information for history item. let rootTask = task.rootTask let parentTask = task.parentTask // Mark this as a user-initiated cancellation so provider-only rehydration can occur task.setAbortReason("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() // Kick off abort (sets abort flag synchronously; stream exit and final saveClineMessages // happen asynchronously). We capture the promise so we can await its completion below — // this ensures task.initialStatus ("active") cannot overwrite "interrupted" after we // persist it (issue #560). const abortPromise = task.abortTask() // Immediately mark the original instance as abandoned to prevent any residual activity task.requestAbandon() 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") }) // Wait for abortTask to fully settle (including its final saveClineMessages write) // before we persist "interrupted", so our write is always the last one. await abortPromise.catch(() => {}) // 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 } } if (!historyItem) { return } if (task.parentTaskId) { try { await this.runDelegationTransition(task.parentTaskId, async () => { const { historyItem: parentHistory } = await this.getTaskWithId(task.parentTaskId!) if (parentHistory?.status === "delegated" && parentHistory?.awaitingChildId === task.taskId) { // Refresh the child after acquiring the parent transition lock. The pre-abort // history snapshot can be stale if another serialized path interrupted it. historyItem = this.taskHistoryStore.get(task.taskId) ?? (await this.getTaskWithId(task.taskId)).historyItem // Mark the child interrupted and leave parent delegated with awaitingChildId // intact — the user can resume this child later and it will report back. // A previous cancellation may already have persisted the interrupted status // before its caller lost the response. Treat that replay as success without // weakening the lifecycle state machine's self-loop rejection. if (historyItem!.status !== "interrupted") { historyItem = interruptDelegatedChild(parentHistory, historyItem!) await this.updateTaskHistory(historyItem) this.log( `[cancelTask] Marked child ${task.taskId} interrupted; parent ${task.parentTaskId} stays delegated`, ) } else { this.log( `[cancelTask] Child ${task.taskId} is already interrupted; parent ${task.parentTaskId} stays delegated`, ) } // Clear any stale fail-closed entry from a prior failed cancel attempt so // reopenParentFromDelegation is not incorrectly blocked on resume. this.cancelledDelegationChildIds.delete(task.taskId) } }) } catch (error) { // Fail closed: if we cannot persist the interrupted status, sever the link // so later completions don't reopen a stale delegated parent. parentTask = undefined rootTask = undefined this.cancelledDelegationChildIds.add(task.taskId) historyItem = { ...historyItem, parentTaskId: undefined, rootTaskId: undefined, } try { await this.updateTaskHistory(historyItem) } catch (historyError) { this.log( `[cancelTask] Failed to persist interrupted child state for ${task.taskId}: ${ historyError instanceof Error ? historyError.message : String(historyError) }`, ) throw historyError } this.log( `[cancelTask] Failed to mark child interrupted for ${task.taskId}: ${ error instanceof Error ? error.message : String(error) }`, ) } } // 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 { const task = this.taskRegistry.current if (task) { 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 { return DEFAULT_MODES.map(({ slug, name }) => ({ slug, name })) } } public async getMode(): Promise { const { mode } = await this.getState() return mode } public async setMode(mode: string): Promise { 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 { const { currentApiConfigName = "default" } = await this.getState() return currentApiConfigName } public async setProviderProfile(name: string): Promise { 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, releaseChannel: Package.releaseChannel, 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 { 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, } } const apiProvider = apiConfiguration?.apiProvider return { language, mode, taskId: task?.taskId, parentTaskId: task?.parentTaskId, apiProvider: apiProvider && !isRetiredProvider(apiProvider) ? apiProvider : undefined, modelId: task?.api?.getModel().id, diffStrategy: task?.diffStrategy?.getName(), isSubtask: task ? !!task.parentTaskId : undefined, ...(todos && { todos }), } } private async getGitProperties(): Promise { if (!this._gitProperties) { this._gitProperties = await getWorkspaceGitInfo() } return this._gitProperties } public get gitProperties(): GitProperties | undefined { return this._gitProperties } public async getTelemetryProperties(): Promise { return { ...this.getAppProperties(), ...this.getCloudProperties(), ...(await this.getTaskProperties()), ...(await this.getGitProperties()), } } /** Workspace explicitly associated with this provider, without an active-editor fallback. */ public get workspacePath(): string | undefined { return this.currentWorkspacePath } 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 pendingActionId?: string }): Promise { return runDelegationTransition(ClineProvider.delegationTransitionLocks, params.parentTaskId, () => ClineProvider.prototype.delegateParentAndOpenChildUnlocked.call(this, params), ) } private async delegateParentAndOpenChildUnlocked(params: { parentTaskId: string message: string initialTodos: TodoItem[] mode: string pendingActionId?: string }): Promise { const { parentTaskId, message, initialTodos, mode, pendingActionId } = 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}`, ) } // A different provider may have delegated this parent while this call // waited on the shared lock. Refresh before mutating either task stack. await this.taskHistoryStore.invalidate(parentTaskId) const authoritativeParent = this.taskHistoryStore.get(parentTaskId) if (authoritativeParent?.status === "delegated") { const awaitedChildId = authoritativeParent.awaitingChildId if (!awaitedChildId) throw new Error("Cannot re-delegate a parent with no awaited child") await this.taskHistoryStore.invalidate(awaitedChildId) if (this.taskHistoryStore.get(awaitedChildId)?.status !== "interrupted") { throw new Error("Cannot re-delegate while the awaited child is not interrupted") } } if (pendingActionId) { const parentHistory = this.taskHistoryStore.get(parentTaskId) if (parentHistory?.pendingAction?.actionId !== pendingActionId) { throw new Error( `[delegateParentAndOpenChild] Pending action mismatch for parent ${parentTaskId}: expected ${pendingActionId}, found ${parentHistory?.pendingAction?.actionId}`, ) } } const parentExecutionContext: DelegatedChildContext = { mode, apiConfigName: await parent.getTaskApiConfigName(), apiConfiguration: structuredClone(parent.apiConfiguration), } const parentMode = await parent.getTaskMode() const lockApiConfigAcrossModes = mode !== parentMode && this.context.workspaceState.get("lockApiConfigAcrossModes", false) let savedModeProfile: { name?: string; apiConfiguration: ProviderSettings } | undefined if (mode !== parentMode && !lockApiConfigAcrossModes) { const savedConfigId = await this.providerSettingsManager.getModeConfigId(mode as Mode) if (savedConfigId) { try { const { name, id: _id, ...savedConfiguration } = await this.providerSettingsManager.getProfile({ id: savedConfigId, }) savedModeProfile = { name, apiConfiguration: savedConfiguration } } catch (error) { this.log( `[delegateParentAndOpenChild] Saved profile ${savedConfigId} for mode '${mode}' could not be loaded for parent ${parentTaskId}: ${error instanceof Error ? error.message : String(error)}. Using the parent task configuration.`, ) } } } const handoffExecutionContext = selectHandoffExecutionContext( parentExecutionContext, mode, parentMode, lockApiConfigAcrossModes, savedModeProfile, ) // 2) Flush pending tool results to API history BEFORE disposing the parent. // This is critical: 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 { const flushSuccess = await parent.flushPendingToolResultsToHistory() if (!flushSuccess) { console.warn(`[delegateParentAndOpenChild] Flush failed for parent ${parentTaskId}, retrying...`) const retrySuccess = await parent.retrySaveApiConversationHistory() if (!retrySuccess) { console.error( `[delegateParentAndOpenChild] CRITICAL: Parent ${parentTaskId} API history not persisted to disk. Child return may produce stale state.`, ) vscode.window.showWarningMessage( "Warning: Parent task state could not be saved. The parent task may lose recent context when resumed.", ) } } } 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 } // 4) Bind the child directly to the delegating task's local provider // context. Delegation never mutates shared profile/global state. // 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. // // Pass startTask: false to prevent the child from beginning its task loop // (and writing to globalState via saveClineMessages → updateTaskHistory) // before we persist the parent's delegation metadata in step 5. // Without this, the child's fire-and-forget startTask() races with step 5, // and the last writer to globalState overwrites the other's changes— // causing the parent's delegation fields to be lost. const child = await this.createTask(message, undefined, parent as any, { initialTodos, initialStatus: "active", startTask: false, handoffExecutionContext, }) // 5) Persist parent delegation metadata BEFORE the child starts writing. // atomicReadAndUpdate reads from the in-memory cache and writes back within a // single lock acquisition — no concurrent writer can slip between the read and // write, and the pure updater cannot re-enter the lock (no deadlock). // Broadcast and cache invalidation happen outside the lock after it releases. // // If the parent is already "delegated" to a previous interrupted child (the user // navigated back to the parent and continued working), we implicitly sever the old // link here (delegated → active → delegated) so no explicit Abandon step is needed. // The old awaited child's status is re-read INSIDE the updater (which runs // synchronously under the store lock) so a concurrent abandon or completion cannot // slip between the status snapshot and the write. An active child must never be // silently detached. try { await this.taskHistoryStore.atomicReadAndUpdate(parentTaskId, (historyItem) => { if (pendingActionId && historyItem.pendingAction?.actionId !== pendingActionId) { throw new Error( `[delegateParentAndOpenChild] Pending action mismatch for parent ${parentTaskId}: expected ${pendingActionId}, found ${historyItem.pendingAction?.actionId}`, ) } const awaitedChildStatus = historyItem.awaitingChildId ? this.taskHistoryStore.get(historyItem.awaitingChildId)?.status : undefined const delegated = delegateTaskToChild(historyItem, child.taskId, awaitedChildStatus) return { ...delegated, pendingAction: delegated.pendingAction?.actionId === pendingActionId ? undefined : delegated.pendingAction, } }) this.recentTasksCache = undefined if (this.isViewLaunched) { const updatedItem = this.taskHistoryStore.get(parentTaskId) if (updatedItem) { await this.postMessageToWebview({ type: "taskHistoryItemUpdated", taskHistoryItem: updatedItem }) } } } catch (err) { this.log( `[delegateParentAndOpenChild] Failed to persist parent metadata for ${parentTaskId} -> ${child.taskId}: ${ (err as Error)?.message ?? String(err) }`, ) // The authoritative parent record rejected this delegation (#1714). // Settle the matching pending create_subtask action durably through // the disk-authoritative compare-and-clear so a retry cannot replay // a rejected action and a replacement action from another host is // never cleared, then propagate the original error. let settlementFailed = false if (pendingActionId && err instanceof LifecycleTransitionError) { try { const authoritative = await this.taskHistoryStore.clearPendingActionIfMatching( parentTaskId, pendingActionId, ) settlementFailed = authoritative.pendingAction?.kind === "create_subtask" && authoritative.pendingAction.actionId === pendingActionId this.recentTasksCache = undefined } catch (settlementError) { settlementFailed = true this.log( `[delegateParentAndOpenChild] Failed to settle pending action ${pendingActionId} for parent ${parentTaskId}: ${ (settlementError as Error)?.message ?? String(settlementError) }`, ) } } try { // Only pop the stack if the child we just created is still on top. // A concurrent delegation could have pushed another child since we created ours. if (this.getCurrentTask()?.taskId === child.taskId) { await this.removeClineFromStack() } } catch (cleanupError) { this.log( `[delegateParentAndOpenChild] Failed to close paused child ${child.taskId} during rollback: ${ (cleanupError as Error)?.message ?? String(cleanupError) }`, ) } try { await this.deleteTaskWithId(child.taskId, false) } catch (cleanupError) { this.log( `[delegateParentAndOpenChild] Failed to delete paused child ${child.taskId} during rollback: ${ (cleanupError as Error)?.message ?? String(cleanupError) }`, ) } try { // A failed settlement write leaves the rejected pending action in // durable storage. Restoring the stored parent would replay it in // this process, so leave the parent unrestored. Restart recovery also // settles interrupted create-subtask actions before allowing replay. if (!settlementFailed) { const { historyItem: parentHistory } = await this.getTaskWithId(parentTaskId) await this.createTaskWithHistoryItem(parentHistory) } } catch (rollbackError) { this.log( `[delegateParentAndOpenChild] Failed to restore parent ${parentTaskId} during rollback: ${ (rollbackError as Error)?.message ?? String(rollbackError) }`, ) } throw err } // Stamp only committed delegations; rejected ones must not link to a rolled-back child. try { const globalStoragePath = this.contextProxy.globalStorageUri.fsPath const parentMessages = await readTaskMessages({ taskId: parentTaskId, globalStoragePath }) for (let messageIndex = parentMessages.length - 1; messageIndex >= 0; messageIndex--) { const candidate = parentMessages[messageIndex] if (candidate.ask !== "tool" || !candidate.text) continue let tool: { tool?: unknown; content?: unknown } | null try { tool = JSON.parse(candidate.text) as { tool?: unknown; content?: unknown } | null } catch { continue } if (tool?.tool !== "newTask") continue // Never walk past the newest delegation and stamp an earlier rejected ask. // startSubtask delegates without an ask, so the newest ask may belong to another delegation. // NewTaskTool shows the raw content but delegates with one level of "\\@" unescaped. const matchesDelegation = typeof tool.content === "string" && tool.content.replace(/\\\\@/g, "\\@") === message if (matchesDelegation && !candidate.childTaskId) { candidate.childTaskId = child.taskId await saveTaskMessages({ messages: parentMessages, taskId: parentTaskId, globalStoragePath, merge: true, }) } break } } catch (error) { this.log( `[delegateParentAndOpenChild] Failed to save child link for parent ${parentTaskId}: ${error instanceof Error ? error.message : String(error)}`, ) } // 6) Start the child task now that parent metadata is safely persisted. scheduleTask(this.taskScheduler, child, "delegateParentAndOpenChild") // 7) 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 pendingActionId?: string }): Promise { const { parentTaskId, childTaskId, completionResultSummary, pendingActionId } = params return this.runDelegationTransition(parentTaskId, async () => { const globalStoragePath = this.contextProxy.globalStorageUri.fsPath // 1) Load parent from history and current persisted messages const { historyItem } = await this.getTaskWithId(parentTaskId) const childHistory = this.taskHistoryStore.get(childTaskId) if (pendingActionId && childHistory?.pendingAction?.actionId !== pendingActionId) { this.log( `[reopenParentFromDelegation] Aborting: child ${childTaskId} pending action does not match ${pendingActionId}`, ) return false } // Guard: re-validate delegation state after the async approval gap. // cancelTask() or removeClineFromStack() may have already detached the parent // (setting status → "active", awaitingChildId → undefined) while the user was // approving the subtask finish. If the parent no longer awaits this child, // routing output back would corrupt an unrelated task. if ( this.cancelledDelegationChildIds.has(childTaskId) || (historyItem.status !== "delegated" && historyItem.status !== "active") || historyItem.awaitingChildId !== childTaskId ) { this.log( `[reopenParentFromDelegation] Aborting: parent ${parentTaskId} is no longer delegated to child ${childTaskId} ` + `(status=${historyItem.status}, awaitingChildId=${historyItem.awaitingChildId})`, ) return false } let parentClineMessages: ClineMessage[] = [] try { parentClineMessages = await readTaskMessages({ taskId: parentTaskId, globalStoragePath, }) } catch (error) { this.log( `[reopenParentFromDelegation] Failed to read messages for parent ${parentTaskId}: ${error instanceof Error ? error.message : String(error)}`, ) return false } let parentApiMessages: ApiMessage[] = [] try { parentApiMessages = await readApiMessages({ taskId: parentTaskId, globalStoragePath, }) } catch (error) { this.log( `[reopenParentFromDelegation] Failed to read API messages for parent ${parentTaskId}: ${error instanceof Error ? error.message : String(error)}`, ) return false } // 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 = { messageId: crypto.randomUUID(), type: "say", say: "subtask_result", text: completionResultSummary, ts, childTaskId, } const lastParentClineMessage = parentClineMessages.at(-1) if ( lastParentClineMessage?.type !== "say" || lastParentClineMessage.say !== "subtask_result" || lastParentClineMessage.text !== completionResultSummary || (lastParentClineMessage.childTaskId !== undefined && lastParentClineMessage.childTaskId !== childTaskId) ) { parentClineMessages.push(subtaskUiMessage) } else { lastParentClineMessage.childTaskId = childTaskId } parentClineMessages = await saveTaskMessages({ messages: parentClineMessages, taskId: parentTaskId, globalStoragePath, merge: true, }) // 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 } } // Preferred: if the parent history contains the native tool_use for new_task, // inject a matching tool_result for the Anthropic message contract: // user → assistant (tool_use) → user (tool_result) 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({ messageId: crypto.randomUUID(), role: "user", content: [ { type: "tool_result" as const, tool_use_id: toolUseId, content: `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 } } else { // If there is no corresponding tool_use in the parent API history, we cannot emit a // tool_result. Fall back to a plain user text note so the parent can still resume. const fallbackText = `Subtask ${childTaskId} completed.\n\nResult:\n${completionResultSummary}` const lastParentApiMessage = parentApiMessages.at(-1) const alreadyHasFallback = lastParentApiMessage?.role === "user" && Array.isArray(lastParentApiMessage.content) && lastParentApiMessage.content.some( (block: { type?: string; text?: string }) => block.type === "text" && block.text === fallbackText, ) if (!alreadyHasFallback) { parentApiMessages.push({ messageId: crypto.randomUUID(), role: "user", content: [ { type: "text" as const, text: fallbackText, }, ], ts, }) } } parentApiMessages = await saveApiMessages({ messages: parentApiMessages, taskId: parentTaskId, globalStoragePath, merge: true, }) // 4) Close child instance if still open (single-open-task invariant). // This MUST happen BEFORE marking the child "completed" because // removeClineFromStack() → abortTask(true) → saveClineMessages() writes // the historyItem with initialStatus (typically "active"), which would // overwrite a "completed" status set later. const current = this.getCurrentTask() if (current?.taskId === childTaskId) { await this.removeClineFromStack() } // 3+5) Atomically mark child completed and parent active in one lock acquisition. // No intermediate state is ever persisted — no sentinel needed. // Build the parent update inside the updater from the locked snapshot so // any concurrent write that landed between step 1 and the lock acquisition // is preserved rather than silently overwritten. let updatedHistory!: typeof historyItem let completingChild!: HistoryItem await this.taskHistoryStore.atomicUpdatePair( childTaskId, parentTaskId, (child) => { if (pendingActionId && child.pendingAction?.actionId !== pendingActionId) { throw new Error(`[reopenParentFromDelegation] Pending action mismatch for child ${childTaskId}`) } completingChild = { ...child } const lifecycleUpdate = completeDelegatedChild(historyItem, child, completionResultSummary) return { ...lifecycleUpdate.child, pendingAction: child.pendingAction?.actionId === pendingActionId ? undefined : child.pendingAction, } }, (parent) => { const lifecycleUpdate = completeDelegatedChild(parent, completingChild, completionResultSummary) updatedHistory = lifecycleUpdate.parent return updatedHistory }, ) this.recentTasksCache = undefined // Notify the webview of both updated items so its in-memory history stays current. if (this.isViewLaunched) { const updatedChild = this.taskHistoryStore.get(childTaskId) const updatedParent = this.taskHistoryStore.get(parentTaskId) if (updatedChild) { await this.postMessageToWebview({ type: "taskHistoryItemUpdated", taskHistoryItem: updatedChild }) } if (updatedParent) { await this.postMessageToWebview({ type: "taskHistoryItemUpdated", taskHistoryItem: updatedParent }) } } // 6) Emit TaskDelegationCompleted (provider-level) try { this.emit(RooCodeEventName.TaskDelegationCompleted, parentTaskId, childTaskId, completionResultSummary) } catch { // non-fatal } // 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, false) } catch { // non-fatal } try { await parentInstance.overwriteApiConversationHistory(parentApiMessages, false) } catch { // non-fatal } let admitContinuation!: () => void const continuationAdmitted = new Promise((resolve) => { admitContinuation = resolve }) let schedulerAdmitted = false // Reserve the continuation's place in the shared parent queue before this // completion transition releases. Its body waits until scheduler admission, // so the completing child can release its permit without deadlocking. const continuation = this.runDelegationTransition(parentTaskId, async () => { await continuationAdmitted if (!schedulerAdmitted) return {} await this.taskHistoryStore.invalidate(parentTaskId) const persistedParent = this.taskHistoryStore.get(parentTaskId) const currentTask = this.getCurrentTask() if ( this.cancelledDelegationChildIds.has(childTaskId) || parentInstance.abort || parentInstance.abandoned || currentTask !== parentInstance || persistedParent?.status !== "active" || persistedParent.completedByChildId !== childTaskId || persistedParent.awaitingChildId !== undefined || persistedParent.delegatedToId !== undefined ) { this.log( `[reopenParentFromDelegation] Skipping stale parent continuation for ${parentTaskId} after child ${childTaskId}`, ) return {} } // Keep the run promise inside an object so the transition queue does not // assimilate it and retain the parent key for the full resumed task loop. return { runPromise: parentInstance.resumeAfterDelegation() } }) void this.taskScheduler .schedule(parentInstance, async () => { schedulerAdmitted = true admitContinuation() const { runPromise } = await continuation if (!runPromise) return try { await runPromise try { this.emit(RooCodeEventName.TaskDelegationResumed, parentTaskId, childTaskId) } catch { // non-fatal } } catch (error) { const message = `Failed to resume parent task ${parentTaskId} after subtask ${childTaskId}: ${error instanceof Error ? error.message : String(error)}` this.log(`[reopenParentFromDelegation] ${message}`) await vscode.window.showErrorMessage(`${message}. Open the task from history to retry.`) throw error } }) .then(admitContinuation, (error) => { admitContinuation() console.error( `[${ClineProvider.prototype.reopenParentFromDelegation.name}] taskScheduler.schedule failed:`, error, ) }) } this.cancelledDelegationChildIds.delete(childTaskId) return true }) } /** Emits completion after delegated child disposal through the provider-owned event channel. */ public emitDelegatedTaskCompleted(taskId: string, tokenUsage: TokenUsage, toolUsage: ToolUsage): void { this.emit(RooCodeEventName.TaskCompleted, taskId, tokenUsage, toolUsage) } /** * Explicitly sever a delegated parent-child link, e.g. when the user gives up on * an "interrupted" subtask instead of resuming it. Unlike removeClineFromStack()'s * automatic repair, this is user-initiated and works even while the child is * "interrupted" (which removeClineFromStack intentionally leaves alone so the child * can still resume and report back). Only interrupted children can be abandoned — a * still-running child must be cancelled first, so its link is never severed mid-stream. * * Parent transitions delegated → active (its normal "no longer awaiting a child" * state). The child's own status is left untouched (interrupted stays interrupted; * VALID_TRANSITIONS only allows interrupted → completed) — only its parent/root * links are cleared so a later resume-and-complete cannot reattach it. */ public async abandonSubtask(childTaskId: string): Promise { const { historyItem: childHistory } = await this.getTaskWithId(childTaskId) const parentTaskId = childHistory.parentTaskId if (!parentTaskId) { return false } // Only an interrupted (cancelled, not running) child may be abandoned. A still-running // child must be cancelled first — severing the link out from under a live stream would // orphan it silently instead of giving the user the normal cancel/resume flow. if (childHistory.status !== "interrupted") { this.log( `[abandonSubtask] Aborting: child ${childTaskId} is not interrupted (status=${childHistory.status})`, ) return false } return this.runDelegationTransition(parentTaskId, async () => { const { historyItem: parentHistory } = await this.getTaskWithId(parentTaskId) if (parentHistory?.status !== "delegated" || parentHistory?.awaitingChildId !== childTaskId) { this.log( `[abandonSubtask] Aborting: parent ${parentTaskId} is no longer delegated to child ${childTaskId} ` + `(status=${parentHistory?.status}, awaitingChildId=${parentHistory?.awaitingChildId})`, ) return false } // Re-check inside the lock: the child may have been resumed (and be streaming again, // or have completed) between the check above and acquiring the delegation transition lock. const freshChild = this.taskHistoryStore.get(childTaskId) if (freshChild?.status !== "interrupted") { this.log( `[abandonSubtask] Aborting: child ${childTaskId} is no longer interrupted (status=${freshChild?.status})`, ) return false } // Close the live child instance (if it's still the open task — the common case, // since an interrupted child is rehydrated onto the stack after cancelTask) BEFORE // clearing its persisted links. Task#saveClineMessages() rebuilds parentTaskId/ // rootTaskId from the live (readonly) Task fields on every save, so any save that // happens after we clear the persisted links — including abortTask's own final // save — would silently reattach the child to its old parent. const current = this.getCurrentTask() if (current?.taskId === childTaskId) { await this.removeClineFromStack() } await this.taskHistoryStore.atomicUpdatePair( childTaskId, parentTaskId, (child) => abandonDelegatedChild(parentHistory, child).child, (parent) => abandonDelegatedChild(parent, freshChild).parent, ) this.recentTasksCache = undefined // Guard against a stale in-flight resume/completion (e.g. a resume that was already // in progress when abandon was clicked) reattaching the child after the link above // was cleared. AttemptCompletionTool re-reads parent status from the persisted store, // not the live task's readonly parentTaskId field, so this is the authoritative gate. this.cancelledDelegationChildIds.add(childTaskId) if (this.isViewLaunched) { const updatedChild = this.taskHistoryStore.get(childTaskId) const updatedParent = this.taskHistoryStore.get(parentTaskId) if (updatedChild) { await this.postMessageToWebview({ type: "taskHistoryItemUpdated", taskHistoryItem: updatedChild }) } if (updatedParent) { await this.postMessageToWebview({ type: "taskHistoryItemUpdated", taskHistoryItem: updatedParent }) } } this.log(`[abandonSubtask] Severed link between parent ${parentTaskId} and child ${childTaskId}`) return true }) } /** * 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() } } }