Compare commits

...

12 Commits

Author SHA1 Message Date
Eve Killaby 99234a0d8d Changes from usability feedback 2025-10-21 15:10:10 -07:00
Eve Killaby b7c7502985 Simplify and improve console.log() output from hooks 2025-10-21 15:10:10 -07:00
Eve Killaby de467a5668 Improve TaskCancel hook UI 2025-10-21 15:10:10 -07:00
Eve Killaby 8371c0817d Improvements to hooks error messaging 2025-10-21 15:10:10 -07:00
Eve Killaby ce639b6b53 Un-hard-code FeatureFlagsServive::getHooksEnabled() 2025-10-21 15:10:10 -07:00
Eve Killaby 0b81c74b60 Hooks hardening and handling edge cases 2025-10-21 15:10:10 -07:00
Eve Killaby def8f84d91 Add missing docs 2025-10-21 15:10:10 -07:00
Eve Killaby 8d4cde1e79 Fix failing tests 2025-10-21 15:10:10 -07:00
Eve Killaby c371c72f1b Separate hooks UI into separate files 2025-10-21 15:10:10 -07:00
Eve Killaby f96ff00ba8 Hook discovery improvements 2025-10-21 15:10:10 -07:00
Eve Killaby 41389269da Hooks UI improvements 2025-10-21 15:10:10 -07:00
Eve Killaby 2c1aaeea83 feat(hooks): Initial implementation of hooks UI using background terminal UI 2025-10-21 15:10:09 -07:00
30 changed files with 3691 additions and 320 deletions
+321
View File
@@ -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
+2
View File
@@ -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
+11 -1
View File
@@ -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 })
}
+264
View File
@@ -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
}
}
}
+120
View File
@@ -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
})
}
}
+350
View File
@@ -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
}
}
}
}
+63
View File
@@ -0,0 +1,63 @@
import { HookProcess } from "./HookProcess"
/**
* Global registry for tracking active hook processes.
*
* Purpose:
* - Prevents zombie processes by tracking all running hooks
* - Enables cleanup on extension deactivation
* - Provides visibility into active hook executions
*
* Usage:
* - HookProcess automatically registers/unregisters itself
* - Extension deactivation calls terminateAll()
* - Can query active count for monitoring/debugging
*/
export class HookProcessRegistry {
private static activeProcesses = new Set<HookProcess>()
/**
* Register a hook process as active.
* Called by HookProcess when execution starts.
*/
static register(process: HookProcess): void {
HookProcessRegistry.activeProcesses.add(process)
}
/**
* Unregister a hook process (completed or failed).
* Called by HookProcess when execution ends.
*/
static unregister(process: HookProcess): void {
HookProcessRegistry.activeProcesses.delete(process)
}
/**
* Terminate all active hook processes.
* Called during extension deactivation to prevent zombie processes.
*/
static async terminateAll(): Promise<void> {
const processes = Array.from(HookProcessRegistry.activeProcesses)
if (processes.length > 0) {
console.log(`[HookProcessRegistry] Terminating ${processes.length} active hook process(es)`)
await Promise.all(processes.map((p) => p.terminate()))
HookProcessRegistry.activeProcesses.clear()
}
}
/**
* Get the number of currently active hook processes.
* Useful for monitoring and debugging.
*/
static getActiveCount(): number {
return HookProcessRegistry.activeProcesses.size
}
/**
* Clear the registry (for testing only).
* @internal
*/
static resetForTesting(): void {
HookProcessRegistry.activeProcesses.clear()
}
}
+21 -12
View File
@@ -40,10 +40,19 @@ describe("Hook System", () => {
sandbox.stub(StateManager, "get").returns({
getGlobalStateKey: () => [{ path: tempDir }],
} as any)
// Reset hook discovery cache for clean test state
const { HookDiscoveryCache } = await import("../HookDiscoveryCache")
HookDiscoveryCache.resetForTesting()
})
afterEach(async () => {
sandbox.restore()
// Clean up hook discovery cache
const { HookDiscoveryCache } = await import("../HookDiscoveryCache")
HookDiscoveryCache.resetForTesting()
try {
await fs.rm(tempDir, { recursive: true, force: true })
} catch (error) {
@@ -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 () => {
+23 -14
View File
@@ -42,10 +42,19 @@ describe("TaskCancel Hook", () => {
} as any)
getEnv = () => ({ tempDir })
// Reset hook discovery cache for clean test state
const { HookDiscoveryCache } = await import("../HookDiscoveryCache")
HookDiscoveryCache.resetForTesting()
})
afterEach(async () => {
sandbox.restore()
// Clean up hook discovery cache
const { HookDiscoveryCache } = await import("../HookDiscoveryCache")
HookDiscoveryCache.resetForTesting()
try {
await fs.rm(tempDir, { recursive: true, force: true })
} catch (error) {
@@ -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()
})
})
+21 -15
View File
@@ -41,6 +41,12 @@ describe("TaskResume Hook", () => {
})
afterEach(async () => {
sandbox.restore()
// Clean up hook discovery cache
const { HookDiscoveryCache } = await import("../HookDiscoveryCache")
HookDiscoveryCache.resetForTesting()
sandbox.restore()
try {
await fs.rm(tempDir, { recursive: true, force: true })
@@ -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 () => {
+19 -14
View File
@@ -46,6 +46,11 @@ describe("TaskStart Hook", () => {
afterEach(async () => {
sandbox.restore()
// Clean up hook discovery cache
const { HookDiscoveryCache } = await import("../HookDiscoveryCache")
HookDiscoveryCache.resetForTesting()
try {
await fs.rm(tempDir, { recursive: true, force: true })
} catch (error) {
@@ -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
View File
@@ -1,4 +1,3 @@
import { spawn } from "node:child_process"
import fs from "fs/promises"
import path from "path"
import { version as clineVersion } from "../../../package.json"
@@ -17,6 +16,8 @@ import {
} from "../../shared/proto/cline/hooks"
import { getAllHooksDirs } from "../storage/disk"
import { StateManager } from "../storage/StateManager"
import { HookExecutionError } from "./HookError"
import { HookProcess } from "./HookProcess"
// Hook execution timeout (30 seconds)
const HOOK_EXECUTION_TIMEOUT_MS = 30000
@@ -24,6 +25,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)
+400
View File
@@ -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
+8
View File
@@ -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
View File
@@ -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
View File
@@ -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
*/
+12
View File
@@ -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()
}
}
+21
View File
@@ -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)
}
/**
+31
View File
@@ -167,6 +167,8 @@ export type ClineSay =
| "load_mcp_documentation"
| "info" // Added for general informational messages like retry status
| "task_progress"
| "hook" // Hook 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]
+329
View File
@@ -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 (
+5 -1
View File
@@ -1,6 +1,7 @@
import { findLast } from "@shared/array"
import { combineApiRequests } from "@shared/combineApiRequests"
import { combineCommandSequences } from "@shared/combineCommandSequences"
import { combineHookSequences } from "@shared/combineHookSequences"
import type { ClineApiReqInfo, ClineMessage } from "@shared/ExtensionMessage"
import { getApiMetrics } from "@shared/getApiMetrics"
import { BooleanRequest, StringRequest } from "@shared/proto/cline/common"
@@ -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) => {