feat: implement Claude Code-style hooks system

Add lifecycle hooks for tool execution with 12 event types:
- PreToolUse, PostToolUse, PostToolUseFailure, PermissionRequest
- SessionStart, SessionEnd, Stop, SubagentStart, SubagentStop
- UserPromptSubmit, Notification, PreCompact

Core implementation:
- HookManager service with config loading, validation, and execution
- HookExecutor for shell command execution with timeout, stdin JSON, env vars
- HookConfigLoader with project/global/mode-specific precedence
- HookMatcher for pattern matching (exact, regex, glob)
- ToolExecutionHooks adapter for pipeline interception

Tool pipeline integration:
- PreToolUse hooks can block or modify tool input
- PostToolUse/PostToolUseFailure for non-blocking notifications
- PermissionRequest hooks before approval prompts

Webview UI:
- Hooks tab in Settings with enable/disable toggles
- Hook Activity log with real-time status updates
- Reload config and open folder actions

Includes comprehensive tests for all components.

Relates to: Claude Code hooks feature parity
This commit is contained in:
Toray Altas 2026-01-16 14:49:38 -05:00
parent f48ea389df
commit 22eda99812
23 changed files with 5589 additions and 32 deletions

View file

@ -95,6 +95,7 @@ export interface ExtensionMessage {
| "customToolsResult"
| "modes"
| "taskWithAggregatedCosts"
| "hookExecutionStatus"
text?: string
payload?: any // eslint-disable-line @typescript-eslint/no-explicit-any
checkpointWarning?: {
@ -190,6 +191,96 @@ export interface ExtensionMessage {
childrenCost: number
}
historyItem?: HistoryItem
hookExecutionStatus?: HookExecutionStatusPayload
}
/**
* HookExecutionStatusPayload
* Sent when hook execution starts, completes, or fails.
*/
export interface HookExecutionStatusPayload {
/** Status of the hook execution */
status: "running" | "completed" | "failed" | "blocked"
/** Event type that triggered the hook */
event: string
/** Tool name if this is a tool-related event */
toolName?: string
/** Hook ID being executed */
hookId?: string
/** Duration in milliseconds (only for completed/failed) */
duration?: number
/** Error message if failed */
error?: string
/** Block message if hook blocked the operation */
blockMessage?: string
/** Whether tool input was modified */
modified?: boolean
}
/**
* Serializable hook information for webview display.
* This is a subset of ResolvedHook that can be safely serialized to JSON.
*/
export interface HookInfo {
/** Unique identifier for this hook */
id: string
/** The event type this hook is registered for */
event: string
/** Tool name filter (regex/glob pattern) */
matcher?: string
/** Preview of the command (truncated for display) */
commandPreview: string
/** Whether this hook is enabled */
enabled: boolean
/** Source of this hook configuration */
source: "project" | "mode" | "global"
/** Timeout in seconds */
timeout: number
/** Override shell if specified */
shell?: string
/** Human-readable description */
description?: string
}
/**
* Serializable hook execution record for webview display.
*/
export interface HookExecutionRecord {
/** When the hook was executed (ISO string) */
timestamp: string
/** The hook ID that was executed */
hookId: string
/** The event that triggered execution */
event: string
/** Tool name if this was a tool-related event */
toolName?: string
/** Exit code from the process */
exitCode: number | null
/** Execution duration in milliseconds */
duration: number
/** Whether the hook timed out */
timedOut: boolean
/** Whether the hook blocked execution */
blocked: boolean
/** Error message if the hook failed */
error?: string
/** Block message if the hook blocked */
blockMessage?: string
}
/**
* Hooks state for webview display.
* Contains all information needed to render the Hooks settings tab.
*/
export interface HooksState {
/** Array of resolved hooks with display information */
enabledHooks: HookInfo[]
/** Recent execution history (last N records) */
executionHistory: HookExecutionRecord[]
/** Whether project-level hooks are present (for security warnings) */
hasProjectHooks: boolean
/** When the config snapshot was last loaded (ISO string) */
snapshotTimestamp?: string
}
export type ExtensionState = Pick<
@ -335,6 +426,9 @@ export type ExtensionState = Pick<
claudeCodeIsAuthenticated?: boolean
openAiCodexIsAuthenticated?: boolean
debug?: boolean
/** Hooks configuration and execution state for the Hooks settings tab */
hooks?: HooksState
}
export interface Command {
@ -521,6 +615,9 @@ export interface WebviewMessage {
| "requestModes"
| "switchMode"
| "debugSetting"
| "hooksReloadConfig"
| "hooksSetEnabled"
| "hooksOpenConfigFolder"
text?: string
editedMessageContent?: string
tab?: "settings" | "history" | "mcp" | "modes" | "chat" | "marketplace" | "cloud"
@ -576,6 +673,9 @@ export interface WebviewMessage {
list?: string[] // For dismissedUpsells response
organizationId?: string | null // For organization switching
useProviderSignup?: boolean // For rooCloudSignIn to use provider signup flow
hookId?: string // For hooksSetEnabled
hookEnabled?: boolean // For hooksSetEnabled
hooksSource?: "global" | "project" // For hooksOpenConfigFolder
codeIndexSettings?: {
// Global state settings
codebaseIndexEnabled: boolean

View file

@ -0,0 +1,257 @@
// npx vitest src/core/assistant-message/__tests__/presentAssistantMessage-hooks.spec.ts
import { describe, it, expect, beforeEach, vi } from "vitest"
// Mock dependencies that are noisy / unrelated to these tests
vi.mock("../../task/Task")
vi.mock("../../tools/validateToolUse", () => ({
validateToolUse: vi.fn(),
}))
vi.mock("@roo-code/telemetry", () => ({
TelemetryService: {
instance: {
captureToolUsage: vi.fn(),
captureConsecutiveMistakeError: vi.fn(),
captureException: vi.fn(),
},
},
}))
// Mock a tool that uses askApproval so we can exercise the PermissionRequest integration.
vi.mock("../../tools/ListFilesTool", () => ({
listFilesTool: {
handle: vi.fn(async (_task: any, block: any, callbacks: any) => {
// Allow tests to trigger a failure path without real side effects.
if (block?.params?.path === "FAIL") {
await callbacks.handleError("listing files", new Error("boom"))
return
}
const didApprove = await callbacks.askApproval("tool", `list_files:${String(block?.params?.path ?? "")}`)
if (!didApprove) {
return
}
callbacks.pushToolResult("ok")
}),
},
}))
let presentAssistantMessage: (task: any) => Promise<void>
describe("presentAssistantMessage - hooks integration", () => {
let mockTask: any
beforeEach(async () => {
if (!presentAssistantMessage) {
;({ presentAssistantMessage } = await import("../presentAssistantMessage"))
}
mockTask = {
taskId: "test-task-id",
instanceId: "test-instance",
cwd: "/project",
abort: false,
presentAssistantMessageLocked: false,
presentAssistantMessageHasPendingUpdates: false,
currentStreamingContentIndex: 0,
assistantMessageContent: [],
userMessageContent: [],
didCompleteReadingStream: false,
didRejectTool: false,
didAlreadyUseTool: false,
diffEnabled: false,
consecutiveMistakeCount: 0,
api: {
getModel: () => ({ id: "test-model", info: {} }),
},
browserSession: {
closeBrowser: vi.fn().mockResolvedValue(undefined),
},
recordToolUsage: vi.fn(),
toolRepetitionDetector: {
check: vi.fn().mockReturnValue({ allowExecution: true }),
},
toolExecutionHooks: {
executePermissionRequest: vi.fn().mockResolvedValue({ proceed: true, hookResult: {} }),
executePreToolUse: vi.fn().mockResolvedValue({ proceed: true, hookResult: {} }),
executePostToolUse: vi.fn().mockResolvedValue({ results: [], blocked: false, totalDuration: 0 }),
executePostToolUseFailure: vi.fn().mockResolvedValue({ results: [], blocked: false, totalDuration: 0 }),
},
providerRef: {
deref: () => ({
getState: vi.fn().mockResolvedValue({
mode: "code",
customModes: [],
}),
}),
},
say: vi.fn().mockResolvedValue(undefined),
// ask() is called by presentAssistantMessage via askApproval
ask: vi.fn().mockResolvedValue({ response: "yesButtonClicked" }),
}
mockTask.pushToolResultToUserContent = vi.fn().mockImplementation((toolResult: any) => {
const existingResult = mockTask.userMessageContent.find(
(block: any) => block.type === "tool_result" && block.tool_use_id === toolResult.tool_use_id,
)
if (existingResult) {
return false
}
mockTask.userMessageContent.push(toolResult)
return true
})
})
it("PreToolUse can block execution", async () => {
const toolCallId = "tool_call_block"
mockTask.assistantMessageContent = [
{
type: "tool_use",
id: toolCallId,
name: "list_files",
params: { path: "." },
},
]
mockTask.toolExecutionHooks.executePreToolUse.mockResolvedValue({
proceed: false,
blockReason: "nope",
hookResult: { results: [], blocked: true, totalDuration: 1 },
})
await presentAssistantMessage(mockTask)
expect(mockTask.toolExecutionHooks.executePreToolUse).toHaveBeenCalledTimes(1)
// Should not even attempt to show approval prompt
expect(mockTask.toolExecutionHooks.executePermissionRequest).not.toHaveBeenCalled()
// Should emit a tool_result (native protocol) with the denial message
const toolResult = mockTask.userMessageContent.find(
(item: any) => item.type === "tool_result" && item.tool_use_id === toolCallId,
)
expect(toolResult).toBeDefined()
expect(toolResult.content).toContain("nope")
})
it("PreToolUse can modify tool input", async () => {
const toolCallId = "tool_call_modify"
mockTask.assistantMessageContent = [
{
type: "tool_use",
id: toolCallId,
name: "list_files",
params: { path: "original" },
},
]
mockTask.toolExecutionHooks.executePreToolUse.mockResolvedValue({
proceed: true,
modifiedInput: { path: "modified" },
hookResult: { results: [], blocked: false, totalDuration: 1 },
})
let askedPartialMessage: string | undefined
mockTask.ask = vi.fn().mockImplementation(async (_type: string, partialMessage?: string) => {
askedPartialMessage = partialMessage
return { response: "yesButtonClicked" }
})
await presentAssistantMessage(mockTask)
expect(mockTask.toolExecutionHooks.executePreToolUse).toHaveBeenCalledTimes(1)
// list_files should invoke askApproval via our mock; its message should contain the modified path.
expect(askedPartialMessage).toContain("modified")
})
it("PostToolUse is invoked on success", async () => {
const toolCallId = "tool_call_post_success"
mockTask.assistantMessageContent = [
{
type: "tool_use",
id: toolCallId,
name: "list_files",
params: { path: "." },
},
]
mockTask.ask = vi.fn().mockResolvedValue({ response: "yesButtonClicked" })
await presentAssistantMessage(mockTask)
expect(mockTask.toolExecutionHooks.executePostToolUse).toHaveBeenCalledTimes(1)
expect(mockTask.toolExecutionHooks.executePostToolUseFailure).not.toHaveBeenCalled()
})
it("PostToolUseFailure is invoked on tool failure", async () => {
const toolCallId = "tool_call_post_failure"
mockTask.assistantMessageContent = [
{
type: "tool_use",
id: toolCallId,
name: "list_files",
params: { path: "FAIL" },
},
]
await presentAssistantMessage(mockTask)
expect(mockTask.toolExecutionHooks.executePostToolUseFailure).toHaveBeenCalledTimes(1)
})
it("PermissionRequest hook runs before approval prompt", async () => {
const toolCallId = "tool_call_permission_request"
mockTask.assistantMessageContent = [
{
type: "tool_use",
id: toolCallId,
name: "list_files",
params: { path: "." },
},
]
const calls: string[] = []
mockTask.toolExecutionHooks.executePermissionRequest = vi.fn().mockImplementation(async () => {
calls.push("permission")
return { proceed: true, hookResult: {} }
})
mockTask.ask = vi.fn().mockImplementation(async () => {
calls.push("ask")
return { response: "yesButtonClicked" }
})
await presentAssistantMessage(mockTask)
expect(calls[0]).toBe("permission")
expect(calls[1]).toBe("ask")
})
it("PermissionRequest hook can block showing approval prompt", async () => {
const toolCallId = "tool_call_permission_block"
mockTask.assistantMessageContent = [
{
type: "tool_use",
id: toolCallId,
name: "list_files",
params: { path: "." },
},
]
mockTask.toolExecutionHooks.executePermissionRequest = vi.fn().mockResolvedValue({
proceed: false,
blockReason: "blocked by policy",
hookResult: { results: [], blocked: true, totalDuration: 1 },
})
await presentAssistantMessage(mockTask)
// ask() should never be called if hook blocks the permission prompt
expect(mockTask.ask).not.toHaveBeenCalled()
const toolResult = mockTask.userMessageContent.find(
(item: any) => item.type === "tool_result" && item.tool_use_id === toolCallId,
)
expect(toolResult).toBeDefined()
expect(toolResult.content).toContain("blocked by policy")
})
})

View file

@ -28,6 +28,7 @@ describe("presentAssistantMessage - Image Handling in Native Tool Calls", () =>
mockTask = {
taskId: "test-task-id",
instanceId: "test-instance",
cwd: "/project",
abort: false,
presentAssistantMessageLocked: false,
presentAssistantMessageHasPendingUpdates: false,
@ -49,6 +50,12 @@ describe("presentAssistantMessage - Image Handling in Native Tool Calls", () =>
toolRepetitionDetector: {
check: vi.fn().mockReturnValue({ allowExecution: true }),
},
toolExecutionHooks: {
executePermissionRequest: vi.fn().mockResolvedValue({ proceed: true, hookResult: {} }),
executePreToolUse: vi.fn().mockResolvedValue({ proceed: true, hookResult: {} }),
executePostToolUse: vi.fn().mockResolvedValue({ results: [], blocked: false, totalDuration: 0 }),
executePostToolUseFailure: vi.fn().mockResolvedValue({ results: [], blocked: false, totalDuration: 0 }),
},
providerRef: {
deref: () => ({
getState: vi.fn().mockResolvedValue({

View file

@ -376,6 +376,25 @@ export async function presentAssistantMessage(cline: Task) {
const state = await cline.providerRef.deref()?.getState()
const { mode, customModes, experiments: stateExperiments } = state ?? {}
const buildToolExecutionHookContext = (toolName: string, toolInput: Record<string, unknown>) => {
const projectDirectory = cline.cwd
const projectName = projectDirectory.split(/[/\\]/).filter(Boolean).pop() ?? projectDirectory
return {
toolName,
toolInput,
session: {
taskId: cline.taskId,
sessionId: cline.instanceId,
mode: mode ?? defaultModeSlug,
},
project: {
directory: projectDirectory,
name: projectName,
},
}
}
const toolDescription = (): string => {
switch (block.name) {
case "execute_command":
@ -509,6 +528,7 @@ export async function presentAssistantMessage(cline: Task) {
// Track if we've already pushed a tool result for this tool call (native protocol only)
let hasToolResult = false
let toolOutputForHooks: ToolResponse | undefined
// Determine protocol by checking if this tool call has an ID.
// Native protocol tool calls ALWAYS have an ID (set when parsed from tool_call chunks).
@ -524,6 +544,13 @@ export async function presentAssistantMessage(cline: Task) {
let approvalFeedback: { text: string; images?: string[] } | undefined
const pushToolResult = (content: ToolResponse) => {
// Capture the final tool output for PostToolUse hooks.
// Note: This captures the first tool_result emitted by the tool.
// Tools generally emit a single tool result; if a tool emits multiple, we only retain the first.
if (toolOutputForHooks === undefined) {
toolOutputForHooks = content
}
if (toolProtocol === TOOL_PROTOCOL.NATIVE) {
// For native protocol, only allow ONE tool_result per tool call
if (hasToolResult) {
@ -638,12 +665,41 @@ export async function presentAssistantMessage(cline: Task) {
// allow multiple tool calls in sequence (don't set didAlreadyUseTool)
}
let toolDeniedByHook = false
let toolDeniedByUser = false
let toolExecutionHadFailure = false
let toolFailureAction: string | undefined
let toolFailureMessage: string | undefined
let toolInputForHooks = (block.nativeArgs ?? block.params ?? {}) as Record<string, unknown>
let blockToExecute: any = block
const askApproval = async (
type: ClineAsk,
partialMessage?: string,
progressStatus?: ToolProgressStatus,
isProtected?: boolean,
) => {
// Hooks: PermissionRequest (blocking)
// This hook is invoked immediately before the user approval prompt is shown.
// It must NOT bypass existing approval rules (it can only deny).
if (!block.partial && !toolDeniedByHook) {
const permissionRequest = await cline.toolExecutionHooks.executePermissionRequest(
buildToolExecutionHookContext(blockToExecute.name, toolInputForHooks),
)
if (!permissionRequest.proceed) {
toolDeniedByHook = true
pushToolResult(
formatResponse.toolDeniedWithFeedback(
permissionRequest.blockReason ?? "Blocked by PermissionRequest hook",
toolProtocol,
),
)
cline.didRejectTool = true
return false
}
}
const { response, text, images } = await cline.ask(
type,
partialMessage,
@ -654,6 +710,7 @@ export async function presentAssistantMessage(cline: Task) {
if (response !== "yesButtonClicked") {
// Handle both messageResponse and noButtonClicked with text.
toolDeniedByUser = true
if (text) {
await cline.say("user_feedback", text, images)
pushToolResult(
@ -696,6 +753,9 @@ export async function presentAssistantMessage(cline: Task) {
return
}
const errorString = `Error ${action}: ${JSON.stringify(serializeError(error))}`
toolExecutionHadFailure = true
toolFailureAction = action
toolFailureMessage = error.message ?? errorString
await cline.say(
"error",
@ -869,10 +929,39 @@ export async function presentAssistantMessage(cline: Task) {
}
}
switch (block.name) {
// Hooks: PreToolUse (blocking / may modify tool input)
if (!block.partial) {
const pre = await cline.toolExecutionHooks.executePreToolUse(
buildToolExecutionHookContext(blockToExecute.name, toolInputForHooks),
)
if (!pre.proceed) {
pushToolResult(
formatResponse.toolDeniedWithFeedback(
pre.blockReason ?? "Blocked by PreToolUse hook",
toolProtocol,
),
)
cline.didRejectTool = true
break
}
if (pre.modifiedInput) {
toolInputForHooks = pre.modifiedInput
blockToExecute = {
...block,
params: pre.modifiedInput,
nativeArgs: block.nativeArgs ? pre.modifiedInput : undefined,
}
}
}
const toolExecutionStart = Date.now()
switch (blockToExecute.name) {
case "write_to_file":
await checkpointSaveAndMark(cline)
await writeToFileTool.handle(cline, block as ToolUse<"write_to_file">, {
await writeToFileTool.handle(cline, blockToExecute as ToolUse<"write_to_file">, {
askApproval,
handleError,
pushToolResult,
@ -881,7 +970,7 @@ export async function presentAssistantMessage(cline: Task) {
})
break
case "update_todo_list":
await updateTodoListTool.handle(cline, block as ToolUse<"update_todo_list">, {
await updateTodoListTool.handle(cline, blockToExecute as ToolUse<"update_todo_list">, {
askApproval,
handleError,
pushToolResult,
@ -895,7 +984,7 @@ export async function presentAssistantMessage(cline: Task) {
// Check if this tool call came from native protocol by checking for ID
// Native calls always have IDs, XML calls never do
if (toolProtocol === TOOL_PROTOCOL.NATIVE) {
await applyDiffToolClass.handle(cline, block as ToolUse<"apply_diff">, {
await applyDiffToolClass.handle(cline, blockToExecute as ToolUse<"apply_diff">, {
askApproval,
handleError,
pushToolResult,
@ -918,9 +1007,16 @@ export async function presentAssistantMessage(cline: Task) {
}
if (isMultiFileApplyDiffEnabled) {
await applyDiffTool(cline, block, askApproval, handleError, pushToolResult, removeClosingTag)
await applyDiffTool(
cline,
blockToExecute,
askApproval,
handleError,
pushToolResult,
removeClosingTag,
)
} else {
await applyDiffToolClass.handle(cline, block as ToolUse<"apply_diff">, {
await applyDiffToolClass.handle(cline, blockToExecute as ToolUse<"apply_diff">, {
askApproval,
handleError,
pushToolResult,
@ -932,7 +1028,7 @@ export async function presentAssistantMessage(cline: Task) {
}
case "search_and_replace":
await checkpointSaveAndMark(cline)
await searchAndReplaceTool.handle(cline, block as ToolUse<"search_and_replace">, {
await searchAndReplaceTool.handle(cline, blockToExecute as ToolUse<"search_and_replace">, {
askApproval,
handleError,
pushToolResult,
@ -942,7 +1038,7 @@ export async function presentAssistantMessage(cline: Task) {
break
case "search_replace":
await checkpointSaveAndMark(cline)
await searchReplaceTool.handle(cline, block as ToolUse<"search_replace">, {
await searchReplaceTool.handle(cline, blockToExecute as ToolUse<"search_replace">, {
askApproval,
handleError,
pushToolResult,
@ -952,7 +1048,7 @@ export async function presentAssistantMessage(cline: Task) {
break
case "edit_file":
await checkpointSaveAndMark(cline)
await editFileTool.handle(cline, block as ToolUse<"edit_file">, {
await editFileTool.handle(cline, blockToExecute as ToolUse<"edit_file">, {
askApproval,
handleError,
pushToolResult,
@ -962,7 +1058,7 @@ export async function presentAssistantMessage(cline: Task) {
break
case "apply_patch":
await checkpointSaveAndMark(cline)
await applyPatchTool.handle(cline, block as ToolUse<"apply_patch">, {
await applyPatchTool.handle(cline, blockToExecute as ToolUse<"apply_patch">, {
askApproval,
handleError,
pushToolResult,
@ -972,7 +1068,7 @@ export async function presentAssistantMessage(cline: Task) {
break
case "read_file":
// Type assertion is safe here because we're in the "read_file" case
await readFileTool.handle(cline, block as ToolUse<"read_file">, {
await readFileTool.handle(cline, blockToExecute as ToolUse<"read_file">, {
askApproval,
handleError,
pushToolResult,
@ -981,7 +1077,7 @@ export async function presentAssistantMessage(cline: Task) {
})
break
case "fetch_instructions":
await fetchInstructionsTool.handle(cline, block as ToolUse<"fetch_instructions">, {
await fetchInstructionsTool.handle(cline, blockToExecute as ToolUse<"fetch_instructions">, {
askApproval,
handleError,
pushToolResult,
@ -990,7 +1086,7 @@ export async function presentAssistantMessage(cline: Task) {
})
break
case "list_files":
await listFilesTool.handle(cline, block as ToolUse<"list_files">, {
await listFilesTool.handle(cline, blockToExecute as ToolUse<"list_files">, {
askApproval,
handleError,
pushToolResult,
@ -999,7 +1095,7 @@ export async function presentAssistantMessage(cline: Task) {
})
break
case "codebase_search":
await codebaseSearchTool.handle(cline, block as ToolUse<"codebase_search">, {
await codebaseSearchTool.handle(cline, blockToExecute as ToolUse<"codebase_search">, {
askApproval,
handleError,
pushToolResult,
@ -1008,7 +1104,7 @@ export async function presentAssistantMessage(cline: Task) {
})
break
case "search_files":
await searchFilesTool.handle(cline, block as ToolUse<"search_files">, {
await searchFilesTool.handle(cline, blockToExecute as ToolUse<"search_files">, {
askApproval,
handleError,
pushToolResult,
@ -1019,7 +1115,7 @@ export async function presentAssistantMessage(cline: Task) {
case "browser_action":
await browserActionTool(
cline,
block as ToolUse<"browser_action">,
blockToExecute as ToolUse<"browser_action">,
askApproval,
handleError,
pushToolResult,
@ -1027,7 +1123,7 @@ export async function presentAssistantMessage(cline: Task) {
)
break
case "execute_command":
await executeCommandTool.handle(cline, block as ToolUse<"execute_command">, {
await executeCommandTool.handle(cline, blockToExecute as ToolUse<"execute_command">, {
askApproval,
handleError,
pushToolResult,
@ -1036,7 +1132,7 @@ export async function presentAssistantMessage(cline: Task) {
})
break
case "use_mcp_tool":
await useMcpToolTool.handle(cline, block as ToolUse<"use_mcp_tool">, {
await useMcpToolTool.handle(cline, blockToExecute as ToolUse<"use_mcp_tool">, {
askApproval,
handleError,
pushToolResult,
@ -1045,7 +1141,7 @@ export async function presentAssistantMessage(cline: Task) {
})
break
case "access_mcp_resource":
await accessMcpResourceTool.handle(cline, block as ToolUse<"access_mcp_resource">, {
await accessMcpResourceTool.handle(cline, blockToExecute as ToolUse<"access_mcp_resource">, {
askApproval,
handleError,
pushToolResult,
@ -1054,7 +1150,7 @@ export async function presentAssistantMessage(cline: Task) {
})
break
case "ask_followup_question":
await askFollowupQuestionTool.handle(cline, block as ToolUse<"ask_followup_question">, {
await askFollowupQuestionTool.handle(cline, blockToExecute as ToolUse<"ask_followup_question">, {
askApproval,
handleError,
pushToolResult,
@ -1063,7 +1159,7 @@ export async function presentAssistantMessage(cline: Task) {
})
break
case "switch_mode":
await switchModeTool.handle(cline, block as ToolUse<"switch_mode">, {
await switchModeTool.handle(cline, blockToExecute as ToolUse<"switch_mode">, {
askApproval,
handleError,
pushToolResult,
@ -1072,13 +1168,13 @@ export async function presentAssistantMessage(cline: Task) {
})
break
case "new_task":
await newTaskTool.handle(cline, block as ToolUse<"new_task">, {
await newTaskTool.handle(cline, blockToExecute as ToolUse<"new_task">, {
askApproval,
handleError,
pushToolResult,
removeClosingTag,
toolProtocol,
toolCallId: block.id,
toolCallId: blockToExecute.id,
})
break
case "attempt_completion": {
@ -1093,13 +1189,13 @@ export async function presentAssistantMessage(cline: Task) {
}
await attemptCompletionTool.handle(
cline,
block as ToolUse<"attempt_completion">,
blockToExecute as ToolUse<"attempt_completion">,
completionCallbacks,
)
break
}
case "run_slash_command":
await runSlashCommandTool.handle(cline, block as ToolUse<"run_slash_command">, {
await runSlashCommandTool.handle(cline, blockToExecute as ToolUse<"run_slash_command">, {
askApproval,
handleError,
pushToolResult,
@ -1109,7 +1205,7 @@ export async function presentAssistantMessage(cline: Task) {
break
case "generate_image":
await checkpointSaveAndMark(cline)
await generateImageTool.handle(cline, block as ToolUse<"generate_image">, {
await generateImageTool.handle(cline, blockToExecute as ToolUse<"generate_image">, {
askApproval,
handleError,
pushToolResult,
@ -1128,7 +1224,9 @@ export async function presentAssistantMessage(cline: Task) {
break
}
const customTool = stateExperiments?.customTools ? customToolRegistry.get(block.name) : undefined
const customTool = stateExperiments?.customTools
? customToolRegistry.get(blockToExecute.name)
: undefined
if (customTool) {
try {
@ -1138,7 +1236,7 @@ export async function presentAssistantMessage(cline: Task) {
try {
customToolArgs = customTool.parameters.parse(block.nativeArgs || block.params || {})
} catch (parseParamsError) {
const message = `Custom tool "${block.name}" argument validation failed: ${parseParamsError.message}`
const message = `Custom tool "${blockToExecute.name}" argument validation failed: ${parseParamsError.message}`
console.error(message)
cline.consecutiveMistakeCount++
await cline.say("error", message)
@ -1162,17 +1260,17 @@ export async function presentAssistantMessage(cline: Task) {
cline.consecutiveMistakeCount++
// Record custom tool error with static name
cline.recordToolError("custom_tool", executionError.message)
await handleError(`executing custom tool "${block.name}"`, executionError)
await handleError(`executing custom tool "${blockToExecute.name}"`, executionError)
}
break
}
// Not a custom tool - handle as unknown tool error
const errorMessage = `Unknown tool "${block.name}". This tool does not exist. Please use one of the available tools.`
const errorMessage = `Unknown tool "${blockToExecute.name}". This tool does not exist. Please use one of the available tools.`
cline.consecutiveMistakeCount++
cline.recordToolError(block.name as ToolName, errorMessage)
await cline.say("error", t("tools:unknownToolError", { toolName: block.name }))
cline.recordToolError(blockToExecute.name as ToolName, errorMessage)
await cline.say("error", t("tools:unknownToolError", { toolName: blockToExecute.name }))
// Push tool_result directly for native protocol WITHOUT setting didAlreadyUseTool
// This prevents the stream from being interrupted with "Response interrupted by tool use result"
if (toolProtocol === TOOL_PROTOCOL.NATIVE && toolCallId) {
@ -1189,6 +1287,30 @@ export async function presentAssistantMessage(cline: Task) {
}
}
// Hooks: PostToolUse / PostToolUseFailure (non-blocking)
if (!block.partial && !toolDeniedByHook && !toolDeniedByUser) {
const duration = Date.now() - toolExecutionStart
const finalToolInput = (blockToExecute.nativeArgs ?? blockToExecute.params ?? {}) as Record<
string,
unknown
>
const hookContext = buildToolExecutionHookContext(blockToExecute.name, finalToolInput)
if (toolExecutionHadFailure) {
void cline.toolExecutionHooks
.executePostToolUseFailure(
hookContext,
toolFailureAction ?? "tool_error",
toolFailureMessage ?? "Tool execution failed",
)
.catch(() => {})
} else {
void cline.toolExecutionHooks
.executePostToolUse(hookContext, toolOutputForHooks, duration)
.catch(() => {})
}
}
break
}
}

View file

@ -81,6 +81,7 @@ import { BrowserSession } from "../../services/browser/BrowserSession"
import { McpHub } from "../../services/mcp/McpHub"
import { McpServerManager } from "../../services/mcp/McpServerManager"
import { RepoPerTaskCheckpointService } from "../../services/checkpoints"
import { ToolExecutionHooks, createToolExecutionHooks } from "../../services/hooks"
// integrations
import { DiffViewProvider } from "../../integrations/editor/DiffViewProvider"
@ -334,6 +335,9 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
// Computer User
browserSession: BrowserSession
// Hooks
toolExecutionHooks: ToolExecutionHooks
// Editing
diffViewProvider: DiffViewProvider
diffStrategy?: DiffStrategy
@ -535,6 +539,12 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
}
}
})
// Initialize tool execution hooks
this.toolExecutionHooks = createToolExecutionHooks(provider.getHookManager() ?? null, (status) =>
provider.postHookStatusToWebview(status),
)
this.diffEnabled = enableDiff
this.fuzzyMatchThreshold = fuzzyMatchThreshold
this.consecutiveMistakeLimit = consecutiveMistakeLimit ?? DEFAULT_CONSECUTIVE_MISTAKE_LIMIT

View file

@ -75,6 +75,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 { HookManager, createHookManager, type IHookManager } from "../../services/hooks"
import { fileExistsAtPath } from "../../utils/fs"
import { setTtsEnabled, setTtsSpeed } from "../../utils/tts"
@ -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 hookManager?: IHookManager
private marketplaceManager: MarketplaceManager
private mdmService?: MdmService
private taskCreationCallback: (task: Task) => void
@ -208,6 +210,11 @@ export class ClineProvider
this.log(`Failed to initialize Skills Manager: ${error}`)
})
// Initialize Hook Manager for lifecycle hooks
this.initializeHookManager().catch((error) => {
this.log(`Failed to initialize Hook Manager: ${error}`)
})
this.marketplaceManager = new MarketplaceManager(this.context, this.customModesManager)
// Forward <most> task events to the provider.
@ -2217,6 +2224,58 @@ export class ClineProvider
}
})(),
debug: vscode.workspace.getConfiguration(Package.name).get<boolean>("debug", false),
// Hooks state for settings tab
hooks: this.getHooksStateForWebview(),
}
}
/**
* Build hooks state for webview from HookManager.
* Converts internal types to serializable HooksState.
*/
private getHooksStateForWebview(): ExtensionState["hooks"] {
if (!this.hookManager) {
return undefined
}
const snapshot = this.hookManager.getConfigSnapshot()
const enabledHooks = this.hookManager.getEnabledHooks()
const executionHistory = this.hookManager.getHookExecutionHistory()
// Convert ResolvedHook[] to HookInfo[]
const hookInfos = enabledHooks.map((hook) => ({
id: hook.id,
event: hook.event,
matcher: hook.matcher,
commandPreview: hook.command.length > 100 ? hook.command.substring(0, 97) + "..." : hook.command,
enabled: hook.enabled ?? true,
source: hook.source,
timeout: hook.timeout ?? 60,
shell: hook.shell,
description: hook.description,
}))
// Convert HookExecution[] to HookExecutionRecord[]
// Limit to last 50 records for UI
const executionRecords = executionHistory.slice(-50).map((exec) => ({
timestamp: exec.timestamp.toISOString(),
hookId: exec.hook.id,
event: exec.event,
toolName: exec.result.hook.matcher ? undefined : undefined, // Tool name is in context, not easily accessible here
exitCode: exec.result.exitCode,
duration: exec.result.duration,
timedOut: exec.result.timedOut,
blocked: exec.result.exitCode === 2,
error: exec.result.error?.message,
blockMessage: exec.result.exitCode === 2 ? exec.result.stderr : undefined,
}))
return {
enabledHooks: hookInfos,
executionHistory: executionRecords,
hasProjectHooks: snapshot?.hasProjectHooks ?? false,
snapshotTimestamp: snapshot?.loadedAt?.toISOString(),
}
}
@ -2582,6 +2641,85 @@ export class ClineProvider
return this.mcpHub
}
/**
* Initialize the Hook Manager for lifecycle hooks.
* This loads hooks configuration from project/.roo/hooks/ files.
*/
private async initializeHookManager(): Promise<void> {
const cwd = this.currentWorkspacePath || getWorkspacePath()
if (!cwd) {
this.log("[HookManager] No workspace path available, hooks disabled")
return
}
try {
const state = await this.getState()
this.hookManager = createHookManager({
cwd,
mode: state?.mode,
logger: {
debug: (msg: string) => this.log(`[Hooks/debug] ${msg}`),
info: (msg: string) => this.log(`[Hooks/info] ${msg}`),
warn: (msg: string) => this.log(`[Hooks/warn] ${msg}`),
error: (msg: string) => this.log(`[Hooks/error] ${msg}`),
},
})
// Load hooks configuration
await this.hookManager.loadHooksConfig()
this.log("[HookManager] Hooks loaded successfully")
} catch (error) {
this.log(
`[HookManager] Failed to initialize hooks: ${error instanceof Error ? error.message : String(error)}`,
)
// Don't throw - hooks are optional
this.hookManager = undefined
}
}
/**
* Get the Hook Manager instance.
*/
public getHookManager(): IHookManager | undefined {
return this.hookManager
}
/**
* Reload the Hook Manager configuration.
* Call this when hooks configuration files may have changed.
*/
public async reloadHooksConfig(): Promise<void> {
if (this.hookManager) {
try {
await this.hookManager.reloadHooksConfig()
this.log("[HookManager] Hooks reloaded successfully")
} catch (error) {
this.log(
`[HookManager] Failed to reload hooks: ${error instanceof Error ? error.message : String(error)}`,
)
}
}
}
/**
* Post hook execution status to webview.
*/
public postHookStatusToWebview(status: {
status: "running" | "completed" | "failed" | "blocked"
event: string
toolName?: string
hookId?: string
duration?: number
error?: string
blockMessage?: string
modified?: boolean
}): void {
this.postMessageToWebview({
type: "hookExecutionStatus",
hookExecutionStatus: status,
})
}
public getSkillsManager(): SkillsManager | undefined {
return this.skillsManager
}

View file

@ -0,0 +1,350 @@
// npx vitest run core/webview/__tests__/webviewMessageHandler.hooks.spec.ts
import type { IHookManager, ResolvedHook, HookExecution, HooksConfigSnapshot } from "../../../services/hooks/types"
// Mock vscode before importing webviewMessageHandler
vi.mock("vscode", () => {
const executeCommand = vi.fn().mockResolvedValue(undefined)
const showInformationMessage = vi.fn()
const showErrorMessage = vi.fn()
return {
window: {
showInformationMessage,
showErrorMessage,
},
workspace: {
workspaceFolders: [{ uri: { fsPath: "/mock/workspace" } }],
},
commands: {
executeCommand,
},
Uri: {
file: vi.fn((path: string) => ({ fsPath: path })),
},
}
})
vi.mock("fs/promises", () => {
const mockMkdir = vi.fn().mockResolvedValue(undefined)
return {
default: {
mkdir: mockMkdir,
},
mkdir: mockMkdir,
}
})
vi.mock("../../../utils/fs", () => ({
fileExistsAtPath: vi.fn().mockResolvedValue(true),
}))
vi.mock("../../../api/providers/fetchers/modelCache")
import * as vscode from "vscode"
import * as fs from "fs/promises"
import * as fsUtils from "../../../utils/fs"
import { webviewMessageHandler } from "../webviewMessageHandler"
import type { ClineProvider } from "../ClineProvider"
// Create mock HookManager
const createMockHookManager = (): IHookManager => ({
loadHooksConfig: vi.fn().mockResolvedValue({
hooksByEvent: new Map(),
hooksById: new Map(),
loadedAt: new Date(),
disabledHookIds: new Set(),
hasProjectHooks: false,
}),
reloadHooksConfig: vi.fn().mockResolvedValue(undefined),
getEnabledHooks: vi.fn().mockReturnValue([]),
executeHooks: vi.fn().mockResolvedValue({
results: [],
blocked: false,
totalDuration: 0,
}),
setHookEnabled: vi.fn().mockResolvedValue(undefined),
getHookExecutionHistory: vi.fn().mockReturnValue([]),
getConfigSnapshot: vi.fn().mockReturnValue({
hooksByEvent: new Map(),
hooksById: new Map(),
loadedAt: new Date(),
disabledHookIds: new Set(),
hasProjectHooks: false,
}),
})
// Create mock ClineProvider
const createMockClineProvider = (hookManager?: IHookManager) => {
const mockProvider = {
getState: vi.fn(),
postMessageToWebview: vi.fn(),
postStateToWebview: vi.fn(),
getHookManager: vi.fn().mockReturnValue(hookManager),
log: vi.fn(),
getCurrentTask: vi.fn(),
getTaskWithId: vi.fn(),
createTaskWithHistoryItem: vi.fn(),
cwd: "/mock/workspace",
context: {
extensionPath: "/mock/extension/path",
globalStorageUri: { fsPath: "/mock/global/storage" },
},
contextProxy: {
context: {
extensionPath: "/mock/extension/path",
globalStorageUri: { fsPath: "/mock/global/storage" },
},
setValue: vi.fn(),
getValue: vi.fn(),
},
customModesManager: {
getCustomModes: vi.fn(),
deleteCustomMode: vi.fn(),
},
} as unknown as ClineProvider
return mockProvider
}
describe("webviewMessageHandler - hooks commands", () => {
let mockHookManager: IHookManager
let mockClineProvider: ClineProvider
beforeEach(() => {
vi.clearAllMocks()
mockHookManager = createMockHookManager()
mockClineProvider = createMockClineProvider(mockHookManager)
})
describe("hooksReloadConfig", () => {
it("should call reloadHooksConfig and postStateToWebview when hookManager exists", async () => {
await webviewMessageHandler(mockClineProvider, {
type: "hooksReloadConfig",
})
expect(mockHookManager.reloadHooksConfig).toHaveBeenCalledTimes(1)
expect(mockClineProvider.postStateToWebview).toHaveBeenCalledTimes(1)
})
it("should not throw when hookManager is undefined", async () => {
const providerWithoutHookManager = createMockClineProvider(undefined)
await expect(
webviewMessageHandler(providerWithoutHookManager, {
type: "hooksReloadConfig",
}),
).resolves.not.toThrow()
expect(providerWithoutHookManager.postStateToWebview).not.toHaveBeenCalled()
})
it("should show error message when reloadHooksConfig fails", async () => {
const error = new Error("Failed to load hooks config")
vi.mocked(mockHookManager.reloadHooksConfig).mockRejectedValueOnce(error)
await webviewMessageHandler(mockClineProvider, {
type: "hooksReloadConfig",
})
expect(mockClineProvider.log).toHaveBeenCalledWith(
"Failed to reload hooks config: Failed to load hooks config",
)
expect(vscode.window.showErrorMessage).toHaveBeenCalledWith("Failed to reload hooks configuration")
})
})
describe("hooksSetEnabled", () => {
it("should call setHookEnabled with correct parameters and postStateToWebview", async () => {
await webviewMessageHandler(mockClineProvider, {
type: "hooksSetEnabled",
hookId: "test-hook-id",
hookEnabled: false,
})
expect(mockHookManager.setHookEnabled).toHaveBeenCalledWith("test-hook-id", false)
expect(mockClineProvider.postStateToWebview).toHaveBeenCalledTimes(1)
})
it("should enable a previously disabled hook", async () => {
await webviewMessageHandler(mockClineProvider, {
type: "hooksSetEnabled",
hookId: "test-hook-id",
hookEnabled: true,
})
expect(mockHookManager.setHookEnabled).toHaveBeenCalledWith("test-hook-id", true)
expect(mockClineProvider.postStateToWebview).toHaveBeenCalledTimes(1)
})
it("should not call setHookEnabled when hookId is missing", async () => {
await webviewMessageHandler(mockClineProvider, {
type: "hooksSetEnabled",
hookEnabled: true,
} as any)
expect(mockHookManager.setHookEnabled).not.toHaveBeenCalled()
expect(mockClineProvider.postStateToWebview).not.toHaveBeenCalled()
})
it("should not call setHookEnabled when hookEnabled is not a boolean", async () => {
await webviewMessageHandler(mockClineProvider, {
type: "hooksSetEnabled",
hookId: "test-hook-id",
hookEnabled: "true", // string, not boolean
} as any)
expect(mockHookManager.setHookEnabled).not.toHaveBeenCalled()
expect(mockClineProvider.postStateToWebview).not.toHaveBeenCalled()
})
it("should show error message when setHookEnabled fails", async () => {
const error = new Error("Hook not found")
vi.mocked(mockHookManager.setHookEnabled).mockRejectedValueOnce(error)
await webviewMessageHandler(mockClineProvider, {
type: "hooksSetEnabled",
hookId: "nonexistent-hook",
hookEnabled: true,
})
expect(mockClineProvider.log).toHaveBeenCalledWith("Failed to set hook enabled: Hook not found")
expect(vscode.window.showErrorMessage).toHaveBeenCalledWith("Failed to enable hook")
})
it("should show correct error message when disabling fails", async () => {
const error = new Error("Hook not found")
vi.mocked(mockHookManager.setHookEnabled).mockRejectedValueOnce(error)
await webviewMessageHandler(mockClineProvider, {
type: "hooksSetEnabled",
hookId: "nonexistent-hook",
hookEnabled: false,
})
expect(vscode.window.showErrorMessage).toHaveBeenCalledWith("Failed to disable hook")
})
})
describe("hooksOpenConfigFolder", () => {
it("should open project hooks folder by default", async () => {
await webviewMessageHandler(mockClineProvider, {
type: "hooksOpenConfigFolder",
})
expect(vscode.Uri.file).toHaveBeenCalledWith("/mock/workspace/.roo/hooks")
expect(vscode.commands.executeCommand).toHaveBeenCalledWith("revealFileInOS", expect.any(Object))
})
it("should open global hooks folder when source is global", async () => {
await webviewMessageHandler(mockClineProvider, {
type: "hooksOpenConfigFolder",
hooksSource: "global",
})
expect(vscode.Uri.file).toHaveBeenCalledWith(expect.stringContaining(".roo/hooks"))
expect(vscode.commands.executeCommand).toHaveBeenCalledWith("revealFileInOS", expect.any(Object))
})
it("should open project hooks folder when source is project", async () => {
await webviewMessageHandler(mockClineProvider, {
type: "hooksOpenConfigFolder",
hooksSource: "project",
})
expect(vscode.Uri.file).toHaveBeenCalledWith("/mock/workspace/.roo/hooks")
expect(vscode.commands.executeCommand).toHaveBeenCalledWith("revealFileInOS", expect.any(Object))
})
it("should create hooks folder if it does not exist", async () => {
vi.mocked(fsUtils.fileExistsAtPath).mockResolvedValueOnce(false)
await webviewMessageHandler(mockClineProvider, {
type: "hooksOpenConfigFolder",
})
expect(fs.mkdir).toHaveBeenCalledWith("/mock/workspace/.roo/hooks", { recursive: true })
expect(vscode.commands.executeCommand).toHaveBeenCalledWith("revealFileInOS", expect.any(Object))
})
it("should not create hooks folder if it already exists", async () => {
vi.mocked(fsUtils.fileExistsAtPath).mockResolvedValueOnce(true)
await webviewMessageHandler(mockClineProvider, {
type: "hooksOpenConfigFolder",
})
expect(fs.mkdir).not.toHaveBeenCalled()
expect(vscode.commands.executeCommand).toHaveBeenCalledWith("revealFileInOS", expect.any(Object))
})
it("should show error message when open fails", async () => {
vi.mocked(vscode.commands.executeCommand).mockRejectedValueOnce(new Error("Failed to open folder"))
await webviewMessageHandler(mockClineProvider, {
type: "hooksOpenConfigFolder",
})
expect(mockClineProvider.log).toHaveBeenCalledWith("Failed to open hooks folder: Failed to open folder")
expect(vscode.window.showErrorMessage).toHaveBeenCalledWith("Failed to open hooks configuration folder")
})
})
})
describe("webviewMessageHandler - hooks state integration", () => {
it("should return hooks state when hookManager has data", () => {
const mockResolvedHook: ResolvedHook = {
id: "hook-1",
event: "PreToolUse",
matcher: ".*",
command: "echo 'hello'",
enabled: true,
source: "project",
timeout: 30,
shell: "/bin/bash",
description: "Test hook",
filePath: "/mock/workspace/.roo/hooks/pre-tool-use.json",
includeConversationHistory: false,
}
const mockExecutionHistory: HookExecution[] = [
{
timestamp: new Date(1642000000000),
hook: mockResolvedHook,
event: "PreToolUse",
result: {
hook: mockResolvedHook,
exitCode: 0,
stdout: "hello",
stderr: "",
duration: 100,
timedOut: false,
},
},
]
const mockHookManager = createMockHookManager()
vi.mocked(mockHookManager.getEnabledHooks).mockReturnValue([mockResolvedHook])
vi.mocked(mockHookManager.getHookExecutionHistory).mockReturnValue(mockExecutionHistory)
const hooksByEvent = new Map()
hooksByEvent.set("PreToolUse", [mockResolvedHook])
const hooksById = new Map()
hooksById.set("hook-1", mockResolvedHook)
vi.mocked(mockHookManager.getConfigSnapshot).mockReturnValue({
hooksByEvent,
hooksById,
loadedAt: new Date(1642000000000),
disabledHookIds: new Set(),
hasProjectHooks: true,
})
// Verify mock data is correctly formatted
expect(mockHookManager.getEnabledHooks()).toHaveLength(1)
expect(mockHookManager.getHookExecutionHistory()).toHaveLength(1)
expect(mockHookManager.getConfigSnapshot()?.hasProjectHooks).toBe(true)
})
})

View file

@ -3336,6 +3336,74 @@ export const webviewMessageHandler = async (
break
}
// =====================================================================
// Hooks Management Commands
// =====================================================================
case "hooksReloadConfig": {
// Reload hooks configuration from all sources
const hookManager = provider.getHookManager()
if (hookManager) {
try {
await hookManager.reloadHooksConfig()
await provider.postStateToWebview()
} catch (error) {
provider.log(
`Failed to reload hooks config: ${error instanceof Error ? error.message : String(error)}`,
)
vscode.window.showErrorMessage("Failed to reload hooks configuration")
}
}
break
}
case "hooksSetEnabled": {
// Enable or disable a specific hook
const hookManager = provider.getHookManager()
if (hookManager && message.hookId && typeof message.hookEnabled === "boolean") {
try {
await hookManager.setHookEnabled(message.hookId, message.hookEnabled)
await provider.postStateToWebview()
} catch (error) {
provider.log(
`Failed to set hook enabled: ${error instanceof Error ? error.message : String(error)}`,
)
vscode.window.showErrorMessage(`Failed to ${message.hookEnabled ? "enable" : "disable"} hook`)
}
}
break
}
case "hooksOpenConfigFolder": {
// Open the hooks configuration folder in VS Code
const source = message.hooksSource ?? "project"
try {
let hooksPath: string
if (source === "global") {
// Global hooks: ~/.roo/hooks
hooksPath = path.join(os.homedir(), ".roo", "hooks")
} else {
// Project hooks: .roo/hooks in workspace
const cwd = provider.cwd
hooksPath = path.join(cwd, ".roo", "hooks")
}
// Check if directory exists, create if not
const exists = await fileExistsAtPath(hooksPath)
if (!exists) {
await fs.mkdir(hooksPath, { recursive: true })
}
// Open the folder in VS Code
const uri = vscode.Uri.file(hooksPath)
await vscode.commands.executeCommand("revealFileInOS", uri)
} catch (error) {
provider.log(`Failed to open hooks folder: ${error instanceof Error ? error.message : String(error)}`)
vscode.window.showErrorMessage("Failed to open hooks configuration folder")
}
break
}
default: {
// console.log(`Unhandled message type: ${message.type}`)
//

View file

@ -0,0 +1,362 @@
/**
* Hook Configuration Loader
*
* Loads and merges hook configurations from:
* 1. Project directory: .roo/hooks/*.yaml or *.json (highest priority)
* 2. Mode-specific: .roo/hooks-{mode}/*.yaml or *.json (middle priority)
* 3. Global directory: ~/.roo/hooks/*.yaml or *.json (lowest priority)
*
* Files within each directory are processed in alphabetical order.
* Same hook ID at higher precedence level overrides lower levels.
*/
import * as path from "path"
import fs from "fs/promises"
import YAML from "yaml"
import { z } from "zod"
import {
HooksConfigFileSchema,
HooksConfigSnapshot,
HookDefinition,
ResolvedHook,
HookSource,
HookEventType,
} from "./types"
import { getGlobalRooDirectory, getProjectRooDirectoryForCwd } from "../roo-config"
/**
* Result of loading a single config file.
*/
interface LoadedConfigFile {
filePath: string
source: HookSource
hooks: Map<HookEventType, HookDefinition[]>
errors: string[]
}
/**
* Options for loading hooks configuration.
*/
export interface LoadHooksConfigOptions {
/** Project directory (cwd) */
cwd: string
/** Current mode slug (for mode-specific hooks) */
mode?: string
}
/**
* Result of loading all hooks configuration.
*/
export interface LoadHooksConfigResult {
snapshot: HooksConfigSnapshot
errors: string[]
warnings: string[]
}
/**
* Check if a file has a supported extension (.yaml, .yml, .json).
*/
function isSupportedConfigFile(filename: string): boolean {
const lower = filename.toLowerCase()
return lower.endsWith(".yaml") || lower.endsWith(".yml") || lower.endsWith(".json")
}
/**
* Parse a config file content (YAML or JSON).
*/
function parseConfigContent(content: string, filePath: string): unknown {
const lower = filePath.toLowerCase()
if (lower.endsWith(".json")) {
return JSON.parse(content)
}
// Parse as YAML (which also handles plain JSON)
return YAML.parse(content)
}
/**
* Validate parsed config against the schema.
*/
function validateConfig(
parsed: unknown,
filePath: string,
): { success: true; data: z.infer<typeof HooksConfigFileSchema> } | { success: false; errors: string[] } {
const result = HooksConfigFileSchema.safeParse(parsed)
if (result.success) {
return { success: true, data: result.data }
}
// Format Zod errors nicely
const errors = result.error.errors.map((err) => {
const pathStr = err.path.length > 0 ? err.path.join(".") : "(root)"
return `${filePath}: ${pathStr}: ${err.message}`
})
return { success: false, errors }
}
/**
* Load a single config file.
*/
async function loadConfigFile(filePath: string, source: HookSource): Promise<LoadedConfigFile> {
const result: LoadedConfigFile = {
filePath,
source,
hooks: new Map(),
errors: [],
}
try {
const content = await fs.readFile(filePath, "utf-8")
const parsed = parseConfigContent(content, filePath)
const validated = validateConfig(parsed, filePath)
if (!validated.success) {
result.errors = validated.errors
return result
}
// Convert hooks record to Map
const hooksRecord = validated.data.hooks || {}
for (const [eventStr, definitions] of Object.entries(hooksRecord)) {
const event = eventStr as HookEventType
if (definitions && definitions.length > 0) {
result.hooks.set(event, definitions)
}
}
} catch (err) {
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
// File doesn't exist - not an error, just skip
return result
}
const message = err instanceof Error ? err.message : String(err)
result.errors.push(`${filePath}: Failed to load: ${message}`)
}
return result
}
/**
* List config files in a directory (sorted alphabetically).
*/
async function listConfigFiles(dirPath: string): Promise<string[]> {
try {
const entries = await fs.readdir(dirPath, { withFileTypes: true })
const files = entries
.filter((entry) => entry.isFile() && isSupportedConfigFile(entry.name))
.map((entry) => path.join(dirPath, entry.name))
.sort() // Alphabetical order
return files
} catch (err) {
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
// Directory doesn't exist - not an error
return []
}
throw err
}
}
/**
* Load all config files from a directory.
*/
async function loadConfigDirectory(dirPath: string, source: HookSource): Promise<LoadedConfigFile[]> {
const files = await listConfigFiles(dirPath)
const results: LoadedConfigFile[] = []
for (const filePath of files) {
const loaded = await loadConfigFile(filePath, source)
results.push(loaded)
}
return results
}
/**
* Merge loaded configs into a snapshot, respecting precedence rules.
*
* Precedence (highest to lowest):
* 1. Project hooks (.roo/hooks/)
* 2. Mode-specific hooks (.roo/hooks-{mode}/)
* 3. Global hooks (~/.roo/hooks/)
*
* Within same level: alphabetical file order.
* Same hook ID: higher precedence wins (can also disable with enabled: false).
*/
function mergeConfigs(loadedConfigs: LoadedConfigFile[]): {
hooksByEvent: Map<HookEventType, ResolvedHook[]>
hooksById: Map<string, ResolvedHook>
hasProjectHooks: boolean
} {
// Track hooks by ID to detect overrides
const hooksById = new Map<string, ResolvedHook>()
// Track hooks by event for efficient lookup
const hooksByEvent = new Map<HookEventType, ResolvedHook[]>()
// Track if we have any project hooks (for security warnings)
let hasProjectHooks = false
// Process configs in reverse precedence order (global -> mode -> project)
// so that later (higher precedence) configs override earlier ones
const orderedConfigs = [...loadedConfigs].sort((a, b) => {
const precedence: Record<HookSource, number> = {
global: 0,
mode: 1,
project: 2,
}
return precedence[a.source] - precedence[b.source]
})
for (const config of orderedConfigs) {
if (config.source === "project" && config.hooks.size > 0) {
hasProjectHooks = true
}
for (const [event, definitions] of config.hooks) {
for (const def of definitions) {
const resolved: ResolvedHook = {
...def,
source: config.source,
event,
filePath: config.filePath,
}
// Check for existing hook with same ID
const existing = hooksById.get(def.id)
if (existing) {
// Remove from its event list
const eventList = hooksByEvent.get(existing.event)
if (eventList) {
const idx = eventList.findIndex((h) => h.id === def.id)
if (idx !== -1) {
eventList.splice(idx, 1)
}
}
}
// Add/replace in lookup maps
hooksById.set(def.id, resolved)
// Add to event list
if (!hooksByEvent.has(event)) {
hooksByEvent.set(event, [])
}
hooksByEvent.get(event)!.push(resolved)
}
}
}
return { hooksByEvent, hooksById, hasProjectHooks }
}
/**
* Load hooks configuration from all sources.
*
* @param options - Loading options (cwd, mode)
* @returns Loaded configuration snapshot and any errors/warnings
*/
export async function loadHooksConfig(options: LoadHooksConfigOptions): Promise<LoadHooksConfigResult> {
const { cwd, mode } = options
const errors: string[] = []
const warnings: string[] = []
const loadedConfigs: LoadedConfigFile[] = []
// 1. Load global hooks (~/.roo/hooks/)
const globalDir = path.join(getGlobalRooDirectory(), "hooks")
try {
const globalConfigs = await loadConfigDirectory(globalDir, "global")
loadedConfigs.push(...globalConfigs)
for (const config of globalConfigs) {
errors.push(...config.errors)
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
warnings.push(`Failed to load global hooks from ${globalDir}: ${message}`)
}
// 2. Load mode-specific hooks (.roo/hooks-{mode}/)
if (mode) {
const modeDir = path.join(getProjectRooDirectoryForCwd(cwd), `hooks-${mode}`)
try {
const modeConfigs = await loadConfigDirectory(modeDir, "mode")
loadedConfigs.push(...modeConfigs)
for (const config of modeConfigs) {
errors.push(...config.errors)
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
warnings.push(`Failed to load mode-specific hooks from ${modeDir}: ${message}`)
}
}
// 3. Load project hooks (.roo/hooks/)
const projectDir = path.join(getProjectRooDirectoryForCwd(cwd), "hooks")
try {
const projectConfigs = await loadConfigDirectory(projectDir, "project")
loadedConfigs.push(...projectConfigs)
for (const config of projectConfigs) {
errors.push(...config.errors)
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
warnings.push(`Failed to load project hooks from ${projectDir}: ${message}`)
}
// Merge configs with precedence rules
const { hooksByEvent, hooksById, hasProjectHooks } = mergeConfigs(loadedConfigs)
// Create snapshot
const snapshot: HooksConfigSnapshot = {
hooksByEvent,
hooksById,
loadedAt: new Date(),
disabledHookIds: new Set(),
hasProjectHooks,
}
// Security warning for project hooks
if (hasProjectHooks) {
warnings.push(
"⚠️ Project hooks are active: This workspace has hooks defined in .roo/hooks/. " +
"These hooks run shell commands when Roo Code performs actions. " +
"Only enable project hooks for repositories you trust.",
)
}
return { snapshot, errors, warnings }
}
/**
* Get hooks for a specific event from a snapshot.
*
* @param snapshot - The config snapshot
* @param event - The event type
* @returns Array of hooks for the event (excluding disabled ones)
*/
export function getHooksForEvent(snapshot: HooksConfigSnapshot, event: HookEventType): ResolvedHook[] {
const hooks = snapshot.hooksByEvent.get(event) || []
return hooks.filter((hook) => {
// Check if explicitly disabled via setHookEnabled
if (snapshot.disabledHookIds.has(hook.id)) {
return false
}
// Check if disabled in config
return hook.enabled !== false
})
}
/**
* Get a specific hook by ID from a snapshot.
*
* @param snapshot - The config snapshot
* @param hookId - The hook ID
* @returns The hook, or undefined if not found
*/
export function getHookById(snapshot: HooksConfigSnapshot, hookId: string): ResolvedHook | undefined {
return snapshot.hooksById.get(hookId)
}

View file

@ -0,0 +1,351 @@
/**
* Hook Executor
*
* Executes shell commands for hooks with:
* - JSON context passed via stdin
* - Environment variables set per PRD
* - Timeout handling
* - Exit code interpretation (0=success, 2=block, other=error)
* - stdout/stderr capture
*/
import { spawn, ChildProcess } from "child_process"
import * as os from "os"
import * as path from "path"
import {
ResolvedHook,
HookContext,
HookExecutionResult,
HookExitCode,
HookModificationSchema,
HookModification,
isBlockingEvent,
ConversationHistoryEntry,
} from "./types"
/**
* Default timeout in seconds.
*/
const DEFAULT_TIMEOUT = 60
/**
* Get the default shell based on platform.
* Windows: PowerShell
* Unix: User's shell from SHELL env var, or /bin/sh
*/
function getDefaultShell(): { shell: string; shellArgs: string[] } {
if (os.platform() === "win32") {
return {
shell: "powershell.exe",
shellArgs: ["-NoProfile", "-NonInteractive", "-Command"],
}
}
// Unix: try user's shell, fall back to /bin/sh
const userShell = process.env.SHELL || "/bin/sh"
return {
shell: userShell,
shellArgs: ["-c"],
}
}
/**
* Parse a custom shell specification.
* Supports "bash", "/bin/bash", "powershell.exe", etc.
*/
function parseShellSpec(shellSpec: string): { shell: string; shellArgs: string[] } {
const lower = shellSpec.toLowerCase()
// Handle PowerShell variants
if (lower.includes("powershell") || lower.includes("pwsh")) {
return {
shell: shellSpec,
shellArgs: ["-NoProfile", "-NonInteractive", "-Command"],
}
}
// All other shells use -c
return {
shell: shellSpec,
shellArgs: ["-c"],
}
}
/**
* Build environment variables for hook execution.
*/
function buildEnvVars(hook: ResolvedHook, context: HookContext): NodeJS.ProcessEnv {
const baseEnv = { ...process.env }
return {
...baseEnv,
ROO_PROJECT_DIR: context.project.directory,
ROO_TASK_ID: context.session.taskId,
ROO_SESSION_ID: context.session.sessionId,
ROO_MODE: context.session.mode,
ROO_TOOL_NAME: context.tool?.name || "",
ROO_EVENT: context.event,
ROO_HOOK_ID: hook.id,
}
}
/**
* Build the stdin JSON payload for a hook.
*/
function buildStdinPayload(
hook: ResolvedHook,
context: HookContext,
conversationHistory?: ConversationHistoryEntry[],
): string {
// Start with the base context
const payload: HookContext = { ...context }
// Only include conversation history if the hook opts in
if (hook.includeConversationHistory && conversationHistory) {
payload.conversationHistory = conversationHistory
}
return JSON.stringify(payload)
}
/**
* Try to parse stdout as a modification response.
* Returns undefined if stdout is empty or not valid modification JSON.
*/
function parseModificationResponse(stdout: string, hook: ResolvedHook): HookModification | undefined {
if (!stdout.trim()) {
return undefined
}
try {
const parsed = JSON.parse(stdout)
const result = HookModificationSchema.safeParse(parsed)
if (result.success) {
// Only PreToolUse hooks can modify input
if (hook.event !== "PreToolUse") {
console.warn(`Hook "${hook.id}" returned modification but is not a PreToolUse hook - ignoring`)
return undefined
}
return result.data
}
// Not a valid modification response - that's fine, hooks don't have to return JSON
return undefined
} catch {
// Not valid JSON - that's fine
return undefined
}
}
/**
* Execute a single hook command.
*
* @param hook - The hook to execute
* @param context - The hook context
* @param conversationHistory - Optional conversation history (only included if hook opts in)
* @returns Execution result
*/
export async function executeHook(
hook: ResolvedHook,
context: HookContext,
conversationHistory?: ConversationHistoryEntry[],
): Promise<HookExecutionResult> {
const startTime = Date.now()
const timeout = (hook.timeout || DEFAULT_TIMEOUT) * 1000 // Convert to ms
// Determine shell
const shellConfig = hook.shell ? parseShellSpec(hook.shell) : getDefaultShell()
// Build environment and stdin
const env = buildEnvVars(hook, context)
const stdin = buildStdinPayload(hook, context, conversationHistory)
return new Promise<HookExecutionResult>((resolve) => {
let child: ChildProcess | null = null
let stdout = ""
let stderr = ""
let timedOut = false
let resolved = false
const finalize = (exitCode: number | null, error?: Error) => {
if (resolved) return
resolved = true
const duration = Date.now() - startTime
// Try to parse modification from stdout
const modification = parseModificationResponse(stdout, hook)
resolve({
hook,
exitCode,
stdout,
stderr,
duration,
timedOut,
error,
modification,
})
}
// Set up timeout
const timeoutHandle = setTimeout(() => {
timedOut = true
if (child) {
// Try graceful kill first (SIGTERM), then force (SIGKILL)
child.kill("SIGTERM")
setTimeout(() => {
if (child && !child.killed) {
child.kill("SIGKILL")
}
}, 1000)
}
}, timeout)
try {
// Spawn the shell process
child = spawn(shellConfig.shell, [...shellConfig.shellArgs, hook.command], {
cwd: context.project.directory,
env,
stdio: ["pipe", "pipe", "pipe"],
// Don't throw on Windows if shell not found
windowsHide: true,
})
// Write stdin
if (child.stdin) {
child.stdin.write(stdin)
child.stdin.end()
}
// Capture stdout
if (child.stdout) {
child.stdout.on("data", (data: Buffer) => {
stdout += data.toString()
})
}
// Capture stderr
if (child.stderr) {
child.stderr.on("data", (data: Buffer) => {
stderr += data.toString()
})
}
// Handle process exit
child.on("close", (code) => {
clearTimeout(timeoutHandle)
finalize(code)
})
// Handle spawn errors
child.on("error", (err) => {
clearTimeout(timeoutHandle)
finalize(null, err)
})
} catch (err) {
clearTimeout(timeoutHandle)
finalize(null, err instanceof Error ? err : new Error(String(err)))
}
})
}
/**
* Interpret the result of a hook execution.
*
* @param result - The execution result
* @returns Object with interpretation flags
*/
export function interpretResult(result: HookExecutionResult): {
success: boolean
blocked: boolean
blockMessage: string | undefined
shouldContinue: boolean
} {
// Check for execution errors first
if (result.error || result.exitCode === null) {
return {
success: false,
blocked: false,
blockMessage: undefined,
shouldContinue: true, // Execution errors don't block, per PRD
}
}
// Check for timeout
if (result.timedOut) {
return {
success: false,
blocked: false,
blockMessage: undefined,
shouldContinue: true, // Timeouts don't block, per PRD
}
}
// Exit code 0 = success
if (result.exitCode === HookExitCode.Success) {
return {
success: true,
blocked: false,
blockMessage: undefined,
shouldContinue: true,
}
}
// Exit code 2 = block (only for blocking events)
if (result.exitCode === HookExitCode.Block) {
if (isBlockingEvent(result.hook.event)) {
return {
success: false,
blocked: true,
blockMessage: result.stderr.trim() || `Hook "${result.hook.id}" blocked execution`,
shouldContinue: false,
}
} else {
// Non-blocking event with exit code 2 is treated as regular failure
console.warn(
`Hook "${result.hook.id}" returned exit code 2 (block) but ${result.hook.event} is not a blocking event - treating as error`,
)
return {
success: false,
blocked: false,
blockMessage: undefined,
shouldContinue: true,
}
}
}
// Other non-zero exit codes = error, but don't block
return {
success: false,
blocked: false,
blockMessage: undefined,
shouldContinue: true,
}
}
/**
* Get a human-readable description of a hook result for logging.
*/
export function describeResult(result: HookExecutionResult): string {
const hook = result.hook
if (result.error) {
return `Hook "${hook.id}" failed to execute: ${result.error.message}`
}
if (result.timedOut) {
return `Hook "${hook.id}" timed out after ${hook.timeout || DEFAULT_TIMEOUT}s`
}
if (result.exitCode === HookExitCode.Success) {
return `Hook "${hook.id}" completed successfully in ${result.duration}ms`
}
if (result.exitCode === HookExitCode.Block) {
return `Hook "${hook.id}" blocked with: ${result.stderr.trim() || "(no message)"}`
}
return `Hook "${hook.id}" returned exit code ${result.exitCode} in ${result.duration}ms`
}

View file

@ -0,0 +1,320 @@
/**
* Hook Manager
*
* Central service for orchestrating hooks in Roo Code.
* Implements the IHookManager interface from the PRD.
*
* Key responsibilities:
* - Load and maintain a snapshot of hook configuration
* - Execute matching hooks sequentially for events
* - Manage execution history for debugging
* - Provide enable/disable API for individual hooks
*/
import {
IHookManager,
HookEventType,
HooksConfigSnapshot,
ResolvedHook,
HookExecution,
HooksExecutionResult,
HookExecutionResult,
ExecuteHooksOptions,
HookContext,
ConversationHistoryEntry,
} from "./types"
import { loadHooksConfig, getHooksForEvent, LoadHooksConfigOptions } from "./HookConfigLoader"
import { filterMatchingHooks } from "./HookMatcher"
import { executeHook, interpretResult, describeResult } from "./HookExecutor"
/**
* Default options for the HookManager.
*/
export interface HookManagerOptions {
/** Project directory (cwd) */
cwd: string
/** Current mode slug */
mode?: string
/** Maximum execution history entries to keep (default: 100) */
maxHistoryEntries?: number
/** Optional logger for hook activity */
logger?: {
debug: (message: string) => void
info: (message: string) => void
warn: (message: string) => void
error: (message: string) => void
}
}
/**
* Default maximum history entries.
*/
const DEFAULT_MAX_HISTORY = 100
/**
* HookManager implementation.
*
* This class maintains an immutable snapshot of hook configuration that is
* loaded once and only changes on explicit reload (for security).
*/
export class HookManager implements IHookManager {
private options: HookManagerOptions
private snapshot: HooksConfigSnapshot | null = null
private executionHistory: HookExecution[] = []
private maxHistoryEntries: number
constructor(options: HookManagerOptions) {
this.options = options
this.maxHistoryEntries = options.maxHistoryEntries ?? DEFAULT_MAX_HISTORY
}
/**
* Load hooks configuration from all sources.
* Creates an immutable snapshot that won't change until explicit reload.
*/
async loadHooksConfig(): Promise<HooksConfigSnapshot> {
const loadOptions: LoadHooksConfigOptions = {
cwd: this.options.cwd,
mode: this.options.mode,
}
const { snapshot, errors, warnings } = await loadHooksConfig(loadOptions)
// Log errors and warnings
if (this.options.logger) {
for (const error of errors) {
this.options.logger.error(`Hook config error: ${error}`)
}
for (const warning of warnings) {
this.options.logger.warn(warning)
}
}
// Store the snapshot
this.snapshot = snapshot
// Log summary
const hookCount = Array.from(snapshot.hooksByEvent.values()).reduce((sum, hooks) => sum + hooks.length, 0)
this.log("info", `Loaded ${hookCount} hooks from configuration`)
return snapshot
}
/**
* Explicitly reload hooks configuration.
* Required for security - config changes don't auto-apply.
*/
async reloadHooksConfig(): Promise<void> {
this.log("info", "Reloading hooks configuration...")
// Preserve disabled hook IDs across reload
const previousDisabled = this.snapshot?.disabledHookIds ?? new Set<string>()
await this.loadHooksConfig()
// Restore disabled state for hooks that still exist
if (this.snapshot) {
for (const hookId of previousDisabled) {
if (this.snapshot.hooksById.has(hookId)) {
this.snapshot.disabledHookIds.add(hookId)
}
}
}
this.log("info", "Hooks configuration reloaded")
}
/**
* Execute all matching hooks for an event.
* Hooks are executed sequentially in their defined order.
* If a blocking event returns exit code 2, subsequent hooks are skipped.
*/
async executeHooks(event: HookEventType, options: ExecuteHooksOptions): Promise<HooksExecutionResult> {
const startTime = Date.now()
const results: HookExecutionResult[] = []
// Ensure config is loaded
if (!this.snapshot) {
await this.loadHooksConfig()
}
// Get enabled hooks for this event
const eventHooks = getHooksForEvent(this.snapshot!, event)
// Filter by tool name if this is a tool-related event
let matchingHooks: ResolvedHook[]
if (options.context.tool?.name) {
matchingHooks = filterMatchingHooks(eventHooks, options.context.tool.name)
} else {
matchingHooks = eventHooks
}
this.log("debug", `Executing ${matchingHooks.length} hooks for ${event}`)
let blocked = false
let blockMessage: string | undefined
let blockingHook: ResolvedHook | undefined
let modification: HooksExecutionResult["modification"]
// Execute hooks sequentially
for (const hook of matchingHooks) {
this.log("debug", `Executing hook "${hook.id}" for ${event}`)
// Execute the hook
const result = await executeHook(hook, options.context, options.conversationHistory)
results.push(result)
// Record in history
this.recordExecution(hook, event, result)
// Log the result
this.log("info", describeResult(result))
// Interpret the result
const interpretation = interpretResult(result)
// Check for blocking
if (interpretation.blocked) {
blocked = true
blockMessage = interpretation.blockMessage
blockingHook = hook
this.log("warn", `Hook "${hook.id}" blocked ${event}: ${blockMessage}`)
break // Stop executing subsequent hooks
}
// Check for modification (only first modification wins)
if (!modification && result.modification) {
modification = result.modification
this.log("info", `Hook "${hook.id}" modified tool input`)
}
// If hook returned an error but we should continue, log it
if (!interpretation.success && interpretation.shouldContinue) {
this.log("warn", `Hook "${hook.id}" failed but continuing: ${result.error?.message || result.stderr}`)
}
}
const totalDuration = Date.now() - startTime
this.log("debug", `Executed ${results.length} hooks in ${totalDuration}ms`)
return {
results,
blocked,
blockMessage,
blockingHook,
modification,
totalDuration,
}
}
/**
* Get all currently enabled hooks.
*/
getEnabledHooks(): ResolvedHook[] {
if (!this.snapshot) {
return []
}
const allHooks: ResolvedHook[] = []
for (const hooks of this.snapshot.hooksByEvent.values()) {
for (const hook of hooks) {
// Check if enabled in config AND not disabled at runtime
if (hook.enabled !== false && !this.snapshot.disabledHookIds.has(hook.id)) {
allHooks.push(hook)
}
}
}
return allHooks
}
/**
* Enable or disable a specific hook by ID.
* This persists until config reload.
*/
async setHookEnabled(hookId: string, enabled: boolean): Promise<void> {
if (!this.snapshot) {
await this.loadHooksConfig()
}
const hook = this.snapshot!.hooksById.get(hookId)
if (!hook) {
throw new Error(`Hook not found: ${hookId}`)
}
if (enabled) {
this.snapshot!.disabledHookIds.delete(hookId)
this.log("info", `Enabled hook "${hookId}"`)
} else {
this.snapshot!.disabledHookIds.add(hookId)
this.log("info", `Disabled hook "${hookId}"`)
}
}
/**
* Get execution history for debugging.
*/
getHookExecutionHistory(): HookExecution[] {
return [...this.executionHistory]
}
/**
* Get the current config snapshot (or null if not loaded).
*/
getConfigSnapshot(): HooksConfigSnapshot | null {
return this.snapshot
}
/**
* Update the current mode (useful when mode changes during session).
* Requires reload to take effect.
*/
setMode(mode: string): void {
this.options.mode = mode
}
/**
* Clear execution history.
*/
clearHistory(): void {
this.executionHistory = []
}
/**
* Record a hook execution in history.
*/
private recordExecution(hook: ResolvedHook, event: HookEventType, result: HookExecutionResult): void {
const entry: HookExecution = {
timestamp: new Date(),
hook,
event,
result,
}
this.executionHistory.push(entry)
// Trim history if needed
if (this.executionHistory.length > this.maxHistoryEntries) {
this.executionHistory = this.executionHistory.slice(-this.maxHistoryEntries)
}
}
/**
* Log a message using the configured logger.
*/
private log(level: "debug" | "info" | "warn" | "error", message: string): void {
if (this.options.logger) {
this.options.logger[level](`[Hooks] ${message}`)
}
}
}
/**
* Create a new HookManager instance.
*/
export function createHookManager(options: HookManagerOptions): IHookManager {
return new HookManager(options)
}

View file

@ -0,0 +1,167 @@
/**
* Hook Matcher
*
* Provides pattern matching for hooks against tool names.
* Supports exact match, regex patterns, glob patterns, and match-all.
*/
import { ResolvedHook } from "./types"
/**
* Result of compiling a matcher pattern.
*/
interface CompiledMatcher {
/** The original pattern string */
pattern: string
/** Type of matching to use */
type: "all" | "exact" | "regex" | "glob"
/** Compiled regex for regex/glob matching */
regex?: RegExp
/** Test if a tool name matches this pattern */
matches: (toolName: string) => boolean
}
/**
* Compile a matcher pattern into an efficient matcher function.
*
* Supports:
* - Exact tool name: "Write"
* - Regex pattern: "Edit|Write" (contains | or regex metacharacters)
* - Glob pattern: "mcp__*" (contains * or ?)
* - Match all: "*" or undefined/empty
*
* @param pattern - The matcher pattern string (or undefined for match-all)
* @returns Compiled matcher object
*/
export function compileMatcher(pattern: string | undefined): CompiledMatcher {
// Handle match-all cases
if (!pattern || pattern === "*") {
return {
pattern: pattern || "*",
type: "all",
matches: () => true,
}
}
// Check if pattern looks like a regex (contains regex metacharacters except * and ?)
const regexMetaChars = /[|^$+.()[\]{}\\]/
const isRegexPattern = regexMetaChars.test(pattern)
// Check if pattern looks like a glob (contains * or ?)
const isGlobPattern = /[*?]/.test(pattern) && !isRegexPattern
if (isRegexPattern) {
// Treat as regex pattern
try {
const regex = new RegExp(`^(?:${pattern})$`, "i")
return {
pattern,
type: "regex",
regex,
matches: (toolName: string) => regex.test(toolName),
}
} catch (e) {
// If regex compilation fails, fall back to exact match
console.warn(`Invalid regex pattern "${pattern}", falling back to exact match:`, e)
return {
pattern,
type: "exact",
matches: (toolName: string) => toolName.toLowerCase() === pattern.toLowerCase(),
}
}
}
if (isGlobPattern) {
// Convert glob to regex
// * matches any characters, ? matches single character
const regexPattern = pattern
.replace(/[.+^${}()|[\]\\]/g, "\\$&") // Escape regex special chars except * and ?
.replace(/\*/g, ".*") // * -> .*
.replace(/\?/g, ".") // ? -> .
try {
const regex = new RegExp(`^${regexPattern}$`, "i")
return {
pattern,
type: "glob",
regex,
matches: (toolName: string) => regex.test(toolName),
}
} catch (e) {
// If regex compilation fails, fall back to exact match
console.warn(`Invalid glob pattern "${pattern}", falling back to exact match:`, e)
return {
pattern,
type: "exact",
matches: (toolName: string) => toolName.toLowerCase() === pattern.toLowerCase(),
}
}
}
// Exact match (case-insensitive)
return {
pattern,
type: "exact",
matches: (toolName: string) => toolName.toLowerCase() === pattern.toLowerCase(),
}
}
/**
* Cache of compiled matchers for performance.
* Key is the pattern string, value is the compiled matcher.
*/
const matcherCache = new Map<string, CompiledMatcher>()
/**
* Get a compiled matcher, using cache when possible.
*
* @param pattern - The matcher pattern string (or undefined for match-all)
* @returns Compiled matcher object
*/
export function getMatcher(pattern: string | undefined): CompiledMatcher {
const cacheKey = pattern || "*"
let matcher = matcherCache.get(cacheKey)
if (!matcher) {
matcher = compileMatcher(pattern)
matcherCache.set(cacheKey, matcher)
}
return matcher
}
/**
* Clear the matcher cache (useful for testing).
*/
export function clearMatcherCache(): void {
matcherCache.clear()
}
/**
* Filter hooks that match a given tool name.
*
* @param hooks - Array of hooks to filter
* @param toolName - Tool name to match against
* @returns Hooks that match the tool name
*/
export function filterMatchingHooks(hooks: ResolvedHook[], toolName: string): ResolvedHook[] {
return hooks.filter((hook) => {
const matcher = getMatcher(hook.matcher)
return matcher.matches(toolName)
})
}
/**
* Check if a single hook matches a tool name.
*
* @param hook - The hook to check
* @param toolName - Tool name to match against
* @returns Whether the hook matches
*/
export function hookMatchesTool(hook: ResolvedHook, toolName: string): boolean {
const matcher = getMatcher(hook.matcher)
return matcher.matches(toolName)
}

View file

@ -0,0 +1,457 @@
/**
* Tool Execution Hooks Service
*
* Provides integration between the tool execution pipeline and the hooks system.
* Handles PreToolUse, PostToolUse, PostToolUseFailure, and PermissionRequest events.
*/
import type {
IHookManager,
HookEventType,
HookContext,
HookSessionContext,
HookProjectContext,
HookToolContext,
HooksExecutionResult,
ExecuteHooksOptions,
} from "./types"
/**
* Tool execution context for hooks.
*/
export interface ToolExecutionContext {
/** Tool name being executed */
toolName: string
/** Tool input parameters */
toolInput: Record<string, unknown>
/** Session context */
session: HookSessionContext
/** Project context */
project: HookProjectContext
}
/**
* Result from PreToolUse hook execution.
*/
export interface PreToolUseResult {
/** Whether the tool execution should proceed */
proceed: boolean
/** If blocked, the reason */
blockReason?: string
/** Modified tool input (if hooks modified it) */
modifiedInput?: Record<string, unknown>
/** Hook execution result for debugging */
hookResult: HooksExecutionResult
}
/**
* Result from PermissionRequest hook execution.
*/
export interface PermissionRequestResult {
/** Whether the permission request should proceed to user */
proceed: boolean
/** If blocked, the reason */
blockReason?: string
/** Hook execution result for debugging */
hookResult: HooksExecutionResult
}
/**
* Callback for emitting hook execution status to webview.
*/
export type HookStatusCallback = (status: {
status: "running" | "completed" | "failed" | "blocked"
event: HookEventType
toolName?: string
hookId?: string
duration?: number
error?: string
blockMessage?: string
modified?: boolean
}) => void
/**
* Tool Execution Hooks Service
*
* Orchestrates hook execution for tool lifecycle events.
*/
export class ToolExecutionHooks {
private hookManager: IHookManager | null
private statusCallback?: HookStatusCallback
constructor(hookManager: IHookManager | null, statusCallback?: HookStatusCallback) {
this.hookManager = hookManager
this.statusCallback = statusCallback
}
/**
* Update the hook manager instance.
*/
setHookManager(hookManager: IHookManager | null): void {
this.hookManager = hookManager
}
/**
* Update the status callback.
*/
setStatusCallback(callback: HookStatusCallback | undefined): void {
this.statusCallback = callback
}
/**
* Execute PreToolUse hooks before a tool is executed.
*
* @returns Result indicating whether to proceed, and optionally modified input
*/
async executePreToolUse(context: ToolExecutionContext): Promise<PreToolUseResult> {
if (!this.hookManager) {
// No hooks configured - proceed normally
return {
proceed: true,
hookResult: {
results: [],
blocked: false,
totalDuration: 0,
},
}
}
const hookContext = this.buildToolHookContext("PreToolUse", context)
// Emit running status
this.emitStatus({
status: "running",
event: "PreToolUse",
toolName: context.toolName,
})
try {
const result = await this.hookManager.executeHooks("PreToolUse", { context: hookContext })
if (result.blocked) {
// Hook blocked the execution
this.emitStatus({
status: "blocked",
event: "PreToolUse",
toolName: context.toolName,
blockMessage: result.blockMessage,
duration: result.totalDuration,
})
return {
proceed: false,
blockReason: result.blockMessage || "Blocked by PreToolUse hook",
hookResult: result,
}
}
// Check for modifications
const modified = !!result.modification
const modifiedInput = result.modification?.toolInput
this.emitStatus({
status: "completed",
event: "PreToolUse",
toolName: context.toolName,
duration: result.totalDuration,
modified,
})
return {
proceed: true,
modifiedInput,
hookResult: result,
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
this.emitStatus({
status: "failed",
event: "PreToolUse",
toolName: context.toolName,
error: errorMessage,
})
// On hook execution error, proceed with original execution
// (fail-open for safety)
return {
proceed: true,
hookResult: {
results: [],
blocked: false,
totalDuration: 0,
},
}
}
}
/**
* Execute PostToolUse hooks after successful tool execution.
* This is non-blocking and fire-and-forget.
*/
async executePostToolUse(
context: ToolExecutionContext,
output: unknown,
duration: number,
): Promise<HooksExecutionResult> {
if (!this.hookManager) {
return {
results: [],
blocked: false,
totalDuration: 0,
}
}
const hookContext = this.buildToolHookContext("PostToolUse", context, {
output,
duration,
})
this.emitStatus({
status: "running",
event: "PostToolUse",
toolName: context.toolName,
})
try {
const result = await this.hookManager.executeHooks("PostToolUse", { context: hookContext })
this.emitStatus({
status: "completed",
event: "PostToolUse",
toolName: context.toolName,
duration: result.totalDuration,
})
return result
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
this.emitStatus({
status: "failed",
event: "PostToolUse",
toolName: context.toolName,
error: errorMessage,
})
return {
results: [],
blocked: false,
totalDuration: 0,
}
}
}
/**
* Execute PostToolUseFailure hooks after failed tool execution.
* This is non-blocking and fire-and-forget.
*/
async executePostToolUseFailure(
context: ToolExecutionContext,
error: string,
errorMessage: string,
): Promise<HooksExecutionResult> {
if (!this.hookManager) {
return {
results: [],
blocked: false,
totalDuration: 0,
}
}
const hookContext = this.buildToolHookContext("PostToolUseFailure", context, {
error,
errorMessage,
})
this.emitStatus({
status: "running",
event: "PostToolUseFailure",
toolName: context.toolName,
})
try {
const result = await this.hookManager.executeHooks("PostToolUseFailure", { context: hookContext })
this.emitStatus({
status: "completed",
event: "PostToolUseFailure",
toolName: context.toolName,
duration: result.totalDuration,
})
return result
} catch (err) {
const errMsg = err instanceof Error ? err.message : String(err)
this.emitStatus({
status: "failed",
event: "PostToolUseFailure",
toolName: context.toolName,
error: errMsg,
})
return {
results: [],
blocked: false,
totalDuration: 0,
}
}
}
/**
* Execute PermissionRequest hooks before showing approval prompt.
*
* Note: Even if hooks indicate "block", this does NOT auto-approve restricted tools.
* The hook can only prevent the approval dialog from appearing (denying the tool),
* not bypass the existing approval/auto-approval rules.
*
* @returns Result indicating whether to proceed with showing the prompt
*/
async executePermissionRequest(context: ToolExecutionContext): Promise<PermissionRequestResult> {
if (!this.hookManager) {
return {
proceed: true,
hookResult: {
results: [],
blocked: false,
totalDuration: 0,
},
}
}
const hookContext = this.buildToolHookContext("PermissionRequest", context)
this.emitStatus({
status: "running",
event: "PermissionRequest",
toolName: context.toolName,
})
try {
const result = await this.hookManager.executeHooks("PermissionRequest", { context: hookContext })
if (result.blocked) {
// Hook blocked - do not show approval dialog, deny the tool
this.emitStatus({
status: "blocked",
event: "PermissionRequest",
toolName: context.toolName,
blockMessage: result.blockMessage,
duration: result.totalDuration,
})
return {
proceed: false,
blockReason: result.blockMessage || "Blocked by PermissionRequest hook",
hookResult: result,
}
}
this.emitStatus({
status: "completed",
event: "PermissionRequest",
toolName: context.toolName,
duration: result.totalDuration,
})
return {
proceed: true,
hookResult: result,
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
this.emitStatus({
status: "failed",
event: "PermissionRequest",
toolName: context.toolName,
error: errorMessage,
})
// On hook execution error, proceed with showing approval prompt
// (fail-open for safety)
return {
proceed: true,
hookResult: {
results: [],
blocked: false,
totalDuration: 0,
},
}
}
}
/**
* Check if hooks are configured and available.
*/
hasHooks(): boolean {
return this.hookManager !== null && this.hookManager.getConfigSnapshot() !== null
}
/**
* Build hook context for tool-related events.
*/
private buildToolHookContext(
event: HookEventType,
context: ToolExecutionContext,
extra?: {
output?: unknown
duration?: number
error?: string
errorMessage?: string
},
): HookContext {
const toolContext: HookToolContext = {
name: context.toolName,
input: context.toolInput,
}
if (extra?.output !== undefined) {
toolContext.output = extra.output
}
if (extra?.duration !== undefined) {
toolContext.duration = extra.duration
}
if (extra?.error !== undefined) {
toolContext.error = extra.error
}
if (extra?.errorMessage !== undefined) {
toolContext.errorMessage = extra.errorMessage
}
return {
event,
timestamp: new Date().toISOString(),
session: context.session,
project: context.project,
tool: toolContext,
}
}
/**
* Emit status to webview if callback is set.
*/
private emitStatus(status: Parameters<HookStatusCallback>[0]): void {
if (this.statusCallback) {
try {
this.statusCallback(status)
} catch {
// Ignore callback errors
}
}
}
}
/**
* Create a ToolExecutionHooks instance.
*/
export function createToolExecutionHooks(
hookManager: IHookManager | null,
statusCallback?: HookStatusCallback,
): ToolExecutionHooks {
return new ToolExecutionHooks(hookManager, statusCallback)
}

View file

@ -0,0 +1,337 @@
/**
* Tests for HookConfigLoader
*
* Covers:
* - Config parsing (YAML and JSON)
* - Zod validation
* - Precedence merging (project > mode > global)
* - Error handling for invalid configs
*/
import { loadHooksConfig, getHooksForEvent, getHookById } from "../HookConfigLoader"
import type { HooksConfigSnapshot, HookEventType } from "../types"
// Create hoisted mocks
const mockFsPromises = vi.hoisted(() => ({
readdir: vi.fn(),
readFile: vi.fn(),
access: vi.fn(),
}))
vi.mock("fs/promises", () => ({
default: mockFsPromises,
readdir: mockFsPromises.readdir,
readFile: mockFsPromises.readFile,
access: mockFsPromises.access,
}))
vi.mock("../../roo-config", () => ({
getGlobalRooDirectory: () => "/home/user/.roo",
getProjectRooDirectoryForCwd: (cwd: string) => `${cwd}/.roo`,
}))
describe("HookConfigLoader", () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe("loadHooksConfig", () => {
it("should return empty snapshot when no config files exist", async () => {
// Mock: no directories exist
mockFsPromises.readdir.mockRejectedValue({ code: "ENOENT" })
const result = await loadHooksConfig({ cwd: "/project" })
expect(result.snapshot.hooksByEvent.size).toBe(0)
expect(result.snapshot.hooksById.size).toBe(0)
expect(result.errors).toHaveLength(0)
})
it("should parse YAML config files", async () => {
const yamlContent = `
version: "1"
hooks:
PreToolUse:
- id: lint-check
matcher: "Edit|Write"
command: "./lint.sh"
timeout: 30
`
mockFsPromises.readdir.mockImplementation(async (dirPath) => {
const dir = dirPath.toString()
if (dir.includes("/.roo/hooks")) {
return [{ name: "pre-tool.yaml", isFile: () => true, isDirectory: () => false }]
}
throw { code: "ENOENT" }
})
mockFsPromises.readFile.mockImplementation(async (filePath) => {
if (filePath.toString().endsWith("pre-tool.yaml")) {
return yamlContent
}
throw { code: "ENOENT" }
})
const result = await loadHooksConfig({ cwd: "/project" })
expect(result.errors).toHaveLength(0)
expect(result.snapshot.hooksByEvent.has("PreToolUse")).toBe(true)
const hooks = result.snapshot.hooksByEvent.get("PreToolUse")!
expect(hooks).toHaveLength(1)
expect(hooks[0].id).toBe("lint-check")
expect(hooks[0].command).toBe("./lint.sh")
expect(hooks[0].timeout).toBe(30)
})
it("should parse JSON config files", async () => {
const jsonContent = JSON.stringify({
version: "1",
hooks: {
PostToolUse: [{ id: "notify-slack", command: "./notify.sh" }],
},
})
mockFsPromises.readdir.mockImplementation(async (dirPath) => {
const dir = dirPath.toString()
if (dir.includes("/.roo/hooks")) {
return [{ name: "post-tool.json", isFile: () => true, isDirectory: () => false }]
}
throw { code: "ENOENT" }
})
mockFsPromises.readFile.mockImplementation(async (filePath) => {
if (filePath.toString().endsWith("post-tool.json")) {
return jsonContent
}
throw { code: "ENOENT" }
})
const result = await loadHooksConfig({ cwd: "/project" })
expect(result.errors).toHaveLength(0)
expect(result.snapshot.hooksByEvent.has("PostToolUse")).toBe(true)
})
it("should report validation errors for invalid config", async () => {
// Missing required 'command' field
const invalidYaml = `
version: "1"
hooks:
PreToolUse:
- id: bad-hook
command: ""
`
mockFsPromises.readdir.mockImplementation(async (dirPath) => {
const dir = dirPath.toString()
if (dir.includes("/.roo/hooks")) {
return [{ name: "invalid.yaml", isFile: () => true, isDirectory: () => false }]
}
throw { code: "ENOENT" }
})
mockFsPromises.readFile.mockImplementation(async (filePath) => {
if (filePath.toString().endsWith("invalid.yaml")) {
return invalidYaml
}
throw { code: "ENOENT" }
})
const result = await loadHooksConfig({ cwd: "/project" })
// Should have validation error (command cannot be empty)
expect(result.errors.length).toBeGreaterThan(0)
expect(result.errors[0]).toContain("command")
})
it("should merge configs with project taking precedence over global", async () => {
const globalYaml = `
version: "1"
hooks:
PreToolUse:
- id: shared-hook
command: "global-command"
`
const projectYaml = `
version: "1"
hooks:
PreToolUse:
- id: shared-hook
command: "project-command"
`
mockFsPromises.readdir.mockImplementation(async (dirPath) => {
const dir = dirPath.toString()
if (dir.includes(".roo/hooks")) {
return [{ name: "hooks.yaml", isFile: () => true, isDirectory: () => false }]
}
throw { code: "ENOENT" }
})
mockFsPromises.readFile.mockImplementation(async (filePath) => {
const path = filePath.toString()
if (path.includes("/home/user/.roo") && path.endsWith("hooks.yaml")) {
return globalYaml
}
if (path.includes("/project/.roo") && path.endsWith("hooks.yaml")) {
return projectYaml
}
throw { code: "ENOENT" }
})
const result = await loadHooksConfig({ cwd: "/project" })
expect(result.errors).toHaveLength(0)
const hooks = result.snapshot.hooksByEvent.get("PreToolUse")!
expect(hooks).toHaveLength(1)
// Project should take precedence
expect(hooks[0].command).toBe("project-command")
})
it("should include mode-specific hooks when mode is provided", async () => {
const modeYaml = `
version: "1"
hooks:
PreToolUse:
- id: mode-hook
command: "./mode-specific.sh"
`
mockFsPromises.readdir.mockImplementation(async (dirPath) => {
const dir = dirPath.toString()
if (dir.includes("hooks-code")) {
return [{ name: "hooks.yaml", isFile: () => true, isDirectory: () => false }]
}
throw { code: "ENOENT" }
})
mockFsPromises.readFile.mockImplementation(async (filePath) => {
if (filePath.toString().includes("hooks-code")) {
return modeYaml
}
throw { code: "ENOENT" }
})
const result = await loadHooksConfig({ cwd: "/project", mode: "code" })
expect(result.errors).toHaveLength(0)
expect(result.snapshot.hooksByEvent.has("PreToolUse")).toBe(true)
})
it("should set hasProjectHooks flag when project hooks exist", async () => {
const projectYaml = `
version: "1"
hooks:
PreToolUse:
- id: project-hook
command: "./project.sh"
`
mockFsPromises.readdir.mockImplementation(async (dirPath) => {
const dir = dirPath.toString()
if (dir.endsWith("/.roo/hooks")) {
return [{ name: "hooks.yaml", isFile: () => true, isDirectory: () => false }]
}
throw { code: "ENOENT" }
})
mockFsPromises.readFile.mockImplementation(async (filePath) => {
if (filePath.toString().includes("/project/.roo")) {
return projectYaml
}
throw { code: "ENOENT" }
})
const result = await loadHooksConfig({ cwd: "/project" })
expect(result.snapshot.hasProjectHooks).toBe(true)
})
})
describe("getHooksForEvent", () => {
const createSnapshot = (
hooks: Array<{ id: string; event: HookEventType; command: string }>,
): HooksConfigSnapshot => {
const hooksByEvent = new Map<HookEventType, any[]>()
const hooksById = new Map<string, any>()
for (const h of hooks) {
const hook = { ...h, enabled: true, _runtimeDisabled: false }
if (!hooksByEvent.has(h.event)) {
hooksByEvent.set(h.event, [])
}
hooksByEvent.get(h.event)!.push(hook)
hooksById.set(h.id, hook)
}
return {
hooksByEvent,
hooksById,
hasProjectHooks: false,
loadedAt: new Date(),
disabledHookIds: new Set<string>(),
}
}
it("should return hooks for specific event", () => {
const snapshot = createSnapshot([
{ id: "hook1", event: "PreToolUse", command: "./a.sh" },
{ id: "hook2", event: "PostToolUse", command: "./b.sh" },
])
const result = getHooksForEvent(snapshot, "PreToolUse")
expect(result).toHaveLength(1)
expect(result[0].id).toBe("hook1")
})
it("should exclude disabled hooks", () => {
const snapshot = createSnapshot([{ id: "hook1", event: "PreToolUse", command: "./a.sh" }])
// Manually disable the hook
snapshot.hooksByEvent.get("PreToolUse")![0].enabled = false
const result = getHooksForEvent(snapshot, "PreToolUse")
expect(result).toHaveLength(0)
})
it("should exclude runtime-disabled hooks", () => {
const snapshot = createSnapshot([{ id: "hook1", event: "PreToolUse", command: "./a.sh" }])
// Add hook ID to disabled set
snapshot.disabledHookIds.add("hook1")
const result = getHooksForEvent(snapshot, "PreToolUse")
expect(result).toHaveLength(0)
})
it("should return empty array for events with no hooks", () => {
const snapshot = createSnapshot([])
const result = getHooksForEvent(snapshot, "Notification")
expect(result).toHaveLength(0)
})
})
describe("getHookById", () => {
it("should return hook by ID", () => {
const snapshot: HooksConfigSnapshot = {
hooksByEvent: new Map(),
hooksById: new Map([["my-hook", { id: "my-hook", command: "./test.sh" } as any]]),
hasProjectHooks: false,
loadedAt: new Date(),
disabledHookIds: new Set<string>(),
}
const result = getHookById(snapshot, "my-hook")
expect(result).toBeDefined()
expect(result!.id).toBe("my-hook")
})
it("should return undefined for non-existent hook", () => {
const snapshot: HooksConfigSnapshot = {
hooksByEvent: new Map(),
hooksById: new Map(),
hasProjectHooks: false,
loadedAt: new Date(),
disabledHookIds: new Set<string>(),
}
const result = getHookById(snapshot, "non-existent")
expect(result).toBeUndefined()
})
})
})

View file

@ -0,0 +1,542 @@
/**
* Tests for HookExecutor
*
* Covers:
* - Exit code handling (0=success, 2=block, other=error)
* - Timeout behavior
* - Environment variable setup
* - stdin JSON payload
* - Blocking vs non-blocking events
*/
import { spawn } from "child_process"
import { executeHook, interpretResult, describeResult } from "../HookExecutor"
import type { ResolvedHook, HookContext, HookExecutionResult, HookEventType } from "../types"
// Mock child_process
vi.mock("child_process", () => ({
spawn: vi.fn(),
}))
const mockSpawn = vi.mocked(spawn)
describe("HookExecutor", () => {
const createMockHook = (overrides: Partial<ResolvedHook> = {}): ResolvedHook =>
({
id: "test-hook",
matcher: "*",
enabled: true,
command: "echo test",
timeout: 5,
source: "project",
event: "PreToolUse" as HookEventType,
filePath: "/test/hooks.yaml",
includeConversationHistory: false,
...overrides,
}) as ResolvedHook
const createMockContext = (overrides: Partial<HookContext> = {}): HookContext => ({
event: "PreToolUse",
timestamp: "2026-01-16T12:00:00Z",
session: {
taskId: "task_123",
sessionId: "session_456",
mode: "code",
},
project: {
directory: "/project",
name: "test-project",
},
tool: {
name: "Write",
input: { filePath: "/src/index.ts", content: "// test" },
},
...overrides,
})
describe("interpretResult", () => {
it("should interpret exit code 0 as success", () => {
const result: HookExecutionResult = {
hook: createMockHook(),
exitCode: 0,
stdout: "",
stderr: "",
duration: 100,
timedOut: false,
}
const interpretation = interpretResult(result)
expect(interpretation.success).toBe(true)
expect(interpretation.blocked).toBe(false)
expect(interpretation.shouldContinue).toBe(true)
})
it("should interpret exit code 2 as block for blocking events", () => {
const result: HookExecutionResult = {
hook: createMockHook({ event: "PreToolUse" }),
exitCode: 2,
stdout: "",
stderr: "Lint errors found",
duration: 100,
timedOut: false,
}
const interpretation = interpretResult(result)
expect(interpretation.success).toBe(false)
expect(interpretation.blocked).toBe(true)
expect(interpretation.blockMessage).toBe("Lint errors found")
expect(interpretation.shouldContinue).toBe(false)
})
it("should NOT block for non-blocking events even with exit code 2", () => {
const result: HookExecutionResult = {
hook: createMockHook({ event: "PostToolUse" }),
exitCode: 2,
stdout: "",
stderr: "Some error",
duration: 100,
timedOut: false,
}
const interpretation = interpretResult(result)
expect(interpretation.blocked).toBe(false)
expect(interpretation.shouldContinue).toBe(true)
})
it("should interpret other non-zero codes as error (continue)", () => {
const result: HookExecutionResult = {
hook: createMockHook(),
exitCode: 1,
stdout: "",
stderr: "Command failed",
duration: 100,
timedOut: false,
}
const interpretation = interpretResult(result)
expect(interpretation.success).toBe(false)
expect(interpretation.blocked).toBe(false)
expect(interpretation.shouldContinue).toBe(true)
})
it("should handle timeout (continue)", () => {
const result: HookExecutionResult = {
hook: createMockHook(),
exitCode: null,
stdout: "",
stderr: "",
duration: 5000,
timedOut: true,
}
const interpretation = interpretResult(result)
expect(interpretation.success).toBe(false)
expect(interpretation.blocked).toBe(false)
expect(interpretation.shouldContinue).toBe(true)
})
it("should handle execution error (continue)", () => {
const result: HookExecutionResult = {
hook: createMockHook(),
exitCode: null,
stdout: "",
stderr: "",
duration: 10,
timedOut: false,
error: new Error("Command not found"),
}
const interpretation = interpretResult(result)
expect(interpretation.success).toBe(false)
expect(interpretation.blocked).toBe(false)
expect(interpretation.shouldContinue).toBe(true)
})
describe("blocking events", () => {
const blockingEvents: HookEventType[] = [
"PreToolUse",
"PermissionRequest",
"UserPromptSubmit",
"Stop",
"SubagentStop",
]
for (const event of blockingEvents) {
it(`should allow blocking for ${event}`, () => {
const result: HookExecutionResult = {
hook: createMockHook({ event }),
exitCode: 2,
stdout: "",
stderr: "Blocked",
duration: 100,
timedOut: false,
}
const interpretation = interpretResult(result)
expect(interpretation.blocked).toBe(true)
})
}
})
describe("non-blocking events", () => {
const nonBlockingEvents: HookEventType[] = [
"PostToolUse",
"PostToolUseFailure",
"SubagentStart",
"SessionStart",
"SessionEnd",
"Notification",
"PreCompact",
]
for (const event of nonBlockingEvents) {
it(`should NOT block for ${event}`, () => {
const result: HookExecutionResult = {
hook: createMockHook({ event }),
exitCode: 2,
stdout: "",
stderr: "Attempted block",
duration: 100,
timedOut: false,
}
const interpretation = interpretResult(result)
expect(interpretation.blocked).toBe(false)
})
}
})
})
describe("describeResult", () => {
it("should describe successful execution", () => {
const result: HookExecutionResult = {
hook: createMockHook({ id: "my-hook" }),
exitCode: 0,
stdout: "",
stderr: "",
duration: 150,
timedOut: false,
}
const description = describeResult(result)
expect(description).toContain("my-hook")
expect(description).toContain("successfully")
expect(description).toContain("150ms")
})
it("should describe blocked execution", () => {
const result: HookExecutionResult = {
hook: createMockHook({ id: "blocker" }),
exitCode: 2,
stdout: "",
stderr: "Policy violation",
duration: 100,
timedOut: false,
}
const description = describeResult(result)
expect(description).toContain("blocker")
expect(description).toContain("blocked")
expect(description).toContain("Policy violation")
})
it("should describe timeout", () => {
const result: HookExecutionResult = {
hook: createMockHook({ id: "slow-hook", timeout: 30 }),
exitCode: null,
stdout: "",
stderr: "",
duration: 30000,
timedOut: true,
}
const description = describeResult(result)
expect(description).toContain("slow-hook")
expect(description).toContain("timed out")
expect(description).toContain("30")
})
it("should describe execution error", () => {
const result: HookExecutionResult = {
hook: createMockHook({ id: "broken" }),
exitCode: null,
stdout: "",
stderr: "",
duration: 10,
timedOut: false,
error: new Error("Command not found: badcmd"),
}
const description = describeResult(result)
expect(description).toContain("broken")
expect(description).toContain("failed")
expect(description).toContain("Command not found")
})
})
describe("executeHook", () => {
beforeEach(() => {
vi.clearAllMocks()
})
it("should spawn process with correct arguments", async () => {
const mockProcess = {
stdin: { write: vi.fn(), end: vi.fn() },
stdout: { on: vi.fn() },
stderr: { on: vi.fn() },
on: vi.fn((event, cb) => {
if (event === "close") {
setTimeout(() => cb(0), 10)
}
}),
kill: vi.fn(),
killed: false,
}
mockSpawn.mockReturnValue(mockProcess as any)
const hook = createMockHook({ command: "./test-script.sh" })
const context = createMockContext()
const resultPromise = executeHook(hook, context)
// Wait a tick for the spawn to be called
await new Promise((r) => setTimeout(r, 0))
expect(mockSpawn).toHaveBeenCalled()
const [shell, args, options] = mockSpawn.mock.calls[0]
// Should use shell with -c flag (or PowerShell equivalent)
expect(args[args.length - 1]).toBe("./test-script.sh")
expect(options.cwd).toBe("/project")
const result = await resultPromise
expect(result.exitCode).toBe(0)
})
it("should write JSON context to stdin", async () => {
const stdinWrite = vi.fn()
const mockProcess = {
stdin: { write: stdinWrite, end: vi.fn() },
stdout: { on: vi.fn() },
stderr: { on: vi.fn() },
on: vi.fn((event, cb) => {
if (event === "close") {
setTimeout(() => cb(0), 10)
}
}),
kill: vi.fn(),
killed: false,
}
mockSpawn.mockReturnValue(mockProcess as any)
const hook = createMockHook()
const context = createMockContext()
await executeHook(hook, context)
expect(stdinWrite).toHaveBeenCalled()
const jsonPayload = stdinWrite.mock.calls[0][0]
const parsed = JSON.parse(jsonPayload)
expect(parsed.event).toBe("PreToolUse")
expect(parsed.session.taskId).toBe("task_123")
expect(parsed.project.directory).toBe("/project")
expect(parsed.tool.name).toBe("Write")
})
it("should set environment variables", async () => {
const mockProcess = {
stdin: { write: vi.fn(), end: vi.fn() },
stdout: { on: vi.fn() },
stderr: { on: vi.fn() },
on: vi.fn((event, cb) => {
if (event === "close") {
setTimeout(() => cb(0), 10)
}
}),
kill: vi.fn(),
killed: false,
}
mockSpawn.mockReturnValue(mockProcess as any)
const hook = createMockHook({ id: "env-test" })
const context = createMockContext()
await executeHook(hook, context)
const options = mockSpawn.mock.calls[0][2]
const env = options.env
expect(env).toBeDefined()
if (!env) throw new Error("Expected env to be captured")
expect(env.ROO_PROJECT_DIR).toBe("/project")
expect(env.ROO_TASK_ID).toBe("task_123")
expect(env.ROO_SESSION_ID).toBe("session_456")
expect(env.ROO_MODE).toBe("code")
expect(env.ROO_TOOL_NAME).toBe("Write")
expect(env.ROO_EVENT).toBe("PreToolUse")
expect(env.ROO_HOOK_ID).toBe("env-test")
})
it("should NOT include conversation history by default", async () => {
const stdinWrite = vi.fn()
const mockProcess = {
stdin: { write: stdinWrite, end: vi.fn() },
stdout: { on: vi.fn() },
stderr: { on: vi.fn() },
on: vi.fn((event, cb) => {
if (event === "close") {
setTimeout(() => cb(0), 10)
}
}),
kill: vi.fn(),
killed: false,
}
mockSpawn.mockReturnValue(mockProcess as any)
const hook = createMockHook({ includeConversationHistory: false })
const context = createMockContext()
const history = [{ role: "user" as const, content: "Hello" }]
await executeHook(hook, context, history)
const jsonPayload = stdinWrite.mock.calls[0][0]
const parsed = JSON.parse(jsonPayload)
expect(parsed.conversationHistory).toBeUndefined()
})
it("should include conversation history when opted in", async () => {
const stdinWrite = vi.fn()
const mockProcess = {
stdin: { write: stdinWrite, end: vi.fn() },
stdout: { on: vi.fn() },
stderr: { on: vi.fn() },
on: vi.fn((event, cb) => {
if (event === "close") {
setTimeout(() => cb(0), 10)
}
}),
kill: vi.fn(),
killed: false,
}
mockSpawn.mockReturnValue(mockProcess as any)
const hook = createMockHook({ includeConversationHistory: true })
const context = createMockContext()
const history = [{ role: "user" as const, content: "Hello" }]
await executeHook(hook, context, history)
const jsonPayload = stdinWrite.mock.calls[0][0]
const parsed = JSON.parse(jsonPayload)
expect(parsed.conversationHistory).toBeDefined()
expect(parsed.conversationHistory).toHaveLength(1)
})
it("should capture stdout and stderr", async () => {
let stdoutCallback: ((data: Buffer) => void) | undefined
let stderrCallback: ((data: Buffer) => void) | undefined
const mockProcess = {
stdin: { write: vi.fn(), end: vi.fn() },
stdout: {
on: vi.fn((event, cb) => {
if (event === "data") stdoutCallback = cb
}),
},
stderr: {
on: vi.fn((event, cb) => {
if (event === "data") stderrCallback = cb
}),
},
on: vi.fn((event, cb) => {
if (event === "close") {
setTimeout(() => {
stdoutCallback?.(Buffer.from("stdout content"))
stderrCallback?.(Buffer.from("stderr content"))
cb(0)
}, 10)
}
}),
kill: vi.fn(),
killed: false,
}
mockSpawn.mockReturnValue(mockProcess as any)
const result = await executeHook(createMockHook(), createMockContext())
expect(result.stdout).toContain("stdout content")
expect(result.stderr).toContain("stderr content")
})
it("should handle process spawn errors", async () => {
const mockProcess = {
stdin: null,
stdout: { on: vi.fn() },
stderr: { on: vi.fn() },
on: vi.fn((event, cb) => {
if (event === "error") {
setTimeout(() => cb(new Error("spawn ENOENT")), 10)
}
}),
kill: vi.fn(),
killed: false,
}
mockSpawn.mockReturnValue(mockProcess as any)
const result = await executeHook(createMockHook(), createMockContext())
expect(result.error).toBeDefined()
expect(result.error?.message).toContain("ENOENT")
})
it("should parse modification JSON from stdout for PreToolUse", async () => {
let stdoutCallback: ((data: Buffer) => void) | undefined
const modificationJson = JSON.stringify({
action: "modify",
toolInput: { filePath: "/modified/path.ts", content: "// modified" },
})
const mockProcess = {
stdin: { write: vi.fn(), end: vi.fn() },
stdout: {
on: vi.fn((event, cb) => {
if (event === "data") stdoutCallback = cb
}),
},
stderr: { on: vi.fn() },
on: vi.fn((event, cb) => {
if (event === "close") {
setTimeout(() => {
stdoutCallback?.(Buffer.from(modificationJson))
cb(0)
}, 10)
}
}),
kill: vi.fn(),
killed: false,
}
mockSpawn.mockReturnValue(mockProcess as any)
const result = await executeHook(createMockHook({ event: "PreToolUse" }), createMockContext())
expect(result.modification).toBeDefined()
expect(result.modification?.action).toBe("modify")
expect(result.modification?.toolInput.filePath).toBe("/modified/path.ts")
})
})
})

View file

@ -0,0 +1,548 @@
/**
* Tests for HookManager
*
* Covers:
* - Config loading and snapshot management
* - Sequential hook execution
* - Enable/disable functionality
* - Execution history tracking
*/
import { HookManager, createHookManager } from "../HookManager"
import * as HookConfigLoader from "../HookConfigLoader"
import * as HookExecutor from "../HookExecutor"
import type { HooksConfigSnapshot, ResolvedHook, HookEventType, HookContext } from "../types"
// Mock dependencies
vi.mock("../HookConfigLoader")
vi.mock("../HookExecutor")
const mockLoadHooksConfig = vi.mocked(HookConfigLoader.loadHooksConfig)
const mockGetHooksForEvent = vi.mocked(HookConfigLoader.getHooksForEvent)
const mockExecuteHook = vi.mocked(HookExecutor.executeHook)
const mockInterpretResult = vi.mocked(HookExecutor.interpretResult)
const mockDescribeResult = vi.mocked(HookExecutor.describeResult)
describe("HookManager", () => {
const createMockHook = (id: string, event: HookEventType = "PreToolUse"): ResolvedHook =>
({
id,
matcher: "*",
enabled: true,
command: `echo ${id}`,
timeout: 60,
source: "project",
event,
filePath: "/test/hooks.yaml",
}) as ResolvedHook
const createMockSnapshot = (hooks: ResolvedHook[] = []): HooksConfigSnapshot => {
const hooksByEvent = new Map<HookEventType, ResolvedHook[]>()
const hooksById = new Map<string, ResolvedHook>()
for (const hook of hooks) {
if (!hooksByEvent.has(hook.event)) {
hooksByEvent.set(hook.event, [])
}
hooksByEvent.get(hook.event)!.push(hook)
hooksById.set(hook.id, hook)
}
return {
hooksByEvent,
hooksById,
loadedAt: new Date(),
disabledHookIds: new Set(),
hasProjectHooks: hooks.some((h) => h.source === "project"),
}
}
beforeEach(() => {
vi.clearAllMocks()
mockDescribeResult.mockReturnValue("Hook executed")
})
describe("loadHooksConfig", () => {
it("should load config and return snapshot", async () => {
const mockSnapshot = createMockSnapshot([createMockHook("hook1")])
mockLoadHooksConfig.mockResolvedValue({
snapshot: mockSnapshot,
errors: [],
warnings: [],
})
const manager = createHookManager({ cwd: "/project" })
const snapshot = await manager.loadHooksConfig()
expect(mockLoadHooksConfig).toHaveBeenCalledWith({ cwd: "/project", mode: undefined })
expect(snapshot).toBe(mockSnapshot)
})
it("should pass mode to config loader", async () => {
const mockSnapshot = createMockSnapshot([])
mockLoadHooksConfig.mockResolvedValue({
snapshot: mockSnapshot,
errors: [],
warnings: [],
})
const manager = createHookManager({ cwd: "/project", mode: "code" })
await manager.loadHooksConfig()
expect(mockLoadHooksConfig).toHaveBeenCalledWith({ cwd: "/project", mode: "code" })
})
it("should log errors and warnings", async () => {
const mockSnapshot = createMockSnapshot([])
const errorLog: string[] = []
const warnLog: string[] = []
mockLoadHooksConfig.mockResolvedValue({
snapshot: mockSnapshot,
errors: ["Config error 1"],
warnings: ["Warning 1"],
})
const manager = createHookManager({
cwd: "/project",
logger: {
debug: vi.fn(),
info: vi.fn(),
warn: (msg) => warnLog.push(msg),
error: (msg) => errorLog.push(msg),
},
})
await manager.loadHooksConfig()
expect(errorLog.some((e) => e.includes("Config error 1"))).toBe(true)
expect(warnLog.some((w) => w.includes("Warning 1"))).toBe(true)
})
})
describe("reloadHooksConfig", () => {
it("should reload config while preserving disabled hooks", async () => {
const hook1 = createMockHook("hook1")
const hook2 = createMockHook("hook2")
// Initial load
const initialSnapshot = createMockSnapshot([hook1, hook2])
mockLoadHooksConfig.mockResolvedValueOnce({
snapshot: initialSnapshot,
errors: [],
warnings: [],
})
const manager = createHookManager({ cwd: "/project" })
await manager.loadHooksConfig()
// Disable hook2
await manager.setHookEnabled("hook2", false)
// Reload with same hooks
const reloadedSnapshot = createMockSnapshot([hook1, hook2])
mockLoadHooksConfig.mockResolvedValueOnce({
snapshot: reloadedSnapshot,
errors: [],
warnings: [],
})
await manager.reloadHooksConfig()
const snapshot = manager.getConfigSnapshot()
expect(snapshot?.disabledHookIds.has("hook2")).toBe(true)
})
})
describe("executeHooks", () => {
const createMockContext = (): HookContext => ({
event: "PreToolUse",
timestamp: "2026-01-16T12:00:00Z",
session: { taskId: "task_1", sessionId: "session_1", mode: "code" },
project: { directory: "/project", name: "test" },
tool: { name: "Write", input: { filePath: "/test.ts", content: "test" } },
})
it("should execute hooks sequentially", async () => {
const hook1 = createMockHook("hook1")
const hook2 = createMockHook("hook2")
const snapshot = createMockSnapshot([hook1, hook2])
mockLoadHooksConfig.mockResolvedValue({
snapshot,
errors: [],
warnings: [],
})
mockGetHooksForEvent.mockReturnValue([hook1, hook2])
mockExecuteHook.mockResolvedValue({
hook: hook1,
exitCode: 0,
stdout: "",
stderr: "",
duration: 100,
timedOut: false,
})
mockInterpretResult.mockReturnValue({
success: true,
blocked: false,
blockMessage: undefined,
shouldContinue: true,
})
const manager = createHookManager({ cwd: "/project" })
await manager.loadHooksConfig()
const result = await manager.executeHooks("PreToolUse", { context: createMockContext() })
// Should execute both hooks
expect(mockExecuteHook).toHaveBeenCalledTimes(2)
expect(result.results).toHaveLength(2)
expect(result.blocked).toBe(false)
})
it("should stop execution when a hook blocks", async () => {
const hook1 = createMockHook("blocker")
const hook2 = createMockHook("after-block")
const snapshot = createMockSnapshot([hook1, hook2])
mockLoadHooksConfig.mockResolvedValue({
snapshot,
errors: [],
warnings: [],
})
mockGetHooksForEvent.mockReturnValue([hook1, hook2])
mockExecuteHook.mockResolvedValue({
hook: hook1,
exitCode: 2,
stdout: "",
stderr: "Blocked!",
duration: 100,
timedOut: false,
})
mockInterpretResult.mockReturnValue({
success: false,
blocked: true,
blockMessage: "Blocked!",
shouldContinue: false,
})
const manager = createHookManager({ cwd: "/project" })
await manager.loadHooksConfig()
const result = await manager.executeHooks("PreToolUse", { context: createMockContext() })
// Should only execute first hook (the blocker)
expect(mockExecuteHook).toHaveBeenCalledTimes(1)
expect(result.results).toHaveLength(1)
expect(result.blocked).toBe(true)
expect(result.blockMessage).toBe("Blocked!")
})
it("should continue on non-blocking failures", async () => {
const hook1 = createMockHook("failing")
const hook2 = createMockHook("after-fail")
const snapshot = createMockSnapshot([hook1, hook2])
mockLoadHooksConfig.mockResolvedValue({
snapshot,
errors: [],
warnings: [],
})
mockGetHooksForEvent.mockReturnValue([hook1, hook2])
let callCount = 0
mockExecuteHook.mockImplementation(async (hook) => ({
hook,
exitCode: callCount++ === 0 ? 1 : 0,
stdout: "",
stderr: callCount === 1 ? "Error" : "",
duration: 100,
timedOut: false,
}))
mockInterpretResult.mockImplementation((result) => ({
success: result.exitCode === 0,
blocked: false,
blockMessage: undefined,
shouldContinue: true,
}))
const manager = createHookManager({ cwd: "/project" })
await manager.loadHooksConfig()
const result = await manager.executeHooks("PreToolUse", { context: createMockContext() })
// Should execute both hooks despite first failure
expect(mockExecuteHook).toHaveBeenCalledTimes(2)
expect(result.results).toHaveLength(2)
expect(result.blocked).toBe(false)
})
it("should return first modification only", async () => {
const hook1 = createMockHook("modifier1")
const hook2 = createMockHook("modifier2")
const snapshot = createMockSnapshot([hook1, hook2])
mockLoadHooksConfig.mockResolvedValue({
snapshot,
errors: [],
warnings: [],
})
mockGetHooksForEvent.mockReturnValue([hook1, hook2])
let callCount = 0
mockExecuteHook.mockImplementation(async (hook) => ({
hook,
exitCode: 0,
stdout: "",
stderr: "",
duration: 100,
timedOut: false,
modification:
callCount++ === 0
? { action: "modify" as const, toolInput: { from: "first" } }
: { action: "modify" as const, toolInput: { from: "second" } },
}))
mockInterpretResult.mockReturnValue({
success: true,
blocked: false,
blockMessage: undefined,
shouldContinue: true,
})
const manager = createHookManager({ cwd: "/project" })
await manager.loadHooksConfig()
const result = await manager.executeHooks("PreToolUse", { context: createMockContext() })
expect(result.modification?.toolInput).toEqual({ from: "first" })
})
it("should auto-load config if not loaded", async () => {
const hook1 = createMockHook("hook1")
const snapshot = createMockSnapshot([hook1])
mockLoadHooksConfig.mockResolvedValue({
snapshot,
errors: [],
warnings: [],
})
mockGetHooksForEvent.mockReturnValue([hook1])
mockExecuteHook.mockResolvedValue({
hook: hook1,
exitCode: 0,
stdout: "",
stderr: "",
duration: 100,
timedOut: false,
})
mockInterpretResult.mockReturnValue({
success: true,
blocked: false,
blockMessage: undefined,
shouldContinue: true,
})
const manager = createHookManager({ cwd: "/project" })
// Don't call loadHooksConfig explicitly
await manager.executeHooks("PreToolUse", { context: createMockContext() })
// Should have auto-loaded
expect(mockLoadHooksConfig).toHaveBeenCalled()
})
})
describe("getEnabledHooks", () => {
it("should return all enabled hooks", async () => {
const hook1 = createMockHook("hook1")
const hook2 = createMockHook("hook2")
const disabledHook = { ...createMockHook("disabled"), enabled: false } as ResolvedHook
const snapshot = createMockSnapshot([hook1, hook2, disabledHook])
mockLoadHooksConfig.mockResolvedValue({
snapshot,
errors: [],
warnings: [],
})
const manager = createHookManager({ cwd: "/project" })
await manager.loadHooksConfig()
const enabled = manager.getEnabledHooks()
expect(enabled).toHaveLength(2)
expect(enabled.map((h) => h.id).sort()).toEqual(["hook1", "hook2"])
})
it("should exclude runtime-disabled hooks", async () => {
const hook1 = createMockHook("hook1")
const hook2 = createMockHook("hook2")
const snapshot = createMockSnapshot([hook1, hook2])
mockLoadHooksConfig.mockResolvedValue({
snapshot,
errors: [],
warnings: [],
})
const manager = createHookManager({ cwd: "/project" })
await manager.loadHooksConfig()
await manager.setHookEnabled("hook2", false)
const enabled = manager.getEnabledHooks()
expect(enabled).toHaveLength(1)
expect(enabled[0].id).toBe("hook1")
})
})
describe("setHookEnabled", () => {
it("should disable a hook", async () => {
const hook1 = createMockHook("hook1")
const snapshot = createMockSnapshot([hook1])
mockLoadHooksConfig.mockResolvedValue({
snapshot,
errors: [],
warnings: [],
})
const manager = createHookManager({ cwd: "/project" })
await manager.loadHooksConfig()
await manager.setHookEnabled("hook1", false)
expect(manager.getConfigSnapshot()?.disabledHookIds.has("hook1")).toBe(true)
})
it("should re-enable a disabled hook", async () => {
const hook1 = createMockHook("hook1")
const snapshot = createMockSnapshot([hook1])
mockLoadHooksConfig.mockResolvedValue({
snapshot,
errors: [],
warnings: [],
})
const manager = createHookManager({ cwd: "/project" })
await manager.loadHooksConfig()
await manager.setHookEnabled("hook1", false)
await manager.setHookEnabled("hook1", true)
expect(manager.getConfigSnapshot()?.disabledHookIds.has("hook1")).toBe(false)
})
it("should throw for non-existent hook", async () => {
const snapshot = createMockSnapshot([])
mockLoadHooksConfig.mockResolvedValue({
snapshot,
errors: [],
warnings: [],
})
const manager = createHookManager({ cwd: "/project" })
await manager.loadHooksConfig()
await expect(manager.setHookEnabled("nonexistent", false)).rejects.toThrow("Hook not found")
})
})
describe("getHookExecutionHistory", () => {
it("should return execution history", async () => {
const hook1 = createMockHook("hook1")
const snapshot = createMockSnapshot([hook1])
mockLoadHooksConfig.mockResolvedValue({
snapshot,
errors: [],
warnings: [],
})
mockGetHooksForEvent.mockReturnValue([hook1])
mockExecuteHook.mockResolvedValue({
hook: hook1,
exitCode: 0,
stdout: "",
stderr: "",
duration: 100,
timedOut: false,
})
mockInterpretResult.mockReturnValue({
success: true,
blocked: false,
blockMessage: undefined,
shouldContinue: true,
})
const manager = createHookManager({ cwd: "/project" })
await manager.loadHooksConfig()
await manager.executeHooks("PreToolUse", {
context: {
event: "PreToolUse",
timestamp: "2026-01-16T12:00:00Z",
session: { taskId: "task_1", sessionId: "session_1", mode: "code" },
project: { directory: "/project", name: "test" },
},
})
const history = manager.getHookExecutionHistory()
expect(history).toHaveLength(1)
expect(history[0].hook.id).toBe("hook1")
expect(history[0].event).toBe("PreToolUse")
})
it("should limit history size", async () => {
const hooks = Array.from({ length: 150 }, (_, i) => createMockHook(`hook${i}`))
const snapshot = createMockSnapshot(hooks)
mockLoadHooksConfig.mockResolvedValue({
snapshot,
errors: [],
warnings: [],
})
mockGetHooksForEvent.mockReturnValue(hooks)
mockExecuteHook.mockImplementation(async (hook) => ({
hook,
exitCode: 0,
stdout: "",
stderr: "",
duration: 10,
timedOut: false,
}))
mockInterpretResult.mockReturnValue({
success: true,
blocked: false,
blockMessage: undefined,
shouldContinue: true,
})
const manager = createHookManager({ cwd: "/project", maxHistoryEntries: 100 })
await manager.loadHooksConfig()
await manager.executeHooks("PreToolUse", {
context: {
event: "PreToolUse",
timestamp: "2026-01-16T12:00:00Z",
session: { taskId: "task_1", sessionId: "session_1", mode: "code" },
project: { directory: "/project", name: "test" },
},
})
const history = manager.getHookExecutionHistory()
expect(history.length).toBeLessThanOrEqual(100)
})
})
describe("createHookManager", () => {
it("should create a HookManager instance", () => {
const manager = createHookManager({ cwd: "/project" })
expect(manager).toBeInstanceOf(HookManager)
})
})
})

View file

@ -0,0 +1,219 @@
/**
* Tests for HookMatcher
*
* Covers:
* - Exact matching
* - Regex pattern matching
* - Glob pattern matching
* - Match-all behavior
* - Cache behavior
*/
import { compileMatcher, getMatcher, clearMatcherCache, filterMatchingHooks, hookMatchesTool } from "../HookMatcher"
import type { ResolvedHook } from "../types"
describe("HookMatcher", () => {
beforeEach(() => {
clearMatcherCache()
})
describe("compileMatcher", () => {
describe("match-all patterns", () => {
it('should match all tools with "*" pattern', () => {
const matcher = compileMatcher("*")
expect(matcher.type).toBe("all")
expect(matcher.matches("Write")).toBe(true)
expect(matcher.matches("Read")).toBe(true)
expect(matcher.matches("Bash")).toBe(true)
expect(matcher.matches("anything")).toBe(true)
})
it("should match all tools with undefined pattern", () => {
const matcher = compileMatcher(undefined)
expect(matcher.type).toBe("all")
expect(matcher.matches("Write")).toBe(true)
expect(matcher.matches("Read")).toBe(true)
})
it("should match all tools with empty string pattern", () => {
const matcher = compileMatcher("")
expect(matcher.type).toBe("all")
expect(matcher.matches("Write")).toBe(true)
})
})
describe("exact matching", () => {
it("should match exact tool name (case-insensitive)", () => {
const matcher = compileMatcher("Write")
expect(matcher.type).toBe("exact")
expect(matcher.matches("Write")).toBe(true)
expect(matcher.matches("write")).toBe(true)
expect(matcher.matches("WRITE")).toBe(true)
expect(matcher.matches("Read")).toBe(false)
})
it("should not match partial names", () => {
const matcher = compileMatcher("Write")
expect(matcher.matches("WriteFile")).toBe(false)
expect(matcher.matches("FileWrite")).toBe(false)
})
})
describe("regex pattern matching", () => {
it("should match regex with pipe (|) alternation", () => {
const matcher = compileMatcher("Edit|Write")
expect(matcher.type).toBe("regex")
expect(matcher.matches("Edit")).toBe(true)
expect(matcher.matches("Write")).toBe(true)
expect(matcher.matches("Read")).toBe(false)
})
it("should match regex with character classes", () => {
const matcher = compileMatcher("File[RW].*")
expect(matcher.type).toBe("regex")
expect(matcher.matches("FileRead")).toBe(true)
expect(matcher.matches("FileWrite")).toBe(true)
expect(matcher.matches("FileDelete")).toBe(false)
})
it("should be case-insensitive", () => {
const matcher = compileMatcher("edit|write")
expect(matcher.matches("Edit")).toBe(true)
expect(matcher.matches("WRITE")).toBe(true)
})
it("should fall back to exact match on invalid regex", () => {
// Unclosed bracket is invalid regex
const matcher = compileMatcher("Edit[")
expect(matcher.type).toBe("exact")
expect(matcher.matches("Edit[")).toBe(true)
})
})
describe("glob pattern matching", () => {
it("should match glob with * wildcard", () => {
const matcher = compileMatcher("mcp__*")
expect(matcher.type).toBe("glob")
expect(matcher.matches("mcp__tool1")).toBe(true)
expect(matcher.matches("mcp__server__action")).toBe(true)
expect(matcher.matches("mcp_")).toBe(false)
expect(matcher.matches("other_tool")).toBe(false)
})
it("should match glob with ? single-char wildcard", () => {
const matcher = compileMatcher("Tool?")
expect(matcher.type).toBe("glob")
expect(matcher.matches("Tool1")).toBe(true)
expect(matcher.matches("ToolA")).toBe(true)
expect(matcher.matches("Tool")).toBe(false)
expect(matcher.matches("Tool12")).toBe(false)
})
it("should match glob with * in middle", () => {
const matcher = compileMatcher("*File*")
expect(matcher.type).toBe("glob")
expect(matcher.matches("FileRead")).toBe(true)
expect(matcher.matches("ReadFile")).toBe(true)
expect(matcher.matches("ReadFileNow")).toBe(true)
expect(matcher.matches("Tool")).toBe(false)
})
it("should be case-insensitive", () => {
const matcher = compileMatcher("MCP__*")
expect(matcher.matches("mcp__tool")).toBe(true)
expect(matcher.matches("MCP__TOOL")).toBe(true)
})
})
})
describe("getMatcher (caching)", () => {
it("should return same matcher for same pattern", () => {
const matcher1 = getMatcher("Write")
const matcher2 = getMatcher("Write")
expect(matcher1).toBe(matcher2)
})
it("should cache undefined as '*'", () => {
const matcher1 = getMatcher(undefined)
const matcher2 = getMatcher("*")
expect(matcher1).toBe(matcher2)
})
it("should return different matchers for different patterns", () => {
const matcher1 = getMatcher("Write")
const matcher2 = getMatcher("Read")
expect(matcher1).not.toBe(matcher2)
})
})
describe("filterMatchingHooks", () => {
const createMockHook = (id: string, matcher?: string): ResolvedHook =>
({
id,
matcher,
enabled: true,
command: "echo test",
timeout: 60,
source: "project",
event: "PreToolUse",
filePath: "/test/hooks.yaml",
}) as ResolvedHook
it("should filter hooks by tool name", () => {
const hooks = [
createMockHook("hook1", "Write"),
createMockHook("hook2", "Read"),
createMockHook("hook3", "Edit|Write"),
]
const matching = filterMatchingHooks(hooks, "Write")
expect(matching).toHaveLength(2)
expect(matching.map((h) => h.id)).toEqual(["hook1", "hook3"])
})
it("should include match-all hooks", () => {
const hooks = [
createMockHook("hook1", "*"),
createMockHook("hook2", undefined),
createMockHook("hook3", "Read"),
]
const matching = filterMatchingHooks(hooks, "Write")
expect(matching).toHaveLength(2)
expect(matching.map((h) => h.id)).toEqual(["hook1", "hook2"])
})
it("should return empty array when no hooks match", () => {
const hooks = [createMockHook("hook1", "Read"), createMockHook("hook2", "Edit")]
const matching = filterMatchingHooks(hooks, "Write")
expect(matching).toHaveLength(0)
})
})
describe("hookMatchesTool", () => {
const createMockHook = (matcher?: string): ResolvedHook =>
({
id: "test",
matcher,
enabled: true,
command: "echo test",
timeout: 60,
source: "project",
event: "PreToolUse",
filePath: "/test/hooks.yaml",
}) as ResolvedHook
it("should return true for matching hook", () => {
expect(hookMatchesTool(createMockHook("Write"), "Write")).toBe(true)
})
it("should return false for non-matching hook", () => {
expect(hookMatchesTool(createMockHook("Read"), "Write")).toBe(false)
})
it("should return true for match-all hook", () => {
expect(hookMatchesTool(createMockHook("*"), "AnyTool")).toBe(true)
})
})
})

102
src/services/hooks/index.ts Normal file
View file

@ -0,0 +1,102 @@
/**
* Hooks Service
*
* Provides Claude Code-style hooks for Roo Code.
* Hooks allow users to run custom shell commands at key lifecycle events.
*
* @example
* ```typescript
* import { createHookManager, HookEventType } from './services/hooks'
*
* const hookManager = createHookManager({
* cwd: '/path/to/project',
* mode: 'code'
* })
*
* // Load configuration
* await hookManager.loadHooksConfig()
*
* // Execute hooks for an event
* const result = await hookManager.executeHooks('PreToolUse', {
* context: {
* event: 'PreToolUse',
* timestamp: new Date().toISOString(),
* session: { taskId: 'task_1', sessionId: 'session_1', mode: 'code' },
* project: { directory: '/path/to/project', name: 'my-project' },
* tool: { name: 'Write', input: { filePath: '/src/index.ts', content: '...' } }
* }
* })
*
* if (result.blocked) {
* console.log('Hook blocked:', result.blockMessage)
* }
* ```
*/
// Types
export {
// Event types
HookEventType,
BLOCKING_EVENTS,
isBlockingEvent,
// Schema types
HookDefinitionSchema,
HooksConfigFileSchema,
HookModificationSchema,
// Type definitions
type HookDefinition,
type HooksConfigFile,
type HookSource,
type ResolvedHook,
type HooksConfigSnapshot,
// Context types
type HookContext,
type HookSessionContext,
type HookProjectContext,
type HookToolContext,
type HookPromptContext,
type HookNotificationContext,
type ConversationHistoryEntry,
// Execution types
HookExitCode,
type HookExecutionResult,
type HooksExecutionResult,
type HookModification,
type HookExecution,
type ExecuteHooksOptions,
// Manager interface
type IHookManager,
} from "./types"
// Config loader
export {
loadHooksConfig,
getHooksForEvent,
getHookById,
type LoadHooksConfigOptions,
type LoadHooksConfigResult,
} from "./HookConfigLoader"
// Matcher
export { compileMatcher, getMatcher, clearMatcherCache, filterMatchingHooks, hookMatchesTool } from "./HookMatcher"
// Executor
export { executeHook, interpretResult, describeResult } from "./HookExecutor"
// Manager
export { HookManager, createHookManager, type HookManagerOptions } from "./HookManager"
// Tool Execution Integration
export {
ToolExecutionHooks,
createToolExecutionHooks,
type ToolExecutionContext,
type PreToolUseResult,
type PermissionRequestResult,
type HookStatusCallback,
} from "./ToolExecutionHooks"

379
src/services/hooks/types.ts Normal file
View file

@ -0,0 +1,379 @@
/**
* Hook System Types and Zod Schemas
*
* This module defines the configuration schema and types for the Roo Code hooks system.
* Compatible with Claude Code hook semantics.
*/
import { z } from "zod"
// ============================================================================
// Hook Events
// ============================================================================
/**
* All supported hook event types.
* Blocking events can halt/modify execution via exit code 2.
* Non-blocking events are informational only.
*/
export const HookEventType = z.enum([
"PreToolUse", // Before tool execution (blocking)
"PostToolUse", // After successful tool completion
"PostToolUseFailure", // After tool execution fails
"PermissionRequest", // When tool approval dialog shown (blocking)
"UserPromptSubmit", // When user sends message (blocking)
"Stop", // When task completes or stops (blocking)
"SubagentStop", // When subtask completes (blocking)
"SubagentStart", // When subtask begins
"SessionStart", // When new task created
"SessionEnd", // When task fully ends
"Notification", // When status messages sent
"PreCompact", // Before context compaction
])
export type HookEventType = z.infer<typeof HookEventType>
/**
* Events that can block execution by returning exit code 2.
*/
export const BLOCKING_EVENTS: Set<HookEventType> = new Set([
"PreToolUse",
"PermissionRequest",
"UserPromptSubmit",
"Stop",
"SubagentStop",
])
/**
* Check if an event type supports blocking behavior.
*/
export function isBlockingEvent(event: HookEventType): boolean {
return BLOCKING_EVENTS.has(event)
}
// ============================================================================
// Hook Definition Schema
// ============================================================================
/**
* Schema for a single hook definition within a config file.
*/
export const HookDefinitionSchema = z.object({
/** Unique identifier for this hook */
id: z.string().min(1, "Hook ID cannot be empty"),
/** Tool name filter (regex/glob pattern). If omitted, matches all tools. */
matcher: z.string().optional(),
/** Whether this hook is enabled. Defaults to true. */
enabled: z.boolean().optional().default(true),
/** Shell command to execute */
command: z.string().min(1, "Command cannot be empty"),
/** Timeout in seconds. Defaults to 60. */
timeout: z.number().positive().optional().default(60),
/** Human-readable description of what this hook does */
description: z.string().optional(),
/** Override shell (default: user's shell on Unix, PowerShell on Windows) */
shell: z.string().optional(),
/** Opt-in to receive conversation history in stdin. Defaults to false. */
includeConversationHistory: z.boolean().optional().default(false),
})
export type HookDefinition = z.infer<typeof HookDefinitionSchema>
// ============================================================================
// Hook Config File Schema
// ============================================================================
/**
* Schema for a hooks configuration file (.roo/hooks/*.yaml or *.json).
*/
export const HooksConfigFileSchema = z.object({
/** Config format version */
version: z.literal("1"),
/** Hooks organized by event type */
hooks: z.record(HookEventType, z.array(HookDefinitionSchema)).optional().default({}),
})
export type HooksConfigFile = z.infer<typeof HooksConfigFileSchema>
// ============================================================================
// Internal Types (for runtime use)
// ============================================================================
/**
* Source of a hook configuration.
*/
export type HookSource = "project" | "mode" | "global"
/**
* Extended hook definition with source information.
* Used internally after merging configs from multiple sources.
*/
export interface ResolvedHook extends HookDefinition {
/** Which config source this hook came from */
source: HookSource
/** The event type this hook is registered for */
event: HookEventType
/** File path where this hook was defined */
filePath: string
}
/**
* In-memory snapshot of all loaded hooks configuration.
* This is immutable once created - changes require explicit reload.
*/
export interface HooksConfigSnapshot {
/** All resolved hooks, organized by event type */
hooksByEvent: Map<HookEventType, ResolvedHook[]>
/** Lookup by hook ID for quick access */
hooksById: Map<string, ResolvedHook>
/** When this snapshot was created */
loadedAt: Date
/** IDs of hooks that have been disabled at runtime */
disabledHookIds: Set<string>
/** Whether project hooks are present (for security warnings) */
hasProjectHooks: boolean
}
// ============================================================================
// Hook Context (passed to hooks via stdin)
// ============================================================================
/**
* Session information included in hook context.
*/
export interface HookSessionContext {
taskId: string
sessionId: string
mode: string
}
/**
* Project information included in hook context.
*/
export interface HookProjectContext {
directory: string
name: string
}
/**
* Tool information for tool-related events.
*/
export interface HookToolContext {
name: string
input: Record<string, unknown>
/** Only present for PostToolUse */
output?: unknown
/** Only present for PostToolUse */
duration?: number
/** Only present for PostToolUseFailure */
error?: string
/** Only present for PostToolUseFailure */
errorMessage?: string
}
/**
* User prompt information for UserPromptSubmit event.
*/
export interface HookPromptContext {
text: string
images?: string[]
}
/**
* Notification information for Notification event.
*/
export interface HookNotificationContext {
message: string
type: string
}
/**
* Conversation history entry (opt-in via includeConversationHistory).
*/
export interface ConversationHistoryEntry {
role: "user" | "assistant"
content: string
}
/**
* Full context passed to hooks via stdin as JSON.
*/
export interface HookContext {
event: HookEventType
timestamp: string
session: HookSessionContext
project: HookProjectContext
/** Tool context - present for tool-related events */
tool?: HookToolContext
/** Prompt context - present for UserPromptSubmit */
prompt?: HookPromptContext
/** Notification context - present for Notification event */
notification?: HookNotificationContext
/** Stop reason - present for Stop event */
reason?: string
/** Summary - present for Stop event */
summary?: string
/** Conversation history - only if hook opts in via includeConversationHistory */
conversationHistory?: ConversationHistoryEntry[]
}
// ============================================================================
// Hook Execution Types
// ============================================================================
/**
* Exit code semantics for hooks.
*/
export enum HookExitCode {
/** Success - continue execution */
Success = 0,
/** Block/deny - halt execution (only valid for blocking events) */
Block = 2,
}
/**
* Result of executing a single hook.
*/
export interface HookExecutionResult {
/** The hook that was executed */
hook: ResolvedHook
/** Exit code from the process */
exitCode: number | null
/** stdout output (may contain JSON for modification) */
stdout: string
/** stderr output (shown to user on block) */
stderr: string
/** Execution duration in milliseconds */
duration: number
/** Whether the hook timed out */
timedOut: boolean
/** Error if hook failed to execute */
error?: Error
/** Parsed modification request from stdout (PreToolUse only) */
modification?: HookModification
}
/**
* Modification request from hook stdout (PreToolUse only).
*/
export interface HookModification {
action: "modify"
toolInput: Record<string, unknown>
}
/**
* Schema for hook stdout modification response.
*/
export const HookModificationSchema = z.object({
action: z.literal("modify"),
toolInput: z.record(z.unknown()),
})
/**
* Aggregated result of executing all hooks for an event.
*/
export interface HooksExecutionResult {
/** Results from each hook */
results: HookExecutionResult[]
/** Whether any hook blocked execution (exit code 2) */
blocked: boolean
/** Block message from stderr if blocked */
blockMessage?: string
/** The hook that blocked, if any */
blockingHook?: ResolvedHook
/** Tool input modification, if any hook modified it */
modification?: HookModification
/** Total execution time for all hooks */
totalDuration: number
}
// ============================================================================
// Hook Manager Interface
// ============================================================================
/**
* Execution history entry for debugging/UI.
*/
export interface HookExecution {
/** When the hook was executed */
timestamp: Date
/** The hook that was executed */
hook: ResolvedHook
/** The event that triggered execution */
event: HookEventType
/** Execution result */
result: HookExecutionResult
}
/**
* Options for hook execution.
*/
export interface ExecuteHooksOptions {
/** Context to pass to hooks */
context: HookContext
/** Conversation history (will be included only for hooks with includeConversationHistory: true) */
conversationHistory?: ConversationHistoryEntry[]
}
/**
* Hook manager service interface (per PRD FR5).
*/
export interface IHookManager {
/** Load hooks configuration from all sources */
loadHooksConfig(): Promise<HooksConfigSnapshot>
/** Explicitly reload hooks configuration */
reloadHooksConfig(): Promise<void>
/** Execute all matching hooks for an event */
executeHooks(event: HookEventType, options: ExecuteHooksOptions): Promise<HooksExecutionResult>
/** Get all currently enabled hooks */
getEnabledHooks(): ResolvedHook[]
/** Enable or disable a specific hook by ID */
setHookEnabled(hookId: string, enabled: boolean): Promise<void>
/** Get execution history for debugging */
getHookExecutionHistory(): HookExecution[]
/** Get the current config snapshot (or null if not loaded) */
getConfigSnapshot(): HooksConfigSnapshot | null
}

View file

@ -0,0 +1,334 @@
import React, { useCallback, useEffect, useState } from "react"
import { RefreshCw, FolderOpen, AlertTriangle, Clock, Zap, X } from "lucide-react"
import { useAppTranslation } from "@src/i18n/TranslationContext"
import { useExtensionState } from "@src/context/ExtensionStateContext"
import { vscode } from "@src/utils/vscode"
import { Button, StandardTooltip } from "@src/components/ui"
import type { HookInfo, HookExecutionRecord, HookExecutionStatusPayload } from "@roo-code/types"
import { SectionHeader } from "./SectionHeader"
import { Section } from "./Section"
export const HooksSettings: React.FC = () => {
const { t } = useAppTranslation()
const { hooks } = useExtensionState()
const [executionHistory, setExecutionHistory] = useState<HookExecutionRecord[]>(hooks?.executionHistory || [])
// Listen for realtime hookExecutionStatus messages
useEffect(() => {
const handleMessage = (event: MessageEvent) => {
const message = event.data
if (message.type === "hookExecutionStatus") {
const payload: HookExecutionStatusPayload = message.hookExecutionStatus
// Convert realtime status to execution record format when completed/failed
if (payload.status === "completed" || payload.status === "failed" || payload.status === "blocked") {
const record: HookExecutionRecord = {
timestamp: new Date().toISOString(),
hookId: payload.hookId || "unknown",
event: payload.event,
toolName: payload.toolName,
exitCode: payload.status === "completed" ? 0 : 1,
duration: payload.duration || 0,
timedOut: false,
blocked: payload.status === "blocked",
error: payload.error,
blockMessage: payload.blockMessage,
}
setExecutionHistory((prev) => [record, ...prev].slice(0, 50)) // Keep last 50
}
}
}
window.addEventListener("message", handleMessage)
return () => window.removeEventListener("message", handleMessage)
}, [])
// Update local state when extension state changes
useEffect(() => {
if (hooks?.executionHistory) {
setExecutionHistory(hooks.executionHistory)
}
}, [hooks?.executionHistory])
const handleReloadConfig = useCallback(() => {
vscode.postMessage({ type: "hooksReloadConfig" })
}, [])
const handleOpenConfigFolder = useCallback((source: "global" | "project") => {
vscode.postMessage({ type: "hooksOpenConfigFolder", hooksSource: source })
}, [])
const handleToggleHook = useCallback((hookId: string, enabled: boolean) => {
vscode.postMessage({ type: "hooksSetEnabled", hookId, hookEnabled: enabled })
}, [])
const enabledHooks = hooks?.enabledHooks || []
const hasProjectHooks = hooks?.hasProjectHooks || false
const snapshotTimestamp = hooks?.snapshotTimestamp
return (
<div>
<SectionHeader>{t("settings:sections.hooks")}</SectionHeader>
<Section>
{/* Header with actions */}
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<h3 className="text-base font-medium m-0">{t("settings:hooks.configuredHooks")}</h3>
{snapshotTimestamp && (
<StandardTooltip
content={t("settings:hooks.lastLoadedTooltip", {
time: new Date(snapshotTimestamp).toLocaleString(),
})}>
<Clock className="w-4 h-4 text-vscode-descriptionForeground" />
</StandardTooltip>
)}
</div>
<div className="flex items-center gap-2">
<StandardTooltip content={t("settings:hooks.reloadTooltip")}>
<Button variant="ghost" size="sm" onClick={handleReloadConfig}>
<RefreshCw className="w-4 h-4" />
<span className="ml-1">{t("settings:hooks.reload")}</span>
</Button>
</StandardTooltip>
<StandardTooltip
content={
hasProjectHooks
? t("settings:hooks.openProjectFolderTooltip")
: t("settings:hooks.openGlobalFolderTooltip")
}>
<Button
variant="ghost"
size="sm"
onClick={() => handleOpenConfigFolder(hasProjectHooks ? "project" : "global")}>
<FolderOpen className="w-4 h-4" />
<span className="ml-1">
{hasProjectHooks
? t("settings:hooks.openProjectFolder")
: t("settings:hooks.openGlobalFolder")}
</span>
</Button>
</StandardTooltip>
</div>
</div>
{/* Security warning for project hooks */}
{hasProjectHooks && (
<div className="flex items-start gap-2 p-3 mb-4 rounded bg-yellow-500/10 border border-yellow-500/30">
<AlertTriangle className="w-5 h-5 text-yellow-500 flex-shrink-0 mt-0.5" />
<div className="text-sm">
<div className="font-medium mb-1">{t("settings:hooks.projectHooksWarningTitle")}</div>
<div className="text-vscode-descriptionForeground">
{t("settings:hooks.projectHooksWarningMessage")}
</div>
</div>
</div>
)}
{/* Note about edits requiring reload */}
<div className="text-sm text-vscode-descriptionForeground mb-4">{t("settings:hooks.reloadNote")}</div>
{/* Hooks list */}
{enabledHooks.length === 0 ? (
<div className="text-center py-8 text-vscode-descriptionForeground">
<Zap className="w-12 h-12 mx-auto mb-3 opacity-50" />
<p className="text-base mb-2">{t("settings:hooks.noHooksConfigured")}</p>
<p className="text-sm">{t("settings:hooks.noHooksHint")}</p>
</div>
) : (
<div className="space-y-3">
{enabledHooks.map((hook) => (
<HookItem key={hook.id} hook={hook} onToggle={handleToggleHook} />
))}
</div>
)}
{/* Hook Activity Log */}
<HookActivityLog executionHistory={executionHistory} />
</Section>
</div>
)
}
interface HookItemProps {
hook: HookInfo
onToggle: (hookId: string, enabled: boolean) => void
}
const HookItem: React.FC<HookItemProps> = ({ hook, onToggle }) => {
const { t } = useAppTranslation()
return (
<div className="p-3 rounded border border-vscode-input-border bg-vscode-input-background">
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-2">
<Zap className="w-4 h-4 flex-shrink-0 text-vscode-textLink-foreground" />
<code className="text-sm font-mono text-vscode-textLink-foreground">{hook.event}</code>
{hook.matcher && (
<>
<span className="text-vscode-descriptionForeground"></span>
<code className="text-xs font-mono text-vscode-descriptionForeground">
{hook.matcher}
</code>
</>
)}
<span
className={`ml-auto text-xs px-2 py-0.5 rounded ${
hook.source === "project"
? "bg-yellow-500/20 text-yellow-500"
: hook.source === "mode"
? "bg-blue-500/20 text-blue-500"
: "bg-gray-500/20 text-gray-400"
}`}>
{hook.source}
</span>
</div>
{hook.description && <p className="text-sm text-vscode-foreground mb-2">{hook.description}</p>}
<div className="flex items-center gap-3 text-xs text-vscode-descriptionForeground">
<code className="font-mono bg-vscode-editor-background px-2 py-1 rounded">
{hook.commandPreview}
</code>
{hook.shell && (
<span>
{t("settings:hooks.shell")}: <code className="font-mono">{hook.shell}</code>
</span>
)}
<span>
{t("settings:hooks.timeout")}: {hook.timeout}s
</span>
</div>
</div>
<label className="flex items-center gap-2 cursor-pointer flex-shrink-0">
<input
type="checkbox"
checked={hook.enabled}
onChange={(e) => onToggle(hook.id, e.target.checked)}
className="w-4 h-4 cursor-pointer"
/>
<span className="text-sm">{t("settings:hooks.enabled")}</span>
</label>
</div>
</div>
)
}
interface HookActivityLogProps {
executionHistory: HookExecutionRecord[]
}
const HookActivityLog: React.FC<HookActivityLogProps> = ({ executionHistory }) => {
const { t } = useAppTranslation()
const [isExpanded, setIsExpanded] = useState(false)
if (executionHistory.length === 0) {
return null
}
return (
<div className="mt-6">
<button
onClick={() => setIsExpanded(!isExpanded)}
className="flex items-center gap-2 text-base font-medium mb-3 hover:text-vscode-textLink-foreground transition-colors w-full text-left">
<span
className="transform transition-transform"
style={{ transform: isExpanded ? "rotate(90deg)" : "" }}>
</span>
{t("settings:hooks.activityLog")} ({executionHistory.length})
</button>
{isExpanded && (
<div className="space-y-2 max-h-96 overflow-y-auto">
{executionHistory.map((record, index) => (
<ActivityLogItem key={`${record.timestamp}-${index}`} record={record} />
))}
</div>
)}
</div>
)
}
interface ActivityLogItemProps {
record: HookExecutionRecord
}
const ActivityLogItem: React.FC<ActivityLogItemProps> = ({ record }) => {
const { t } = useAppTranslation()
const getStatusDisplay = () => {
if (record.blocked) {
return {
label: t("settings:hooks.status.blocked"),
className: "bg-red-500/20 text-red-500",
icon: <X className="w-3 h-3" />,
}
}
if (record.error || record.exitCode !== 0) {
return {
label: t("settings:hooks.status.failed"),
className: "bg-red-500/20 text-red-500",
icon: <X className="w-3 h-3" />,
}
}
if (record.timedOut) {
return {
label: t("settings:hooks.status.timeout"),
className: "bg-yellow-500/20 text-yellow-500",
icon: <Clock className="w-3 h-3" />,
}
}
return {
label: t("settings:hooks.status.completed"),
className: "bg-green-500/20 text-green-500",
icon: <Zap className="w-3 h-3" />,
}
}
const status = getStatusDisplay()
const timestamp = new Date(record.timestamp)
const timeAgo = getTimeAgo(timestamp)
return (
<div className="p-2 rounded border border-vscode-input-border bg-vscode-input-background text-sm">
<div className="flex items-center justify-between gap-2 mb-1">
<div className="flex items-center gap-2 flex-1 min-w-0">
<span
className={`flex items-center gap-1 px-2 py-0.5 rounded text-xs font-medium ${status.className}`}>
{status.icon}
{status.label}
</span>
<code className="text-xs font-mono text-vscode-textLink-foreground truncate">
{record.event}
{record.toolName && ` (${record.toolName})`}
</code>
</div>
<div className="flex items-center gap-2 text-xs text-vscode-descriptionForeground flex-shrink-0">
<span>{record.duration}ms</span>
<StandardTooltip content={timestamp.toLocaleString()}>
<span className="cursor-help">{timeAgo}</span>
</StandardTooltip>
</div>
</div>
{(record.error || record.blockMessage) && (
<div className="mt-2 p-2 rounded bg-vscode-editor-background text-xs font-mono text-red-400 overflow-x-auto">
{record.blockMessage || record.error}
</div>
)}
</div>
)
}
function getTimeAgo(date: Date): string {
const seconds = Math.floor((Date.now() - date.getTime()) / 1000)
if (seconds < 60) return `${seconds}s ago`
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`
return `${Math.floor(seconds / 86400)}d ago`
}

View file

@ -28,6 +28,7 @@ import {
Server,
Users2,
ArrowLeft,
Zap,
} from "lucide-react"
import {
@ -79,6 +80,7 @@ import { SlashCommandsSettings } from "./SlashCommandsSettings"
import { UISettings } from "./UISettings"
import ModesView from "../modes/ModesView"
import McpView from "../mcp/McpView"
import { HooksSettings } from "./HooksSettings"
import { SettingsSearch } from "./SettingsSearch"
import { useSearchIndexRegistry, SearchIndexProvider } from "./useSettingsSearch"
@ -104,6 +106,7 @@ export const sectionNames = [
"terminal",
"modes",
"mcp",
"hooks",
"prompts",
"ui",
"experimental",
@ -530,6 +533,7 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, t
{ id: "notifications", icon: Bell },
{ id: "contextManagement", icon: Database },
{ id: "terminal", icon: SquareTerminal },
{ id: "hooks", icon: Zap },
{ id: "prompts", icon: MessageSquare },
{ id: "ui", icon: Glasses },
{ id: "experimental", icon: FlaskConical },
@ -880,6 +884,9 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, t
{/* MCP Section */}
{renderTab === "mcp" && <McpView />}
{/* Hooks Section */}
{renderTab === "hooks" && <HooksSettings />}
{/* Prompts Section */}
{renderTab === "prompts" && (
<PromptsSettings

View file

@ -0,0 +1,353 @@
// cd webview-ui && npx vitest run src/components/settings/__tests__/HooksSettings.spec.tsx
import { render, screen, fireEvent, waitFor } from "@testing-library/react"
import { vi, describe, it, expect, beforeEach } from "vitest"
import { HooksSettings } from "../HooksSettings"
import type { HookInfo, HookExecutionRecord, HooksState } from "@roo-code/types"
// Mock vscode utilities
vi.mock("@src/utils/vscode", () => ({
vscode: {
postMessage: vi.fn(),
},
}))
// Mock the translation hook
vi.mock("@src/i18n/TranslationContext", () => ({
useAppTranslation: () => ({
t: (key: string, params?: any) => {
// Simple mock that returns translation keys
if (params) {
return key.replace(/\{\{(\w+)\}\}/g, (_, k) => params[k] || "")
}
return key
},
}),
}))
// Mock the ExtensionStateContext
const mockHooksState: HooksState = {
enabledHooks: [],
executionHistory: [],
hasProjectHooks: false,
snapshotTimestamp: undefined,
}
let currentHooksState = mockHooksState
vi.mock("@src/context/ExtensionStateContext", () => ({
useExtensionState: () => ({
hooks: currentHooksState,
}),
}))
// Mock UI components
vi.mock("@src/components/ui", () => ({
Button: ({ children, onClick, ...props }: any) => (
<button onClick={onClick} data-testid={props["data-testid"]} {...props}>
{children}
</button>
),
StandardTooltip: ({ children, content }: any) => <div title={content}>{children}</div>,
}))
// Mock Section components
vi.mock("../SectionHeader", () => ({
SectionHeader: ({ children }: any) => <h2>{children}</h2>,
}))
vi.mock("../Section", () => ({
Section: ({ children }: any) => <div data-testid="section">{children}</div>,
}))
describe("HooksSettings", () => {
beforeEach(async () => {
vi.clearAllMocks()
// Reset to default state
currentHooksState = {
enabledHooks: [],
executionHistory: [],
hasProjectHooks: false,
snapshotTimestamp: undefined,
}
// Get fresh reference to mocked vscode
const { vscode } = await import("@src/utils/vscode")
vi.mocked(vscode.postMessage).mockClear()
})
it("renders with no hooks configured", () => {
render(<HooksSettings />)
expect(screen.getByText("settings:sections.hooks")).toBeInTheDocument()
expect(screen.getByText("settings:hooks.noHooksConfigured")).toBeInTheDocument()
expect(screen.getByText("settings:hooks.noHooksHint")).toBeInTheDocument()
})
it("renders hooks list when hooks are configured", () => {
const mockHook: HookInfo = {
id: "hook-1",
event: "before_execute_command",
matcher: "git*",
commandPreview: "echo 'Before git command'",
enabled: true,
source: "project",
timeout: 30,
description: "Test hook",
}
currentHooksState = {
enabledHooks: [mockHook],
executionHistory: [],
hasProjectHooks: true,
snapshotTimestamp: new Date().toISOString(),
}
render(<HooksSettings />)
expect(screen.getByText(mockHook.event)).toBeInTheDocument()
expect(screen.getByText(mockHook.matcher!)).toBeInTheDocument()
expect(screen.getByText(mockHook.commandPreview)).toBeInTheDocument()
})
it("shows project hooks warning when hasProjectHooks is true", () => {
currentHooksState = {
...mockHooksState,
hasProjectHooks: true,
}
render(<HooksSettings />)
expect(screen.getByText("settings:hooks.projectHooksWarningTitle")).toBeInTheDocument()
expect(screen.getByText("settings:hooks.projectHooksWarningMessage")).toBeInTheDocument()
})
it("sends hooksReloadConfig message when Reload button is clicked", async () => {
const { vscode } = await import("@src/utils/vscode")
render(<HooksSettings />)
const reloadButton = screen.getByText("settings:hooks.reload")
fireEvent.click(reloadButton)
expect(vscode.postMessage).toHaveBeenCalledWith({ type: "hooksReloadConfig" })
})
it("sends hooksOpenConfigFolder message when Open Folder button is clicked", async () => {
const { vscode } = await import("@src/utils/vscode")
currentHooksState = {
...mockHooksState,
hasProjectHooks: true,
}
render(<HooksSettings />)
const openFolderButton = screen.getByText("settings:hooks.openProjectFolder")
fireEvent.click(openFolderButton)
expect(vscode.postMessage).toHaveBeenCalledWith({
type: "hooksOpenConfigFolder",
hooksSource: "project",
})
})
it("sends hooksSetEnabled message when hook toggle is changed", async () => {
const { vscode } = await import("@src/utils/vscode")
const mockHook: HookInfo = {
id: "hook-1",
event: "before_execute_command",
commandPreview: "echo test",
enabled: true,
source: "global",
timeout: 30,
}
currentHooksState = {
enabledHooks: [mockHook],
executionHistory: [],
hasProjectHooks: false,
}
render(<HooksSettings />)
const checkbox = screen.getByRole("checkbox")
fireEvent.click(checkbox)
expect(vscode.postMessage).toHaveBeenCalledWith({
type: "hooksSetEnabled",
hookId: "hook-1",
hookEnabled: false,
})
})
it("renders execution history when available", () => {
const mockRecord: HookExecutionRecord = {
timestamp: new Date().toISOString(),
hookId: "hook-1",
event: "before_execute_command",
exitCode: 0,
duration: 150,
timedOut: false,
blocked: false,
}
currentHooksState = {
enabledHooks: [],
executionHistory: [mockRecord],
hasProjectHooks: false,
}
render(<HooksSettings />)
// Activity log should be present
expect(screen.getByText(/settings:hooks.activityLog/)).toBeInTheDocument()
})
it("updates execution history on realtime hookExecutionStatus message", async () => {
render(<HooksSettings />)
// Simulate receiving a hookExecutionStatus message
const event = new MessageEvent("message", {
data: {
type: "hookExecutionStatus",
hookExecutionStatus: {
status: "completed",
event: "before_execute_command",
hookId: "hook-1",
duration: 200,
},
},
})
window.dispatchEvent(event)
// Wait for state update
await waitFor(() => {
// The activity log should now show the new execution
const activityLog = screen.getByText(/settings:hooks.activityLog/)
expect(activityLog).toBeInTheDocument()
})
})
it("expands and collapses activity log on click", () => {
const mockRecord: HookExecutionRecord = {
timestamp: new Date().toISOString(),
hookId: "hook-1",
event: "before_execute_command",
exitCode: 0,
duration: 150,
timedOut: false,
blocked: false,
}
currentHooksState = {
enabledHooks: [],
executionHistory: [mockRecord],
hasProjectHooks: false,
}
render(<HooksSettings />)
const activityLogButton = screen.getByText(/settings:hooks.activityLog/)
// Should start collapsed
expect(screen.queryByText(mockRecord.event)).not.toBeInTheDocument()
// Click to expand
fireEvent.click(activityLogButton)
// Should now show the record
expect(screen.getByText(mockRecord.event)).toBeInTheDocument()
})
it("displays different hook sources with appropriate styling", () => {
const hooks: HookInfo[] = [
{
id: "hook-1",
event: "event1",
commandPreview: "cmd1",
enabled: true,
source: "project",
timeout: 30,
},
{
id: "hook-2",
event: "event2",
commandPreview: "cmd2",
enabled: true,
source: "mode",
timeout: 30,
},
{
id: "hook-3",
event: "event3",
commandPreview: "cmd3",
enabled: true,
source: "global",
timeout: 30,
},
]
currentHooksState = {
enabledHooks: hooks,
executionHistory: [],
hasProjectHooks: false,
}
render(<HooksSettings />)
// All three source types should be present
expect(screen.getByText("project")).toBeInTheDocument()
expect(screen.getByText("mode")).toBeInTheDocument()
expect(screen.getByText("global")).toBeInTheDocument()
})
it("displays activity log status pills correctly", () => {
const records: HookExecutionRecord[] = [
{
timestamp: new Date().toISOString(),
hookId: "hook-1",
event: "event1",
exitCode: 0,
duration: 100,
timedOut: false,
blocked: false,
},
{
timestamp: new Date().toISOString(),
hookId: "hook-2",
event: "event2",
exitCode: 1,
duration: 200,
timedOut: false,
blocked: false,
error: "Command failed",
},
{
timestamp: new Date().toISOString(),
hookId: "hook-3",
event: "event3",
exitCode: null,
duration: 300,
timedOut: false,
blocked: true,
blockMessage: "Operation blocked",
},
]
currentHooksState = {
enabledHooks: [],
executionHistory: records,
hasProjectHooks: false,
}
render(<HooksSettings />)
// Expand activity log
const activityLogButton = screen.getByText(/settings:hooks.activityLog/)
fireEvent.click(activityLogButton)
// Check for status labels
expect(screen.getByText("settings:hooks.status.completed")).toBeInTheDocument()
expect(screen.getByText("settings:hooks.status.failed")).toBeInTheDocument()
expect(screen.getByText("settings:hooks.status.blocked")).toBeInTheDocument()
})
})

View file

@ -36,12 +36,39 @@
"contextManagement": "Context",
"terminal": "Terminal",
"slashCommands": "Slash Commands",
"hooks": "Hooks",
"prompts": "Prompts",
"ui": "UI",
"experimental": "Experimental",
"language": "Language",
"about": "About Roo Code"
},
"hooks": {
"configuredHooks": "Configured Hooks",
"lastLoadedTooltip": "Last loaded at {{time}}",
"reloadTooltip": "Reload hooks configuration from disk",
"reload": "Reload",
"openProjectFolderTooltip": "Open project hooks configuration folder",
"openGlobalFolderTooltip": "Open global hooks configuration folder",
"openProjectFolder": "Open Project Folder",
"openGlobalFolder": "Open Global Folder",
"projectHooksWarningTitle": "Project-level hooks detected",
"projectHooksWarningMessage": "This project includes hook configurations that will execute shell commands. Only enable hooks from sources you trust.",
"reloadNote": "Changes to hook configuration files require clicking Reload to take effect.",
"noHooksConfigured": "No hooks configured",
"noHooksHint": "Create hook configuration files to automate actions on tool execution events.",
"enabled": "Enabled",
"shell": "Shell",
"timeout": "Timeout",
"activityLog": "Hook Activity",
"status": {
"running": "Running",
"completed": "Completed",
"failed": "Failed",
"blocked": "Blocked",
"timeout": "Timeout"
}
},
"about": {
"bugReport": {
"label": "Found a bug?",