Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion packages/types/src/message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { z } from "zod"
* - `mistake_limit_reached`: Too many errors encountered, needs user guidance on how to proceed
* - `use_mcp_server`: Permission to use Model Context Protocol (MCP) server functionality
* - `auto_approval_max_req_reached`: Auto-approval limit has been reached, manual approval required
* - `hook`: Approval to execute a prompt-based hook at an agent lifecycle event
*/
export const clineAsks = [
"followup",
Expand All @@ -36,6 +37,7 @@ export const clineAsks = [
"mistake_limit_reached",
"use_mcp_server",
"auto_approval_max_req_reached",
"hook",
] as const

export const clineAskSchema = z.enum(clineAsks)
Expand Down Expand Up @@ -81,7 +83,13 @@ export function isResumableAsk(ask: ClineAsk): ask is ResumableAsk {
* Asks that put the task into an "user interaction required" state.
*/

export const interactiveAsks = ["followup", "command", "tool", "use_mcp_server"] as const satisfies readonly ClineAsk[]
export const interactiveAsks = [
"followup",
"command",
"tool",
"use_mcp_server",
"hook",
] as const satisfies readonly ClineAsk[]

export type InteractiveAsk = (typeof interactiveAsks)[number]

Expand Down Expand Up @@ -170,6 +178,7 @@ export const clineSays = [
"user_edit_todos",
"too_many_tools_warning",
"tool",
"hook_output",
] as const

export const clineSaySchema = z.enum(clineSays)
Expand Down
87 changes: 87 additions & 0 deletions src/core/assistant-message/presentAssistantMessage.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { serializeError } from "serialize-error"
import { executeHooks, formatHookResults } from "../../services/hooks/HookExecutor"
import type { HookContext } from "../../shared/hooks"
import { Anthropic } from "@anthropic-ai/sdk"

import type { ToolName, ClineAsk, ToolProgressStatus } from "@roo-code/types"
Expand Down Expand Up @@ -553,6 +555,12 @@ export async function presentAssistantMessage(cline: Task) {
pushToolResult(formatResponse.toolError(errorString))
}

// === askHookApproval: separate approval flow for prompt-based hooks ===
const askHookApproval = async (hookEvent: string, hookDescription: string): Promise<boolean> => {
const { response } = await cline.ask("hook", JSON.stringify({ hookEvent, hookDescription }))
return response === "yesButtonClicked"
}

if (!block.partial) {
// Check if this is a custom tool - if so, record as "custom_tool" (like MCP tools)
const isCustomTool = stateExperiments?.customTools && customToolRegistry.has(block.name)
Expand Down Expand Up @@ -675,6 +683,37 @@ export async function presentAssistantMessage(cline: Task) {
}
}

// === PreToolUse Hook (fires for ALL tools at the central location) ===
if (!block.partial) {
try {
const hooksManager = cline.providerRef.deref()?.getHooksManager()
if (hooksManager?.hasHooksForEvent("PreToolUse")) {
const matchingHooks = hooksManager.getMatchingHooks("PreToolUse", block.name)
if (matchingHooks.length > 0) {
const hookDescription = `PreToolUse hook for ${block.name} (${matchingHooks.length} hook(s) will run)`
const approved = await askHookApproval("PreToolUse", hookDescription)
if (approved) {
const hookContext: HookContext = {
event: "PreToolUse",
toolName: block.name,
toolInput: block.nativeArgs || block.params,
}
const hookResults = await executeHooks(
matchingHooks,
hookContext,
cline.apiConfiguration,
)
const hookOutput = formatHookResults(hookResults)
if (hookOutput) {
await cline.say("hook_output", `PreToolUse hook for ${block.name}:\n${hookOutput}`)
}
}
}
}
} catch (hookError) {
console.warn(`[presentAssistantMessage] PreToolUse hook error:`, hookError)
}
}
switch (block.name) {
case "write_to_file":
await checkpointSaveAndMark(cline)
Expand Down Expand Up @@ -917,6 +956,54 @@ export async function presentAssistantMessage(cline: Task) {
}
}

// === PostToolUse Hook ===
if (!block.partial) {
try {
const hooksManager = cline.providerRef.deref()?.getHooksManager()
if (hooksManager?.hasHooksForEvent("PostToolUse")) {
const matchingHooks = hooksManager.getMatchingHooks("PostToolUse", block.name)
if (matchingHooks.length > 0) {
const hookDescription = `PostToolUse hook for ${block.name} (${matchingHooks.length} hook(s) will run)`
const approved = await askHookApproval("PostToolUse", hookDescription)
if (approved) {
// Get the last tool result text for context
const lastResult = cline.userMessageContent
.filter(
(b): b is import("@anthropic-ai/sdk").Anthropic.ToolResultBlockParam =>
b.type === "tool_result",
)
.pop()
const resultText =
typeof lastResult?.content === "string"
? lastResult.content
: Array.isArray(lastResult?.content)
? lastResult.content
.filter((b): b is Anthropic.TextBlockParam => b.type === "text")
.map((b) => b.text)
.join("\n") || ""
: ""
const hookContext: HookContext = {
event: "PostToolUse",
toolName: block.name,
toolResult: resultText.slice(0, 2000),
}
const hookResults = await executeHooks(
matchingHooks,
hookContext,
cline.apiConfiguration,
)
const hookOutput = formatHookResults(hookResults)
if (hookOutput) {
await cline.say("hook_output", `PostToolUse hook for ${block.name}:\n${hookOutput}`)
}
}
}
}
} catch (hookError) {
console.warn(`[presentAssistantMessage] PostToolUse hook error:`, hookError)
}
}

break
}
}
Expand Down
25 changes: 25 additions & 0 deletions src/core/tools/AttemptCompletionTool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import type { ToolUse } from "../../shared/tools"
import { t } from "../../i18n"

