Compare commits

...
Author SHA1 Message Date
Eve Killaby 00e0846b16 Exclude .clinerules/hooks/ files from Rules feature 2025-10-30 17:11:54 -07:00
Eve Killaby 4e533b2795 Don't use vscode API; use cross-compatible solutions instead 2025-10-30 12:49:31 -07:00
Eve Killaby 5ba290f54a Remove if that always resolves to true 2025-10-30 12:31:41 -07:00
Eve Killaby 3ca586bcce Changes as per PR feedback from Evan 2025-10-30 12:13:25 -07:00
Eve Killaby c0e220947e Add comment as per ellipsis-dev PR feedback 2025-10-30 11:44:42 -07:00
Eve Killaby c400871ff0 Bee and Eve fixing 'API Request...' test in e2e tests 2025-10-30 11:44:42 -07:00
Eve Killaby 0937ff8929 Fix broken unit tests 2025-10-30 11:44:42 -07:00
Eve Killaby 8602d8dbd5 Sqaush commits to improve merge tool behavior
Plan out the work needed to resolve race conditions

Fix race conditions (1)

Fix race conditions (2)

Fix race conditions (3)

Fix race conditions (4)

Fix race conditions (5)

Fix unit tests

Updated implementation plan doc

Code quality improvement

Temporary logging to troubleshoot race conditions in hooks UI blocks

Fix streaming output for hooks

Remove in-progress planning file

Revert breakage

Remove unneeded arg output in PreToolUse hook UI

Fix race condition with PreToolUse and PostToolUse hooks

Fix PreToolUse attempt_completion use case

Fix ChatTextArea prompt input

Fixed part of the rersume bug

Clean-up before code review

Change cancel button to abort button

Fixed TaskCancel behavior

Fixing resume behavior (partially fixed)

Make notch arrow visible in hooks block up expand/contract toggle

Prevent cancel/abort from clearing unsent user message

Change 'Cancelled' to 'Aborted' in hook UI block

Use color styles consistent with background terminal

Fix npm compile issue

Remove unneeded hot-cold tracking from hooks

Remove unused constant as per PR feedback

Minor changes

Skip combineHookSequences() if hooks feature setting not enabled

Improve mutex pattern for general use and fix additional race condition

Refactor to reduce duplication (keep it DRY)

Refactor to reduce duplication (keep it DRY) part 2

Fix tests

Skip some tests on Windows (not yet supported)

Hooks unsupported on Windows

Fix broken unit tests
2025-10-30 11:44:42 -07:00
Eve Killaby 3131d170d4 Fix resume 2025-10-30 11:44:42 -07:00
Eve Killaby 5e1c202ef6 Fixing cancel behavior 2025-10-30 11:44:42 -07:00
Eve Killaby e1a4dc150d Fixing cancel behavior 2025-10-30 11:44:42 -07:00
Eve Killaby 1feb89fbf2 Remove unnecessary polling pattern for hook cancellation 2025-10-30 11:44:42 -07:00
Eve Killaby c451b0a94c remove cancelHookExecution() in favor of cancelTask() 2025-10-30 11:44:42 -07:00
Eve Killaby 4600b7b171 Remove unnecessary files from branch 2025-10-30 11:44:42 -07:00
Eve Killaby 0ed10dc9b2 Fix unsafe return in finally block
Refactored PostToolUse hook execution to avoid using return statement
in finally block, which the linter flags as unsafe. Instead of early
return, wrapped the hook logic in a conditional check for abort status.

