Compare commits

...

27 Commits

Author SHA1 Message Date
Eve Killaby 3e49b986f0 Fixing cancel behavior 2025-10-24 16:05:55 -07:00
Eve Killaby db3f6e05a5 Fixing cancel behavior 2025-10-24 15:28:27 -07:00
Eve Killaby dc2b305744 Remove unnecessary polling pattern for hook cancellation 2025-10-24 15:28:26 -07:00
Eve Killaby edfa9d6bf7 remove cancelHookExecution() in favor of cancelTask() 2025-10-24 15:28:26 -07:00
Eve Killaby cef31ad57b Remove unnecessary files from branch 2025-10-24 15:28:26 -07:00
Eve Killaby 88d29d23b7 Fix unsafe return in finally block
Refactored PostToolUse hook execution to avoid using return statement
in finally block, which the linter flags as unsafe. Instead of early
return, wrapped the hook logic in a conditional check for abort status.

This resolves the biome lint error:
lint/correctness/noUnsafeFinally - Unsafe usage of 'return' in finally block
2025-10-24 15:28:26 -07:00
Eve Killaby 8032894f1e Refactored away loadTaskStateWithoutWorkflow 2025-10-24 15:28:26 -07:00
Eve Killaby bf0e6e2e64 Add examples to .clinerules/hooks/ directory 2025-10-24 15:28:26 -07:00
Eve Killaby 2167d563f6 Demoed 2025-10-24 15:28:26 -07:00
Eve Killaby ccc3ccf42b Trying another fix 2025-10-24 15:28:26 -07:00
Eve Killaby 8d8b7cb1db Fixing cancel behaviors 2025-10-24 15:28:26 -07:00
Eve Killaby 88ec0f60bc Getting things working again 2025-10-24 15:28:26 -07:00
Eve Killaby e5055f9d5b fix: add cancel button support for TaskResume and UserPromptSubmit hooks 2025-10-24 15:28:26 -07:00
Eve Killaby d983ae43b5 fix: resolve cancel functionality in PostToolUse hook using flag pattern 2025-10-24 15:28:26 -07:00
Eve Killaby 33ffba012a test: verify linting fixes 2025-10-24 15:28:26 -07:00
Eve Killaby 01c4493e34 Changes from usability feedback 2025-10-24 15:28:26 -07:00
Eve Killaby a524e8b76b Simplify and improve console.log() output from hooks 2025-10-24 15:28:26 -07:00
Eve Killaby 5133d1b713 Improve TaskCancel hook UI 2025-10-24 15:28:25 -07:00
Eve Killaby 6b9ceb4659 Improvements to hooks error messaging 2025-10-24 15:28:25 -07:00
Eve Killaby 058d886894 Un-hard-code FeatureFlagsServive::getHooksEnabled() 2025-10-24 15:28:25 -07:00
Eve Killaby a96f7f2138 Hooks hardening and handling edge cases 2025-10-24 15:28:25 -07:00
Eve Killaby 2e45f6fbee Add missing docs 2025-10-24 15:28:25 -07:00
Eve Killaby aab8d4798b Fix failing tests 2025-10-24 15:28:25 -07:00
Eve Killaby 8ddd349161 Separate hooks UI into separate files 2025-10-24 15:28:25 -07:00
Eve Killaby 5d83c7676c Hook discovery improvements 2025-10-24 15:28:25 -07:00
Eve Killaby 89b16ad15a Hooks UI improvements 2025-10-24 15:28:25 -07:00
Eve Killaby 3ac63cf031 feat(hooks): Initial implementation of hooks UI using background terminal UI 2025-10-24 15:28:25 -07:00
37 changed files with 3539 additions and 527 deletions
+7 -46
View File
@@ -1,54 +1,15 @@
#!/usr/bin/env bash
# PostToolUse Hook Example
#
# This hook runs AFTER a tool is executed. It can:
# 1. Observe tool results and outcomes
# 2. Add context for FUTURE tool uses via contextModification
# 3. Log or track tool usage patterns
#
# IMPORTANT: Context injection affects FUTURE AI decisions, not the current tool execution.
# The tool has already completed when this hook runs.
# Read the hook input (JSON via stdin)
input=$(cat)
for i in {1..100}; do
echo "$i"
done
# Extract tool information
tool_name=$(echo "$input" | jq -r '.postToolUse.toolName // "unknown"')
parameters=$(echo "$input" | jq -r '.postToolUse.parameters // {}')
result=$(echo "$input" | jq -r '.postToolUse.result // ""')
success=$(echo "$input" | jq -r '.postToolUse.success // false')
execution_time=$(echo "$input" | jq -r '.postToolUse.executionTimeMs // 0')
sleep 3
# Example 1: Learning from file operations
# Track successful file creations to build context about project structure
# if [[ "$tool_name" == "write_to_file" && "$success" == "true" ]]; then
# path=$(echo "$parameters" | jq -r '.path // ""')
# cat <<EOF
# {
# "shouldContinue": true,
# "contextModification": "FILE_OPERATIONS: Successfully created '$path'. Future operations should maintain consistency with this file's patterns and structure."
# }
# EOF
# exit 0
# fi
# Example 2: Performance monitoring
# Warn about slow operations
# if [[ "$execution_time" -gt 5000 ]]; then
# cat <<EOF
# {
# "shouldContinue": true,
# "contextModification": "PERFORMANCE: Tool '$tool_name' took ${execution_time}ms to complete. Consider optimizing future similar operations or breaking them into smaller steps."
# }
# EOF
# exit 0
# fi
# Example 3: Context injection for future tool uses
# The context will be available in the NEXT API request
cat <<EOF
{
"shouldContinue": true,
"contextModification": "TOOL_RESULT: The tool '$tool_name' completed with success=$success. Consider validating the results before proceeding to the next step."
"cancel": false,
"contextModification": "WORKSPACE_RULES: This is from the local bar/ workspace.",
"errorMessage": "PostToolUse hook custom errorMessage: foo"
}
EOF
+7 -34
View File
@@ -1,42 +1,15 @@
#!/usr/bin/env bash
# PreToolUse Hook Example
#
# This hook runs BEFORE a tool is executed. It can:
# 1. Block execution by returning {"shouldContinue": false}
# 2. Add context for FUTURE tool uses via contextModification
# 3. Validate tool parameters
#
# IMPORTANT: Context injection affects FUTURE AI decisions, not the current tool execution.
# The tool parameters are already determined when this hook runs.
# Read the hook input (JSON via stdin)
input=$(cat)
for i in {1..100}; do
echo "$i"
done
# Extract tool information
tool_name=$(echo "$input" | jq -r '.preToolUse.toolName // "unknown"')
parameters=$(echo "$input" | jq -r '.preToolUse.parameters // {}')
sleep 3
# Example 1: Validation - Block invalid operations
# Uncomment to prevent creating .js files in a TypeScript project
# if [[ "$tool_name" == "write_to_file" ]]; then
# path=$(echo "$parameters" | jq -r '.path // ""')
# if [[ "$path" == *.js ]]; then
# cat <<EOF
# {
# "shouldContinue": false,
# "errorMessage": "VALIDATION FAILED: Cannot create .js files in TypeScript project. Please use .ts extension instead.",
# "contextModification": "WORKSPACE_RULES: This is a strict TypeScript project. All new files must use .ts or .tsx extensions."
# }
# EOF
# exit 0
# fi
# fi
# Example 2: Context injection for future tool uses
# The context will be available in the NEXT API request after this tool completes
cat <<EOF
{
"shouldContinue": true,
"contextModification": "WORKSPACE_RULES: [For future tool uses] This is a TypeScript React project. When creating files, use .ts/.tsx extensions and include detailed comments explaining the purpose and usage of each function."
"cancel": false,
"contextModification": "Make sure that all variables in python code in this repo always start with the string 'EVIEDEBUG_'. This is important.",
"errorMessage": "PreToolUse hook custom errorMessage: foo"
}
EOF
+60 -20
View File
@@ -17,6 +17,30 @@ Hooks run automatically when enabled.
## Available Hooks
### TaskStart Hook
- **When**: Runs when a NEW task is started (not when resuming)
- **Purpose**: Initialize task context, validate task requirements, set up environment
- **Global Location**: `~/Documents/Cline/Rules/Hooks/TaskStart` (all platforms)
- **Workspace Location**: `.clinerules/hooks/TaskStart` (all platforms)
### TaskResume Hook
- **When**: Runs when an EXISTING task is resumed
- **Purpose**: Validate resumed task state, restore context, check for changes since last run
- **Global Location**: `~/Documents/Cline/Rules/Hooks/TaskResume` (all platforms)
- **Workspace Location**: `.clinerules/hooks/TaskResume` (all platforms)
### TaskCancel Hook
- **When**: Runs when a task is cancelled by the user
- **Purpose**: Clean up resources, log cancellation, save state
- **Global Location**: `~/Documents/Cline/Rules/Hooks/TaskCancel` (all platforms)
- **Workspace Location**: `.clinerules/hooks/TaskCancel` (all platforms)
### UserPromptSubmit Hook
- **When**: Runs when the user submits a prompt/message
- **Purpose**: Validate user input, preprocess prompts, add context to user messages
- **Global Location**: `~/Documents/Cline/Rules/Hooks/UserPromptSubmit` (all platforms)
- **Workspace Location**: `.clinerules/hooks/UserPromptSubmit` (all platforms)
### PreToolUse Hook
- **When**: Runs BEFORE a tool is executed
- **Purpose**: Validate parameters, block execution, or add context
@@ -107,11 +131,23 @@ All hooks receive:
```json
{
"clineVersion": "string",
"hookName": "PreToolUse" | "PostToolUse",
"hookName": "TaskStart" | "TaskResume" | "TaskCancel" | "UserPromptSubmit" | "PreToolUse" | "PostToolUse",
"timestamp": "string",
"taskId": "string",
"workspaceRoots": ["string"],
"userId": "string",
"taskStart": { // Only for TaskStart
"task": "string"
},
"taskResume": { // Only for TaskResume
"task": "string"
},
"taskCancel": { // Only for TaskCancel
"reason": "string"
},
"userPromptSubmit": { // Only for UserPromptSubmit
"prompt": "string"
},
"preToolUse": { // Only for PreToolUse
"toolName": "string",
"parameters": {}
@@ -131,12 +167,16 @@ All hooks receive:
All hooks must return:
```json
{
"shouldContinue": boolean, // Required: Allow or block execution
"contextModification": "string", // Optional: Context for future tool uses
"cancel": boolean, // Required: false to continue, true to block execution
"contextModification": "string", // Optional: Context for future AI decisions
"errorMessage": "string" // Optional: Error details if blocking
}
```
**Note**: The `cancel` field works as follows:
- `false` (or omitted): Allow execution to continue
- `true`: Block execution and show error message to user
## Context Modification Format
Use structured prefixes to help the AI understand context type:
@@ -152,7 +192,7 @@ Example:
```bash
cat <<EOF
{
"shouldContinue": true,
"cancel": false,
"contextModification": "WORKSPACE_RULES: This is a TypeScript project. All new files must use .ts or .tsx extensions."
}
EOF
@@ -177,7 +217,7 @@ path=$(echo "$input" | jq -r '.preToolUse.parameters.path // ""')
if [[ "$tool_name" == "write_to_file" && "$path" == *.js ]]; then
cat <<EOF
{
"shouldContinue": false,
"cancel": true,
"errorMessage": "Cannot create .js files in TypeScript project",
"contextModification": "WORKSPACE_RULES: Use .ts/.tsx extensions only"
}
@@ -185,7 +225,7 @@ EOF
exit 0
fi
echo '{"shouldContinue": true}'
echo '{"cancel": false}'
```
### 2. Context Building - Learn from Operations
@@ -200,12 +240,12 @@ path=$(echo "$input" | jq -r '.postToolUse.parameters.path // ""')
if [[ "$tool_name" == "write_to_file" && "$success" == "true" ]]; then
cat <<EOF
{
"shouldContinue": true,
"cancel": false,
"contextModification": "FILE_OPERATIONS: Created '$path'. Maintain consistency with this file's patterns in future operations."
}
EOF
else
echo '{"shouldContinue": true}'
echo '{"cancel": false}'
fi
```
@@ -220,12 +260,12 @@ tool_name=$(echo "$input" | jq -r '.postToolUse.toolName')
if [[ "$execution_time" -gt 5000 ]]; then
cat <<EOF
{
"shouldContinue": true,
"cancel": false,
"contextModification": "PERFORMANCE: Tool '$tool_name' took ${execution_time}ms. Consider optimizing future similar operations."
}
EOF
else
echo '{"shouldContinue": true}'
echo '{"cancel": false}'
fi
```
@@ -239,7 +279,7 @@ input=$(cat)
echo "$input" >> ~/.cline/hook-logs/tool-usage.jsonl
# Allow execution
echo '{"shouldContinue": true}'
echo '{"cancel": false}'
```
## Global vs Workspace Hooks
@@ -261,13 +301,13 @@ Cline supports two levels of hooks:
### Hook Execution
When multiple hooks exist (global and/or workspace):
- All hooks for a given step (PreToolUse or PostToolUse) are executed
- All hooks for a given step are executed
- **Execution order is not guaranteed** - hooks may run concurrently
- If ALL hooks allow execution (`shouldContinue: true`), the tool proceeds
- If ANY hook blocks (`shouldContinue: false`), execution is blocked
- If ALL hooks allow execution (`cancel: false`), the tool proceeds
- If ANY hook blocks (`cancel: true`), execution is blocked
**Result Combination:**
- `shouldContinue`: Must be `true` from ALL hooks for execution to proceed
- `cancel`: If ANY hook returns `true`, execution is blocked
- `contextModification`: All context strings are concatenated
- `errorMessage`: All error messages are concatenated
@@ -301,11 +341,11 @@ tool_name=$(echo "$input" | jq -r '.preToolUse.toolName')
path=$(echo "$input" | jq -r '.preToolUse.parameters.path // ""')
if [[ "$tool_name" == "write_to_file" && "$path" == *"package.json"* ]]; then
echo '{"shouldContinue": false, "errorMessage": "Global policy: Cannot modify package.json"}'
echo '{"cancel": true, "errorMessage": "Global policy: Cannot modify package.json"}'
exit 0
fi
echo '{"shouldContinue": true}'
echo '{"cancel": false}'
```
**Workspace Hook** (applies to specific project):
@@ -318,11 +358,11 @@ tool_name=$(echo "$input" | jq -r '.preToolUse.toolName')
path=$(echo "$input" | jq -r '.preToolUse.parameters.path // ""')
if [[ "$tool_name" == "write_to_file" && "$path" == *.js ]]; then
echo '{"shouldContinue": false, "errorMessage": "Project rule: Use .ts files only"}'
echo '{"cancel": true, "errorMessage": "Project rule: Use .ts files only"}'
exit 0
fi
echo '{"shouldContinue": true}'
echo '{"cancel": false}'
```
**All hooks must allow execution for the tool to proceed.** Hooks may execute concurrently.
@@ -331,7 +371,7 @@ echo '{"shouldContinue": true}'
If you have multiple workspace roots, you can place hooks in each root's `.clinerules/hooks/` directory. All hooks (global and workspace) may execute concurrently. Their results will be combined:
- **shouldContinue**: If ANY hook returns false, execution is blocked
- **cancel**: If ANY hook returns `true`, execution is blocked
- **contextModification**: All context modifications are concatenated
- **errorMessage**: All error messages are concatenated
+15
View File
@@ -0,0 +1,15 @@
#!/usr/bin/env bash
for i in {1..100}; do
echo "$i"
done
sleep 3
cat <<EOF
{
"cancel", false,
"contextModification": "WORKSPACE_RULES: This is from the local bar/ workspace.",
"errorMessage": "TaskCancel hook custom errorMessage: foo"
}
EOF
+15
View File
@@ -0,0 +1,15 @@
#!/usr/bin/env bash
for i in {1..100}; do
echo "$i"
done
sleep 3
cat <<EOF
{
"cancel": false,
"contextModification": "WORKSPACE_RULES: This is from the local bar/ workspace.",
"errorMessage": "TaskResume hook custom errorMessage: foo"
}
EOF
+15
View File
@@ -0,0 +1,15 @@
#!/usr/bin/env bash
for i in {1..100}; do
echo "$i"
done
sleep 1
cat <<EOF
{
"cancel": false,
"contextModification": "WORKSPACE_RULES: This is from the local bar/ workspace.",
"errorMessage": "TaskStart hook custom errorMessage: foo"
}
EOF
+15
View File
@@ -0,0 +1,15 @@
#!/usr/bin/env bash
for i in {1..100}; do
echo "$i"
done
sleep 3
cat <<EOF
{
"cancel": false,
"contextModification": "WORKSPACE_RULES: This is from the local bar/ workspace.",
"errorMessage": "UserPromptSubmit hook custom errorMessage: foo"
}
EOF
+1 -1
View File
@@ -29,7 +29,7 @@ message HookInput {
// Output message for all hooks
message HookOutput {
string context_modification = 1;
bool should_continue = 2;
bool cancel = 2;
string error_message = 3;
}
+60 -4
View File
@@ -333,6 +333,17 @@ export class Controller {
taskLockAcquired,
})
// IMPORTANT: Now that the task instance is fully assigned to controller.task,
// we can safely start the task initialization. This prevents race conditions
// where hooks (especially TaskStart) run before the task is ready to be cancelled.
if (historyItem) {
// Resume an existing task - this will show the resume button first
this.task.resumeTaskFromHistory()
} else if (task || images || files) {
// Start a new task - this will run TaskStart hook immediately
this.task.startTask(task, images, files)
}
return this.task.taskId
}
@@ -411,13 +422,17 @@ export class Controller {
async cancelTask() {
if (this.task) {
console.log(`[Controller.cancelTask] Starting cancellation for task ${this.task.taskId}`)
this.updateBackgroundCommandState(false)
const { historyItem } = await this.getTaskWithId(this.task.taskId)
try {
console.log(`[Controller.cancelTask] Calling abortTask()`)
await this.task.abortTask()
} catch (error) {
console.error("Failed to abort task", error)
}
console.log(`[Controller.cancelTask] Waiting for abort to complete...`)
await pWaitFor(
() =>
this.task === undefined ||
@@ -430,13 +445,50 @@ export class Controller {
).catch(() => {
console.error("Failed to abort task")
})
console.log(
`[Controller.cancelTask] Abort complete, didFinishAbortingStream=${this.task?.taskState.didFinishAbortingStream}`,
)
if (this.task) {
// 'abandoned' will prevent this cline instance from affecting future cline instance gui. this may happen if its hanging on a streaming request
this.task.taskState.abandoned = true
}
await this.initTask(undefined, undefined, undefined, historyItem) // clears task again, so we need to abortTask manually above
// Dont send the state to the webview, the new Cline instance will send state when it's ready.
// Sending the state here sent an empty messages array to webview leading to virtuoso having to reload the entire list
// Small delay to ensure state manager has persisted the history update
await new Promise((resolve) => setTimeout(resolve, 100))
// NOW try to get history after abort has finished (hook may have saved messages)
let historyItem: HistoryItem | undefined
try {
console.log(`[Controller.cancelTask] Attempting to get task history for ${this.task.taskId}`)
console.log(
`[Controller.cancelTask] Current taskHistory has ${this.stateManager.getGlobalStateKey("taskHistory").length} items`,
)
const result = await this.getTaskWithId(this.task.taskId)
historyItem = result.historyItem
console.log(
`[Controller.cancelTask] Found history item with ${result.apiConversationHistory.length} API messages`,
)
} catch (error) {
// Task not in history yet (new task with no messages)
console.log(`[Controller.cancelTask] Task not found in history: ${error}`)
}
// Only re-initialize if we found a history item, otherwise just clear
if (historyItem) {
console.log(`[Controller.cancelTask] Re-initializing task with history`)
// Re-initialize task to keep it visible in UI with resume button
await this.initTask(undefined, undefined, undefined, historyItem, undefined)
} else {
console.log(`[Controller.cancelTask] No history found, clearing task`)
// No history to restore, just clear the task
await this.clearTask()
}
// Ensure state is sent to webview after cancellation
await this.postStateToWebview()
console.log(`[Controller.cancelTask] Cancellation complete`)
}
}
@@ -985,14 +1037,18 @@ export class Controller {
*/
async updateTaskHistory(item: HistoryItem): Promise<HistoryItem[]> {
console.log(`[Controller.updateTaskHistory] Updating history for task ${item.id}`)
const history = this.stateManager.getGlobalStateKey("taskHistory")
const existingItemIndex = history.findIndex((h) => h.id === item.id)
if (existingItemIndex !== -1) {
console.log(`[Controller.updateTaskHistory] Updating existing item at index ${existingItemIndex}`)
history[existingItemIndex] = item
} else {
console.log(`[Controller.updateTaskHistory] Adding new item to history`)
history.push(item)
}
this.stateManager.setGlobalState("taskHistory", history)
console.log(`[Controller.updateTaskHistory] History now has ${history.length} items`)
return history
}
}
+264
View File
@@ -0,0 +1,264 @@
import * as vscode from "vscode"
import { getAllHooksDirs } from "../storage/disk"
import { HookFactory, Hooks } from "./hook-factory"
type HookName = keyof Hooks
/**
* Cached hook discovery results
*/
interface HookCacheEntry {
scriptPaths: string[] // Paths to hook scripts for this hook name
timestamp: number // When this was last scanned
}
/**
* Singleton cache for hook script discovery with lazy file system watching.
*
* Features:
* - Lazy watcher initialization (only when directories are accessed)
* - Per-directory caching
* - Automatic invalidation on file changes
* - Graceful error handling
* - Optional debug logging
*/
export class HookDiscoveryCache {
private static instance: HookDiscoveryCache | null = null
// Cache: hookName -> discovered script paths
private cache = new Map<HookName, HookCacheEntry>()
// Watchers: directory path -> file watcher
private watchers = new Map<string, vscode.FileSystemWatcher>()
// Directories we've tried to watch (even if watcher creation failed)
private watchedDirs = new Set<string>()
// Currently scanning (to prevent concurrent scans)
private scanning = new Set<HookName>()
// For disposal
private context: vscode.ExtensionContext | null = null
private createFileWatcher: ((dir: string) => vscode.FileSystemWatcher | null) | null = null
private disposed = false
// Debug logging (enabled via DEBUG_HOOKS env var)
private debug = process.env.DEBUG_HOOKS === "true"
private constructor() {}
static getInstance(): HookDiscoveryCache {
if (!HookDiscoveryCache.instance) {
HookDiscoveryCache.instance = new HookDiscoveryCache()
}
return HookDiscoveryCache.instance
}
/**
* Initialize with extension context for proper cleanup
*/
initialize(context: vscode.ExtensionContext, createFileWatcher?: (dir: string) => vscode.FileSystemWatcher | null): void {
this.context = context
this.createFileWatcher = createFileWatcher || null
// Watch for workspace changes to invalidate cache
context.subscriptions.push(
vscode.workspace.onDidChangeWorkspaceFolders(() => {
this.log("Workspace folders changed, invalidating cache")
this.invalidateAll()
}),
)
}
/**
* Get cached hook scripts or scan if not cached
*/
async get(hookName: HookName): Promise<string[]> {
this.log(`Getting hooks for ${hookName}`)
const cached = this.cache.get(hookName)
if (cached) {
this.log(`Cache hit for ${hookName}: ${cached.scriptPaths.length} scripts`)
return cached.scriptPaths
}
this.log(`Cache miss for ${hookName}, scanning...`)
return this.scan(hookName)
}
/**
* Scan for hook scripts and cache the result
*/
private async scan(hookName: HookName): Promise<string[]> {
// Prevent concurrent scans of the same hook
if (this.scanning.has(hookName)) {
this.log(`Already scanning ${hookName}, waiting...`)
await new Promise((resolve) => setTimeout(resolve, 50))
return this.get(hookName)
}
this.scanning.add(hookName)
try {
// Get all current hooks directories
const hooksDirs = await getAllHooksDirs()
this.log(`Scanning ${hooksDirs.length} directories for ${hookName}`)
// Ensure watchers are set up for each directory (lazy initialization)
for (const dir of hooksDirs) {
this.ensureWatcher(dir)
}
// Scan each directory for this hook
const scriptPromises = hooksDirs.map((dir) => HookFactory.findHookInHooksDir(hookName, dir))
const results = await Promise.all(scriptPromises)
const scripts = results.filter((path): path is string => path !== undefined)
this.log(`Found ${scripts.length} scripts for ${hookName}`)
// Cache the result
this.cache.set(hookName, {
scriptPaths: scripts,
timestamp: Date.now(),
})
return scripts
} catch (error) {
console.error(`Error scanning for ${hookName} hooks:`, error)
// Return empty array on error - don't break the whole system
return []
} finally {
this.scanning.delete(hookName)
}
}
/**
* Ensure a file watcher exists for the given directory
*/
private ensureWatcher(dir: string): void {
// Skip if already watching or tried to watch
if (this.watchedDirs.has(dir)) {
return
}
this.watchedDirs.add(dir)
if (!this.context) {
this.log(`No context available, skipping watcher for ${dir}`)
return
}
// If no watcher creation function provided, skip watching
if (!this.createFileWatcher) {
this.log(`No watcher creator available, skipping watcher for ${dir}`)
return
}
try {
// Create watcher using the provided function
const watcher = this.createFileWatcher(dir)
if (!watcher) {
this.log(`Watcher creation returned null for ${dir}`)
return
}
// Invalidate cache on any change
const invalidate = () => {
this.log(`File change detected in ${dir}, invalidating cache`)
this.invalidateDirectory(dir)
}
watcher.onDidCreate(invalidate)
watcher.onDidChange(invalidate)
watcher.onDidDelete(invalidate)
// Add to context subscriptions for proper cleanup
if (this.context) {
this.context.subscriptions.push(watcher)
}
this.watchers.set(dir, watcher)
this.log(`Created watcher for ${dir}`)
} catch (error) {
// Log but don't fail - directory might not exist yet
this.log(`Failed to create watcher for ${dir}: ${error}`)
}
}
/**
* Invalidate all cached hooks that have scripts in this directory
*/
private invalidateDirectory(dir: string): void {
let invalidated = 0
for (const [hookName, entry] of this.cache) {
if (entry.scriptPaths.some((scriptPath) => scriptPath.startsWith(dir))) {
this.cache.delete(hookName)
invalidated++
}
}
this.log(`Invalidated ${invalidated} hooks for directory ${dir}`)
}
/**
* Invalidate entire cache
*/
invalidateAll(): void {
const size = this.cache.size
this.cache.clear()
this.log(`Invalidated entire cache (${size} entries)`)
}
/**
* Get cache statistics (for debugging/monitoring)
*/
getStats() {
return {
cacheSize: this.cache.size,
watcherCount: this.watchers.size,
watchedDirs: this.watchedDirs.size,
}
}
/**
* Log debug message if debug mode is enabled
*/
private log(message: string): void {
if (this.debug) {
console.log(`[HookCache] ${message}`)
}
}
/**
* Clean up resources
*/
dispose(): void {
if (this.disposed) {
return
}
this.log(`Disposing cache (${this.watchers.size} watchers)`)
for (const watcher of this.watchers.values()) {
watcher.dispose()
}
this.watchers.clear()
this.watchedDirs.clear()
this.cache.clear()
this.disposed = true
}
/**
* Reset singleton instance (for testing)
*/
static resetForTesting(): void {
if (HookDiscoveryCache.instance) {
HookDiscoveryCache.instance.dispose()
HookDiscoveryCache.instance = null
}
}
}
+120
View File
@@ -0,0 +1,120 @@
/**
* Types of errors that can occur during hook execution
*/
export enum HookErrorType {
/** Hook execution exceeded the timeout limit */
TIMEOUT = "timeout",
/** Hook output failed JSON validation */
VALIDATION = "validation",
/** Hook script execution failed (non-zero exit, crash, etc.) */
EXECUTION = "execution",
/** Hook was cancelled by user */
CANCELLATION = "cancellation",
}
/**
* Structured error information for hook failures.
* Provides both user-friendly messages and technical details.
*/
export interface HookErrorInfo {
/** Type of error that occurred */
type: HookErrorType
/** User-friendly error message */
message: string
/** Technical details for debugging (optional, shown in expansion) */
details?: string
/** Path to the hook script that failed */
scriptPath?: string
/** Exit code if available */
exitCode?: number
/** Stderr output if available */
stderr?: string
}
/**
* Error thrown during hook execution with structured information.
* This allows proper error handling without string parsing.
*/
export class HookExecutionError extends Error {
constructor(
public readonly errorInfo: HookErrorInfo,
message?: string,
) {
super(message || errorInfo.message)
this.name = "HookExecutionError"
}
/**
* Check if an error is a HookExecutionError
*/
static isHookError(error: unknown): error is HookExecutionError {
return error instanceof HookExecutionError
}
/**
* Create a timeout error
*/
static timeout(scriptPath: string, timeoutMs: number, stderr?: string): HookExecutionError {
return new HookExecutionError({
type: HookErrorType.TIMEOUT,
message: `Hook execution timed out after ${timeoutMs}ms`,
details:
`The hook took longer than ${timeoutMs / 1000} seconds to complete.\n\n` +
`Common causes:\n` +
`• Infinite loop in hook script\n` +
`• Network request hanging without timeout\n` +
`• File I/O operation stuck\n` +
`• Heavy computation taking too long\n\n` +
`Recommendations:\n` +
`1. Check your hook script for infinite loops\n` +
`2. Add timeouts to network requests\n` +
`3. Use background jobs for long operations\n` +
`4. Test your hook script independently`,
scriptPath,
stderr,
})
}
/**
* Create a validation error
*/
static validation(validationError: string, scriptPath: string, stdoutPreview: string): HookExecutionError {
return new HookExecutionError({
type: HookErrorType.VALIDATION,
message: "Hook output validation failed",
details: `${validationError}\n\nOutput preview:\n${stdoutPreview}`,
scriptPath,
})
}
/**
* Create an execution error
*/
static execution(scriptPath: string, exitCode: number, stderr?: string): HookExecutionError {
const message = `Hook script exited with code ${exitCode}`
return new HookExecutionError(
{
type: HookErrorType.EXECUTION,
message,
details: stderr ? `stderr:\n${stderr}` : undefined,
scriptPath,
exitCode,
stderr,
},
message,
)
}
/**
* Create a cancellation error
*/
static cancellation(scriptPath: string): HookExecutionError {
return new HookExecutionError({
type: HookErrorType.CANCELLATION,
message: "Hook execution was cancelled",
details: "The hook was cancelled by the user before completion",
scriptPath,
exitCode: 130, // Standard SIGINT exit code
})
}
}
+378
View File
@@ -0,0 +1,378 @@
import { ChildProcess, spawn } from "child_process"
import { EventEmitter } from "events"
import { HookProcessRegistry } from "./HookProcessRegistry"
// Maximum total output size (stdout + stderr combined)
const MAX_HOOK_OUTPUT_SIZE = 1024 * 1024 // 1MB
/**
* HookProcess manages the execution of a hook script with streaming output capabilities.
* Similar to StandaloneTerminalProcess but specialized for hook execution.
*
* Key features:
* - Real-time stdout/stderr streaming via line events
* - Separate handling of visual output vs. JSON response
* - 30-second execution timeout
* - 1MB output size limit (prevents memory issues)
* - Hot state tracking (actively outputting)
* - Process lifecycle management
*/
export class HookProcess extends EventEmitter {
private childProcess: ChildProcess | null = null
private buffer = ""
private fullOutput = ""
private lastRetrievedIndex = 0
private isHot = false
private hotTimer: NodeJS.Timeout | null = null
private exitCode: number | null = null
private isCompleted = false
private timeoutHandle: NodeJS.Timeout | null = null
// Separate buffers for stdout and stderr
private stdoutBuffer = ""
private stderrBuffer = ""
// Output size tracking
private stdoutSize = 0
private stderrSize = 0
private outputTruncated = false
constructor(
private readonly scriptPath: string,
private readonly timeoutMs: number = 30000,
private readonly abortSignal?: AbortSignal,
) {
super()
}
/**
* Execute the hook script with the given JSON input
* @param inputJson The JSON string to pass to the hook via stdin
*/
async run(inputJson: string): Promise<void> {
return new Promise((resolve, reject) => {
// Register this process for tracking
HookProcessRegistry.register(this)
// Check if already aborted
if (this.abortSignal?.aborted) {
HookProcessRegistry.unregister(this)
reject(new Error("Hook execution cancelled"))
return
}
// Set up abort handler
const abortHandler = () => {
if (this.childProcess && !this.isCompleted) {
this.isCompleted = true // Mark as completed immediately
// Remove abort listener immediately to prevent double-rejection
if (this.abortSignal) {
this.abortSignal.removeEventListener("abort", abortHandler)
}
// Clean up timers
if (this.hotTimer) {
clearTimeout(this.hotTimer)
this.isHot = false
}
if (this.timeoutHandle) {
clearTimeout(this.timeoutHandle)
this.timeoutHandle = null
}
// Unregister from active processes
HookProcessRegistry.unregister(this)
// Kill the process (async, fire-and-forget)
if (this.childProcess.pid) {
this.childProcess.kill("SIGTERM")
}
// Reject immediately - don't wait for process to die
reject(new Error("Hook execution cancelled by user"))
}
}
if (this.abortSignal) {
this.abortSignal.addEventListener("abort", abortHandler, { once: true })
}
// Spawn the hook process
// On Unix: detached=true creates a process group, allowing us to kill all children
// On Windows: shell=true handles script execution
this.childProcess = spawn(this.scriptPath, [], {
stdio: ["pipe", "pipe", "pipe"],
shell: process.platform === "win32",
detached: process.platform !== "win32", // Create process group on Unix
})
let didEmitEmptyLine = false
// Set up timeout
this.timeoutHandle = setTimeout(() => {
if (this.childProcess && !this.isCompleted) {
this.childProcess.kill("SIGTERM")
reject(
new Error(
`Hook execution timed out after ${this.timeoutMs}ms. The hook script at '${this.scriptPath}' took too long to complete.`,
),
)
}
}, this.timeoutMs)
// Handle stdout
this.childProcess.stdout?.on("data", (data) => {
const output = data.toString()
this.stdoutBuffer += output
this.handleOutput(output, didEmitEmptyLine, "stdout")
if (!didEmitEmptyLine && output) {
this.emit("line", "", "stdout") // Signal start of output
didEmitEmptyLine = true
}
})
// Handle stderr
this.childProcess.stderr?.on("data", (data) => {
const output = data.toString()
this.stderrBuffer += output
this.handleOutput(output, didEmitEmptyLine, "stderr")
if (!didEmitEmptyLine && output) {
this.emit("line", "", "stderr") // Signal start of output
didEmitEmptyLine = true
}
})
// Handle process completion
this.childProcess.on("close", (code, signal) => {
this.exitCode = code
this.isCompleted = true
this.emitRemainingBuffer()
// Unregister from active processes
HookProcessRegistry.unregister(this)
// Clear timers
if (this.hotTimer) {
clearTimeout(this.hotTimer)
this.isHot = false
}
if (this.timeoutHandle) {
clearTimeout(this.timeoutHandle)
this.timeoutHandle = null
}
// Remove abort listener
if (this.abortSignal) {
this.abortSignal.removeEventListener("abort", abortHandler)
}
this.emit("completed", code, signal)
if (code === 0) {
resolve()
} else {
reject(new Error(`Hook exited with code ${code}${signal ? `, signal ${signal}` : ""}`))
}
})
// Handle process errors
this.childProcess.on("error", (error) => {
// Unregister from active processes
HookProcessRegistry.unregister(this)
if (this.timeoutHandle) {
clearTimeout(this.timeoutHandle)
this.timeoutHandle = null
}
// Remove abort listener
if (this.abortSignal) {
this.abortSignal.removeEventListener("abort", abortHandler)
}
this.emit("error", error)
reject(error)
})
// Send input to the process
try {
this.childProcess.stdin?.write(inputJson)
this.childProcess.stdin?.end()
} catch (error) {
reject(new Error(`Failed to write input to hook: ${error}`))
}
})
}
/**
* Handle output data and emit line events.
* Enforces 1MB total output limit to prevent memory issues.
*/
private handleOutput(data: string, _didEmitEmptyLine: boolean, stream: "stdout" | "stderr"): void {
// Check output size limit
const dataSize = Buffer.byteLength(data)
const currentTotalSize = this.stdoutSize + this.stderrSize
if (currentTotalSize + dataSize > MAX_HOOK_OUTPUT_SIZE) {
if (!this.outputTruncated) {
this.outputTruncated = true
const truncationMsg = "\n\n[Output truncated: exceeded 1MB limit]"
this.emit("line", truncationMsg, stream)
console.warn(`[HookProcess] Output exceeded ${MAX_HOOK_OUTPUT_SIZE} bytes, truncating`)
}
return // Drop further output
}
// Track size by stream
if (stream === "stdout") {
this.stdoutSize += dataSize
} else {
this.stderrSize += dataSize
}
// Set process as hot (actively outputting)
this.isHot = true
if (this.hotTimer) {
clearTimeout(this.hotTimer)
}
// Use a shorter hot timeout for hooks since they typically complete quickly
const hotTimeout = 1000 // 1 second
this.hotTimer = setTimeout(() => {
this.isHot = false
}, hotTimeout)
// Store full output
this.fullOutput += data
// Emit lines immediately
this.emitLines(data, stream)
}
/**
* Emit complete lines from buffered output
*/
private emitLines(chunk: string, stream: "stdout" | "stderr"): void {
this.buffer += chunk
let lineEndIndex
while ((lineEndIndex = this.buffer.indexOf("\n")) !== -1) {
const line = this.buffer.slice(0, lineEndIndex).trimEnd()
this.emit("line", line, stream)
this.buffer = this.buffer.slice(lineEndIndex + 1)
}
this.lastRetrievedIndex = this.fullOutput.length - this.buffer.length
}
/**
* Emit any remaining buffered output when process completes
*/
private emitRemainingBuffer(): void {
if (this.buffer) {
const remainingBuffer = this.buffer.trimEnd()
if (remainingBuffer) {
// Determine which stream this came from based on content
// This is a fallback; in practice, line events should capture most output
this.emit("line", remainingBuffer, "stdout")
}
this.buffer = ""
this.lastRetrievedIndex = this.fullOutput.length
}
}
/**
* Get unretrieved output (for compatibility with terminal process interface)
*/
getUnretrievedOutput(): string {
const unretrieved = this.fullOutput.slice(this.lastRetrievedIndex)
this.lastRetrievedIndex = this.fullOutput.length
return unretrieved.trimEnd()
}
/**
* Check if process is actively outputting
*/
isProcessHot(): boolean {
return this.isHot
}
/**
* Get the complete stdout buffer (for JSON parsing)
*/
getStdout(): string {
return this.stdoutBuffer
}
/**
* Get the complete stderr buffer (for error reporting)
*/
getStderr(): string {
return this.stderrBuffer
}
/**
* Get the exit code
*/
getExitCode(): number | null {
return this.exitCode
}
/**
* Check if process has completed
*/
hasCompleted(): boolean {
return this.isCompleted
}
/**
* Terminate the process and its entire process tree.
* Uses process groups on Unix to kill child processes.
* Implements graceful shutdown with 2-second timeout before force kill.
*/
async terminate(): Promise<void> {
if (!this.childProcess || this.isCompleted) {
return
}
const pid = this.childProcess.pid
if (!pid) {
return
}
try {
// On Unix, kill process group (negative PID kills all children)
// On Windows, just kill the process (tree-kill would be better but adds dependency)
if (process.platform !== "win32") {
// Kill process group with SIGTERM for graceful shutdown
process.kill(-pid, "SIGTERM")
} else {
// On Windows, just kill the process
this.childProcess.kill("SIGTERM")
}
// Wait up to 2 seconds for graceful shutdown
const gracefulTimeout = new Promise((resolve) => setTimeout(resolve, 2000))
const processExit = new Promise((resolve) => {
this.childProcess?.once("exit", resolve)
})
await Promise.race([processExit, gracefulTimeout])
// Force kill if still running
if (!this.isCompleted) {
if (process.platform !== "win32") {
process.kill(-pid, "SIGKILL")
} else {
this.childProcess?.kill("SIGKILL")
}
}
} catch (error) {
// Process might already be dead, which is fine
console.debug(`[HookProcess] Error during termination: ${error}`)
} finally {
// Clear timeout regardless
if (this.timeoutHandle) {
clearTimeout(this.timeoutHandle)
this.timeoutHandle = null
}
}
}
}
+63
View File
@@ -0,0 +1,63 @@
import { HookProcess } from "./HookProcess"
/**
* Global registry for tracking active hook processes.
*
* Purpose:
* - Prevents zombie processes by tracking all running hooks
* - Enables cleanup on extension deactivation
* - Provides visibility into active hook executions
*
* Usage:
* - HookProcess automatically registers/unregisters itself
* - Extension deactivation calls terminateAll()
* - Can query active count for monitoring/debugging
*/
export class HookProcessRegistry {
private static activeProcesses = new Set<HookProcess>()
/**
* Register a hook process as active.
* Called by HookProcess when execution starts.
*/
static register(process: HookProcess): void {
HookProcessRegistry.activeProcesses.add(process)
}
/**
* Unregister a hook process (completed or failed).
* Called by HookProcess when execution ends.
*/
static unregister(process: HookProcess): void {
HookProcessRegistry.activeProcesses.delete(process)
}
/**
* Terminate all active hook processes.
* Called during extension deactivation to prevent zombie processes.
*/
static async terminateAll(): Promise<void> {
const processes = Array.from(HookProcessRegistry.activeProcesses)
if (processes.length > 0) {
console.log(`[HookProcessRegistry] Terminating ${processes.length} active hook process(es)`)
await Promise.all(processes.map((p) => p.terminate()))
HookProcessRegistry.activeProcesses.clear()
}
}
/**
* Get the number of currently active hook processes.
* Useful for monitoring and debugging.
*/
static getActiveCount(): number {
return HookProcessRegistry.activeProcesses.size
}
/**
* Clear the registry (for testing only).
* @internal
*/
static resetForTesting(): void {
HookProcessRegistry.activeProcesses.clear()
}
}
+21 -12
View File
@@ -40,10 +40,19 @@ describe("Hook System", () => {
sandbox.stub(StateManager, "get").returns({
getGlobalStateKey: () => [{ path: tempDir }],
} as any)
// Reset hook discovery cache for clean test state
const { HookDiscoveryCache } = await import("../HookDiscoveryCache")
HookDiscoveryCache.resetForTesting()
})
afterEach(async () => {
sandbox.restore()
// Clean up hook discovery cache
const { HookDiscoveryCache } = await import("../HookDiscoveryCache")
HookDiscoveryCache.resetForTesting()
try {
await fs.rm(tempDir, { recursive: true, force: true })
} catch (error) {
@@ -184,18 +193,18 @@ console.log("not valid json")`
const factory = new HookFactory()
const runner = await factory.create("PreToolUse")
try {
await runner.run({
taskId: "test-task",
preToolUse: {
toolName: "test_tool",
parameters: {},
},
})
throw new Error("Should have thrown")
} catch (error: any) {
error.message.should.match(/Failed to parse hook output/)
}
// When hook exits 0 but has malformed JSON, it returns success without context
const result = await runner.run({
taskId: "test-task",
preToolUse: {
toolName: "test_tool",
parameters: {},
},
})
// Hook succeeded (exit 0) but couldn't parse JSON, so returns success without context
result.shouldContinue.should.be.true()
;(result.contextModification === undefined || result.contextModification === "").should.be.true()
})
it("should pass hook input via stdin", async () => {
+23 -14
View File
@@ -42,10 +42,19 @@ describe("TaskCancel Hook", () => {
} as any)
getEnv = () => ({ tempDir })
// Reset hook discovery cache for clean test state
const { HookDiscoveryCache } = await import("../HookDiscoveryCache")
HookDiscoveryCache.resetForTesting()
})
afterEach(async () => {
sandbox.restore()
// Clean up hook discovery cache
const { HookDiscoveryCache } = await import("../HookDiscoveryCache")
HookDiscoveryCache.resetForTesting()
try {
await fs.rm(tempDir, { recursive: true, force: true })
} catch (error) {
@@ -351,21 +360,21 @@ console.log("not valid json")`
const factory = new HookFactory()
const runner = await factory.create("TaskCancel")
try {
await runner.run({
taskId: "test-task-id",
taskCancel: {
taskMetadata: {
taskId: "test-task-id",
ulid: "test-ulid",
completionStatus: "cancelled",
},
// When hook exits 0 but has malformed JSON, it returns success without context
const result = await runner.run({
taskId: "test-task-id",
taskCancel: {
taskMetadata: {
taskId: "test-task-id",
ulid: "test-ulid",
completionStatus: "cancelled",
},
})
throw new Error("Should have thrown")
} catch (error: any) {
error.message.should.match(/Failed to parse hook output/)
}
},
})
// Hook succeeded (exit 0) but couldn't parse JSON, so returns success without context
result.shouldContinue.should.be.true()
;(result.contextModification === undefined || result.contextModification === "").should.be.true()
})
})
+21 -15
View File
@@ -41,6 +41,12 @@ describe("TaskResume Hook", () => {
})
afterEach(async () => {
sandbox.restore()
// Clean up hook discovery cache
const { HookDiscoveryCache } = await import("../HookDiscoveryCache")
HookDiscoveryCache.resetForTesting()
sandbox.restore()
try {
await fs.rm(tempDir, { recursive: true, force: true })
@@ -386,22 +392,22 @@ console.log("not valid json")`
const factory = new HookFactory()
const runner = await factory.create("TaskResume")
try {
await runner.run({
taskId: "test-task",
taskResume: {
taskMetadata: { taskId: "test-task", ulid: "test-ulid" },
previousState: {
lastMessageTs: Date.now().toString(),
messageCount: "5",
conversationHistoryDeleted: "false",
},
// When hook exits 0 but has malformed JSON, it returns success without context
const result = await runner.run({
taskId: "test-task",
taskResume: {
taskMetadata: { taskId: "test-task", ulid: "test-ulid" },
previousState: {
lastMessageTs: Date.now().toString(),
messageCount: "5",
conversationHistoryDeleted: "false",
},
})
throw new Error("Should have thrown parse error")
} catch (error: any) {
error.message.should.match(/Failed to parse hook output/)
}
},
})
// Hook succeeded (exit 0) but couldn't parse JSON, so returns success without context
result.shouldContinue.should.be.true()
;(result.contextModification === undefined || result.contextModification === "").should.be.true()
})
it("should handle invalid timestamp gracefully", async () => {
+19 -14
View File
@@ -46,6 +46,11 @@ describe("TaskStart Hook", () => {
afterEach(async () => {
sandbox.restore()
// Clean up hook discovery cache
const { HookDiscoveryCache } = await import("../HookDiscoveryCache")
HookDiscoveryCache.resetForTesting()
try {
await fs.rm(tempDir, { recursive: true, force: true })
} catch (error) {
@@ -280,21 +285,21 @@ console.log("not valid json")`
const factory = new HookFactory()
const runner = await factory.create("TaskStart")
try {
await runner.run({
taskId: "test-task-id",
taskStart: {
taskMetadata: {
taskId: "test-task-id",
ulid: "test-ulid",
initialTask: "Test task",
},
// When hook exits 0 but has malformed JSON, it returns success without context
const result = await runner.run({
taskId: "test-task-id",
taskStart: {
taskMetadata: {
taskId: "test-task-id",
ulid: "test-ulid",
initialTask: "Test task",
},
})
throw new Error("Should have thrown")
} catch (error: any) {
error.message.should.match(/Failed to parse hook output/)
}
},
})
// Hook succeeded (exit 0) but couldn't parse JSON, so returns success without context
result.shouldContinue.should.be.true()
;(result.contextModification === undefined || result.contextModification === "").should.be.true()
})
})
@@ -42,6 +42,11 @@ describe("UserPromptSubmit Hook", () => {
afterEach(async () => {
sandbox.restore()
// Clean up hook discovery cache
const { HookDiscoveryCache } = await import("../HookDiscoveryCache")
HookDiscoveryCache.resetForTesting()
try {
await fs.rm(tempDir, { recursive: true, force: true })
} catch (error) {
@@ -235,18 +240,18 @@ console.log("not valid json")`
const factory = new HookFactory()
const runner = await factory.create("UserPromptSubmit")
try {
await runner.run({
taskId: "test-task",
userPromptSubmit: {
prompt: "Test",
attachments: [],
},
})
throw new Error("Should have thrown parse error")
} catch (error: any) {
error.message.should.match(/Failed to parse hook output/)
}
// When hook exits 0 but has malformed JSON, it returns success without context
const result = await runner.run({
taskId: "test-task",
userPromptSubmit: {
prompt: "Test",
attachments: [],
},
})
// Hook succeeded (exit 0) but couldn't parse JSON, so returns success without context
result.shouldContinue.should.be.true()
;(result.contextModification === undefined || result.contextModification === "").should.be.true()
})
it("should handle hook script errors", async () => {
@@ -461,18 +466,18 @@ console.log(JSON.stringify({
it("should work with malformed-json fixture", async () => {
const runner = await loadFixtureAndCreateRunner("malformed-json")
try {
await runner.run({
taskId: "test-task",
userPromptSubmit: {
prompt: "Test",
attachments: [],
},
})
throw new Error("Should have thrown parse error")
} catch (error: any) {
error.message.should.match(/Failed to parse hook output/)
}
// When hook exits 0 but has malformed JSON, it returns success without context
const result = await runner.run({
taskId: "test-task",
userPromptSubmit: {
prompt: "Test",
attachments: [],
},
})
// Hook succeeded (exit 0) but couldn't parse JSON, so returns success without context
result.shouldContinue.should.be.true()
;(result.contextModification === undefined || result.contextModification === "").should.be.true()
})
it("should work with multiline fixture", async () => {
+328 -71
View File
@@ -1,4 +1,3 @@
import { spawn } from "node:child_process"
import fs from "fs/promises"
import path from "path"
import { version as clineVersion } from "../../../package.json"
@@ -17,6 +16,8 @@ import {
} from "../../shared/proto/cline/hooks"
import { getAllHooksDirs } from "../storage/disk"
import { StateManager } from "../storage/StateManager"
import { HookExecutionError } from "./HookError"
import { HookProcess } from "./HookProcess"
// Hook execution timeout (30 seconds)
const HOOK_EXECUTION_TIMEOUT_MS = 30000
@@ -24,6 +25,73 @@ const HOOK_EXECUTION_TIMEOUT_MS = 30000
// Maximum size for context modification (to prevent prompt overflow)
const MAX_CONTEXT_MODIFICATION_SIZE = 50000 // ~50KB
/**
* Validates hook output JSON structure.
* Ensures required fields are present and have correct types.
*/
function validateHookOutput(output: any): { valid: boolean; error?: string } {
// Check if deprecated shouldContinue field is present
if (output.shouldContinue !== undefined) {
return {
valid: false,
error:
"Invalid hook output: The 'shouldContinue' field has been removed.\n\n" +
"Use 'cancel: true' instead to trigger task cancellation.\n\n" +
"Migration guide:\n" +
" Before: { shouldContinue: false, errorMessage: '...' }\n" +
" After: { cancel: true, errorMessage: '...' }\n\n" +
"Example valid response:\n" +
JSON.stringify(
{
cancel: false,
contextModification: "Optional context here",
errorMessage: "",
},
null,
2,
),
}
}
// cancel is optional, but if provided must be a boolean
if (output.cancel !== undefined && typeof output.cancel !== "boolean") {
return {
valid: false,
error:
"Invalid hook output: 'cancel' must be a boolean.\n\n" +
`Received type: ${typeof output.cancel}\n\n` +
"Example valid response:\n" +
JSON.stringify({ cancel: true, errorMessage: "Cancelling task" }, null, 2),
}
}
// contextModification is optional, but if provided must be a string
if (output.contextModification !== undefined && typeof output.contextModification !== "string") {
return {
valid: false,
error:
"Invalid hook output: 'contextModification' must be a string.\n\n" +
`Received type: ${typeof output.contextModification}\n\n` +
"Example valid response:\n" +
JSON.stringify({ contextModification: "Context here" }, null, 2),
}
}
// errorMessage is optional, but if provided must be a string
if (output.errorMessage !== undefined && typeof output.errorMessage !== "string") {
return {
valid: false,
error:
"Invalid hook output: 'errorMessage' must be a string.\n\n" +
`Received type: ${typeof output.errorMessage}\n\n` +
"Example valid response:\n" +
JSON.stringify({ cancel: true, errorMessage: "Error description" }, null, 2),
}
}
return { valid: true }
}
export interface Hooks {
PreToolUse: {
preToolUse: PreToolUseData
@@ -94,8 +162,23 @@ export abstract class HookRunner<Name extends HookName> {
abstract [exec](params: HookInput): Promise<HookOutput>
// Completes the hook input parameters by adding the common hook parameters to the
// hook-specific parameters provided by the caller.
/**
* Completes the hook input by adding common metadata to caller-provided parameters.
*
* This method enriches the hook-specific input (like preToolUse or postToolUse data)
* with standard information that all hooks receive:
* - clineVersion: Current Cline extension version
* - hookName: The type of hook being executed (e.g., "PreToolUse")
* - timestamp: Execution time in milliseconds since epoch
* - workspaceRoots: Array of workspace folder paths
* - userId: Cline user ID, machine ID, or generated UUID
*
* This separation allows hook scripts to receive consistent metadata without
* requiring callers to manually provide it each time.
*
* @param params The hook-specific input parameters (taskId + hook data)
* @returns Complete HookInput ready to be serialized and sent to the hook script
*/
protected async completeParams(params: NamedHookInput<Name>): Promise<HookInput> {
const workspaceRoots =
StateManager.get()
@@ -112,73 +195,108 @@ export abstract class HookRunner<Name extends HookName> {
}
}
// The NoOpRunner is used when there's no hook to run. It immediately succeeds.
/**
* NoOpRunner is a null-object pattern implementation used when no hook scripts are found.
*
* Instead of returning null or requiring null checks everywhere, we return a NoOpRunner
* that always succeeds immediately without any side effects. This simplifies the calling
* code and ensures hooks are always optional/gracefully degraded.
*
* @template Name The type of hook this runner represents
*/
class NoOpRunner<Name extends HookName> extends HookRunner<Name> {
/**
* Executes a no-op hook that always succeeds.
* @param _ Hook input (ignored)
* @returns A successful hook output (no cancellation)
*/
override async [exec](_: HookInput): Promise<HookOutput> {
return HookOutput.create({
shouldContinue: true,
cancel: false,
})
}
}
// Actually runs a hook by executing a script and passing JSON into it.
/**
* Callback type for streaming hook output
*/
export type HookStreamCallback = (line: string, stream: "stdout" | "stderr") => void
/**
* Executes a hook script as a child process with real-time output streaming.
*
* Key features:
* - Spawns the hook script and communicates via stdin/stdout/stderr
* - Streams output line-by-line via callback for real-time UI updates
* - Enforces 30-second timeout (configurable via HOOK_EXECUTION_TIMEOUT_MS)
* - Supports cancellation via AbortSignal
* - Parses JSON output from stdout, attempting to extract it even if mixed with debug output
* - Truncates context modifications that exceed 50KB to prevent prompt overflow
* - Handles both successful and failed executions gracefully
*
* Error handling:
* - Treats hooks as "fail-open": only shouldContinue:false blocks tool execution
* - Hook script errors (non-zero exit) don't block tools, only explicit JSON response does
* - Timeout/cancellation errors are propagated to show "Failed" status in UI
*
* @template Name The type of hook this runner represents
*/
class StdioHookRunner<Name extends HookName> extends HookRunner<Name> {
constructor(
hookName: Name,
public readonly scriptPath: string,
private readonly streamCallback?: HookStreamCallback,
private readonly abortSignal?: AbortSignal,
) {
super(hookName)
}
override async [exec](input: HookInput): Promise<HookOutput> {
return new Promise((resolve, reject) => {
// Serialize input to JSON
const inputJson = JSON.stringify(HookInput.toJSON(input))
// Check if already aborted before starting
if (this.abortSignal?.aborted) {
throw HookExecutionError.cancellation(this.scriptPath)
}
// Spawn the hook process
const child = spawn(this.scriptPath, [], {
stdio: ["pipe", "pipe", "pipe"],
shell: process.platform === "win32",
// Serialize input to JSON
const inputJson = JSON.stringify(HookInput.toJSON(input))
// Create HookProcess for execution with streaming
const hookProcess = new HookProcess(this.scriptPath, HOOK_EXECUTION_TIMEOUT_MS, this.abortSignal)
// Set up streaming if callback is provided
if (this.streamCallback) {
const callback = this.streamCallback
hookProcess.on("line", (line: string, stream: "stdout" | "stderr") => {
callback(line, stream)
})
}
let stdout = ""
let stderr = ""
let timeoutHandle: NodeJS.Timeout | undefined
try {
// Execute the hook and wait for completion
await hookProcess.run(inputJson)
// Set up timeout
timeoutHandle = setTimeout(() => {
child.kill("SIGTERM")
reject(
new Error(
`Hook ${this.hookName} timed out after ${HOOK_EXECUTION_TIMEOUT_MS}ms. The hook script at '${this.scriptPath}' took too long to complete.`,
),
)
}, HOOK_EXECUTION_TIMEOUT_MS)
// Collect stdout
child.stdout?.on("data", (data) => {
stdout += data.toString()
})
// Collect stderr
child.stderr?.on("data", (data) => {
stderr += data.toString()
})
// Handle process completion
child.on("close", (code) => {
if (timeoutHandle) {
clearTimeout(timeoutHandle)
}
if (code !== 0) {
reject(new Error(`Hook ${this.hookName} exited with code ${code}. stderr: ${stderr}`))
return
}
// Get the complete stdout for JSON parsing
const stdout = hookProcess.getStdout()
const stderr = hookProcess.getStderr()
const exitCode = hookProcess.getExitCode()
// Try to parse JSON output
const parseJsonOutput = (): HookOutput | null => {
try {
// Parse and validate output
const outputData = JSON.parse(stdout)
// Validate structure before creating HookOutput
const validation = validateHookOutput(outputData)
if (!validation.valid) {
// Don't use streamCallback - it creates red text
// Throw validation error instead
throw HookExecutionError.validation(
validation.error!,
this.scriptPath,
stdout.slice(0, 500) + (stdout.length > 500 ? "..." : ""),
)
}
const output = HookOutput.fromJSON(outputData)
// Validate and truncate context modification if too large
@@ -192,29 +310,127 @@ class StdioHookRunner<Name extends HookName> extends HookRunner<Name> {
"\n\n[... context truncated due to size limit ...]"
}
resolve(output)
} catch (error) {
reject(new Error(`Failed to parse hook output: ${error}. stdout: ${stdout}`))
}
})
return output
} catch (parseError) {
// If it's already a HookExecutionError, re-throw it
if (HookExecutionError.isHookError(parseError)) {
throw parseError
}
// Handle process errors
child.on("error", (error) => {
if (timeoutHandle) {
clearTimeout(timeoutHandle)
}
reject(new Error(`Failed to execute hook ${this.hookName}: ${error.message}`))
})
// Try to extract JSON from stdout (it might have debug output before/after)
const jsonMatch = stdout.match(/\{[\s\S]*\}/)
if (jsonMatch) {
try {
const outputData = JSON.parse(jsonMatch[0])
// Send input to the process
child.stdin?.write(inputJson)
child.stdin?.end()
})
// Validate structure
const validation = validateHookOutput(outputData)
if (!validation.valid) {
throw HookExecutionError.validation(
validation.error!,
this.scriptPath,
stdout.slice(0, 500) + (stdout.length > 500 ? "..." : ""),
)
}
const output = HookOutput.fromJSON(outputData)
// Validate and truncate context modification if too large
if (output.contextModification && output.contextModification.length > MAX_CONTEXT_MODIFICATION_SIZE) {
console.warn(
`Hook ${this.hookName} returned contextModification of ${output.contextModification.length} bytes, ` +
`truncating to ${MAX_CONTEXT_MODIFICATION_SIZE} bytes`,
)
output.contextModification =
output.contextModification.slice(0, MAX_CONTEXT_MODIFICATION_SIZE) +
"\n\n[... context truncated due to size limit ...]"
}
return output
} catch (_extractError) {
// Fall through to validation error below
}
}
// Couldn't parse JSON at all
const errorMsg = parseError instanceof Error ? parseError.message : String(parseError)
throw HookExecutionError.validation(
`Failed to parse JSON output: ${errorMsg}`,
this.scriptPath,
stdout.slice(0, 500) + (stdout.length > 500 ? "..." : ""),
)
}
}
const parsedOutput = parseJsonOutput()
// If we have valid JSON, honor it regardless of exit code
if (parsedOutput) {
// Log warning if non-zero exit but valid JSON (for developers)
if (exitCode !== 0) {
console.warn(`[Hook ${this.hookName}] Exited with code ${exitCode} but provided valid JSON response`)
if (stderr) {
console.warn(`[Hook ${this.hookName}] stderr: ${stderr}`)
}
}
return parsedOutput
}
// No valid JSON found
if (exitCode === 0) {
// Hook succeeded but didn't provide JSON - allow execution (no cancellation)
console.warn(`[Hook ${this.hookName}] Completed successfully but no JSON response found`)
return HookOutput.create({
cancel: false,
})
} else {
// Hook failed with non-zero exit
throw HookExecutionError.execution(this.scriptPath, exitCode ?? 1, stderr)
}
} catch (error) {
// If it's already a HookExecutionError, re-throw it
if (HookExecutionError.isHookError(error)) {
throw error
}
// Hook execution failed - categorize the error
const stderr = hookProcess.getStderr()
const exitCode = hookProcess.getExitCode()
// Check for timeout
if (error instanceof Error && error.message.includes("timed out")) {
throw HookExecutionError.timeout(this.scriptPath, HOOK_EXECUTION_TIMEOUT_MS, stderr)
}
// Check for cancellation
if (error instanceof Error && error.message.includes("cancelled")) {
throw HookExecutionError.cancellation(this.scriptPath)
}
// Generic execution error
throw HookExecutionError.execution(this.scriptPath, exitCode ?? 1, stderr)
}
}
}
// CombinedHookRunner runs multiple hooks and combines the results. Used when a workspace
// has multiple roots contributing the same hook.
/**
* Combines multiple hook runners and executes them in parallel.
*
* Used in multi-root workspaces where both global hooks (from ~/Documents/Cline/Rules/Hooks/)
* and workspace-specific hooks (from each workspace's .clinerules/hooks/) exist for the
* same hook type.
*
* Behavior:
* - Executes all hooks concurrently using Promise.all
* - If ANY hook returns cancel: true, the merged result will have cancel: true
* - Concatenates all contextModification strings with double newlines
* - Concatenates all errorMessage strings with single newlines
*
* This means if ANY hook requests cancellation, the task will be cancelled.
* All hooks' context contributions are merged into the conversation.
*
* @template Name The type of hook this runner represents
*/
class CombinedHookRunner<Name extends HookName> extends HookRunner<Name> {
constructor(
hookName: Name,
@@ -228,11 +444,11 @@ class CombinedHookRunner<Name extends HookName> extends HookRunner<Name> {
const results = await Promise.all(this.runners.map((runner) => runner[exec](input)))
// Merge results:
// - If any hook indicates execution should stop, then stop
// - If any hook requests cancellation, set cancel to true
// - Combine context contributions from all hooks
// - Collect any error messages
const shouldContinue = results.every((result) => result.shouldContinue)
const cancel = results.some((result) => result.cancel === true)
const contextModification = results
.map((result) => result.contextModification?.trim())
.filter((mod) => mod)
@@ -243,7 +459,7 @@ class CombinedHookRunner<Name extends HookName> extends HookRunner<Name> {
.join("\n")
return HookOutput.create({
shouldContinue,
cancel,
contextModification,
errorMessage,
})
@@ -285,9 +501,50 @@ function isExpectedHookError(error: unknown): boolean {
}
export class HookFactory {
async create<Name extends HookName>(hookName: Name): Promise<HookRunner<Name>> {
/**
* Check if any hook scripts exist for the given hook name
* @returns true if at least one hook script exists, false otherwise
*/
async hasHook<Name extends HookName>(hookName: Name): Promise<boolean> {
const scripts = await HookFactory.findHookScripts(hookName)
const runners = scripts.map((script) => new StdioHookRunner(hookName, script))
return scripts.length > 0
}
/**
* Create a hook runner without streaming support (backwards compatible)
*/
async create<Name extends HookName>(hookName: Name): Promise<HookRunner<Name>> {
return this.createWithStreaming(hookName)
}
/**
* Create a hook runner with optional streaming callback and abort signal support.
*
* This is the primary factory method for creating hooks. It:
* 1. Uses HookDiscoveryCache to find hook scripts (fast O(1) lookup after first scan)
* 2. Creates StdioHookRunner instances for each discovered script
* 3. Returns NoOpRunner if no scripts found (null-object pattern)
* 4. Returns CombinedHookRunner if multiple scripts found (parallel execution)
*
* The streaming callback receives hook output line-by-line in real-time, allowing
* the UI to display progress as the hook executes. The abort signal enables
* cancellation of long-running hooks.
*
* @param hookName The type of hook to create (e.g., "PreToolUse", "PostToolUse")
* @param streamCallback Optional callback for real-time output streaming
* @param abortSignal Optional signal to cancel hook execution
* @returns A HookRunner that executes the hook(s), or NoOpRunner if none found
*/
async createWithStreaming<Name extends HookName>(
hookName: Name,
streamCallback?: HookStreamCallback,
abortSignal?: AbortSignal,
): Promise<HookRunner<Name>> {
// Use cache for hook discovery instead of direct file system scan
const { HookDiscoveryCache } = await import("./HookDiscoveryCache")
const scripts = await HookDiscoveryCache.getInstance().get(hookName)
const runners = scripts.map((script) => new StdioHookRunner(hookName, script, streamCallback, abortSignal))
if (runners.length === 0) {
return new NoOpRunner(hookName)
}
@@ -316,7 +573,7 @@ export class HookFactory {
* @returns the path to the hook to execute, or undefined if none found
* @throws Error if an unexpected file system error occurs
*/
private static async findHookInHooksDir(hookName: HookName, hooksDir: string): Promise<string | undefined> {
static async findHookInHooksDir(hookName: HookName, hooksDir: string): Promise<string | undefined> {
return process.platform === "win32"
? HookFactory.findWindowsHook(hookName, hooksDir)
: HookFactory.findUnixHook(hookName, hooksDir)
+8
View File
@@ -62,6 +62,14 @@ export class TaskState {
didFinishAbortingStream = false
abandoned = false
// Hook execution tracking for cancellation
activeHookExecution?: {
hookName: string
toolName?: string
messageTs: number
abortController: AbortController
}
// Auto-context summarization
currentlySummarizing: boolean = false
lastAutoCompactTriggerIndex?: number
+421 -53
View File
@@ -6,13 +6,14 @@ import { BrowserSession } from "@services/browser/BrowserSession"
import { UrlContentFetcher } from "@services/browser/UrlContentFetcher"
import { featureFlagsService } from "@services/feature-flags"
import { McpHub } from "@services/mcp/McpHub"
import { ClineAsk, ClineSay } from "@shared/ExtensionMessage"
import { ClineAsk, ClineSay, ClineSayHook } from "@shared/ExtensionMessage"
import { ClineDefaultTool } from "@shared/tools"
import { ClineAskResponse } from "@shared/WebviewMessage"
import * as vscode from "vscode"
import { modelDoesntSupportWebp } from "@/utils/model-utils"
import { ToolUse } from "../assistant-message"
import { ContextManager } from "../context/context-management/ContextManager"
import { HookExecutionError } from "../hooks/HookError"
import { HookFactory } from "../hooks/hook-factory"
import { formatResponse } from "../prompts/responses"
import { StateManager } from "../storage/StateManager"
@@ -228,7 +229,14 @@ export class ToolExecutor {
}
/**
* Handles errors during tool execution
* Handles errors during tool execution.
*
* Logs the error, displays it to the user via the UI, and adds an error
* result to the conversation context so the AI can see what went wrong.
*
* @param action Description of what was being attempted (e.g., "executing read_file")
* @param error The error that occurred
* @param block The tool use block that caused the error
*/
private async handleError(action: string, error: Error, block: ToolUse): Promise<void> {
console.log(error)
@@ -240,6 +248,17 @@ export class ToolExecutor {
this.pushToolResult(errorResponse, block)
}
/**
* Pushes a tool result to the user message content.
*
* This is a critical method that:
* - Formats the tool result appropriately for the API
* - Adds it to the conversation context
* - Marks that a tool has been used in this turn
*
* @param content The tool response content to add
* @param block The tool use block that generated this result
*/
private pushToolResult = (content: ToolResponse, block: ToolUse) => {
// Use the ToolResultUtils to properly format and push the tool result
ToolResultUtils.pushToolResult(
@@ -265,7 +284,18 @@ export class ToolExecutor {
]
/**
* Execute a tool through the coordinator if it's registered
* Execute a tool through the coordinator if it's registered.
*
* This is the main entry point for tool execution, called by the Task class.
* It handles:
* - Checking if the tool is registered with the coordinator
* - Validating tool execution is allowed (not rejected, not already used, etc.)
* - Enforcing plan mode restrictions on file modification tools
* - Delegating to partial or complete block handlers
* - Error handling and checkpointing
*
* @param block The tool use block to execute
* @returns true if the tool was handled (even if execution failed), false if not registered
*/
private async execute(block: ToolUse): Promise<boolean> {
if (!this.coordinator.has(block.name)) {
@@ -330,14 +360,27 @@ export class ToolExecutor {
}
/**
* Check if a tool is restricted in plan mode
* Check if a tool is restricted in plan mode.
*
* In strict plan mode, file modification tools (write_to_file, editedExistingFile, etc.)
* are blocked. The AI must switch to Act mode to use these tools.
*
* @param toolName The name of the tool to check
* @returns true if the tool is restricted in plan mode, false otherwise
*/
private isPlanModeToolRestricted(toolName: ClineDefaultTool): boolean {
return ToolExecutor.PLAN_MODE_RESTRICTED_TOOLS.includes(toolName)
}
/**
* Create a tool rejection message and add it to user message content
* Create a tool rejection message and add it to user message content.
*
* Used when a tool cannot be executed (e.g., user rejected a previous tool,
* tool was interrupted, etc.). Adds a text message to the conversation explaining
* why the tool was not executed.
*
* @param block The tool use block that was rejected
* @param reason Human-readable explanation of why the tool was rejected
*/
private createToolRejectionMessage(block: ToolUse, reason: string): void {
this.taskState.userMessageContent.push({
@@ -385,7 +428,16 @@ export class ToolExecutor {
}
/**
* Handle partial block streaming UI updates
* Handle partial block streaming UI updates.
*
* During streaming API responses, the AI sends partial tool use blocks as they're
* generated. This method updates the UI to show the tool being constructed in real-time.
*
* NOTE: This is ONLY for UI updates. No tool results are pushed to the conversation
* during partial block handling. The complete block handler will add the final result.
*
* @param block The partial tool use block with incomplete parameters
* @param config The task configuration containing all necessary context
*/
private async handlePartialBlock(block: ToolUse, config: TaskConfig): Promise<void> {
// NOTE: We don't push tool results in partial blocks because this is only for UI streaming.
@@ -402,52 +454,241 @@ export class ToolExecutor {
}
/**
* Handle complete block execution
* 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
*
* 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
* @param config The task configuration containing all necessary context
*/
private async handleCompleteBlock(block: ToolUse, config: any): Promise<void> {
// Check abort flag at the very start to prevent execution after cancellation
if (this.taskState.abort) {
return
}
// Check if hooks are enabled (both feature flag and user setting must be true)
const featureFlagEnabled = featureFlagsService.getHooksEnabled()
const userEnabled = this.stateManager.getGlobalSettingsKey("hooksEnabled")
const hooksEnabled = featureFlagEnabled && userEnabled
let executionSuccess = true
let toolResult: any = null
// Track if we need to cancel after hooks complete
let shouldCancelAfterHook = false
// Run PreToolUse hook, if enabled
// ============================================================
// PHASE 1: Run PreToolUse hook (OUTSIDE try-catch-finally)
// This allows early return on cancellation without triggering finally block
// ============================================================
if (hooksEnabled) {
let preToolUseResult: any = null
try {
const hookFactory = new HookFactory()
const preToolUseHook = await hookFactory.create("PreToolUse")
const hookFactory = new HookFactory()
const hasPreToolUseHook = await hookFactory.hasHook("PreToolUse")
preToolUseResult = await preToolUseHook.run({
taskId: this.taskId,
preToolUse: {
if (hasPreToolUseHook) {
let preToolUseResult: any = null
let hookMessageTs: number | undefined
const abortController = new AbortController()
try {
// 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") {
// Include a preview of content (first 200 chars)
pendingToolInfo.content = block.params.content.slice(0, 200)
}
if (block.params.diff && typeof block.params.diff === "string") {
// Include a preview of diff (first 200 chars)
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
}
// Show hook execution indicator with pending tool info
const hookMetadata: ClineSayHook = {
hookName: "PreToolUse",
toolName: block.name,
parameters: block.params,
},
})
status: "running",
pendingToolInfo, // Include tool info in hook message
}
hookMessageTs = await this.say("hook", JSON.stringify(hookMetadata))
// Check if hook wants to stop execution
if (!preToolUseResult.shouldContinue) {
const errorMessage = preToolUseResult.errorMessage || "PreToolUse hook prevented tool execution"
await this.say("error", errorMessage)
this.pushToolResult(formatResponse.toolError(errorMessage), block)
return
// Track active hook execution for cancellation (only if message was created)
if (hookMessageTs !== undefined) {
this.taskState.activeHookExecution = {
hookName: "PreToolUse",
toolName: block.name,
messageTs: hookMessageTs,
abortController,
}
}
// Create streaming callback that displays hook output in real-time
const streamCallback = async (line: string, _stream: "stdout" | "stderr") => {
// Display the output line in the UI
await this.say("hook_output", line)
}
const preToolUseHook = await hookFactory.createWithStreaming(
"PreToolUse",
streamCallback,
abortController.signal,
)
preToolUseResult = await preToolUseHook.run({
taskId: this.taskId,
preToolUse: {
toolName: block.name,
parameters: block.params,
},
})
console.log("[PreToolUse Hook]", preToolUseResult)
// Clear active hook execution
this.taskState.activeHookExecution = undefined
// Check if hook wants to cancel the task
if (preToolUseResult.cancel === true) {
// Update hook status to cancelled before triggering abort
if (hookMessageTs !== undefined) {
const clineMessages = this.messageStateHandler.getClineMessages()
const hookMessageIndex = clineMessages.findIndex((m) => m.ts === hookMessageTs)
if (hookMessageIndex !== -1) {
const cancelledMetadata: ClineSayHook = {
hookName: "PreToolUse",
toolName: block.name,
status: "cancelled",
exitCode: 130, // Standard cancellation exit code
hasJsonResponse: true,
}
await this.messageStateHandler.updateClineMessage(hookMessageIndex, {
text: JSON.stringify(cancelledMetadata),
})
}
}
// 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
}
// Update hook status to completed (only if not cancelled)
if (hookMessageTs !== undefined) {
const clineMessages = this.messageStateHandler.getClineMessages()
const hookMessageIndex = clineMessages.findIndex((m) => m.ts === hookMessageTs)
if (hookMessageIndex !== -1) {
const completedMetadata: ClineSayHook = {
hookName: "PreToolUse",
toolName: block.name,
status: "completed",
exitCode: 0,
hasJsonResponse: true,
}
await this.messageStateHandler.updateClineMessage(hookMessageIndex, {
text: JSON.stringify(completedMetadata),
})
}
}
// Add context modification to the conversation if provided by the hook
this.addHookContextToConversation(preToolUseResult.contextModification, "PreToolUse")
} catch (hookError) {
// Clear active hook execution
this.taskState.activeHookExecution = undefined
// Extract structured error info if available
const isStructuredError = HookExecutionError.isHookError(hookError)
const errorInfo = isStructuredError ? hookError.errorInfo : null
// Update hook status with structured error info (update the same message if it exists)
if (hookMessageTs !== undefined) {
const clineMessages = this.messageStateHandler.getClineMessages()
const hookMessageIndex = clineMessages.findIndex((m) => m.ts === hookMessageTs)
if (hookMessageIndex !== -1) {
const failedMetadata: ClineSayHook = {
hookName: "PreToolUse",
toolName: block.name,
status: errorInfo?.type === "cancellation" ? "cancelled" : "failed",
exitCode: errorInfo?.exitCode ?? 1,
...(errorInfo && {
error: {
type: errorInfo.type,
message: errorInfo.message,
details: errorInfo.details,
scriptPath: errorInfo.scriptPath,
},
}),
}
await this.messageStateHandler.updateClineMessage(hookMessageIndex, {
text: JSON.stringify(failedMetadata),
})
}
}
// If task was aborted (e.g., via cancel button), stop execution
if (this.taskState.abort) {
shouldCancelAfterHook = true
}
// Hook errors never block tool execution (fail-open)
// Only explicit cancel: true blocks execution
// Don't return - continue to tool execution below
}
// Add context modification to the conversation if provided by the hook
this.addHookContextToConversation(preToolUseResult.contextModification, "PreToolUse")
} catch (hookError) {
const errorMessage = `PreToolUse hook failed: ${hookError.toString()}`
await this.say("error", errorMessage)
this.pushToolResult(formatResponse.toolError(errorMessage), block)
return
}
}
// ============================================================
// 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
const executionStartTime = Date.now()
try {
// Final abort check immediately before tool execution
if (this.taskState.abort) {
return
}
// Execute the actual tool
toolResult = await this.coordinator.execute(config, block)
this.pushToolResult(toolResult, block)
@@ -457,33 +698,160 @@ export class ToolExecutor {
this.pushToolResult(toolResult, block)
throw error
} finally {
// Run PostToolUse hook if enabled
if (hooksEnabled) {
// Run PostToolUse hook if enabled and task not aborted (only runs if we entered the try block)
// Skip PostToolUse for attempt_completion since it marks task completion, not actual work
if (!this.taskState.abort && hooksEnabled && block.name !== "attempt_completion") {
const hookFactory = new HookFactory()
const postToolUseHook = await hookFactory.create("PostToolUse")
const hasPostToolUseHook = await hookFactory.hasHook("PostToolUse")
const executionTimeMs = Date.now() - executionStartTime
const postToolUseResult = await postToolUseHook.run({
taskId: this.taskId,
postToolUse: {
toolName: block.name,
parameters: block.params,
result: typeof toolResult === "string" ? toolResult : JSON.stringify(toolResult),
success: executionSuccess,
executionTimeMs,
},
})
if (hasPostToolUseHook) {
let hookMessageTs: number | undefined
const abortController = new AbortController()
// Add context modification to the conversation if provided by the hook
this.addHookContextToConversation(postToolUseResult.contextModification, "PostToolUse")
try {
// Show hook execution indicator and capture timestamp
const hookMetadata = {
hookName: "PostToolUse",
toolName: block.name,
status: "running",
}
hookMessageTs = await this.say("hook", JSON.stringify(hookMetadata))
// Log any error messages from the hook
if (postToolUseResult.errorMessage) {
this.say("error", postToolUseResult.errorMessage)
// Track active hook execution for cancellation (only if message was created)
if (hookMessageTs !== undefined) {
this.taskState.activeHookExecution = {
hookName: "PostToolUse",
toolName: block.name,
messageTs: hookMessageTs,
abortController,
}
}
// Create streaming callback that displays hook output in real-time
const streamCallback = async (line: string, _stream: "stdout" | "stderr") => {
await this.say("hook_output", line)
}
const postToolUseHook = await hookFactory.createWithStreaming(
"PostToolUse",
streamCallback,
abortController.signal,
)
const executionTimeMs = Date.now() - executionStartTime
const postToolUseResult = await postToolUseHook.run({
taskId: this.taskId,
postToolUse: {
toolName: block.name,
parameters: block.params,
result: typeof toolResult === "string" ? toolResult : JSON.stringify(toolResult),
success: executionSuccess,
executionTimeMs,
},
})
console.log("[PostToolUse Hook]", postToolUseResult)
// Clear active hook execution
this.taskState.activeHookExecution = undefined
// Check if hook wants to cancel the task
if (postToolUseResult.cancel === true) {
// Update hook status to cancelled
if (hookMessageTs !== undefined) {
const clineMessages = this.messageStateHandler.getClineMessages()
const hookMessageIndex = clineMessages.findIndex((m) => m.ts === hookMessageTs)
if (hookMessageIndex !== -1) {
const cancelledMetadata = {
hookName: "PostToolUse",
toolName: block.name,
status: "cancelled",
exitCode: 130,
hasJsonResponse: true,
}
await this.messageStateHandler.updateClineMessage(hookMessageIndex, {
text: JSON.stringify(cancelledMetadata),
})
}
}
// Hook requested cancellation - trigger task abort
const errorMessage = postToolUseResult.errorMessage || "Hook requested task cancellation"
await this.say("error", errorMessage)
// Trigger task cancellation and set flag to exit early
await config.callbacks.cancelTask()
shouldCancelAfterHook = true
} else {
// Update hook status to completed (only if not cancelled)
if (hookMessageTs !== undefined) {
const clineMessages = this.messageStateHandler.getClineMessages()
const hookMessageIndex = clineMessages.findIndex((m) => m.ts === hookMessageTs)
if (hookMessageIndex !== -1) {
const completedMetadata = {
hookName: "PostToolUse",
toolName: block.name,
status: "completed",
exitCode: 0,
hasJsonResponse: true,
}
await this.messageStateHandler.updateClineMessage(hookMessageIndex, {
text: JSON.stringify(completedMetadata),
})
}
}
// Add context modification to the conversation if provided by the hook
this.addHookContextToConversation(postToolUseResult.contextModification, "PostToolUse")
}
} catch (hookError) {
// Clear active hook execution
this.taskState.activeHookExecution = undefined
// Extract structured error info if available
const isStructuredError = HookExecutionError.isHookError(hookError)
const errorInfo = isStructuredError ? hookError.errorInfo : null
// Update hook status with structured error info (update the same message if it exists)
if (hookMessageTs !== undefined) {
const clineMessages = this.messageStateHandler.getClineMessages()
const hookMessageIndex = clineMessages.findIndex((m) => m.ts === hookMessageTs)
if (hookMessageIndex !== -1) {
const failedMetadata: ClineSayHook = {
hookName: "PostToolUse",
toolName: block.name,
status: errorInfo?.type === "cancellation" ? "cancelled" : "failed",
exitCode: errorInfo?.exitCode ?? 1,
...(errorInfo && {
error: {
type: errorInfo.type,
message: errorInfo.message,
details: errorInfo.details,
scriptPath: errorInfo.scriptPath,
},
}),
}
await this.messageStateHandler.updateClineMessage(hookMessageIndex, {
text: JSON.stringify(failedMetadata),
})
}
}
// If task was aborted (e.g., via cancel button), stop execution
if (this.taskState.abort) {
shouldCancelAfterHook = true
}
// PostToolUse hook failure is non-fatal (observation only)
}
}
}
}
// Early return if hook requested cancellation
if (shouldCancelAfterHook) {
return
}
// Handle focus chain updates
if (!block.partial && this.stateManager.getGlobalSettingsKey("focusChainSettings").enabled) {
await this.updateFCListFromToolResponse(block.params.task_progress)
+775 -179
View File
File diff suppressed because it is too large Load Diff
+12
View File
@@ -139,4 +139,16 @@ export class MessageStateHandler {
// Save changes and update history
await this.saveClineMessagesAndUpdateHistory()
}
async deleteClineMessage(index: number): Promise<void> {
if (index < 0 || index >= this.clineMessages.length) {
throw new Error(`Invalid message index: ${index}`)
}
// Remove the message at the specified index
this.clineMessages.splice(index, 1)
// Save changes and update history
await this.saveClineMessagesAndUpdateHistory()
}
}
+21
View File
@@ -53,6 +53,17 @@ https://github.com/microsoft/vscode-webview-ui-toolkit-samples/tree/main/framewo
export async function activate(context: vscode.ExtensionContext) {
setupHostProvider(context)
// Initialize hook discovery cache for performance optimization
const { HookDiscoveryCache } = await import("./core/hooks/HookDiscoveryCache")
HookDiscoveryCache.getInstance().initialize(context, (dir: string) => {
try {
const pattern = new vscode.RelativePattern(dir, "*")
return vscode.workspace.createFileSystemWatcher(pattern)
} catch {
return null
}
})
const webview = (await initialize(context)) as VscodeWebviewProvider
Logger.log("Cline extension activated")
@@ -446,11 +457,21 @@ async function getBinaryLocation(name: string): Promise<string> {
// This method is called when your extension is deactivated
export async function deactivate() {
Logger.log("Cline extension deactivating, cleaning up resources...")
tearDown()
// Clean up test mode
cleanupTestMode()
// Kill any running hook processes to prevent zombies
const { HookProcessRegistry } = await import("./core/hooks/HookProcessRegistry")
await HookProcessRegistry.terminateAll()
// Clean up hook discovery cache
const { HookDiscoveryCache } = await import("./core/hooks/HookDiscoveryCache")
HookDiscoveryCache.getInstance().dispose()
Logger.log("Cline extension deactivated")
}
@@ -87,7 +87,7 @@ export class FeatureFlagsService {
}
public getHooksEnabled(): boolean {
return this.getBooleanFlagEnabled(FeatureFlag.HOOKS)
return true //this.getBooleanFlagEnabled(FeatureFlag.HOOKS, false)
}
/**
+30
View File
@@ -167,6 +167,8 @@ export type ClineSay =
| "load_mcp_documentation"
| "info" // Added for general informational messages like retry status
| "task_progress"
| "hook"
| "hook_output"
export interface ClineSayTool {
tool:
@@ -187,6 +189,34 @@ export interface ClineSayTool {
operationIsLocatedInWorkspace?: boolean
}
export interface ClineSayHook {
hookName: string // Name of the hook (e.g., "PreToolUse", "PostToolUse")
toolName?: string // Tool name if applicable (for PreToolUse/PostToolUse)
status: "running" | "completed" | "failed" | "cancelled" // Execution status
exitCode?: number // Exit code when completed
hasJsonResponse?: boolean // Whether a JSON response was parsed
// Pending tool information (only present during PreToolUse "running" status)
pendingToolInfo?: {
tool: string // Tool name (e.g., "write_to_file", "execute_command")
path?: string // File path for file operations
command?: string // Command for execute_command
content?: string // Content preview (first 200 chars)
diff?: string // Diff preview (first 200 chars)
regex?: string // Regex pattern for search_files
url?: string // URL for web_fetch or browser_action
mcpTool?: string // MCP tool name
mcpServer?: string // MCP server name
resourceUri?: string // MCP resource URI
}
// Structured error information (only present when status is "failed")
error?: {
type: "timeout" | "validation" | "execution" | "cancellation" // Type of error
message: string // User-friendly error message
details?: string // Technical details for expansion
scriptPath?: string // Path to the hook script
}
}
// must keep in sync with system prompt
export const browserActions = ["launch", "click", "type", "scroll_down", "scroll_up", "close"] as const
export type BrowserAction = (typeof browserActions)[number]
+328
View File
@@ -0,0 +1,328 @@
import { ClineMessage } from "./ExtensionMessage"
/**
* Hook metadata extracted from hook message text.
* Mirrors the ClineSayHook interface but represents parsed data.
*/
interface HookMetadata {
hookName: string
toolName?: string
status: string
exitCode?: number
hasJsonResponse?: boolean
}
// ============================================================================
// PART 1: TYPE GUARDS & UTILITIES
// ============================================================================
/**
* Type guard to check if a message is a tool or command.
*/
function isToolOrCommandMessage(msg: ClineMessage): boolean {
return msg.ask === "tool" || msg.say === "tool" || msg.ask === "command" || msg.say === "command"
}
/**
* Safely parses hook metadata from a hook message.
* Returns null if parsing fails or message is not a hook.
*/
function parseHookMetadata(hookMessage: ClineMessage): HookMetadata | null {
if (hookMessage.say !== "hook" || !hookMessage.text) {
return null
}
try {
const outputIndex = hookMessage.text.indexOf(HOOK_OUTPUT_STRING)
const metadataStr = outputIndex !== -1 ? hookMessage.text.slice(0, outputIndex).trim() : hookMessage.text.trim()
return JSON.parse(metadataStr) as HookMetadata
} catch {
return null
}
}
// ============================================================================
// PART 2: FILTERING & COMBINING
// ============================================================================
/**
* Filters out partial tool/command messages while preserving all other types.
* Reasoning messages are always kept, even if marked partial.
*
* This prevents duplicate messages during React render cycles where partial
* messages are removed and replaced with complete versions.
*/
function filterPartialToolMessages(messages: ClineMessage[]): ClineMessage[] {
return messages.filter((msg) => {
// Always keep reasoning messages
if (msg.say === "reasoning") {
return true
}
// Filter out partial tool/command messages only
const isToolOrCommand = isToolOrCommandMessage(msg)
return !(isToolOrCommand && msg.partial === true)
})
}
/**
* Combines a single hook message with all subsequent hook_output messages.
*
* @param hookMessage The hook message to start combining from
* @param startIndex The index of the hook message in the messages array
* @param messages The full messages array
* @returns Object containing the combined message and the next index to process
*/
function combineHookWithOutputs(
hookMessage: ClineMessage,
startIndex: number,
messages: ClineMessage[],
): { combined: ClineMessage; nextIndex: number } {
let combinedText = hookMessage.text || ""
let hasOutput = false
let i = startIndex + 1
// Collect all hook_output messages until we hit another hook or end of array
while (i < messages.length && messages[i].say !== "hook") {
if (messages[i].say === "hook_output") {
// Add marker before first output
if (!hasOutput) {
combinedText += `\n${HOOK_OUTPUT_STRING}`
hasOutput = true
}
// Append output if not empty
const output = messages[i].text || ""
if (output.length > 0) {
combinedText += "\n" + output
}
}
i++
}
return {
combined: { ...hookMessage, text: combinedText },
nextIndex: i,
}
}
/**
* Combines all hooks with their outputs and removes hook_output messages.
*
* This is a two-pass process:
* 1. Scan through and combine each hook with its outputs
* 2. Build final array without hook_output messages, using combined hooks
*/
function combineAllHooks(messages: ClineMessage[]): ClineMessage[] {
// Pass 1: Build map of combined hooks by timestamp
const combinedHooksByTs = new Map<number, ClineMessage>()
for (let i = 0; i < messages.length; i++) {
if (messages[i].say === "hook") {
const { combined, nextIndex } = combineHookWithOutputs(messages[i], i, messages)
combinedHooksByTs.set(combined.ts, combined)
i = nextIndex - 1 // Adjust for loop increment
}
}
// Pass 2: Build result array
const result: ClineMessage[] = []
for (const msg of messages) {
if (msg.say === "hook_output") {
} else if (msg.say === "hook") {
// Use combined version
result.push(combinedHooksByTs.get(msg.ts) || msg)
} else {
// Keep all other messages as-is
result.push(msg)
}
}
return result
}
// ============================================================================
// PART 3: PRETOOLUSE REORDERING
// ============================================================================
/**
* Finds the timestamp of the next tool/command after a given index.
*
* Searches in the original messages array (not filtered) to catch tools
* that might still be partial. This ensures PreToolUse hooks are matched
* immediately even if their tool hasn't fully arrived yet.
*
* @param hookIndex The starting index to search from
* @param messages The original messages array (may include partial tools)
* @returns The timestamp of the next tool, or null if none found
*/
function findNextToolTimestamp(hookIndex: number, messages: ClineMessage[]): number | null {
for (let i = hookIndex + 1; i < messages.length; i++) {
if (isToolOrCommandMessage(messages[i])) {
return messages[i].ts
}
}
return null
}
/**
* Builds a map of tool timestamps to their PreToolUse hooks.
*
* This map indicates which hooks should be moved to appear before which tools.
* Only PreToolUse hooks are included; PostToolUse hooks stay in their original position.
*
* @param processedMessages Messages after filtering and combining
* @param originalMessages Original messages array (used to find tools)
* @returns Map of tool timestamp -> array of PreToolUse hooks for that tool
*/
function buildPreToolUseMap(processedMessages: ClineMessage[], originalMessages: ClineMessage[]): Map<number, ClineMessage[]> {
const map = new Map<number, ClineMessage[]>()
// Build timestamp-to-index map once to avoid O(n) findIndex calls
const timestampToIndex = new Map<number, number>()
for (let i = 0; i < originalMessages.length; i++) {
timestampToIndex.set(originalMessages[i].ts, i)
}
for (const msg of processedMessages) {
// Only process PreToolUse hooks
const metadata = parseHookMetadata(msg)
if (metadata?.hookName !== "PreToolUse") {
continue
}
// Find this hook's position in the original array using the index map
const hookIndexInOriginal = timestampToIndex.get(msg.ts)
if (hookIndexInOriginal === undefined) {
continue // Shouldn't happen, but be safe
}
// Find the next tool after this hook in the original array
const toolTimestamp = findNextToolTimestamp(hookIndexInOriginal, originalMessages)
if (toolTimestamp === null) {
// No tool found - hook will stay in original position
continue
}
// Map this hook to appear before that tool
if (!map.has(toolTimestamp)) {
map.set(toolTimestamp, [])
}
map.get(toolTimestamp)!.push(msg)
}
return map
}
/**
* Reorders messages so PreToolUse hooks appear before their associated tools.
*
* Algorithm:
* 1. When we encounter a tool, check if it has PreToolUse hooks mapped to it
* 2. If yes, insert those hooks BEFORE the tool
* 3. Track which hooks and tools we've already added to avoid duplicates
* 4. For PreToolUse hooks encountered in their original position:
* - If their tool is available and we'll process them before it, skip them
* - Otherwise, add them in their current position (tool not available yet)
*
* @param messages Messages after filtering and combining
* @param preToolUseMap Map of tool timestamp -> PreToolUse hooks
* @returns Reordered messages array
*/
function reorderWithPreToolUseHooks(messages: ClineMessage[], preToolUseMap: Map<number, ClineMessage[]>): ClineMessage[] {
const result: ClineMessage[] = []
const addedHooks = new Set<number>()
const addedTools = new Set<number>()
// Build set of available tool timestamps for quick lookup
const availableTools = new Set<number>()
for (const msg of messages) {
if (isToolOrCommandMessage(msg)) {
availableTools.add(msg.ts)
}
}
for (const msg of messages) {
// Case 1: This is a tool with PreToolUse hooks
if (isToolOrCommandMessage(msg) && preToolUseMap.has(msg.ts)) {
const hooksForTool = preToolUseMap.get(msg.ts)!
// Insert hooks that haven't been added yet
const newHooks = hooksForTool.filter((h) => !addedHooks.has(h.ts))
result.push(...newHooks)
newHooks.forEach((h) => addedHooks.add(h.ts))
// Add the tool
result.push(msg)
addedTools.add(msg.ts)
continue
}
// Case 2: This tool was already added with its hooks
if (addedTools.has(msg.ts)) {
continue
}
// Case 3: This is a PreToolUse hook in its original position
const metadata = parseHookMetadata(msg)
if (metadata?.hookName === "PreToolUse") {
// Find which tool (if any) this hook is mapped to
let mappedToolTs: number | undefined
for (const [toolTs, hooks] of preToolUseMap) {
if (hooks.some((h) => h.ts === msg.ts)) {
mappedToolTs = toolTs
break
}
}
// If this hook's tool is available and we'll insert it before that tool, skip it here
if (mappedToolTs !== undefined && availableTools.has(mappedToolTs)) {
continue
}
// Otherwise, keep hook in original position (tool not available yet)
}
// Case 4: All other messages (text, PostToolUse hooks, reasoning, etc.)
result.push(msg)
}
return result
}
// ============================================================================
// MAIN FUNCTION
// ============================================================================
/**
* Combines sequences of hook and hook_output messages, and reorders
* PreToolUse hooks to appear before their associated tool messages.
*
* Process:
* 1. Filter out partial tool/command messages (React render cycle cleanup)
* 2. Combine hooks with their hook_output messages
* 3. Build mapping of tools to their PreToolUse hooks
* 4. Reorder so PreToolUse hooks appear before their tools
*
* @param messages Array of ClineMessage objects to process
* @returns New array with hooks combined and PreToolUse hooks reordered
*/
export function combineHookSequences(messages: ClineMessage[]): ClineMessage[] {
// Phase 1: Filter out partial tool/command messages
const filtered = filterPartialToolMessages(messages)
// Phase 2: Combine hooks with their outputs
const combined = combineAllHooks(filtered)
// Phase 3: Build PreToolUse hook mapping
const preToolUseMap = buildPreToolUseMap(combined, messages)
// Phase 4: Reorder to place PreToolUse hooks before tools
const reordered = reorderWithPreToolUseHooks(combined, preToolUseMap)
return reordered
}
export const HOOK_OUTPUT_STRING = "__HOOK_OUTPUT__"
@@ -102,6 +102,8 @@ function convertClineSayToProtoEnum(say: AppClineSay | undefined): ClineSay | un
info: ClineSay.INFO,
task_progress: ClineSay.TASK_PROGRESS,
error_retry: ClineSay.ERROR_RETRY,
hook: ClineSay.INFO,
hook_output: ClineSay.COMMAND_OUTPUT_SAY,
}
const result = mapping[say]
@@ -36,6 +36,7 @@ import { CheckpointControls } from "../common/CheckpointControls"
import CodeAccordian, { cleanPathPrefix } from "../common/CodeAccordian"
import { ErrorBlockTitle } from "./ErrorBlockTitle"
import ErrorRow from "./ErrorRow"
import HookMessage from "./HookMessage"
import NewTaskPreview from "./NewTaskPreview"
import QuoteButton from "./QuoteButton"
import ReportBugPreview from "./ReportBugPreview"
@@ -54,6 +55,22 @@ const ChatRowContainer = styled.div`
&:hover ${CheckpointControls} {
opacity: 1;
}
/* Fade-in animation for hook messages being inserted */
&.hook-message-animate {
animation: hookFadeSlideIn 0.6s cubic-bezier(0.16, 1, 0.3, 1);
}
@keyframes hookFadeSlideIn {
from {
opacity: 0;
transform: translateY(-12px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
`
interface ChatRowProps {
@@ -1602,6 +1619,11 @@ export const ChatRowContent = memo(
</div>
)
}
case "hook":
return <HookMessage CommandOutput={CommandOutput} message={message} />
case "hook_output":
// hook_output messages are combined with hook messages, so we don't render them separately
return null
case "shell_integration_warning_with_suggestion":
const isBackgroundModeEnabled = vscodeTerminalExecutionMode === "backgroundExec"
return (
+5 -1
View File
@@ -1,6 +1,7 @@
import { findLast } from "@shared/array"
import { combineApiRequests } from "@shared/combineApiRequests"
import { combineCommandSequences } from "@shared/combineCommandSequences"
import { combineHookSequences } from "@shared/combineHookSequences"
import type { ClineApiReqInfo, ClineMessage } from "@shared/ExtensionMessage"
import { getApiMetrics } from "@shared/getApiMetrics"
import { BooleanRequest, StringRequest } from "@shared/proto/cline/common"
@@ -57,7 +58,10 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
//const task = messages.length > 0 ? (messages[0].say === "task" ? messages[0] : undefined) : undefined) : undefined
const task = useMemo(() => messages.at(0), [messages]) // leaving this less safe version here since if the first message is not a task, then the extension is in a bad state and needs to be debugged (see Cline.abort)
const modifiedMessages = useMemo(() => combineApiRequests(combineCommandSequences(messages.slice(1))), [messages])
const modifiedMessages = useMemo(
() => combineApiRequests(combineCommandSequences(combineHookSequences(messages.slice(1)))),
[messages],
)
// has to be after api_req_finished are all reduced into api_req_started messages
const apiMetrics = useMemo(() => getApiMetrics(modifiedMessages), [modifiedMessages])
@@ -0,0 +1,275 @@
import { ClineMessage } from "@shared/ExtensionMessage"
import { EmptyRequest } from "@shared/proto/cline/common"
import { memo, useMemo, useState } from "react"
import { TaskServiceClient } from "@/services/grpc-client"
import { CHAT_ROW_EXPANDED_BG_COLOR } from "../common/CodeBlock"
import { HOOK_OUTPUT_STRING } from "./constants"
import PendingToolInfo from "./PendingToolInfo"
const normalColor = "var(--vscode-foreground)"
const errorColor = "var(--vscode-errorForeground)"
const successColor = "var(--vscode-charts-green)"
const runningColor = "var(--vscode-charts-orange)"
const _cancelledColor = "var(--vscode-descriptionForeground)"
interface HookMessageProps {
message: ClineMessage
// CommandOutput component - we'll import and use it here
CommandOutput: React.ComponentType<{
output: string
isOutputFullyExpanded: boolean
onToggle: () => void
isContainerExpanded: boolean
}>
}
interface HookMetadata {
hookName: string
toolName?: string
status: string
exitCode?: number
hasJsonResponse?: boolean
pendingToolInfo?: {
tool: string
path?: string
command?: string
content?: string
diff?: string
regex?: string
url?: string
mcpTool?: string
mcpServer?: string
resourceUri?: string
}
error?: {
type: "timeout" | "validation" | "execution" | "cancellation"
message: string
details?: string
scriptPath?: string
}
}
/**
* Displays a hook execution message with status, pending tool info, and output.
*
* Smart expansion defaults:
* - Failed hooks: Expanded by default (show error details)
* - Cancelled hooks: Expanded by default (show what happened)
* - Successful hooks: Collapsed by default (minimize clutter)
* - Running hooks: Always shows pending tool info
*/
const HookMessage = memo(({ message, CommandOutput }: HookMessageProps) => {
// Parse hook metadata and output
const { metadata, output } = useMemo(() => {
const splitMessage = (text: string) => {
const outputIndex = text.indexOf(HOOK_OUTPUT_STRING)
if (outputIndex === -1) {
return { metadata: text, output: "" }
}
return {
metadata: text.slice(0, outputIndex).trim(),
output: text
.slice(outputIndex + HOOK_OUTPUT_STRING.length)
.trim()
.split("")
.map((char) => {
switch (char) {
case "\t":
return "→ "
case "\b":
return "⌫"
case "\f":
return "⏏"
case "\v":
return "⇳"
default:
return char
}
})
.join(""),
}
}
const { metadata: metadataStr, output } = splitMessage(message.text || "")
let hookMetadata: HookMetadata
try {
hookMetadata = JSON.parse(metadataStr)
} catch {
hookMetadata = { hookName: "Unknown", status: "unknown" }
}
return { metadata: hookMetadata, output }
}, [message.text])
// Smart defaults:
// - Historical messages (>5 seconds old): Always collapsed for better UX
// - Fresh messages: Expand if failed/cancelled, collapse if successful
const isHistoricalMessage = message.ts && Date.now() - message.ts > 5000
const shouldExpandByDefault = !isHistoricalMessage && (metadata.status === "failed" || metadata.status === "cancelled")
const [isHookOutputExpanded, setIsHookOutputExpanded] = useState(shouldExpandByDefault)
const isRunning = metadata.status === "running"
const isCompleted = metadata.status === "completed"
const isFailed = metadata.status === "failed"
const isCancelled = metadata.status === "cancelled"
const headerStyle: React.CSSProperties = {
display: "flex",
alignItems: "center",
gap: "10px",
marginBottom: "12px",
}
return (
<>
<div style={headerStyle}>
<span
className="codicon codicon-symbol-event"
style={{
color: normalColor,
marginBottom: "-1.5px",
}}></span>
<span style={{ color: normalColor, fontWeight: "bold" }}>Hook:</span>
<span style={{ color: normalColor }}>{metadata.hookName}</span>
{metadata.toolName && (
<span style={{ color: "var(--vscode-descriptionForeground)", fontSize: "0.9em" }}>({metadata.toolName})</span>
)}
</div>
<div
style={{
borderRadius: 6,
border: "1px solid var(--vscode-editorGroup-border)",
overflow: "hidden",
backgroundColor: CHAT_ROW_EXPANDED_BG_COLOR,
transition: "all 0.3s ease-in-out",
}}>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "8px 10px",
backgroundColor: CHAT_ROW_EXPANDED_BG_COLOR,
borderBottom:
metadata.pendingToolInfo || output.length > 0 ? "1px solid var(--vscode-editorGroup-border)" : "none",
borderTopLeftRadius: "6px",
borderTopRightRadius: "6px",
}}>
<div
style={{
display: "flex",
alignItems: "center",
gap: "8px",
flex: 1,
minWidth: 0,
}}>
<div
style={{
width: "8px",
height: "8px",
borderRadius: "50%",
backgroundColor: isRunning ? runningColor : isFailed || isCancelled ? errorColor : successColor,
animation: isRunning ? "pulse 2s ease-in-out infinite" : "none",
flexShrink: 0,
}}
/>
<span
style={{
color: isRunning ? runningColor : isFailed || isCancelled ? errorColor : successColor,
fontWeight: 500,
fontSize: "13px",
flexShrink: 0,
}}>
{isRunning
? "Running"
: isFailed
? "Failed"
: isCancelled
? "Cancelled"
: isCompleted
? "Completed"
: "Unknown"}
</span>
{metadata.exitCode !== undefined && metadata.exitCode !== 0 && (
<span
style={{
color: "var(--vscode-descriptionForeground)",
fontSize: "12px",
}}>
(exit: {metadata.exitCode})
</span>
)}
</div>
{isRunning && metadata.hookName !== "TaskCancel" && (
<button
onClick={(e) => {
e.stopPropagation()
// Cancel the task - cancelling a hook always cancels the entire task
TaskServiceClient.cancelTask(EmptyRequest.create({})).catch((err) =>
console.error("Failed to cancel task:", err),
)
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = "var(--vscode-button-secondaryHoverBackground)"
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = "var(--vscode-button-secondaryBackground)"
}}
style={{
background: "var(--vscode-button-secondaryBackground)",
color: "var(--vscode-button-secondaryForeground)",
border: "none",
borderRadius: "2px",
padding: "4px 10px",
fontSize: "12px",
cursor: "pointer",
fontFamily: "inherit",
}}>
cancel
</button>
)}
</div>
{/* Show pending tool info when hook is running */}
{isRunning && metadata.pendingToolInfo && <PendingToolInfo pendingToolInfo={metadata.pendingToolInfo} />}
{/* Show concise error message for specific error types */}
{isFailed && metadata.error && metadata.error.type === "timeout" && (
<div
style={{
padding: "12px",
borderBottom: output.length > 0 ? "1px solid var(--vscode-editorGroup-border)" : "none",
fontSize: "13px",
color: "var(--vscode-descriptionForeground)",
}}>
Took longer than 30 seconds. Check for infinite loops or add timeouts to network requests.
</div>
)}
{isFailed && metadata.error && metadata.error.type === "validation" && (
<div
style={{
padding: "12px",
borderBottom: output.length > 0 ? "1px solid var(--vscode-editorGroup-border)" : "none",
fontSize: "13px",
color: "var(--vscode-descriptionForeground)",
}}>
Hook returned invalid JSON. See error details below for more information.
</div>
)}
{/* Show hook output if present */}
{output.length > 0 && (
<CommandOutput
isContainerExpanded={true}
isOutputFullyExpanded={isHookOutputExpanded}
onToggle={() => setIsHookOutputExpanded(!isHookOutputExpanded)}
output={output}
/>
)}
</div>
</>
)
})
export default HookMessage
@@ -0,0 +1,73 @@
import { memo } from "react"
import { CODE_BLOCK_BG_COLOR } from "../common/CodeBlock"
interface PendingToolInfoProps {
pendingToolInfo: {
tool: string
path?: string
command?: string
content?: string
diff?: string
regex?: string
url?: string
mcpTool?: string
mcpServer?: string
resourceUri?: string
}
}
/**
* Displays information about a tool that is pending execution while a hook runs.
* This component shows a preview of what the tool will do, helping users understand
* what the hook is evaluating.
*/
const PendingToolInfo = memo(({ pendingToolInfo }: PendingToolInfoProps) => {
const renderField = (label: string, value: string, isPreview = false) => (
<div style={{ marginBottom: 6 }}>
<span style={{ fontWeight: 500 }}>{label}:</span>
{isPreview ? (
<div
style={{
marginTop: 4,
padding: 6,
backgroundColor: CODE_BLOCK_BG_COLOR,
borderRadius: 3,
fontFamily: "monospace",
fontSize: "0.85em",
whiteSpace: "pre-wrap",
wordBreak: "break-word",
}}>
{value}
{value.length >= 200 && "..."}
</div>
) : (
<span className="ph-no-capture" style={{ marginLeft: 6, fontFamily: "monospace", fontSize: "0.9em" }}>
{value}
</span>
)}
</div>
)
return (
<div
style={{
padding: "12px",
backgroundColor: "var(--vscode-editor-background)",
borderBottom: "1px solid var(--vscode-editorGroup-border)",
opacity: 0.8,
}}>
{renderField("Tool", pendingToolInfo.tool)}
{pendingToolInfo.path && renderField("Path", pendingToolInfo.path)}
{pendingToolInfo.command && renderField("Command", pendingToolInfo.command)}
{pendingToolInfo.content && renderField("Content Preview", pendingToolInfo.content, true)}
{pendingToolInfo.diff && renderField("Diff Preview", pendingToolInfo.diff, true)}
{pendingToolInfo.regex && renderField("Regex", pendingToolInfo.regex)}
{pendingToolInfo.url && renderField("URL", pendingToolInfo.url)}
{pendingToolInfo.mcpServer && renderField("MCP Server", pendingToolInfo.mcpServer)}
{pendingToolInfo.mcpTool && renderField("MCP Tool", pendingToolInfo.mcpTool)}
{pendingToolInfo.resourceUri && renderField("Resource URI", pendingToolInfo.resourceUri)}
</div>
)
})
export default PendingToolInfo
@@ -68,6 +68,13 @@ export const ActionButtons: React.FC<ActionButtonsProps> = ({
return
}
setIsProcessing(true)
// Special handling for cancel action
if (action === "cancel") {
// Reset processing state immediately for cancel to ensure UI responsiveness
setIsProcessing(false)
}
messageHandlers.executeButtonAction(action, text, images, files)
},
[messageHandlers, isProcessing],
@@ -40,7 +40,9 @@ export function useMessageHandlers(messages: ClineMessage[], chatState: ChatStat
}
if (hasContent) {
console.log("[ChatView] handleSendMessage - Sending message:", messageToSend)
console.log("[ChatView] handleSendMessage - Sending message:", messageToSend, "clineAsk:", clineAsk)
let messageSent = false
if (messages.length === 0) {
await TaskServiceClient.newTask(
NewTaskRequest.create({
@@ -49,45 +51,83 @@ export function useMessageHandlers(messages: ClineMessage[], chatState: ChatStat
files,
}),
)
messageSent = true
} else if (clineAsk) {
switch (clineAsk) {
case "followup":
case "plan_mode_respond":
case "tool":
case "browser_action_launch":
case "command":
case "command_output":
case "use_mcp_server":
case "completion_result":
case "resume_task":
case "resume_completed_task":
case "mistake_limit_reached":
case "auto_approval_max_req_reached":
case "api_req_failed":
case "new_task":
case "condense":
case "report_bug":
await TaskServiceClient.askResponse(
AskResponseRequest.create({
responseType: "messageResponse",
text: messageToSend,
images,
files,
}),
)
break
// For resume_task and resume_completed_task, use yesButtonClicked to match Resume button behavior
// This ensures Enter key and Resume button work identically
if (clineAsk === "resume_task" || clineAsk === "resume_completed_task") {
await TaskServiceClient.askResponse(
AskResponseRequest.create({
responseType: "yesButtonClicked",
text: messageToSend,
images,
files,
}),
)
messageSent = true
} else {
// All other ask types use messageResponse
switch (clineAsk) {
case "followup":
case "plan_mode_respond":
case "tool":
case "browser_action_launch":
case "command":
case "command_output":
case "use_mcp_server":
case "completion_result":
case "mistake_limit_reached":
case "auto_approval_max_req_reached":
case "api_req_failed":
case "new_task":
case "condense":
case "report_bug":
await TaskServiceClient.askResponse(
AskResponseRequest.create({
responseType: "messageResponse",
text: messageToSend,
images,
files,
}),
)
messageSent = true
break
}
}
} else if (messages.length > 0) {
// No clineAsk set - check if task is actively running
// If so, allow interrupting it with feedback
const lastMessage = messages[messages.length - 1]
const isTaskRunning =
lastMessage.partial === true || (lastMessage.type === "say" && lastMessage.say === "api_req_started")
if (isTaskRunning) {
// Task is running - send message as interruption/feedback
await TaskServiceClient.askResponse(
AskResponseRequest.create({
responseType: "messageResponse",
text: messageToSend,
images,
files,
}),
)
messageSent = true
}
}
setInputValue("")
setActiveQuote(null)
setSendingDisabled(true)
setSelectedImages([])
setSelectedFiles([])
setEnableButtons(false)
// Reset auto-scroll
if ("disableAutoScrollRef" in chatState) {
;(chatState as any).disableAutoScrollRef.current = false
// Only clear input and disable UI if message was actually sent
if (messageSent) {
setInputValue("")
setActiveQuote(null)
setSendingDisabled(true)
setSelectedImages([])
setSelectedFiles([])
setEnableButtons(false)
// Reset auto-scroll
if ("disableAutoScrollRef" in chatState) {
;(chatState as any).disableAutoScrollRef.current = false
}
}
}
},
@@ -191,8 +231,8 @@ export function useMessageHandlers(messages: ClineMessage[], chatState: ChatStat
responseType: "yesButtonClicked",
}),
)
clearInputState()
}
clearInputState()
break
case "new_task":
@@ -215,6 +255,10 @@ export function useMessageHandlers(messages: ClineMessage[], chatState: ChatStat
} else {
await TaskServiceClient.cancelTask(EmptyRequest.create({}))
}
// Clear any pending state that might interfere with resume
setSendingDisabled(false)
setEnableButtons(true)
clearInputState()
break
case "utility":
@@ -218,6 +218,11 @@ export function getButtonConfig(message: ClineMessage | undefined, _mode: Mode =
return BUTTON_CONFIGS.default
}
// Add debugging for resume messages
if (message.type === "ask" && (message.ask === "resume_task" || message.ask === "resume_completed_task")) {
console.log("[ButtonConfig] Processing resume message:", message.ask, "at", new Date().toISOString())
}
const isStreaming = message.partial === true
const isError = message?.ask ? errorTypes.includes(message.ask) : false
@@ -0,0 +1,10 @@
/**
* Shared constants for chat components
*/
/**
* Marker string used to separate hook metadata from hook output in hook messages.
* When a hook executes, its metadata (status, tool info, etc.) is followed by this
* marker, which is then followed by the actual output from the hook script.
*/
export const HOOK_OUTPUT_STRING = "__HOOK_OUTPUT__"
@@ -1,5 +1,6 @@
import { combineApiRequests } from "@shared/combineApiRequests"
import { combineCommandSequences } from "@shared/combineCommandSequences"
import { combineHookSequences } from "@shared/combineHookSequences"
import { ClineMessage } from "@shared/ExtensionMessage"
import React, { useCallback, useEffect, useMemo, useRef } from "react"
import { Virtuoso } from "react-virtuoso"
@@ -27,7 +28,7 @@ const TaskTimeline: React.FC<TaskTimelineProps> = ({ messages, onBlockClick }) =
return { taskTimelinePropsMessages: [], messageIndexMap: [] }
}
const processed = combineApiRequests(combineCommandSequences(messages.slice(1)))
const processed = combineApiRequests(combineCommandSequences(combineHookSequences(messages.slice(1))))
const indexMap: number[] = []
const filtered = processed.filter((msg, _processedIndex) => {