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 { type TaskProviderLike, type TaskProviderEvents, type GlobalState, type ProviderName, type ProviderSettings, type ProviderSettingsWithId, type RooCodeSettings, type ProviderSettingsEntry, type StaticAppProperties, type DynamicAppProperties, type CloudAppProperties, type TaskProperties, type GitProperties, type TelemetryProperties, type TelemetryPropertiesProvider, type CodeActionId, type CodeActionName, type TerminalActionId, type TerminalActionPromptType, type HistoryItem, type PendingTaskAction, type CloudUserInfo, type CloudOrganizationMembership, type CreateTaskOptions, type TokenUsage, type ToolUsage, type ExtensionMessage, type ExtensionState, type WebviewThemeFixture, type 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, PROVIDER_SETTINGS_KEYS, } 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 { fileExistsAtPath } from "../../utils/fs" import { setTtsEnabled, setTtsSpeed } from "../../utils/tts" import { getWorkspaceGitInfo } from "../../utils/git" import { getWorkspacePath } from "../../utils/path" import { OrganizationAllowListViolationError } from "../../utils/errors" import { setPanel } from "../../activate/registerCommands" import { t } from "../../i18n" import { buildApiHandler } from "../../api" import { forceFullModelDetailsLoad, hasLoadedFullDetails } from "../../api/providers/fetchers/lmstudio" import { ContextProxy } from "../config/ContextProxy" import { ProviderSettingsManager, 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 /** * 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 } 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() private disposables: vscode.Disposable[] = [] 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 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 rateLimitClock: RateLimitClock = createRateLimitClock() private recentTasksCache?: string[] public readonly taskHistoryStore: TaskHistoryStore private taskHistoryStoreInitialized = false public static readonly PENDING_OPERATION_TIMEOUT_MS = 30000 // 30 seconds private providerProfileMutationQueue = Promise.resolve() private historyTaskCreationQueue = Promise.resolve() private runDelegationTransition(parentTaskId: string, fn: () => Promise): Promise { return runDelegationTransition(ClineProvider.delegationTransitionLocks, parentTaskId, fn) } /** * With `runToCompletion`, a timeout only rejects the caller: the mutation is not aborted and the * queue advances once it settles. Use it for multi-write mutations that must not stop halfway * (storage saved but not activated) or overlap with the next mutation. */ private enqueueProviderProfileMutation( fn: (signal: AbortSignal) => Promise, options: { runToCompletion?: boolean } = {}, ): Promise { const controller = new AbortController() // Run fn after either outcome so a rejected mutation never poisons the queue. const run = this.providerProfileMutationQueue.then( () => fn(controller.signal), () => fn(controller.signal), ) const callerResult = this.withProviderProfileMutationTimeout(run, () => { if (options.runToCompletion) { this.log("Provider profile mutation timed out; waiting for it to finish before the next mutation") return } controller.abort() this.log("Provider profile mutation timed out; aborting in-flight mutation") }) void run.then( () => { if (controller.signal.aborted) { this.log("Provider profile mutation completed after cancellation") } }, (error) => { if (controller.signal.aborted) { this.log( `Provider profile mutation errored after cancellation: ${ error instanceof Error ? error.message : String(error) }`, ) } }, ) // Advance from the timeout-bounded result. Each fn checks its AbortSignal before // writing state, so advancing the queue on timeout cannot produce stale overwrites. this.providerProfileMutationQueue = (options.runToCompletion ? run : callerResult).then( () => undefined, () => undefined, ) return callerResult } /** * Resolves once the currently queued provider-profile mutation (and everything queued * before it) has settled. A caller that timed out on a run-to-completion mutation waits * on this before acting on state that mutation can still change. */ public awaitProfileMutationSettled(): Promise { return this.providerProfileMutationQueue.then( () => undefined, () => undefined, ) } 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 /** * Whether this instance wrote an entry under its own session-local temporary id. * Temporary ids restart from 0 on every extension host start, so an entry under the * same name can belong to a different session. Only re-key entries this instance wrote. */ private wroteUnderTemporaryViewStateId = false /** * 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 = {} /** * 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 public settingsImportedAt?: number public readonly latestAnnouncementId = "oct-2026-v3.90.0-providers-tools-reasoning" // v3.90.0 providers and models, safer tool execution, reasoning and streaming 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), ) 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 { 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 } catch (error) { 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. Writes captured * while the provider still holds its temporary (pre-launch) id persist under that id * and are re-keyed to the stable view id when the webview registers one, so a change * that lands before the launch message stays durable instead of being lost. */ 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 const write = ClineProvider.persistedViewStateWriteQueue.then(async () => { const states = this.getPersistedViewStates({ fresh: true }) // The first write to an unowned temporary id claims the slot instead of merging // into it: temporary names restart from 0 on every host start, so an entry under // this name can be a leftover from an earlier session, and merging would adopt its // fields and later re-key them under the stable id. Entries under a stable id, or // under the temporary id this instance already authored, keep the merge semantics. const ownsTargetEntry = viewStateId !== this.viewId || this.wroteUnderTemporaryViewStateId const current = ownsTargetEntry ? (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 } } let wroteEntry = false if (!next.mode && !next.currentApiConfigName) { delete states[viewStateId] } else { next.updatedAt = values.updatedAt ?? Date.now() states[viewStateId] = next wroteEntry = true } try { await this.contextProxy.setValue("viewStates", this.prunePersistedViewStates(states)) } catch (error) { // 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 } // Authorship is recorded only after the durable write lands: a failed persistence // must not mark the temporary id as owned, or a later load would adopt an entry // that was never actually stored. if (wroteEntry && viewStateId === this.viewId) { this.wroteUnderTemporaryViewStateId = true } }) 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) } 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) { try { await this.contextProxy.setValue("viewStates", this.prunePersistedViewStates(states)) } 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. */ private prunePersistedViewStates(states: Record): Record { return Object.fromEntries( Object.entries(states) .filter(([, entry]) => !!entry && typeof entry === "object") .sort(([, a], [, b]) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0)) .slice(0, ClineProvider.MAX_PERSISTED_VIEW_STATES), ) } /** * Re-keys this provider's temporary pre-launch viewStates entry to the newly * registered stable id so pre-launch writes become durable under the stable key * instead of orphaning under a session-local temporary id. Only the provider's own * temporary id is eligible: an entry under a previously registered stable id belongs * to that webview's storage and is left alone. When the stable entry already exists * it wins and the temporary entry is dropped, because temporary ids are session * counters that can collide across window reloads. Runs through the serialized write * queue like every other viewStates mutation. */ private async rekeyPersistedViewStateEntry(nextViewStateId: string): Promise { const previousViewStateId = this.viewId const write = ClineProvider.persistedViewStateWriteQueue.then(async () => { // An entry under this name can belong to a different session, because temporary // ids restart from 0 on every extension host start. Only re-key entries this // instance wrote, otherwise a stale entry from another session is adopted. if (!this.wroteUnderTemporaryViewStateId) { return } const states = this.getPersistedViewStates({ fresh: true }) const previous = states[previousViewStateId] if (!previous) { return } delete states[previousViewStateId] if (!states[nextViewStateId]) { states[nextViewStateId] = previous } try { await this.contextProxy.setValue("viewStates", this.prunePersistedViewStates(states)) } catch (error) { // updateGlobalState applies the new map to the in-memory cache before // the durable write, so a rejected re-key would leave the cache serving // the stable entry while durable storage still holds the temporary one. // Restore the cache to the value durable storage actually holds (the same // rollback savePersistedViewState applies), so a later load or a retry of // the registration sees the pre-rekey entry. 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 } // Only a successful write clears authorship: a failed attempt leaves the entry // under the temporary name, and a retry must still be able to re-key it. this.wroteUnderTemporaryViewStateId = false }) ClineProvider.persistedViewStateWriteQueue = write.catch(() => {}) await write } /** * 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 { // Re-key any durable entry written under the temporary pre-launch id before // loading, so the load sees the view's own pre-registration selections. await this.rekeyPersistedViewStateEntry(this.viewStateId) 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 and the load instead of the guard above early-returning for // the failed id. 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 // While the stable id has not been registered yet, the key is this instance's // session-local name. Temporary names restart from 0 on every host start, so an // entry under the same name can belong to a different session. Load only entries // this instance authored. if (loadedForViewId === this.viewId && !this.wroteUnderTemporaryViewStateId) { 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 } 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 view * id active when the change was made, re-keyed to the stable id on registration. */ 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) const cleanupFunctions = this.taskEventListeners.get(task) if (cleanupFunctions) { cleanupFunctions.forEach((cleanup) => cleanup()) this.taskEventListeners.delete(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): void { 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 */ private clearPendingEditOperation(operationId: string): boolean { return this.pendingEditOperations.clear(operationId) } /** * 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")) while (this.webviewDisposables.length) { const x = this.webviewDisposables.pop() if (x) { x.dispose() } } } /** 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.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() // Clean up cloud service event listener if (CloudService.hasInstance()) { CloudService.instance.off("settings-updated", this.handleCloudSettingsUpdate) } while (this.disposables.length) { const x = this.disposables.pop() if (x) { x.dispose() } } try { 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") } finally { // Deregistration must not depend on the cleanup above completing: a // throwing disposable (e.g. an MCP client whose disposal rejects) // would otherwise leave this provider registered while _disposed is // already set, so no later retry could repair the registration. ClineProvider.activeInstances.delete(this) // 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) { this.view = webviewView const inTabMode = "onDidChangeViewState" in webviewView 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, } webviewView.webview.html = this.contextProxy.extensionMode === vscode.ExtensionMode.Development && process.env.ROO_CODE_THEME_FIXTURE_PROBE !== "1" ? await this.getHMRHtmlContent(webviewView.webview) : await this.getHtmlContent(webviewView.webview) // 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, }) => { 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) }, ) // Sets up an event listener to listen for messages passed from the webview view context // and executes code based on the message that is received. this.setWebviewMessageListener(webviewView.webview) this.webviewDisposables.push(this.webviewFocusTracker.init(this, webviewView)) // Listen for when the panel becomes visible. // https://github.com/microsoft/vscode-discussions/discussions/840 if ("onDidChangeViewState" in webviewView) { // WebviewView and WebviewPanel have all the same properties except // for this visibility listener panel. const viewStateDisposable = webviewView.onDidChangeViewState(() => { if (this.view?.visible) { void this.postMessageToWebview({ type: "action", action: "didBecomeVisible" }) } else { this.logWebviewHiddenDiagnostics() } }) this.webviewDisposables.push(viewStateDisposable) } else if ("onDidChangeVisibility" in webviewView) { // sidebar const visibilityDisposable = webviewView.onDidChangeVisibility(() => { if (this.view?.visible) { void this.postMessageToWebview({ type: "action", action: "didBecomeVisible" }) } else { this.logWebviewHiddenDiagnostics() } }) this.webviewDisposables.push(visibilityDisposable) } // Listen for when the view is disposed // This happens when the user closes the view or when the view is closed programmatically webviewView.onDidDispose( async () => { if (inTabMode) { this.log("Disposing ClineProvider instance for tab view") await this.dispose() } else { this.log("Clearing webview resources for sidebar view") this.clearWebviewResources() } }, null, this.disposables, ) // Listen for when color changes const configDisposable = vscode.workspace.onDidChangeConfiguration(async (e) => { if (e && e.affectsConfiguration("workbench.colorTheme")) { // Sends latest theme name to webview await this.postMessageToWebview({ type: "theme", text: JSON.stringify(await getTheme()) }) } }) this.webviewDisposables.push(configDisposable) // If the extension is starting a new session, clear previous task state. // But don't clear if there's already an active task (e.g., resumed via IPC/bridge). const currentTask = this.getCurrentTask() if (!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. const previous = this.historyTaskCreationQueue ?? Promise.resolve() const run = previous.then( () => ClineProvider.prototype.createTaskWithHistoryItemUnlocked.call(this, historyItem, options), () => ClineProvider.prototype.createTaskWithHistoryItemUnlocked.call(this, historyItem, options), ) 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 }) } 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 }, ) } } 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 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}`, ) } // Remove event listeners from the old task const cleanupFunctions = this.taskEventListeners.get(oldTask) if (cleanupFunctions) { cleanupFunctions.forEach((cleanup) => cleanup()) this.taskEventListeners.delete(oldTask) } // Replace in-place: preserves stack index and current pointer this.taskRegistry.replace(oldTask.taskId, task) } task.emit(RooCodeEventName.TaskFocused) // Perform preparation tasks and set up event listeners await this.performPreparationTasks(task) this.log( `[createTaskWithHistoryItem] rehydrated task ${task.taskId}.${task.instanceId} in-place (flicker-free)`, ) if (options?.startTask !== false) { scheduleTask(this.taskScheduler, task, "createTaskWithHistoryItem", undefined, (error) => this.cleanupFailedHistoryTask(task, error), ) } } else { await this.addClineToStack(task) this.log( `[createTaskWithHistoryItem] ${task.parentTask ? "child" : "parent"} task ${task.taskId}.${task.instanceId} instantiated`, ) if (options?.startTask !== false) { scheduleTask(this.taskScheduler, task, "createTaskWithHistoryItem", undefined, (error) => this.cleanupFailedHistoryTask(task, error), ) } } // Check if there's a pending edit after checkpoint restoration const operationId = `task-${task.taskId}` const pendingEdit = this.getPendingEditOperation(operationId) if (pendingEdit) { this.clearPendingEditOperation(operationId) // Clear the pending edit this.log(`[createTaskWithHistoryItem] Processing pending edit after checkpoint restoration`) // Process the pending edit after a short delay to ensure the task is fully initialized setTimeout(async () => { try { // Find the message index in the restored state const { messageIndex, apiConversationHistoryIndex } = (() => { const messageIndex = task.clineMessages.findIndex((msg) => msg.ts === pendingEdit.messageTs) const apiConversationHistoryIndex = task.apiConversationHistory.findIndex( (msg) => msg.ts === pendingEdit.messageTs, ) return { messageIndex, apiConversationHistoryIndex } })() if (messageIndex !== -1) { // Remove the target message and all subsequent messages await task.overwriteClineMessages(task.clineMessages.slice(0, messageIndex)) if (apiConversationHistoryIndex !== -1) { await task.overwriteApiConversationHistory( task.apiConversationHistory.slice(0, apiConversationHistoryIndex), ) } // Process the edited message await task.handleWebviewAskResponse( "messageResponse", pendingEdit.editedContent, pendingEdit.images, ) } } catch (error) { this.log(`[createTaskWithHistoryItem] Error processing pending edit: ${error}`) } }, 100) // Small delay to ensure task is fully ready } return task } public async postMessageToWebview(message: ExtensionMessage) { if (this._disposed) { 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() } 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}`) } catch { vscode.window.showErrorMessage(t("common:errors.hmr_not_running")) return this.getHtmlContent(webview) } const nonce = getNonce() // Get the OpenRouter base URL from configuration const { apiConfiguration } = await this.getState() const openRouterBaseUrl = apiConfiguration.openRouterBaseUrl || "https://openrouter.ai" // Extract the domain for CSP const openRouterDomain = openRouterBaseUrl.match(/^(https?:\/\/[^/]+)/)?.[1] || "https://openrouter.ai" const stylesUri = getUri(webview, this.contextProxy.extensionUri, [ "webview-ui", "build", "assets", "index.css", ]) const codiconsUri = getUri(webview, this.contextProxy.extensionUri, ["assets", "codicons", "codicon.css"]) const materialIconsUri = getUri(webview, this.contextProxy.extensionUri, [ "assets", "vscode-material-icons", "icons", ]) const imagesUri = getUri(webview, this.contextProxy.extensionUri, ["assets", "images"]) const audioUri = getUri(webview, this.contextProxy.extensionUri, ["webview-ui", "audio"]) const file = "src/index.tsx" const scriptUri = `http://${localServerUrl}/${file}` const reactRefresh = /*html*/ ` ` 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 webview A reference to the extension webview */ private setWebviewMessageListener(webview: vscode.Webview) { const onReceiveMessage = async (message: WebviewMessage) => webviewMessageHandler(this, message, this.marketplaceManager) const messageDisposable = webview.onDidReceiveMessage(onReceiveMessage) this.webviewDisposables.push(messageDisposable) } /** * Handle switching to a new mode, including updating the associated API configuration * @param newMode The mode to switch to * @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. * A task that is not this view's focused task only receives the task-scoped effects * (history entry + in-memory mode): the view's durable mode, the ModeChanged * broadcast, and profile activation keep applying to the focused task's selection. * Unknown mode slugs are ignored (logged + no-op) so unvalidated callers (the webview * "mode" message sends message.text as Mode) cannot persist invalid modes. */ public async handleModeSwitch(newMode: Mode, targetTask: Task | null | undefined = this.getCurrentTask()) { const targetMode = getModeBySlug(newMode, await this.customModesManager.getCustomModes()) if (!targetMode) { this.log(`[ClineProvider#handleModeSwitch] ignoring unknown mode "${newMode}"`) return } return this.enqueueProviderProfileMutation( (signal) => this.handleModeSwitchUnlocked(newMode, targetTask, signal), // The switch and its nested activation must own the queue until every // write - including compensation - settles: a timeout must not let the // next queued mutation overlap writes that are still in flight. { runToCompletion: true }, ) } private async handleModeSwitchUnlocked( newMode: Mode, targetTask: Task | null | undefined, signal?: AbortSignal, ): Promise { const task = targetTask // Captured for the pin-write rollback below: the mode this task held before // the switch (bracket access: _taskMode is private to Task). const previousTaskMode = task ? task["_taskMode"] : undefined let rollbackTaskWrites: (() => Promise) | undefined // A cancelled or timed-out switch must not be partially applied: bail out // before the task history / _taskMode writes as well as the durable mode // write below. Aborts that land while the history write is in flight are // handled in flight (the landed write is rolled back); the pre-write check // further down still covers the remaining gap before the durable write. if (signal?.aborted) { return } 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 }) // An abort that lands while the history write is in flight has // already persisted the new mode: restore the pre-switch item and // bail before the in-memory task write and the emit, so task // history, task state, and provider mode cannot diverge. A failed // rollback must not surface as the persistence error of the // cancelled switch: log it and keep the cancellation result. if (signal?.aborted) { try { // Restore only the field this switch changed: re-read the item so // fields the running task persisted during the pending window // (tokens, cost, status, apiConfigName) survive the rollback. const latest = this.getTaskHistoryItem(task.taskId) ?? taskHistoryItem await this.updateTaskHistory({ ...latest, mode: taskHistoryItem.mode }) } catch (rollbackError) { this.log(`Failed to roll back mode switch ${task.taskId}: ${String(rollbackError)}`) } return } } else if (signal?.aborted) { return } task.emit(RooCodeEventName.TaskModeSwitched, task.taskId, newMode) // Only update the task's mode after successful persistence. ;(task as any)._taskMode = newMode // If the view's durable mode pin below fails after these task-side writes // landed, restore them: the caller delivers the message on the caught // error, and the new task mode must not run against the view's unchanged // mode and mode-linked API configuration. const historyItemBefore = taskHistoryItem rollbackTaskWrites = async () => { task["_taskMode"] = previousTaskMode // Listeners were told about the new mode before the failure landed: re-emit // with the restored mode so API/IPC consumers track the rollback. if (previousTaskMode) { task.emit(RooCodeEventName.TaskModeSwitched, task.taskId, previousTaskMode) } if (historyItemBefore) { // Restore only the field this switch changed: re-read the item so // fields the running task persisted during the write survive. const latest = this.getTaskHistoryItem(task.taskId) ?? historyItemBefore await this.updateTaskHistory({ ...latest, mode: historyItemBefore.mode }) } } } 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 } } // A cancelled or timed-out switch must not write the mode or emit // ModeChanged: check the mutation signal right before the durable write. if (signal?.aborted) { return } // A mode switch requested for a task that is not this view's focused task applies // only to that task (history entry + in-memory mode): pinning the view's durable // mode, broadcasting ModeChanged, or activating a profile on behalf of a // background task would clobber the focused task's selection. const viewScopedSwitch = task === undefined || task === null || this.getCurrentTask() === task // Captured for the reconcile below: the view's pre-switch durable mode pin // (undefined means the view never pinned a mode) and the pre-switch shared // mode. The pin write only touches the view buffer and the per-view store, so // reading the shared value here is unaffected by it. const previousViewMode = this.viewLocalState.mode const previousMode = this.getValue("mode") let pinLanded = false let sharedAttempted = false // Explicit partial-failure behavior: every failure after the task-mode write // rolls back all writes this switch owns - shared mode, view pin, task mode, // and the task-history entry - so the caller (Task#submitUserMessage) delivers // the submitted message on the pre-switch mode and profile instead of a // half-applied switch that runs the new mode against the unchanged profile. // Each restore is awaited on its own and logged on failure, never swallowed. const reconcileSwitchWrites = async () => { if (sharedAttempted) { // The shared write may have landed before its promise rejected: restore // it unconditionally so getValues() cannot mix a fresh shared mode with // the rolled-back view state. 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) }`, ) } } if (pinLanded) { try { await this.saveViewState("mode", previousViewMode) } catch (rollbackError) { this.log( `[handleModeSwitch] Failed to roll back the view mode pin after persistence failure: ${ rollbackError instanceof Error ? rollbackError.message : String(rollbackError) }`, ) } } if (rollbackTaskWrites) { try { await rollbackTaskWrites() } catch (rollbackError) { this.log( `[handleModeSwitch] Failed to roll back task mode writes after the view mode pin write failed: ${ rollbackError instanceof Error ? rollbackError.message : String(rollbackError) }`, ) } } } // Once the mode-linked activation below has started writing shared state, a // mode-only rollback would pair the old mode with the new profile: the catch must // then keep the switch instead of reconciling. let activationStarted = false try { if (viewScopedSwitch) { // Pin the view's durable per-view mode first: the pin is this view's own // selection. If any write below fails, the reconcile above restores every // write that landed, including this pin, so a delivered message never runs // the new task mode against an unchanged view mode or mode-linked profile. await this.saveViewState("mode", newMode) pinLanded = true // 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. sharedAttempted = true await this.setValue("mode", newMode) } // If workspace lock is on, keep the current API config: the mode-linked // activation below is skipped, and the committed switch falls through to the // emit/post tail outside the reconciled region. const lockApiConfigAcrossModes = this.context.workspaceState.get("lockApiConfigAcrossModes", false) if (!lockApiConfigAcrossModes) { // A background-task switch applies to the task only: no view pin, no // ModeChanged, and no mode-linked activation on its behalf. if (!viewScopedSwitch) { 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() // The pin and shared mode writes above have committed the mode: an abort // here skips the remaining activation work but falls through to the // emit/post tail so listeners and the webview learn the committed mode. if (!signal?.aborted) { 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) { // The committed-switch guard flips only when the activation has // actually written the activated profile: a lookup failure (e.g. a // deleted profile) throws before any write and must still reconcile. await this.activateProviderProfileUnlocked( { name: profile.name }, { ...(targetTask === null ? { skipCurrentTaskRebuild: true } : {}), onSharedWriteStart: () => { activationStarted = true }, }, signal, ) } else { // The task will continue with the current/default configuration. } } else { // The task will continue with the current/default configuration. } } else { // If no saved config for this mode, save current config as default. // Use this view's effective profile: with per-view pins the shared slot can // belong to a sibling view, and recording that profile as this mode's default // would activate the wrong profile for every later switch to it. const currentApiConfigNameAfter = this.getValues().currentApiConfigName if (currentApiConfigNameAfter) { const config = listApiConfig.find((c) => c.name === currentApiConfigNameAfter) if (config?.id) { await this.providerSettingsManager.setModeConfig(newMode, config.id) } } } } } } catch (error) { // Two reasons to keep the switch instead of reconciling: once the mode-linked // activation has started writing shared state, a mode-only rollback would pair // the old mode with the new profile; and after a timeout abort the queue has // advanced, so a newer mutation may own these fields and replaying the // pre-switch values would clobber it. if (!activationStarted && !signal?.aborted) { await reconcileSwitchWrites() } else if (signal?.aborted) { // After an abort the mutation queue has advanced: a newer mutation may // already own the mode fields, so emitting here could announce a mode the // newer mutation replaced - listeners are intentionally not notified on // this path. this.log(`[handleModeSwitch] Skipping rollback for aborted switch to "${newMode}"`) } else { // The activation had already written the activated profile: the switch is // kept. Listeners must still learn the committed mode before the rethrow, // and the webview must refresh in case the activation failed before its // own state post. if (viewScopedSwitch) { this.emit(RooCodeEventName.ModeChanged, newMode) } if (targetTask !== null) { try { await this.postStateToWebview() } catch (postError: unknown) { this.log( `[handleModeSwitch] Mode switch to "${newMode}" kept but the state post failed: ${ postError instanceof Error ? postError.message : String(postError) }`, ) } } } this.log( `[handleModeSwitch] Failed to persist mode "${newMode}": ${error instanceof Error ? error.message : String(error)}`, ) throw error } // The switch has committed: notify listeners only now, so a reconciled failure // never leaves listeners tracking a mode the stores rolled back from. After an // abort the mutation queue has advanced: announce only while this switch's // committed mode is still current - a superseded commit stays silent because // the newer mutation already announced its own mode. if (signal?.aborted && this.getValues().mode !== newMode) { return } if (viewScopedSwitch) { this.emit(RooCodeEventName.ModeChanged, newMode) } // The switch has committed: a UI-refresh failure must not roll it back, so the // trailing post runs outside the reconciled region and its failure is only logged. if (targetTask !== null) { try { await this.postStateToWebview() } catch (postError: unknown) { this.log( `[handleModeSwitch] Mode switch to "${newMode}" committed but the state post failed: ${ postError instanceof Error ? postError.message : String(postError) }`, ) } } } // 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 } = {}, ): void { if (options.skipCurrentTaskRebuild) return const task = this.getCurrentTask() if (!task) return const { forceRebuild = false } = options // Determine if we need to rebuild using the previous configuration snapshot const prevConfig = task.apiConfiguration const prevProvider = prevConfig?.apiProvider const prevModelId = prevConfig ? getModelId(prevConfig) : undefined const newProvider = providerSettings.apiProvider const newModelId = getModelId(providerSettings) const needsRebuild = forceRebuild || prevProvider !== newProvider || prevModelId !== newModelId if (needsRebuild) { // Use updateApiConfiguration which handles both API handler rebuild and parser sync. // Note: updateApiConfiguration is declared async but has no actual async operations, // so we can safely call it without awaiting. task.updateApiConfiguration(providerSettings) } else { // No rebuild needed, just sync apiConfiguration ;(task as any).apiConfiguration = providerSettings } } getProviderProfileEntries(): ProviderSettingsEntry[] { return this.contextProxy.getValues().listApiConfigMeta || [] } getProviderProfileEntry(name: string): ProviderSettingsEntry | undefined { return this.getProviderProfileEntries().find((profile) => profile.name === name) } public hasProviderProfileEntry(name: string): boolean { return !!this.getProviderProfileEntry(name) } async upsertProviderProfile( name: string, providerSettings: ProviderSettings, activate: boolean = true, ): Promise { try { return await this.enqueueProviderProfileMutation( (signal) => this.persistProviderProfile(name, providerSettings, activate, signal), { runToCompletion: true }, ) } catch (error) { this.log( `Error create new api configuration: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`, ) vscode.window.showErrorMessage(t("common:errors.create_api_config")) return undefined } } /** * Applies a model change to the latest stored profile inside the mutation queue, * so a stale webview snapshot cannot overwrite newer profile settings. * A `null` patch value clears the key. */ async updateProfileModel(name: string, expectedProvider: string, patch: Record): Promise { let partialState = false try { await this.enqueueProviderProfileMutation( async (signal) => { const { name: _name, ...stored } = await this.providerSettingsManager.getProfile({ name }) // Unset provider (e.g. the seeded default profile) resolves to anthropic, matching getState(). const storedProvider = stored.apiProvider ?? providerIdentifiers.anthropic if (storedProvider !== expectedProvider) { throw new Error( `Profile "${name}" provider changed from "${expectedProvider}" to "${storedProvider}"`, ) } const merged: Record = { ...stored, apiProvider: storedProvider } for (const [key, value] of Object.entries(patch)) { if (PROFILE_MODEL_ID_PATCH_KEYS.has(key)) { if (typeof value !== "string" || value.length === 0) { throw new Error(`Key "${key}" must be a non-empty string in a model patch`) } merged[key] = value } else if (PROFILE_MODEL_RESET_ONLY_KEYS.has(key)) { if (value !== null && value !== undefined) { throw new Error(`Key "${key}" can only be cleared in a model patch`) } delete merged[key] } else { throw new Error(`Key "${key}" is not allowed in a model patch`) } } if (storedProvider === providerIdentifiers.bedrock) { if (merged.apiModelId === BEDROCK_CUSTOM_ARN_MODEL_ID) { // The allow-list only sees apiModelId, so a patch must not select an ARN it cannot supply. if (!merged.awsCustomArn) { throw new Error( `Model "${BEDROCK_CUSTOM_ARN_MODEL_ID}" requires an existing custom ARN`, ) } } else { // A retained ARN would override the allow-listed model, so drop it with its context fields. for (const key of PROFILE_MODEL_RESET_ONLY_KEYS) { if (key.startsWith("aws")) delete merged[key] } } } const { organizationAllowList } = await this.getState() if (!ProfileValidator.isProfileAllowed(merged as ProviderSettings, organizationAllowList)) { throw new OrganizationAllowListViolationError( t("common:errors.violated_organization_allowlist"), ) } if (signal.aborted) return try { await this.persistProviderProfile(name, merged as ProviderSettings, true, signal) } catch (persistError) { // Saving and activating are separate writes. Put the previous profile back so // storage, context settings, and the task do not disagree about the model. try { await this.persistProviderProfile(name, stored as ProviderSettings, true, signal) } catch (restoreError) { partialState = true this.log( `Error restoring profile after failed model update: ${JSON.stringify(restoreError, Object.getOwnPropertyNames(restoreError), 2)}`, ) } throw persistError } }, { runToCompletion: true }, ) } catch (error) { this.log(`Error update profile model: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`) vscode.window.showErrorMessage( partialState ? t("common:errors.update_profile_model_partial") : error instanceof OrganizationAllowListViolationError ? error.message : t("common:errors.create_api_config"), ) } } private async persistProviderProfile( name: string, providerSettings: ProviderSettings, activate: boolean, signal: AbortSignal, ): Promise { // TODO: Do we need to be calling `activateProfile`? It's not // clear to me what the source of truth should be; in some cases // we rely on the `ContextProxy`'s data store and in other cases // we rely on the `ProviderSettingsManager`'s data store. It might // be simpler to unify these two. // Snapshot the prior manager record before the save so a failed post-save // read-back can compensate the landed write instead of leaving the record // changed while the shared store was not. let previousRecord: Awaited> | undefined try { previousRecord = await this.providerSettingsManager.getProfile({ name }) } catch { // Absent record: compensation deletes the landed create instead. } const id = await this.providerSettingsManager.saveConfig(name, providerSettings) if (signal.aborted) return id // 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 save (restore the prior record, // or delete a landed create) and fail the group so the raw caller // payload never stands in for the validated stored record. try { if (previousRecord) { const { name: _previousName, id: _previousId, ...previousSettings } = previousRecord await this.providerSettingsManager.saveConfig(name, previousSettings) } else { await this.providerSettingsManager.deleteConfig(name) } } catch (restoreError) { this.log( `[persistProviderProfile] failed to compensate the landed save of '${name}': ${restoreError instanceof Error ? restoreError.message : String(restoreError)}`, ) } throw new Error( `Provider profile '${name}' could not be read back after save: ${error instanceof Error ? error.message : String(error)}`, ) } 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. const listApiConfigMeta = await this.providerSettingsManager.listConfig() // Same sibling protection as the activation path: the shared write // below replaces the shared store, so pin other profiles' settings // into the views that reference them first. await this.snapshotSiblingOverlaysBeforeSharedSettingsWrite(name) // `allSettled` so the caller's error path never races a write still in flight. const activationResults = await Promise.allSettled([ this.updateGlobalState("listApiConfigMeta", listApiConfigMeta), // 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 { const listApiConfigMeta = await this.providerSettingsManager.listConfig() await this.updateGlobalState("listApiConfigMeta", listApiConfigMeta) this._updateViewLocalStateFromMutation({ listApiConfigMeta }) // 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() return id } async deleteProviderProfile(profileToDelete: ProviderSettingsEntry) { const globalSettings = this.contextProxy.getValues() let profileToActivate: string | undefined = globalSettings.currentApiConfigName if (profileToDelete.name === profileToActivate) { profileToActivate = this.getProviderProfileEntries().find(({ name }) => name !== profileToDelete.name)?.name } if (!profileToActivate) { throw new Error("You cannot delete the last profile") } // 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 { // Absent record: the idempotent not-found path below still prunes the // stale list entry, and compensation skips the record restore. } const previousEntries = this.getProviderProfileEntries() const previousSelection = globalSettings.currentApiConfigName const previousSharedSettings = this.contextProxy.getProviderSettings() const previousViewStates = this.getPersistedViewStates({ fresh: true }) // 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 { // Re-point any persisted view pin that referenced the deleted profile so views // do not rehydrate a missing profile name after a reload. await this.repointPersistedViewStates(profileToDelete.name, profileToActivate) const viewPinsDeletedProfile = this.viewLocalState.currentApiConfigName === profileToDelete.name || // No view-local pin: the view follows the shared selection, so it only needs the // replacement activation when that shared selection referenced the deleted profile; // an unrelated deletion must not rebuild this view's task handler. (this.viewLocalState.currentApiConfigName === undefined && globalSettings.currentApiConfigName === profileToDelete.name) if (viewPinsDeletedProfile) { // Apply the replacement through the activation path so this view's // viewLocalState.apiConfiguration and the current task's api handler are // refreshed; a name-only update would leave the deleted profile's settings // behind in both. await this.activateProviderProfile({ name: profileToActivate }) // The return below would otherwise skip the sibling re-pin further down: // the activation's pinned-name refresh only selects siblings pinned to // the replacement, so live views still buffered on the deleted profile // must be re-pointed here as well, with the replacement's settings when // they can be resolved (a name-only re-pin is still better than leaving // the deleted name behind). let replacementSettings: ProviderSettings | undefined try { const { name: _replacementName, ...settings } = await this.providerSettingsManager.getProfile({ name: profileToActivate, }) replacementSettings = settings as ProviderSettings } catch (error) { this.log( `[deleteProviderProfile] Unable to resolve API profile '${profileToActivate}': ${ error instanceof Error ? error.message : String(error) }`, ) } await this.rePinViewLocalStateForDeletedProfile( profileToDelete.name, profileToActivate, replacementSettings, ) return } // This view pins an unrelated profile, which must survive the deletion: sync the // shared profile list and post the updated state only. The buffer already holds // the surviving pin, so the current-profile slot is left untouched here: a // setValue would only trigger a viewStates prune write and could clobber the pin // with the shared slot's value. 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) // Resolve the surviving profile's settings so the shared provider keys // and any other live view still pinned to the deleted profile can be // updated with a matching configuration. let survivingSettings: ProviderSettings | undefined try { const { name: _survivingName, ...settings } = await this.providerSettingsManager.getProfile({ name: profileToActivate, }) survivingSettings = settings as ProviderSettings } catch (error) { this.log( `[deleteProviderProfile] Unable to resolve API profile '${profileToActivate}': ${ error instanceof Error ? error.message : String(error) }`, ) } // This view does not pin the deleted profile here (that case returned // above), so only the shared selection can dangle: when the deleted profile // was the globally selected one, the shared slot and the shared provider // settings still carry its configuration. const deletedWasGlobal = profileToDelete.name === globalSettings.currentApiConfigName if (deletedWasGlobal) { // The shared selection changed, but this view's own pin (if any) still // names a surviving profile: update the shared store only, leaving the // view-local pin untouched. await this.contextProxy.setValue("currentApiConfigName", profileToActivate) } if (deletedWasGlobal && survivingSettings) { // The deleted profile was the globally active one, so the shared // provider keys still carry its settings; replace them so getState() // reports the surviving profile's configuration. await this.contextProxy.setProviderSettings(survivingSettings) } // 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) 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. if (previousRecord) { try { const { name: _previousName, id: _previousId, ...previousSettings } = previousRecord await this.providerSettingsManager.saveConfig(profileToDelete.name, previousSettings) } catch (restoreError) { this.log( `[deleteProviderProfile] failed to restore the manager record '${profileToDelete.name}': ${ 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 } 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 } = {}, ): Promise { if (options.skipCurrentTaskRebuild) return const task = this.getCurrentTask() if (!task) { return } try { // Update in-memory state immediately so sticky behavior works even before the task has // been persisted into taskHistory (it will be captured on the next save). task.setTaskApiConfigName(apiConfigName) const 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 or upsert 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 activated * profile's settings - and a task created from that view would run against * the wrong provider, model, and credentials. Siblings pinned to the * activated profile are refreshed by `refreshViewLocalStateForUpdatedProfile`; * unpinned siblings follow the shared slot, where name and settings move * together. */ private async snapshotSiblingOverlaysBeforeSharedSettingsWrite(activatedProfileName: string): Promise { const affected = ClineProvider.getAllInstances().filter( (instance) => instance !== this && instance.pinnedProfileName !== undefined && instance.pinnedProfileName !== activatedProfileName && 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) { // If the pinned profile cannot be resolved, leave the overlay as is: // the deletion path re-points such views, and a wrong snapshot is // worse than the pre-write shared values. this.log( `[snapshotSiblingOverlaysBeforeSharedSettingsWrite] Unable to resolve profile '${pinName}': ${ error instanceof Error ? error.message : String(error) }`, ) } }), ) } async activateProviderProfile( args: { name: string } | { id: string }, options?: { persistModeConfig?: boolean persistTaskHistory?: boolean skipCurrentTaskRebuild?: boolean preserveForeignTask?: boolean }, ) { return this.enqueueProviderProfileMutation((signal) => this.activateProviderProfileUnlocked(args, options, signal), ) } private async activateProviderProfileUnlocked( args: { name: string } | { id: string }, options?: { persistModeConfig?: boolean persistTaskHistory?: boolean skipCurrentTaskRebuild?: boolean preserveForeignTask?: boolean // Fires once the activation has written the activated profile to shared // state, so callers can mark the switch committed at the true point of no // return (a profile lookup failure must stay reconcilable). onSharedWriteStart?: () => void }, signal?: AbortSignal, ): Promise { const { name, id, ...providerSettings } = await this.providerSettingsManager.activateProfile(args) // The manager has now written the activated profile: from here on a failure // must keep the switch rather than roll the mode back onto the old profile. options?.onSharedWriteStart?.() if (signal?.aborted) return const persistModeConfig = options?.persistModeConfig ?? true const persistTaskHistory = options?.persistTaskHistory ?? true const skipCurrentTaskRebuild = options?.skipCurrentTaskRebuild ?? false if (!skipCurrentTaskRebuild) { // See `upsertProviderProfile` for a description of what this is doing. const listApiConfigMeta = await this.providerSettingsManager.listConfig() // Siblings 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) await Promise.all([ this.contextProxy.setValue("listApiConfigMeta", listApiConfigMeta), // 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 }), ]) // 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. await this.refreshViewLocalStateForUpdatedProfile(name, providerSettings) } const { mode } = await this.getState() if (id && persistModeConfig) { await this.providerSettingsManager.setModeConfig(mode, id) } // Change the provider for the current task. A launch-time pin repair // (preserveForeignTask) must not switch a task that follows a different // sticky profile: the repair deliberately leaves the task's sticky profile // untouched, so its handler and credentials must stay on that profile too. const foreignTaskFollowed = options?.preserveForeignTask === true && this.getCurrentTask()?.taskApiConfigName !== undefined && this.getCurrentTask()?.taskApiConfigName !== name this.updateTaskApiHandlerIfNeeded(providerSettings, { forceRebuild: true, skipCurrentTaskRebuild: skipCurrentTaskRebuild || foreignTaskFollowed, }) // 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 }) } 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. */ private async refreshViewLocalStateForUpdatedProfile( name: string, providerSettings: ProviderSettings, ): Promise { const affected = ClineProvider.getAllInstances().filter( (instance) => instance !== this && instance.pinnedProfileName === name, ) if (affected.length === 0) { return } await Promise.all( affected.map(async (instance) => { // Direct private access: compile-time safe across sibling instances. await instance._saveViewLocalStateFromMutation({ apiConfiguration: providerSettings }) // 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 (instance.getCurrentTask()?.taskApiConfigName === name) { instance.updateTaskApiHandlerIfNeeded(providerSettings, { forceRebuild: true }) } // A sibling's UI refresh must not reject an already-applied profile mutation or // trigger its rollback: log the failure and let the remaining siblings refresh. try { await instance.postStateToWebview() } catch (error) { this.log( `[refreshViewLocalStateForUpdatedProfile] failed to post state to sibling view ${instance.viewId}: ${ error instanceof Error ? error.message : String(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, } if (replacementSettings) { 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) } try { await instance.postStateToWebview() } catch (error) { this.log( `[rePinViewLocalStateForDeletedProfile] failed to post state to sibling view ${instance.viewId}: ${ error instanceof Error ? error.message : String(error) }`, ) } }), ) } /** * 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) } try { await instance.postStateToWebview() } catch (error) { this.log( `[repointViewLocalStateAfterProfileRename] failed to post state to sibling view ${instance.viewId}: ${ error instanceof Error ? error.message : String(error) }`, ) } }), ) } 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) { if (id !== this.getCurrentTask()?.taskId) { // Non-current task. const { historyItem } = await this.getTaskWithId(id) await this.createTaskWithHistoryItem(historyItem) // Clears existing task. } await this.postMessageToWebview({ type: "action", action: "chatButtonClicked" }) } async exportTaskWithId(id: string) { const { historyItem, apiConversationHistory } = await this.getTaskWithId(id) 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) } } /* 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, 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, 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, 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. * * @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 const history = await this.taskHistoryStore.upsert(item) 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) ?? item 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) 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. */ private async _restoreSharedValuesAfterFailedViewWrite(previous: RooCodeSettings): Promise { try { await this.contextProxy.setValues(previous) } 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) { const sanitizedValues = { ...values } 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. // A payload that carries its own customModes (createTask and setConfiguration // call setValues before registering them) validates against those too, so a // brand-new custom mode in the same payload is not silently dropped. if ( typeof sanitizedValues.mode !== "string" || !getModeBySlug(sanitizedValues.mode, [ ...(await this.customModesManager.getCustomModes()), ...(sanitizedValues.customModes ?? []), ]) ) { 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) 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. If the // mutation carries such a key, drop this view's overlay instead so it cannot // mask the just-written shared values in getState() (the overlay, seeded by // a persisted pin or a sibling refresh, is older than this shared write). if (PROVIDER_SETTINGS_KEYS.some((key) => key in values)) { delete this.viewLocalState.apiConfiguration } } /** * 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 = {} } /** * Broadcast a reset/import invalidation to all live ClineProvider instances, clearing * both in-memory view-local caches and durable per-view selections so stale view state * cannot mask imported/reset shared state after reload. */ async broadcastResetToAllInstances(): Promise { const allInstances = ClineProvider.getAllInstances() // The durable per-view selections live in one shared global-state slot, so // the reset needs exactly one durable clear. Route it through the persisted // write queue (and run it before the in-memory clears): a per-instance clear // outside the queue could interleave with a queued view-state write and // either clobber that write or leave a re-created entry behind. const clearWrite = ClineProvider.persistedViewStateWriteQueue.then(async () => { await this.contextProxy.setValue("viewStates", undefined) }) ClineProvider.persistedViewStateWriteQueue = clearWrite.catch(() => {}) // Durable invalidation must not gate live invalidation: when the durable // clear rejects, every live buffer still has to be cleared and every // sibling still has to be posted, or the reset leaves views serving stale // selections behind a failed shared slot. The error is re-thrown after the // loop so callers still see the durable failure. let durableClearError: unknown try { await clearWrite } catch (error) { durableClearError = error this.log( `[broadcastResetToAllInstances] durable viewStates clear failed; live state is still invalidated: ${ error instanceof Error ? error.message : String(error) }`, ) } for (const instance of allInstances) { instance._clearViewLocalState() if (instance !== this) { // A sibling's post can throw mid-reset (state generation reaches the // settings file through customModesManager.getCustomModes): the failure // must not stop the reset from reaching the remaining instances, whose // buffers are already cleared above. The originator is deliberately not // posted here: resetState posts its own state immediately after the // broadcast, and a second post would refresh its webview twice. try { await instance.postStateToWebview() } catch (error) { this.log( `[broadcastResetToAllInstances] failed to post reset state to a sibling view: ${ error instanceof Error ? error.message : String(error) }`, ) } } } if (durableClearError) { throw durableClearError } } // 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() // Per-view state (this view's buffer and durable entry) is cleared by the // broadcast below: it clears the durable map once through the serialized // write queue and every live buffer, and only rethrows after that work, so // a per-view clear here would only add a failure point that can strand the // reset between the shared and provider resets. await this.providerSettingsManager.resetAllConfigs() await this.customModesManager.resetCustomModes() await this.removeClineFromStack() // Clear durable and in-memory per-view state across live instances so parallel tabs don't keep stale state. await this.broadcastResetToAllInstances() 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`, ) } 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) { // Capture both selection layers a registration failure must roll back to: // setValues below persists the payload's mode to the shared store AND the // per-view record while the payload's custom modes are still unregistered. // The pin is read defensively because createTask is also driven through // structural provider doubles that carry no view-local buffer. const previousMode = this.contextProxy.getValue("mode") const previousViewMode = this.viewLocalState?.mode 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) { try { for (const mode of configuration.customModes) { await this.customModesManager.updateCustomMode(mode.slug, mode) } } catch (error) { // setValues already persisted the payload's mode to shared and per-view // state; if registration fails the mode is not actually usable, so each // layer rolls back to its own prior value (an unset pin stays unset) // before the failure surfaces. Each restore is awaited on its own and // logged on failure, never swallowed; the original failure is what // callers see. try { await this.contextProxy.setValue("mode", previousMode) } catch (rollbackError) { this.log( `[ClineProvider#createTask] Failed to restore the shared mode after custom-mode registration failed: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`, ) } try { await this.saveViewState("mode", previousViewMode) } catch (rollbackError) { this.log( `[ClineProvider#createTask] Failed to restore the view mode pin after custom-mode registration failed: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`, ) } throw error } } } 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 } // 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, } const lastParentClineMessage = parentClineMessages.at(-1) if ( lastParentClineMessage?.type !== "say" || lastParentClineMessage.say !== "subtask_result" || lastParentClineMessage.text !== completionResultSummary ) { parentClineMessages.push(subtaskUiMessage) } 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() } } }