This resolves the biome lint error:
lint/correctness/noUnsafeFinally - Unsafe usage of 'return' in finally block
2025-10-30 11:44:42 -07:00
Eve Killaby f68c11704a Refactored away loadTaskStateWithoutWorkflow 2025-10-30 11:44:42 -07:00
Eve Killaby d3d06c3ef3 Add examples to .clinerules/hooks/ directory 2025-10-30 11:44:42 -07:00
Eve Killaby 2c1b39b626 Demoed 2025-10-30 11:44:42 -07:00
Eve Killaby 214fd25535 Trying another fix 2025-10-30 11:44:42 -07:00
Eve Killaby 4c13fd746e Fixing cancel behaviors 2025-10-30 11:44:42 -07:00
Eve Killaby 92af2697c3 Getting things working again 2025-10-30 11:44:42 -07:00
Eve Killaby b5694e212d fix: add cancel button support for TaskResume and UserPromptSubmit hooks 2025-10-30 11:44:42 -07:00
Eve Killaby 08e85379ae fix: resolve cancel functionality in PostToolUse hook using flag pattern 2025-10-30 11:44:42 -07:00
Eve Killaby 750efa1936 test: verify linting fixes 2025-10-30 11:44:42 -07:00
Eve Killaby 370c39d5f7 Changes from usability feedback 2025-10-30 11:44:42 -07:00
Eve Killaby 57c840ff23 Simplify and improve console.log() output from hooks 2025-10-30 11:44:42 -07:00
Eve Killaby b33d58e477 Improve TaskCancel hook UI 2025-10-30 11:44:42 -07:00
Eve Killaby 41dcffa3b0 Improvements to hooks error messaging 2025-10-30 11:44:41 -07:00
Eve Killaby b7c48afe65 Un-hard-code FeatureFlagsServive::getHooksEnabled() 2025-10-30 11:44:41 -07:00
Eve Killaby b10a5fbbb5 Hooks hardening and handling edge cases 2025-10-30 11:44:41 -07:00
Eve Killaby ee7db64841 Add missing docs 2025-10-30 11:44:41 -07:00
Eve Killaby ed192205c3 Fix failing tests 2025-10-30 11:44:41 -07:00
Eve Killaby 6b8acafb5c Separate hooks UI into separate files 2025-10-30 11:44:41 -07:00
Eve Killaby 66460996fd Hook discovery improvements 2025-10-30 11:44:41 -07:00
Eve Killaby 8b754e1c29 Hooks UI improvements 2025-10-30 11:44:41 -07:00
Eve Killaby 6f166bfa19 feat(hooks): Initial implementation of hooks UI using background terminal UI 2025-10-30 11:44:41 -07:00
78 changed files with 5121 additions and 793 deletions
+7 -46
View File
@@ -1,54 +1,15 @@
#!/usr/bin/env bash
# PostToolUse Hook Example
#
# This hook runs AFTER a tool is executed. It can:
# 1. Observe tool results and outcomes
# 2. Add context for FUTURE tool uses via contextModification
# 3. Log or track tool usage patterns
#
# IMPORTANT: Context injection affects FUTURE AI decisions, not the current tool execution.
# The tool has already completed when this hook runs.
# Read the hook input (JSON via stdin)
input=$(cat)
for i in {1..100}; do
echo "$i"
done
# Extract tool information
tool_name=$(echo "$input" | jq -r '.postToolUse.toolName // "unknown"')
parameters=$(echo "$input" | jq -r '.postToolUse.parameters // {}')
result=$(echo "$input" | jq -r '.postToolUse.result // ""')
success=$(echo "$input" | jq -r '.postToolUse.success // false')
execution_time=$(echo "$input" | jq -r '.postToolUse.executionTimeMs // 0')
sleep 3
# Example 1: Learning from file operations
# Track successful file creations to build context about project structure
# if [[ "$tool_name" == "write_to_file" && "$success" == "true" ]]; then
# path=$(echo "$parameters" | jq -r '.path // ""')
# cat <<EOF
# {
# "shouldContinue": true,
# "contextModification": "FILE_OPERATIONS: Successfully created '$path'. Future operations should maintain consistency with this file's patterns and structure."
# }
# EOF
# exit 0
# fi
# Example 2: Performance monitoring
# Warn about slow operations
# if [[ "$execution_time" -gt 5000 ]]; then
# cat <<EOF
# {
# "shouldContinue": true,
# "contextModification": "PERFORMANCE: Tool '$tool_name' took ${execution_time}ms to complete. Consider optimizing future similar operations or breaking them into smaller steps."
# }
# EOF
# exit 0
# fi
# Example 3: Context injection for future tool uses
# The context will be available in the NEXT API request
cat <<EOF
{
"shouldContinue": true,
"contextModification": "TOOL_RESULT: The tool '$tool_name' completed with success=$success. Consider validating the results before proceeding to the next step."
"cancel": false,
"contextModification": "WORKSPACE_RULES: This is from the local bar/ workspace.",
"errorMessage": "PostToolUse hook custom errorMessage: foo"
}
EOF
+7 -34
View File
@@ -1,42 +1,15 @@
#!/usr/bin/env bash
# PreToolUse Hook Example
#
# This hook runs BEFORE a tool is executed. It can:
# 1. Block execution by returning {"shouldContinue": false}
# 2. Add context for FUTURE tool uses via contextModification
# 3. Validate tool parameters
#
# IMPORTANT: Context injection affects FUTURE AI decisions, not the current tool execution.
# The tool parameters are already determined when this hook runs.
# Read the hook input (JSON via stdin)
input=$(cat)
for i in {1..100}; do
echo "$i"
done
# Extract tool information
tool_name=$(echo "$input" | jq -r '.preToolUse.toolName // "unknown"')
parameters=$(echo "$input" | jq -r '.preToolUse.parameters // {}')
sleep 3
# Example 1: Validation - Block invalid operations
# Uncomment to prevent creating .js files in a TypeScript project
# if [[ "$tool_name" == "write_to_file" ]]; then
# path=$(echo "$parameters" | jq -r '.path // ""')
# if [[ "$path" == *.js ]]; then
# cat <<EOF
# {
# "shouldContinue": false,
# "errorMessage": "VALIDATION FAILED: Cannot create .js files in TypeScript project. Please use .ts extension instead.",
# "contextModification": "WORKSPACE_RULES: This is a strict TypeScript project. All new files must use .ts or .tsx extensions."
# }
# EOF
# exit 0
# fi
# fi
# Example 2: Context injection for future tool uses
# The context will be available in the NEXT API request after this tool completes
cat <<EOF
{
"shouldContinue": true,
"contextModification": "WORKSPACE_RULES: [For future tool uses] This is a TypeScript React project. When creating files, use .ts/.tsx extensions and include detailed comments explaining the purpose and usage of each function."
"cancel": false,
"contextModification": "Make sure that all variables in python code in this repo always start with the string 'EVIEDEBUG_'. This is important.",
"errorMessage": "PreToolUse hook custom errorMessage: foo"
}
EOF
+122 -41
View File
@@ -3,7 +3,7 @@
## Overview
Cline hooks allow you to execute custom scripts at specific points in the agentic workflow. Hooks can be placed in either:
- **Global hooks directory**: `~/Documents/Cline/Rules/Hooks/` (applies to all workspaces)
- **Global hooks directory**: `~/Documents/Cline/Hooks/` (applies to all workspaces)
- **Workspace hooks directory**: `.clinerules/hooks/` (applies to specific workspace)
Hooks run automatically when enabled.
@@ -17,18 +17,55 @@ 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/Hooks/TaskStart` (all platforms)
- **Workspace Location**: `.clinerules/hooks/TaskStart` (all platforms)
### TaskResume Hook
- **When**: Runs when an EXISTING task is resumed (after user clicks resume button)
- **Purpose**: Validate resumed task state, restore context, check for changes since last run
- **Global Location**: `~/Documents/Cline/Hooks/TaskResume` (all platforms)
- **Workspace Location**: `.clinerules/hooks/TaskResume` (all platforms)
### TaskCancel Hook
- **When**: Runs when a task is cancelled by the user (only if there's actual active work or work was started)
- **Purpose**: Clean up resources, log cancellation, save state
- **Global Location**: `~/Documents/Cline/Hooks/TaskCancel` (all platforms)
- **Workspace Location**: `.clinerules/hooks/TaskCancel` (all platforms)
- **Note**: This hook is NOT cancellable and will complete even if the task is being aborted
### TaskComplete Hook
- **When**: Runs when a task is marked as complete
- **Purpose**: Log completion status, perform final cleanup, generate reports
- **Global Location**: `~/Documents/Cline/Hooks/TaskComplete` (all platforms)
- **Workspace Location**: `.clinerules/hooks/TaskComplete` (all platforms)
### UserPromptSubmit Hook
- **When**: Runs when the user submits a prompt/message (initial task, resume, or feedback)
- **Purpose**: Validate user input, preprocess prompts, add context to user messages
- **Global Location**: `~/Documents/Cline/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
- **Global Location**: `~/Documents/Cline/Rules/Hooks/PreToolUse` (all platforms)
- **Global Location**: `~/Documents/Cline/Hooks/PreToolUse` (all platforms)
- **Workspace Location**: `.clinerules/hooks/PreToolUse` (all platforms)
### PostToolUse Hook
- **When**: Runs AFTER a tool completes
- **Purpose**: Observe results, track patterns, or add context
- **Global Location**: `~/Documents/Cline/Rules/Hooks/PostToolUse` (all platforms)
- **Global Location**: `~/Documents/Cline/Hooks/PostToolUse` (all platforms)
- **Workspace Location**: `.clinerules/hooks/PostToolUse` (all platforms)
### PreCompact Hook
- **When**: Runs BEFORE the conversation context is compacted/truncated
- **Purpose**: Observe compaction events, log context management, track token usage
- **Global Location**: `~/Documents/Cline/Hooks/PreCompact` (all platforms)
- **Workspace Location**: `.clinerules/hooks/PreCompact` (all platforms)
## Cross-Platform Hook Format
Cline uses a git-style approach for hooks that works consistently across all platforms:
@@ -55,16 +92,16 @@ This means:
**On Unix/Linux/macOS:**
```bash
# Create hook file
nano ~/Documents/Cline/Rules/Hooks/PreToolUse
nano ~/Documents/Cline/Hooks/PreToolUse
# Make executable
chmod +x ~/Documents/Cline/Rules/Hooks/PreToolUse
chmod +x ~/Documents/Cline/Hooks/PreToolUse
```
**On Windows:**
```batch
REM Create hook file (note: no file extension)
notepad %USERPROFILE%\Documents\Cline\Rules\Hooks\PreToolUse
notepad %USERPROFILE%\Documents\Cline\Hooks\PreToolUse
```
## Context Injection Timing
@@ -107,11 +144,46 @@ All hooks receive:
```json
{
"clineVersion": "string",
"hookName": "PreToolUse" | "PostToolUse",
"hookName": "TaskStart" | "TaskResume" | "TaskCancel" | "TaskComplete" | "UserPromptSubmit" | "PreToolUse" | "PostToolUse" | "PreCompact",
"timestamp": "string",
"taskId": "string",
"workspaceRoots": ["string"],
"userId": "string",
"taskStart": { // Only for TaskStart
"taskMetadata": {
"taskId": "string",
"ulid": "string",
"initialTask": "string"
}
},
"taskResume": { // Only for TaskResume
"taskMetadata": {
"taskId": "string",
"ulid": "string"
},
"previousState": {
"lastMessageTs": "string",
"messageCount": "string",
"conversationHistoryDeleted": "string"
}
},
"taskCancel": { // Only for TaskCancel
"taskMetadata": {
"taskId": "string",
"ulid": "string",
"completionStatus": "string"
}
},
"taskComplete": { // Only for TaskComplete
"taskMetadata": {
"taskId": "string",
"ulid": "string"
}
},
"userPromptSubmit": { // Only for UserPromptSubmit
"prompt": "string",
"attachments": ["string"]
},
"preToolUse": { // Only for PreToolUse
"toolName": "string",
"parameters": {}
@@ -122,6 +194,11 @@ All hooks receive:
"result": "string",
"success": boolean,
"executionTimeMs": number
},
"preCompact": { // Only for PreCompact
"contextSize": number,
"messagesToCompact": number,
"compactionStrategy": "string"
}
}
```
@@ -131,12 +208,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 +233,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
@@ -160,9 +241,9 @@ EOF
## Hook Execution Limits
- **Timeout**: Hooks must complete within 30 seconds
- **Context Size**: Context modifications are limited to 50KB
- **Error Handling**: Unexpected file system errors are propagated; expected errors (file not found, permission denied) are handled silently
- **Timeout**: Hooks must complete within 30 seconds (configurable via `HOOK_EXECUTION_TIMEOUT_MS`)
- **Context Size**: Context modifications are limited to 50KB (configurable via `MAX_CONTEXT_MODIFICATION_SIZE`)
- **Error Handling**: Expected errors (file not found, permission denied, not a directory) are handled silently; unexpected file system errors are propagated
## Common Use Cases
@@ -177,7 +258,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 +266,7 @@ EOF
exit 0
fi
echo '{"shouldContinue": true}'
echo '{"cancel": false}'
```
### 2. Context Building - Learn from Operations
@@ -200,12 +281,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 +301,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 +320,7 @@ input=$(cat)
echo "$input" >> ~/.cline/hook-logs/tool-usage.jsonl
# Allow execution
echo '{"shouldContinue": true}'
echo '{"cancel": false}'
```
## Global vs Workspace Hooks
@@ -247,44 +328,44 @@ echo '{"shouldContinue": true}'
Cline supports two levels of hooks:
### Global Hooks
- **Location**: `~/Documents/Cline/Rules/Hooks/` (macOS/Linux) or `%USERPROFILE%\Documents\Cline\Rules\Hooks\` (Windows)
- **Location**: `~/Documents/Cline/Hooks/` (macOS/Linux) or `%USERPROFILE%\Documents\Cline\Hooks\` (Windows)
- **Scope**: Apply to ALL workspaces and projects
- **Use Case**: Organization-wide policies, personal preferences, universal validations
- **Priority**: Execute FIRST, before workspace hooks
- **Priority**: Order not guaranteed when combined with workspace hooks
### Workspace Hooks
- **Location**: `.clinerules/hooks/` in each workspace root
- **Scope**: Apply only to the specific workspace
- **Use Case**: Project-specific rules, team conventions, repository requirements
- **Priority**: Execute AFTER global hooks
- **Priority**: Order not guaranteed when combined with global hooks
### Hook Execution
When multiple hooks exist (global and/or workspace):
- All hooks for a given step (PreToolUse or PostToolUse) 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
- All hooks for a given step are executed **concurrently** using `Promise.all`
- **Execution order is not guaranteed** - hooks run in parallel
- 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
- `contextModification`: All context strings are concatenated
- `errorMessage`: All error messages are concatenated
- `cancel`: If ANY hook returns `true`, execution is blocked
- `contextModification`: All context strings are concatenated with double newlines (`\n\n`)
- `errorMessage`: All error messages are concatenated with single newlines (`\n`)
### Setting Up Global Hooks
1. The global hooks directory is automatically created at:
- macOS/Linux: `~/Documents/Cline/Rules/Hooks/`
- Windows: `%USERPROFILE%\Documents\Cline\Rules\Hooks\`
- macOS/Linux: `~/Documents/Cline/Hooks/`
- Windows: `%USERPROFILE%\Documents\Cline\Hooks\`
2. Add your hook script:
```bash
# Unix/Linux/macOS
nano ~/Documents/Cline/Rules/Hooks/PreToolUse
chmod +x ~/Documents/Cline/Rules/Hooks/PreToolUse
nano ~/Documents/Cline/Hooks/PreToolUse
chmod +x ~/Documents/Cline/Hooks/PreToolUse
# Windows
notepad %USERPROFILE%\Documents\Cline\Rules\Hooks\PreToolUse
notepad %USERPROFILE%\Documents\Cline\Hooks\PreToolUse
```
3. Enable hooks in Cline settings
@@ -294,18 +375,18 @@ When multiple hooks exist (global and/or workspace):
**Global Hook** (applies to all projects):
```bash
#!/usr/bin/env bash
# ~/Documents/Cline/Rules/Hooks/PreToolUse
# ~/Documents/Cline/Hooks/PreToolUse
# Universal rule: Never delete package.json
input=$(cat)
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 +399,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 +412,7 @@ echo '{"shouldContinue": true}'
If you have multiple workspace roots, you can place hooks in each root's `.clinerules/hooks/` directory. All hooks (global and workspace) may execute concurrently. Their results will be combined:
- **shouldContinue**: If ANY hook returns false, execution is blocked
- **cancel**: If ANY hook returns `true`, execution is blocked
- **contextModification**: All context modifications are concatenated
- **errorMessage**: All error messages are concatenated
+15
View File
@@ -0,0 +1,15 @@
#!/usr/bin/env bash
for i in {1..100}; do
echo "$i"
done
sleep 3
cat <<EOF
{
"cancel", false,
"contextModification": "WORKSPACE_RULES: This is from the local bar/ workspace.",
"errorMessage": "TaskCancel hook custom errorMessage: foo"
}
EOF
+15
View File
@@ -0,0 +1,15 @@
#!/usr/bin/env bash
for i in {1..100}; do
echo "$i"
done
sleep 3
cat <<EOF
{
"cancel": false,
"contextModification": "WORKSPACE_RULES: This is from the local bar/ workspace.",
"errorMessage": "TaskResume hook custom errorMessage: foo"
}
EOF
+15
View File
@@ -0,0 +1,15 @@
#!/usr/bin/env bash
for i in {1..100}; do
echo "$i"
done
sleep 1
cat <<EOF
{
"cancel": false,
"contextModification": "WORKSPACE_RULES: This is from the local bar/ workspace.",
"errorMessage": "TaskStart hook custom errorMessage: foo"
}
EOF
+15
View File
@@ -0,0 +1,15 @@
#!/usr/bin/env bash
for i in {1..100}; do
echo "$i"
done
sleep 3
cat <<EOF
{
"cancel": false,
"contextModification": "WORKSPACE_RULES: This is from the local bar/ workspace.",
"errorMessage": "UserPromptSubmit hook custom errorMessage: foo"
}
EOF
+28
View File
@@ -80,6 +80,7 @@
"open-graph-scraper": "^6.9.0",
"openai": "^4.83.0",
"os-name": "^6.0.0",
"p-mutex": "^1.0.0",
"p-timeout": "^6.1.4",
"p-wait-for": "^5.0.2",
"pdf-parse": "^1.1.1",
@@ -15190,6 +15191,33 @@
"node": ">=6"
}
},
"node_modules/p-mutex": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/p-mutex/-/p-mutex-1.0.0.tgz",
"integrity": "sha512-UlthGzEMsg2VnZAR58wkzL7muskxtNamoTR1Q6/VYBUKqPaMM+YtSncjWIvyjfUvVECKck1SYC/4XIWWJU3gBw==",
"license": "MIT",
"dependencies": {
"yocto-queue": "^1.2.1"
},
"engines": {
"node": ">=20"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/p-mutex/node_modules/yocto-queue": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.1.tgz",
"integrity": "sha512-AyeEbWOu/TAXdxlV9wmGcR0+yh2j3vYPGOECcIj2S7MkrLyC7ne+oye2BKTItt0ii2PHk4cDy+95+LshzbXnGg==",
"license": "MIT",
"engines": {
"node": ">=12.20"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/p-timeout": {
"version": "6.1.4",
"license": "MIT",
+1
View File
@@ -474,6 +474,7 @@
"open-graph-scraper": "^6.9.0",
"openai": "^4.83.0",
"os-name": "^6.0.0",
"p-mutex": "^1.0.0",
"p-timeout": "^6.1.4",
"p-wait-for": "^5.0.2",
"pdf-parse": "^1.1.1",
+1 -1
View File
@@ -29,7 +29,7 @@ message HookInput {
// Output message for all hooks
message HookOutput {
string context_modification = 1;
bool should_continue = 2;
bool cancel = 2;
string error_message = 3;
}
@@ -40,7 +40,10 @@ export const getLocalClineRules = async (cwd: string, toggles: ClineRulesToggles
if (await fileExistsAtPath(clineRulesFilePath)) {
if (await isDirectory(clineRulesFilePath)) {
try {
const rulesFilePaths = await readDirectory(clineRulesFilePath, [[".clinerules", "workflows"]])
const rulesFilePaths = await readDirectory(clineRulesFilePath, [
[".clinerules", "workflows"],
[".clinerules", "hooks"],
])
const rulesFilesTotalContent = await getRuleFilesTotalContent(rulesFilePaths, cwd, toggles)
if (rulesFilesTotalContent) {
@@ -84,6 +87,7 @@ export async function refreshClineRulesToggles(
const localClineRulesFilePath = path.resolve(workingDirectory, GlobalFileNames.clineRules)
const updatedLocalToggles = await synchronizeRuleToggles(localClineRulesFilePath, localClineRulesToggles, "", [
[".clinerules", "workflows"],
[".clinerules", "hooks"],
])
controller.stateManager.setWorkspaceState("localClineRulesToggles", updatedLocalToggles)
+53 -5
View File
@@ -75,6 +75,9 @@ export class Controller {
private backgroundCommandRunning = false
private backgroundCommandTaskId?: string
// Flag to prevent duplicate cancellations from spam clicking
private cancelInProgress = false
// Shell integration warning tracker
private shellIntegrationWarningTracker: {
timestamps: number[]
@@ -333,6 +336,12 @@ export class Controller {
taskLockAcquired,
})
if (historyItem) {
this.task.resumeTaskFromHistory()
} else if (task || images || files) {
this.task.startTask(task, images, files)
}
return this.task.taskId
}
@@ -410,14 +419,28 @@ export class Controller {
}
async cancelTask() {
if (this.task) {
// Prevent duplicate cancellations from spam clicking
if (this.cancelInProgress) {
console.log(`[Controller.cancelTask] Cancellation already in progress, ignoring duplicate request`)
return
}
if (!this.task) {
return
}
// Set flag to prevent concurrent cancellations
this.cancelInProgress = true
try {
this.updateBackgroundCommandState(false)
const { historyItem } = await this.getTaskWithId(this.task.taskId)
try {
await this.task.abortTask()
} catch (error) {
console.error("Failed to abort task", error)
}
await pWaitFor(
() =>
this.task === undefined ||
@@ -430,13 +453,38 @@ export class Controller {
).catch(() => {
console.error("Failed to abort task")
})
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 {
const result = await this.getTaskWithId(this.task.taskId)
historyItem = result.historyItem
} catch (error) {
// Task not in history yet (new task with no messages); catch the
// error to enable the agent to continue making progress.
console.log(`[Controller.cancelTask] Task not found in history: ${error}`)
}
// Only re-initialize if we found a history item, otherwise just clear
if (historyItem) {
// Re-initialize task to keep it visible in UI with resume button
await this.initTask(undefined, undefined, undefined, historyItem, undefined)
} else {
await this.clearTask()
}
await this.postStateToWebview()
} finally {
// Always clear the flag, even if cancellation fails
this.cancelInProgress = false
}
}
+8 -1
View File
@@ -327,7 +327,14 @@ export async function updateSettings(controller: Controller, request: UpdateSett
}
if (request.hooksEnabled !== undefined) {
controller.stateManager.setGlobalState("hooksEnabled", !!request.hooksEnabled)
const isEnabled = !!request.hooksEnabled
// Platform validation: Only allow enabling hooks on macOS and Linux
if (isEnabled && process.platform === "win32") {
throw new Error("Hooks are not yet supported on Windows")
}
controller.stateManager.setGlobalState("hooksEnabled", isEnabled)
}
if (request.subagentsEnabled !== undefined) {
+292
View File
@@ -0,0 +1,292 @@
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
}
/**
* Generic disposable interface for resource cleanup
*/
interface Disposable {
dispose(): void
}
/**
* Generic file watcher interface
*/
interface FileWatcher extends Disposable {
onDidCreate(listener: () => void): void
onDidChange(listener: () => void): void
onDidDelete(listener: () => void): void
}
/**
* Generic context interface for managing subscriptions
*/
interface ExtensionContext {
subscriptions: Disposable[]
}
/**
* 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, FileWatcher>()
// 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: ExtensionContext | null = null
private createFileWatcher: ((dir: string) => FileWatcher | 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: ExtensionContext,
createFileWatcher?: (dir: string) => FileWatcher | null,
onWorkspaceFoldersChanged?: (callback: () => void) => Disposable,
): void {
this.context = context
this.createFileWatcher = createFileWatcher || null
// Watch for workspace changes to invalidate cache (if callback provided)
if (onWorkspaceFoldersChanged) {
context.subscriptions.push(
onWorkspaceFoldersChanged(() => {
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
}
}
}
+123
View File
@@ -0,0 +1,123 @@
/**
* 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, hookName?: string): HookExecutionError {
const hookPrefix = hookName ? `${hookName} hook` : "Hook"
return new HookExecutionError({
type: HookErrorType.TIMEOUT,
message: `${hookPrefix} 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, hookName?: string): HookExecutionError {
const hookPrefix = hookName ? `${hookName} hook` : "Hook script"
const message = `${hookPrefix} 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, hookName?: string): HookExecutionError {
const hookPrefix = hookName ? `${hookName} hook` : "Hook"
return new HookExecutionError({
type: HookErrorType.CANCELLATION,
message: `${hookPrefix} execution was cancelled`,
details: "The hook was cancelled by the user before completion",
scriptPath,
exitCode: 130, // Standard SIGINT exit code
})
}
}
+374
View File
@@ -0,0 +1,374 @@
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)
* - Process lifecycle management with abort support
*/
export class HookProcess extends EventEmitter {
private childProcess: ChildProcess | null = null
private buffer = ""
private fullOutput = ""
private lastRetrievedIndex = 0
private exitCode: number | null = null
private isCompleted = false
private timeoutHandle: NodeJS.Timeout | null = null // 30-second execution timeout
// Separate buffers for stdout and stderr
private stdoutBuffer = ""
private stderrBuffer = ""
// Output size tracking
private stdoutSize = 0
private stderrSize = 0
private outputTruncated = false
// Track registration state to prevent leaks and ensure cleanup
private isRegistered = 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> {
// Wrap in try/finally to guarantee cleanup even if errors occur
try {
return await new Promise((resolve, reject) => {
// Register this process for tracking
HookProcessRegistry.register(this)
this.isRegistered = true
// Check if already aborted
if (this.abortSignal?.aborted) {
this.safeUnregister()
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 execution timeout timer
if (this.timeoutHandle) {
clearTimeout(this.timeoutHandle)
this.timeoutHandle = null
}
// Unregister from active processes
this.safeUnregister()
// 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 through shell on all platforms
// This is the git-style approach: the shell interprets the shebang line
// and executes the appropriate interpreter (bash, node, python, etc.)
// On Unix: detached=true creates a process group, allowing us to kill all children
this.childProcess = spawn(this.scriptPath, [], {
stdio: ["pipe", "pipe", "pipe"],
shell: true, // Use shell on all platforms for shebang interpretation
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
this.safeUnregister()
// Clear execution timeout timer
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
this.safeUnregister()
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}`))
}
})
} finally {
// Guaranteed cleanup even if process setup fails or throws
this.safeUnregister()
}
}
/**
* Safely unregister from the process registry.
* This is idempotent and prevents double-unregistration issues.
*/
private safeUnregister(): void {
if (this.isRegistered) {
HookProcessRegistry.unregister(this)
this.isRegistered = false
}
}
/**
* 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
}
// 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()
}
/**
* 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) {
// Still ensure unregistration even if process already completed
this.safeUnregister()
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
}
// Ensure unregistration even if termination fails
this.safeUnregister()
}
}
}
+63
View File
@@ -0,0 +1,63 @@
import { HookProcess } from "./HookProcess"
/**
* Global registry for tracking active hook processes.
*
* Purpose:
* - Prevents zombie processes by tracking all running hooks
* - Enables cleanup on extension deactivation
* - Provides visibility into active hook executions
*
* Usage:
* - HookProcess automatically registers/unregisters itself
* - Extension deactivation calls terminateAll()
* - Can query active count for monitoring/debugging
*/
export class HookProcessRegistry {
private static activeProcesses = new Set<HookProcess>()
/**
* Register a hook process as active.
* Called by HookProcess when execution starts.
*/
static register(process: HookProcess): void {
HookProcessRegistry.activeProcesses.add(process)
}
/**
* Unregister a hook process (completed or failed).
* Called by HookProcess when execution ends.
*/
static unregister(process: HookProcess): void {
HookProcessRegistry.activeProcesses.delete(process)
}
/**
* Terminate all active hook processes.
* Called during extension deactivation to prevent zombie processes.
*/
static async terminateAll(): Promise<void> {
const processes = Array.from(HookProcessRegistry.activeProcesses)
if (processes.length > 0) {
console.log(`[HookProcessRegistry] Terminating ${processes.length} active hook process(es)`)
await Promise.all(processes.map((p) => p.terminate()))
HookProcessRegistry.activeProcesses.clear()
}
}
/**
* Get the number of currently active hook processes.
* Useful for monitoring and debugging.
*/
static getActiveCount(): number {
return HookProcessRegistry.activeProcesses.size
}
/**
* Clear the registry (for testing only).
* @internal
*/
static resetForTesting(): void {
HookProcessRegistry.activeProcesses.clear()
}
}
+15 -15
View File
@@ -37,7 +37,7 @@ it("should work with real hook", async () => {
const runner = await factory.create("PreToolUse")
const result = await runner.run(buildPreToolUseInput({ toolName: "test_tool" }))
result.shouldContinue.should.be.true()
result.cancel.should.be.false()
})
```
@@ -50,15 +50,15 @@ For more control, you can also manually copy fixture files.
### PreToolUse Hooks
#### `hooks/pretooluse/success`
- **Returns**: `{ shouldContinue: true, contextModification: "PreToolUse hook executed successfully", errorMessage: "" }`
- **Returns**: `{ cancel: false, contextModification: "PreToolUse hook executed successfully", errorMessage: "" }`
- **Use for**: Testing happy path scenarios
#### `hooks/pretooluse/blocking`
- **Returns**: `{ shouldContinue: false, contextModification: "", errorMessage: "Tool execution blocked by hook" }`
- **Returns**: `{ cancel: true, contextModification: "", errorMessage: "Tool execution blocked by hook" }`
- **Use for**: Testing tool execution blocking
#### `hooks/pretooluse/context-injection`
- **Returns**: `{ shouldContinue: true, contextModification: "WORKSPACE_RULES: Tool [toolName] requires review", errorMessage: "" }`
- **Returns**: `{ cancel: false, contextModification: "WORKSPACE_RULES: Tool [toolName] requires review", errorMessage: "" }`
- **Use for**: Testing context injection with type prefixes
- **Note**: Dynamically includes tool name from input
@@ -69,7 +69,7 @@ For more control, you can also manually copy fixture files.
### PostToolUse Hooks
#### `hooks/posttooluse/success`
- **Returns**: `{ shouldContinue: true, contextModification: "PostToolUse hook executed successfully", errorMessage: "" }`
- **Returns**: `{ cancel: false, contextModification: "PostToolUse hook executed successfully", errorMessage: "" }`
- **Use for**: Testing PostToolUse execution
#### `hooks/posttooluse/error`
@@ -79,34 +79,34 @@ For more control, you can also manually copy fixture files.
### UserPromptSubmit Hooks
#### `hooks/userpromptsubmit/success`
- **Returns**: `{ shouldContinue: true, contextModification: "Prompt approved", errorMessage: "" }`
- **Returns**: `{ cancel: false, contextModification: "Prompt approved", errorMessage: "" }`
- **Use for**: Testing successful prompt submission
#### `hooks/userpromptsubmit/blocking`
- **Returns**: `{ shouldContinue: false, contextModification: "", errorMessage: "Prompt violates policy" }`
- **Returns**: `{ cancel: true, contextModification: "", errorMessage: "Prompt violates policy" }`
- **Use for**: Testing prompt submission blocking
#### `hooks/userpromptsubmit/context-injection`
- **Returns**: `{ shouldContinue: true, contextModification: "CONTEXT_INJECTION: User is in plan mode", errorMessage: "" }`
- **Returns**: `{ cancel: false, contextModification: "CONTEXT_INJECTION: User is in plan mode", errorMessage: "" }`
- **Use for**: Testing context injection into task request
#### `hooks/userpromptsubmit/multiline`
- **Returns**: `{ shouldContinue: true, contextModification: "Line count: N", errorMessage: "" }`
- **Returns**: `{ cancel: false, contextModification: "Line count: N", errorMessage: "" }`
- **Use for**: Testing multiline prompt handling
- **Note**: Dynamically counts newlines in the prompt
#### `hooks/userpromptsubmit/large-prompt`
- **Returns**: `{ shouldContinue: true, contextModification: "Prompt size: N", errorMessage: "" }`
- **Returns**: `{ cancel: false, contextModification: "Prompt size: N", errorMessage: "" }`
- **Use for**: Testing large prompt handling
- **Note**: Dynamically reports prompt character count
#### `hooks/userpromptsubmit/special-chars`
- **Returns**: `{ shouldContinue: true, contextModification: "Special chars preserved" | "Missing special chars", errorMessage: "" }`
- **Returns**: `{ cancel: false, contextModification: "Special chars preserved" | "Missing special chars", errorMessage: "" }`
- **Use for**: Testing special character preservation
- **Note**: Checks for @, #, and $ characters
#### `hooks/userpromptsubmit/empty-prompt`
- **Returns**: `{ shouldContinue: true, contextModification: "Prompt length: 0", errorMessage: "" }`
- **Returns**: `{ cancel: false, contextModification: "Prompt length: 0", errorMessage: "" }`
- **Use for**: Testing empty prompt handling
- **Note**: Safely handles undefined or empty prompts
@@ -121,11 +121,11 @@ For more control, you can also manually copy fixture files.
### TaskStart Hooks
#### `hooks/taskstart/success`
- **Returns**: `{ shouldContinue: true, contextModification: "TaskStart hook executed successfully", errorMessage: "" }`
- **Returns**: `{ cancel: false, contextModification: "TaskStart hook executed successfully", errorMessage: "" }`
- **Use for**: Testing TaskStart hook success path, allowing task to proceed
#### `hooks/taskstart/blocking`
- **Returns**: `{ shouldContinue: false, contextModification: "", errorMessage: "Task execution blocked by hook" }`
- **Returns**: `{ cancel: true, contextModification: "", errorMessage: "Task execution blocked by hook" }`
- **Use for**: Testing task blocking at start (e.g., policy enforcement)
#### `hooks/taskstart/error`
@@ -158,7 +158,7 @@ cat > src/core/hooks/__tests__/fixtures/hooks/pretooluse/my-new-scenario/PreTool
#!/usr/bin/env node
const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
console.log(JSON.stringify({
shouldContinue: true,
cancel: false,
contextModification: "My custom context",
errorMessage: ""
}));
@@ -1,7 +1,7 @@
#!/usr/bin/env node
const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
console.log(JSON.stringify({
shouldContinue: true,
cancel: false,
contextModification: "PostToolUse hook executed successfully",
errorMessage: ""
}));
@@ -1,7 +1,7 @@
#!/usr/bin/env node
const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
console.log(JSON.stringify({
shouldContinue: false,
cancel: true,
contextModification: "",
errorMessage: "Tool execution blocked by hook"
}));
@@ -2,7 +2,7 @@
const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
const toolName = input.preToolUse?.toolName || 'unknown';
console.log(JSON.stringify({
shouldContinue: true,
cancel: false,
contextModification: `WORKSPACE_RULES: Tool ${toolName} requires review`,
errorMessage: ""
}));
@@ -1,7 +1,7 @@
#!/usr/bin/env node
const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
console.log(JSON.stringify({
shouldContinue: true,
cancel: false,
contextModification: "PreToolUse hook executed successfully",
errorMessage: ""
}));
@@ -2,6 +2,6 @@
// Note: For TaskCancel, contextModification is completely ignored.
console.log(JSON.stringify({
shouldContinue: false,
cancel: true,
errorMessage: ""
}));
@@ -2,6 +2,6 @@
// Note: For TaskCancel, contextModification is completely ignored.
console.log(JSON.stringify({
shouldContinue: false,
cancel: true,
errorMessage: "some error happened"
}));
@@ -2,6 +2,6 @@
// Note: For TaskCancel, contextModification is completely ignored.
console.log(JSON.stringify({
shouldContinue: true,
cancel: false,
errorMessage: ""
}));
@@ -2,6 +2,6 @@
// Note: For TaskCancel, contextModification is completely ignored.
console.log(JSON.stringify({
shouldContinue: true,
cancel: false,
errorMessage: "some error happened"
}));
@@ -3,7 +3,7 @@ const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
const deleted = input.taskResume?.previousState?.conversationHistoryDeleted === 'true';
console.log(JSON.stringify({
shouldContinue: true,
cancel: false,
contextModification: deleted
? "TASK_CONTEXT: Some conversation history was truncated due to context window limits"
: "",
@@ -3,7 +3,7 @@ const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
const taskId = input.taskResume?.taskMetadata?.taskId || 'unknown';
console.log(JSON.stringify({
shouldContinue: true,
cancel: false,
contextModification: `WORKSPACE_RULES: Task ${taskId} resumed - review previous context`,
errorMessage: ""
}));
@@ -5,7 +5,7 @@ const now = Date.now();
const hoursAgo = Math.floor((now - lastMessageTs) / 3600000);
console.log(JSON.stringify({
shouldContinue: true,
cancel: false,
contextModification: hoursAgo >= 1
? `TASK_CONTEXT: Task was paused ${hoursAgo} hours ago - you may need to re-familiarize yourself with the context`
: "",
@@ -3,7 +3,7 @@ const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
const messageCount = parseInt(input.taskResume?.previousState?.messageCount || '0');
console.log(JSON.stringify({
shouldContinue: true,
cancel: false,
contextModification: `TASK_CONTEXT: Resuming task with ${messageCount} previous messages`,
errorMessage: ""
}));
@@ -5,7 +5,7 @@ const now = Date.now();
const minutesAgo = Math.floor((now - lastMessageTs) / 60000);
console.log(JSON.stringify({
shouldContinue: true,
cancel: false,
contextModification: minutesAgo < 5
? "TASK_CONTEXT: Recently paused task - context is still fresh"
: "",
@@ -2,7 +2,7 @@
const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
console.log(JSON.stringify({
shouldContinue: true,
cancel: false,
contextModification: "TaskResume hook executed successfully",
errorMessage: ""
}));
@@ -2,7 +2,7 @@
const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
console.log(JSON.stringify({
shouldContinue: false,
cancel: true,
contextModification: "",
errorMessage: "Task execution blocked by hook"
}));
@@ -2,7 +2,7 @@
const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
console.log(JSON.stringify({
shouldContinue: true,
cancel: false,
contextModification: "TaskStart hook executed successfully",
errorMessage: ""
}));
@@ -1,7 +1,7 @@
#!/usr/bin/env node
const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
console.log(JSON.stringify({
shouldContinue: false,
cancel: true,
contextModification: "",
errorMessage: "Prompt violates policy"
}));
@@ -1,7 +1,7 @@
#!/usr/bin/env node
const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
console.log(JSON.stringify({
shouldContinue: true,
cancel: false,
contextModification: "CONTEXT_INJECTION: User is in plan mode",
errorMessage: ""
}));
@@ -2,7 +2,7 @@
const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
const promptLength = typeof input.userPromptSubmit.prompt === 'string' ? input.userPromptSubmit.prompt.length : 0;
console.log(JSON.stringify({
shouldContinue: true,
cancel: false,
contextModification: "Prompt length: " + promptLength,
errorMessage: ""
}));
@@ -2,7 +2,7 @@
const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
const size = input.userPromptSubmit.prompt.length;
console.log(JSON.stringify({
shouldContinue: true,
cancel: false,
contextModification: "Prompt size: " + size,
errorMessage: ""
}));
@@ -2,7 +2,7 @@
const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
const lineCount = (input.userPromptSubmit.prompt.match(/\n/g) || []).length + 1;
console.log(JSON.stringify({
shouldContinue: true,
cancel: false,
contextModification: "Line count: " + lineCount,
errorMessage: ""
}));
@@ -3,7 +3,7 @@ const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
const prompt = input.userPromptSubmit.prompt;
const hasSpecialChars = prompt.includes("@") && prompt.includes("#") && prompt.includes("$");
console.log(JSON.stringify({
shouldContinue: true,
cancel: false,
contextModification: hasSpecialChars ? "Special chars preserved" : "Missing special chars",
errorMessage: ""
}));
@@ -1,7 +1,7 @@
#!/usr/bin/env node
const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
console.log(JSON.stringify({
shouldContinue: true,
cancel: false,
contextModification: "Prompt approved",
errorMessage: ""
}));
@@ -59,7 +59,7 @@ try {
// Error handling - hooks should handle their own errors gracefully
const errorMessage = error instanceof Error ? error.message : String(error);
console.log(JSON.stringify({
shouldContinue: false,
cancel: true,
contextModification: "",
errorMessage: `HOOK_ERROR: ${errorMessage}`
}));
+51 -42
View File
@@ -40,10 +40,19 @@ describe("Hook System", () => {
sandbox.stub(StateManager, "get").returns({
getGlobalStateKey: () => [{ path: tempDir }],
} as any)
// Reset hook discovery cache for clean test state
const { HookDiscoveryCache } = await import("../HookDiscoveryCache")
HookDiscoveryCache.resetForTesting()
})
afterEach(async () => {
sandbox.restore()
// Clean up hook discovery cache
const { HookDiscoveryCache } = await import("../HookDiscoveryCache")
HookDiscoveryCache.resetForTesting()
try {
await fs.rm(tempDir, { recursive: true, force: true })
} catch (error) {
@@ -64,7 +73,7 @@ describe("Hook System", () => {
},
})
result.shouldContinue.should.be.true()
result.cancel.should.be.false()
;(result.contextModification === undefined || result.contextModification === "").should.be.true()
})
})
@@ -76,7 +85,7 @@ describe("Hook System", () => {
const hookScript = `#!/usr/bin/env node
const input = require('fs').readFileSync(0, 'utf-8');
console.log(JSON.stringify({
shouldContinue: true,
cancel: false,
contextModification: "TEST_CONTEXT: Added by hook"
}))`
@@ -94,7 +103,7 @@ console.log(JSON.stringify({
},
})
result.shouldContinue.should.be.true()
result.cancel.should.be.false()
result.contextModification!.should.equal("TEST_CONTEXT: Added by hook")
})
@@ -102,7 +111,7 @@ console.log(JSON.stringify({
const hookPath = path.join(tempDir, ".clinerules", "hooks", "PreToolUse")
const hookScript = `#!/usr/bin/env node
console.log(JSON.stringify({
shouldContinue: false,
cancel: true,
errorMessage: "Hook blocked execution"
}))`
@@ -119,7 +128,7 @@ console.log(JSON.stringify({
},
})
result.shouldContinue.should.be.false()
result.cancel.should.be.true()
result.errorMessage!.should.equal("Hook blocked execution")
})
@@ -129,7 +138,7 @@ console.log(JSON.stringify({
const largeContext = "x".repeat(60000)
const hookScript = `#!/usr/bin/env node
console.log(JSON.stringify({
shouldContinue: true,
cancel: false,
contextModification: "${largeContext}"
}))`
@@ -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.cancel.should.be.false()
;(result.contextModification === undefined || result.contextModification === "").should.be.true()
})
it("should pass hook input via stdin", async () => {
@@ -203,7 +212,7 @@ console.log("not valid json")`
const hookScript = `#!/usr/bin/env node
const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
console.log(JSON.stringify({
shouldContinue: true,
cancel: false,
contextModification: "Received tool: " + input.preToolUse.toolName
}))`
@@ -230,7 +239,7 @@ console.log(JSON.stringify({
const hookScript = `#!/usr/bin/env node
const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
console.log(JSON.stringify({
shouldContinue: true,
cancel: false,
contextModification: "Tool succeeded: " + input.postToolUse.success
}))`
@@ -258,7 +267,7 @@ console.log(JSON.stringify({
it("should find executable hook", async () => {
const hookPath = path.join(tempDir, ".clinerules", "hooks", "PreToolUse")
const hookScript = `#!/usr/bin/env node
console.log(JSON.stringify({ shouldContinue: true }))`
console.log(JSON.stringify({ cancel: false }))`
await fs.writeFile(hookPath, hookScript)
await fs.chmod(hookPath, 0o755)
@@ -275,13 +284,13 @@ console.log(JSON.stringify({ shouldContinue: true }))`
},
})
result.shouldContinue.should.be.true()
result.cancel.should.be.false()
})
it("should not find non-executable file", async () => {
const hookPath = path.join(tempDir, ".clinerules", "hooks", "PreToolUse")
const hookScript = `#!/usr/bin/env node
console.log(JSON.stringify({ shouldContinue: true }))`
console.log(JSON.stringify({ cancel: false }))`
// Write but don't make executable
await fs.writeFile(hookPath, hookScript)
@@ -301,7 +310,7 @@ console.log(JSON.stringify({ shouldContinue: true }))`
})
// NoOpRunner always returns success
result.shouldContinue.should.be.true()
result.cancel.should.be.false()
})
it("should handle missing hooks gracefully", async () => {
@@ -318,7 +327,7 @@ console.log(JSON.stringify({ shouldContinue: true }))`
},
})
result.shouldContinue.should.be.true()
result.cancel.should.be.false()
})
})
@@ -337,7 +346,7 @@ console.log(JSON.stringify({ shouldContinue: true }))`
},
})
result.shouldContinue.should.be.true()
result.cancel.should.be.false()
})
it("should handle hook input with all parameters", async () => {
@@ -347,7 +356,7 @@ const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
const hasAllFields = input.clineVersion && input.hookName && input.timestamp &&
input.taskId && input.workspaceRoots !== undefined;
console.log(JSON.stringify({
shouldContinue: true,
cancel: false,
contextModification: hasAllFields ? "All fields present" : "Missing fields"
}))`
@@ -394,7 +403,7 @@ console.log(JSON.stringify({
const globalHookScript = `#!/usr/bin/env node
const input = require('fs').readFileSync(0, 'utf-8');
console.log(JSON.stringify({
shouldContinue: true,
cancel: false,
contextModification: "GLOBAL: Context added"
}))`
await writeHookScript(globalHookPath, globalHookScript)
@@ -404,7 +413,7 @@ console.log(JSON.stringify({
const workspaceHookScript = `#!/usr/bin/env node
const input = require('fs').readFileSync(0, 'utf-8');
console.log(JSON.stringify({
shouldContinue: true,
cancel: false,
contextModification: "WORKSPACE: Context added"
}))`
await writeHookScript(workspaceHookPath, workspaceHookScript)
@@ -418,7 +427,7 @@ console.log(JSON.stringify({
})
// Both contexts should be present (order not guaranteed)
result.shouldContinue.should.be.true()
result.cancel.should.be.false()
result.contextModification!.should.match(/GLOBAL: Context added/)
result.contextModification!.should.match(/WORKSPACE: Context added/)
})
@@ -428,7 +437,7 @@ console.log(JSON.stringify({
const globalHookPath = path.join(globalHooksDir, "PreToolUse")
const globalHookScript = `#!/usr/bin/env node
console.log(JSON.stringify({
shouldContinue: false,
cancel: true,
errorMessage: "Global policy violation"
}))`
await writeHookScript(globalHookPath, globalHookScript)
@@ -437,7 +446,7 @@ console.log(JSON.stringify({
const workspaceHookPath = path.join(tempDir, ".clinerules", "hooks", "PreToolUse")
const workspaceHookScript = `#!/usr/bin/env node
console.log(JSON.stringify({
shouldContinue: true
cancel: false
}))`
await writeHookScript(workspaceHookPath, workspaceHookScript)
@@ -448,7 +457,7 @@ console.log(JSON.stringify({
preToolUse: { toolName: "test_tool", parameters: {} },
})
result.shouldContinue.should.be.false()
result.cancel.should.be.true()
result.errorMessage!.should.match(/Global policy violation/)
})
@@ -457,7 +466,7 @@ console.log(JSON.stringify({
const globalHookPath = path.join(globalHooksDir, "PreToolUse")
const globalHookScript = `#!/usr/bin/env node
console.log(JSON.stringify({
shouldContinue: true,
cancel: false,
contextModification: "Global hook only"
}))`
await writeHookScript(globalHookPath, globalHookScript)
@@ -469,7 +478,7 @@ console.log(JSON.stringify({
preToolUse: { toolName: "test_tool", parameters: {} },
})
result.shouldContinue.should.be.true()
result.cancel.should.be.false()
result.contextModification!.should.equal("Global hook only")
})
@@ -478,7 +487,7 @@ console.log(JSON.stringify({
const globalHookPath = path.join(globalHooksDir, "PreToolUse")
const globalHookScript = `#!/usr/bin/env node
console.log(JSON.stringify({
shouldContinue: true,
cancel: false,
contextModification: "Global allows"
}))`
await writeHookScript(globalHookPath, globalHookScript)
@@ -487,7 +496,7 @@ console.log(JSON.stringify({
const workspaceHookPath = path.join(tempDir, ".clinerules", "hooks", "PreToolUse")
const workspaceHookScript = `#!/usr/bin/env node
console.log(JSON.stringify({
shouldContinue: false,
cancel: true,
errorMessage: "Workspace blocks"
}))`
await writeHookScript(workspaceHookPath, workspaceHookScript)
@@ -499,7 +508,7 @@ console.log(JSON.stringify({
preToolUse: { toolName: "test_tool", parameters: {} },
})
result.shouldContinue.should.be.false()
result.cancel.should.be.true()
result.errorMessage!.should.match(/Workspace blocks/)
// Context from global should still be included
result.contextModification!.should.match(/Global allows/)
@@ -510,7 +519,7 @@ console.log(JSON.stringify({
const globalHookPath = path.join(globalHooksDir, "PreToolUse")
const globalHookScript = `#!/usr/bin/env node
console.log(JSON.stringify({
shouldContinue: false,
cancel: true,
errorMessage: "Global error"
}))`
await writeHookScript(globalHookPath, globalHookScript)
@@ -519,7 +528,7 @@ console.log(JSON.stringify({
const workspaceHookPath = path.join(tempDir, ".clinerules", "hooks", "PreToolUse")
const workspaceHookScript = `#!/usr/bin/env node
console.log(JSON.stringify({
shouldContinue: false,
cancel: true,
errorMessage: "Workspace error"
}))`
await writeHookScript(workspaceHookPath, workspaceHookScript)
@@ -531,7 +540,7 @@ console.log(JSON.stringify({
preToolUse: { toolName: "test_tool", parameters: {} },
})
result.shouldContinue.should.be.false()
result.cancel.should.be.true()
result.errorMessage!.should.match(/Global error/)
result.errorMessage!.should.match(/Workspace error/)
})
@@ -542,7 +551,7 @@ console.log(JSON.stringify({
const globalHookScript = `#!/usr/bin/env node
const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
console.log(JSON.stringify({
shouldContinue: true,
cancel: false,
contextModification: "Global observed: " + input.postToolUse.success
}))`
await writeHookScript(globalHookPath, globalHookScript)
@@ -0,0 +1,111 @@
import { afterEach, beforeEach, describe, it } from "mocha"
import "should"
import sinon from "sinon"
import { getHooksEnabledSafe } from "../hooks-utils"
describe("hooks-utils", () => {
let sandbox: sinon.SinonSandbox
let originalPlatform: string
beforeEach(() => {
sandbox = sinon.createSandbox()
originalPlatform = process.platform
})
afterEach(() => {
sandbox.restore()
// Restore original platform
Object.defineProperty(process, "platform", {
value: originalPlatform,
writable: true,
configurable: true,
})
})
describe("getHooksEnabledSafe", () => {
describe("on Windows platform", () => {
beforeEach(() => {
// Mock Windows platform
Object.defineProperty(process, "platform", {
value: "win32",
writable: true,
configurable: true,
})
})
it("should return false when user setting is true", () => {
const result = getHooksEnabledSafe(true)
result.should.be.false()
})
it("should return false when user setting is false", () => {
const result = getHooksEnabledSafe(false)
result.should.be.false()
})
it("should return false when user setting is undefined", () => {
const result = getHooksEnabledSafe(undefined)
result.should.be.false()
})
})
describe("on non-Windows platforms", () => {
const platforms = ["darwin", "linux", "freebsd", "openbsd", "sunos", "aix"]
platforms.forEach((platform) => {
describe(`on ${platform}`, () => {
beforeEach(() => {
Object.defineProperty(process, "platform", {
value: platform,
writable: true,
configurable: true,
})
})
it("should return true when user setting is true", () => {
const result = getHooksEnabledSafe(true)
result.should.be.true()
})
it("should return false when user setting is false", () => {
const result = getHooksEnabledSafe(false)
result.should.be.false()
})
it("should return false when user setting is undefined (default)", () => {
const result = getHooksEnabledSafe(undefined)
result.should.be.false()
})
})
})
})
describe("edge cases", () => {
it("should handle macOS platform correctly", () => {
Object.defineProperty(process, "platform", {
value: "darwin",
writable: true,
configurable: true,
})
// macOS should respect user setting
getHooksEnabledSafe(true).should.be.true()
getHooksEnabledSafe(false).should.be.false()
getHooksEnabledSafe(undefined).should.be.false()
})
it("should handle Linux platform correctly", () => {
Object.defineProperty(process, "platform", {
value: "linux",
writable: true,
configurable: true,
})
// Linux should respect user setting
getHooksEnabledSafe(true).should.be.true()
getHooksEnabledSafe(false).should.be.false()
getHooksEnabledSafe(undefined).should.be.false()
})
})
})
})
+59 -50
View File
@@ -42,10 +42,19 @@ describe("TaskCancel Hook", () => {
} as any)
getEnv = () => ({ tempDir })
// Reset hook discovery cache for clean test state
const { HookDiscoveryCache } = await import("../HookDiscoveryCache")
HookDiscoveryCache.resetForTesting()
})
afterEach(async () => {
sandbox.restore()
// Clean up hook discovery cache
const { HookDiscoveryCache } = await import("../HookDiscoveryCache")
HookDiscoveryCache.resetForTesting()
try {
await fs.rm(tempDir, { recursive: true, force: true })
} catch (error) {
@@ -61,7 +70,7 @@ const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
const metadata = input.taskCancel.taskMetadata;
const hasAllFields = metadata.taskId && metadata.ulid && metadata.completionStatus;
console.log(JSON.stringify({
shouldContinue: true,
cancel: false,
contextModification: hasAllFields ? "Test passed" : "Missing metadata",
errorMessage: ""
}))`
@@ -82,7 +91,7 @@ console.log(JSON.stringify({
},
})
result.shouldContinue.should.be.true()
result.cancel.should.be.false()
// Note: contextModification is ignored for TaskCancel hooks
})
@@ -96,7 +105,7 @@ if (status !== "abandoned") {
process.exit(1);
}
console.log(JSON.stringify({
shouldContinue: true,
cancel: false,
contextModification: "",
errorMessage: ""
}))`
@@ -117,7 +126,7 @@ console.log(JSON.stringify({
},
})
result.shouldContinue.should.be.true()
result.cancel.should.be.false()
// Note: contextModification is ignored for TaskCancel hooks
})
@@ -133,7 +142,7 @@ if (!hasAllFields) {
process.exit(1);
}
console.log(JSON.stringify({
shouldContinue: true,
cancel: false,
contextModification: "",
errorMessage: ""
}))`
@@ -154,7 +163,7 @@ console.log(JSON.stringify({
},
})
result.shouldContinue.should.be.true()
result.cancel.should.be.false()
// Note: contextModification is ignored for TaskCancel hooks
})
})
@@ -164,7 +173,7 @@ console.log(JSON.stringify({
const hookPath = path.join(tempDir, ".clinerules", "hooks", "TaskCancel")
const hookScript = `#!/usr/bin/env node
console.log(JSON.stringify({
shouldContinue: true,
cancel: false,
contextModification: "This is a context modification that should be ignored",
errorMessage: ""
}))`
@@ -185,14 +194,14 @@ console.log(JSON.stringify({
},
})
// Hook returns contextModification, but it's completely ignored
result1.shouldContinue.should.be.true()
// Hook returns contextModification, but it's completely ignored (fire-and-forget)
result1.cancel.should.be.false()
result1.contextModification!.should.equal("This is a context modification that should be ignored")
// Update hook to return different contextModification
const hookScript2 = `#!/usr/bin/env node
console.log(JSON.stringify({
shouldContinue: true,
cancel: false,
contextModification: "Different context that is also ignored",
errorMessage: ""
}))`
@@ -210,10 +219,10 @@ console.log(JSON.stringify({
})
// Both results behave identically - contextModification has no effect
result2.shouldContinue.should.be.true()
result2.cancel.should.be.false()
result2.contextModification!.should.equal("Different context that is also ignored")
// The key point: both executions succeeded with shouldContinue: true
// The contextModification value is different but behavior is identical
// The key point: both executions succeeded with cancel: false
// The contextModification value is different but behavior is identical (fire-and-forget)
})
it("should succeed regardless of hook return value", async () => {
@@ -221,7 +230,7 @@ console.log(JSON.stringify({
const hookScript = `#!/usr/bin/env node
// Note: contextModification is ignored for TaskCancel hooks
console.log(JSON.stringify({
shouldContinue: true,
cancel: false,
contextModification: "",
errorMessage: ""
}))`
@@ -243,14 +252,14 @@ console.log(JSON.stringify({
})
// TaskCancel is fire-and-forget, so it always reports success
result.shouldContinue.should.be.true()
result.cancel.should.be.false()
})
it("should return error message when hook returns shouldContinue: false", async () => {
it("should return error message when hook returns cancel: true", async () => {
const hookPath = path.join(tempDir, ".clinerules", "hooks", "TaskCancel")
const hookScript = `#!/usr/bin/env node
console.log(JSON.stringify({
shouldContinue: false,
cancel: true,
contextModification: "",
errorMessage: "Hook tried to block cancellation"
}))`
@@ -271,10 +280,10 @@ console.log(JSON.stringify({
},
})
// Hook result includes shouldContinue: false and errorMessage
// Hook result includes cancel: true and errorMessage
// In abortTask(), the errorMessage will be surfaced to the user via this.say("error", ...)
// but cancellation will still proceed (fire-and-forget behavior)
result.shouldContinue.should.be.false()
result.cancel.should.be.true()
result.errorMessage!.should.equal("Hook tried to block cancellation")
})
@@ -286,7 +295,7 @@ const status = input.taskCancel.taskMetadata.completionStatus;
// Hook can perform cleanup/logging based on status
// Note: contextModification is ignored for TaskCancel hooks
console.log(JSON.stringify({
shouldContinue: true,
cancel: false,
contextModification: "",
errorMessage: ""
}))`
@@ -307,7 +316,7 @@ console.log(JSON.stringify({
},
})
result.shouldContinue.should.be.true()
result.cancel.should.be.false()
})
})
@@ -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.cancel.should.be.false()
;(result.contextModification === undefined || result.contextModification === "").should.be.true()
})
})
@@ -393,7 +402,7 @@ console.log("not valid json")`
const globalHookScript = `#!/usr/bin/env node
// Note: contextModification is ignored for TaskCancel hooks
console.log(JSON.stringify({
shouldContinue: true,
cancel: false,
contextModification: "",
errorMessage: ""
}))`
@@ -404,7 +413,7 @@ console.log(JSON.stringify({
const workspaceHookScript = `#!/usr/bin/env node
// Note: contextModification is ignored for TaskCancel hooks
console.log(JSON.stringify({
shouldContinue: true,
cancel: false,
contextModification: "",
errorMessage: ""
}))`
@@ -423,7 +432,7 @@ console.log(JSON.stringify({
},
})
result.shouldContinue.should.be.true()
result.cancel.should.be.false()
// Both hooks executed successfully
})
@@ -434,7 +443,7 @@ const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
// Can perform cleanup based on completion status
// Note: contextModification is ignored for TaskCancel hooks
console.log(JSON.stringify({
shouldContinue: true,
cancel: false,
contextModification: "",
errorMessage: ""
}))`
@@ -444,7 +453,7 @@ console.log(JSON.stringify({
const workspaceHookScript = `#!/usr/bin/env node
// Note: contextModification is ignored for TaskCancel hooks
console.log(JSON.stringify({
shouldContinue: true,
cancel: false,
contextModification: "",
errorMessage: ""
}))`
@@ -463,7 +472,7 @@ console.log(JSON.stringify({
},
})
result.shouldContinue.should.be.true()
result.cancel.should.be.false()
// Both hooks executed successfully
})
})
@@ -484,12 +493,12 @@ console.log(JSON.stringify({
},
})
result.shouldContinue.should.be.true()
result.cancel.should.be.false()
})
})
describe("Fixture-Based Tests", () => {
it("should handle shouldContinue: false with no error message", async () => {
it("should handle cancel: true with no error message", async () => {
await loadFixture("hooks/taskcancel/false-no-error", getEnv().tempDir)
const factory = new HookFactory()
@@ -506,13 +515,13 @@ console.log(JSON.stringify({
},
})
result.shouldContinue.should.be.false()
result.cancel.should.be.true()
result.errorMessage!.should.equal("")
// In abortTask(), no error is surfaced since errorMessage is empty
// Cancellation still proceeds (fire-and-forget)
})
it("should handle shouldContinue: false with error message", async () => {
it("should handle cancel: true with error message", async () => {
await loadFixture("hooks/taskcancel/false-with-error", getEnv().tempDir)
const factory = new HookFactory()
@@ -529,13 +538,13 @@ console.log(JSON.stringify({
},
})
result.shouldContinue.should.be.false()
result.cancel.should.be.true()
result.errorMessage!.should.equal("some error happened")
// In abortTask(), the errorMessage WILL be surfaced to user via this.say("error", ...)
// Cancellation still proceeds (fire-and-forget)
})
it("should handle shouldContinue: true with no error message", async () => {
it("should handle cancel: false with no error message", async () => {
await loadFixture("hooks/taskcancel/true-no-error", getEnv().tempDir)
const factory = new HookFactory()
@@ -552,12 +561,12 @@ console.log(JSON.stringify({
},
})
result.shouldContinue.should.be.true()
result.cancel.should.be.false()
result.errorMessage!.should.equal("")
// Normal success case - no errors to surface
})
it("should handle shouldContinue: true with error message", async () => {
it("should handle cancel: false with error message", async () => {
await loadFixture("hooks/taskcancel/true-with-error", getEnv().tempDir)
const factory = new HookFactory()
@@ -574,7 +583,7 @@ console.log(JSON.stringify({
},
})
result.shouldContinue.should.be.true()
result.cancel.should.be.false()
result.errorMessage!.should.equal("some error happened")
// In abortTask(), the errorMessage WILL be surfaced to user via this.say("error", ...)
// This is the scenario that was fixed - error messages are now displayed regardless of shouldContinue value
+44 -38
View File
@@ -41,6 +41,12 @@ describe("TaskResume Hook", () => {
})
afterEach(async () => {
sandbox.restore()
// Clean up hook discovery cache
const { HookDiscoveryCache } = await import("../HookDiscoveryCache")
HookDiscoveryCache.resetForTesting()
sandbox.restore()
try {
await fs.rm(tempDir, { recursive: true, force: true })
@@ -61,7 +67,7 @@ const hasRequiredFields =
typeof input.taskResume.taskMetadata.taskId === 'string' &&
typeof input.taskResume.previousState.messageCount === 'string';
console.log(JSON.stringify({
shouldContinue: true,
cancel: false,
contextModification: hasRequiredFields ? "All fields present" : "Missing fields"
}))`
@@ -85,7 +91,7 @@ console.log(JSON.stringify({
},
})
result.shouldContinue.should.be.true()
result.cancel.should.be.false()
result.contextModification!.should.equal("All fields present")
})
@@ -96,7 +102,7 @@ const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
const hasAllFields = input.clineVersion && input.hookName && input.timestamp &&
input.taskId && input.workspaceRoots !== undefined;
console.log(JSON.stringify({
shouldContinue: true,
cancel: false,
contextModification: hasAllFields ? "All fields present" : "Missing fields"
}))`
@@ -130,7 +136,7 @@ const lastTs = parseInt(input.taskResume.previousState.lastMessageTs);
const now = Date.now();
const minutesAgo = Math.floor((now - lastTs) / 60000);
console.log(JSON.stringify({
shouldContinue: true,
cancel: false,
contextModification: "Minutes ago: " + minutesAgo
}))`
@@ -172,7 +178,7 @@ const lastTs = parseInt(input.taskResume.previousState.lastMessageTs);
const now = Date.now();
const daysAgo = Math.floor((now - lastTs) / (24 * 60 * 60 * 1000));
console.log(JSON.stringify({
shouldContinue: true,
cancel: false,
contextModification: daysAgo > 0 ? "Days ago: " + daysAgo : "Recent"
}))`
@@ -206,7 +212,7 @@ const lastTs = parseInt(input.taskResume.previousState.lastMessageTs);
const now = Date.now();
const isFuture = lastTs > now;
console.log(JSON.stringify({
shouldContinue: true,
cancel: false,
contextModification: isFuture ? "Future timestamp detected" : "Normal timestamp"
}))`
@@ -243,7 +249,7 @@ if (count < 5) category = "short";
else if (count < 20) category = "medium";
else category = "long";
console.log(JSON.stringify({
shouldContinue: true,
cancel: false,
contextModification: "Conversation length: " + category + " (" + count + " messages)"
}))`
@@ -281,7 +287,7 @@ console.log(JSON.stringify({
const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
const count = parseInt(input.taskResume.previousState.messageCount);
console.log(JSON.stringify({
shouldContinue: true,
cancel: false,
contextModification: count === 0 ? "Empty conversation" : "Has messages"
}))`
@@ -316,7 +322,7 @@ const count = parseInt(input.taskResume.previousState.messageCount);
const hoursAgo = Math.floor((Date.now() - lastTs) / (60 * 60 * 1000));
const isStale = hoursAgo > 24 && count > 20;
console.log(JSON.stringify({
shouldContinue: true,
cancel: false,
contextModification: isStale ? "STALE_TASK: Long conversation paused for extended time" : "Active task"
}))`
@@ -348,7 +354,7 @@ const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
const deleted = input.taskResume.previousState.conversationHistoryDeleted === 'true';
const count = parseInt(input.taskResume.previousState.messageCount);
console.log(JSON.stringify({
shouldContinue: true,
cancel: false,
contextModification: deleted && count > 10
? "CONTEXT_WARNING: Large conversation with truncated history"
: "Normal state"
@@ -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.cancel.should.be.false()
;(result.contextModification === undefined || result.contextModification === "").should.be.true()
})
it("should handle invalid timestamp gracefully", async () => {
@@ -411,7 +417,7 @@ const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
const lastTs = parseInt(input.taskResume.previousState.lastMessageTs);
const isValid = !isNaN(lastTs) && lastTs > 0;
console.log(JSON.stringify({
shouldContinue: true,
cancel: false,
contextModification: isValid ? "Valid timestamp" : "Invalid timestamp"
}))`
@@ -456,7 +462,7 @@ console.log(JSON.stringify({
const globalHookPath = path.join(globalHooksDir, "TaskResume")
const globalHookScript = `#!/usr/bin/env node
console.log(JSON.stringify({
shouldContinue: true,
cancel: false,
contextModification: "GLOBAL: Task resumed"
}))`
await writeHookScript(globalHookPath, globalHookScript)
@@ -464,7 +470,7 @@ console.log(JSON.stringify({
const workspaceHookPath = path.join(tempDir, ".clinerules", "hooks", "TaskResume")
const workspaceHookScript = `#!/usr/bin/env node
console.log(JSON.stringify({
shouldContinue: true,
cancel: false,
contextModification: "WORKSPACE: Task resumed"
}))`
await writeHookScript(workspaceHookPath, workspaceHookScript)
@@ -484,7 +490,7 @@ console.log(JSON.stringify({
},
})
result.shouldContinue.should.be.true()
result.cancel.should.be.false()
result.contextModification!.should.match(/GLOBAL: Task resumed/)
result.contextModification!.should.match(/WORKSPACE: Task resumed/)
})
@@ -498,7 +504,7 @@ const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
const lastTs = parseInt(input.taskResume.previousState.lastMessageTs);
const daysAgo = Math.floor((Date.now() - lastTs) / (24 * 60 * 60 * 1000));
console.log(JSON.stringify({
shouldContinue: true,
cancel: false,
contextModification: "GLOBAL_POLICY: " + (daysAgo > 0 ? "Review task context" : "Continue")
}))`
await writeHookScript(globalHookPath, globalHookScript)
@@ -508,7 +514,7 @@ console.log(JSON.stringify({
const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
const count = parseInt(input.taskResume.previousState.messageCount);
console.log(JSON.stringify({
shouldContinue: true,
cancel: false,
contextModification: "PROJECT_NOTE: " + count + " messages in history"
}))`
await writeHookScript(workspaceHookPath, workspaceHookScript)
@@ -550,7 +556,7 @@ console.log(JSON.stringify({
},
})
result.shouldContinue.should.be.true()
result.cancel.should.be.false()
})
})
@@ -578,7 +584,7 @@ console.log(JSON.stringify({
},
})
result.shouldContinue.should.be.true()
result.cancel.should.be.false()
result.contextModification!.should.equal("TaskResume hook executed successfully")
})
@@ -598,7 +604,7 @@ console.log(JSON.stringify({
},
})
result.shouldContinue.should.be.true()
result.cancel.should.be.false()
result.contextModification!.should.match(/Recently paused task/)
})
@@ -618,7 +624,7 @@ console.log(JSON.stringify({
},
})
result.shouldContinue.should.be.true()
result.cancel.should.be.false()
result.contextModification!.should.match(/paused 48 hours ago/)
})
@@ -637,7 +643,7 @@ console.log(JSON.stringify({
},
})
result.shouldContinue.should.be.true()
result.cancel.should.be.false()
result.contextModification!.should.match(/truncated/)
})
@@ -656,7 +662,7 @@ console.log(JSON.stringify({
},
})
result.shouldContinue.should.be.true()
result.cancel.should.be.false()
result.contextModification!.should.equal("TASK_CONTEXT: Resuming task with 25 previous messages")
})
@@ -675,7 +681,7 @@ console.log(JSON.stringify({
},
})
result.shouldContinue.should.be.true()
result.cancel.should.be.false()
result.contextModification!.should.equal("WORKSPACE_RULES: Task test-task resumed - review previous context")
})
+45 -40
View File
@@ -46,6 +46,11 @@ describe("TaskStart Hook", () => {
afterEach(async () => {
sandbox.restore()
// Clean up hook discovery cache
const { HookDiscoveryCache } = await import("../HookDiscoveryCache")
HookDiscoveryCache.resetForTesting()
try {
await fs.rm(tempDir, { recursive: true, force: true })
} catch (error) {
@@ -61,7 +66,7 @@ const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
const metadata = input.taskStart.taskMetadata;
const hasAllFields = metadata.taskId && metadata.ulid && 'initialTask' in metadata;
console.log(JSON.stringify({
shouldContinue: true,
cancel: false,
contextModification: hasAllFields ? "All metadata present" : "Missing metadata",
errorMessage: ""
}))`
@@ -82,7 +87,7 @@ console.log(JSON.stringify({
},
})
result.shouldContinue.should.be.true()
result.cancel.should.be.false()
result.contextModification!.should.equal("All metadata present")
})
@@ -94,7 +99,7 @@ const hasAllFields = input.clineVersion && input.hookName === 'TaskStart' &&
input.timestamp && input.taskId &&
input.workspaceRoots !== undefined;
console.log(JSON.stringify({
shouldContinue: true,
cancel: false,
contextModification: hasAllFields ? "All fields present" : "Missing fields",
errorMessage: ""
}))`
@@ -115,7 +120,7 @@ console.log(JSON.stringify({
},
})
result.shouldContinue.should.be.true()
result.cancel.should.be.false()
result.contextModification!.should.equal("All fields present")
})
@@ -125,7 +130,7 @@ console.log(JSON.stringify({
const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
const initialTask = input.taskStart.taskMetadata.initialTask;
console.log(JSON.stringify({
shouldContinue: true,
cancel: false,
contextModification: "Task length: " + initialTask.length,
errorMessage: ""
}))`
@@ -146,17 +151,17 @@ console.log(JSON.stringify({
},
})
result.shouldContinue.should.be.true()
result.cancel.should.be.false()
result.contextModification!.should.equal("Task length: 0")
})
})
describe("Hook Behavior", () => {
it("should allow task to start when hook returns shouldContinue: true", async () => {
it("should allow task to start when hook returns cancel: false", async () => {
const hookPath = path.join(tempDir, ".clinerules", "hooks", "TaskStart")
const hookScript = `#!/usr/bin/env node
console.log(JSON.stringify({
shouldContinue: true,
cancel: false,
contextModification: "TaskStart hook executed successfully",
errorMessage: ""
}))`
@@ -177,15 +182,15 @@ console.log(JSON.stringify({
},
})
result.shouldContinue.should.be.true()
result.cancel.should.be.false()
result.contextModification!.should.equal("TaskStart hook executed successfully")
})
it("should block task when hook returns shouldContinue: false", async () => {
it("should block task when hook returns cancel: true", async () => {
const hookPath = path.join(tempDir, ".clinerules", "hooks", "TaskStart")
const hookScript = `#!/usr/bin/env node
console.log(JSON.stringify({
shouldContinue: false,
cancel: true,
contextModification: "",
errorMessage: "Task execution blocked by hook"
}))`
@@ -206,7 +211,7 @@ console.log(JSON.stringify({
},
})
result.shouldContinue.should.be.false()
result.cancel.should.be.true()
result.errorMessage!.should.equal("Task execution blocked by hook")
})
@@ -215,7 +220,7 @@ console.log(JSON.stringify({
const hookScript = `#!/usr/bin/env node
const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
console.log(JSON.stringify({
shouldContinue: true,
cancel: false,
contextModification: "TASK_START: Task '" + input.taskStart.taskMetadata.initialTask + "' beginning",
errorMessage: ""
}))`
@@ -236,7 +241,7 @@ console.log(JSON.stringify({
},
})
result.shouldContinue.should.be.true()
result.cancel.should.be.false()
result.contextModification!.should.equal("TASK_START: Task 'Build a todo app' beginning")
})
})
@@ -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.cancel.should.be.false()
;(result.contextModification === undefined || result.contextModification === "").should.be.true()
})
})
@@ -321,7 +326,7 @@ console.log("not valid json")`
const globalHookPath = path.join(globalHooksDir, "TaskStart")
const globalHookScript = `#!/usr/bin/env node
console.log(JSON.stringify({
shouldContinue: true,
cancel: false,
contextModification: "GLOBAL: Task starting",
errorMessage: ""
}))`
@@ -331,7 +336,7 @@ console.log(JSON.stringify({
const workspaceHookPath = path.join(tempDir, ".clinerules", "hooks", "TaskStart")
const workspaceHookScript = `#!/usr/bin/env node
console.log(JSON.stringify({
shouldContinue: true,
cancel: false,
contextModification: "WORKSPACE: Task starting",
errorMessage: ""
}))`
@@ -350,7 +355,7 @@ console.log(JSON.stringify({
},
})
result.shouldContinue.should.be.true()
result.cancel.should.be.false()
result.contextModification!.should.match(/GLOBAL: Task starting/)
result.contextModification!.should.match(/WORKSPACE: Task starting/)
})
@@ -359,7 +364,7 @@ console.log(JSON.stringify({
const globalHookPath = path.join(globalHooksDir, "TaskStart")
const globalHookScript = `#!/usr/bin/env node
console.log(JSON.stringify({
shouldContinue: false,
cancel: true,
contextModification: "",
errorMessage: "Global policy blocks this task"
}))`
@@ -368,7 +373,7 @@ console.log(JSON.stringify({
const workspaceHookPath = path.join(tempDir, ".clinerules", "hooks", "TaskStart")
const workspaceHookScript = `#!/usr/bin/env node
console.log(JSON.stringify({
shouldContinue: true,
cancel: false,
contextModification: "Workspace allows",
errorMessage: ""
}))`
@@ -387,7 +392,7 @@ console.log(JSON.stringify({
},
})
result.shouldContinue.should.be.false()
result.cancel.should.be.true()
result.errorMessage!.should.match(/Global policy blocks this task/)
})
@@ -395,7 +400,7 @@ console.log(JSON.stringify({
const globalHookPath = path.join(globalHooksDir, "TaskStart")
const globalHookScript = `#!/usr/bin/env node
console.log(JSON.stringify({
shouldContinue: true,
cancel: false,
contextModification: "Global allows",
errorMessage: ""
}))`
@@ -404,7 +409,7 @@ console.log(JSON.stringify({
const workspaceHookPath = path.join(tempDir, ".clinerules", "hooks", "TaskStart")
const workspaceHookScript = `#!/usr/bin/env node
console.log(JSON.stringify({
shouldContinue: false,
cancel: true,
contextModification: "",
errorMessage: "Workspace blocks"
}))`
@@ -423,7 +428,7 @@ console.log(JSON.stringify({
},
})
result.shouldContinue.should.be.false()
result.cancel.should.be.true()
result.errorMessage!.should.match(/Workspace blocks/)
})
})
@@ -444,7 +449,7 @@ console.log(JSON.stringify({
},
})
result.shouldContinue.should.be.true()
result.cancel.should.be.false()
})
})
@@ -466,7 +471,7 @@ console.log(JSON.stringify({
},
})
result.shouldContinue.should.be.true()
result.cancel.should.be.false()
result.contextModification!.should.equal("TaskStart hook executed successfully")
})
@@ -487,7 +492,7 @@ console.log(JSON.stringify({
},
})
result.shouldContinue.should.be.false()
result.cancel.should.be.true()
result.errorMessage!.should.equal("Task execution blocked by hook")
})
+13 -13
View File
@@ -37,20 +37,20 @@ export async function createHooksDirectory(baseDir: string): Promise<string> {
* @example
* // Create a simple success hook
* await createTestHook(tempDir, "PreToolUse", {
* shouldContinue: true,
* cancel: false,
* contextModification: "TEST_CONTEXT"
* })
*
* @example
* // Create a hook that delays before responding
* await createTestHook(tempDir, "PreToolUse", {
* shouldContinue: true
* cancel: false
* }, { delay: 100 })
*
* @example
* // Create a hook that exits with an error
* await createTestHook(tempDir, "PreToolUse", {
* shouldContinue: false
* cancel: true
* }, { exitCode: 1 })
*
* @example
@@ -202,17 +202,17 @@ export function buildPostToolUseInput(params: {
*
* @example
* assertHookOutput(result, {
* shouldContinue: true,
* cancel: false,
* contextModification: "Expected context"
* })
*/
export function assertHookOutput(actual: HookOutput, expected: Partial<HookOutput>): void {
if (expected.shouldContinue !== undefined) {
if (actual.shouldContinue !== expected.shouldContinue) {
if (expected.cancel !== undefined) {
if (actual.cancel !== expected.cancel) {
throw new Error(
`Hook output assertion failed for 'shouldContinue':\n` +
` Expected: ${expected.shouldContinue}\n` +
` Received: ${actual.shouldContinue}\n` +
`Hook output assertion failed for 'cancel':\n` +
` Expected: ${expected.cancel}\n` +
` Received: ${actual.cancel}\n` +
` Full output: ${JSON.stringify(actual, null, 2)}`,
)
}
@@ -279,7 +279,7 @@ function isSerializable(value: any): boolean {
*
* @example
* const mockRunner = new MockHookRunner("PreToolUse")
* mockRunner.setResponse({ shouldContinue: true })
* mockRunner.setResponse({ cancel: false })
*
* const result = await mockRunner.run(input)
* mockRunner.assertCalled(1)
@@ -287,7 +287,7 @@ function isSerializable(value: any): boolean {
*/
export class MockHookRunner<Name extends HookName> {
private response: HookOutput = {
shouldContinue: true,
cancel: false,
contextModification: "",
errorMessage: "",
}
@@ -305,7 +305,7 @@ export class MockHookRunner<Name extends HookName> {
*/
setResponse(output: Partial<HookOutput>): void {
this.response = {
shouldContinue: output.shouldContinue ?? true,
cancel: output.cancel ?? false,
contextModification: output.contextModification ?? "",
errorMessage: output.errorMessage ?? "",
}
@@ -399,7 +399,7 @@ export class MockHookRunner<Name extends HookName> {
reset(): void {
this.executionLog = []
this.response = {
shouldContinue: true,
cancel: false,
contextModification: "",
errorMessage: "",
}
@@ -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) {
@@ -58,7 +63,7 @@ describe("UserPromptSubmit Hook", () => {
const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
const hasPrompt = input.userPromptSubmit && typeof input.userPromptSubmit.prompt === 'string' && input.userPromptSubmit.prompt.length > 0;
console.log(JSON.stringify({
shouldContinue: true,
cancel: false,
contextModification: hasPrompt ? "Received prompt" : "Missing prompt"
}))`
@@ -75,7 +80,7 @@ console.log(JSON.stringify({
},
})
result.shouldContinue.should.be.true()
result.cancel.should.be.false()
result.contextModification!.should.equal("Received prompt")
})
@@ -85,7 +90,7 @@ console.log(JSON.stringify({
const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
const lineCount = (input.userPromptSubmit.prompt.match(/\\n/g) || []).length + 1;
console.log(JSON.stringify({
shouldContinue: true,
cancel: false,
contextModification: "Line count: " + lineCount
}))`
@@ -112,7 +117,7 @@ console.log(JSON.stringify({
const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
const size = input.userPromptSubmit.prompt.length;
console.log(JSON.stringify({
shouldContinue: true,
cancel: false,
contextModification: "Prompt size: " + size
}))`
@@ -140,7 +145,7 @@ const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
const hasAllFields = input.clineVersion && input.hookName && input.timestamp &&
input.taskId && input.workspaceRoots !== undefined;
console.log(JSON.stringify({
shouldContinue: true,
cancel: false,
contextModification: hasAllFields ? "All fields present" : "Missing fields"
}))`
@@ -169,14 +174,14 @@ const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
const promptData = input.userPromptSubmit;
if (!promptData) {
console.log(JSON.stringify({
shouldContinue: false,
cancel: true,
errorMessage: "No userPromptSubmit data"
}));
process.exit(0);
}
const promptLength = typeof promptData.prompt === 'string' ? promptData.prompt.length : (promptData.prompt ? String(promptData.prompt).length : 0);
console.log(JSON.stringify({
shouldContinue: true,
cancel: false,
contextModification: "Prompt length: " + promptLength
}))`
@@ -203,7 +208,7 @@ const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
const prompt = input.userPromptSubmit.prompt;
const hasSpecialChars = prompt.includes("@") && prompt.includes("#") && prompt.includes("$");
console.log(JSON.stringify({
shouldContinue: true,
cancel: false,
contextModification: hasSpecialChars ? "Special chars preserved" : "Missing special chars"
}))`
@@ -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.cancel.should.be.false()
;(result.contextModification === undefined || result.contextModification === "").should.be.true()
})
it("should handle hook script errors", async () => {
@@ -299,7 +304,7 @@ process.exit(1)`
const globalHookPath = path.join(globalHooksDir, "UserPromptSubmit")
const globalHookScript = `#!/usr/bin/env node
console.log(JSON.stringify({
shouldContinue: true,
cancel: false,
contextModification: "GLOBAL: Prompt received"
}))`
await writeHookScript(globalHookPath, globalHookScript)
@@ -308,7 +313,7 @@ console.log(JSON.stringify({
const workspaceHookPath = path.join(tempDir, ".clinerules", "hooks", "UserPromptSubmit")
const workspaceHookScript = `#!/usr/bin/env node
console.log(JSON.stringify({
shouldContinue: true,
cancel: false,
contextModification: "WORKSPACE: Prompt received"
}))`
await writeHookScript(workspaceHookPath, workspaceHookScript)
@@ -324,7 +329,7 @@ console.log(JSON.stringify({
},
})
result.shouldContinue.should.be.true()
result.cancel.should.be.false()
result.contextModification!.should.match(/GLOBAL: Prompt received/)
result.contextModification!.should.match(/WORKSPACE: Prompt received/)
})
@@ -334,7 +339,7 @@ console.log(JSON.stringify({
const globalHookPath = path.join(globalHooksDir, "UserPromptSubmit")
const globalHookScript = `#!/usr/bin/env node
console.log(JSON.stringify({
shouldContinue: true,
cancel: false,
contextModification: "Global allows"
}))`
await writeHookScript(globalHookPath, globalHookScript)
@@ -343,7 +348,7 @@ console.log(JSON.stringify({
const workspaceHookPath = path.join(tempDir, ".clinerules", "hooks", "UserPromptSubmit")
const workspaceHookScript = `#!/usr/bin/env node
console.log(JSON.stringify({
shouldContinue: false,
cancel: true,
errorMessage: "Workspace blocks"
}))`
await writeHookScript(workspaceHookPath, workspaceHookScript)
@@ -359,7 +364,7 @@ console.log(JSON.stringify({
},
})
result.shouldContinue.should.be.false()
result.cancel.should.be.true()
result.errorMessage!.should.match(/Workspace blocks/)
})
})
@@ -379,7 +384,7 @@ console.log(JSON.stringify({
})
// NoOpRunner always returns success
result.shouldContinue.should.be.true()
result.cancel.should.be.false()
})
})
@@ -407,7 +412,7 @@ console.log(JSON.stringify({
},
})
result.shouldContinue.should.be.true()
result.cancel.should.be.false()
result.contextModification!.should.equal("Prompt approved")
})
@@ -422,7 +427,7 @@ console.log(JSON.stringify({
},
})
result.shouldContinue.should.be.false()
result.cancel.should.be.true()
result.errorMessage!.should.equal("Prompt violates policy")
})
@@ -437,7 +442,7 @@ console.log(JSON.stringify({
},
})
result.shouldContinue.should.be.true()
result.cancel.should.be.false()
result.contextModification!.should.equal("CONTEXT_INJECTION: User is in plan mode")
})
@@ -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.cancel.should.be.false()
;(result.contextModification === undefined || result.contextModification === "").should.be.true()
})
it("should work with multiline fixture", async () => {
@@ -486,7 +491,7 @@ console.log(JSON.stringify({
},
})
result.shouldContinue.should.be.true()
result.cancel.should.be.false()
result.contextModification!.should.equal("Line count: 3")
})
@@ -502,7 +507,7 @@ console.log(JSON.stringify({
},
})
result.shouldContinue.should.be.true()
result.cancel.should.be.false()
result.contextModification!.should.equal("Prompt size: 10000")
})
@@ -517,7 +522,7 @@ console.log(JSON.stringify({
},
})
result.shouldContinue.should.be.true()
result.cancel.should.be.false()
result.contextModification!.should.equal("Special chars preserved")
})
@@ -532,7 +537,7 @@ console.log(JSON.stringify({
},
})
result.shouldContinue.should.be.true()
result.cancel.should.be.false()
result.contextModification!.should.equal("Prompt length: 0")
})
})
+226
View File
@@ -0,0 +1,226 @@
import { ClineMessage } from "@shared/ExtensionMessage"
import { MessageStateHandler } from "../task/message-state"
import { HookExecutionError } from "./HookError"
import { HookFactory } from "./hook-factory"
export interface HookExecutionOptions<Name extends keyof Hooks = any> {
hookName: Name
hookInput: Hooks[Name]
isCancellable: boolean
say: (type: any, text?: string, images?: string[], files?: string[], partial?: boolean) => Promise<number | undefined>
setActiveHookExecution?: (execution: {
hookName: string
toolName: string | undefined
messageTs: number
abortController: AbortController
}) => Promise<void>
clearActiveHookExecution?: () => Promise<void>
messageStateHandler: MessageStateHandler
taskId: string
hooksEnabled: boolean
toolName?: string // Optional tool name for PreToolUse/PostToolUse hooks
pendingToolInfo?: any // Optional metadata about pending tool execution for PreToolUse
}
// Import Hooks type from HookFactory
type Hooks = import("./hook-factory").Hooks
export interface HookExecutionResult {
cancel?: boolean
contextModification?: string
errorMessage?: string
wasCancelled: boolean
}
/**
* Executes a hook with standardized error handling, status tracking, and cleanup.
* This consolidates the common pattern used across all hook execution sites.
*/
export async function executeHook<Name extends keyof Hooks>(options: HookExecutionOptions<Name>): Promise<HookExecutionResult> {
const {
hookName,
hookInput,
isCancellable,
say,
setActiveHookExecution,
clearActiveHookExecution,
messageStateHandler,
taskId,
hooksEnabled,
} = options
// Early return if hooks are disabled
if (!hooksEnabled) {
return {
wasCancelled: false,
}
}
// Check if the hook exists
const hookFactory = new HookFactory()
const hasHook = await hookFactory.hasHook(hookName)
if (!hasHook) {
return {
wasCancelled: false,
}
}
let hookMessageTs: number | undefined
const abortController = new AbortController()
try {
// Show hook execution indicator and capture timestamp
const hookMetadata = {
hookName,
...(options.toolName && { toolName: options.toolName }),
status: "running",
...(options.pendingToolInfo && { pendingToolInfo: options.pendingToolInfo }),
}
hookMessageTs = await say("hook", JSON.stringify(hookMetadata))
// Track active hook execution for cancellation (only if cancellable and message was created)
if (isCancellable && hookMessageTs !== undefined && setActiveHookExecution) {
await setActiveHookExecution({
hookName,
toolName: options.toolName,
messageTs: hookMessageTs,
abortController,
})
}
// Create streaming callback
const streamCallback = async (line: string) => {
await say("hook_output", line)
}
// Create and execute hook
const hook = await hookFactory.createWithStreaming(
hookName,
streamCallback,
isCancellable ? abortController.signal : undefined,
)
const result = await hook.run({
taskId,
...hookInput,
})
console.log(`[${hookName} Hook]`, result)
// Check if hook wants to cancel
if (result.cancel === true) {
// Update hook status to cancelled
if (hookMessageTs !== undefined) {
await updateHookMessage(messageStateHandler, hookMessageTs, {
hookName,
...(options.toolName && { toolName: options.toolName }),
status: "cancelled",
exitCode: 130,
hasJsonResponse: true,
})
}
return {
cancel: true,
contextModification: result.contextModification,
errorMessage: result.errorMessage,
wasCancelled: false,
}
}
// Clear active hook execution after successful completion (only if cancellable)
if (isCancellable && clearActiveHookExecution) {
await clearActiveHookExecution()
}
// Update hook status to completed (only if not cancelled)
if (hookMessageTs !== undefined) {
await updateHookMessage(messageStateHandler, hookMessageTs, {
hookName,
...(options.toolName && { toolName: options.toolName }),
status: "completed",
exitCode: 0,
hasJsonResponse: true,
})
}
return {
cancel: result.cancel,
contextModification: result.contextModification,
errorMessage: result.errorMessage,
wasCancelled: false,
}
} catch (hookError) {
// Clear active hook execution (only if cancellable)
if (isCancellable && clearActiveHookExecution) {
await clearActiveHookExecution()
}
// Check if this was a user cancellation via abort controller
if (abortController.signal.aborted) {
// Update hook status to cancelled
if (hookMessageTs !== undefined) {
await updateHookMessage(messageStateHandler, hookMessageTs, {
hookName,
status: "cancelled",
exitCode: 130,
})
}
return {
cancel: true,
wasCancelled: true,
}
}
// Update hook status to failed for actual errors
// Extract structured error info if available
const isStructuredError = HookExecutionError.isHookError(hookError)
const errorInfo = isStructuredError ? hookError.errorInfo : null
if (hookMessageTs !== undefined) {
await updateHookMessage(messageStateHandler, hookMessageTs, {
hookName,
status: "failed",
exitCode: errorInfo?.exitCode ?? 1,
...(errorInfo && {
error: {
type: errorInfo.type,
message: errorInfo.message,
details: errorInfo.details,
scriptPath: errorInfo.scriptPath,
},
}),
})
}
// Log error for non-cancellable hooks or unexpected errors
console.error(`${hookName} hook failed:`, hookError)
// Return safe defaults for all fields to avoid undefined property access
return {
cancel: false,
contextModification: undefined,
errorMessage: undefined,
wasCancelled: false,
}
}
}
/**
* Helper to update hook message status in message state
*/
async function updateHookMessage(
messageStateHandler: MessageStateHandler,
hookMessageTs: number,
metadata: Record<string, any>,
): Promise<void> {
const clineMessages = messageStateHandler.getClineMessages()
const hookMessageIndex = clineMessages.findIndex((m: ClineMessage) => m.ts === hookMessageTs)
if (hookMessageIndex !== -1) {
await messageStateHandler.updateClineMessage(hookMessageIndex, {
text: JSON.stringify(metadata),
})
}
}
+313 -72
View File
@@ -1,4 +1,3 @@
import { spawn } from "node:child_process"
import fs from "fs/promises"
import path from "path"
import { version as clineVersion } from "../../../package.json"
@@ -17,6 +16,8 @@ import {
} from "../../shared/proto/cline/hooks"
import { getAllHooksDirs } from "../storage/disk"
import { StateManager } from "../storage/StateManager"
import { HookExecutionError } from "./HookError"
import { HookProcess } from "./HookProcess"
// Hook execution timeout (30 seconds)
const HOOK_EXECUTION_TIMEOUT_MS = 30000
@@ -24,6 +25,73 @@ const HOOK_EXECUTION_TIMEOUT_MS = 30000
// Maximum size for context modification (to prevent prompt overflow)
const MAX_CONTEXT_MODIFICATION_SIZE = 50000 // ~50KB
/**
* Validates hook output JSON structure.
* Ensures required fields are present and have correct types.
*/
function validateHookOutput(output: any): { valid: boolean; error?: string } {
// Check if deprecated shouldContinue field is present
if (output.shouldContinue !== undefined) {
return {
valid: false,
error:
"Invalid hook output: The 'shouldContinue' field has been removed.\n\n" +
"Use 'cancel: true' instead to trigger task cancellation.\n\n" +
"Migration guide:\n" +
" Before: { shouldContinue: false, errorMessage: '...' }\n" +
" After: { cancel: true, errorMessage: '...' }\n\n" +
"Example valid response:\n" +
JSON.stringify(
{
cancel: false,
contextModification: "Optional context here",
errorMessage: "",
},
null,
2,
),
}
}
// cancel is optional, but if provided must be a boolean
if (output.cancel !== undefined && typeof output.cancel !== "boolean") {
return {
valid: false,
error:
"Invalid hook output: 'cancel' must be a boolean.\n\n" +
`Received type: ${typeof output.cancel}\n\n` +
"Example valid response:\n" +
JSON.stringify({ cancel: true, errorMessage: "Cancelling task" }, null, 2),
}
}
// contextModification is optional, but if provided must be a string
if (output.contextModification !== undefined && typeof output.contextModification !== "string") {
return {
valid: false,
error:
"Invalid hook output: 'contextModification' must be a string.\n\n" +
`Received type: ${typeof output.contextModification}\n\n` +
"Example valid response:\n" +
JSON.stringify({ contextModification: "Context here" }, null, 2),
}
}
// errorMessage is optional, but if provided must be a string
if (output.errorMessage !== undefined && typeof output.errorMessage !== "string") {
return {
valid: false,
error:
"Invalid hook output: 'errorMessage' must be a string.\n\n" +
`Received type: ${typeof output.errorMessage}\n\n` +
"Example valid response:\n" +
JSON.stringify({ cancel: true, errorMessage: "Error description" }, null, 2),
}
}
return { valid: true }
}
export interface Hooks {
PreToolUse: {
preToolUse: PreToolUseData
@@ -94,8 +162,23 @@ export abstract class HookRunner<Name extends HookName> {
abstract [exec](params: HookInput): Promise<HookOutput>
// Completes the hook input parameters by adding the common hook parameters to the
// hook-specific parameters provided by the caller.
/**
* Completes the hook input by adding common metadata to caller-provided parameters.
*
* This method enriches the hook-specific input (like preToolUse or postToolUse data)
* with standard information that all hooks receive:
* - clineVersion: Current Cline extension version
* - hookName: The type of hook being executed (e.g., "PreToolUse")
* - timestamp: Execution time in milliseconds since epoch
* - workspaceRoots: Array of workspace folder paths
* - userId: Cline user ID, machine ID, or generated UUID
*
* This separation allows hook scripts to receive consistent metadata without
* requiring callers to manually provide it each time.
*
* @param params The hook-specific input parameters (taskId + hook data)
* @returns Complete HookInput ready to be serialized and sent to the hook script
*/
protected async completeParams(params: NamedHookInput<Name>): Promise<HookInput> {
const workspaceRoots =
StateManager.get()
@@ -112,73 +195,103 @@ 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) {
// Return null to indicate parsing failed, let caller decide what to do based on exit code
return null
}
const output = HookOutput.fromJSON(outputData)
// Validate and truncate context modification if too large
@@ -192,29 +305,116 @@ 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) {
// 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])
// Handle process errors
child.on("error", (error) => {
if (timeoutHandle) {
clearTimeout(timeoutHandle)
}
reject(new Error(`Failed to execute hook ${this.hookName}: ${error.message}`))
})
// Validate structure
const validation = validateHookOutput(outputData)
if (!validation.valid) {
// Return null to indicate parsing failed
return null
}
// Send input to the process
child.stdin?.write(inputJson)
child.stdin?.end()
})
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) {
// Couldn't extract valid JSON, return null
return null
}
}
// Couldn't parse JSON at all, return null
return null
}
}
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 - include hook name in error
throw HookExecutionError.execution(this.scriptPath, exitCode ?? 1, stderr, this.hookName)
}
} 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, this.hookName)
}
// Check for cancellation
if (error instanceof Error && error.message.includes("cancelled")) {
throw HookExecutionError.cancellation(this.scriptPath, this.hookName)
}
// Generic execution error - include hook name
throw HookExecutionError.execution(this.scriptPath, exitCode ?? 1, stderr, this.hookName)
}
}
}
// 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/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 +428,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 +443,7 @@ class CombinedHookRunner<Name extends HookName> extends HookRunner<Name> {
.join("\n")
return HookOutput.create({
shouldContinue,
cancel,
contextModification,
errorMessage,
})
@@ -285,9 +485,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)
}
@@ -296,7 +537,7 @@ export class HookFactory {
/**
* @returns A list of paths to scripts for the given hook name.
* Includes both global hooks (from ~/Documents/Cline/Rules/Hooks/) and workspace hooks
* Includes both global hooks (from ~/Documents/Cline/Hooks/) and workspace hooks
* (from .clinerules/hooks/ in each workspace root).
*/
private static async findHookScripts(hookName: HookName): Promise<string[]> {
@@ -316,7 +557,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)
+13
View File
@@ -0,0 +1,13 @@
/**
* Determines if hooks are safely enabled based on platform support.
*
* Hooks are not yet supported on Windows, so this function ensures they
* remain disabled on that platform regardless of user settings.
*
* @param userSetting The user's hooks enabled setting from global state (may be undefined)
* @returns true if hooks are enabled and supported on this platform, false otherwise
*/
export function getHooksEnabledSafe(userSetting: boolean | undefined): boolean {
// Force hooks to false on Windows (not yet supported)
return process.platform === "win32" ? false : (userSetting ?? false)
}
+4 -6
View File
@@ -107,16 +107,14 @@ export async function ensureMcpServersDirectoryExists(): Promise<string> {
}
export async function ensureHooksDirectoryExists(): Promise<string> {
const rulesDir = await ensureRulesDirectoryExists()
const clineHooksDir = path.join(rulesDir, "Hooks")
const userDocumentsPath = await getDocumentsPath()
const clineHooksDir = path.join(userDocumentsPath, "Cline", "Hooks")
try {
await fs.mkdir(clineHooksDir, { recursive: true })
return clineHooksDir
} catch (_error) {
// If mkdir fails, return a fallback path based on the Rules directory fallback
// This matches the pattern of other ensure*DirectoryExists functions
return path.join(rulesDir, "Hooks")
return path.join(os.homedir(), "Documents", "Cline", "Hooks") // in case creating a directory in documents fails for whatever reason (e.g. permissions) - this is fine because we will fail gracefully with a path that does not exist
}
return clineHooksDir
}
export async function ensureSettingsDirectoryExists(): Promise<string> {
+3 -2
View File
@@ -2,6 +2,7 @@ import { ANTHROPIC_MIN_THINKING_BUDGET, ApiProvider, fireworksDefaultModelId, ty
import { GlobalStateAndSettings, LocalState, SecretKey, Secrets } from "@shared/storage/state-keys"
import { ExtensionContext } from "vscode"
import { Controller } from "@/core/controller"
import { getHooksEnabledSafe } from "@/core/hooks/hooks-utils"
import { DEFAULT_AUTO_APPROVAL_SETTINGS } from "@/shared/AutoApprovalSettings"
import { DEFAULT_BROWSER_SETTINGS } from "@/shared/BrowserSettings"
import { ClineRulesToggles } from "@/shared/cline-rules"
@@ -617,8 +618,8 @@ export async function readGlobalStateFromDisk(context: ExtensionContext): Promis
qwenCodeOauthPath,
customPrompt,
autoCondenseThreshold: autoCondenseThreshold || 0.75, // default to 0.75 if not set
// Hooks require explicit user opt-in
hooksEnabled: hooksEnabled ?? false,
// Hooks require explicit user opt-in and are only supported on macOS/Linux
hooksEnabled: getHooksEnabledSafe(hooksEnabled),
subagentsEnabled: subagentsEnabled ?? false,
lastDismissedInfoBannerVersion: lastDismissedInfoBannerVersion ?? 0,
lastDismissedModelBannerVersion: lastDismissedModelBannerVersion ?? 0,
+8
View File
@@ -64,6 +64,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
+265 -56
View File
@@ -13,7 +13,6 @@ import * as vscode from "vscode"
import { modelDoesntSupportWebp } from "@/utils/model-utils"
import { ToolUse } from "../assistant-message"
import { ContextManager } from "../context/context-management/ContextManager"
import { HookFactory } from "../hooks/hook-factory"
import { formatResponse } from "../prompts/responses"
import { StateManager } from "../storage/StateManager"
import { WorkspaceRootManager } from "../workspace"
@@ -113,6 +112,11 @@ export class ToolExecutor {
private doesLatestTaskCompletionHaveNewChanges: () => Promise<boolean>,
private updateFCListFromToolResponse: (taskProgress: string | undefined) => Promise<void>,
private switchToActMode: () => Promise<boolean>,
// Atomic hook state helpers from Task
private setActiveHookExecution: (hookExecution: NonNullable<typeof taskState.activeHookExecution>) => Promise<void>,
private clearActiveHookExecution: () => Promise<void>,
private getActiveHookExecution: () => Promise<typeof taskState.activeHookExecution>,
) {
this.autoApprover = new AutoApprove(this.stateManager)
@@ -230,7 +234,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)
@@ -242,6 +253,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(
@@ -268,7 +290,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> {
// Note: MCP tool name transformation happens earlier in ToolUseHandler.getPartialToolUsesAsContent()
@@ -336,14 +369,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({
@@ -391,7 +437,74 @@ export class ToolExecutor {
}
/**
* Handle partial block streaming UI updates
* Runs the PostToolUse hook after tool execution.
* This is extracted from handleCompleteBlock to eliminate code duplication
* between success and error paths.
*
* @param block The tool use block that was executed
* @param toolResult The result from the tool execution
* @param executionSuccess Whether the tool executed successfully
* @param executionStartTime The timestamp when tool execution started
* @returns true if hook requested cancellation, false otherwise
*/
private async runPostToolUseHook(
block: ToolUse,
toolResult: any,
executionSuccess: boolean,
executionStartTime: number,
): Promise<boolean> {
const { executeHook } = await import("../hooks/hook-executor")
const executionTimeMs = Date.now() - executionStartTime
console.log(`[HOOK-UI] PostToolUse executing for tool: ${block.name}`)
const postToolResult = await executeHook({
hookName: "PostToolUse",
hookInput: {
postToolUse: {
toolName: block.name,
parameters: block.params,
result: typeof toolResult === "string" ? toolResult : JSON.stringify(toolResult),
success: executionSuccess,
executionTimeMs,
},
},
isCancellable: true,
say: this.say,
setActiveHookExecution: this.setActiveHookExecution,
clearActiveHookExecution: this.clearActiveHookExecution,
messageStateHandler: this.messageStateHandler,
taskId: this.taskId,
hooksEnabled: true, // Already checked by caller
toolName: block.name,
})
// Handle cancellation request
if (postToolResult.cancel === true) {
const errorMessage = postToolResult.errorMessage || "Hook requested task cancellation"
await this.say("error", errorMessage)
return true
}
// Add context modification to the conversation if provided
if (postToolResult.contextModification) {
this.addHookContextToConversation(postToolResult.contextModification, "PostToolUse")
}
return false
}
/**
* 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.
@@ -408,87 +521,183 @@ 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 { executeHook } = await import("../hooks/hook-executor")
preToolUseResult = await preToolUseHook.run({
taskId: this.taskId,
// Build pending tool info for display
const pendingToolInfo: any = {
tool: block.name,
}
// Add relevant parameters for display based on tool type
if (block.params.path) {
pendingToolInfo.path = block.params.path
}
if (block.params.command) {
pendingToolInfo.command = block.params.command
}
if (block.params.content && typeof block.params.content === "string") {
pendingToolInfo.content = block.params.content.slice(0, 200)
}
if (block.params.diff && typeof block.params.diff === "string") {
pendingToolInfo.diff = block.params.diff.slice(0, 200)
}
if (block.params.regex) {
pendingToolInfo.regex = block.params.regex
}
if (block.params.url) {
pendingToolInfo.url = block.params.url
}
// For MCP operations, show tool/resource identifiers
if (block.params.tool_name) {
pendingToolInfo.mcpTool = block.params.tool_name
}
if (block.params.server_name) {
pendingToolInfo.mcpServer = block.params.server_name
}
if (block.params.uri) {
pendingToolInfo.resourceUri = block.params.uri
}
console.log(`[HOOK-UI] PreToolUse executing for tool: ${block.name}`)
const preToolResult = await executeHook({
hookName: "PreToolUse",
hookInput: {
preToolUse: {
toolName: block.name,
parameters: block.params,
},
})
},
isCancellable: true,
say: this.say,
setActiveHookExecution: this.setActiveHookExecution,
clearActiveHookExecution: this.clearActiveHookExecution,
messageStateHandler: this.messageStateHandler,
taskId: this.taskId,
hooksEnabled,
toolName: block.name,
pendingToolInfo,
})
// 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
}
// 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)
// Handle cancellation from hook
if (preToolResult.cancel === true) {
// Trigger task cancellation (same as clicking cancel button)
await config.callbacks.cancelTask()
// Early return - never enters try-catch-finally, so PostToolUse won't run
return
}
// If task was aborted (e.g., via cancel button during hook), stop execution
if (this.taskState.abort) {
shouldCancelAfterHook = true
}
// Add context modification to the conversation if provided by the hook
if (preToolResult.contextModification) {
this.addHookContextToConversation(preToolResult.contextModification, "PreToolUse")
}
}
// ============================================================
// PHASE 2: Execute tool with PostToolUse hook in finally block
// This only runs if PreToolUse didn't cancel above
// ============================================================
// Check abort again before tool execution (could have been set by PreToolUse hook)
if (this.taskState.abort) {
return
}
let executionSuccess = true
let toolResult: any = null
let toolWasExecuted = false
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)
toolWasExecuted = true
this.pushToolResult(toolResult, block)
// Check abort before running PostToolUse hook (success path)
if (this.taskState.abort) {
return
}
// Run PostToolUse hook for successful tool execution
// Skip for attempt_completion since it marks task completion, not actual work
if (hooksEnabled && block.name !== "attempt_completion") {
const hookRequestedCancel = await this.runPostToolUseHook(block, toolResult, executionSuccess, executionStartTime)
if (hookRequestedCancel) {
await config.callbacks.cancelTask()
shouldCancelAfterHook = true
}
}
} catch (error) {
executionSuccess = false
toolResult = formatResponse.toolError(`Tool execution failed: ${error}`)
// Don't push tool result here - let the outer catch block (handleError) handle it
// to avoid duplicate tool_result blocks
throw error
} finally {
// Run PostToolUse hook if enabled
if (hooksEnabled) {
const hookFactory = new HookFactory()
const postToolUseHook = await hookFactory.create("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,
},
})
// Check abort before running PostToolUse hook (error path)
if (this.taskState.abort) {
throw error
}
// Add context modification to the conversation if provided by the hook
this.addHookContextToConversation(postToolUseResult.contextModification, "PostToolUse")
// Log any error messages from the hook
if (postToolUseResult.errorMessage) {
this.say("error", postToolUseResult.errorMessage)
// Run PostToolUse hook for failed tool execution
// Skip for attempt_completion since it marks task completion, not actual work
if (toolWasExecuted && hooksEnabled && block.name !== "attempt_completion") {
const hookRequestedCancel = await this.runPostToolUseHook(block, toolResult, executionSuccess, executionStartTime)
if (hookRequestedCancel) {
await config.callbacks.cancelTask()
shouldCancelAfterHook = true
}
}
// Re-throw the error after PostToolUse completes
throw error
}
// Early return if hook requested cancellation
if (shouldCancelAfterHook) {
return
}
// Handle focus chain updates
+427 -189
View File
@@ -72,6 +72,7 @@ import { arePathsEqual, getDesktopDir } from "@utils/path"
import { filterExistingFiles } from "@utils/tabFiltering"
import cloneDeep from "clone-deep"
import { execa } from "execa"
import Mutex from "p-mutex"
import pWaitFor from "p-wait-for"
import * as path from "path"
import { ulid } from "ulid"
@@ -139,6 +140,50 @@ export class Task {
taskState: TaskState
// ONE mutex for ALL state modifications to prevent race conditions
private stateMutex = new Mutex()
/**
* Execute function with exclusive lock on all task state
* Use this for ANY state modification to prevent races
*/
private async withStateLock<T>(fn: () => T | Promise<T>): Promise<T> {
return await this.stateMutex.withLock(fn)
}
/**
* Atomically set active hook execution with mutex protection
* Prevents TOCTOU races when setting hook execution state
* PUBLIC: Exposed for ToolExecutor to use
*/
public async setActiveHookExecution(hookExecution: NonNullable<typeof this.taskState.activeHookExecution>): Promise<void> {
await this.withStateLock(() => {
this.taskState.activeHookExecution = hookExecution
})
}
/**
* Atomically clear active hook execution with mutex protection
* Prevents TOCTOU races when clearing hook execution state
* PUBLIC: Exposed for ToolExecutor to use
*/
public async clearActiveHookExecution(): Promise<void> {
await this.withStateLock(() => {
this.taskState.activeHookExecution = undefined
})
}
/**
* Atomically read active hook execution state with mutex protection
* Returns a snapshot of the current state to prevent TOCTOU races
* PUBLIC: Exposed for ToolExecutor to use
*/
public async getActiveHookExecution(): Promise<typeof this.taskState.activeHookExecution> {
return await this.withStateLock(() => {
return this.taskState.activeHookExecution
})
}
// Core dependencies
private controller: Controller
private mcpHub: McpHub
@@ -437,12 +482,9 @@ export class Task {
// Set ulid on browserSession for telemetry tracking
this.browserSession.setUlid(this.ulid)
// Continue with task initialization
if (historyItem) {
this.resumeTaskFromHistory()
} else if (task || images || files) {
this.startTask(task, images, files)
}
// Note: Task initialization (startTask/resumeTaskFromHistory) is now called
// from Controller.initTask() AFTER the task instance is fully assigned.
// This prevents race conditions where hooks run before controller.task is ready.
// Set up focus chain file watcher (async, runs in background) only if focus chain is enabled
if (this.FocusChainManager) {
@@ -494,6 +536,10 @@ export class Task {
() => this.checkpointManager?.doesLatestTaskCompletionHaveNewChanges() ?? Promise.resolve(false),
this.FocusChainManager?.updateFCListFromToolResponse.bind(this.FocusChainManager) || (async () => {}),
this.switchToActModeCallback.bind(this),
// Atomic hook state helpers for ToolExecutor
this.setActiveHookExecution.bind(this),
this.clearActiveHookExecution.bind(this),
this.getActiveHookExecution.bind(this),
)
}
@@ -515,8 +561,8 @@ export class Task {
files?: string[]
askTs?: number
}> {
// If this Cline instance was aborted by the provider, then the only thing keeping us alive is a promise still running in the background, in which case we don't want to send its result to the webview as it is attached to a new instance of Cline now. So we can safely ignore the result of any active promises, and this class will be deallocated. (Although we set Cline = undefined in provider, that simply removes the reference to this instance, but the instance is still alive until this promise resolves or rejects.)
if (this.taskState.abort) {
// Allow resume asks even when aborted to enable resume button after cancellation
if (this.taskState.abort && type !== "resume_task" && type !== "resume_completed_task") {
throw new Error("Cline instance aborted")
}
let askTs: number
@@ -650,7 +696,8 @@ export class Task {
files?: string[],
partial?: boolean,
): Promise<number | undefined> {
if (this.taskState.abort) {
// Allow hook messages even when aborted to enable proper cleanup
if (this.taskState.abort && type !== "hook" && type !== "hook_output") {
throw new Error("Cline instance aborted")
}
@@ -763,54 +810,66 @@ export class Task {
private async runUserPromptSubmitHook(
userContent: UserContent,
context: "initial_task" | "resume" | "feedback",
): Promise<{ shouldContinue: boolean; contextModification?: string; errorMessage?: string }> {
_context: "initial_task" | "resume" | "feedback",
): Promise<{ cancel?: boolean; contextModification?: string; errorMessage?: string }> {
const hooksEnabled = featureFlagsService.getHooksEnabled() && this.stateManager.getGlobalSettingsKey("hooksEnabled")
if (!hooksEnabled) {
return { shouldContinue: true }
return {}
}
try {
const { HookFactory } = await import("../hooks/hook-factory")
const hookFactory = new HookFactory()
const hook = await hookFactory.create("UserPromptSubmit")
const { executeHook } = await import("../hooks/hook-executor")
// Serialize UserContent to string for the hook
const promptText = userContent
.map((block) => {
if (block.type === "text") {
return block.text
}
if (block.type === "image") {
return "[IMAGE]"
}
return ""
})
.join("\n\n")
// Serialize UserContent to string for the hook
const promptText = userContent
.map((block) => {
if (block.type === "text") {
return block.text
}
if (block.type === "image") {
return "[IMAGE]"
}
return ""
})
.join("\n\n")
const result = await hook.run({
taskId: this.taskId,
const userPromptResult = await executeHook({
hookName: "UserPromptSubmit",
hookInput: {
userPromptSubmit: {
prompt: promptText,
attachments: [], // Images are inline in UserContent
attachments: [],
},
})
},
isCancellable: true,
say: this.say.bind(this),
setActiveHookExecution: this.setActiveHookExecution.bind(this),
clearActiveHookExecution: this.clearActiveHookExecution.bind(this),
messageStateHandler: this.messageStateHandler,
taskId: this.taskId,
hooksEnabled,
})
return {
shouldContinue: result.shouldContinue,
contextModification: result.contextModification,
errorMessage: result.errorMessage,
}
} catch (error) {
console.error("UserPromptSubmit hook failed:", error)
return { shouldContinue: true }
// Handle cancellation from hook
if (userPromptResult.cancel === true && userPromptResult.wasCancelled) {
// Set flag to allow Controller.cancelTask() to proceed
this.taskState.didFinishAbortingStream = true
// Save BOTH files so Controller.cancelTask() can find the task
await this.messageStateHandler.saveClineMessagesAndUpdateHistory()
await this.messageStateHandler.overwriteApiConversationHistory(this.messageStateHandler.getApiConversationHistory())
await this.postStateToWebview()
}
return {
cancel: userPromptResult.cancel,
contextModification: userPromptResult.contextModification,
errorMessage: userPromptResult.errorMessage,
}
}
// Task lifecycle
private async startTask(task?: string, images?: string[], files?: string[]): Promise<void> {
public async startTask(task?: string, images?: string[], files?: string[]): Promise<void> {
try {
await this.clineIgnoreController.initialize()
} catch (error) {
@@ -849,16 +908,13 @@ export class Task {
}
// Add TaskStart hook context to the conversation if provided
// This follows the same pattern as PreToolUse, PostToolUse, and UserPromptSubmit hooks
const hooksEnabled = featureFlagsService.getHooksEnabled() && this.stateManager.getGlobalSettingsKey("hooksEnabled")
if (hooksEnabled) {
try {
const { HookFactory } = await import("../hooks/hook-factory")
const hookFactory = new HookFactory()
const taskStartHook = await hookFactory.create("TaskStart")
const { executeHook } = await import("../hooks/hook-executor")
const taskStartResult = await taskStartHook.run({
taskId: this.taskId,
const taskStartResult = await executeHook({
hookName: "TaskStart",
hookInput: {
taskStart: {
taskMetadata: {
taskId: this.taskId,
@@ -866,40 +922,75 @@ export class Task {
initialTask: task || "",
},
},
})
},
isCancellable: true,
say: this.say.bind(this),
setActiveHookExecution: this.setActiveHookExecution.bind(this),
clearActiveHookExecution: this.clearActiveHookExecution.bind(this),
messageStateHandler: this.messageStateHandler,
taskId: this.taskId,
hooksEnabled,
})
if (!taskStartResult.shouldContinue) {
const errorMessage = taskStartResult.errorMessage || "TaskStart hook prevented task from starting"
await this.say("error", errorMessage)
// Ensure the error message is saved and posted before aborting
// Handle cancellation from hook
if (taskStartResult.cancel === true) {
// If hook was cancelled by user, save state for resume
if (taskStartResult.wasCancelled) {
console.log(`[TaskStart Hook] User cancelled, saving messages for task ${this.taskId}`)
// Set flag to allow Controller.cancelTask() to proceed
this.taskState.didFinishAbortingStream = true
// Save BOTH clineMessages AND apiConversationHistory so Controller.cancelTask() can find the task
await this.messageStateHandler.saveClineMessagesAndUpdateHistory()
await this.messageStateHandler.overwriteApiConversationHistory(
this.messageStateHandler.getApiConversationHistory(),
)
await this.postStateToWebview()
this.abortTask()
return
console.log(`[TaskStart Hook] Messages saved successfully, returning from hook`)
}
// Add context modification to the conversation if provided
if (taskStartResult.contextModification) {
const contextText = taskStartResult.contextModification.trim()
if (contextText) {
userContent.push({
type: "text",
text: `<hook_context source="TaskStart">\n${contextText}\n</hook_context>`,
})
}
}
} catch (hookError) {
const errorMessage = `TaskStart hook failed: ${hookError instanceof Error ? hookError.message : String(hookError)}`
Logger.error(errorMessage, hookError)
// Show error to user but continue with task (non-fatal)
await this.say("error", errorMessage)
// abortTask will handle cleanup
this.abortTask()
return
}
// Add context modification to the conversation if provided
if (taskStartResult.contextModification) {
const contextText = taskStartResult.contextModification.trim()
if (contextText) {
userContent.push({
type: "text",
text: `<hook_context source="TaskStart">\n${contextText}\n</hook_context>`,
})
}
}
}
// Run UserPromptSubmit hook for initial task (after TaskStart for UI ordering)
const userPromptHookResult = await this.runUserPromptSubmitHook(userContent, "initial_task")
// Defensive check: Verify task wasn't aborted during hook execution (handles async cancellation)
if (this.taskState.abort) {
return
}
// Handle hook cancellation - but DON'T call abortTask()
// Controller.cancelTask() already called it, calling again causes double TaskCancel
if (userPromptHookResult.cancel === true) {
return
}
// Add hook context if provided
if (userPromptHookResult.contextModification) {
userContent.push({
type: "text",
text: `<hook_context source="UserPromptSubmit">\n${userPromptHookResult.contextModification}\n</hook_context>`,
})
}
await this.initiateTaskLoop(userContent)
}
private async resumeTaskFromHistory() {
public async resumeTaskFromHistory() {
try {
await this.clineIgnoreController.initialize()
} catch (error) {
@@ -956,21 +1047,22 @@ export class Task {
}
this.taskState.isInitialized = true
this.taskState.abort = false // Reset abort flag when resuming task
const { response, text, images, files } = await this.ask(askType) // calls poststatetowebview
// Initialize newUserContent array for hook context
const newUserContent: UserContent = []
// Run TaskResume hook
// Run TaskResume hook AFTER user clicks resume button
const hooksEnabled = featureFlagsService.getHooksEnabled() && this.stateManager.getGlobalSettingsKey("hooksEnabled")
if (hooksEnabled) {
try {
const { HookFactory } = await import("../hooks/hook-factory")
const hookFactory = new HookFactory()
const taskResumeHook = await hookFactory.create("TaskResume")
const { executeHook } = await import("../hooks/hook-executor")
const clineMessages = this.messageStateHandler.getClineMessages()
const taskResumeResult = await taskResumeHook.run({
taskId: this.taskId,
const clineMessages = this.messageStateHandler.getClineMessages()
const taskResumeResult = await executeHook({
hookName: "TaskResume",
hookInput: {
taskResume: {
taskMetadata: {
taskId: this.taskId,
@@ -982,32 +1074,46 @@ export class Task {
conversationHistoryDeleted: (this.taskState.conversationHistoryDeletedRange !== undefined).toString(),
},
},
},
isCancellable: true,
say: this.say.bind(this),
setActiveHookExecution: this.setActiveHookExecution.bind(this),
clearActiveHookExecution: this.clearActiveHookExecution.bind(this),
messageStateHandler: this.messageStateHandler,
taskId: this.taskId,
hooksEnabled,
})
// Handle cancellation from hook
if (taskResumeResult.cancel === true) {
// If hook was cancelled by user, save state for resume
if (taskResumeResult.wasCancelled) {
// Set flag to allow Controller.cancelTask() to proceed
this.taskState.didFinishAbortingStream = true
// Save BOTH clineMessages AND apiConversationHistory so Controller.cancelTask() can find the task
await this.messageStateHandler.saveClineMessagesAndUpdateHistory()
await this.messageStateHandler.overwriteApiConversationHistory(
this.messageStateHandler.getApiConversationHistory(),
)
await this.postStateToWebview()
}
// Return without continuing task - Controller.cancelTask() will handle showing resume button
return
}
// Add context if provided
if (taskResumeResult.contextModification) {
newUserContent.push({
type: "text",
text: `<hook_context source="TaskResume" type="general">\n${taskResumeResult.contextModification}\n</hook_context>`,
})
// Check if hook indicates an error condition (non-blocking)
if (!taskResumeResult.shouldContinue && taskResumeResult.errorMessage) {
await this.say("error", taskResumeResult.errorMessage)
}
// Add context if provided
if (taskResumeResult.contextModification) {
newUserContent.push({
type: "text",
text: `<hook_context source="TaskResume" type="general">\n${taskResumeResult.contextModification}\n</hook_context>`,
})
}
} catch (hookError) {
const errorMessage = `TaskResume hook failed: ${hookError instanceof Error ? hookError.message : String(hookError)}`
await this.say("error", errorMessage)
// Non-fatal: continue with resume
}
}
const { response, text, images, files } = await this.ask(askType) // calls poststatetowebview
let responseText: string | undefined
let responseImages: string[] | undefined
let responseFiles: string[] | undefined
if (response === "messageResponse") {
if (response === "messageResponse" || text || (images && images.length > 0) || (files && files.length > 0)) {
await this.say("user_feedback", text, images, files)
await this.checkpointManager?.saveCheckpoint()
responseText = text
@@ -1017,9 +1123,9 @@ export class Task {
// need to make sure that the api conversation history can be resumed by the api, even if it goes out of sync with cline messages
const existingApiConversationHistory: Anthropic.Messages.MessageParam[] = await getSavedApiConversationHistory(
this.taskId,
)
// Use the already-loaded API conversation history from memory instead of reloading from disk
// This prevents issues where the file might be empty or stale after hook execution
const existingApiConversationHistory = this.messageStateHandler.getApiConversationHistory()
// Remove the last user message so we can update it with the resume message
let modifiedOldUserContent: UserContent // either the last message if its user message, or the user message before the last (assistant) message
@@ -1039,7 +1145,10 @@ export class Task {
throw new Error("Unexpected: Last message is not a user or assistant message")
}
} else {
throw new Error("Unexpected: No existing API conversation history")
// No API conversation history yet (e.g., cancelled during hook before first API request)
// Start fresh with empty history and no previous content
modifiedApiConversationHistory = []
modifiedOldUserContent = []
}
// Add previous content to newUserContent array
@@ -1118,6 +1227,29 @@ export class Task {
})
}
// Run UserPromptSubmit hook for task resumption AFTER all content is assembled
const userPromptHookResult = await this.runUserPromptSubmitHook(newUserContent, "resume")
// Defensive check: Verify task wasn't aborted during hook execution (handles async cancellation)
if (this.taskState.abort) {
return
}
// Handle hook cancellation request
if (userPromptHookResult.cancel === true) {
// The hook already updated its status to "cancelled" internally and saved state
this.abortTask()
return
}
// Add hook context if provided (after all other content)
if (userPromptHookResult.contextModification) {
newUserContent.push({
type: "text",
text: `<hook_context source="UserPromptSubmit">\n${userPromptHookResult.contextModification}\n</hook_context>`,
})
}
await this.messageStateHandler.overwriteApiConversationHistory(modifiedApiConversationHistory)
await this.initiateTaskLoop(newUserContent)
}
@@ -1153,72 +1285,149 @@ export class Task {
}
}
/**
* Determines if the TaskCancel hook should run.
* Only runs if there's actual active work happening or if work was started in this session.
* Does NOT run when just showing the resume button with no active work.
* @returns true if the hook should run, false otherwise
*/
private async shouldRunTaskCancelHook(): Promise<boolean> {
// Atomically check for active hook execution (work happening now)
const activeHook = await this.getActiveHookExecution()
if (activeHook) {
return true
}
// Run if the API is currently streaming (work happening now)
if (this.taskState.isStreaming) {
return true
}
// Run if we're waiting for the first chunk (work happening now)
if (this.taskState.isWaitingForFirstChunk) {
return true
}
// Run if there's active background command (work happening now)
if (this.activeBackgroundCommand) {
return true
}
// Check if we're at the resume button state (no active work, just waiting)
const clineMessages = this.messageStateHandler.getClineMessages()
const lastMessage = clineMessages.at(-1)
const isAtResumeButton =
lastMessage?.type === "ask" && (lastMessage.ask === "resume_task" || lastMessage.ask === "resume_completed_task")
if (isAtResumeButton) {
// At resume button - DON'T run hook because we're just waiting for user input
// The resume button appears in two scenarios:
// 1. Opening from history (no new work)
// 2. After cancelling during active work (but work already stopped)
// In both cases, we shouldn't run TaskCancel hook
return false
}
// Not at resume button - we're in the middle of work or just finished something
// Run the hook since cancelling would interrupt actual work
return true
}
async abortTask() {
try {
// Run TaskCancel hook
const hooksEnabled = featureFlagsService.getHooksEnabled() && this.stateManager.getGlobalSettingsKey("hooksEnabled")
if (hooksEnabled) {
// PHASE 1: Check if TaskCancel should run BEFORE any cleanup
// We must capture this state now because subsequent cleanup will
// clear the active work indicators that shouldRunTaskCancelHook checks
const shouldRunTaskCancelHook = await this.shouldRunTaskCancelHook()
// PHASE 2: Set abort flag to prevent race conditions
// This must happen before canceling hooks so that hook catch blocks
// can properly detect the abort state
this.taskState.abort = true
// PHASE 3: Cancel any running hook execution
const activeHook = await this.getActiveHookExecution()
if (activeHook) {
try {
const { HookFactory } = await import("../hooks/hook-factory")
const hookFactory = new HookFactory()
const taskCancelHook = await hookFactory.create("TaskCancel")
const taskCancelResult = await taskCancelHook.run({
taskId: this.taskId,
taskCancel: {
taskMetadata: {
taskId: this.taskId,
ulid: this.ulid,
completionStatus: this.taskState.abandoned ? "abandoned" : "cancelled",
},
},
})
// Surface errors from hook but don't block cancellation
// Only try to display errors if not already aborted (to prevent blocking cleanup)
if (!this.taskState.abort) {
// Display error message if present, or default message if shouldContinue is false
if (taskCancelResult.errorMessage) {
await this.say("error", taskCancelResult.errorMessage).catch(() => {
// If say() fails, log to console instead
console.error("TaskCancel hook error:", taskCancelResult.errorMessage)
})
} else if (!taskCancelResult.shouldContinue) {
// For consistency with other hooks, show a default error when shouldContinue: false with no message
await this.say("error", "TaskCancel hook indicated an issue but provided no error message").catch(
() => {
console.error("TaskCancel hook indicated an issue (shouldContinue: false)")
},
)
}
} else {
// Already aborted, just log to console
if (taskCancelResult.errorMessage) {
console.error("TaskCancel hook error (already aborted):", taskCancelResult.errorMessage)
} else if (!taskCancelResult.shouldContinue) {
console.error("TaskCancel hook indicated an issue (already aborted, shouldContinue: false)")
}
}
// TaskCancel is fire-and-forget - we don't block cancellation based on hook result
} catch (hookError) {
const errorMessage = `TaskCancel hook failed: ${hookError instanceof Error ? hookError.message : String(hookError)}`
Logger.error(errorMessage, hookError)
// Show error to user but continue with abort (non-fatal)
// Only display if not already aborted
if (!this.taskState.abort) {
await this.say("error", errorMessage).catch(() => {
// If say() fails, already logged above
})
}
await this.cancelHookExecution()
// Clear activeHookExecution after hook is signaled
await this.clearActiveHookExecution()
} catch (error) {
Logger.error("Failed to cancel hook during task abort", error)
// Still clear state even on error to prevent stuck state
await this.clearActiveHookExecution()
}
}
// Check for incomplete progress before aborting
// PHASE 4: Run TaskCancel hook
// This allows the hook UI to appear in the webview
// Use the shouldRunTaskCancelHook value we captured in Phase 1
const hooksEnabled = featureFlagsService.getHooksEnabled() && this.stateManager.getGlobalSettingsKey("hooksEnabled")
if (hooksEnabled && shouldRunTaskCancelHook) {
try {
const { executeHook } = await import("../hooks/hook-executor")
const taskCancelResult = await executeHook({
hookName: "TaskCancel",
hookInput: {
taskCancel: {
taskMetadata: {
taskId: this.taskId,
ulid: this.ulid,
completionStatus: this.taskState.abandoned ? "abandoned" : "cancelled",
},
},
},
isCancellable: false, // TaskCancel is NOT cancellable
say: this.say.bind(this),
// No setActiveHookExecution or clearActiveHookExecution for non-cancellable hooks
messageStateHandler: this.messageStateHandler,
taskId: this.taskId,
hooksEnabled,
})
// TaskCancel completed successfully
// Present resume button after successful TaskCancel hook
const lastClineMessage = this.messageStateHandler
.getClineMessages()
.slice()
.reverse()
.find((m) => !(m.ask === "resume_task" || m.ask === "resume_completed_task"))
let askType: ClineAsk
if (lastClineMessage?.ask === "completion_result") {
askType = "resume_completed_task"
} else {
askType = "resume_task"
}
// Present the resume ask - this will show the resume button in the UI
// We don't await this because we want to set the abort flag immediately
// The ask will be waiting when the user decides to resume
this.ask(askType).catch((error) => {
// If ask fails (e.g., task was cleared), that's okay - just log it
console.log("[TaskCancel] Resume ask failed (task may have been cleared):", error)
})
} catch (error) {
// TaskCancel hook failed - non-fatal, just log
console.error("[TaskCancel Hook] Failed (non-fatal):", error)
}
}
// PHASE 5: Immediately update UI to reflect abort state
try {
await this.messageStateHandler.saveClineMessagesAndUpdateHistory()
await this.postStateToWebview()
} catch (error) {
Logger.error("Failed to post state after setting abort flag", error)
}
// PHASE 6: Check for incomplete progress
if (this.FocusChainManager) {
this.FocusChainManager.checkIncompleteProgressOnCompletion()
}
this.taskState.abort = true // will stop any autonomously running promises
// PHASE 7: Clean up resources
this.terminalManager.disposeAll()
this.urlContentFetcher.closeBrowser()
await this.browserSession.dispose()
@@ -1243,6 +1452,13 @@ export class Task {
console.error(`[Task ${this.taskId}] Failed to release task lock:`, error)
}
}
// Final state update to notify UI that abort is complete
try {
await this.postStateToWebview()
} catch (error) {
Logger.error("Failed to post final state after abort", error)
}
}
}
@@ -1702,6 +1918,49 @@ export class Task {
return true
}
/**
* Cancel a currently running hook execution
* @returns true if a hook was cancelled, false if no hook was running
*/
public async cancelHookExecution(): Promise<boolean> {
const activeHook = await this.getActiveHookExecution()
if (!activeHook) {
return false
}
const { hookName, toolName, messageTs, abortController } = activeHook
try {
// Abort the hook process
abortController.abort()
// Update hook message status to "cancelled"
const clineMessages = this.messageStateHandler.getClineMessages()
const hookMessageIndex = clineMessages.findIndex((m) => m.ts === messageTs)
if (hookMessageIndex !== -1) {
const cancelledMetadata = {
hookName,
toolName,
status: "cancelled",
exitCode: 130, // Standard SIGTERM exit code
}
await this.messageStateHandler.updateClineMessage(hookMessageIndex, {
text: JSON.stringify(cancelledMetadata),
})
}
// Notify UI that hook was cancelled
await this.say("hook_output", "\nHook execution cancelled by user")
// Return success - let caller (abortTask) handle next steps
// DON'T call abortTask() here to avoid infinite recursion
return true
} catch (error) {
Logger.error("Failed to cancel hook execution", error)
return false
}
}
/**
* Migrates the disableBrowserTool setting from VSCode configuration to browserSettings
*/
@@ -2149,8 +2408,9 @@ export class Task {
}
async recursivelyMakeClineRequests(userContent: UserContent, includeFileDetails: boolean = false): Promise<boolean> {
// Check abort flag at the very start to prevent any execution after cancellation
if (this.taskState.abort) {
throw new Error("Cline instance aborted")
throw new Error("Task instance aborted")
}
// Increment API request counter for focus chain list management
@@ -2272,15 +2532,6 @@ export class Task {
// Save checkpoint if this is the first API request
const isFirstRequest = this.messageStateHandler.getClineMessages().filter((m) => m.say === "api_req_started").length === 0
// getting verbose details is an expensive operation, it uses globby to top-down build file structure of project which for large projects can take a few seconds
// for the best UX we show a placeholder api_req_started message with a loading spinner as this happens
await this.say(
"api_req_started",
JSON.stringify({
request: userContent.map((block) => formatContentBlockToMarkdown(block)).join("\n\n") + "\n\nLoading...",
}),
)
// Initialize checkpointManager first if enabled and it's the first request
if (
isFirstRequest &&
@@ -2454,28 +2705,15 @@ export class Task {
userContent.push({ type: "text", text: environmentDetails })
}
// Run UserPromptSubmit hook before sending to API
const hookResult = await this.runUserPromptSubmitHook(
userContent,
this.taskState.apiRequestCount === 1 ? "initial_task" : "feedback",
// getting verbose details is an expensive operation, it uses globby to top-down build file structure of project which for large projects can take a few seconds
// for the best UX we show a placeholder api_req_started message with a loading spinner as this happens
await this.say(
"api_req_started",
JSON.stringify({
request: userContent.map((block) => formatContentBlockToMarkdown(block)).join("\n\n") + "\n\nLoading...",
}),
)
// Handle hook blocking
if (!hookResult.shouldContinue) {
const errorMessage = hookResult.errorMessage || "UserPromptSubmit hook prevented this request"
await this.say("error", errorMessage)
// Return true to end the loop gracefully
return true
}
// Add hook context if provided
if (hookResult.contextModification) {
userContent.push({
type: "text",
text: `<hook_context source="UserPromptSubmit">\n${hookResult.contextModification}\n</hook_context>`,
})
}
await this.messageStateHandler.addToApiConversationHistory({
role: "user",
content: userContent,
+95 -20
View File
@@ -1,6 +1,7 @@
import Anthropic from "@anthropic-ai/sdk"
import CheckpointTracker from "@integrations/checkpoints/CheckpointTracker"
import getFolderSize from "get-folder-size"
import Mutex from "p-mutex"
import { findLastIndex } from "@/shared/array"
import { combineApiRequests } from "@/shared/combineApiRequests"
import { combineCommandSequences } from "@/shared/combineCommandSequences"
@@ -30,6 +31,12 @@ export class MessageStateHandler {
private ulid: string
private taskState: TaskState
// Mutex to prevent concurrent state modifications (RC-4)
// Protects against data loss from race conditions when multiple
// operations try to modify message state simultaneously
// This follows the same pattern as Task.stateMutex for consistency
private stateMutex = new Mutex()
constructor(params: MessageStateHandlerParams) {
this.taskId = params.taskId
this.ulid = params.ulid
@@ -42,6 +49,15 @@ export class MessageStateHandler {
this.checkpointTracker = tracker
}
/**
* Execute function with exclusive lock on message state
* Use this for ANY state modification to prevent race conditions
* This follows the same pattern as Task.withStateLock for consistency
*/
private async withStateLock<T>(fn: () => T | Promise<T>): Promise<T> {
return await this.stateMutex.withLock(fn)
}
getApiConversationHistory(): Anthropic.MessageParam[] {
return this.apiConversationHistory
}
@@ -58,7 +74,12 @@ export class MessageStateHandler {
this.clineMessages = newMessages
}
async saveClineMessagesAndUpdateHistory(): Promise<void> {
/**
* Internal method to save messages and update history (without mutex protection)
* This is used by methods that already hold the stateMutex lock
* Should NOT be called directly - use saveClineMessagesAndUpdateHistory() instead
*/
private async saveClineMessagesAndUpdateHistoryInternal(): Promise<void> {
try {
await saveClineMessages(this.taskId, this.clineMessages)
@@ -104,39 +125,93 @@ export class MessageStateHandler {
}
}
/**
* Save cline messages and update task history (public API with mutex protection)
* This is the main entry point for saving message state from external callers
*/
async saveClineMessagesAndUpdateHistory(): Promise<void> {
return await this.withStateLock(async () => {
await this.saveClineMessagesAndUpdateHistoryInternal()
})
}
async addToApiConversationHistory(message: Anthropic.MessageParam) {
this.apiConversationHistory.push(message)
await saveApiConversationHistory(this.taskId, this.apiConversationHistory)
// Protect with mutex to prevent concurrent modifications from corrupting data (RC-4)
return await this.withStateLock(async () => {
this.apiConversationHistory.push(message)
await saveApiConversationHistory(this.taskId, this.apiConversationHistory)
})
}
async overwriteApiConversationHistory(newHistory: Anthropic.MessageParam[]): Promise<void> {
this.apiConversationHistory = newHistory
await saveApiConversationHistory(this.taskId, this.apiConversationHistory)
// Protect with mutex to prevent concurrent modifications from corrupting data (RC-4)
return await this.withStateLock(async () => {
this.apiConversationHistory = newHistory
await saveApiConversationHistory(this.taskId, this.apiConversationHistory)
})
}
/**
* Add a new message to clineMessages array with proper index tracking
* CRITICAL: This entire operation must be atomic to prevent race conditions (RC-4)
* The conversationHistoryIndex must be set correctly based on the current state,
* and the message must be added and saved without any interleaving operations
*/
async addToClineMessages(message: ClineMessage) {
// these values allow us to reconstruct the conversation history at the time this cline message was created
// it's important that apiConversationHistory is initialized before we add cline messages
message.conversationHistoryIndex = this.apiConversationHistory.length - 1 // NOTE: this is the index of the last added message which is the user message, and once the clinemessages have been presented we update the apiconversationhistory with the completed assistant message. This means when resetting to a message, we need to +1 this index to get the correct assistant message that this tool use corresponds to
message.conversationHistoryDeletedRange = this.taskState.conversationHistoryDeletedRange
this.clineMessages.push(message)
await this.saveClineMessagesAndUpdateHistory()
return await this.withStateLock(async () => {
// these values allow us to reconstruct the conversation history at the time this cline message was created
// it's important that apiConversationHistory is initialized before we add cline messages
message.conversationHistoryIndex = this.apiConversationHistory.length - 1 // NOTE: this is the index of the last added message which is the user message, and once the clinemessages have been presented we update the apiconversationhistory with the completed assistant message. This means when resetting to a message, we need to +1 this index to get the correct assistant message that this tool use corresponds to
message.conversationHistoryDeletedRange = this.taskState.conversationHistoryDeletedRange
this.clineMessages.push(message)
await this.saveClineMessagesAndUpdateHistoryInternal()
})
}
/**
* Replace the entire clineMessages array with new messages
* Protected by mutex to prevent concurrent modifications (RC-4)
*/
async overwriteClineMessages(newMessages: ClineMessage[]) {
this.clineMessages = newMessages
await this.saveClineMessagesAndUpdateHistory()
return await this.withStateLock(async () => {
this.clineMessages = newMessages
await this.saveClineMessagesAndUpdateHistoryInternal()
})
}
/**
* Update a specific message in the clineMessages array
* The entire operation (validate, update, save) is atomic to prevent races (RC-4)
*/
async updateClineMessage(index: number, updates: Partial<ClineMessage>): Promise<void> {
if (index < 0 || index >= this.clineMessages.length) {
throw new Error(`Invalid message index: ${index}`)
}
return await this.withStateLock(async () => {
if (index < 0 || index >= this.clineMessages.length) {
throw new Error(`Invalid message index: ${index}`)
}
// Apply updates to the message
Object.assign(this.clineMessages[index], updates)
// Apply updates to the message
Object.assign(this.clineMessages[index], updates)
// Save changes and update history
await this.saveClineMessagesAndUpdateHistory()
// Save changes and update history
await this.saveClineMessagesAndUpdateHistoryInternal()
})
}
/**
* Delete a specific message from the clineMessages array
* The entire operation (validate, delete, save) is atomic to prevent races (RC-4)
*/
async deleteClineMessage(index: number): Promise<void> {
return await this.withStateLock(async () => {
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.saveClineMessagesAndUpdateHistoryInternal()
})
}
}
@@ -80,6 +80,22 @@ export class AttemptCompletionHandler implements IToolHandler, IPartialBlockHand
}
}
// Remove any partial completion_result message that may exist
// PreToolUse hook inserts messages after the partial, so we need to search backwards to find it
const clineMessages = config.messageState.getClineMessages()
const partialCompletionIndex = findLastIndex(
clineMessages,
(m) => m.partial === true && m.type === "say" && m.say === "completion_result",
)
if (partialCompletionIndex !== -1) {
const updatedMessages = [
...clineMessages.slice(0, partialCompletionIndex),
...clineMessages.slice(partialCompletionIndex + 1),
]
config.messageState.setClineMessages(updatedMessages)
await config.messageState.saveClineMessagesAndUpdateHistory()
}
let commandResult: any
const lastMessage = config.messageState.getClineMessages().at(-1)
@@ -115,6 +131,7 @@ export class AttemptCompletionHandler implements IToolHandler, IPartialBlockHand
// user didn't reject, but the command may have output
commandResult = execCommandResult
} else {
// Send the complete completion_result message (partial was already removed above)
const completionMessageTs = await config.callbacks.say("completion_result", result, undefined, undefined, false)
await config.callbacks.saveCheckpoint(true, completionMessageTs)
await addNewChangesFlagToLastCompletionResultMessage()
+34
View File
@@ -27,6 +27,8 @@ import { fixWithCline } from "./core/controller/commands/fixWithCline"
import { improveWithCline } from "./core/controller/commands/improveWithCline"
import { sendAddToInputEvent } from "./core/controller/ui/subscribeToAddToInput"
import { sendFocusChatInputEvent } from "./core/controller/ui/subscribeToFocusChatInput"
import { HookDiscoveryCache } from "./core/hooks/HookDiscoveryCache"
import { HookProcessRegistry } from "./core/hooks/HookProcessRegistry"
import { workspaceResolver } from "./core/workspace"
import { focusChatInput, getContextForCommand } from "./hosts/vscode/commandUtils"
import { abortCommitGeneration, generateCommitMessage } from "./hosts/vscode/commit-message-generator"
@@ -53,6 +55,30 @@ 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
HookDiscoveryCache.getInstance().initialize(
context as any, // Adapt VSCode ExtensionContext to generic interface
(dir: string) => {
try {
const pattern = new vscode.RelativePattern(dir, "*")
const watcher = vscode.workspace.createFileSystemWatcher(pattern)
// Adapt VSCode FileSystemWatcher to generic interface
return {
onDidCreate: (listener: () => void) => watcher.onDidCreate(listener),
onDidChange: (listener: () => void) => watcher.onDidChange(listener),
onDidDelete: (listener: () => void) => watcher.onDidDelete(listener),
dispose: () => watcher.dispose(),
}
} catch {
return null
}
},
(callback: () => void) => {
// Adapt VSCode Disposable to generic interface
return vscode.workspace.onDidChangeWorkspaceFolders(callback)
},
)
const webview = (await initialize(context)) as VscodeWebviewProvider
Logger.log("Cline extension activated")
@@ -446,11 +472,19 @@ 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
await HookProcessRegistry.terminateAll()
// Clean up hook discovery cache
HookDiscoveryCache.getInstance().dispose()
Logger.log("Cline extension deactivated")
}
+30
View File
@@ -167,6 +167,8 @@ export type ClineSay =
| "load_mcp_documentation"
| "info" // Added for general informational messages like retry status
| "task_progress"
| "hook"
| "hook_output"
export interface ClineSayTool {
tool:
@@ -187,6 +189,34 @@ export interface ClineSayTool {
operationIsLocatedInWorkspace?: boolean
}
export interface ClineSayHook {
hookName: string // Name of the hook (e.g., "PreToolUse", "PostToolUse")
toolName?: string // Tool name if applicable (for PreToolUse/PostToolUse)
status: "running" | "completed" | "failed" | "cancelled" // Execution status
exitCode?: number // Exit code when completed
hasJsonResponse?: boolean // Whether a JSON response was parsed
// Pending tool information (only present during PreToolUse "running" status)
pendingToolInfo?: {
tool: string // Tool name (e.g., "write_to_file", "execute_command")
path?: string // File path for file operations
command?: string // Command for execute_command
content?: string // Content preview (first 200 chars)
diff?: string // Diff preview (first 200 chars)
regex?: string // Regex pattern for search_files
url?: string // URL for web_fetch or browser_action
mcpTool?: string // MCP tool name
mcpServer?: string // MCP server name
resourceUri?: string // MCP resource URI
}
// Structured error information (only present when status is "failed")
error?: {
type: "timeout" | "validation" | "execution" | "cancellation" // Type of error
message: string // User-friendly error message
details?: string // Technical details for expansion
scriptPath?: string // Path to the hook script
}
}
// must keep in sync with system prompt
export const browserActions = ["launch", "click", "type", "scroll_down", "scroll_up", "close"] as const
export type BrowserAction = (typeof browserActions)[number]
+328
View File
@@ -0,0 +1,328 @@
import { ClineMessage } from "./ExtensionMessage"
/**
* Hook metadata extracted from hook message text.
* Mirrors the ClineSayHook interface but represents parsed data.
*/
interface HookMetadata {
hookName: string
toolName?: string
status: string
exitCode?: number
hasJsonResponse?: boolean
}
// ============================================================================
// PART 1: TYPE GUARDS & UTILITIES
// ============================================================================
/**
* Type guard to check if a message is a tool or command.
*/
function isToolOrCommandMessage(msg: ClineMessage): boolean {
return msg.ask === "tool" || msg.say === "tool" || msg.ask === "command" || msg.say === "command"
}
/**
* Safely parses hook metadata from a hook message.
* Returns null if parsing fails or message is not a hook.
*/
function parseHookMetadata(hookMessage: ClineMessage): HookMetadata | null {
if (hookMessage.say !== "hook" || !hookMessage.text) {
return null
}
try {
const outputIndex = hookMessage.text.indexOf(HOOK_OUTPUT_STRING)
const metadataStr = outputIndex !== -1 ? hookMessage.text.slice(0, outputIndex).trim() : hookMessage.text.trim()
return JSON.parse(metadataStr) as HookMetadata
} catch {
return null
}
}
// ============================================================================
// PART 2: FILTERING & COMBINING
// ============================================================================
/**
* Filters out partial tool/command messages while preserving all other types.
* Reasoning messages are always kept, even if marked partial.
*
* This prevents duplicate messages during React render cycles where partial
* messages are removed and replaced with complete versions.
*/
function filterPartialToolMessages(messages: ClineMessage[]): ClineMessage[] {
return messages.filter((msg) => {
// Always keep reasoning messages
if (msg.say === "reasoning") {
return true
}
// Filter out partial tool/command messages only
const isToolOrCommand = isToolOrCommandMessage(msg)
return !(isToolOrCommand && msg.partial === true)
})
}
/**
* Combines a single hook message with all subsequent hook_output messages.
*
* @param hookMessage The hook message to start combining from
* @param startIndex The index of the hook message in the messages array
* @param messages The full messages array
* @returns Object containing the combined message and the next index to process
*/
function combineHookWithOutputs(
hookMessage: ClineMessage,
startIndex: number,
messages: ClineMessage[],
): { combined: ClineMessage; nextIndex: number } {
let combinedText = hookMessage.text || ""
let hasOutput = false
let i = startIndex + 1
// Collect all hook_output messages until we hit another hook or end of array
while (i < messages.length && messages[i].say !== "hook") {
if (messages[i].say === "hook_output") {
// Add marker before first output
if (!hasOutput) {
combinedText += `\n${HOOK_OUTPUT_STRING}`
hasOutput = true
}
// Append output if not empty
const output = messages[i].text || ""
if (output.length > 0) {
combinedText += "\n" + output
}
}
i++
}
return {
combined: { ...hookMessage, text: combinedText },
nextIndex: i,
}
}
/**
* Combines all hooks with their outputs and removes hook_output messages.
*
* This is a two-pass process:
* 1. Scan through and combine each hook with its outputs
* 2. Build final array without hook_output messages, using combined hooks
*/
function combineAllHooks(messages: ClineMessage[]): ClineMessage[] {
// Pass 1: Build map of combined hooks by timestamp
const combinedHooksByTs = new Map<number, ClineMessage>()
for (let i = 0; i < messages.length; i++) {
if (messages[i].say === "hook") {
const { combined, nextIndex } = combineHookWithOutputs(messages[i], i, messages)
combinedHooksByTs.set(combined.ts, combined)
i = nextIndex - 1 // Adjust for loop increment
}
}
// Pass 2: Build result array
const result: ClineMessage[] = []
for (const msg of messages) {
if (msg.say === "hook_output") {
} else if (msg.say === "hook") {
// Use combined version
result.push(combinedHooksByTs.get(msg.ts) || msg)
} else {
// Keep all other messages as-is
result.push(msg)
}
}
return result
}
// ============================================================================
// PART 3: PRETOOLUSE REORDERING
// ============================================================================
/**
* Finds the timestamp of the next tool/command after a given index.
*
* Searches in the original messages array (not filtered) to catch tools
* that might still be partial. This ensures PreToolUse hooks are matched
* immediately even if their tool hasn't fully arrived yet.
*
* @param hookIndex The starting index to search from
* @param messages The original messages array (may include partial tools)
* @returns The timestamp of the next tool, or null if none found
*/
function findNextToolTimestamp(hookIndex: number, messages: ClineMessage[]): number | null {
for (let i = hookIndex + 1; i < messages.length; i++) {
if (isToolOrCommandMessage(messages[i])) {
return messages[i].ts
}
}
return null
}
/**
* Builds a map of tool timestamps to their PreToolUse hooks.
*
* This map indicates which hooks should be moved to appear before which tools.
* Only PreToolUse hooks are included; PostToolUse hooks stay in their original position.
*
* @param processedMessages Messages after filtering and combining
* @param originalMessages Original messages array (used to find tools)
* @returns Map of tool timestamp -> array of PreToolUse hooks for that tool
*/
function buildPreToolUseMap(processedMessages: ClineMessage[], originalMessages: ClineMessage[]): Map<number, ClineMessage[]> {
const map = new Map<number, ClineMessage[]>()
// Build timestamp-to-index map once to avoid O(n) findIndex calls
const timestampToIndex = new Map<number, number>()
for (let i = 0; i < originalMessages.length; i++) {
timestampToIndex.set(originalMessages[i].ts, i)
}
for (const msg of processedMessages) {
// Only process PreToolUse hooks
const metadata = parseHookMetadata(msg)
if (metadata?.hookName !== "PreToolUse") {
continue
}
// Find this hook's position in the original array using the index map
const hookIndexInOriginal = timestampToIndex.get(msg.ts)
if (hookIndexInOriginal === undefined) {
continue // Shouldn't happen, but be safe
}
// Find the next tool after this hook in the original array
const toolTimestamp = findNextToolTimestamp(hookIndexInOriginal, originalMessages)
if (toolTimestamp === null) {
// No tool found - hook will stay in original position
continue
}
// Map this hook to appear before that tool
if (!map.has(toolTimestamp)) {
map.set(toolTimestamp, [])
}
map.get(toolTimestamp)!.push(msg)
}
return map
}
/**
* Reorders messages so PreToolUse hooks appear before their associated tools.
*
* Algorithm:
* 1. When we encounter a tool, check if it has PreToolUse hooks mapped to it
* 2. If yes, insert those hooks BEFORE the tool
* 3. Track which hooks and tools we've already added to avoid duplicates
* 4. For PreToolUse hooks encountered in their original position:
* - If their tool is available and we'll process them before it, skip them
* - Otherwise, add them in their current position (tool not available yet)
*
* @param messages Messages after filtering and combining
* @param preToolUseMap Map of tool timestamp -> PreToolUse hooks
* @returns Reordered messages array
*/
function reorderWithPreToolUseHooks(messages: ClineMessage[], preToolUseMap: Map<number, ClineMessage[]>): ClineMessage[] {
const result: ClineMessage[] = []
const addedHooks = new Set<number>()
const addedTools = new Set<number>()
// Build set of available tool timestamps for quick lookup
const availableTools = new Set<number>()
for (const msg of messages) {
if (isToolOrCommandMessage(msg)) {
availableTools.add(msg.ts)
}
}
for (const msg of messages) {
// Case 1: This is a tool with PreToolUse hooks
if (isToolOrCommandMessage(msg) && preToolUseMap.has(msg.ts)) {
const hooksForTool = preToolUseMap.get(msg.ts)!
// Insert hooks that haven't been added yet
const newHooks = hooksForTool.filter((h) => !addedHooks.has(h.ts))
result.push(...newHooks)
newHooks.forEach((h) => addedHooks.add(h.ts))
// Add the tool
result.push(msg)
addedTools.add(msg.ts)
continue
}
// Case 2: This tool was already added with its hooks
if (addedTools.has(msg.ts)) {
continue
}
// Case 3: This is a PreToolUse hook in its original position
const metadata = parseHookMetadata(msg)
if (metadata?.hookName === "PreToolUse") {
// Find which tool (if any) this hook is mapped to
let mappedToolTs: number | undefined
for (const [toolTs, hooks] of preToolUseMap) {
if (hooks.some((h) => h.ts === msg.ts)) {
mappedToolTs = toolTs
break
}
}
// If this hook's tool is available and we'll insert it before that tool, skip it here
if (mappedToolTs !== undefined && availableTools.has(mappedToolTs)) {
continue
}
// Otherwise, keep hook in original position (tool not available yet)
}
// Case 4: All other messages (text, PostToolUse hooks, reasoning, etc.)
result.push(msg)
}
return result
}
// ============================================================================
// MAIN FUNCTION
// ============================================================================
/**
* Combines sequences of hook and hook_output messages, and reorders
* PreToolUse hooks to appear before their associated tool messages.
*
* Process:
* 1. Filter out partial tool/command messages (React render cycle cleanup)
* 2. Combine hooks with their hook_output messages
* 3. Build mapping of tools to their PreToolUse hooks
* 4. Reorder so PreToolUse hooks appear before their tools
*
* @param messages Array of ClineMessage objects to process
* @returns New array with hooks combined and PreToolUse hooks reordered
*/
export function combineHookSequences(messages: ClineMessage[]): ClineMessage[] {
// Phase 1: Filter out partial tool/command messages
const filtered = filterPartialToolMessages(messages)
// Phase 2: Combine hooks with their outputs
const combined = combineAllHooks(filtered)
// Phase 3: Build PreToolUse hook mapping
const preToolUseMap = buildPreToolUseMap(combined, messages)
// Phase 4: Reorder to place PreToolUse hooks before tools
const reordered = reorderWithPreToolUseHooks(combined, preToolUseMap)
return reordered
}
export const HOOK_OUTPUT_STRING = "__HOOK_OUTPUT__"
@@ -102,6 +102,8 @@ function convertClineSayToProtoEnum(say: AppClineSay | undefined): ClineSay | un
info: ClineSay.INFO,
task_progress: ClineSay.TASK_PROGRESS,
error_retry: ClineSay.ERROR_RETRY,
hook: ClineSay.INFO,
hook_output: ClineSay.COMMAND_OUTPUT_SAY,
}
const result = mapping[say]
+1 -4
View File
@@ -10,12 +10,9 @@ e2e("Chat - can send messages and switch between modes", async ({ helper, sideba
await expect(inputbox).toBeVisible()
await inputbox.fill("Hello, Cline!")
await expect(inputbox).toHaveValue("Hello, Cline!")
await sidebar.getByTestId("send-button").click({ delay: 100 })
await sidebar.getByTestId("send-button").click()
await expect(inputbox).toHaveValue("")
// Loading State initially
await expect(sidebar.getByText("API Request...")).toBeVisible()
// Starting a new task should clear the current chat view and show the recent tasks
await sidebar.getByRole("button", { name: "New Task", exact: true }).first().click()
await expect(sidebar.getByText("Recent Tasks")).toBeVisible()
+3 -3
View File
@@ -14,9 +14,9 @@ e2e.describe("Diff Editor", () => {
const inputbox = sidebar.getByTestId("chat-input")
await expect(inputbox).toBeVisible()
await inputbox.fill("Hello, Cline!")
await expect(inputbox).toHaveValue("Hello, Cline!")
await sidebar.getByTestId("send-button").click({ delay: 100 })
await inputbox.fill("[diff.test.ts] Hello, Cline!")
await expect(inputbox).toHaveValue("[diff.test.ts] Hello, Cline!")
await sidebar.getByTestId("send-button").click()
await expect(inputbox).toHaveValue("")
// Loading State initially
+6
View File
@@ -384,6 +384,12 @@ export class ClineApiServerMock {
if (body.includes("edit_request")) {
responseText = E2E_MOCK_API_RESPONSES.EDIT_REQUEST
}
if (body.includes("[diff.test.ts] Hello, Cline!")) {
// The playwright test in diff.test.ts needs the "API Request..." text
// to be on the screen long enough to detect it. This worked at 100ms
// too, but setting to 500ms to cover slower CI boxes.
await new Promise((resolve) => setTimeout(resolve, 500))
}
const generationId = `gen_${++controller.generationCounter}_${Date.now()}`
+641
View File
@@ -0,0 +1,641 @@
import { afterEach, beforeEach, describe, it } from "mocha"
import "should"
import * as fs from "fs/promises"
import * as os from "os"
import * as path from "path"
import * as sinon from "sinon"
import { HookDiscoveryCache } from "../core/hooks/HookDiscoveryCache"
import { executeHook } from "../core/hooks/hook-executor"
import { StateManager } from "../core/storage/StateManager"
import { MessageStateHandler } from "../core/task/message-state"
import { TaskState } from "../core/task/TaskState"
import { ClineMessage } from "../shared/ExtensionMessage"
/**
* Unit tests for the hook-executor module
* These tests verify the consolidated hook execution logic that replaced
* ~400 lines of duplicated code across TaskStart, TaskResume, UserPromptSubmit, and TaskCancel
*/
describe("Hook Executor", () => {
// Skip all hook tests on Windows as hooks are not yet supported on that platform
if (process.platform === "win32") {
it.skip("Hook tests are not supported on Windows yet", () => {
// This is intentional - hooks will be implemented for Windows in a future release
})
return
}
let tempDir: string
let baseTempDir: string // Store base directory for cleanup
let testHandler: MessageStateHandler
let mockMessages: ClineMessage[]
let stateManagerStub: sinon.SinonStub
/**
* Helper to create a minimal MessageStateHandler for testing
*/
function createTestHandler(): MessageStateHandler {
const taskState = new TaskState()
return new MessageStateHandler({
taskId: "test-task-id",
ulid: "test-ulid",
taskState,
updateTaskHistory: async () => [],
})
}
/**
* Helper to create a test hook script
*/
async function createHookScript(
hookName: string,
output: { cancel?: boolean; contextModification?: string; errorMessage?: string },
exitCode: number = 0,
delayMs: number = 0,
): Promise<string> {
const scriptPath = path.join(tempDir, hookName)
const scriptContent = `#!/usr/bin/env node
const delay = ${delayMs};
setTimeout(() => {
console.log(${JSON.stringify(JSON.stringify(output))});
process.exit(${exitCode});
}, delay);
`
await fs.writeFile(scriptPath, scriptContent, { mode: 0o755 })
return scriptPath
}
beforeEach(async () => {
// Reset the hook discovery cache before each test
// This ensures tests get a fresh cache and can discover newly created hooks
HookDiscoveryCache.resetForTesting()
// Create temporary directory for test hooks
baseTempDir = await fs.mkdtemp(path.join(os.tmpdir(), "hook-test-"))
// Create .clinerules/hooks subdirectory structure
tempDir = path.join(baseTempDir, ".clinerules", "hooks")
await fs.mkdir(tempDir, { recursive: true })
testHandler = createTestHandler()
mockMessages = []
// Mock StateManager to return baseTempDir as workspace root
// This allows HookFactory to find hooks in baseTempDir/.clinerules/hooks/
stateManagerStub = sinon.stub(StateManager, "get").returns({
getGlobalStateKey: (key: string) => {
if (key === "workspaceRoots") {
return [{ path: baseTempDir }]
}
return undefined
},
} as any)
})
afterEach(async () => {
// Clean up temporary directory (including entire base directory)
try {
await fs.rm(baseTempDir, { recursive: true, force: true })
} catch (error) {
// Ignore cleanup errors
}
// Restore StateManager stub
stateManagerStub.restore()
})
describe("Basic Hook Execution", () => {
it("should return wasCancelled: false when hooks are disabled", async () => {
const result = await executeHook({
hookName: "TaskStart",
hookInput: {
taskStart: {
taskMetadata: {
taskId: "test-task",
ulid: "test-ulid",
initialTask: "test task",
},
},
},
isCancellable: true,
say: async () => undefined,
messageStateHandler: testHandler,
taskId: "test-task",
hooksEnabled: false, // Disabled
})
result.should.deepEqual({
wasCancelled: false,
})
})
it("should return wasCancelled: false when hook doesn't exist", async () => {
// Point to non-existent directory
const result = await executeHook({
hookName: "TaskStart",
hookInput: {
taskStart: {
taskMetadata: {
taskId: "test-task",
ulid: "test-ulid",
initialTask: "test task",
},
},
},
isCancellable: true,
say: async () => undefined,
messageStateHandler: testHandler,
taskId: "test-task",
hooksEnabled: true,
})
result.should.deepEqual({
wasCancelled: false,
})
})
it("should execute hook successfully and return result", async function () {
this.timeout(5000)
// Create a simple hook that returns success
await createHookScript("TaskStart", {
cancel: false,
contextModification: "Test context modification",
})
const sayMessages: Array<{ type: string; text: string }> = []
const result = await executeHook({
hookName: "TaskStart",
hookInput: {
taskStart: {
taskMetadata: {
taskId: "test-task",
ulid: "test-ulid",
initialTask: "test task",
},
},
},
isCancellable: true,
say: async (type: any, text?: string) => {
sayMessages.push({ type, text: text || "" })
return Date.now()
},
messageStateHandler: testHandler,
taskId: "test-task",
hooksEnabled: true,
})
// Verify result
result.cancel!.should.equal(false)
result.contextModification!.should.equal("Test context modification")
result.wasCancelled.should.equal(false)
// Verify messages were sent
sayMessages.should.matchAny((msg: any) => msg.type === "hook")
})
it("should handle hook that requests cancellation", async function () {
this.timeout(5000)
await createHookScript("TaskStart", {
cancel: true,
contextModification: "Cancelling task",
errorMessage: "Task cancelled by hook",
})
const result = await executeHook({
hookName: "TaskStart",
hookInput: {
taskStart: {
taskMetadata: {
taskId: "test-task",
ulid: "test-ulid",
initialTask: "test task",
},
},
},
isCancellable: true,
say: async () => Date.now(),
messageStateHandler: testHandler,
taskId: "test-task",
hooksEnabled: true,
})
result.cancel!.should.equal(true)
result.contextModification!.should.equal("Cancelling task")
result.errorMessage!.should.equal("Task cancelled by hook")
result.wasCancelled.should.equal(false) // Not user-cancelled, hook requested cancel
})
})
describe("Cancellable Hooks", () => {
it("should support user cancellation for cancellable hooks", async function () {
this.timeout(5000)
// Create a hook that takes some time to execute
await createHookScript(
"TaskStart",
{
cancel: false,
},
0,
2000, // 2 second delay
)
let capturedAbortController: AbortController | null = null
let setHookCalled = false
let clearHookCalled = false
const result = await executeHook({
hookName: "TaskStart",
hookInput: {
taskStart: {
taskMetadata: {
taskId: "test-task",
ulid: "test-ulid",
initialTask: "test task",
},
},
},
isCancellable: true,
say: async () => Date.now(),
setActiveHookExecution: async (execution) => {
setHookCalled = true
capturedAbortController = execution.abortController
// Abort after capturing the controller
setTimeout(() => {
capturedAbortController?.abort()
}, 100)
},
clearActiveHookExecution: async () => {
clearHookCalled = true
},
messageStateHandler: testHandler,
taskId: "test-task",
hooksEnabled: true,
})
result.cancel!.should.equal(true)
result.wasCancelled.should.equal(true)
setHookCalled.should.equal(true)
// clearHookCalled should be true after abort
clearHookCalled.should.equal(true)
})
it("should not allow cancellation for non-cancellable hooks", async function () {
this.timeout(5000)
await createHookScript("TaskCancel", {
cancel: false,
})
// For non-cancellable hooks, setActiveHookExecution should not be called
let setHookCalled = false
const result = await executeHook({
hookName: "TaskCancel",
hookInput: {
taskCancel: {
taskMetadata: {
taskId: "test-task",
ulid: "test-ulid",
completionStatus: "cancelled",
},
},
},
isCancellable: false, // Not cancellable
say: async () => Date.now(),
setActiveHookExecution: async () => {
setHookCalled = true
},
messageStateHandler: testHandler,
taskId: "test-task",
hooksEnabled: true,
})
result.cancel!.should.equal(false)
result.wasCancelled.should.equal(false)
// setActiveHookExecution should not be called for non-cancellable hooks
// (In real execution, this would be verified, but test doesn't reach that point)
})
})
describe("Error Handling", () => {
it("should handle hook execution failure gracefully", async function () {
this.timeout(5000)
// Create a hook that exits with non-zero status
await createHookScript("TaskStart", {}, 1)
const result = await executeHook({
hookName: "TaskStart",
hookInput: {
taskStart: {
taskMetadata: {
taskId: "test-task",
ulid: "test-ulid",
initialTask: "test task",
},
},
},
isCancellable: true,
say: async () => Date.now(),
messageStateHandler: testHandler,
taskId: "test-task",
hooksEnabled: true,
})
// Hook failure should not crash, just return safe defaults
result.wasCancelled.should.equal(false)
})
it("should update message state on hook failure", async function () {
this.timeout(5000)
await createHookScript("TaskStart", {}, 1) // Exit with error
const messages: ClineMessage[] = []
const mockHandler = {
...testHandler,
getClineMessages: () => messages,
addToClineMessages: async (msg: ClineMessage) => {
messages.push(msg)
},
updateClineMessage: async (index: number, updates: Partial<ClineMessage>) => {
if (messages[index]) {
Object.assign(messages[index], updates)
}
},
} as any
await executeHook({
hookName: "TaskStart",
hookInput: {
taskStart: {
taskMetadata: {
taskId: "test-task",
ulid: "test-ulid",
initialTask: "test task",
},
},
},
isCancellable: true,
say: async (type: any, text?: string) => {
const msg: ClineMessage = {
ts: Date.now(),
type: "say",
say: type,
text,
}
messages.push(msg)
return msg.ts
},
messageStateHandler: mockHandler,
taskId: "test-task",
hooksEnabled: true,
})
// Should have recorded hook message
messages.length.should.be.greaterThan(0)
})
})
describe("Message State Updates", () => {
it("should create hook message with running status", async function () {
this.timeout(5000)
await createHookScript("TaskStart", {
cancel: false,
})
const messages: ClineMessage[] = []
await executeHook({
hookName: "TaskStart",
hookInput: {
taskStart: {
taskMetadata: {
taskId: "test-task",
ulid: "test-ulid",
initialTask: "test task",
},
},
},
isCancellable: true,
say: async (type: any, text?: string) => {
const msg: ClineMessage = {
ts: Date.now(),
type: "say",
say: type,
text,
}
messages.push(msg)
return msg.ts
},
messageStateHandler: testHandler,
taskId: "test-task",
hooksEnabled: true,
})
// Should have at least one hook message
messages.length.should.be.greaterThan(0)
const hookMessage = messages.find((m) => m.say === "hook")
should.exist(hookMessage)
})
it("should update hook message to completed status on success", async function () {
this.timeout(5000)
await createHookScript("TaskStart", {
cancel: false,
})
const messages: ClineMessage[] = []
const mockHandler = {
...testHandler,
getClineMessages: () => messages,
updateClineMessage: async (index: number, updates: Partial<ClineMessage>) => {
if (messages[index]) {
Object.assign(messages[index], updates)
}
},
} as any
await executeHook({
hookName: "TaskStart",
hookInput: {
taskStart: {
taskMetadata: {
taskId: "test-task",
ulid: "test-ulid",
initialTask: "test task",
},
},
},
isCancellable: true,
say: async (type: any, text?: string) => {
const msg: ClineMessage = {
ts: Date.now(),
type: "say",
say: type,
text,
}
messages.push(msg)
return msg.ts
},
messageStateHandler: mockHandler,
taskId: "test-task",
hooksEnabled: true,
})
// Verify hook message exists
messages.length.should.be.greaterThan(0)
})
})
describe("Different Hook Types", () => {
it("should execute TaskResume hook with correct input structure", async function () {
this.timeout(5000)
await createHookScript("TaskResume", {
cancel: false,
contextModification: "Resume context",
})
const result = await executeHook({
hookName: "TaskResume",
hookInput: {
taskResume: {
taskMetadata: {
taskId: "test-task",
ulid: "test-ulid",
},
previousState: {
lastMessageTs: "12345",
messageCount: "10",
conversationHistoryDeleted: "false",
},
},
},
isCancellable: true,
say: async () => Date.now(),
messageStateHandler: testHandler,
taskId: "test-task",
hooksEnabled: true,
})
result.wasCancelled.should.equal(false)
})
it("should execute UserPromptSubmit hook with correct input structure", async function () {
this.timeout(5000)
await createHookScript("UserPromptSubmit", {
cancel: false,
})
const result = await executeHook({
hookName: "UserPromptSubmit",
hookInput: {
userPromptSubmit: {
prompt: "Test prompt",
attachments: [],
},
},
isCancellable: true,
say: async () => Date.now(),
messageStateHandler: testHandler,
taskId: "test-task",
hooksEnabled: true,
})
result.wasCancelled.should.equal(false)
})
it("should execute TaskCancel hook as non-cancellable", async function () {
this.timeout(5000)
await createHookScript("TaskCancel", {
cancel: false,
})
const result = await executeHook({
hookName: "TaskCancel",
hookInput: {
taskCancel: {
taskMetadata: {
taskId: "test-task",
ulid: "test-ulid",
completionStatus: "cancelled",
},
},
},
isCancellable: false, // TaskCancel is not cancellable
say: async () => Date.now(),
messageStateHandler: testHandler,
taskId: "test-task",
hooksEnabled: true,
})
result.wasCancelled.should.equal(false)
})
})
describe("Edge Cases", () => {
it("should handle empty context modification", async function () {
this.timeout(5000)
await createHookScript("TaskStart", {
cancel: false,
contextModification: "",
})
const result = await executeHook({
hookName: "TaskStart",
hookInput: {
taskStart: {
taskMetadata: {
taskId: "test-task",
ulid: "test-ulid",
initialTask: "test task",
},
},
},
isCancellable: true,
say: async () => Date.now(),
messageStateHandler: testHandler,
taskId: "test-task",
hooksEnabled: true,
})
result.contextModification!.should.equal("")
result.wasCancelled.should.equal(false)
})
it("should handle undefined optional fields in result", async function () {
this.timeout(5000)
await createHookScript("TaskStart", {
cancel: false,
})
const result = await executeHook({
hookName: "TaskStart",
hookInput: {
taskStart: {
taskMetadata: {
taskId: "test-task",
ulid: "test-ulid",
initialTask: "test task",
},
},
},
isCancellable: true,
say: async () => Date.now(),
messageStateHandler: testHandler,
taskId: "test-task",
hooksEnabled: true,
})
result.wasCancelled.should.equal(false)
// contextModification and errorMessage may be undefined
})
})
})
+266
View File
@@ -0,0 +1,266 @@
import { describe, it } from "mocha"
import "should"
import { MessageStateHandler } from "../core/task/message-state"
import { TaskState } from "../core/task/TaskState"
import { ClineMessage } from "../shared/ExtensionMessage"
/**
* Unit tests for MessageStateHandler's mutex protection (RC-4)
* These tests verify that concurrent operations on message state are properly serialized
* to prevent race conditions, particularly the TOCTOU bug in addToClineMessages
*/
describe("MessageStateHandler Mutex Protection", () => {
/**
* Helper to create a minimal MessageStateHandler for testing
*/
function createTestHandler(): MessageStateHandler {
const taskState = new TaskState()
return new MessageStateHandler({
taskId: "test-task-id",
ulid: "test-ulid",
taskState,
updateTaskHistory: async () => [],
})
}
/**
* Helper to create a test ClineMessage
*/
function createTestMessage(text: string): ClineMessage {
return {
ts: Date.now(),
type: "say",
say: "text",
text,
}
}
it("should initialize with empty message arrays", () => {
const handler = createTestHandler()
handler.getClineMessages().length.should.equal(0)
handler.getApiConversationHistory().length.should.equal(0)
})
it("should set and get API conversation history", () => {
const handler = createTestHandler()
const testHistory = [{ role: "user" as const, content: "test message" }]
handler.setApiConversationHistory(testHistory)
handler.getApiConversationHistory().should.deepEqual(testHistory)
})
it("should set and get cline messages", () => {
const handler = createTestHandler()
const testMessages = [createTestMessage("test1"), createTestMessage("test2")]
handler.setClineMessages(testMessages)
handler.getClineMessages().should.deepEqual(testMessages)
})
/**
* CRITICAL TEST: Verify that addToClineMessages is atomic
* This test simulates the race condition that can occur when multiple
* addToClineMessages calls happen concurrently without proper mutex protection
*/
it("should handle concurrent addToClineMessages atomically", async function () {
// Increase timeout for this test as it involves async operations
this.timeout(5000)
const handler = createTestHandler()
// Set up initial API conversation history
const initialHistory = [
{ role: "user" as const, content: "msg1" },
{ role: "assistant" as const, content: "response1" },
{ role: "user" as const, content: "msg2" },
]
handler.setApiConversationHistory(initialHistory)
// Add initial message to establish baseline
const initialMsg = createTestMessage("initial")
await handler.addToClineMessages(initialMsg)
// Verify initial state
const messages = handler.getClineMessages()
messages.length.should.equal(1)
messages[0].conversationHistoryIndex!.should.equal(2) // length - 1 = 3 - 1 = 2
// Now simulate concurrent additions
// Without mutex protection, these could race and get the same index
const msg1 = createTestMessage("concurrent1")
const msg2 = createTestMessage("concurrent2")
const msg3 = createTestMessage("concurrent3")
// Add more messages to API history to simulate ongoing conversation
handler.setApiConversationHistory([
...initialHistory,
{ role: "assistant" as const, content: "response2" },
{ role: "user" as const, content: "msg3" },
])
// Execute concurrent operations
const results = await Promise.all([
handler.addToClineMessages(msg1),
handler.addToClineMessages(msg2),
handler.addToClineMessages(msg3),
])
// Verify all operations completed
results.length.should.equal(3)
// Get final state
const finalMessages = handler.getClineMessages()
finalMessages.length.should.equal(4) // initial + 3 concurrent
// CRITICAL ASSERTION: Each message should have a valid conversationHistoryIndex
// With proper mutex protection, these indices should be set correctly
// even though the operations ran concurrently
finalMessages.forEach((msg, idx) => {
should.exist(msg.conversationHistoryIndex)
msg.conversationHistoryIndex!.should.be.a.Number()
msg.conversationHistoryIndex!.should.be.greaterThanOrEqual(0)
})
})
/**
* Test that updateClineMessage operations are atomic
*/
it("should handle concurrent updateClineMessage atomically", async function () {
this.timeout(5000)
const handler = createTestHandler()
// Set up initial messages
const msgs = [createTestMessage("msg1"), createTestMessage("msg2"), createTestMessage("msg3")]
handler.setClineMessages(msgs)
// Perform concurrent updates to different messages
await Promise.all([
handler.updateClineMessage(0, { text: "updated1" }),
handler.updateClineMessage(1, { text: "updated2" }),
handler.updateClineMessage(2, { text: "updated3" }),
])
const finalMessages = handler.getClineMessages()
finalMessages[0]!.text!.should.equal("updated1")
finalMessages[1]!.text!.should.equal("updated2")
finalMessages[2]!.text!.should.equal("updated3")
})
/**
* Test that deleteClineMessage operations are atomic
*/
it("should handle deleteClineMessage with proper validation", async () => {
const handler = createTestHandler()
// Set up initial messages
const msgs = [createTestMessage("msg1"), createTestMessage("msg2"), createTestMessage("msg3")]
handler.setClineMessages(msgs)
// Delete middle message
await handler.deleteClineMessage(1)
const finalMessages = handler.getClineMessages()
finalMessages.length.should.equal(2)
finalMessages[0]!.text!.should.equal("msg1")
finalMessages[1]!.text!.should.equal("msg3")
})
/**
* Test that invalid indices are rejected
*/
it("should throw error for invalid message index in updateClineMessage", async () => {
const handler = createTestHandler()
handler.setClineMessages([createTestMessage("msg1")])
try {
await handler.updateClineMessage(5, { text: "invalid" })
throw new Error("Should have thrown")
} catch (error) {
if (error instanceof Error) {
error.message.should.match(/Invalid message index/)
}
}
})
/**
* Test that invalid indices are rejected in deleteClineMessage
*/
it("should throw error for invalid message index in deleteClineMessage", async () => {
const handler = createTestHandler()
handler.setClineMessages([createTestMessage("msg1")])
try {
await handler.deleteClineMessage(-1)
throw new Error("Should have thrown")
} catch (error) {
if (error instanceof Error) {
error.message.should.match(/Invalid message index/)
}
}
})
/**
* Test API conversation history operations
*/
it("should handle concurrent API conversation history operations", async function () {
this.timeout(5000)
const handler = createTestHandler()
// Perform concurrent additions
await Promise.all([
handler.addToApiConversationHistory({ role: "user", content: "msg1" }),
handler.addToApiConversationHistory({ role: "assistant", content: "response1" }),
handler.addToApiConversationHistory({ role: "user", content: "msg2" }),
])
const history = handler.getApiConversationHistory()
history.length.should.equal(3)
history[0].role.should.equal("user")
history[1].role.should.equal("assistant")
history[2].role.should.equal("user")
})
/**
* Test overwrite operations
*/
it("should handle overwriteClineMessages atomically", async () => {
const handler = createTestHandler()
// Set initial messages
handler.setClineMessages([createTestMessage("old1"), createTestMessage("old2")])
// Overwrite with new messages
const newMessages = [createTestMessage("new1"), createTestMessage("new2"), createTestMessage("new3")]
await handler.overwriteClineMessages(newMessages)
const finalMessages = handler.getClineMessages()
finalMessages.length.should.equal(3)
finalMessages[0]!.text!.should.equal("new1")
finalMessages[1]!.text!.should.equal("new2")
finalMessages[2]!.text!.should.equal("new3")
})
/**
* Test overwrite API conversation history
*/
it("should handle overwriteApiConversationHistory atomically", async () => {
const handler = createTestHandler()
// Set initial history
handler.setApiConversationHistory([{ role: "user", content: "old" }])
// Overwrite with new history
const newHistory = [
{ role: "user" as const, content: "new1" },
{ role: "assistant" as const, content: "new2" },
]
await handler.overwriteApiConversationHistory(newHistory)
const finalHistory = handler.getApiConversationHistory()
finalHistory.length.should.equal(2)
finalHistory[0].content.should.equal("new1")
finalHistory[1].content.should.equal("new2")
})
})
+379
View File
@@ -0,0 +1,379 @@
import { afterEach, beforeEach, describe, it } from "mocha"
import "should"
import * as sinon from "sinon"
import { executeHook } from "../core/hooks/hook-executor"
import { StateManager } from "../core/storage/StateManager"
import { MessageStateHandler } from "../core/task/message-state"
import { TaskState } from "../core/task/TaskState"
/**
* Unit tests for tool hook execution (PreToolUse and PostToolUse)
* These tests verify the consolidated hook execution logic for tool-specific hooks
*/
describe("Tool Executor Hooks", () => {
let stateManagerStub: sinon.SinonStub
beforeEach(() => {
// Mock StateManager to return empty workspace roots
stateManagerStub = sinon.stub(StateManager, "get").returns({
getGlobalStateKey: (key: string) => {
if (key === "workspaceRoots") {
return []
}
return undefined
},
} as any)
})
afterEach(() => {
// Restore StateManager stub
stateManagerStub.restore()
})
/**
* Helper to create a minimal MessageStateHandler for testing
*/
function createTestHandler(): MessageStateHandler {
const taskState = new TaskState()
return new MessageStateHandler({
taskId: "test-task-id",
ulid: "test-ulid",
taskState,
updateTaskHistory: async () => [],
})
}
describe("PreToolUse Hook", () => {
it("should include toolName and pendingToolInfo in hook metadata", async () => {
const testHandler = createTestHandler()
const sayMessages: Array<{ type: string; text: string }> = []
const pendingToolInfo = {
tool: "write_to_file",
path: "/test/file.ts",
content: "test content",
}
const result = await executeHook({
hookName: "PreToolUse",
hookInput: {
preToolUse: {
toolName: "write_to_file",
parameters: { path: "/test/file.ts", content: "test content" },
},
},
isCancellable: true,
say: async (type: any, text?: string) => {
sayMessages.push({ type, text: text || "" })
return Date.now()
},
messageStateHandler: testHandler,
taskId: "test-task",
hooksEnabled: false, // Disabled so hook doesn't actually run
toolName: "write_to_file",
pendingToolInfo,
})
// Should return early since hooks are disabled
result.wasCancelled.should.equal(false)
})
it("should handle PreToolUse hook with pendingToolInfo parameter", async () => {
const testHandler = createTestHandler()
const pendingToolInfo = {
tool: "execute_command",
command: "npm test",
}
const result = await executeHook({
hookName: "PreToolUse",
hookInput: {
preToolUse: {
toolName: "execute_command",
parameters: { command: "npm test" },
},
},
isCancellable: true,
say: async () => Date.now(),
messageStateHandler: testHandler,
taskId: "test-task",
hooksEnabled: false, // Hook doesn't exist, so returns early
toolName: "execute_command",
pendingToolInfo,
})
result.wasCancelled.should.equal(false)
})
it("should support cancellation for PreToolUse hooks", async () => {
const testHandler = createTestHandler()
// Test that cancellable hooks can use setActiveHookExecution
let setHookCalled = false
const result = await executeHook({
hookName: "PreToolUse",
hookInput: {
preToolUse: {
toolName: "write_to_file",
parameters: {},
},
},
isCancellable: true,
say: async () => Date.now(),
setActiveHookExecution: async () => {
setHookCalled = true
},
clearActiveHookExecution: async () => {},
messageStateHandler: testHandler,
taskId: "test-task",
hooksEnabled: false, // Doesn't run, but we verify the parameter is accepted
toolName: "write_to_file",
})
result.wasCancelled.should.equal(false)
// In real execution, setHookCalled would be true, but hook doesn't exist here
})
it("should pass through context modification from PreToolUse", async () => {
const testHandler = createTestHandler()
const result = await executeHook({
hookName: "PreToolUse",
hookInput: {
preToolUse: {
toolName: "read_file",
parameters: { path: "/test/file.ts" },
},
},
isCancellable: true,
say: async () => Date.now(),
messageStateHandler: testHandler,
taskId: "test-task",
hooksEnabled: false,
toolName: "read_file",
})
result.wasCancelled.should.equal(false)
// Hook doesn't exist, so no context modification
})
})
describe("PostToolUse Hook", () => {
it("should include toolName in hook metadata", async () => {
const testHandler = createTestHandler()
const sayMessages: Array<{ type: string; text: string }> = []
const result = await executeHook({
hookName: "PostToolUse",
hookInput: {
postToolUse: {
toolName: "write_to_file",
parameters: { path: "/test/file.ts" },
result: "File written successfully",
success: true,
executionTimeMs: 150,
},
},
isCancellable: true,
say: async (type: any, text?: string) => {
sayMessages.push({ type, text: text || "" })
return Date.now()
},
messageStateHandler: testHandler,
taskId: "test-task",
hooksEnabled: false,
toolName: "write_to_file",
})
result.wasCancelled.should.equal(false)
})
it("should include execution metrics in PostToolUse hook input", async () => {
const testHandler = createTestHandler()
const result = await executeHook({
hookName: "PostToolUse",
hookInput: {
postToolUse: {
toolName: "execute_command",
parameters: { command: "npm test" },
result: "Tests passed",
success: true,
executionTimeMs: 5000, // 5 seconds
},
},
isCancellable: true,
say: async () => Date.now(),
messageStateHandler: testHandler,
taskId: "test-task",
hooksEnabled: false,
toolName: "execute_command",
})
result.wasCancelled.should.equal(false)
})
it("should handle PostToolUse hook for failed tool execution", async () => {
const testHandler = createTestHandler()
const result = await executeHook({
hookName: "PostToolUse",
hookInput: {
postToolUse: {
toolName: "read_file",
parameters: { path: "/nonexistent/file.ts" },
result: "Error: File not found",
success: false,
executionTimeMs: 50,
},
},
isCancellable: true,
say: async () => Date.now(),
messageStateHandler: testHandler,
taskId: "test-task",
hooksEnabled: false,
toolName: "read_file",
})
result.wasCancelled.should.equal(false)
})
it("should support cancellation for PostToolUse hooks", async () => {
const testHandler = createTestHandler()
const result = await executeHook({
hookName: "PostToolUse",
hookInput: {
postToolUse: {
toolName: "browser_action",
parameters: { action: "launch", url: "https://example.com" },
result: "Browser launched",
success: true,
executionTimeMs: 1200,
},
},
isCancellable: true,
say: async () => Date.now(),
setActiveHookExecution: async () => {},
clearActiveHookExecution: async () => {},
messageStateHandler: testHandler,
taskId: "test-task",
hooksEnabled: false,
toolName: "browser_action",
})
result.wasCancelled.should.equal(false)
})
})
describe("Tool Hook Edge Cases", () => {
it("should handle hook execution when hooks are disabled", async () => {
const testHandler = createTestHandler()
const result = await executeHook({
hookName: "PreToolUse",
hookInput: {
preToolUse: {
toolName: "write_to_file",
parameters: {},
},
},
isCancellable: true,
say: async () => Date.now(),
messageStateHandler: testHandler,
taskId: "test-task",
hooksEnabled: false, // Explicitly disabled
toolName: "write_to_file",
})
result.should.deepEqual({
wasCancelled: false,
})
})
it("should handle hook execution when hook doesn't exist", async () => {
const testHandler = createTestHandler()
const result = await executeHook({
hookName: "PostToolUse",
hookInput: {
postToolUse: {
toolName: "list_files",
parameters: {},
result: "[]",
success: true,
executionTimeMs: 10,
},
},
isCancellable: true,
say: async () => Date.now(),
messageStateHandler: testHandler,
taskId: "test-task",
hooksEnabled: true, // Enabled but hook doesn't exist
toolName: "list_files",
})
result.should.deepEqual({
wasCancelled: false,
})
})
it("should handle PreToolUse with complex pendingToolInfo", async () => {
const testHandler = createTestHandler()
const complexPendingInfo = {
tool: "use_mcp_tool",
mcpServer: "github",
mcpTool: "create_issue",
}
const result = await executeHook({
hookName: "PreToolUse",
hookInput: {
preToolUse: {
toolName: "use_mcp_tool",
parameters: {
server_name: "github",
tool_name: "create_issue",
arguments: JSON.stringify({ title: "Bug report", body: "Found an issue..." }),
},
},
},
isCancellable: true,
say: async () => Date.now(),
messageStateHandler: testHandler,
taskId: "test-task",
hooksEnabled: false,
toolName: "use_mcp_tool",
pendingToolInfo: complexPendingInfo,
})
result.wasCancelled.should.equal(false)
})
it("should handle PostToolUse with execution time metrics", async () => {
const testHandler = createTestHandler()
const result = await executeHook({
hookName: "PostToolUse",
hookInput: {
postToolUse: {
toolName: "search_files",
parameters: { path: ".", regex: "test.*", file_pattern: "*.ts" },
result: "Found 25 matches",
success: true,
executionTimeMs: 2500,
},
},
isCancellable: true,
say: async () => Date.now(),
messageStateHandler: testHandler,
taskId: "test-task",
hooksEnabled: false,
toolName: "search_files",
})
result.wasCancelled.should.equal(false)
})
})
})
+57
View File
@@ -256,4 +256,61 @@ describe("Filesystem Utilities", () => {
multiExcludeFiles.sort().should.deepEqual(rootOnlyFiles.sort())
})
it("should exclude .clinerules/hooks directory specifically", async () => {
// Create a test directory structure
const clinerulesDirTest = path.join(tmpDir, "clinerules-hooks-test")
const clinerulesDirPath = path.join(clinerulesDirTest, ".clinerules")
// Create .clinerules directory and root files
await fs.mkdir(clinerulesDirPath, { recursive: true })
await fs.writeFile(path.join(clinerulesDirPath, "config.json"), "{}")
await fs.writeFile(path.join(clinerulesDirPath, "settings.js"), "// settings")
// Create .clinerules/workflows directory and files
const workflowsDirPath = path.join(clinerulesDirPath, "workflows")
await fs.mkdir(workflowsDirPath, { recursive: true })
await fs.writeFile(path.join(workflowsDirPath, "workflow1.js"), "// workflow1")
// Create .clinerules/hooks directory and files
const hooksDirPath = path.join(clinerulesDirPath, "hooks")
await fs.mkdir(hooksDirPath, { recursive: true })
await fs.writeFile(path.join(hooksDirPath, "PreToolUse"), "#!/usr/bin/env bash")
await fs.writeFile(path.join(hooksDirPath, "PostToolUse"), "#!/usr/bin/env bash")
// Get all files WITHOUT exclusion
const allFiles = await readDirectory(clinerulesDirPath)
// Verify all files are included
allFiles.length.should.equal(5) // 2 in root + 1 in workflows + 2 in hooks
allFiles.some((file) => file.includes("PreToolUse")).should.be.true()
allFiles.some((file) => file.includes("PostToolUse")).should.be.true()
// Get files WITH hooks directory excluded
const filteredFiles = await readDirectory(clinerulesDirPath, [[".clinerules", "hooks"]])
// Verify hooks files are excluded but others remain
filteredFiles.length.should.equal(3) // 2 in root + 1 in workflows
const expectedFiles = [
path.resolve(clinerulesDirPath, "config.json"),
path.resolve(clinerulesDirPath, "settings.js"),
path.resolve(workflowsDirPath, "workflow1.js"),
]
filteredFiles.sort().should.deepEqual(expectedFiles.sort())
// Test with multiple exclusions (both workflows and hooks)
const multiExcludeFiles = await readDirectory(clinerulesDirPath, [
[".clinerules", "workflows"],
[".clinerules", "hooks"],
])
// Verify both workflows and hooks directories are excluded
multiExcludeFiles.length.should.equal(2) // only the 2 files in root
const rootOnlyFiles = [path.resolve(clinerulesDirPath, "config.json"), path.resolve(clinerulesDirPath, "settings.js")]
multiExcludeFiles.sort().should.deepEqual(rootOnlyFiles.sort())
})
})
@@ -17,6 +17,7 @@ import styled from "styled-components"
import { OptionsButtons } from "@/components/chat/OptionsButtons"
import TaskFeedbackButtons from "@/components/chat/TaskFeedbackButtons"
import { CheckmarkControl } from "@/components/common/CheckmarkControl"
import { CheckpointControls } from "@/components/common/CheckpointControls"
import CodeBlock, {
CHAT_ROW_EXPANDED_BG_COLOR,
CODE_BLOCK_BG_COLOR,
@@ -36,6 +37,7 @@ import { findMatchingResourceOrTemplate, getMcpServerDisplayName } from "@/utils
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"
@@ -50,6 +52,26 @@ const _cancelledColor = "var(--vscode-descriptionForeground)"
const ChatRowContainer = styled.div`
padding: 10px 6px 10px 15px;
position: relative;
&: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 +1624,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 (
+9 -1
View File
@@ -1,6 +1,7 @@
import { findLast } from "@shared/array"
import { combineApiRequests } from "@shared/combineApiRequests"
import { combineCommandSequences } from "@shared/combineCommandSequences"
import { combineHookSequences } from "@shared/combineHookSequences"
import type { ClineApiReqInfo, ClineMessage } from "@shared/ExtensionMessage"
import { getApiMetrics } from "@shared/getApiMetrics"
import { BooleanRequest, StringRequest } from "@shared/proto/cline/common"
@@ -51,13 +52,20 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
mode,
userInfo,
currentFocusChainChecklist,
hooksEnabled,
} = useExtensionState()
const isProdHostedApp = userInfo?.apiBaseUrl === "https://app.cline.bot"
const shouldShowQuickWins = isProdHostedApp && (!taskHistory || taskHistory.length < QUICK_WINS_HISTORY_THRESHOLD)
//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(() => {
const slicedMessages = messages.slice(1)
// Only combine hook sequences if hooks are enabled (both user setting and feature flag)
const areHooksEnabled = hooksEnabled?.user && hooksEnabled?.featureFlag
const withHooks = areHooksEnabled ? combineHookSequences(slicedMessages) : slicedMessages
return combineApiRequests(combineCommandSequences(withHooks))
}, [messages, hooksEnabled])
// has to be after api_req_finished are all reduced into api_req_started messages
const apiMetrics = useMemo(() => getApiMetrics(modifiedMessages), [modifiedMessages])
@@ -0,0 +1,299 @@
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"
const normalColor = "var(--vscode-foreground)"
const errorColor = "var(--vscode-errorForeground)"
const successColor = "var(--vscode-charts-green)"
const completedColor = "var(--vscode-descriptionForeground)"
/**
* Determines if a hook message should be expanded by default.
*
* Expansion logic:
* - Historical messages (>5 seconds old): Always collapsed for better UX
* - Fresh failed/cancelled hooks: Expanded to show error details
* - Fresh successful hooks: Collapsed to minimize clutter
* - Running hooks: Not applicable (handled separately)
*
* @param message The message containing timestamp information
* @param metadata The hook metadata containing status
* @returns true if the hook output should be expanded by default
*/
function shouldExpandHookByDefault(message: ClineMessage, metadata: HookMetadata): boolean {
// Always collapse historical messages (>5 seconds old) for better UX
const isHistorical = message.ts && Date.now() - message.ts > 5000
if (isHistorical) {
return false
}
// Expand fresh failed/cancelled hooks to show error details
return metadata.status === "failed" || metadata.status === "cancelled"
}
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)
* - Aborted 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) => {
// Log when component mounts/updates
console.log(
`[HOOK-UI RENDER] HookMessage rendering: ${JSON.stringify({ hookName: message.text?.substring(0, 100), ts: message.ts })}`,
)
// 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])
// Determine initial expansion state using pure function
const [isHookOutputExpanded, setIsHookOutputExpanded] = useState(() => shouldExpandHookByDefault(message, metadata))
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: "visible",
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 ? successColor : isFailed || isCancelled ? errorColor : completedColor,
animation: isRunning ? "pulse 2s ease-in-out infinite" : "none",
flexShrink: 0,
}}
/>
<span
style={{
color: isRunning ? successColor : isFailed || isCancelled ? errorColor : completedColor,
fontWeight: 500,
fontSize: "13px",
flexShrink: 0,
}}>
{isRunning
? "Running"
: isFailed
? "Failed"
: isCancelled
? "Aborted"
: 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()
console.log(
`[HOOK-UI CANCEL] Hook cancel button clicked for ${metadata.hookName}${metadata.toolName ? ` (${metadata.toolName})` : ""}`,
)
// 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",
}}>
Abort
</button>
)}
</div>
{/* 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
@@ -68,6 +68,12 @@ export const ActionButtons: React.FC<ActionButtonsProps> = ({
return
}
setIsProcessing(true)
// Special handling for cancel action
if (action === "cancel") {
setIsProcessing(false)
}
messageHandlers.executeButtonAction(action, text, images, files)
},
[messageHandlers, isProcessing],
@@ -41,6 +41,8 @@ export function useMessageHandlers(messages: ClineMessage[], chatState: ChatStat
if (hasContent) {
console.log("[ChatView] handleSendMessage - Sending message:", messageToSend)
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,9 @@ 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)
break
case "utility":
@@ -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"
@@ -26,7 +27,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) => {
@@ -375,18 +375,26 @@ const FeatureSettingsSection = ({ renderSectionHeader }: FeatureSettingsSectionP
<div className="mt-2.5">
<VSCodeCheckbox
checked={hooksEnabled.user}
disabled={!isMacOSOrLinux()}
onChange={(e: any) => {
const checked = e.target.checked === true
updateSetting("hooksEnabled", checked)
}}>
Enable Hooks
</VSCodeCheckbox>
<p className="text-xs">
<span className="text-(--vscode-errorForeground)">Experimental: </span>{" "}
<span className="text-description">
Allows execution of hooks from .clinerules/hooks/ directory.
</span>
</p>
{!isMacOSOrLinux() ? (
<p className="text-xs mt-1" style={{ color: "var(--vscode-inputValidation-warningForeground)" }}>
Hooks are not yet supported on Windows. This feature is currently available on macOS and Linux
only.
</p>
) : (
<p className="text-xs">
<span className="text-(--vscode-errorForeground)">Experimental: </span>{" "}
<span className="text-description">
Allows execution of hooks from .clinerules/hooks/ directory.
</span>
</p>
)}
</div>
)}
<div style={{ marginTop: 10 }}>