mirror of
https://github.com/cline/cline.git
synced 2026-09-04 11:44:01 +08:00
Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3e49b986f0 | |||
| db3f6e05a5 | |||
| dc2b305744 | |||
| edfa9d6bf7 | |||
| cef31ad57b | |||
| 88d29d23b7 | |||
| 8032894f1e | |||
| bf0e6e2e64 | |||
| 2167d563f6 | |||
| ccc3ccf42b | |||
| 8d8b7cb1db | |||
| 88ec0f60bc | |||
| e5055f9d5b | |||
| d983ae43b5 | |||
| 33ffba012a | |||
| 01c4493e34 | |||
| a524e8b76b | |||
| 5133d1b713 | |||
| 6b9ceb4659 | |||
| 058d886894 | |||
| a96f7f2138 | |||
| 2e45f6fbee | |||
| aab8d4798b | |||
| 8ddd349161 | |||
| 5d83c7676c | |||
| 89b16ad15a | |||
| 3ac63cf031 |
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
Executable
+15
@@ -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
|
||||
Executable
+15
@@ -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
|
||||
Executable
+15
@@ -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
|
||||
Executable
+15
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user