Compare commits

...

5 Commits

20 changed files with 398 additions and 103 deletions
@@ -0,0 +1,10 @@
/**
* Error thrown when a PreToolUse hook requests cancellation.
* This signals to the tool handler that execution should be aborted.
*/
export class PreToolUseHookCancellationError extends Error {
constructor(message: string = "PreToolUse hook requested cancellation") {
super(message)
this.name = "PreToolUseHookCancellationError"
}
}
+50
View File
@@ -79,6 +79,12 @@ export async function executeHook<Name extends keyof Hooks>(options: HookExecuti
}
hookMessageTs = await say("hook", JSON.stringify(hookMetadata))
// Reorder messages immediately so hook UI appears above tool UI
// This must happen right after creating the hook message, before the hook runs
if (hookName === "PreToolUse") {
await reorderHookAndToolMessages(messageStateHandler)
}
// Track active hook execution for cancellation (only if cancellable and message was created)
if (isCancellable && hookMessageTs !== undefined && setActiveHookExecution) {
await setActiveHookExecution({
@@ -224,3 +230,47 @@ async function updateHookMessage(
})
}
}
/**
* Reorders hook and tool messages so hook UI appears before tool UI.
* This is called immediately after a hook message is created.
*
* The algorithm:
* 1. Find the most recent tool message (ask or say with type "tool")
* 2. Find any hook messages that came after it
* 3. Delete the tool message
* 4. Re-add the tool message at the end (after hook messages)
*/
async function reorderHookAndToolMessages(messageStateHandler: MessageStateHandler): Promise<void> {
const { findLastIndex } = await import("@shared/array")
const clineMessages = messageStateHandler.getClineMessages()
// Find the most recent tool message
const lastToolMessageIndex = findLastIndex(clineMessages, (m: ClineMessage) => m.ask === "tool" || m.say === "tool")
if (lastToolMessageIndex === -1) {
return // No tool message found, nothing to reorder
}
// Check if there are any hook messages after the tool message
let hasHookMessagesAfterTool = false
for (let i = lastToolMessageIndex + 1; i < clineMessages.length; i++) {
if (clineMessages[i].say === "hook" || clineMessages[i].say === "hook_output") {
hasHookMessagesAfterTool = true
break
}
}
if (!hasHookMessagesAfterTool) {
return // No reordering needed
}
// Store the tool message (deep copy to preserve all properties)
const toolMessage = { ...clineMessages[lastToolMessageIndex] }
// Delete the tool message at its current position
await messageStateHandler.deleteClineMessage(lastToolMessageIndex)
// Re-add the tool message at the end (after hook messages)
await messageStateHandler.addToClineMessages(toolMessage)
}
+2 -6
View File
@@ -1,6 +1,7 @@
import { Anthropic } from "@anthropic-ai/sdk"
import { AssistantMessageContent } from "@core/assistant-message"
import { ClineAskResponse } from "@shared/WebviewMessage"
import type { HookExecution } from "./types/HookExecution"
export class TaskState {
// Streaming flags
@@ -62,12 +63,7 @@ export class TaskState {
abandoned = false
// Hook execution tracking for cancellation
activeHookExecution?: {
hookName: string
toolName?: string
messageTs: number
abortController: AbortController
}
activeHookExecution?: HookExecution
// Auto-context summarization
currentlySummarizing: boolean = false
+11 -97
View File
@@ -174,6 +174,9 @@ export class ToolExecutor {
shouldAutoApproveToolWithPath: this.shouldAutoApproveToolWithPath.bind(this),
applyLatestBrowserSettings: this.applyLatestBrowserSettings.bind(this),
switchToActMode: this.switchToActMode,
setActiveHookExecution: this.setActiveHookExecution,
clearActiveHookExecution: this.clearActiveHookExecution,
getActiveHookExecution: this.getActiveHookExecution,
},
coordinator: this.coordinator,
}
@@ -528,14 +531,15 @@ export class ToolExecutor {
* Handle complete block execution.
*
* This is the main execution flow for a tool:
* 1. Run PreToolUse hooks (if enabled) - can block execution
* 2. Execute the actual tool
* 3. Run PostToolUse hooks (if enabled) - cannot block, only observe
* 4. Add hook context modifications to the conversation
* 5. Update focus chain tracking
* 1. Execute the actual tool (tool handlers now run PreToolUse hooks post-approval)
* 2. Run PostToolUse hooks (if enabled) - cannot block, only observe
* 3. Add hook context modifications to the conversation
* 4. Update focus chain tracking
*
* Note: PreToolUse hooks are now executed by individual tool handlers after approval
* and before the actual tool operation. This provides better UX as approval dialogs
* appear immediately without hook execution delay.
*
* Hooks are executed with streaming output to provide real-time feedback.
* PreToolUse hooks can prevent tool execution by returning shouldContinue: false.
* PostToolUse hooks are for observation/logging only and cannot block.
*
* @param block The complete tool use block with all parameters
@@ -553,96 +557,6 @@ export class ToolExecutor {
// Track if we need to cancel after hooks complete
let shouldCancelAfterHook = false
// ============================================================
// PHASE 1: Run PreToolUse hook (OUTSIDE try-catch-finally)
// This allows early return on cancellation without triggering finally block
// ============================================================
if (hooksEnabled) {
const { executeHook } = await import("../hooks/hook-executor")
// Build pending tool info for display
const pendingToolInfo: any = {
tool: block.name,
}
// Add relevant parameters for display based on tool type
if (block.params.path) {
pendingToolInfo.path = block.params.path
}
if (block.params.command) {
pendingToolInfo.command = block.params.command
}
if (block.params.content && typeof block.params.content === "string") {
pendingToolInfo.content = block.params.content.slice(0, 200)
}
if (block.params.diff && typeof block.params.diff === "string") {
pendingToolInfo.diff = block.params.diff.slice(0, 200)
}
if (block.params.regex) {
pendingToolInfo.regex = block.params.regex
}
if (block.params.url) {
pendingToolInfo.url = block.params.url
}
// For MCP operations, show tool/resource identifiers
if (block.params.tool_name) {
pendingToolInfo.mcpTool = block.params.tool_name
}
if (block.params.server_name) {
pendingToolInfo.mcpServer = block.params.server_name
}
if (block.params.uri) {
pendingToolInfo.resourceUri = block.params.uri
}
const preToolResult = await executeHook({
hookName: "PreToolUse",
hookInput: {
preToolUse: {
toolName: block.name,
parameters: block.params,
},
},
isCancellable: true,
say: this.say,
setActiveHookExecution: this.setActiveHookExecution,
clearActiveHookExecution: this.clearActiveHookExecution,
messageStateHandler: this.messageStateHandler,
taskId: this.taskId,
hooksEnabled,
toolName: block.name,
pendingToolInfo,
})
// Handle cancellation from hook
if (preToolResult.cancel === true) {
// Trigger task cancellation (same as clicking cancel button)
await config.callbacks.cancelTask()
// Early return - never enters try-catch-finally, so PostToolUse won't run
return
}
// If task was aborted (e.g., via cancel button during hook), stop execution
if (this.taskState.abort) {
shouldCancelAfterHook = true
}
// Add context modification to the conversation if provided by the hook
if (preToolResult.contextModification) {
this.addHookContextToConversation(preToolResult.contextModification, "PreToolUse")
}
}
// ============================================================
// PHASE 2: Execute tool with PostToolUse hook in finally block
// This only runs if PreToolUse didn't cancel above
// ============================================================
// Check abort again before tool execution (could have been set by PreToolUse hook)
if (this.taskState.abort) {
return
}
let executionSuccess = true
let toolResult: any = null
let toolWasExecuted = false
@@ -126,6 +126,18 @@ export class AccessMcpResourceHandler implements IFullyManagedTool {
}
}
// Run PreToolUse hook after approval but before execution
try {
const { ToolHookUtils } = await import("../utils/ToolHookUtils")
await ToolHookUtils.runPreToolUseIfEnabled(config, block)
} catch (error) {
const { PreToolUseHookCancellationError } = await import("@core/hooks/PreToolUseHookCancellationError")
if (error instanceof PreToolUseHookCancellationError) {
return formatResponse.toolDenied()
}
throw error
}
await config.callbacks.say("mcp_server_request_started")
// Execute the MCP resource access
@@ -269,6 +269,19 @@ export class ApplyPatchHandler implements IFullyManagedTool {
this.appliedCommit = commit
this.config = config
// Run PreToolUse hook before applying changes
try {
const { ToolHookUtils } = await import("../utils/ToolHookUtils")
await ToolHookUtils.runPreToolUseIfEnabled(config, block)
} catch (error) {
const { PreToolUseHookCancellationError } = await import("@core/hooks/PreToolUseHookCancellationError")
if (error instanceof PreToolUseHookCancellationError) {
await provider.reset()
return "The user denied this patch operation."
}
throw error
}
// Apply the commit
const applyResults = await this.applyCommit(commit)
@@ -52,6 +52,18 @@ export class AttemptCompletionHandler implements IToolHandler, IPartialBlockHand
config.taskState.consecutiveMistakeCount = 0
// Run PreToolUse hook before execution
try {
const { ToolHookUtils } = await import("../utils/ToolHookUtils")
await ToolHookUtils.runPreToolUseIfEnabled(config, block)
} catch (error) {
const { PreToolUseHookCancellationError } = await import("@core/hooks/PreToolUseHookCancellationError")
if (error instanceof PreToolUseHookCancellationError) {
return formatResponse.toolDenied()
}
throw error
}
// Show notification if enabled
if (config.autoApprovalSettings.enableNotifications) {
showSystemNotification({
@@ -105,6 +105,18 @@ export class BrowserToolHandler implements IFullyManagedTool {
}
}
// Run PreToolUse hook after approval but before execution
try {
const { ToolHookUtils } = await import("../utils/ToolHookUtils")
await ToolHookUtils.runPreToolUseIfEnabled(config, block)
} catch (error) {
const { PreToolUseHookCancellationError } = await import("@core/hooks/PreToolUseHookCancellationError")
if (error instanceof PreToolUseHookCancellationError) {
return formatResponse.toolDenied()
}
throw error
}
// Start loading spinner
await config.callbacks.say("browser_action_result", "")
@@ -205,6 +205,18 @@ export class ExecuteCommandToolHandler implements IFullyManagedTool {
)
}
// Run PreToolUse hook after approval but before execution
try {
const { ToolHookUtils } = await import("../utils/ToolHookUtils")
await ToolHookUtils.runPreToolUseIfEnabled(config, block)
} catch (error) {
const { PreToolUseHookCancellationError } = await import("@core/hooks/PreToolUseHookCancellationError")
if (error instanceof PreToolUseHookCancellationError) {
return formatResponse.toolDenied()
}
throw error
}
// Setup timeout notification for long-running auto-approved commands
let timeoutId: NodeJS.Timeout | undefined
if (didAutoApprove && config.autoApprovalSettings.enableNotifications) {
@@ -134,6 +134,18 @@ export class ListCodeDefinitionNamesToolHandler implements IFullyManagedTool {
}
}
// Run PreToolUse hook after approval but before execution
try {
const { ToolHookUtils } = await import("../utils/ToolHookUtils")
await ToolHookUtils.runPreToolUseIfEnabled(config, block)
} catch (error) {
const { PreToolUseHookCancellationError } = await import("@core/hooks/PreToolUseHookCancellationError")
if (error instanceof PreToolUseHookCancellationError) {
return formatResponse.toolDenied()
}
throw error
}
return result
}
}
@@ -84,6 +84,13 @@ export class ListFilesToolHandler implements IFullyManagedTool {
resolutionMethod: (typeof pathResult !== "string" ? "hint" : "primary_fallback") as "hint" | "primary_fallback",
}
// Check clineignore access
const accessValidation = this.validator.checkClineIgnorePath(relDirPath!)
if (!accessValidation.ok) {
await config.callbacks.say("clineignore_error", relDirPath)
return formatResponse.toolError(formatResponse.clineIgnoreError(relDirPath!))
}
// Execute the actual list files operation
const [files, didHitLimit] = await listFiles(absolutePath, recursive, 200)
@@ -151,6 +158,18 @@ export class ListFilesToolHandler implements IFullyManagedTool {
}
}
// Run PreToolUse hook after approval but before execution
try {
const { ToolHookUtils } = await import("../utils/ToolHookUtils")
await ToolHookUtils.runPreToolUseIfEnabled(config, block)
} catch (error) {
const { PreToolUseHookCancellationError } = await import("@core/hooks/PreToolUseHookCancellationError")
if (error instanceof PreToolUseHookCancellationError) {
return formatResponse.toolDenied()
}
throw error
}
return result
}
}
@@ -149,6 +149,18 @@ export class ReadFileToolHandler implements IFullyManagedTool {
}
}
// Run PreToolUse hook after approval but before execution
try {
const { ToolHookUtils } = await import("../utils/ToolHookUtils")
await ToolHookUtils.runPreToolUseIfEnabled(config, block)
} catch (error) {
const { PreToolUseHookCancellationError } = await import("@core/hooks/PreToolUseHookCancellationError")
if (error instanceof PreToolUseHookCancellationError) {
return formatResponse.toolDenied()
}
throw error
}
// Execute the actual file read operation
const supportsImages = config.api.getModel().info.supportsImages ?? false
const fileContent = await extractFileContent(absolutePath, supportsImages)
@@ -357,6 +357,18 @@ export class SearchFilesToolHandler implements IFullyManagedTool {
}
}
// Run PreToolUse hook after approval but before execution
try {
const { ToolHookUtils } = await import("../utils/ToolHookUtils")
await ToolHookUtils.runPreToolUseIfEnabled(config, block)
} catch (error) {
const { PreToolUseHookCancellationError } = await import("@core/hooks/PreToolUseHookCancellationError")
if (error instanceof PreToolUseHookCancellationError) {
return formatResponse.toolDenied()
}
throw error
}
return results
}
}
@@ -141,6 +141,18 @@ export class UseMcpToolHandler implements IFullyManagedTool {
}
}
// Run PreToolUse hook after approval but before execution
try {
const { ToolHookUtils } = await import("../utils/ToolHookUtils")
await ToolHookUtils.runPreToolUseIfEnabled(config, block)
} catch (error) {
const { PreToolUseHookCancellationError } = await import("@core/hooks/PreToolUseHookCancellationError")
if (error instanceof PreToolUseHookCancellationError) {
return formatResponse.toolDenied()
}
throw error
}
// Show MCP request started message
await config.callbacks.say("mcp_server_request_started")
@@ -109,6 +109,18 @@ export class WebFetchToolHandler implements IFullyManagedTool {
}
}
// Run PreToolUse hook after approval but before execution
try {
const { ToolHookUtils } = await import("../utils/ToolHookUtils")
await ToolHookUtils.runPreToolUseIfEnabled(config, block)
} catch (error) {
const { PreToolUseHookCancellationError } = await import("@core/hooks/PreToolUseHookCancellationError")
if (error instanceof PreToolUseHookCancellationError) {
return formatResponse.toolDenied()
}
throw error
}
// Execute the actual fetch
const urlContentFetcher = config.services?.urlContentFetcher as UrlContentFetcher
@@ -270,6 +270,20 @@ export class WriteToFileToolHandler implements IFullyManagedTool {
}
}
// Run PreToolUse hook after approval but before execution
try {
const { ToolHookUtils } = await import("../utils/ToolHookUtils")
await ToolHookUtils.runPreToolUseIfEnabled(config, block)
} catch (error) {
const { PreToolUseHookCancellationError } = await import("@core/hooks/PreToolUseHookCancellationError")
if (error instanceof PreToolUseHookCancellationError) {
await config.services.diffViewProvider.revertChanges()
await config.services.diffViewProvider.reset()
return formatResponse.toolDenied()
}
throw error
}
// Mark the file as edited by Cline
config.services.fileContextTracker.markFileAsEditedByCline(relPath)
+6
View File
@@ -19,6 +19,7 @@ import type { StateManager } from "../../../storage/StateManager"
import type { MessageStateHandler } from "../../message-state"
import type { TaskState } from "../../TaskState"
import type { AutoApprove } from "../../tools/autoApprove"
import type { HookExecution } from "../../types/HookExecution"
import type { ToolExecutorCoordinator } from "../ToolExecutorCoordinator"
import { TASK_CALLBACKS_KEYS, TASK_CONFIG_KEYS, TASK_SERVICES_KEYS } from "../utils/ToolConstants"
@@ -116,6 +117,11 @@ export interface TaskCallbacks {
applyLatestBrowserSettings: () => Promise<BrowserSession>
switchToActMode: () => Promise<boolean>
// Hook execution callbacks
setActiveHookExecution: (hookExecution: HookExecution) => Promise<void>
clearActiveHookExecution: () => Promise<void>
getActiveHookExecution: () => Promise<HookExecution | undefined>
}
/**
@@ -64,6 +64,9 @@ export const TASK_CALLBACKS_KEYS = [
"cancelTask",
"updateTaskHistory",
"switchToActMode",
"setActiveHookExecution",
"clearActiveHookExecution",
"getActiveHookExecution",
] as const
/**
+148
View File
@@ -0,0 +1,148 @@
import type { ToolUse } from "@core/assistant-message"
import { PreToolUseHookCancellationError } from "@core/hooks/PreToolUseHookCancellationError"
import type { TaskConfig } from "../types/TaskConfig"
/**
* Utility functions for tool hook execution.
*/
export class ToolHookUtils {
/**
* Runs the PreToolUse hook if enabled.
*
* This should be called by tool handlers after approval succeeds
* but before the actual tool execution begins.
*
* @param config The task configuration
* @param block The tool use block being executed
* @returns Promise<boolean> - true if execution should continue, false if hook cancelled
* @throws PreToolUseHookCancellationError if the hook requests cancellation
*/
static async runPreToolUseIfEnabled(config: TaskConfig, block: ToolUse): Promise<boolean> {
// Check if hooks are enabled via user setting
const hooksEnabled = config.services.stateManager.getGlobalSettingsKey("hooksEnabled")
if (!hooksEnabled) {
return true // Hooks disabled, continue execution
}
// Import the hook executor dynamically
const { executeHook } = await import("@core/hooks/hook-executor")
// Build pending tool info for display
const pendingToolInfo: any = {
tool: block.name,
}
// Add relevant parameters for display based on tool type
if (block.params.path) {
pendingToolInfo.path = block.params.path
}
if (block.params.command) {
pendingToolInfo.command = block.params.command
}
if (block.params.content && typeof block.params.content === "string") {
pendingToolInfo.content = block.params.content.slice(0, 200)
}
if (block.params.diff && typeof block.params.diff === "string") {
pendingToolInfo.diff = block.params.diff.slice(0, 200)
}
if (block.params.regex) {
pendingToolInfo.regex = block.params.regex
}
if (block.params.url) {
pendingToolInfo.url = block.params.url
}
// For MCP operations, show tool/resource identifiers
if (block.params.tool_name) {
pendingToolInfo.mcpTool = block.params.tool_name
}
if (block.params.server_name) {
pendingToolInfo.mcpServer = block.params.server_name
}
if (block.params.uri) {
pendingToolInfo.resourceUri = block.params.uri
}
// Execute the PreToolUse hook
const preToolResult = await executeHook({
hookName: "PreToolUse",
hookInput: {
preToolUse: {
toolName: block.name,
parameters: block.params,
},
},
isCancellable: true,
say: config.callbacks.say,
setActiveHookExecution: config.callbacks.setActiveHookExecution,
clearActiveHookExecution: config.callbacks.clearActiveHookExecution,
messageStateHandler: config.messageState,
taskId: config.taskId,
hooksEnabled,
toolName: block.name,
pendingToolInfo,
})
// Handle cancellation from hook
if (preToolResult.cancel === true) {
throw new PreToolUseHookCancellationError(preToolResult.errorMessage || "PreToolUse hook requested cancellation")
}
// If task was aborted (e.g., via cancel button during hook), throw cancellation error
if (config.taskState.abort) {
throw new PreToolUseHookCancellationError("Task was aborted during PreToolUse hook execution")
}
// Add context modification to the conversation if provided by the hook
if (preToolResult.contextModification) {
ToolHookUtils.addHookContextToConversation(config, preToolResult.contextModification, "PreToolUse")
}
return true // Hook succeeded, continue execution
}
/**
* Adds hook context modification to the conversation if provided.
* Parses the context to extract type prefix and formats as XML.
*
* @param config The task configuration
* @param contextModification The context string from the hook output
* @param source The hook source name ("PreToolUse" or "PostToolUse")
*/
private static addHookContextToConversation(
config: TaskConfig,
contextModification: string | undefined,
source: string,
): void {
if (!contextModification) {
return
}
const contextText = contextModification.trim()
if (!contextText) {
return
}
// Extract context type from first line if specified (e.g., "WORKSPACE_RULES: ...")
const lines = contextText.split("\n")
const firstLine = lines[0]
let contextType = "general"
let content = contextText
// Check if first line specifies a type: "TYPE: content"
const typeMatchRegex = /^([A-Z_]+):\s*(.*)/
const typeMatch = typeMatchRegex.exec(firstLine)
if (typeMatch) {
contextType = typeMatch[1].toLowerCase()
const remainingLines = lines.slice(1).filter((l: string) => l.trim())
content = typeMatch[2] ? [typeMatch[2], ...remainingLines].join("\n") : remainingLines.join("\n")
}
const hookContextBlock = {
type: "text" as const,
text: `<hook_context source="${source}" type="${contextType}">\n${content}\n</hook_context>`,
}
config.taskState.userMessageContent.push(hookContextBlock)
}
}
+14
View File
@@ -0,0 +1,14 @@
/**
* Represents an active hook execution that can be cancelled.
* This is tracked in TaskState to allow cancellation via UI or programmatic triggers.
*/
export interface HookExecution {
/** The name of the hook being executed (e.g., "PreToolUse", "PostToolUse") */
hookName: string
/** The name of the tool that triggered this hook (for PreToolUse/PostToolUse hooks) */
toolName?: string
/** The timestamp of the message showing hook execution status */
messageTs: number
/** The abort controller used to cancel the hook execution */
abortController: AbortController
}