mirror of
https://github.com/cline/cline.git
synced 2026-09-15 13:02:17 +08:00
feat(hooks): Initial implementation of hooks UI using background terminal UI
This commit is contained in:
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user