import { BaseTool, ToolCallbacks } from "./BaseTool"
import { executeHooks, formatHookResults } from "../../services/hooks/HookExecutor"
import type { HookContext } from "../../shared/hooks"

interface AttemptCompletionParams {
result: string
Expand Down Expand Up @@ -78,6 +80,29 @@ export class AttemptCompletionTool extends BaseTool<"attempt_completion"> {

task.consecutiveMistakeCount = 0

// === Stop Hook ===
try {
const hooksManager = task.providerRef.deref()?.getHooksManager()
if (hooksManager?.hasHooksForEvent("Stop")) {
const stopHooks = hooksManager.getHooksForEvent("Stop")
const hookDescription = `Stop hook (${stopHooks.length} hook(s) will run)`
const { response } = await task.ask("hook", JSON.stringify({ hookEvent: "Stop", hookDescription }))
if (response === "yesButtonClicked") {
const hookContext: HookContext = {
event: "Stop",
completionResult: result,
}
const hookResults = await executeHooks(stopHooks, hookContext, task.apiConfiguration)
const hookOutput = formatHookResults(hookResults)
if (hookOutput) {
await task.say("hook_output", `Stop hook:\n${hookOutput}`)
}
}
}
} catch (hookError) {
console.warn(`[AttemptCompletionTool] Stop hook error:`, hookError)
}

await task.say("completion_result", result, undefined, false)

// Force final token usage update before emitting TaskCompleted
Expand Down
13 changes: 13 additions & 0 deletions src/core/webview/ClineProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ import { CodeIndexManager } from "../../services/code-index/manager"
import type { IndexProgressUpdate } from "../../services/code-index/interfaces/manager"
import { MdmService } from "../../services/mdm/MdmService"
import { SkillsManager } from "../../services/skills/SkillsManager"
import { HooksManager } from "../../services/hooks/HooksManager"

import { fileExistsAtPath } from "../../utils/fs"
import { setTtsEnabled, setTtsSpeed } from "../../utils/tts"
Expand Down Expand Up @@ -142,6 +143,7 @@ export class ClineProvider
private _workspaceTracker?: WorkspaceTracker // workSpaceTracker read-only for access outside this class
protected mcpHub?: McpHub // Change from private to protected
protected skillsManager?: SkillsManager
protected hooksManager?: HooksManager
private marketplaceManager: MarketplaceManager
private mdmService?: MdmService
private taskCreationCallback: (task: Task) => void
Expand Down Expand Up @@ -216,6 +218,12 @@ export class ClineProvider
this.log(`Failed to initialize Skills Manager: ${error}`)
})

// Initialize Hooks Manager for prompt-based hooks
this.hooksManager = new HooksManager(this.cwd)
this.hooksManager.initialize().catch((error) => {
this.log(`Failed to initialize Hooks Manager: ${error}`)
})

this.marketplaceManager = new MarketplaceManager(this.context, this.customModesManager)

