ソースを参照

Merge branch 'main' of github.com:NyxJae/Roo-Code into human-relay

Felix NyxJae 1 年間 前
コミット
40dd7ac532
31 ファイル変更1016 行追加1096 行削除
  1. 0 5
      .changeset/healthy-buckets-attack.md
  2. 0 5
      .changeset/weak-cameras-hope.md
  3. 12 0
      CHANGELOG.md
  4. 2 2
      package-lock.json
  5. 1 1
      package.json
  6. 1 1
      src/api/providers/__tests__/openrouter.test.ts
  7. 17 2
      src/api/providers/anthropic.ts
  8. 1 1
      src/api/providers/openrouter.ts
  9. 86 57
      src/core/Cline.ts
  10. 8 5
      src/core/prompts/system.ts
  11. 9 0
      src/core/webview/ClineProvider.ts
  12. 16 0
      src/core/webview/__tests__/ClineProvider.test.ts
  13. 0 29
      src/services/checkpoints/CheckpointServiceFactory.ts
  14. 0 440
      src/services/checkpoints/LocalCheckpointService.ts
  15. 97 34
      src/services/checkpoints/ShadowCheckpointService.ts
  16. 0 385
      src/services/checkpoints/__tests__/LocalCheckpointService.test.ts
  17. 169 7
      src/services/checkpoints/__tests__/ShadowCheckpointService.test.ts
  18. 0 2
      src/services/checkpoints/index.ts
  19. 36 3
      src/services/checkpoints/types.ts
  20. 2 0
      src/shared/ExtensionMessage.ts
  21. 1 0
      src/shared/WebviewMessage.ts
  22. 325 0
      src/shared/__tests__/context-mentions.test.ts
  23. 1 1
      src/shared/api.ts
  24. 82 49
      src/shared/context-mentions.ts
  25. 1 0
      src/shared/globalState.ts
  26. 69 12
      webview-ui/src/components/chat/ChatView.tsx
  27. 1 3
      webview-ui/src/components/chat/checkpoints/CheckpointSaved.tsx
  28. 0 2
      webview-ui/src/components/chat/checkpoints/schema.ts
  29. 2 2
      webview-ui/src/components/settings/ApiOptions.tsx
  30. 74 48
      webview-ui/src/components/settings/SettingsView.tsx
  31. 3 0
      webview-ui/src/context/ExtensionStateContext.tsx

+ 0 - 5
.changeset/healthy-buckets-attack.md

@@ -1,5 +0,0 @@
----
-"roo-cline": patch
----
-
-ExtensionStateContext does not correctly merge state

+ 0 - 5
.changeset/weak-cameras-hope.md

@@ -1,5 +0,0 @@
----
-"roo-cline": patch
----
-
-Default middle-out compression to on for OpenRouter

+ 12 - 0
CHANGELOG.md

@@ -1,5 +1,17 @@
 # Roo Code Changelog
 
+## [3.7.12]
+
+- Expand max tokens of thinking models to 128k, and max thinking budget to over 100k (thanks @monotykamary!)
+- Fix issue where keyboard mode switcher wasn't updating API profile (thanks @aheizi!)
+- Use the count_tokens API in the Anthropic provider for more accurate context window management
+- Default middle-out compression to on for OpenRouter
+- Exclude MCP instructions from the prompt if the mode doesn't support MCP
+- Add a checkbox to disable the browser tool
+- Show a warning if checkpoints are taking too long to load
+- Update the warning text for the VS LM API
+- Correctly populate the default OpenRouter model on the welcome screen
+
 ## [3.7.11]
 
 - Don't honor custom max tokens for non thinking models

+ 2 - 2
package-lock.json

