Compare commits

...
Author SHA1 Message Date
nighttrek f59ef0c43c refactor(telemetry): Remove email from user identification
To enhance user privacy and avoid sending unnecessary Personally Identifiable Information (PII), the user's email address is no longer included in telemetry identification events.

The `TelemetryService` now strips the `email` property from the user information object before passing it to the various telemetry providers.

This change includes:
- Introducing an `AnonymousClineAccountUserInfo` type that omits the `email` field.
- Updating the `ITelemetryProvider` interface and its implementations (PostHog, OpenTelemetry) to use this new anonymous type for user identification calls.
2025-11-03 13:03:40 -08:00
Ara ea9b8fe0b1 Enable Terminal Timeouts by defualt (#7171)
* Remove redundant test code that's very confusing

* feat: add vscodeTerminalExecutionMode to TaskConfig and apply timeout

Add vscodeTerminalExecutionMode parameter to TaskConfig and thread it through ToolExecutor and Task classes. Apply default 30-second timeout to commands executed in backgroundExec mode, similar to existing yolo mode behavior.

This change enables different execution strategies for VSCode terminal commands and ensures background executions have appropriate timeout protection to prevent hanging processes.
2025-11-03 11:33:10 -08:00
canvrno 05213f2a71 fix: Remove orphaned tool_results after truncation (#7225) 2025-11-03 10:25:16 -08:00
Ara d5bad1357d fix: Support interleaved thinking for miniMax provider (#7162)
* fix: interleaved thinking

* Adding native tool calling
2025-11-03 09:31:08 -08:00
Saoud Rizwan a4b1549dac Revise title in README.md
Updated the title of the README file and removed the subtitle.
2025-11-02 23:29:49 -08:00
Andrei EternalandAndrei Edell e015ce94c0 cli polish ahead of release - disable doctor, better node errors (#7215)
Co-authored-by: Andrei Edell <andrei@nugbase.com>
2025-11-02 14:52:54 -08:00
Wahaj Ahmed KhanandWahaj Ahmed Khan be076cf407 feat: Add support for Claude 4.5 Sonnet in SAP AI Core provider (#7217)
- Add anthropic--claude-4.5-sonnet model definition to sapAiCoreModels
- Update SAP AI Core provider to handle Claude 4.5 Sonnet in anthropicModels array
- Enable caching support for Claude 4.5 Sonnet using converse-stream endpoint
- Add Claude 4.5 Sonnet to streamCompletionSonnet37 method for proper response handling

Fixes #7216

Co-authored-by: Wahaj Ahmed Khan <“wahaj.khan@sap.com”>
2025-11-01 20:01:04 -07:00
Nick Baumann 580db36476 Add provider field to tool usage telemetry events (#7214)
- Add provider parameter to captureToolUsage() and captureDiffEditFailure() methods in TelemetryService
- Update all 11 tool handlers to extract and pass provider information
- Extract provider using plan/act mode differentiation from state manager
- Update UIHelpers.ts captureTelemetry wrapper to include provider
- Update test file to include provider parameter in test calls

This enables tracking which API provider (anthropic, openai, etc.) was used for each tool execution.
2025-10-31 18:31:45 -07:00
Sarah Fortune 8f8b98bb58 Add global rules and workflows to the remote config schema (#7198)
Add a way for the admin to configure global cline rules and workflows for their users.
2025-10-31 10:24:40 -07:00
CandiedUniverse 36022438cb 🪝Hooks: Exclude .clinerules/hooks/ files from Rules feature (#7202)
* Exclude .clinerules/hooks/ files from Rules feature

* Escape whitespace in paths correctly when discovering hooks/ directories
2025-10-31 10:18:51 -07:00
Bee c7afb61e28 fix: react-remark rendering in ModelDescriptionMarkdown (#7205)
* fix: react-remark rendering in ModelDescriptionMarkdown

- Add useRemark hook to properly parse and render markdown content that was removed in last git commit
- Extract props interface to ModelDescriptionMarkdownProps for better type safety
- Add useEffect to reactively update markdown when content changes
- Set fixed height (h-20) for collapsed state to improve layout consistency
- Replace raw markdown text display with processed reactContent

This change ensures markdown formatting (links, bold, italics, etc.) is correctly rendered in model descriptions instead of showing raw markdown syntax.

* changeset
2025-10-31 10:03:27 -07:00
CandiedUniverse dfd113a6e5 🪝Hooks: Gorgeous UI [ENG-994] (#7126)
* feat(hooks): Initial implementation of hooks UI using background terminal UI

* Hooks UI improvements

* Hook discovery improvements

* Separate hooks UI into separate files

* Fix failing tests

* Add missing docs

* Hooks hardening and handling edge cases

* Improvements to hooks error messaging

* Improve TaskCancel hook UI

* Simplify and improve console.log() output from hooks

* Changes from usability feedback

* test: verify linting fixes

* fix: resolve cancel functionality in PostToolUse hook using flag pattern

* fix: add cancel button support for TaskResume and UserPromptSubmit hooks

* Getting things working again

* Fixing cancel behaviors

* Trying another fix

* Demoed

* Add examples to .clinerules/hooks/ directory

* Refactored away loadTaskStateWithoutWorkflow

* Fix unsafe return in finally block

Refactored PostToolUse hook execution to avoid using return statement
in finally block, which the linter flags as unsafe. Instead of early
return, wrapped the hook logic in a conditional check for abort status.

This resolves the biome lint error:
lint/correctness/noUnsafeFinally - Unsafe usage of 'return' in finally block

* Remove unnecessary files from branch

* remove cancelHookExecution() in favor of cancelTask()

* Remove unnecessary polling pattern for hook cancellation

* Fixing cancel behavior

* Fixing cancel behavior

* Fix resume

* Sqaush commits to improve merge tool behavior

Plan out the work needed to resolve race conditions

Fix race conditions (1)

Fix race conditions (2)

Fix race conditions (3)

Fix race conditions (4)

Fix race conditions (5)

Fix unit tests

Updated implementation plan doc

Code quality improvement

Temporary logging to troubleshoot race conditions in hooks UI blocks

Fix streaming output for hooks

Remove in-progress planning file

Revert breakage

Remove unneeded arg output in PreToolUse hook UI

Fix race condition with PreToolUse and PostToolUse hooks

Fix PreToolUse attempt_completion use case

Fix ChatTextArea prompt input

Fixed part of the rersume bug

Clean-up before code review

Change cancel button to abort button

Fixed TaskCancel behavior

Fixing resume behavior (partially fixed)

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

Prevent cancel/abort from clearing unsent user message

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

Use color styles consistent with background terminal

Fix npm compile issue

Remove unneeded hot-cold tracking from hooks

Remove unused constant as per PR feedback

Minor changes

Skip combineHookSequences() if hooks feature setting not enabled

Improve mutex pattern for general use and fix additional race condition

Refactor to reduce duplication (keep it DRY)

Refactor to reduce duplication (keep it DRY) part 2

Fix tests

Skip some tests on Windows (not yet supported)

Hooks unsupported on Windows

Fix broken unit tests

* Fix broken unit tests

* Bee and Eve fixing 'API Request...' test in e2e tests

* Add comment as per ellipsis-dev PR feedback

* Changes as per PR feedback from Evan

* Remove if that always resolves to true

* Don't use vscode API; use cross-compatible solutions instead

* Remove mention of obsolete clineAsk handler
2025-10-31 05:50:18 -07:00
github-actions[bot] 3698d2356c v3.35.0 Release Notes (#7127)
- 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>

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-10-30 18:41:23 -07:00
Bee ff20c4addc feat: add native tool call setting with feature flag support (#7193)
* feat: add native tool call setting with feature flag support

Add a new native tool call setting that allows users to enable/disable native tool calling functionality. The setting is controlled by both a user preference and a feature flag, providing flexibility for gradual rollout. Changes include:
- Added native_tool_call_enabled field to UpdateSettingsRequest proto
- Implemented state management for the setting in controller and storage
- Added UI toggle in feature settings section
- Updated task execution logic to check both user setting and feature flag
- Extended extension state interface to include the new setting

* Add changeset
2025-10-30 17:47:35 -07:00
Bee 8eeeabb966 fix: ensure hasThinkingBudget validates non-empty thinkingConfig object (#7192)
Previously, hasThinkingBudget would return true for empty objects ({}),
causing incorrect behavior. Now checks both for truthiness and that the
object contains at least one key.

Also updates thinkingConfig assignment logic to avoid setting empty
objects and converts imports to type-only imports for better tree-shaking.
2025-10-30 16:22:51 -07:00
Saoud Rizwan 9b1dc5bd92 Improve auto-approve menu design and remove unnecessary options (#7180) 2025-10-30 16:10:14 -07:00
CandiedUniverse 1cfff0a45f Hide hooks toggle from appearing in the the Feature Settings (#7191) 2025-10-30 15:44:31 -07:00
167 changed files with 6439 additions and 1866 deletions
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": minor
---
added zai-glm-4.6 as a Cerebras model
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
Created GPT5 family specific system prompt template
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
Remove orphaned tool_results after truncation
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
Fix: render model description in markdown
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
Telemtry change: collect domain of openai compatible endpoints when telemetry is enabled
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
Requesty base URL, and API key fixes
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
Delete all Auth Tokens when logging out
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
Support for <think> tags for models that prefer that over <thinking>
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": minor
---
This PR introduces a Python gRPC codegen flow analogous to the existing Go flow
+7 -46
View File
@@ -1,54 +1,15 @@
#!/usr/bin/env bash
# PostToolUse Hook Example
#
# This hook runs AFTER a tool is executed. It can:
# 1. Observe tool results and outcomes
# 2. Add context for FUTURE tool uses via contextModification
# 3. Log or track tool usage patterns
#
# IMPORTANT: Context injection affects FUTURE AI decisions, not the current tool execution.
# The tool has already completed when this hook runs.
# Read the hook input (JSON via stdin)
input=$(cat)
for i in {1..100}; do
echo "$i"
done
# Extract tool information
tool_name=$(echo "$input" | jq -r '.postToolUse.toolName // "unknown"')
parameters=$(echo "$input" | jq -r '.postToolUse.parameters // {}')
result=$(echo "$input" | jq -r '.postToolUse.result // ""')
success=$(echo "$input" | jq -r '.postToolUse.success // false')
execution_time=$(echo "$input" | jq -r '.postToolUse.executionTimeMs // 0')
sleep 3
# Example 1: Learning from file operations
# Track successful file creations to build context about project structure
# if [[ "$tool_name" == "write_to_file" && "$success" == "true" ]]; then
# path=$(echo "$parameters" | jq -r '.path // ""')
# cat <<EOF
# {
# "shouldContinue": true,
# "contextModification": "FILE_OPERATIONS: Successfully created '$path'. Future operations should maintain consistency with this file's patterns and structure."
# }
# EOF
# exit 0
# fi
# Example 2: Performance monitoring
# Warn about slow operations
# if [[ "$execution_time" -gt 5000 ]]; then
# cat <<EOF
# {
# "shouldContinue": true,
# "contextModification": "PERFORMANCE: Tool '$tool_name' took ${execution_time}ms to complete. Consider optimizing future similar operations or breaking them into smaller steps."
# }
# EOF
# exit 0
# fi
# Example 3: Context injection for future tool uses
# The context will be available in the NEXT API request
cat <<EOF
{
"shouldContinue": true,
"contextModification": "TOOL_RESULT: The tool '$tool_name' completed with success=$success. Consider validating the results before proceeding to the next step."
"cancel": false,
"contextModification": "WORKSPACE_RULES: This is from the local bar/ workspace.",
"errorMessage": "PostToolUse hook custom errorMessage: foo"
}
EOF
+7 -34
View File
@@ -1,42 +1,15 @@
#!/usr/bin/env bash
# PreToolUse Hook Example
#
# This hook runs BEFORE a tool is executed. It can:
# 1. Block execution by returning {"shouldContinue": false}
# 2. Add context for FUTURE tool uses via contextModification
# 3. Validate tool parameters
#
# IMPORTANT: Context injection affects FUTURE AI decisions, not the current tool execution.
# The tool parameters are already determined when this hook runs.
# Read the hook input (JSON via stdin)
input=$(cat)
for i in {1..100}; do
echo "$i"
done
# Extract tool information
tool_name=$(echo "$input" | jq -r '.preToolUse.toolName // "unknown"')
parameters=$(echo "$input" | jq -r '.preToolUse.parameters // {}')
sleep 3
# Example 1: Validation - Block invalid operations
# Uncomment to prevent creating .js files in a TypeScript project
# if [[ "$tool_name" == "write_to_file" ]]; then
# path=$(echo "$parameters" | jq -r '.path // ""')
# if [[ "$path" == *.js ]]; then
# cat <<EOF
# {
# "shouldContinue": false,
# "errorMessage": "VALIDATION FAILED: Cannot create .js files in TypeScript project. Please use .ts extension instead.",
# "contextModification": "WORKSPACE_RULES: This is a strict TypeScript project. All new files must use .ts or .tsx extensions."
# }
# EOF
# exit 0
# fi
# fi
# Example 2: Context injection for future tool uses
# The context will be available in the NEXT API request after this tool completes
cat <<EOF
{
"shouldContinue": true,
"contextModification": "WORKSPACE_RULES: [For future tool uses] This is a TypeScript React project. When creating files, use .ts/.tsx extensions and include detailed comments explaining the purpose and usage of each function."
"cancel": false,
"contextModification": "Make sure that all variables in python code in this repo always start with the string 'EVIEDEBUG_'. This is important.",
"errorMessage": "PreToolUse hook custom errorMessage: foo"
}
EOF
+122 -41
View File
@@ -3,7 +3,7 @@
## Overview
Cline hooks allow you to execute custom scripts at specific points in the agentic workflow. Hooks can be placed in either:
- **Global hooks directory**: `~/Documents/Cline/Rules/Hooks/` (applies to all workspaces)
- **Global hooks directory**: `~/Documents/Cline/Hooks/` (applies to all workspaces)
- **Workspace hooks directory**: `.clinerules/hooks/` (applies to specific workspace)
Hooks run automatically when enabled.
@@ -17,18 +17,55 @@ Hooks run automatically when enabled.
## Available Hooks
### TaskStart Hook
- **When**: Runs when a NEW task is started (not when resuming)
- **Purpose**: Initialize task context, validate task requirements, set up environment
- **Global Location**: `~/Documents/Cline/Hooks/TaskStart` (all platforms)
- **Workspace Location**: `.clinerules/hooks/TaskStart` (all platforms)
### TaskResume Hook
- **When**: Runs when an EXISTING task is resumed (after user clicks resume button)
- **Purpose**: Validate resumed task state, restore context, check for changes since last run
- **Global Location**: `~/Documents/Cline/Hooks/TaskResume` (all platforms)
- **Workspace Location**: `.clinerules/hooks/TaskResume` (all platforms)
### TaskCancel Hook
- **When**: Runs when a task is cancelled by the user (only if there's actual active work or work was started)
- **Purpose**: Clean up resources, log cancellation, save state
- **Global Location**: `~/Documents/Cline/Hooks/TaskCancel` (all platforms)
- **Workspace Location**: `.clinerules/hooks/TaskCancel` (all platforms)
- **Note**: This hook is NOT cancellable and will complete even if the task is being aborted
### TaskComplete Hook
- **When**: Runs when a task is marked as complete
- **Purpose**: Log completion status, perform final cleanup, generate reports
- **Global Location**: `~/Documents/Cline/Hooks/TaskComplete` (all platforms)
- **Workspace Location**: `.clinerules/hooks/TaskComplete` (all platforms)
### UserPromptSubmit Hook
- **When**: Runs when the user submits a prompt/message (initial task, resume, or feedback)
- **Purpose**: Validate user input, preprocess prompts, add context to user messages
- **Global Location**: `~/Documents/Cline/Hooks/UserPromptSubmit` (all platforms)
- **Workspace Location**: `.clinerules/hooks/UserPromptSubmit` (all platforms)
### PreToolUse Hook
- **When**: Runs BEFORE a tool is executed
- **Purpose**: Validate parameters, block execution, or add context
- **Global Location**: `~/Documents/Cline/Rules/Hooks/PreToolUse` (all platforms)
- **Global Location**: `~/Documents/Cline/Hooks/PreToolUse` (all platforms)
- **Workspace Location**: `.clinerules/hooks/PreToolUse` (all platforms)
### PostToolUse Hook
- **When**: Runs AFTER a tool completes
- **Purpose**: Observe results, track patterns, or add context
- **Global Location**: `~/Documents/Cline/Rules/Hooks/PostToolUse` (all platforms)
- **Global Location**: `~/Documents/Cline/Hooks/PostToolUse` (all platforms)
- **Workspace Location**: `.clinerules/hooks/PostToolUse` (all platforms)
### PreCompact Hook
- **When**: Runs BEFORE the conversation context is compacted/truncated
- **Purpose**: Observe compaction events, log context management, track token usage
- **Global Location**: `~/Documents/Cline/Hooks/PreCompact` (all platforms)
- **Workspace Location**: `.clinerules/hooks/PreCompact` (all platforms)
## Cross-Platform Hook Format
Cline uses a git-style approach for hooks that works consistently across all platforms:
@@ -55,16 +92,16 @@ This means:
**On Unix/Linux/macOS:**
```bash
# Create hook file
nano ~/Documents/Cline/Rules/Hooks/PreToolUse
nano ~/Documents/Cline/Hooks/PreToolUse
# Make executable
chmod +x ~/Documents/Cline/Rules/Hooks/PreToolUse
chmod +x ~/Documents/Cline/Hooks/PreToolUse
```
**On Windows:**
```batch
REM Create hook file (note: no file extension)
notepad %USERPROFILE%\Documents\Cline\Rules\Hooks\PreToolUse
notepad %USERPROFILE%\Documents\Cline\Hooks\PreToolUse
```
## Context Injection Timing
@@ -107,11 +144,46 @@ All hooks receive:
```json
{
"clineVersion": "string",
"hookName": "PreToolUse" | "PostToolUse",
"hookName": "TaskStart" | "TaskResume" | "TaskCancel" | "TaskComplete" | "UserPromptSubmit" | "PreToolUse" | "PostToolUse" | "PreCompact",
"timestamp": "string",
"taskId": "string",
"workspaceRoots": ["string"],
"userId": "string",
"taskStart": { // Only for TaskStart
"taskMetadata": {
"taskId": "string",
"ulid": "string",
"initialTask": "string"
}
},
"taskResume": { // Only for TaskResume
"taskMetadata": {
"taskId": "string",
"ulid": "string"
},
"previousState": {
"lastMessageTs": "string",
"messageCount": "string",
"conversationHistoryDeleted": "string"
}
},
"taskCancel": { // Only for TaskCancel
"taskMetadata": {
"taskId": "string",
"ulid": "string",
"completionStatus": "string"
}
},
"taskComplete": { // Only for TaskComplete
"taskMetadata": {
"taskId": "string",
"ulid": "string"
}
},
"userPromptSubmit": { // Only for UserPromptSubmit
"prompt": "string",
"attachments": ["string"]
},
"preToolUse": { // Only for PreToolUse
"toolName": "string",
"parameters": {}
@@ -122,6 +194,11 @@ All hooks receive:
"result": "string",
"success": boolean,
"executionTimeMs": number
},
"preCompact": { // Only for PreCompact
"contextSize": number,
"messagesToCompact": number,
"compactionStrategy": "string"
}
}
```
@@ -131,12 +208,16 @@ All hooks receive:
All hooks must return:
```json
{
"shouldContinue": boolean, // Required: Allow or block execution
"contextModification": "string", // Optional: Context for future tool uses
"cancel": boolean, // Required: false to continue, true to block execution
"contextModification": "string", // Optional: Context for future AI decisions
"errorMessage": "string" // Optional: Error details if blocking
}
```
**Note**: The `cancel` field works as follows:
- `false` (or omitted): Allow execution to continue
- `true`: Block execution and show error message to user
## Context Modification Format
Use structured prefixes to help the AI understand context type:
@@ -152,7 +233,7 @@ Example:
```bash
cat <<EOF
{
"shouldContinue": true,
"cancel": false,
"contextModification": "WORKSPACE_RULES: This is a TypeScript project. All new files must use .ts or .tsx extensions."
}
EOF
@@ -160,9 +241,9 @@ EOF
## Hook Execution Limits
- **Timeout**: Hooks must complete within 30 seconds
- **Context Size**: Context modifications are limited to 50KB
- **Error Handling**: Unexpected file system errors are propagated; expected errors (file not found, permission denied) are handled silently
- **Timeout**: Hooks must complete within 30 seconds (configurable via `HOOK_EXECUTION_TIMEOUT_MS`)
- **Context Size**: Context modifications are limited to 50KB (configurable via `MAX_CONTEXT_MODIFICATION_SIZE`)
- **Error Handling**: Expected errors (file not found, permission denied, not a directory) are handled silently; unexpected file system errors are propagated
## Common Use Cases
@@ -177,7 +258,7 @@ path=$(echo "$input" | jq -r '.preToolUse.parameters.path // ""')
if [[ "$tool_name" == "write_to_file" && "$path" == *.js ]]; then
cat <<EOF
{
"shouldContinue": false,
"cancel": true,
"errorMessage": "Cannot create .js files in TypeScript project",
"contextModification": "WORKSPACE_RULES: Use .ts/.tsx extensions only"
}
@@ -185,7 +266,7 @@ EOF
exit 0
fi
echo '{"shouldContinue": true}'
echo '{"cancel": false}'
```
### 2. Context Building - Learn from Operations
@@ -200,12 +281,12 @@ path=$(echo "$input" | jq -r '.postToolUse.parameters.path // ""')
if [[ "$tool_name" == "write_to_file" && "$success" == "true" ]]; then
cat <<EOF
{
"shouldContinue": true,
"cancel": false,
"contextModification": "FILE_OPERATIONS: Created '$path'. Maintain consistency with this file's patterns in future operations."
}
EOF
else
echo '{"shouldContinue": true}'
echo '{"cancel": false}'
fi
```
@@ -220,12 +301,12 @@ tool_name=$(echo "$input" | jq -r '.postToolUse.toolName')
if [[ "$execution_time" -gt 5000 ]]; then
cat <<EOF
{
"shouldContinue": true,
"cancel": false,
"contextModification": "PERFORMANCE: Tool '$tool_name' took ${execution_time}ms. Consider optimizing future similar operations."
}
EOF
else
echo '{"shouldContinue": true}'
echo '{"cancel": false}'
fi
```
@@ -239,7 +320,7 @@ input=$(cat)
echo "$input" >> ~/.cline/hook-logs/tool-usage.jsonl
# Allow execution
echo '{"shouldContinue": true}'
echo '{"cancel": false}'
```
## Global vs Workspace Hooks
@@ -247,44 +328,44 @@ echo '{"shouldContinue": true}'
Cline supports two levels of hooks:
### Global Hooks
- **Location**: `~/Documents/Cline/Rules/Hooks/` (macOS/Linux) or `%USERPROFILE%\Documents\Cline\Rules\Hooks\` (Windows)
- **Location**: `~/Documents/Cline/Hooks/` (macOS/Linux) or `%USERPROFILE%\Documents\Cline\Hooks\` (Windows)
- **Scope**: Apply to ALL workspaces and projects
- **Use Case**: Organization-wide policies, personal preferences, universal validations
- **Priority**: Execute FIRST, before workspace hooks
- **Priority**: Order not guaranteed when combined with workspace hooks
### Workspace Hooks
- **Location**: `.clinerules/hooks/` in each workspace root
- **Scope**: Apply only to the specific workspace
- **Use Case**: Project-specific rules, team conventions, repository requirements
- **Priority**: Execute AFTER global hooks
- **Priority**: Order not guaranteed when combined with global hooks
### Hook Execution
When multiple hooks exist (global and/or workspace):
- All hooks for a given step (PreToolUse or PostToolUse) are executed
- **Execution order is not guaranteed** - hooks may run concurrently
- If ALL hooks allow execution (`shouldContinue: true`), the tool proceeds
- If ANY hook blocks (`shouldContinue: false`), execution is blocked
- All hooks for a given step are executed **concurrently** using `Promise.all`
- **Execution order is not guaranteed** - hooks run in parallel
- If ALL hooks allow execution (`cancel: false`), the tool proceeds
- If ANY hook blocks (`cancel: true`), execution is blocked
**Result Combination:**
- `shouldContinue`: Must be `true` from ALL hooks for execution to proceed
- `contextModification`: All context strings are concatenated
- `errorMessage`: All error messages are concatenated
- `cancel`: If ANY hook returns `true`, execution is blocked
- `contextModification`: All context strings are concatenated with double newlines (`\n\n`)
- `errorMessage`: All error messages are concatenated with single newlines (`\n`)
### Setting Up Global Hooks
1. The global hooks directory is automatically created at:
- macOS/Linux: `~/Documents/Cline/Rules/Hooks/`
- Windows: `%USERPROFILE%\Documents\Cline\Rules\Hooks\`
- macOS/Linux: `~/Documents/Cline/Hooks/`
- Windows: `%USERPROFILE%\Documents\Cline\Hooks\`
2. Add your hook script:
```bash
# Unix/Linux/macOS
nano ~/Documents/Cline/Rules/Hooks/PreToolUse
chmod +x ~/Documents/Cline/Rules/Hooks/PreToolUse
nano ~/Documents/Cline/Hooks/PreToolUse
chmod +x ~/Documents/Cline/Hooks/PreToolUse
# Windows
notepad %USERPROFILE%\Documents\Cline\Rules\Hooks\PreToolUse
notepad %USERPROFILE%\Documents\Cline\Hooks\PreToolUse
```
3. Enable hooks in Cline settings
@@ -294,18 +375,18 @@ When multiple hooks exist (global and/or workspace):
**Global Hook** (applies to all projects):
```bash
#!/usr/bin/env bash
# ~/Documents/Cline/Rules/Hooks/PreToolUse
# ~/Documents/Cline/Hooks/PreToolUse
# Universal rule: Never delete package.json
input=$(cat)
tool_name=$(echo "$input" | jq -r '.preToolUse.toolName')
path=$(echo "$input" | jq -r '.preToolUse.parameters.path // ""')
if [[ "$tool_name" == "write_to_file" && "$path" == *"package.json"* ]]; then
echo '{"shouldContinue": false, "errorMessage": "Global policy: Cannot modify package.json"}'
echo '{"cancel": true, "errorMessage": "Global policy: Cannot modify package.json"}'
exit 0
fi
echo '{"shouldContinue": true}'
echo '{"cancel": false}'
```
**Workspace Hook** (applies to specific project):
@@ -318,11 +399,11 @@ tool_name=$(echo "$input" | jq -r '.preToolUse.toolName')
path=$(echo "$input" | jq -r '.preToolUse.parameters.path // ""')
if [[ "$tool_name" == "write_to_file" && "$path" == *.js ]]; then
echo '{"shouldContinue": false, "errorMessage": "Project rule: Use .ts files only"}'
echo '{"cancel": true, "errorMessage": "Project rule: Use .ts files only"}'
exit 0
fi
echo '{"shouldContinue": true}'
echo '{"cancel": false}'
```
**All hooks must allow execution for the tool to proceed.** Hooks may execute concurrently.
@@ -331,7 +412,7 @@ echo '{"shouldContinue": true}'
If you have multiple workspace roots, you can place hooks in each root's `.clinerules/hooks/` directory. All hooks (global and workspace) may execute concurrently. Their results will be combined:
- **shouldContinue**: If ANY hook returns false, execution is blocked
- **cancel**: If ANY hook returns `true`, execution is blocked
- **contextModification**: All context modifications are concatenated
- **errorMessage**: All error messages are concatenated
+15
View File
@@ -0,0 +1,15 @@
#!/usr/bin/env bash
for i in {1..100}; do
echo "$i"
done
sleep 3
cat <<EOF
{
"cancel", false,
"contextModification": "WORKSPACE_RULES: This is from the local bar/ workspace.",
"errorMessage": "TaskCancel hook custom errorMessage: foo"
}
EOF
+15
View File
@@ -0,0 +1,15 @@
#!/usr/bin/env bash
for i in {1..100}; do
echo "$i"
done
sleep 3
cat <<EOF
{
"cancel": false,
"contextModification": "WORKSPACE_RULES: This is from the local bar/ workspace.",
"errorMessage": "TaskResume hook custom errorMessage: foo"
}
EOF
+15
View File
@@ -0,0 +1,15 @@
#!/usr/bin/env bash
for i in {1..100}; do
echo "$i"
done
sleep 1
cat <<EOF
{
"cancel": false,
"contextModification": "WORKSPACE_RULES: This is from the local bar/ workspace.",
"errorMessage": "TaskStart hook custom errorMessage: foo"
}
EOF
+15
View File
@@ -0,0 +1,15 @@
#!/usr/bin/env bash
for i in {1..100}; do
echo "$i"
done
sleep 3
cat <<EOF
{
"cancel": false,
"contextModification": "WORKSPACE_RULES: This is from the local bar/ workspace.",
"errorMessage": "UserPromptSubmit hook custom errorMessage: foo"
}
EOF
+11
View File
@@ -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
+1 -1
View File
@@ -2,7 +2,7 @@
English | <a href="https://github.com/cline/cline/blob/main/locales/es/README.md" target="_blank">Español</a> | <a href="https://github.com/cline/cline/blob/main/locales/de/README.md" target="_blank">Deutsch</a> | <a href="https://github.com/cline/cline/blob/main/locales/ja/README.md" target="_blank">日本語</a> | <a href="https://github.com/cline/cline/blob/main/locales/zh-cn/README.md" target="_blank">简体中文</a> | <a href="https://github.com/cline/cline/blob/main/locales/zh-tw/README.md" target="_blank">繁體中文</a> | <a href="https://github.com/cline/cline/blob/main/locales/ko/README.md" target="_blank">한국어</a>
</sub></div>
# Cline \#1 on OpenRouter
# Cline
<p align="center">
<img src="https://media.githubusercontent.com/media/cline/cline/main/assets/docs/demo.gif" width="100%" />
+2 -2
View File
@@ -182,7 +182,7 @@ see the manual page: man cline`,
rootCmd.AddCommand(cli.NewVersionCommand())
rootCmd.AddCommand(cli.NewAuthCommand())
rootCmd.AddCommand(cli.NewLogsCommand())
rootCmd.AddCommand(cli.NewDoctorCommand())
// rootCmd.AddCommand(cli.NewDoctorCommand()) // Disabled for now
if err := rootCmd.ExecuteContext(context.Background()); err != nil {
os.Exit(1)
@@ -345,4 +345,4 @@ func getContentFromStdinAndArgs(args []string) (string, error) {
}
return content.String(), nil
}
}
+60 -66
View File
@@ -1,68 +1,62 @@
{
"name": "cline",
"version": "1.0.0-nightly.18",
"description": "Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
"main": "cline-core.js",
"bin": {
"cline": "./bin/cline",
"cline-host": "./bin/cline-host"
},
"man": "./man/cline.1",
"scripts": {
"postinstall": "node postinstall.js"
},
"bundleDependencies": [
"@grpc/grpc-js",
"@grpc/reflection",
"better-sqlite3",
"grpc-health-check",
"open",
"vscode-uri"
],
"engines": {
"node": ">=18.0.0"
},
"keywords": [
"cline",
"claude",
"dev",
"mcp",
"openrouter",
"coding",
"agent",
"autonomous",
"chatgpt",
"sonnet",
"ai",
"llama",
"cli"
],
"author": {
"name": "Cline Bot Inc."
},
"license": "Apache-2.0",
"repository": {
"type": "git",
"url": "https://github.com/cline/cline"
},
"homepage": "https://cline.bot",
"bugs": {
"url": "https://github.com/cline/cline/issues"
},
"dependencies": {
"@grpc/grpc-js": "^1.13.3",
"@grpc/reflection": "^1.0.4",
"better-sqlite3": "^12.2.0",
"grpc-health-check": "^2.0.2",
"open": "^10.1.2",
"vscode-uri": "^3.1.0"
},
"os": [
"darwin",
"linux"
],
"cpu": [
"x64",
"arm64"
]
"name": "cline",
"version": "1.0.3",
"description": "Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
"main": "cline-core.js",
"bin": {
"cline": "./bin/cline",
"cline-host": "./bin/cline-host"
},
"man": "./man/cline.1",
"scripts": {
"postinstall": "node postinstall.js"
},
"bundleDependencies": [
"@grpc/grpc-js",
"@grpc/reflection",
"better-sqlite3",
"grpc-health-check",
"open",
"vscode-uri"
],
"engines": {
"node": ">=20.0.0"
},
"keywords": [
"cline",
"claude",
"dev",
"mcp",
"openrouter",
"coding",
"agent",
"autonomous",
"chatgpt",
"sonnet",
"ai",
"llama",
"cli"
],
"author": {
"name": "Cline Bot Inc."
},
"license": "Apache-2.0",
"repository": {
"type": "git",
"url": "https://github.com/cline/cline"
},
"homepage": "https://cline.bot",
"bugs": {
"url": "https://github.com/cline/cline/issues"
},
"dependencies": {
"@grpc/grpc-js": "^1.13.3",
"@grpc/reflection": "^1.0.4",
"better-sqlite3": "^12.2.0",
"grpc-health-check": "^2.0.2",
"open": "^10.1.2",
"vscode-uri": "^3.1.0"
},
"os": ["darwin", "linux"],
"cpu": ["x64", "arm64"]
}
+1 -1
View File
@@ -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))
}
}
-21
View File
@@ -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)
+3 -3
View File
@@ -208,9 +208,9 @@ func listLogFiles(logsDir string) ([]logFileInfo, error) {
})
}
// Sort by created time (newest first)
// Sort by created time (oldest first)
sort.Slice(logs, func(i, j int) bool {
return logs[i].created.After(logs[j].created)
return logs[i].created.Before(logs[j].created)
})
return logs, nil
@@ -379,4 +379,4 @@ func renderLogsTable(logs []logFileInfo, markForDeletion bool) error {
fmt.Println()
return nil
}
}
-2
View File
@@ -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{},
},
}
-12
View File
@@ -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 {
+10 -13
View File
@@ -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:
+2 -2
View File
@@ -375,7 +375,7 @@ func showFailureMessage(channel string) {
func getCacheFilePath() string {
configDir := filepath.Join(os.Getenv("HOME"), ".cline", "data")
return filepath.Join(configDir, ".update-cache")
return filepath.Join(configDir, "cli-update-cache")
}
func loadCache() (cacheData, error) {
@@ -406,4 +406,4 @@ func saveCache(cache cacheData) error {
}
return os.WriteFile(cacheFile, data, 0644)
}
}
+27 -1
View File
@@ -4,7 +4,9 @@ import (
"context"
"fmt"
"net"
"os/exec"
"strconv"
"strings"
"time"
"google.golang.org/grpc"
@@ -124,6 +126,16 @@ func NormalizeAddressForGRPC(address string) (string, error) {
return address, nil
}
// GetNodeVersion returns the current Node.js version, or "unknown" if unable to detect
func GetNodeVersion() string {
cmd := exec.Command("node", "--version")
output, err := cmd.Output()
if err != nil {
return "unknown"
}
return strings.TrimSpace(string(output))
}
// RetryOperation performs an operation with retry logic
func RetryOperation(maxRetries int, timeoutPerAttempt time.Duration, operation func() error) error {
var lastErr error
@@ -155,5 +167,19 @@ func RetryOperation(maxRetries int, timeoutPerAttempt time.Duration, operation f
}
}
return fmt.Errorf("operation failed after %d attempts: %w", maxRetries, lastErr)
return fmt.Errorf(`operation failed to after %d attempts: %w
This is usually caused by an incompatible Node.js version
REQUIREMENTS:
• Node.js version 20+ is required
• Current Node.js version: %s
DEBUGGING STEPS:
1. View recent logs: cline log list
2. Logs are available in: ~/.cline/logs/
3. The most recent cline-core log file is usually valuable
For additional help, visit: https://github.com/cline/cline/issues
`, maxRetries, lastErr, GetNodeVersion())
}
+30 -2
View File
@@ -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
View File
@@ -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",
+1 -1
View File
@@ -29,7 +29,7 @@ message HookInput {
// Output message for all hooks
message HookOutput {
string context_modification = 1;
bool should_continue = 2;
bool cancel = 2;
string error_message = 3;
}
+5 -10
View File
@@ -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 {
+6 -7
View File
@@ -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
+155 -53
View File
@@ -1,34 +1,39 @@
import { Anthropic } from "@anthropic-ai/sdk"
import OpenAI from "openai"
import type { ChatCompletionTool } from "openai/resources/chat/completions"
import { Tool as AnthropicTool } from "@anthropic-ai/sdk/resources/index"
import { Stream as AnthropicStream } from "@anthropic-ai/sdk/streaming"
import { MinimaxModelId, ModelInfo, minimaxDefaultModelId, minimaxModels } from "@/shared/api"
import { ClineTool } from "@/shared/tools"
import { ApiHandler, CommonApiHandlerOptions } from "../index"
import { withRetry } from "../retry"
import { convertToOpenAiMessages } from "../transform/openai-format"
import { ApiStream } from "../transform/stream"
import { getOpenAIToolParams, ToolCallProcessor } from "../transform/tool-call-processor"
interface MinimaxHandlerOptions extends CommonApiHandlerOptions {
minimaxApiKey?: string
minimaxApiLine?: string
apiModelId?: string
thinkingBudgetTokens?: number
}
export class MinimaxHandler implements ApiHandler {
private client: OpenAI | undefined
private options: MinimaxHandlerOptions
private client: Anthropic | undefined
constructor(private readonly options: MinimaxHandlerOptions) {}
constructor(options: MinimaxHandlerOptions) {
this.options = options
}
private ensureClient(): OpenAI {
private ensureClient(): Anthropic {
if (!this.client) {
if (!this.options.minimaxApiKey) {
throw new Error("MiniMax API key is required")
}
try {
this.client = new OpenAI({
baseURL:
this.options.minimaxApiLine === "china" ? "https://api.minimaxi.com/v1" : "https://api.minimax.io/v1",
this.client = new Anthropic({
apiKey: this.options.minimaxApiKey,
baseURL:
this.options.minimaxApiLine === "china"
? "https://api.minimaxi.com/anthropic"
: "https://api.minimax.io/anthropic",
})
} catch (error) {
throw new Error(`Error creating MiniMax client: ${error.message}`)
@@ -38,60 +43,157 @@ export class MinimaxHandler implements ApiHandler {
}
@withRetry()
async *createMessage(
systemPrompt: string,
messages: Anthropic.Messages.MessageParam[],
tools?: ChatCompletionTool[],
): ApiStream {
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[], tools?: ClineTool[]): ApiStream {
const client = this.ensureClient()
const model = this.getModel()
const openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [
{ role: "system", content: systemPrompt },
...convertToOpenAiMessages(messages),
]
// Tools are available only when native tools are enabled
const nativeToolsOn = tools?.length && tools?.length > 0
const stream = await client.chat.completions.create({
// MiniMax M2 uses Anthropic API format
// Note: According to MiniMax docs, some Anthropic parameters like 'thinking' are ignored
// but we'll include the standard Anthropic streaming pattern for consistency
const stream: AnthropicStream<Anthropic.RawMessageStreamEvent> = await client.messages.create({
model: model.id,
messages: openAiMessages,
max_tokens: model.info.maxTokens,
max_tokens: model.info.maxTokens || 8192,
temperature: 1.0, // MiniMax recommends 1.0, range is (0.0, 1.0]
system: [{ text: systemPrompt, type: "text" }],
messages,
stream: true,
stream_options: { include_usage: true },
...getOpenAIToolParams(tools),
tools: nativeToolsOn ? (tools as AnthropicTool[]) : undefined,
tool_choice: nativeToolsOn ? { type: "any" } : undefined,
})
const toolCallProcessor = new ToolCallProcessor()
let thinkingDeltaAccumulator = ""
const lastStartedToolCall = { id: "", name: "", arguments: "" }
for await (const chunk of stream) {
const delta = chunk.choices[0]?.delta
if (delta?.content) {
yield {
type: "text",
text: delta.content,
}
}
switch (chunk?.type) {
case "message_start":
// tells us cache reads/writes/input/output
const usage = chunk.message.usage
yield {
type: "usage",
inputTokens: usage.input_tokens || 0,
outputTokens: usage.output_tokens || 0,
cacheWriteTokens: usage.cache_creation_input_tokens || undefined,
cacheReadTokens: usage.cache_read_input_tokens || undefined,
}
break
case "message_delta":
// tells us stop_reason, stop_sequence, and output tokens along the way and at the end of the message
yield {
type: "usage",
inputTokens: 0,
outputTokens: chunk.usage.output_tokens || 0,
}
break
case "message_stop":
// no usage data, just an indicator that the message is done
break
case "content_block_start":
switch (chunk.content_block.type) {
case "thinking":
yield {
type: "reasoning",
reasoning: chunk.content_block.thinking || "",
}
const thinking = chunk.content_block.thinking
const signature = chunk.content_block.signature
if (thinking && signature) {
yield {
type: "ant_thinking",
thinking,
signature,
}
}
break
case "redacted_thinking":
// Content is encrypted, and we don't want to pass placeholder text back to the API
yield {
type: "reasoning",
reasoning: "[Redacted thinking block]",
}
yield {
type: "ant_redacted_thinking",
data: chunk.content_block.data,
}
break
case "tool_use":
if (chunk.content_block.id && chunk.content_block.name) {
// Store tool call information for streaming
lastStartedToolCall.id = chunk.content_block.id
lastStartedToolCall.name = chunk.content_block.name
lastStartedToolCall.arguments = ""
}
break
case "text":
// we may receive multiple text blocks, in which case just insert a line break between them
if (chunk.index > 0) {
yield {
type: "text",
text: "\n",
}
}
yield {
type: "text",
text: chunk.content_block.text,
}
break
}
break
case "content_block_delta":
switch (chunk.delta.type) {
case "thinking_delta":
// 'reasoning' type just displays in the UI, but ant_thinking will be used to send the thinking traces back to the API
yield {
type: "reasoning",
reasoning: chunk.delta.thinking,
}
thinkingDeltaAccumulator += chunk.delta.thinking
break
case "signature_delta":
// It's used when sending the thinking block back to the API
// API expects this in completed form, not as array of deltas
if (thinkingDeltaAccumulator && chunk.delta.signature) {
yield {
type: "ant_thinking",
thinking: thinkingDeltaAccumulator,
signature: chunk.delta.signature,
}
}
break
case "text_delta":
yield {
type: "text",
text: chunk.delta.text,
}
break
case "input_json_delta":
if (lastStartedToolCall.id && lastStartedToolCall.name && chunk.delta.partial_json) {
// Convert Anthropic tool_use to OpenAI-compatible format for internal processing
yield {
type: "tool_calls",
tool_call: {
...lastStartedToolCall,
function: {
...lastStartedToolCall,
id: lastStartedToolCall.id,
name: lastStartedToolCall.name,
arguments: chunk.delta.partial_json,
},
},
}
}
break
}
break
if (delta && "reasoning_content" in delta && delta.reasoning_content) {
yield {
type: "reasoning",
reasoning: (delta.reasoning_content as string | undefined) || "",
}
}
if (delta?.tool_calls) {
yield* toolCallProcessor.processToolCallDeltas(delta.tool_calls)
}
if (chunk.usage) {
yield {
type: "usage",
inputTokens: chunk.usage.prompt_tokens || 0,
outputTokens: chunk.usage.completion_tokens || 0,
// @ts-ignore-next-line
cacheReadTokens: chunk.usage.prompt_cache_hit_tokens || 0,
// @ts-ignore-next-line
cacheWriteTokens: chunk.usage.prompt_cache_miss_tokens || 0,
}
case "content_block_stop":
lastStartedToolCall.id = ""
lastStartedToolCall.name = ""
lastStartedToolCall.arguments = ""
break
}
}
}
+3
View File
@@ -555,6 +555,7 @@ export class SapAiCoreHandler implements ApiHandler {
}
const anthropicModels = [
"anthropic--claude-4.5-sonnet",
"anthropic--claude-4-sonnet",
"anthropic--claude-4-opus",
"anthropic--claude-3.7-sonnet",
@@ -599,6 +600,7 @@ export class SapAiCoreHandler implements ApiHandler {
const secondLastMsgUserIndex = userMsgIndices[userMsgIndices.length - 2] ?? -1
if (
model.id === "anthropic--claude-4.5-sonnet" ||
model.id === "anthropic--claude-4-sonnet" ||
model.id === "anthropic--claude-4-opus" ||
model.id === "anthropic--claude-3.7-sonnet"
@@ -710,6 +712,7 @@ export class SapAiCoreHandler implements ApiHandler {
} else if (openAIModels.includes(model.id)) {
yield* this.streamCompletionGPT(response.data, model)
} else if (
model.id === "anthropic--claude-4.5-sonnet" ||
model.id === "anthropic--claude-4-sonnet" ||
model.id === "anthropic--claude-4-opus" ||
model.id === "anthropic--claude-3.7-sonnet"
@@ -434,13 +434,6 @@ export class ContextManager {
}
}
// Add any orphaned tool_results not in toolUseIds (shouldn't happen, but be safe)
for (const [toolUseId, toolResult] of toolResultMap) {
if (!processedToolResults.has(toolUseId)) {
newContent.push(toolResult)
}
}
// Add all other blocks
newContent.push(...otherBlocks)
@@ -464,6 +457,21 @@ export class ContextManager {
const secondChunk = messages.slice(startFromIndex) // get remaining messages within context
const messagesToUpdate = [...firstChunk, ...secondChunk]
// Remove orphaned tool_results from the first message after truncation (if it's a user message)
if (startFromIndex > 2 && messagesToUpdate.length > 2) {
const firstMessageAfterTruncation = messagesToUpdate[2]
if (firstMessageAfterTruncation.role === "user" && Array.isArray(firstMessageAfterTruncation.content)) {
const hasToolResults = firstMessageAfterTruncation.content.some((block) => block.type === "tool_result")
if (hasToolResults) {
// Clone and filter out all tool_result blocks
messagesToUpdate[2] = cloneDeep(firstMessageAfterTruncation)
;(messagesToUpdate[2].content as Anthropic.Messages.ContentBlockParam[]) = (
firstMessageAfterTruncation.content as Anthropic.Messages.ContentBlockParam[]
).filter((block) => block.type !== "tool_result")
}
}
}
// we need the mapping from the local indices in messagesToUpdate to the global array of updates in this.contextHistoryUpdates
const originalIndices = [
...Array(2).keys(),
@@ -153,5 +153,48 @@ describe("ContextManager", () => {
expect(result[1].role).to.equal("assistant")
expect(result[2].role).to.equal("user")
})
it("removes orphaned tool_results after truncation", () => {
// Create messages with tool_use and tool_result blocks
const messages: Anthropic.Messages.MessageParam[] = [
{ role: "user", content: "Initial task" },
{ role: "assistant", content: "Response 1" },
// Assistant message with tool_use that will be truncated
{
role: "assistant",
content: [
{ type: "text", text: "Using a tool" },
{ type: "tool_use", id: "tool_123", name: "read_file", input: { path: "test.ts" } },
],
},
// User message with tool_result - should have tool_result removed after truncation
{
role: "user",
content: [
{ type: "tool_result", tool_use_id: "tool_123", content: "file content here" },
{ type: "text", text: "Additional user text" },
],
},
{ role: "assistant", content: "Response 2" },
]
// Truncate to remove the assistant message with tool_use
const range: [number, number] = [2, 2]
const result = contextManager.getTruncatedMessages(messages, range)
// Should have 4 messages (original 5 minus 1 truncated)
expect(result).to.have.lengthOf(4)
// The user message at index 2 should have tool_result removed but text preserved
const userMessageAfterTruncation = result[2]
expect(userMessageAfterTruncation.role).to.equal("user")
expect(Array.isArray(userMessageAfterTruncation.content)).to.be.true
const content = userMessageAfterTruncation.content as Anthropic.Messages.ContentBlockParam[]
// Should only have the text block, not the tool_result
expect(content).to.have.lengthOf(1)
expect(content[0].type).to.equal("text")
expect((content[0] as Anthropic.Messages.TextBlockParam).text).to.equal("Additional user text")
})
})
})
@@ -40,7 +40,10 @@ export const getLocalClineRules = async (cwd: string, toggles: ClineRulesToggles
if (await fileExistsAtPath(clineRulesFilePath)) {
if (await isDirectory(clineRulesFilePath)) {
try {
const rulesFilePaths = await readDirectory(clineRulesFilePath, [[".clinerules", "workflows"]])
const rulesFilePaths = await readDirectory(clineRulesFilePath, [
[".clinerules", "workflows"],
[".clinerules", "hooks"],
])
const rulesFilesTotalContent = await getRuleFilesTotalContent(rulesFilePaths, cwd, toggles)
if (rulesFilesTotalContent) {
@@ -84,6 +87,7 @@ export async function refreshClineRulesToggles(
const localClineRulesFilePath = path.resolve(workingDirectory, GlobalFileNames.clineRules)
const updatedLocalToggles = await synchronizeRuleToggles(localClineRulesFilePath, localClineRulesToggles, "", [
[".clinerules", "workflows"],
[".clinerules", "hooks"],
])
controller.stateManager.setWorkspaceState("localClineRulesToggles", updatedLocalToggles)
+57 -5
View File
@@ -75,6 +75,9 @@ export class Controller {
private backgroundCommandRunning = false
private backgroundCommandTaskId?: string
// Flag to prevent duplicate cancellations from spam clicking
private cancelInProgress = false
// Shell integration warning tracker
private shellIntegrationWarningTracker: {
timestamps: number[]
@@ -333,6 +336,12 @@ export class Controller {
taskLockAcquired,
})
if (historyItem) {
this.task.resumeTaskFromHistory()
} else if (task || images || files) {
this.task.startTask(task, images, files)
}
return this.task.taskId
}
@@ -410,14 +419,28 @@ export class Controller {
}
async cancelTask() {
if (this.task) {
// Prevent duplicate cancellations from spam clicking
if (this.cancelInProgress) {
console.log(`[Controller.cancelTask] Cancellation already in progress, ignoring duplicate request`)
return
}
if (!this.task) {
return
}
// Set flag to prevent concurrent cancellations
this.cancelInProgress = true
try {
this.updateBackgroundCommandState(false)
const { historyItem } = await this.getTaskWithId(this.task.taskId)
try {
await this.task.abortTask()
} catch (error) {
console.error("Failed to abort task", error)
}
await pWaitFor(
() =>
this.task === undefined ||
@@ -430,13 +453,38 @@ export class Controller {
).catch(() => {
console.error("Failed to abort task")
})
if (this.task) {
// 'abandoned' will prevent this cline instance from affecting future cline instance gui. this may happen if its hanging on a streaming request
this.task.taskState.abandoned = true
}
await this.initTask(undefined, undefined, undefined, historyItem) // clears task again, so we need to abortTask manually above
// Dont send the state to the webview, the new Cline instance will send state when it's ready.
// Sending the state here sent an empty messages array to webview leading to virtuoso having to reload the entire list
// Small delay to ensure state manager has persisted the history update
//await new Promise((resolve) => setTimeout(resolve, 100))
// NOW try to get history after abort has finished (hook may have saved messages)
let historyItem: HistoryItem | undefined
try {
const result = await this.getTaskWithId(this.task.taskId)
historyItem = result.historyItem
} catch (error) {
// Task not in history yet (new task with no messages); catch the
// error to enable the agent to continue making progress.
console.log(`[Controller.cancelTask] Task not found in history: ${error}`)
}
// Only re-initialize if we found a history item, otherwise just clear
if (historyItem) {
// Re-initialize task to keep it visible in UI with resume button
await this.initTask(undefined, undefined, undefined, historyItem, undefined)
} else {
await this.clearTask()
}
await this.postStateToWebview()
} finally {
// Always clear the flag, even if cancellation fails
this.cancelInProgress = false
}
}
@@ -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
@@ -97,7 +97,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()
+12 -1
View File
@@ -327,7 +327,14 @@ export async function updateSettings(controller: Controller, request: UpdateSett
}
if (request.hooksEnabled !== undefined) {
controller.stateManager.setGlobalState("hooksEnabled", !!request.hooksEnabled)
const isEnabled = !!request.hooksEnabled
// Platform validation: Only allow enabling hooks on macOS and Linux
if (isEnabled && process.platform === "win32") {
throw new Error("Hooks are not yet supported on Windows")
}
controller.stateManager.setGlobalState("hooksEnabled", isEnabled)
}
if (request.subagentsEnabled !== undefined) {
@@ -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
-4
View File
@@ -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
+292
View File
@@ -0,0 +1,292 @@
import { getAllHooksDirs } from "../storage/disk"
import { HookFactory, Hooks } from "./hook-factory"
type HookName = keyof Hooks
/**
* Cached hook discovery results
*/
interface HookCacheEntry {
scriptPaths: string[] // Paths to hook scripts for this hook name
timestamp: number // When this was last scanned
}
/**
* Generic disposable interface for resource cleanup
*/
interface Disposable {
dispose(): void
}
/**
* Generic file watcher interface
*/
interface FileWatcher extends Disposable {
onDidCreate(listener: () => void): void
onDidChange(listener: () => void): void
onDidDelete(listener: () => void): void
}
/**
* Generic context interface for managing subscriptions
*/
interface ExtensionContext {
subscriptions: Disposable[]
}
/**
* Singleton cache for hook script discovery with lazy file system watching.
*
* Features:
* - Lazy watcher initialization (only when directories are accessed)
* - Per-directory caching
* - Automatic invalidation on file changes
* - Graceful error handling
* - Optional debug logging
*/
export class HookDiscoveryCache {
private static instance: HookDiscoveryCache | null = null
// Cache: hookName -> discovered script paths
private cache = new Map<HookName, HookCacheEntry>()
// Watchers: directory path -> file watcher
private watchers = new Map<string, FileWatcher>()
// Directories we've tried to watch (even if watcher creation failed)
private watchedDirs = new Set<string>()
// Currently scanning (to prevent concurrent scans)
private scanning = new Set<HookName>()
// For disposal
private context: ExtensionContext | null = null
private createFileWatcher: ((dir: string) => FileWatcher | null) | null = null
private disposed = false
// Debug logging (enabled via DEBUG_HOOKS env var)
private debug = process.env.DEBUG_HOOKS === "true"
private constructor() {}
static getInstance(): HookDiscoveryCache {
if (!HookDiscoveryCache.instance) {
HookDiscoveryCache.instance = new HookDiscoveryCache()
}
return HookDiscoveryCache.instance
}
/**
* Initialize with extension context for proper cleanup
*/
initialize(
context: ExtensionContext,
createFileWatcher?: (dir: string) => FileWatcher | null,
onWorkspaceFoldersChanged?: (callback: () => void) => Disposable,
): void {
this.context = context
this.createFileWatcher = createFileWatcher || null
// Watch for workspace changes to invalidate cache (if callback provided)
if (onWorkspaceFoldersChanged) {
context.subscriptions.push(
onWorkspaceFoldersChanged(() => {
this.log("Workspace folders changed, invalidating cache")
this.invalidateAll()
}),
)
}
}
/**
* Get cached hook scripts or scan if not cached
*/
async get(hookName: HookName): Promise<string[]> {
this.log(`Getting hooks for ${hookName}`)
const cached = this.cache.get(hookName)
if (cached) {
this.log(`Cache hit for ${hookName}: ${cached.scriptPaths.length} scripts`)
return cached.scriptPaths
}
this.log(`Cache miss for ${hookName}, scanning...`)
return this.scan(hookName)
}
/**
* Scan for hook scripts and cache the result
*/
private async scan(hookName: HookName): Promise<string[]> {
// Prevent concurrent scans of the same hook
if (this.scanning.has(hookName)) {
this.log(`Already scanning ${hookName}, waiting...`)
await new Promise((resolve) => setTimeout(resolve, 50))
return this.get(hookName)
}
this.scanning.add(hookName)
try {
// Get all current hooks directories
const hooksDirs = await getAllHooksDirs()
this.log(`Scanning ${hooksDirs.length} directories for ${hookName}`)
// Ensure watchers are set up for each directory (lazy initialization)
for (const dir of hooksDirs) {
this.ensureWatcher(dir)
}
// Scan each directory for this hook
const scriptPromises = hooksDirs.map((dir) => HookFactory.findHookInHooksDir(hookName, dir))
const results = await Promise.all(scriptPromises)
const scripts = results.filter((path): path is string => path !== undefined)
this.log(`Found ${scripts.length} scripts for ${hookName}`)
// Cache the result
this.cache.set(hookName, {
scriptPaths: scripts,
timestamp: Date.now(),
})
return scripts
} catch (error) {
console.error(`Error scanning for ${hookName} hooks:`, error)
// Return empty array on error - don't break the whole system
return []
} finally {
this.scanning.delete(hookName)
}
}
/**
* Ensure a file watcher exists for the given directory
*/
private ensureWatcher(dir: string): void {
// Skip if already watching or tried to watch
if (this.watchedDirs.has(dir)) {
return
}
this.watchedDirs.add(dir)
if (!this.context) {
this.log(`No context available, skipping watcher for ${dir}`)
return
}
// If no watcher creation function provided, skip watching
if (!this.createFileWatcher) {
this.log(`No watcher creator available, skipping watcher for ${dir}`)
return
}
try {
// Create watcher using the provided function
const watcher = this.createFileWatcher(dir)
if (!watcher) {
this.log(`Watcher creation returned null for ${dir}`)
return
}
// Invalidate cache on any change
const invalidate = () => {
this.log(`File change detected in ${dir}, invalidating cache`)
this.invalidateDirectory(dir)
}
watcher.onDidCreate(invalidate)
watcher.onDidChange(invalidate)
watcher.onDidDelete(invalidate)
// Add to context subscriptions for proper cleanup
if (this.context) {
this.context.subscriptions.push(watcher)
}
this.watchers.set(dir, watcher)
this.log(`Created watcher for ${dir}`)
} catch (error) {
// Log but don't fail - directory might not exist yet
this.log(`Failed to create watcher for ${dir}: ${error}`)
}
}
/**
* Invalidate all cached hooks that have scripts in this directory
*/
private invalidateDirectory(dir: string): void {
let invalidated = 0
for (const [hookName, entry] of this.cache) {
if (entry.scriptPaths.some((scriptPath) => scriptPath.startsWith(dir))) {
this.cache.delete(hookName)
invalidated++
}
}
this.log(`Invalidated ${invalidated} hooks for directory ${dir}`)
}
/**
* Invalidate entire cache
*/
invalidateAll(): void {
const size = this.cache.size
this.cache.clear()
this.log(`Invalidated entire cache (${size} entries)`)
}
/**
* Get cache statistics (for debugging/monitoring)
*/
getStats() {
return {
cacheSize: this.cache.size,
watcherCount: this.watchers.size,
watchedDirs: this.watchedDirs.size,
}
}
/**
* Log debug message if debug mode is enabled
*/
private log(message: string): void {
if (this.debug) {
console.log(`[HookCache] ${message}`)
}
}
/**
* Clean up resources
*/
dispose(): void {
if (this.disposed) {
return
}
this.log(`Disposing cache (${this.watchers.size} watchers)`)
for (const watcher of this.watchers.values()) {
watcher.dispose()
}
this.watchers.clear()
this.watchedDirs.clear()
this.cache.clear()
this.disposed = true
}
/**
* Reset singleton instance (for testing)
*/
static resetForTesting(): void {
if (HookDiscoveryCache.instance) {
HookDiscoveryCache.instance.dispose()
HookDiscoveryCache.instance = null
}
}
}
+123
View File
@@ -0,0 +1,123 @@
/**
* Types of errors that can occur during hook execution
*/
export enum HookErrorType {
/** Hook execution exceeded the timeout limit */
TIMEOUT = "timeout",
/** Hook output failed JSON validation */
VALIDATION = "validation",
/** Hook script execution failed (non-zero exit, crash, etc.) */
EXECUTION = "execution",
/** Hook was cancelled by user */
CANCELLATION = "cancellation",
}
/**
* Structured error information for hook failures.
* Provides both user-friendly messages and technical details.
*/
export interface HookErrorInfo {
/** Type of error that occurred */
type: HookErrorType
/** User-friendly error message */
message: string
/** Technical details for debugging (optional, shown in expansion) */
details?: string
/** Path to the hook script that failed */
scriptPath?: string
/** Exit code if available */
exitCode?: number
/** Stderr output if available */
stderr?: string
}
/**
* Error thrown during hook execution with structured information.
* This allows proper error handling without string parsing.
*/
export class HookExecutionError extends Error {
constructor(
public readonly errorInfo: HookErrorInfo,
message?: string,
) {
super(message || errorInfo.message)
this.name = "HookExecutionError"
}
/**
* Check if an error is a HookExecutionError
*/
static isHookError(error: unknown): error is HookExecutionError {
return error instanceof HookExecutionError
}
/**
* Create a timeout error
*/
static timeout(scriptPath: string, timeoutMs: number, stderr?: string, hookName?: string): HookExecutionError {
const hookPrefix = hookName ? `${hookName} hook` : "Hook"
return new HookExecutionError({
type: HookErrorType.TIMEOUT,
message: `${hookPrefix} execution timed out after ${timeoutMs}ms`,
details:
`The hook took longer than ${timeoutMs / 1000} seconds to complete.\n\n` +
`Common causes:\n` +
`• Infinite loop in hook script\n` +
`• Network request hanging without timeout\n` +
`• File I/O operation stuck\n` +
`• Heavy computation taking too long\n\n` +
`Recommendations:\n` +
`1. Check your hook script for infinite loops\n` +
`2. Add timeouts to network requests\n` +
`3. Use background jobs for long operations\n` +
`4. Test your hook script independently`,
scriptPath,
stderr,
})
}
/**
* Create a validation error
*/
static validation(validationError: string, scriptPath: string, stdoutPreview: string): HookExecutionError {
return new HookExecutionError({
type: HookErrorType.VALIDATION,
message: "Hook output validation failed",
details: `${validationError}\n\nOutput preview:\n${stdoutPreview}`,
scriptPath,
})
}
/**
* Create an execution error
*/
static execution(scriptPath: string, exitCode: number, stderr?: string, hookName?: string): HookExecutionError {
const hookPrefix = hookName ? `${hookName} hook` : "Hook script"
const message = `${hookPrefix} exited with code ${exitCode}`
return new HookExecutionError(
{
type: HookErrorType.EXECUTION,
message,
details: stderr ? `stderr:\n${stderr}` : undefined,
scriptPath,
exitCode,
stderr,
},
message,
)
}
/**
* Create a cancellation error
*/
static cancellation(scriptPath: string, hookName?: string): HookExecutionError {
const hookPrefix = hookName ? `${hookName} hook` : "Hook"
return new HookExecutionError({
type: HookErrorType.CANCELLATION,
message: `${hookPrefix} execution was cancelled`,
details: "The hook was cancelled by the user before completion",
scriptPath,
exitCode: 130, // Standard SIGINT exit code
})
}
}
+376
View File
@@ -0,0 +1,376 @@
import { ChildProcess, spawn } from "child_process"
import { EventEmitter } from "events"
import { HookProcessRegistry } from "./HookProcessRegistry"
import { escapeShellPath } from "./shell-escape"
// 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
const escapedScriptPath = escapeShellPath(this.scriptPath)
this.childProcess = spawn(escapedScriptPath, [], {
stdio: ["pipe", "pipe", "pipe"],
shell: true, // Use shell on all platforms for shebang interpretation
detached: process.platform !== "win32", // Create process group on Unix
})
let didEmitEmptyLine = false
// Set up timeout
this.timeoutHandle = setTimeout(() => {
if (this.childProcess && !this.isCompleted) {
this.childProcess.kill("SIGTERM")
reject(
new Error(
`Hook execution timed out after ${this.timeoutMs}ms. The hook script at '${this.scriptPath}' took too long to complete.`,
),
)
}
}, this.timeoutMs)
// Handle stdout
this.childProcess.stdout?.on("data", (data) => {
const output = data.toString()
this.stdoutBuffer += output
this.handleOutput(output, didEmitEmptyLine, "stdout")
if (!didEmitEmptyLine && output) {
this.emit("line", "", "stdout") // Signal start of output
didEmitEmptyLine = true
}
})
// Handle stderr
this.childProcess.stderr?.on("data", (data) => {
const output = data.toString()
this.stderrBuffer += output
this.handleOutput(output, didEmitEmptyLine, "stderr")
if (!didEmitEmptyLine && output) {
this.emit("line", "", "stderr") // Signal start of output
didEmitEmptyLine = true
}
})
// Handle process completion
this.childProcess.on("close", (code, signal) => {
this.exitCode = code
this.isCompleted = true
this.emitRemainingBuffer()
// Unregister from active processes
this.safeUnregister()
// Clear execution timeout timer
if (this.timeoutHandle) {
clearTimeout(this.timeoutHandle)
this.timeoutHandle = null
}
// Remove abort listener
if (this.abortSignal) {
this.abortSignal.removeEventListener("abort", abortHandler)
}
this.emit("completed", code, signal)
if (code === 0) {
resolve()
} else {
reject(new Error(`Hook exited with code ${code}${signal ? `, signal ${signal}` : ""}`))
}
})
// Handle process errors
this.childProcess.on("error", (error) => {
// Unregister from active processes
this.safeUnregister()
if (this.timeoutHandle) {
clearTimeout(this.timeoutHandle)
this.timeoutHandle = null
}
// Remove abort listener
if (this.abortSignal) {
this.abortSignal.removeEventListener("abort", abortHandler)
}
this.emit("error", error)
reject(error)
})
// Send input to the process
try {
this.childProcess.stdin?.write(inputJson)
this.childProcess.stdin?.end()
} catch (error) {
reject(new Error(`Failed to write input to hook: ${error}`))
}
})
} finally {
// Guaranteed cleanup even if process setup fails or throws
this.safeUnregister()
}
}
/**
* Safely unregister from the process registry.
* This is idempotent and prevents double-unregistration issues.
*/
private safeUnregister(): void {
if (this.isRegistered) {
HookProcessRegistry.unregister(this)
this.isRegistered = false
}
}
/**
* Handle output data and emit line events.
* Enforces 1MB total output limit to prevent memory issues.
*/
private handleOutput(data: string, _didEmitEmptyLine: boolean, stream: "stdout" | "stderr"): void {
// Check output size limit
const dataSize = Buffer.byteLength(data)
const currentTotalSize = this.stdoutSize + this.stderrSize
if (currentTotalSize + dataSize > MAX_HOOK_OUTPUT_SIZE) {
if (!this.outputTruncated) {
this.outputTruncated = true
const truncationMsg = "\n\n[Output truncated: exceeded 1MB limit]"
this.emit("line", truncationMsg, stream)
console.warn(`[HookProcess] Output exceeded ${MAX_HOOK_OUTPUT_SIZE} bytes, truncating`)
}
return // Drop further output
}
// Track size by stream
if (stream === "stdout") {
this.stdoutSize += dataSize
} else {
this.stderrSize += dataSize
}
// Store full output
this.fullOutput += data
// Emit lines immediately
this.emitLines(data, stream)
}
/**
* Emit complete lines from buffered output
*/
private emitLines(chunk: string, stream: "stdout" | "stderr"): void {
this.buffer += chunk
let lineEndIndex
while ((lineEndIndex = this.buffer.indexOf("\n")) !== -1) {
const line = this.buffer.slice(0, lineEndIndex).trimEnd()
this.emit("line", line, stream)
this.buffer = this.buffer.slice(lineEndIndex + 1)
}
this.lastRetrievedIndex = this.fullOutput.length - this.buffer.length
}
/**
* Emit any remaining buffered output when process completes
*/
private emitRemainingBuffer(): void {
if (this.buffer) {
const remainingBuffer = this.buffer.trimEnd()
if (remainingBuffer) {
// Determine which stream this came from based on content
// This is a fallback; in practice, line events should capture most output
this.emit("line", remainingBuffer, "stdout")
}
this.buffer = ""
this.lastRetrievedIndex = this.fullOutput.length
}
}
/**
* Get unretrieved output (for compatibility with terminal process interface)
*/
getUnretrievedOutput(): string {
const unretrieved = this.fullOutput.slice(this.lastRetrievedIndex)
this.lastRetrievedIndex = this.fullOutput.length
return unretrieved.trimEnd()
}
/**
* Get the complete stdout buffer (for JSON parsing)
*/
getStdout(): string {
return this.stdoutBuffer
}
/**
* Get the complete stderr buffer (for error reporting)
*/
getStderr(): string {
return this.stderrBuffer
}
/**
* Get the exit code
*/
getExitCode(): number | null {
return this.exitCode
}
/**
* Check if process has completed
*/
hasCompleted(): boolean {
return this.isCompleted
}
/**
* Terminate the process and its entire process tree.
* Uses process groups on Unix to kill child processes.
* Implements graceful shutdown with 2-second timeout before force kill.
*/
async terminate(): Promise<void> {
if (!this.childProcess || this.isCompleted) {
// Still ensure unregistration even if process already completed
this.safeUnregister()
return
}
const pid = this.childProcess.pid
if (!pid) {
return
}
try {
// On Unix, kill process group (negative PID kills all children)
// On Windows, just kill the process (tree-kill would be better but adds dependency)
if (process.platform !== "win32") {
// Kill process group with SIGTERM for graceful shutdown
process.kill(-pid, "SIGTERM")
} else {
// On Windows, just kill the process
this.childProcess.kill("SIGTERM")
}
// Wait up to 2 seconds for graceful shutdown
const gracefulTimeout = new Promise((resolve) => setTimeout(resolve, 2000))
const processExit = new Promise((resolve) => {
this.childProcess?.once("exit", resolve)
})
await Promise.race([processExit, gracefulTimeout])
// Force kill if still running
if (!this.isCompleted) {
if (process.platform !== "win32") {
process.kill(-pid, "SIGKILL")
} else {
this.childProcess?.kill("SIGKILL")
}
}
} catch (error) {
// Process might already be dead, which is fine
console.debug(`[HookProcess] Error during termination: ${error}`)
} finally {
// Clear timeout regardless
if (this.timeoutHandle) {
clearTimeout(this.timeoutHandle)
this.timeoutHandle = null
}
// Ensure unregistration even if termination fails
this.safeUnregister()
}
}
}
+63
View File
@@ -0,0 +1,63 @@
import { HookProcess } from "./HookProcess"
/**
* Global registry for tracking active hook processes.
*
* Purpose:
* - Prevents zombie processes by tracking all running hooks
* - Enables cleanup on extension deactivation
* - Provides visibility into active hook executions
*
* Usage:
* - HookProcess automatically registers/unregisters itself
* - Extension deactivation calls terminateAll()
* - Can query active count for monitoring/debugging
*/
export class HookProcessRegistry {
private static activeProcesses = new Set<HookProcess>()
/**
* Register a hook process as active.
* Called by HookProcess when execution starts.
*/
static register(process: HookProcess): void {
HookProcessRegistry.activeProcesses.add(process)
}
/**
* Unregister a hook process (completed or failed).
* Called by HookProcess when execution ends.
*/
static unregister(process: HookProcess): void {
HookProcessRegistry.activeProcesses.delete(process)
}
/**
* Terminate all active hook processes.
* Called during extension deactivation to prevent zombie processes.
*/
static async terminateAll(): Promise<void> {
const processes = Array.from(HookProcessRegistry.activeProcesses)
if (processes.length > 0) {
console.log(`[HookProcessRegistry] Terminating ${processes.length} active hook process(es)`)
await Promise.all(processes.map((p) => p.terminate()))
HookProcessRegistry.activeProcesses.clear()
}
}
/**
* Get the number of currently active hook processes.
* Useful for monitoring and debugging.
*/
static getActiveCount(): number {
return HookProcessRegistry.activeProcesses.size
}
/**
* Clear the registry (for testing only).
* @internal
*/
static resetForTesting(): void {
HookProcessRegistry.activeProcesses.clear()
}
}
+15 -15
View File
@@ -37,7 +37,7 @@ it("should work with real hook", async () => {
const runner = await factory.create("PreToolUse")
const result = await runner.run(buildPreToolUseInput({ toolName: "test_tool" }))
result.shouldContinue.should.be.true()
result.cancel.should.be.false()
})
```
@@ -50,15 +50,15 @@ For more control, you can also manually copy fixture files.
### PreToolUse Hooks
#### `hooks/pretooluse/success`
- **Returns**: `{ shouldContinue: true, contextModification: "PreToolUse hook executed successfully", errorMessage: "" }`
- **Returns**: `{ cancel: false, contextModification: "PreToolUse hook executed successfully", errorMessage: "" }`
- **Use for**: Testing happy path scenarios
#### `hooks/pretooluse/blocking`
- **Returns**: `{ shouldContinue: false, contextModification: "", errorMessage: "Tool execution blocked by hook" }`
- **Returns**: `{ cancel: true, contextModification: "", errorMessage: "Tool execution blocked by hook" }`
- **Use for**: Testing tool execution blocking
#### `hooks/pretooluse/context-injection`
- **Returns**: `{ shouldContinue: true, contextModification: "WORKSPACE_RULES: Tool [toolName] requires review", errorMessage: "" }`
- **Returns**: `{ cancel: false, contextModification: "WORKSPACE_RULES: Tool [toolName] requires review", errorMessage: "" }`
- **Use for**: Testing context injection with type prefixes
- **Note**: Dynamically includes tool name from input
@@ -69,7 +69,7 @@ For more control, you can also manually copy fixture files.
### PostToolUse Hooks
#### `hooks/posttooluse/success`
- **Returns**: `{ shouldContinue: true, contextModification: "PostToolUse hook executed successfully", errorMessage: "" }`
- **Returns**: `{ cancel: false, contextModification: "PostToolUse hook executed successfully", errorMessage: "" }`
- **Use for**: Testing PostToolUse execution
#### `hooks/posttooluse/error`
@@ -79,34 +79,34 @@ For more control, you can also manually copy fixture files.
### UserPromptSubmit Hooks
#### `hooks/userpromptsubmit/success`
- **Returns**: `{ shouldContinue: true, contextModification: "Prompt approved", errorMessage: "" }`
- **Returns**: `{ cancel: false, contextModification: "Prompt approved", errorMessage: "" }`
- **Use for**: Testing successful prompt submission
#### `hooks/userpromptsubmit/blocking`
- **Returns**: `{ shouldContinue: false, contextModification: "", errorMessage: "Prompt violates policy" }`
- **Returns**: `{ cancel: true, contextModification: "", errorMessage: "Prompt violates policy" }`
- **Use for**: Testing prompt submission blocking
#### `hooks/userpromptsubmit/context-injection`
- **Returns**: `{ shouldContinue: true, contextModification: "CONTEXT_INJECTION: User is in plan mode", errorMessage: "" }`
- **Returns**: `{ cancel: false, contextModification: "CONTEXT_INJECTION: User is in plan mode", errorMessage: "" }`
- **Use for**: Testing context injection into task request
#### `hooks/userpromptsubmit/multiline`
- **Returns**: `{ shouldContinue: true, contextModification: "Line count: N", errorMessage: "" }`
- **Returns**: `{ cancel: false, contextModification: "Line count: N", errorMessage: "" }`
- **Use for**: Testing multiline prompt handling
- **Note**: Dynamically counts newlines in the prompt
#### `hooks/userpromptsubmit/large-prompt`
- **Returns**: `{ shouldContinue: true, contextModification: "Prompt size: N", errorMessage: "" }`
- **Returns**: `{ cancel: false, contextModification: "Prompt size: N", errorMessage: "" }`
- **Use for**: Testing large prompt handling
- **Note**: Dynamically reports prompt character count
#### `hooks/userpromptsubmit/special-chars`
- **Returns**: `{ shouldContinue: true, contextModification: "Special chars preserved" | "Missing special chars", errorMessage: "" }`
- **Returns**: `{ cancel: false, contextModification: "Special chars preserved" | "Missing special chars", errorMessage: "" }`
- **Use for**: Testing special character preservation
- **Note**: Checks for @, #, and $ characters
#### `hooks/userpromptsubmit/empty-prompt`
- **Returns**: `{ shouldContinue: true, contextModification: "Prompt length: 0", errorMessage: "" }`
- **Returns**: `{ cancel: false, contextModification: "Prompt length: 0", errorMessage: "" }`
- **Use for**: Testing empty prompt handling
- **Note**: Safely handles undefined or empty prompts
@@ -121,11 +121,11 @@ For more control, you can also manually copy fixture files.
### TaskStart Hooks
#### `hooks/taskstart/success`
- **Returns**: `{ shouldContinue: true, contextModification: "TaskStart hook executed successfully", errorMessage: "" }`
- **Returns**: `{ cancel: false, contextModification: "TaskStart hook executed successfully", errorMessage: "" }`
- **Use for**: Testing TaskStart hook success path, allowing task to proceed
#### `hooks/taskstart/blocking`
- **Returns**: `{ shouldContinue: false, contextModification: "", errorMessage: "Task execution blocked by hook" }`
- **Returns**: `{ cancel: true, contextModification: "", errorMessage: "Task execution blocked by hook" }`
- **Use for**: Testing task blocking at start (e.g., policy enforcement)
#### `hooks/taskstart/error`
@@ -158,7 +158,7 @@ cat > src/core/hooks/__tests__/fixtures/hooks/pretooluse/my-new-scenario/PreTool
#!/usr/bin/env node
const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
console.log(JSON.stringify({
shouldContinue: true,
cancel: false,
contextModification: "My custom context",
errorMessage: ""
}));
@@ -1,7 +1,7 @@
#!/usr/bin/env node
const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
console.log(JSON.stringify({
shouldContinue: true,
cancel: false,
contextModification: "PostToolUse hook executed successfully",
errorMessage: ""
}));
@@ -1,7 +1,7 @@
#!/usr/bin/env node
const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
console.log(JSON.stringify({
shouldContinue: false,
cancel: true,
contextModification: "",
errorMessage: "Tool execution blocked by hook"
}));
@@ -2,7 +2,7 @@
const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
const toolName = input.preToolUse?.toolName || 'unknown';
console.log(JSON.stringify({
shouldContinue: true,
cancel: false,
contextModification: `WORKSPACE_RULES: Tool ${toolName} requires review`,
errorMessage: ""
}));
@@ -1,7 +1,7 @@
#!/usr/bin/env node
const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
console.log(JSON.stringify({
shouldContinue: true,
cancel: false,
contextModification: "PreToolUse hook executed successfully",
errorMessage: ""
}));
@@ -2,6 +2,6 @@
// Note: For TaskCancel, contextModification is completely ignored.
console.log(JSON.stringify({
shouldContinue: false,
cancel: true,
errorMessage: ""
}));
@@ -2,6 +2,6 @@
// Note: For TaskCancel, contextModification is completely ignored.
console.log(JSON.stringify({
shouldContinue: false,
cancel: true,
errorMessage: "some error happened"
}));
@@ -2,6 +2,6 @@
// Note: For TaskCancel, contextModification is completely ignored.
console.log(JSON.stringify({
shouldContinue: true,
cancel: false,
errorMessage: ""
}));
@@ -2,6 +2,6 @@
// Note: For TaskCancel, contextModification is completely ignored.
console.log(JSON.stringify({
shouldContinue: true,
cancel: false,
errorMessage: "some error happened"
}));
@@ -3,7 +3,7 @@ const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
const deleted = input.taskResume?.previousState?.conversationHistoryDeleted === 'true';
console.log(JSON.stringify({
shouldContinue: true,
cancel: false,
contextModification: deleted
? "TASK_CONTEXT: Some conversation history was truncated due to context window limits"
: "",
@@ -3,7 +3,7 @@ const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
const taskId = input.taskResume?.taskMetadata?.taskId || 'unknown';
console.log(JSON.stringify({
shouldContinue: true,
cancel: false,
contextModification: `WORKSPACE_RULES: Task ${taskId} resumed - review previous context`,
errorMessage: ""
}));
@@ -5,7 +5,7 @@ const now = Date.now();
const hoursAgo = Math.floor((now - lastMessageTs) / 3600000);
console.log(JSON.stringify({
shouldContinue: true,
cancel: false,
contextModification: hoursAgo >= 1
? `TASK_CONTEXT: Task was paused ${hoursAgo} hours ago - you may need to re-familiarize yourself with the context`
: "",
@@ -3,7 +3,7 @@ const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
const messageCount = parseInt(input.taskResume?.previousState?.messageCount || '0');
console.log(JSON.stringify({
shouldContinue: true,
cancel: false,
contextModification: `TASK_CONTEXT: Resuming task with ${messageCount} previous messages`,
errorMessage: ""
}));
@@ -5,7 +5,7 @@ const now = Date.now();
const minutesAgo = Math.floor((now - lastMessageTs) / 60000);
console.log(JSON.stringify({
shouldContinue: true,
cancel: false,
contextModification: minutesAgo < 5
? "TASK_CONTEXT: Recently paused task - context is still fresh"
: "",
@@ -2,7 +2,7 @@
const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
console.log(JSON.stringify({
shouldContinue: true,
cancel: false,
contextModification: "TaskResume hook executed successfully",
errorMessage: ""
}));
@@ -2,7 +2,7 @@
const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
console.log(JSON.stringify({
shouldContinue: false,
cancel: true,
contextModification: "",
errorMessage: "Task execution blocked by hook"
}));
@@ -2,7 +2,7 @@
const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
console.log(JSON.stringify({
shouldContinue: true,
cancel: false,
contextModification: "TaskStart hook executed successfully",
errorMessage: ""
}));
@@ -1,7 +1,7 @@
#!/usr/bin/env node
const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
console.log(JSON.stringify({
shouldContinue: false,
cancel: true,
contextModification: "",
errorMessage: "Prompt violates policy"
}));
@@ -1,7 +1,7 @@
#!/usr/bin/env node
const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
console.log(JSON.stringify({
shouldContinue: true,
cancel: false,
contextModification: "CONTEXT_INJECTION: User is in plan mode",
errorMessage: ""
}));
@@ -2,7 +2,7 @@
const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
const promptLength = typeof input.userPromptSubmit.prompt === 'string' ? input.userPromptSubmit.prompt.length : 0;
console.log(JSON.stringify({
shouldContinue: true,
cancel: false,
contextModification: "Prompt length: " + promptLength,
errorMessage: ""
}));
@@ -2,7 +2,7 @@
const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
const size = input.userPromptSubmit.prompt.length;
console.log(JSON.stringify({
shouldContinue: true,
cancel: false,
contextModification: "Prompt size: " + size,
errorMessage: ""
}));
@@ -2,7 +2,7 @@
const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
const lineCount = (input.userPromptSubmit.prompt.match(/\n/g) || []).length + 1;
console.log(JSON.stringify({
shouldContinue: true,
cancel: false,
contextModification: "Line count: " + lineCount,
errorMessage: ""
}));
@@ -3,7 +3,7 @@ const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
const prompt = input.userPromptSubmit.prompt;
const hasSpecialChars = prompt.includes("@") && prompt.includes("#") && prompt.includes("$");
console.log(JSON.stringify({
shouldContinue: true,
cancel: false,
contextModification: hasSpecialChars ? "Special chars preserved" : "Missing special chars",
errorMessage: ""
}));
@@ -1,7 +1,7 @@
#!/usr/bin/env node
const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
console.log(JSON.stringify({
shouldContinue: true,
cancel: false,
contextModification: "Prompt approved",
errorMessage: ""
}));
@@ -59,7 +59,7 @@ try {
// Error handling - hooks should handle their own errors gracefully
const errorMessage = error instanceof Error ? error.message : String(error);
console.log(JSON.stringify({
shouldContinue: false,
cancel: true,
contextModification: "",
errorMessage: `HOOK_ERROR: ${errorMessage}`
}));
+51 -42
View File
@@ -40,10 +40,19 @@ describe("Hook System", () => {
sandbox.stub(StateManager, "get").returns({
getGlobalStateKey: () => [{ path: tempDir }],
} as any)
// Reset hook discovery cache for clean test state
const { HookDiscoveryCache } = await import("../HookDiscoveryCache")
HookDiscoveryCache.resetForTesting()
})
afterEach(async () => {
sandbox.restore()
// Clean up hook discovery cache
const { HookDiscoveryCache } = await import("../HookDiscoveryCache")
HookDiscoveryCache.resetForTesting()
try {
await fs.rm(tempDir, { recursive: true, force: true })
} catch (error) {
@@ -64,7 +73,7 @@ describe("Hook System", () => {
},
})
result.shouldContinue.should.be.true()
result.cancel.should.be.false()
;(result.contextModification === undefined || result.contextModification === "").should.be.true()
})
})
@@ -76,7 +85,7 @@ describe("Hook System", () => {
const hookScript = `#!/usr/bin/env node
const input = require('fs').readFileSync(0, 'utf-8');
console.log(JSON.stringify({
shouldContinue: true,
cancel: false,
contextModification: "TEST_CONTEXT: Added by hook"
}))`
@@ -94,7 +103,7 @@ console.log(JSON.stringify({
},
})
result.shouldContinue.should.be.true()
result.cancel.should.be.false()
result.contextModification!.should.equal("TEST_CONTEXT: Added by hook")
})
@@ -102,7 +111,7 @@ console.log(JSON.stringify({
const hookPath = path.join(tempDir, ".clinerules", "hooks", "PreToolUse")
const hookScript = `#!/usr/bin/env node
console.log(JSON.stringify({
shouldContinue: false,
cancel: true,
errorMessage: "Hook blocked execution"
}))`
@@ -119,7 +128,7 @@ console.log(JSON.stringify({
},
})
result.shouldContinue.should.be.false()
result.cancel.should.be.true()
result.errorMessage!.should.equal("Hook blocked execution")
})
@@ -129,7 +138,7 @@ console.log(JSON.stringify({
const largeContext = "x".repeat(60000)
const hookScript = `#!/usr/bin/env node
console.log(JSON.stringify({
shouldContinue: true,
cancel: false,
contextModification: "${largeContext}"
}))`
@@ -184,18 +193,18 @@ console.log("not valid json")`
const factory = new HookFactory()
const runner = await factory.create("PreToolUse")
try {
await runner.run({
taskId: "test-task",
preToolUse: {
toolName: "test_tool",
parameters: {},
},
})
throw new Error("Should have thrown")
} catch (error: any) {
error.message.should.match(/Failed to parse hook output/)
}
// When hook exits 0 but has malformed JSON, it returns success without context
const result = await runner.run({
taskId: "test-task",
preToolUse: {
toolName: "test_tool",
parameters: {},
},
})
// Hook succeeded (exit 0) but couldn't parse JSON, so returns success without context
result.cancel.should.be.false()
;(result.contextModification === undefined || result.contextModification === "").should.be.true()
})
it("should pass hook input via stdin", async () => {
@@ -203,7 +212,7 @@ console.log("not valid json")`
const hookScript = `#!/usr/bin/env node
const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
console.log(JSON.stringify({
shouldContinue: true,
cancel: false,
contextModification: "Received tool: " + input.preToolUse.toolName
}))`
@@ -230,7 +239,7 @@ console.log(JSON.stringify({
const hookScript = `#!/usr/bin/env node
const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
console.log(JSON.stringify({
shouldContinue: true,
cancel: false,
contextModification: "Tool succeeded: " + input.postToolUse.success
}))`
@@ -258,7 +267,7 @@ console.log(JSON.stringify({
it("should find executable hook", async () => {
const hookPath = path.join(tempDir, ".clinerules", "hooks", "PreToolUse")
const hookScript = `#!/usr/bin/env node
console.log(JSON.stringify({ shouldContinue: true }))`
console.log(JSON.stringify({ cancel: false }))`
await fs.writeFile(hookPath, hookScript)
await fs.chmod(hookPath, 0o755)
@@ -275,13 +284,13 @@ console.log(JSON.stringify({ shouldContinue: true }))`
},
})
result.shouldContinue.should.be.true()
result.cancel.should.be.false()
})
it("should not find non-executable file", async () => {
const hookPath = path.join(tempDir, ".clinerules", "hooks", "PreToolUse")
const hookScript = `#!/usr/bin/env node
console.log(JSON.stringify({ shouldContinue: true }))`
console.log(JSON.stringify({ cancel: false }))`
// Write but don't make executable
await fs.writeFile(hookPath, hookScript)
@@ -301,7 +310,7 @@ console.log(JSON.stringify({ shouldContinue: true }))`
})
// NoOpRunner always returns success
result.shouldContinue.should.be.true()
result.cancel.should.be.false()
})
it("should handle missing hooks gracefully", async () => {
@@ -318,7 +327,7 @@ console.log(JSON.stringify({ shouldContinue: true }))`
},
})
result.shouldContinue.should.be.true()
result.cancel.should.be.false()
})
})
@@ -337,7 +346,7 @@ console.log(JSON.stringify({ shouldContinue: true }))`
},
})
result.shouldContinue.should.be.true()
result.cancel.should.be.false()
})
it("should handle hook input with all parameters", async () => {
@@ -347,7 +356,7 @@ const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
const hasAllFields = input.clineVersion && input.hookName && input.timestamp &&
input.taskId && input.workspaceRoots !== undefined;
console.log(JSON.stringify({
shouldContinue: true,
cancel: false,
contextModification: hasAllFields ? "All fields present" : "Missing fields"
}))`
@@ -394,7 +403,7 @@ console.log(JSON.stringify({
const globalHookScript = `#!/usr/bin/env node
const input = require('fs').readFileSync(0, 'utf-8');
console.log(JSON.stringify({
shouldContinue: true,
cancel: false,
contextModification: "GLOBAL: Context added"
}))`
await writeHookScript(globalHookPath, globalHookScript)
@@ -404,7 +413,7 @@ console.log(JSON.stringify({
const workspaceHookScript = `#!/usr/bin/env node
const input = require('fs').readFileSync(0, 'utf-8');
console.log(JSON.stringify({
shouldContinue: true,
cancel: false,
contextModification: "WORKSPACE: Context added"
}))`
await writeHookScript(workspaceHookPath, workspaceHookScript)
@@ -418,7 +427,7 @@ console.log(JSON.stringify({
})
// Both contexts should be present (order not guaranteed)
result.shouldContinue.should.be.true()
result.cancel.should.be.false()
result.contextModification!.should.match(/GLOBAL: Context added/)
result.contextModification!.should.match(/WORKSPACE: Context added/)
})
@@ -428,7 +437,7 @@ console.log(JSON.stringify({
const globalHookPath = path.join(globalHooksDir, "PreToolUse")
const globalHookScript = `#!/usr/bin/env node
console.log(JSON.stringify({
shouldContinue: false,
cancel: true,
errorMessage: "Global policy violation"
}))`
await writeHookScript(globalHookPath, globalHookScript)
@@ -437,7 +446,7 @@ console.log(JSON.stringify({
const workspaceHookPath = path.join(tempDir, ".clinerules", "hooks", "PreToolUse")
const workspaceHookScript = `#!/usr/bin/env node
console.log(JSON.stringify({
shouldContinue: true
cancel: false
}))`
await writeHookScript(workspaceHookPath, workspaceHookScript)
@@ -448,7 +457,7 @@ console.log(JSON.stringify({
preToolUse: { toolName: "test_tool", parameters: {} },
})
result.shouldContinue.should.be.false()
result.cancel.should.be.true()
result.errorMessage!.should.match(/Global policy violation/)
})
@@ -457,7 +466,7 @@ console.log(JSON.stringify({
const globalHookPath = path.join(globalHooksDir, "PreToolUse")
const globalHookScript = `#!/usr/bin/env node
console.log(JSON.stringify({
shouldContinue: true,
cancel: false,
contextModification: "Global hook only"
}))`
await writeHookScript(globalHookPath, globalHookScript)
@@ -469,7 +478,7 @@ console.log(JSON.stringify({
preToolUse: { toolName: "test_tool", parameters: {} },
})
result.shouldContinue.should.be.true()
result.cancel.should.be.false()
result.contextModification!.should.equal("Global hook only")
})
@@ -478,7 +487,7 @@ console.log(JSON.stringify({
const globalHookPath = path.join(globalHooksDir, "PreToolUse")
const globalHookScript = `#!/usr/bin/env node
console.log(JSON.stringify({
shouldContinue: true,
cancel: false,
contextModification: "Global allows"
}))`
await writeHookScript(globalHookPath, globalHookScript)
@@ -487,7 +496,7 @@ console.log(JSON.stringify({
const workspaceHookPath = path.join(tempDir, ".clinerules", "hooks", "PreToolUse")
const workspaceHookScript = `#!/usr/bin/env node
console.log(JSON.stringify({
shouldContinue: false,
cancel: true,
errorMessage: "Workspace blocks"
}))`
await writeHookScript(workspaceHookPath, workspaceHookScript)
@@ -499,7 +508,7 @@ console.log(JSON.stringify({
preToolUse: { toolName: "test_tool", parameters: {} },
})
result.shouldContinue.should.be.false()
result.cancel.should.be.true()
result.errorMessage!.should.match(/Workspace blocks/)
// Context from global should still be included
result.contextModification!.should.match(/Global allows/)
@@ -510,7 +519,7 @@ console.log(JSON.stringify({
const globalHookPath = path.join(globalHooksDir, "PreToolUse")
const globalHookScript = `#!/usr/bin/env node
console.log(JSON.stringify({
shouldContinue: false,
cancel: true,
errorMessage: "Global error"
}))`
await writeHookScript(globalHookPath, globalHookScript)
@@ -519,7 +528,7 @@ console.log(JSON.stringify({
const workspaceHookPath = path.join(tempDir, ".clinerules", "hooks", "PreToolUse")
const workspaceHookScript = `#!/usr/bin/env node
console.log(JSON.stringify({
shouldContinue: false,
cancel: true,
errorMessage: "Workspace error"
}))`
await writeHookScript(workspaceHookPath, workspaceHookScript)
@@ -531,7 +540,7 @@ console.log(JSON.stringify({
preToolUse: { toolName: "test_tool", parameters: {} },
})
result.shouldContinue.should.be.false()
result.cancel.should.be.true()
result.errorMessage!.should.match(/Global error/)
result.errorMessage!.should.match(/Workspace error/)
})
@@ -542,7 +551,7 @@ console.log(JSON.stringify({
const globalHookScript = `#!/usr/bin/env node
const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
console.log(JSON.stringify({
shouldContinue: true,
cancel: false,
contextModification: "Global observed: " + input.postToolUse.success
}))`
await writeHookScript(globalHookPath, globalHookScript)
@@ -0,0 +1,111 @@
import { afterEach, beforeEach, describe, it } from "mocha"
import "should"
import sinon from "sinon"
import { getHooksEnabledSafe } from "../hooks-utils"
describe("hooks-utils", () => {
let sandbox: sinon.SinonSandbox
let originalPlatform: string
beforeEach(() => {
sandbox = sinon.createSandbox()
originalPlatform = process.platform
})
afterEach(() => {
sandbox.restore()
// Restore original platform
Object.defineProperty(process, "platform", {
value: originalPlatform,
writable: true,
configurable: true,
})
})
describe("getHooksEnabledSafe", () => {
describe("on Windows platform", () => {
beforeEach(() => {
// Mock Windows platform
Object.defineProperty(process, "platform", {
value: "win32",
writable: true,
configurable: true,
})
})
it("should return false when user setting is true", () => {
const result = getHooksEnabledSafe(true)
result.should.be.false()
})
it("should return false when user setting is false", () => {
const result = getHooksEnabledSafe(false)
result.should.be.false()
})
it("should return false when user setting is undefined", () => {
const result = getHooksEnabledSafe(undefined)
result.should.be.false()
})
})
describe("on non-Windows platforms", () => {
const platforms = ["darwin", "linux", "freebsd", "openbsd", "sunos", "aix"]
platforms.forEach((platform) => {
describe(`on ${platform}`, () => {
beforeEach(() => {
Object.defineProperty(process, "platform", {
value: platform,
writable: true,
configurable: true,
})
})
it("should return true when user setting is true", () => {
const result = getHooksEnabledSafe(true)
result.should.be.true()
})
it("should return false when user setting is false", () => {
const result = getHooksEnabledSafe(false)
result.should.be.false()
})
it("should return false when user setting is undefined (default)", () => {
const result = getHooksEnabledSafe(undefined)
result.should.be.false()
})
})
})
})
describe("edge cases", () => {
it("should handle macOS platform correctly", () => {
Object.defineProperty(process, "platform", {
value: "darwin",
writable: true,
configurable: true,
})
// macOS should respect user setting
getHooksEnabledSafe(true).should.be.true()
getHooksEnabledSafe(false).should.be.false()
getHooksEnabledSafe(undefined).should.be.false()
})
it("should handle Linux platform correctly", () => {
Object.defineProperty(process, "platform", {
value: "linux",
writable: true,
configurable: true,
})
// Linux should respect user setting
getHooksEnabledSafe(true).should.be.true()
getHooksEnabledSafe(false).should.be.false()
getHooksEnabledSafe(undefined).should.be.false()
})
})
})
})
@@ -0,0 +1,285 @@
import { describe, it } from "mocha"
import "should"
import { escapeShellPath } from "../shell-escape"
describe("Shell Path Escaping", () => {
const originalPlatform = process.platform
// Helper to temporarily set platform
const setPlatform = (platform: NodeJS.Platform) => {
Object.defineProperty(process, "platform", {
value: platform,
writable: true,
configurable: true,
})
}
// Restore platform after tests
after(() => {
Object.defineProperty(process, "platform", {
value: originalPlatform,
writable: true,
configurable: true,
})
})
describe("Unix/Linux/macOS path escaping", () => {
before(() => {
setPlatform("darwin") // macOS, but same escaping as Linux
})
it("should handle paths without special characters", () => {
const path = "/Users/user/Documents/Cline/Hooks/PreToolUse"
const escaped = escapeShellPath(path)
escaped.should.equal("'/Users/user/Documents/Cline/Hooks/PreToolUse'")
})
it("should handle paths with spaces", () => {
const path = "/Users/user/My Project/Hooks/PreToolUse"
const escaped = escapeShellPath(path)
escaped.should.equal("'/Users/user/My Project/Hooks/PreToolUse'")
})
it("should handle paths with multiple spaces", () => {
const path = "/Users/user/My Test Project/Hooks/PreToolUse"
const escaped = escapeShellPath(path)
escaped.should.equal("'/Users/user/My Test Project/Hooks/PreToolUse'")
})
it("should handle paths with single quotes", () => {
const path = "/Users/user/Test's Project/Hooks/PreToolUse"
const escaped = escapeShellPath(path)
// Single quote is escaped as '\'' (close quote, escaped quote, open quote)
escaped.should.equal("'/Users/user/Test'\\''s Project/Hooks/PreToolUse'")
})
it("should handle paths with multiple single quotes", () => {
const path = "/Users/user/Test's Project's Hooks/PreToolUse"
const escaped = escapeShellPath(path)
escaped.should.equal("'/Users/user/Test'\\''s Project'\\''s Hooks/PreToolUse'")
})
it("should handle paths with double quotes", () => {
const path = '/Users/user/Test "Quoted" Project/Hooks/PreToolUse'
const escaped = escapeShellPath(path)
// Double quotes are safe inside single quotes
escaped.should.equal("'/Users/user/Test \"Quoted\" Project/Hooks/PreToolUse'")
})
it("should handle paths with special shell characters", () => {
const path = "/Users/user/Test$Project/Hooks/PreToolUse"
const escaped = escapeShellPath(path)
// Special characters like $ are safe inside single quotes
escaped.should.equal("'/Users/user/Test$Project/Hooks/PreToolUse'")
})
it("should handle paths with backticks", () => {
const path = "/Users/user/Test`Project/Hooks/PreToolUse"
const escaped = escapeShellPath(path)
// Backticks are safe inside single quotes
escaped.should.equal("'/Users/user/Test`Project/Hooks/PreToolUse'")
})
it("should handle paths with parentheses", () => {
const path = "/Users/user/Test (Project)/Hooks/PreToolUse"
const escaped = escapeShellPath(path)
escaped.should.equal("'/Users/user/Test (Project)/Hooks/PreToolUse'")
})
it("should handle paths with ampersands", () => {
const path = "/Users/user/Test & Project/Hooks/PreToolUse"
const escaped = escapeShellPath(path)
escaped.should.equal("'/Users/user/Test & Project/Hooks/PreToolUse'")
})
it("should handle paths with semicolons", () => {
const path = "/Users/user/Test;Project/Hooks/PreToolUse"
const escaped = escapeShellPath(path)
escaped.should.equal("'/Users/user/Test;Project/Hooks/PreToolUse'")
})
it("should handle paths with pipes", () => {
const path = "/Users/user/Test|Project/Hooks/PreToolUse"
const escaped = escapeShellPath(path)
escaped.should.equal("'/Users/user/Test|Project/Hooks/PreToolUse'")
})
it("should handle global hooks directory with spaces", () => {
const path = "/Users/user name/Documents/Cline/Hooks/PreToolUse"
const escaped = escapeShellPath(path)
escaped.should.equal("'/Users/user name/Documents/Cline/Hooks/PreToolUse'")
})
it("should handle workspace hooks with spaces in root", () => {
const path = "/Users/user/My Example Project/.clinerules/hooks/PreToolUse"
const escaped = escapeShellPath(path)
escaped.should.equal("'/Users/user/My Example Project/.clinerules/hooks/PreToolUse'")
})
it("should handle paths with newlines (edge case)", () => {
const path = "/Users/user/Test\nProject/Hooks/PreToolUse"
const escaped = escapeShellPath(path)
// Newlines are safe inside single quotes
escaped.should.equal("'/Users/user/Test\nProject/Hooks/PreToolUse'")
})
it("should handle paths with tabs", () => {
const path = "/Users/user/Test\tProject/Hooks/PreToolUse"
const escaped = escapeShellPath(path)
escaped.should.equal("'/Users/user/Test\tProject/Hooks/PreToolUse'")
})
})
describe("Windows path escaping", () => {
before(() => {
setPlatform("win32")
})
it("should handle paths without special characters", () => {
const path = "C:\\Users\\user\\Documents\\Cline\\Hooks\\PreToolUse"
const escaped = escapeShellPath(path)
escaped.should.equal('"C:\\Users\\user\\Documents\\Cline\\Hooks\\PreToolUse"')
})
it("should handle paths with spaces", () => {
const path = "C:\\Users\\user\\My Project\\Hooks\\PreToolUse"
const escaped = escapeShellPath(path)
escaped.should.equal('"C:\\Users\\user\\My Project\\Hooks\\PreToolUse"')
})
it("should handle paths with multiple spaces", () => {
const path = "C:\\Users\\user\\My Test Project\\Hooks\\PreToolUse"
const escaped = escapeShellPath(path)
escaped.should.equal('"C:\\Users\\user\\My Test Project\\Hooks\\PreToolUse"')
})
it("should handle paths with double quotes", () => {
const path = 'C:\\Users\\user\\Test "Quoted" Project\\Hooks\\PreToolUse'
const escaped = escapeShellPath(path)
// Double quotes are escaped by doubling them
escaped.should.equal('"C:\\Users\\user\\Test ""Quoted"" Project\\Hooks\\PreToolUse"')
})
it("should handle paths with backslashes before quotes", () => {
const path = 'C:\\Users\\user\\Test\\"Project\\Hooks\\PreToolUse'
const escaped = escapeShellPath(path)
// Backslash before quote needs to be doubled, then quote is doubled
escaped.should.equal('"C:\\Users\\user\\Test\\\\""Project\\Hooks\\PreToolUse"')
})
it("should handle paths with single quotes", () => {
const path = "C:\\Users\\user\\Test's Project\\Hooks\\PreToolUse"
const escaped = escapeShellPath(path)
// Single quotes are safe inside double quotes on Windows
escaped.should.equal('"C:\\Users\\user\\Test\'s Project\\Hooks\\PreToolUse"')
})
it("should handle paths with special characters", () => {
const path = "C:\\Users\\user\\Test$Project\\Hooks\\PreToolUse"
const escaped = escapeShellPath(path)
// Most special characters are safe inside double quotes on Windows
escaped.should.equal('"C:\\Users\\user\\Test$Project\\Hooks\\PreToolUse"')
})
it("should handle paths with parentheses", () => {
const path = "C:\\Users\\user\\Test (Project)\\Hooks\\PreToolUse"
const escaped = escapeShellPath(path)
escaped.should.equal('"C:\\Users\\user\\Test (Project)\\Hooks\\PreToolUse"')
})
it("should handle paths with ampersands", () => {
const path = "C:\\Users\\user\\Test & Project\\Hooks\\PreToolUse"
const escaped = escapeShellPath(path)
escaped.should.equal('"C:\\Users\\user\\Test & Project\\Hooks\\PreToolUse"')
})
it("should handle global hooks directory with spaces", () => {
const path = "C:\\Users\\user name\\Documents\\Cline\\Hooks\\PreToolUse"
const escaped = escapeShellPath(path)
escaped.should.equal('"C:\\Users\\user name\\Documents\\Cline\\Hooks\\PreToolUse"')
})
it("should handle workspace hooks with spaces in root", () => {
const path = "C:\\Users\\user\\My Example Project\\.clinerules\\hooks\\PreToolUse"
const escaped = escapeShellPath(path)
escaped.should.equal('"C:\\Users\\user\\My Example Project\\.clinerules\\hooks\\PreToolUse"')
})
it("should handle UNC paths with spaces", () => {
const path = "\\\\server\\share\\My Project\\Hooks\\PreToolUse"
const escaped = escapeShellPath(path)
escaped.should.equal('"\\\\server\\share\\My Project\\Hooks\\PreToolUse"')
})
})
describe("Real-world scenarios", () => {
it("should handle typical macOS global hooks path with space in username", () => {
setPlatform("darwin")
const path = "/Users/John Doe/Documents/Cline/Hooks/PreToolUse"
const escaped = escapeShellPath(path)
escaped.should.equal("'/Users/John Doe/Documents/Cline/Hooks/PreToolUse'")
})
it("should handle typical Windows global hooks path with space in username", () => {
setPlatform("win32")
const path = "C:\\Users\\John Doe\\Documents\\Cline\\Hooks\\PreToolUse"
const escaped = escapeShellPath(path)
escaped.should.equal('"C:\\Users\\John Doe\\Documents\\Cline\\Hooks\\PreToolUse"')
})
it("should handle workspace with company name and spaces", () => {
setPlatform("darwin")
const path = "/Users/user/Projects/ACME Corp Project/.clinerules/hooks/PreToolUse"
const escaped = escapeShellPath(path)
escaped.should.equal("'/Users/user/Projects/ACME Corp Project/.clinerules/hooks/PreToolUse'")
})
it("should handle workspace with version numbers and spaces", () => {
setPlatform("darwin")
const path = "/Users/user/Projects/My Project v2.0/.clinerules/hooks/PreToolUse"
const escaped = escapeShellPath(path)
escaped.should.equal("'/Users/user/Projects/My Project v2.0/.clinerules/hooks/PreToolUse'")
})
it("should handle workspace with mixed special characters", () => {
setPlatform("darwin")
const path = "/Users/user/Projects/Test's (New) Project v2.0/.clinerules/hooks/PreToolUse"
const escaped = escapeShellPath(path)
escaped.should.equal("'/Users/user/Projects/Test'\\''s (New) Project v2.0/.clinerules/hooks/PreToolUse'")
})
})
describe("Multi-root workspace scenarios", () => {
it("should handle multiple roots with spaces (macOS)", () => {
setPlatform("darwin")
const roots = [
"/Users/user/My Frontend Project/.clinerules/hooks/PreToolUse",
"/Users/user/My Backend Project/.clinerules/hooks/PreToolUse",
"/Users/user/Shared Utils/.clinerules/hooks/PreToolUse",
]
const escaped = roots.map(escapeShellPath)
escaped.should.deepEqual([
"'/Users/user/My Frontend Project/.clinerules/hooks/PreToolUse'",
"'/Users/user/My Backend Project/.clinerules/hooks/PreToolUse'",
"'/Users/user/Shared Utils/.clinerules/hooks/PreToolUse'",
])
})
it("should handle multiple roots with spaces (Windows)", () => {
setPlatform("win32")
const roots = [
"C:\\Users\\user\\My Frontend Project\\.clinerules\\hooks\\PreToolUse",
"C:\\Users\\user\\My Backend Project\\.clinerules\\hooks\\PreToolUse",
"C:\\Users\\user\\Shared Utils\\.clinerules\\hooks\\PreToolUse",
]
const escaped = roots.map(escapeShellPath)
escaped.should.deepEqual([
'"C:\\Users\\user\\My Frontend Project\\.clinerules\\hooks\\PreToolUse"',
'"C:\\Users\\user\\My Backend Project\\.clinerules\\hooks\\PreToolUse"',
'"C:\\Users\\user\\Shared Utils\\.clinerules\\hooks\\PreToolUse"',
])
})
})
})
+59 -50
View File
@@ -42,10 +42,19 @@ describe("TaskCancel Hook", () => {
} as any)
getEnv = () => ({ tempDir })
// Reset hook discovery cache for clean test state
const { HookDiscoveryCache } = await import("../HookDiscoveryCache")
HookDiscoveryCache.resetForTesting()
})
afterEach(async () => {
sandbox.restore()
// Clean up hook discovery cache
const { HookDiscoveryCache } = await import("../HookDiscoveryCache")
HookDiscoveryCache.resetForTesting()
try {
await fs.rm(tempDir, { recursive: true, force: true })
} catch (error) {
@@ -61,7 +70,7 @@ const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
const metadata = input.taskCancel.taskMetadata;
const hasAllFields = metadata.taskId && metadata.ulid && metadata.completionStatus;
console.log(JSON.stringify({
shouldContinue: true,
cancel: false,
contextModification: hasAllFields ? "Test passed" : "Missing metadata",
errorMessage: ""
}))`
@@ -82,7 +91,7 @@ console.log(JSON.stringify({
},
})
result.shouldContinue.should.be.true()
result.cancel.should.be.false()
// Note: contextModification is ignored for TaskCancel hooks
})
@@ -96,7 +105,7 @@ if (status !== "abandoned") {
process.exit(1);
}
console.log(JSON.stringify({
shouldContinue: true,
cancel: false,
contextModification: "",
errorMessage: ""
}))`
@@ -117,7 +126,7 @@ console.log(JSON.stringify({
},
})
result.shouldContinue.should.be.true()
result.cancel.should.be.false()
// Note: contextModification is ignored for TaskCancel hooks
})
@@ -133,7 +142,7 @@ if (!hasAllFields) {
process.exit(1);
}
console.log(JSON.stringify({
shouldContinue: true,
cancel: false,
contextModification: "",
errorMessage: ""
}))`
@@ -154,7 +163,7 @@ console.log(JSON.stringify({
},
})
result.shouldContinue.should.be.true()
result.cancel.should.be.false()
// Note: contextModification is ignored for TaskCancel hooks
})
})
@@ -164,7 +173,7 @@ console.log(JSON.stringify({
const hookPath = path.join(tempDir, ".clinerules", "hooks", "TaskCancel")
const hookScript = `#!/usr/bin/env node
console.log(JSON.stringify({
shouldContinue: true,
cancel: false,
contextModification: "This is a context modification that should be ignored",
errorMessage: ""
}))`
@@ -185,14 +194,14 @@ console.log(JSON.stringify({
},
})
// Hook returns contextModification, but it's completely ignored
result1.shouldContinue.should.be.true()
// Hook returns contextModification, but it's completely ignored (fire-and-forget)
result1.cancel.should.be.false()
result1.contextModification!.should.equal("This is a context modification that should be ignored")
// Update hook to return different contextModification
const hookScript2 = `#!/usr/bin/env node
console.log(JSON.stringify({
shouldContinue: true,
cancel: false,
contextModification: "Different context that is also ignored",
errorMessage: ""
}))`
@@ -210,10 +219,10 @@ console.log(JSON.stringify({
})
// Both results behave identically - contextModification has no effect
result2.shouldContinue.should.be.true()
result2.cancel.should.be.false()
result2.contextModification!.should.equal("Different context that is also ignored")
// The key point: both executions succeeded with shouldContinue: true
// The contextModification value is different but behavior is identical
// The key point: both executions succeeded with cancel: false
// The contextModification value is different but behavior is identical (fire-and-forget)
})
it("should succeed regardless of hook return value", async () => {
@@ -221,7 +230,7 @@ console.log(JSON.stringify({
const hookScript = `#!/usr/bin/env node
// Note: contextModification is ignored for TaskCancel hooks
console.log(JSON.stringify({
shouldContinue: true,
cancel: false,
contextModification: "",
errorMessage: ""
}))`
@@ -243,14 +252,14 @@ console.log(JSON.stringify({
})
// TaskCancel is fire-and-forget, so it always reports success
result.shouldContinue.should.be.true()
result.cancel.should.be.false()
})
it("should return error message when hook returns shouldContinue: false", async () => {
it("should return error message when hook returns cancel: true", async () => {
const hookPath = path.join(tempDir, ".clinerules", "hooks", "TaskCancel")
const hookScript = `#!/usr/bin/env node
console.log(JSON.stringify({
shouldContinue: false,
cancel: true,
contextModification: "",
errorMessage: "Hook tried to block cancellation"
}))`
@@ -271,10 +280,10 @@ console.log(JSON.stringify({
},
})
// Hook result includes shouldContinue: false and errorMessage
// Hook result includes cancel: true and errorMessage
// In abortTask(), the errorMessage will be surfaced to the user via this.say("error", ...)
// but cancellation will still proceed (fire-and-forget behavior)
result.shouldContinue.should.be.false()
result.cancel.should.be.true()
result.errorMessage!.should.equal("Hook tried to block cancellation")
})
@@ -286,7 +295,7 @@ const status = input.taskCancel.taskMetadata.completionStatus;
// Hook can perform cleanup/logging based on status
// Note: contextModification is ignored for TaskCancel hooks
console.log(JSON.stringify({
shouldContinue: true,
cancel: false,
contextModification: "",
errorMessage: ""
}))`
@@ -307,7 +316,7 @@ console.log(JSON.stringify({
},
})
result.shouldContinue.should.be.true()
result.cancel.should.be.false()
})
})
@@ -351,21 +360,21 @@ console.log("not valid json")`
const factory = new HookFactory()
const runner = await factory.create("TaskCancel")
try {
await runner.run({
taskId: "test-task-id",
taskCancel: {
taskMetadata: {
taskId: "test-task-id",
ulid: "test-ulid",
completionStatus: "cancelled",
},
// When hook exits 0 but has malformed JSON, it returns success without context
const result = await runner.run({
taskId: "test-task-id",
taskCancel: {
taskMetadata: {
taskId: "test-task-id",
ulid: "test-ulid",
completionStatus: "cancelled",
},
})
throw new Error("Should have thrown")
} catch (error: any) {
error.message.should.match(/Failed to parse hook output/)
}
},
})
// Hook succeeded (exit 0) but couldn't parse JSON, so returns success without context
result.cancel.should.be.false()
;(result.contextModification === undefined || result.contextModification === "").should.be.true()
})
})
@@ -393,7 +402,7 @@ console.log("not valid json")`
const globalHookScript = `#!/usr/bin/env node
// Note: contextModification is ignored for TaskCancel hooks
console.log(JSON.stringify({
shouldContinue: true,
cancel: false,
contextModification: "",
errorMessage: ""
}))`
@@ -404,7 +413,7 @@ console.log(JSON.stringify({
const workspaceHookScript = `#!/usr/bin/env node
// Note: contextModification is ignored for TaskCancel hooks
console.log(JSON.stringify({
shouldContinue: true,
cancel: false,
contextModification: "",
errorMessage: ""
}))`
@@ -423,7 +432,7 @@ console.log(JSON.stringify({
},
})
result.shouldContinue.should.be.true()
result.cancel.should.be.false()
// Both hooks executed successfully
})
@@ -434,7 +443,7 @@ const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
// Can perform cleanup based on completion status
// Note: contextModification is ignored for TaskCancel hooks
console.log(JSON.stringify({
shouldContinue: true,
cancel: false,
contextModification: "",
errorMessage: ""
}))`
@@ -444,7 +453,7 @@ console.log(JSON.stringify({
const workspaceHookScript = `#!/usr/bin/env node
// Note: contextModification is ignored for TaskCancel hooks
console.log(JSON.stringify({
shouldContinue: true,
cancel: false,
contextModification: "",
errorMessage: ""
}))`
@@ -463,7 +472,7 @@ console.log(JSON.stringify({
},
})
result.shouldContinue.should.be.true()
result.cancel.should.be.false()
// Both hooks executed successfully
})
})
@@ -484,12 +493,12 @@ console.log(JSON.stringify({
},
})
result.shouldContinue.should.be.true()
result.cancel.should.be.false()
})
})
describe("Fixture-Based Tests", () => {
it("should handle shouldContinue: false with no error message", async () => {
it("should handle cancel: true with no error message", async () => {
await loadFixture("hooks/taskcancel/false-no-error", getEnv().tempDir)
const factory = new HookFactory()
@@ -506,13 +515,13 @@ console.log(JSON.stringify({
},
})
result.shouldContinue.should.be.false()
result.cancel.should.be.true()
result.errorMessage!.should.equal("")
// In abortTask(), no error is surfaced since errorMessage is empty
// Cancellation still proceeds (fire-and-forget)
})
it("should handle shouldContinue: false with error message", async () => {
it("should handle cancel: true with error message", async () => {
await loadFixture("hooks/taskcancel/false-with-error", getEnv().tempDir)
const factory = new HookFactory()
@@ -529,13 +538,13 @@ console.log(JSON.stringify({
},
})
result.shouldContinue.should.be.false()
result.cancel.should.be.true()
result.errorMessage!.should.equal("some error happened")
// In abortTask(), the errorMessage WILL be surfaced to user via this.say("error", ...)
// Cancellation still proceeds (fire-and-forget)
})
it("should handle shouldContinue: true with no error message", async () => {
it("should handle cancel: false with no error message", async () => {
await loadFixture("hooks/taskcancel/true-no-error", getEnv().tempDir)
const factory = new HookFactory()
@@ -552,12 +561,12 @@ console.log(JSON.stringify({
},
})
result.shouldContinue.should.be.true()
result.cancel.should.be.false()
result.errorMessage!.should.equal("")
// Normal success case - no errors to surface
})
it("should handle shouldContinue: true with error message", async () => {
it("should handle cancel: false with error message", async () => {
await loadFixture("hooks/taskcancel/true-with-error", getEnv().tempDir)
const factory = new HookFactory()
@@ -574,7 +583,7 @@ console.log(JSON.stringify({
},
})
result.shouldContinue.should.be.true()
result.cancel.should.be.false()
result.errorMessage!.should.equal("some error happened")
// In abortTask(), the errorMessage WILL be surfaced to user via this.say("error", ...)
// This is the scenario that was fixed - error messages are now displayed regardless of shouldContinue value
+44 -38
View File
@@ -41,6 +41,12 @@ describe("TaskResume Hook", () => {
})
afterEach(async () => {
sandbox.restore()
// Clean up hook discovery cache
const { HookDiscoveryCache } = await import("../HookDiscoveryCache")
HookDiscoveryCache.resetForTesting()
sandbox.restore()
try {
await fs.rm(tempDir, { recursive: true, force: true })
@@ -61,7 +67,7 @@ const hasRequiredFields =
typeof input.taskResume.taskMetadata.taskId === 'string' &&
typeof input.taskResume.previousState.messageCount === 'string';
console.log(JSON.stringify({
shouldContinue: true,
cancel: false,
contextModification: hasRequiredFields ? "All fields present" : "Missing fields"
}))`
@@ -85,7 +91,7 @@ console.log(JSON.stringify({
},
})
result.shouldContinue.should.be.true()
result.cancel.should.be.false()
result.contextModification!.should.equal("All fields present")
})
@@ -96,7 +102,7 @@ const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
const hasAllFields = input.clineVersion && input.hookName && input.timestamp &&
input.taskId && input.workspaceRoots !== undefined;
console.log(JSON.stringify({
shouldContinue: true,
cancel: false,
contextModification: hasAllFields ? "All fields present" : "Missing fields"
}))`
@@ -130,7 +136,7 @@ const lastTs = parseInt(input.taskResume.previousState.lastMessageTs);
const now = Date.now();
const minutesAgo = Math.floor((now - lastTs) / 60000);
console.log(JSON.stringify({
shouldContinue: true,
cancel: false,
contextModification: "Minutes ago: " + minutesAgo
}))`
@@ -172,7 +178,7 @@ const lastTs = parseInt(input.taskResume.previousState.lastMessageTs);
const now = Date.now();
const daysAgo = Math.floor((now - lastTs) / (24 * 60 * 60 * 1000));
console.log(JSON.stringify({
shouldContinue: true,
cancel: false,
contextModification: daysAgo > 0 ? "Days ago: " + daysAgo : "Recent"
}))`
@@ -206,7 +212,7 @@ const lastTs = parseInt(input.taskResume.previousState.lastMessageTs);
const now = Date.now();
const isFuture = lastTs > now;
console.log(JSON.stringify({
shouldContinue: true,
cancel: false,
contextModification: isFuture ? "Future timestamp detected" : "Normal timestamp"
}))`
@@ -243,7 +249,7 @@ if (count < 5) category = "short";
else if (count < 20) category = "medium";
else category = "long";
console.log(JSON.stringify({
shouldContinue: true,
cancel: false,
contextModification: "Conversation length: " + category + " (" + count + " messages)"
}))`
@@ -281,7 +287,7 @@ console.log(JSON.stringify({
const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
const count = parseInt(input.taskResume.previousState.messageCount);
console.log(JSON.stringify({
shouldContinue: true,
cancel: false,
contextModification: count === 0 ? "Empty conversation" : "Has messages"
}))`
@@ -316,7 +322,7 @@ const count = parseInt(input.taskResume.previousState.messageCount);
const hoursAgo = Math.floor((Date.now() - lastTs) / (60 * 60 * 1000));
const isStale = hoursAgo > 24 && count > 20;
console.log(JSON.stringify({
shouldContinue: true,
cancel: false,
contextModification: isStale ? "STALE_TASK: Long conversation paused for extended time" : "Active task"
}))`
@@ -348,7 +354,7 @@ const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
const deleted = input.taskResume.previousState.conversationHistoryDeleted === 'true';
const count = parseInt(input.taskResume.previousState.messageCount);
console.log(JSON.stringify({
shouldContinue: true,
cancel: false,
contextModification: deleted && count > 10
? "CONTEXT_WARNING: Large conversation with truncated history"
: "Normal state"
@@ -386,22 +392,22 @@ console.log("not valid json")`
const factory = new HookFactory()
const runner = await factory.create("TaskResume")
try {
await runner.run({
taskId: "test-task",
taskResume: {
taskMetadata: { taskId: "test-task", ulid: "test-ulid" },
previousState: {
lastMessageTs: Date.now().toString(),
messageCount: "5",
conversationHistoryDeleted: "false",
},
// When hook exits 0 but has malformed JSON, it returns success without context
const result = await runner.run({
taskId: "test-task",
taskResume: {
taskMetadata: { taskId: "test-task", ulid: "test-ulid" },
previousState: {
lastMessageTs: Date.now().toString(),
messageCount: "5",
conversationHistoryDeleted: "false",
},
})
throw new Error("Should have thrown parse error")
} catch (error: any) {
error.message.should.match(/Failed to parse hook output/)
}
},
})
// Hook succeeded (exit 0) but couldn't parse JSON, so returns success without context
result.cancel.should.be.false()
;(result.contextModification === undefined || result.contextModification === "").should.be.true()
})
it("should handle invalid timestamp gracefully", async () => {
@@ -411,7 +417,7 @@ const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
const lastTs = parseInt(input.taskResume.previousState.lastMessageTs);
const isValid = !isNaN(lastTs) && lastTs > 0;
console.log(JSON.stringify({
shouldContinue: true,
cancel: false,
contextModification: isValid ? "Valid timestamp" : "Invalid timestamp"
}))`
@@ -456,7 +462,7 @@ console.log(JSON.stringify({
const globalHookPath = path.join(globalHooksDir, "TaskResume")
const globalHookScript = `#!/usr/bin/env node
console.log(JSON.stringify({
shouldContinue: true,
cancel: false,
contextModification: "GLOBAL: Task resumed"
}))`
await writeHookScript(globalHookPath, globalHookScript)
@@ -464,7 +470,7 @@ console.log(JSON.stringify({
const workspaceHookPath = path.join(tempDir, ".clinerules", "hooks", "TaskResume")
const workspaceHookScript = `#!/usr/bin/env node
console.log(JSON.stringify({
shouldContinue: true,
cancel: false,
contextModification: "WORKSPACE: Task resumed"
}))`
await writeHookScript(workspaceHookPath, workspaceHookScript)
@@ -484,7 +490,7 @@ console.log(JSON.stringify({
},
})
result.shouldContinue.should.be.true()
result.cancel.should.be.false()
result.contextModification!.should.match(/GLOBAL: Task resumed/)
result.contextModification!.should.match(/WORKSPACE: Task resumed/)
})
@@ -498,7 +504,7 @@ const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
const lastTs = parseInt(input.taskResume.previousState.lastMessageTs);
const daysAgo = Math.floor((Date.now() - lastTs) / (24 * 60 * 60 * 1000));
console.log(JSON.stringify({
shouldContinue: true,
cancel: false,
contextModification: "GLOBAL_POLICY: " + (daysAgo > 0 ? "Review task context" : "Continue")
}))`
await writeHookScript(globalHookPath, globalHookScript)
@@ -508,7 +514,7 @@ console.log(JSON.stringify({
const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
const count = parseInt(input.taskResume.previousState.messageCount);
console.log(JSON.stringify({
shouldContinue: true,
cancel: false,
contextModification: "PROJECT_NOTE: " + count + " messages in history"
}))`
await writeHookScript(workspaceHookPath, workspaceHookScript)
@@ -550,7 +556,7 @@ console.log(JSON.stringify({
},
})
result.shouldContinue.should.be.true()
result.cancel.should.be.false()
})
})
@@ -578,7 +584,7 @@ console.log(JSON.stringify({
},
})
result.shouldContinue.should.be.true()
result.cancel.should.be.false()
result.contextModification!.should.equal("TaskResume hook executed successfully")
})
@@ -598,7 +604,7 @@ console.log(JSON.stringify({
},
})
result.shouldContinue.should.be.true()
result.cancel.should.be.false()
result.contextModification!.should.match(/Recently paused task/)
})
@@ -618,7 +624,7 @@ console.log(JSON.stringify({
},
})
result.shouldContinue.should.be.true()
result.cancel.should.be.false()
result.contextModification!.should.match(/paused 48 hours ago/)
})
@@ -637,7 +643,7 @@ console.log(JSON.stringify({
},
})
result.shouldContinue.should.be.true()
result.cancel.should.be.false()
result.contextModification!.should.match(/truncated/)
})
@@ -656,7 +662,7 @@ console.log(JSON.stringify({
},
})
result.shouldContinue.should.be.true()
result.cancel.should.be.false()
result.contextModification!.should.equal("TASK_CONTEXT: Resuming task with 25 previous messages")
})
@@ -675,7 +681,7 @@ console.log(JSON.stringify({
},
})
result.shouldContinue.should.be.true()
result.cancel.should.be.false()
result.contextModification!.should.equal("WORKSPACE_RULES: Task test-task resumed - review previous context")
})
+45 -40
View File
@@ -46,6 +46,11 @@ describe("TaskStart Hook", () => {
afterEach(async () => {
sandbox.restore()
// Clean up hook discovery cache
const { HookDiscoveryCache } = await import("../HookDiscoveryCache")
HookDiscoveryCache.resetForTesting()
try {
await fs.rm(tempDir, { recursive: true, force: true })
} catch (error) {
@@ -61,7 +66,7 @@ const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
const metadata = input.taskStart.taskMetadata;
const hasAllFields = metadata.taskId && metadata.ulid && 'initialTask' in metadata;
console.log(JSON.stringify({
shouldContinue: true,
cancel: false,
contextModification: hasAllFields ? "All metadata present" : "Missing metadata",
errorMessage: ""
}))`
@@ -82,7 +87,7 @@ console.log(JSON.stringify({
},
})
result.shouldContinue.should.be.true()
result.cancel.should.be.false()
result.contextModification!.should.equal("All metadata present")
})
@@ -94,7 +99,7 @@ const hasAllFields = input.clineVersion && input.hookName === 'TaskStart' &&
input.timestamp && input.taskId &&
input.workspaceRoots !== undefined;
console.log(JSON.stringify({
shouldContinue: true,
cancel: false,
contextModification: hasAllFields ? "All fields present" : "Missing fields",
errorMessage: ""
}))`
@@ -115,7 +120,7 @@ console.log(JSON.stringify({
},
})
result.shouldContinue.should.be.true()
result.cancel.should.be.false()
result.contextModification!.should.equal("All fields present")
})
@@ -125,7 +130,7 @@ console.log(JSON.stringify({
const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
const initialTask = input.taskStart.taskMetadata.initialTask;
console.log(JSON.stringify({
shouldContinue: true,
cancel: false,
contextModification: "Task length: " + initialTask.length,
errorMessage: ""
}))`
@@ -146,17 +151,17 @@ console.log(JSON.stringify({
},
})
result.shouldContinue.should.be.true()
result.cancel.should.be.false()
result.contextModification!.should.equal("Task length: 0")
})
})
describe("Hook Behavior", () => {
it("should allow task to start when hook returns shouldContinue: true", async () => {
it("should allow task to start when hook returns cancel: false", async () => {
const hookPath = path.join(tempDir, ".clinerules", "hooks", "TaskStart")
const hookScript = `#!/usr/bin/env node
console.log(JSON.stringify({
shouldContinue: true,
cancel: false,
contextModification: "TaskStart hook executed successfully",
errorMessage: ""
}))`
@@ -177,15 +182,15 @@ console.log(JSON.stringify({
},
})
result.shouldContinue.should.be.true()
result.cancel.should.be.false()
result.contextModification!.should.equal("TaskStart hook executed successfully")
})
it("should block task when hook returns shouldContinue: false", async () => {
it("should block task when hook returns cancel: true", async () => {
const hookPath = path.join(tempDir, ".clinerules", "hooks", "TaskStart")
const hookScript = `#!/usr/bin/env node
console.log(JSON.stringify({
shouldContinue: false,
cancel: true,
contextModification: "",
errorMessage: "Task execution blocked by hook"
}))`
@@ -206,7 +211,7 @@ console.log(JSON.stringify({
},
})
result.shouldContinue.should.be.false()
result.cancel.should.be.true()
result.errorMessage!.should.equal("Task execution blocked by hook")
})
@@ -215,7 +220,7 @@ console.log(JSON.stringify({
const hookScript = `#!/usr/bin/env node
const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
console.log(JSON.stringify({
shouldContinue: true,
cancel: false,
contextModification: "TASK_START: Task '" + input.taskStart.taskMetadata.initialTask + "' beginning",
errorMessage: ""
}))`
@@ -236,7 +241,7 @@ console.log(JSON.stringify({
},
})
result.shouldContinue.should.be.true()
result.cancel.should.be.false()
result.contextModification!.should.equal("TASK_START: Task 'Build a todo app' beginning")
})
})
@@ -280,21 +285,21 @@ console.log("not valid json")`
const factory = new HookFactory()
const runner = await factory.create("TaskStart")
try {
await runner.run({
taskId: "test-task-id",
taskStart: {
taskMetadata: {
taskId: "test-task-id",
ulid: "test-ulid",
initialTask: "Test task",
},
// When hook exits 0 but has malformed JSON, it returns success without context
const result = await runner.run({
taskId: "test-task-id",
taskStart: {
taskMetadata: {
taskId: "test-task-id",
ulid: "test-ulid",
initialTask: "Test task",
},
})
throw new Error("Should have thrown")
} catch (error: any) {
error.message.should.match(/Failed to parse hook output/)
}
},
})
// Hook succeeded (exit 0) but couldn't parse JSON, so returns success without context
result.cancel.should.be.false()
;(result.contextModification === undefined || result.contextModification === "").should.be.true()
})
})
@@ -321,7 +326,7 @@ console.log("not valid json")`
const globalHookPath = path.join(globalHooksDir, "TaskStart")
const globalHookScript = `#!/usr/bin/env node
console.log(JSON.stringify({
shouldContinue: true,
cancel: false,
contextModification: "GLOBAL: Task starting",
errorMessage: ""
}))`
@@ -331,7 +336,7 @@ console.log(JSON.stringify({
const workspaceHookPath = path.join(tempDir, ".clinerules", "hooks", "TaskStart")
const workspaceHookScript = `#!/usr/bin/env node
console.log(JSON.stringify({
shouldContinue: true,
cancel: false,
contextModification: "WORKSPACE: Task starting",
errorMessage: ""
}))`
@@ -350,7 +355,7 @@ console.log(JSON.stringify({
},
})
result.shouldContinue.should.be.true()
result.cancel.should.be.false()
result.contextModification!.should.match(/GLOBAL: Task starting/)
result.contextModification!.should.match(/WORKSPACE: Task starting/)
})
@@ -359,7 +364,7 @@ console.log(JSON.stringify({
const globalHookPath = path.join(globalHooksDir, "TaskStart")
const globalHookScript = `#!/usr/bin/env node
console.log(JSON.stringify({
shouldContinue: false,
cancel: true,
contextModification: "",
errorMessage: "Global policy blocks this task"
}))`
@@ -368,7 +373,7 @@ console.log(JSON.stringify({
const workspaceHookPath = path.join(tempDir, ".clinerules", "hooks", "TaskStart")
const workspaceHookScript = `#!/usr/bin/env node
console.log(JSON.stringify({
shouldContinue: true,
cancel: false,
contextModification: "Workspace allows",
errorMessage: ""
}))`
@@ -387,7 +392,7 @@ console.log(JSON.stringify({
},
})
result.shouldContinue.should.be.false()
result.cancel.should.be.true()
result.errorMessage!.should.match(/Global policy blocks this task/)
})
@@ -395,7 +400,7 @@ console.log(JSON.stringify({
const globalHookPath = path.join(globalHooksDir, "TaskStart")
const globalHookScript = `#!/usr/bin/env node
console.log(JSON.stringify({
shouldContinue: true,
cancel: false,
contextModification: "Global allows",
errorMessage: ""
}))`
@@ -404,7 +409,7 @@ console.log(JSON.stringify({
const workspaceHookPath = path.join(tempDir, ".clinerules", "hooks", "TaskStart")
const workspaceHookScript = `#!/usr/bin/env node
console.log(JSON.stringify({
shouldContinue: false,
cancel: true,
contextModification: "",
errorMessage: "Workspace blocks"
}))`
@@ -423,7 +428,7 @@ console.log(JSON.stringify({
},
})
result.shouldContinue.should.be.false()
result.cancel.should.be.true()
result.errorMessage!.should.match(/Workspace blocks/)
})
})
@@ -444,7 +449,7 @@ console.log(JSON.stringify({
},
})
result.shouldContinue.should.be.true()
result.cancel.should.be.false()
})
})
@@ -466,7 +471,7 @@ console.log(JSON.stringify({
},
})
result.shouldContinue.should.be.true()
result.cancel.should.be.false()
result.contextModification!.should.equal("TaskStart hook executed successfully")
})
@@ -487,7 +492,7 @@ console.log(JSON.stringify({
},
})
result.shouldContinue.should.be.false()
result.cancel.should.be.true()
result.errorMessage!.should.equal("Task execution blocked by hook")
})
+13 -13
View File
@@ -37,20 +37,20 @@ export async function createHooksDirectory(baseDir: string): Promise<string> {
* @example
* // Create a simple success hook
* await createTestHook(tempDir, "PreToolUse", {
* shouldContinue: true,
* cancel: false,
* contextModification: "TEST_CONTEXT"
* })
*
* @example
* // Create a hook that delays before responding
* await createTestHook(tempDir, "PreToolUse", {
* shouldContinue: true
* cancel: false
* }, { delay: 100 })
*
* @example
* // Create a hook that exits with an error
* await createTestHook(tempDir, "PreToolUse", {
* shouldContinue: false
* cancel: true
* }, { exitCode: 1 })
*
* @example
@@ -202,17 +202,17 @@ export function buildPostToolUseInput(params: {
*
* @example
* assertHookOutput(result, {
* shouldContinue: true,
* cancel: false,
* contextModification: "Expected context"
* })
*/
export function assertHookOutput(actual: HookOutput, expected: Partial<HookOutput>): void {
if (expected.shouldContinue !== undefined) {
if (actual.shouldContinue !== expected.shouldContinue) {
if (expected.cancel !== undefined) {
if (actual.cancel !== expected.cancel) {
throw new Error(
`Hook output assertion failed for 'shouldContinue':\n` +
` Expected: ${expected.shouldContinue}\n` +
` Received: ${actual.shouldContinue}\n` +
`Hook output assertion failed for 'cancel':\n` +
` Expected: ${expected.cancel}\n` +
` Received: ${actual.cancel}\n` +
` Full output: ${JSON.stringify(actual, null, 2)}`,
)
}
@@ -279,7 +279,7 @@ function isSerializable(value: any): boolean {
*
* @example
* const mockRunner = new MockHookRunner("PreToolUse")
* mockRunner.setResponse({ shouldContinue: true })
* mockRunner.setResponse({ cancel: false })
*
* const result = await mockRunner.run(input)
* mockRunner.assertCalled(1)
@@ -287,7 +287,7 @@ function isSerializable(value: any): boolean {
*/
export class MockHookRunner<Name extends HookName> {
private response: HookOutput = {
shouldContinue: true,
cancel: false,
contextModification: "",
errorMessage: "",
}
@@ -305,7 +305,7 @@ export class MockHookRunner<Name extends HookName> {
*/
setResponse(output: Partial<HookOutput>): void {
this.response = {
shouldContinue: output.shouldContinue ?? true,
cancel: output.cancel ?? false,
contextModification: output.contextModification ?? "",
errorMessage: output.errorMessage ?? "",
}
@@ -399,7 +399,7 @@ export class MockHookRunner<Name extends HookName> {
reset(): void {
this.executionLog = []
this.response = {
shouldContinue: true,
cancel: false,
contextModification: "",
errorMessage: "",
}
@@ -42,6 +42,11 @@ describe("UserPromptSubmit Hook", () => {
afterEach(async () => {
sandbox.restore()
// Clean up hook discovery cache
const { HookDiscoveryCache } = await import("../HookDiscoveryCache")
HookDiscoveryCache.resetForTesting()
try {
await fs.rm(tempDir, { recursive: true, force: true })
} catch (error) {
@@ -58,7 +63,7 @@ describe("UserPromptSubmit Hook", () => {
const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
const hasPrompt = input.userPromptSubmit && typeof input.userPromptSubmit.prompt === 'string' && input.userPromptSubmit.prompt.length > 0;
console.log(JSON.stringify({
shouldContinue: true,
cancel: false,
contextModification: hasPrompt ? "Received prompt" : "Missing prompt"
}))`
@@ -75,7 +80,7 @@ console.log(JSON.stringify({
},
})
result.shouldContinue.should.be.true()
result.cancel.should.be.false()
result.contextModification!.should.equal("Received prompt")
})
@@ -85,7 +90,7 @@ console.log(JSON.stringify({
const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
const lineCount = (input.userPromptSubmit.prompt.match(/\\n/g) || []).length + 1;
console.log(JSON.stringify({
shouldContinue: true,
cancel: false,
contextModification: "Line count: " + lineCount
}))`
@@ -112,7 +117,7 @@ console.log(JSON.stringify({
const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
const size = input.userPromptSubmit.prompt.length;
console.log(JSON.stringify({
shouldContinue: true,
cancel: false,
contextModification: "Prompt size: " + size
}))`
@@ -140,7 +145,7 @@ const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
const hasAllFields = input.clineVersion && input.hookName && input.timestamp &&
input.taskId && input.workspaceRoots !== undefined;
console.log(JSON.stringify({
shouldContinue: true,
cancel: false,
contextModification: hasAllFields ? "All fields present" : "Missing fields"
}))`
@@ -169,14 +174,14 @@ const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
const promptData = input.userPromptSubmit;
if (!promptData) {
console.log(JSON.stringify({
shouldContinue: false,
cancel: true,
errorMessage: "No userPromptSubmit data"
}));
process.exit(0);
}
const promptLength = typeof promptData.prompt === 'string' ? promptData.prompt.length : (promptData.prompt ? String(promptData.prompt).length : 0);
console.log(JSON.stringify({
shouldContinue: true,
cancel: false,
contextModification: "Prompt length: " + promptLength
}))`
@@ -203,7 +208,7 @@ const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
const prompt = input.userPromptSubmit.prompt;
const hasSpecialChars = prompt.includes("@") && prompt.includes("#") && prompt.includes("$");
console.log(JSON.stringify({
shouldContinue: true,
cancel: false,
contextModification: hasSpecialChars ? "Special chars preserved" : "Missing special chars"
}))`
@@ -235,18 +240,18 @@ console.log("not valid json")`
const factory = new HookFactory()
const runner = await factory.create("UserPromptSubmit")
try {
await runner.run({
taskId: "test-task",
userPromptSubmit: {
prompt: "Test",
attachments: [],
},
})
throw new Error("Should have thrown parse error")
} catch (error: any) {
error.message.should.match(/Failed to parse hook output/)
}
// When hook exits 0 but has malformed JSON, it returns success without context
const result = await runner.run({
taskId: "test-task",
userPromptSubmit: {
prompt: "Test",
attachments: [],
},
})
// Hook succeeded (exit 0) but couldn't parse JSON, so returns success without context
result.cancel.should.be.false()
;(result.contextModification === undefined || result.contextModification === "").should.be.true()
})
it("should handle hook script errors", async () => {
@@ -299,7 +304,7 @@ process.exit(1)`
const globalHookPath = path.join(globalHooksDir, "UserPromptSubmit")
const globalHookScript = `#!/usr/bin/env node
console.log(JSON.stringify({
shouldContinue: true,
cancel: false,
contextModification: "GLOBAL: Prompt received"
}))`
await writeHookScript(globalHookPath, globalHookScript)
@@ -308,7 +313,7 @@ console.log(JSON.stringify({
const workspaceHookPath = path.join(tempDir, ".clinerules", "hooks", "UserPromptSubmit")
const workspaceHookScript = `#!/usr/bin/env node
console.log(JSON.stringify({
shouldContinue: true,
cancel: false,
contextModification: "WORKSPACE: Prompt received"
}))`
await writeHookScript(workspaceHookPath, workspaceHookScript)
@@ -324,7 +329,7 @@ console.log(JSON.stringify({
},
})
result.shouldContinue.should.be.true()
result.cancel.should.be.false()
result.contextModification!.should.match(/GLOBAL: Prompt received/)
result.contextModification!.should.match(/WORKSPACE: Prompt received/)
})
@@ -334,7 +339,7 @@ console.log(JSON.stringify({
const globalHookPath = path.join(globalHooksDir, "UserPromptSubmit")
const globalHookScript = `#!/usr/bin/env node
console.log(JSON.stringify({
shouldContinue: true,
cancel: false,
contextModification: "Global allows"
}))`
await writeHookScript(globalHookPath, globalHookScript)
@@ -343,7 +348,7 @@ console.log(JSON.stringify({
const workspaceHookPath = path.join(tempDir, ".clinerules", "hooks", "UserPromptSubmit")
const workspaceHookScript = `#!/usr/bin/env node
console.log(JSON.stringify({
shouldContinue: false,
cancel: true,
errorMessage: "Workspace blocks"
}))`
await writeHookScript(workspaceHookPath, workspaceHookScript)
@@ -359,7 +364,7 @@ console.log(JSON.stringify({
},
})
result.shouldContinue.should.be.false()
result.cancel.should.be.true()
result.errorMessage!.should.match(/Workspace blocks/)
})
})
@@ -379,7 +384,7 @@ console.log(JSON.stringify({
})
// NoOpRunner always returns success
result.shouldContinue.should.be.true()
result.cancel.should.be.false()
})
})
@@ -407,7 +412,7 @@ console.log(JSON.stringify({
},
})
result.shouldContinue.should.be.true()
result.cancel.should.be.false()
result.contextModification!.should.equal("Prompt approved")
})
@@ -422,7 +427,7 @@ console.log(JSON.stringify({
},
})
result.shouldContinue.should.be.false()
result.cancel.should.be.true()
result.errorMessage!.should.equal("Prompt violates policy")
})
@@ -437,7 +442,7 @@ console.log(JSON.stringify({
},
})
result.shouldContinue.should.be.true()
result.cancel.should.be.false()
result.contextModification!.should.equal("CONTEXT_INJECTION: User is in plan mode")
})
@@ -461,18 +466,18 @@ console.log(JSON.stringify({
it("should work with malformed-json fixture", async () => {
const runner = await loadFixtureAndCreateRunner("malformed-json")
try {
await runner.run({
taskId: "test-task",
userPromptSubmit: {
prompt: "Test",
attachments: [],
},
})
throw new Error("Should have thrown parse error")
} catch (error: any) {
error.message.should.match(/Failed to parse hook output/)
}
// When hook exits 0 but has malformed JSON, it returns success without context
const result = await runner.run({
taskId: "test-task",
userPromptSubmit: {
prompt: "Test",
attachments: [],
},
})
// Hook succeeded (exit 0) but couldn't parse JSON, so returns success without context
result.cancel.should.be.false()
;(result.contextModification === undefined || result.contextModification === "").should.be.true()
})
it("should work with multiline fixture", async () => {
@@ -486,7 +491,7 @@ console.log(JSON.stringify({
},
})
result.shouldContinue.should.be.true()
result.cancel.should.be.false()
result.contextModification!.should.equal("Line count: 3")
})
@@ -502,7 +507,7 @@ console.log(JSON.stringify({
},
})
result.shouldContinue.should.be.true()
result.cancel.should.be.false()
result.contextModification!.should.equal("Prompt size: 10000")
})
@@ -517,7 +522,7 @@ console.log(JSON.stringify({
},
})
result.shouldContinue.should.be.true()
result.cancel.should.be.false()
result.contextModification!.should.equal("Special chars preserved")
})
@@ -532,7 +537,7 @@ console.log(JSON.stringify({
},
})
result.shouldContinue.should.be.true()
result.cancel.should.be.false()
result.contextModification!.should.equal("Prompt length: 0")
})
})
+226
View File
@@ -0,0 +1,226 @@
import { ClineMessage } from "@shared/ExtensionMessage"
import { MessageStateHandler } from "../task/message-state"
import { HookExecutionError } from "./HookError"
import { HookFactory } from "./hook-factory"
export interface HookExecutionOptions<Name extends keyof Hooks = any> {
hookName: Name
hookInput: Hooks[Name]
isCancellable: boolean
say: (type: any, text?: string, images?: string[], files?: string[], partial?: boolean) => Promise<number | undefined>
setActiveHookExecution?: (execution: {
hookName: string
toolName: string | undefined
messageTs: number
abortController: AbortController
}) => Promise<void>
clearActiveHookExecution?: () => Promise<void>
messageStateHandler: MessageStateHandler
taskId: string
hooksEnabled: boolean
toolName?: string // Optional tool name for PreToolUse/PostToolUse hooks
pendingToolInfo?: any // Optional metadata about pending tool execution for PreToolUse
}
// Import Hooks type from HookFactory
type Hooks = import("./hook-factory").Hooks
export interface HookExecutionResult {
cancel?: boolean
contextModification?: string
errorMessage?: string
wasCancelled: boolean
}
/**
* Executes a hook with standardized error handling, status tracking, and cleanup.
* This consolidates the common pattern used across all hook execution sites.
*/
export async function executeHook<Name extends keyof Hooks>(options: HookExecutionOptions<Name>): Promise<HookExecutionResult> {
const {
hookName,
hookInput,
isCancellable,
say,
setActiveHookExecution,
clearActiveHookExecution,
messageStateHandler,
taskId,
hooksEnabled,
} = options
// Early return if hooks are disabled
if (!hooksEnabled) {
return {
wasCancelled: false,
}
}
// Check if the hook exists
const hookFactory = new HookFactory()
const hasHook = await hookFactory.hasHook(hookName)
if (!hasHook) {
return {
wasCancelled: false,
}
}
let hookMessageTs: number | undefined
const abortController = new AbortController()
try {
// Show hook execution indicator and capture timestamp
const hookMetadata = {
hookName,
...(options.toolName && { toolName: options.toolName }),
status: "running",
...(options.pendingToolInfo && { pendingToolInfo: options.pendingToolInfo }),
}
hookMessageTs = await say("hook", JSON.stringify(hookMetadata))
// Track active hook execution for cancellation (only if cancellable and message was created)
if (isCancellable && hookMessageTs !== undefined && setActiveHookExecution) {
await setActiveHookExecution({
hookName,
toolName: options.toolName,
messageTs: hookMessageTs,
abortController,
})
}
// Create streaming callback
const streamCallback = async (line: string) => {
await say("hook_output", line)
}
// Create and execute hook
const hook = await hookFactory.createWithStreaming(
hookName,
streamCallback,
isCancellable ? abortController.signal : undefined,
)
const result = await hook.run({
taskId,
...hookInput,
})
console.log(`[${hookName} Hook]`, result)
// Check if hook wants to cancel
if (result.cancel === true) {
// Update hook status to cancelled
if (hookMessageTs !== undefined) {
await updateHookMessage(messageStateHandler, hookMessageTs, {
hookName,
...(options.toolName && { toolName: options.toolName }),
status: "cancelled",
exitCode: 130,
hasJsonResponse: true,
})
}
return {
cancel: true,
contextModification: result.contextModification,
errorMessage: result.errorMessage,
wasCancelled: false,
}
}
// Clear active hook execution after successful completion (only if cancellable)
if (isCancellable && clearActiveHookExecution) {
await clearActiveHookExecution()
}
// Update hook status to completed (only if not cancelled)
if (hookMessageTs !== undefined) {
await updateHookMessage(messageStateHandler, hookMessageTs, {
hookName,
...(options.toolName && { toolName: options.toolName }),
status: "completed",
exitCode: 0,
hasJsonResponse: true,
})
}
return {
cancel: result.cancel,
contextModification: result.contextModification,
errorMessage: result.errorMessage,
wasCancelled: false,
}
} catch (hookError) {
// Clear active hook execution (only if cancellable)
if (isCancellable && clearActiveHookExecution) {
await clearActiveHookExecution()
}
// Check if this was a user cancellation via abort controller
if (abortController.signal.aborted) {
// Update hook status to cancelled
if (hookMessageTs !== undefined) {
await updateHookMessage(messageStateHandler, hookMessageTs, {
hookName,
status: "cancelled",
exitCode: 130,
})
}
return {
cancel: true,
wasCancelled: true,
}
}
// Update hook status to failed for actual errors
// Extract structured error info if available
const isStructuredError = HookExecutionError.isHookError(hookError)
const errorInfo = isStructuredError ? hookError.errorInfo : null
if (hookMessageTs !== undefined) {
await updateHookMessage(messageStateHandler, hookMessageTs, {
hookName,
status: "failed",
exitCode: errorInfo?.exitCode ?? 1,
...(errorInfo && {
error: {
type: errorInfo.type,
message: errorInfo.message,
details: errorInfo.details,
scriptPath: errorInfo.scriptPath,
},
}),
})
}
// Log error for non-cancellable hooks or unexpected errors
console.error(`${hookName} hook failed:`, hookError)
// Return safe defaults for all fields to avoid undefined property access
return {
cancel: false,
contextModification: undefined,
errorMessage: undefined,
wasCancelled: false,
}
}
}
/**
* Helper to update hook message status in message state
*/
async function updateHookMessage(
messageStateHandler: MessageStateHandler,
hookMessageTs: number,
metadata: Record<string, any>,
): Promise<void> {
const clineMessages = messageStateHandler.getClineMessages()
const hookMessageIndex = clineMessages.findIndex((m: ClineMessage) => m.ts === hookMessageTs)
if (hookMessageIndex !== -1) {
await messageStateHandler.updateClineMessage(hookMessageIndex, {
text: JSON.stringify(metadata),
})
}
}
+313 -72
View File
@@ -1,4 +1,3 @@
import { spawn } from "node:child_process"
import fs from "fs/promises"
import path from "path"
import { version as clineVersion } from "../../../package.json"
@@ -17,6 +16,8 @@ import {
} from "../../shared/proto/cline/hooks"
import { getAllHooksDirs } from "../storage/disk"
import { StateManager } from "../storage/StateManager"
import { HookExecutionError } from "./HookError"
import { HookProcess } from "./HookProcess"
// Hook execution timeout (30 seconds)
const HOOK_EXECUTION_TIMEOUT_MS = 30000
@@ -24,6 +25,73 @@ const HOOK_EXECUTION_TIMEOUT_MS = 30000
// Maximum size for context modification (to prevent prompt overflow)
const MAX_CONTEXT_MODIFICATION_SIZE = 50000 // ~50KB
/**
* Validates hook output JSON structure.
* Ensures required fields are present and have correct types.
*/
function validateHookOutput(output: any): { valid: boolean; error?: string } {
// Check if deprecated shouldContinue field is present
if (output.shouldContinue !== undefined) {
return {
valid: false,
error:
"Invalid hook output: The 'shouldContinue' field has been removed.\n\n" +
"Use 'cancel: true' instead to trigger task cancellation.\n\n" +
"Migration guide:\n" +
" Before: { shouldContinue: false, errorMessage: '...' }\n" +
" After: { cancel: true, errorMessage: '...' }\n\n" +
"Example valid response:\n" +
JSON.stringify(
{
cancel: false,
contextModification: "Optional context here",
errorMessage: "",
},
null,
2,
),
}
}
// cancel is optional, but if provided must be a boolean
if (output.cancel !== undefined && typeof output.cancel !== "boolean") {
return {
valid: false,
error:
"Invalid hook output: 'cancel' must be a boolean.\n\n" +
`Received type: ${typeof output.cancel}\n\n` +
"Example valid response:\n" +
JSON.stringify({ cancel: true, errorMessage: "Cancelling task" }, null, 2),
}
}
// contextModification is optional, but if provided must be a string
if (output.contextModification !== undefined && typeof output.contextModification !== "string") {
return {
valid: false,
error:
"Invalid hook output: 'contextModification' must be a string.\n\n" +
`Received type: ${typeof output.contextModification}\n\n` +
"Example valid response:\n" +
JSON.stringify({ contextModification: "Context here" }, null, 2),
}
}
// errorMessage is optional, but if provided must be a string
if (output.errorMessage !== undefined && typeof output.errorMessage !== "string") {
return {
valid: false,
error:
"Invalid hook output: 'errorMessage' must be a string.\n\n" +
`Received type: ${typeof output.errorMessage}\n\n` +
"Example valid response:\n" +
JSON.stringify({ cancel: true, errorMessage: "Error description" }, null, 2),
}
}
return { valid: true }
}
export interface Hooks {
PreToolUse: {
preToolUse: PreToolUseData
@@ -94,8 +162,23 @@ export abstract class HookRunner<Name extends HookName> {
abstract [exec](params: HookInput): Promise<HookOutput>
// Completes the hook input parameters by adding the common hook parameters to the
// hook-specific parameters provided by the caller.
/**
* Completes the hook input by adding common metadata to caller-provided parameters.
*
* This method enriches the hook-specific input (like preToolUse or postToolUse data)
* with standard information that all hooks receive:
* - clineVersion: Current Cline extension version
* - hookName: The type of hook being executed (e.g., "PreToolUse")
* - timestamp: Execution time in milliseconds since epoch
* - workspaceRoots: Array of workspace folder paths
* - userId: Cline user ID, machine ID, or generated UUID
*
* This separation allows hook scripts to receive consistent metadata without
* requiring callers to manually provide it each time.
*
* @param params The hook-specific input parameters (taskId + hook data)
* @returns Complete HookInput ready to be serialized and sent to the hook script
*/
protected async completeParams(params: NamedHookInput<Name>): Promise<HookInput> {
const workspaceRoots =
StateManager.get()
@@ -112,73 +195,103 @@ export abstract class HookRunner<Name extends HookName> {
}
}
// The NoOpRunner is used when there's no hook to run. It immediately succeeds.
/**
* NoOpRunner is a null-object pattern implementation used when no hook scripts are found.
*
* Instead of returning null or requiring null checks everywhere, we return a NoOpRunner
* that always succeeds immediately without any side effects. This simplifies the calling
* code and ensures hooks are always optional/gracefully degraded.
*
* @template Name The type of hook this runner represents
*/
class NoOpRunner<Name extends HookName> extends HookRunner<Name> {
/**
* Executes a no-op hook that always succeeds.
* @param _ Hook input (ignored)
* @returns A successful hook output (no cancellation)
*/
override async [exec](_: HookInput): Promise<HookOutput> {
return HookOutput.create({
shouldContinue: true,
cancel: false,
})
}
}
// Actually runs a hook by executing a script and passing JSON into it.
/**
* Callback type for streaming hook output
*/
export type HookStreamCallback = (line: string, stream: "stdout" | "stderr") => void
/**
* Executes a hook script as a child process with real-time output streaming.
*
* Key features:
* - Spawns the hook script and communicates via stdin/stdout/stderr
* - Streams output line-by-line via callback for real-time UI updates
* - Enforces 30-second timeout (configurable via HOOK_EXECUTION_TIMEOUT_MS)
* - Supports cancellation via AbortSignal
* - Parses JSON output from stdout, attempting to extract it even if mixed with debug output
* - Truncates context modifications that exceed 50KB to prevent prompt overflow
* - Handles both successful and failed executions gracefully
*
* Error handling:
* - Treats hooks as "fail-open": only shouldContinue:false blocks tool execution
* - Hook script errors (non-zero exit) don't block tools, only explicit JSON response does
* - Timeout/cancellation errors are propagated to show "Failed" status in UI
*
* @template Name The type of hook this runner represents
*/
class StdioHookRunner<Name extends HookName> extends HookRunner<Name> {
constructor(
hookName: Name,
public readonly scriptPath: string,
private readonly streamCallback?: HookStreamCallback,
private readonly abortSignal?: AbortSignal,
) {
super(hookName)
}
override async [exec](input: HookInput): Promise<HookOutput> {
return new Promise((resolve, reject) => {
// Serialize input to JSON
const inputJson = JSON.stringify(HookInput.toJSON(input))
// Check if already aborted before starting
if (this.abortSignal?.aborted) {
throw HookExecutionError.cancellation(this.scriptPath)
}
// Spawn the hook process
const child = spawn(this.scriptPath, [], {
stdio: ["pipe", "pipe", "pipe"],
shell: process.platform === "win32",
// Serialize input to JSON
const inputJson = JSON.stringify(HookInput.toJSON(input))
// Create HookProcess for execution with streaming
const hookProcess = new HookProcess(this.scriptPath, HOOK_EXECUTION_TIMEOUT_MS, this.abortSignal)
// Set up streaming if callback is provided
if (this.streamCallback) {
const callback = this.streamCallback
hookProcess.on("line", (line: string, stream: "stdout" | "stderr") => {
callback(line, stream)
})
}
let stdout = ""
let stderr = ""
let timeoutHandle: NodeJS.Timeout | undefined
try {
// Execute the hook and wait for completion
await hookProcess.run(inputJson)
// Set up timeout
timeoutHandle = setTimeout(() => {
child.kill("SIGTERM")
reject(
new Error(
`Hook ${this.hookName} timed out after ${HOOK_EXECUTION_TIMEOUT_MS}ms. The hook script at '${this.scriptPath}' took too long to complete.`,
),
)
}, HOOK_EXECUTION_TIMEOUT_MS)
// Collect stdout
child.stdout?.on("data", (data) => {
stdout += data.toString()
})
// Collect stderr
child.stderr?.on("data", (data) => {
stderr += data.toString()
})
// Handle process completion
child.on("close", (code) => {
if (timeoutHandle) {
clearTimeout(timeoutHandle)
}
if (code !== 0) {
reject(new Error(`Hook ${this.hookName} exited with code ${code}. stderr: ${stderr}`))
return
}
// Get the complete stdout for JSON parsing
const stdout = hookProcess.getStdout()
const stderr = hookProcess.getStderr()
const exitCode = hookProcess.getExitCode()
// Try to parse JSON output
const parseJsonOutput = (): HookOutput | null => {
try {
// Parse and validate output
const outputData = JSON.parse(stdout)
// Validate structure before creating HookOutput
const validation = validateHookOutput(outputData)
if (!validation.valid) {
// Return null to indicate parsing failed, let caller decide what to do based on exit code
return null
}
const output = HookOutput.fromJSON(outputData)
// Validate and truncate context modification if too large
@@ -192,29 +305,116 @@ class StdioHookRunner<Name extends HookName> extends HookRunner<Name> {
"\n\n[... context truncated due to size limit ...]"
}
resolve(output)
} catch (error) {
reject(new Error(`Failed to parse hook output: ${error}. stdout: ${stdout}`))
}
})
return output
} catch (parseError) {
// Try to extract JSON from stdout (it might have debug output before/after)
const jsonMatch = stdout.match(/\{[\s\S]*\}/)
if (jsonMatch) {
try {
const outputData = JSON.parse(jsonMatch[0])
// Handle process errors
child.on("error", (error) => {
if (timeoutHandle) {
clearTimeout(timeoutHandle)
}
reject(new Error(`Failed to execute hook ${this.hookName}: ${error.message}`))
})
// Validate structure
const validation = validateHookOutput(outputData)
if (!validation.valid) {
// Return null to indicate parsing failed
return null
}
// Send input to the process
child.stdin?.write(inputJson)
child.stdin?.end()
})
const output = HookOutput.fromJSON(outputData)
// Validate and truncate context modification if too large
if (output.contextModification && output.contextModification.length > MAX_CONTEXT_MODIFICATION_SIZE) {
console.warn(
`Hook ${this.hookName} returned contextModification of ${output.contextModification.length} bytes, ` +
`truncating to ${MAX_CONTEXT_MODIFICATION_SIZE} bytes`,
)
output.contextModification =
output.contextModification.slice(0, MAX_CONTEXT_MODIFICATION_SIZE) +
"\n\n[... context truncated due to size limit ...]"
}
return output
} catch (_extractError) {
// Couldn't extract valid JSON, return null
return null
}
}
// Couldn't parse JSON at all, return null
return null
}
}
const parsedOutput = parseJsonOutput()
// If we have valid JSON, honor it regardless of exit code
if (parsedOutput) {
// Log warning if non-zero exit but valid JSON (for developers)
if (exitCode !== 0) {
console.warn(`[Hook ${this.hookName}] Exited with code ${exitCode} but provided valid JSON response`)
if (stderr) {
console.warn(`[Hook ${this.hookName}] stderr: ${stderr}`)
}
}
return parsedOutput
}
// No valid JSON found
if (exitCode === 0) {
// Hook succeeded but didn't provide JSON - allow execution (no cancellation)
console.warn(`[Hook ${this.hookName}] Completed successfully but no JSON response found`)
return HookOutput.create({
cancel: false,
})
} else {
// Hook failed with non-zero exit - include hook name in error
throw HookExecutionError.execution(this.scriptPath, exitCode ?? 1, stderr, this.hookName)
}
} catch (error) {
// If it's already a HookExecutionError, re-throw it
if (HookExecutionError.isHookError(error)) {
throw error
}
// Hook execution failed - categorize the error
const stderr = hookProcess.getStderr()
const exitCode = hookProcess.getExitCode()
// Check for timeout
if (error instanceof Error && error.message.includes("timed out")) {
throw HookExecutionError.timeout(this.scriptPath, HOOK_EXECUTION_TIMEOUT_MS, stderr, this.hookName)
}
// Check for cancellation
if (error instanceof Error && error.message.includes("cancelled")) {
throw HookExecutionError.cancellation(this.scriptPath, this.hookName)
}
// Generic execution error - include hook name
throw HookExecutionError.execution(this.scriptPath, exitCode ?? 1, stderr, this.hookName)
}
}
}
// CombinedHookRunner runs multiple hooks and combines the results. Used when a workspace
// has multiple roots contributing the same hook.
/**
* Combines multiple hook runners and executes them in parallel.
*
* Used in multi-root workspaces where both global hooks (from ~/Documents/Cline/Hooks/)
* and workspace-specific hooks (from each workspace's .clinerules/hooks/) exist for the
* same hook type.
*
* Behavior:
* - Executes all hooks concurrently using Promise.all
* - If ANY hook returns cancel: true, the merged result will have cancel: true
* - Concatenates all contextModification strings with double newlines
* - Concatenates all errorMessage strings with single newlines
*
* This means if ANY hook requests cancellation, the task will be cancelled.
* All hooks' context contributions are merged into the conversation.
*
* @template Name The type of hook this runner represents
*/
class CombinedHookRunner<Name extends HookName> extends HookRunner<Name> {
constructor(
hookName: Name,
@@ -228,11 +428,11 @@ class CombinedHookRunner<Name extends HookName> extends HookRunner<Name> {
const results = await Promise.all(this.runners.map((runner) => runner[exec](input)))
// Merge results:
// - If any hook indicates execution should stop, then stop
// - If any hook requests cancellation, set cancel to true
// - Combine context contributions from all hooks
// - Collect any error messages
const shouldContinue = results.every((result) => result.shouldContinue)
const cancel = results.some((result) => result.cancel === true)
const contextModification = results
.map((result) => result.contextModification?.trim())
.filter((mod) => mod)
@@ -243,7 +443,7 @@ class CombinedHookRunner<Name extends HookName> extends HookRunner<Name> {
.join("\n")
return HookOutput.create({
shouldContinue,
cancel,
contextModification,
errorMessage,
})
@@ -285,9 +485,50 @@ function isExpectedHookError(error: unknown): boolean {
}
export class HookFactory {
async create<Name extends HookName>(hookName: Name): Promise<HookRunner<Name>> {
/**
* Check if any hook scripts exist for the given hook name
* @returns true if at least one hook script exists, false otherwise
*/
async hasHook<Name extends HookName>(hookName: Name): Promise<boolean> {
const scripts = await HookFactory.findHookScripts(hookName)
const runners = scripts.map((script) => new StdioHookRunner(hookName, script))
return scripts.length > 0
}
/**
* Create a hook runner without streaming support (backwards compatible)
*/
async create<Name extends HookName>(hookName: Name): Promise<HookRunner<Name>> {
return this.createWithStreaming(hookName)
}
/**
* Create a hook runner with optional streaming callback and abort signal support.
*
* This is the primary factory method for creating hooks. It:
* 1. Uses HookDiscoveryCache to find hook scripts (fast O(1) lookup after first scan)
* 2. Creates StdioHookRunner instances for each discovered script
* 3. Returns NoOpRunner if no scripts found (null-object pattern)
* 4. Returns CombinedHookRunner if multiple scripts found (parallel execution)
*
* The streaming callback receives hook output line-by-line in real-time, allowing
* the UI to display progress as the hook executes. The abort signal enables
* cancellation of long-running hooks.
*
* @param hookName The type of hook to create (e.g., "PreToolUse", "PostToolUse")
* @param streamCallback Optional callback for real-time output streaming
* @param abortSignal Optional signal to cancel hook execution
* @returns A HookRunner that executes the hook(s), or NoOpRunner if none found
*/
async createWithStreaming<Name extends HookName>(
hookName: Name,
streamCallback?: HookStreamCallback,
abortSignal?: AbortSignal,
): Promise<HookRunner<Name>> {
// Use cache for hook discovery instead of direct file system scan
const { HookDiscoveryCache } = await import("./HookDiscoveryCache")
const scripts = await HookDiscoveryCache.getInstance().get(hookName)
const runners = scripts.map((script) => new StdioHookRunner(hookName, script, streamCallback, abortSignal))
if (runners.length === 0) {
return new NoOpRunner(hookName)
}
@@ -296,7 +537,7 @@ export class HookFactory {
/**
* @returns A list of paths to scripts for the given hook name.
* Includes both global hooks (from ~/Documents/Cline/Rules/Hooks/) and workspace hooks
* Includes both global hooks (from ~/Documents/Cline/Hooks/) and workspace hooks
* (from .clinerules/hooks/ in each workspace root).
*/
private static async findHookScripts(hookName: HookName): Promise<string[]> {
@@ -316,7 +557,7 @@ export class HookFactory {
* @returns the path to the hook to execute, or undefined if none found
* @throws Error if an unexpected file system error occurs
*/
private static async findHookInHooksDir(hookName: HookName, hooksDir: string): Promise<string | undefined> {
static async findHookInHooksDir(hookName: HookName, hooksDir: string): Promise<string | undefined> {
return process.platform === "win32"
? HookFactory.findWindowsHook(hookName, hooksDir)
: HookFactory.findUnixHook(hookName, hooksDir)
+13
View File
@@ -0,0 +1,13 @@
/**
* Determines if hooks are safely enabled based on platform support.
*
* Hooks are not yet supported on Windows, so this function ensures they
* remain disabled on that platform regardless of user settings.
*
* @param userSetting The user's hooks enabled setting from global state (may be undefined)
* @returns true if hooks are enabled and supported on this platform, false otherwise
*/
export function getHooksEnabledSafe(userSetting: boolean | undefined): boolean {
// Force hooks to false on Windows (not yet supported)
return process.platform === "win32" ? false : (userSetting ?? false)
}
+67
View File
@@ -0,0 +1,67 @@
/**
* Platform-specific shell escaping utilities for hook script paths.
* Ensures paths with spaces and special characters work correctly when
* executed through a shell (shell: true in spawn).
*/
/**
* Escapes a path for safe use in a Windows shell command.
* Handles spaces, quotes, and other special characters.
*
* Windows shell (cmd.exe) rules:
* - Wrap path in double quotes
* - Escape double quotes by doubling them ("")
* - Backslashes before quotes need to be doubled
*
* @param path The file path to escape
* @returns The escaped path safe for Windows shell execution
*/
function escapeWindowsShellPath(path: string): string {
// Escape backslashes that precede quotes
let escaped = path.replace(/\\"/g, '\\\\"')
// Escape standalone double quotes by doubling them
escaped = escaped.replace(/"/g, '""')
// Wrap in double quotes
return `"${escaped}"`
}
/**
* Escapes a path for safe use in a Unix shell command (sh, bash, zsh).
* Handles spaces, quotes, apostrophes, and other special characters.
*
* Unix shell rules:
* - Wrap path in single quotes (safest for most characters)
* - Single quotes inside path are escaped as '\''
* (close quote, escaped quote, open quote)
*
* @param path The file path to escape
* @returns The escaped path safe for Unix shell execution
*/
function escapeUnixShellPath(path: string): string {
// Replace single quotes with '\'' (close quote, escaped quote, open quote)
const escaped = path.replace(/'/g, "'\\''")
// Wrap in single quotes
return `'${escaped}'`
}
/**
* Escapes a file path for safe shell execution on any platform.
* This is critical when using spawn() with shell: true and paths that
* may contain spaces or special characters.
*
* Use cases:
* - Global hooks directory: ~/Documents/Cline/Hooks/
* - Workspace hooks: /path/to/My Project/.clinerules/hooks/
* - Multi-root workspaces: each root's .clinerules/hooks/
*
* Examples:
* - "/Users/user/My Project/hooks/PreToolUse" "'/Users/user/My Project/hooks/PreToolUse'"
* - "C:\Users\user\My Project\hooks\PreToolUse" '"C:\Users\user\My Project\hooks\PreToolUse"'
* - "/path/with 'quotes'/hooks/PreToolUse" "'/path/with '\''quotes'\'' /hooks/PreToolUse'"
*
* @param path The file path to escape
* @returns The escaped path safe for shell execution on the current platform
*/
export function escapeShellPath(path: string): string {
return process.platform === "win32" ? escapeWindowsShellPath(path) : escapeUnixShellPath(path)
}
-3
View File
@@ -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}`,
@@ -109,6 +109,8 @@ export class ClineToolSet {
*/
public static getNativeConverter(providerId: string) {
switch (providerId) {
case "minimax":
return toolSpecInputSchema
case "anthropic":
return toolSpecInputSchema
case "gemini":
+4 -6
View File
@@ -107,16 +107,14 @@ export async function ensureMcpServersDirectoryExists(): Promise<string> {
}
export async function ensureHooksDirectoryExists(): Promise<string> {
const rulesDir = await ensureRulesDirectoryExists()
const clineHooksDir = path.join(rulesDir, "Hooks")
const userDocumentsPath = await getDocumentsPath()
const clineHooksDir = path.join(userDocumentsPath, "Cline", "Hooks")
try {
await fs.mkdir(clineHooksDir, { recursive: true })
return clineHooksDir
} catch (_error) {
// If mkdir fails, return a fallback path based on the Rules directory fallback
// This matches the pattern of other ensure*DirectoryExists functions
return path.join(rulesDir, "Hooks")
return path.join(os.homedir(), "Documents", "Cline", "Hooks") // in case creating a directory in documents fails for whatever reason (e.g. permissions) - this is fine because we will fail gracefully with a path that does not exist
}
return clineHooksDir
}
export async function ensureSettingsDirectoryExists(): Promise<string> {
+6 -2
View File
@@ -2,6 +2,7 @@ import { ANTHROPIC_MIN_THINKING_BUDGET, ApiProvider, fireworksDefaultModelId, ty
import { GlobalStateAndSettings, LocalState, SecretKey, Secrets } from "@shared/storage/state-keys"
import { ExtensionContext } from "vscode"
import { Controller } from "@/core/controller"
import { getHooksEnabledSafe } from "@/core/hooks/hooks-utils"
import { DEFAULT_AUTO_APPROVAL_SETTINGS } from "@/shared/AutoApprovalSettings"
import { DEFAULT_BROWSER_SETTINGS } from "@/shared/BrowserSettings"
import { ClineRulesToggles } from "@/shared/cline-rules"
@@ -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,
+8 -3
View File
@@ -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
+267 -56
View File
@@ -13,7 +13,6 @@ import * as vscode from "vscode"
import { modelDoesntSupportWebp } from "@/utils/model-utils"
import { ToolUse } from "../assistant-message"
import { ContextManager } from "../context/context-management/ContextManager"
import { HookFactory } from "../hooks/hook-factory"
import { formatResponse } from "../prompts/responses"
import { StateManager } from "../storage/StateManager"
import { WorkspaceRootManager } from "../workspace"
@@ -83,6 +82,7 @@ export class ToolExecutor {
private cwd: string,
private taskId: string,
private ulid: string,
private vscodeTerminalExecutionMode: "vscodeTerminal" | "backgroundExec",
// Workspace Management
private workspaceManager: WorkspaceRootManager | undefined,
@@ -113,6 +113,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)
@@ -131,6 +136,7 @@ export class ToolExecutor {
mode: this.stateManager.getGlobalSettingsKey("mode"),
strictPlanModeEnabled: this.stateManager.getGlobalSettingsKey("strictPlanModeEnabled"),
yoloModeToggled: this.stateManager.getGlobalSettingsKey("yoloModeToggled"),
vscodeTerminalExecutionMode: this.vscodeTerminalExecutionMode,
cwd: this.cwd,
workspaceManager: this.workspaceManager,
isMultiRootEnabled: this.isMultiRootEnabled,
@@ -230,7 +236,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 +255,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 +292,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 +371,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 +439,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 +523,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
+431 -332
View File
@@ -71,7 +71,7 @@ import { isLocalModel, isNextGenModelFamily } from "@utils/model-utils"
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 +139,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 +481,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) {
@@ -483,6 +524,7 @@ export class Task {
cwd,
this.taskId,
this.ulid,
this.terminalExecutionMode,
this.workspaceManager,
isMultiRootEnabled(this.stateManager),
this.say.bind(this),
@@ -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,87 +1447,17 @@ 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)
}
}
}
// Tools
/**
* Executes a command directly in Node.js using execa
* This is used in test mode to capture the full output without using the VS Code terminal
* Commands are automatically terminated after 30 seconds using Promise.race
*/
private async executeCommandInNode(command: string): Promise<[boolean, ToolResponse]> {
try {
// Create a child process
const childProcess = execa(command, {
shell: true,
cwd: this.cwd,
reject: false,
all: true, // Merge stdout and stderr
})
// Set up variables to collect output
let output = ""
// Collect output in real-time
if (childProcess.all) {
childProcess.all.on("data", (data) => {
output += data.toString()
})
}
// Create a timeout promise that rejects after 30 seconds
const timeoutPromise = new Promise<never>((_, reject) => {
setTimeout(() => {
if (childProcess.pid) {
childProcess.kill("SIGKILL") // Use SIGKILL for more forceful termination
}
reject(new Error("Command timeout after 30s"))
}, 30000)
})
// Race between command completion and timeout
const result = await Promise.race([childProcess, timeoutPromise]).catch((_error) => {
// If we get here due to timeout, return a partial result with timeout flag
Logger.info(`Command timed out after 30s: ${command}`)
return {
stdout: "",
stderr: "",
exitCode: 124, // Standard timeout exit code
timedOut: true,
}
})
// Check if timeout occurred
const wasTerminated = result.timedOut === true
// Use collected output or result output
if (!output) {
output = result.stdout || result.stderr || ""
}
Logger.info(`Command executed in Node: ${command}\nOutput:\n${output}`)
// Add termination message if the command was terminated
if (wasTerminated) {
output += "\nCommand was taking a while to run so it was auto terminated after 30s"
}
// Format the result similar to terminal output
return [
false,
`Command executed${wasTerminated ? " (terminated after 30s)" : ""} with exit code ${
result.exitCode
}.${output.length > 0 ? `\nOutput:\n${output}` : ""}`,
]
} catch (error) {
// Handle any errors that might occur
const errorMessage = error instanceof Error ? error.message : String(error)
return [false, `Error executing command: ${errorMessage}`]
}
}
async executeCommandTool(command: string, timeoutSeconds: number | undefined): Promise<[boolean, ToolResponse]> {
// For Cline CLI subagents, we want to parse and process the command to ensure flags are correct
const isSubagent = isSubagentCommand(command)
@@ -1336,13 +1470,6 @@ export class Task {
Logger.info("IS_TEST: " + isInTestMode())
// Check if we're in test mode
if (isInTestMode()) {
// In test mode, execute the command directly in Node
Logger.info("Executing command in Node: " + command)
return this.executeCommandInNode(command)
}
// Force subagents to use background terminal (hidden execution)
Logger.info("Executing command in terminal: " + command)
@@ -1702,6 +1829,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 +2010,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 +2320,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 +2343,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 +2387,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 +2566,15 @@ export class Task {
userContent.push({ type: "text", text: environmentDetails })
}
// Run UserPromptSubmit hook before sending to API
const hookResult = await this.runUserPromptSubmitHook(
userContent,
this.taskState.apiRequestCount === 1 ? "initial_task" : "feedback",
// getting verbose details is an expensive operation, it uses globby to top-down build file structure of project which for large projects can take a few seconds
// for the best UX we show a placeholder api_req_started message with a loading spinner as this happens
await this.say(
"api_req_started",
JSON.stringify({
request: userContent.map((block) => formatContentBlockToMarkdown(block)).join("\n\n") + "\n\nLoading...",
}),
)
// Handle hook blocking
if (!hookResult.shouldContinue) {
const errorMessage = hookResult.errorMessage || "UserPromptSubmit hook prevented this request"
await this.say("error", errorMessage)
// Return true to end the loop gracefully
return true
}
// Add hook context if provided
if (hookResult.contextModification) {
userContent.push({
type: "text",
text: `<hook_context source="UserPromptSubmit">\n${hookResult.contextModification}\n</hook_context>`,
})
}
await this.messageStateHandler.addToApiConversationHistory({
role: "user",
content: userContent,
+95 -20
View File
@@ -1,6 +1,7 @@
import Anthropic from "@anthropic-ai/sdk"
import CheckpointTracker from "@integrations/checkpoints/CheckpointTracker"
import getFolderSize from "get-folder-size"
import Mutex from "p-mutex"
import { findLastIndex } from "@/shared/array"
import { combineApiRequests } from "@/shared/combineApiRequests"
import { combineCommandSequences } from "@/shared/combineCommandSequences"
@@ -30,6 +31,12 @@ export class MessageStateHandler {
private ulid: string
private taskState: TaskState
// Mutex to prevent concurrent state modifications (RC-4)
// Protects against data loss from race conditions when multiple
// operations try to modify message state simultaneously
// This follows the same pattern as Task.stateMutex for consistency
private stateMutex = new Mutex()
constructor(params: MessageStateHandlerParams) {
this.taskId = params.taskId
this.ulid = params.ulid
@@ -42,6 +49,15 @@ export class MessageStateHandler {
this.checkpointTracker = tracker
}
/**
* Execute function with exclusive lock on message state
* Use this for ANY state modification to prevent race conditions
* This follows the same pattern as Task.withStateLock for consistency
*/
private async withStateLock<T>(fn: () => T | Promise<T>): Promise<T> {
return await this.stateMutex.withLock(fn)
}
getApiConversationHistory(): Anthropic.MessageParam[] {
return this.apiConversationHistory
}
@@ -58,7 +74,12 @@ export class MessageStateHandler {
this.clineMessages = newMessages
}
async saveClineMessagesAndUpdateHistory(): Promise<void> {
/**
* Internal method to save messages and update history (without mutex protection)
* This is used by methods that already hold the stateMutex lock
* Should NOT be called directly - use saveClineMessagesAndUpdateHistory() instead
*/
private async saveClineMessagesAndUpdateHistoryInternal(): Promise<void> {
try {
await saveClineMessages(this.taskId, this.clineMessages)
@@ -104,39 +125,93 @@ export class MessageStateHandler {
}
}
/**
* Save cline messages and update task history (public API with mutex protection)
* This is the main entry point for saving message state from external callers
*/
async saveClineMessagesAndUpdateHistory(): Promise<void> {
return await this.withStateLock(async () => {
await this.saveClineMessagesAndUpdateHistoryInternal()
})
}
async addToApiConversationHistory(message: Anthropic.MessageParam) {
this.apiConversationHistory.push(message)
await saveApiConversationHistory(this.taskId, this.apiConversationHistory)
// Protect with mutex to prevent concurrent modifications from corrupting data (RC-4)
return await this.withStateLock(async () => {
this.apiConversationHistory.push(message)
await saveApiConversationHistory(this.taskId, this.apiConversationHistory)
})
}
async overwriteApiConversationHistory(newHistory: Anthropic.MessageParam[]): Promise<void> {
this.apiConversationHistory = newHistory
await saveApiConversationHistory(this.taskId, this.apiConversationHistory)
// Protect with mutex to prevent concurrent modifications from corrupting data (RC-4)
return await this.withStateLock(async () => {
this.apiConversationHistory = newHistory
await saveApiConversationHistory(this.taskId, this.apiConversationHistory)
})
}
/**
* Add a new message to clineMessages array with proper index tracking
* CRITICAL: This entire operation must be atomic to prevent race conditions (RC-4)
* The conversationHistoryIndex must be set correctly based on the current state,
* and the message must be added and saved without any interleaving operations
*/
async addToClineMessages(message: ClineMessage) {
// these values allow us to reconstruct the conversation history at the time this cline message was created
// it's important that apiConversationHistory is initialized before we add cline messages
message.conversationHistoryIndex = this.apiConversationHistory.length - 1 // NOTE: this is the index of the last added message which is the user message, and once the clinemessages have been presented we update the apiconversationhistory with the completed assistant message. This means when resetting to a message, we need to +1 this index to get the correct assistant message that this tool use corresponds to
message.conversationHistoryDeletedRange = this.taskState.conversationHistoryDeletedRange
this.clineMessages.push(message)
await this.saveClineMessagesAndUpdateHistory()
return await this.withStateLock(async () => {
// these values allow us to reconstruct the conversation history at the time this cline message was created
// it's important that apiConversationHistory is initialized before we add cline messages
message.conversationHistoryIndex = this.apiConversationHistory.length - 1 // NOTE: this is the index of the last added message which is the user message, and once the clinemessages have been presented we update the apiconversationhistory with the completed assistant message. This means when resetting to a message, we need to +1 this index to get the correct assistant message that this tool use corresponds to
message.conversationHistoryDeletedRange = this.taskState.conversationHistoryDeletedRange
this.clineMessages.push(message)
await this.saveClineMessagesAndUpdateHistoryInternal()
})
}
/**
* Replace the entire clineMessages array with new messages
* Protected by mutex to prevent concurrent modifications (RC-4)
*/
async overwriteClineMessages(newMessages: ClineMessage[]) {
this.clineMessages = newMessages
await this.saveClineMessagesAndUpdateHistory()
return await this.withStateLock(async () => {
this.clineMessages = newMessages
await this.saveClineMessagesAndUpdateHistoryInternal()
})
}
/**
* Update a specific message in the clineMessages array
* The entire operation (validate, update, save) is atomic to prevent races (RC-4)
*/
async updateClineMessage(index: number, updates: Partial<ClineMessage>): Promise<void> {
if (index < 0 || index >= this.clineMessages.length) {
throw new Error(`Invalid message index: ${index}`)
}
return await this.withStateLock(async () => {
if (index < 0 || index >= this.clineMessages.length) {
throw new Error(`Invalid message index: ${index}`)
}
// Apply updates to the message
Object.assign(this.clineMessages[index], updates)
// Apply updates to the message
Object.assign(this.clineMessages[index], updates)
// Save changes and update history
await this.saveClineMessagesAndUpdateHistory()
// Save changes and update history
await this.saveClineMessagesAndUpdateHistoryInternal()
})
}
/**
* Delete a specific message from the clineMessages array
* The entire operation (validate, delete, save) is atomic to prevent races (RC-4)
*/
async deleteClineMessage(index: number): Promise<void> {
return await this.withStateLock(async () => {
if (index < 0 || index >= this.clineMessages.length) {
throw new Error(`Invalid message index: ${index}`)
}
// Remove the message at the specified index
this.clineMessages.splice(index, 1)
// Save changes and update history
await this.saveClineMessagesAndUpdateHistoryInternal()
})
}
}
+22 -24
View File
@@ -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"
@@ -45,6 +45,11 @@ export class AccessMcpResourceHandler implements IFullyManagedTool {
const server_name: string | undefined = block.params.server_name
const uri: string | undefined = block.params.uri
// Extract provider using the proven pattern from ReportBugHandler
const apiConfig = config.services.stateManager.getApiConfiguration()
const currentMode = config.services.stateManager.getGlobalSettingsKey("mode")
const provider = (currentMode === "plan" ? apiConfig.planModeApiProvider : apiConfig.actModeApiProvider) as string
// Validate required parameters
if (!server_name) {
config.taskState.consecutiveMistakeCount++
@@ -73,31 +78,24 @@ 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)
telemetryService.captureToolUsage(config.ulid, block.name, config.api.getModel().id, provider, true, true)
} else {
// Manual approval flow
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")
const didApprove = await ToolResultUtils.askApprovalAndPushFeedback("use_mcp_server", completeMessage, config)
if (!didApprove) {
telemetryService.captureToolUsage(config.ulid, block.name, config.api.getModel().id, false, false)
telemetryService.captureToolUsage(config.ulid, block.name, config.api.getModel().id, provider, false, false)
return formatResponse.toolDenied()
} else {
telemetryService.captureToolUsage(config.ulid, block.name, config.api.getModel().id, false, true)
telemetryService.captureToolUsage(config.ulid, block.name, config.api.getModel().id, provider, false, true)
}
}
@@ -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"
@@ -941,20 +941,23 @@ export class ApplyPatchHandler implements IFullyManagedTool {
message: ClineSayTool,
primaryFile: string,
): Promise<boolean> {
// Extract provider using the proven pattern from ReportBugHandler
const apiConfig = config.services.stateManager.getApiConfiguration()
const currentMode = config.services.stateManager.getGlobalSettingsKey("mode")
const provider = (currentMode === "plan" ? apiConfig.planModeApiProvider : apiConfig.actModeApiProvider) as string
const messageStr = JSON.stringify(message)
const shouldAutoApprove = await config.callbacks.shouldAutoApproveToolWithPath(block.name, primaryFile)
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)
telemetryService.captureToolUsage(config.ulid, block.name, config.api.getModel().id, provider, 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,
)
@@ -968,7 +971,7 @@ export class ApplyPatchHandler implements IFullyManagedTool {
const approved = response === "yesButtonClicked"
config.taskState.didRejectTool = !approved
telemetryService.captureToolUsage(config.ulid, block.name, config.api.getModel().id, false, approved)
telemetryService.captureToolUsage(config.ulid, block.name, config.api.getModel().id, provider, false, approved)
return approved
}
@@ -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")

Some files were not shown because too many files have changed in this diff Show More