mirror of
https://github.com/cline/cline.git
synced 2026-09-12 00:50:27 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
00e0846b16 | ||
|
|
4e533b2795 | ||
|
|
5ba290f54a | ||
|
|
3ca586bcce | ||
|
|
c0e220947e | ||
|
|
c400871ff0 | ||
|
|
0937ff8929 | ||
|
|
8602d8dbd5 | ||
|
|
3131d170d4 | ||
|
|
5e1c202ef6 | ||
|
|
e1a4dc150d | ||
|
|
1feb89fbf2 | ||
|
|
c451b0a94c | ||
|
|
4600b7b171 | ||
|
|
0ed10dc9b2 | ||
|
|
f68c11704a | ||
|
|
d3d06c3ef3 | ||
|
|
2c1b39b626 | ||
|
|
214fd25535 | ||
|
|
4c13fd746e | ||
|
|
92af2697c3 | ||
|
|
b5694e212d | ||
|
|
08e85379ae | ||
|
|
750efa1936 | ||
|
|
370c39d5f7 | ||
|
|
57c840ff23 | ||
|
|
b33d58e477 | ||
|
|
41dcffa3b0 | ||
|
|
b7c48afe65 | ||
|
|
b10a5fbbb5 | ||
|
|
ee7db64841 | ||
|
|
ed192205c3 | ||
|
|
6b8acafb5c | ||
|
|
66460996fd | ||
|
|
8b754e1c29 | ||
|
|
6f166bfa19 |
@@ -1,54 +1,15 @@
|
||||
#!/usr/bin/env bash
|
||||
# PostToolUse Hook Example
|
||||
#
|
||||
# This hook runs AFTER a tool is executed. It can:
|
||||
# 1. Observe tool results and outcomes
|
||||
# 2. Add context for FUTURE tool uses via contextModification
|
||||
# 3. Log or track tool usage patterns
|
||||
#
|
||||
# IMPORTANT: Context injection affects FUTURE AI decisions, not the current tool execution.
|
||||
# The tool has already completed when this hook runs.
|
||||
|
||||
# Read the hook input (JSON via stdin)
|
||||
input=$(cat)
|
||||
for i in {1..100}; do
|
||||
echo "$i"
|
||||
done
|
||||
|
||||
# Extract tool information
|
||||
tool_name=$(echo "$input" | jq -r '.postToolUse.toolName // "unknown"')
|
||||
parameters=$(echo "$input" | jq -r '.postToolUse.parameters // {}')
|
||||
result=$(echo "$input" | jq -r '.postToolUse.result // ""')
|
||||
success=$(echo "$input" | jq -r '.postToolUse.success // false')
|
||||
execution_time=$(echo "$input" | jq -r '.postToolUse.executionTimeMs // 0')
|
||||
sleep 3
|
||||
|
||||
# Example 1: Learning from file operations
|
||||
# Track successful file creations to build context about project structure
|
||||
# if [[ "$tool_name" == "write_to_file" && "$success" == "true" ]]; then
|
||||
# path=$(echo "$parameters" | jq -r '.path // ""')
|
||||
# cat <<EOF
|
||||
# {
|
||||
# "shouldContinue": true,
|
||||
# "contextModification": "FILE_OPERATIONS: Successfully created '$path'. Future operations should maintain consistency with this file's patterns and structure."
|
||||
# }
|
||||
# EOF
|
||||
# exit 0
|
||||
# fi
|
||||
|
||||
# Example 2: Performance monitoring
|
||||
# Warn about slow operations
|
||||
# if [[ "$execution_time" -gt 5000 ]]; then
|
||||
# cat <<EOF
|
||||
# {
|
||||
# "shouldContinue": true,
|
||||
# "contextModification": "PERFORMANCE: Tool '$tool_name' took ${execution_time}ms to complete. Consider optimizing future similar operations or breaking them into smaller steps."
|
||||
# }
|
||||
# EOF
|
||||
# exit 0
|
||||
# fi
|
||||
|
||||
# Example 3: Context injection for future tool uses
|
||||
# The context will be available in the NEXT API request
|
||||
cat <<EOF
|
||||
{
|
||||
"shouldContinue": true,
|
||||
"contextModification": "TOOL_RESULT: The tool '$tool_name' completed with success=$success. Consider validating the results before proceeding to the next step."
|
||||
"cancel": false,
|
||||
"contextModification": "WORKSPACE_RULES: This is from the local bar/ workspace.",
|
||||
"errorMessage": "PostToolUse hook custom errorMessage: foo"
|
||||
}
|
||||
EOF
|
||||
|
||||
@@ -1,42 +1,15 @@
|
||||
#!/usr/bin/env bash
|
||||
# PreToolUse Hook Example
|
||||
#
|
||||
# This hook runs BEFORE a tool is executed. It can:
|
||||
# 1. Block execution by returning {"shouldContinue": false}
|
||||
# 2. Add context for FUTURE tool uses via contextModification
|
||||
# 3. Validate tool parameters
|
||||
#
|
||||
# IMPORTANT: Context injection affects FUTURE AI decisions, not the current tool execution.
|
||||
# The tool parameters are already determined when this hook runs.
|
||||
|
||||
# Read the hook input (JSON via stdin)
|
||||
input=$(cat)
|
||||
for i in {1..100}; do
|
||||
echo "$i"
|
||||
done
|
||||
|
||||
# Extract tool information
|
||||
tool_name=$(echo "$input" | jq -r '.preToolUse.toolName // "unknown"')
|
||||
parameters=$(echo "$input" | jq -r '.preToolUse.parameters // {}')
|
||||
sleep 3
|
||||
|
||||
# Example 1: Validation - Block invalid operations
|
||||
# Uncomment to prevent creating .js files in a TypeScript project
|
||||
# if [[ "$tool_name" == "write_to_file" ]]; then
|
||||
# path=$(echo "$parameters" | jq -r '.path // ""')
|
||||
# if [[ "$path" == *.js ]]; then
|
||||
# cat <<EOF
|
||||
# {
|
||||
# "shouldContinue": false,
|
||||
# "errorMessage": "VALIDATION FAILED: Cannot create .js files in TypeScript project. Please use .ts extension instead.",
|
||||
# "contextModification": "WORKSPACE_RULES: This is a strict TypeScript project. All new files must use .ts or .tsx extensions."
|
||||
# }
|
||||
# EOF
|
||||
# exit 0
|
||||
# fi
|
||||
# fi
|
||||
|
||||
# Example 2: Context injection for future tool uses
|
||||
# The context will be available in the NEXT API request after this tool completes
|
||||
cat <<EOF
|
||||
{
|
||||
"shouldContinue": true,
|
||||
"contextModification": "WORKSPACE_RULES: [For future tool uses] This is a TypeScript React project. When creating files, use .ts/.tsx extensions and include detailed comments explaining the purpose and usage of each function."
|
||||
"cancel": false,
|
||||
"contextModification": "Make sure that all variables in python code in this repo always start with the string 'EVIEDEBUG_'. This is important.",
|
||||
"errorMessage": "PreToolUse hook custom errorMessage: foo"
|
||||
}
|
||||
EOF
|
||||
|
||||
+122
-41
@@ -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
|
||||
|
||||
|
||||
Executable
+15
@@ -0,0 +1,15 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
for i in {1..100}; do
|
||||
echo "$i"
|
||||
done
|
||||
|
||||
sleep 3
|
||||
|
||||
cat <<EOF
|
||||
{
|
||||
"cancel", false,
|
||||
"contextModification": "WORKSPACE_RULES: This is from the local bar/ workspace.",
|
||||
"errorMessage": "TaskCancel hook custom errorMessage: foo"
|
||||
}
|
||||
EOF
|
||||
Executable
+15
@@ -0,0 +1,15 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
for i in {1..100}; do
|
||||
echo "$i"
|
||||
done
|
||||
|
||||
sleep 3
|
||||
|
||||
cat <<EOF
|
||||
{
|
||||
"cancel": false,
|
||||
"contextModification": "WORKSPACE_RULES: This is from the local bar/ workspace.",
|
||||
"errorMessage": "TaskResume hook custom errorMessage: foo"
|
||||
}
|
||||
EOF
|
||||
Executable
+15
@@ -0,0 +1,15 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
for i in {1..100}; do
|
||||
echo "$i"
|
||||
done
|
||||
|
||||
sleep 1
|
||||
|
||||
cat <<EOF
|
||||
{
|
||||
"cancel": false,
|
||||
"contextModification": "WORKSPACE_RULES: This is from the local bar/ workspace.",
|
||||
"errorMessage": "TaskStart hook custom errorMessage: foo"
|
||||
}
|
||||
EOF
|
||||
Executable
+15
@@ -0,0 +1,15 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
for i in {1..100}; do
|
||||
echo "$i"
|
||||
done
|
||||
|
||||
sleep 3
|
||||
|
||||
cat <<EOF
|
||||
{
|
||||
"cancel": false,
|
||||
"contextModification": "WORKSPACE_RULES: This is from the local bar/ workspace.",
|
||||
"errorMessage": "UserPromptSubmit hook custom errorMessage: foo"
|
||||
}
|
||||
EOF
|
||||
Generated
+28
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
-1
@@ -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: ""
|
||||
}));
|
||||
|
||||
+1
-1
@@ -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: ""
|
||||
}));
|
||||
|
||||
+1
-1
@@ -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: ""
|
||||
}));
|
||||
|
||||
+1
-1
@@ -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}`
|
||||
}));
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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> {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
|
||||
@@ -167,6 +167,8 @@ export type ClineSay =
|
||||
| "load_mcp_documentation"
|
||||
| "info" // Added for general informational messages like retry status
|
||||
| "task_progress"
|
||||
| "hook"
|
||||
| "hook_output"
|
||||
|
||||
export interface ClineSayTool {
|
||||
tool:
|
||||
@@ -187,6 +189,34 @@ export interface ClineSayTool {
|
||||
operationIsLocatedInWorkspace?: boolean
|
||||
}
|
||||
|
||||
export interface ClineSayHook {
|
||||
hookName: string // Name of the hook (e.g., "PreToolUse", "PostToolUse")
|
||||
toolName?: string // Tool name if applicable (for PreToolUse/PostToolUse)
|
||||
status: "running" | "completed" | "failed" | "cancelled" // Execution status
|
||||
exitCode?: number // Exit code when completed
|
||||
hasJsonResponse?: boolean // Whether a JSON response was parsed
|
||||
// Pending tool information (only present during PreToolUse "running" status)
|
||||
pendingToolInfo?: {
|
||||
tool: string // Tool name (e.g., "write_to_file", "execute_command")
|
||||
path?: string // File path for file operations
|
||||
command?: string // Command for execute_command
|
||||
content?: string // Content preview (first 200 chars)
|
||||
diff?: string // Diff preview (first 200 chars)
|
||||
regex?: string // Regex pattern for search_files
|
||||
url?: string // URL for web_fetch or browser_action
|
||||
mcpTool?: string // MCP tool name
|
||||
mcpServer?: string // MCP server name
|
||||
resourceUri?: string // MCP resource URI
|
||||
}
|
||||
// Structured error information (only present when status is "failed")
|
||||
error?: {
|
||||
type: "timeout" | "validation" | "execution" | "cancellation" // Type of error
|
||||
message: string // User-friendly error message
|
||||
details?: string // Technical details for expansion
|
||||
scriptPath?: string // Path to the hook script
|
||||
}
|
||||
}
|
||||
|
||||
// must keep in sync with system prompt
|
||||
export const browserActions = ["launch", "click", "type", "scroll_down", "scroll_up", "close"] as const
|
||||
export type BrowserAction = (typeof browserActions)[number]
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
import { ClineMessage } from "./ExtensionMessage"
|
||||
|
||||
/**
|
||||
* Hook metadata extracted from hook message text.
|
||||
* Mirrors the ClineSayHook interface but represents parsed data.
|
||||
*/
|
||||
interface HookMetadata {
|
||||
hookName: string
|
||||
toolName?: string
|
||||
status: string
|
||||
exitCode?: number
|
||||
hasJsonResponse?: boolean
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// PART 1: TYPE GUARDS & UTILITIES
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Type guard to check if a message is a tool or command.
|
||||
*/
|
||||
function isToolOrCommandMessage(msg: ClineMessage): boolean {
|
||||
return msg.ask === "tool" || msg.say === "tool" || msg.ask === "command" || msg.say === "command"
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely parses hook metadata from a hook message.
|
||||
* Returns null if parsing fails or message is not a hook.
|
||||
*/
|
||||
function parseHookMetadata(hookMessage: ClineMessage): HookMetadata | null {
|
||||
if (hookMessage.say !== "hook" || !hookMessage.text) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const outputIndex = hookMessage.text.indexOf(HOOK_OUTPUT_STRING)
|
||||
const metadataStr = outputIndex !== -1 ? hookMessage.text.slice(0, outputIndex).trim() : hookMessage.text.trim()
|
||||
|
||||
return JSON.parse(metadataStr) as HookMetadata
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// PART 2: FILTERING & COMBINING
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Filters out partial tool/command messages while preserving all other types.
|
||||
* Reasoning messages are always kept, even if marked partial.
|
||||
*
|
||||
* This prevents duplicate messages during React render cycles where partial
|
||||
* messages are removed and replaced with complete versions.
|
||||
*/
|
||||
function filterPartialToolMessages(messages: ClineMessage[]): ClineMessage[] {
|
||||
return messages.filter((msg) => {
|
||||
// Always keep reasoning messages
|
||||
if (msg.say === "reasoning") {
|
||||
return true
|
||||
}
|
||||
|
||||
// Filter out partial tool/command messages only
|
||||
const isToolOrCommand = isToolOrCommandMessage(msg)
|
||||
return !(isToolOrCommand && msg.partial === true)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Combines a single hook message with all subsequent hook_output messages.
|
||||
*
|
||||
* @param hookMessage The hook message to start combining from
|
||||
* @param startIndex The index of the hook message in the messages array
|
||||
* @param messages The full messages array
|
||||
* @returns Object containing the combined message and the next index to process
|
||||
*/
|
||||
function combineHookWithOutputs(
|
||||
hookMessage: ClineMessage,
|
||||
startIndex: number,
|
||||
messages: ClineMessage[],
|
||||
): { combined: ClineMessage; nextIndex: number } {
|
||||
let combinedText = hookMessage.text || ""
|
||||
let hasOutput = false
|
||||
let i = startIndex + 1
|
||||
|
||||
// Collect all hook_output messages until we hit another hook or end of array
|
||||
while (i < messages.length && messages[i].say !== "hook") {
|
||||
if (messages[i].say === "hook_output") {
|
||||
// Add marker before first output
|
||||
if (!hasOutput) {
|
||||
combinedText += `\n${HOOK_OUTPUT_STRING}`
|
||||
hasOutput = true
|
||||
}
|
||||
|
||||
// Append output if not empty
|
||||
const output = messages[i].text || ""
|
||||
if (output.length > 0) {
|
||||
combinedText += "\n" + output
|
||||
}
|
||||
}
|
||||
i++
|
||||
}
|
||||
|
||||
return {
|
||||
combined: { ...hookMessage, text: combinedText },
|
||||
nextIndex: i,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Combines all hooks with their outputs and removes hook_output messages.
|
||||
*
|
||||
* This is a two-pass process:
|
||||
* 1. Scan through and combine each hook with its outputs
|
||||
* 2. Build final array without hook_output messages, using combined hooks
|
||||
*/
|
||||
function combineAllHooks(messages: ClineMessage[]): ClineMessage[] {
|
||||
// Pass 1: Build map of combined hooks by timestamp
|
||||
const combinedHooksByTs = new Map<number, ClineMessage>()
|
||||
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
if (messages[i].say === "hook") {
|
||||
const { combined, nextIndex } = combineHookWithOutputs(messages[i], i, messages)
|
||||
combinedHooksByTs.set(combined.ts, combined)
|
||||
i = nextIndex - 1 // Adjust for loop increment
|
||||
}
|
||||
}
|
||||
|
||||
// Pass 2: Build result array
|
||||
const result: ClineMessage[] = []
|
||||
|
||||
for (const msg of messages) {
|
||||
if (msg.say === "hook_output") {
|
||||
} else if (msg.say === "hook") {
|
||||
// Use combined version
|
||||
result.push(combinedHooksByTs.get(msg.ts) || msg)
|
||||
} else {
|
||||
// Keep all other messages as-is
|
||||
result.push(msg)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// PART 3: PRETOOLUSE REORDERING
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Finds the timestamp of the next tool/command after a given index.
|
||||
*
|
||||
* Searches in the original messages array (not filtered) to catch tools
|
||||
* that might still be partial. This ensures PreToolUse hooks are matched
|
||||
* immediately even if their tool hasn't fully arrived yet.
|
||||
*
|
||||
* @param hookIndex The starting index to search from
|
||||
* @param messages The original messages array (may include partial tools)
|
||||
* @returns The timestamp of the next tool, or null if none found
|
||||
*/
|
||||
function findNextToolTimestamp(hookIndex: number, messages: ClineMessage[]): number | null {
|
||||
for (let i = hookIndex + 1; i < messages.length; i++) {
|
||||
if (isToolOrCommandMessage(messages[i])) {
|
||||
return messages[i].ts
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a map of tool timestamps to their PreToolUse hooks.
|
||||
*
|
||||
* This map indicates which hooks should be moved to appear before which tools.
|
||||
* Only PreToolUse hooks are included; PostToolUse hooks stay in their original position.
|
||||
*
|
||||
* @param processedMessages Messages after filtering and combining
|
||||
* @param originalMessages Original messages array (used to find tools)
|
||||
* @returns Map of tool timestamp -> array of PreToolUse hooks for that tool
|
||||
*/
|
||||
function buildPreToolUseMap(processedMessages: ClineMessage[], originalMessages: ClineMessage[]): Map<number, ClineMessage[]> {
|
||||
const map = new Map<number, ClineMessage[]>()
|
||||
|
||||
// Build timestamp-to-index map once to avoid O(n) findIndex calls
|
||||
const timestampToIndex = new Map<number, number>()
|
||||
for (let i = 0; i < originalMessages.length; i++) {
|
||||
timestampToIndex.set(originalMessages[i].ts, i)
|
||||
}
|
||||
|
||||
for (const msg of processedMessages) {
|
||||
// Only process PreToolUse hooks
|
||||
const metadata = parseHookMetadata(msg)
|
||||
if (metadata?.hookName !== "PreToolUse") {
|
||||
continue
|
||||
}
|
||||
|
||||
// Find this hook's position in the original array using the index map
|
||||
const hookIndexInOriginal = timestampToIndex.get(msg.ts)
|
||||
if (hookIndexInOriginal === undefined) {
|
||||
continue // Shouldn't happen, but be safe
|
||||
}
|
||||
|
||||
// Find the next tool after this hook in the original array
|
||||
const toolTimestamp = findNextToolTimestamp(hookIndexInOriginal, originalMessages)
|
||||
if (toolTimestamp === null) {
|
||||
// No tool found - hook will stay in original position
|
||||
continue
|
||||
}
|
||||
|
||||
// Map this hook to appear before that tool
|
||||
if (!map.has(toolTimestamp)) {
|
||||
map.set(toolTimestamp, [])
|
||||
}
|
||||
map.get(toolTimestamp)!.push(msg)
|
||||
}
|
||||
|
||||
return map
|
||||
}
|
||||
|
||||
/**
|
||||
* Reorders messages so PreToolUse hooks appear before their associated tools.
|
||||
*
|
||||
* Algorithm:
|
||||
* 1. When we encounter a tool, check if it has PreToolUse hooks mapped to it
|
||||
* 2. If yes, insert those hooks BEFORE the tool
|
||||
* 3. Track which hooks and tools we've already added to avoid duplicates
|
||||
* 4. For PreToolUse hooks encountered in their original position:
|
||||
* - If their tool is available and we'll process them before it, skip them
|
||||
* - Otherwise, add them in their current position (tool not available yet)
|
||||
*
|
||||
* @param messages Messages after filtering and combining
|
||||
* @param preToolUseMap Map of tool timestamp -> PreToolUse hooks
|
||||
* @returns Reordered messages array
|
||||
*/
|
||||
function reorderWithPreToolUseHooks(messages: ClineMessage[], preToolUseMap: Map<number, ClineMessage[]>): ClineMessage[] {
|
||||
const result: ClineMessage[] = []
|
||||
const addedHooks = new Set<number>()
|
||||
const addedTools = new Set<number>()
|
||||
|
||||
// Build set of available tool timestamps for quick lookup
|
||||
const availableTools = new Set<number>()
|
||||
for (const msg of messages) {
|
||||
if (isToolOrCommandMessage(msg)) {
|
||||
availableTools.add(msg.ts)
|
||||
}
|
||||
}
|
||||
|
||||
for (const msg of messages) {
|
||||
// Case 1: This is a tool with PreToolUse hooks
|
||||
if (isToolOrCommandMessage(msg) && preToolUseMap.has(msg.ts)) {
|
||||
const hooksForTool = preToolUseMap.get(msg.ts)!
|
||||
|
||||
// Insert hooks that haven't been added yet
|
||||
const newHooks = hooksForTool.filter((h) => !addedHooks.has(h.ts))
|
||||
result.push(...newHooks)
|
||||
newHooks.forEach((h) => addedHooks.add(h.ts))
|
||||
|
||||
// Add the tool
|
||||
result.push(msg)
|
||||
addedTools.add(msg.ts)
|
||||
continue
|
||||
}
|
||||
|
||||
// Case 2: This tool was already added with its hooks
|
||||
if (addedTools.has(msg.ts)) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Case 3: This is a PreToolUse hook in its original position
|
||||
const metadata = parseHookMetadata(msg)
|
||||
if (metadata?.hookName === "PreToolUse") {
|
||||
// Find which tool (if any) this hook is mapped to
|
||||
let mappedToolTs: number | undefined
|
||||
for (const [toolTs, hooks] of preToolUseMap) {
|
||||
if (hooks.some((h) => h.ts === msg.ts)) {
|
||||
mappedToolTs = toolTs
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// If this hook's tool is available and we'll insert it before that tool, skip it here
|
||||
if (mappedToolTs !== undefined && availableTools.has(mappedToolTs)) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Otherwise, keep hook in original position (tool not available yet)
|
||||
}
|
||||
|
||||
// Case 4: All other messages (text, PostToolUse hooks, reasoning, etc.)
|
||||
result.push(msg)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// MAIN FUNCTION
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Combines sequences of hook and hook_output messages, and reorders
|
||||
* PreToolUse hooks to appear before their associated tool messages.
|
||||
*
|
||||
* Process:
|
||||
* 1. Filter out partial tool/command messages (React render cycle cleanup)
|
||||
* 2. Combine hooks with their hook_output messages
|
||||
* 3. Build mapping of tools to their PreToolUse hooks
|
||||
* 4. Reorder so PreToolUse hooks appear before their tools
|
||||
*
|
||||
* @param messages Array of ClineMessage objects to process
|
||||
* @returns New array with hooks combined and PreToolUse hooks reordered
|
||||
*/
|
||||
export function combineHookSequences(messages: ClineMessage[]): ClineMessage[] {
|
||||
// Phase 1: Filter out partial tool/command messages
|
||||
const filtered = filterPartialToolMessages(messages)
|
||||
|
||||
// Phase 2: Combine hooks with their outputs
|
||||
const combined = combineAllHooks(filtered)
|
||||
|
||||
// Phase 3: Build PreToolUse hook mapping
|
||||
const preToolUseMap = buildPreToolUseMap(combined, messages)
|
||||
|
||||
// Phase 4: Reorder to place PreToolUse hooks before tools
|
||||
const reordered = reorderWithPreToolUseHooks(combined, preToolUseMap)
|
||||
|
||||
return reordered
|
||||
}
|
||||
|
||||
export const HOOK_OUTPUT_STRING = "__HOOK_OUTPUT__"
|
||||
@@ -102,6 +102,8 @@ function convertClineSayToProtoEnum(say: AppClineSay | undefined): ClineSay | un
|
||||
info: ClineSay.INFO,
|
||||
task_progress: ClineSay.TASK_PROGRESS,
|
||||
error_retry: ClineSay.ERROR_RETRY,
|
||||
hook: ClineSay.INFO,
|
||||
hook_output: ClineSay.COMMAND_OUTPUT_SAY,
|
||||
}
|
||||
|
||||
const result = mapping[say]
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()}`
|
||||
|
||||
|
||||
@@ -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
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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")
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 }}>
|
||||
|
||||
Reference in New Issue
Block a user