mirror of
https://github.com/cline/cline.git
synced 2026-09-12 00:50:27 +08:00
Compare commits
30
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7190d0fe10 | ||
|
|
b5d0f6faf9 | ||
|
|
7c3909d8e8 | ||
|
|
48abfd4536 | ||
|
|
9c4a18b170 | ||
|
|
e99e5b990e | ||
|
|
9ed7ef54dd | ||
|
|
59bcb08466 | ||
|
|
72d8d53d1f | ||
|
|
309e9546f1 | ||
|
|
dd3fda8a3b | ||
|
|
51cfd464cd | ||
|
|
7ced24fd13 | ||
|
|
21dae24b0a | ||
|
|
92eb399c33 | ||
|
|
b4590f2a21 | ||
|
|
ce72bb6b3a | ||
|
|
dfd113a6e5 | ||
|
|
3698d2356c | ||
|
|
ff20c4addc | ||
|
|
8eeeabb966 | ||
|
|
9b1dc5bd92 | ||
|
|
1cfff0a45f | ||
|
|
e7d00dec2d | ||
|
|
2c5748ccfd | ||
|
|
5196adce33 | ||
|
|
d6529a81e8 | ||
|
|
52e621bfa1 | ||
|
|
e110259167 | ||
|
|
8ef3a3b735 |
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": minor
|
||||
---
|
||||
|
||||
added zai-glm-4.6 as a Cerebras model
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Created GPT5 family specific system prompt template
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Telemtry change: collect domain of openai compatible endpoints when telemetry is enabled
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Requesty base URL, and API key fixes
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Delete all Auth Tokens when logging out
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Support for <think> tags for models that prefer that over <thinking>
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": minor
|
||||
---
|
||||
|
||||
This PR introduces a Python gRPC codegen flow analogous to the existing Go flow
|
||||
@@ -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
|
||||
|
||||
+109
-28
@@ -17,6 +17,37 @@ Hooks run automatically when enabled.
|
||||
|
||||
## Available Hooks
|
||||
|
||||
### TaskStart Hook
|
||||
- **When**: Runs when a NEW task is started (not when resuming)
|
||||
- **Purpose**: Initialize task context, validate task requirements, set up environment
|
||||
- **Global Location**: `~/Documents/Cline/Rules/Hooks/TaskStart` (all platforms)
|
||||
- **Workspace Location**: `.clinerules/hooks/TaskStart` (all platforms)
|
||||
|
||||
### TaskResume Hook
|
||||
- **When**: Runs when an EXISTING task is resumed (after user clicks resume button)
|
||||
- **Purpose**: Validate resumed task state, restore context, check for changes since last run
|
||||
- **Global Location**: `~/Documents/Cline/Rules/Hooks/TaskResume` (all platforms)
|
||||
- **Workspace Location**: `.clinerules/hooks/TaskResume` (all platforms)
|
||||
|
||||
### TaskCancel Hook
|
||||
- **When**: Runs when a task is cancelled by the user (only if there's actual active work or work was started)
|
||||
- **Purpose**: Clean up resources, log cancellation, save state
|
||||
- **Global Location**: `~/Documents/Cline/Rules/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/Rules/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/Rules/Hooks/UserPromptSubmit` (all platforms)
|
||||
- **Workspace Location**: `.clinerules/hooks/UserPromptSubmit` (all platforms)
|
||||
|
||||
### PreToolUse Hook
|
||||
- **When**: Runs BEFORE a tool is executed
|
||||
- **Purpose**: Validate parameters, block execution, or add context
|
||||
@@ -29,6 +60,12 @@ Hooks run automatically when enabled.
|
||||
- **Global Location**: `~/Documents/Cline/Rules/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/Rules/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:
|
||||
@@ -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
|
||||
@@ -250,26 +331,26 @@ Cline supports two levels of hooks:
|
||||
- **Location**: `~/Documents/Cline/Rules/Hooks/` (macOS/Linux) or `%USERPROFILE%\Documents\Cline\Rules\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
|
||||
|
||||
@@ -301,11 +382,11 @@ tool_name=$(echo "$input" | jq -r '.preToolUse.toolName')
|
||||
path=$(echo "$input" | jq -r '.preToolUse.parameters.path // ""')
|
||||
|
||||
if [[ "$tool_name" == "write_to_file" && "$path" == *"package.json"* ]]; then
|
||||
echo '{"shouldContinue": false, "errorMessage": "Global policy: Cannot modify package.json"}'
|
||||
echo '{"cancel": true, "errorMessage": "Global policy: Cannot modify package.json"}'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo '{"shouldContinue": true}'
|
||||
echo '{"cancel": false}'
|
||||
```
|
||||
|
||||
**Workspace Hook** (applies to specific project):
|
||||
@@ -318,11 +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
|
||||
@@ -1,5 +1,16 @@
|
||||
# Changelog
|
||||
|
||||
## 3.35.0
|
||||
|
||||
- Add native tool calling support with configurable setting.
|
||||
- Auto-approve is now always-on with a redesigned expanding menu. Settings simplified and notifications moved to General Settings.
|
||||
- added zai-glm-4.6 as a Cerebras model
|
||||
- Created GPT5 family specific system prompt template
|
||||
- Fix: show reasoning budget slider to models with valid thinking config
|
||||
- Requesty base URL, and API key fixes
|
||||
- Delete all Auth Tokens when logging out
|
||||
- Support for <think> tags for models that prefer that over <thinking>
|
||||
|
||||
## [3.34.1]
|
||||
|
||||
- Added support for MiniMax provider with MiniMax-M2 model
|
||||
|
||||
@@ -189,7 +189,7 @@ func renderAutoApprovalSettings(value interface{}, censor bool) error {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Print other fields normally (enabled, maxRequests, enableNotifications, favorites)
|
||||
// Print other fields normally (enabled, enableNotifications, favorites)
|
||||
fmt.Printf(" %s: %s\n", camelToKebab(key), formatValue(val, key, censor))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,8 +52,6 @@ func (h *AskHandler) Handle(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
return h.handleResumeCompletedTask(msg, dc)
|
||||
case string(types.AskTypeMistakeLimitReached):
|
||||
return h.handleMistakeLimitReached(msg, dc)
|
||||
case string(types.AskTypeAutoApprovalMaxReached):
|
||||
return h.handleAutoApprovalMaxReached(msg, dc)
|
||||
case string(types.AskTypeBrowserActionLaunch):
|
||||
return h.handleBrowserActionLaunch(msg, dc)
|
||||
case string(types.AskTypeUseMcpServer):
|
||||
@@ -255,25 +253,6 @@ func (h *AskHandler) handleMistakeLimitReached(msg *types.ClineMessage, dc *Disp
|
||||
return dc.Renderer.RenderMessage("ERROR", fmt.Sprintf("Mistake Limit Reached: %s. Approval required.", msg.Text), true)
|
||||
}
|
||||
|
||||
// handleAutoApprovalMaxReached handles auto-approval max reached
|
||||
func (h *AskHandler) handleAutoApprovalMaxReached(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
if dc.SystemRenderer != nil {
|
||||
details := make(map[string]string)
|
||||
if msg.Text != "" {
|
||||
details["reason"] = msg.Text
|
||||
}
|
||||
dc.SystemRenderer.RenderError(
|
||||
"warning",
|
||||
"Auto-Approval Limit Reached",
|
||||
"The maximum number of auto-approved requests has been reached. Manual approval is now required.",
|
||||
details,
|
||||
)
|
||||
fmt.Printf("\n**Approval required to continue.**\n")
|
||||
return nil
|
||||
}
|
||||
return dc.Renderer.RenderMessage("WARNING", fmt.Sprintf("Auto-approval limit reached: %s. Approval required.", msg.Text), true)
|
||||
}
|
||||
|
||||
// handleBrowserActionLaunch handles browser action launch requests
|
||||
func (h *AskHandler) handleBrowserActionLaunch(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
url := strings.TrimSpace(msg.Text)
|
||||
|
||||
@@ -282,7 +282,6 @@ func (m *Manager) CheckSendEnabled(ctx context.Context) error {
|
||||
errorTypes := []string{
|
||||
string(types.AskTypeAPIReqFailed), // "api_req_failed"
|
||||
string(types.AskTypeMistakeLimitReached), // "mistake_limit_reached"
|
||||
string(types.AskTypeAutoApprovalMaxReached), // "auto_approval_max_req_reached"
|
||||
}
|
||||
|
||||
isError := false
|
||||
@@ -1243,7 +1242,6 @@ func (m *Manager) UpdateTaskAutoApprovalAction(ctx context.Context, actionKey st
|
||||
|
||||
settings := &cline.Settings{
|
||||
AutoApprovalSettings: &cline.AutoApprovalSettings{
|
||||
Enabled: boolPtr(true),
|
||||
Actions: &cline.AutoApprovalActions{},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -416,18 +416,6 @@ func setNestedField(settings *cline.Settings, parentField string, childFields ma
|
||||
func setAutoApprovalSettings(settings *cline.AutoApprovalSettings, fields map[string]string) error {
|
||||
for key, value := range fields {
|
||||
switch key {
|
||||
case "enabled":
|
||||
val, err := parseBool(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
settings.Enabled = boolPtr(val)
|
||||
case "max_requests":
|
||||
val, err := parseInt32(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
settings.MaxRequests = int32Ptr(val)
|
||||
case "enable_notifications":
|
||||
val, err := parseBool(value)
|
||||
if err != nil {
|
||||
|
||||
@@ -37,17 +37,16 @@ const (
|
||||
type AskType string
|
||||
|
||||
const (
|
||||
AskTypeFollowup AskType = "followup"
|
||||
AskTypePlanModeRespond AskType = "plan_mode_respond"
|
||||
AskTypeCommand AskType = "command"
|
||||
AskTypeCommandOutput AskType = "command_output"
|
||||
AskTypeCompletionResult AskType = "completion_result"
|
||||
AskTypeTool AskType = "tool"
|
||||
AskTypeAPIReqFailed AskType = "api_req_failed"
|
||||
AskTypeResumeTask AskType = "resume_task"
|
||||
AskTypeResumeCompletedTask AskType = "resume_completed_task"
|
||||
AskTypeMistakeLimitReached AskType = "mistake_limit_reached"
|
||||
AskTypeAutoApprovalMaxReached AskType = "auto_approval_max_req_reached"
|
||||
AskTypeFollowup AskType = "followup"
|
||||
AskTypePlanModeRespond AskType = "plan_mode_respond"
|
||||
AskTypeCommand AskType = "command"
|
||||
AskTypeCommandOutput AskType = "command_output"
|
||||
AskTypeCompletionResult AskType = "completion_result"
|
||||
AskTypeTool AskType = "tool"
|
||||
AskTypeAPIReqFailed AskType = "api_req_failed"
|
||||
AskTypeResumeTask AskType = "resume_task"
|
||||
AskTypeResumeCompletedTask AskType = "resume_completed_task"
|
||||
AskTypeMistakeLimitReached AskType = "mistake_limit_reached"
|
||||
AskTypeBrowserActionLaunch AskType = "browser_action_launch"
|
||||
AskTypeUseMcpServer AskType = "use_mcp_server"
|
||||
AskTypeNewTask AskType = "new_task"
|
||||
@@ -247,8 +246,6 @@ func convertProtoAskType(askType cline.ClineAsk) string {
|
||||
return string(AskTypeResumeCompletedTask)
|
||||
case cline.ClineAsk_MISTAKE_LIMIT_REACHED:
|
||||
return string(AskTypeMistakeLimitReached)
|
||||
case cline.ClineAsk_AUTO_APPROVAL_MAX_REQ_REACHED:
|
||||
return string(AskTypeAutoApprovalMaxReached)
|
||||
case cline.ClineAsk_BROWSER_ACTION_LAUNCH:
|
||||
return string(AskTypeBrowserActionLaunch)
|
||||
case cline.ClineAsk_USE_MCP_SERVER:
|
||||
|
||||
Generated
+30
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "claude-dev",
|
||||
"version": "3.34.1",
|
||||
"version": "3.35.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "claude-dev",
|
||||
"version": "3.34.1",
|
||||
"version": "3.35.0",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.37.0",
|
||||
@@ -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",
|
||||
|
||||
+2
-1
@@ -2,7 +2,7 @@
|
||||
"name": "claude-dev",
|
||||
"displayName": "Cline",
|
||||
"description": "Autonomous coding agent right in your IDE, capable of creating/editing files, running commands, using the browser, and more with your permission every step of the way.",
|
||||
"version": "3.34.1",
|
||||
"version": "3.35.0",
|
||||
"icon": "assets/icons/icon.png",
|
||||
"engines": {
|
||||
"vscode": "^1.84.0"
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -94,6 +94,7 @@ message OpenRouterModelInfo {
|
||||
optional ThinkingConfig thinking_config = 10;
|
||||
optional bool supports_global_endpoint = 11;
|
||||
repeated ModelTier tiers = 12;
|
||||
optional string name = 13;
|
||||
}
|
||||
|
||||
// Shared response message for model information
|
||||
|
||||
+5
-10
@@ -46,11 +46,8 @@ message AutoApprovalActions {
|
||||
// Auto approval settings for task execution
|
||||
message AutoApprovalSettings {
|
||||
int32 version = 1;
|
||||
optional bool enabled = 2;
|
||||
AutoApprovalActions actions = 3;
|
||||
optional int32 max_requests = 4;
|
||||
optional bool enable_notifications = 5;
|
||||
repeated string favorites = 6;
|
||||
AutoApprovalActions actions = 2;
|
||||
optional bool enable_notifications = 3;
|
||||
}
|
||||
|
||||
message Secrets {
|
||||
@@ -283,11 +280,8 @@ message ResetStateRequest {
|
||||
message AutoApprovalSettingsRequest {
|
||||
Metadata metadata = 1;
|
||||
int32 version = 2;
|
||||
bool enabled = 3;
|
||||
AutoApprovalActions actions = 4;
|
||||
int32 max_requests = 5;
|
||||
bool enable_notifications = 6;
|
||||
repeated string favorites = 7;
|
||||
AutoApprovalActions actions = 3;
|
||||
bool enable_notifications = 4;
|
||||
}
|
||||
|
||||
enum TelemetrySettingEnum {
|
||||
@@ -356,6 +350,7 @@ message UpdateSettingsRequest {
|
||||
optional bool subagents_enabled = 29;
|
||||
optional int32 subagent_terminal_output_line_limit = 30;
|
||||
optional string cline_env = 31;
|
||||
optional bool native_tool_call_enabled = 32;
|
||||
}
|
||||
|
||||
message UpdateTerminalConnectionTimeoutRequest {
|
||||
|
||||
@@ -26,13 +26,12 @@ enum ClineAsk {
|
||||
RESUME_TASK = 7;
|
||||
RESUME_COMPLETED_TASK = 8;
|
||||
MISTAKE_LIMIT_REACHED = 9;
|
||||
AUTO_APPROVAL_MAX_REQ_REACHED = 10;
|
||||
BROWSER_ACTION_LAUNCH = 11;
|
||||
USE_MCP_SERVER = 12;
|
||||
NEW_TASK = 13;
|
||||
CONDENSE = 14;
|
||||
REPORT_BUG = 15;
|
||||
SUMMARIZE_TASK = 16;
|
||||
BROWSER_ACTION_LAUNCH = 10;
|
||||
USE_MCP_SERVER = 11;
|
||||
NEW_TASK = 12;
|
||||
CONDENSE = 13;
|
||||
REPORT_BUG = 14;
|
||||
SUMMARIZE_TASK = 15;
|
||||
}
|
||||
|
||||
// Enum for ClineSay types
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -807,9 +855,9 @@ export class Controller {
|
||||
const vscodeTerminalExecutionMode = this.stateManager.getGlobalStateKey("vscodeTerminalExecutionMode")
|
||||
const defaultTerminalProfile = this.stateManager.getGlobalSettingsKey("defaultTerminalProfile")
|
||||
const isNewUser = this.stateManager.getGlobalStateKey("isNewUser")
|
||||
const welcomeViewCompleted = Boolean(
|
||||
this.stateManager.getGlobalStateKey("welcomeViewCompleted") || this.authService.getInfo()?.user?.uid,
|
||||
)
|
||||
// Can be undefined but is set to either true or false by the migration that runs on extension launch in extension.ts
|
||||
const welcomeViewCompleted = !!this.stateManager.getGlobalStateKey("welcomeViewCompleted")
|
||||
|
||||
const customPrompt = this.stateManager.getGlobalSettingsKey("customPrompt")
|
||||
const mcpResponsesCollapsed = this.stateManager.getGlobalStateKey("mcpResponsesCollapsed")
|
||||
const terminalOutputLineLimit = this.stateManager.getGlobalSettingsKey("terminalOutputLineLimit")
|
||||
@@ -886,7 +934,7 @@ export class Controller {
|
||||
vscodeTerminalExecutionMode: vscodeTerminalExecutionMode,
|
||||
defaultTerminalProfile,
|
||||
isNewUser,
|
||||
welcomeViewCompleted: welcomeViewCompleted as boolean, // Can be undefined but is set to either true or false by the migration that runs on extension launch in extension.ts
|
||||
welcomeViewCompleted,
|
||||
mcpResponsesCollapsed,
|
||||
terminalOutputLineLimit,
|
||||
maxConsecutiveMistakes,
|
||||
@@ -915,6 +963,10 @@ export class Controller {
|
||||
remoteConfigSettings: this.stateManager.getRemoteConfigSettings(),
|
||||
lastDismissedCliBannerVersion,
|
||||
subagentsEnabled,
|
||||
nativeToolCallSetting: {
|
||||
user: this.stateManager.getGlobalStateKey("nativeToolCallEnabled"),
|
||||
featureFlag: featureFlagsService.getNativeToolCallEnabled(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { ensureCacheDirectoryExists, GlobalFileNames } from "@core/storage/disk"
|
||||
import { ModelInfo } from "@shared/api"
|
||||
import type { ModelInfo } from "@shared/api"
|
||||
import axios from "axios"
|
||||
import cloneDeep from "clone-deep"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { CLAUDE_SONNET_1M_TIERS, openRouterClaudeSonnet41mModelId, openRouterClaudeSonnet451mModelId } from "@/shared/api"
|
||||
import { Controller } from ".."
|
||||
import type { Controller } from ".."
|
||||
|
||||
type OpenRouterSupportedParams =
|
||||
| "frequency_penalty"
|
||||
@@ -59,7 +59,7 @@ interface OpenRouterRawModelInfo {
|
||||
input_cache_read: string
|
||||
input_cache_write: string
|
||||
} | null
|
||||
thinking_config: any | null
|
||||
thinking_config: Record<string, unknown> | null
|
||||
supports_global_endpoint: boolean | null
|
||||
tiers: any[] | null
|
||||
supported_parameters?: OpenRouterSupportedParams[] | null
|
||||
@@ -88,6 +88,7 @@ export async function refreshOpenRouterModels(controller: Controller): Promise<R
|
||||
for (const rawModel of rawModels as OpenRouterRawModelInfo[]) {
|
||||
const supportThinking = rawModel.supported_parameters?.some((p) => p === "include_reasoning")
|
||||
const modelInfo: ModelInfo = {
|
||||
name: rawModel.name,
|
||||
maxTokens: rawModel.top_provider?.max_completion_tokens ?? 0,
|
||||
contextWindow: rawModel.context_length ?? 0,
|
||||
supportsImages: rawModel.architecture?.modality?.includes("image") ?? false,
|
||||
@@ -97,7 +98,7 @@ export async function refreshOpenRouterModels(controller: Controller): Promise<R
|
||||
cacheWritesPrice: parsePrice(rawModel.pricing?.input_cache_write),
|
||||
cacheReadsPrice: parsePrice(rawModel.pricing?.input_cache_read),
|
||||
description: rawModel.description ?? "",
|
||||
thinkingConfig: supportThinking ? (rawModel.thinking_config ?? {}) : undefined,
|
||||
thinkingConfig: (supportThinking && rawModel.thinking_config) || undefined,
|
||||
supportsGlobalEndpoint: rawModel.supports_global_endpoint ?? undefined,
|
||||
tiers: rawModel.tiers ?? undefined,
|
||||
}
|
||||
|
||||
@@ -19,10 +19,7 @@ export async function updateAutoApprovalSettings(controller: Controller, request
|
||||
const settings = {
|
||||
...currentSettings,
|
||||
...(request.version !== undefined && { version: request.version }),
|
||||
...(request.enabled !== undefined && { enabled: request.enabled }),
|
||||
...(request.maxRequests !== undefined && { maxRequests: request.maxRequests }),
|
||||
...(request.enableNotifications !== undefined && { enableNotifications: request.enableNotifications }),
|
||||
...(request.favorites && request.favorites.length > 0 && { favorites: request.favorites }),
|
||||
actions: {
|
||||
...currentSettings.actions,
|
||||
...(request.actions
|
||||
@@ -31,16 +28,6 @@ export async function updateAutoApprovalSettings(controller: Controller, request
|
||||
},
|
||||
}
|
||||
|
||||
if (controller.task) {
|
||||
const maxRequestsChanged =
|
||||
controller.stateManager.getGlobalSettingsKey("autoApprovalSettings").maxRequests !== settings.maxRequests
|
||||
|
||||
// Reset counter if max requests limit changed
|
||||
if (maxRequestsChanged) {
|
||||
controller.task.resetConsecutiveAutoApprovedRequestsCount()
|
||||
}
|
||||
}
|
||||
|
||||
controller.stateManager.setGlobalState("autoApprovalSettings", settings)
|
||||
|
||||
await controller.postStateToWebview()
|
||||
|
||||
@@ -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) {
|
||||
@@ -349,6 +356,10 @@ export async function updateSettings(controller: Controller, request: UpdateSett
|
||||
controller.stateManager.setGlobalState("subagentsEnabled", !!request.subagentsEnabled)
|
||||
}
|
||||
|
||||
if (request.nativeToolCallEnabled !== undefined) {
|
||||
controller.stateManager.setGlobalState("nativeToolCallEnabled", !!request.nativeToolCallEnabled)
|
||||
}
|
||||
|
||||
// Post updated state to webview
|
||||
await controller.postStateToWebview()
|
||||
|
||||
|
||||
@@ -87,13 +87,9 @@ export async function updateSettingsCli(controller: Controller, request: UpdateS
|
||||
const mergedSettings = {
|
||||
...currentAutoApprovalSettings,
|
||||
...(autoApprovalSettings.version !== undefined && { version: autoApprovalSettings.version }),
|
||||
...(autoApprovalSettings.enabled !== undefined && { enabled: autoApprovalSettings.enabled }),
|
||||
...(autoApprovalSettings.maxRequests !== undefined && { maxRequests: autoApprovalSettings.maxRequests }),
|
||||
...(autoApprovalSettings.enableNotifications !== undefined && {
|
||||
enableNotifications: autoApprovalSettings.enableNotifications,
|
||||
}),
|
||||
...(autoApprovalSettings.favorites &&
|
||||
autoApprovalSettings.favorites.length > 0 && { favorites: autoApprovalSettings.favorites }),
|
||||
actions: {
|
||||
...currentAutoApprovalSettings.actions,
|
||||
...(autoApprovalSettings.actions
|
||||
|
||||
@@ -76,13 +76,9 @@ export async function updateTaskSettings(controller: Controller, request: Update
|
||||
const mergedSettings = {
|
||||
...currentAutoApprovalSettings,
|
||||
...(autoApprovalSettings.version !== undefined && { version: autoApprovalSettings.version }),
|
||||
...(autoApprovalSettings.enabled !== undefined && { enabled: autoApprovalSettings.enabled }),
|
||||
...(autoApprovalSettings.maxRequests !== undefined && { maxRequests: autoApprovalSettings.maxRequests }),
|
||||
...(autoApprovalSettings.enableNotifications !== undefined && {
|
||||
enableNotifications: autoApprovalSettings.enableNotifications,
|
||||
}),
|
||||
...(autoApprovalSettings.favorites &&
|
||||
autoApprovalSettings.favorites.length > 0 && { favorites: autoApprovalSettings.favorites }),
|
||||
actions: {
|
||||
...currentAutoApprovalSettings.actions,
|
||||
...(autoApprovalSettings.actions
|
||||
|
||||
@@ -43,13 +43,9 @@ export async function newTask(controller: Controller, request: NewTaskRequest):
|
||||
return {
|
||||
...globalSettings,
|
||||
...(incomingSettings.version !== undefined && { version: incomingSettings.version }),
|
||||
...(incomingSettings.enabled !== undefined && { enabled: incomingSettings.enabled }),
|
||||
...(incomingSettings.maxRequests !== undefined && { maxRequests: incomingSettings.maxRequests }),
|
||||
...(incomingSettings.enableNotifications !== undefined && {
|
||||
enableNotifications: incomingSettings.enableNotifications,
|
||||
}),
|
||||
...(incomingSettings.favorites &&
|
||||
incomingSettings.favorites.length > 0 && { favorites: incomingSettings.favorites }),
|
||||
actions: {
|
||||
...globalSettings.actions,
|
||||
...(incomingSettings.actions
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
}
|
||||
}
|
||||
+312
-71
@@ -1,4 +1,3 @@
|
||||
import { spawn } from "node:child_process"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { version as clineVersion } from "../../../package.json"
|
||||
@@ -17,6 +16,8 @@ import {
|
||||
} from "../../shared/proto/cline/hooks"
|
||||
import { getAllHooksDirs } from "../storage/disk"
|
||||
import { StateManager } from "../storage/StateManager"
|
||||
import { HookExecutionError } from "./HookError"
|
||||
import { HookProcess } from "./HookProcess"
|
||||
|
||||
// Hook execution timeout (30 seconds)
|
||||
const HOOK_EXECUTION_TIMEOUT_MS = 30000
|
||||
@@ -24,6 +25,73 @@ const HOOK_EXECUTION_TIMEOUT_MS = 30000
|
||||
// Maximum size for context modification (to prevent prompt overflow)
|
||||
const MAX_CONTEXT_MODIFICATION_SIZE = 50000 // ~50KB
|
||||
|
||||
/**
|
||||
* Validates hook output JSON structure.
|
||||
* Ensures required fields are present and have correct types.
|
||||
*/
|
||||
function validateHookOutput(output: any): { valid: boolean; error?: string } {
|
||||
// Check if deprecated shouldContinue field is present
|
||||
if (output.shouldContinue !== undefined) {
|
||||
return {
|
||||
valid: false,
|
||||
error:
|
||||
"Invalid hook output: The 'shouldContinue' field has been removed.\n\n" +
|
||||
"Use 'cancel: true' instead to trigger task cancellation.\n\n" +
|
||||
"Migration guide:\n" +
|
||||
" Before: { shouldContinue: false, errorMessage: '...' }\n" +
|
||||
" After: { cancel: true, errorMessage: '...' }\n\n" +
|
||||
"Example valid response:\n" +
|
||||
JSON.stringify(
|
||||
{
|
||||
cancel: false,
|
||||
contextModification: "Optional context here",
|
||||
errorMessage: "",
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
// cancel is optional, but if provided must be a boolean
|
||||
if (output.cancel !== undefined && typeof output.cancel !== "boolean") {
|
||||
return {
|
||||
valid: false,
|
||||
error:
|
||||
"Invalid hook output: 'cancel' must be a boolean.\n\n" +
|
||||
`Received type: ${typeof output.cancel}\n\n` +
|
||||
"Example valid response:\n" +
|
||||
JSON.stringify({ cancel: true, errorMessage: "Cancelling task" }, null, 2),
|
||||
}
|
||||
}
|
||||
|
||||
// contextModification is optional, but if provided must be a string
|
||||
if (output.contextModification !== undefined && typeof output.contextModification !== "string") {
|
||||
return {
|
||||
valid: false,
|
||||
error:
|
||||
"Invalid hook output: 'contextModification' must be a string.\n\n" +
|
||||
`Received type: ${typeof output.contextModification}\n\n` +
|
||||
"Example valid response:\n" +
|
||||
JSON.stringify({ contextModification: "Context here" }, null, 2),
|
||||
}
|
||||
}
|
||||
|
||||
// errorMessage is optional, but if provided must be a string
|
||||
if (output.errorMessage !== undefined && typeof output.errorMessage !== "string") {
|
||||
return {
|
||||
valid: false,
|
||||
error:
|
||||
"Invalid hook output: 'errorMessage' must be a string.\n\n" +
|
||||
`Received type: ${typeof output.errorMessage}\n\n` +
|
||||
"Example valid response:\n" +
|
||||
JSON.stringify({ cancel: true, errorMessage: "Error description" }, null, 2),
|
||||
}
|
||||
}
|
||||
|
||||
return { valid: true }
|
||||
}
|
||||
|
||||
export interface Hooks {
|
||||
PreToolUse: {
|
||||
preToolUse: PreToolUseData
|
||||
@@ -94,8 +162,23 @@ export abstract class HookRunner<Name extends HookName> {
|
||||
|
||||
abstract [exec](params: HookInput): Promise<HookOutput>
|
||||
|
||||
// Completes the hook input parameters by adding the common hook parameters to the
|
||||
// hook-specific parameters provided by the caller.
|
||||
/**
|
||||
* Completes the hook input by adding common metadata to caller-provided parameters.
|
||||
*
|
||||
* This method enriches the hook-specific input (like preToolUse or postToolUse data)
|
||||
* with standard information that all hooks receive:
|
||||
* - clineVersion: Current Cline extension version
|
||||
* - hookName: The type of hook being executed (e.g., "PreToolUse")
|
||||
* - timestamp: Execution time in milliseconds since epoch
|
||||
* - workspaceRoots: Array of workspace folder paths
|
||||
* - userId: Cline user ID, machine ID, or generated UUID
|
||||
*
|
||||
* This separation allows hook scripts to receive consistent metadata without
|
||||
* requiring callers to manually provide it each time.
|
||||
*
|
||||
* @param params The hook-specific input parameters (taskId + hook data)
|
||||
* @returns Complete HookInput ready to be serialized and sent to the hook script
|
||||
*/
|
||||
protected async completeParams(params: NamedHookInput<Name>): Promise<HookInput> {
|
||||
const workspaceRoots =
|
||||
StateManager.get()
|
||||
@@ -112,73 +195,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/Rules/Hooks/)
|
||||
* and workspace-specific hooks (from each workspace's .clinerules/hooks/) exist for the
|
||||
* same hook type.
|
||||
*
|
||||
* Behavior:
|
||||
* - Executes all hooks concurrently using Promise.all
|
||||
* - If ANY hook returns cancel: true, the merged result will have cancel: true
|
||||
* - Concatenates all contextModification strings with double newlines
|
||||
* - Concatenates all errorMessage strings with single newlines
|
||||
*
|
||||
* This means if ANY hook requests cancellation, the task will be cancelled.
|
||||
* All hooks' context contributions are merged into the conversation.
|
||||
*
|
||||
* @template Name The type of hook this runner represents
|
||||
*/
|
||||
class CombinedHookRunner<Name extends HookName> extends HookRunner<Name> {
|
||||
constructor(
|
||||
hookName: Name,
|
||||
@@ -228,11 +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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -42,9 +42,6 @@ Otherwise, if you have not completed the task and do not need additional informa
|
||||
tooManyMistakes: (feedback?: string) =>
|
||||
`You seem to be having trouble proceeding. The user has provided the following feedback to help guide you:\n<feedback>\n${feedback}\n</feedback>`,
|
||||
|
||||
autoApprovalMaxReached: (feedback?: string) =>
|
||||
`Auto-approval limit reached. The user has provided the following feedback to help guide you:\n<feedback>\n${feedback}\n</feedback>`,
|
||||
|
||||
missingToolParameterError: (paramName: string) =>
|
||||
`Missing value for required parameter '${paramName}'. Please retry with complete response.\n\n${toolUseInstructionsReminder}`,
|
||||
|
||||
|
||||
@@ -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"
|
||||
@@ -468,6 +469,8 @@ export async function readGlobalStateFromDisk(context: ExtensionContext): Promis
|
||||
*/
|
||||
const primaryRootIndex = context.globalState.get<GlobalStateAndSettings["primaryRootIndex"]>("primaryRootIndex")
|
||||
const multiRootEnabled = context.globalState.get<GlobalStateAndSettings["multiRootEnabled"]>("multiRootEnabled")
|
||||
const nativeToolCallEnabled =
|
||||
context.globalState.get<GlobalStateAndSettings["nativeToolCallEnabled"]>("nativeToolCallEnabled")
|
||||
|
||||
return {
|
||||
// api configuration fields
|
||||
@@ -617,12 +620,13 @@ 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,
|
||||
lastDismissedCliBannerVersion: lastDismissedCliBannerVersion ?? 0,
|
||||
nativeToolCallEnabled: nativeToolCallEnabled ?? false,
|
||||
// Multi-root workspace support
|
||||
workspaceRoots,
|
||||
primaryRootIndex: primaryRootIndex ?? 0,
|
||||
|
||||
@@ -39,9 +39,6 @@ export class TaskState {
|
||||
didAlreadyUseTool = false
|
||||
didEditFile: boolean = false
|
||||
|
||||
// Consecutive request tracking
|
||||
consecutiveAutoApprovedRequestsCount: number = 0
|
||||
|
||||
// Error tracking
|
||||
consecutiveMistakeCount: number = 0
|
||||
didAutomaticallyRetryFailedApiRequest = false
|
||||
@@ -64,6 +61,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
|
||||
|
||||
+430
-247
@@ -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,13 +536,13 @@ 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),
|
||||
)
|
||||
}
|
||||
|
||||
public resetConsecutiveAutoApprovedRequestsCount(): void {
|
||||
this.taskState.consecutiveAutoApprovedRequestsCount = 0
|
||||
}
|
||||
|
||||
// Communicate with webview
|
||||
|
||||
// partial has three valid states true (partial message), false (completion of partial message), undefined (individual complete message)
|
||||
@@ -515,8 +557,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 +692,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 +806,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 +904,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 +918,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 +1043,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 +1070,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 +1119,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 +1141,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 +1223,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)
|
||||
}
|
||||
@@ -1130,7 +1258,6 @@ export class Task {
|
||||
includeFileDetails = false // we only need file details the first time
|
||||
|
||||
// The way this agentic loop works is that cline will be given a task that he then calls tools to complete. unless there's an attempt_completion call, we keep responding back to him with his tool's responses until he either attempt_completion or does not use anymore tools. If he does not use anymore tools, we ask him to consider if he's completed the task and then call attempt_completion, otherwise proceed with completing the task.
|
||||
// There is a MAX_REQUESTS_PER_TASK limit to prevent infinite requests, but Cline is prompted to finish the task as efficiently as he can.
|
||||
|
||||
//const totalCost = this.calculateApiCost(totalInputTokens, totalOutputTokens)
|
||||
if (didEndLoop) {
|
||||
@@ -1153,72 +1280,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 +1447,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 +1913,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
|
||||
*/
|
||||
@@ -1840,7 +2094,8 @@ export class Task {
|
||||
workspaceRoots,
|
||||
isSubagentsEnabledAndCliInstalled,
|
||||
isCliSubagent,
|
||||
enableNativeToolCalls: featureFlagsService.getNativeToolCallEnabled(),
|
||||
enableNativeToolCalls:
|
||||
featureFlagsService.getNativeToolCallEnabled() && this.stateManager.getGlobalStateKey("nativeToolCallEnabled"),
|
||||
}
|
||||
|
||||
const { systemPrompt, tools } = await getSystemPrompt(promptContext)
|
||||
@@ -2149,8 +2404,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
|
||||
@@ -2171,7 +2427,7 @@ export class Task {
|
||||
|
||||
if (this.taskState.consecutiveMistakeCount >= this.stateManager.getGlobalSettingsKey("maxConsecutiveMistakes")) {
|
||||
const autoApprovalSettings = this.stateManager.getGlobalSettingsKey("autoApprovalSettings")
|
||||
if (autoApprovalSettings.enabled && autoApprovalSettings.enableNotifications) {
|
||||
if (autoApprovalSettings.enableNotifications) {
|
||||
showSystemNotification({
|
||||
subtitle: "Error",
|
||||
message: "Cline is having trouble. Would you like to continue the task?",
|
||||
@@ -2215,72 +2471,12 @@ export class Task {
|
||||
this.taskState.autoRetryAttempts = 0 // need to reset this if the user chooses to manually retry after the mistake limit is reached
|
||||
}
|
||||
|
||||
const autoApprovalSettings = this.stateManager.getGlobalSettingsKey("autoApprovalSettings")
|
||||
|
||||
if (
|
||||
!this.stateManager.getGlobalSettingsKey("yoloModeToggled") &&
|
||||
autoApprovalSettings.enabled &&
|
||||
this.taskState.consecutiveAutoApprovedRequestsCount >= autoApprovalSettings.maxRequests
|
||||
) {
|
||||
if (autoApprovalSettings.enableNotifications) {
|
||||
showSystemNotification({
|
||||
subtitle: "Max Requests Reached",
|
||||
message: `Cline has auto-approved ${autoApprovalSettings.maxRequests.toString()} API requests.`,
|
||||
})
|
||||
}
|
||||
const { response, text, images, files } = await this.ask(
|
||||
"auto_approval_max_req_reached",
|
||||
`Cline has auto-approved ${autoApprovalSettings.maxRequests.toString()} API requests. Would you like to reset the count and proceed with the task?`,
|
||||
)
|
||||
// if we get past the promise it means the user approved and did not start a new task
|
||||
this.taskState.consecutiveAutoApprovedRequestsCount = 0
|
||||
|
||||
// Process user feedback if provided
|
||||
if (response === "messageResponse") {
|
||||
// Display the user's message in the chat UI
|
||||
await this.say("user_feedback", text, images, files)
|
||||
|
||||
// This userContent is for the *next* API call.
|
||||
const feedbackUserContent: UserContent = []
|
||||
feedbackUserContent.push({
|
||||
type: "text",
|
||||
text: formatResponse.autoApprovalMaxReached(text),
|
||||
})
|
||||
if (images && images.length > 0) {
|
||||
feedbackUserContent.push(...formatResponse.imageBlocks(images))
|
||||
}
|
||||
|
||||
let fileContentString = ""
|
||||
if (files && files.length > 0) {
|
||||
fileContentString = await processFilesIntoText(files)
|
||||
}
|
||||
|
||||
if (fileContentString) {
|
||||
feedbackUserContent.push({
|
||||
type: "text",
|
||||
text: fileContentString,
|
||||
})
|
||||
}
|
||||
|
||||
userContent = feedbackUserContent
|
||||
}
|
||||
}
|
||||
|
||||
// get previous api req's index to check token usage and determine if we need to truncate conversation history
|
||||
const previousApiReqIndex = findLastIndex(this.messageStateHandler.getClineMessages(), (m) => m.say === "api_req_started")
|
||||
|
||||
// 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 +2650,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()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,30 +62,28 @@ export class AutoApprove {
|
||||
|
||||
const autoApprovalSettings = this.stateManager.getGlobalSettingsKey("autoApprovalSettings")
|
||||
|
||||
if (autoApprovalSettings.enabled) {
|
||||
switch (toolName) {
|
||||
case ClineDefaultTool.FILE_READ:
|
||||
case ClineDefaultTool.LIST_FILES:
|
||||
case ClineDefaultTool.LIST_CODE_DEF:
|
||||
case ClineDefaultTool.SEARCH:
|
||||
return [autoApprovalSettings.actions.readFiles, autoApprovalSettings.actions.readFilesExternally ?? false]
|
||||
case ClineDefaultTool.NEW_RULE:
|
||||
case ClineDefaultTool.FILE_NEW:
|
||||
case ClineDefaultTool.FILE_EDIT:
|
||||
return [autoApprovalSettings.actions.editFiles, autoApprovalSettings.actions.editFilesExternally ?? false]
|
||||
case ClineDefaultTool.BASH:
|
||||
return [
|
||||
autoApprovalSettings.actions.executeSafeCommands ?? false,
|
||||
autoApprovalSettings.actions.executeAllCommands ?? false,
|
||||
]
|
||||
case ClineDefaultTool.BROWSER:
|
||||
return autoApprovalSettings.actions.useBrowser
|
||||
case ClineDefaultTool.WEB_FETCH:
|
||||
return autoApprovalSettings.actions.useBrowser
|
||||
case ClineDefaultTool.MCP_ACCESS:
|
||||
case ClineDefaultTool.MCP_USE:
|
||||
return autoApprovalSettings.actions.useMcp
|
||||
}
|
||||
switch (toolName) {
|
||||
case ClineDefaultTool.FILE_READ:
|
||||
case ClineDefaultTool.LIST_FILES:
|
||||
case ClineDefaultTool.LIST_CODE_DEF:
|
||||
case ClineDefaultTool.SEARCH:
|
||||
return [autoApprovalSettings.actions.readFiles, autoApprovalSettings.actions.readFilesExternally ?? false]
|
||||
case ClineDefaultTool.NEW_RULE:
|
||||
case ClineDefaultTool.FILE_NEW:
|
||||
case ClineDefaultTool.FILE_EDIT:
|
||||
return [autoApprovalSettings.actions.editFiles, autoApprovalSettings.actions.editFilesExternally ?? false]
|
||||
case ClineDefaultTool.BASH:
|
||||
return [
|
||||
autoApprovalSettings.actions.executeSafeCommands ?? false,
|
||||
autoApprovalSettings.actions.executeAllCommands ?? false,
|
||||
]
|
||||
case ClineDefaultTool.BROWSER:
|
||||
return autoApprovalSettings.actions.useBrowser
|
||||
case ClineDefaultTool.WEB_FETCH:
|
||||
return autoApprovalSettings.actions.useBrowser
|
||||
case ClineDefaultTool.MCP_ACCESS:
|
||||
case ClineDefaultTool.MCP_USE:
|
||||
return autoApprovalSettings.actions.useMcp
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { ClineAsk, ClineAskUseMcpServer } from "@shared/ExtensionMessage"
|
||||
import { telemetryService } from "@/services/telemetry"
|
||||
import { ClineDefaultTool } from "@/shared/tools"
|
||||
import type { ToolResponse } from "../../index"
|
||||
import { showNotificationForApprovalIfAutoApprovalEnabled } from "../../utils"
|
||||
import { showNotificationForApproval } from "../../utils"
|
||||
import type { IFullyManagedTool } from "../ToolExecutorCoordinator"
|
||||
import type { TaskConfig } from "../types/TaskConfig"
|
||||
import type { StronglyTypedUIHelpers } from "../types/UIHelpers"
|
||||
@@ -73,9 +73,6 @@ export class AccessMcpResourceHandler implements IFullyManagedTool {
|
||||
// Auto-approval flow
|
||||
await config.callbacks.removeLastPartialMessageIfExistsWithType("ask", "use_mcp_server")
|
||||
await config.callbacks.say("use_mcp_server", completeMessage, undefined, undefined, false)
|
||||
if (!config.yoloModeToggled) {
|
||||
config.taskState.consecutiveAutoApprovedRequestsCount++
|
||||
}
|
||||
|
||||
// Capture telemetry
|
||||
telemetryService.captureToolUsage(config.ulid, block.name, config.api.getModel().id, true, true)
|
||||
@@ -84,11 +81,7 @@ export class AccessMcpResourceHandler implements IFullyManagedTool {
|
||||
const notificationMessage = `Cline wants to access ${uri || "unknown resource"} on ${server_name || "unknown server"}`
|
||||
|
||||
// Show notification
|
||||
showNotificationForApprovalIfAutoApprovalEnabled(
|
||||
notificationMessage,
|
||||
config.autoApprovalSettings.enabled,
|
||||
config.autoApprovalSettings.enableNotifications,
|
||||
)
|
||||
showNotificationForApproval(notificationMessage, config.autoApprovalSettings.enableNotifications)
|
||||
|
||||
await config.callbacks.removeLastPartialMessageIfExistsWithType("say", "use_mcp_server")
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import { telemetryService } from "@/services/telemetry"
|
||||
import { ClineDefaultTool } from "@/shared/tools"
|
||||
import { isLocatedInWorkspace } from "@/utils/path"
|
||||
import type { ToolResponse } from "../../index"
|
||||
import { showNotificationForApprovalIfAutoApprovalEnabled } from "../../utils"
|
||||
import { showNotificationForApproval } from "../../utils"
|
||||
import type { IFullyManagedTool } from "../ToolExecutorCoordinator"
|
||||
import type { ToolValidator } from "../ToolValidator"
|
||||
import type { TaskConfig } from "../types/TaskConfig"
|
||||
@@ -946,15 +946,13 @@ export class ApplyPatchHandler implements IFullyManagedTool {
|
||||
|
||||
if (shouldAutoApprove) {
|
||||
await config.callbacks.say("tool", messageStr, undefined, undefined, false)
|
||||
config.taskState.consecutiveAutoApprovedRequestsCount++
|
||||
telemetryService.captureToolUsage(config.ulid, block.name, config.api.getModel().id, true, true)
|
||||
return true
|
||||
}
|
||||
|
||||
const fileCount = Object.keys(JSON.parse(messageStr).content.match(/\d+/)?.[0] || "0").length
|
||||
showNotificationForApprovalIfAutoApprovalEnabled(
|
||||
showNotificationForApproval(
|
||||
`Cline wants to apply a patch to ${fileCount} file(s)`,
|
||||
config.autoApprovalSettings.enabled,
|
||||
config.autoApprovalSettings.enableNotifications,
|
||||
)
|
||||
|
||||
|
||||
@@ -40,8 +40,8 @@ export class AskFollowupQuestionToolHandler implements IToolHandler, IPartialBlo
|
||||
}
|
||||
config.taskState.consecutiveMistakeCount = 0
|
||||
|
||||
// Show notification if auto-approval is enabled
|
||||
if (config.autoApprovalSettings.enabled && config.autoApprovalSettings.enableNotifications) {
|
||||
// Show notification if enabled
|
||||
if (config.autoApprovalSettings.enableNotifications) {
|
||||
showSystemNotification({
|
||||
subtitle: "Cline has a question...",
|
||||
message: question.replace(/\n/g, " "),
|
||||
|
||||
@@ -52,8 +52,8 @@ export class AttemptCompletionHandler implements IToolHandler, IPartialBlockHand
|
||||
|
||||
config.taskState.consecutiveMistakeCount = 0
|
||||
|
||||
// Show notification if auto-approval is enabled
|
||||
if (config.autoApprovalSettings.enabled && config.autoApprovalSettings.enableNotifications) {
|
||||
// Show notification if enabled
|
||||
if (config.autoApprovalSettings.enableNotifications) {
|
||||
showSystemNotification({
|
||||
subtitle: "Task Completed",
|
||||
message: result.replace(/\n/g, " "),
|
||||
@@ -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()
|
||||
|
||||
@@ -3,7 +3,7 @@ import { ClineDefaultTool } from "@/shared/tools"
|
||||
import { ToolUse } from "../../../assistant-message"
|
||||
import { formatResponse } from "../../../prompts/responses"
|
||||
import { ToolResponse } from "../.."
|
||||
import { showNotificationForApprovalIfAutoApprovalEnabled } from "../../utils"
|
||||
import { showNotificationForApproval } from "../../utils"
|
||||
import type { IFullyManagedTool } from "../ToolExecutorCoordinator"
|
||||
import type { TaskConfig } from "../types/TaskConfig"
|
||||
import type { StronglyTypedUIHelpers } from "../types/UIHelpers"
|
||||
@@ -92,14 +92,10 @@ export class BrowserToolHandler implements IFullyManagedTool {
|
||||
if (autoApprover.shouldAutoApproveTool(block.name)) {
|
||||
await config.callbacks.removeLastPartialMessageIfExistsWithType("ask", "browser_action_launch")
|
||||
await config.callbacks.say("browser_action_launch", url, undefined, undefined, false)
|
||||
if (!config.yoloModeToggled) {
|
||||
config.taskState.consecutiveAutoApprovedRequestsCount++
|
||||
}
|
||||
} else {
|
||||
// Show notification for approval if auto approval enabled
|
||||
showNotificationForApprovalIfAutoApprovalEnabled(
|
||||
// Show notification for approval if enabled
|
||||
showNotificationForApproval(
|
||||
`Cline wants to use a browser and launch ${url}`,
|
||||
config.autoApprovalSettings.enabled,
|
||||
config.autoApprovalSettings.enableNotifications,
|
||||
)
|
||||
await config.callbacks.removeLastPartialMessageIfExistsWithType("say", "browser_action_launch")
|
||||
|
||||
@@ -30,8 +30,8 @@ export class CondenseHandler implements IToolHandler, IPartialBlockHandler {
|
||||
|
||||
config.taskState.consecutiveMistakeCount = 0
|
||||
|
||||
// Show notification if auto-approval is enabled
|
||||
if (config.autoApprovalSettings.enabled && config.autoApprovalSettings.enableNotifications) {
|
||||
// Show notification if enabled
|
||||
if (config.autoApprovalSettings.enableNotifications) {
|
||||
showSystemNotification({
|
||||
subtitle: "Cline wants to condense the conversation...",
|
||||
message: `Cline is suggesting to condense your conversation with: ${context}`,
|
||||
|
||||
@@ -9,7 +9,7 @@ import { fixModelHtmlEscaping } from "@utils/string"
|
||||
import { telemetryService } from "@/services/telemetry"
|
||||
import { ClineDefaultTool } from "@/shared/tools"
|
||||
import type { ToolResponse } from "../../index"
|
||||
import { showNotificationForApprovalIfAutoApprovalEnabled } from "../../utils"
|
||||
import { showNotificationForApproval } from "../../utils"
|
||||
import type { IFullyManagedTool } from "../ToolExecutorCoordinator"
|
||||
import type { ToolValidator } from "../ToolValidator"
|
||||
import type { TaskConfig } from "../types/TaskConfig"
|
||||
@@ -153,16 +153,12 @@ export class ExecuteCommandToolHandler implements IFullyManagedTool {
|
||||
// Auto-approve flow
|
||||
await config.callbacks.removeLastPartialMessageIfExistsWithType("ask", "command")
|
||||
await config.callbacks.say("command", actualCommand, undefined, undefined, false)
|
||||
if (!config.yoloModeToggled) {
|
||||
config.taskState.consecutiveAutoApprovedRequestsCount++
|
||||
}
|
||||
didAutoApprove = true
|
||||
telemetryService.captureToolUsage(config.ulid, block.name, config.api.getModel().id, true, true, workspaceContext)
|
||||
} else {
|
||||
// Manual approval flow
|
||||
showNotificationForApprovalIfAutoApprovalEnabled(
|
||||
showNotificationForApproval(
|
||||
`Cline wants to execute a command: ${actualCommand}`,
|
||||
config.autoApprovalSettings.enabled,
|
||||
config.autoApprovalSettings.enableNotifications,
|
||||
)
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import { formatResponse } from "@/core/prompts/responses"
|
||||
import { telemetryService } from "@/services/telemetry"
|
||||
import { ClineDefaultTool } from "@/shared/tools"
|
||||
import type { ToolResponse } from "../../index"
|
||||
import { showNotificationForApprovalIfAutoApprovalEnabled } from "../../utils"
|
||||
import { showNotificationForApproval } from "../../utils"
|
||||
import type { IFullyManagedTool } from "../ToolExecutorCoordinator"
|
||||
import type { ToolValidator } from "../ToolValidator"
|
||||
import type { TaskConfig } from "../types/TaskConfig"
|
||||
@@ -81,9 +81,6 @@ export class ListCodeDefinitionNamesToolHandler implements IFullyManagedTool {
|
||||
// Auto-approval flow
|
||||
await config.callbacks.removeLastPartialMessageIfExistsWithType("ask", "tool")
|
||||
await config.callbacks.say("tool", completeMessage, undefined, undefined, false)
|
||||
if (!config.yoloModeToggled) {
|
||||
config.taskState.consecutiveAutoApprovedRequestsCount++
|
||||
}
|
||||
|
||||
// Capture telemetry
|
||||
telemetryService.captureToolUsage(config.ulid, block.name, config.api.getModel().id, true, true)
|
||||
@@ -92,11 +89,7 @@ export class ListCodeDefinitionNamesToolHandler implements IFullyManagedTool {
|
||||
const notificationMessage = `Cline wants to analyze code definitions in ${getWorkspaceBasename(absolutePath, "ListCodeDefinitionNamesToolHandler.notification")}`
|
||||
|
||||
// Show notification
|
||||
showNotificationForApprovalIfAutoApprovalEnabled(
|
||||
notificationMessage,
|
||||
config.autoApprovalSettings.enabled,
|
||||
config.autoApprovalSettings.enableNotifications,
|
||||
)
|
||||
showNotificationForApproval(notificationMessage, config.autoApprovalSettings.enableNotifications)
|
||||
|
||||
await config.callbacks.removeLastPartialMessageIfExistsWithType("say", "tool")
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import { arePathsEqual, getReadablePath, isLocatedInWorkspace } from "@utils/pat
|
||||
import { telemetryService } from "@/services/telemetry"
|
||||
import { ClineDefaultTool } from "@/shared/tools"
|
||||
import type { ToolResponse } from "../../index"
|
||||
import { showNotificationForApprovalIfAutoApprovalEnabled } from "../../utils"
|
||||
import { showNotificationForApproval } from "../../utils"
|
||||
import type { IFullyManagedTool } from "../ToolExecutorCoordinator"
|
||||
import type { ToolValidator } from "../ToolValidator"
|
||||
import type { TaskConfig } from "../types/TaskConfig"
|
||||
@@ -98,9 +98,6 @@ export class ListFilesToolHandler implements IFullyManagedTool {
|
||||
// Auto-approval flow
|
||||
await config.callbacks.removeLastPartialMessageIfExistsWithType("ask", "tool")
|
||||
await config.callbacks.say("tool", completeMessage, undefined, undefined, false)
|
||||
if (!config.yoloModeToggled) {
|
||||
config.taskState.consecutiveAutoApprovedRequestsCount++
|
||||
}
|
||||
|
||||
// Capture telemetry
|
||||
telemetryService.captureToolUsage(config.ulid, block.name, config.api.getModel().id, true, true, workspaceContext)
|
||||
@@ -109,11 +106,7 @@ export class ListFilesToolHandler implements IFullyManagedTool {
|
||||
const notificationMessage = `Cline wants to view directory ${getWorkspaceBasename(absolutePath, "ListFilesToolHandler.notification")}/`
|
||||
|
||||
// Show notification
|
||||
showNotificationForApprovalIfAutoApprovalEnabled(
|
||||
notificationMessage,
|
||||
config.autoApprovalSettings.enabled,
|
||||
config.autoApprovalSettings.enableNotifications,
|
||||
)
|
||||
showNotificationForApproval(notificationMessage, config.autoApprovalSettings.enableNotifications)
|
||||
|
||||
await config.callbacks.removeLastPartialMessageIfExistsWithType("say", "tool")
|
||||
|
||||
|
||||
@@ -35,8 +35,8 @@ export class NewTaskHandler implements IToolHandler, IPartialBlockHandler {
|
||||
|
||||
config.taskState.consecutiveMistakeCount = 0
|
||||
|
||||
// Show notification if auto-approval is enabled
|
||||
if (config.autoApprovalSettings.enabled && config.autoApprovalSettings.enableNotifications) {
|
||||
// Show notification if enabled
|
||||
if (config.autoApprovalSettings.enableNotifications) {
|
||||
showSystemNotification({
|
||||
subtitle: "Cline wants to start a new task...",
|
||||
message: `Cline is suggesting to start a new task with: ${context}`,
|
||||
|
||||
@@ -8,7 +8,7 @@ import { telemetryService } from "@/services/telemetry"
|
||||
import { ClineSayTool } from "@/shared/ExtensionMessage"
|
||||
import { ClineDefaultTool } from "@/shared/tools"
|
||||
import type { ToolResponse } from "../../index"
|
||||
import { showNotificationForApprovalIfAutoApprovalEnabled } from "../../utils"
|
||||
import { showNotificationForApproval } from "../../utils"
|
||||
import type { IFullyManagedTool } from "../ToolExecutorCoordinator"
|
||||
import type { ToolValidator } from "../ToolValidator"
|
||||
import type { TaskConfig } from "../types/TaskConfig"
|
||||
@@ -96,9 +96,6 @@ export class ReadFileToolHandler implements IFullyManagedTool {
|
||||
// Auto-approval flow
|
||||
await config.callbacks.removeLastPartialMessageIfExistsWithType("ask", "tool")
|
||||
await config.callbacks.say("tool", completeMessage, undefined, undefined, false)
|
||||
if (!config.yoloModeToggled) {
|
||||
config.taskState.consecutiveAutoApprovedRequestsCount++
|
||||
}
|
||||
|
||||
// Capture telemetry
|
||||
telemetryService.captureToolUsage(config.ulid, block.name, config.api.getModel().id, true, true, workspaceContext)
|
||||
@@ -107,11 +104,7 @@ export class ReadFileToolHandler implements IFullyManagedTool {
|
||||
const notificationMessage = `Cline wants to read ${getWorkspaceBasename(absolutePath, "ReadFileToolHandler.notification")}`
|
||||
|
||||
// Show notification
|
||||
showNotificationForApprovalIfAutoApprovalEnabled(
|
||||
notificationMessage,
|
||||
config.autoApprovalSettings.enabled,
|
||||
config.autoApprovalSettings.enableNotifications,
|
||||
)
|
||||
showNotificationForApproval(notificationMessage, config.autoApprovalSettings.enableNotifications)
|
||||
|
||||
await config.callbacks.removeLastPartialMessageIfExistsWithType("say", "tool")
|
||||
|
||||
|
||||
@@ -64,8 +64,8 @@ export class ReportBugHandler implements IToolHandler, IPartialBlockHandler {
|
||||
|
||||
config.taskState.consecutiveMistakeCount = 0
|
||||
|
||||
// Show notification if auto-approval is enabled
|
||||
if (config.autoApprovalSettings.enabled && config.autoApprovalSettings.enableNotifications) {
|
||||
// Show notification if enabled
|
||||
if (config.autoApprovalSettings.enableNotifications) {
|
||||
showSystemNotification({
|
||||
subtitle: "Cline wants to create a github issue...",
|
||||
message: `Cline is suggesting to create a github issue with the title: ${title}`,
|
||||
|
||||
@@ -10,7 +10,7 @@ import { telemetryService } from "@/services/telemetry"
|
||||
import { ClineSayTool } from "@/shared/ExtensionMessage"
|
||||
import { ClineDefaultTool } from "@/shared/tools"
|
||||
import type { ToolResponse } from "../../index"
|
||||
import { showNotificationForApprovalIfAutoApprovalEnabled } from "../../utils"
|
||||
import { showNotificationForApproval } from "../../utils"
|
||||
import type { IFullyManagedTool } from "../ToolExecutorCoordinator"
|
||||
import type { ToolValidator } from "../ToolValidator"
|
||||
import type { TaskConfig } from "../types/TaskConfig"
|
||||
@@ -304,9 +304,6 @@ export class SearchFilesToolHandler implements IFullyManagedTool {
|
||||
// Auto-approval flow
|
||||
await config.callbacks.removeLastPartialMessageIfExistsWithType("ask", "tool")
|
||||
await config.callbacks.say("tool", completeMessage, undefined, undefined, false)
|
||||
if (!config.yoloModeToggled) {
|
||||
config.taskState.consecutiveAutoApprovedRequestsCount++
|
||||
}
|
||||
|
||||
// Capture telemetry
|
||||
telemetryService.captureToolUsage(config.ulid, block.name, config.api.getModel().id, true, true, workspaceContext)
|
||||
@@ -315,11 +312,7 @@ export class SearchFilesToolHandler implements IFullyManagedTool {
|
||||
const notificationMessage = `Cline wants to search files for ${regex}`
|
||||
|
||||
// Show notification
|
||||
showNotificationForApprovalIfAutoApprovalEnabled(
|
||||
notificationMessage,
|
||||
config.autoApprovalSettings.enabled,
|
||||
config.autoApprovalSettings.enableNotifications,
|
||||
)
|
||||
showNotificationForApproval(notificationMessage, config.autoApprovalSettings.enableNotifications)
|
||||
|
||||
await config.callbacks.removeLastPartialMessageIfExistsWithType("say", "tool")
|
||||
|
||||
|
||||
@@ -102,9 +102,6 @@ export class SummarizeTaskHandler implements IToolHandler, IPartialBlockHandler
|
||||
const { absolutePath, displayPath } =
|
||||
typeof pathResult === "string" ? { absolutePath: pathResult, displayPath: relPath } : pathResult
|
||||
|
||||
// Increment counter for successful auto-approved read
|
||||
config.taskState.consecutiveAutoApprovedRequestsCount++
|
||||
|
||||
// Read file content, we dont allow images to be read here
|
||||
// This throws if an image or if we can't read the file, implicitly skipping
|
||||
const fileContent = await extractFileContent(absolutePath, false)
|
||||
|
||||
@@ -4,7 +4,7 @@ import { ClineAsk, ClineAskUseMcpServer } from "@shared/ExtensionMessage"
|
||||
import { telemetryService } from "@/services/telemetry"
|
||||
import { ClineDefaultTool } from "@/shared/tools"
|
||||
import type { ToolResponse } from "../../index"
|
||||
import { showNotificationForApprovalIfAutoApprovalEnabled } from "../../utils"
|
||||
import { showNotificationForApproval } from "../../utils"
|
||||
import type { IFullyManagedTool } from "../ToolExecutorCoordinator"
|
||||
import type { TaskConfig } from "../types/TaskConfig"
|
||||
import type { StronglyTypedUIHelpers } from "../types/UIHelpers"
|
||||
@@ -88,9 +88,6 @@ export class UseMcpToolHandler implements IFullyManagedTool {
|
||||
// Auto-approval flow
|
||||
await config.callbacks.removeLastPartialMessageIfExistsWithType("ask", "use_mcp_server")
|
||||
await config.callbacks.say("use_mcp_server", completeMessage, undefined, undefined, false)
|
||||
if (!config.yoloModeToggled) {
|
||||
config.taskState.consecutiveAutoApprovedRequestsCount++
|
||||
}
|
||||
|
||||
// Capture telemetry
|
||||
telemetryService.captureToolUsage(config.ulid, block.name, config.api.getModel().id, true, true)
|
||||
@@ -99,11 +96,7 @@ export class UseMcpToolHandler implements IFullyManagedTool {
|
||||
const notificationMessage = `Cline wants to use ${tool_name || "unknown tool"} on ${server_name || "unknown server"}`
|
||||
|
||||
// Show notification
|
||||
showNotificationForApprovalIfAutoApprovalEnabled(
|
||||
notificationMessage,
|
||||
config.autoApprovalSettings.enabled,
|
||||
config.autoApprovalSettings.enableNotifications,
|
||||
)
|
||||
showNotificationForApproval(notificationMessage, config.autoApprovalSettings.enableNotifications)
|
||||
|
||||
await config.callbacks.removeLastPartialMessageIfExistsWithType("say", "use_mcp_server")
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import { telemetryService } from "@/services/telemetry"
|
||||
import { ToolUse } from "../../../assistant-message"
|
||||
import { formatResponse } from "../../../prompts/responses"
|
||||
import { ToolResponse } from "../.."
|
||||
import { showNotificationForApprovalIfAutoApprovalEnabled } from "../../utils"
|
||||
import { showNotificationForApproval } from "../../utils"
|
||||
import type { IFullyManagedTool } from "../ToolExecutorCoordinator"
|
||||
import type { TaskConfig } from "../types/TaskConfig"
|
||||
import type { StronglyTypedUIHelpers } from "../types/UIHelpers"
|
||||
@@ -59,15 +59,11 @@ export class WebFetchToolHandler implements IFullyManagedTool {
|
||||
// Auto-approve flow
|
||||
await config.callbacks.removeLastPartialMessageIfExistsWithType("ask", "tool")
|
||||
await config.callbacks.say("tool", completeMessage, undefined, undefined, false)
|
||||
if (!config.yoloModeToggled) {
|
||||
config.taskState.consecutiveAutoApprovedRequestsCount++
|
||||
}
|
||||
telemetryService.captureToolUsage(config.ulid, "web_fetch", config.api.getModel().id, true, true)
|
||||
} else {
|
||||
// Manual approval flow
|
||||
showNotificationForApprovalIfAutoApprovalEnabled(
|
||||
showNotificationForApproval(
|
||||
`Cline wants to fetch content from ${url}`,
|
||||
config.autoApprovalSettings.enabled,
|
||||
config.autoApprovalSettings.enableNotifications,
|
||||
)
|
||||
await config.callbacks.removeLastPartialMessageIfExistsWithType("say", "tool")
|
||||
|
||||
@@ -12,7 +12,7 @@ import { fixModelHtmlEscaping, removeInvalidChars } from "@utils/string"
|
||||
import { telemetryService } from "@/services/telemetry"
|
||||
import { ClineDefaultTool } from "@/shared/tools"
|
||||
import type { ToolResponse } from "../../index"
|
||||
import { showNotificationForApprovalIfAutoApprovalEnabled } from "../../utils"
|
||||
import { showNotificationForApproval } from "../../utils"
|
||||
import type { IFullyManagedTool } from "../ToolExecutorCoordinator"
|
||||
import type { ToolValidator } from "../ToolValidator"
|
||||
import type { TaskConfig } from "../types/TaskConfig"
|
||||
@@ -167,9 +167,6 @@ export class WriteToFileToolHandler implements IFullyManagedTool {
|
||||
// Auto-approval flow
|
||||
await config.callbacks.removeLastPartialMessageIfExistsWithType("ask", "tool")
|
||||
await config.callbacks.say("tool", completeMessage, undefined, undefined, false)
|
||||
if (!config.yoloModeToggled) {
|
||||
config.taskState.consecutiveAutoApprovedRequestsCount++
|
||||
}
|
||||
|
||||
// Capture telemetry
|
||||
telemetryService.captureToolUsage(config.ulid, block.name, config.api.getModel().id, true, true, workspaceContext)
|
||||
@@ -181,11 +178,7 @@ export class WriteToFileToolHandler implements IFullyManagedTool {
|
||||
const notificationMessage = `Cline wants to ${fileExists ? "edit" : "create"} ${getWorkspaceBasename(relPath, "WriteToFile.notification")}`
|
||||
|
||||
// Show notification
|
||||
showNotificationForApprovalIfAutoApprovalEnabled(
|
||||
notificationMessage,
|
||||
config.autoApprovalSettings.enabled,
|
||||
config.autoApprovalSettings.enableNotifications,
|
||||
)
|
||||
showNotificationForApproval(notificationMessage, config.autoApprovalSettings.enableNotifications)
|
||||
|
||||
await config.callbacks.removeLastPartialMessageIfExistsWithType("say", "tool")
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { ClineDefaultTool } from "@shared/tools"
|
||||
import type { ClineAskResponse } from "@shared/WebviewMessage"
|
||||
import { telemetryService } from "@/services/telemetry"
|
||||
import type { ToolParamName, ToolUse } from "../../../assistant-message"
|
||||
import { showNotificationForApprovalIfAutoApprovalEnabled } from "../../utils"
|
||||
import { showNotificationForApproval } from "../../utils"
|
||||
import { removeClosingTag } from "../utils/ToolConstants"
|
||||
import type { TaskConfig } from "./TaskConfig"
|
||||
|
||||
@@ -61,11 +61,7 @@ export function createUIHelpers(config: TaskConfig): StronglyTypedUIHelpers {
|
||||
telemetryService.captureToolUsage(config.ulid, toolName, config.api.getModel().id, autoApproved, approved)
|
||||
},
|
||||
showNotificationIfEnabled: (message: string) => {
|
||||
showNotificationForApprovalIfAutoApprovalEnabled(
|
||||
message,
|
||||
config.autoApprovalSettings.enabled,
|
||||
config.autoApprovalSettings.enableNotifications,
|
||||
)
|
||||
showNotificationForApproval(message, config.autoApprovalSettings.enableNotifications)
|
||||
},
|
||||
getConfig: () => config,
|
||||
}
|
||||
|
||||
@@ -5,12 +5,8 @@ import { ClineApiReqCancelReason, ClineApiReqInfo } from "@/shared/ExtensionMess
|
||||
import { calculateApiCostAnthropic } from "@/utils/cost"
|
||||
import { MessageStateHandler } from "./message-state"
|
||||
|
||||
export const showNotificationForApprovalIfAutoApprovalEnabled = (
|
||||
message: string,
|
||||
autoApprovalSettingsEnabled: boolean,
|
||||
notificationsEnabled: boolean,
|
||||
) => {
|
||||
if (autoApprovalSettingsEnabled && notificationsEnabled) {
|
||||
export const showNotificationForApproval = (message: string, notificationsEnabled: boolean) => {
|
||||
if (notificationsEnabled) {
|
||||
showSystemNotification({
|
||||
subtitle: "Approval Required",
|
||||
message,
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { type EmptyRequest, String } from "@shared/proto/cline/common"
|
||||
import { ClineEnv } from "@/config"
|
||||
import { Controller } from "@/core/controller"
|
||||
import { getRequestRegistry, type StreamingResponseHandler } from "@/core/controller/grpc-handler"
|
||||
import { setWelcomeViewCompleted } from "@/core/controller/state/setWelcomeViewCompleted"
|
||||
import { HostProvider } from "@/hosts/host-provider"
|
||||
import { telemetryService } from "@/services/telemetry"
|
||||
import { openExternal } from "@/utils/env"
|
||||
@@ -229,8 +230,9 @@ export class AuthService {
|
||||
})
|
||||
}
|
||||
|
||||
async createAuthRequest(): Promise<String> {
|
||||
if (this._authenticated) {
|
||||
async createAuthRequest(strict = false): Promise<String> {
|
||||
// In strict mode, we do not open a new auth window if already authenticated
|
||||
if (strict && this._authenticated) {
|
||||
this.sendAuthStatusUpdate()
|
||||
return String.create({ value: "Already authenticated" })
|
||||
}
|
||||
@@ -279,11 +281,13 @@ export class AuthService {
|
||||
this._authenticated = this._clineAuthInfo?.idToken !== undefined
|
||||
|
||||
telemetryService.captureAuthSucceeded(this._provider.name)
|
||||
await this.sendAuthStatusUpdate()
|
||||
await setWelcomeViewCompleted(this._controller, { value: true })
|
||||
} catch (error) {
|
||||
console.error("Error signing in with custom token:", error)
|
||||
telemetryService.captureAuthFailed(this._provider.name)
|
||||
throw error
|
||||
} finally {
|
||||
await this.sendAuthStatusUpdate()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user