Controller: dependency assembly and the Task factory
Responsibilities
The Controller is the middle layer between extension.ts and the Task. The webview.controller you get after activate() runs initialize() is exactly this (class Controller:70). It holds the Task instance (this.task), manages long-lived services — StateManager, AuthService, OcaAuthService, ClineAccountService, BannerService, McpHub — and centralizes cross-cutting capabilities like task history, telemetry, and remote config. In one line: the Controller is "a Task factory plus a shared-service container across tasks."
The methods it exposes fall roughly into three groups. One group is task lifecycle: initTask, cancelTask, clearTask, reinitExistingTaskFromId. Another is state synchronization: postStateToWebview and getStateToPostToWebview, which pack the current task's clineMessages, taskHistory, apiConfiguration, and a large pile of state into an ExtensionState and push it to the webview. The third group is settings and accounts: handleSignOut, setUserInfo, updateTelemetrySetting, toggleActModeForYoloMode, and so on. Every gRPC request coming from the webview ultimately lands on some specific method on the controller.
Design motivation
- The Controller does not own the LLM stream: the Controller does not deal with LLM calls, block parsing, or tool execution — all of that lives inside the Task. The Controller only "creates a Task, gives the Task its dependencies, and reclaims the Task when it finishes." This layering means unit-testing the Task does not require mocking the Controller.
- Task factory pattern:
initTaskis the single entry point for creating a Task (initTask:231). It first callsclearTaskto prevent co-existence, reads settings like autoApproval and shell integration timeout, acquires the task lock, and finallynew Task({...})injects every dependency throughTaskParams. The Task never news its own dependencies; they all come in from the Controller. - Centralized state push via postStateToWebview: the Controller does not let the Task postMessage directly. Instead it passes
postStateToWebview: () => this.postStateToWebview()as a callback into the Task (postStateToWebview callback:311). That way the Task only mutates clineMessages insidemessageStateHandler, and the Controller is responsible for serializing state intoExtensionStateand pushing it to the webview. - Periodic remote config fetch:
startRemoteConfigTimeris started asynchronously at the end of the constructor (startRemoteConfigTimer:114), fetching immediately and then once per hour, so enterprise policies (yoloModeAllowed, allowedMCPServers, etc.) can hot-reload. - cancelTask is idempotent: the
cancelInProgressflag prevents re-entry when the user hammers the cancel button (cancelInProgress:428). The cancel flow needsabortTask+ waiting for the stream to stop + re-initializing a Task from history, which can take seconds; during that window it must be idempotent. - Callbacks over inheritance: the Controller stuffs
updateTaskHistory,reinitExistingTaskFromId,cancelTask, and other methods intoTaskParamsas arrow functions (TaskParams callbacks:310). When the Task callsthis.cancelTask(), thethisit captures is the Controller's. This is looser coupling than having the Task hold a Controller reference.
Key files
class Controller:70— class declaration; fields includetask?,mcpHub,accountService,authService,stateManager,workspaceManager.constructor:121— assembles StateManager, AuthService, OcaAuthService, BannerService, McpHub; runscleanupLegacyCheckpointsandcheckCliInstallation.startRemoteConfigTimer:114— fetches remote config once immediately, then once per hour.initTask:231— Task factory entry; reads settings, acquires the lock, news the Task, and calls startTask or resumeTaskFromHistory.tryAcquireTaskLockWithRetry:286— prevents the same task from running in two Cline instances at once; throws when cross-window contention occurs.new Task:307— injects every dependency into the Task viaTaskParams; the Task news none of the cross-cutting services itself.cancelTask:426— phased cancel: re-entry guard → abortTask → wait for stream to stop → restore from history or clearTask.postStateToWebview:846— callsgetStateToPostToWebviewto assemble state, thensendStateUpdateto push it to the webview.getStateToPostToWebview:851— assembles 30+ stateKeys intoExtensionState, including clineMessages, taskHistory, apiConfiguration.clearTask:1016— clears the task settings cache, callstask.abortTask, and setsthis.task = undefinedfor GC.dispose:168— clears the remote config timer, clears the task, and calls mcpHub.dispose.new Controller:19— WebviewProvider synchronously news the Controller in its constructor; the two are bound 1:1.
Data flow
initTask is the Controller's most central method. Its flow is "read settings → acquire lock → new Task → start":
// apps/vscode/src/core/controller/index.ts
await this.clearTask() // ensures that an existing task doesn't exist before starting a new one
const autoApprovalSettings = this.stateManager.getGlobalSettingsKey("autoApprovalSettings")
const shellIntegrationTimeout = this.stateManager.getGlobalSettingsKey("shellIntegrationTimeout")
// ...more settings reads
const taskId = historyItem?.id || Date.now().toString()
// Acquire task lock
const lockResult: FolderLockWithRetryResult = await tryAcquireTaskLockWithRetry(taskId)
if (!lockResult.acquired && !lockResult.skipped) {
throw new Error(errorMessage) // Prevents task initialization
}
this.task = new Task({
controller: this,
mcpHub: this.mcpHub,
updateTaskHistory: (historyItem) => this.updateTaskHistory(historyItem),
postStateToWebview: () => this.postStateToWebview(),
reinitExistingTaskFromId: (taskId) => this.reinitExistingTaskFromId(taskId),
cancelTask: () => this.cancelTask(),
// ...plus shellIntegrationTimeout, terminalReuseEnabled, cwd, taskId, etc.
})
if (historyItem) {
this.task.resumeTaskFromHistory()
} else if (task || images || files) {
this.task.startTask(task, images, files)
}This sits near initTask body:247. Note that the Task receives controller: this, but only actually touches a few fields like controller.stateManager and controller.mcpHub, and every state mutation goes back through the postStateToWebview callback. Once the Task finishes — either by calling cancelTask itself or by the user clicking cancel in the webview — the Controller enters cancelTask: first abortTask lets the recursive loop exit on its own, then pWaitFor waits for the stream to actually stop (pWaitFor stream stop:449), and finally either a fresh Task instance is restored from history and hung on the UI (so the user sees the resume button) or clearTask runs directly.
Boundaries and failure modes
- Task lock not acquired: when
tryAcquireTaskLockWithRetryreturnsacquired=falsewithskipped=false, it throws straight away (lock fail throw:288); the Task is never created.skipped=trueis the fallback for the VSCode single-instance case, which is allowed to proceed. - New-user threshold: once task history reaches 10 entries,
isNewUseris flipped to false (new user threshold:261), which collapses the onboarding UI. - autoApproval version bump: every
initTaskincrementsautoApprovalSettings.versionby 1 (autoApproval version bump:267), invalidating older auto-approval configs and forcing the user to reconfirm. - cancelTask 3-second timeout:
pWaitForwaits at most 3 seconds for the stream to stop (cancel timeout:456); on timeout it only logs an error and does not block, and the task is markedabandoned = trueso it no longer affects the UI. - StateManager persistence errors don't interrupt: the
onPersistenceErrorcallback only logs (onPersistenceError:127); it does not callreInitialize()(that would flipisInitialized=falseand break the running task) and does not surface a warning (data is safe in memory and the next debounced persistence will retry). - dispose order: Controller dispose first clears the remote config timer, then
clearTask, thenmcpHub.dispose(dispose:168). The order cannot be reversed — if McpHub is disposed while a task still references it, it crashes.
Summary
The Controller is Cline's key separation between "shared services across tasks" and "the per-task state machine." It never touches the LLM stream itself; it only assembles dependencies, factory-builds Tasks, and centralizes state pushes. To see how the webview and the extension route messages to the Controller, go to /startup/webview-bridge; to see how the Task itself runs its recursive loop, go to /agent-loop/task-class and /agent-loop/recursion.