mirror of
https://github.com/cline/cline.git
synced 2026-09-06 12:28:08 +08:00
Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 99234a0d8d | |||
| b7c7502985 | |||
| de467a5668 | |||
| 8371c0817d | |||
| ce639b6b53 | |||
| 0b81c74b60 | |||
| def8f84d91 | |||
| 8d4cde1e79 | |||
| c371c72f1b | |||
| f96ff00ba8 | |||
| 41389269da | |||
| 2c1aaeea83 |
+321
@@ -0,0 +1,321 @@
|
||||
---
|
||||
title: "Hooks System MVP - Phase 1"
|
||||
description: "Technical specification for Phase 1 hooks implementation with protobuf-based interfaces"
|
||||
date: 2025-09-09
|
||||
draft: false
|
||||
---
|
||||
|
||||
# Hooks System MVP - Phase 1
|
||||
|
||||
This page documents the minimum viable product (MVP) implementation for Cline's hooks system, focusing on the seven Phase 1 hooks identified by client requirements. Each hook provides standardized input/output interfaces using protobuf-based data structures for consistency with Cline's existing gRPC architecture.
|
||||
|
||||
## Phase 1 Hook Overview
|
||||
|
||||
The MVP focuses on essential lifecycle and tool execution hooks that provide the highest value for automation and integration workflows:
|
||||
|
||||
| Hook Name | Category | Trigger Point | Implementation Hours |
|
||||
|-----------|----------|---------------|---------------------|
|
||||
| `PreToolUse` | Tool Execution | Before any tool execution | 8-12 hours |
|
||||
| `PostToolUse` | Tool Execution | After successful tool execution | 6-10 hours |
|
||||
| `UserPromptSubmit` | User Interaction | When user submits a message | 4-6 hours |
|
||||
| `TaskStart` | Task Lifecycle | When a new task begins | 6-8 hours |
|
||||
| `TaskResume` | Task Lifecycle | When resuming an existing task | 8-10 hours |
|
||||
| `TaskCancel` | Task Lifecycle | User cancels task | 4-6 hours |
|
||||
| `TaskComplete` | Task Lifecycle | When attempt_completion succeeds | 4-6 hours |
|
||||
| `PreCompact` | System Events | Before context compaction | 10-14 hours |
|
||||
|
||||
**Total estimated effort: 46-66 hours**
|
||||
|
||||
## Addressing Amazon's Requirements:
|
||||
|
||||
| Req | Judgement |
|
||||
|--|--|
|
||||
| Hooks that inject context should support blocking/synchronous behavior with timeouts | all hooks blocking & timeout should be implemented by hook |
|
||||
| Hooks that do not inject context can run Asynchronously | up to hook: start a background process & return no changes to context |
|
||||
| Hook failures should be communicated clearly to the user and logged | supported: error field in hook return |
|
||||
| Hooks should support both parallel and sequential execution to minimize latency | sequential only, single hook entrypoint only, up to implementers |
|
||||
| Hooks should support both synchronous and asynchronous execution | up to hook: start a background process & return no changes to context |
|
||||
| Hooks should support a timeout in order to not block the agent if failing | up to hook implementation |
|
||||
| Configuration should be simple and flexible | same as git hooks |
|
||||
| Hooks should have access to relevant context about the triggering event | included in spec |
|
||||
| Context retention should be configurable - some hooks need persistent context, others should avoid consuming context window | we support persistent context only, use a subagent (cline-cli) in hook |
|
||||
| Hook actions should be instrumented, and observable to see exactly what hooks are doing to help debug / iterate. | up to hook implementation |
|
||||
|
||||
| Req | Judgement |
|
||||
|--|--|
|
||||
| Configuration Format | Git hooks style instead of claude style |
|
||||
| Context scope | Support global hooks in `~/.cline` and folder level hooks at `MyRepo/.clinerules` |
|
||||
| Multiple hooks | Single entry point executable, manage multiple hooks however you want |
|
||||
| Async vs sync | We only support sync & permanent context |
|
||||
| Error handling | We support returning errors from hooks |
|
||||
| Telemetry | Up to hook implementation |
|
||||
| Toggling hooks | like git hooks, use `chmod` to change executable bit |
|
||||
|
||||
## Data Structures
|
||||
|
||||
|
||||
### Hook Directory Structure
|
||||
|
||||
Implemented the same way as git hooks: a single entry point that can be any executable. Toggling hooks is done via `chmod +x` or `-x`
|
||||
|
||||
```
|
||||
.clinerules/ (or .cline)
|
||||
├── hooks/
|
||||
│ ├── TaskStart*
|
||||
│ ├── TaskComplete*
|
||||
│ ├── PreFileWrite*
|
||||
│ ├── PostFileWrite*
|
||||
│ └── ...
|
||||
└── logs/
|
||||
├── TaskStart.log
|
||||
└── ...
|
||||
```
|
||||
|
||||
All hooks use protobuf-based data structures converted to JSON for consistency with Cline's gRPC architecture:
|
||||
|
||||
### Base Hook Input
|
||||
```protobuf
|
||||
message HookInput {
|
||||
string hook_name = 1;
|
||||
string timestamp = 2;
|
||||
string task_id = 3;
|
||||
repeated string workspace_roots = 4;
|
||||
string user_id = 5;
|
||||
oneof data {
|
||||
PreToolUseData pre_tool_use = 10;
|
||||
PostToolUseData post_tool_use = 11;
|
||||
UserPromptSubmitData user_prompt_submit = 12;
|
||||
TaskStartData task_start = 13;
|
||||
TaskResumeData task_resume = 14;
|
||||
TaskCancelData task_complete = 15;
|
||||
TaskCompleteData task_complete = 16;
|
||||
PreCompactData pre_compact = 17;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Base Hook Output
|
||||
```protobuf
|
||||
message HookOutput {
|
||||
string context_modification = 1;
|
||||
bool should_continue = 2;
|
||||
string error_message = 3;
|
||||
}
|
||||
```
|
||||
|
||||
## Hook Specifications
|
||||
|
||||
### PreToolUse Hook
|
||||
|
||||
**Trigger:** Before any tool execution
|
||||
**Purpose:** Validation, permission checks, parameter modification
|
||||
|
||||
**Input Data:**
|
||||
```protobuf
|
||||
message PreToolUseData {
|
||||
string tool_name = 1;
|
||||
map<string, string> parameters = 2;
|
||||
}
|
||||
```
|
||||
|
||||
**Use Cases:**
|
||||
- Validate tool parameters before execution
|
||||
- Implement custom permission checks
|
||||
- Log tool usage for audit trails
|
||||
- Modify parameters based on workspace context
|
||||
- Block dangerous operations in production environments
|
||||
|
||||
**Implementation Notes:**
|
||||
- Hook can prevent tool execution by setting `should_continue = false`
|
||||
- Context modifications can add warnings or guidance to the AI
|
||||
- Parameter validation should be comprehensive but fast
|
||||
|
||||
---
|
||||
|
||||
### PostToolUse Hook
|
||||
|
||||
**Trigger:** After successful tool execution
|
||||
**Purpose:** Logging, backup creation, result processing
|
||||
|
||||
**Input Data:**
|
||||
```protobuf
|
||||
message PostToolUseData {
|
||||
string tool_name = 1;
|
||||
map<string, string> parameters = 2;
|
||||
string result = 3;
|
||||
bool success = 4;
|
||||
int64 execution_time_ms = 5;
|
||||
}
|
||||
```
|
||||
|
||||
**Use Cases:**
|
||||
- Create automatic backups after file modifications
|
||||
- Log successful operations for debugging
|
||||
- Trigger downstream automation workflows
|
||||
- Update external systems with operation results
|
||||
- Generate metrics and performance data
|
||||
|
||||
**Implementation Notes:**
|
||||
- Hook receives full tool execution context
|
||||
- Can add context about operation success/failure
|
||||
- Should handle errors gracefully to avoid breaking workflows
|
||||
|
||||
---
|
||||
|
||||
### UserPromptSubmit Hook
|
||||
|
||||
**Trigger:** When user submits a message
|
||||
**Purpose:** Input validation, preprocessing, context enhancement
|
||||
|
||||
**Input Data:**
|
||||
```protobuf
|
||||
message UserPromptSubmitData {
|
||||
string prompt = 1;
|
||||
repeated string attachments = 2;
|
||||
}
|
||||
```
|
||||
|
||||
**Use Cases:**
|
||||
- Validate user input for security concerns
|
||||
- Preprocess prompts to add context or formatting
|
||||
- Log user interactions for analysis
|
||||
- Implement custom prompt templates
|
||||
- Add workspace-specific context automatically
|
||||
|
||||
**Implementation Notes:**
|
||||
- Can modify user prompt before AI processing
|
||||
- Should preserve user intent while enhancing context
|
||||
- Fast execution critical for user experience
|
||||
|
||||
---
|
||||
|
||||
### TaskStart Hook
|
||||
|
||||
**Trigger:** When a new task begins
|
||||
**Purpose:** Initialize logging, setup workspace, prepare environment
|
||||
|
||||
**Input Data:**
|
||||
```protobuf
|
||||
message TaskStartData {
|
||||
map<string, string> task_metadata = 1;
|
||||
}
|
||||
```
|
||||
|
||||
**Use Cases:**
|
||||
- Initialize task-specific logging systems
|
||||
- Set up workspace environment variables
|
||||
- Create task directories and scaffolding
|
||||
- Notify external systems of new task
|
||||
- Load task-specific configuration
|
||||
|
||||
**Implementation Notes:**
|
||||
- First hook called in task lifecycle
|
||||
- Can set up persistent context for entire task
|
||||
- Should handle workspace initialization robustly
|
||||
|
||||
---
|
||||
|
||||
### TaskResume Hook
|
||||
|
||||
**Trigger:** When resuming an existing task
|
||||
**Purpose:** Restore context, validate state, prepare for continuation
|
||||
|
||||
**Input Data:**
|
||||
```protobuf
|
||||
message TaskResumeData {
|
||||
map<string, string> task_metadata = 1;
|
||||
map<string, string> previous_state = 2;
|
||||
}
|
||||
```
|
||||
|
||||
**Use Cases:**
|
||||
- Restore workspace state from previous session
|
||||
- Validate that environment is ready for continuation
|
||||
- Load cached data or intermediate results
|
||||
- Notify team members of task resumption
|
||||
- Reconcile changes made outside of Cline
|
||||
|
||||
**Implementation Notes:**
|
||||
- More complex than TaskStart due to state restoration
|
||||
- Should validate workspace consistency
|
||||
- Can provide context about what changed since last session
|
||||
|
||||
---
|
||||
|
||||
### TaskCancel Hook
|
||||
|
||||
**Trigger:** When user cancels the task manually
|
||||
**Purpose:** Cleanup, notifications, metrics collection
|
||||
|
||||
**Input Data:**
|
||||
```protobuf
|
||||
message TaskCancelData {
|
||||
map<string, string> task_metadata = 1;
|
||||
}
|
||||
```
|
||||
|
||||
**Use Cases:**
|
||||
- Clean up temporary files and resources
|
||||
- Send completion notifications to stakeholders
|
||||
- Generate task completion reports
|
||||
- Update project management systems
|
||||
- Archive task artifacts
|
||||
|
||||
**Implementation Notes:**
|
||||
- Final hook in successful task lifecycle
|
||||
- Should handle cleanup even if other operations fail
|
||||
- Can provide summary context about task completion
|
||||
|
||||
---
|
||||
|
||||
### TaskComplete Hook
|
||||
|
||||
**Trigger:** When attempt_completion succeeds
|
||||
**Purpose:** Cleanup, notifications, metrics collection
|
||||
|
||||
**Input Data:**
|
||||
```protobuf
|
||||
message TaskCompleteData {
|
||||
map<string, string> task_metadata = 1;
|
||||
}
|
||||
```
|
||||
|
||||
**Use Cases:**
|
||||
- Clean up temporary files and resources
|
||||
- Send completion notifications to stakeholders
|
||||
- Generate task completion reports
|
||||
- Update project management systems
|
||||
- Archive task artifacts
|
||||
|
||||
**Implementation Notes:**
|
||||
- Final hook in successful task lifecycle
|
||||
- Should handle cleanup even if other operations fail
|
||||
- Can provide summary context about task completion
|
||||
|
||||
---
|
||||
|
||||
### PreCompact Hook
|
||||
|
||||
**Trigger:** Before context compaction occurs
|
||||
**Purpose:** Archive conversation history, preserve important context
|
||||
|
||||
**Input Data:**
|
||||
```protobuf
|
||||
message PreCompactData {
|
||||
int64 context_size = 1;
|
||||
int32 messages_to_compact = 2;
|
||||
string compaction_strategy = 3;
|
||||
}
|
||||
```
|
||||
|
||||
**Use Cases:**
|
||||
- Archive full conversation history before compaction
|
||||
- Extract and preserve critical information
|
||||
- Generate summaries of compacted content
|
||||
- Update external knowledge bases
|
||||
- Implement custom compaction strategies
|
||||
|
||||
**Implementation Notes:**
|
||||
- Most complex hook due to context management requirements
|
||||
- Should execute quickly to avoid delaying AI responses
|
||||
- Can influence compaction strategy through context modifications
|
||||
@@ -12,6 +12,8 @@ service TaskService {
|
||||
rpc cancelTask(EmptyRequest) returns (Empty);
|
||||
// Cancels the currently running background command
|
||||
rpc cancelBackgroundCommand(EmptyRequest) returns (Empty);
|
||||
// Cancels the currently running hook execution
|
||||
rpc cancelHookExecution(EmptyRequest) returns (Boolean);
|
||||
// Clears the current task
|
||||
rpc clearTask(EmptyRequest) returns (Empty);
|
||||
// Gets the total size of all tasks
|
||||
|
||||
@@ -243,6 +243,7 @@ export class Controller {
|
||||
files?: string[],
|
||||
historyItem?: HistoryItem,
|
||||
taskSettings?: Partial<Settings>,
|
||||
skipResume?: boolean,
|
||||
) {
|
||||
try {
|
||||
await fetchRemoteConfig(this)
|
||||
@@ -333,6 +334,7 @@ export class Controller {
|
||||
historyItem,
|
||||
taskId,
|
||||
taskLockAcquired,
|
||||
skipResume,
|
||||
})
|
||||
|
||||
return this.task.taskId
|
||||
@@ -436,7 +438,8 @@ export class Controller {
|
||||
// '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
|
||||
// Re-initialize task to keep it visible in UI, but skip resuming the workflow
|
||||
await this.initTask(undefined, undefined, undefined, historyItem, undefined, true)
|
||||
// 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
|
||||
}
|
||||
@@ -459,6 +462,13 @@ export class Controller {
|
||||
}
|
||||
}
|
||||
|
||||
async cancelHookExecution(): Promise<boolean> {
|
||||
if (!this.task) {
|
||||
return false
|
||||
}
|
||||
return await this.task.cancelHookExecution()
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if we should show the background terminal suggestion based on shell integration warning frequency
|
||||
* @returns true if we should show the suggestion, false otherwise
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Boolean, EmptyRequest } from "@shared/proto/cline/common"
|
||||
import { Controller } from ".."
|
||||
|
||||
/**
|
||||
* Cancels the currently running hook execution
|
||||
* @param controller The controller instance
|
||||
* @param _request Empty request (no parameters needed)
|
||||
* @returns Boolean indicating whether a hook was successfully cancelled
|
||||
*/
|
||||
export async function cancelHookExecution(controller: Controller, _request: EmptyRequest): Promise<Boolean> {
|
||||
const success = await controller.cancelHookExecution()
|
||||
return Boolean.create({ value: success })
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
import * as vscode from "vscode"
|
||||
import { getAllHooksDirs } from "../storage/disk"
|
||||
import { HookFactory, Hooks } from "./hook-factory"
|
||||
|
||||
type HookName = keyof Hooks
|
||||
|
||||
/**
|
||||
* Cached hook discovery results
|
||||
*/
|
||||
interface HookCacheEntry {
|
||||
scriptPaths: string[] // Paths to hook scripts for this hook name
|
||||
timestamp: number // When this was last scanned
|
||||
}
|
||||
|
||||
/**
|
||||
* Singleton cache for hook script discovery with lazy file system watching.
|
||||
*
|
||||
* Features:
|
||||
* - Lazy watcher initialization (only when directories are accessed)
|
||||
* - Per-directory caching
|
||||
* - Automatic invalidation on file changes
|
||||
* - Graceful error handling
|
||||
* - Optional debug logging
|
||||
*/
|
||||
export class HookDiscoveryCache {
|
||||
private static instance: HookDiscoveryCache | null = null
|
||||
|
||||
// Cache: hookName -> discovered script paths
|
||||
private cache = new Map<HookName, HookCacheEntry>()
|
||||
|
||||
// Watchers: directory path -> file watcher
|
||||
private watchers = new Map<string, vscode.FileSystemWatcher>()
|
||||
|
||||
// Directories we've tried to watch (even if watcher creation failed)
|
||||
private watchedDirs = new Set<string>()
|
||||
|
||||
// Currently scanning (to prevent concurrent scans)
|
||||
private scanning = new Set<HookName>()
|
||||
|
||||
// For disposal
|
||||
private context: vscode.ExtensionContext | null = null
|
||||
private createFileWatcher: ((dir: string) => vscode.FileSystemWatcher | null) | null = null
|
||||
private disposed = false
|
||||
|
||||
// Debug logging (enabled via DEBUG_HOOKS env var)
|
||||
private debug = process.env.DEBUG_HOOKS === "true"
|
||||
|
||||
private constructor() {}
|
||||
|
||||
static getInstance(): HookDiscoveryCache {
|
||||
if (!HookDiscoveryCache.instance) {
|
||||
HookDiscoveryCache.instance = new HookDiscoveryCache()
|
||||
}
|
||||
return HookDiscoveryCache.instance
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize with extension context for proper cleanup
|
||||
*/
|
||||
initialize(context: vscode.ExtensionContext, createFileWatcher?: (dir: string) => vscode.FileSystemWatcher | null): void {
|
||||
this.context = context
|
||||
this.createFileWatcher = createFileWatcher || null
|
||||
|
||||
// Watch for workspace changes to invalidate cache
|
||||
context.subscriptions.push(
|
||||
vscode.workspace.onDidChangeWorkspaceFolders(() => {
|
||||
this.log("Workspace folders changed, invalidating cache")
|
||||
this.invalidateAll()
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cached hook scripts or scan if not cached
|
||||
*/
|
||||
async get(hookName: HookName): Promise<string[]> {
|
||||
this.log(`Getting hooks for ${hookName}`)
|
||||
|
||||
const cached = this.cache.get(hookName)
|
||||
if (cached) {
|
||||
this.log(`Cache hit for ${hookName}: ${cached.scriptPaths.length} scripts`)
|
||||
return cached.scriptPaths
|
||||
}
|
||||
|
||||
this.log(`Cache miss for ${hookName}, scanning...`)
|
||||
return this.scan(hookName)
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan for hook scripts and cache the result
|
||||
*/
|
||||
private async scan(hookName: HookName): Promise<string[]> {
|
||||
// Prevent concurrent scans of the same hook
|
||||
if (this.scanning.has(hookName)) {
|
||||
this.log(`Already scanning ${hookName}, waiting...`)
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
return this.get(hookName)
|
||||
}
|
||||
|
||||
this.scanning.add(hookName)
|
||||
|
||||
try {
|
||||
// Get all current hooks directories
|
||||
const hooksDirs = await getAllHooksDirs()
|
||||
this.log(`Scanning ${hooksDirs.length} directories for ${hookName}`)
|
||||
|
||||
// Ensure watchers are set up for each directory (lazy initialization)
|
||||
for (const dir of hooksDirs) {
|
||||
this.ensureWatcher(dir)
|
||||
}
|
||||
|
||||
// Scan each directory for this hook
|
||||
const scriptPromises = hooksDirs.map((dir) => HookFactory.findHookInHooksDir(hookName, dir))
|
||||
|
||||
const results = await Promise.all(scriptPromises)
|
||||
const scripts = results.filter((path): path is string => path !== undefined)
|
||||
|
||||
this.log(`Found ${scripts.length} scripts for ${hookName}`)
|
||||
|
||||
// Cache the result
|
||||
this.cache.set(hookName, {
|
||||
scriptPaths: scripts,
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
|
||||
return scripts
|
||||
} catch (error) {
|
||||
console.error(`Error scanning for ${hookName} hooks:`, error)
|
||||
// Return empty array on error - don't break the whole system
|
||||
return []
|
||||
} finally {
|
||||
this.scanning.delete(hookName)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure a file watcher exists for the given directory
|
||||
*/
|
||||
private ensureWatcher(dir: string): void {
|
||||
// Skip if already watching or tried to watch
|
||||
if (this.watchedDirs.has(dir)) {
|
||||
return
|
||||
}
|
||||
|
||||
this.watchedDirs.add(dir)
|
||||
|
||||
if (!this.context) {
|
||||
this.log(`No context available, skipping watcher for ${dir}`)
|
||||
return
|
||||
}
|
||||
|
||||
// If no watcher creation function provided, skip watching
|
||||
if (!this.createFileWatcher) {
|
||||
this.log(`No watcher creator available, skipping watcher for ${dir}`)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// Create watcher using the provided function
|
||||
const watcher = this.createFileWatcher(dir)
|
||||
|
||||
if (!watcher) {
|
||||
this.log(`Watcher creation returned null for ${dir}`)
|
||||
return
|
||||
}
|
||||
|
||||
// Invalidate cache on any change
|
||||
const invalidate = () => {
|
||||
this.log(`File change detected in ${dir}, invalidating cache`)
|
||||
this.invalidateDirectory(dir)
|
||||
}
|
||||
|
||||
watcher.onDidCreate(invalidate)
|
||||
watcher.onDidChange(invalidate)
|
||||
watcher.onDidDelete(invalidate)
|
||||
|
||||
// Add to context subscriptions for proper cleanup
|
||||
if (this.context) {
|
||||
this.context.subscriptions.push(watcher)
|
||||
}
|
||||
this.watchers.set(dir, watcher)
|
||||
|
||||
this.log(`Created watcher for ${dir}`)
|
||||
} catch (error) {
|
||||
// Log but don't fail - directory might not exist yet
|
||||
this.log(`Failed to create watcher for ${dir}: ${error}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate all cached hooks that have scripts in this directory
|
||||
*/
|
||||
private invalidateDirectory(dir: string): void {
|
||||
let invalidated = 0
|
||||
|
||||
for (const [hookName, entry] of this.cache) {
|
||||
if (entry.scriptPaths.some((scriptPath) => scriptPath.startsWith(dir))) {
|
||||
this.cache.delete(hookName)
|
||||
invalidated++
|
||||
}
|
||||
}
|
||||
|
||||
this.log(`Invalidated ${invalidated} hooks for directory ${dir}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate entire cache
|
||||
*/
|
||||
invalidateAll(): void {
|
||||
const size = this.cache.size
|
||||
this.cache.clear()
|
||||
this.log(`Invalidated entire cache (${size} entries)`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cache statistics (for debugging/monitoring)
|
||||
*/
|
||||
getStats() {
|
||||
return {
|
||||
cacheSize: this.cache.size,
|
||||
watcherCount: this.watchers.size,
|
||||
watchedDirs: this.watchedDirs.size,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log debug message if debug mode is enabled
|
||||
*/
|
||||
private log(message: string): void {
|
||||
if (this.debug) {
|
||||
console.log(`[HookCache] ${message}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up resources
|
||||
*/
|
||||
dispose(): void {
|
||||
if (this.disposed) {
|
||||
return
|
||||
}
|
||||
|
||||
this.log(`Disposing cache (${this.watchers.size} watchers)`)
|
||||
|
||||
for (const watcher of this.watchers.values()) {
|
||||
watcher.dispose()
|
||||
}
|
||||
|
||||
this.watchers.clear()
|
||||
this.watchedDirs.clear()
|
||||
this.cache.clear()
|
||||
this.disposed = true
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset singleton instance (for testing)
|
||||
*/
|
||||
static resetForTesting(): void {
|
||||
if (HookDiscoveryCache.instance) {
|
||||
HookDiscoveryCache.instance.dispose()
|
||||
HookDiscoveryCache.instance = null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* Types of errors that can occur during hook execution
|
||||
*/
|
||||
export enum HookErrorType {
|
||||
/** Hook execution exceeded the timeout limit */
|
||||
TIMEOUT = "timeout",
|
||||
/** Hook output failed JSON validation */
|
||||
VALIDATION = "validation",
|
||||
/** Hook script execution failed (non-zero exit, crash, etc.) */
|
||||
EXECUTION = "execution",
|
||||
/** Hook was cancelled by user */
|
||||
CANCELLATION = "cancellation",
|
||||
}
|
||||
|
||||
/**
|
||||
* Structured error information for hook failures.
|
||||
* Provides both user-friendly messages and technical details.
|
||||
*/
|
||||
export interface HookErrorInfo {
|
||||
/** Type of error that occurred */
|
||||
type: HookErrorType
|
||||
/** User-friendly error message */
|
||||
message: string
|
||||
/** Technical details for debugging (optional, shown in expansion) */
|
||||
details?: string
|
||||
/** Path to the hook script that failed */
|
||||
scriptPath?: string
|
||||
/** Exit code if available */
|
||||
exitCode?: number
|
||||
/** Stderr output if available */
|
||||
stderr?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Error thrown during hook execution with structured information.
|
||||
* This allows proper error handling without string parsing.
|
||||
*/
|
||||
export class HookExecutionError extends Error {
|
||||
constructor(
|
||||
public readonly errorInfo: HookErrorInfo,
|
||||
message?: string,
|
||||
) {
|
||||
super(message || errorInfo.message)
|
||||
this.name = "HookExecutionError"
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an error is a HookExecutionError
|
||||
*/
|
||||
static isHookError(error: unknown): error is HookExecutionError {
|
||||
return error instanceof HookExecutionError
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a timeout error
|
||||
*/
|
||||
static timeout(scriptPath: string, timeoutMs: number, stderr?: string): HookExecutionError {
|
||||
return new HookExecutionError({
|
||||
type: HookErrorType.TIMEOUT,
|
||||
message: `Hook execution timed out after ${timeoutMs}ms`,
|
||||
details:
|
||||
`The hook took longer than ${timeoutMs / 1000} seconds to complete.\n\n` +
|
||||
`Common causes:\n` +
|
||||
`• Infinite loop in hook script\n` +
|
||||
`• Network request hanging without timeout\n` +
|
||||
`• File I/O operation stuck\n` +
|
||||
`• Heavy computation taking too long\n\n` +
|
||||
`Recommendations:\n` +
|
||||
`1. Check your hook script for infinite loops\n` +
|
||||
`2. Add timeouts to network requests\n` +
|
||||
`3. Use background jobs for long operations\n` +
|
||||
`4. Test your hook script independently`,
|
||||
scriptPath,
|
||||
stderr,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a validation error
|
||||
*/
|
||||
static validation(validationError: string, scriptPath: string, stdoutPreview: string): HookExecutionError {
|
||||
return new HookExecutionError({
|
||||
type: HookErrorType.VALIDATION,
|
||||
message: "Hook output validation failed",
|
||||
details: `${validationError}\n\nOutput preview:\n${stdoutPreview}`,
|
||||
scriptPath,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an execution error
|
||||
*/
|
||||
static execution(scriptPath: string, exitCode: number, stderr?: string): HookExecutionError {
|
||||
const message = `Hook script exited with code ${exitCode}`
|
||||
return new HookExecutionError(
|
||||
{
|
||||
type: HookErrorType.EXECUTION,
|
||||
message,
|
||||
details: stderr ? `stderr:\n${stderr}` : undefined,
|
||||
scriptPath,
|
||||
exitCode,
|
||||
stderr,
|
||||
},
|
||||
message,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a cancellation error
|
||||
*/
|
||||
static cancellation(scriptPath: string): HookExecutionError {
|
||||
return new HookExecutionError({
|
||||
type: HookErrorType.CANCELLATION,
|
||||
message: "Hook execution was cancelled",
|
||||
details: "The hook was cancelled by the user before completion",
|
||||
scriptPath,
|
||||
exitCode: 130, // Standard SIGINT exit code
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,350 @@
|
||||
import { ChildProcess, spawn } from "child_process"
|
||||
import { EventEmitter } from "events"
|
||||
import { HookProcessRegistry } from "./HookProcessRegistry"
|
||||
|
||||
// Maximum total output size (stdout + stderr combined)
|
||||
const MAX_HOOK_OUTPUT_SIZE = 1024 * 1024 // 1MB
|
||||
|
||||
/**
|
||||
* HookProcess manages the execution of a hook script with streaming output capabilities.
|
||||
* Similar to StandaloneTerminalProcess but specialized for hook execution.
|
||||
*
|
||||
* Key features:
|
||||
* - Real-time stdout/stderr streaming via line events
|
||||
* - Separate handling of visual output vs. JSON response
|
||||
* - 30-second execution timeout
|
||||
* - 1MB output size limit (prevents memory issues)
|
||||
* - Hot state tracking (actively outputting)
|
||||
* - Process lifecycle management
|
||||
*/
|
||||
export class HookProcess extends EventEmitter {
|
||||
private childProcess: ChildProcess | null = null
|
||||
private buffer = ""
|
||||
private fullOutput = ""
|
||||
private lastRetrievedIndex = 0
|
||||
private isHot = false
|
||||
private hotTimer: NodeJS.Timeout | null = null
|
||||
private exitCode: number | null = null
|
||||
private isCompleted = false
|
||||
private timeoutHandle: NodeJS.Timeout | null = null
|
||||
|
||||
// Separate buffers for stdout and stderr
|
||||
private stdoutBuffer = ""
|
||||
private stderrBuffer = ""
|
||||
|
||||
// Output size tracking
|
||||
private stdoutSize = 0
|
||||
private stderrSize = 0
|
||||
private outputTruncated = false
|
||||
|
||||
constructor(
|
||||
private readonly scriptPath: string,
|
||||
private readonly timeoutMs: number = 30000,
|
||||
private readonly abortSignal?: AbortSignal,
|
||||
) {
|
||||
super()
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the hook script with the given JSON input
|
||||
* @param inputJson The JSON string to pass to the hook via stdin
|
||||
*/
|
||||
async run(inputJson: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
// Register this process for tracking
|
||||
HookProcessRegistry.register(this)
|
||||
|
||||
// Check if already aborted
|
||||
if (this.abortSignal?.aborted) {
|
||||
HookProcessRegistry.unregister(this)
|
||||
reject(new Error("Hook execution cancelled"))
|
||||
return
|
||||
}
|
||||
|
||||
// Set up abort handler
|
||||
const abortHandler = () => {
|
||||
if (this.childProcess && !this.isCompleted) {
|
||||
this.childProcess.kill("SIGTERM")
|
||||
reject(new Error("Hook execution cancelled by user"))
|
||||
}
|
||||
}
|
||||
|
||||
if (this.abortSignal) {
|
||||
this.abortSignal.addEventListener("abort", abortHandler)
|
||||
}
|
||||
|
||||
// Spawn the hook process
|
||||
this.childProcess = spawn(this.scriptPath, [], {
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
shell: process.platform === "win32",
|
||||
})
|
||||
|
||||
let didEmitEmptyLine = false
|
||||
|
||||
// Set up timeout
|
||||
this.timeoutHandle = setTimeout(() => {
|
||||
if (this.childProcess && !this.isCompleted) {
|
||||
this.childProcess.kill("SIGTERM")
|
||||
reject(
|
||||
new Error(
|
||||
`Hook execution timed out after ${this.timeoutMs}ms. The hook script at '${this.scriptPath}' took too long to complete.`,
|
||||
),
|
||||
)
|
||||
}
|
||||
}, this.timeoutMs)
|
||||
|
||||
// Handle stdout
|
||||
this.childProcess.stdout?.on("data", (data) => {
|
||||
const output = data.toString()
|
||||
this.stdoutBuffer += output
|
||||
this.handleOutput(output, didEmitEmptyLine, "stdout")
|
||||
if (!didEmitEmptyLine && output) {
|
||||
this.emit("line", "", "stdout") // Signal start of output
|
||||
didEmitEmptyLine = true
|
||||
}
|
||||
})
|
||||
|
||||
// Handle stderr
|
||||
this.childProcess.stderr?.on("data", (data) => {
|
||||
const output = data.toString()
|
||||
this.stderrBuffer += output
|
||||
this.handleOutput(output, didEmitEmptyLine, "stderr")
|
||||
if (!didEmitEmptyLine && output) {
|
||||
this.emit("line", "", "stderr") // Signal start of output
|
||||
didEmitEmptyLine = true
|
||||
}
|
||||
})
|
||||
|
||||
// Handle process completion
|
||||
this.childProcess.on("close", (code, signal) => {
|
||||
this.exitCode = code
|
||||
this.isCompleted = true
|
||||
this.emitRemainingBuffer()
|
||||
|
||||
// Unregister from active processes
|
||||
HookProcessRegistry.unregister(this)
|
||||
|
||||
// Clear timers
|
||||
if (this.hotTimer) {
|
||||
clearTimeout(this.hotTimer)
|
||||
this.isHot = false
|
||||
}
|
||||
if (this.timeoutHandle) {
|
||||
clearTimeout(this.timeoutHandle)
|
||||
this.timeoutHandle = null
|
||||
}
|
||||
|
||||
// Remove abort listener
|
||||
if (this.abortSignal) {
|
||||
this.abortSignal.removeEventListener("abort", abortHandler)
|
||||
}
|
||||
|
||||
this.emit("completed", code, signal)
|
||||
|
||||
if (code === 0) {
|
||||
resolve()
|
||||
} else {
|
||||
reject(new Error(`Hook exited with code ${code}${signal ? `, signal ${signal}` : ""}`))
|
||||
}
|
||||
})
|
||||
|
||||
// Handle process errors
|
||||
this.childProcess.on("error", (error) => {
|
||||
// Unregister from active processes
|
||||
HookProcessRegistry.unregister(this)
|
||||
|
||||
if (this.timeoutHandle) {
|
||||
clearTimeout(this.timeoutHandle)
|
||||
this.timeoutHandle = null
|
||||
}
|
||||
// Remove abort listener
|
||||
if (this.abortSignal) {
|
||||
this.abortSignal.removeEventListener("abort", abortHandler)
|
||||
}
|
||||
this.emit("error", error)
|
||||
reject(error)
|
||||
})
|
||||
|
||||
// Send input to the process
|
||||
try {
|
||||
this.childProcess.stdin?.write(inputJson)
|
||||
this.childProcess.stdin?.end()
|
||||
} catch (error) {
|
||||
reject(new Error(`Failed to write input to hook: ${error}`))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle output data and emit line events.
|
||||
* Enforces 1MB total output limit to prevent memory issues.
|
||||
*/
|
||||
private handleOutput(data: string, _didEmitEmptyLine: boolean, stream: "stdout" | "stderr"): void {
|
||||
// Check output size limit
|
||||
const dataSize = Buffer.byteLength(data)
|
||||
const currentTotalSize = this.stdoutSize + this.stderrSize
|
||||
|
||||
if (currentTotalSize + dataSize > MAX_HOOK_OUTPUT_SIZE) {
|
||||
if (!this.outputTruncated) {
|
||||
this.outputTruncated = true
|
||||
const truncationMsg = "\n\n[Output truncated: exceeded 1MB limit]"
|
||||
this.emit("line", truncationMsg, stream)
|
||||
console.warn(`[HookProcess] Output exceeded ${MAX_HOOK_OUTPUT_SIZE} bytes, truncating`)
|
||||
}
|
||||
return // Drop further output
|
||||
}
|
||||
|
||||
// Track size by stream
|
||||
if (stream === "stdout") {
|
||||
this.stdoutSize += dataSize
|
||||
} else {
|
||||
this.stderrSize += dataSize
|
||||
}
|
||||
|
||||
// Set process as hot (actively outputting)
|
||||
this.isHot = true
|
||||
if (this.hotTimer) {
|
||||
clearTimeout(this.hotTimer)
|
||||
}
|
||||
|
||||
// Use a shorter hot timeout for hooks since they typically complete quickly
|
||||
const hotTimeout = 1000 // 1 second
|
||||
this.hotTimer = setTimeout(() => {
|
||||
this.isHot = false
|
||||
}, hotTimeout)
|
||||
|
||||
// Store full output
|
||||
this.fullOutput += data
|
||||
|
||||
// Emit lines immediately
|
||||
this.emitLines(data, stream)
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit complete lines from buffered output
|
||||
*/
|
||||
private emitLines(chunk: string, stream: "stdout" | "stderr"): void {
|
||||
this.buffer += chunk
|
||||
let lineEndIndex
|
||||
while ((lineEndIndex = this.buffer.indexOf("\n")) !== -1) {
|
||||
const line = this.buffer.slice(0, lineEndIndex).trimEnd()
|
||||
this.emit("line", line, stream)
|
||||
this.buffer = this.buffer.slice(lineEndIndex + 1)
|
||||
}
|
||||
this.lastRetrievedIndex = this.fullOutput.length - this.buffer.length
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit any remaining buffered output when process completes
|
||||
*/
|
||||
private emitRemainingBuffer(): void {
|
||||
if (this.buffer) {
|
||||
const remainingBuffer = this.buffer.trimEnd()
|
||||
if (remainingBuffer) {
|
||||
// Determine which stream this came from based on content
|
||||
// This is a fallback; in practice, line events should capture most output
|
||||
this.emit("line", remainingBuffer, "stdout")
|
||||
}
|
||||
this.buffer = ""
|
||||
this.lastRetrievedIndex = this.fullOutput.length
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get unretrieved output (for compatibility with terminal process interface)
|
||||
*/
|
||||
getUnretrievedOutput(): string {
|
||||
const unretrieved = this.fullOutput.slice(this.lastRetrievedIndex)
|
||||
this.lastRetrievedIndex = this.fullOutput.length
|
||||
return unretrieved.trimEnd()
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if process is actively outputting
|
||||
*/
|
||||
isProcessHot(): boolean {
|
||||
return this.isHot
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the complete stdout buffer (for JSON parsing)
|
||||
*/
|
||||
getStdout(): string {
|
||||
return this.stdoutBuffer
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the complete stderr buffer (for error reporting)
|
||||
*/
|
||||
getStderr(): string {
|
||||
return this.stderrBuffer
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the exit code
|
||||
*/
|
||||
getExitCode(): number | null {
|
||||
return this.exitCode
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if process has completed
|
||||
*/
|
||||
hasCompleted(): boolean {
|
||||
return this.isCompleted
|
||||
}
|
||||
|
||||
/**
|
||||
* Terminate the process and its entire process tree.
|
||||
* Uses process groups on Unix to kill child processes.
|
||||
* Implements graceful shutdown with 2-second timeout before force kill.
|
||||
*/
|
||||
async terminate(): Promise<void> {
|
||||
if (!this.childProcess || this.isCompleted) {
|
||||
return
|
||||
}
|
||||
|
||||
const pid = this.childProcess.pid
|
||||
if (!pid) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// On Unix, kill process group (negative PID kills all children)
|
||||
// On Windows, just kill the process (tree-kill would be better but adds dependency)
|
||||
if (process.platform !== "win32") {
|
||||
// Kill process group with SIGTERM for graceful shutdown
|
||||
process.kill(-pid, "SIGTERM")
|
||||
} else {
|
||||
// On Windows, just kill the process
|
||||
this.childProcess.kill("SIGTERM")
|
||||
}
|
||||
|
||||
// Wait up to 2 seconds for graceful shutdown
|
||||
const gracefulTimeout = new Promise((resolve) => setTimeout(resolve, 2000))
|
||||
const processExit = new Promise((resolve) => {
|
||||
this.childProcess?.once("exit", resolve)
|
||||
})
|
||||
|
||||
await Promise.race([processExit, gracefulTimeout])
|
||||
|
||||
// Force kill if still running
|
||||
if (!this.isCompleted) {
|
||||
if (process.platform !== "win32") {
|
||||
process.kill(-pid, "SIGKILL")
|
||||
} else {
|
||||
this.childProcess?.kill("SIGKILL")
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Process might already be dead, which is fine
|
||||
console.debug(`[HookProcess] Error during termination: ${error}`)
|
||||
} finally {
|
||||
// Clear timeout regardless
|
||||
if (this.timeoutHandle) {
|
||||
clearTimeout(this.timeoutHandle)
|
||||
this.timeoutHandle = null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { HookProcess } from "./HookProcess"
|
||||
|
||||
/**
|
||||
* Global registry for tracking active hook processes.
|
||||
*
|
||||
* Purpose:
|
||||
* - Prevents zombie processes by tracking all running hooks
|
||||
* - Enables cleanup on extension deactivation
|
||||
* - Provides visibility into active hook executions
|
||||
*
|
||||
* Usage:
|
||||
* - HookProcess automatically registers/unregisters itself
|
||||
* - Extension deactivation calls terminateAll()
|
||||
* - Can query active count for monitoring/debugging
|
||||
*/
|
||||
export class HookProcessRegistry {
|
||||
private static activeProcesses = new Set<HookProcess>()
|
||||
|
||||
/**
|
||||
* Register a hook process as active.
|
||||
* Called by HookProcess when execution starts.
|
||||
*/
|
||||
static register(process: HookProcess): void {
|
||||
HookProcessRegistry.activeProcesses.add(process)
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregister a hook process (completed or failed).
|
||||
* Called by HookProcess when execution ends.
|
||||
*/
|
||||
static unregister(process: HookProcess): void {
|
||||
HookProcessRegistry.activeProcesses.delete(process)
|
||||
}
|
||||
|
||||
/**
|
||||
* Terminate all active hook processes.
|
||||
* Called during extension deactivation to prevent zombie processes.
|
||||
*/
|
||||
static async terminateAll(): Promise<void> {
|
||||
const processes = Array.from(HookProcessRegistry.activeProcesses)
|
||||
if (processes.length > 0) {
|
||||
console.log(`[HookProcessRegistry] Terminating ${processes.length} active hook process(es)`)
|
||||
await Promise.all(processes.map((p) => p.terminate()))
|
||||
HookProcessRegistry.activeProcesses.clear()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the number of currently active hook processes.
|
||||
* Useful for monitoring and debugging.
|
||||
*/
|
||||
static getActiveCount(): number {
|
||||
return HookProcessRegistry.activeProcesses.size
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the registry (for testing only).
|
||||
* @internal
|
||||
*/
|
||||
static resetForTesting(): void {
|
||||
HookProcessRegistry.activeProcesses.clear()
|
||||
}
|
||||
}
|
||||
@@ -40,10 +40,19 @@ describe("Hook System", () => {
|
||||
sandbox.stub(StateManager, "get").returns({
|
||||
getGlobalStateKey: () => [{ path: tempDir }],
|
||||
} as any)
|
||||
|
||||
// Reset hook discovery cache for clean test state
|
||||
const { HookDiscoveryCache } = await import("../HookDiscoveryCache")
|
||||
HookDiscoveryCache.resetForTesting()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
sandbox.restore()
|
||||
|
||||
// Clean up hook discovery cache
|
||||
const { HookDiscoveryCache } = await import("../HookDiscoveryCache")
|
||||
HookDiscoveryCache.resetForTesting()
|
||||
|
||||
try {
|
||||
await fs.rm(tempDir, { recursive: true, force: true })
|
||||
} catch (error) {
|
||||
@@ -184,18 +193,18 @@ console.log("not valid json")`
|
||||
const factory = new HookFactory()
|
||||
const runner = await factory.create("PreToolUse")
|
||||
|
||||
try {
|
||||
await runner.run({
|
||||
taskId: "test-task",
|
||||
preToolUse: {
|
||||
toolName: "test_tool",
|
||||
parameters: {},
|
||||
},
|
||||
})
|
||||
throw new Error("Should have thrown")
|
||||
} catch (error: any) {
|
||||
error.message.should.match(/Failed to parse hook output/)
|
||||
}
|
||||
// When hook exits 0 but has malformed JSON, it returns success without context
|
||||
const result = await runner.run({
|
||||
taskId: "test-task",
|
||||
preToolUse: {
|
||||
toolName: "test_tool",
|
||||
parameters: {},
|
||||
},
|
||||
})
|
||||
|
||||
// Hook succeeded (exit 0) but couldn't parse JSON, so returns success without context
|
||||
result.shouldContinue.should.be.true()
|
||||
;(result.contextModification === undefined || result.contextModification === "").should.be.true()
|
||||
})
|
||||
|
||||
it("should pass hook input via stdin", async () => {
|
||||
|
||||
@@ -42,10 +42,19 @@ describe("TaskCancel Hook", () => {
|
||||
} as any)
|
||||
|
||||
getEnv = () => ({ tempDir })
|
||||
|
||||
// Reset hook discovery cache for clean test state
|
||||
const { HookDiscoveryCache } = await import("../HookDiscoveryCache")
|
||||
HookDiscoveryCache.resetForTesting()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
sandbox.restore()
|
||||
|
||||
// Clean up hook discovery cache
|
||||
const { HookDiscoveryCache } = await import("../HookDiscoveryCache")
|
||||
HookDiscoveryCache.resetForTesting()
|
||||
|
||||
try {
|
||||
await fs.rm(tempDir, { recursive: true, force: true })
|
||||
} catch (error) {
|
||||
@@ -351,21 +360,21 @@ console.log("not valid json")`
|
||||
const factory = new HookFactory()
|
||||
const runner = await factory.create("TaskCancel")
|
||||
|
||||
try {
|
||||
await runner.run({
|
||||
taskId: "test-task-id",
|
||||
taskCancel: {
|
||||
taskMetadata: {
|
||||
taskId: "test-task-id",
|
||||
ulid: "test-ulid",
|
||||
completionStatus: "cancelled",
|
||||
},
|
||||
// When hook exits 0 but has malformed JSON, it returns success without context
|
||||
const result = await runner.run({
|
||||
taskId: "test-task-id",
|
||||
taskCancel: {
|
||||
taskMetadata: {
|
||||
taskId: "test-task-id",
|
||||
ulid: "test-ulid",
|
||||
completionStatus: "cancelled",
|
||||
},
|
||||
})
|
||||
throw new Error("Should have thrown")
|
||||
} catch (error: any) {
|
||||
error.message.should.match(/Failed to parse hook output/)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
// Hook succeeded (exit 0) but couldn't parse JSON, so returns success without context
|
||||
result.shouldContinue.should.be.true()
|
||||
;(result.contextModification === undefined || result.contextModification === "").should.be.true()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -41,6 +41,12 @@ describe("TaskResume Hook", () => {
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
sandbox.restore()
|
||||
|
||||
// Clean up hook discovery cache
|
||||
const { HookDiscoveryCache } = await import("../HookDiscoveryCache")
|
||||
HookDiscoveryCache.resetForTesting()
|
||||
|
||||
sandbox.restore()
|
||||
try {
|
||||
await fs.rm(tempDir, { recursive: true, force: true })
|
||||
@@ -386,22 +392,22 @@ console.log("not valid json")`
|
||||
const factory = new HookFactory()
|
||||
const runner = await factory.create("TaskResume")
|
||||
|
||||
try {
|
||||
await runner.run({
|
||||
taskId: "test-task",
|
||||
taskResume: {
|
||||
taskMetadata: { taskId: "test-task", ulid: "test-ulid" },
|
||||
previousState: {
|
||||
lastMessageTs: Date.now().toString(),
|
||||
messageCount: "5",
|
||||
conversationHistoryDeleted: "false",
|
||||
},
|
||||
// When hook exits 0 but has malformed JSON, it returns success without context
|
||||
const result = await runner.run({
|
||||
taskId: "test-task",
|
||||
taskResume: {
|
||||
taskMetadata: { taskId: "test-task", ulid: "test-ulid" },
|
||||
previousState: {
|
||||
lastMessageTs: Date.now().toString(),
|
||||
messageCount: "5",
|
||||
conversationHistoryDeleted: "false",
|
||||
},
|
||||
})
|
||||
throw new Error("Should have thrown parse error")
|
||||
} catch (error: any) {
|
||||
error.message.should.match(/Failed to parse hook output/)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
// Hook succeeded (exit 0) but couldn't parse JSON, so returns success without context
|
||||
result.shouldContinue.should.be.true()
|
||||
;(result.contextModification === undefined || result.contextModification === "").should.be.true()
|
||||
})
|
||||
|
||||
it("should handle invalid timestamp gracefully", async () => {
|
||||
|
||||
@@ -46,6 +46,11 @@ describe("TaskStart Hook", () => {
|
||||
|
||||
afterEach(async () => {
|
||||
sandbox.restore()
|
||||
|
||||
// Clean up hook discovery cache
|
||||
const { HookDiscoveryCache } = await import("../HookDiscoveryCache")
|
||||
HookDiscoveryCache.resetForTesting()
|
||||
|
||||
try {
|
||||
await fs.rm(tempDir, { recursive: true, force: true })
|
||||
} catch (error) {
|
||||
@@ -280,21 +285,21 @@ console.log("not valid json")`
|
||||
const factory = new HookFactory()
|
||||
const runner = await factory.create("TaskStart")
|
||||
|
||||
try {
|
||||
await runner.run({
|
||||
taskId: "test-task-id",
|
||||
taskStart: {
|
||||
taskMetadata: {
|
||||
taskId: "test-task-id",
|
||||
ulid: "test-ulid",
|
||||
initialTask: "Test task",
|
||||
},
|
||||
// When hook exits 0 but has malformed JSON, it returns success without context
|
||||
const result = await runner.run({
|
||||
taskId: "test-task-id",
|
||||
taskStart: {
|
||||
taskMetadata: {
|
||||
taskId: "test-task-id",
|
||||
ulid: "test-ulid",
|
||||
initialTask: "Test task",
|
||||
},
|
||||
})
|
||||
throw new Error("Should have thrown")
|
||||
} catch (error: any) {
|
||||
error.message.should.match(/Failed to parse hook output/)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
// Hook succeeded (exit 0) but couldn't parse JSON, so returns success without context
|
||||
result.shouldContinue.should.be.true()
|
||||
;(result.contextModification === undefined || result.contextModification === "").should.be.true()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -42,6 +42,11 @@ describe("UserPromptSubmit Hook", () => {
|
||||
|
||||
afterEach(async () => {
|
||||
sandbox.restore()
|
||||
|
||||
// Clean up hook discovery cache
|
||||
const { HookDiscoveryCache } = await import("../HookDiscoveryCache")
|
||||
HookDiscoveryCache.resetForTesting()
|
||||
|
||||
try {
|
||||
await fs.rm(tempDir, { recursive: true, force: true })
|
||||
} catch (error) {
|
||||
@@ -235,18 +240,18 @@ console.log("not valid json")`
|
||||
const factory = new HookFactory()
|
||||
const runner = await factory.create("UserPromptSubmit")
|
||||
|
||||
try {
|
||||
await runner.run({
|
||||
taskId: "test-task",
|
||||
userPromptSubmit: {
|
||||
prompt: "Test",
|
||||
attachments: [],
|
||||
},
|
||||
})
|
||||
throw new Error("Should have thrown parse error")
|
||||
} catch (error: any) {
|
||||
error.message.should.match(/Failed to parse hook output/)
|
||||
}
|
||||
// When hook exits 0 but has malformed JSON, it returns success without context
|
||||
const result = await runner.run({
|
||||
taskId: "test-task",
|
||||
userPromptSubmit: {
|
||||
prompt: "Test",
|
||||
attachments: [],
|
||||
},
|
||||
})
|
||||
|
||||
// Hook succeeded (exit 0) but couldn't parse JSON, so returns success without context
|
||||
result.shouldContinue.should.be.true()
|
||||
;(result.contextModification === undefined || result.contextModification === "").should.be.true()
|
||||
})
|
||||
|
||||
it("should handle hook script errors", async () => {
|
||||
@@ -461,18 +466,18 @@ console.log(JSON.stringify({
|
||||
it("should work with malformed-json fixture", async () => {
|
||||
const runner = await loadFixtureAndCreateRunner("malformed-json")
|
||||
|
||||
try {
|
||||
await runner.run({
|
||||
taskId: "test-task",
|
||||
userPromptSubmit: {
|
||||
prompt: "Test",
|
||||
attachments: [],
|
||||
},
|
||||
})
|
||||
throw new Error("Should have thrown parse error")
|
||||
} catch (error: any) {
|
||||
error.message.should.match(/Failed to parse hook output/)
|
||||
}
|
||||
// When hook exits 0 but has malformed JSON, it returns success without context
|
||||
const result = await runner.run({
|
||||
taskId: "test-task",
|
||||
userPromptSubmit: {
|
||||
prompt: "Test",
|
||||
attachments: [],
|
||||
},
|
||||
})
|
||||
|
||||
// Hook succeeded (exit 0) but couldn't parse JSON, so returns success without context
|
||||
result.shouldContinue.should.be.true()
|
||||
;(result.contextModification === undefined || result.contextModification === "").should.be.true()
|
||||
})
|
||||
|
||||
it("should work with multiline fixture", async () => {
|
||||
|
||||
+302
-67
@@ -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,51 @@ 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 shouldContinue field
|
||||
if (typeof output.shouldContinue !== "boolean") {
|
||||
return {
|
||||
valid: false,
|
||||
error:
|
||||
"Invalid hook output: Missing or invalid 'shouldContinue' field.\n\n" +
|
||||
"Expected: {'shouldContinue': true}\n" +
|
||||
"Required: shouldContinue must be a boolean (true or false)\n\n" +
|
||||
"Example valid response:\n" +
|
||||
JSON.stringify(
|
||||
{
|
||||
shouldContinue: true,
|
||||
contextModification: "Optional context here",
|
||||
errorMessage: "Optional error message",
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
// Check contextModification if present
|
||||
if (output.contextModification !== undefined && typeof output.contextModification !== "string") {
|
||||
return {
|
||||
valid: false,
|
||||
error: "Invalid hook output: 'contextModification' must be a string if provided",
|
||||
}
|
||||
}
|
||||
|
||||
// Check errorMessage if present
|
||||
if (output.errorMessage !== undefined && typeof output.errorMessage !== "string") {
|
||||
return {
|
||||
valid: false,
|
||||
error: "Invalid hook output: 'errorMessage' must be a string if provided",
|
||||
}
|
||||
}
|
||||
|
||||
return { valid: true }
|
||||
}
|
||||
|
||||
export interface Hooks {
|
||||
PreToolUse: {
|
||||
preToolUse: PreToolUseData
|
||||
@@ -94,8 +140,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,8 +173,21 @@ 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 with shouldContinue: true
|
||||
*/
|
||||
override async [exec](_: HookInput): Promise<HookOutput> {
|
||||
return HookOutput.create({
|
||||
shouldContinue: true,
|
||||
@@ -121,64 +195,86 @@ class NoOpRunner<Name extends HookName> extends HookRunner<Name> {
|
||||
}
|
||||
}
|
||||
|
||||
// Actually runs a hook by executing a script and passing JSON into it.
|
||||
/**
|
||||
* Callback type for streaming hook output
|
||||
*/
|
||||
export type HookStreamCallback = (line: string, stream: "stdout" | "stderr") => void
|
||||
|
||||
/**
|
||||
* Executes a hook script as a child process with real-time output streaming.
|
||||
*
|
||||
* Key features:
|
||||
* - Spawns the hook script and communicates via stdin/stdout/stderr
|
||||
* - Streams output line-by-line via callback for real-time UI updates
|
||||
* - Enforces 30-second timeout (configurable via HOOK_EXECUTION_TIMEOUT_MS)
|
||||
* - Supports cancellation via AbortSignal
|
||||
* - Parses JSON output from stdout, attempting to extract it even if mixed with debug output
|
||||
* - Truncates context modifications that exceed 50KB to prevent prompt overflow
|
||||
* - Handles both successful and failed executions gracefully
|
||||
*
|
||||
* Error handling:
|
||||
* - Treats hooks as "fail-open": only shouldContinue:false blocks tool execution
|
||||
* - Hook script errors (non-zero exit) don't block tools, only explicit JSON response does
|
||||
* - Timeout/cancellation errors are propagated to show "Failed" status in UI
|
||||
*
|
||||
* @template Name The type of hook this runner represents
|
||||
*/
|
||||
class StdioHookRunner<Name extends HookName> extends HookRunner<Name> {
|
||||
constructor(
|
||||
hookName: Name,
|
||||
public readonly scriptPath: string,
|
||||
private readonly streamCallback?: HookStreamCallback,
|
||||
private readonly abortSignal?: AbortSignal,
|
||||
) {
|
||||
super(hookName)
|
||||
}
|
||||
|
||||
override async [exec](input: HookInput): Promise<HookOutput> {
|
||||
return new Promise((resolve, reject) => {
|
||||
// Serialize input to JSON
|
||||
const inputJson = JSON.stringify(HookInput.toJSON(input))
|
||||
// Check if already aborted before starting
|
||||
if (this.abortSignal?.aborted) {
|
||||
throw HookExecutionError.cancellation(this.scriptPath)
|
||||
}
|
||||
|
||||
// Spawn the hook process
|
||||
const child = spawn(this.scriptPath, [], {
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
shell: process.platform === "win32",
|
||||
// Serialize input to JSON
|
||||
const inputJson = JSON.stringify(HookInput.toJSON(input))
|
||||
|
||||
// Create HookProcess for execution with streaming
|
||||
const hookProcess = new HookProcess(this.scriptPath, HOOK_EXECUTION_TIMEOUT_MS, this.abortSignal)
|
||||
|
||||
// Set up streaming if callback is provided
|
||||
if (this.streamCallback) {
|
||||
const callback = this.streamCallback
|
||||
hookProcess.on("line", (line: string, stream: "stdout" | "stderr") => {
|
||||
callback(line, stream)
|
||||
})
|
||||
}
|
||||
|
||||
let stdout = ""
|
||||
let stderr = ""
|
||||
let timeoutHandle: NodeJS.Timeout | undefined
|
||||
try {
|
||||
// Execute the hook and wait for completion
|
||||
await hookProcess.run(inputJson)
|
||||
|
||||
// Set up timeout
|
||||
timeoutHandle = setTimeout(() => {
|
||||
child.kill("SIGTERM")
|
||||
reject(
|
||||
new Error(
|
||||
`Hook ${this.hookName} timed out after ${HOOK_EXECUTION_TIMEOUT_MS}ms. The hook script at '${this.scriptPath}' took too long to complete.`,
|
||||
),
|
||||
)
|
||||
}, HOOK_EXECUTION_TIMEOUT_MS)
|
||||
|
||||
// Collect stdout
|
||||
child.stdout?.on("data", (data) => {
|
||||
stdout += data.toString()
|
||||
})
|
||||
|
||||
// Collect stderr
|
||||
child.stderr?.on("data", (data) => {
|
||||
stderr += data.toString()
|
||||
})
|
||||
|
||||
// Handle process completion
|
||||
child.on("close", (code) => {
|
||||
if (timeoutHandle) {
|
||||
clearTimeout(timeoutHandle)
|
||||
}
|
||||
|
||||
if (code !== 0) {
|
||||
reject(new Error(`Hook ${this.hookName} exited with code ${code}. stderr: ${stderr}`))
|
||||
return
|
||||
}
|
||||
// Get the complete stdout for JSON parsing
|
||||
const stdout = hookProcess.getStdout()
|
||||
const stderr = hookProcess.getStderr()
|
||||
const exitCode = hookProcess.getExitCode()
|
||||
|
||||
// Try to parse JSON output
|
||||
const parseJsonOutput = (): HookOutput | null => {
|
||||
try {
|
||||
// Parse and validate output
|
||||
const outputData = JSON.parse(stdout)
|
||||
|
||||
// Validate structure before creating HookOutput
|
||||
const validation = validateHookOutput(outputData)
|
||||
if (!validation.valid) {
|
||||
// Don't use streamCallback - it creates red text
|
||||
// Throw validation error instead
|
||||
throw HookExecutionError.validation(
|
||||
validation.error!,
|
||||
this.scriptPath,
|
||||
stdout.slice(0, 500) + (stdout.length > 500 ? "..." : ""),
|
||||
)
|
||||
}
|
||||
|
||||
const output = HookOutput.fromJSON(outputData)
|
||||
|
||||
// Validate and truncate context modification if too large
|
||||
@@ -192,29 +288,127 @@ class StdioHookRunner<Name extends HookName> extends HookRunner<Name> {
|
||||
"\n\n[... context truncated due to size limit ...]"
|
||||
}
|
||||
|
||||
resolve(output)
|
||||
} catch (error) {
|
||||
reject(new Error(`Failed to parse hook output: ${error}. stdout: ${stdout}`))
|
||||
}
|
||||
})
|
||||
return output
|
||||
} catch (parseError) {
|
||||
// If it's already a HookExecutionError, re-throw it
|
||||
if (HookExecutionError.isHookError(parseError)) {
|
||||
throw parseError
|
||||
}
|
||||
|
||||
// Handle process errors
|
||||
child.on("error", (error) => {
|
||||
if (timeoutHandle) {
|
||||
clearTimeout(timeoutHandle)
|
||||
}
|
||||
reject(new Error(`Failed to execute hook ${this.hookName}: ${error.message}`))
|
||||
})
|
||||
// Try to extract JSON from stdout (it might have debug output before/after)
|
||||
const jsonMatch = stdout.match(/\{[\s\S]*\}/)
|
||||
if (jsonMatch) {
|
||||
try {
|
||||
const outputData = JSON.parse(jsonMatch[0])
|
||||
|
||||
// Send input to the process
|
||||
child.stdin?.write(inputJson)
|
||||
child.stdin?.end()
|
||||
})
|
||||
// Validate structure
|
||||
const validation = validateHookOutput(outputData)
|
||||
if (!validation.valid) {
|
||||
throw HookExecutionError.validation(
|
||||
validation.error!,
|
||||
this.scriptPath,
|
||||
stdout.slice(0, 500) + (stdout.length > 500 ? "..." : ""),
|
||||
)
|
||||
}
|
||||
|
||||
const output = HookOutput.fromJSON(outputData)
|
||||
|
||||
// Validate and truncate context modification if too large
|
||||
if (output.contextModification && output.contextModification.length > MAX_CONTEXT_MODIFICATION_SIZE) {
|
||||
console.warn(
|
||||
`Hook ${this.hookName} returned contextModification of ${output.contextModification.length} bytes, ` +
|
||||
`truncating to ${MAX_CONTEXT_MODIFICATION_SIZE} bytes`,
|
||||
)
|
||||
output.contextModification =
|
||||
output.contextModification.slice(0, MAX_CONTEXT_MODIFICATION_SIZE) +
|
||||
"\n\n[... context truncated due to size limit ...]"
|
||||
}
|
||||
|
||||
return output
|
||||
} catch (_extractError) {
|
||||
// Fall through to validation error below
|
||||
}
|
||||
}
|
||||
|
||||
// Couldn't parse JSON at all
|
||||
const errorMsg = parseError instanceof Error ? parseError.message : String(parseError)
|
||||
throw HookExecutionError.validation(
|
||||
`Failed to parse JSON output: ${errorMsg}`,
|
||||
this.scriptPath,
|
||||
stdout.slice(0, 500) + (stdout.length > 500 ? "..." : ""),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const parsedOutput = parseJsonOutput()
|
||||
|
||||
// If we have valid JSON, honor it regardless of exit code
|
||||
if (parsedOutput) {
|
||||
// Log warning if non-zero exit but valid JSON (for developers)
|
||||
if (exitCode !== 0) {
|
||||
console.warn(`[Hook ${this.hookName}] Exited with code ${exitCode} but provided valid JSON response`)
|
||||
if (stderr) {
|
||||
console.warn(`[Hook ${this.hookName}] stderr: ${stderr}`)
|
||||
}
|
||||
}
|
||||
return parsedOutput
|
||||
}
|
||||
|
||||
// No valid JSON found
|
||||
if (exitCode === 0) {
|
||||
// Hook succeeded but didn't provide JSON - allow execution
|
||||
console.warn(`[Hook ${this.hookName}] Completed successfully but no JSON response found`)
|
||||
return HookOutput.create({
|
||||
shouldContinue: true,
|
||||
})
|
||||
} else {
|
||||
// Hook failed with non-zero exit
|
||||
throw HookExecutionError.execution(this.scriptPath, exitCode ?? 1, stderr)
|
||||
}
|
||||
} catch (error) {
|
||||
// If it's already a HookExecutionError, re-throw it
|
||||
if (HookExecutionError.isHookError(error)) {
|
||||
throw error
|
||||
}
|
||||
|
||||
// Hook execution failed - categorize the error
|
||||
const stderr = hookProcess.getStderr()
|
||||
const exitCode = hookProcess.getExitCode()
|
||||
|
||||
// Check for timeout
|
||||
if (error instanceof Error && error.message.includes("timed out")) {
|
||||
throw HookExecutionError.timeout(this.scriptPath, HOOK_EXECUTION_TIMEOUT_MS, stderr)
|
||||
}
|
||||
|
||||
// Check for cancellation
|
||||
if (error instanceof Error && error.message.includes("cancelled")) {
|
||||
throw HookExecutionError.cancellation(this.scriptPath)
|
||||
}
|
||||
|
||||
// Generic execution error
|
||||
throw HookExecutionError.execution(this.scriptPath, exitCode ?? 1, stderr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// CombinedHookRunner runs multiple hooks and combines the results. Used when a workspace
|
||||
// has multiple roots contributing the same hook.
|
||||
/**
|
||||
* Combines multiple hook runners and executes them in parallel.
|
||||
*
|
||||
* Used in multi-root workspaces where both global hooks (from ~/Documents/Cline/Rules/Hooks/)
|
||||
* and workspace-specific hooks (from each workspace's .clinerules/hooks/) exist for the
|
||||
* same hook type.
|
||||
*
|
||||
* Behavior:
|
||||
* - Executes all hooks concurrently using Promise.all
|
||||
* - Combines all shouldContinue flags with logical AND (all must be true to continue)
|
||||
* - Concatenates all contextModification strings with double newlines
|
||||
* - Concatenates all errorMessage strings with single newlines
|
||||
*
|
||||
* This means if ANY hook returns shouldContinue: false, tool execution is blocked.
|
||||
* 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,
|
||||
@@ -285,9 +479,50 @@ function isExpectedHookError(error: unknown): boolean {
|
||||
}
|
||||
|
||||
export class HookFactory {
|
||||
async create<Name extends HookName>(hookName: Name): Promise<HookRunner<Name>> {
|
||||
/**
|
||||
* Check if any hook scripts exist for the given hook name
|
||||
* @returns true if at least one hook script exists, false otherwise
|
||||
*/
|
||||
async hasHook<Name extends HookName>(hookName: Name): Promise<boolean> {
|
||||
const scripts = await HookFactory.findHookScripts(hookName)
|
||||
const runners = scripts.map((script) => new StdioHookRunner(hookName, script))
|
||||
return scripts.length > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a hook runner without streaming support (backwards compatible)
|
||||
*/
|
||||
async create<Name extends HookName>(hookName: Name): Promise<HookRunner<Name>> {
|
||||
return this.createWithStreaming(hookName)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a hook runner with optional streaming callback and abort signal support.
|
||||
*
|
||||
* This is the primary factory method for creating hooks. It:
|
||||
* 1. Uses HookDiscoveryCache to find hook scripts (fast O(1) lookup after first scan)
|
||||
* 2. Creates StdioHookRunner instances for each discovered script
|
||||
* 3. Returns NoOpRunner if no scripts found (null-object pattern)
|
||||
* 4. Returns CombinedHookRunner if multiple scripts found (parallel execution)
|
||||
*
|
||||
* The streaming callback receives hook output line-by-line in real-time, allowing
|
||||
* the UI to display progress as the hook executes. The abort signal enables
|
||||
* cancellation of long-running hooks.
|
||||
*
|
||||
* @param hookName The type of hook to create (e.g., "PreToolUse", "PostToolUse")
|
||||
* @param streamCallback Optional callback for real-time output streaming
|
||||
* @param abortSignal Optional signal to cancel hook execution
|
||||
* @returns A HookRunner that executes the hook(s), or NoOpRunner if none found
|
||||
*/
|
||||
async createWithStreaming<Name extends HookName>(
|
||||
hookName: Name,
|
||||
streamCallback?: HookStreamCallback,
|
||||
abortSignal?: AbortSignal,
|
||||
): Promise<HookRunner<Name>> {
|
||||
// Use cache for hook discovery instead of direct file system scan
|
||||
const { HookDiscoveryCache } = await import("./HookDiscoveryCache")
|
||||
const scripts = await HookDiscoveryCache.getInstance().get(hookName)
|
||||
|
||||
const runners = scripts.map((script) => new StdioHookRunner(hookName, script, streamCallback, abortSignal))
|
||||
if (runners.length === 0) {
|
||||
return new NoOpRunner(hookName)
|
||||
}
|
||||
@@ -316,7 +551,7 @@ export class HookFactory {
|
||||
* @returns the path to the hook to execute, or undefined if none found
|
||||
* @throws Error if an unexpected file system error occurs
|
||||
*/
|
||||
private static async findHookInHooksDir(hookName: HookName, hooksDir: string): Promise<string | undefined> {
|
||||
static async findHookInHooksDir(hookName: HookName, hooksDir: string): Promise<string | undefined> {
|
||||
return process.platform === "win32"
|
||||
? HookFactory.findWindowsHook(hookName, hooksDir)
|
||||
: HookFactory.findUnixHook(hookName, hooksDir)
|
||||
|
||||
@@ -0,0 +1,400 @@
|
||||
# Edge Cases & Error Recovery - Implementation Plan
|
||||
|
||||
## Current State Analysis
|
||||
|
||||
### What's Already Good ✅
|
||||
1. **Basic timeout handling** - 30s timeout with clear error message in HookProcess.ts
|
||||
2. **AbortSignal support** - Cancellation infrastructure is in place
|
||||
3. **Separate stdout/stderr** - Already tracked separately
|
||||
4. **Context truncation** - 50KB limit exists in hook-factory.ts
|
||||
5. **Graceful termination** - Basic SIGTERM → SIGKILL flow exists
|
||||
|
||||
### What Needs Improvement 🔧
|
||||
|
||||
## High Priority Implementations
|
||||
|
||||
### 1. Hook Output Size Limits
|
||||
**File:** `src/core/hooks/HookProcess.ts`
|
||||
|
||||
**Current Issue:**
|
||||
- No limit on stdout/stderr size
|
||||
- Could cause memory issues or UI freezes with verbose hooks
|
||||
|
||||
**Implementation:**
|
||||
```typescript
|
||||
// Add constants
|
||||
const MAX_HOOK_OUTPUT_SIZE = 1024 * 1024 // 1MB total
|
||||
|
||||
// Add tracking fields
|
||||
private stdoutSize = 0
|
||||
private stderrSize = 0
|
||||
private outputTruncated = false
|
||||
|
||||
// Modify handleOutput to check size
|
||||
private handleOutput(data: string, ...) {
|
||||
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]'
|
||||
// Emit truncation warning
|
||||
this.emit('line', truncationMsg, stream)
|
||||
console.warn(`Hook output exceeded ${MAX_HOOK_OUTPUT_SIZE} bytes`)
|
||||
}
|
||||
return // Drop further output
|
||||
}
|
||||
|
||||
// Track size
|
||||
if (stream === 'stdout') {
|
||||
this.stdoutSize += dataSize
|
||||
} else {
|
||||
this.stderrSize += dataSize
|
||||
}
|
||||
|
||||
// Continue with normal processing
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
**Testing:**
|
||||
- Create test hook that outputs 2MB of data
|
||||
- Verify truncation at 1MB
|
||||
- Verify warning message appears
|
||||
- Check memory usage doesn't spike
|
||||
|
||||
### 2. Hook Process Registry & Resource Cleanup
|
||||
**Files:**
|
||||
- New: `src/core/hooks/HookProcessRegistry.ts`
|
||||
- Modified: `src/core/hooks/HookProcess.ts`
|
||||
- Modified: `src/extension.ts`
|
||||
|
||||
**Current Issue:**
|
||||
- No centralized tracking of running hooks
|
||||
- Extension deactivation doesn't kill hooks
|
||||
- Potential zombie processes
|
||||
|
||||
**Implementation:**
|
||||
|
||||
**HookProcessRegistry.ts:**
|
||||
```typescript
|
||||
export class HookProcessRegistry {
|
||||
private static activeProcesses = new Set<HookProcess>()
|
||||
|
||||
static register(process: HookProcess): void {
|
||||
this.activeProcesses.add(process)
|
||||
}
|
||||
|
||||
static unregister(process: HookProcess): void {
|
||||
this.activeProcesses.delete(process)
|
||||
}
|
||||
|
||||
static async terminateAll(): Promise<void> {
|
||||
const processes = Array.from(this.activeProcesses)
|
||||
console.log(`[HookProcessRegistry] Terminating ${processes.length} active hook processes`)
|
||||
await Promise.all(processes.map(p => p.terminate()))
|
||||
this.activeProcesses.clear()
|
||||
}
|
||||
|
||||
static getActiveCount(): number {
|
||||
return this.activeProcesses.size
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**HookProcess.ts modifications:**
|
||||
```typescript
|
||||
import { HookProcessRegistry } from './HookProcessRegistry'
|
||||
|
||||
async run(inputJson: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
// Register on start
|
||||
HookProcessRegistry.register(this)
|
||||
|
||||
// ... existing code ...
|
||||
|
||||
this.childProcess.on("close", (code, signal) => {
|
||||
// Unregister on completion
|
||||
HookProcessRegistry.unregister(this)
|
||||
// ... rest of close handler
|
||||
})
|
||||
|
||||
this.childProcess.on("error", (error) => {
|
||||
// Unregister on error
|
||||
HookProcessRegistry.unregister(this)
|
||||
// ... rest of error handler
|
||||
})
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
**extension.ts modifications:**
|
||||
```typescript
|
||||
import { HookProcessRegistry } from './core/hooks/HookProcessRegistry'
|
||||
|
||||
export async function deactivate() {
|
||||
console.log("[Cline] Extension deactivating, cleaning up resources...")
|
||||
|
||||
// Kill any running hooks
|
||||
await HookProcessRegistry.terminateAll()
|
||||
|
||||
// Clean up hook cache
|
||||
const { HookDiscoveryCache } = await import('./core/hooks/HookDiscoveryCache')
|
||||
HookDiscoveryCache.getInstance().dispose()
|
||||
|
||||
// ... existing cleanup
|
||||
}
|
||||
```
|
||||
|
||||
**Testing:**
|
||||
- Start long-running hook (sleep 60s)
|
||||
- Deactivate extension
|
||||
- Verify hook process is killed
|
||||
- Check no zombie processes remain
|
||||
|
||||
### 3. Improved Timeout Error Messages
|
||||
**File:** `src/core/hooks/hook-factory.ts`
|
||||
|
||||
**Current Issue:**
|
||||
- Timeout error is generic
|
||||
- No context about what hook was doing
|
||||
- No actionable advice
|
||||
|
||||
**Implementation:**
|
||||
```typescript
|
||||
// In StdioHookRunner[exec]
|
||||
catch (error) {
|
||||
const stderr = hookProcess.getStderr()
|
||||
const exitCode = hookProcess.getExitCode()
|
||||
|
||||
// Enhance timeout errors
|
||||
if (error instanceof Error && error.message.includes('timed out')) {
|
||||
const enhancedMessage =
|
||||
`${this.hookName} hook timed out after ${HOOK_EXECUTION_TIMEOUT_MS}ms.\n\n` +
|
||||
`Possible causes:\n` +
|
||||
` - Infinite loop in hook script\n` +
|
||||
` - Network request hanging\n` +
|
||||
` - File I/O operation stuck\n` +
|
||||
` - Heavy computation taking too long\n\n` +
|
||||
`Script: ${this.scriptPath}\n\n` +
|
||||
`Recommendations:\n` +
|
||||
` 1. Check your hook script for infinite loops\n` +
|
||||
` 2. Add timeout to any network requests\n` +
|
||||
` 3. Use background jobs for long operations\n` +
|
||||
` 4. Test your hook script independently`
|
||||
|
||||
if (stderr) {
|
||||
throw new Error(`${enhancedMessage}\n\nStderr: ${stderr}`)
|
||||
}
|
||||
throw new Error(enhancedMessage)
|
||||
}
|
||||
|
||||
// ... existing error handling
|
||||
}
|
||||
```
|
||||
|
||||
**Testing:**
|
||||
- Create hook with sleep 60s
|
||||
- Verify enhanced timeout message appears
|
||||
- Check recommendations are clear
|
||||
|
||||
## Medium Priority Implementations
|
||||
|
||||
### 4. Invalid JSON Validation
|
||||
**File:** `src/core/hooks/hook-factory.ts`
|
||||
|
||||
**Current Issue:**
|
||||
- Basic JSON.parse with try-catch
|
||||
- No field validation
|
||||
- Generic error messages
|
||||
|
||||
**Implementation:**
|
||||
```typescript
|
||||
/**
|
||||
* Validates hook output JSON structure
|
||||
*/
|
||||
function validateHookOutput(output: any): { valid: boolean; error?: string } {
|
||||
// Check shouldContinue field
|
||||
if (typeof output.shouldContinue !== 'boolean') {
|
||||
return {
|
||||
valid: false,
|
||||
error:
|
||||
'Invalid hook output: Missing or invalid "shouldContinue" field.\n\n' +
|
||||
'Expected: {"shouldContinue": true}\n' +
|
||||
'Required: shouldContinue must be a boolean (true or false)\n\n' +
|
||||
'Example valid response:\n' +
|
||||
JSON.stringify({
|
||||
shouldContinue: true,
|
||||
contextModification: "Optional context here",
|
||||
errorMessage: "Optional error message"
|
||||
}, null, 2)
|
||||
}
|
||||
}
|
||||
|
||||
// Check contextModification if present
|
||||
if (output.contextModification !== undefined && typeof output.contextModification !== 'string') {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'Invalid hook output: "contextModification" must be a string if provided'
|
||||
}
|
||||
}
|
||||
|
||||
// Check errorMessage if present
|
||||
if (output.errorMessage !== undefined && typeof output.errorMessage !== 'string') {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'Invalid hook output: "errorMessage" must be a string if provided'
|
||||
}
|
||||
}
|
||||
|
||||
return { valid: true }
|
||||
}
|
||||
|
||||
// Use in parseJsonOutput:
|
||||
const parseJsonOutput = (): HookOutput | null => {
|
||||
try {
|
||||
const outputData = JSON.parse(stdout)
|
||||
|
||||
// Validate structure
|
||||
const validation = validateHookOutput(outputData)
|
||||
if (!validation.valid) {
|
||||
// Emit validation error
|
||||
if (this.streamCallback) {
|
||||
this.streamCallback(`\n❌ ${validation.error}`, 'stderr')
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const output = HookOutput.fromJSON(outputData)
|
||||
// ... rest of validation
|
||||
} catch (parseError) {
|
||||
// Enhanced parse error message
|
||||
if (this.streamCallback) {
|
||||
this.streamCallback(
|
||||
`\n❌ Failed to parse hook JSON output.\n` +
|
||||
`Error: ${parseError instanceof Error ? parseError.message : String(parseError)}\n\n` +
|
||||
`Stdout:\n${stdout.slice(0, 500)}${stdout.length > 500 ? '...' : ''}`,
|
||||
'stderr'
|
||||
)
|
||||
}
|
||||
// ... rest of parse error handling
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Testing:**
|
||||
- Hook with missing shouldContinue
|
||||
- Hook with wrong type for contextModification
|
||||
- Hook with invalid JSON
|
||||
- Verify error messages are helpful
|
||||
|
||||
### 5. Improved Process Termination
|
||||
**File:** `src/core/hooks/HookProcess.ts`
|
||||
|
||||
**Current Issue:**
|
||||
- SIGTERM only kills parent, not process tree
|
||||
- 5s timeout for force kill is long
|
||||
- No process group handling
|
||||
|
||||
**Implementation:**
|
||||
```typescript
|
||||
/**
|
||||
* Terminate the process and its entire process tree
|
||||
*/
|
||||
async terminate(): Promise<void> {
|
||||
if (!this.childProcess || this.isCompleted) {
|
||||
return
|
||||
}
|
||||
|
||||
const pid = this.childProcess.pid
|
||||
if (!pid) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// On Unix, kill process group (negative PID)
|
||||
// On Windows, tree-kill package would be better, but SIGTERM works for simple cases
|
||||
if (process.platform !== 'win32') {
|
||||
// Kill process group with SIGTERM
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Testing:**
|
||||
- Hook that spawns child processes
|
||||
- Verify all children are killed
|
||||
- Test on both Unix and Windows
|
||||
- Verify no zombies remain
|
||||
|
||||
## Implementation Order
|
||||
|
||||
1. ✅ **Hook Output Size Limits** - Prevents memory issues (30 min)
|
||||
2. ✅ **Hook Process Registry** - Prevents zombie processes (45 min)
|
||||
3. ✅ **Extension Cleanup** - Ensures proper shutdown (15 min)
|
||||
4. ✅ **Improved Timeout Messages** - Better UX (20 min)
|
||||
5. ✅ **JSON Validation** - Better error messages (30 min)
|
||||
6. ✅ **Improved Termination** - More reliable (30 min)
|
||||
|
||||
**Total estimated time: ~3 hours**
|
||||
|
||||
## Testing Plan
|
||||
|
||||
### Unit Tests
|
||||
- Output size limiting
|
||||
- Registry registration/unregistration
|
||||
- JSON validation edge cases
|
||||
|
||||
### Integration Tests
|
||||
- Long-running hook cancellation
|
||||
- Extension deactivation cleanup
|
||||
- Multiple concurrent hooks
|
||||
|
||||
### Manual Tests
|
||||
- Create hooks that:
|
||||
- Output 2MB of data
|
||||
- Run for 60 seconds
|
||||
- Return invalid JSON
|
||||
- Spawn child processes
|
||||
- Verify all improvements work correctly
|
||||
|
||||
## Documentation Updates
|
||||
|
||||
After implementation:
|
||||
1. Update hooks README with:
|
||||
- 1MB output limit
|
||||
- 30s timeout (with note about checking scripts)
|
||||
- Proper JSON response format with examples
|
||||
2. Add troubleshooting section for common errors
|
||||
3. Update examples to show good practices
|
||||
@@ -62,6 +62,14 @@ export class TaskState {
|
||||
didFinishAbortingStream = false
|
||||
abandoned = false
|
||||
|
||||
// Hook execution tracking for cancellation
|
||||
activeHookExecution?: {
|
||||
hookName: string
|
||||
toolName?: string
|
||||
messageTs: number
|
||||
abortController: AbortController
|
||||
}
|
||||
|
||||
// Auto-context summarization
|
||||
currentlySummarizing: boolean = false
|
||||
lastAutoCompactTriggerIndex?: number
|
||||
|
||||
+347
-48
@@ -6,13 +6,14 @@ import { BrowserSession } from "@services/browser/BrowserSession"
|
||||
import { UrlContentFetcher } from "@services/browser/UrlContentFetcher"
|
||||
import { featureFlagsService } from "@services/feature-flags"
|
||||
import { McpHub } from "@services/mcp/McpHub"
|
||||
import { ClineAsk, ClineSay } from "@shared/ExtensionMessage"
|
||||
import { ClineAsk, ClineSay, ClineSayHook } from "@shared/ExtensionMessage"
|
||||
import { ClineDefaultTool } from "@shared/tools"
|
||||
import { ClineAskResponse } from "@shared/WebviewMessage"
|
||||
import * as vscode from "vscode"
|
||||
import { modelDoesntSupportWebp } from "@/utils/model-utils"
|
||||
import { ToolUse } from "../assistant-message"
|
||||
import { ContextManager } from "../context/context-management/ContextManager"
|
||||
import { HookExecutionError } from "../hooks/HookError"
|
||||
import { HookFactory } from "../hooks/hook-factory"
|
||||
import { formatResponse } from "../prompts/responses"
|
||||
import { StateManager } from "../storage/StateManager"
|
||||
@@ -228,7 +229,14 @@ export class ToolExecutor {
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles errors during tool execution
|
||||
* Handles errors during tool execution.
|
||||
*
|
||||
* Logs the error, displays it to the user via the UI, and adds an error
|
||||
* result to the conversation context so the AI can see what went wrong.
|
||||
*
|
||||
* @param action Description of what was being attempted (e.g., "executing read_file")
|
||||
* @param error The error that occurred
|
||||
* @param block The tool use block that caused the error
|
||||
*/
|
||||
private async handleError(action: string, error: Error, block: ToolUse): Promise<void> {
|
||||
console.log(error)
|
||||
@@ -240,6 +248,17 @@ export class ToolExecutor {
|
||||
this.pushToolResult(errorResponse, block)
|
||||
}
|
||||
|
||||
/**
|
||||
* Pushes a tool result to the user message content.
|
||||
*
|
||||
* This is a critical method that:
|
||||
* - Formats the tool result appropriately for the API
|
||||
* - Adds it to the conversation context
|
||||
* - Marks that a tool has been used in this turn
|
||||
*
|
||||
* @param content The tool response content to add
|
||||
* @param block The tool use block that generated this result
|
||||
*/
|
||||
private pushToolResult = (content: ToolResponse, block: ToolUse) => {
|
||||
// Use the ToolResultUtils to properly format and push the tool result
|
||||
ToolResultUtils.pushToolResult(
|
||||
@@ -265,7 +284,18 @@ export class ToolExecutor {
|
||||
]
|
||||
|
||||
/**
|
||||
* Execute a tool through the coordinator if it's registered
|
||||
* Execute a tool through the coordinator if it's registered.
|
||||
*
|
||||
* This is the main entry point for tool execution, called by the Task class.
|
||||
* It handles:
|
||||
* - Checking if the tool is registered with the coordinator
|
||||
* - Validating tool execution is allowed (not rejected, not already used, etc.)
|
||||
* - Enforcing plan mode restrictions on file modification tools
|
||||
* - Delegating to partial or complete block handlers
|
||||
* - Error handling and checkpointing
|
||||
*
|
||||
* @param block The tool use block to execute
|
||||
* @returns true if the tool was handled (even if execution failed), false if not registered
|
||||
*/
|
||||
private async execute(block: ToolUse): Promise<boolean> {
|
||||
if (!this.coordinator.has(block.name)) {
|
||||
@@ -330,14 +360,27 @@ export class ToolExecutor {
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a tool is restricted in plan mode
|
||||
* Check if a tool is restricted in plan mode.
|
||||
*
|
||||
* In strict plan mode, file modification tools (write_to_file, editedExistingFile, etc.)
|
||||
* are blocked. The AI must switch to Act mode to use these tools.
|
||||
*
|
||||
* @param toolName The name of the tool to check
|
||||
* @returns true if the tool is restricted in plan mode, false otherwise
|
||||
*/
|
||||
private isPlanModeToolRestricted(toolName: ClineDefaultTool): boolean {
|
||||
return ToolExecutor.PLAN_MODE_RESTRICTED_TOOLS.includes(toolName)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a tool rejection message and add it to user message content
|
||||
* Create a tool rejection message and add it to user message content.
|
||||
*
|
||||
* Used when a tool cannot be executed (e.g., user rejected a previous tool,
|
||||
* tool was interrupted, etc.). Adds a text message to the conversation explaining
|
||||
* why the tool was not executed.
|
||||
*
|
||||
* @param block The tool use block that was rejected
|
||||
* @param reason Human-readable explanation of why the tool was rejected
|
||||
*/
|
||||
private createToolRejectionMessage(block: ToolUse, reason: string): void {
|
||||
this.taskState.userMessageContent.push({
|
||||
@@ -385,7 +428,16 @@ export class ToolExecutor {
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle partial block streaming UI updates
|
||||
* Handle partial block streaming UI updates.
|
||||
*
|
||||
* During streaming API responses, the AI sends partial tool use blocks as they're
|
||||
* generated. This method updates the UI to show the tool being constructed in real-time.
|
||||
*
|
||||
* NOTE: This is ONLY for UI updates. No tool results are pushed to the conversation
|
||||
* during partial block handling. The complete block handler will add the final result.
|
||||
*
|
||||
* @param block The partial tool use block with incomplete parameters
|
||||
* @param config The task configuration containing all necessary context
|
||||
*/
|
||||
private async handlePartialBlock(block: ToolUse, config: TaskConfig): Promise<void> {
|
||||
// NOTE: We don't push tool results in partial blocks because this is only for UI streaming.
|
||||
@@ -402,7 +454,21 @@ 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 if hooks are enabled (both feature flag and user setting must be true)
|
||||
@@ -412,37 +478,183 @@ export class ToolExecutor {
|
||||
|
||||
let executionSuccess = true
|
||||
let toolResult: any = null
|
||||
let pendingToolTs: number | undefined
|
||||
|
||||
// Run PreToolUse hook, if enabled
|
||||
if (hooksEnabled) {
|
||||
let preToolUseResult: any = null
|
||||
try {
|
||||
const hookFactory = new HookFactory()
|
||||
const preToolUseHook = await hookFactory.create("PreToolUse")
|
||||
const hookFactory = new HookFactory()
|
||||
const hasPreToolUseHook = await hookFactory.hasHook("PreToolUse")
|
||||
|
||||
preToolUseResult = await preToolUseHook.run({
|
||||
taskId: this.taskId,
|
||||
preToolUse: {
|
||||
if (hasPreToolUseHook) {
|
||||
let preToolUseResult: any = null
|
||||
let hookMessageTs: number | undefined
|
||||
const abortController = new AbortController()
|
||||
|
||||
try {
|
||||
// Build pending tool info for display
|
||||
const pendingToolInfo: any = {
|
||||
tool: block.name,
|
||||
}
|
||||
|
||||
// Add relevant parameters for display based on tool type
|
||||
if (block.params.path) {
|
||||
pendingToolInfo.path = block.params.path
|
||||
}
|
||||
if (block.params.command) {
|
||||
pendingToolInfo.command = block.params.command
|
||||
}
|
||||
if (block.params.content && typeof block.params.content === "string") {
|
||||
// Include a preview of content (first 200 chars)
|
||||
pendingToolInfo.content = block.params.content.slice(0, 200)
|
||||
}
|
||||
if (block.params.diff && typeof block.params.diff === "string") {
|
||||
// Include a preview of diff (first 200 chars)
|
||||
pendingToolInfo.diff = block.params.diff.slice(0, 200)
|
||||
}
|
||||
if (block.params.regex) {
|
||||
pendingToolInfo.regex = block.params.regex
|
||||
}
|
||||
if (block.params.url) {
|
||||
pendingToolInfo.url = block.params.url
|
||||
}
|
||||
// For MCP operations, show tool/resource identifiers
|
||||
if (block.params.tool_name) {
|
||||
pendingToolInfo.mcpTool = block.params.tool_name
|
||||
}
|
||||
if (block.params.server_name) {
|
||||
pendingToolInfo.mcpServer = block.params.server_name
|
||||
}
|
||||
if (block.params.uri) {
|
||||
pendingToolInfo.resourceUri = block.params.uri
|
||||
}
|
||||
|
||||
// Show hook execution indicator with pending tool info
|
||||
const hookMetadata: ClineSayHook = {
|
||||
hookName: "PreToolUse",
|
||||
toolName: block.name,
|
||||
parameters: block.params,
|
||||
},
|
||||
})
|
||||
status: "running",
|
||||
pendingToolInfo, // Include tool info in hook message
|
||||
}
|
||||
hookMessageTs = await this.say("hook", JSON.stringify(hookMetadata))
|
||||
|
||||
// Check if hook wants to stop execution
|
||||
if (!preToolUseResult.shouldContinue) {
|
||||
const errorMessage = preToolUseResult.errorMessage || "PreToolUse hook prevented tool execution"
|
||||
await this.say("error", errorMessage)
|
||||
this.pushToolResult(formatResponse.toolError(errorMessage), block)
|
||||
return
|
||||
// Track active hook execution for cancellation (only if message was created)
|
||||
if (hookMessageTs !== undefined) {
|
||||
this.taskState.activeHookExecution = {
|
||||
hookName: "PreToolUse",
|
||||
toolName: block.name,
|
||||
messageTs: hookMessageTs,
|
||||
abortController,
|
||||
}
|
||||
}
|
||||
|
||||
// Create streaming callback that displays hook output in real-time
|
||||
const streamCallback = async (line: string, stream: "stdout" | "stderr") => {
|
||||
// Display the output line in the UI
|
||||
await this.say("hook_output", line)
|
||||
}
|
||||
|
||||
const preToolUseHook = await hookFactory.createWithStreaming(
|
||||
"PreToolUse",
|
||||
streamCallback,
|
||||
abortController.signal,
|
||||
)
|
||||
|
||||
preToolUseResult = await preToolUseHook.run({
|
||||
taskId: this.taskId,
|
||||
preToolUse: {
|
||||
toolName: block.name,
|
||||
parameters: block.params,
|
||||
},
|
||||
})
|
||||
console.log("[PreToolUse Hook]", preToolUseResult)
|
||||
|
||||
// Clear active hook execution
|
||||
this.taskState.activeHookExecution = undefined
|
||||
|
||||
// Update hook status to completed (update the same message)
|
||||
if (hookMessageTs !== undefined) {
|
||||
const clineMessages = this.messageStateHandler.getClineMessages()
|
||||
const hookMessageIndex = clineMessages.findIndex((m) => m.ts === hookMessageTs)
|
||||
if (hookMessageIndex !== -1) {
|
||||
const completedMetadata: ClineSayHook = {
|
||||
hookName: "PreToolUse",
|
||||
toolName: block.name,
|
||||
status: "completed",
|
||||
exitCode: 0,
|
||||
hasJsonResponse: true,
|
||||
}
|
||||
await this.messageStateHandler.updateClineMessage(hookMessageIndex, {
|
||||
text: JSON.stringify(completedMetadata),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Check if hook wants to stop execution
|
||||
if (!preToolUseResult.shouldContinue) {
|
||||
// Update hook status to show shouldContinue: false in the UI
|
||||
if (hookMessageTs !== undefined) {
|
||||
const clineMessages = this.messageStateHandler.getClineMessages()
|
||||
const hookMessageIndex = clineMessages.findIndex((m) => m.ts === hookMessageTs)
|
||||
if (hookMessageIndex !== -1) {
|
||||
const blockedMetadata: ClineSayHook = {
|
||||
hookName: "PreToolUse",
|
||||
toolName: block.name,
|
||||
status: "completed",
|
||||
exitCode: 0,
|
||||
hasJsonResponse: true,
|
||||
shouldContinue: false,
|
||||
}
|
||||
await this.messageStateHandler.updateClineMessage(hookMessageIndex, {
|
||||
text: JSON.stringify(blockedMetadata),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Block execution with the hook's error message (or default)
|
||||
const errorMessage = preToolUseResult.errorMessage || "Hook prevented tool execution"
|
||||
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) {
|
||||
// Clear active hook execution
|
||||
this.taskState.activeHookExecution = undefined
|
||||
|
||||
// Extract structured error info if available
|
||||
const isStructuredError = HookExecutionError.isHookError(hookError)
|
||||
const errorInfo = isStructuredError ? hookError.errorInfo : null
|
||||
|
||||
// Update hook status with structured error info (update the same message if it exists)
|
||||
if (hookMessageTs !== undefined) {
|
||||
const clineMessages = this.messageStateHandler.getClineMessages()
|
||||
const hookMessageIndex = clineMessages.findIndex((m) => m.ts === hookMessageTs)
|
||||
if (hookMessageIndex !== -1) {
|
||||
const failedMetadata: ClineSayHook = {
|
||||
hookName: "PreToolUse",
|
||||
toolName: block.name,
|
||||
status: errorInfo?.type === "cancellation" ? "cancelled" : "failed",
|
||||
exitCode: errorInfo?.exitCode ?? 1,
|
||||
...(errorInfo && {
|
||||
error: {
|
||||
type: errorInfo.type,
|
||||
message: errorInfo.message,
|
||||
details: errorInfo.details,
|
||||
scriptPath: errorInfo.scriptPath,
|
||||
},
|
||||
}),
|
||||
}
|
||||
await this.messageStateHandler.updateClineMessage(hookMessageIndex, {
|
||||
text: JSON.stringify(failedMetadata),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Hook errors never block tool execution (fail-open)
|
||||
// Only explicit shouldContinue: false in JSON blocks execution
|
||||
// Don't return - continue to tool execution below
|
||||
}
|
||||
|
||||
// Add context modification to the conversation if provided by the hook
|
||||
this.addHookContextToConversation(preToolUseResult.contextModification, "PreToolUse")
|
||||
} catch (hookError) {
|
||||
const errorMessage = `PreToolUse hook failed: ${hookError.toString()}`
|
||||
await this.say("error", errorMessage)
|
||||
this.pushToolResult(formatResponse.toolError(errorMessage), block)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -460,26 +672,113 @@ export class ToolExecutor {
|
||||
// Run PostToolUse hook if enabled
|
||||
if (hooksEnabled) {
|
||||
const hookFactory = new HookFactory()
|
||||
const postToolUseHook = await hookFactory.create("PostToolUse")
|
||||
const hasPostToolUseHook = await hookFactory.hasHook("PostToolUse")
|
||||
|
||||
const executionTimeMs = Date.now() - executionStartTime
|
||||
const postToolUseResult = await postToolUseHook.run({
|
||||
taskId: this.taskId,
|
||||
postToolUse: {
|
||||
toolName: block.name,
|
||||
parameters: block.params,
|
||||
result: typeof toolResult === "string" ? toolResult : JSON.stringify(toolResult),
|
||||
success: executionSuccess,
|
||||
executionTimeMs,
|
||||
},
|
||||
})
|
||||
if (hasPostToolUseHook) {
|
||||
let hookMessageTs: number | undefined
|
||||
const abortController = new AbortController()
|
||||
|
||||
// Add context modification to the conversation if provided by the hook
|
||||
this.addHookContextToConversation(postToolUseResult.contextModification, "PostToolUse")
|
||||
try {
|
||||
// Show hook execution indicator and capture timestamp
|
||||
const hookMetadata = {
|
||||
hookName: "PostToolUse",
|
||||
toolName: block.name,
|
||||
status: "running",
|
||||
}
|
||||
hookMessageTs = await this.say("hook", JSON.stringify(hookMetadata))
|
||||
|
||||
// Log any error messages from the hook
|
||||
if (postToolUseResult.errorMessage) {
|
||||
this.say("error", postToolUseResult.errorMessage)
|
||||
// Track active hook execution for cancellation (only if message was created)
|
||||
if (hookMessageTs !== undefined) {
|
||||
this.taskState.activeHookExecution = {
|
||||
hookName: "PostToolUse",
|
||||
toolName: block.name,
|
||||
messageTs: hookMessageTs,
|
||||
abortController,
|
||||
}
|
||||
}
|
||||
|
||||
// Create streaming callback that displays hook output in real-time
|
||||
const streamCallback = async (line: string, stream: "stdout" | "stderr") => {
|
||||
await this.say("hook_output", line)
|
||||
}
|
||||
|
||||
const postToolUseHook = await hookFactory.createWithStreaming(
|
||||
"PostToolUse",
|
||||
streamCallback,
|
||||
abortController.signal,
|
||||
)
|
||||
|
||||
const executionTimeMs = Date.now() - executionStartTime
|
||||
const postToolUseResult = await postToolUseHook.run({
|
||||
taskId: this.taskId,
|
||||
postToolUse: {
|
||||
toolName: block.name,
|
||||
parameters: block.params,
|
||||
result: typeof toolResult === "string" ? toolResult : JSON.stringify(toolResult),
|
||||
success: executionSuccess,
|
||||
executionTimeMs,
|
||||
},
|
||||
})
|
||||
console.log("[PostToolUse Hook]", postToolUseResult)
|
||||
|
||||
// Update hook status to completed (update the same message)
|
||||
if (hookMessageTs !== undefined) {
|
||||
const clineMessages = this.messageStateHandler.getClineMessages()
|
||||
const hookMessageIndex = clineMessages.findIndex((m) => m.ts === hookMessageTs)
|
||||
if (hookMessageIndex !== -1) {
|
||||
const completedMetadata = {
|
||||
hookName: "PostToolUse",
|
||||
toolName: block.name,
|
||||
status: "completed",
|
||||
exitCode: 0,
|
||||
hasJsonResponse: true,
|
||||
}
|
||||
await this.messageStateHandler.updateClineMessage(hookMessageIndex, {
|
||||
text: JSON.stringify(completedMetadata),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Add context modification to the conversation if provided by the hook
|
||||
this.addHookContextToConversation(postToolUseResult.contextModification, "PostToolUse")
|
||||
|
||||
// Clear active hook execution
|
||||
this.taskState.activeHookExecution = undefined
|
||||
} catch (hookError) {
|
||||
// Clear active hook execution
|
||||
this.taskState.activeHookExecution = undefined
|
||||
|
||||
// Extract structured error info if available
|
||||
const isStructuredError = HookExecutionError.isHookError(hookError)
|
||||
const errorInfo = isStructuredError ? hookError.errorInfo : null
|
||||
|
||||
// Update hook status with structured error info (update the same message if it exists)
|
||||
if (hookMessageTs !== undefined) {
|
||||
const clineMessages = this.messageStateHandler.getClineMessages()
|
||||
const hookMessageIndex = clineMessages.findIndex((m) => m.ts === hookMessageTs)
|
||||
if (hookMessageIndex !== -1) {
|
||||
const failedMetadata: ClineSayHook = {
|
||||
hookName: "PostToolUse",
|
||||
toolName: block.name,
|
||||
status: errorInfo?.type === "cancellation" ? "cancelled" : "failed",
|
||||
exitCode: errorInfo?.exitCode ?? 1,
|
||||
...(errorInfo && {
|
||||
error: {
|
||||
type: errorInfo.type,
|
||||
message: errorInfo.message,
|
||||
details: errorInfo.details,
|
||||
scriptPath: errorInfo.scriptPath,
|
||||
},
|
||||
}),
|
||||
}
|
||||
await this.messageStateHandler.updateClineMessage(hookMessageIndex, {
|
||||
text: JSON.stringify(failedMetadata),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// PostToolUse hook failure is non-fatal (observation only)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+578
-122
@@ -119,6 +119,7 @@ type TaskParams = {
|
||||
historyItem?: HistoryItem
|
||||
taskId: string
|
||||
taskLockAcquired: boolean
|
||||
skipResume?: boolean
|
||||
}
|
||||
|
||||
export class Task {
|
||||
@@ -208,6 +209,7 @@ export class Task {
|
||||
historyItem,
|
||||
taskId,
|
||||
taskLockAcquired,
|
||||
skipResume,
|
||||
} = params
|
||||
|
||||
this.taskInitializationStartTime = performance.now()
|
||||
@@ -427,8 +429,12 @@ export class Task {
|
||||
this.browserSession.setUlid(this.ulid)
|
||||
|
||||
// Continue with task initialization
|
||||
if (historyItem) {
|
||||
if (historyItem && !skipResume) {
|
||||
// Normal resume - load state and start workflow
|
||||
this.resumeTaskFromHistory()
|
||||
} else if (historyItem && skipResume) {
|
||||
// Cancel scenario - load state but don't start workflow
|
||||
this.loadTaskStateWithoutWorkflow()
|
||||
} else if (task || images || files) {
|
||||
this.startTask(task, images, files)
|
||||
}
|
||||
@@ -753,10 +759,29 @@ export class Task {
|
||||
return { shouldContinue: true }
|
||||
}
|
||||
|
||||
const { HookFactory } = await import("../hooks/hook-factory")
|
||||
const hookFactory = new HookFactory()
|
||||
const hasUserPromptSubmitHook = await hookFactory.hasHook("UserPromptSubmit")
|
||||
|
||||
if (!hasUserPromptSubmitHook) {
|
||||
return { shouldContinue: true }
|
||||
}
|
||||
|
||||
let hookMessageTs: number | undefined
|
||||
try {
|
||||
const { HookFactory } = await import("../hooks/hook-factory")
|
||||
const hookFactory = new HookFactory()
|
||||
const hook = await hookFactory.create("UserPromptSubmit")
|
||||
// Show hook execution indicator and capture timestamp
|
||||
const hookMetadata = {
|
||||
hookName: "UserPromptSubmit",
|
||||
status: "running",
|
||||
}
|
||||
hookMessageTs = await this.say("hook", JSON.stringify(hookMetadata))
|
||||
|
||||
// Create streaming callback
|
||||
const streamCallback = async (line: string, stream: "stdout" | "stderr") => {
|
||||
await this.say("hook_output", line)
|
||||
}
|
||||
|
||||
const hook = await hookFactory.createWithStreaming("UserPromptSubmit", streamCallback)
|
||||
|
||||
// Serialize UserContent to string for the hook
|
||||
const promptText = userContent
|
||||
@@ -778,6 +803,24 @@ export class Task {
|
||||
attachments: [], // Images are inline in UserContent
|
||||
},
|
||||
})
|
||||
console.log("[UserPromptSubmit Hook]", result)
|
||||
|
||||
// Update hook status to completed (update the same message)
|
||||
if (hookMessageTs !== undefined) {
|
||||
const clineMessages = this.messageStateHandler.getClineMessages()
|
||||
const hookMessageIndex = clineMessages.findIndex((m) => m.ts === hookMessageTs)
|
||||
if (hookMessageIndex !== -1) {
|
||||
const completedMetadata = {
|
||||
hookName: "UserPromptSubmit",
|
||||
status: "completed",
|
||||
exitCode: 0,
|
||||
hasJsonResponse: true,
|
||||
}
|
||||
await this.messageStateHandler.updateClineMessage(hookMessageIndex, {
|
||||
text: JSON.stringify(completedMetadata),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
shouldContinue: result.shouldContinue,
|
||||
@@ -785,6 +828,22 @@ export class Task {
|
||||
errorMessage: result.errorMessage,
|
||||
}
|
||||
} catch (error) {
|
||||
// Update hook status to failed (update the same message if it exists)
|
||||
if (hookMessageTs !== undefined) {
|
||||
const clineMessages = this.messageStateHandler.getClineMessages()
|
||||
const hookMessageIndex = clineMessages.findIndex((m) => m.ts === hookMessageTs)
|
||||
if (hookMessageIndex !== -1) {
|
||||
const failedMetadata = {
|
||||
hookName: "UserPromptSubmit",
|
||||
status: "failed",
|
||||
exitCode: error instanceof Error ? 1 : undefined,
|
||||
}
|
||||
await this.messageStateHandler.updateClineMessage(hookMessageIndex, {
|
||||
text: JSON.stringify(failedMetadata),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
console.error("UserPromptSubmit hook failed:", error)
|
||||
return { shouldContinue: true }
|
||||
}
|
||||
@@ -834,53 +893,310 @@ export class Task {
|
||||
// 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 { HookFactory } = await import("../hooks/hook-factory")
|
||||
const { HookExecutionError } = await import("../hooks/HookError")
|
||||
const hookFactory = new HookFactory()
|
||||
const hasTaskStartHook = await hookFactory.hasHook("TaskStart")
|
||||
|
||||
const taskStartResult = await taskStartHook.run({
|
||||
taskId: this.taskId,
|
||||
taskStart: {
|
||||
taskMetadata: {
|
||||
taskId: this.taskId,
|
||||
ulid: this.ulid,
|
||||
initialTask: task || "",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
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
|
||||
await this.messageStateHandler.saveClineMessagesAndUpdateHistory()
|
||||
await this.postStateToWebview()
|
||||
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>`,
|
||||
})
|
||||
if (hasTaskStartHook) {
|
||||
let hookMessageTs: number | undefined
|
||||
try {
|
||||
// Show hook execution indicator and capture timestamp
|
||||
const hookMetadata = {
|
||||
hookName: "TaskStart",
|
||||
status: "running",
|
||||
}
|
||||
hookMessageTs = await this.say("hook", JSON.stringify(hookMetadata))
|
||||
|
||||
// Create streaming callback
|
||||
const streamCallback = async (line: string, stream: "stdout" | "stderr") => {
|
||||
await this.say("hook_output", line)
|
||||
}
|
||||
|
||||
const taskStartHook = await hookFactory.createWithStreaming("TaskStart", streamCallback)
|
||||
|
||||
const taskStartResult = await taskStartHook.run({
|
||||
taskId: this.taskId,
|
||||
taskStart: {
|
||||
taskMetadata: {
|
||||
taskId: this.taskId,
|
||||
ulid: this.ulid,
|
||||
initialTask: task || "",
|
||||
},
|
||||
},
|
||||
})
|
||||
console.log("[TaskStart Hook]", taskStartResult)
|
||||
|
||||
// Update hook status to completed (update the same message)
|
||||
if (hookMessageTs !== undefined) {
|
||||
const clineMessages = this.messageStateHandler.getClineMessages()
|
||||
const hookMessageIndex = clineMessages.findIndex((m) => m.ts === hookMessageTs)
|
||||
if (hookMessageIndex !== -1) {
|
||||
const completedMetadata = {
|
||||
hookName: "TaskStart",
|
||||
status: "completed",
|
||||
exitCode: 0,
|
||||
hasJsonResponse: true,
|
||||
}
|
||||
await this.messageStateHandler.updateClineMessage(hookMessageIndex, {
|
||||
text: JSON.stringify(completedMetadata),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (!taskStartResult.shouldContinue) {
|
||||
const errorMessage = taskStartResult.errorMessage || "TaskStart hook prevented task from starting"
|
||||
|
||||
// Update hook status to show blocking
|
||||
if (hookMessageTs !== undefined) {
|
||||
const clineMessages = this.messageStateHandler.getClineMessages()
|
||||
const hookMessageIndex = clineMessages.findIndex((m) => m.ts === hookMessageTs)
|
||||
if (hookMessageIndex !== -1) {
|
||||
const blockedMetadata = {
|
||||
hookName: "TaskStart",
|
||||
status: "completed",
|
||||
exitCode: 0,
|
||||
hasJsonResponse: true,
|
||||
shouldContinue: false,
|
||||
}
|
||||
await this.messageStateHandler.updateClineMessage(hookMessageIndex, {
|
||||
text: JSON.stringify(blockedMetadata),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure the changes are saved and posted before aborting
|
||||
await this.messageStateHandler.saveClineMessagesAndUpdateHistory()
|
||||
await this.postStateToWebview()
|
||||
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>`,
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch (hookError) {
|
||||
// Extract structured error info if available
|
||||
const isStructuredError = HookExecutionError.isHookError(hookError)
|
||||
const errorInfo = isStructuredError ? hookError.errorInfo : null
|
||||
|
||||
// Update hook status with structured error info (update the same message if it exists)
|
||||
if (hookMessageTs !== undefined) {
|
||||
const clineMessages = this.messageStateHandler.getClineMessages()
|
||||
const hookMessageIndex = clineMessages.findIndex((m) => m.ts === hookMessageTs)
|
||||
if (hookMessageIndex !== -1) {
|
||||
const failedMetadata = {
|
||||
hookName: "TaskStart",
|
||||
status: errorInfo?.type === "cancellation" ? "cancelled" : "failed",
|
||||
exitCode: errorInfo?.exitCode ?? 1,
|
||||
...(errorInfo && {
|
||||
error: {
|
||||
type: errorInfo.type,
|
||||
message: errorInfo.message,
|
||||
details: errorInfo.details,
|
||||
scriptPath: errorInfo.scriptPath,
|
||||
},
|
||||
}),
|
||||
}
|
||||
await this.messageStateHandler.updateClineMessage(hookMessageIndex, {
|
||||
text: JSON.stringify(failedMetadata),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TaskStart hook failure is non-fatal - continue with task
|
||||
}
|
||||
} 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)
|
||||
}
|
||||
}
|
||||
|
||||
await this.initiateTaskLoop(userContent)
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads task state without starting the workflow
|
||||
* Used when cancelling a task to keep it visible without triggering TaskResume hook
|
||||
* When the user clicks resume, this will continue the workflow directly
|
||||
*/
|
||||
private async loadTaskStateWithoutWorkflow() {
|
||||
try {
|
||||
await this.clineIgnoreController.initialize()
|
||||
} catch (error) {
|
||||
console.error("Failed to initialize ClineIgnoreController:", error)
|
||||
}
|
||||
|
||||
const savedClineMessages = await getSavedClineMessages(this.taskId)
|
||||
|
||||
// Remove any resume messages that may have been added before
|
||||
const lastRelevantMessageIndex = findLastIndex(
|
||||
savedClineMessages,
|
||||
(m) => !(m.ask === "resume_task" || m.ask === "resume_completed_task"),
|
||||
)
|
||||
if (lastRelevantMessageIndex !== -1) {
|
||||
savedClineMessages.splice(lastRelevantMessageIndex + 1)
|
||||
}
|
||||
|
||||
// Remove incomplete api_req_started messages
|
||||
const lastApiReqStartedIndex = findLastIndex(savedClineMessages, (m) => m.type === "say" && m.say === "api_req_started")
|
||||
if (lastApiReqStartedIndex !== -1) {
|
||||
const lastApiReqStarted = savedClineMessages[lastApiReqStartedIndex]
|
||||
const { cost, cancelReason }: ClineApiReqInfo = JSON.parse(lastApiReqStarted.text || "{}")
|
||||
if (cost === undefined && cancelReason === undefined) {
|
||||
savedClineMessages.splice(lastApiReqStartedIndex, 1)
|
||||
}
|
||||
}
|
||||
|
||||
await this.messageStateHandler.overwriteClineMessages(savedClineMessages)
|
||||
this.messageStateHandler.setClineMessages(await getSavedClineMessages(this.taskId))
|
||||
|
||||
// Load API conversation history
|
||||
const savedApiConversationHistory = await getSavedApiConversationHistory(this.taskId)
|
||||
this.messageStateHandler.setApiConversationHistory(savedApiConversationHistory)
|
||||
|
||||
// Load context history state
|
||||
await ensureTaskDirectoryExists(this.taskId)
|
||||
await this.contextManager.initializeContextHistory(await ensureTaskDirectoryExists(this.taskId))
|
||||
|
||||
this.taskState.isInitialized = true
|
||||
|
||||
// Present the resume ask to show the resume button
|
||||
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"
|
||||
}
|
||||
|
||||
// Wait for user to click resume button
|
||||
const { response, text, images, files } = await this.ask(askType)
|
||||
|
||||
if (response !== "yesButtonClicked") {
|
||||
// Task was cleared (user started new task)
|
||||
return
|
||||
}
|
||||
|
||||
// User clicked resume - continue with workflow directly (without calling resumeTaskFromHistory to avoid double-ask)
|
||||
// Note: In this flow, the response is always "yesButtonClicked" without additional feedback
|
||||
// If we want to support feedback later, we would need to modify the ask interaction
|
||||
let responseText: string | undefined
|
||||
let responseImages: string[] | undefined
|
||||
let responseFiles: string[] | undefined
|
||||
|
||||
// Prepare to continue the workflow
|
||||
const existingApiConversationHistory: Anthropic.Messages.MessageParam[] = await getSavedApiConversationHistory(
|
||||
this.taskId,
|
||||
)
|
||||
|
||||
// Remove the last user message so we can update it with the resume message
|
||||
let modifiedOldUserContent: UserContent
|
||||
let modifiedApiConversationHistory: Anthropic.Messages.MessageParam[]
|
||||
if (existingApiConversationHistory.length > 0) {
|
||||
const lastMessage = existingApiConversationHistory[existingApiConversationHistory.length - 1]
|
||||
if (lastMessage.role === "assistant") {
|
||||
modifiedApiConversationHistory = [...existingApiConversationHistory]
|
||||
modifiedOldUserContent = []
|
||||
} else if (lastMessage.role === "user") {
|
||||
const existingUserContent: UserContent = Array.isArray(lastMessage.content)
|
||||
? lastMessage.content
|
||||
: [{ type: "text", text: lastMessage.content }]
|
||||
modifiedApiConversationHistory = existingApiConversationHistory.slice(0, -1)
|
||||
modifiedOldUserContent = [...existingUserContent]
|
||||
} else {
|
||||
throw new Error("Unexpected: Last message is not a user or assistant message")
|
||||
}
|
||||
} else {
|
||||
throw new Error("Unexpected: No existing API conversation history")
|
||||
}
|
||||
|
||||
const newUserContent: UserContent = [...modifiedOldUserContent]
|
||||
|
||||
const agoText = (() => {
|
||||
const timestamp = lastClineMessage?.ts ?? Date.now()
|
||||
const now = Date.now()
|
||||
const diff = now - timestamp
|
||||
const minutes = Math.floor(diff / 60000)
|
||||
const hours = Math.floor(minutes / 60)
|
||||
const days = Math.floor(hours / 24)
|
||||
|
||||
if (days > 0) {
|
||||
return `${days} day${days > 1 ? "s" : ""} ago`
|
||||
}
|
||||
if (hours > 0) {
|
||||
return `${hours} hour${hours > 1 ? "s" : ""} ago`
|
||||
}
|
||||
if (minutes > 0) {
|
||||
return `${minutes} minute${minutes > 1 ? "s" : ""} ago`
|
||||
}
|
||||
return "just now"
|
||||
})()
|
||||
|
||||
const wasRecent = lastClineMessage?.ts && Date.now() - lastClineMessage.ts < 30_000
|
||||
|
||||
const pendingContextWarning = await this.fileContextTracker.retrieveAndClearPendingFileContextWarning()
|
||||
const hasPendingFileContextWarnings = pendingContextWarning && pendingContextWarning.length > 0
|
||||
|
||||
const mode = this.stateManager.getGlobalSettingsKey("mode")
|
||||
const [taskResumptionMessage, userResponseMessage] = formatResponse.taskResumption(
|
||||
mode === "plan" ? "plan" : "act",
|
||||
agoText,
|
||||
this.cwd,
|
||||
wasRecent,
|
||||
responseText,
|
||||
hasPendingFileContextWarnings,
|
||||
)
|
||||
|
||||
if (taskResumptionMessage !== "") {
|
||||
newUserContent.push({
|
||||
type: "text",
|
||||
text: taskResumptionMessage,
|
||||
})
|
||||
}
|
||||
|
||||
if (userResponseMessage !== "") {
|
||||
newUserContent.push({
|
||||
type: "text",
|
||||
text: userResponseMessage,
|
||||
})
|
||||
}
|
||||
|
||||
if (responseImages && responseImages.length > 0) {
|
||||
newUserContent.push(...formatResponse.imageBlocks(responseImages))
|
||||
}
|
||||
|
||||
if (responseFiles && responseFiles.length > 0) {
|
||||
const fileContentString = await processFilesIntoText(responseFiles)
|
||||
if (fileContentString) {
|
||||
newUserContent.push({
|
||||
type: "text",
|
||||
text: fileContentString,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (pendingContextWarning && pendingContextWarning.length > 0) {
|
||||
const fileContextWarning = formatResponse.fileContextWarning(pendingContextWarning)
|
||||
newUserContent.push({
|
||||
type: "text",
|
||||
text: fileContextWarning,
|
||||
})
|
||||
}
|
||||
|
||||
await this.messageStateHandler.overwriteApiConversationHistory(modifiedApiConversationHistory)
|
||||
await this.initiateTaskLoop(newUserContent)
|
||||
}
|
||||
|
||||
private async resumeTaskFromHistory() {
|
||||
try {
|
||||
await this.clineIgnoreController.initialize()
|
||||
@@ -944,43 +1260,102 @@ export class Task {
|
||||
// Run TaskResume hook
|
||||
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 { HookFactory } = await import("../hooks/hook-factory")
|
||||
const { HookExecutionError } = await import("../hooks/HookError")
|
||||
const hookFactory = new HookFactory()
|
||||
const hasTaskResumeHook = await hookFactory.hasHook("TaskResume")
|
||||
|
||||
const clineMessages = this.messageStateHandler.getClineMessages()
|
||||
const taskResumeResult = await taskResumeHook.run({
|
||||
taskId: this.taskId,
|
||||
taskResume: {
|
||||
taskMetadata: {
|
||||
taskId: this.taskId,
|
||||
ulid: this.ulid,
|
||||
if (hasTaskResumeHook) {
|
||||
let hookMessageTs: number | undefined
|
||||
try {
|
||||
// Show hook execution indicator
|
||||
const hookMetadata = {
|
||||
hookName: "TaskResume",
|
||||
status: "running",
|
||||
}
|
||||
hookMessageTs = await this.say("hook", JSON.stringify(hookMetadata))
|
||||
|
||||
// Create streaming callback
|
||||
const streamCallback = async (line: string, stream: "stdout" | "stderr") => {
|
||||
await this.say("hook_output", line)
|
||||
}
|
||||
|
||||
const taskResumeHook = await hookFactory.createWithStreaming("TaskResume", streamCallback)
|
||||
|
||||
const clineMessages = this.messageStateHandler.getClineMessages()
|
||||
const taskResumeResult = await taskResumeHook.run({
|
||||
taskId: this.taskId,
|
||||
taskResume: {
|
||||
taskMetadata: {
|
||||
taskId: this.taskId,
|
||||
ulid: this.ulid,
|
||||
},
|
||||
previousState: {
|
||||
lastMessageTs: lastClineMessage?.ts?.toString() || "",
|
||||
messageCount: clineMessages.length.toString(),
|
||||
conversationHistoryDeleted: (
|
||||
this.taskState.conversationHistoryDeletedRange !== undefined
|
||||
).toString(),
|
||||
},
|
||||
},
|
||||
previousState: {
|
||||
lastMessageTs: lastClineMessage?.ts?.toString() || "",
|
||||
messageCount: clineMessages.length.toString(),
|
||||
conversationHistoryDeleted: (this.taskState.conversationHistoryDeletedRange !== undefined).toString(),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// 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>`,
|
||||
})
|
||||
console.log("[TaskResume Hook]", taskResumeResult)
|
||||
|
||||
// Update hook status to completed
|
||||
if (hookMessageTs !== undefined) {
|
||||
const clineMessagesUpdated = this.messageStateHandler.getClineMessages()
|
||||
const hookMessageIndex = clineMessagesUpdated.findIndex((m) => m.ts === hookMessageTs)
|
||||
if (hookMessageIndex !== -1) {
|
||||
const completedMetadata = {
|
||||
hookName: "TaskResume",
|
||||
status: "completed",
|
||||
exitCode: 0,
|
||||
hasJsonResponse: true,
|
||||
}
|
||||
await this.messageStateHandler.updateClineMessage(hookMessageIndex, {
|
||||
text: JSON.stringify(completedMetadata),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
// Extract structured error info if available
|
||||
const isStructuredError = HookExecutionError.isHookError(hookError)
|
||||
const errorInfo = isStructuredError ? hookError.errorInfo : null
|
||||
|
||||
// Update hook status with structured error info
|
||||
if (hookMessageTs !== undefined) {
|
||||
const clineMessagesUpdated = this.messageStateHandler.getClineMessages()
|
||||
const hookMessageIndex = clineMessagesUpdated.findIndex((m) => m.ts === hookMessageTs)
|
||||
if (hookMessageIndex !== -1) {
|
||||
const failedMetadata = {
|
||||
hookName: "TaskResume",
|
||||
status: errorInfo?.type === "cancellation" ? "cancelled" : "failed",
|
||||
exitCode: errorInfo?.exitCode ?? 1,
|
||||
...(errorInfo && {
|
||||
error: {
|
||||
type: errorInfo.type,
|
||||
message: errorInfo.message,
|
||||
details: errorInfo.details,
|
||||
scriptPath: errorInfo.scriptPath,
|
||||
},
|
||||
}),
|
||||
}
|
||||
await this.messageStateHandler.updateClineMessage(hookMessageIndex, {
|
||||
text: JSON.stringify(failedMetadata),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TaskResume hook failure is non-fatal - continue with resume
|
||||
}
|
||||
} catch (hookError) {
|
||||
const errorMessage = `TaskResume hook failed: ${hookError instanceof Error ? hookError.message : String(hookError)}`
|
||||
await this.say("error", errorMessage)
|
||||
// Non-fatal: continue with resume
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1136,60 +1511,98 @@ export class Task {
|
||||
|
||||
async abortTask() {
|
||||
try {
|
||||
// Cancel any running hook execution first
|
||||
try {
|
||||
await this.cancelHookExecution()
|
||||
} catch (error) {
|
||||
Logger.error("Failed to cancel hook during task abort", error)
|
||||
}
|
||||
|
||||
// Run TaskCancel hook
|
||||
const hooksEnabled = featureFlagsService.getHooksEnabled() && this.stateManager.getGlobalSettingsKey("hooksEnabled")
|
||||
if (hooksEnabled) {
|
||||
try {
|
||||
const { HookFactory } = await import("../hooks/hook-factory")
|
||||
const hookFactory = new HookFactory()
|
||||
const taskCancelHook = await hookFactory.create("TaskCancel")
|
||||
const { HookFactory } = await import("../hooks/hook-factory")
|
||||
const { HookExecutionError } = await import("../hooks/HookError")
|
||||
const hookFactory = new HookFactory()
|
||||
const hasTaskCancelHook = await hookFactory.hasHook("TaskCancel")
|
||||
|
||||
const taskCancelResult = await taskCancelHook.run({
|
||||
taskId: this.taskId,
|
||||
taskCancel: {
|
||||
taskMetadata: {
|
||||
taskId: this.taskId,
|
||||
ulid: this.ulid,
|
||||
completionStatus: this.taskState.abandoned ? "abandoned" : "cancelled",
|
||||
},
|
||||
},
|
||||
})
|
||||
if (hasTaskCancelHook) {
|
||||
let hookMessageTs: number | undefined
|
||||
try {
|
||||
// Show hook execution indicator (only if not already aborted)
|
||||
if (!this.taskState.abort) {
|
||||
const hookMetadata = {
|
||||
hookName: "TaskCancel",
|
||||
status: "running",
|
||||
}
|
||||
hookMessageTs = await this.say("hook", JSON.stringify(hookMetadata))
|
||||
}
|
||||
|
||||
// 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)")
|
||||
// Create streaming callback
|
||||
const streamCallback = async (line: string, stream: "stdout" | "stderr") => {
|
||||
await this.say("hook_output", line)
|
||||
}
|
||||
|
||||
const taskCancelHook = await hookFactory.createWithStreaming("TaskCancel", streamCallback)
|
||||
|
||||
const taskCancelResult = await taskCancelHook.run({
|
||||
taskId: this.taskId,
|
||||
taskCancel: {
|
||||
taskMetadata: {
|
||||
taskId: this.taskId,
|
||||
ulid: this.ulid,
|
||||
completionStatus: this.taskState.abandoned ? "abandoned" : "cancelled",
|
||||
},
|
||||
)
|
||||
}
|
||||
} 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
|
||||
},
|
||||
})
|
||||
console.log("[TaskCancel Hook]", taskCancelResult)
|
||||
|
||||
// Update hook status to completed (only if not already aborted)
|
||||
if (!this.taskState.abort && hookMessageTs !== undefined) {
|
||||
const clineMessages = this.messageStateHandler.getClineMessages()
|
||||
const hookMessageIndex = clineMessages.findIndex((m) => m.ts === hookMessageTs)
|
||||
if (hookMessageIndex !== -1) {
|
||||
const completedMetadata = {
|
||||
hookName: "TaskCancel",
|
||||
status: "completed",
|
||||
exitCode: 0,
|
||||
hasJsonResponse: true,
|
||||
}
|
||||
await this.messageStateHandler.updateClineMessage(hookMessageIndex, {
|
||||
text: JSON.stringify(completedMetadata),
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch (hookError) {
|
||||
// Extract structured error info if available
|
||||
const isStructuredError = HookExecutionError.isHookError(hookError)
|
||||
const errorInfo = isStructuredError ? hookError.errorInfo : null
|
||||
|
||||
// Update hook status with structured error info (only if not already aborted)
|
||||
if (!this.taskState.abort && hookMessageTs !== undefined) {
|
||||
const clineMessages = this.messageStateHandler.getClineMessages()
|
||||
const hookMessageIndex = clineMessages.findIndex((m) => m.ts === hookMessageTs)
|
||||
if (hookMessageIndex !== -1) {
|
||||
const failedMetadata = {
|
||||
hookName: "TaskCancel",
|
||||
status: errorInfo?.type === "cancellation" ? "cancelled" : "failed",
|
||||
exitCode: errorInfo?.exitCode ?? 1,
|
||||
...(errorInfo && {
|
||||
error: {
|
||||
type: errorInfo.type,
|
||||
message: errorInfo.message,
|
||||
details: errorInfo.details,
|
||||
scriptPath: errorInfo.scriptPath,
|
||||
},
|
||||
}),
|
||||
}
|
||||
await this.messageStateHandler.updateClineMessage(hookMessageIndex, {
|
||||
text: JSON.stringify(failedMetadata),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TaskCancel hook failure is non-fatal (cancellation proceeds)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1677,6 +2090,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> {
|
||||
if (!this.taskState.activeHookExecution) {
|
||||
return false
|
||||
}
|
||||
|
||||
const { hookName, toolName, messageTs, abortController } = this.taskState.activeHookExecution
|
||||
|
||||
try {
|
||||
// Signal cancellation to abort the hook process
|
||||
abortController.abort()
|
||||
|
||||
// Clear active hook execution state
|
||||
this.taskState.activeHookExecution = undefined
|
||||
|
||||
// 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 true
|
||||
} catch (error) {
|
||||
Logger.error("Failed to cancel hook execution", error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrates the disableBrowserTool setting from VSCode configuration to browserSettings
|
||||
*/
|
||||
|
||||
@@ -139,4 +139,16 @@ export class MessageStateHandler {
|
||||
// Save changes and update history
|
||||
await this.saveClineMessagesAndUpdateHistory()
|
||||
}
|
||||
|
||||
async deleteClineMessage(index: number): Promise<void> {
|
||||
if (index < 0 || index >= this.clineMessages.length) {
|
||||
throw new Error(`Invalid message index: ${index}`)
|
||||
}
|
||||
|
||||
// Remove the message at the specified index
|
||||
this.clineMessages.splice(index, 1)
|
||||
|
||||
// Save changes and update history
|
||||
await this.saveClineMessagesAndUpdateHistory()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,6 +53,17 @@ https://github.com/microsoft/vscode-webview-ui-toolkit-samples/tree/main/framewo
|
||||
export async function activate(context: vscode.ExtensionContext) {
|
||||
setupHostProvider(context)
|
||||
|
||||
// Initialize hook discovery cache for performance optimization
|
||||
const { HookDiscoveryCache } = await import("./core/hooks/HookDiscoveryCache")
|
||||
HookDiscoveryCache.getInstance().initialize(context, (dir: string) => {
|
||||
try {
|
||||
const pattern = new vscode.RelativePattern(dir, "*")
|
||||
return vscode.workspace.createFileSystemWatcher(pattern)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
})
|
||||
|
||||
const webview = (await initialize(context)) as VscodeWebviewProvider
|
||||
|
||||
Logger.log("Cline extension activated")
|
||||
@@ -446,11 +457,21 @@ async function getBinaryLocation(name: string): Promise<string> {
|
||||
|
||||
// This method is called when your extension is deactivated
|
||||
export async function deactivate() {
|
||||
Logger.log("Cline extension deactivating, cleaning up resources...")
|
||||
|
||||
tearDown()
|
||||
|
||||
// Clean up test mode
|
||||
cleanupTestMode()
|
||||
|
||||
// Kill any running hook processes to prevent zombies
|
||||
const { HookProcessRegistry } = await import("./core/hooks/HookProcessRegistry")
|
||||
await HookProcessRegistry.terminateAll()
|
||||
|
||||
// Clean up hook discovery cache
|
||||
const { HookDiscoveryCache } = await import("./core/hooks/HookDiscoveryCache")
|
||||
HookDiscoveryCache.getInstance().dispose()
|
||||
|
||||
Logger.log("Cline extension deactivated")
|
||||
}
|
||||
|
||||
|
||||
@@ -88,7 +88,7 @@ export class FeatureFlagsService {
|
||||
}
|
||||
|
||||
public getHooksEnabled(): boolean {
|
||||
return this.getBooleanFlagEnabled(FeatureFlag.HOOKS, false)
|
||||
return true //this.getBooleanFlagEnabled(FeatureFlag.HOOKS, false)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -167,6 +167,8 @@ export type ClineSay =
|
||||
| "load_mcp_documentation"
|
||||
| "info" // Added for general informational messages like retry status
|
||||
| "task_progress"
|
||||
| "hook" // Hook execution indicator
|
||||
| "hook_output" // Hook streaming output
|
||||
|
||||
export interface ClineSayTool {
|
||||
tool:
|
||||
@@ -187,6 +189,35 @@ export interface ClineSayTool {
|
||||
operationIsLocatedInWorkspace?: boolean
|
||||
}
|
||||
|
||||
export interface ClineSayHook {
|
||||
hookName: string // Name of the hook (e.g., "PreToolUse", "PostToolUse")
|
||||
toolName?: string // Tool name if applicable (for PreToolUse/PostToolUse)
|
||||
status: "running" | "completed" | "failed" | "cancelled" // Execution status
|
||||
exitCode?: number // Exit code when completed
|
||||
hasJsonResponse?: boolean // Whether a JSON response was parsed
|
||||
shouldContinue?: boolean // Whether hook allowed tool execution to proceed (false = blocked)
|
||||
// Pending tool information (only present during PreToolUse "running" status)
|
||||
pendingToolInfo?: {
|
||||
tool: string // Tool name (e.g., "write_to_file", "execute_command")
|
||||
path?: string // File path for file operations
|
||||
command?: string // Command for execute_command
|
||||
content?: string // Content preview (first 200 chars)
|
||||
diff?: string // Diff preview (first 200 chars)
|
||||
regex?: string // Regex pattern for search_files
|
||||
url?: string // URL for web_fetch or browser_action
|
||||
mcpTool?: string // MCP tool name
|
||||
mcpServer?: string // MCP server name
|
||||
resourceUri?: string // MCP resource URI
|
||||
}
|
||||
// Structured error information (only present when status is "failed")
|
||||
error?: {
|
||||
type: "timeout" | "validation" | "execution" | "cancellation" // Type of error
|
||||
message: string // User-friendly error message
|
||||
details?: string // Technical details for expansion
|
||||
scriptPath?: string // Path to the hook script
|
||||
}
|
||||
}
|
||||
|
||||
// must keep in sync with system prompt
|
||||
export const browserActions = ["launch", "click", "type", "scroll_down", "scroll_up", "close"] as const
|
||||
export type BrowserAction = (typeof browserActions)[number]
|
||||
|
||||
@@ -0,0 +1,329 @@
|
||||
import { ClineMessage } from "./ExtensionMessage"
|
||||
|
||||
/**
|
||||
* Hook metadata extracted from hook message text.
|
||||
* Mirrors the ClineSayHook interface but represents parsed data.
|
||||
*/
|
||||
interface HookMetadata {
|
||||
hookName: string // e.g., "PreToolUse", "PostToolUse"
|
||||
toolName?: string
|
||||
status?: "running" | "completed" | "failed" | "cancelled"
|
||||
exitCode?: number
|
||||
hasJsonResponse?: boolean
|
||||
shouldContinue?: boolean
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// PART 1: TYPE GUARDS & UTILITIES
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Type guard to check if a message is a tool or command.
|
||||
*/
|
||||
function isToolOrCommandMessage(msg: ClineMessage): boolean {
|
||||
return msg.ask === "tool" || msg.say === "tool" || msg.ask === "command" || msg.say === "command"
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely parses hook metadata from a hook message.
|
||||
* Returns null if parsing fails or message is not a hook.
|
||||
*/
|
||||
function parseHookMetadata(hookMessage: ClineMessage): HookMetadata | null {
|
||||
if (hookMessage.say !== "hook" || !hookMessage.text) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const outputIndex = hookMessage.text.indexOf(HOOK_OUTPUT_STRING)
|
||||
const metadataStr = outputIndex !== -1 ? hookMessage.text.slice(0, outputIndex).trim() : hookMessage.text.trim()
|
||||
|
||||
return JSON.parse(metadataStr) as HookMetadata
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// PART 2: FILTERING & COMBINING
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Filters out partial tool/command messages while preserving all other types.
|
||||
* Reasoning messages are always kept, even if marked partial.
|
||||
*
|
||||
* This prevents duplicate messages during React render cycles where partial
|
||||
* messages are removed and replaced with complete versions.
|
||||
*/
|
||||
function filterPartialToolMessages(messages: ClineMessage[]): ClineMessage[] {
|
||||
return messages.filter((msg) => {
|
||||
// Always keep reasoning messages
|
||||
if (msg.say === "reasoning") {
|
||||
return true
|
||||
}
|
||||
|
||||
// Filter out partial tool/command messages only
|
||||
const isToolOrCommand = isToolOrCommandMessage(msg)
|
||||
return !(isToolOrCommand && msg.partial === true)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Combines a single hook message with all subsequent hook_output messages.
|
||||
*
|
||||
* @param hookMessage The hook message to start combining from
|
||||
* @param startIndex The index of the hook message in the messages array
|
||||
* @param messages The full messages array
|
||||
* @returns Object containing the combined message and the next index to process
|
||||
*/
|
||||
function combineHookWithOutputs(
|
||||
hookMessage: ClineMessage,
|
||||
startIndex: number,
|
||||
messages: ClineMessage[],
|
||||
): { combined: ClineMessage; nextIndex: number } {
|
||||
let combinedText = hookMessage.text || ""
|
||||
let hasOutput = false
|
||||
let i = startIndex + 1
|
||||
|
||||
// Collect all hook_output messages until we hit another hook or end of array
|
||||
while (i < messages.length && messages[i].say !== "hook") {
|
||||
if (messages[i].say === "hook_output") {
|
||||
// Add marker before first output
|
||||
if (!hasOutput) {
|
||||
combinedText += `\n${HOOK_OUTPUT_STRING}`
|
||||
hasOutput = true
|
||||
}
|
||||
|
||||
// Append output if not empty
|
||||
const output = messages[i].text || ""
|
||||
if (output.length > 0) {
|
||||
combinedText += "\n" + output
|
||||
}
|
||||
}
|
||||
i++
|
||||
}
|
||||
|
||||
return {
|
||||
combined: { ...hookMessage, text: combinedText },
|
||||
nextIndex: i,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Combines all hooks with their outputs and removes hook_output messages.
|
||||
*
|
||||
* This is a two-pass process:
|
||||
* 1. Scan through and combine each hook with its outputs
|
||||
* 2. Build final array without hook_output messages, using combined hooks
|
||||
*/
|
||||
function combineAllHooks(messages: ClineMessage[]): ClineMessage[] {
|
||||
// Pass 1: Build map of combined hooks by timestamp
|
||||
const combinedHooksByTs = new Map<number, ClineMessage>()
|
||||
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
if (messages[i].say === "hook") {
|
||||
const { combined, nextIndex } = combineHookWithOutputs(messages[i], i, messages)
|
||||
combinedHooksByTs.set(combined.ts, combined)
|
||||
i = nextIndex - 1 // Adjust for loop increment
|
||||
}
|
||||
}
|
||||
|
||||
// Pass 2: Build result array
|
||||
const result: ClineMessage[] = []
|
||||
|
||||
for (const msg of messages) {
|
||||
if (msg.say === "hook_output") {
|
||||
} else if (msg.say === "hook") {
|
||||
// Use combined version
|
||||
result.push(combinedHooksByTs.get(msg.ts) || msg)
|
||||
} else {
|
||||
// Keep all other messages as-is
|
||||
result.push(msg)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// PART 3: PRETOOLUSE REORDERING
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Finds the timestamp of the next tool/command after a given index.
|
||||
*
|
||||
* Searches in the original messages array (not filtered) to catch tools
|
||||
* that might still be partial. This ensures PreToolUse hooks are matched
|
||||
* immediately even if their tool hasn't fully arrived yet.
|
||||
*
|
||||
* @param hookIndex The starting index to search from
|
||||
* @param messages The original messages array (may include partial tools)
|
||||
* @returns The timestamp of the next tool, or null if none found
|
||||
*/
|
||||
function findNextToolTimestamp(hookIndex: number, messages: ClineMessage[]): number | null {
|
||||
for (let i = hookIndex + 1; i < messages.length; i++) {
|
||||
if (isToolOrCommandMessage(messages[i])) {
|
||||
return messages[i].ts
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a map of tool timestamps to their PreToolUse hooks.
|
||||
*
|
||||
* This map indicates which hooks should be moved to appear before which tools.
|
||||
* Only PreToolUse hooks are included; PostToolUse hooks stay in their original position.
|
||||
*
|
||||
* @param processedMessages Messages after filtering and combining
|
||||
* @param originalMessages Original messages array (used to find tools)
|
||||
* @returns Map of tool timestamp -> array of PreToolUse hooks for that tool
|
||||
*/
|
||||
function buildPreToolUseMap(processedMessages: ClineMessage[], originalMessages: ClineMessage[]): Map<number, ClineMessage[]> {
|
||||
const map = new Map<number, ClineMessage[]>()
|
||||
|
||||
// Build timestamp-to-index map once to avoid O(n) findIndex calls
|
||||
const timestampToIndex = new Map<number, number>()
|
||||
for (let i = 0; i < originalMessages.length; i++) {
|
||||
timestampToIndex.set(originalMessages[i].ts, i)
|
||||
}
|
||||
|
||||
for (const msg of processedMessages) {
|
||||
// Only process PreToolUse hooks
|
||||
const metadata = parseHookMetadata(msg)
|
||||
if (metadata?.hookName !== "PreToolUse") {
|
||||
continue
|
||||
}
|
||||
|
||||
// Find this hook's position in the original array using the index map
|
||||
const hookIndexInOriginal = timestampToIndex.get(msg.ts)
|
||||
if (hookIndexInOriginal === undefined) {
|
||||
continue // Shouldn't happen, but be safe
|
||||
}
|
||||
|
||||
// Find the next tool after this hook in the original array
|
||||
const toolTimestamp = findNextToolTimestamp(hookIndexInOriginal, originalMessages)
|
||||
if (toolTimestamp === null) {
|
||||
// No tool found - hook will stay in original position
|
||||
continue
|
||||
}
|
||||
|
||||
// Map this hook to appear before that tool
|
||||
if (!map.has(toolTimestamp)) {
|
||||
map.set(toolTimestamp, [])
|
||||
}
|
||||
map.get(toolTimestamp)!.push(msg)
|
||||
}
|
||||
|
||||
return map
|
||||
}
|
||||
|
||||
/**
|
||||
* Reorders messages so PreToolUse hooks appear before their associated tools.
|
||||
*
|
||||
* Algorithm:
|
||||
* 1. When we encounter a tool, check if it has PreToolUse hooks mapped to it
|
||||
* 2. If yes, insert those hooks BEFORE the tool
|
||||
* 3. Track which hooks and tools we've already added to avoid duplicates
|
||||
* 4. For PreToolUse hooks encountered in their original position:
|
||||
* - If their tool is available and we'll process them before it, skip them
|
||||
* - Otherwise, add them in their current position (tool not available yet)
|
||||
*
|
||||
* @param messages Messages after filtering and combining
|
||||
* @param preToolUseMap Map of tool timestamp -> PreToolUse hooks
|
||||
* @returns Reordered messages array
|
||||
*/
|
||||
function reorderWithPreToolUseHooks(messages: ClineMessage[], preToolUseMap: Map<number, ClineMessage[]>): ClineMessage[] {
|
||||
const result: ClineMessage[] = []
|
||||
const addedHooks = new Set<number>()
|
||||
const addedTools = new Set<number>()
|
||||
|
||||
// Build set of available tool timestamps for quick lookup
|
||||
const availableTools = new Set<number>()
|
||||
for (const msg of messages) {
|
||||
if (isToolOrCommandMessage(msg)) {
|
||||
availableTools.add(msg.ts)
|
||||
}
|
||||
}
|
||||
|
||||
for (const msg of messages) {
|
||||
// Case 1: This is a tool with PreToolUse hooks
|
||||
if (isToolOrCommandMessage(msg) && preToolUseMap.has(msg.ts)) {
|
||||
const hooksForTool = preToolUseMap.get(msg.ts)!
|
||||
|
||||
// Insert hooks that haven't been added yet
|
||||
const newHooks = hooksForTool.filter((h) => !addedHooks.has(h.ts))
|
||||
result.push(...newHooks)
|
||||
newHooks.forEach((h) => addedHooks.add(h.ts))
|
||||
|
||||
// Add the tool
|
||||
result.push(msg)
|
||||
addedTools.add(msg.ts)
|
||||
continue
|
||||
}
|
||||
|
||||
// Case 2: This tool was already added with its hooks
|
||||
if (addedTools.has(msg.ts)) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Case 3: This is a PreToolUse hook in its original position
|
||||
const metadata = parseHookMetadata(msg)
|
||||
if (metadata?.hookName === "PreToolUse") {
|
||||
// Find which tool (if any) this hook is mapped to
|
||||
let mappedToolTs: number | undefined
|
||||
for (const [toolTs, hooks] of preToolUseMap) {
|
||||
if (hooks.some((h) => h.ts === msg.ts)) {
|
||||
mappedToolTs = toolTs
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// If this hook's tool is available and we'll insert it before that tool, skip it here
|
||||
if (mappedToolTs !== undefined && availableTools.has(mappedToolTs)) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Otherwise, keep hook in original position (tool not available yet)
|
||||
}
|
||||
|
||||
// Case 4: All other messages (text, PostToolUse hooks, reasoning, etc.)
|
||||
result.push(msg)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// MAIN FUNCTION
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Combines sequences of hook and hook_output messages, and reorders
|
||||
* PreToolUse hooks to appear before their associated tool messages.
|
||||
*
|
||||
* Process:
|
||||
* 1. Filter out partial tool/command messages (React render cycle cleanup)
|
||||
* 2. Combine hooks with their hook_output messages
|
||||
* 3. Build mapping of tools to their PreToolUse hooks
|
||||
* 4. Reorder so PreToolUse hooks appear before their tools
|
||||
*
|
||||
* @param messages Array of ClineMessage objects to process
|
||||
* @returns New array with hooks combined and PreToolUse hooks reordered
|
||||
*/
|
||||
export function combineHookSequences(messages: ClineMessage[]): ClineMessage[] {
|
||||
// Phase 1: Filter out partial tool/command messages
|
||||
const filtered = filterPartialToolMessages(messages)
|
||||
|
||||
// Phase 2: Combine hooks with their outputs
|
||||
const combined = combineAllHooks(filtered)
|
||||
|
||||
// Phase 3: Build PreToolUse hook mapping
|
||||
const preToolUseMap = buildPreToolUseMap(combined, messages)
|
||||
|
||||
// Phase 4: Reorder to place PreToolUse hooks before tools
|
||||
const reordered = reorderWithPreToolUseHooks(combined, preToolUseMap)
|
||||
|
||||
return reordered
|
||||
}
|
||||
|
||||
export const HOOK_OUTPUT_STRING = "__HOOK_OUTPUT__"
|
||||
@@ -102,6 +102,8 @@ function convertClineSayToProtoEnum(say: AppClineSay | undefined): ClineSay | un
|
||||
info: ClineSay.INFO,
|
||||
task_progress: ClineSay.TASK_PROGRESS,
|
||||
error_retry: ClineSay.ERROR_RETRY,
|
||||
hook: ClineSay.INFO, // Map hook messages to INFO enum for proto compatibility
|
||||
hook_output: ClineSay.COMMAND_OUTPUT_SAY, // Map hook_output to COMMAND_OUTPUT_SAY for proto compatibility
|
||||
}
|
||||
|
||||
const result = mapping[say]
|
||||
|
||||
@@ -36,6 +36,7 @@ import { CheckpointControls } from "../common/CheckpointControls"
|
||||
import CodeAccordian, { cleanPathPrefix } from "../common/CodeAccordian"
|
||||
import { ErrorBlockTitle } from "./ErrorBlockTitle"
|
||||
import ErrorRow from "./ErrorRow"
|
||||
import HookMessage from "./HookMessage"
|
||||
import NewTaskPreview from "./NewTaskPreview"
|
||||
import QuoteButton from "./QuoteButton"
|
||||
import ReportBugPreview from "./ReportBugPreview"
|
||||
@@ -54,6 +55,22 @@ const ChatRowContainer = styled.div`
|
||||
&:hover ${CheckpointControls} {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Fade-in animation for hook messages being inserted */
|
||||
&.hook-message-animate {
|
||||
animation: hookFadeSlideIn 0.6s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
@keyframes hookFadeSlideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-12px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
interface ChatRowProps {
|
||||
@@ -1602,6 +1619,11 @@ export const ChatRowContent = memo(
|
||||
</div>
|
||||
)
|
||||
}
|
||||
case "hook":
|
||||
return <HookMessage CommandOutput={CommandOutput} message={message} />
|
||||
case "hook_output":
|
||||
// hook_output messages are combined with hook messages, so we don't render them separately
|
||||
return null
|
||||
case "shell_integration_warning_with_suggestion":
|
||||
const isBackgroundModeEnabled = vscodeTerminalExecutionMode === "backgroundExec"
|
||||
return (
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { findLast } from "@shared/array"
|
||||
import { combineApiRequests } from "@shared/combineApiRequests"
|
||||
import { combineCommandSequences } from "@shared/combineCommandSequences"
|
||||
import { combineHookSequences } from "@shared/combineHookSequences"
|
||||
import type { ClineApiReqInfo, ClineMessage } from "@shared/ExtensionMessage"
|
||||
import { getApiMetrics } from "@shared/getApiMetrics"
|
||||
import { BooleanRequest, StringRequest } from "@shared/proto/cline/common"
|
||||
@@ -57,7 +58,10 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
|
||||
|
||||
//const task = messages.length > 0 ? (messages[0].say === "task" ? messages[0] : undefined) : undefined) : undefined
|
||||
const task = useMemo(() => messages.at(0), [messages]) // leaving this less safe version here since if the first message is not a task, then the extension is in a bad state and needs to be debugged (see Cline.abort)
|
||||
const modifiedMessages = useMemo(() => combineApiRequests(combineCommandSequences(messages.slice(1))), [messages])
|
||||
const modifiedMessages = useMemo(
|
||||
() => combineApiRequests(combineCommandSequences(combineHookSequences(messages.slice(1)))),
|
||||
[messages],
|
||||
)
|
||||
// has to be after api_req_finished are all reduced into api_req_started messages
|
||||
const apiMetrics = useMemo(() => getApiMetrics(modifiedMessages), [modifiedMessages])
|
||||
|
||||
|
||||
@@ -0,0 +1,291 @@
|
||||
import { ClineMessage } from "@shared/ExtensionMessage"
|
||||
import { memo, useMemo, useState } from "react"
|
||||
import { TaskServiceClient } from "@/services/grpc-client"
|
||||
import { CHAT_ROW_EXPANDED_BG_COLOR } from "../common/CodeBlock"
|
||||
import { HOOK_OUTPUT_STRING } from "./constants"
|
||||
import PendingToolInfo from "./PendingToolInfo"
|
||||
|
||||
const normalColor = "var(--vscode-foreground)"
|
||||
const errorColor = "var(--vscode-errorForeground)"
|
||||
const successColor = "var(--vscode-charts-green)"
|
||||
const cancelledColor = "var(--vscode-descriptionForeground)"
|
||||
|
||||
interface HookMessageProps {
|
||||
message: ClineMessage
|
||||
// CommandOutput component - we'll import and use it here
|
||||
CommandOutput: React.ComponentType<{
|
||||
output: string
|
||||
isOutputFullyExpanded: boolean
|
||||
onToggle: () => void
|
||||
isContainerExpanded: boolean
|
||||
}>
|
||||
}
|
||||
|
||||
interface HookMetadata {
|
||||
hookName: string
|
||||
toolName?: string
|
||||
status: string
|
||||
exitCode?: number
|
||||
hasJsonResponse?: boolean
|
||||
shouldContinue?: boolean
|
||||
pendingToolInfo?: {
|
||||
tool: string
|
||||
path?: string
|
||||
command?: string
|
||||
content?: string
|
||||
diff?: string
|
||||
regex?: string
|
||||
url?: string
|
||||
mcpTool?: string
|
||||
mcpServer?: string
|
||||
resourceUri?: string
|
||||
}
|
||||
error?: {
|
||||
type: "timeout" | "validation" | "execution" | "cancellation"
|
||||
message: string
|
||||
details?: string
|
||||
scriptPath?: string
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays a hook execution message with status, pending tool info, and output.
|
||||
*
|
||||
* Smart expansion defaults:
|
||||
* - Failed hooks: Expanded by default (show error details)
|
||||
* - Cancelled hooks: Expanded by default (show what happened)
|
||||
* - Successful hooks: Collapsed by default (minimize clutter)
|
||||
* - Running hooks: Always shows pending tool info
|
||||
*/
|
||||
const HookMessage = memo(({ message, CommandOutput }: HookMessageProps) => {
|
||||
// Parse hook metadata and output
|
||||
const { metadata, output } = useMemo(() => {
|
||||
const splitMessage = (text: string) => {
|
||||
const outputIndex = text.indexOf(HOOK_OUTPUT_STRING)
|
||||
if (outputIndex === -1) {
|
||||
return { metadata: text, output: "" }
|
||||
}
|
||||
return {
|
||||
metadata: text.slice(0, outputIndex).trim(),
|
||||
output: text
|
||||
.slice(outputIndex + HOOK_OUTPUT_STRING.length)
|
||||
.trim()
|
||||
.split("")
|
||||
.map((char) => {
|
||||
switch (char) {
|
||||
case "\t":
|
||||
return "→ "
|
||||
case "\b":
|
||||
return "⌫"
|
||||
case "\f":
|
||||
return "⏏"
|
||||
case "\v":
|
||||
return "⇳"
|
||||
default:
|
||||
return char
|
||||
}
|
||||
})
|
||||
.join(""),
|
||||
}
|
||||
}
|
||||
|
||||
const { metadata: metadataStr, output } = splitMessage(message.text || "")
|
||||
|
||||
let hookMetadata: HookMetadata
|
||||
try {
|
||||
hookMetadata = JSON.parse(metadataStr)
|
||||
} catch {
|
||||
hookMetadata = { hookName: "Unknown", status: "unknown" }
|
||||
}
|
||||
|
||||
return { metadata: hookMetadata, output }
|
||||
}, [message.text])
|
||||
|
||||
// Smart defaults:
|
||||
// - Expand if failed/cancelled (show error details)
|
||||
// - Collapse if successful (minimize clutter)
|
||||
// - Show hook output if present
|
||||
const shouldExpandByDefault = metadata.status === "failed" || metadata.status === "cancelled"
|
||||
const [isHookOutputExpanded, setIsHookOutputExpanded] = useState(shouldExpandByDefault)
|
||||
|
||||
const isRunning = metadata.status === "running"
|
||||
const isCompleted = metadata.status === "completed"
|
||||
const isFailed = metadata.status === "failed"
|
||||
const isCancelled = metadata.status === "cancelled"
|
||||
|
||||
const headerStyle: React.CSSProperties = {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "10px",
|
||||
marginBottom: "12px",
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div style={headerStyle}>
|
||||
<span
|
||||
className="codicon codicon-symbol-event"
|
||||
style={{
|
||||
color: normalColor,
|
||||
marginBottom: "-1.5px",
|
||||
}}></span>
|
||||
<span style={{ color: normalColor, fontWeight: "bold" }}>Hook:</span>
|
||||
<span style={{ color: normalColor }}>{metadata.hookName}</span>
|
||||
{metadata.toolName && (
|
||||
<span style={{ color: "var(--vscode-descriptionForeground)", fontSize: "0.9em" }}>({metadata.toolName})</span>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
borderRadius: 6,
|
||||
border: "1px solid var(--vscode-editorGroup-border)",
|
||||
overflow: "hidden",
|
||||
backgroundColor: CHAT_ROW_EXPANDED_BG_COLOR,
|
||||
transition: "all 0.3s ease-in-out",
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
padding: "8px 10px",
|
||||
backgroundColor: CHAT_ROW_EXPANDED_BG_COLOR,
|
||||
borderBottom:
|
||||
metadata.pendingToolInfo || output.length > 0 ? "1px solid var(--vscode-editorGroup-border)" : "none",
|
||||
borderTopLeftRadius: "6px",
|
||||
borderTopRightRadius: "6px",
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "8px",
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
width: "8px",
|
||||
height: "8px",
|
||||
borderRadius: "50%",
|
||||
backgroundColor: isRunning ? successColor : isFailed || isCancelled ? errorColor : successColor,
|
||||
animation: isRunning ? "pulse 2s ease-in-out infinite" : "none",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
style={{
|
||||
color: isRunning ? successColor : isFailed || isCancelled ? errorColor : successColor,
|
||||
fontWeight: 500,
|
||||
fontSize: "13px",
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
{isRunning
|
||||
? "Running"
|
||||
: isFailed
|
||||
? "Failed"
|
||||
: isCancelled
|
||||
? "Cancelled"
|
||||
: isCompleted
|
||||
? "Completed"
|
||||
: "Unknown"}
|
||||
</span>
|
||||
{metadata.exitCode !== undefined && metadata.exitCode !== 0 && (
|
||||
<span
|
||||
style={{
|
||||
color: "var(--vscode-descriptionForeground)",
|
||||
fontSize: "12px",
|
||||
}}>
|
||||
(exit: {metadata.exitCode})
|
||||
</span>
|
||||
)}
|
||||
{metadata.shouldContinue === false && (
|
||||
<span
|
||||
style={{
|
||||
color: "var(--vscode-descriptionForeground)",
|
||||
fontSize: "12px",
|
||||
}}>
|
||||
(shouldContinue: false)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{isRunning && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
TaskServiceClient.cancelHookExecution({}).catch((err) =>
|
||||
console.error("Failed to cancel hook:", err),
|
||||
)
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = "var(--vscode-button-secondaryHoverBackground)"
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = "var(--vscode-button-secondaryBackground)"
|
||||
}}
|
||||
style={{
|
||||
background: "var(--vscode-button-secondaryBackground)",
|
||||
color: "var(--vscode-button-secondaryForeground)",
|
||||
border: "none",
|
||||
borderRadius: "2px",
|
||||
padding: "4px 10px",
|
||||
fontSize: "12px",
|
||||
cursor: "pointer",
|
||||
fontFamily: "inherit",
|
||||
}}>
|
||||
cancel
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{/* Show pending tool info when hook is running */}
|
||||
{isRunning && metadata.pendingToolInfo && <PendingToolInfo pendingToolInfo={metadata.pendingToolInfo} />}
|
||||
|
||||
{/* Show concise error message for specific error types */}
|
||||
{isFailed && metadata.error && metadata.error.type === "timeout" && (
|
||||
<div
|
||||
style={{
|
||||
padding: "12px",
|
||||
borderBottom: output.length > 0 ? "1px solid var(--vscode-editorGroup-border)" : "none",
|
||||
fontSize: "13px",
|
||||
color: "var(--vscode-descriptionForeground)",
|
||||
}}>
|
||||
Took longer than 30 seconds. Check for infinite loops or add timeouts to network requests.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isFailed && metadata.error && metadata.error.type === "validation" && (
|
||||
<div
|
||||
style={{
|
||||
padding: "12px",
|
||||
borderBottom: output.length > 0 ? "1px solid var(--vscode-editorGroup-border)" : "none",
|
||||
fontSize: "13px",
|
||||
color: "var(--vscode-descriptionForeground)",
|
||||
}}>
|
||||
Hook must return JSON with{" "}
|
||||
<code
|
||||
style={{
|
||||
backgroundColor: "var(--vscode-textCodeBlock-background)",
|
||||
padding: "2px 4px",
|
||||
borderRadius: "3px",
|
||||
}}>
|
||||
shouldContinue
|
||||
</code>{" "}
|
||||
boolean field.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Show hook output if present */}
|
||||
{output.length > 0 && (
|
||||
<CommandOutput
|
||||
isContainerExpanded={true}
|
||||
isOutputFullyExpanded={isHookOutputExpanded}
|
||||
onToggle={() => setIsHookOutputExpanded(!isHookOutputExpanded)}
|
||||
output={output}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
})
|
||||
|
||||
export default HookMessage
|
||||
@@ -0,0 +1,73 @@
|
||||
import { memo } from "react"
|
||||
import { CODE_BLOCK_BG_COLOR } from "../common/CodeBlock"
|
||||
|
||||
interface PendingToolInfoProps {
|
||||
pendingToolInfo: {
|
||||
tool: string
|
||||
path?: string
|
||||
command?: string
|
||||
content?: string
|
||||
diff?: string
|
||||
regex?: string
|
||||
url?: string
|
||||
mcpTool?: string
|
||||
mcpServer?: string
|
||||
resourceUri?: string
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays information about a tool that is pending execution while a hook runs.
|
||||
* This component shows a preview of what the tool will do, helping users understand
|
||||
* what the hook is evaluating.
|
||||
*/
|
||||
const PendingToolInfo = memo(({ pendingToolInfo }: PendingToolInfoProps) => {
|
||||
const renderField = (label: string, value: string, isPreview = false) => (
|
||||
<div style={{ marginBottom: 6 }}>
|
||||
<span style={{ fontWeight: 500 }}>{label}:</span>
|
||||
{isPreview ? (
|
||||
<div
|
||||
style={{
|
||||
marginTop: 4,
|
||||
padding: 6,
|
||||
backgroundColor: CODE_BLOCK_BG_COLOR,
|
||||
borderRadius: 3,
|
||||
fontFamily: "monospace",
|
||||
fontSize: "0.85em",
|
||||
whiteSpace: "pre-wrap",
|
||||
wordBreak: "break-word",
|
||||
}}>
|
||||
{value}
|
||||
{value.length >= 200 && "..."}
|
||||
</div>
|
||||
) : (
|
||||
<span className="ph-no-capture" style={{ marginLeft: 6, fontFamily: "monospace", fontSize: "0.9em" }}>
|
||||
{value}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
padding: "12px",
|
||||
backgroundColor: "var(--vscode-editor-background)",
|
||||
borderBottom: "1px solid var(--vscode-editorGroup-border)",
|
||||
opacity: 0.8,
|
||||
}}>
|
||||
{renderField("Tool", pendingToolInfo.tool)}
|
||||
{pendingToolInfo.path && renderField("Path", pendingToolInfo.path)}
|
||||
{pendingToolInfo.command && renderField("Command", pendingToolInfo.command)}
|
||||
{pendingToolInfo.content && renderField("Content Preview", pendingToolInfo.content, true)}
|
||||
{pendingToolInfo.diff && renderField("Diff Preview", pendingToolInfo.diff, true)}
|
||||
{pendingToolInfo.regex && renderField("Regex", pendingToolInfo.regex)}
|
||||
{pendingToolInfo.url && renderField("URL", pendingToolInfo.url)}
|
||||
{pendingToolInfo.mcpServer && renderField("MCP Server", pendingToolInfo.mcpServer)}
|
||||
{pendingToolInfo.mcpTool && renderField("MCP Tool", pendingToolInfo.mcpTool)}
|
||||
{pendingToolInfo.resourceUri && renderField("Resource URI", pendingToolInfo.resourceUri)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
export default PendingToolInfo
|
||||
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Shared constants for chat components
|
||||
*/
|
||||
|
||||
/**
|
||||
* Marker string used to separate hook metadata from hook output in hook messages.
|
||||
* When a hook executes, its metadata (status, tool info, etc.) is followed by this
|
||||
* marker, which is then followed by the actual output from the hook script.
|
||||
*/
|
||||
export const HOOK_OUTPUT_STRING = "__HOOK_OUTPUT__"
|
||||
@@ -1,5 +1,6 @@
|
||||
import { combineApiRequests } from "@shared/combineApiRequests"
|
||||
import { combineCommandSequences } from "@shared/combineCommandSequences"
|
||||
import { combineHookSequences } from "@shared/combineHookSequences"
|
||||
import { ClineMessage } from "@shared/ExtensionMessage"
|
||||
import React, { useCallback, useEffect, useMemo, useRef } from "react"
|
||||
import { Virtuoso } from "react-virtuoso"
|
||||
@@ -27,7 +28,7 @@ const TaskTimeline: React.FC<TaskTimelineProps> = ({ messages, onBlockClick }) =
|
||||
return { taskTimelinePropsMessages: [], messageIndexMap: [] }
|
||||
}
|
||||
|
||||
const processed = combineApiRequests(combineCommandSequences(messages.slice(1)))
|
||||
const processed = combineApiRequests(combineCommandSequences(combineHookSequences(messages.slice(1))))
|
||||
const indexMap: number[] = []
|
||||
|
||||
const filtered = processed.filter((msg, _processedIndex) => {
|
||||
|
||||
Reference in New Issue
Block a user