Compare commits

...

6 Commits

Author SHA1 Message Date
Saoud Rizwan edb100b50f Fix test 2025-04-11 00:00:54 -07:00
Saoud Rizwan 83f592fdae Create odd-jeans-wash.md 2025-04-10 23:31:10 -07:00
Saoud Rizwan e844dd62ce Fix test 2025-04-10 23:08:54 -07:00
Saoud Rizwan 5a31ddcff3 Remove controllerRef 2025-04-10 22:45:46 -07:00
Saoud Rizwan 8ac1d1cfc0 Remove webviewProviderRef 2025-04-10 21:53:01 -07:00
Saoud Rizwan 49cffbffbb Remove controllerRef 2025-04-10 21:37:40 -07:00
13 changed files with 182 additions and 222 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
Remove WeakRef usage
@@ -21,8 +21,3 @@ export interface TaskMetadata {
files_in_context: FileMetadataEntry[]
model_usage: ModelMetadataEntry[]
}
// Interface for the controller to avoid direct dependency
export interface ControllerLike {
context: vscode.ExtensionContext
}
@@ -5,11 +5,10 @@ import * as vscode from "vscode"
import * as path from "path"
import { FileContextTracker } from "./FileContextTracker"
import * as diskModule from "../../storage/disk"
import type { TaskMetadata, ControllerLike, FileMetadataEntry } from "./ContextTrackerTypes"
import type { TaskMetadata, FileMetadataEntry } from "./ContextTrackerTypes"
describe("FileContextTracker", () => {
let sandbox: sinon.SinonSandbox
let mockController: ControllerLike
let mockContext: vscode.ExtensionContext
let mockWorkspace: sinon.SinonStub
let mockFileSystemWatcher: any
@@ -48,10 +47,6 @@ describe("FileContextTracker", () => {
globalStorageUri: { fsPath: "/mock/storage" },
} as unknown as vscode.ExtensionContext
mockController = {
context: mockContext,
}
// Mock disk module functions
mockTaskMetadata = { files_in_context: [], model_usage: [] }
getTaskMetadataStub = sandbox.stub(diskModule, "getTaskMetadata").resolves(mockTaskMetadata)
@@ -59,7 +54,7 @@ describe("FileContextTracker", () => {
// Create tracker instance
taskId = "test-task-id"
tracker = new FileContextTracker(mockController, taskId)
tracker = new FileContextTracker(mockContext, taskId)
})
afterEach(() => {
@@ -1,7 +1,7 @@
import * as path from "path"
import * as vscode from "vscode"
import { getTaskMetadata, saveTaskMetadata } from "../../storage/disk"
import type { FileMetadataEntry, ControllerLike } from "./ContextTrackerTypes"
import type { FileMetadataEntry } from "./ContextTrackerTypes"
// This class is responsible for tracking file operations that may result in stale context.
// If a user modifies a file outside of Cline, the context may become stale and need to be updated.
@@ -16,29 +16,19 @@ import type { FileMetadataEntry, ControllerLike } from "./ContextTrackerTypes"
// If the full contents of a file are pass to Cline via a tool, mention, or edit, the file is marked as active.
// If a file is modified outside of Cline, we detect and track this change to prevent stale context.
export class FileContextTracker {
private context: vscode.ExtensionContext
readonly taskId: string
private controllerRef: WeakRef<ControllerLike>
// File tracking and watching
private fileWatchers = new Map<string, vscode.FileSystemWatcher>()
private recentlyModifiedFiles = new Set<string>()
private recentlyEditedByCline = new Set<string>()
constructor(controller: ControllerLike, taskId: string) {
this.controllerRef = new WeakRef(controller)
constructor(context: vscode.ExtensionContext, taskId: string) {
this.context = context
this.taskId = taskId
}
// While a task is ref'd by a controller, it will always have access to the extension context
// This error is thrown if the controller derefs the task after e.g., aborting the task
private context(): vscode.ExtensionContext {
const context = this.controllerRef.deref()?.context
if (!context) {
throw new Error("Unable to access extension context")
}
return context
}
// Gets the current working directory or returns undefined if it cannot be determined
private getCwd(): string | undefined {
const cwd = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0)
@@ -89,9 +79,8 @@ export class FileContextTracker {
return
}
const context = this.context()
// Add file to metadata
await this.addFileToFileContextTracker(context, this.taskId, filePath, operation)
await this.addFileToFileContextTracker(this.context, this.taskId, filePath, operation)
// Set up file watcher for this file
await this.setupFileWatcher(filePath)
@@ -4,11 +4,10 @@ import * as sinon from "sinon"
import * as vscode from "vscode"
import { ModelContextTracker } from "./ModelContextTracker"
import * as diskModule from "../../storage/disk"
import type { TaskMetadata, ControllerLike } from "./ContextTrackerTypes"
import type { TaskMetadata } from "./ContextTrackerTypes"
describe("ModelContextTracker", () => {
let sandbox: sinon.SinonSandbox
let mockController: ControllerLike
let mockContext: vscode.ExtensionContext
let tracker: ModelContextTracker
let taskId: string
@@ -24,10 +23,6 @@ describe("ModelContextTracker", () => {
globalStorageUri: { fsPath: "/mock/storage" },
} as unknown as vscode.ExtensionContext
mockController = {
context: mockContext,
}
// Mock disk module functions
mockTaskMetadata = { files_in_context: [], model_usage: [] }
getTaskMetadataStub = sandbox.stub(diskModule, "getTaskMetadata").resolves(mockTaskMetadata)
@@ -35,7 +30,7 @@ describe("ModelContextTracker", () => {
// Create tracker instance
taskId = "test-task-id"
tracker = new ModelContextTracker(mockController, taskId)
tracker = new ModelContextTracker(mockContext, taskId)
})
afterEach(() => {
@@ -83,8 +78,7 @@ describe("ModelContextTracker", () => {
it("should throw an error when controller is dereferenced", async () => {
// Create a new tracker with a controller that will be garbage collected
const weakMockController = { context: mockContext }
const weakTracker = new ModelContextTracker(weakMockController, taskId)
const weakTracker = new ModelContextTracker(mockContext, taskId)
// Force the WeakRef to return null by overriding the deref method
const weakRef = { deref: sandbox.stub().returns(null) }
@@ -1,29 +1,17 @@
import * as vscode from "vscode"
import { getTaskMetadata, saveTaskMetadata } from "../../storage/disk"
import type { ControllerLike } from "./ContextTrackerTypes"
export class ModelContextTracker {
readonly taskId: string
private controllerRef: WeakRef<ControllerLike>
private context: vscode.ExtensionContext
constructor(controller: ControllerLike, taskId: string) {
this.controllerRef = new WeakRef(controller)
constructor(context: vscode.ExtensionContext, taskId: string) {
this.context = context
this.taskId = taskId
}
// While a task is ref'd by a controller, it will always have access to the extension context
// This error is thrown if the controller derefs the task after e.g., aborting the task
private context(): vscode.ExtensionContext {
const context = this.controllerRef.deref()?.context
if (!context) {
throw new Error("Unable to access extension context")
}
return context
}
async recordModelUsage(apiProviderId: string, modelId: string, mode: string) {
const context = this.context()
const metadata = await getTaskMetadata(context, this.taskId)
const metadata = await getTaskMetadata(this.context, this.taskId)
if (!metadata.model_usage) {
metadata.model_usage = []
@@ -47,6 +35,6 @@ export class ModelContextTracker {
mode: mode,
})
await saveTaskMetadata(context, this.taskId, metadata)
await saveTaskMetadata(this.context, this.taskId, metadata)
}
}
+51 -48
View File
@@ -17,10 +17,12 @@ import { selectImages } from "../../integrations/misc/process-images"
import { getTheme } from "../../integrations/theme/getTheme"
import WorkspaceTracker from "../../integrations/workspace/WorkspaceTracker"
import { ClineAccountService } from "../../services/account/ClineAccountService"
import { discoverChromeInstances } from "../../services/browser/BrowserDiscovery"
import { BrowserSession } from "../../services/browser/BrowserSession"
import { McpHub } from "../../services/mcp/McpHub"
import { searchWorkspaceFiles } from "../../services/search/file-search"
import { telemetryService } from "../../services/telemetry/TelemetryService"
import { ApiProvider, ModelInfo } from "../../shared/api"
import { findLast } from "../../shared/array"
import { ChatContent } from "../../shared/ChatContent"
import { ChatSettings } from "../../shared/ChatSettings"
import { ExtensionMessage, ExtensionState, Invoke, Platform } from "../../shared/ExtensionMessage"
@@ -30,9 +32,10 @@ import { TelemetrySetting } from "../../shared/TelemetrySetting"
import { ClineCheckpointRestore, WebviewMessage } from "../../shared/WebviewMessage"
import { fileExistsAtPath } from "../../utils/fs"
import { searchCommits } from "../../utils/git"
import { getWorkspacePath } from "../../utils/path"
import { getTotalTasksSize } from "../../utils/storage"
import { Task } from "../task"
import { openMention } from "../mentions"
import { GlobalFileNames } from "../storage/disk"
import {
getAllExtensionState,
getGlobalState,
@@ -42,12 +45,7 @@ import {
updateApiConfiguration,
updateGlobalState,
} from "../storage/state"
import { WebviewProvider } from "../webview"
import { BrowserSession } from "../../services/browser/BrowserSession"
import { GlobalFileNames } from "../storage/disk"
import { discoverChromeInstances } from "../../services/browser/BrowserDiscovery"
import { searchWorkspaceFiles } from "../../services/search/file-search"
import { getWorkspacePath } from "../../utils/path"
import { Task } from "../task"
/*
https://github.com/microsoft/vscode-webview-ui-toolkit-samples/blob/main/default/weather-webview/src/providers/WeatherViewProvider.ts
@@ -56,25 +54,37 @@ https://github.com/KumarVariable/vscode-extension-sidebar-html/blob/master/src/c
*/
export class Controller {
private postMessage: (message: any) => Thenable<boolean> | undefined
private disposables: vscode.Disposable[] = []
private task?: Task
workspaceTracker?: WorkspaceTracker
mcpHub?: McpHub
accountService?: ClineAccountService
workspaceTracker: WorkspaceTracker
mcpHub: McpHub
accountService: ClineAccountService
private latestAnnouncementId = "april-10-2025" // update to some unique identifier when we add a new announcement
private webviewProviderRef: WeakRef<WebviewProvider>
constructor(
readonly context: vscode.ExtensionContext,
private readonly outputChannel: vscode.OutputChannel,
webviewProvider: WebviewProvider,
postMessage: (message: any) => Thenable<boolean> | undefined,
) {
this.outputChannel.appendLine("ClineProvider instantiated")
this.webviewProviderRef = new WeakRef(webviewProvider)
this.postMessage = postMessage
this.workspaceTracker = new WorkspaceTracker(this)
this.mcpHub = new McpHub(this)
this.accountService = new ClineAccountService(this)
this.workspaceTracker = new WorkspaceTracker((msg) => this.postMessageToWebview(msg))
this.mcpHub = new McpHub(
() => this.ensureMcpServersDirectoryExists(),
() => this.ensureSettingsDirectoryExists(),
(msg) => this.postMessageToWebview(msg),
this.context.extension?.packageJSON?.version ?? "1.0.0",
)
this.accountService = new ClineAccountService(
(msg) => this.postMessageToWebview(msg),
async () => {
const { apiConfiguration } = await this.getStateToPostToWebview()
return apiConfiguration?.clineApiKey
},
)
// Clean up legacy checkpoints
cleanupLegacyCheckpoints(this.context.globalStorageUri.fsPath, this.outputChannel).catch((error) => {
@@ -97,11 +107,8 @@ export class Controller {
x.dispose()
}
}
this.workspaceTracker?.dispose()
this.workspaceTracker = undefined
this.mcpHub?.dispose()
this.mcpHub = undefined
this.accountService = undefined
this.workspaceTracker.dispose()
this.mcpHub.dispose()
this.outputChannel.appendLine("Disposed all disposables")
console.error("Controller disposed")
@@ -124,12 +131,19 @@ export class Controller {
await updateGlobalState(this.context, "userInfo", info)
}
async initClineWithTask(task?: string, images?: string[]) {
async initTask(task?: string, images?: string[], historyItem?: HistoryItem) {
await this.clearTask() // ensures that an existing task doesn't exist before starting a new one, although this shouldn't be possible since user must clear task before starting a new one
const { apiConfiguration, customInstructions, autoApprovalSettings, browserSettings, chatSettings } =
await getAllExtensionState(this.context)
this.task = new Task(
this,
this.context,
this.mcpHub,
this.workspaceTracker,
(historyItem) => this.updateTaskHistory(historyItem),
() => this.postStateToWebview(),
(message) => this.postMessageToWebview(message),
(taskId) => this.reinitExistingTaskFromId(taskId),
() => this.cancelTask(),
apiConfiguration,
autoApprovalSettings,
browserSettings,
@@ -137,29 +151,20 @@ export class Controller {
customInstructions,
task,
images,
)
}
async initClineWithHistoryItem(historyItem: HistoryItem) {
await this.clearTask()
const { apiConfiguration, customInstructions, autoApprovalSettings, browserSettings, chatSettings } =
await getAllExtensionState(this.context)
this.task = new Task(
this,
apiConfiguration,
autoApprovalSettings,
browserSettings,
chatSettings,
customInstructions,
undefined,
undefined,
historyItem,
)
}
async reinitExistingTaskFromId(taskId: string) {
const history = await this.getTaskWithId(taskId)
if (history) {
await this.initTask(undefined, undefined, history.historyItem)
}
}
// Send any JSON serializable data to the react app
async postMessageToWebview(message: ExtensionMessage) {
await this.webviewProviderRef.deref()?.view?.webview.postMessage(message)
await this.postMessage(message)
}
/**
@@ -259,7 +264,7 @@ export class Controller {
// Could also do this in extension .ts
//this.postMessageToWebview({ type: "text", text: `Extension: ${Date.now()}` })
// initializing new instance of Cline will make sure that any agentically running promises in old instance don't affect our new task. this essentially creates a fresh slate for the new task
await this.initClineWithTask(message.text, message.images)
await this.initTask(message.text, message.images)
break
case "apiConfiguration":
if (message.apiConfiguration) {
@@ -1078,7 +1083,7 @@ export class Controller {
// 'abandoned' will prevent this cline instance from affecting future cline instance gui. this may happen if its hanging on a streaming request
this.task.abandoned = true
}
await this.initClineWithHistoryItem(historyItem) // clears task again, so we need to abortTask manually above
await this.initTask(undefined, undefined, historyItem) // clears task again, so we need to abortTask manually above
// await this.postStateToWebview() // new Cline instance will post state when it's ready. having this here sent an empty messages array to webview leading to virtuoso having to reload the entire list
}
}
@@ -1396,7 +1401,7 @@ export class Controller {
Here is the project's README to help you get started:\n\n${mcpDetails.readmeContent}\n${mcpDetails.llmsInstallationContent}`
// Initialize task and show chat view
await this.initClineWithTask(task)
await this.initTask(task)
await this.postMessageToWebview({
type: "action",
action: "chatButtonClicked",
@@ -1684,9 +1689,7 @@ Here is the project's README to help you get started:\n\n${mcpDetails.readmeCont
const fileMention = this.getFileMentionFromPath(filePath)
const problemsString = this.convertDiagnosticsToProblemsString(diagnostics)
await this.initClineWithTask(
`Fix the following code in ${fileMention}\n\`\`\`\n${code}\n\`\`\`\n\nProblems:\n${problemsString}`,
)
await this.initTask(`Fix the following code in ${fileMention}\n\`\`\`\n${code}\n\`\`\`\n\nProblems:\n${problemsString}`)
console.log("fixWithCline", code, filePath, languageId, diagnostics, problemsString)
}
@@ -1762,7 +1765,7 @@ Here is the project's README to help you get started:\n\n${mcpDetails.readmeCont
if (id !== this.task?.taskId) {
// non-current task
const { historyItem } = await this.getTaskWithId(id)
await this.initClineWithHistoryItem(historyItem) // clears existing task
await this.initTask(undefined, undefined, historyItem) // clears existing task
}
await this.postMessageToWebview({
type: "action",
+74 -70
View File
@@ -49,6 +49,7 @@ import {
ClineSayBrowserAction,
ClineSayTool,
COMPLETION_RESULT_CHANGES_FLAG,
ExtensionMessage,
} from "../../shared/ExtensionMessage"
import { getApiMetrics } from "../../shared/getApiMetrics"
import { HistoryItem } from "../../shared/HistoryItem"
@@ -82,6 +83,8 @@ import {
GlobalFileNames,
getTaskMetadata,
} from "../storage/disk"
import { McpHub } from "../../services/mcp/McpHub"
import WorkspaceTracker from "../../integrations/workspace/WorkspaceTracker"
const cwd = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0) ?? path.join(os.homedir(), "Desktop") // may or may not exist but fs checking existence would immediately ask for permission which would be bad UX, need to come up with a better solution
@@ -89,6 +92,16 @@ type ToolResponse = string | Array<Anthropic.TextBlockParam | Anthropic.ImageBlo
type UserContent = Array<Anthropic.ContentBlockParam>
export class Task {
// dependencies
private context: vscode.ExtensionContext
private mcpHub: McpHub
private workspaceTracker: WorkspaceTracker
private updateTaskHistory: (historyItem: HistoryItem) => Promise<HistoryItem[]>
private postStateToWebview: () => Promise<void>
private postMessageToWebview: (message: ExtensionMessage) => Promise<void>
private reinitExistingTaskFromId: (taskId: string) => Promise<void>
private cancelTask: () => Promise<void>
readonly taskId: string
readonly apiProvider?: string
api: ApiHandler
@@ -110,7 +123,6 @@ export class Task {
private lastMessageTs?: number
private consecutiveAutoApprovedRequestsCount: number = 0
private consecutiveMistakeCount: number = 0
private controllerRef: WeakRef<Controller>
private abort: boolean = false
didFinishAbortingStream = false
abandoned = false
@@ -141,7 +153,14 @@ export class Task {
private didAutomaticallyRetryFailedApiRequest = false
constructor(
controller: Controller,
context: vscode.ExtensionContext,
mcpHub: McpHub,
workspaceTracker: WorkspaceTracker,
updateTaskHistory: (historyItem: HistoryItem) => Promise<HistoryItem[]>,
postStateToWebview: () => Promise<void>,
postMessageToWebview: (message: ExtensionMessage) => Promise<void>,
reinitExistingTaskFromId: (taskId: string) => Promise<void>,
cancelTask: () => Promise<void>,
apiConfiguration: ApiConfiguration,
autoApprovalSettings: AutoApprovalSettings,
browserSettings: BrowserSettings,
@@ -151,15 +170,22 @@ export class Task {
images?: string[],
historyItem?: HistoryItem,
) {
this.context = context
this.mcpHub = mcpHub
this.workspaceTracker = workspaceTracker
this.updateTaskHistory = updateTaskHistory
this.postStateToWebview = postStateToWebview
this.postMessageToWebview = postMessageToWebview
this.reinitExistingTaskFromId = reinitExistingTaskFromId
this.cancelTask = cancelTask
this.clineIgnoreController = new ClineIgnoreController(cwd)
this.clineIgnoreController.initialize().catch((error) => {
console.error("Failed to initialize ClineIgnoreController:", error)
})
this.controllerRef = new WeakRef(controller)
this.apiProvider = apiConfiguration.apiProvider
this.terminalManager = new TerminalManager()
this.urlContentFetcher = new UrlContentFetcher(controller.context)
this.browserSession = new BrowserSession(controller.context, browserSettings)
this.urlContentFetcher = new UrlContentFetcher(context)
this.browserSession = new BrowserSession(context, browserSettings)
this.contextManager = new ContextManager()
this.diffViewProvider = new DiffViewProvider(cwd)
this.customInstructions = customInstructions
@@ -178,8 +204,8 @@ export class Task {
}
// Initialize file context tracker
this.fileContextTracker = new FileContextTracker(controller, this.taskId)
this.modelContextTracker = new ModelContextTracker(controller, this.taskId)
this.fileContextTracker = new FileContextTracker(context, this.taskId)
this.modelContextTracker = new ModelContextTracker(context, this.taskId)
// Now that taskId is initialized, we can build the API handler
this.api = buildApiHandler({
...apiConfiguration,
@@ -208,7 +234,7 @@ export class Task {
// While a task is ref'd by a controller, it will always have access to the extension context
// This error is thrown if the controller derefs the task after e.g., aborting the task
private getContext(): vscode.ExtensionContext {
const context = this.controllerRef.deref()?.context
const context = this.context
if (!context) {
throw new Error("Unable to access extension context")
}
@@ -260,7 +286,7 @@ export class Task {
} catch (error) {
console.error("Failed to get task directory size:", taskDir, error)
}
await this.controllerRef.deref()?.updateTaskHistory({
await this.updateTaskHistory({
id: this.taskId,
ts: lastRelevantMessage.ts,
task: taskMessage.text ?? "",
@@ -295,15 +321,12 @@ export class Task {
case "workspace":
if (!this.checkpointTracker && !this.checkpointTrackerErrorMessage) {
try {
this.checkpointTracker = await CheckpointTracker.create(
this.taskId,
this.controllerRef.deref()?.context.globalStorageUri.fsPath,
)
this.checkpointTracker = await CheckpointTracker.create(this.taskId, this.context.globalStorageUri.fsPath)
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "Unknown error"
console.error("Failed to initialize checkpoint tracker:", errorMessage)
this.checkpointTrackerErrorMessage = errorMessage
await this.controllerRef.deref()?.postStateToWebview()
await this.postStateToWebview()
vscode.window.showErrorMessage(errorMessage)
didWorkspaceRestoreFail = true
}
@@ -385,17 +408,17 @@ export class Task {
await this.saveClineMessagesAndUpdateHistory()
await this.controllerRef.deref()?.postMessageToWebview({ type: "relinquishControl" })
await this.postMessageToWebview({ type: "relinquishControl" })
this.controllerRef.deref()?.cancelTask() // the task is already cancelled by the provider beforehand, but we need to re-init to get the updated messages
this.cancelTask() // the task is already cancelled by the provider beforehand, but we need to re-init to get the updated messages
} else {
await this.controllerRef.deref()?.postMessageToWebview({ type: "relinquishControl" })
await this.postMessageToWebview({ type: "relinquishControl" })
}
}
async presentMultifileDiff(messageTs: number, seeNewChangesSinceLastTaskCompletion: boolean) {
const relinquishButton = () => {
this.controllerRef.deref()?.postMessageToWebview({ type: "relinquishControl" })
this.postMessageToWebview({ type: "relinquishControl" })
}
console.log("presentMultifileDiff", messageTs)
@@ -416,15 +439,12 @@ export class Task {
// TODO: handle if this is called from outside original workspace, in which case we need to show user error message we cant show diff outside of workspace?
if (!this.checkpointTracker && !this.checkpointTrackerErrorMessage) {
try {
this.checkpointTracker = await CheckpointTracker.create(
this.taskId,
this.controllerRef.deref()?.context.globalStorageUri.fsPath,
)
this.checkpointTracker = await CheckpointTracker.create(this.taskId, this.context.globalStorageUri.fsPath)
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "Unknown error"
console.error("Failed to initialize checkpoint tracker:", errorMessage)
this.checkpointTrackerErrorMessage = errorMessage
await this.controllerRef.deref()?.postStateToWebview()
await this.postStateToWebview()
vscode.window.showErrorMessage(errorMessage)
relinquishButton()
return
@@ -531,10 +551,7 @@ export class Task {
if (!this.checkpointTracker && !this.checkpointTrackerErrorMessage) {
try {
this.checkpointTracker = await CheckpointTracker.create(
this.taskId,
this.controllerRef.deref()?.context.globalStorageUri.fsPath,
)
this.checkpointTracker = await CheckpointTracker.create(this.taskId, this.context.globalStorageUri.fsPath)
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "Unknown error"
console.error("Failed to initialize checkpoint tracker:", errorMessage)
@@ -605,8 +622,8 @@ export class Task {
lastMessage.partial = partial
// todo be more efficient about saving and posting only new data or one whole message at a time so ignore partial for saves, and only post parts of partial message instead of whole array in new listener
// await this.saveClineMessagesAndUpdateHistory()
// await this.controllerRef.deref()?.postStateToWebview()
await this.controllerRef.deref()?.postMessageToWebview({
// await this.postStateToWebview()
await this.postMessageToWebview({
type: "partialMessage",
partialMessage: lastMessage,
})
@@ -625,7 +642,7 @@ export class Task {
text,
partial,
})
await this.controllerRef.deref()?.postStateToWebview()
await this.postStateToWebview()
throw new Error("Current ask promise was ignored 2")
}
} else {
@@ -648,8 +665,8 @@ export class Task {
lastMessage.text = text
lastMessage.partial = false
await this.saveClineMessagesAndUpdateHistory()
// await this.controllerRef.deref()?.postStateToWebview()
await this.controllerRef.deref()?.postMessageToWebview({
// await this.postStateToWebview()
await this.postMessageToWebview({
type: "partialMessage",
partialMessage: lastMessage,
})
@@ -666,7 +683,7 @@ export class Task {
ask: type,
text,
})
await this.controllerRef.deref()?.postStateToWebview()
await this.postStateToWebview()
}
}
} else {
@@ -683,7 +700,7 @@ export class Task {
ask: type,
text,
})
await this.controllerRef.deref()?.postStateToWebview()
await this.postStateToWebview()
}
await pWaitFor(() => this.askResponse !== undefined || this.lastMessageTs !== askTs, { interval: 100 })
@@ -722,7 +739,7 @@ export class Task {
lastMessage.text = text
lastMessage.images = images
lastMessage.partial = partial
await this.controllerRef.deref()?.postMessageToWebview({
await this.postMessageToWebview({
type: "partialMessage",
partialMessage: lastMessage,
})
@@ -738,7 +755,7 @@ export class Task {
images,
partial,
})
await this.controllerRef.deref()?.postStateToWebview()
await this.postStateToWebview()
}
} else {
// partial=false means its a complete version of a previously partial message
@@ -752,8 +769,8 @@ export class Task {
// instead of streaming partialMessage events, we do a save and post like normal to persist to disk
await this.saveClineMessagesAndUpdateHistory()
// await this.controllerRef.deref()?.postStateToWebview()
await this.controllerRef.deref()?.postMessageToWebview({
// await this.postStateToWebview()
await this.postMessageToWebview({
type: "partialMessage",
partialMessage: lastMessage,
}) // more performant than an entire postStateToWebview
@@ -768,7 +785,7 @@ export class Task {
text,
images,
})
await this.controllerRef.deref()?.postStateToWebview()
await this.postStateToWebview()
}
}
} else {
@@ -782,7 +799,7 @@ export class Task {
text,
images,
})
await this.controllerRef.deref()?.postStateToWebview()
await this.postStateToWebview()
}
}
@@ -801,7 +818,7 @@ export class Task {
if (lastMessage?.partial && lastMessage.type === type && (lastMessage.ask === askOrSay || lastMessage.say === askOrSay)) {
this.clineMessages.pop()
await this.saveClineMessagesAndUpdateHistory()
await this.controllerRef.deref()?.postStateToWebview()
await this.postStateToWebview()
}
}
@@ -813,7 +830,7 @@ export class Task {
this.clineMessages = []
this.apiConversationHistory = []
await this.controllerRef.deref()?.postStateToWebview()
await this.postStateToWebview()
await this.say("text", task, images)
@@ -1209,21 +1226,16 @@ export class Task {
async *attemptApiRequest(previousApiReqIndex: number): ApiStream {
// Wait for MCP servers to be connected before generating system prompt
await pWaitFor(() => this.controllerRef.deref()?.mcpHub?.isConnecting !== true, { timeout: 10_000 }).catch(() => {
await pWaitFor(() => this.mcpHub.isConnecting !== true, { timeout: 10_000 }).catch(() => {
console.error("MCP servers failed to connect in time")
})
const mcpHub = this.controllerRef.deref()?.mcpHub
if (!mcpHub) {
throw new Error("MCP hub not available")
}
const disableBrowserTool = vscode.workspace.getConfiguration("cline").get<boolean>("disableBrowserTool") ?? false
const modelSupportsComputerUse = this.api.getModel().info.supportsComputerUse ?? false
const supportsComputerUse = modelSupportsComputerUse && !disableBrowserTool // only enable computer use if the model supports it and the user hasn't disabled it
let systemPrompt = await SYSTEM_PROMPT(cwd, supportsComputerUse, mcpHub, this.browserSettings)
let systemPrompt = await SYSTEM_PROMPT(cwd, supportsComputerUse, this.mcpHub, this.browserSettings)
let settingsCustomInstructions = this.customInstructions?.trim()
const preferredLanguage = getLanguageKey(
@@ -1900,7 +1912,7 @@ export class Task {
}
if (!fileExists) {
this.controllerRef.deref()?.workspaceTracker?.populateFilePaths()
this.workspaceTracker.populateFilePaths()
}
await this.diffViewProvider.reset()
@@ -2296,10 +2308,9 @@ export class Task {
await this.say("browser_action_result", "") // starts loading spinner
// Re-make browserSession to make sure latest settings apply
const localContext = this.controllerRef.deref()?.context
if (localContext) {
if (this.context) {
await this.browserSession.dispose()
this.browserSession = new BrowserSession(localContext, this.browserSettings)
this.browserSession = new BrowserSession(this.context, this.browserSettings)
} else {
console.warn("no controller context available for browserSession")
}
@@ -2498,7 +2509,7 @@ export class Task {
}
// Re-populate file paths in case the command modified the workspace (vscode listeners do not trigger unless the user manually creates/deletes files)
this.controllerRef.deref()?.workspaceTracker?.populateFilePaths()
this.workspaceTracker.populateFilePaths()
pushToolResult(result)
@@ -2580,9 +2591,8 @@ export class Task {
arguments: mcp_arguments,
} satisfies ClineAskUseMcpServer)
const isToolAutoApproved = this.controllerRef
.deref()
?.mcpHub?.connections?.find((conn) => conn.server.name === server_name)
const isToolAutoApproved = this.mcpHub.connections
?.find((conn) => conn.server.name === server_name)
?.server.tools?.find((tool) => tool.name === tool_name)?.autoApprove
if (this.shouldAutoApproveTool(block.name) && isToolAutoApproved) {
@@ -2603,9 +2613,7 @@ export class Task {
// now execute the tool
await this.say("mcp_server_request_started") // same as browser_action_result
const toolResult = await this.controllerRef
.deref()
?.mcpHub?.callTool(server_name, tool_name, parsedArguments)
const toolResult = await this.mcpHub.callTool(server_name, tool_name, parsedArguments)
// TODO: add progress indicator and ability to parse images and non-text responses
const toolResultPretty =
@@ -2694,7 +2702,7 @@ export class Task {
// now execute the tool
await this.say("mcp_server_request_started")
const resourceResult = await this.controllerRef.deref()?.mcpHub?.readResource(server_name, uri)
const resourceResult = await this.mcpHub.readResource(server_name, uri)
const resourceResultPretty =
resourceResult?.contents
.map((item) => {
@@ -3189,7 +3197,7 @@ export class Task {
if (!this.checkpointTracker && !this.checkpointTrackerErrorMessage) {
try {
this.checkpointTracker = await pTimeout(
CheckpointTracker.create(this.taskId, this.controllerRef.deref()?.context.globalStorageUri.fsPath),
CheckpointTracker.create(this.taskId, this.context.globalStorageUri.fsPath),
{
milliseconds: 15_000,
message:
@@ -3231,7 +3239,7 @@ export class Task {
request: userContent.map((block) => formatContentBlockToMarkdown(block)).join("\n\n"),
} satisfies ClineApiReqInfo)
await this.saveClineMessagesAndUpdateHistory()
await this.controllerRef.deref()?.postStateToWebview()
await this.postStateToWebview()
try {
let cacheWriteTokens = 0
@@ -3391,11 +3399,7 @@ export class Task {
const errorMessage = this.formatErrorWithStatusCode(error)
await abortStream("streaming_failed", errorMessage)
const history = await this.controllerRef.deref()?.getTaskWithId(this.taskId)
if (history) {
await this.controllerRef.deref()?.initClineWithHistoryItem(history.historyItem)
// await this.controllerRef.deref()?.postStateToWebview()
}
await this.reinitExistingTaskFromId(this.taskId)
}
} finally {
this.isStreaming = false
@@ -3414,7 +3418,7 @@ export class Task {
}
updateApiReqMsg()
await this.saveClineMessagesAndUpdateHistory()
await this.controllerRef.deref()?.postStateToWebview()
await this.postStateToWebview()
})
}
@@ -3438,7 +3442,7 @@ export class Task {
updateApiReqMsg()
await this.saveClineMessagesAndUpdateHistory()
await this.controllerRef.deref()?.postStateToWebview()
await this.postStateToWebview()
// now add to apiconversationhistory
// need to save assistant responses to file before proceeding to tool use since user can exit at any moment and we wouldn't be able to save the assistant's response
+1 -1
View File
@@ -23,7 +23,7 @@ export class WebviewProvider implements vscode.WebviewViewProvider {
private readonly outputChannel: vscode.OutputChannel,
) {
WebviewProvider.activeInstances.add(this)
this.controller = new Controller(context, outputChannel, this)
this.controller = new Controller(context, outputChannel, (message) => this.view?.webview.postMessage(message))
}
async dispose() {
@@ -2,17 +2,17 @@ import * as vscode from "vscode"
import * as path from "path"
import { listFiles } from "../../services/glob/list-files"
import { Controller } from "../../core/controller"
import { ExtensionMessage } from "../../shared/ExtensionMessage"
const cwd = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0)
// Note: this is not a drop-in replacement for listFiles at the start of tasks, since that will be done for Desktops when there is no workspace selected
class WorkspaceTracker {
private controllerRef: WeakRef<Controller>
private disposables: vscode.Disposable[] = []
private filePaths: Set<string> = new Set()
constructor(controller: Controller) {
this.controllerRef = new WeakRef(controller)
constructor(private readonly postMessageToWebview: (message: ExtensionMessage) => Promise<void>) {
this.postMessageToWebview = postMessageToWebview
this.registerListeners()
}
@@ -85,7 +85,7 @@ class WorkspaceTracker {
if (!cwd) {
return
}
this.controllerRef.deref()?.postMessageToWebview({
this.postMessageToWebview({
type: "workspaceUpdated",
filePaths: Array.from(this.filePaths).map((file) => {
const relativePath = path.relative(cwd, file).toPosix()
+12 -20
View File
@@ -1,26 +1,18 @@
import axios, { AxiosRequestConfig, AxiosResponse } from "axios"
import { Controller } from "../../core/controller"
import type { BalanceResponse, PaymentTransaction, UsageTransaction } from "../../shared/ClineAccount"
import { ExtensionMessage } from "../../shared/ExtensionMessage"
export class ClineAccountService {
private readonly baseUrl = "https://api.cline.bot/v1"
private controllerRef: WeakRef<Controller>
private postMessageToWebview: (message: ExtensionMessage) => Promise<void>
private getClineApiKey: () => Promise<string | undefined>
constructor(controller: Controller) {
this.controllerRef = new WeakRef(controller)
}
/**
* Get the user's Cline Account key from the apiConfiguration
*/
private async getClineApiKey(): Promise<string | undefined> {
const provider = this.controllerRef.deref()
if (!provider) {
return undefined
}
const { apiConfiguration } = await provider.getStateToPostToWebview()
return apiConfiguration?.clineApiKey
constructor(
postMessageToWebview: (message: ExtensionMessage) => Promise<void>,
getClineApiKey: () => Promise<string | undefined>,
) {
this.postMessageToWebview = postMessageToWebview
this.getClineApiKey = getClineApiKey
}
/**
@@ -64,7 +56,7 @@ export class ClineAccountService {
const data = await this.authenticatedRequest<BalanceResponse>("/user/credits/balance")
// Post to webview
await this.controllerRef.deref()?.postMessageToWebview({
await this.postMessageToWebview({
type: "userCreditsBalance",
userCreditsBalance: data,
})
@@ -84,7 +76,7 @@ export class ClineAccountService {
const data = await this.authenticatedRequest<UsageTransaction[]>("/user/credits/usage")
// Post to webview
await this.controllerRef.deref()?.postMessageToWebview({
await this.postMessageToWebview({
type: "userCreditsUsage",
userCreditsUsage: data,
})
@@ -104,7 +96,7 @@ export class ClineAccountService {
const data = await this.authenticatedRequest<PaymentTransaction[]>("/user/credits/payments")
// Post to webview
await this.controllerRef.deref()?.postMessageToWebview({
await this.postMessageToWebview({
type: "userCreditsPayments",
userCreditsPayments: data,
})
+19 -24
View File
@@ -14,7 +14,6 @@ import * as fs from "fs/promises"
import * as path from "path"
import * as vscode from "vscode"
import { z } from "zod"
import { Controller } from "../../core/controller"
import {
DEFAULT_MCP_TIMEOUT_SECONDS,
McpMode,
@@ -31,6 +30,7 @@ import { arePathsEqual } from "../../utils/path"
import { secondsToMs } from "../../utils/time"
import { GlobalFileNames } from "../../core/storage/disk"
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"
import { ExtensionMessage } from "../../shared/ExtensionMessage"
// Default timeout for internal MCP data requests in milliseconds; is not the same as the user facing timeout stored as DEFAULT_MCP_TIMEOUT_SECONDS
const DEFAULT_REQUEST_TIMEOUT_MS = 5000
@@ -76,15 +76,27 @@ const McpSettingsSchema = z.object({
})
export class McpHub {
private controllerRef: WeakRef<Controller>
getMcpServersPath: () => Promise<string>
private getSettingsDirectoryPath: () => Promise<string>
private postMessageToWebview: (message: ExtensionMessage) => Promise<void>
private clientVersion: string
private disposables: vscode.Disposable[] = []
private settingsWatcher?: vscode.FileSystemWatcher
private fileWatchers: Map<string, FSWatcher> = new Map()
connections: McpConnection[] = []
isConnecting: boolean = false
constructor(controller: Controller) {
this.controllerRef = new WeakRef(controller)
constructor(
getMcpServersPath: () => Promise<string>,
getSettingsDirectoryPath: () => Promise<string>,
postMessageToWebview: (message: ExtensionMessage) => Promise<void>,
clientVersion: string,
) {
this.getMcpServersPath = getMcpServersPath
this.getSettingsDirectoryPath = getSettingsDirectoryPath
this.postMessageToWebview = postMessageToWebview
this.clientVersion = clientVersion
this.watchMcpSettingsFile()
this.initializeMcpServers()
}
@@ -98,21 +110,8 @@ export class McpHub {
return vscode.workspace.getConfiguration("cline.mcp").get<McpMode>("mode", "full")
}
async getMcpServersPath(): Promise<string> {
const provider = this.controllerRef.deref()
if (!provider) {
throw new Error("Provider not available")
}
const mcpServersPath = await provider.ensureMcpServersDirectoryExists()
return mcpServersPath
}
async getMcpSettingsFilePath(): Promise<string> {
const provider = this.controllerRef.deref()
if (!provider) {
throw new Error("Provider not available")
}
const mcpSettingsFilePath = path.join(await provider.ensureSettingsDirectoryExists(), GlobalFileNames.mcpSettings)
const mcpSettingsFilePath = path.join(await this.getSettingsDirectoryPath(), GlobalFileNames.mcpSettings)
const fileExists = await fileExistsAtPath(mcpSettingsFilePath)
if (!fileExists) {
await fs.writeFile(
@@ -197,7 +196,7 @@ export class McpHub {
const client = new Client(
{
name: "Cline",
version: this.controllerRef.deref()?.context.extension?.packageJSON?.version ?? "1.0.0",
version: this.clientVersion,
},
{
capabilities: {},
@@ -455,10 +454,6 @@ export class McpHub {
async restartConnection(serverName: string): Promise<void> {
this.isConnecting = true
const provider = this.controllerRef.deref()
if (!provider) {
return
}
// Get existing connection and update its status
const connection = this.connections.find((conn) => conn.server.name === serverName)
@@ -490,7 +485,7 @@ export class McpHub {
const content = await fs.readFile(settingsPath, "utf-8")
const config = JSON.parse(content)
const serverOrder = Object.keys(config.mcpServers || {})
await this.controllerRef.deref()?.postMessageToWebview({
await this.postMessageToWebview({
type: "mcpServers",
mcpServers: [...this.connections]
.sort((a, b) => {
+1 -1
View File
@@ -116,7 +116,7 @@ export function createTestServer(webviewProvider?: WebviewProvider): http.Server
}
// Initiate the new task
const taskId = await visibleWebview.controller.initClineWithTask(task)
const taskId = await visibleWebview.controller.initTask(task)
// Return success response with the task ID
res.writeHead(200, { "Content-Type": "application/json" })