feat(hooks): Initial implementation of hooks UI using background terminal UI

This commit is contained in:
Eve Killaby
2025-10-22 15:49:51 -07:00
parent a820026e0b
commit 0a88976b44
11 changed files with 1085 additions and 144 deletions
+248
View File
@@ -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<void> {
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
}
}
}
+124 -61
View File
@@ -1,4 +1,3 @@
import { spawn } from "node:child_process"
import fs from "fs/promises"
import path from "path"
import { version as clineVersion } from "../../../package.json"
@@ -17,6 +16,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<Name extends HookName> extends HookRunner<Name> {
}
}
// Actually runs a hook by executing a script and passing JSON into it.
/**
* Callback type for streaming hook output
*/
export type HookStreamCallback = (line: string, stream: "stdout" | "stderr") => void
/**
* Actually runs a hook by executing a script with streaming support.
*/
class StdioHookRunner<Name extends HookName> extends HookRunner<Name> {
constructor(
hookName: Name,
public readonly scriptPath: string,
private readonly streamCallback?: HookStreamCallback,
) {
super(hookName)
}
override async [exec](input: HookInput): Promise<HookOutput> {
return new Promise((resolve, reject) => {
// Serialize input to JSON
const inputJson = JSON.stringify(HookInput.toJSON(input))
// 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<Name extends HookName> extends HookRunner<Name> {
"\n\n[... context truncated due to size limit ...]"
}
resolve(output)
} catch (error) {
reject(new Error(`Failed to parse hook output: ${error}. stdout: ${stdout}`))
}
})
return output
} catch (parseError) {
// 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<Name extends HookName>(hookName: Name): Promise<HookRunner<Name>> {
/**
* Check if any hook scripts exist for the given hook name
* @returns true if at least one hook script exists, false otherwise
*/
async hasHook<Name extends HookName>(hookName: Name): Promise<boolean> {
const scripts = await HookFactory.findHookScripts(hookName)
const runners = scripts.map((script) => new StdioHookRunner(hookName, script))
return scripts.length > 0
}
/**
* Create a hook runner without streaming support (backwards compatible)
*/
async create<Name extends HookName>(hookName: Name): Promise<HookRunner<Name>> {
return this.createWithStreaming(hookName)
}
/**
* Create a hook runner with optional streaming callback support
*/
async createWithStreaming<Name extends HookName>(
hookName: Name,
streamCallback?: HookStreamCallback,
): Promise<HookRunner<Name>> {
const scripts = await HookFactory.findHookScripts(hookName)
const runners = scripts.map((script) => new StdioHookRunner(hookName, script, streamCallback))
if (runners.length === 0) {
return new NoOpRunner(hookName)
}
+152 -39
View File
@@ -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)
}
}
}
}
+143 -41
View File
@@ -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: `<hook_context source="TaskStart">\n${contextText}\n</hook_context>`,
})
if (hasTaskStartHook) {
let hookMessageTs: number | undefined
try {
// Show hook execution indicator and capture timestamp
const hookMetadata = {
hookName: "TaskStart",
status: "running",
}
hookMessageTs = await this.say("hook", JSON.stringify(hookMetadata))
// Create streaming callback
const streamCallback = async (line: string, stream: "stdout" | "stderr") => {
await this.say("hook_output", line)
}
const taskStartHook = await hookFactory.createWithStreaming("TaskStart", streamCallback)
const taskStartResult = await taskStartHook.run({
taskId: this.taskId,
taskStart: {
taskMetadata: {
taskId: this.taskId,
ulid: this.ulid,
initialTask: task || "",
},
},
})
// 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: `<hook_context source="TaskStart">\n${contextText}\n</hook_context>`,
})
}
}
} 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)
}
}
@@ -88,7 +88,7 @@ export class FeatureFlagsService {
}
public getHooksEnabled(): boolean {
return this.getBooleanFlagEnabled(FeatureFlag.HOOKS, false)
return true //this.getBooleanFlagEnabled(FeatureFlag.HOOKS, false)
}
/**
+10
View File
@@ -167,6 +167,8 @@ export type ClineSay =
| "load_mcp_documentation"
| "info" // Added for general informational messages like retry status
| "task_progress"
| "hook" // Hook execution indicator
| "hook_output" // Hook streaming output
export interface ClineSayTool {
tool:
@@ -187,6 +189,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]
+211
View File
@@ -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<number, ClineMessage[]>()
// 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<number>()
const processedToolTimestamps = new Set<number>()
// Find which tool timestamps actually exist in processedMessages
const availableToolTimestamps = new Set<number>()
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__"
@@ -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]
+187
View File
@@ -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<boolean>(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(
</div>
)
}
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 (
<>
<div style={headerStyle}>
<span
className="codicon codicon-symbol-event"
style={{
color: normalColor,
marginBottom: "-1.5px",
}}></span>
<span style={{ color: normalColor, fontWeight: "bold" }}>Hook:</span>
<span style={{ color: normalColor }}>{hookMetadata.hookName}</span>
{hookMetadata.toolName && (
<span style={{ color: "var(--vscode-descriptionForeground)", fontSize: "0.9em" }}>
({hookMetadata.toolName})
</span>
)}
</div>
<div
style={{
borderRadius: 6,
border: "1px solid var(--vscode-editorGroup-border)",
overflow: "hidden",
backgroundColor: CHAT_ROW_EXPANDED_BG_COLOR,
transition: "all 0.3s ease-in-out",
}}>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "8px 10px",
backgroundColor: CHAT_ROW_EXPANDED_BG_COLOR,
borderBottom:
output.length > 0 ? "1px solid var(--vscode-editorGroup-border)" : "none",
borderTopLeftRadius: "6px",
borderTopRightRadius: "6px",
}}>
<div
style={{
display: "flex",
alignItems: "center",
gap: "8px",
flex: 1,
minWidth: 0,
}}>
<div
style={{
width: "8px",
height: "8px",
borderRadius: "50%",
backgroundColor: isRunning
? successColor
: isFailed
? errorColor
: successColor,
animation: isRunning ? "pulse 2s ease-in-out infinite" : "none",
flexShrink: 0,
}}
/>
<span
style={{
color: isRunning ? successColor : isFailed ? errorColor : successColor,
fontWeight: 500,
fontSize: "13px",
flexShrink: 0,
}}>
{isRunning
? "Running"
: isFailed
? "Failed"
: isCompleted
? "Completed"
: "Unknown"}
</span>
{hookMetadata.exitCode !== undefined && hookMetadata.exitCode !== 0 && (
<span
style={{
color: "var(--vscode-descriptionForeground)",
fontSize: "12px",
}}>
(exit: {hookMetadata.exitCode})
</span>
)}
</div>
</div>
{output.length > 0 && (
<CommandOutput
isContainerExpanded={true}
isOutputFullyExpanded={isHookOutputExpanded}
onToggle={() => setIsHookOutputExpanded(!isHookOutputExpanded)}
output={output}
/>
)}
</div>
</>
)
}
case "hook_output":
// hook_output messages are combined with hook messages, so we don't render them separately
return null
case "shell_integration_warning_with_suggestion":
const isBackgroundModeEnabled = vscodeTerminalExecutionMode === "backgroundExec"
return (
+5 -1
View File
@@ -1,6 +1,7 @@
import { findLast } from "@shared/array"
import { combineApiRequests } from "@shared/combineApiRequests"
import { combineCommandSequences } from "@shared/combineCommandSequences"
import { combineHookSequences } from "@shared/combineHookSequences"
import type { ClineApiReqInfo, ClineMessage } from "@shared/ExtensionMessage"
import { getApiMetrics } from "@shared/getApiMetrics"
import { BooleanRequest, StringRequest } from "@shared/proto/cline/common"
@@ -57,7 +58,10 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
//const task = messages.length > 0 ? (messages[0].say === "task" ? messages[0] : undefined) : undefined) : undefined
const task = useMemo(() => messages.at(0), [messages]) // leaving this less safe version here since if the first message is not a task, then the extension is in a bad state and needs to be debugged (see Cline.abort)
const modifiedMessages = useMemo(() => combineApiRequests(combineCommandSequences(messages.slice(1))), [messages])
const modifiedMessages = useMemo(
() => combineApiRequests(combineCommandSequences(combineHookSequences(messages.slice(1)))),
[messages],
)
// has to be after api_req_finished are all reduced into api_req_started messages
const apiMetrics = useMemo(() => getApiMetrics(modifiedMessages), [modifiedMessages])
@@ -1,5 +1,6 @@
import { combineApiRequests } from "@shared/combineApiRequests"
import { combineCommandSequences } from "@shared/combineCommandSequences"
import { combineHookSequences } from "@shared/combineHookSequences"
import { ClineMessage } from "@shared/ExtensionMessage"
import React, { useCallback, useEffect, useMemo, useRef } from "react"
import { Virtuoso } from "react-virtuoso"
@@ -27,7 +28,7 @@ const TaskTimeline: React.FC<TaskTimelineProps> = ({ messages, onBlockClick }) =
return { taskTimelinePropsMessages: [], messageIndexMap: [] }
}
const processed = combineApiRequests(combineCommandSequences(messages.slice(1)))
const processed = combineApiRequests(combineCommandSequences(combineHookSequences(messages.slice(1))))
const indexMap: number[] = []
const filtered = processed.filter((msg, _processedIndex) => {