// Forward <most> task events to the provider.
Expand Down Expand Up @@ -664,6 +672,7 @@ export class ClineProvider
await this.mcpHub?.unregisterClient()
this.mcpHub = undefined
await this.skillsManager?.dispose()
this.hooksManager?.dispose()
this.skillsManager = undefined
this.marketplaceManager?.cleanup()
this.customModesManager?.dispose()
Expand Down Expand Up @@ -2717,6 +2726,10 @@ export class ClineProvider
return this.skillsManager
}

public getHooksManager(): HooksManager | undefined {
return this.hooksManager
}

/**
* Check if the current state is compliant with MDM policy
* @returns true if compliant or no MDM policy exists, false if MDM policy exists and user is non-compliant
Expand Down
147 changes: 147 additions & 0 deletions src/services/hooks/HookExecutor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
/**
* HookExecutor runs prompt-based hooks by sending the hook prompt (with context)
* to the configured API provider via the singleCompletionHandler.
*
* Hooks are read-only: they have no tool access and can only produce advisory text.
* The advisory text is injected into the conversation as informational context.
*/

import type { ProviderSettings } from "@roo-code/types"

import { singleCompletionHandler } from "../../utils/single-completion-handler"
import type { HookDefinition, HookEventName, HookContext, HookResult } from "../../shared/hooks"

/**
* Builds a full prompt string from the hook definition and context.
*/
export function buildHookPrompt(hook: HookDefinition, context: HookContext): string {
const parts: string[] = []

parts.push("You are a hook that runs at a specific point in an AI coding assistant's workflow.")
parts.push("Your role is advisory only - you cannot use tools or take actions.")
parts.push("Provide concise, actionable feedback based on the context below.")
parts.push("")

parts.push(`## Event: ${context.event}`)
parts.push("")

if (context.toolName) {
parts.push(`**Tool:** ${context.toolName}`)
}

if (context.toolInput) {
parts.push(`**Tool Input:**`)
parts.push("```json")
try {
parts.push(JSON.stringify(context.toolInput, null, 2))
} catch {
parts.push("(unable to serialize tool input)")
}
parts.push("```")
}

if (context.toolResult) {
parts.push(`**Tool Result:**`)
// Truncate very long results
const maxResultLen = 2000
const truncated =
context.toolResult.length > maxResultLen
? context.toolResult.slice(0, maxResultLen) + "\n... (truncated)"
: context.toolResult
parts.push(truncated)
}

if (context.completionResult) {
parts.push(`**Completion Result:**`)
parts.push(context.completionResult)
}

if (context.conversationSummary) {
parts.push("")
parts.push("## Recent Conversation Context")
// Truncate to keep within reasonable limits
const maxContextLen = 4000
const truncated =
context.conversationSummary.length > maxContextLen
? context.conversationSummary.slice(0, maxContextLen) + "\n... (truncated)"
: context.conversationSummary
parts.push(truncated)
}

parts.push("")
parts.push("## Your Task")
parts.push(hook.prompt)

return parts.join("\n")
}

/**
* Executes a single hook and returns the result.
*/
export async function executeHook(
hook: HookDefinition,
context: HookContext,
apiConfiguration: ProviderSettings,
hookIndex: number,
): Promise<HookResult | null> {
try {
const prompt = buildHookPrompt(hook, context)
const output = await singleCompletionHandler(apiConfiguration, prompt)

if (!output || output.trim().length === 0) {
return null
}

return {
output: output.trim(),
event: context.event,
hookIndex,
}
} catch (error) {
console.warn(`[HookExecutor] Failed to execute hook (${context.event}[${hookIndex}]):`, error)
return null
}
}

/**
* Executes all matching hooks for an event and returns their combined results.
* Hooks are executed sequentially to avoid overwhelming the API.
*/
export async function executeHooks(
hooks: HookDefinition[],
context: HookContext,
apiConfiguration: ProviderSettings,
): Promise<HookResult[]> {
const results: HookResult[] = []

for (let i = 0; i < hooks.length; i++) {
const result = await executeHook(hooks[i], context, apiConfiguration, i)

if (result) {
results.push(result)
}
}

return results
}

/**
* Formats hook results into a text block that can be injected into the
* conversation context.
*/
export function formatHookResults(results: HookResult[]): string {
if (results.length === 0) {
return ""
}

const parts: string[] = []
parts.push("[Hook Advisory Output]")

for (const result of results) {
parts.push(result.output)
}

parts.push("[End Hook Advisory Output]")

return parts.join("\n\n")
}
Loading
Loading