Compare commits

...

12 Commits

11 changed files with 902 additions and 248 deletions
+16 -41
View File
@@ -20,7 +20,6 @@ import { UserInfo } from "@shared/UserInfo"
import { fileExistsAtPath } from "@utils/fs"
import axios from "axios"
import fs from "fs/promises"
import pWaitFor from "p-wait-for"
import * as path from "path"
import type { FolderLockWithRetryResult } from "src/core/locks/types"
import * as vscode from "vscode"
@@ -435,52 +434,19 @@ export class Controller {
try {
this.updateBackgroundCommandState(false)
// Task.abortTask() now handles everything:
// - Runs TaskCancel hook if needed
// - Presents resume button and waits for user
// - Runs TaskResume hook
// - Starts task execution in background
// No need for Controller to do any re-initialization or double-checking
try {
await this.task.abortTask()
} catch (error) {
console.error("Failed to abort task", error)
}
await pWaitFor(
() =>
this.task === undefined ||
this.task.taskState.isStreaming === false ||
this.task.taskState.didFinishAbortingStream ||
this.task.taskState.isWaitingForFirstChunk, // if only first chunk is processed, then there's no need to wait for graceful abort (closes edits, browser, etc)
{
timeout: 3_000,
},
).catch(() => {
console.error("Failed to abort task")
})
if (this.task) {
// 'abandoned' will prevent this cline instance from affecting future cline instance gui. this may happen if its hanging on a streaming request
this.task.taskState.abandoned = true
}
// Small delay to ensure state manager has persisted the history update
//await new Promise((resolve) => setTimeout(resolve, 100))
// NOW try to get history after abort has finished (hook may have saved messages)
let historyItem: HistoryItem | undefined
try {
const result = await this.getTaskWithId(this.task.taskId)
historyItem = result.historyItem
} catch (error) {
// Task not in history yet (new task with no messages); catch the
// error to enable the agent to continue making progress.
console.log(`[Controller.cancelTask] Task not found in history: ${error}`)
}
// Only re-initialize if we found a history item, otherwise just clear
if (historyItem) {
// Re-initialize task to keep it visible in UI with resume button
await this.initTask(undefined, undefined, undefined, historyItem, undefined)
} else {
await this.clearTask()
}
// Update UI to reflect current state
await this.postStateToWebview()
} finally {
// Always clear the flag, even if cancellation fails
@@ -488,6 +454,15 @@ export class Controller {
}
}
/**
* Called by Task when it enters the resume flow (_handleResumeFlow).
* This clears the Controller's cancel guard to allow cancellation during hooks.
*/
clearCancelInProgress() {
console.log(`[Controller.clearCancelInProgress] Clearing cancel guard for resume flow`)
this.cancelInProgress = false
}
updateBackgroundCommandState(running: boolean, taskId?: string) {
const nextTaskId = running ? taskId : undefined
if (this.backgroundCommandRunning === running && this.backgroundCommandTaskId === nextTaskId) {
+1 -1
View File
@@ -29,7 +29,7 @@ export async function resetState(controller: Controller, request: ResetStateRequ
}
if (controller.task) {
controller.task.abortTask()
await controller.task.abortTask("user_cancel")
controller.task = undefined
}
+10
View File
@@ -132,6 +132,11 @@ export class HookDiscoveryCache {
const hooksDirs = await getAllHooksDirs()
this.log(`Scanning ${hooksDirs.length} directories for ${hookName}`)
// Log each directory being scanned
hooksDirs.forEach((dir, index) => {
this.log(` [${index + 1}] ${dir}`)
})
// Ensure watchers are set up for each directory (lazy initialization)
for (const dir of hooksDirs) {
this.ensureWatcher(dir)
@@ -145,6 +150,11 @@ export class HookDiscoveryCache {
this.log(`Found ${scripts.length} scripts for ${hookName}`)
// Log each found script
scripts.forEach((script, index) => {
this.log(` [${index + 1}] ${script}`)
})
// Cache the result
this.cache.set(hookName, {
scriptPaths: scripts,
+54
View File
@@ -0,0 +1,54 @@
/**
* HookOutputChannel - Explicit pub-sub routing for hook outputs
*
* This class provides a dedicated communication channel for each hook execution,
* ensuring that outputs are routed to the correct hook message without relying
* on post-hoc timestamp matching.
*
* Architecture:
* - Each hook gets its own channel instance
* - Channel handles output routing via timestamp prefix
* - Timestamps preserved for message identity
* - Channels handle routing concern separately
*/
export class HookOutputChannel {
private hookTs: number
private say: (type: any, text?: string, images?: string[], files?: string[], partial?: boolean) => Promise<number | undefined>
/**
* Creates a new output channel for a specific hook execution
* @param hookTs The timestamp of the hook message this channel routes to
* @param say The say function for writing messages
*/
constructor(
hookTs: number,
say: (type: any, text?: string, images?: string[], files?: string[], partial?: boolean) => Promise<number | undefined>,
) {
this.hookTs = hookTs
this.say = say
}
/**
* Publishes a line of output to this hook's message stream
* The line is automatically prefixed with the hook's timestamp for routing
* @param line The output line to publish
*/
async publish(line: string): Promise<void> {
const prefixedOutput = `${this.hookTs}:${line}`
try {
await this.say("hook_output", prefixedOutput)
} catch (error) {
console.error(`[HookOutputChannel ${this.hookTs}] Failed to publish output:`, error)
// Don't throw - allow hook execution to continue even if output fails
}
}
/**
* Gets the timestamp this channel routes to
* Useful for debugging and verification
*/
getHookTimestamp(): number {
return this.hookTs
}
}
+154 -29
View File
@@ -1,7 +1,7 @@
import { ClineMessage } from "@shared/ExtensionMessage"
import { MessageStateHandler } from "../task/message-state"
import { HookExecutionError } from "./HookError"
import { HookFactory } from "./hook-factory"
import { HookFactory, HookStreamCallback } from "./hook-factory"
export interface HookExecutionOptions<Name extends keyof Hooks = any> {
hookName: Name
@@ -13,8 +13,9 @@ export interface HookExecutionOptions<Name extends keyof Hooks = any> {
toolName: string | undefined
messageTs: number
abortController: AbortController
scriptPath?: string
}) => Promise<void>
clearActiveHookExecution?: () => Promise<void>
clearActiveHookExecution?: (messageTs: number) => Promise<void>
messageStateHandler: MessageStateHandler
taskId: string
hooksEnabled: boolean
@@ -35,6 +36,9 @@ export interface HookExecutionResult {
/**
* Executes a hook with standardized error handling, status tracking, and cleanup.
* This consolidates the common pattern used across all hook execution sites.
*
* When multiple hooks exist (e.g., global + workspace), each hook gets its own
* background terminal with separate output streaming.
*/
export async function executeHook<Name extends keyof Hooks>(options: HookExecutionOptions<Name>): Promise<HookExecutionResult> {
const {
@@ -56,57 +60,170 @@ export async function executeHook<Name extends keyof Hooks>(options: HookExecuti
}
}
// Check if the hook exists
// Discover all hook scripts for this hook type
const hookFactory = new HookFactory()
const hasHook = await hookFactory.hasHook(hookName)
const hookScripts = await hookFactory.discoverHookScripts(hookName)
if (!hasHook) {
if (hookScripts.length === 0) {
return {
wasCancelled: false,
}
}
let hookMessageTs: number | undefined
const abortController = new AbortController()
// Create hook messages sequentially to ensure unique timestamps
// (but execute the hooks themselves in parallel for performance)
const hookExecutions: Array<Promise<HookExecutionResult>> = []
try {
// Show hook execution indicator and capture timestamp
for (const scriptPath of hookScripts) {
// Create the hook message first (sequentially to get unique timestamp)
const hookMetadata = {
hookName,
scriptPath,
...(options.toolName && { toolName: options.toolName }),
status: "running",
...(options.pendingToolInfo && { pendingToolInfo: options.pendingToolInfo }),
}
hookMessageTs = await say("hook", JSON.stringify(hookMetadata))
const hookMessageTs = await say("hook", JSON.stringify(hookMetadata))
// Add small delay to ensure different timestamps even on fast systems
await new Promise((resolve) => setTimeout(resolve, 1))
// Log for debugging
console.log(`[Hook ${hookName}] Created message ts=${hookMessageTs} for ${scriptPath}`)
// Now start the hook execution (don't await - let them run in parallel)
const execution = executeIndividualHook({
scriptPath,
hookName,
hookInput,
isCancellable,
say,
setActiveHookExecution,
clearActiveHookExecution,
messageStateHandler,
taskId,
toolName: options.toolName,
pendingToolInfo: options.pendingToolInfo,
hookMessageTs, // Pass the pre-created timestamp
})
hookExecutions.push(execution)
}
// Wait for all hook executions to complete in parallel
const results = await Promise.all(hookExecutions)
// Merge results:
// - If ANY hook was cancelled by user, return wasCancelled: true
// - If ANY hook requests task cancellation, return cancel: true
// - Combine all context modifications
// - Combine all error messages
const wasCancelled = results.some((r) => r.wasCancelled)
const cancel = results.some((r) => r.cancel === true)
const contextModification = results
.map((r) => r.contextModification?.trim())
.filter((mod) => mod)
.join("\n\n")
const errorMessage = results
.map((r) => r.errorMessage?.trim())
.filter((msg) => msg)
.join("\n")
return {
cancel: cancel || undefined,
contextModification: contextModification || undefined,
errorMessage: errorMessage || undefined,
wasCancelled,
}
}
/**
* Execute a single hook script with its own terminal message and output stream.
*/
async function executeIndividualHook<Name extends keyof Hooks>(params: {
scriptPath: string
hookName: Name
hookInput: Hooks[Name]
isCancellable: boolean
say: (type: any, text?: string, images?: string[], files?: string[], partial?: boolean) => Promise<number | undefined>
setActiveHookExecution?: (execution: {
hookName: string
toolName: string | undefined
messageTs: number
abortController: AbortController
scriptPath?: string
}) => Promise<void>
clearActiveHookExecution?: (messageTs: number) => Promise<void>
messageStateHandler: MessageStateHandler
taskId: string
toolName?: string
pendingToolInfo?: any
hookMessageTs?: number // Pre-created timestamp for this hook message
}): Promise<HookExecutionResult> {
const {
scriptPath,
hookName,
hookInput,
isCancellable,
say,
setActiveHookExecution,
clearActiveHookExecution,
messageStateHandler,
taskId,
toolName,
pendingToolInfo,
hookMessageTs: providedHookMessageTs,
} = params
let hookMessageTs: number | undefined = providedHookMessageTs
const abortController = new AbortController()
try {
// Only create hook message if not already created
if (hookMessageTs === undefined) {
const hookMetadata = {
hookName,
scriptPath, // Include script path to identify which hook this is
...(toolName && { toolName }),
status: "running",
...(pendingToolInfo && { pendingToolInfo }),
}
hookMessageTs = await say("hook", JSON.stringify(hookMetadata))
}
// Track active hook execution for cancellation (only if cancellable and message was created)
if (isCancellable && hookMessageTs !== undefined && setActiveHookExecution) {
await setActiveHookExecution({
hookName,
toolName: options.toolName,
toolName,
messageTs: hookMessageTs,
abortController,
scriptPath,
})
}
// Create streaming callback
const streamCallback = async (line: string) => {
await say("hook_output", line)
// Create dedicated output channel for this hook (pub-sub architecture)
// The channel handles routing outputs to the correct hook message
const outputChannel = new (await import("./HookOutputChannel")).HookOutputChannel(hookMessageTs!, say)
// Create streaming callback that publishes to the channel
// Channel internally handles the timestamp prefixing for routing
const streamCallback: HookStreamCallback = async (line: string) => {
await outputChannel.publish(line)
}
// Create and execute hook
const hook = await hookFactory.createWithStreaming(
hookName,
streamCallback,
isCancellable ? abortController.signal : undefined,
)
// Create runner directly for THIS SPECIFIC SCRIPT ONLY
// Don't use createWithStreaming() as it rediscovers all scripts!
const { StdioHookRunner } = await import("./hook-factory")
const hook = new StdioHookRunner(hookName, scriptPath, streamCallback, isCancellable ? abortController.signal : undefined)
const result = await hook.run({
taskId,
...hookInput,
})
console.log(`[${hookName} Hook]`, result)
console.log(`[${hookName} Hook - ${scriptPath}]`, result)
// Check if hook wants to cancel
if (result.cancel === true) {
@@ -114,7 +231,8 @@ export async function executeHook<Name extends keyof Hooks>(options: HookExecuti
if (hookMessageTs !== undefined) {
await updateHookMessage(messageStateHandler, hookMessageTs, {
hookName,
...(options.toolName && { toolName: options.toolName }),
scriptPath,
...(toolName && { toolName }),
status: "cancelled",
exitCode: 130,
hasJsonResponse: true,
@@ -130,15 +248,16 @@ export async function executeHook<Name extends keyof Hooks>(options: HookExecuti
}
// Clear active hook execution after successful completion (only if cancellable)
if (isCancellable && clearActiveHookExecution) {
await clearActiveHookExecution()
if (isCancellable && clearActiveHookExecution && hookMessageTs !== undefined) {
await clearActiveHookExecution(hookMessageTs)
}
// Update hook status to completed (only if not cancelled)
if (hookMessageTs !== undefined) {
await updateHookMessage(messageStateHandler, hookMessageTs, {
hookName,
...(options.toolName && { toolName: options.toolName }),
scriptPath,
...(toolName && { toolName }),
status: "completed",
exitCode: 0,
hasJsonResponse: true,
@@ -153,8 +272,8 @@ export async function executeHook<Name extends keyof Hooks>(options: HookExecuti
}
} catch (hookError) {
// Clear active hook execution (only if cancellable)
if (isCancellable && clearActiveHookExecution) {
await clearActiveHookExecution()
if (isCancellable && clearActiveHookExecution && hookMessageTs !== undefined) {
await clearActiveHookExecution(hookMessageTs)
}
// Check if this was a user cancellation via abort controller
@@ -163,6 +282,7 @@ export async function executeHook<Name extends keyof Hooks>(options: HookExecuti
if (hookMessageTs !== undefined) {
await updateHookMessage(messageStateHandler, hookMessageTs, {
hookName,
scriptPath,
status: "cancelled",
exitCode: 130,
})
@@ -182,6 +302,7 @@ export async function executeHook<Name extends keyof Hooks>(options: HookExecuti
if (hookMessageTs !== undefined) {
await updateHookMessage(messageStateHandler, hookMessageTs, {
hookName,
scriptPath,
status: "failed",
exitCode: errorInfo?.exitCode ?? 1,
...(errorInfo && {
@@ -196,7 +317,7 @@ export async function executeHook<Name extends keyof Hooks>(options: HookExecuti
}
// Log error for non-cancellable hooks or unexpected errors
console.error(`${hookName} hook failed:`, hookError)
console.error(`${hookName} hook failed (${scriptPath}):`, hookError)
// Return safe defaults for all fields to avoid undefined property access
return {
@@ -209,7 +330,8 @@ export async function executeHook<Name extends keyof Hooks>(options: HookExecuti
}
/**
* Helper to update hook message status in message state
* Helper to update hook message status in message state.
* Ensures the update is persisted and posted to webview before returning.
*/
async function updateHookMessage(
messageStateHandler: MessageStateHandler,
@@ -222,5 +344,8 @@ async function updateHookMessage(
await messageStateHandler.updateClineMessage(hookMessageIndex, {
text: JSON.stringify(metadata),
})
// CRITICAL: Persist and post the update so it's reflected in the UI
// before the hook execution function returns
await messageStateHandler.saveClineMessagesAndUpdateHistory()
}
}
+11 -1
View File
@@ -241,7 +241,7 @@ export type HookStreamCallback = (line: string, stream: "stdout" | "stderr") =>
*
* @template Name The type of hook this runner represents
*/
class StdioHookRunner<Name extends HookName> extends HookRunner<Name> {
export class StdioHookRunner<Name extends HookName> extends HookRunner<Name> {
constructor(
hookName: Name,
public readonly scriptPath: string,
@@ -494,6 +494,16 @@ export class HookFactory {
return scripts.length > 0
}
/**
* Discover all hook scripts for the given hook name without creating runners.
* This is useful when you want to execute each hook individually with separate contexts.
* @returns Array of paths to hook scripts
*/
async discoverHookScripts<Name extends HookName>(hookName: Name): Promise<string[]> {
const { HookDiscoveryCache } = await import("./HookDiscoveryCache")
return await HookDiscoveryCache.getInstance().get(hookName)
}
/**
* Create a hook runner without streaming support (backwards compatible)
*/
+39 -1
View File
@@ -61,14 +61,52 @@ export class TaskState {
didFinishAbortingStream = false
abandoned = false
// Hook execution tracking for cancellation
// ============================================================================
// HOOK STATE - Dual Architecture for Feature Flag Protection
// ============================================================================
// These fields exist in two forms to support both legacy (hooks disabled)
// and new (hooks enabled) architectures without breaking existing code.
// LEGACY STRUCTURE (used when hooks feature flag is DISABLED)
// Single hook execution tracking for cancellation
activeHookExecution?: {
hookName: string
toolName?: string
messageTs: number
abortController: AbortController
scriptPath?: string
}
// NEW STRUCTURE (used when hooks feature flag is ENABLED)
// Multi-hook execution tracking via Map for concurrent hooks
activeHookExecutions: Map<
number,
{
hookName: string
toolName?: string
messageTs: number
abortController: AbortController
scriptPath?: string
}
> = new Map()
// NEW ABORT FLOW ENHANCEMENTS (only used when hooks enabled)
// Single-flight guard to prevent concurrent abortTask() calls
isAborting: boolean = false
abortPromise?: Promise<{ waitingAtResumeButton: boolean; abortReason: "user_cancel" | "internal_resume" }>
// Abort reason to distinguish user cancellation from internal resume flow
abortReason: "user_cancel" | "internal_resume" = "user_cancel"
// Session work tracking for TaskCancel hook decision
// Set to true when substantive work begins (API request, tool execution, user feedback)
// Used by shouldRunTaskCancelHook() to determine if TaskCancel should run
// Eliminates race conditions from checking "currently active" work indicators
didPerformWork: boolean = false
// Flag to prevent duplicate TaskCancel hook execution
// Set to true once TaskCancel hook has run, prevents running it again on subsequent abortTask() calls
didRunTaskCancelHook: boolean = false
// Auto-context summarization
currentlySummarizing: boolean = false
lastAutoCompactTriggerIndex?: number
+9 -4
View File
@@ -114,10 +114,15 @@ export class ToolExecutor {
private updateFCListFromToolResponse: (taskProgress: string | undefined) => Promise<void>,
private switchToActMode: () => Promise<boolean>,
// Atomic hook state helpers from Task
private setActiveHookExecution: (hookExecution: NonNullable<typeof taskState.activeHookExecution>) => Promise<void>,
private clearActiveHookExecution: () => Promise<void>,
private getActiveHookExecution: () => Promise<typeof taskState.activeHookExecution>,
// Atomic hook state helpers from Task (Map-based API)
private setActiveHookExecution: (hookExecution: {
hookName: string
toolName: string | undefined
messageTs: number
abortController: AbortController
scriptPath?: string
}) => Promise<void>,
private clearActiveHookExecution: (messageTs: number) => Promise<void>,
) {
this.autoApprover = new AutoApprove(this.stateManager)
+542 -148
View File
@@ -151,35 +151,149 @@ export class Task {
}
/**
* Atomically set active hook execution with mutex protection
* Prevents TOCTOU races when setting hook execution state
* PUBLIC: Exposed for ToolExecutor to use
* Atomically set active hook execution with mutex protection.
*
* FEATURE FLAG PROTECTED: Supports both legacy single-hook and new multi-hook Map architectures.
*
* **Legacy Mode (hooks disabled):**
* - Stores single hook in activeHookExecution
* - Overwrites any previous hook
*
* **New Mode (hooks enabled):**
* - Uses Map<messageTs, HookExecution> to track multiple concurrent hooks
* - Each hook gets its own entry keyed by its message timestamp
* - Supports concurrent execution of multiple hooks (e.g., global + workspace)
*
* **Cancellation Flow (new mode):**
* 1. Hook starts → setActiveHookExecution() adds to Map
* 2. Hook completes → clearActiveHookExecution(messageTs) removes from Map
* 3. User cancels → abortTask() iterates Map, calls abort() on all
*
* @param hookExecution The hook execution state to track
* @param hookExecution.hookName Name of the hook (e.g., "PreToolUse")
* @param hookExecution.toolName Optional tool name for tool-specific hooks
* @param hookExecution.messageTs Unique timestamp identifying this hook's message
* @param hookExecution.abortController Controller for cancelling the hook
* @param hookExecution.scriptPath Path to the hook script for identification
*
* @public Exposed for hook-executor.ts to coordinate hook lifecycle
*/
public async setActiveHookExecution(hookExecution: NonNullable<typeof this.taskState.activeHookExecution>): Promise<void> {
public async setActiveHookExecution(hookExecution: {
hookName: string
toolName: string | undefined
messageTs: number
abortController: AbortController
scriptPath?: string
}): Promise<void> {
const hooksEnabled = featureFlagsService.getHooksEnabled() && this.stateManager.getGlobalSettingsKey("hooksEnabled")
await this.withStateLock(() => {
this.taskState.activeHookExecution = hookExecution
if (hooksEnabled) {
// New Map-based architecture for concurrent multi-hook support
this.taskState.activeHookExecutions.set(hookExecution.messageTs, hookExecution)
} else {
// Legacy single-hook architecture
this.taskState.activeHookExecution = hookExecution
}
})
}
/**
* Atomically clear active hook execution with mutex protection
* Prevents TOCTOU races when clearing hook execution state
* PUBLIC: Exposed for ToolExecutor to use
* Atomically clear active hook execution with mutex protection.
*
* FEATURE FLAG PROTECTED: Supports both legacy single-hook and new multi-hook Map architectures.
*
* **Legacy Mode (hooks disabled):**
* - Clears the single activeHookExecution (messageTs parameter ignored)
*
* **New Mode (hooks enabled):**
* - Removes specific hook from the active executions Map
* - If entry doesn't exist (e.g., already cleared by abortTask), this is a no-op
*
* @param messageTs The unique timestamp of the hook execution to clear (used in new mode only)
*
* @public Exposed for hook-executor.ts to signal hook completion
*/
public async clearActiveHookExecution(): Promise<void> {
public async clearActiveHookExecution(messageTs?: number): Promise<void> {
const hooksEnabled = featureFlagsService.getHooksEnabled() && this.stateManager.getGlobalSettingsKey("hooksEnabled")
await this.withStateLock(() => {
this.taskState.activeHookExecution = undefined
if (hooksEnabled && messageTs !== undefined) {
// New Map-based architecture - delete specific hook by messageTs
this.taskState.activeHookExecutions.delete(messageTs)
} else {
// Legacy single-hook architecture - clear the single hook
this.taskState.activeHookExecution = undefined
}
})
}
/**
* Atomically read active hook execution state with mutex protection
* Returns a snapshot of the current state to prevent TOCTOU races
* PUBLIC: Exposed for ToolExecutor to use
* Atomically clear all active hook executions with mutex protection.
*
* FEATURE FLAG PROTECTED: Only clears state for the currently active architecture.
*
* Used during task cancellation to ensure all hook state is properly cleaned up.
* This is called by abortTask() AFTER aborting all hooks.
*
* @public Used internally by abortTask()
*/
public async getActiveHookExecution(): Promise<typeof this.taskState.activeHookExecution> {
public async clearAllActiveHookExecutions(): Promise<void> {
const hooksEnabled = featureFlagsService.getHooksEnabled() && this.stateManager.getGlobalSettingsKey("hooksEnabled")
await this.withStateLock(() => {
if (hooksEnabled) {
// New Map-based architecture - clear the entire Map
this.taskState.activeHookExecutions.clear()
} else {
// Legacy single-hook architecture - clear the single hook
this.taskState.activeHookExecution = undefined
}
})
}
/**
* Atomically read active hook execution state with mutex protection.
*
* FEATURE FLAG PROTECTED: Returns appropriate format for the currently active architecture.
*
* Returns a snapshot array of all currently active hook executions.
* The snapshot prevents TOCTOU races - the returned array is stable
* even if hooks complete or new hooks start after this call.
*
* **Legacy Mode (hooks disabled):**
* - Returns array with single hook if one is active, empty array otherwise
*
* **New Mode (hooks enabled):**
* - Returns array of all hooks active in the Map
*
* **Use Cases:**
* - abortTask() uses this to get all hooks to cancel
* - shouldRunTaskCancelHook() uses this to detect active work
*
* @returns Array of active hook execution states (empty if none active)
*
* @public Exposed for abortTask() and hook decision logic
*/
public async getActiveHookExecutions(): Promise<
Array<{
hookName: string
toolName?: string
messageTs: number
abortController: AbortController
scriptPath?: string
}>
> {
const hooksEnabled = featureFlagsService.getHooksEnabled() && this.stateManager.getGlobalSettingsKey("hooksEnabled")
return await this.withStateLock(() => {
return this.taskState.activeHookExecution
if (hooksEnabled) {
// New Map-based architecture - return all hooks as array
return Array.from(this.taskState.activeHookExecutions.values())
} else {
// Legacy single-hook architecture - return array with single hook if exists
return this.taskState.activeHookExecution ? [this.taskState.activeHookExecution] : []
}
})
}
@@ -536,10 +650,9 @@ export class Task {
() => this.checkpointManager?.doesLatestTaskCompletionHaveNewChanges() ?? Promise.resolve(false),
this.FocusChainManager?.updateFCListFromToolResponse.bind(this.FocusChainManager) || (async () => {}),
this.switchToActModeCallback.bind(this),
// Atomic hook state helpers for ToolExecutor
// Atomic hook state helpers for ToolExecutor (Map-based API)
this.setActiveHookExecution.bind(this),
this.clearActiveHookExecution.bind(this),
this.getActiveHookExecution.bind(this),
)
}
@@ -881,6 +994,15 @@ export class Task {
await this.say("text", task, images, files)
// Mark that substantive work is starting as soon as task is displayed
// This ensures TaskCancel will run if user cancels during hooks or early execution
// FEATURE FLAG PROTECTED: Only track work for hooks-enabled flow
const hooksEnabledForWork =
featureFlagsService.getHooksEnabled() && this.stateManager.getGlobalSettingsKey("hooksEnabled")
if (hooksEnabledForWork) {
this.taskState.didPerformWork = true
}
this.taskState.isInitialized = true
const imageBlocks: Anthropic.ImageBlockParam[] = formatResponse.imageBlocks(images)
@@ -944,8 +1066,9 @@ export class Task {
console.log(`[TaskStart Hook] Messages saved successfully, returning from hook`)
}
// abortTask will handle cleanup
this.abortTask()
// CRITICAL: Must await abortTask() to ensure TaskCancel hooks complete
// before the function returns and resume button is shown
await this.abortTask("user_cancel")
return
}
@@ -1047,6 +1170,15 @@ export class Task {
const { response, text, images, files } = await this.ask(askType) // calls poststatetowebview
// Mark that substantive work begins as soon as user clicks resume
// Even if no feedback is provided, clicking resume means work has started
// FEATURE FLAG PROTECTED: Only track work for hooks-enabled flow
const hooksEnabledForWork =
featureFlagsService.getHooksEnabled() && this.stateManager.getGlobalSettingsKey("hooksEnabled")
if (hooksEnabledForWork) {
this.taskState.didPerformWork = true
}
// Initialize newUserContent array for hook context
const newUserContent: UserContent = []
@@ -1094,7 +1226,9 @@ export class Task {
await this.postStateToWebview()
}
// Return without continuing task - Controller.cancelTask() will handle showing resume button
// CRITICAL: Must await abortTask() to ensure TaskCancel hooks complete
// before the function returns and resume button is shown
await this.abortTask("user_cancel")
return
}
@@ -1233,8 +1367,9 @@ export class Task {
// Handle hook cancellation request
if (userPromptHookResult.cancel === true) {
// The hook already updated its status to "cancelled" internally and saved state
this.abortTask()
// CRITICAL: Must await abortTask() to ensure TaskCancel hooks complete
// before the function returns and resume button is shown
await this.abortTask("user_cancel")
return
}
@@ -1280,55 +1415,238 @@ export class Task {
}
}
/**
* Handles the complete resume flow after TaskCancel has run.
*
* Flow:
* 1. Present resume button to user
* 2. Wait for user to click resume
* 3. Run TaskResume hook
* 4. Start task execution in background
*
* @returns true if resumed successfully, false if cancelled during resume
*/
private async _handleResumeFlow(): Promise<boolean> {
// CRITICAL FIX: Clear Controller's cancel guard so user can cancel during hooks
// This must happen BEFORE presenting the resume button
this.controller.clearCancelInProgress()
// Reset abort flag so resume button can be shown
this.taskState.abort = false
// CRITICAL: Reset didRunTaskCancelHook so future cancels will run TaskCancel again
// This flag tracks "did TaskCancel run in THIS abort sequence", not "did it ever run"
// When user resumes, we start a fresh sequence, so reset the flag
this.taskState.didRunTaskCancelHook = false
// Get last message for context
const lastClineMessage = this.messageStateHandler
.getClineMessages()
.slice()
.reverse()
.find((m) => !(m.ask === "resume_task" || m.ask === "resume_completed_task"))
let askType: ClineAsk
if (lastClineMessage?.ask === "completion_result") {
askType = "resume_completed_task"
} else {
askType = "resume_task"
}
// Present resume button and wait for user
const { response, text, images, files } = await this.ask(askType)
// Handle user feedback if provided
if (response === "messageResponse" || text || (images && images.length > 0) || (files && files.length > 0)) {
await this.say("user_feedback", text, images, files)
await this.checkpointManager?.saveCheckpoint()
}
// Prepare content for task execution
const newUserContent: UserContent = []
// Run TaskResume hook
const { executeHook } = await import("../hooks/hook-executor")
const clineMessages = this.messageStateHandler.getClineMessages()
const taskResumeResult = await executeHook({
hookName: "TaskResume",
hookInput: {
taskResume: {
taskMetadata: {
taskId: this.taskId,
ulid: this.ulid,
},
previousState: {
lastMessageTs: lastClineMessage?.ts?.toString() || "",
messageCount: clineMessages.length.toString(),
conversationHistoryDeleted: (this.taskState.conversationHistoryDeletedRange !== undefined).toString(),
},
},
},
isCancellable: true,
say: this.say.bind(this),
setActiveHookExecution: this.setActiveHookExecution.bind(this),
clearActiveHookExecution: this.clearActiveHookExecution.bind(this),
messageStateHandler: this.messageStateHandler,
taskId: this.taskId,
hooksEnabled: true,
})
// Check if user cancelled during TaskResume
if (taskResumeResult.cancel === true) {
return false // Indicate cancellation
}
// Add TaskResume context if provided
if (taskResumeResult.contextModification) {
newUserContent.push({
type: "text",
text: `<hook_context source="TaskResume" type="general">\n${taskResumeResult.contextModification}\n</hook_context>`,
})
}
// Add user feedback content
if (text) {
newUserContent.push({
type: "text",
text: formatResponse.tooManyMistakes(text),
})
}
if (images && images.length > 0) {
newUserContent.push(...formatResponse.imageBlocks(images))
}
if (files && files.length > 0) {
const fileContentString = await processFilesIntoText(files)
if (fileContentString) {
newUserContent.push({
type: "text",
text: fileContentString,
})
}
}
// Run UserPromptSubmit hook for internal resume (after TaskResume for UI ordering)
const userPromptHookResult = await this.runUserPromptSubmitHook(newUserContent, "resume")
// Defensive check: Verify task wasn't aborted during hook execution
if (this.taskState.abort) {
return false // Indicate cancellation
}
// Handle hook cancellation request
if (userPromptHookResult.cancel === true) {
return false // Indicate cancellation
}
// Add hook context if provided (after all other content)
if (userPromptHookResult.contextModification) {
newUserContent.push({
type: "text",
text: `<hook_context source="UserPromptSubmit">\n${userPromptHookResult.contextModification}\n</hook_context>`,
})
}
// Start task execution in background
this.initiateTaskLoop(newUserContent.length > 0 ? newUserContent : []).catch((error) => {
console.error("[Task] Background task loop failed:", error)
})
return true // Indicate successful resume
}
/**
* Determines if the TaskCancel hook should run.
* Only runs if there's actual active work happening or if work was started in this session.
* Does NOT run when just showing the resume button with no active work.
* @returns true if the hook should run, false otherwise
*
* Decision Logic:
* - DON'T run if already at resume button (previous cancellation or opened from history with no work)
* - DO run if any substantive work was performed in this session
*
* This eliminates race conditions from checking "currently active" work indicators
* and instead checks "was work started" which is stable and deterministic.
*
* @returns true if TaskCancel should run, false otherwise
*/
private async shouldRunTaskCancelHook(): Promise<boolean> {
// Atomically check for active hook execution (work happening now)
const activeHook = await this.getActiveHookExecution()
if (activeHook) {
return true
}
// Run if the API is currently streaming (work happening now)
if (this.taskState.isStreaming) {
return true
}
// Run if we're waiting for the first chunk (work happening now)
if (this.taskState.isWaitingForFirstChunk) {
return true
}
// Run if there's active background command (work happening now)
if (this.activeBackgroundCommand) {
return true
}
// Check if we're at the resume button state (no active work, just waiting)
// Check if we're currently showing the resume button
const clineMessages = this.messageStateHandler.getClineMessages()
const lastMessage = clineMessages.at(-1)
const isAtResumeButton =
lastMessage?.type === "ask" && (lastMessage.ask === "resume_task" || lastMessage.ask === "resume_completed_task")
if (isAtResumeButton) {
// At resume button - DON'T run hook because we're just waiting for user input
// The resume button appears in two scenarios:
// 1. Opening from history (no new work)
// 2. After cancelling during active work (but work already stopped)
// In both cases, we shouldn't run TaskCancel hook
// Already showing resume button from a previous cancellation
// OR task was opened from history and user hasn't provided new input yet
// In both cases, don't run TaskCancel hook again
return false
}
// Not at resume button - we're in the middle of work or just finished something
// Run the hook since cancelling would interrupt actual work
return true
// If we're not at resume button and work was performed in this session,
// then TaskCancel should run to properly clean up
return this.taskState.didPerformWork
}
async abortTask() {
async abortTask(
reason: "user_cancel" | "internal_resume" = "user_cancel",
): Promise<{ waitingAtResumeButton: boolean; abortReason: "user_cancel" | "internal_resume" }> {
const hooksEnabled = featureFlagsService.getHooksEnabled() && this.stateManager.getGlobalSettingsKey("hooksEnabled")
if (hooksEnabled) {
// NEW ENHANCED ABORT FLOW (hooks enabled)
// Includes: single-flight guard, TaskCancel hook, resume flow, work tracking
// Single-flight guard: prevent concurrent abort calls
if (this.taskState.isAborting) {
// If user is cancelling, override internal resume behavior
if (reason === "user_cancel") {
this.taskState.abortReason = "user_cancel"
}
// Signal all active hook AbortControllers before returning
// This ensures hooks receive cancellation signal when user presses cancel during TaskResume
const activeHooks = await this.getActiveHookExecutions()
if (activeHooks.length > 0) {
try {
for (const hook of activeHooks) {
hook.abortController.abort()
}
await this.clearAllActiveHookExecutions()
} catch (error) {
Logger.error("Failed to cancel hooks during abort", error)
await this.clearAllActiveHookExecutions()
}
}
return this.taskState.abortPromise!
}
// Mark as aborting and store promise for concurrent callers
this.taskState.isAborting = true
this.taskState.abortReason = reason // Store the reason for decision logic
this.taskState.abortPromise = this._executeAbort()
try {
const result = await this.taskState.abortPromise
return result
} finally {
this.taskState.isAborting = false
this.taskState.abortPromise = undefined
}
} else {
// LEGACY ABORT FLOW (hooks disabled)
// Simple abort without TaskCancel hooks or enhanced tracking
return await this._executeSimpleLegacyAbort()
}
}
/**
* Internal abort execution - called only once via the single-flight guard in abortTask()
* HOOKS ENABLED VERSION - includes TaskCancel hook and resume flow
*/
private async _executeAbort(): Promise<{ waitingAtResumeButton: boolean; abortReason: "user_cancel" | "internal_resume" }> {
// Flag to determine if we should skip cleanup (when waiting at resume button)
let skipCleanup = false
let waitingAtResumeButton = false
try {
// PHASE 1: Check if TaskCancel should run BEFORE any cleanup
// We must capture this state now because subsequent cleanup will
@@ -1340,29 +1658,37 @@ export class Task {
// can properly detect the abort state
this.taskState.abort = true
// PHASE 3: Cancel any running hook execution
const activeHook = await this.getActiveHookExecution()
if (activeHook) {
// PHASE 3: Cancel all running hook executions
const activeHooks = await this.getActiveHookExecutions()
if (activeHooks.length > 0) {
try {
await this.cancelHookExecution()
// Clear activeHookExecution after hook is signaled
await this.clearActiveHookExecution()
// Cancel all active hooks
for (const hook of activeHooks) {
hook.abortController.abort()
}
// Clear all hook state
await this.clearAllActiveHookExecutions()
} catch (error) {
Logger.error("Failed to cancel hook during task abort", error)
Logger.error("Failed to cancel hooks during task abort", error)
// Still clear state even on error to prevent stuck state
await this.clearActiveHookExecution()
await this.clearAllActiveHookExecutions()
}
}
// PHASE 4: Run TaskCancel hook
// This allows the hook UI to appear in the webview
// PHASE 4: Run TaskCancel hook if conditions are met
// Use the shouldRunTaskCancelHook value we captured in Phase 1
const hooksEnabled = featureFlagsService.getHooksEnabled() && this.stateManager.getGlobalSettingsKey("hooksEnabled")
if (hooksEnabled && shouldRunTaskCancelHook) {
// Check if TaskCancel already ran to prevent duplicate execution from sequential abort calls
if (hooksEnabled && shouldRunTaskCancelHook && !this.taskState.didRunTaskCancelHook) {
// Mark that we're running TaskCancel to prevent duplicate execution
this.taskState.didRunTaskCancelHook = true
try {
const { executeHook } = await import("../hooks/hook-executor")
const taskCancelResult = await executeHook({
// Always run TaskCancel hook when conditions are met
await executeHook({
hookName: "TaskCancel",
hookInput: {
taskCancel: {
@@ -1381,28 +1707,48 @@ export class Task {
hooksEnabled,
})
// TaskCancel completed successfully
// Present resume button after successful TaskCancel hook
const lastClineMessage = this.messageStateHandler
.getClineMessages()
.slice()
.reverse()
.find((m) => !(m.ask === "resume_task" || m.ask === "resume_completed_task"))
// CRITICAL: Flush all hook status updates to webview before showing resume button
// This prevents race condition where hooks show "Running" after completing
// The hook executor now ensures all status updates are persisted before returning,
// so we don't need artificial delays here
await this.messageStateHandler.saveClineMessagesAndUpdateHistory()
await this.postStateToWebview()
let askType: ClineAsk
if (lastClineMessage?.ask === "completion_result") {
askType = "resume_completed_task"
} else {
askType = "resume_task"
// TaskCancel hook completed - now decide what to do next
// Show resume button if:
// 1. internal_resume (consecutive mistakes feature), OR
// 2. TaskCancel ran (meaning work was done, regardless of abort reason)
const shouldShowResumeButton =
this.taskState.abortReason === "internal_resume" || this.taskState.didRunTaskCancelHook
if (shouldShowResumeButton) {
// Show resume button - either consecutive mistakes OR TaskCancel ran
skipCleanup = true
// CRITICAL FIX: Clear single-flight guard BEFORE _handleResumeFlow
// This allows user to cancel during TaskResume hooks without deadlock
// The original abort sequence is complete; _handleResumeFlow starts a NEW user interaction phase
this.taskState.isAborting = false
this.taskState.abortPromise = undefined
// Handle resume flow
const resumed = await this._handleResumeFlow()
if (!resumed) {
// User cancelled during TaskResume or UserPromptSubmit - return to resume button state
waitingAtResumeButton = true
return {
waitingAtResumeButton: true,
abortReason: (this.taskState.abortReason || "user_cancel") as "user_cancel" | "internal_resume",
}
}
// Task resumed successfully and is running in background
// Return successfully without cleanup
return {
waitingAtResumeButton: false,
abortReason: (this.taskState.abortReason || "user_cancel") as "user_cancel" | "internal_resume",
}
}
// Present the resume ask - this will show the resume button in the UI
// We don't await this because we want to set the abort flag immediately
// The ask will be waiting when the user decides to resume
this.ask(askType).catch((error) => {
// If ask fails (e.g., task was cleared), that's okay - just log it
console.log("[TaskCancel] Resume ask failed (task may have been cleared):", error)
})
// else: No work done (e.g., X button at startup) - just cleanup and close
} catch (error) {
// TaskCancel hook failed - non-fatal, just log
console.error("[TaskCancel Hook] Failed (non-fatal):", error)
@@ -1436,25 +1782,126 @@ export class Task {
if (this.FocusChainManager) {
this.FocusChainManager.dispose()
}
} finally {
// Skip cleanup if we're waiting at resume button
if (skipCleanup) {
waitingAtResumeButton = true
} else {
// Release task folder lock
if (this.taskLockAcquired) {
try {
await releaseTaskLock(this.taskId)
this.taskLockAcquired = false
console.info(`[Task ${this.taskId}] Task lock released`)
} catch (error) {
console.error(`[Task ${this.taskId}] Failed to release task lock:`, error)
}
}
// Final state update to notify UI that abort is complete
try {
await this.postStateToWebview()
} catch (error) {
Logger.error("Failed to post final state after abort", error)
}
}
}
// Return the result, including the reason so Controller can make informed decisions
return {
waitingAtResumeButton,
abortReason: this.taskState.abortReason || "user_cancel",
}
}
/**
* Legacy simplified abort implementation for when hooks are disabled.
* HOOKS DISABLED VERSION - no TaskCancel hook, no resume flow, no work tracking
*
* This is the original simple abort flow that:
* 1. Sets abort flag
* 2. Cancels any active hook (single hook only)
* 3. Cleans up resources
* 4. Releases locks
*
* Does NOT include:
* - didPerformWork tracking
* - shouldRunTaskCancelHook logic
* - TaskCancel hook execution
* - Resume flow with TaskResume hook
* - Single-flight guard (simpler, no concurrent calls expected)
*/
private async _executeSimpleLegacyAbort(): Promise<{
waitingAtResumeButton: boolean
abortReason: "user_cancel" | "internal_resume"
}> {
try {
// Set abort flag to stop execution
this.taskState.abort = true
// Cancel any active hook (legacy single-hook only)
const activeHooks = await this.getActiveHookExecutions()
if (activeHooks.length > 0) {
try {
for (const hook of activeHooks) {
hook.abortController.abort()
}
await this.clearAllActiveHookExecutions()
} catch (error) {
Logger.error("Failed to cancel hook during legacy abort", error)
await this.clearAllActiveHookExecutions()
}
}
// Update UI to reflect abort state
try {
await this.messageStateHandler.saveClineMessagesAndUpdateHistory()
await this.postStateToWebview()
} catch (error) {
Logger.error("Failed to post state after setting abort flag", error)
}
// Check for incomplete progress
if (this.FocusChainManager) {
this.FocusChainManager.checkIncompleteProgressOnCompletion()
}
// Clean up resources
this.terminalManager.disposeAll()
this.urlContentFetcher.closeBrowser()
await this.browserSession.dispose()
this.clineIgnoreController.dispose()
this.fileContextTracker.dispose()
await this.diffViewProvider.revertChanges()
this.mcpHub.clearNotificationCallback()
if (this.FocusChainManager) {
this.FocusChainManager.dispose()
}
} finally {
// Release task folder lock
if (this.taskLockAcquired) {
try {
await releaseTaskLock(this.taskId)
this.taskLockAcquired = false
console.info(`[Task ${this.taskId}] Task lock released`)
console.info(`[Task ${this.taskId}] Task lock released (legacy abort)`)
} catch (error) {
console.error(`[Task ${this.taskId}] Failed to release task lock:`, error)
}
}
// Final state update to notify UI that abort is complete
// Final state update
try {
await this.postStateToWebview()
} catch (error) {
Logger.error("Failed to post final state after abort", error)
Logger.error("Failed to post final state after legacy abort", error)
}
}
// Legacy abort never waits at resume button - always fully cleans up
return {
waitingAtResumeButton: false,
abortReason: "user_cancel",
}
}
// Tools
@@ -1584,6 +2031,9 @@ export class Task {
if (text || (images && images.length > 0) || (files && files.length > 0)) {
userFeedback = { text, images, files }
}
// Continue command execution in background
didContinue = true
process.continue()
} else if (response === "noButtonClicked" && text === COMMAND_CANCEL_TOKEN) {
telemetryService.captureTerminalUserIntervention(TerminalUserInterventionAction.CANCELLED)
didCancelViaUi = true
@@ -1591,19 +2041,6 @@ export class Task {
} else {
userFeedback = { text, images, files }
}
didContinue = true
process.continue()
if (didCancelViaUi) {
outputBuffer = []
outputBufferSize = 0
await this.say("command_output", "Command cancelled")
}
// If more output accumulated, flush again
if (!didCancelViaUi && outputBuffer.length > 0) {
await flushBuffer()
}
} catch {
Logger.error("Error while asking for command output")
} finally {
@@ -1829,49 +2266,6 @@ export class Task {
return true
}
/**
* Cancel a currently running hook execution
* @returns true if a hook was cancelled, false if no hook was running
*/
public async cancelHookExecution(): Promise<boolean> {
const activeHook = await this.getActiveHookExecution()
if (!activeHook) {
return false
}
const { hookName, toolName, messageTs, abortController } = activeHook
try {
// Abort the hook process
abortController.abort()
// Update hook message status to "cancelled"
const clineMessages = this.messageStateHandler.getClineMessages()
const hookMessageIndex = clineMessages.findIndex((m) => m.ts === messageTs)
if (hookMessageIndex !== -1) {
const cancelledMetadata = {
hookName,
toolName,
status: "cancelled",
exitCode: 130, // Standard SIGTERM exit code
}
await this.messageStateHandler.updateClineMessage(hookMessageIndex, {
text: JSON.stringify(cancelledMetadata),
})
}
// Notify UI that hook was cancelled
await this.say("hook_output", "\nHook execution cancelled by user")
// Return success - let caller (abortTask) handle next steps
// DON'T call abortTask() here to avoid infinite recursion
return true
} catch (error) {
Logger.error("Failed to cancel hook execution", error)
return false
}
}
/**
* Migrates the disableBrowserTool setting from VSCode configuration to browserSettings
*/
@@ -2891,7 +3285,7 @@ export class Task {
}
// needs to happen after the say, otherwise the say would fail
this.abortTask() // if the stream failed, there's various states the task could be in (i.e. could have streamed some tools the user may have executed), so we just resort to replicating a cancel task
await this.abortTask("user_cancel") // if the stream failed, there's various states the task could be in (i.e. could have streamed some tools the user may have executed), so we just resort to replicating a cancel task
await abortStream("streaming_failed", errorMessage)
await this.reinitExistingTaskFromId(this.taskId)
+66 -18
View File
@@ -67,43 +67,90 @@ function filterPartialToolMessages(messages: ClineMessage[]): ClineMessage[] {
}
/**
* Combines a single hook message with all subsequent hook_output messages.
* Combines a single hook message with all hook_output messages routed to it.
*
* @param hookMessage The hook message to start combining from
* Architecture: Pub-Sub Output Routing
* ------------------------------------
* Each hook execution gets a dedicated HookOutputChannel that handles routing.
* The channel automatically prefixes outputs with the hook's timestamp:
* `${hookTimestamp}:${actualOutput}`
*
* This function trusts the channel's routing and matches outputs by timestamp prefix.
* Since channels ensure correct routing, we can scan all messages without worrying
* about arrival order - each output is guaranteed to have the correct prefix.
*
* Benefits:
* - Explicit routing via channels (not implicit post-hoc matching)
* - Order-independent (outputs can arrive in any sequence)
* - Type-safe (channels enforce correct hook-output relationships)
* - Timestamps preserved for message identity
*
* @param hookMessage The hook message to combine with its outputs
* @param startIndex The index of the hook message in the messages array
* @param messages The full messages array
* @param messages The full messages array (scanned completely for this hook's outputs)
* @returns Object containing the combined message and the next index to process
*/
function combineHookWithOutputs(
hookMessage: ClineMessage,
startIndex: number,
messages: ClineMessage[],
consumedOutputIndices: Set<number>,
): { combined: ClineMessage; nextIndex: number } {
let combinedText = hookMessage.text || ""
let hasOutput = false
let i = startIndex + 1
const hookTs = hookMessage.ts
// Collect all hook_output messages until we hit another hook or end of array
while (i < messages.length && messages[i].say !== "hook") {
// Scan all remaining messages for outputs routed to this hook
// Since HookOutputChannel routes via timestamp prefix, we can trust the routing
// and don't need to worry about message order
for (let i = startIndex + 1; i < messages.length; i++) {
if (messages[i].say === "hook_output") {
// Add marker before first output
if (!hasOutput) {
combinedText += `\n${HOOK_OUTPUT_STRING}`
hasOutput = true
const outputText = messages[i].text || ""
// Check if this output belongs to this hook
// Format: ${parentTs}:${actualOutput}
const colonIndex = outputText.indexOf(":")
let belongsToThisHook = false
let actualOutput = outputText
if (colonIndex > 0) {
const parentTsStr = outputText.substring(0, colonIndex)
const parsedParentTs = parseInt(parentTsStr, 10)
if (!isNaN(parsedParentTs) && parsedParentTs === hookTs) {
// This output belongs to this hook (routed by HookOutputChannel)
belongsToThisHook = true
actualOutput = outputText.substring(colonIndex + 1)
}
} else {
// No parent timestamp prefix - legacy format
// For backward compatibility, only consume if not already consumed by another hook
if (!consumedOutputIndices.has(i)) {
belongsToThisHook = true
}
}
// Append output if not empty
const output = messages[i].text || ""
if (output.length > 0) {
combinedText += "\n" + output
if (belongsToThisHook) {
// Mark this output as consumed
consumedOutputIndices.add(i)
// Add marker before first output
if (!hasOutput) {
combinedText += `\n${HOOK_OUTPUT_STRING}`
hasOutput = true
}
// Append output if not empty
if (actualOutput.length > 0) {
combinedText += "\n" + actualOutput
}
}
}
i++
}
return {
combined: { ...hookMessage, text: combinedText },
nextIndex: i,
nextIndex: messages.length,
}
}
@@ -117,12 +164,13 @@ function combineHookWithOutputs(
function combineAllHooks(messages: ClineMessage[]): ClineMessage[] {
// Pass 1: Build map of combined hooks by timestamp
const combinedHooksByTs = new Map<number, ClineMessage>()
const consumedOutputIndices = new Set<number>()
for (let i = 0; i < messages.length; i++) {
if (messages[i].say === "hook") {
const { combined, nextIndex } = combineHookWithOutputs(messages[i], i, messages)
const { combined } = combineHookWithOutputs(messages[i], i, messages, consumedOutputIndices)
combinedHooksByTs.set(combined.ts, combined)
i = nextIndex - 1 // Adjust for loop increment
// Don't skip ahead - each hook independently scans all messages
}
}
@@ -81,11 +81,6 @@ interface HookMetadata {
* - Running hooks: Always shows pending tool info
*/
const HookMessage = memo(({ message, CommandOutput }: HookMessageProps) => {
// Log when component mounts/updates
console.log(
`[HOOK-UI RENDER] HookMessage rendering: ${JSON.stringify({ hookName: message.text?.substring(0, 100), ts: message.ts })}`,
)
// Parse hook metadata and output
const { metadata, output } = useMemo(() => {
const splitMessage = (text: string) => {