@@ -1,12 +1,12 @@
 {
 	"name": "roo-cline",
-	"version": "3.7.11",
+	"version": "3.7.12",
 	"lockfileVersion": 3,
 	"requires": true,
 	"packages": {
 		"": {
 			"name": "roo-cline",
-			"version": "3.7.11",
+			"version": "3.7.12",
 			"dependencies": {
 				"@anthropic-ai/bedrock-sdk": "^0.10.2",
 				"@anthropic-ai/sdk": "^0.37.0",

+ 1 - 1
package.json

@@ -3,7 +3,7 @@
 	"displayName": "Roo Code (prev. Roo Cline)",
 	"description": "A whole dev team of AI agents in your editor.",
 	"publisher": "RooVeterinaryInc",
-	"version": "3.7.11",
+	"version": "3.7.12",
 	"icon": "assets/icons/rocket.png",
 	"galleryBanner": {
 		"color": "#617A91",

+ 1 - 1
src/api/providers/__tests__/openrouter.test.ts

@@ -72,7 +72,7 @@ describe("OpenRouterHandler", () => {
 			openRouterModelId: "test-model",
 			openRouterModelInfo: {
 				...mockOpenRouterModelInfo,
-				maxTokens: 64_000,
+				maxTokens: 128_000,
 				thinking: true,
 			},
 			modelMaxTokens: 32_768,

+ 17 - 2
src/api/providers/anthropic.ts

@@ -29,7 +29,7 @@ export class AnthropicHandler extends BaseProvider implements SingleCompletionHa
 	async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
 		let stream: AnthropicStream<Anthropic.Messages.RawMessageStreamEvent>
 		const cacheControl: CacheControlEphemeral = { type: "ephemeral" }
-		let { id: modelId, maxTokens, thinking, temperature } = this.getModel()
+		let { id: modelId, maxTokens, thinking, temperature, virtualId } = this.getModel()
 
 		switch (modelId) {
 			case "claude-3-7-sonnet-20250219":
@@ -82,13 +82,24 @@ export class AnthropicHandler extends BaseProvider implements SingleCompletionHa
 						// prompt caching: https://x.com/alexalbert__/status/1823751995901272068
 						// https://github.com/anthropics/anthropic-sdk-typescript?tab=readme-ov-file#default-headers
 						// https://github.com/anthropics/anthropic-sdk-typescript/commit/c920b77fc67bd839bfeb6716ceab9d7c9bbe7393
+
+						const betas = []
+
+						// Check for the thinking-128k variant first
+						if (virtualId === "claude-3-7-sonnet-20250219:thinking") {
+							betas.push("output-128k-2025-02-19")
+						}
+
+						// Then check for models that support prompt caching
 						switch (modelId) {
+							case "claude-3-7-sonnet-20250219":
 							case "claude-3-5-sonnet-20241022":
 							case "claude-3-5-haiku-20241022":
 							case "claude-3-opus-20240229":
 							case "claude-3-haiku-20240307":
+								betas.push("prompt-caching-2024-07-31")
 								return {
-									headers: { "anthropic-beta": "prompt-caching-2024-07-31" },
+									headers: { "anthropic-beta": betas.join(",") },
 								}
 							default:
 								return undefined
@@ -184,6 +195,9 @@ export class AnthropicHandler extends BaseProvider implements SingleCompletionHa
 		let id = modelId && modelId in anthropicModels ? (modelId as AnthropicModelId) : anthropicDefaultModelId
 		const info: ModelInfo = anthropicModels[id]
 
+		// Track the original model ID for special variant handling
+		const virtualId = id
+
 		// The `:thinking` variant is a virtual identifier for the
 		// `claude-3-7-sonnet-20250219` model with a thinking budget.
 		// We can handle this more elegantly in the future.
@@ -194,6 +208,7 @@ export class AnthropicHandler extends BaseProvider implements SingleCompletionHa
 		return {
 			id,
 			info,
+			virtualId, // Include the original ID to use for header selection
 			...getModelParams({ options: this.options, model: info, defaultMaxTokens: ANTHROPIC_DEFAULT_MAX_TOKENS }),
 		}
 	}

+ 1 - 1
src/api/providers/openrouter.ts

@@ -263,7 +263,7 @@ export async function getOpenRouterModels() {
 					modelInfo.supportsPromptCache = true
 					modelInfo.cacheWritesPrice = 3.75
 					modelInfo.cacheReadsPrice = 0.3
-					modelInfo.maxTokens = rawModel.id === "anthropic/claude-3.7-sonnet:thinking" ? 64_000 : 16_384
+					modelInfo.maxTokens = rawModel.id === "anthropic/claude-3.7-sonnet:thinking" ? 128_000 : 16_384
 					break
 				case rawModel.id.startsWith("anthropic/claude-3.5-sonnet-20240620"):
 					modelInfo.supportsPromptCache = true

+ 86 - 57
src/core/Cline.ts

@@ -10,10 +10,10 @@ import getFolderSize from "get-folder-size"
 import * as path from "path"
 import { serializeError } from "serialize-error"
 import * as vscode from "vscode"
-import { ApiHandler, SingleCompletionHandler, buildApiHandler } from "../api"
+import { ApiHandler, buildApiHandler } from "../api"
 import { ApiStream } from "../api/transform/stream"
 import { DIFF_VIEW_URI_SCHEME, DiffViewProvider } from "../integrations/editor/DiffViewProvider"
-import { CheckpointService, CheckpointServiceFactory } from "../services/checkpoints"
+import { ShadowCheckpointService } from "../services/checkpoints/ShadowCheckpointService"
 import { findToolName, formatContentBlockToMarkdown } from "../integrations/misc/export-markdown"
 import {
 	extractTextFromFile,
@@ -116,7 +116,7 @@ export class Cline {
 
 	// checkpoints
 	enableCheckpoints: boolean = false
-	private checkpointService?: CheckpointService
+	private checkpointService?: ShadowCheckpointService
 
 	// streaming
 	isWaitingForFirstChunk = false
@@ -747,8 +747,11 @@ export class Cline {
 	}
 
 	private async initiateTaskLoop(userContent: UserContent): Promise<void> {
+		this.initializeCheckpoints()
+
 		let nextUserContent = userContent
 		let includeFileDetails = true
+
 		while (!this.abort) {
 			const didEndLoop = await this.recursivelyMakeClineRequests(nextUserContent, includeFileDetails)
 			includeFileDetails = false // we only need file details the first time
@@ -946,6 +949,7 @@ export class Cline {
 			preferredLanguage,
 			experiments,
 			enableMcpServerCreation,
+			browserToolEnabled,
 		} = (await this.providerRef.deref()?.getState()) ?? {}
 		const { customModes } = (await this.providerRef.deref()?.getState()) ?? {}
 		const systemPrompt = await (async () => {
@@ -956,7 +960,7 @@ export class Cline {
 			return SYSTEM_PROMPT(
 				provider.context,
 				cwd,
-				this.api.getModel().info.supportsComputerUse ?? false,
+				(this.api.getModel().info.supportsComputerUse ?? false) && (browserToolEnabled ?? true),
 				mcpHub,
 				this.diffStrategy,
 				browserViewportSize,
@@ -2772,7 +2776,7 @@ export class Cline {
 		}
 
 		if (isCheckpointPossible) {
-			await this.checkpointSave({ isFirst: false })
+			this.checkpointSave()
 		}
 
 		/*
@@ -2838,13 +2842,6 @@ export class Cline {
 		// get previous api req's index to check token usage and determine if we need to truncate conversation history
 		const previousApiReqIndex = findLastIndex(this.clineMessages, (m) => m.say === "api_req_started")
 
-		// Save checkpoint if this is the first API request.
-		const isFirstRequest = this.clineMessages.filter((m) => m.say === "api_req_started").length === 0
-
-		if (isFirstRequest) {
-			await this.checkpointSave({ isFirst: true })
-		}
-
 		// getting verbose details is an expensive operation, it uses globby to top-down build file structure of project which for large projects can take a few seconds
 		// for the best UX we show a placeholder api_req_started message with a loading spinner as this happens
 		await this.say(
@@ -3355,37 +3352,88 @@ export class Cline {
 
 	// Checkpoints
 
-	private async getCheckpointService() {
+	private async initializeCheckpoints() {
 		if (!this.enableCheckpoints) {
-			throw new Error("Checkpoints are disabled")
+			return
 		}
 
-		if (!this.checkpointService) {
+		const log = (message: string) => {
+			console.log(message)
+
+			try {
+				this.providerRef.deref()?.log(message)
+			} catch (err) {
+				// NO-OP
+			}
+		}
+
+		try {
+			if (this.checkpointService) {
+				log("[Cline#initializeCheckpoints] checkpointService already initialized")
+				return
+			}
+
 			const workspaceDir = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0)
-			const shadowDir = this.providerRef.deref()?.context.globalStorageUri.fsPath
 
 			if (!workspaceDir) {
-				this.providerRef.deref()?.log("[getCheckpointService] workspace folder not found")
-				throw new Error("Workspace directory not found")
+				log("[Cline#initializeCheckpoints] workspace folder not found, disabling checkpoints")
+				this.enableCheckpoints = false
+				return
 			}
 
+			const shadowDir = this.providerRef.deref()?.context.globalStorageUri.fsPath
+
 			if (!shadowDir) {
-				this.providerRef.deref()?.log("[getCheckpointService] shadowDir not found")
-				throw new Error("Global storage directory not found")
+				log("[Cline#initializeCheckpoints] shadowDir not found, disabling checkpoints")
+				this.enableCheckpoints = false
+				return
+			}
+
+			const service = await ShadowCheckpointService.create({ taskId: this.taskId, workspaceDir, shadowDir, log })
+
+			if (!service) {
+				log("[Cline#initializeCheckpoints] failed to create checkpoint service, disabling checkpoints")
+				this.enableCheckpoints = false
+				return
 			}
 
-			this.checkpointService = await CheckpointServiceFactory.create({
-				strategy: "shadow",
-				options: {
-					taskId: this.taskId,
-					workspaceDir,
-					shadowDir,
-					log: (message) => this.providerRef.deref()?.log(message),
-				},
+			service.on("initialize", ({ workspaceDir, created, duration }) => {
+				try {
+					if (created) {
+						log(`[Cline#initializeCheckpoints] created new shadow repo (${workspaceDir}) in ${duration}ms`)
+					} else {
+						log(
+							`[Cline#initializeCheckpoints] found existing shadow repo (${workspaceDir}) in ${duration}ms`,
+						)
+					}
+
+					this.checkpointService = service
+					this.checkpointSave()
+				} catch (err) {
+					log("[Cline#initializeCheckpoints] caught error in on('initialize'), disabling checkpoints")
+					this.enableCheckpoints = false
+				}
 			})
-		}
 
-		return this.checkpointService
+			service.on("checkpoint", ({ isFirst, fromHash: from, toHash: to }) => {
+				try {
+					log(`[Cline#initializeCheckpoints] ${isFirst ? "initial" : "incremental"} checkpoint saved: ${to}`)
+					this.providerRef.deref()?.postMessageToWebview({ type: "currentCheckpointUpdated", text: to })
+
+					this.say("checkpoint_saved", to, undefined, undefined, { isFirst, from, to }).catch((e) =>
+						console.error("Error saving checkpoint message:", e),
+					)
+				} catch (err) {
+					log("[Cline#initializeCheckpoints] caught error in on('checkpoint'), disabling checkpoints")
+					this.enableCheckpoints = false
+				}
+			})
+
+			service.initShadowGit()
+		} catch (err) {
+			log("[Cline#initializeCheckpoints] caught error in initializeCheckpoints(), disabling checkpoints")
+			this.enableCheckpoints = false
+		}
 	}
 
 	public async checkpointDiff({
@@ -3397,7 +3445,7 @@ export class Cline {
 		commitHash: string
 		mode: "full" | "checkpoint"
 	}) {
-		if (!this.enableCheckpoints) {
+		if (!this.checkpointService || !this.enableCheckpoints) {
 			return
 		}
 
@@ -3413,8 +3461,7 @@ export class Cline {
 		}
 
 		try {
-			const service = await this.getCheckpointService()
-			const changes = await service.getDiff({ from: previousCommitHash, to: commitHash })
+			const changes = await this.checkpointService.getDiff({ from: previousCommitHash, to: commitHash })
 
 			if (!changes?.length) {
 				vscode.window.showInformationMessage("No changes found.")
@@ -3440,30 +3487,13 @@ export class Cline {
 		}
 	}
 
-	public async checkpointSave({ isFirst }: { isFirst: boolean }) {
-		if (!this.enableCheckpoints) {
+	public checkpointSave() {
+		if (!this.checkpointService || !this.enableCheckpoints) {
 			return
 		}
 
-		try {
-			const service = await this.getCheckpointService()
-			const strategy = service.strategy
-			const version = service.version
-
-			const commit = await service.saveCheckpoint(`Task: ${this.taskId}, Time: ${Date.now()}`)
-			const fromHash = service.baseHash
-			const toHash = isFirst ? commit?.commit || fromHash : commit?.commit
-
-			if (toHash) {
-				await this.providerRef.deref()?.postMessageToWebview({ type: "currentCheckpointUpdated", text: toHash })
-
-				const checkpoint = { isFirst, from: fromHash, to: toHash, strategy, version }
-				await this.say("checkpoint_saved", toHash, undefined, undefined, checkpoint)
-			}
-		} catch (err) {
-			this.providerRef.deref()?.log("[checkpointSave] disabling checkpoints for this task")
-			this.enableCheckpoints = false
-		}
+		// Start the checkpoint process in the background.
+		this.checkpointService.saveCheckpoint(`Task: ${this.taskId}, Time: ${Date.now()}`)
 	}
 
 	public async checkpointRestore({
@@ -3475,7 +3505,7 @@ export class Cline {
 		commitHash: string
 		mode: "preview" | "restore"
 	}) {
-		if (!this.enableCheckpoints) {
+		if (!this.checkpointService || !this.enableCheckpoints) {
 			return
 		}
 
@@ -3486,8 +3516,7 @@ export class Cline {
 		}
 
 		try {
-			const service = await this.getCheckpointService()
-			await service.restoreCheckpoint(commitHash)
+			await this.checkpointService.restoreCheckpoint(commitHash)
 
 			await this.providerRef.deref()?.postMessageToWebview({ type: "currentCheckpointUpdated", text: commitHash })
 

+ 8 - 5
src/core/prompts/system.ts

@@ -7,6 +7,7 @@ import {
 	defaultModeSlug,
 	ModeConfig,
 	getModeBySlug,
+	getGroupName,
 } from "../../shared/modes"
 import { DiffStrategy } from "../diff/DiffStrategy"
 import { McpHub } from "../../services/mcp/McpHub"
@@ -50,15 +51,17 @@ async function generatePrompt(
 	// If diff is disabled, don't pass the diffStrategy
 	const effectiveDiffStrategy = diffEnabled ? diffStrategy : undefined
 
-	const [mcpServersSection, modesSection] = await Promise.all([
-		getMcpServersSection(mcpHub, effectiveDiffStrategy, enableMcpServerCreation),
-		getModesSection(context),
-	])
-
 	// Get the full mode config to ensure we have the role definition
 	const modeConfig = getModeBySlug(mode, customModeConfigs) || modes.find((m) => m.slug === mode) || modes[0]
 	const roleDefinition = promptComponent?.roleDefinition || modeConfig.roleDefinition
 
+	const [modesSection, mcpServersSection] = await Promise.all([
+		getModesSection(context),
+		modeConfig.groups.some((groupEntry) => getGroupName(groupEntry) === "mcp")
+			? getMcpServersSection(mcpHub, effectiveDiffStrategy, enableMcpServerCreation)
+			: Promise.resolve(""),
+	])
+
 	const basePrompt = `${roleDefinition}
 
 ${getSharedToolUseSection()}

+ 9 - 0
src/core/webview/ClineProvider.ts

@@ -1234,6 +1234,10 @@ export class ClineProvider implements vscode.WebviewViewProvider {
 						await this.updateGlobalState("maxOpenTabsContext", tabCount)
 						await this.postStateToWebview()
 						break
+					case "browserToolEnabled":
+						await this.updateGlobalState("browserToolEnabled", message.bool ?? true)
+						await this.postStateToWebview()
+						break
 					case "enhancementApiConfigId":
 						await this.updateGlobalState("enhancementApiConfigId", message.text)
 						await this.postStateToWebview()
@@ -2051,6 +2055,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
 			autoApprovalEnabled,
 			experiments,
 			maxOpenTabsContext,
+			browserToolEnabled,
 		} = await this.getState()
 
 		const allowedCommands = vscode.workspace.getConfiguration("roo-cline").get<string[]>("allowedCommands") || []
@@ -2104,6 +2109,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
 			mcpServers: this.mcpHub?.getAllServers() ?? [],
 			maxOpenTabsContext: maxOpenTabsContext ?? 20,
 			cwd: cwd,
+			browserToolEnabled: browserToolEnabled ?? true,
 		}
 	}
 
@@ -2243,6 +2249,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
 			modelMaxTokens,
 			modelMaxThinkingTokens,
 			maxOpenTabsContext,
+			browserToolEnabled,
 		] = await Promise.all([
 			this.getGlobalState("apiProvider") as Promise<ApiProvider | undefined>,
 			this.getGlobalState("apiModelId") as Promise<string | undefined>,
@@ -2327,6 +2334,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
 			this.getGlobalState("modelMaxTokens") as Promise<number | undefined>,
 			this.getGlobalState("anthropicThinking") as Promise<number | undefined>,
 			this.getGlobalState("maxOpenTabsContext") as Promise<number | undefined>,
+			this.getGlobalState("browserToolEnabled") as Promise<boolean | undefined>,
 		])
 
 		let apiProvider: ApiProvider
@@ -2460,6 +2468,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
 			customModes,
 			maxOpenTabsContext: maxOpenTabsContext ?? 20,
 			openRouterUseMiddleOutTransform: openRouterUseMiddleOutTransform ?? true,
+			browserToolEnabled: browserToolEnabled ?? true,
 		}
 	}
 

+ 16 - 0
src/core/webview/__tests__/ClineProvider.test.ts

@@ -381,6 +381,7 @@ describe("ClineProvider", () => {
 			customModes: [],
 			experiments: experimentDefault,
 			maxOpenTabsContext: 20,
+			browserToolEnabled: true,
 		}
 
 		const message: ExtensionMessage = {
@@ -591,6 +592,21 @@ describe("ClineProvider", () => {
 		expect(provider.configManager.setModeConfig).toHaveBeenCalledWith("architect", "new-id")
 	})
 
+	test("handles browserToolEnabled setting", async () => {
+		await provider.resolveWebviewView(mockWebviewView)
+		const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as jest.Mock).mock.calls[0][0]
+
+		// Test browserToolEnabled
+		await messageHandler({ type: "browserToolEnabled", bool: true })
+		expect(mockContext.globalState.update).toHaveBeenCalledWith("browserToolEnabled", true)
+		expect(mockPostMessage).toHaveBeenCalled()
+
+		// Verify state includes browserToolEnabled
+		const state = await provider.getState()
+		expect(state).toHaveProperty("browserToolEnabled")
+		expect(state.browserToolEnabled).toBe(true) // Default value should be true
+	})
+
 	test("handles request delay settings messages", async () => {
 		await provider.resolveWebviewView(mockWebviewView)
 		const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as jest.Mock).mock.calls[0][0]

+ 0 - 29
src/services/checkpoints/CheckpointServiceFactory.ts

@@ -1,29 +0,0 @@
-import { LocalCheckpointService, LocalCheckpointServiceOptions } from "./LocalCheckpointService"
-import { ShadowCheckpointService, ShadowCheckpointServiceOptions } from "./ShadowCheckpointService"
-
-export type CreateCheckpointServiceFactoryOptions =
-	| {
-			strategy: "local"
-			options: LocalCheckpointServiceOptions
-	  }
-	| {
-			strategy: "shadow"
-			options: ShadowCheckpointServiceOptions
-	  }
-
-type CheckpointServiceType<T extends CreateCheckpointServiceFactoryOptions> = T extends { strategy: "local" }
-	? LocalCheckpointService
-	: T extends { strategy: "shadow" }
-		? ShadowCheckpointService
-		: never
-
-export class CheckpointServiceFactory {
-	public static create<T extends CreateCheckpointServiceFactoryOptions>(options: T): CheckpointServiceType<T> {
-		switch (options.strategy) {
-			case "local":
-				return LocalCheckpointService.create(options.options) as any
-			case "shadow":
-				return ShadowCheckpointService.create(options.options) as any
-		}
-	}
-}

+ 0 - 440
src/services/checkpoints/LocalCheckpointService.ts

@@ -1,440 +0,0 @@
-import fs from "fs/promises"
-import { existsSync } from "fs"
-import path from "path"
-
-import simpleGit, { SimpleGit, CleanOptions } from "simple-git"
-
-import { CheckpointStrategy, CheckpointService, CheckpointServiceOptions } from "./types"
-
-export interface LocalCheckpointServiceOptions extends CheckpointServiceOptions {}
-
-/**
- * The CheckpointService provides a mechanism for storing a snapshot of the
- * current VSCode workspace each time a Roo Code tool is executed. It uses Git
- * under the hood.
- *
- * HOW IT WORKS
- *
- * Two branches are used:
- *  - A main branch for normal operation (the branch you are currently on).
- *  - A hidden branch for storing checkpoints.
- *
- * Saving a checkpoint:
- *  - A temporary branch is created to store the current state.
- *  - All changes (including untracked files) are staged and committed on the temp branch.
- *  - The hidden branch is reset to match main.
- *  - The temporary branch commit is cherry-picked onto the hidden branch.
- *  - The workspace is restored to its original state and the temp branch is deleted.
- *
- * Restoring a checkpoint:
- *  - The workspace is restored to the state of the specified checkpoint using
- *    `git restore` and `git clean`.
- *
- * This approach allows for:
- *  - Non-destructive version control (main branch remains untouched).
- *  - Preservation of the full history of checkpoints.
- *  - Safe restoration to any previous checkpoint.
- *  - Atomic checkpoint operations with proper error recovery.
- *
- * NOTES
- *
- *  - Git must be installed.
- *  - If the current working directory is not a Git repository, we will
- *    initialize a new one with a .gitkeep file.
- *  - If you manually edit files and then restore a checkpoint, the changes
- *    will be lost. Addressing this adds some complexity to the implementation
- *    and it's not clear whether it's worth it.
- */
-
-export class LocalCheckpointService implements CheckpointService {
-	private static readonly USER_NAME = "Roo Code"
-	private static readonly USER_EMAIL = "[email protected]"
-	private static readonly CHECKPOINT_BRANCH = "roo-code-checkpoints"
-	private static readonly STASH_BRANCH = "roo-code-stash"
-
-	public readonly strategy: CheckpointStrategy = "local"
-	public readonly version = 1
-
-	public get baseHash() {
-		return this._baseHash
-	}
-
-	constructor(
-		public readonly taskId: string,
-		public readonly git: SimpleGit,
-		public readonly workspaceDir: string,
-		private readonly mainBranch: string,
-		private _baseHash: string,
-		private readonly hiddenBranch: string,
-		private readonly log: (message: string) => void,
-	) {}
-
-	private async ensureBranch(expectedBranch: string) {
-		const branch = await this.git.revparse(["--abbrev-ref", "HEAD"])
-
-		if (branch.trim() !== expectedBranch) {
-			throw new Error(`Git branch mismatch: expected '${expectedBranch}' but found '${branch}'`)
-		}
-	}
-
-	public async getDiff({ from, to }: { from?: string; to?: string }) {
-		const result = []
-
-		if (!from) {
-			from = this.baseHash
-		}
-
-		const { files } = await this.git.diffSummary([`${from}..${to}`])
-
-		for (const file of files.filter((f) => !f.binary)) {
-			const relPath = file.file
-			const absPath = path.join(this.workspaceDir, relPath)
-			const before = await this.git.show([`${from}:${relPath}`]).catch(() => "")
-
-			const after = to
-				? await this.git.show([`${to}:${relPath}`]).catch(() => "")
-				: await fs.readFile(absPath, "utf8").catch(() => "")
-
-			result.push({
-				paths: { relative: relPath, absolute: absPath },
-				content: { before, after },
-			})
-		}
-
-		return result
-	}
-
-	private async restoreMain({
-		branch,
-		stashSha,
-		force = false,
-	}: {
-		branch: string
-		stashSha: string
-		force?: boolean
-	}) {
-		let currentBranch = await this.git.revparse(["--abbrev-ref", "HEAD"])
-
-		if (currentBranch !== this.mainBranch) {
-			if (force) {
-				try {
-					await this.git.checkout(["-f", this.mainBranch])
-				} catch (err) {
-					this.log(
-						`[restoreMain] failed to force checkout ${this.mainBranch}: ${err instanceof Error ? err.message : String(err)}`,
-					)
-				}
-			} else {
-				try {
-					await this.git.checkout(this.mainBranch)
-				} catch (err) {
-					this.log(
-						`[restoreMain] failed to checkout ${this.mainBranch}: ${err instanceof Error ? err.message : String(err)}`,
-					)
-
-					// Escalate to a forced checkout if we can't checkout the
-					// main branch under normal circumstances.
-					currentBranch = await this.git.revparse(["--abbrev-ref", "HEAD"])
-
-					if (currentBranch !== this.mainBranch) {
-						await this.git.checkout(["-f", this.mainBranch]).catch(() => {})
-					}
-				}
-			}
-		}
-
-		currentBranch = await this.git.revparse(["--abbrev-ref", "HEAD"])
-
-		if (currentBranch !== this.mainBranch) {
-			throw new Error(`Unable to restore ${this.mainBranch}`)
-		}
-
-		if (stashSha) {
-			this.log(`[restoreMain] applying stash ${stashSha}`)
-
-			try {
-				await this.git.raw(["stash", "apply", "--index", stashSha])
-			} catch (err) {
-				this.log(`[restoreMain] Failed to apply stash: ${err instanceof Error ? err.message : String(err)}`)
-			}
-		}
-
-		this.log(`[restoreMain] restoring from ${branch} branch`)
-
-		try {
-			await this.git.raw(["restore", "--source", branch, "--worktree", "--", "."])
-		} catch (err) {
-			this.log(`[restoreMain] Failed to restore branch: ${err instanceof Error ? err.message : String(err)}`)
-		}
-	}
-
-	public async saveCheckpoint(message: string) {
-		const startTime = Date.now()
-
-		await this.ensureBranch(this.mainBranch)
-
-		const stashSha = (await this.git.raw(["stash", "create"])).trim()
-		const latestSha = await this.git.revparse([this.hiddenBranch])
-
-		/**
-		 * PHASE: Create stash
-		 * Mutations:
-		 *   - Create branch
-		 *   - Change branch
-		 */
-		const stashBranch = `${LocalCheckpointService.STASH_BRANCH}-${Date.now()}`
-		await this.git.checkout(["-b", stashBranch])
-		this.log(`[saveCheckpoint] created and checked out ${stashBranch}`)
-
-		/**
-		 * Phase: Stage stash
-		 * Mutations: None
-		 * Recovery:
-		 *   - UNDO: Create branch
-		 *   - UNDO: Change branch
-		 */
-		try {
-			await this.git.add(["-A"])
-		} catch (err) {
-			this.log(
-				`[saveCheckpoint] failed in stage stash phase: ${err instanceof Error ? err.message : String(err)}`,
-			)
-			await this.restoreMain({ branch: stashBranch, stashSha, force: true })
-			await this.git.branch(["-D", stashBranch]).catch(() => {})
-			throw err
-		}
-
-		/**
-		 * Phase: Commit stash
-		 * Mutations:
-		 *   - Commit stash
-		 *   - Change branch
-		 * Recovery:
-		 *   - UNDO: Create branch
-		 *   - UNDO: Change branch
-		 */
-		let stashCommit
-
-		try {
-			stashCommit = await this.git.commit(message, undefined, { "--no-verify": null })
-			this.log(`[saveCheckpoint] stashCommit: ${message} -> ${JSON.stringify(stashCommit)}`)
-		} catch (err) {
-			this.log(
-				`[saveCheckpoint] failed in stash commit phase: ${err instanceof Error ? err.message : String(err)}`,
-			)
-			await this.restoreMain({ branch: stashBranch, stashSha, force: true })
-			await this.git.branch(["-D", stashBranch]).catch(() => {})
-			throw err
-		}
-
-		if (!stashCommit) {
-			this.log("[saveCheckpoint] no stash commit")
-			await this.restoreMain({ branch: stashBranch, stashSha })
-			await this.git.branch(["-D", stashBranch])
-			return undefined
-		}
-
-		/**
-		 * PHASE: Diff
-		 * Mutations:
-		 *   - Checkout hidden branch
-		 * Recovery:
-		 *   - UNDO: Create branch
-		 *   - UNDO: Change branch
-		 *   - UNDO: Commit stash
-		 */
-		let diff
-
-		try {
-			diff = await this.git.diff([latestSha, stashBranch])
-		} catch (err) {
-			this.log(`[saveCheckpoint] failed in diff phase: ${err instanceof Error ? err.message : String(err)}`)
-			await this.restoreMain({ branch: stashBranch, stashSha, force: true })
-			await this.git.branch(["-D", stashBranch]).catch(() => {})
-			throw err
-		}
-
-		if (!diff) {
-			this.log("[saveCheckpoint] no diff")
-			await this.restoreMain({ branch: stashBranch, stashSha })
-			await this.git.branch(["-D", stashBranch])
-			return undefined
-		}
-
-		/**
-		 * PHASE: Reset
-		 * Mutations:
-		 *   - Reset hidden branch
-		 * Recovery:
-		 *   - UNDO: Create branch
-		 *   - UNDO: Change branch
-		 *   - UNDO: Commit stash
-		 */
-		try {
-			await this.git.checkout(this.hiddenBranch)
-			this.log(`[saveCheckpoint] checked out ${this.hiddenBranch}`)
-			await this.git.reset(["--hard", this.mainBranch])
-			this.log(`[saveCheckpoint] reset ${this.hiddenBranch}`)
-		} catch (err) {
-			this.log(`[saveCheckpoint] failed in reset phase: ${err instanceof Error ? err.message : String(err)}`)
-			await this.restoreMain({ branch: stashBranch, stashSha, force: true })
-			await this.git.branch(["-D", stashBranch]).catch(() => {})
-			throw err
-		}
-
-		/**
-		 * PHASE: Cherry pick
-		 * Mutations:
-		 *   - Hidden commit (NOTE: reset on hidden branch no longer needed in
-		 *     success scenario.)
-		 * Recovery:
-		 *   - UNDO: Create branch
-		 *   - UNDO: Change branch
-		 *   - UNDO: Commit stash
-		 *   - UNDO: Reset hidden branch
-		 */
-		let commit = ""
-
-		try {
-			try {
-				await this.git.raw(["cherry-pick", stashBranch])
-			} catch (err) {
-				// Check if we're in the middle of a cherry-pick.
-				// If the cherry-pick resulted in an empty commit (e.g., only
-				// deletions) then complete it with --allow-empty.
-				// Otherwise, rethrow the error.
-				if (existsSync(path.join(this.workspaceDir, ".git/CHERRY_PICK_HEAD"))) {
-					await this.git.raw(["commit", "--allow-empty", "--no-edit"])
-				} else {
-					throw err
-				}
-			}
-
-			commit = await this.git.revparse(["HEAD"])
-			this.log(`[saveCheckpoint] cherry-pick commit = ${commit}`)
-		} catch (err) {
-			this.log(
-				`[saveCheckpoint] failed in cherry pick phase: ${err instanceof Error ? err.message : String(err)}`,
-			)
-			await this.git.reset(["--hard", latestSha]).catch(() => {})
-			await this.restoreMain({ branch: stashBranch, stashSha, force: true })
-			await this.git.branch(["-D", stashBranch]).catch(() => {})
-			throw err
-		}
-
-		await this.restoreMain({ branch: stashBranch, stashSha })
-		await this.git.branch(["-D", stashBranch])
-
-		// We've gotten reports that checkpoints can be slow in some cases, so
-		// we'll log the duration of the checkpoint save.
-		const duration = Date.now() - startTime
-		this.log(`[saveCheckpoint] saved checkpoint ${commit} in ${duration}ms`)
-
-		return { commit }
-	}
-
-	public async restoreCheckpoint(commitHash: string) {
-		const startTime = Date.now()
-		await this.ensureBranch(this.mainBranch)
-		await this.git.clean([CleanOptions.FORCE, CleanOptions.RECURSIVE])
-		await this.git.raw(["restore", "--source", commitHash, "--worktree", "--", "."])
-		const duration = Date.now() - startTime
-		this.log(`[restoreCheckpoint] restored checkpoint ${commitHash} in ${duration}ms`)
-	}
-
-	public static async create({ taskId, workspaceDir, log = console.log }: LocalCheckpointServiceOptions) {
-		const git = simpleGit(workspaceDir)
-		const version = await git.version()
-
-		if (!version?.installed) {
-			throw new Error(`Git is not installed. Please install Git if you wish to use checkpoints.`)
-		}
-
-		if (!workspaceDir || !existsSync(workspaceDir)) {
-			throw new Error(`Base directory is not set or does not exist.`)
-		}
-
-		const { currentBranch, currentSha, hiddenBranch } = await LocalCheckpointService.initRepo(git, {
-			taskId,
-			workspaceDir,
-			log,
-		})
-
-		log(
-			`[create] taskId = ${taskId}, workspaceDir = ${workspaceDir}, currentBranch = ${currentBranch}, currentSha = ${currentSha}, hiddenBranch = ${hiddenBranch}`,
-		)
-
-		return new LocalCheckpointService(taskId, git, workspaceDir, currentBranch, currentSha, hiddenBranch, log)
-	}
-
-	private static async initRepo(
-		git: SimpleGit,
-		{ taskId, workspaceDir, log }: Required<LocalCheckpointServiceOptions>,
-	) {
-		const isExistingRepo = existsSync(path.join(workspaceDir, ".git"))
-
-		if (!isExistingRepo) {
-			await git.init()
-			log(`[initRepo] Initialized new Git repository at ${workspaceDir}`)
-		}
-
-		const globalUserName = await git.getConfig("user.name", "global")
-		const localUserName = await git.getConfig("user.name", "local")
-		const userName = localUserName.value || globalUserName.value
-
-		const globalUserEmail = await git.getConfig("user.email", "global")
-		const localUserEmail = await git.getConfig("user.email", "local")
-		const userEmail = localUserEmail.value || globalUserEmail.value
-
-		// Prior versions of this service indiscriminately set the local user
-		// config, and it should not override the global config. To address
-		// this we remove the local user config if it matches the default
-		// user name and email and there's a global config.
-		if (globalUserName.value && localUserName.value === LocalCheckpointService.USER_NAME) {
-			await git.raw(["config", "--unset", "--local", "user.name"])
-		}
-
-		if (globalUserEmail.value && localUserEmail.value === LocalCheckpointService.USER_EMAIL) {
-			await git.raw(["config", "--unset", "--local", "user.email"])
-		}
-
-		// Only set user config if not already configured.
-		if (!userName) {
-			await git.addConfig("user.name", LocalCheckpointService.USER_NAME)
-		}
-
-		if (!userEmail) {
-			await git.addConfig("user.email", LocalCheckpointService.USER_EMAIL)
-		}
-
-		if (!isExistingRepo) {
-			// We need at least one file to commit, otherwise the initial
-			// commit will fail, unless we use the `--allow-empty` flag.
-			// However, using an empty commit causes problems when restoring
-			// the checkpoint (i.e. the `git restore` command doesn't work
-			// for empty commits).
-			await fs.writeFile(path.join(workspaceDir, ".gitkeep"), "")
-			await git.add(".gitkeep")
-			const commit = await git.commit("Initial commit")
-
-			if (!commit.commit) {
-				throw new Error("Failed to create initial commit")
-			}
-
-			log(`[initRepo] Initial commit: ${commit.commit}`)
-		}
-
-		const currentBranch = await git.revparse(["--abbrev-ref", "HEAD"])
-		const currentSha = await git.revparse(["HEAD"])
-
-		const hiddenBranch = `${LocalCheckpointService.CHECKPOINT_BRANCH}-${taskId}`
-		const branchSummary = await git.branch()
-
-		if (!branchSummary.all.includes(hiddenBranch)) {
-			await git.checkoutBranch(hiddenBranch, currentBranch)
-			await git.checkout(currentBranch)
-		}
-
-		return { currentBranch, currentSha, hiddenBranch }
-	}
-}

+ 97 - 34
src/services/checkpoints/ShadowCheckpointService.ts

@@ -5,17 +5,18 @@ import { globby } from "globby"
 import simpleGit, { SimpleGit } from "simple-git"
 
 import { GIT_DISABLED_SUFFIX, GIT_EXCLUDES } from "./constants"
-import { CheckpointStrategy, CheckpointService, CheckpointServiceOptions } from "./types"
+import { CheckpointService, CheckpointServiceOptions, CheckpointEventEmitter } from "./types"
 
 export interface ShadowCheckpointServiceOptions extends CheckpointServiceOptions {
 	shadowDir: string
 }
 
-export class ShadowCheckpointService implements CheckpointService {
-	public readonly strategy: CheckpointStrategy = "shadow"
+export class ShadowCheckpointService extends CheckpointEventEmitter implements CheckpointService {
 	public readonly version = 1
 
+	private _checkpoints: string[] = []
 	private _baseHash?: string
+	private _isInitialized = false
 
 	public get baseHash() {
 		return this._baseHash
@@ -25,6 +26,14 @@ export class ShadowCheckpointService implements CheckpointService {
 		this._baseHash = value
 	}
 
+	public get isInitialized() {
+		return this._isInitialized
+	}
+
+	private set isInitialized(value: boolean) {
+		this._isInitialized = value
+	}
+
 	private readonly shadowGitDir: string
 	private shadowGitConfigWorktree?: string
 
@@ -35,18 +44,26 @@ export class ShadowCheckpointService implements CheckpointService {
 		public readonly workspaceDir: string,
 		private readonly log: (message: string) => void,
 	) {
+		super()
 		this.shadowGitDir = path.join(this.shadowDir, "tasks", this.taskId, "checkpoints", ".git")
 	}
 
-	private async initShadowGit() {
+	public async initShadowGit() {
+		if (this.isInitialized) {
+			return
+		}
+
 		const fileExistsAtPath = (path: string) =>
 			fs
 				.access(path)
 				.then(() => true)
 				.catch(() => false)
 
+		let created = false
+		const startTime = Date.now()
+
 		if (await fileExistsAtPath(this.shadowGitDir)) {
-			this.log(`[initShadowGit] shadow git repo already exists at ${this.shadowGitDir}`)
+			this.log(`[CheckpointService#initShadowGit] shadow git repo already exists at ${this.shadowGitDir}`)
 			const worktree = await this.getShadowGitConfigWorktree()
 
 			if (worktree !== this.workspaceDir) {
@@ -57,7 +74,7 @@ export class ShadowCheckpointService implements CheckpointService {
 
 			this.baseHash = await this.git.revparse(["--abbrev-ref", "HEAD"])
 		} else {
-			this.log(`[initShadowGit] creating shadow git repo at ${this.workspaceDir}`)
+			this.log(`[CheckpointService#initShadowGit] creating shadow git repo at ${this.workspaceDir}`)
 
 			await this.git.init()
 			await this.git.addConfig("core.worktree", this.workspaceDir) // Sets the working tree to the current workspace.
@@ -78,7 +95,7 @@ export class ShadowCheckpointService implements CheckpointService {
 				}
 			} catch (error) {
 				this.log(
-					`[initShadowGit] failed to read .gitattributes: ${error instanceof Error ? error.message : String(error)}`,
+					`[CheckpointService#initShadowGit] failed to read .gitattributes: ${error instanceof Error ? error.message : String(error)}`,
 				)
 			}
 
@@ -93,8 +110,23 @@ export class ShadowCheckpointService implements CheckpointService {
 			await this.stageAll()
 			const { commit } = await this.git.commit("initial commit", { "--allow-empty": null })
 			this.baseHash = commit
-			this.log(`[initShadowGit] base commit is ${commit}`)
+			this.log(`[CheckpointService#initShadowGit] base commit is ${commit}`)
+
+			created = true
 		}
+
+		const duration = Date.now() - startTime
+		this.log(`[CheckpointService#initShadowGit] initialized shadow git in ${duration}ms`)
+
+		this.isInitialized = true
+
+		this.emit("initialize", {
+			type: "initialize",
+			workspaceDir: this.workspaceDir,
+			baseHash: this.baseHash,
+			created,
+			duration,
+		})
 	}
 
 	private async stageAll() {
@@ -103,7 +135,9 @@ export class ShadowCheckpointService implements CheckpointService {
 		try {
 			await this.git.add(".")
 		} catch (error) {
-			this.log(`[stageAll] failed to add files to git: ${error instanceof Error ? error.message : String(error)}`)
+			this.log(
+				`[CheckpointService#stageAll] failed to add files to git: ${error instanceof Error ? error.message : String(error)}`,
+			)
 		} finally {
 			await this.renameNestedGitRepos(false)
 		}
@@ -137,10 +171,12 @@ export class ShadowCheckpointService implements CheckpointService {
 
 			try {
 				await fs.rename(fullPath, newPath)
-				this.log(`${disable ? "disabled" : "enabled"} nested git repo ${gitPath}`)
+				this.log(
+					`[CheckpointService#renameNestedGitRepos] ${disable ? "disabled" : "enabled"} nested git repo ${gitPath}`,
+				)
 			} catch (error) {
 				this.log(
-					`failed to ${disable ? "disable" : "enable"} nested git repo ${gitPath}: ${error instanceof Error ? error.message : String(error)}`,
+					`[CheckpointService#renameNestedGitRepos] failed to ${disable ? "disable" : "enable"} nested git repo ${gitPath}: ${error instanceof Error ? error.message : String(error)}`,
 				)
 			}
 		}
@@ -152,7 +188,7 @@ export class ShadowCheckpointService implements CheckpointService {
 				this.shadowGitConfigWorktree = (await this.git.getConfig("core.worktree")).value || undefined
 			} catch (error) {
 				this.log(
-					`[getShadowGitConfigWorktree] failed to get core.worktree: ${error instanceof Error ? error.message : String(error)}`,
+					`[CheckpointService#getShadowGitConfigWorktree] failed to get core.worktree: ${error instanceof Error ? error.message : String(error)}`,
 				)
 			}
 		}
@@ -162,35 +198,63 @@ export class ShadowCheckpointService implements CheckpointService {
 
 	public async saveCheckpoint(message: string) {
 		try {
-			const startTime = Date.now()
-			await this.stageAll()
-			const result = await this.git.commit(message)
+			this.log("[CheckpointService#saveCheckpoint] starting checkpoint save")
 
-			if (result.commit) {
-				const duration = Date.now() - startTime
-				this.log(`[saveCheckpoint] saved checkpoint ${result.commit} in ${duration}ms`)
-				return result
-			} else {
-				return undefined
+			if (!this.isInitialized) {
+				throw new Error("Shadow git repo not initialized")
 			}
-		} catch (error) {
-			this.log(
-				`[saveCheckpoint] failed to create checkpoint: ${error instanceof Error ? error.message : String(error)}`,
-			)
 
+			const startTime = Date.now()
+			await this.stageAll()
+			const result = await this.git.commit(message)
+			const isFirst = this._checkpoints.length === 0
+			const fromHash = this._checkpoints[this._checkpoints.length - 1] ?? this.baseHash!
+			const toHash = result.commit ?? fromHash
+			this._checkpoints.push(toHash)
+			const duration = Date.now() - startTime
+			this.emit("checkpoint", { type: "checkpoint", isFirst, fromHash, toHash, duration })
+			return result.commit ? result : undefined
+		} catch (e) {
+			const error = e instanceof Error ? e : new Error(String(e))
+			this.log(`[CheckpointService#saveCheckpoint] failed to create checkpoint: ${error.message}`)
+			this.emit("error", { type: "error", error })
 			throw error
 		}
 	}
 
 	public async restoreCheckpoint(commitHash: string) {
-		const start = Date.now()
-		await this.git.clean("f", ["-d", "-f"])
-		await this.git.reset(["--hard", commitHash])
-		const duration = Date.now() - start
-		this.log(`[restoreCheckpoint] restored checkpoint ${commitHash} in ${duration}ms`)
+		try {
+			if (!this.isInitialized) {
+				throw new Error("Shadow git repo not initialized")
+			}
+
+			const start = Date.now()
+			await this.git.clean("f", ["-d", "-f"])
+			await this.git.reset(["--hard", commitHash])
+
+			// Remove all checkpoints after the specified commitHash.
+			const checkpointIndex = this._checkpoints.indexOf(commitHash)
+
+			if (checkpointIndex !== -1) {
+				this._checkpoints = this._checkpoints.slice(0, checkpointIndex + 1)
+			}
+
+			const duration = Date.now() - start
+			this.emit("restore", { type: "restore", commitHash, duration })
+			this.log(`[CheckpointService#restoreCheckpoint] restored checkpoint ${commitHash} in ${duration}ms`)
+		} catch (e) {
+			const error = e instanceof Error ? e : new Error(String(e))
+			this.log(`[CheckpointService#restoreCheckpoint] failed to restore checkpoint: ${error.message}`)
+			this.emit("error", { type: "error", error })
+			throw error
+		}
 	}
 
 	public async getDiff({ from, to }: { from?: string; to?: string }) {
+		if (!this.isInitialized) {
+			throw new Error("Shadow git repo not initialized")
+		}
+
 		const result = []
 
 		if (!from) {
@@ -223,6 +287,7 @@ export class ShadowCheckpointService implements CheckpointService {
 		try {
 			await simpleGit().version()
 		} catch (error) {
+			log("[CheckpointService#create] git is not installed")
 			throw new Error("Git must be installed to use checkpoints.")
 		}
 
@@ -241,9 +306,7 @@ export class ShadowCheckpointService implements CheckpointService {
 		const gitDir = path.join(checkpointsDir, ".git")
 		const git = simpleGit(path.dirname(gitDir))
 
-		log(`[create] taskId = ${taskId}, workspaceDir = ${workspaceDir}, shadowDir = ${shadowDir}`)
-		const service = new ShadowCheckpointService(taskId, git, shadowDir, workspaceDir, log)
-		await service.initShadowGit()
-		return service
+		log(`[CheckpointService#create] taskId = ${taskId}, workspaceDir = ${workspaceDir}, shadowDir = ${shadowDir}`)
+		return new ShadowCheckpointService(taskId, git, shadowDir, workspaceDir, log)
 	}
 }

+ 0 - 385
src/services/checkpoints/__tests__/LocalCheckpointService.test.ts

@@ -1,385 +0,0 @@
-// npx jest src/services/checkpoints/__tests__/LocalCheckpointService.test.ts
-
-import fs from "fs/promises"
-import path from "path"
-import os from "os"
-
-import { simpleGit, SimpleGit } from "simple-git"
-
-import { CheckpointServiceFactory } from "../CheckpointServiceFactory"
-import { LocalCheckpointService } from "../LocalCheckpointService"
-
-const tmpDir = path.join(os.tmpdir(), "test-LocalCheckpointService")
-
-describe("LocalCheckpointService", () => {
-	const taskId = "test-task"
-
-	let testFile: string
-	let service: LocalCheckpointService
-
-	const initRepo = async ({
-		workspaceDir,
-		userName = "Roo Code",
-		userEmail = "[email protected]",
-		testFileName = "test.txt",
-		textFileContent = "Hello, world!",
-	}: {
-		workspaceDir: string
-		userName?: string
-		userEmail?: string
-		testFileName?: string
-		textFileContent?: string
-	}) => {
-		// Create a temporary directory for testing.
-		await fs.mkdir(workspaceDir, { recursive: true })
-
-		// Initialize git repo.
-		const git = simpleGit(workspaceDir)
-		await git.init()
-		await git.addConfig("user.name", userName)
-		await git.addConfig("user.email", userEmail)
-
-		// Create test file.
-		const testFile = path.join(workspaceDir, testFileName)
-		await fs.writeFile(testFile, textFileContent)
-
-		// Create initial commit.
-		await git.add(".")
-		await git.commit("Initial commit")!
-
-		return { testFile }
-	}
-
-	beforeEach(async () => {
-		const workspaceDir = path.join(tmpDir, `checkpoint-service-test-${Date.now()}`)
-		const repo = await initRepo({ workspaceDir })
-
-		testFile = repo.testFile
-		service = await CheckpointServiceFactory.create({
-			strategy: "local",
-			options: { taskId, workspaceDir, log: () => {} },
-		})
-	})
-
-	afterEach(async () => {
-		jest.restoreAllMocks()
-	})
-
-	afterAll(async () => {
-		await fs.rm(tmpDir, { recursive: true, force: true })
-	})
-
-	describe("getDiff", () => {
-		it("returns the correct diff between commits", async () => {
-			await fs.writeFile(testFile, "Ahoy, world!")
-			const commit1 = await service.saveCheckpoint("First checkpoint")
-			expect(commit1?.commit).toBeTruthy()
-
-			await fs.writeFile(testFile, "Goodbye, world!")
-			const commit2 = await service.saveCheckpoint("Second checkpoint")
-			expect(commit2?.commit).toBeTruthy()
-
-			const diff1 = await service.getDiff({ to: commit1!.commit })
-			expect(diff1).toHaveLength(1)
-			expect(diff1[0].paths.relative).toBe("test.txt")
-			expect(diff1[0].paths.absolute).toBe(testFile)
-			expect(diff1[0].content.before).toBe("Hello, world!")
-			expect(diff1[0].content.after).toBe("Ahoy, world!")
-
-			const diff2 = await service.getDiff({ to: commit2!.commit })
-			expect(diff2).toHaveLength(1)
-			expect(diff2[0].paths.relative).toBe("test.txt")
-			expect(diff2[0].paths.absolute).toBe(testFile)
-			expect(diff2[0].content.before).toBe("Hello, world!")
-			expect(diff2[0].content.after).toBe("Goodbye, world!")
-
-			const diff12 = await service.getDiff({ from: commit1!.commit, to: commit2!.commit })
-			expect(diff12).toHaveLength(1)
-			expect(diff12[0].paths.relative).toBe("test.txt")
-			expect(diff12[0].paths.absolute).toBe(testFile)
-			expect(diff12[0].content.before).toBe("Ahoy, world!")
-			expect(diff12[0].content.after).toBe("Goodbye, world!")
-		})
-
-		it("handles new files in diff", async () => {
-			const newFile = path.join(service.workspaceDir, "new.txt")
-			await fs.writeFile(newFile, "New file content")
-			const commit = await service.saveCheckpoint("Add new file")
-			expect(commit?.commit).toBeTruthy()
-
-			const changes = await service.getDiff({ to: commit!.commit })
-			const change = changes.find((c) => c.paths.relative === "new.txt")
-			expect(change).toBeDefined()
-			expect(change?.content.before).toBe("")
-			expect(change?.content.after).toBe("New file content")
-		})
-
-		it("handles deleted files in diff", async () => {
-			const fileToDelete = path.join(service.workspaceDir, "new.txt")
-			await fs.writeFile(fileToDelete, "New file content")
-			const commit1 = await service.saveCheckpoint("Add file")
-			expect(commit1?.commit).toBeTruthy()
-
-			await fs.unlink(fileToDelete)
-			const commit2 = await service.saveCheckpoint("Delete file")
-			expect(commit2?.commit).toBeTruthy()
-
-			const changes = await service.getDiff({ from: commit1!.commit, to: commit2!.commit })
-			const change = changes.find((c) => c.paths.relative === "new.txt")
-			expect(change).toBeDefined()
-			expect(change!.content.before).toBe("New file content")
-			expect(change!.content.after).toBe("")
-		})
-	})
-
-	describe("saveCheckpoint", () => {
-		it("creates a checkpoint if there are pending changes", async () => {
-			await fs.writeFile(testFile, "Ahoy, world!")
-			const commit1 = await service.saveCheckpoint("First checkpoint")
-			expect(commit1?.commit).toBeTruthy()
-			const details1 = await service.git.show([commit1!.commit])
-			expect(details1).toContain("-Hello, world!")
-			expect(details1).toContain("+Ahoy, world!")
-
-			await fs.writeFile(testFile, "Hola, world!")
-			const commit2 = await service.saveCheckpoint("Second checkpoint")
-			expect(commit2?.commit).toBeTruthy()
-			const details2 = await service.git.show([commit2!.commit])
-			expect(details2).toContain("-Hello, world!")
-			expect(details2).toContain("+Hola, world!")
-
-			// Switch to checkpoint 1.
-			await service.restoreCheckpoint(commit1!.commit)
-			expect(await fs.readFile(testFile, "utf-8")).toBe("Ahoy, world!")
-
-			// Switch to checkpoint 2.
-			await service.restoreCheckpoint(commit2!.commit)
-			expect(await fs.readFile(testFile, "utf-8")).toBe("Hola, world!")
-
-			// Switch back to initial commit.
-			expect(service.baseHash).toBeTruthy()
-			await service.restoreCheckpoint(service.baseHash!)
-			expect(await fs.readFile(testFile, "utf-8")).toBe("Hello, world!")
-		})
-
-		it("preserves workspace and index state after saving checkpoint", async () => {
-			// Create three files with different states: staged, unstaged, and mixed.
-			const unstagedFile = path.join(service.workspaceDir, "unstaged.txt")
-			const stagedFile = path.join(service.workspaceDir, "staged.txt")
-			const mixedFile = path.join(service.workspaceDir, "mixed.txt")
-
-			await fs.writeFile(unstagedFile, "Initial unstaged")
-			await fs.writeFile(stagedFile, "Initial staged")
-			await fs.writeFile(mixedFile, "Initial mixed")
-			await service.git.add(["."])
-			const result = await service.git.commit("Add initial files")
-			expect(result?.commit).toBeTruthy()
-
-			await fs.writeFile(unstagedFile, "Modified unstaged")
-
-			await fs.writeFile(stagedFile, "Modified staged")
-			await service.git.add([stagedFile])
-
-			await fs.writeFile(mixedFile, "Modified mixed - staged")
-			await service.git.add([mixedFile])
-			await fs.writeFile(mixedFile, "Modified mixed - unstaged")
-
-			// Save checkpoint.
-			const commit = await service.saveCheckpoint("Test checkpoint")
-			expect(commit?.commit).toBeTruthy()
-
-			// Verify workspace state is preserved.
-			const status = await service.git.status()
-
-			// All files should be modified.
-			expect(status.modified).toContain("unstaged.txt")
-			expect(status.modified).toContain("staged.txt")
-			expect(status.modified).toContain("mixed.txt")
-
-			// Only staged and mixed files should be staged.
-			expect(status.staged).not.toContain("unstaged.txt")
-			expect(status.staged).toContain("staged.txt")
-			expect(status.staged).toContain("mixed.txt")
-
-			// Verify file contents.
-			expect(await fs.readFile(unstagedFile, "utf-8")).toBe("Modified unstaged")
-			expect(await fs.readFile(stagedFile, "utf-8")).toBe("Modified staged")
-			expect(await fs.readFile(mixedFile, "utf-8")).toBe("Modified mixed - unstaged")
-
-			// Verify staged changes (--cached shows only staged changes).
-			const stagedDiff = await service.git.diff(["--cached", "mixed.txt"])
-			expect(stagedDiff).toContain("-Initial mixed")
-			expect(stagedDiff).toContain("+Modified mixed - staged")
-
-			// Verify unstaged changes (shows working directory changes).
-			const unstagedDiff = await service.git.diff(["mixed.txt"])
-			expect(unstagedDiff).toContain("-Modified mixed - staged")
-			expect(unstagedDiff).toContain("+Modified mixed - unstaged")
-		})
-
-		it("does not create a checkpoint if there are no pending changes", async () => {
-			const commit0 = await service.saveCheckpoint("Zeroth checkpoint")
-			expect(commit0?.commit).toBeFalsy()
-
-			await fs.writeFile(testFile, "Ahoy, world!")
-			const commit1 = await service.saveCheckpoint("First checkpoint")
-			expect(commit1?.commit).toBeTruthy()
-
-			const commit2 = await service.saveCheckpoint("Second checkpoint")
-			expect(commit2?.commit).toBeFalsy()
-		})
-
-		it("includes untracked files in checkpoints", async () => {
-			// Create an untracked file.
-			const untrackedFile = path.join(service.workspaceDir, "untracked.txt")
-			await fs.writeFile(untrackedFile, "I am untracked!")
-
-			// Save a checkpoint with the untracked file.
-			const commit1 = await service.saveCheckpoint("Checkpoint with untracked file")
-			expect(commit1?.commit).toBeTruthy()
-
-			// Verify the untracked file was included in the checkpoint.
-			const details = await service.git.show([commit1!.commit])
-			expect(details).toContain("+I am untracked!")
-
-			// Create another checkpoint with a different state.
-			await fs.writeFile(testFile, "Changed tracked file")
-			const commit2 = await service.saveCheckpoint("Second checkpoint")
-			expect(commit2?.commit).toBeTruthy()
-
-			// Restore first checkpoint and verify untracked file is preserved.
-			await service.restoreCheckpoint(commit1!.commit)
-			expect(await fs.readFile(untrackedFile, "utf-8")).toBe("I am untracked!")
-			expect(await fs.readFile(testFile, "utf-8")).toBe("Hello, world!")
-
-			// Restore second checkpoint and verify untracked file remains (since
-			// restore preserves untracked files)
-			await service.restoreCheckpoint(commit2!.commit)
-			expect(await fs.readFile(untrackedFile, "utf-8")).toBe("I am untracked!")
-			expect(await fs.readFile(testFile, "utf-8")).toBe("Changed tracked file")
-		})
-
-		it("throws if we're on the wrong branch", async () => {
-			// Create and switch to a feature branch.
-			const currentBranch = await service.git.revparse(["--abbrev-ref", "HEAD"])
-			await service.git.checkoutBranch("feature", currentBranch)
-
-			// Attempt to save checkpoint from feature branch.
-			await expect(service.saveCheckpoint("test")).rejects.toThrow(
-				`Git branch mismatch: expected '${currentBranch}' but found 'feature'`,
-			)
-
-			// Attempt to restore checkpoint from feature branch.
-			expect(service.baseHash).toBeTruthy()
-
-			await expect(service.restoreCheckpoint(service.baseHash!)).rejects.toThrow(
-				`Git branch mismatch: expected '${currentBranch}' but found 'feature'`,
-			)
-		})
-
-		it("cleans up staged files if a commit fails", async () => {
-			await fs.writeFile(testFile, "Changed content")
-
-			// Mock git commit to simulate failure.
-			jest.spyOn(service.git, "commit").mockRejectedValue(new Error("Simulated commit failure"))
-
-			// Attempt to save checkpoint.
-			await expect(service.saveCheckpoint("test")).rejects.toThrow("Simulated commit failure")
-
-			// Verify files are unstaged.
-			const status = await service.git.status()
-			expect(status.staged).toHaveLength(0)
-		})
-
-		it("handles file deletions correctly", async () => {
-			await fs.writeFile(testFile, "I am tracked!")
-			const untrackedFile = path.join(service.workspaceDir, "new.txt")
-			await fs.writeFile(untrackedFile, "I am untracked!")
-			const commit1 = await service.saveCheckpoint("First checkpoint")
-			expect(commit1?.commit).toBeTruthy()
-
-			await fs.unlink(testFile)
-			await fs.unlink(untrackedFile)
-			const commit2 = await service.saveCheckpoint("Second checkpoint")
-			expect(commit2?.commit).toBeTruthy()
-
-			// Verify files are gone.
-			await expect(fs.readFile(testFile, "utf-8")).rejects.toThrow()
-			await expect(fs.readFile(untrackedFile, "utf-8")).rejects.toThrow()
-
-			// Restore first checkpoint.
-			await service.restoreCheckpoint(commit1!.commit)
-			expect(await fs.readFile(testFile, "utf-8")).toBe("I am tracked!")
-			expect(await fs.readFile(untrackedFile, "utf-8")).toBe("I am untracked!")
-
-			// Restore second checkpoint.
-			await service.restoreCheckpoint(commit2!.commit)
-			await expect(fs.readFile(testFile, "utf-8")).rejects.toThrow()
-			await expect(fs.readFile(untrackedFile, "utf-8")).rejects.toThrow()
-		})
-	})
-
-	describe("create", () => {
-		it("initializes a git repository if one does not already exist", async () => {
-			const workspaceDir = path.join(tmpDir, `checkpoint-service-test2-${Date.now()}`)
-			await fs.mkdir(workspaceDir)
-			const newTestFile = path.join(workspaceDir, "test.txt")
-			await fs.writeFile(newTestFile, "Hello, world!")
-
-			// Ensure the git repository was initialized.
-			const gitDir = path.join(workspaceDir, ".git")
-			await expect(fs.stat(gitDir)).rejects.toThrow()
-			const newService = await LocalCheckpointService.create({ taskId, workspaceDir, log: () => {} })
-			expect(await fs.stat(gitDir)).toBeTruthy()
-
-			// Save a checkpoint: Hello, world!
-			const commit1 = await newService.saveCheckpoint("Hello, world!")
-			expect(commit1?.commit).toBeTruthy()
-			expect(await fs.readFile(newTestFile, "utf-8")).toBe("Hello, world!")
-
-			// Restore initial commit; the file should no longer exist.
-			expect(newService.baseHash).toBeTruthy()
-			await newService.restoreCheckpoint(newService.baseHash!)
-			await expect(fs.access(newTestFile)).rejects.toThrow()
-
-			// Restore to checkpoint 1; the file should now exist.
-			await newService.restoreCheckpoint(commit1!.commit)
-			expect(await fs.readFile(newTestFile, "utf-8")).toBe("Hello, world!")
-
-			// Save a new checkpoint: Ahoy, world!
-			await fs.writeFile(newTestFile, "Ahoy, world!")
-			const commit2 = await newService.saveCheckpoint("Ahoy, world!")
-			expect(commit2?.commit).toBeTruthy()
-			expect(await fs.readFile(newTestFile, "utf-8")).toBe("Ahoy, world!")
-
-			// Restore "Hello, world!"
-			await newService.restoreCheckpoint(commit1!.commit)
-			expect(await fs.readFile(newTestFile, "utf-8")).toBe("Hello, world!")
-
-			// Restore "Ahoy, world!"
-			await newService.restoreCheckpoint(commit2!.commit)
-			expect(await fs.readFile(newTestFile, "utf-8")).toBe("Ahoy, world!")
-
-			// Restore initial commit.
-			expect(newService.baseHash).toBeTruthy()
-			await newService.restoreCheckpoint(newService.baseHash!)
-			await expect(fs.access(newTestFile)).rejects.toThrow()
-
-			await fs.rm(newService.workspaceDir, { recursive: true, force: true })
-		})
-
-		it("respects existing git user configuration", async () => {
-			const workspaceDir = path.join(tmpDir, `checkpoint-service-test-config2-${Date.now()}`)
-			const userName = "Custom User"
-			const userEmail = "[email protected]"
-			await initRepo({ workspaceDir, userName, userEmail })
-
-			const newService = await LocalCheckpointService.create({ taskId, workspaceDir, log: () => {} })
-
-			expect((await newService.git.getConfig("user.name")).value).toBe(userName)
-			expect((await newService.git.getConfig("user.email")).value).toBe(userEmail)
-
-			await fs.rm(workspaceDir, { recursive: true, force: true })
-		})
-	})
-})

+ 169 - 7
src/services/checkpoints/__tests__/ShadowCheckpointService.test.ts

@@ -3,11 +3,11 @@
 import fs from "fs/promises"
 import path from "path"
 import os from "os"
+import { EventEmitter } from "events"
 
 import { simpleGit, SimpleGit } from "simple-git"
 
 import { ShadowCheckpointService } from "../ShadowCheckpointService"
-import { CheckpointServiceFactory } from "../CheckpointServiceFactory"
 
 jest.mock("globby", () => ({
 	globby: jest.fn().mockResolvedValue([]),
@@ -63,13 +63,10 @@ describe("ShadowCheckpointService", () => {
 		const repo = await initRepo({ workspaceDir })
 
 		testFile = repo.testFile
-
-		service = await CheckpointServiceFactory.create({
-			strategy: "shadow",
-			options: { taskId, shadowDir, workspaceDir, log: () => {} },
-		})
-
 		workspaceGit = repo.git
+
+		service = await ShadowCheckpointService.create({ taskId, shadowDir, workspaceDir, log: () => {} })
+		await service.initShadowGit()
 	})
 
 	afterEach(async () => {
@@ -313,6 +310,7 @@ describe("ShadowCheckpointService", () => {
 			const gitDir = path.join(shadowDir, "tasks", taskId, "checkpoints", ".git")
 			await expect(fs.stat(gitDir)).rejects.toThrow()
 			const newService = await ShadowCheckpointService.create({ taskId, shadowDir, workspaceDir, log: () => {} })
+			await newService.initShadowGit()
 			expect(await fs.stat(gitDir)).toBeTruthy()
 
 			// Save a new checkpoint: Ahoy, world!
@@ -333,4 +331,168 @@ describe("ShadowCheckpointService", () => {
 			await fs.rm(newService.workspaceDir, { recursive: true, force: true })
 		})
 	})
+
+	describe("events", () => {
+		it("emits initialize event when service is created", async () => {
+			const shadowDir = path.join(tmpDir, `shadow-event-test-${Date.now()}`)
+			const workspaceDir = path.join(tmpDir, `workspace-event-test-${Date.now()}`)
+			await fs.mkdir(workspaceDir, { recursive: true })
+
+			const newTestFile = path.join(workspaceDir, "test.txt")
+			await fs.writeFile(newTestFile, "Testing events!")
+
+			// Create a mock implementation of emit to track events.
+			const emitSpy = jest.spyOn(EventEmitter.prototype, "emit")
+
+			// Create the service - this will trigger the initialize event.
+			const newService = await ShadowCheckpointService.create({ taskId, shadowDir, workspaceDir, log: () => {} })
+			await newService.initShadowGit()
+
+			// Find the initialize event in the emit calls.
+			let initializeEvent = null
+
+			for (let i = 0; i < emitSpy.mock.calls.length; i++) {
+				const call = emitSpy.mock.calls[i]
+
+				if (call[0] === "initialize") {
+					initializeEvent = call[1]
+					break
+				}
+			}
+
+			// Restore the spy.
+			emitSpy.mockRestore()
+
+			// Verify the event was emitted with the correct data.
+			expect(initializeEvent).not.toBeNull()
+			expect(initializeEvent.type).toBe("initialize")
+			expect(initializeEvent.workspaceDir).toBe(workspaceDir)
+			expect(initializeEvent.baseHash).toBeTruthy()
+			expect(typeof initializeEvent.created).toBe("boolean")
+			expect(typeof initializeEvent.duration).toBe("number")
+
+			// Verify the event was emitted with the correct data.
+			expect(initializeEvent).not.toBeNull()
+			expect(initializeEvent.type).toBe("initialize")
+			expect(initializeEvent.workspaceDir).toBe(workspaceDir)
+			expect(initializeEvent.baseHash).toBeTruthy()
+			expect(typeof initializeEvent.created).toBe("boolean")
+			expect(typeof initializeEvent.duration).toBe("number")
+
+			// Clean up.
+			await fs.rm(shadowDir, { recursive: true, force: true })
+			await fs.rm(workspaceDir, { recursive: true, force: true })
+		})
+
+		it("emits checkpoint event when saving checkpoint", async () => {
+			const checkpointHandler = jest.fn()
+			service.on("checkpoint", checkpointHandler)
+
+			await fs.writeFile(testFile, "Changed content for checkpoint event test")
+			const result = await service.saveCheckpoint("Test checkpoint event")
+			expect(result?.commit).toBeDefined()
+
+			expect(checkpointHandler).toHaveBeenCalledTimes(1)
+			const eventData = checkpointHandler.mock.calls[0][0]
+			expect(eventData.type).toBe("checkpoint")
+			expect(eventData.toHash).toBeDefined()
+			expect(eventData.toHash).toBe(result!.commit)
+			expect(typeof eventData.duration).toBe("number")
+		})
+
+		it("emits restore event when restoring checkpoint", async () => {
+			// First create a checkpoint to restore.
+			await fs.writeFile(testFile, "Content for restore test")
+			const commit = await service.saveCheckpoint("Checkpoint for restore test")
+			expect(commit?.commit).toBeTruthy()
+
+			// Change the file again.
+			await fs.writeFile(testFile, "Changed after checkpoint")
+
+			// Setup restore event listener.
+			const restoreHandler = jest.fn()
+			service.on("restore", restoreHandler)
+
+			// Restore the checkpoint.
+			await service.restoreCheckpoint(commit!.commit)
+
+			// Verify the event was emitted.
+			expect(restoreHandler).toHaveBeenCalledTimes(1)
+			const eventData = restoreHandler.mock.calls[0][0]
+			expect(eventData.type).toBe("restore")
+			expect(eventData.commitHash).toBe(commit!.commit)
+			expect(typeof eventData.duration).toBe("number")
+
+			// Verify the file was actually restored.
+			expect(await fs.readFile(testFile, "utf-8")).toBe("Content for restore test")
+		})
+
+		it("emits error event when an error occurs", async () => {
+			const errorHandler = jest.fn()
+			service.on("error", errorHandler)
+
+			// Force an error by providing an invalid commit hash.
+			const invalidCommitHash = "invalid-commit-hash"
+
+			// Try to restore an invalid checkpoint.
+			try {
+				await service.restoreCheckpoint(invalidCommitHash)
+			} catch (error) {
+				// Expected to throw, we're testing the event emission.
+			}
+
+			// Verify the error event was emitted.
+			expect(errorHandler).toHaveBeenCalledTimes(1)
+			const eventData = errorHandler.mock.calls[0][0]
+			expect(eventData.type).toBe("error")
+			expect(eventData.error).toBeInstanceOf(Error)
+		})
+
+		it("supports multiple event listeners for the same event", async () => {
+			const checkpointHandler1 = jest.fn()
+			const checkpointHandler2 = jest.fn()
+
+			service.on("checkpoint", checkpointHandler1)
+			service.on("checkpoint", checkpointHandler2)
+
+			await fs.writeFile(testFile, "Content for multiple listeners test")
+			const result = await service.saveCheckpoint("Testing multiple listeners")
+
+			// Verify both handlers were called with the same event data.
+			expect(checkpointHandler1).toHaveBeenCalledTimes(1)
+			expect(checkpointHandler2).toHaveBeenCalledTimes(1)
+
+			const eventData1 = checkpointHandler1.mock.calls[0][0]
+			const eventData2 = checkpointHandler2.mock.calls[0][0]
+
+			expect(eventData1).toEqual(eventData2)
+			expect(eventData1.type).toBe("checkpoint")
+			expect(eventData1.toHash).toBe(result?.commit)
+		})
+
+		it("allows removing event listeners", async () => {
+			const checkpointHandler = jest.fn()
+
+			// Add the listener.
+			service.on("checkpoint", checkpointHandler)
+
+			// Make a change and save a checkpoint.
+			await fs.writeFile(testFile, "Content for remove listener test - part 1")
+			await service.saveCheckpoint("Testing listener - part 1")
+
+			// Verify handler was called.
+			expect(checkpointHandler).toHaveBeenCalledTimes(1)
+			checkpointHandler.mockClear()
+
+			// Remove the listener.
+			service.off("checkpoint", checkpointHandler)
+
+			// Make another change and save a checkpoint.
+			await fs.writeFile(testFile, "Content for remove listener test - part 2")
+			await service.saveCheckpoint("Testing listener - part 2")
+
+			// Verify handler was not called after being removed.
+			expect(checkpointHandler).not.toHaveBeenCalled()
+		})
+	})
 })

+ 0 - 2
src/services/checkpoints/index.ts

@@ -1,2 +0,0 @@
-export * from "./types"
-export * from "./CheckpointServiceFactory"

+ 36 - 3
src/services/checkpoints/types.ts

@@ -1,3 +1,4 @@
+import EventEmitter from "events"
 import { CommitResult } from "simple-git"
 
 export type CheckpointResult = Partial<CommitResult> & Pick<CommitResult, "commit">
@@ -13,15 +14,12 @@ export type CheckpointDiff = {
 	}
 }
 
-export type CheckpointStrategy = "local" | "shadow"
-
 export interface CheckpointService {
 	saveCheckpoint(message: string): Promise<CheckpointResult | undefined>
 	restoreCheckpoint(commit: string): Promise<void>
 	getDiff(range: { from?: string; to?: string }): Promise<CheckpointDiff[]>
 	workspaceDir: string
 	baseHash?: string
-	strategy: CheckpointStrategy
 	version: number
 }
 
@@ -30,3 +28,38 @@ export interface CheckpointServiceOptions {
 	workspaceDir: string
 	log?: (message: string) => void
 }
+
+/**
+ * EventEmitter
+ */
+
+export interface CheckpointEventMap {
+	initialize: { type: "initialize"; workspaceDir: string; baseHash: string; created: boolean; duration: number }
+	checkpoint: {
+		type: "checkpoint"
+		isFirst: boolean
+		fromHash: string
+		toHash: string
+		duration: number
+	}
+	restore: { type: "restore"; commitHash: string; duration: number }
+	error: { type: "error"; error: Error }
+}
+
+export class CheckpointEventEmitter extends EventEmitter {
+	override emit<K extends keyof CheckpointEventMap>(event: K, data: CheckpointEventMap[K]): boolean {
+		return super.emit(event, data)
+	}
+
+	override on<K extends keyof CheckpointEventMap>(event: K, listener: (data: CheckpointEventMap[K]) => void): this {
+		return super.on(event, listener)
+	}
+
+	override off<K extends keyof CheckpointEventMap>(event: K, listener: (data: CheckpointEventMap[K]) => void): this {
+		return super.off(event, listener)
+	}
+
+	override once<K extends keyof CheckpointEventMap>(event: K, listener: (data: CheckpointEventMap[K]) => void): this {
+		return super.once(event, listener)
+	}
+}

+ 2 - 0
src/shared/ExtensionMessage.ts

@@ -48,6 +48,7 @@ export interface ExtensionMessage {
 		| "showHumanRelayDialog"
 		| "humanRelayResponse"
 		| "humanRelayCancel"
+		| "browserToolEnabled"
 	text?: string
 	action?:
 		| "chatButtonClicked"
@@ -106,6 +107,7 @@ export interface ExtensionState {
 	alwaysAllowMcp?: boolean
 	alwaysApproveResubmit?: boolean
 	alwaysAllowModeSwitch?: boolean
+	browserToolEnabled?: boolean
 	requestDelaySeconds: number
 	rateLimitSeconds: number // Minimum time between successive requests (0 = disabled)
 	uriScheme?: string

+ 1 - 0
src/shared/WebviewMessage.ts

@@ -96,6 +96,7 @@ export interface WebviewMessage {
 		| "maxOpenTabsContext"
 		| "humanRelayResponse"
 		| "humanRelayCancel"
+		| "browserToolEnabled"
 	text?: string
 	disabled?: boolean
 	askResponse?: ClineAskResponse

+ 325 - 0
src/shared/__tests__/context-mentions.test.ts

@@ -0,0 +1,325 @@
+import { mentionRegex, mentionRegexGlobal } from "../context-mentions"
+
+interface TestResult {
+	actual: string | null
+	expected: string | null
+}
+
+function testMention(input: string, expected: string | null): TestResult {
+	const match = mentionRegex.exec(input)
+	return {
+		actual: match ? match[0] : null,
+		expected,
+	}
+}
+
+function expectMatch(result: TestResult) {
+	if (result.expected === null) {
+		return expect(result.actual).toBeNull()
+	}
+	if (result.actual !== result.expected) {
+		// Instead of console.log, use expect().toBe() with a descriptive message
+		expect(result.actual).toBe(result.expected)
+	}
+}
+
+describe("Mention Regex", () => {
+	describe("Windows Path Support", () => {
+		it("matches simple Windows paths", () => {
+			const cases: Array<[string, string]> = [
+				["@C:\\folder\\file.txt", "@C:\\folder\\file.txt"],
+				["@c:\\Program/ Files\\file.txt", "@c:\\Program/ Files\\file.txt"],
+				["@C:\\file.txt", "@C:\\file.txt"],
+			]
+
+			cases.forEach(([input, expected]) => {
+				const result = testMention(input, expected)
+				expectMatch(result)
+			})
+		})
+
+		it("matches Windows network shares", () => {
+			const cases: Array<[string, string]> = [
+				["@\\\\server\\share\\file.txt", "@\\\\server\\share\\file.txt"],
+				["@\\\\127.0.0.1\\network-path\\file.txt", "@\\\\127.0.0.1\\network-path\\file.txt"],
+			]
+
+			cases.forEach(([input, expected]) => {
+				const result = testMention(input, expected)
+				expectMatch(result)
+			})
+		})
+
+		it("matches mixed separators", () => {
+			const result = testMention("@C:\\folder\\file.txt", "@C:\\folder\\file.txt")
+			expectMatch(result)
+		})
+
+		it("matches Windows relative paths", () => {
+			const cases: Array<[string, string]> = [
+				["@folder\\file.txt", "@folder\\file.txt"],
+				["@.\\folder\\file.txt", "@.\\folder\\file.txt"],
+				["@..\\parent\\file.txt", "@..\\parent\\file.txt"],
+				["@path\\to\\directory\\", "@path\\to\\directory\\"],
+				["@.\\current\\path\\with/ space.txt", "@.\\current\\path\\with/ space.txt"],
+			]
+
+			cases.forEach(([input, expected]) => {
+				const result = testMention(input, expected)
+				expectMatch(result)
+			})
+		})
+	})
+
+	describe("Escaped Spaces Support", () => {
+		it("matches Unix paths with escaped spaces", () => {
+			const cases: Array<[string, string]> = [
+				["@/path/to/file\\ with\\ spaces.txt", "@/path/to/file\\ with\\ spaces.txt"],
+				["@/path/with\\ \\ multiple\\ spaces.txt", "@/path/with\\ \\ multiple\\ spaces.txt"],
+			]
+
+			cases.forEach(([input, expected]) => {
+				const result = testMention(input, expected)
+				expectMatch(result)
+			})
+		})
+
+		it("matches Windows paths with escaped spaces", () => {
+			const cases: Array<[string, string]> = [
+				["@C:\\path\\to\\file/ with/ spaces.txt", "@C:\\path\\to\\file/ with/ spaces.txt"],
+				["@C:\\Program/ Files\\app\\file.txt", "@C:\\Program/ Files\\app\\file.txt"],
+			]
+
+			cases.forEach(([input, expected]) => {
+				const result = testMention(input, expected)
+				expectMatch(result)
+			})
+		})
+	})
+
+	describe("Combined Path Variations", () => {
+		it("matches complex path combinations", () => {
+			const cases: Array<[string, string]> = [
+				[
+					"@C:\\Users\\name\\Documents\\file/ with/ spaces.txt",
+					"@C:\\Users\\name\\Documents\\file/ with/ spaces.txt",
+				],
+				[
+					"@\\\\server\\share\\path/ with/ spaces\\file.txt",
+					"@\\\\server\\share\\path/ with/ spaces\\file.txt",
+				],
+				["@C:\\path/ with/ spaces\\file.txt", "@C:\\path/ with/ spaces\\file.txt"],
+			]
+
+			cases.forEach(([input, expected]) => {
+				const result = testMention(input, expected)
+				expectMatch(result)
+			})
+		})
+	})
+
+	describe("Edge Cases", () => {
+		it("handles edge cases correctly", () => {
+			const cases: Array<[string, string]> = [
+				["@C:\\", "@C:\\"],
+				["@/path/to/folder", "@/path/to/folder"],
+				["@C:\\folder\\file with spaces.txt", "@C:\\folder\\file"],
+				["@C:\\Users\\name\\path\\to\\文件夹\\file.txt", "@C:\\Users\\name\\path\\to\\文件夹\\file.txt"],
+				["@/path123/file-name_2.0.txt", "@/path123/file-name_2.0.txt"],
+			]
+
+			cases.forEach(([input, expected]) => {
+				const result = testMention(input, expected)
+				expectMatch(result)
+			})
+		})
+	})
+
+	describe("Existing Functionality", () => {
+		it("matches Unix paths", () => {
+			const cases: Array<[string, string]> = [
+				["@/usr/local/bin/file", "@/usr/local/bin/file"],
+				["@/path/to/file.txt", "@/path/to/file.txt"],
+			]
+
+			cases.forEach(([input, expected]) => {
+				const result = testMention(input, expected)
+				expectMatch(result)
+			})
+		})
+
+		it("matches URLs", () => {
+			const cases: Array<[string, string]> = [
+				["@http://example.com", "@http://example.com"],
+				["@https://example.com/path/to/file.html", "@https://example.com/path/to/file.html"],
+				["@ftp://server.example.com/file.zip", "@ftp://server.example.com/file.zip"],
+			]
+
+			cases.forEach(([input, expected]) => {
+				const result = testMention(input, expected)
+				expectMatch(result)
+			})
+		})
+
+		it("matches git hashes", () => {
+			const cases: Array<[string, string]> = [
+				["@a1b2c3d4e5f6g7h8i9j0", "@a1b2c3d4e5f6g7h8i9j0"],
+				["@abcdef1234567890abcdef1234567890abcdef12", "@abcdef1234567890abcdef1234567890abcdef12"],
+			]
+
+			cases.forEach(([input, expected]) => {
+				const result = testMention(input, expected)
+				expectMatch(result)
+			})
+		})
+
+		it("matches special keywords", () => {
+			const cases: Array<[string, string]> = [
+				["@problems", "@problems"],
+				["@git-changes", "@git-changes"],
+				["@terminal", "@terminal"],
+			]
+
+			cases.forEach(([input, expected]) => {
+				const result = testMention(input, expected)
+				expectMatch(result)
+			})
+		})
+	})
+
+	describe("Invalid Patterns", () => {
+		it("rejects invalid patterns", () => {
+			const cases: Array<[string, null]> = [
+				["C:\\folder\\file.txt", null],
+				["@", null],
+				["@ C:\\file.txt", null],
+			]
+
+			cases.forEach(([input, expected]) => {
+				const result = testMention(input, expected)
+				expectMatch(result)
+			})
+		})
+
+		it("matches only until invalid characters", () => {
+			const result = testMention("@C:\\folder\\file.txt invalid suffix", "@C:\\folder\\file.txt")
+			expectMatch(result)
+		})
+	})
+
+	describe("In Context", () => {
+		it("matches mentions within text", () => {
+			const cases: Array<[string, string]> = [
+				["Check the file at @C:\\folder\\file.txt for details.", "@C:\\folder\\file.txt"],
+				["See @/path/to/file\\ with\\ spaces.txt for an example.", "@/path/to/file\\ with\\ spaces.txt"],
+				["Review @problems and @git-changes.", "@problems"],
+				["Multiple: @/file1.txt and @C:\\file2.txt and @terminal", "@/file1.txt"],
+			]
+
+			cases.forEach(([input, expected]) => {
+				const result = testMention(input, expected)
+				expectMatch(result)
+			})
+		})
+	})
+
+	describe("Multiple Mentions", () => {
+		it("finds all mentions in a string using global regex", () => {
+			const text = "Check @/path/file1.txt and @C:\\folder\\file2.txt and report any @problems to @git-changes"
+			const matches = text.match(mentionRegexGlobal)
+			expect(matches).toEqual(["@/path/file1.txt", "@C:\\folder\\file2.txt", "@problems", "@git-changes"])
+		})
+	})
+
+	describe("Special Characters in Paths", () => {
+		it("handles special characters in file paths", () => {
+			const cases: Array<[string, string]> = [
+				["@/path/with-dash/file_underscore.txt", "@/path/with-dash/file_underscore.txt"],
+				["@C:\\folder+plus\\file(parens)[]brackets.txt", "@C:\\folder+plus\\file(parens)[]brackets.txt"],
+				["@/path/with/file#hash%percent.txt", "@/path/with/file#hash%percent.txt"],
+				["@/path/with/file@symbol$dollar.txt", "@/path/with/file@symbol$dollar.txt"],
+			]
+
+			cases.forEach(([input, expected]) => {
+				const result = testMention(input, expected)
+				expectMatch(result)
+			})
+		})
+	})
+
+	describe("Mixed Path Types in Single String", () => {
+		it("correctly identifies the first path in a string with multiple path types", () => {
+			const text = "Check both @/unix/path and @C:\\windows\\path for details."
+			const result = mentionRegex.exec(text)
+			expect(result?.[0]).toBe("@/unix/path")
+
+			// Test starting from after the first match
+			const secondSearchStart = text.indexOf("@C:")
+			const secondResult = mentionRegex.exec(text.substring(secondSearchStart))
+			expect(secondResult?.[0]).toBe("@C:\\windows\\path")
+		})
+	})
+
+	describe("Non-Latin Character Support", () => {
+		it("handles international characters in paths", () => {
+			const cases: Array<[string, string]> = [
+				["@/path/to/你好/file.txt", "@/path/to/你好/file.txt"],
+				["@C:\\用户\\документы\\файл.txt", "@C:\\用户\\документы\\файл.txt"],
+				["@/путь/к/файлу.txt", "@/путь/к/файлу.txt"],
+				["@C:\\folder\\file_äöü.txt", "@C:\\folder\\file_äöü.txt"],
+			]
+
+			cases.forEach(([input, expected]) => {
+				const result = testMention(input, expected)
+				expectMatch(result)
+			})
+		})
+	})
+
+	describe("Mixed Path Delimiters", () => {
+		// Modifying expectations to match current behavior
+		it("documents behavior with mixed forward and backward slashes in Windows paths", () => {
+			const cases: Array<[string, null]> = [
+				// Current implementation doesn't support mixed slashes
+				["@C:\\Users/Documents\\folder/file.txt", null],
+				["@C:/Windows\\System32/drivers\\etc/hosts", null],
+			]
+
+			cases.forEach(([input, expected]) => {
+				const result = testMention(input, expected)
+				expectMatch(result)
+			})
+		})
+	})
+
+	describe("Extended Negative Tests", () => {
+		// Modifying expectations to match current behavior
+		it("documents behavior with potentially invalid characters", () => {
+			const cases: Array<[string, string]> = [
+				// Current implementation actually matches these patterns
+				["@/path/with<illegal>chars.txt", "@/path/with<illegal>chars.txt"],
+				["@C:\\folder\\file|with|pipe.txt", "@C:\\folder\\file|with|pipe.txt"],
+				['@/path/with"quotes".txt', '@/path/with"quotes".txt'],
+			]
+
+			cases.forEach(([input, expected]) => {
+				const result = testMention(input, expected)
+				expectMatch(result)
+			})
+		})
+	})
+
+	// // These are documented as "not implemented yet"
+	// describe("Future Enhancement Candidates", () => {
+	// 	it("identifies patterns that could be supported in future enhancements", () => {
+	// 		// These patterns aren't currently supported by the regex
+	// 		// but might be considered for future improvements
+	// 		console.log(
+	// 			"The following patterns are not currently supported but might be considered for future enhancements:",
+	// 		)
+	// 		console.log("- Paths with double slashes: @/path//with/double/slash.txt")
+	// 		console.log("- Complex path traversals: @/very/./long/../../path/.././traversal.txt")
+	// 		console.log("- Environment variables in paths: @$HOME/file.txt, @C:\\Users\\%USERNAME%\\file.txt")
+	// 	})
+	// })
+})

+ 1 - 1
src/shared/api.ts

@@ -100,7 +100,7 @@ export type AnthropicModelId = keyof typeof anthropicModels
 export const anthropicDefaultModelId: AnthropicModelId = "claude-3-7-sonnet-20250219"
 export const anthropicModels = {
 	"claude-3-7-sonnet-20250219:thinking": {
-		maxTokens: 64_000,
+		maxTokens: 128_000,
 		contextWindow: 200_000,
 		supportsImages: true,
 		supportsComputerUse: true,

+ 82 - 49
src/shared/context-mentions.ts

@@ -1,57 +1,90 @@
 /*
-Mention regex:
-- **Purpose**: 
-  - To identify and highlight specific mentions in text that start with '@'. 
-  - These mentions can be file paths, URLs, or the exact word 'problems'.
-  - Ensures that trailing punctuation marks (like commas, periods, etc.) are not included in the match, allowing punctuation to follow the mention without being part of it.
-
 - **Regex Breakdown**:
-  - `/@`: 
-	- **@**: The mention must start with the '@' symbol.
-  
-  - `((?:\/|\w+:\/\/)[^\s]+?|problems\b|git-changes\b)`:
-	- **Capturing Group (`(...)`)**: Captures the part of the string that matches one of the specified patterns.
-	- `(?:\/|\w+:\/\/)`: 
-	  - **Non-Capturing Group (`(?:...)`)**: Groups the alternatives without capturing them for back-referencing.
-	  - `\/`: 
-		- **Slash (`/`)**: Indicates that the mention is a file or folder path starting with a '/'.
-	  - `|`: Logical OR.
-	  - `\w+:\/\/`: 
-		- **Protocol (`\w+://`)**: Matches URLs that start with a word character sequence followed by '://', such as 'http://', 'https://', 'ftp://', etc.
-	- `[^\s]+?`: 
-	  - **Non-Whitespace Characters (`[^\s]+`)**: Matches one or more characters that are not whitespace.
-	  - **Non-Greedy (`+?`)**: Ensures the smallest possible match, preventing the inclusion of trailing punctuation.
-	- `|`: Logical OR.
-	- `problems\b`: 
-	  - **Exact Word ('problems')**: Matches the exact word 'problems'.
-	  - **Word Boundary (`\b`)**: Ensures that 'problems' is matched as a whole word and not as part of another word (e.g., 'problematic').
-		- `|`: Logical OR.
-    - `terminal\b`:
-      - **Exact Word ('terminal')**: Matches the exact word 'terminal'.
-      - **Word Boundary (`\b`)**: Ensures that 'terminal' is matched as a whole word and not as part of another word (e.g., 'terminals').
-  - `(?=[.,;:!?]?(?=[\s\r\n]|$))`:
-	- **Positive Lookahead (`(?=...)`)**: Ensures that the match is followed by specific patterns without including them in the match.
-	- `[.,;:!?]?`: 
-	  - **Optional Punctuation (`[.,;:!?]?`)**: Matches zero or one of the specified punctuation marks.
-	- `(?=[\s\r\n]|$)`: 
-	  - **Nested Positive Lookahead (`(?=[\s\r\n]|$)`)**: Ensures that the punctuation (if present) is followed by a whitespace character, a line break, or the end of the string.
-  
-- **Summary**:
-  - The regex effectively matches:
-	- Mentions that are file or folder paths starting with '/' and containing any non-whitespace characters (including periods within the path).
-	- URLs that start with a protocol (like 'http://') followed by any non-whitespace characters (including query parameters).
-	- The exact word 'problems'.
-	- The exact word 'git-changes'.
-    - The exact word 'terminal'.
-  - It ensures that any trailing punctuation marks (such as ',', '.', '!', etc.) are not included in the matched mention, allowing the punctuation to follow the mention naturally in the text.
 
-- **Global Regex**:
-  - `mentionRegexGlobal`: Creates a global version of the `mentionRegex` to find all matches within a given string.
+  1. **Pattern Components**:
+     - The regex is built from multiple patterns joined with OR (|) operators
+     - Each pattern handles a specific type of mention:
+       - Unix/Linux paths
+       - Windows paths with drive letters
+       - Windows relative paths
+       - Windows network shares
+       - URLs with protocols
+       - Git commit hashes
+       - Special keywords (problems, git-changes, terminal)
+
+  2. **Unix Path Pattern**:
+     - `(?:\\/|^)`: Starts with a forward slash or beginning of line
+     - `(?:[^\\/\\s\\\\]|\\\\[ \\t])+`: Path segment that can include escaped spaces
+     - `(?:\\/(?:[^\\/\\s\\\\]|\\\\[ \\t])+)*`: Additional path segments after slashes
+     - `\\/?`: Optional trailing slash
+
+  3. **Windows Path Pattern**:
+     - `[A-Za-z]:\\\\`: Drive letter followed by colon and double backslash
+     - `(?:(?:[^\\\\\\s/]+|\\/[ ])+`: Path segment that can include spaces escaped with forward slash
+     - `(?:\\\\(?:[^\\\\\\s/]+|\\/[ ])+)*)?`: Additional path segments after backslashes
+
+  4. **Windows Relative Path Pattern**:
+     - `(?:\\.{0,2}|[^\\\\\\s/]+)`: Path prefix that can be:
+       - Current directory (.)
+       - Parent directory (..)
+       - Any directory name not containing spaces, backslashes, or forward slashes
+     - `\\\\`: Backslash separator
+     - `(?:[^\\\\\\s/]+|\\\\[ \\t]|\\/[ ])+`: Path segment that can include spaces escaped with backslash or forward slash
+     - `(?:\\\\(?:[^\\\\\\s/]+|\\\\[ \\t]|\\/[ ])+)*`: Additional path segments after backslashes
+     - `\\\\?`: Optional trailing backslash
+
+  5. **Network Share Pattern**:
+     - `\\\\\\\\`: Double backslash (escaped) to start network path
+     - `[^\\\\\\s]+`: Server name
+     - `(?:\\\\(?:[^\\\\\\s/]+|\\/[ ])+)*`: Share name and additional path components
+     - `(?:\\\\)?`: Optional trailing backslash
 
+  6. **URL Pattern**:
+     - `\\w+:\/\/`: Protocol (http://, https://, etc.)
+     - `[^\\s]+`: Rest of the URL (non-whitespace characters)
+
+  7. **Git Hash Pattern**:
+     - `[a-zA-Z0-9]{7,40}\\b`: 7-40 alphanumeric characters followed by word boundary
+
+  8. **Special Keywords Pattern**:
+     - `problems\\b`, `git-changes\\b`, `terminal\\b`: Exact word matches with word boundaries
+
+  9. **Termination Logic**:
+     - `(?=[.,;:!?]?(?=[\\s\\r\\n]|$))`: Positive lookahead that:
+       - Allows an optional punctuation mark after the mention
+       - Ensures the mention (and optional punctuation) is followed by whitespace or end of string
+
+- **Behavior Summary**:
+  - Matches @-prefixed mentions
+  - Handles different path formats across operating systems
+  - Supports escaped spaces in paths using OS-appropriate conventions
+  - Cleanly terminates at whitespace or end of string
+  - Excludes trailing punctuation from the match
+  - Creates both single-match and global-match regex objects
 */
-export const mentionRegex =
-	/@((?:\/|\w+:\/\/)[^\s]+?|[a-f0-9]{7,40}\b|problems\b|git-changes\b|terminal\b)(?=[.,;:!?]?(?=[\s\r\n]|$))/
-export const mentionRegexGlobal = new RegExp(mentionRegex.source, "g")
+
+const mentionPatterns = [
+	// Unix paths with escaped spaces using backslash
+	"(?:\\/|^)(?:[^\\/\\s\\\\]|\\\\[ \\t])+(?:\\/(?:[^\\/\\s\\\\]|\\\\[ \\t])+)*\\/?",
+	// Windows paths with drive letters (C:\path) with support for escaped spaces using forward slash
+	"[A-Za-z]:\\\\(?:(?:[^\\\\\\s/]+|\\/[ ])+(?:\\\\(?:[^\\\\\\s/]+|\\/[ ])+)*)?",
+	// Windows relative paths (folder\file or .\folder\file) with support for escaped spaces
+	"(?:\\.{0,2}|[^\\\\\\s/]+)\\\\(?:[^\\\\\\s/]+|\\\\[ \\t]|\\/[ ])+(?:\\\\(?:[^\\\\\\s/]+|\\\\[ \\t]|\\/[ ])+)*\\\\?",
+	// Windows network shares (\\server\share) with support for escaped spaces using forward slash
+	"\\\\\\\\[^\\\\\\s]+(?:\\\\(?:[^\\\\\\s/]+|\\/[ ])+)*(?:\\\\)?",
+	// URLs with protocols (http://, https://, etc.)
+	"\\w+:\/\/[^\\s]+",
+	// Git hashes (7-40 alphanumeric characters)
+	"[a-zA-Z0-9]{7,40}\\b",
+	// Special keywords
+	"problems\\b",
+	"git-changes\\b",
+	"terminal\\b",
+]
+// Build the full regex pattern by joining the patterns with OR operator
+const mentionRegexPattern = `@(${mentionPatterns.join("|")})(?=[.,;:!?]?(?=[\\s\\r\\n]|$))`
+export const mentionRegex = new RegExp(mentionRegexPattern)
+export const mentionRegexGlobal = new RegExp(mentionRegexPattern, "g")
 
 export interface MentionSuggestion {
 	type: "file" | "folder" | "git" | "problems"

+ 1 - 0
src/shared/globalState.ts

@@ -84,3 +84,4 @@ export type GlobalStateKey =
 	| "anthropicThinking" // TODO: Rename to `modelMaxThinkingTokens`.
 	| "mistralCodestralUrl"
 	| "maxOpenTabsContext"
+	| "browserToolEnabled" // Setting to enable/disable the browser tool

+ 69 - 12
webview-ui/src/components/chat/ChatView.tsx

@@ -1,4 +1,4 @@
-import { VSCodeButton } from "@vscode/webview-ui-toolkit/react"
+import { VSCodeButton, VSCodeLink } from "@vscode/webview-ui-toolkit/react"
 import debounce from "debounce"
 import { useCallback, useEffect, useMemo, useRef, useState } from "react"
 import { useDeepCompareEffect, useEvent, useMount } from "react-use"
@@ -88,6 +88,7 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
 	const [isAtBottom, setIsAtBottom] = useState(false)
 
 	const [wasStreaming, setWasStreaming] = useState<boolean>(false)
+	const [showCheckpointWarning, setShowCheckpointWarning] = useState<boolean>(false)
 
 	// UI layout depends on the last 2 messages
 	// (since it relies on the content of these messages, we are deep comparing. i.e. the button state after hitting button sets enableButtons to false, and this effect otherwise would have to true again even if messages didn't change
@@ -882,6 +883,48 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
 	}, [])
 	useEvent("wheel", handleWheel, window, { passive: true }) // passive improves scrolling performance
 
+	// Effect to handle showing the checkpoint warning after a delay
+	useEffect(() => {
+		// Only show the warning when there's a task but no visible messages yet
+		if (task && modifiedMessages.length === 0 && !isStreaming) {
+			const timer = setTimeout(() => {
+				setShowCheckpointWarning(true)
+			}, 5000) // 5 seconds
+
+			return () => clearTimeout(timer)
+		}
+	}, [task, modifiedMessages.length, isStreaming])
+
+	// Effect to hide the checkpoint warning when messages appear
+	useEffect(() => {
+		if (modifiedMessages.length > 0 || isStreaming) {
+			setShowCheckpointWarning(false)
+		}
+	}, [modifiedMessages.length, isStreaming])
+
+	// Checkpoint warning component
+	const CheckpointWarningMessage = useCallback(
+		() => (
+			<div className="flex items-center p-3 my-3 bg-vscode-inputValidation-warningBackground border border-vscode-inputValidation-warningBorder rounded">
+				<span className="codicon codicon-loading codicon-modifier-spin mr-2" />
+				<span className="text-vscode-foreground">
+					Still initializing checkpoint... If this takes too long, you can{" "}
+					<VSCodeLink
+						href="#"
+						onClick={(e) => {
+							e.preventDefault()
+							window.postMessage({ type: "action", action: "settingsButtonClicked" }, "*")
+						}}
+						className="inline px-0.5">
+						disable checkpoints in settings
+					</VSCodeLink>{" "}
+					and restart your task.
+				</span>
+			</div>
+		),
+		[],
+	)
+
 	const placeholderText = useMemo(() => {
 		const baseText = task ? "Type a message..." : "Type your task here..."
 		const contextText = "(@ to add context, / to switch modes"
@@ -973,7 +1016,12 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
 		const allModes = getAllModes(customModes)
 		const currentModeIndex = allModes.findIndex((m) => m.slug === mode)
 		const nextModeIndex = (currentModeIndex + 1) % allModes.length
+		// Update local state and notify extension to sync mode change
 		setMode(allModes[nextModeIndex].slug)
+		vscode.postMessage({
+			type: "mode",
+			text: allModes[nextModeIndex].slug,
+		})
 	}, [mode, setMode, customModes])
 
 	// Add keyboard event handler
@@ -1009,17 +1057,26 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
 				overflow: "hidden",
 			}}>
 			{task ? (
-				<TaskHeader
-					task={task}
-					tokensIn={apiMetrics.totalTokensIn}
-					tokensOut={apiMetrics.totalTokensOut}
-					doesModelSupportPromptCache={selectedModelInfo.supportsPromptCache}
-					cacheWrites={apiMetrics.totalCacheWrites}
-					cacheReads={apiMetrics.totalCacheReads}
-					totalCost={apiMetrics.totalCost}
-					contextTokens={apiMetrics.contextTokens}
-					onClose={handleTaskCloseButtonClick}
-				/>
+				<>
+					<TaskHeader
+						task={task}
+						tokensIn={apiMetrics.totalTokensIn}
+						tokensOut={apiMetrics.totalTokensOut}
+						doesModelSupportPromptCache={selectedModelInfo.supportsPromptCache}
+						cacheWrites={apiMetrics.totalCacheWrites}
+						cacheReads={apiMetrics.totalCacheReads}
+						totalCost={apiMetrics.totalCost}
+						contextTokens={apiMetrics.contextTokens}
+						onClose={handleTaskCloseButtonClick}
+					/>
+
+					{/* Checkpoint warning message */}
+					{showCheckpointWarning && (
+						<div className="px-3">
+							<CheckpointWarningMessage />
+						</div>
+					)}
+				</>
 			) : (
 				<div
 					style={{

+ 1 - 3
webview-ui/src/components/chat/checkpoints/CheckpointSaved.tsx

@@ -3,8 +3,6 @@ import { useMemo } from "react"
 import { CheckpointMenu } from "./CheckpointMenu"
 import { checkpointSchema } from "./schema"
 
-const REQUIRED_VERSION = 1
-
 type CheckpointSavedProps = {
 	ts: number
 	commitHash: string
@@ -22,7 +20,7 @@ export const CheckpointSaved = ({ checkpoint, ...props }: CheckpointSavedProps)
 
 		const result = checkpointSchema.safeParse(checkpoint)
 
-		if (!result.success || result.data.version < REQUIRED_VERSION) {
+		if (!result.success) {
 			return undefined
 		}
 

+ 0 - 2
webview-ui/src/components/chat/checkpoints/schema.ts

@@ -4,8 +4,6 @@ export const checkpointSchema = z.object({
 	isFirst: z.boolean(),
 	from: z.string(),
 	to: z.string(),
-	strategy: z.enum(["local", "shadow"]),
-	version: z.number(),
 })
 
 export type Checkpoint = z.infer<typeof checkpointSchema>

+ 2 - 2
webview-ui/src/components/settings/ApiOptions.tsx

@@ -1210,8 +1210,8 @@ const ApiOptions = ({
 								color: "var(--vscode-errorForeground)",
 								fontWeight: 500,
 							}}>
-							Note: This is a very experimental integration and may not work as expected. Please report
-							any issues to the Roo-Code GitHub repository.
+							Note: This is a very experimental integration and provider support will vary. If you get an
+							error about a model not being supported, that's an issue on the provider's end.
 						</p>
 					</div>
 				</div>

+ 74 - 48
webview-ui/src/components/settings/SettingsView.tsx

@@ -51,6 +51,7 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone },
 		alwaysAllowModeSwitch,
 		alwaysAllowWrite,
 		alwaysApproveResubmit,
+		browserToolEnabled,
 		browserViewportSize,
 		enableCheckpoints,
 		diffEnabled,
@@ -140,6 +141,7 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone },
 			vscode.postMessage({ type: "alwaysAllowBrowser", bool: alwaysAllowBrowser })
 			vscode.postMessage({ type: "alwaysAllowMcp", bool: alwaysAllowMcp })
 			vscode.postMessage({ type: "allowedCommands", commands: allowedCommands ?? [] })
+			vscode.postMessage({ type: "browserToolEnabled", bool: browserToolEnabled })
 			vscode.postMessage({ type: "soundEnabled", bool: soundEnabled })
 			vscode.postMessage({ type: "soundVolume", value: soundVolume })
 			vscode.postMessage({ type: "diffEnabled", bool: diffEnabled })
@@ -537,59 +539,83 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone },
 				<div style={{ marginBottom: 40 }}>
 					<h3 style={{ color: "var(--vscode-foreground)", margin: "0 0 15px 0" }}>Browser Settings</h3>
 					<div style={{ marginBottom: 15 }}>
-						<label style={{ fontWeight: "500", display: "block", marginBottom: 5 }}>Viewport size</label>
-						<div className="dropdown-container">
-							<Dropdown
-								value={browserViewportSize}
-								onChange={(value: unknown) => {
-									setCachedStateField("browserViewportSize", (value as DropdownOption).value)
-								}}
-								style={{ width: "100%" }}
-								options={[
-									{ value: "1280x800", label: "Large Desktop (1280x800)" },
-									{ value: "900x600", label: "Small Desktop (900x600)" },
-									{ value: "768x1024", label: "Tablet (768x1024)" },
-									{ value: "360x640", label: "Mobile (360x640)" },
-								]}
-							/>
-						</div>
-						<p
-							style={{
-								fontSize: "12px",
-								marginTop: "5px",
-								color: "var(--vscode-descriptionForeground)",
-							}}>
-							Select the viewport size for browser interactions. This affects how websites are displayed
-							and interacted with.
+						<VSCodeCheckbox
+							checked={browserToolEnabled}
+							onChange={(e: any) => setCachedStateField("browserToolEnabled", e.target.checked)}>
+							<span style={{ fontWeight: "500" }}>Enable browser tool</span>
+						</VSCodeCheckbox>
+						<p style={{ fontSize: "12px", marginTop: "5px", color: "var(--vscode-descriptionForeground)" }}>
+							When enabled, Roo can use a browser to interact with websites when using models that support
+							computer use.
 						</p>
 					</div>
+					{browserToolEnabled && (
+						<div
+							style={{
+								marginLeft: 0,
+								paddingLeft: 10,
+								borderLeft: "2px solid var(--vscode-button-background)",
+							}}>
+							<div style={{ marginBottom: 15 }}>
+								<label style={{ fontWeight: "500", display: "block", marginBottom: 5 }}>
+									Viewport size
+								</label>
+								<div className="dropdown-container">
+									<Dropdown
+										value={browserViewportSize}
+										onChange={(value: unknown) => {
+											setCachedStateField("browserViewportSize", (value as DropdownOption).value)
+										}}
+										style={{ width: "100%" }}
+										options={[
+											{ value: "1280x800", label: "Large Desktop (1280x800)" },
+											{ value: "900x600", label: "Small Desktop (900x600)" },
+											{ value: "768x1024", label: "Tablet (768x1024)" },
+											{ value: "360x640", label: "Mobile (360x640)" },
+										]}
+									/>
+								</div>
+								<p
+									style={{
+										fontSize: "12px",
+										marginTop: "5px",
+										color: "var(--vscode-descriptionForeground)",
+									}}>
+									Select the viewport size for browser interactions. This affects how websites are
+									displayed and interacted with.
+								</p>
+							</div>
 
-					<div style={{ marginBottom: 15 }}>
-						<div style={{ display: "flex", flexDirection: "column", gap: "5px" }}>
-							<span style={{ fontWeight: "500" }}>Screenshot quality</span>
-							<div style={{ display: "flex", alignItems: "center", gap: "5px" }}>
-								<input
-									type="range"
-									min="1"
-									max="100"
-									step="1"
-									value={screenshotQuality ?? 75}
-									className="h-2 focus:outline-0 w-4/5 accent-vscode-button-background"
-									onChange={(e) => setCachedStateField("screenshotQuality", parseInt(e.target.value))}
-								/>
-								<span style={{ ...sliderLabelStyle }}>{screenshotQuality ?? 75}%</span>
+							<div style={{ marginBottom: 15 }}>
+								<div style={{ display: "flex", flexDirection: "column", gap: "5px" }}>
+									<span style={{ fontWeight: "500" }}>Screenshot quality</span>
+									<div style={{ display: "flex", alignItems: "center", gap: "5px" }}>
+										<input
+											type="range"
+											min="1"
+											max="100"
+											step="1"
+											value={screenshotQuality ?? 75}
+											className="h-2 focus:outline-0 w-4/5 accent-vscode-button-background"
+											onChange={(e) =>
+												setCachedStateField("screenshotQuality", parseInt(e.target.value))
+											}
+										/>
+										<span style={{ ...sliderLabelStyle }}>{screenshotQuality ?? 75}%</span>
+									</div>
+								</div>
+								<p
+									style={{
+										fontSize: "12px",
+										marginTop: "5px",
+										color: "var(--vscode-descriptionForeground)",
+									}}>
+									Adjust the WebP quality of browser screenshots. Higher values provide clearer
+									screenshots but increase token usage.
+								</p>
 							</div>
 						</div>
-						<p
-							style={{
-								fontSize: "12px",
-								marginTop: "5px",
-								color: "var(--vscode-descriptionForeground)",
-							}}>
-							Adjust the WebP quality of browser screenshots. Higher values provide clearer screenshots
-							but increase token usage.
-						</p>
-					</div>
+					)}
 				</div>
 
 				<div style={{ marginBottom: 40 }}>

+ 3 - 0
webview-ui/src/context/ExtensionStateContext.tsx

@@ -27,6 +27,7 @@ export interface ExtensionStateContextType extends ExtensionState {
 	setAlwaysAllowBrowser: (value: boolean) => void
 	setAlwaysAllowMcp: (value: boolean) => void
 	setAlwaysAllowModeSwitch: (value: boolean) => void
+	setBrowserToolEnabled: (value: boolean) => void
 	setShowAnnouncement: (value: boolean) => void
 	setAllowedCommands: (value: string[]) => void
 	setSoundEnabled: (value: boolean) => void
@@ -128,6 +129,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
 		customModes: [],
 		maxOpenTabsContext: 20,
 		cwd: "",
+		browserToolEnabled: true,
 	})
 
 	const [didHydrateState, setDidHydrateState] = useState(false)
@@ -265,6 +267,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
 		setAutoApprovalEnabled: (value) => setState((prevState) => ({ ...prevState, autoApprovalEnabled: value })),
 		setCustomModes: (value) => setState((prevState) => ({ ...prevState, customModes: value })),
 		setMaxOpenTabsContext: (value) => setState((prevState) => ({ ...prevState, maxOpenTabsContext: value })),
+		setBrowserToolEnabled: (value) => setState((prevState) => ({ ...prevState, browserToolEnabled: value })),
 	}
 
 	return <ExtensionStateContext.Provider value={contextValue}>{children}</ExtensionStateContext.Provider>