From 0a88976b44b021646e107afc5a7045b6357bef5e Mon Sep 17 00:00:00 2001 From: Eve Killaby <132302818+candieduniverse@users.noreply.github.com> Date: Fri, 17 Oct 2025 13:57:22 -0700 Subject: [PATCH] feat(hooks): Initial implementation of hooks UI using background terminal UI --- src/core/hooks/HookProcess.ts | 248 ++++++++++++++++++ src/core/hooks/hook-factory.ts | 185 ++++++++----- src/core/task/ToolExecutor.ts | 191 +++++++++++--- src/core/task/index.ts | 184 ++++++++++--- .../feature-flags/FeatureFlagsService.ts | 2 +- src/shared/ExtensionMessage.ts | 10 + src/shared/combineHookSequences.ts | 211 +++++++++++++++ src/shared/proto-conversions/cline-message.ts | 2 + webview-ui/src/components/chat/ChatRow.tsx | 187 +++++++++++++ webview-ui/src/components/chat/ChatView.tsx | 6 +- .../chat/task-header/TaskTimeline.tsx | 3 +- 11 files changed, 1085 insertions(+), 144 deletions(-) create mode 100644 src/core/hooks/HookProcess.ts create mode 100644 src/shared/combineHookSequences.ts diff --git a/src/core/hooks/HookProcess.ts b/src/core/hooks/HookProcess.ts new file mode 100644 index 0000000000..268eebe6d1 --- /dev/null +++ b/src/core/hooks/HookProcess.ts @@ -0,0 +1,248 @@ +import { ChildProcess, spawn } from "child_process" +import { EventEmitter } from "events" + +/** + * 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 + * - 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 = "" + + constructor( + private readonly scriptPath: string, + private readonly timeoutMs: number = 30000, + ) { + 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 { + return new Promise((resolve, reject) => { + // 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() + + // Clear timers + if (this.hotTimer) { + clearTimeout(this.hotTimer) + this.isHot = false + } + if (this.timeoutHandle) { + clearTimeout(this.timeoutHandle) + this.timeoutHandle = null + } + + 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) => { + if (this.timeoutHandle) { + clearTimeout(this.timeoutHandle) + this.timeoutHandle = null + } + 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 + */ + private handleOutput(data: string, _didEmitEmptyLine: boolean, stream: "stdout" | "stderr"): void { + // 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 if still running + */ + terminate(): void { + if (this.childProcess && !this.isCompleted) { + this.childProcess.kill("SIGTERM") + + // Force kill after timeout + setTimeout(() => { + if (!this.isCompleted && this.childProcess) { + this.childProcess.kill("SIGKILL") + } + }, 5000) + } + + // Clear timeout + if (this.timeoutHandle) { + clearTimeout(this.timeoutHandle) + this.timeoutHandle = null + } + } +} diff --git a/src/core/hooks/hook-factory.ts b/src/core/hooks/hook-factory.ts index 5c88b35b41..1eeaa76ae5 100644 --- a/src/core/hooks/hook-factory.ts +++ b/src/core/hooks/hook-factory.ts @@ -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,7 @@ import { } from "../../shared/proto/cline/hooks" import { getAllHooksDirs } from "../storage/disk" import { StateManager } from "../storage/StateManager" +import { HookProcess } from "./HookProcess" // Hook execution timeout (30 seconds) const HOOK_EXECUTION_TIMEOUT_MS = 30000 @@ -121,63 +121,51 @@ class NoOpRunner extends HookRunner { } } -// 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 + +/** + * Actually runs a hook by executing a script with streaming support. + */ class StdioHookRunner extends HookRunner { constructor( hookName: Name, public readonly scriptPath: string, + private readonly streamCallback?: HookStreamCallback, ) { super(hookName) } override async [exec](input: HookInput): Promise { - return new Promise((resolve, reject) => { - // Serialize input to JSON - const inputJson = JSON.stringify(HookInput.toJSON(input)) + // Serialize input to JSON + const inputJson = JSON.stringify(HookInput.toJSON(input)) - // Spawn the hook process - const child = spawn(this.scriptPath, [], { - stdio: ["pipe", "pipe", "pipe"], - shell: process.platform === "win32", + // Create HookProcess for execution with streaming + const hookProcess = new HookProcess(this.scriptPath, HOOK_EXECUTION_TIMEOUT_MS) + + // 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() + // Hook status is determined by exit code + if (exitCode === 0) { + // Hook succeeded - try to parse JSON output try { - // Parse and validate output const outputData = JSON.parse(stdout) const output = HookOutput.fromJSON(outputData) @@ -192,24 +180,77 @@ class StdioHookRunner extends HookRunner { "\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) { + // JSON parsing failed, but hook succeeded (exit code 0) + // 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]) + const output = HookOutput.fromJSON(outputData) - // Handle process errors - child.on("error", (error) => { - if (timeoutHandle) { - clearTimeout(timeoutHandle) - } - reject(new Error(`Failed to execute hook ${this.hookName}: ${error.message}`)) - }) + // 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 ...]" + } - // Send input to the process - child.stdin?.write(inputJson) - child.stdin?.end() - }) + return output + } catch (extractError) { + // Could not extract valid JSON, but hook succeeded + // Append JSON parsing error to stderr for display + if (this.streamCallback) { + this.streamCallback( + `\n⚠️ Warning: Hook completed successfully but JSON response could not be parsed.`, + "stderr", + ) + this.streamCallback(` No context will be added to the conversation.`, "stderr") + this.streamCallback( + ` Error: ${extractError instanceof Error ? extractError.message : String(extractError)}`, + "stderr", + ) + } + return HookOutput.create({ + shouldContinue: true, + }) + } + } else { + // No JSON found in output, but hook succeeded + if (this.streamCallback) { + this.streamCallback( + `\n⚠️ Warning: Hook completed successfully but no JSON response found in output.`, + "stderr", + ) + this.streamCallback(` No context will be added to the conversation.`, "stderr") + } + return HookOutput.create({ + shouldContinue: true, + }) + } + } + } else { + // Hook failed with non-zero exit code + const errorDetails = stderr ? `. stderr: ${stderr}` : "" + throw new Error(`Hook exited with code ${exitCode}${errorDetails}`) + } + } catch (error) { + // Hook execution failed + const stderr = hookProcess.getStderr() + const exitCode = hookProcess.getExitCode() + + if (error instanceof Error) { + // Include stderr in error message if available + const errorDetails = stderr ? `. stderr: ${stderr}` : "" + throw new Error(`${error.message}${errorDetails}`) + } + throw error + } } } @@ -285,9 +326,31 @@ function isExpectedHookError(error: unknown): boolean { } export class HookFactory { - async create(hookName: Name): Promise> { + /** + * Check if any hook scripts exist for the given hook name + * @returns true if at least one hook script exists, false otherwise + */ + async hasHook(hookName: Name): Promise { 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(hookName: Name): Promise> { + return this.createWithStreaming(hookName) + } + + /** + * Create a hook runner with optional streaming callback support + */ + async createWithStreaming( + hookName: Name, + streamCallback?: HookStreamCallback, + ): Promise> { + const scripts = await HookFactory.findHookScripts(hookName) + const runners = scripts.map((script) => new StdioHookRunner(hookName, script, streamCallback)) if (runners.length === 0) { return new NoOpRunner(hookName) } diff --git a/src/core/task/ToolExecutor.ts b/src/core/task/ToolExecutor.ts index 41d1f722f8..701a616b77 100644 --- a/src/core/task/ToolExecutor.ts +++ b/src/core/task/ToolExecutor.ts @@ -6,7 +6,7 @@ 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" @@ -415,34 +415,88 @@ export class ToolExecutor { // 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 + try { + // Show hook execution indicator and capture timestamp + const hookMetadata: ClineSayHook = { + hookName: "PreToolUse", toolName: block.name, - parameters: block.params, - }, - }) + status: "running", + } + 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" + // 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) + + preToolUseResult = await preToolUseHook.run({ + taskId: this.taskId, + preToolUse: { + toolName: block.name, + parameters: block.params, + }, + }) + + // 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) { + const errorMessage = preToolUseResult.errorMessage || "PreToolUse hook prevented tool execution" + await this.say("error", errorMessage) + this.pushToolResult(formatResponse.toolError(errorMessage), block) + return + } + + // Add context modification to the conversation if provided by the hook + this.addHookContextToConversation(preToolUseResult.contextModification, "PreToolUse") + } catch (hookError) { + // 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: ClineSayHook = { + hookName: "PreToolUse", + toolName: block.name, + status: "failed", + exitCode: hookError instanceof Error ? 1 : undefined, + } + await this.messageStateHandler.updateClineMessage(hookMessageIndex, { + text: JSON.stringify(failedMetadata), + }) + } + } + + const errorMessage = `PreToolUse hook failed: ${hookError.toString()}` await this.say("error", errorMessage) this.pushToolResult(formatResponse.toolError(errorMessage), block) return } - - // Add context modification to the conversation if provided by the hook - this.addHookContextToConversation(preToolUseResult.contextModification, "PreToolUse") - } catch (hookError) { - const errorMessage = `PreToolUse hook failed: ${hookError.toString()}` - await this.say("error", errorMessage) - this.pushToolResult(formatResponse.toolError(errorMessage), block) - return } } @@ -460,26 +514,85 @@ 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 + 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)) - // Add context modification to the conversation if provided by the hook - this.addHookContextToConversation(postToolUseResult.contextModification, "PostToolUse") + // Create streaming callback that displays hook output in real-time + const streamCallback = async (line: string, stream: "stdout" | "stderr") => { + await this.say("hook_output", line) + } - // Log any error messages from the hook - if (postToolUseResult.errorMessage) { - this.say("error", postToolUseResult.errorMessage) + const postToolUseHook = await hookFactory.createWithStreaming("PostToolUse", streamCallback) + + 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, + }, + }) + + // 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") + + // Log any error messages from the hook + if (postToolUseResult.errorMessage) { + this.say("error", postToolUseResult.errorMessage) + } + } catch (hookError) { + // 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: "PostToolUse", + toolName: block.name, + status: "failed", + exitCode: hookError instanceof Error ? 1 : undefined, + } + await this.messageStateHandler.updateClineMessage(hookMessageIndex, { + text: JSON.stringify(failedMetadata), + }) + } + } + + // PostToolUse hook failure is non-fatal, just log it + const errorMessage = `PostToolUse hook failed: ${hookError.toString()}` + await this.say("error", errorMessage) + } } } } diff --git a/src/core/task/index.ts b/src/core/task/index.ts index 15d05742de..f978e7631b 100644 --- a/src/core/task/index.ts +++ b/src/core/task/index.ts @@ -753,10 +753,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 @@ -779,12 +798,45 @@ export class Task { }, }) + // 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, contextModification: result.contextModification, 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,47 +886,97 @@ 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 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: `\n${contextText}\n`, - }) + 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 || "", + }, + }, + }) + + // 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" + 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: `\n${contextText}\n`, + }) + } + } + } catch (hookError) { + // 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: "TaskStart", + status: "failed", + exitCode: hookError instanceof Error ? 1 : undefined, + } + await this.messageStateHandler.updateClineMessage(hookMessageIndex, { + text: JSON.stringify(failedMetadata), + }) + } + } + + 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) } - } 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) } } diff --git a/src/services/feature-flags/FeatureFlagsService.ts b/src/services/feature-flags/FeatureFlagsService.ts index 4ab690e1b4..7f26c0d150 100644 --- a/src/services/feature-flags/FeatureFlagsService.ts +++ b/src/services/feature-flags/FeatureFlagsService.ts @@ -88,7 +88,7 @@ export class FeatureFlagsService { } public getHooksEnabled(): boolean { - return this.getBooleanFlagEnabled(FeatureFlag.HOOKS, false) + return true //this.getBooleanFlagEnabled(FeatureFlag.HOOKS, false) } /** diff --git a/src/shared/ExtensionMessage.ts b/src/shared/ExtensionMessage.ts index 3d442d5fcb..370acfd452 100644 --- a/src/shared/ExtensionMessage.ts +++ b/src/shared/ExtensionMessage.ts @@ -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,14 @@ 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" // Execution status + exitCode?: number // Exit code when completed + hasJsonResponse?: boolean // Whether a JSON response was parsed +} + // 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] diff --git a/src/shared/combineHookSequences.ts b/src/shared/combineHookSequences.ts new file mode 100644 index 0000000000..0c4c305053 --- /dev/null +++ b/src/shared/combineHookSequences.ts @@ -0,0 +1,211 @@ +import { ClineMessage } from "./ExtensionMessage" + +/** + * Combines sequences of hook and hook_output messages in an array of ClineMessages, + * and reorders PreToolUse hooks to appear before their associated tool messages. + * + * This function: + * 1. Combines 'hook' messages with their following 'hook_output' messages + * 2. Reorders PreToolUse hooks to appear BEFORE their associated tool messages + * 3. Keeps PostToolUse hooks AFTER their associated tool messages + * + * @param messages - An array of ClineMessage objects to process. + * @returns A new array of ClineMessage objects with hook sequences combined and reordered. + */ +export function combineHookSequences(messages: ClineMessage[]): ClineMessage[] { + // Filter out partial tool/command messages to prevent duplicates during React render cycles + // (partial messages are removed and replaced with complete versions, but may briefly coexist) + // IMPORTANT: Only filter tool/command messages, NOT reasoning or other message types + const filteredMessages = messages.filter((msg) => { + // NEVER filter reasoning messages, even if they have partial: true + if (msg.say === "reasoning") { + return true // Always keep reasoning messages + } + + // Only check tool and command messages for partial filtering + const isToolMessage = msg.ask === "tool" || msg.say === "tool" + const isCommandMessage = msg.ask === "command" || msg.say === "command" + const isToolOrCommand = isToolMessage || isCommandMessage + + // Keep all messages EXCEPT partial tool/command messages + // This preserves: reasoning (explicitly checked above), text, hooks, and complete tool/command messages + if (isToolOrCommand && msg.partial === true) { + return false // Filter out partial tool/command messages + } + return true // Keep everything else + }) + + const combinedHooks: ClineMessage[] = [] + + // First pass: combine hooks with their outputs (using filtered messages) + for (let i = 0; i < filteredMessages.length; i++) { + if (filteredMessages[i].say === "hook") { + let combinedText = filteredMessages[i].text || "" + let didAddOutput = false + let j = i + 1 + + while (j < filteredMessages.length) { + if (filteredMessages[j].say === "hook") { + // Stop if we encounter the next hook + break + } + if (filteredMessages[j].say === "hook_output") { + if (!didAddOutput) { + // Add a marker before the first output + combinedText += `\n${HOOK_OUTPUT_STRING}` + didAddOutput = true + } + // Handle cases where we receive empty hook_output + const output = filteredMessages[j].text || "" + if (output.length > 0) { + combinedText += "\n" + output + } + } + j++ + } + + combinedHooks.push({ + ...filteredMessages[i], + text: combinedText, + }) + + i = j - 1 // Move to the index just before the next hook or end of array + } + } + + // Second pass: remove hook_outputs and replace original hooks with combined ones (using filtered messages) + const processedMessages = filteredMessages + .filter((msg) => msg.say !== "hook_output") + .map((msg) => { + if (msg.say === "hook") { + const combinedHook = combinedHooks.find((hook) => hook.ts === msg.ts) + return combinedHook || msg + } + return msg + }) + + // Third pass: reorder PreToolUse hooks to appear before their associated tool/command messages + // Build a map of tool timestamps to their PreToolUse hooks + const preToolUseHooksByNextTool = new Map() + + // First scan: identify PreToolUse hooks and map them to the next tool/command + // IMPORTANT: We look for tools in the ORIGINAL messages array to match hooks immediately, + // even if the tool is still partial. This prevents delays in showing hooks. + for (let i = 0; i < processedMessages.length; i++) { + const msg = processedMessages[i] + + if (msg.say === "hook") { + try { + const outputIndex = msg.text?.indexOf(HOOK_OUTPUT_STRING) ?? -1 + const metadataStr = outputIndex !== -1 ? msg.text?.slice(0, outputIndex).trim() : msg.text?.trim() + const metadata = JSON.parse(metadataStr || "{}") + + if (metadata.hookName === "PreToolUse") { + // Find the corresponding tool in the ORIGINAL messages array (not filtered) + // Look backwards from the hook's position in the original array + const hookIndexInOriginal = messages.findIndex((m) => m.ts === msg.ts) + + for (let j = hookIndexInOriginal - 1; j >= 0; j--) { + const prevMsg = messages[j] + const isToolOrCommand = + prevMsg.ask === "tool" || + prevMsg.say === "tool" || + prevMsg.ask === "command" || + prevMsg.say === "command" + + if (isToolOrCommand) { + // Map this hook to appear before this tool + // Use the tool's timestamp even if it's still partial + if (!preToolUseHooksByNextTool.has(prevMsg.ts)) { + preToolUseHooksByNextTool.set(prevMsg.ts, []) + } + preToolUseHooksByNextTool.get(prevMsg.ts)!.push(msg) + break + } + } + } + } catch (e) { + // If parsing fails, continue + } + } + } + + // Second scan: build the reordered array + const reorderedMessages: ClineMessage[] = [] + const processedHookTimestamps = new Set() + const processedToolTimestamps = new Set() + + // Find which tool timestamps actually exist in processedMessages + const availableToolTimestamps = new Set() + for (const msg of processedMessages) { + const isToolOrCommand = msg.ask === "tool" || msg.say === "tool" || msg.ask === "command" || msg.say === "command" + if (isToolOrCommand) { + availableToolTimestamps.add(msg.ts) + } + } + + for (const msg of processedMessages) { + // Check if this tool/command has PreToolUse hooks that should appear before it + const hooksForThisTool = preToolUseHooksByNextTool.get(msg.ts) + if (hooksForThisTool && hooksForThisTool.length > 0) { + // Only insert hooks that haven't been added yet + const hooksToAdd = hooksForThisTool.filter((hook) => !processedHookTimestamps.has(hook.ts)) + + if (hooksToAdd.length > 0) { + // Insert hooks before the tool + reorderedMessages.push(...hooksToAdd) + // Mark these hooks as processed + hooksToAdd.forEach((hook) => processedHookTimestamps.add(hook.ts)) + } + + // Mark this tool as having been processed + processedToolTimestamps.add(msg.ts) + // Add the tool immediately after its hooks + reorderedMessages.push(msg) + continue // Skip the default add at the end + } + + // Check if this tool was already added with its hooks + if (processedToolTimestamps.has(msg.ts)) { + // Skip this tool, it's already been added with its PreToolUse hooks + continue + } + + // Check if this is a PreToolUse hook that will be moved before its tool + if (msg.say === "hook") { + try { + const outputIndex = msg.text?.indexOf(HOOK_OUTPUT_STRING) ?? -1 + const metadataStr = outputIndex !== -1 ? msg.text?.slice(0, outputIndex).trim() : msg.text?.trim() + const metadata = JSON.parse(metadataStr || "{}") + + if (metadata.hookName === "PreToolUse") { + // Find which tool (if any) this hook is mapped to + let matchedToolTimestamp: number | undefined + for (const [toolTs, hooks] of preToolUseHooksByNextTool.entries()) { + if (hooks.some((h) => h.ts === msg.ts)) { + matchedToolTimestamp = toolTs + break + } + } + + // Only skip this hook if its tool is present AND we'll process it before the tool + if (matchedToolTimestamp !== undefined && availableToolTimestamps.has(matchedToolTimestamp)) { + // Skip - already inserted before its tool + continue + } + // Otherwise fall through to add in normal position + } + } catch (e) { + // If parsing fails, fall through to normal processing + } + } + + // Add the message in its normal position + // This includes: PreToolUse hooks whose tools aren't available yet, PostToolUse hooks, reasoning, text, etc. + reorderedMessages.push(msg) + } + + return reorderedMessages +} + +export const HOOK_OUTPUT_STRING = "__HOOK_OUTPUT__" diff --git a/src/shared/proto-conversions/cline-message.ts b/src/shared/proto-conversions/cline-message.ts index 4f04bcbda2..02cbaa69b9 100644 --- a/src/shared/proto-conversions/cline-message.ts +++ b/src/shared/proto-conversions/cline-message.ts @@ -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] diff --git a/webview-ui/src/components/chat/ChatRow.tsx b/webview-ui/src/components/chat/ChatRow.tsx index 2cdf2edc0a..32daabdbce 100644 --- a/webview-ui/src/components/chat/ChatRow.tsx +++ b/webview-ui/src/components/chat/ChatRow.tsx @@ -54,6 +54,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 { @@ -269,6 +285,8 @@ export const ChatRowContent = memo( // Command output expansion state (for all messages, but only used by command messages) const [isOutputFullyExpanded, setIsOutputFullyExpanded] = useState(false) const prevCommandExecutingRef = useRef(false) + // Hook output expansion state (for all messages, but only used by hook messages) + const [isHookOutputExpanded, setIsHookOutputExpanded] = useState(false) const [cost, apiReqCancelReason, apiReqStreamingFailedMessage, retryStatus] = useMemo(() => { if (message.text != null && message.say === "api_req_started") { const info: ClineApiReqInfo = JSON.parse(message.text) @@ -292,6 +310,23 @@ export const ChatRowContent = memo( const isCommandPending = isCommandMessage && isLast && !message.commandCompleted && !commandHasOutput const isCommandCompleted = isCommandMessage && message.commandCompleted === true + // Hook message detection and parsing + const isHookStatusMessage = message.say === "hook" + const isHookOutputMessage = message.say === "hook_output" + const HOOK_OUTPUT_STRING = "__HOOK_OUTPUT__" + + // Parse hook metadata if this is a hook status message + const hookMetadata = useMemo(() => { + if (isHookStatusMessage && message.text) { + try { + return JSON.parse(message.text) + } catch { + return null + } + } + return null + }, [isHookStatusMessage, message.text]) + const isMcpServerResponding = isLast && lastModifiedMessage?.say === "mcp_server_request_started" const type = message.type === "ask" ? message.ask : message.say @@ -1602,6 +1637,158 @@ export const ChatRowContent = memo( ) } + case "hook": { + // Parse hook output similar to command output + 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 || "") + + // Parse the metadata JSON + let hookMetadata: { + hookName: string + toolName?: string + status: string + exitCode?: number + hasJsonResponse?: boolean + } + try { + hookMetadata = JSON.parse(metadataStr) + } catch { + // If parsing fails, still show something + hookMetadata = { hookName: "Unknown", status: "unknown" } + } + + const isRunning = hookMetadata?.status === "running" + const isCompleted = hookMetadata?.status === "completed" + const isFailed = hookMetadata?.status === "failed" + + return ( + <> +
+ + Hook: + {hookMetadata.hookName} + {hookMetadata.toolName && ( + + ({hookMetadata.toolName}) + + )} +
+
+
0 ? "1px solid var(--vscode-editorGroup-border)" : "none", + borderTopLeftRadius: "6px", + borderTopRightRadius: "6px", + }}> +
+
+ + {isRunning + ? "Running" + : isFailed + ? "Failed" + : isCompleted + ? "Completed" + : "Unknown"} + + {hookMetadata.exitCode !== undefined && hookMetadata.exitCode !== 0 && ( + + (exit: {hookMetadata.exitCode}) + + )} +
+
+ {output.length > 0 && ( + setIsHookOutputExpanded(!isHookOutputExpanded)} + output={output} + /> + )} +
+ + ) + } + 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 ( diff --git a/webview-ui/src/components/chat/ChatView.tsx b/webview-ui/src/components/chat/ChatView.tsx index c760d55b5f..6d13454341 100644 --- a/webview-ui/src/components/chat/ChatView.tsx +++ b/webview-ui/src/components/chat/ChatView.tsx @@ -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]) diff --git a/webview-ui/src/components/chat/task-header/TaskTimeline.tsx b/webview-ui/src/components/chat/task-header/TaskTimeline.tsx index a03247bc19..5cad610afa 100644 --- a/webview-ui/src/components/chat/task-header/TaskTimeline.tsx +++ b/webview-ui/src/components/chat/task-header/TaskTimeline.tsx @@ -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 = ({ 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) => {