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 { PROVIDER_SETTINGS_KEYS, 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 ProviderSettingsWithId, 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, ProviderSettingsNotFoundError } from "../config/ProviderSettingsManager" import { CustomModesManager } from "../config/CustomModesManager" import { PendingActionSettlementError, Task } from "../task/Task" import { 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" type PersistedViewState = NonNullable[string] /** * Values that can be held in a view-local state buffer (in-memory) and, for the * non-secret subset, persisted durably per stable view id. */ type ViewLocalStateValues = Partial & Partial /** * The keys the view-local mutation helper actually applies. A wider key would update * neither the in-memory buffer nor the persisted entry, so it must not be accepted by * the public save path. */ type PerViewSavedStateKey = "mode" | "currentApiConfigName" | "apiConfiguration" /** * 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 } /** * In-flight profile-create claims shared by every provider instance in this * extension host. A rollback may only delete a record whose creation it can * prove it owns: when two views concurrently create the same new name, both * observe `hasConfig === false`, yet the surviving record belongs to the * create that completed. A failed create must therefore skip the deletion * while another create for the same name is still in flight, and the last * claimant to settle performs it. */ const profileCreateClaims = new Map() function claimProfileCreate(name: string): void { profileCreateClaims.set(name, (profileCreateClaims.get(name) ?? 0) + 1) } /** * Names whose create claim has already landed: a failed create must not * delete the record a successful sibling create saved, even when it settles * last. */ const profileCreatesSucceeded = new Set() /** Records that a create for this name landed, so failed siblings spare the record. */ function markProfileCreateSucceeded(name: string): void { profileCreatesSucceeded.add(name) } /** Drops one claim and reports whether the caller was the last claimant. */ function releaseProfileCreate(name: string): boolean { const remaining = (profileCreateClaims.get(name) ?? 1) - 1 if (remaining <= 0) { profileCreateClaims.delete(name) return true } profileCreateClaims.set(name, remaining) return false } type ProfileActivationSnapshot = { listApiConfigMeta: ProviderSettingsEntry[] | undefined currentApiConfigName: string | undefined viewCurrentApiConfigName: string | undefined providerSettings: RooCodeSettings viewApiConfiguration: ProviderSettings | undefined viewStates: Record } type ProviderManagerSnapshot = { previousRecord: ProviderSettingsWithId | undefined recordExisted: boolean | undefined // True only when a read actually failed, so a caller can tell "absent" from // "could not tell" without re-reading the store. recordUnreadable: boolean previousCurrentProfileName: string | undefined claimedCreate: 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 static nextViewId = 0 private static readonly MAX_PERSISTED_VIEW_STATES = 50 private static readonly MAX_VIEW_STATE_ID_LENGTH = 64 private static persistedViewStateWriteQueue: Promise = Promise.resolve() /** * View ids whose durable entry a queued write intentionally removed (unpin or clear). * Activation rollback treats these as tombstones and never resurrects a tracked eviction * the clear has since deleted; a write that lands an entry lifts its tombstone. * * A tombstone is only ever consulted for an id in some provider's current * prunedViewIdsDuringActivation list, and that list is rebuilt at the start of every * activation group, so the set has a natural bound: each provider records the tombstones it * added and releases them once no live group guards the id. A tombstone that a live group * still guards is handed to clearedViewStateIdsWithoutOwner and drained by the next group, * which keeps the set from growing for as long as the host runs. */ private static clearedViewStateIds = new Set() /** * Tombstones whose provider was disposed while an activation group still guarded the id. * Drained by the next activation group, once no live pruned-eviction list names them. */ private static clearedViewStateIdsWithoutOwner = 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) } /** * Serialize provider-profile mutations. A timeout only rejects the caller: the mutation is not * aborted and keeps the queue until it settles, so multi-write mutations cannot stop halfway * (storage saved but not activated) or overlap with the next mutation. The cancellation checks * the callback used to receive are gone with the abort: none of these mutations is abortable. */ private enqueueProviderProfileMutation(fn: () => Promise): Promise { // Set when the caller's timeout fires while this mutation is still queued behind a // longer-running one: the callback must not start after its caller already saw a // timeout, or the mutation would change state while reporting failure. let callerTimedOut = false // Run fn after either outcome so a rejected mutation never poisons the queue. const start = () => { if (callerTimedOut) { return Promise.reject(new Error("Provider profile mutation timed out while queued")) } return fn() } const run = this.providerProfileMutationQueue.then(start, start) const callerResult = this.withProviderProfileMutationTimeout(run, () => { callerTimedOut = true this.log("Provider profile mutation timed out; waiting for it to finish before the next mutation") }) // Advance from the unbounded run, not the timeout-bounded caller result: a mutation that // outlives its caller's timeout still owns the queue until every write settles. this.providerProfileMutationQueue = run.then( () => { if (callerTimedOut) { // The caller already saw a failure, but the mutation went through: these writes are // live now, so the views must be told. Without this the webview keeps rendering the // pre-mutation profile list under an error toast even though the profile was saved, // activated and picked up by every task handler. this.log("Provider profile mutation committed after its caller timed out; re-posting state") void this.postStateToWebview().catch((error) => { this.log( `[enqueueProviderProfileMutation] re-post after a late commit failed: ${ error instanceof Error ? error.message : String(error) }`, ) }) } return 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 /** * Unique identifier for this provider instance's view. * Based on renderContext and a monotonically increasing counter to ensure uniqueness across multiple instances. */ public readonly viewId: string /** * Stable identifier for persisted per-view state keys. * Defaults to viewId until the webview reports its VS Code-persisted id. */ private viewStateId: string /** * Selections made before the webview registered its stable id. The session-local id cannot * serve as a persisted key: it restarts from 0 on every host start, so an entry written under * it is not attributable to this view and is never loadable by the next session's provider. The * change waits here and is written by the registration flush, under the name a later session * can actually read. */ private pendingViewState: Partial | undefined /** * The tombstones this instance added to the shared set, so dispose can release them. */ private addedClearedViewStateIds = new Set() /** * Local state buffer for this specific view instance. * Used to isolate mode, apiConfiguration, and other fields from the shared ContextProxy singleton * when running in parallel (multi-tab) mode. */ private viewLocalState: Partial = {} /** * View ids evicted from the durable viewStates map by this instance's writes * since the last activation snapshot: the rollback restores exactly these * entries, so a sibling's intentional pin clear is never resurrected. */ private prunedViewIdsDuringActivation: string[] = [] /** * This view's pinned profile name from the view-local buffer. Exposed for * sibling-instance inspection (getAllInstances() filtering): `viewLocalState` * is private and must not be reached through bracket access. */ get pinnedProfileName(): string | undefined { return this.viewLocalState.currentApiConfigName } public isViewLaunched = false // Callers that must actually deliver a webview message wait on this gate. The // SidebarProvider.focus command settles when VS Code opens the pane - well before // resolveWebviewView hands this provider its view - and postMessageToWebview returns // without sending while `view` is unset, so a message posted straight after that command // can be dropped. The gate settles when the view resolves and on every path that removes // it, so a caller can never hang on a view that is never coming. private viewResolvedGate?: Promise private viewResolvedGateRelease?: () => void 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() // Initialize viewId based on renderContext and monotonically increasing instance identifier for uniqueness. // activeInstances is used for visibility/iteration checks, so we keep tracking instances separately. this.viewId = `${renderContext}-${ClineProvider.nextViewId++}` this.viewStateId = this.viewId ClineProvider.activeInstances.add(this) 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() }) // Load initial state from global state into viewLocalState buffer after dependencies used by getState are ready. void this.loadViewState() // 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)}`) } } /** * Reads the registered viewStates map, returning a defensive copy. * When fresh is set, the map is read directly from globalState (bypassing the * ContextProxy cache) so serialized writes never observe a stale in-memory value. */ private getPersistedViewStates(options: { fresh?: boolean } = {}): Record { const viewStates = options.fresh ? this.context.globalState.get("viewStates") : this.contextProxy.getValue("viewStates") if (!viewStates || typeof viewStates !== "object" || Array.isArray(viewStates)) { return Object.create(null) } // Copy onto a null prototype. A view id that names an Object.prototype key // ("constructor", "toString", "valueOf", ...) must not resolve through the // prototype chain and be mistaken for a persisted entry. return Object.assign(Object.create(null), viewStates) } /** * Persists this view's non-secret selections through the serialized write queue. * The write re-reads the map fresh and merges into the existing entry, removing the * entry entirely when nothing persistable remains, so concurrent views cannot clobber it. * The entry is keyed by the view id active when the change was made. A change captured while * the provider still holds its session-local (pre-registration) id is not written at all: * temporary ids restart from 0 on every host start, so an entry under one is not attributable * to this view and no later session can load it, yet it would occupy a slot in the shared map * and outrank loadable entries when the map is pruned. Such a change waits in pendingViewState * and is written by the registration flush under the stable id, which is the only name another * session could load it under. A provider that goes away while a change is still held drops it: * writing it under the session-local id would put an entry in the shared map that loadViewState * refuses to read back, for the price of a slot that outranks loadable entries when the map is * pruned. */ private async savePersistedViewState(values: Partial): Promise { // Capture the id at change time: a write belongs to the view that was active // when the change was made, even if a newer id is registered while it is queued. const viewStateId = this.viewStateId // Pre-registration change: hold it for the registration flush instead of persisting it under // the session-local id. Temporary ids restart from 0 on every host start, so an entry under // one is a slot in the shared map that no view can ever load, and it outranks loadable entries // when the map is pruned. Spreading keeps a key the change explicitly cleared present with an // undefined value, which is what the write below turns back into a deletion. if (viewStateId === this.viewId) { this.pendingViewState = { ...this.pendingViewState, ...values } return } const write = ClineProvider.persistedViewStateWriteQueue.then(async () => { const states = this.getPersistedViewStates({ fresh: true }) // Merge onto what the entry holds now. The guard above means the id is a stable one, so // the entry under it belongs to this view, while another view's concurrent write to the // shared map must survive this one. const current = states[viewStateId] ?? {} const next: PersistedViewState = { ...current } if ("mode" in values) { if (values.mode === undefined || values.mode === null) { delete next.mode } else { next.mode = values.mode } } if ("currentApiConfigName" in values) { if (values.currentApiConfigName === undefined || values.currentApiConfigName === null) { delete next.currentApiConfigName } else { next.currentApiConfigName = values.currentApiConfigName } } // The tombstone transition rides on this write, so it is provisional until the write // lands: the catch below puts it back, or a rollback would treat an entry that durable // storage still holds as an intentional clear. const tombstoneTransition: "added" | "lifted" | undefined = next.mode || next.currentApiConfigName ? "lifted" : "added" if (tombstoneTransition === "added") { delete states[viewStateId] this._rememberClearedViewStateId(viewStateId) } else { next.updatedAt = values.updatedAt ?? Date.now() states[viewStateId] = next this._forgetClearedViewStateId(viewStateId) } const pruned = this.prunePersistedViewStates(states) const evicted = Object.keys(states).filter((viewId) => !Object.hasOwn(pruned, viewId)) if (evicted.length > 0) { this.prunedViewIdsDuringActivation.push(...evicted) } try { await this.contextProxy.setValue("viewStates", pruned) } catch (error) { // Undo the provisional tombstone transition first: a failed write means storage is // unchanged, and leaving the transition in place would either hide an entry that is // still persisted behind an intentional clear or resurrect one the clear did delete. if (tombstoneTransition === "added") { this._forgetClearedViewStateId(viewStateId) } else if (tombstoneTransition === "lifted") { this._rememberClearedViewStateId(viewStateId) } // updateGlobalState applies the new map to the in-memory cache before // the durable write, so a rejected write leaves the cache claiming an // entry that never reached disk. Restore the cache to the value durable // storage actually holds (undefined when the key was never written); the // cache update lands even if this compensating write also fails, so // readers cannot observe the lost entry either way. try { await this.contextProxy.setValue("viewStates", this.context.globalState.get("viewStates")) } catch { // The cache is updated before the durable attempt: it matches the // durable value regardless of whether this restore write landed. } throw error } }) ClineProvider.persistedViewStateWriteQueue = write.catch(() => {}) await write } /** * Removes the given view's entry from the registered viewStates map. * Runs through the serialized write queue to avoid racing concurrent view-state writes. */ private async clearPersistedViewState(viewStateId = this.viewStateId): Promise { const write = ClineProvider.persistedViewStateWriteQueue.then(async () => { const states = this.getPersistedViewStates({ fresh: true }) delete states[viewStateId] try { await this.contextProxy.setValue("viewStates", states) // The tombstone is recorded only after the durable delete lands: a rejected write // leaves the entry in storage, and tombstoning an id that is still persisted would // let a later activation rollback skip a pin that pruning had evicted. this._rememberClearedViewStateId(viewStateId) } catch (error) { // updateGlobalState applies the new map to the in-memory cache before // the durable write, so a rejected write leaves the cache missing an // entry that durable storage still holds. Restore the cache to the // value storage actually holds (undefined when the key was never // written); the cache update lands even if this compensating write // also fails, so readers cannot observe the loss either way. try { await this.contextProxy.setValue("viewStates", this.context.globalState.get("viewStates")) } catch { // The cache is updated before the durable attempt: it matches the // durable value regardless of whether this restore write landed. } throw error } }) ClineProvider.persistedViewStateWriteQueue = write.catch(() => {}) await write } /** * Re-points persisted view pins that reference a removed profile so views do not * rehydrate a missing profile name after a reload. Runs through the serialized * write queue like every other viewStates mutation. */ private async repointPersistedViewStates( removedProfileName: string, replacementProfileName: string, ): Promise { const write = ClineProvider.persistedViewStateWriteQueue.then(async () => { const states = this.getPersistedViewStates({ fresh: true }) let changed = false for (const [viewId, entry] of Object.entries(states)) { if (entry?.currentApiConfigName !== removedProfileName) { continue } changed = true const { currentApiConfigName: _removed, ...rest } = entry if (rest.mode) { states[viewId] = { ...rest, currentApiConfigName: replacementProfileName, updatedAt: Date.now() } } else { states[viewId] = { currentApiConfigName: replacementProfileName, updatedAt: Date.now() } } } if (changed) { const pruned = this.prunePersistedViewStates(states) const evicted = Object.keys(states).filter((viewId) => !Object.hasOwn(pruned, viewId)) if (evicted.length > 0) { this.prunedViewIdsDuringActivation.push(...evicted) } try { await this.contextProxy.setValue("viewStates", pruned) } catch (error) { // updateGlobalState applies the new map to the in-memory cache // before the durable write: restore the cache to the value durable // storage actually holds so closed-view pins cannot serve re-pointed // names that never reached disk, then let the caller compensate. try { await this.contextProxy.setValue("viewStates", this.context.globalState.get("viewStates")) } catch { // The cache is updated before the durable attempt: it matches the // durable value regardless of whether this restore write landed. } throw error } } }) ClineProvider.persistedViewStateWriteQueue = write.catch(() => {}) await write } /** * Keeps only the most recently updated entries of the persisted view states map, * bounded by MAX_PERSISTED_VIEW_STATES so the global key cannot grow unboundedly. */ /** * Records a tombstone in the shared set and under this instance, so its lifetime is bounded by * the provider that wrote it rather than by the life of the host. */ private _rememberClearedViewStateId(viewStateId: string): void { ClineProvider.clearedViewStateIds.add(viewStateId) this.addedClearedViewStateIds.add(viewStateId) } /** Lifts a tombstone from the shared set and from this instance's record of them. */ private _forgetClearedViewStateId(viewStateId: string): void { ClineProvider.clearedViewStateIds.delete(viewStateId) this.addedClearedViewStateIds.delete(viewStateId) } /** * Whether any live provider's current pruned-eviction list still names this id. That list is the * only consumer of the tombstone set, and it is rebuilt at the start of every activation group, * so an id no live list names cannot affect a rollback now or in a later group: storage no * longer holds the entry, so nothing can evict it again, and a write that re-lands the entry * lifts the tombstone itself. */ private static _tombstoneIsGuarded(viewStateId: string): boolean { for (const instance of ClineProvider.activeInstances) { if (instance.prunedViewIdsDuringActivation.includes(viewStateId)) { return true } } return false } /** * Releases the tombstones this instance added. Anything a live group still guards is handed to * the ownerless set, which the next activation group drains once nothing guards it. */ private _releaseClearedViewStateIds(): void { for (const viewStateId of this.addedClearedViewStateIds) { if (ClineProvider._tombstoneIsGuarded(viewStateId)) { ClineProvider.clearedViewStateIdsWithoutOwner.add(viewStateId) } else { ClineProvider.clearedViewStateIds.delete(viewStateId) } } this.addedClearedViewStateIds.clear() } /** * Drops the ownerless tombstones that no live pruned-eviction list guards any more. Runs at the * start of an activation group, which is the point where the last group's eviction list is let go. */ private static _drainUnguardedClearedViewStateIds(): void { for (const viewStateId of [...ClineProvider.clearedViewStateIdsWithoutOwner]) { if (!ClineProvider._tombstoneIsGuarded(viewStateId)) { ClineProvider.clearedViewStateIds.delete(viewStateId) ClineProvider.clearedViewStateIdsWithoutOwner.delete(viewStateId) } } } private prunePersistedViewStates(states: Record): Record { return Object.fromEntries( Object.entries(states) .sort(([, a], [, b]) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0)) .slice(0, ClineProvider.MAX_PERSISTED_VIEW_STATES), ) } /** * Registers this provider's stable view identifier and loads any persisted selections it owns. * The identifier is sanitized so it remains a safe object key in the shared viewStates map. */ public async setViewStateId(viewStateId: string | undefined): Promise { // The webview generates this id on purpose - it has to survive a webview reload, // which recreates the view on both sides - so the host cannot mint it instead. // What the host owns is which view may hold a given persisted key, so everything // below validates the supplied value before it becomes one. if (typeof viewStateId !== "string") { // A crafted postMessage can carry any JSON value here even though the message // type declares a string; the persisted key must stay a string. this.log(`[setViewStateId] Ignoring a non-string viewStateId for view ${this.viewId}.`) return } const normalizedViewStateId = viewStateId.trim().replace(/[^A-Za-z0-9_-]/g, "_") if ( !normalizedViewStateId || normalizedViewStateId === this.viewStateId || // Reject "__proto__": writing states["__proto__"] would go through the // Object.prototype setter and be silently dropped by the later spread. normalizedViewStateId === "__proto__" ) { return } // Bound the key: the webview generates a UUID, so a much longer value is not a // view id, and an unbounded key is unbounded growth in the shared viewStates map. if (normalizedViewStateId.length > ClineProvider.MAX_VIEW_STATE_ID_LENGTH) { this.log( `[setViewStateId] Ignoring an oversized viewStateId (${normalizedViewStateId.length} chars) for view ${this.viewId}.`, ) return } const claimant = ClineProvider.getAllInstances().find( (instance) => instance !== this && instance.viewStateId === normalizedViewStateId, ) if (claimant) { // Ownership check: a key another live view holds must not be adopted. Doing so // would load that view's pinned profile, nested overlay, and mode into this // provider, and this webview could then overwrite that view's persisted entry. // Staying on the temporary id keeps this view working against the shared state. this.log( `[setViewStateId] Refusing viewStateId ${normalizedViewStateId} for view ${this.viewId}: already claimed by live view ${claimant.viewId}.`, ) return } const previousViewStateId = this.viewStateId this.viewStateId = normalizedViewStateId try { // Flush the selections that were held back while the view was unregistered, now that the // name they will be read under is known. The buffer is cleared only after the write lands, // so a registration that fails and restores the previous id can retry the flush instead of // dropping the user's change. const pendingViewState = this.pendingViewState if (pendingViewState) { await this.savePersistedViewState(pendingViewState) this.pendingViewState = undefined } await this.loadViewState() } catch (error) { // A persistence failure must not leave the provider holding an id that was never // registered: restore the previous id so a later launch retries the registration, the // flush and the load instead of the guard above early-returning for the failed id. The // held selections stay in pendingViewState for the same reason. this.viewStateId = previousViewStateId throw error } } /** * Loads non-secret persisted selections from the registered viewStates map. * Missing entries are intentionally left unset so getState() falls back to shared ContextProxy values. * Fields mutated while the async profile lookup is in flight are reapplied on top of the * loaded state, field by field, so in-flight user selections are not clobbered by the load. */ private async loadViewState(): Promise { // Capture the id this load is for: a newer id registered while an async // profile lookup is in flight must not be overwritten by this stale load. const loadedForViewId = this.viewStateId // Until the stable id is registered the only name available is this instance's session-local // one, and temporary ids restart from 0 on every host start, so an entry under it cannot be // attributed to this view. There is nothing to load: pre-registration selections live in // pendingViewState and the in-memory buffer until the registration flush lands them. if (loadedForViewId === this.viewId) { return } try { const persisted = this.getPersistedViewStates()[loadedForViewId] const loadedState: Partial = {} // Snapshot the in-memory buffer before the async profile lookup. The // mutation paths update viewLocalState in place, so a shallow copy is // what makes fields mutated during the load window observable below. const preLoadBuffer = { ...this.viewLocalState } if (persisted?.mode) { // A persisted mode may reference a custom mode that was deleted after it was // pinned: restore it only when the slug still resolves, so a stale slug cannot // shadow the shared mode from getState(). const customModes = await this.customModesManager.getCustomModes() if (getModeBySlug(persisted.mode, customModes)) { loadedState.mode = persisted.mode as Mode } else { this.log(`[loadViewState] Ignoring unknown persisted mode "${persisted.mode}"`) } } if (persisted?.currentApiConfigName) { loadedState.currentApiConfigName = persisted.currentApiConfigName try { const { name: _name, ...apiConfiguration } = await this.providerSettingsManager.getProfile({ name: persisted.currentApiConfigName, }) loadedState.apiConfiguration = apiConfiguration as ProviderSettings } catch (error) { this.log( `[loadViewState] Unable to resolve API profile '${persisted.currentApiConfigName}' for viewId ${this.viewId}: ${error instanceof Error ? error.message : String(error)}`, ) } } if (this.viewStateId !== loadedForViewId) { this.log(`[loadViewState] Discarding stale state for superseded view id ${loadedForViewId}`) return } // Reapply the buffer fields that changed while the load was in flight, // tracking the change instead of testing against undefined: a field cleared // during the load window must stay cleared (the loaded value must not // resurrect it), and a field written to a new value must win over it. // Untouched fields keep the persisted values authoritative, and the // pre-load buffer is never merged wholesale so stale temporary-id state // cannot override the stable persisted state. const postLoadBuffer = this.viewLocalState const mergedState: Partial = { ...loadedState } // The lookup resolved the profile that was pinned when the load started. If the view // pinned a different profile during the lookup window, those settings belong to a // profile this view no longer names, and keeping them would report the new name with // the old profile's configuration. Dropping the overlay falls back to the shared // store, which is the same outcome as any other unresolved view-local profile. if ( persisted?.currentApiConfigName && !Object.is(preLoadBuffer.currentApiConfigName, postLoadBuffer.currentApiConfigName) && !Object.is(persisted.currentApiConfigName, postLoadBuffer.currentApiConfigName) ) { delete mergedState.apiConfiguration } if (!Object.is(preLoadBuffer.mode, postLoadBuffer.mode)) { if (postLoadBuffer.mode === undefined) { delete mergedState.mode } else { mergedState.mode = postLoadBuffer.mode } } if (!Object.is(preLoadBuffer.currentApiConfigName, postLoadBuffer.currentApiConfigName)) { if (postLoadBuffer.currentApiConfigName === undefined) { delete mergedState.currentApiConfigName } else { mergedState.currentApiConfigName = postLoadBuffer.currentApiConfigName } } if (!Object.is(preLoadBuffer.apiConfiguration, postLoadBuffer.apiConfiguration)) { if (postLoadBuffer.apiConfiguration === undefined) { delete mergedState.apiConfiguration } else { mergedState.apiConfiguration = postLoadBuffer.apiConfiguration } } this.viewLocalState = mergedState this.log(`[loadViewState] Loaded state for viewId ${this.viewId}`) } catch (error) { this.log( `[loadViewState] Error loading state for viewId ${this.viewId}: ${error instanceof Error ? error.message : String(error)}`, ) } } /** * Saves a single view-local state value. The in-memory buffer is always updated; the * non-secret subset (mode, currentApiConfigName) is persisted durably under the stable * view id, or held in pendingViewState until the webview registers one. * * Only the three per-view fields are accepted: the mutation helper applies exactly * those, so any other key would leave both the buffer and the persisted store * untouched while the method still reported a successful save. */ public async saveViewState( key: K, value: ViewLocalStateValues[K] | undefined, ): Promise { await this._saveViewLocalStateFromMutation({ [key]: value } as ViewLocalStateValues) this.log(`[saveViewState] Saved ${String(key)} for viewId ${this.viewId}`) } /** * 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() { 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() // 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() this.settleViewResolvedGate() // 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") // A change made before the webview registered a stable id waits in memory, and the only thing // that ever writes it is that registration. A provider that goes away before it lands drops the // change rather than writing it under its session-local id: loadViewState refuses to attribute // a temporary-id entry to any view, so such a write is a record nobody can read back, and its // cost is a slot in the shared map that outranks loadable entries when the map is pruned. this.pendingViewState = undefined ClineProvider.activeInstances.delete(this) // Tombstones this provider wrote are no longer its responsibility. Done after leaving // activeInstances so this provider's own eviction list cannot keep its tombstone guarded. this._releaseClearedViewStateIds() 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) } /** * Returns the live instance whose current view is the given view or panel, * if any. Title-bar commands on a specific surface use this to target the * instance that owns that surface rather than the visible-instance * heuristic (which picks whichever surface the user last focused). */ public static getInstanceForView(view: vscode.WebviewView | vscode.WebviewPanel): ClineProvider | undefined { return Array.from(this.activeInstances).find((instance) => instance.view === view) } 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 this.settleViewResolvedGate() 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() this.settleViewResolvedGate() } }, 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 } // Persist the restored mode through this view's per-view pin rather than the // shared global: a global write would leak the restored mode into other views // in parallel mode, and a buffer-only write would be lost after a reload. await this.saveViewState("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 } /** * Settles once this provider can actually post into a webview: either the view is resolved * (or was already) or the view and provider are gone, which is the only other way the wait * can end without hanging the caller. Callers that must deliver a message, rather than merely * try, await this before posting. */ async whenViewResolved(): Promise { if (this.view || this._disposed) { return } if (!this.viewResolvedGate) { this.viewResolvedGate = new Promise((resolve) => { this.viewResolvedGateRelease = resolve }) } return this.viewResolvedGate } /** * Releases every pending whenViewResolved waiter and drops the gate so the next wait starts * fresh: a view that resolves again after a disposal must still be gated. */ private settleViewResolvedGate(): void { const release = this.viewResolvedGateRelease this.viewResolvedGate = undefined this.viewResolvedGateRelease = undefined release?.() } 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 } const webview = this.view?.webview if (!webview) { return } // Dispatch without awaiting the renderer ack: VS Code settles postMessage only when the // webview page acknowledges the message, and a page reload or view dispose in flight // orphans that promise forever. Awaiting it could wedge every caller on the task critical // path (e.g. the trailing postStateToWebview in handleModeSwitchUnlocked gates the next // turn after a mode switch). Message ordering is enforced by the message seq, not the ack. // Promise.resolve() normalizes non-promise returns (e.g. test doubles) before the catch. void Promise.resolve(webview.postMessage(omitOriginalContentFromExtensionMessage(message))).catch((error) => { // Swallow: postMessage rejects when the webview is disposed in flight. // Log the dropped message type so a wedged webview channel is diagnosable // instead of silently losing state updates. this.log( `[postMessageToWebview] dropped message type=${message.type}: ${error instanceof Error ? error.message : String(error)}`, ) }) } 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()) { // The switch and its nested activation own the queue until every write - including // compensation - settles: a timeout only rejects the caller. return this.enqueueProviderProfileMutation(() => this.handleModeSwitchUnlocked(newMode, targetTask)) } private async handleModeSwitchUnlocked(newMode: Mode, targetTask: Task | null | undefined): Promise { const task = targetTask // Captured before any write so the persistence failure below can undo the task-side // commits instead of leaving them pointing at the rejected mode. const previousTaskMode = task?.taskMode const previousHistoryMode = task ? this.getTaskHistoryItem(task.taskId)?.mode : undefined if (task) { TelemetryService.instance.captureModeSwitch(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 }) } task.emit(RooCodeEventName.TaskModeSwitched, task.taskId, 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 } } // setValue (not the deprecated updateGlobalState) so the in-memory viewLocalState // buffer stays in sync with the durable global write: getValues() merges // viewLocalState on top of the ContextProxy values, so an unsynced stale // restored mode would otherwise shadow the fresh switch for consumers. // If the durable write fails, roll the shared write back so getValues() // cannot mix a fresh shared mode with the stale pre-switch buffer. const previousMode = this.getValue("mode") try { await this.setValue("mode", newMode) } catch (error) { // The task-history write and the task's own mode committed before this write // rejected. Roll them back too, or the view reverts to the previous mode while the // task and its history item keep the rejected one and the webview shows a failed // switch on top of a task that still runs the new mode. if (task) { const historyItem = this.getTaskHistoryItem(task.taskId) if (historyItem && previousHistoryMode !== historyItem.mode) { try { await this.updateTaskHistory({ ...historyItem, mode: previousHistoryMode }) } catch (rollbackError) { this.log( `[handleModeSwitch] Failed to restore task history mode after persistence failure: ${ rollbackError instanceof Error ? rollbackError.message : String(rollbackError) }`, ) } } if (previousTaskMode !== undefined && task.taskMode !== previousTaskMode) { // Task has no public mode setter (see the commit above); bracket access keeps // the rollback off the suppression list. task["_taskMode"] = previousTaskMode } } try { await this.contextProxy.setValue("mode", previousMode) } catch (rollbackError) { this.log( `[handleModeSwitch] Failed to roll back shared mode after persistence failure: ${ rollbackError instanceof Error ? rollbackError.message : String(rollbackError) }`, ) } this.log( `[handleModeSwitch] Failed to persist mode "${newMode}": ${error instanceof Error ? error.message : String(error)}`, ) throw error } 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 } // Load the saved API config for the new mode if it exists. const savedConfigId = await this.providerSettingsManager.getModeConfigId(newMode) const listApiConfig = await this.providerSettingsManager.listConfig() // Update listApiConfigMeta first to ensure UI has latest data. await this.updateGlobalState("listApiConfigMeta", listApiConfig) // If this mode has a saved config, use it. if (savedConfigId) { const profile = listApiConfig.find(({ id }) => id === savedConfigId) if (profile?.name) { // Check if the profile has actual API configuration (not just an id). // In CLI mode, the ProviderSettingsManager may return empty default profiles // that only contain 'id' and 'name' fields. Activating such a profile would // overwrite the CLI's working API configuration with empty settings. // Skip activation if the profile has no apiProvider set - this indicates // an unconfigured/empty profile. const fullProfile = await this.providerSettingsManager.getProfile({ name: profile.name }) const hasActualSettings = !!fullProfile.apiProvider if (hasActualSettings) { await this.activateProviderProfileUnlocked( { name: profile.name }, targetTask === null ? { skipCurrentTaskRebuild: true } : undefined, ) } 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) } /** * Prior state that the profile activation writes overwrite, captured before the * concurrent write group so a failure can compensate the writes that landed. */ private _snapshotProfileActivationState(): ProfileActivationSnapshot { // The rollback may only restore entries this activation's own writes evict. this.prunedViewIdsDuringActivation = [] // This group is the point at which the previous group's eviction list is let go, so it is // also where tombstones that nothing guards any more are dropped. ClineProvider._drainUnguardedClearedViewStateIds() // Capture only the provider-settings keys. getValues() returns the whole // global state, and restoring that copy would write unrelated keys // (viewStates, mode, taskHistory, ...) back over changes that legitimately // landed while the activation group was in flight. const allValues = this.contextProxy.getValues() const providerSettings = Object.fromEntries( PROVIDER_SETTINGS_KEYS.map((key) => [key, allValues[key]]), ) as RooCodeSettings return { listApiConfigMeta: this.contextProxy.getValue("listApiConfigMeta"), currentApiConfigName: this.contextProxy.getValue("currentApiConfigName"), // This view's own pin: the rollback must restore it as-is instead of // overwriting it with the shared selection. viewCurrentApiConfigName: this.viewLocalState?.currentApiConfigName, providerSettings, viewApiConfiguration: this.viewLocalState?.apiConfiguration, // The activation's pin write merges into and prunes the durable map: the // rollback needs the exact pre-activation map to bring back any sibling the // pruning evicted, entry metadata included. viewStates: this.getPersistedViewStates({ fresh: true }), } } /** * Compensate the profile activation writes after one of them failed. Every * restore is awaited independently and logged: one failed restore must not * strand the others. The caller rethrows the original failure. */ private async _restoreProfileActivationState(snapshot: ProfileActivationSnapshot): Promise { try { await this.updateGlobalState("listApiConfigMeta", snapshot.listApiConfigMeta) } catch (error) { this.log( `[ClineProvider] profile-activation rollback failed for the profile list: ${error instanceof Error ? error.message : String(error)}`, ) } try { // Restore the shared selection through the ContextProxy directly: // this.setValue would also write the shared value into this view's pin // and its durable viewStates entry, replacing the view's own selection // with another view's choice. await this.contextProxy.setValue("currentApiConfigName", snapshot.currentApiConfigName) } catch (error) { this.log( `[ClineProvider] profile-activation rollback failed for the current profile selection: ${error instanceof Error ? error.message : String(error)}`, ) } try { await this.contextProxy.setProviderSettings(snapshot.providerSettings) } catch (error) { this.log( `[ClineProvider] profile-activation rollback failed for the shared provider settings: ${error instanceof Error ? error.message : String(error)}`, ) } try { // Restore this view's own pin and overlay together: an absent pin stays // absent (undefined deletes the entry) instead of materializing a pin // from the shared value. await this._saveViewLocalStateFromMutation({ currentApiConfigName: snapshot.viewCurrentApiConfigName, apiConfiguration: snapshot.viewApiConfiguration, }) } catch (error) { this.log( `[ClineProvider] profile-activation rollback failed for the view-local overlay: ${error instanceof Error ? error.message : String(error)}`, ) } try { // Restore the siblings the activation's pruning evicted, last, so it // lands after the overlay restore's own pin write, with their original // entry metadata. Only tracked evictions are restored: an entry a // sibling deleted on purpose after the snapshot is newer than the // snapshot too, and resurrecting it would undo the sibling's clear on // reload. The mutation queue is per-instance, so sibling views keep // writing to the durable map while an activation group is in flight; // merge, never replace, to leave those newer writes alone. const restore = ClineProvider.persistedViewStateWriteQueue.then(async () => { const current = this.getPersistedViewStates({ fresh: true }) const merged = { ...current } for (const viewId of this.prunedViewIdsDuringActivation) { const entry = snapshot.viewStates[viewId] if (entry && !Object.hasOwn(merged, viewId) && !ClineProvider.clearedViewStateIds.has(viewId)) { merged[viewId] = entry } } await this.contextProxy.setValue("viewStates", merged) }) ClineProvider.persistedViewStateWriteQueue = restore.catch(() => {}) await restore } catch (error) { this.log( `[ClineProvider] profile-activation rollback failed for the durable viewStates map: ${error instanceof Error ? error.message : String(error)}`, ) } } /** * Prior provider-manager state that the profile mutation group overwrites: the * manager's record for the profile being saved (or its confirmed absence) and * the manager-owned current selection. The manager store is the source of truth * that the profile list and the shared settings are derived from, so * compensation has to restore it and not only its ContextProxy copies. */ private async _snapshotProviderManagerState(name: string): Promise { let previousRecord: ProviderSettingsWithId | undefined let recordExisted: boolean | undefined let recordUnreadable = false let claimedCreate = false // Presence is tracked separately from the record: a store that cannot answer the // presence question at all stays best-effort (nothing was proven to be there), while // "present, but the record itself cannot be read" is the case a caller must refuse. let presenceEstablished: boolean | undefined try { // hasConfig distinguishes a confirmed absence from a read failure; the // generic wrapper getProfile throws for a missing record cannot, because // the manager does not type its not-found rejections. const exists = await this.providerSettingsManager.hasConfig(name) presenceEstablished = typeof exists === "boolean" ? exists : undefined recordExisted = presenceEstablished if (presenceEstablished) { const { name: _name, ...record } = await this.providerSettingsManager.getProfile({ name }) previousRecord = record as ProviderSettingsWithId } else { // Confirmed absence: claim the create so a concurrent create of the // same name by another view can make this rollback skip the deletion // that would destroy the other view's successful save. claimProfileCreate(name) claimedCreate = true } } catch { // The restore must not delete what it cannot see. When hasConfig already proved // the record was there, only the record read failed, and that is the case the // caller must refuse instead of overwriting. recordExisted = undefined recordUnreadable = presenceEstablished === true } let previousCurrentProfileName: string | undefined try { previousCurrentProfileName = await this.providerSettingsManager.getCurrentProfileName() } catch { // Best-effort: the selection restore is skipped when unreadable. } return { previousRecord, recordExisted, recordUnreadable, previousCurrentProfileName, claimedCreate } } /** * Selection-only variant of the manager snapshot, for paths that overwrite the * manager selection without touching any profile record. */ private async _snapshotProviderManagerSelection(): Promise { let previousCurrentProfileName: string | undefined try { previousCurrentProfileName = await this.providerSettingsManager.getCurrentProfileName() } catch { // Best-effort: the selection restore is skipped when unreadable. } return { previousRecord: undefined, recordExisted: undefined, recordUnreadable: false, claimedCreate: false, previousCurrentProfileName, } } /** * Restore the manager record for a mutation that failed after the record write * landed. A record the mutation created is deleted; a deletion that refuses * (last remaining configuration) is logged and leaves the record behind. */ private async _restoreProviderManagerRecord( name: string, snapshot: ProviderManagerSnapshot, written?: ProviderSettings, ): Promise { try { if (snapshot.recordExisted === true && snapshot.previousRecord) { if (written && !(await this._managerRecordStillHolds(name, written))) { // A save of the same profile from another view landed after this mutation's // write: replaying the pre-mutation record would discard that newer save. this.log( `[ClineProvider] skipping the provider-manager record rollback for '${name}': the stored record was superseded`, ) return } await this.providerSettingsManager.saveConfig(name, snapshot.previousRecord) } else if (snapshot.recordExisted === false) { // Delete only when this mutation owns the last create claim for the // name: another view may have created the same profile concurrently // and succeeded, and the record belongs to that save. const lastClaimant = snapshot.claimedCreate ? releaseProfileCreate(name) : true if (lastClaimant) { // A successful claimant owns the record: delete only when no claimant // succeeded, and clear the bookkeeping either way. const anySucceeded = profileCreatesSucceeded.delete(name) if (!anySucceeded) { await this.providerSettingsManager.deleteConfig(name) } } } } catch (error) { this.log( `[ClineProvider] profile-mutation rollback failed for the provider-manager record: ${error instanceof Error ? error.message : String(error)}`, ) } } /** * Whether the stored record still holds exactly the settings this mutation wrote. A store * that cannot answer keeps the previous behaviour (restore), and a record that is now gone * counts as superseded. Settings are compared through a canonical key/value serialisation so * key order cannot fake a difference. */ private async _managerRecordStillHolds(name: string, written: ProviderSettings): Promise { try { const current = await this.providerSettingsManager.getProfile({ name }) const { name: _currentName, id: _currentId, ...currentSettings } = current as ProviderSettings & { name?: string id?: string } const canonical = (settings: Record) => JSON.stringify( Object.keys(settings) .sort() .map((key) => [key, settings[key]]), ) return ( canonical(currentSettings as Record) === canonical(written as Record) ) } catch (error) { if (error instanceof ProviderSettingsNotFoundError) { return false } return true } } /** * Restore the manager-owned current selection captured by a prior snapshot. */ private async _restoreProviderManagerSelection(snapshot: ProviderManagerSnapshot): Promise { try { if (snapshot.previousCurrentProfileName) { await this.providerSettingsManager.activateProfile({ name: snapshot.previousCurrentProfileName }) } } catch (error) { this.log( `[ClineProvider] profile-mutation rollback failed for the provider-manager selection: ${error instanceof Error ? error.message : String(error)}`, ) } } async upsertProviderProfile( name: string, providerSettings: ProviderSettings, activate: boolean = true, ): Promise { try { return await this.enqueueProviderProfileMutation(() => this.persistProviderProfile(name, providerSettings, activate), ) } 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 () => { 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")) } try { await this.persistProviderProfile(name, merged as ProviderSettings, true) } 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) } catch (restoreError) { partialState = true this.log( `Error restoring profile after failed model update: ${JSON.stringify(restoreError, Object.getOwnPropertyNames(restoreError), 2)}`, ) } throw persistError } }) } 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, ): 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. // The manager record written below belongs to the same mutation group as // the activation writes: snapshot the prior manager record and the // manager's own selection BEFORE saveConfig so a failure anywhere in the // group compensates the manager store too, not just its ContextProxy // copies. The shared/per-view snapshot is taken at the same point. const managerSnapshot = await this._snapshotProviderManagerState(name) if (managerSnapshot.recordUnreadable) { // The pre-save read could not establish whether the record existed, so neither // restore branch of the compensation can decide what a landed record means. Stop // before saveConfig instead of writing a replacement that a later failure would // leave stored under the profile's name. throw new Error( `Provider profile '${name}' could not be inspected before saving; the save was not attempted`, ) } const preWriteSnapshot = this._snapshotProfileActivationState() let id: string try { id = await this.providerSettingsManager.saveConfig(name, providerSettings) } catch (error) { // saveConfig assigns the record before awaiting the store write, so a // rejection may still have landed the record: route through the // manager-record rollback (which deletes a landed record and spends the // create claim) instead of escaping the group with the claim leaked. await this._restoreProviderManagerRecord(name, managerSnapshot) throw error } // saveConfig normalizes the record (schema parse, legacy downgrades): // read the stored copy back and use it for every activation write, // sibling refresh, overlay, and task-handler update below, so raw // webview fields the profile schema strips cannot enter the shared // store, sibling view state, or task configuration. Fail closed when // the read-back fails: the raw caller payload must never stand in for // the validated record, so the landed save is compensated and the // mutation group fails instead of propagating unvalidated settings. let normalizedSettings: ProviderSettings try { const { name: _savedName, id: _savedId, ...storedRecord } = await this.providerSettingsManager.getProfile({ name }) normalizedSettings = storedRecord as ProviderSettings } catch (error) { // Fail closed: compensate the landed manager record and fail the // group so raw webview data cannot reach shared writes, sibling // overlays, or task configuration. await this._restoreProviderManagerRecord(name, managerSnapshot) throw new Error( `Provider profile '${name}' could not be read back after save: ${error instanceof Error ? error.message : String(error)}`, ) } // The mode mapping is compensated from a record of the mapping write that was actually issued, // not from the mode captured below. The capture runs before the sibling snapshot and the // prior-mapping read, so gating the rollback on it alone would replay a restore for a write // that never started and clear a mapping this call never overwrote. let modeMappingWrite: { mode: Mode; priorModeConfigId: string | undefined } | undefined try { if (activate) { const { mode } = await this.getState() // These promises do the following: // 1. Adds or updates the list of provider profiles. // 2. Sets the current provider profile. // 3. Sets the current mode's provider profile. // 4. Copies the provider settings to the context. // // Note: 1, 2, and 4 can be done in one `ContextProxy` call: // this.contextProxy.setValues({ ...providerSettings, listApiConfigMeta: ..., currentApiConfigName: ... }) // We should probably switch to that and verify that it works. // I left the original implementation in just to be safe. // Sibling views pinned to other profiles must keep their own // settings: the shared write below replaces the shared store, so // pin their profile's settings into their overlays first. await this.snapshotSiblingOverlaysBeforeSharedSettingsWrite(name, { requireResolvablePins: true }) let priorModeConfigId: string | undefined try { priorModeConfigId = await this.providerSettingsManager.getModeConfigId(mode) } catch { // Best-effort snapshot: when the prior mapping cannot be read the // compensation restores the mapping as absent. } // Issue the mapping write and record the pairing it used at the same moment: the // compensation below restores exactly this mapping, and only if this line ran. modeMappingWrite = { mode, priorModeConfigId } // `allSettled` so the compensation below never races a write still in flight. const activationResults = await Promise.allSettled([ this.updateGlobalState("listApiConfigMeta", await this.providerSettingsManager.listConfig()), // Route through setValue so the in-memory viewLocalState buffer tracks the // activated profile: a plain global write would leave a stale loaded // currentApiConfigName shadowing the new value in getValues(). this.setValue("currentApiConfigName", name), this.providerSettingsManager.setModeConfig(mode, id), this.contextProxy.setProviderSettings(normalizedSettings), // setProviderSettings writes the shared store directly, bypassing the // view-local mutation path: clear this view's buffered apiConfiguration // overlay (if any) so a stale loaded profile cannot keep shadowing the // new settings in getState(). this._saveViewLocalStateFromMutation({ apiConfiguration: undefined }), ]) const failedActivation = activationResults.find( (result): result is PromiseRejectedResult => result.status === "rejected", ) if (failedActivation) throw failedActivation.reason // Other live views may have buffered this profile's settings earlier; // refresh them so their getState() cannot report the updated profile's // name with stale settings. await this.refreshViewLocalStateForUpdatedProfile(name, normalizedSettings) // Change the provider for the current task. // TODO: We should rename `buildApiHandler` for clarity (e.g. `getProviderClient`). this.updateTaskApiHandlerIfNeeded(normalizedSettings, { 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()) // A save that does not activate still changes the profile's settings, and // every view pinned to it buffers a full copy of them. Refresh those views // (and this one when it is the pinned view) or their getState() keeps // serving the settings captured before the save under the same name. if (this.pinnedProfileName === name) { await this._saveViewLocalStateFromMutation({ apiConfiguration: normalizedSettings }) // Only rebuild when the task actually runs under the saved profile: a // view pinned to this profile can hold a task that follows a different // sticky profile, and the forced rebuild would silently switch that // conversation to the saved profile's settings. if (this.getCurrentTask()?.taskApiConfigName === name) { this.updateTaskApiHandlerIfNeeded(normalizedSettings, { forceRebuild: true }) } } await this.refreshViewLocalStateForUpdatedProfile(name, normalizedSettings) } await this.postStateToWebview() // The create completed: the claim is spent and later rollbacks of // other creates must see this one as finished - and spare the record // it saved. if (managerSnapshot.claimedCreate) { markProfileCreateSucceeded(name) if (releaseProfileCreate(name)) { profileCreatesSucceeded.delete(name) } } return id } catch (error) { // Compensate every write that may have landed, each restore awaited // on its own so one failure cannot strand the rest: the manager // record and selection first (the manager store is the source of // truth that the profile list and the shared settings are derived // from), then the mode mapping, then the shared and per-view state. // The original failure is re-thrown for the caller. await this._restoreProviderManagerRecord(name, managerSnapshot, normalizedSettings) await this._restoreProviderManagerSelection(managerSnapshot) if (modeMappingWrite) { try { await this.providerSettingsManager.setModeConfig( modeMappingWrite.mode, modeMappingWrite.priorModeConfigId as string, ) } catch (restoreError) { this.log( `[ClineProvider] profile-activation rollback failed for the mode mapping of ${modeMappingWrite.mode}: ${ restoreError instanceof Error ? restoreError.message : String(restoreError) }`, ) } } await this._restoreProfileActivationState(preWriteSnapshot) throw error } } 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") } // Snapshot the manager record and every persistence surface this // deletion rewrites BEFORE the manager delete commits, so a failure // inside the write group below can be compensated instead of leaving // the secrets-backed record gone while metadata and view pins still // reference it. let previousRecord: Awaited> | undefined try { previousRecord = await this.providerSettingsManager.getProfile({ name: profileToDelete.name }) } catch (error) { // Only a typed not-found means the record was already gone. Any other failure // means the snapshot is unavailable, and deleting without a restore point would // leave the deletion uncompensatable, so it propagates instead. if (!(error instanceof ProviderSettingsNotFoundError)) { throw error } } const previousEntries = this.getProviderProfileEntries() const previousSelection = globalSettings.currentApiConfigName const previousSharedSettings = this.contextProxy.getProviderSettings() const previousViewStates = this.getPersistedViewStates({ fresh: true }) // Remember what each live sibling's overlay holds before anything is rewritten, so // the compensation can undo the re-pin: leaving a sibling on the survivor after a // deletion that failed is a state no caller asked for. Captured outside the write // group so the catch below can reach it. const rePinnedSiblings = ClineProvider.getAllInstances() .filter((instance) => instance !== this && instance.pinnedProfileName === profileToDelete.name) .map((instance) => ({ instance, apiConfiguration: instance.viewLocalState.apiConfiguration })) // Remove the profile from the settings store (context.secrets) so it cannot be // resurrected by a later listApiConfigMeta sync. A not-found rejection means // the secret was already gone (e.g. pruned by an earlier run): branch on the // typed ProviderSettingsNotFoundError so the stale list entry below is still // pruned as an idempotent success, while any other failure (e.g. refusing to // delete the last remaining configuration) propagates. Matching message text // instead would let a profile whose name contains "not found" swallow an // unrelated failure. try { await this.providerSettingsManager.deleteConfig(profileToDelete.name) } catch (error) { if (!(error instanceof ProviderSettingsNotFoundError)) { throw error } this.log( `deleteProviderProfile: settings for '${profileToDelete.name}' were not found; pruning the stale list entry only`, ) } try { const entries = this.getProviderProfileEntries().filter(({ name }) => name !== profileToDelete.name) // Write only the profile list back: replaying the full settings snapshot // captured above would also rewrite unrelated keys (including viewStates, // which ClineProvider mutates directly in storage for concurrent views) // with this view's stale cached copy. await this.contextProxy.setValue("listApiConfigMeta", entries) // Two views in this extension host share the ContextProxy while their provider queues // and their settings-manager locks stay per instance, so a sibling view can move the // shared selection between this method's entry snapshot and the moment the deletion // became durable above. Decide what the shared store owes the selection as it stands // now that the record is gone, not as it was at entry. If it names the profile this // deletion just removed, the selection has to move to a survivor; the survivor itself stays // the one chosen above, because picking a different one here would mean deciding the // user's next selection for them. The remaining race - an activation that lands after // this read and before the // writes below - needs a lock spanning ProviderSettingsManager instances, which is // tracked as its own unit rather than widened into this method. const selectionAfterDelete = this.contextProxy.getValues().currentApiConfigName const deletedWasGlobal = profileToDelete.name === selectionAfterDelete // Capture this view's pin before any rewrite: a view pinned to the // deleted profile while the global selection points elsewhere must still be // reconfigured, or getState() would keep the deleted profile's settings under // the surviving profile's name. const viewWasPinnedToDeleted = this.viewLocalState.currentApiConfigName === profileToDelete.name // Whether anything is actually going to be re-pointed at the survivor: this view's // own pin, the shared selection, a live sibling's pin, or a closed view's // persisted pin. Only then does the survivor's configuration have to be known. const rePointsToSurvivor = deletedWasGlobal || viewWasPinnedToDeleted || rePinnedSiblings.length > 0 || Object.values(previousViewStates).some((entry) => entry?.currentApiConfigName === profileToDelete.name) // Resolve the surviving profile's settings so this view and any other live view // still pinned to the deleted profile can be re-pinned with a matching // configuration. When something is about to re-point at the survivor and its // configuration cannot be read, the failure propagates: those pins would all // name a profile whose settings are unknown while the shared store keeps // carrying the deleted profile's credentials under the survivor's name, and the // catch at the bottom of this method restores the record, selection, and shared // settings instead. Where nothing re-points, the survivor is not touched at all, // so pruning a stale list entry stays an idempotent success even when the // survivor cannot be read. let survivingSettings: ProviderSettings | undefined if (rePointsToSurvivor) { const { name: _survivingName, ...survivingRecord } = await this.providerSettingsManager.getProfile({ name: profileToActivate, }) survivingSettings = survivingRecord as ProviderSettings } // The shared writes below (setValue's shared persistence, or the // survivor's setProviderSettings) replace the shared store: views pinned // to a third profile with no overlay serve the shared store under their // own pin, so pin their profiles' settings into their overlays first, or // their getState() would pair their pin with the survivor's settings. // This view is covered too unless its own pin named the deleted profile // (that case re-points the pin and the overlay together below). if (deletedWasGlobal || viewWasPinnedToDeleted) { await this.snapshotSiblingOverlaysBeforeSharedSettingsWrite(profileToActivate, { includeSelf: !viewWasPinnedToDeleted, }) } if (viewWasPinnedToDeleted) { // This view's pin now dangles: re-point it. setValue also persists the // survivor to the shared store, which covers the deleted-was-global case // for every other view as well as this one. await this.setValue("currentApiConfigName", profileToActivate) } else if (deletedWasGlobal) { // The shared selection changed, but this view's own pin still names a // surviving profile: update the shared store only, leaving the // view-local pin untouched. await this.contextProxy.setValue("currentApiConfigName", profileToActivate) } if ((deletedWasGlobal || viewWasPinnedToDeleted) && survivingSettings) { // The deleted profile was the active one (globally, or for this view), so // the shared provider keys still carry its settings; replace them so // getState() reports the surviving profile's configuration. await this.contextProxy.setProviderSettings(survivingSettings) if (viewWasPinnedToDeleted) { // This view's nested overlay (viewLocalState.apiConfiguration, seeded // by loadViewState) still serves the deleted profile's configuration: // replace it with the survivor's so the re-pointed pin serves matching // settings. A view pinned to another profile keeps its own overlay. await this._saveViewLocalStateFromMutation({ apiConfiguration: survivingSettings }) } } // The acting view's own conversation may be running the deleted profile's // handler: re-pointing the pin and the shared store does not touch a live // handler, so the running task keeps sending the deleted profile's // configuration. Same guard the sibling re-pin above uses - rebuild only when // the task actually runs under the deleted profile, so a conversation that // follows a different sticky profile is neither switched nor rewritten - and // the sticky name moves to the survivor as well, or a later reload re-resolves // a profile that no longer exists. if (this.getCurrentTask()?.taskApiConfigName === profileToDelete.name) { if (survivingSettings) { this.updateTaskApiHandlerIfNeeded(survivingSettings, { forceRebuild: true }) } await this.persistStickyProviderProfileToCurrentTask(profileToActivate) } // Re-pin other live views still buffered on the deleted profile: their // buffer and durable viewStates entry would otherwise keep serving the // deleted profile's name and configuration. await this.rePinViewLocalStateForDeletedProfile(profileToDelete.name, profileToActivate, survivingSettings) // Closed views keep their durable viewStates entry after disposal: a pin // naming the deleted profile would rehydrate a missing profile name on // the next load. Re-point those persisted pins to the survivor too. await this.repointPersistedViewStates(profileToDelete.name, profileToActivate) await this.postStateToWebview() } catch (error) { // The manager delete committed but a later write failed: restore the // deleted record first, then the shared metadata, selection, and shared // settings the deletion had rewritten, and finally merge the affected // persisted pins back. The merge re-reads the durable map and rewrites // only entries that named the deleted profile in the pre-delete // snapshot, so concurrent viewStates updates from sibling views // survive. Each restore is independent and logged; the original // failure is what the caller sees. let recordRestored = !previousRecord if (previousRecord) { try { // Keep the original id: mode-to-profile mappings, the restored list entry and // enhancementApiConfigId all reference the record by id. const { name: _previousName, ...previousSettings } = previousRecord await this.providerSettingsManager.saveConfig(profileToDelete.name, previousSettings) recordRestored = true } catch (restoreError) { this.log( `[deleteProviderProfile] failed to restore the manager record '${profileToDelete.name}': ${ restoreError instanceof Error ? restoreError.message : String(restoreError) }`, ) } } if (recordRestored) { // The manager record is back, so a sibling may be pinned to the restored // profile again. Undo only the re-pins this deletion made: a sibling that // moved its own pin since then is newer than this rollback and is left alone. for (const { instance, apiConfiguration } of rePinnedSiblings) { if (instance.pinnedProfileName !== profileToActivate) { continue } try { await instance._saveViewLocalStateFromMutation({ currentApiConfigName: profileToDelete.name, apiConfiguration, }) } catch (restoreError) { this.log( `[deleteProviderProfile] failed to restore a sibling view pin: ${ restoreError instanceof Error ? restoreError.message : String(restoreError) }`, ) } } } try { // Merge rather than clobber: another instance may have added // profiles between the snapshot and the compensation. const currentEntries = this.getProviderProfileEntries() const deletedEntry = previousEntries.find((entry) => entry.name === profileToDelete.name) if (deletedEntry && !currentEntries.some((entry) => entry.name === profileToDelete.name)) { await this.contextProxy.setValue("listApiConfigMeta", [...currentEntries, deletedEntry]) } } catch (restoreError) { this.log( `[deleteProviderProfile] failed to restore the profile list: ${restoreError instanceof Error ? restoreError.message : String(restoreError)}`, ) } try { await this.contextProxy.setValue("currentApiConfigName", previousSelection) } catch (restoreError) { this.log( `[deleteProviderProfile] failed to restore the shared selection: ${restoreError instanceof Error ? restoreError.message : String(restoreError)}`, ) } try { await this.contextProxy.setProviderSettings(previousSharedSettings) } catch (restoreError) { this.log( `[deleteProviderProfile] failed to restore the shared provider settings: ${restoreError instanceof Error ? restoreError.message : String(restoreError)}`, ) } try { const restore = ClineProvider.persistedViewStateWriteQueue.then(async () => { const current = this.getPersistedViewStates({ fresh: true }) const merged = { ...current } let changed = false for (const [viewId, entry] of Object.entries(previousViewStates)) { if (entry?.currentApiConfigName !== profileToDelete.name) { continue } if (current[viewId] && current[viewId].currentApiConfigName !== profileToActivate) { // Undo only a pin this deletion actually re-pointed. A sibling that moved // its own entry somewhere else during the failure window is newer than // this snapshot, and rewriting it here would resurrect the deleted // profile's name. continue } merged[viewId] = entry changed = true } if (changed) { await this.contextProxy.setValue("viewStates", merged) } }) ClineProvider.persistedViewStateWriteQueue = restore.catch(() => {}) await restore } catch (restoreError) { this.log( `[deleteProviderProfile] failed to restore the persisted view pins: ${restoreError instanceof Error ? restoreError.message : String(restoreError)}`, ) } throw error } } 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) }`, ) } } /** * Snapshot the settings of sibling views pinned to a different profile into * their view-local overlays before an activation, upsert, or deletion * overwrites the shared provider-settings store. `getState()` merges the * overlay over the shared store, so a sibling pinned to another profile with * no overlay would otherwise start reporting its pin's name together with * the newly written profile's settings - and a task created from that view * would run against the wrong provider, model, and credentials. Siblings * pinned to the written profile are refreshed by * `refreshViewLocalStateForUpdatedProfile` (or the deletion re-pin); * unpinned siblings follow the shared slot, where name and settings move * together. `includeSelf` covers the deletion path, where this view's own * pin may name a third profile with no overlay. */ private async snapshotSiblingOverlaysBeforeSharedSettingsWrite( writtenProfileName: string, options?: { includeSelf?: boolean; requireResolvablePins?: boolean }, ): Promise { // requireResolvablePins is for the callers that are about to replace the shared // settings: a sibling whose pinned profile cannot be read would keep reporting that // pin while its tasks run the new profile's credentials, so the activation must fail // before the shared write instead of proceeding without the snapshot. The deletion // caller keeps tolerating unresolvable pins because there the pin is the profile being // deleted and the same helper re-points such views. const requireResolvablePins = options?.requireResolvablePins === true const unresolvablePins: unknown[] = [] const affected = ClineProvider.getAllInstances().filter( (instance) => (instance !== this || options?.includeSelf === true) && instance.pinnedProfileName !== undefined && instance.pinnedProfileName !== writtenProfileName && instance.viewLocalState.apiConfiguration === undefined, ) await Promise.all( affected.map(async (instance) => { const pinName = instance.pinnedProfileName if (pinName === undefined) { return } try { const { name: _siblingPinName, ...settings } = await this.providerSettingsManager.getProfile({ name: pinName, }) // Direct private access: compile-time safe across sibling instances. await instance._saveViewLocalStateFromMutation({ apiConfiguration: settings as ProviderSettings }) await instance.postStateToWebview() } catch (error) { // A pin that names a profile the store no longer has is the tolerated case in // every caller: the deletion path re-points such views and there is no correct // snapshot to take for a profile that is gone. Any other read failure means the // sibling's settings are unknown, and unknown settings must not be guessed from // the shared store the caller is about to replace - that is the case the // activation callers fail on. Same discrimination as the deletion path above. if (requireResolvablePins && !(error instanceof ProviderSettingsNotFoundError)) { unresolvablePins.push(error) } else { this.log( `[snapshotSiblingOverlaysBeforeSharedSettingsWrite] Unable to resolve profile '${pinName}': ${ error instanceof Error ? error.message : String(error) }`, ) } } }), ) if (unresolvablePins.length > 0) { throw unresolvablePins[0] } } 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 }, ) { // The queue stays owned until this run - including its compensation - settles: a // group write that hangs past the mutation timeout must not let a later activation // complete before this rollback, or the late restore would overwrite the later // activation's selection and settings. return this.enqueueProviderProfileMutation(() => this.activateProviderProfileUnlocked(args, options)) } private async activateProviderProfileUnlocked( args: { name: string } | { id: string }, options?: { persistModeConfig?: boolean persistTaskHistory?: boolean skipCurrentTaskRebuild?: boolean expectedCurrentTaskId?: string }, ): Promise { // The manager selection written by activateProfile belongs to the same // mutation group as the writes below: snapshot it first so a failed group // compensates the manager store, not just its copies. const managerSelectionSnapshot = await this._snapshotProviderManagerSelection() const { name, id, ...providerSettings } = await this.providerSettingsManager.activateProfile(args) const persistModeConfig = options?.persistModeConfig ?? true const persistTaskHistory = options?.persistTaskHistory ?? true const skipCurrentTaskRebuild = options?.skipCurrentTaskRebuild ?? false // Hoisted out of the group below: the mode-binding failure further down happens after // the selection, the profile list and the shared settings have already landed, and it has // to roll the same activation back. let activationSnapshot: ProfileActivationSnapshot | undefined if (!skipCurrentTaskRebuild) { // See `upsertProviderProfile` for a description of what this is doing. // Sibling views pinned to other profiles must keep their own settings: // the shared write below replaces the shared store, so pin their // profile's settings into their overlays first. activationSnapshot = this._snapshotProfileActivationState() try { await this.snapshotSiblingOverlaysBeforeSharedSettingsWrite(name, { requireResolvablePins: true }) // `allSettled` so the compensation below never races a write still in flight. const activationResults = await Promise.allSettled([ this.contextProxy.setValue("listApiConfigMeta", await this.providerSettingsManager.listConfig()), // Route through setValue so the in-memory viewLocalState buffer tracks the // activated profile: a plain ContextProxy write would leave a stale loaded // currentApiConfigName shadowing the new value in getValues(). this.setValue("currentApiConfigName", name), this.contextProxy.setProviderSettings(providerSettings), // setProviderSettings writes the shared store directly, bypassing the // view-local mutation path: clear this view's buffered apiConfiguration // overlay (if any) so a stale loaded profile cannot keep shadowing the // new settings in getState(). this._saveViewLocalStateFromMutation({ apiConfiguration: undefined }), ]) const failedActivation = activationResults.find( (result): result is PromiseRejectedResult => result.status === `rejected`, ) if (failedActivation) throw failedActivation.reason } catch (error) { // Compensate every write that may have landed before the failure, // including the manager selection written by activateProfile. await this._restoreProviderManagerSelection(managerSelectionSnapshot) await this._restoreProfileActivationState(activationSnapshot) throw error } // Other live views may have buffered this profile's settings earlier; // refresh them so their getState() cannot report the activated profile's // name with stale settings. Compensated on failure like the writes above: a // sibling refresh that throws must not leave the activation half applied. try { await this.refreshViewLocalStateForUpdatedProfile(name, providerSettings) } catch (error) { await this._restoreProviderManagerSelection(managerSelectionSnapshot) await this._restoreProfileActivationState(activationSnapshot) throw error } } const { mode } = await this.getState() if (id && persistModeConfig) { // The mode mapping is one more write that can fail after the selection and the // shared state already moved. Snapshot the prior mapping so the failure restores it // instead of leaving the activated profile bound to the mode. const previousModeConfigId = await this.providerSettingsManager.getModeConfigId(mode) try { await this.providerSettingsManager.setModeConfig(mode, id) } catch (error) { if (previousModeConfigId) { try { await this.providerSettingsManager.setModeConfig(mode, previousModeConfigId) } catch (restoreError) { this.log( `[activateProviderProfile] Failed to restore the mode mapping for '${mode}': ${ restoreError instanceof Error ? restoreError.message : String(restoreError) }`, ) } } await this._restoreProviderManagerSelection(managerSelectionSnapshot) if (activationSnapshot) { // The activation is already applied at this point: selection, profile list and shared // settings have all moved. A caller that receives an error must not end up with a // profile that is activated but bound to no mode. await this._restoreProfileActivationState(activationSnapshot) } throw error } } // 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 }) } } /** * Refresh the view-local apiConfiguration buffer of the other live views * pinned to the given profile. An upsert/activation rewrites the profile's * settings in the shared store and the store-backed manager, but a view * whose buffer loaded the profile earlier keeps shadowing the stale * settings in its getState() until its own next mutation. The originating * view refreshes its buffer at the mutation site itself. * * A failure part-way through is compensated here: the caller's rollback cannot reach another * view's in-memory buffer, so every sibling this refresh already moved is put back, unless it * has since taken a newer change of its own. */ private async refreshViewLocalStateForUpdatedProfile( name: string, providerSettings: ProviderSettings, ): Promise { const affected = ClineProvider.getAllInstances().filter( (instance) => instance !== this && instance.pinnedProfileName === name, ) if (affected.length === 0) { return } // Each sibling's write is independent, so a later sibling can fail after earlier ones already // took the saved settings. The caller's activation rollback only reaches this view's own overlay // plus the durable maps, so anything this refresh already applied to another view has to be // undone here: otherwise a sibling keeps serving the settings of a refresh the caller reports as // failed. Sequential application is what makes the order below a real apply order. const applied: { instance: ClineProvider task: Task | undefined previousApiConfiguration: ProviderSettings | undefined previousTaskApiConfiguration: ProviderSettings | undefined }[] = [] try { for (const instance of affected) { // Direct private access: compile-time safe across sibling instances. const task = instance.getCurrentTask() const { apiConfiguration: previousApiConfiguration } = instance.viewLocalState // The task's own handler was built from these settings and the rebuild below replaces // them, so they have to be remembered here rather than read back afterwards. const previousTaskApiConfiguration = task?.taskApiConfigName === name ? task.apiConfiguration : undefined await instance._saveViewLocalStateFromMutation({ apiConfiguration: providerSettings }) applied.push({ instance, task, previousApiConfiguration, previousTaskApiConfiguration }) // The active task of a pinned view holds its own API handler built from // the profile's settings: rebuild it with the saved settings or the // running conversation keeps sending the old token even though the // view's buffer has caught up. Only rebuild when the task actually runs // under the saved profile: a task that follows a different sticky // profile must keep its own settings. Direct private access across // sibling instances of the same class is compile-time safe. if (task && task.taskApiConfigName === name) { instance.updateTaskApiHandlerIfNeeded(providerSettings, { forceRebuild: true }) } await instance.postStateToWebview() } } catch (error) { // Undo in reverse, and only where this refresh's values are still in place: a sibling that // moved its own overlay afterwards is newer than this operation, and overwriting it would // drop the sibling's own change instead of this one. Each restore is logged and swallowed: // a failed restore must not strand the remaining siblings, and the caller rethrows the // original failure. for (const { instance, task, previousApiConfiguration, previousTaskApiConfiguration } of [ ...applied, ].reverse()) { if (instance.viewLocalState.apiConfiguration !== providerSettings) { continue } try { await instance._saveViewLocalStateFromMutation({ apiConfiguration: previousApiConfiguration }) // The same guard on the task side: only a handler this refresh rebuilt, on a task that // still runs under the saved profile, may be rebuilt back to the previous settings. if ( previousTaskApiConfiguration && instance.getCurrentTask() === task && task?.taskApiConfigName === name ) { instance.updateTaskApiHandlerIfNeeded(previousTaskApiConfiguration, { forceRebuild: true }) } await instance.postStateToWebview() } catch (restoreError) { this.log( `[refreshViewLocalStateForUpdatedProfile] Unable to restore the overlay of view ${instance.viewId} after the refresh of profile '${name}' failed: ${restoreError instanceof Error ? restoreError.message : String(restoreError)}`, ) } } throw error } } /** * Re-pin the other live views whose buffer still names a deleted profile: * without this their in-memory buffer and durable viewStates entry keep * serving the deleted profile's name and configuration on top of the * surviving shared state. Each affected view's durable entry is re-pinned * through the serialized write queue, so the rename survives reloads. */ private async rePinViewLocalStateForDeletedProfile( deletedProfileName: string, replacementName: string, replacementSettings: ProviderSettings | undefined, ): Promise { const affected = ClineProvider.getAllInstances().filter( (instance) => instance !== this && instance.pinnedProfileName === deletedProfileName, ) if (affected.length === 0) { return } await Promise.all( affected.map(async (instance) => { const values: Partial & Partial = { currentApiConfigName: replacementName, } // Assigning even when undefined is what clears the overlay: without a // resolvable replacement the re-pointed pin names a profile whose settings // are unknown, so the deleted profile's configuration must not survive under // the new name. values.apiConfiguration = replacementSettings // Direct private access: compile-time safe across sibling instances. await instance._saveViewLocalStateFromMutation(values) // The active task of a view pinned to the deleted profile holds its own API // handler and sticky profile name built from the deleted profile: rebuild // the handler with the replacement's settings and re-point the sticky name, // or the running conversation keeps sending the deleted profile's // configuration even though the view's buffer was re-pointed (the posted // state prefers the task's own apiConfiguration). Only re-point when the // task actually runs under the deleted profile: a view pinned to it can // hold a task that follows a different sticky profile, and forcing the // replacement would silently switch that conversation and rewrite its // persisted profile name without the user asking. if (instance.getCurrentTask()?.taskApiConfigName === deletedProfileName) { if (replacementSettings) { instance.updateTaskApiHandlerIfNeeded(replacementSettings, { forceRebuild: true }) } await instance.persistStickyProviderProfileToCurrentTask(replacementName) } await instance.postStateToWebview() }), ) } /** * Re-point live views still pinned to a profile that is being renamed onto * the new name. A rename keeps the profile id, so a view pinned to the old * name should follow the record instead of falling back to the shared * selection: without this, the pin dangles at a name that no longer * resolves and the buffered overlay keeps serving the pre-rename copy. * Must run before the old name is deleted. */ public async repointViewLocalStateAfterProfileRename( oldName: string, newName: string, renamedSettings: ProviderSettingsWithId | undefined, ): Promise { // Closed views keep their pin only in the persisted map: re-point those entries // first so a view that launches after an intervening activation still follows the // renamed profile instead of dangling at the deleted name. await this.repointPersistedViewStates(oldName, newName) const affected = ClineProvider.getAllInstances().filter((instance) => instance.pinnedProfileName === oldName) if (affected.length === 0) { return } await Promise.all( affected.map(async (instance) => { // Assigning the settings (even undefined) is what replaces or clears the // overlay: the renamed record's current settings are the only correct // overlay for a pin that now names the new profile. await instance._saveViewLocalStateFromMutation({ currentApiConfigName: newName, apiConfiguration: renamedSettings, }) // A task running under the old name holds its own API handler and sticky // profile name: re-point both, or the conversation keeps sending the // pre-rename configuration under a name that no longer resolves. if (instance.getCurrentTask()?.taskApiConfigName === oldName) { if (renamedSettings) { instance.updateTaskApiHandlerIfNeeded(renamedSettings, { forceRebuild: true }) } await instance.persistStickyProviderProfileToCurrentTask(newName) } await instance.postStateToWebview() }), ) } 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 need the token saved to disk and the views pinned // to them refreshed: a direct saveConfig leaves a pinned view serving the // old token until it reloads. await this.upsertProviderProfile(entry.name, updated, false) } } } } 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() // Merge viewLocalState on top of global state so a provider can serve // state values scoped to its own view while preserving ContextProxy defaults. const mergedStateValues = { ...stateValues, ...this.viewLocalState } const customModes = await this.customModesManager.getCustomModes() // Determine apiProvider with the same logic as before, while filtering retired providers. // Use mergedStateValues to prioritize viewLocalState for parallel mode support const apiProvider: ProviderName = mergedStateValues.apiProvider && !isRetiredProvider(mergedStateValues.apiProvider) ? mergedStateValues.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. // A view-local profile is a complete snapshot, not a patch over the shared store. // Spreading it on top of providerSettings would let a key the profile never defined // leak in from another profile, which ContextProxy.setProviderSettings deliberately // prevents by clearing omitted keys. Use the view's own settings in place and take // only apiProvider from the shared state when the profile does not carry one. // The overlay is read from the buffer itself: the raw apiConfiguration state key // is a partial patch from shared-store paths, not a view snapshot. const viewApiConfiguration = this.viewLocalState.apiConfiguration const resolvedApiConfiguration = viewApiConfiguration ? { ...viewApiConfiguration, apiProvider: viewApiConfiguration.apiProvider ?? providerSettings.apiProvider, } : providerSettings return { apiConfiguration: resolvedApiConfiguration, lastShownAnnouncementId: mergedStateValues.lastShownAnnouncementId, customInstructions: mergedStateValues.customInstructions, apiModelId: mergedStateValues.apiModelId, alwaysAllowReadOnly: mergedStateValues.alwaysAllowReadOnly ?? false, alwaysAllowReadOnlyOutsideWorkspace: mergedStateValues.alwaysAllowReadOnlyOutsideWorkspace ?? false, allowedReadFiles: mergedStateValues.allowedReadFiles ?? [], alwaysAllowWrite: mergedStateValues.alwaysAllowWrite ?? false, alwaysAllowWriteOutsideWorkspace: mergedStateValues.alwaysAllowWriteOutsideWorkspace ?? false, alwaysAllowWriteProtected: mergedStateValues.alwaysAllowWriteProtected ?? false, allowedWriteFiles: mergedStateValues.allowedWriteFiles ?? [], alwaysAllowExecute: mergedStateValues.alwaysAllowExecute ?? false, destructiveCommandGuardEnabled: mergedStateValues.destructiveCommandGuardEnabled ?? DEFAULT_DESTRUCTIVE_COMMAND_GUARD_ENABLED, alwaysDenyUnapprovedCommands: mergedStateValues.alwaysDenyUnapprovedCommands ?? DEFAULT_ALWAYS_DENY_UNAPPROVED_COMMANDS, alwaysAllowMcp: mergedStateValues.alwaysAllowMcp ?? false, alwaysAllowModeSwitch: mergedStateValues.alwaysAllowModeSwitch ?? false, alwaysAllowSubtasks: mergedStateValues.alwaysAllowSubtasks ?? false, alwaysAllowFollowupQuestions: mergedStateValues.alwaysAllowFollowupQuestions ?? false, followupAutoApproveTimeoutMs: mergedStateValues.followupAutoApproveTimeoutMs ?? 60000, diagnosticsEnabled: mergedStateValues.diagnosticsEnabled ?? true, allowedMaxRequests: mergedStateValues.allowedMaxRequests, allowedMaxCost: mergedStateValues.allowedMaxCost, autoCondenseContext: mergedStateValues.autoCondenseContext ?? true, autoCondenseContextPercent: mergedStateValues.autoCondenseContextPercent ?? 100, taskHistory: includeTaskHistory ? this.taskHistoryStore.getAll() : [], allowedCommands: mergedStateValues.allowedCommands, deniedCommands: mergedStateValues.deniedCommands, soundEnabled: mergedStateValues.soundEnabled ?? false, ttsEnabled: mergedStateValues.ttsEnabled ?? false, ttsSpeed: mergedStateValues.ttsSpeed ?? 1.0, enableCheckpoints: mergedStateValues.enableCheckpoints ?? true, checkpointTimeout: mergedStateValues.checkpointTimeout ?? DEFAULT_CHECKPOINT_TIMEOUT_SECONDS, soundVolume: mergedStateValues.soundVolume, writeDelayMs: mergedStateValues.writeDelayMs ?? DEFAULT_WRITE_DELAY_MS, diffFuzzyThreshold: mergedStateValues.diffFuzzyThreshold ?? DEFAULT_DIFF_FUZZY_THRESHOLD, terminalShellIntegrationTimeout: mergedStateValues.terminalShellIntegrationTimeout ?? Terminal.defaultShellIntegrationTimeout, terminalShellIntegrationDisabled: mergedStateValues.terminalShellIntegrationDisabled ?? true, collapseCommandOutputByDefault: mergedStateValues.collapseCommandOutputByDefault ?? DEFAULT_COLLAPSE_COMMAND_OUTPUT_BY_DEFAULT, terminalCommandDelay: mergedStateValues.terminalCommandDelay ?? 0, terminalPowershellCounter: mergedStateValues.terminalPowershellCounter ?? false, terminalZshClearEolMark: mergedStateValues.terminalZshClearEolMark ?? true, terminalZshOhMy: mergedStateValues.terminalZshOhMy ?? false, terminalZshP10k: mergedStateValues.terminalZshP10k ?? false, terminalZdotdir: mergedStateValues.terminalZdotdir ?? false, terminalProfile: mergedStateValues.terminalProfile, mode: (mergedStateValues.mode as Mode) ?? defaultModeSlug, language: mergedStateValues.language ?? formatLanguage(vscode.env.language), mcpEnabled: mergedStateValues.mcpEnabled ?? true, mcpServers: this.mcpHub?.getAllServers() ?? [], currentApiConfigName: mergedStateValues.currentApiConfigName ?? "default", listApiConfigMeta: mergedStateValues.listApiConfigMeta ?? [], pinnedApiConfigs: mergedStateValues.pinnedApiConfigs ?? {}, modeApiConfigs: (mergedStateValues.modeApiConfigs as Record) ?? ({} as Record), customModePrompts: mergedStateValues.customModePrompts ?? {}, customSupportPrompts: mergedStateValues.customSupportPrompts ?? {}, enhancementApiConfigId: mergedStateValues.enhancementApiConfigId, experiments: mergedStateValues.experiments ?? experimentDefault, autoApprovalEnabled: mergedStateValues.autoApprovalEnabled ?? false, customModes, maxOpenTabsContext: mergedStateValues.maxOpenTabsContext ?? 20, maxWorkspaceFiles: mergedStateValues.maxWorkspaceFiles ?? 200, disabledTools: mergedStateValues.disabledTools, telemetrySetting: mergedStateValues.telemetrySetting || "unset", showRooIgnoredFiles: mergedStateValues.showRooIgnoredFiles ?? false, enableSubfolderRules: mergedStateValues.enableSubfolderRules ?? false, maxImageFileSize: mergedStateValues.maxImageFileSize ?? 5, maxTotalImageSize: mergedStateValues.maxTotalImageSize ?? 20, historyPreviewCollapsed: mergedStateValues.historyPreviewCollapsed ?? false, reasoningBlockCollapsed: mergedStateValues.reasoningBlockCollapsed ?? true, chatFontSize: mergedStateValues.chatFontSize, timeFormat: mergedStateValues.timeFormat ?? "auto", dateFormat: mergedStateValues.dateFormat ?? "auto", showRelativeTimes: mergedStateValues.showRelativeTimes ?? true, enterBehavior: mergedStateValues.enterBehavior ?? "send", cloudUserInfo, cloudIsAuthenticated, sharingEnabled, publicSharingEnabled, organizationAllowList, organizationSettingsVersion, customCondensingPrompt: mergedStateValues.customCondensingPrompt, codebaseIndexModels: mergedStateValues.codebaseIndexModels ?? EMBEDDING_MODEL_PROFILES, codebaseIndexConfig: { codebaseIndexEnabled: mergedStateValues.codebaseIndexConfig?.codebaseIndexEnabled ?? false, codebaseIndexQdrantUrl: mergedStateValues.codebaseIndexConfig?.codebaseIndexQdrantUrl ?? "http://localhost:6333", codebaseIndexEmbedderProvider: mergedStateValues.codebaseIndexConfig?.codebaseIndexEmbedderProvider ?? providerIdentifiers.openai, codebaseIndexEmbedderBaseUrl: mergedStateValues.codebaseIndexConfig?.codebaseIndexEmbedderBaseUrl ?? "", codebaseIndexEmbedderModelId: mergedStateValues.codebaseIndexConfig?.codebaseIndexEmbedderModelId ?? "", codebaseIndexEmbedderModelDimension: mergedStateValues.codebaseIndexConfig?.codebaseIndexEmbedderModelDimension, codebaseIndexOpenAiCompatibleBaseUrl: mergedStateValues.codebaseIndexConfig?.codebaseIndexOpenAiCompatibleBaseUrl, codebaseIndexSearchMaxResults: mergedStateValues.codebaseIndexConfig?.codebaseIndexSearchMaxResults, codebaseIndexSearchMinScore: mergedStateValues.codebaseIndexConfig?.codebaseIndexSearchMinScore, codebaseIndexBedrockRegion: mergedStateValues.codebaseIndexConfig?.codebaseIndexBedrockRegion, codebaseIndexBedrockProfile: mergedStateValues.codebaseIndexConfig?.codebaseIndexBedrockProfile, codebaseIndexOpenRouterSpecificProvider: mergedStateValues.codebaseIndexConfig?.codebaseIndexOpenRouterSpecificProvider, }, profileThresholds: mergedStateValues.profileThresholds ?? {}, lockApiConfigAcrossModes: this.context.workspaceState.get("lockApiConfigAcrossModes", false), includeDiagnosticMessages: mergedStateValues.includeDiagnosticMessages ?? true, maxDiagnosticMessages: mergedStateValues.maxDiagnosticMessages ?? 50, includeTaskHistoryInEnhance: mergedStateValues.includeTaskHistoryInEnhance ?? true, includeCurrentTime: mergedStateValues.includeCurrentTime ?? true, includeCurrentCost: mergedStateValues.includeCurrentCost ?? true, maxGitStatusFiles: mergedStateValues.maxGitStatusFiles ?? 0, taskSyncEnabled, imageGenerationProvider: mergedStateValues.imageGenerationProvider, openRouterImageApiKey: mergedStateValues.openRouterImageApiKey, openRouterImageGenerationSelectedModel: mergedStateValues.openRouterImageGenerationSelectedModel, autoCloseZooOpenedFiles: mergedStateValues.autoCloseZooOpenedFiles, autoCloseZooOpenedFilesAfterUserEdited: mergedStateValues.autoCloseZooOpenedFilesAfterUserEdited, autoCloseZooOpenedNewFiles: mergedStateValues.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]) { const previousSharedValues = { [key]: this.contextProxy.getValue(key) } as RooCodeSettings await this.contextProxy.setValue(key, value) try { await this._saveViewLocalStateFromMutation({ [key]: value }) } catch (error) { // The per-view record did not land: restore the prior shared value so the two // persistence layers cannot diverge, then surface the original failure. await this._restoreSharedValuesAfterFailedViewWrite(previousSharedValues, { [key]: value, } as RooCodeSettings) throw error } } /** * Restore shared values captured before a setter wrote them, used when the paired * per-view persistence failed so the shared store cannot move ahead of the per-view * record. A failed rollback is logged; the original failure is what callers see. * * A key is restored only while the shared store still holds the value this call * wrote: another view may have committed a newer value for that key in the window * between the two writes, and writing the pre-mutation snapshot back would undo it. */ private async _restoreSharedValuesAfterFailedViewWrite( previous: RooCodeSettings, written: RooCodeSettings, ): Promise { const restore = Object.fromEntries( Object.entries(previous).filter(([key]) => Object.is( this.contextProxy.getValue(key as keyof RooCodeSettings), written[key as keyof RooCodeSettings], ), ), ) as RooCodeSettings if (Object.keys(restore).length === 0) { // Every key was superseded by a newer write: there is nothing left of this call to // undo. return } try { await this.contextProxy.setValues(restore) } catch (rollbackError) { this.log( `[ClineProvider] Shared-state rollback after failed per-view write failed: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`, ) } } public getValue(key: K) { return this.contextProxy.getValue(key) } public getValues() { return { ...this.contextProxy.getValues(), ...this.viewLocalState } } public async setValues(values: RooCodeSettings) { // The display settings are filtered before anything is written, as upstream does: an invalid // value never reaches ContextProxy. Everything after this is this unit's write behaviour - // the mode guard, the view-local half of the write, and the rollback of both halves when a // write fails - kept beside it rather than replaced. const sanitizedValues = { ...withoutInvalidDisplaySettings(values, (key) => this.log(`[setValues] Ignored an invalid value for ${key}`), ), } if ("viewStates" in sanitizedValues) { // The registered viewStates map is host-owned: this provider rewrites it from a full read of // the map (savePersistedViewState, clearPersistedViewState, prunePersistedViewStates), so a map // handed to a settings write by a caller - the webview's newTask configuration, or the extension // API's - would replace every other view's durable selection in one write, and an explicit unset // would erase the map outright. Entries arriving this way are validated nowhere, so the key is // dropped rather than sanitised; the rest of the settings object is the caller's to set. this.log( "[setValues] Ignored a caller-supplied viewStates map; the registered map is written by the extension host.", ) delete sanitizedValues.viewStates } if (sanitizedValues.mode !== undefined) { // An unknown or non-string mode (e.g. from an API payload) must not be persisted: // a new Task would read it from getState() and persist it into task history. if ( typeof sanitizedValues.mode !== "string" || !getModeBySlug(sanitizedValues.mode, await this.customModesManager.getCustomModes()) ) { this.log(`[ClineProvider#setValues] Ignoring invalid mode "${String(sanitizedValues.mode)}"`) delete sanitizedValues.mode } } const previousSharedValues = Object.fromEntries( Object.keys(sanitizedValues).map((key) => [key, this.contextProxy.getValue(key as keyof RooCodeSettings)]), ) as RooCodeSettings await this.contextProxy.setValues(sanitizedValues) try { await this._saveViewLocalStateFromMutation(sanitizedValues) } catch (error) { // The per-view record did not land: restore the prior shared values so the two // persistence layers cannot diverge, then surface the original failure. await this._restoreSharedValuesAfterFailedViewWrite(previousSharedValues, sanitizedValues) throw error } } /** * Persists the view-local subset of a ContextProxy mutation, then updates the in-memory * viewLocalState buffer. Persistence is awaited first so a failed durable write cannot * leave the local cache ahead of the persisted state. */ private async _saveViewLocalStateFromMutation( values: Partial & Partial, ): Promise { await this._persistViewLocalStateFromMutation(values) this._updateViewLocalStateFromMutation(values) } /** * Update or invalidate viewLocalState when ContextProxy is mutated via setValues, setValue, * profile upsert/activation/deletion, or resetState. This ensures the local cache stays in * sync with global state changes that would otherwise be invisible behind mergedStateValues. */ private _updateViewLocalStateFromMutation(values: Partial & Partial): void { if ("mode" in values) { const val = values.mode if (val === undefined || val === null) { delete this.viewLocalState.mode } else { this.viewLocalState.mode = val } } if ("currentApiConfigName" in values) { const val = values.currentApiConfigName if (val === undefined || val === null) { delete this.viewLocalState.currentApiConfigName } else { this.viewLocalState.currentApiConfigName = val } } if ("apiConfiguration" in values) { const val = values.apiConfiguration if (val === undefined || val === null) { delete this.viewLocalState.apiConfiguration } else { this.viewLocalState.apiConfiguration = val } } // Flat provider-settings keys (PROVIDER_SETTINGS_KEYS) are shared settings: // they are written through the ContextProxy above and must NOT be merged // into viewLocalState.apiConfiguration, which would turn them into a // per-view override masking later shared updates from other views. } /** * Writes the durably persisted subset of a mutation (mode and currentApiConfigName) * into the registered viewStates map for this view. */ private async _persistViewLocalStateFromMutation( values: Partial & Partial, ): Promise { const persistedValues: Partial = {} if ("mode" in values) { persistedValues.mode = values.mode as PersistedViewState["mode"] } if ("currentApiConfigName" in values) { persistedValues.currentApiConfigName = values.currentApiConfigName } if ("mode" in persistedValues || "currentApiConfigName" in persistedValues) { await this.savePersistedViewState(persistedValues) } } /** * Clear view-local state cache so that getState() falls back to ContextProxy defaults. */ private _clearViewLocalState(): void { this.viewLocalState = {} } /** * Clears the per-view `mode` override of every live view that pinned `slug`. * * Deleting a custom mode only moves the acting view to the default mode. A sibling that * pinned the deleted slug still holds it in its own overlay and in its own durable * viewStates entry, so that view's getState() reports a mode that no longer exists and any * task it creates writes that slug into its history. Only overrides that name the deleted * slug are cleared - a sibling serving another mode keeps its own - and each affected view * gets its own postStateToWebview, the same fan-out resetState() uses. The acting view is * the caller's business: its reset goes through setValue, which updates its own overlay. */ async clearViewLocalModeForDeletedSlug(slug: string): Promise { for (const instance of ClineProvider.getAllInstances()) { if (instance === this) { continue } // Direct private access: compile-time safe across sibling instances. if (instance.viewLocalState.mode !== slug) { continue } try { await instance._saveViewLocalStateFromMutation({ mode: undefined }) } catch (error) { instance.log( `[clearViewLocalModeForDeletedSlug] Failed to clear the mode override for '${slug}': ${ error instanceof Error ? error.message : String(error) }`, ) continue } await instance.postStateToWebview() } } // 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() // Clear view-local state cache so getState() falls back to ContextProxy defaults. this._clearViewLocalState() // Clear this view's persisted entry too, so the reset selections are not // re-applied from the durable viewStates pin after a reload. await this.clearPersistedViewState() // Other live webviews hold their own overlay and their own durable pin: a reset in // one view would otherwise leave the others serving their pre-reset mode and profile // in getState() until their next mutation. Direct private access: compile-time safe // across sibling instances. for (const instance of ClineProvider.getAllInstances()) { if (instance === this) { continue } instance._clearViewLocalState() try { await instance.clearPersistedViewState() } catch (error) { instance.log( `[resetState] Failed to clear a sibling view's persisted state: ${ error instanceof Error ? error.message : String(error) }`, ) } await instance.postStateToWebview() } 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() } } }