feat: add prompt-based hooks for agent lifecycle events#11505
feat: add prompt-based hooks for agent lifecycle events#11505roomote[bot] wants to merge 5 commits intomainfrom
Conversation
Implements prompt-based hooks that allow a smaller/different model to step in at specific agent lifecycle events and provide read-only advisory output. Hook events supported: - PreToolUse: fires before tool execution (with optional tool name matcher) - PostToolUse: fires after tool execution completes - Stop: fires when attempt_completion is invoked Configuration via .roo/hooks.json (project) and ~/.roo/hooks.json (global), using a Claude Code-compatible format. Hooks are read-only (no tool access) and receive conversation context. Uses the active profile API configuration. New files: - src/shared/hooks.ts: types, interfaces, and validation - src/services/hooks/HooksManager.ts: config loading with file watchers - src/services/hooks/HookExecutor.ts: hook prompt building and execution - src/shared/__tests__/hooks.spec.ts: 18 validation tests - src/services/hooks/__tests__/HookExecutor.spec.ts: 15 executor tests Modified files: - packages/types/src/message.ts: added hook_output ClineSay type - src/core/webview/ClineProvider.ts: HooksManager initialization - src/core/assistant-message/presentAssistantMessage.ts: PreToolUse/PostToolUse hooks - src/core/tools/AttemptCompletionTool.ts: Stop hook Addresses #11504
Re-reviewed after commit e8dc425. All four previously flagged issues are now resolved. No new issues found.
Mention @roomote in a comment to request specific changes to this pull request or fix all unresolved issues. |
| const lastResult = cline.userMessageContent | ||
| .filter( | ||
| (b): b is import("@anthropic-ai/sdk").Anthropic.ToolResultBlockParam => | ||
| b.type === "tool_result", | ||
| ) | ||
| .pop() | ||
| const resultText = typeof lastResult?.content === "string" ? lastResult.content : "" |
There was a problem hiding this comment.
ToolResultBlockParam.content in the Anthropic SDK is typed as string | (TextBlockParam | ImageBlockParam)[]. When a tool result uses the array format (e.g., results that include images or structured text blocks), typeof lastResult?.content === "string" is false and resultText falls back to "". The PostToolUse hook then receives an empty string instead of the actual result content. Consider extracting text from array-format content:
| const lastResult = cline.userMessageContent | |
| .filter( | |
| (b): b is import("@anthropic-ai/sdk").Anthropic.ToolResultBlockParam => | |
| b.type === "tool_result", | |
| ) | |
| .pop() | |
| const resultText = typeof lastResult?.content === "string" ? lastResult.content : "" | |
| const lastResult = cline.userMessageContent | |
| .filter( | |
| (b): b is import("@anthropic-ai/sdk").Anthropic.ToolResultBlockParam => | |
| b.type === "tool_result", | |
| ) | |
| .pop() | |
| const resultText = | |
| typeof lastResult?.content === "string" | |
| ? lastResult.content | |
| : Array.isArray(lastResult?.content) | |
| ? lastResult.content | |
| .filter((b): b is import("@anthropic-ai/sdk").Anthropic.TextBlockParam => b.type === "text") | |
| .map((b) => b.text) | |
| .join("\n") | |
| : "" |
Fix it with Roo Code or mention @roomote and request a fix.
There was a problem hiding this comment.
Fixaroo
See task
Fixed the PostToolUse hook to extract text from array-format ToolResultBlockParam.content instead of silently returning an empty string. All tests (33/33) and full monorepo lint + type checks pass.
| // === PreToolUse Hook === | ||
| if (!block.partial) { | ||
| try { | ||
| const hooksManager = cline.providerRef.deref()?.getHooksManager() | ||
| if (hooksManager?.hasHooksForEvent("PreToolUse")) { | ||
| const matchingHooks = hooksManager.getMatchingHooks("PreToolUse", block.name) | ||
| if (matchingHooks.length > 0) { | ||
| const hookContext: HookContext = { | ||
| event: "PreToolUse", | ||
| toolName: block.name, | ||
| toolInput: block.nativeArgs || block.params, | ||
| } | ||
| const hookResults = await executeHooks(matchingHooks, hookContext, cline.apiConfiguration) | ||
| const hookOutput = formatHookResults(hookResults) | ||
| if (hookOutput) { | ||
| await cline.say("hook_output", `PreToolUse hook for ${block.name}:\n${hookOutput}`) | ||
| } | ||
| } | ||
| } | ||
| } catch (hookError) { | ||
| console.warn(`[presentAssistantMessage] PreToolUse hook error:`, hookError) | ||
| } | ||
| } |
There was a problem hiding this comment.
PreToolUse hooks fire here, before the tool-approval dialog that happens inside each tool's handler (e.g., askApproval in writeToFileTool.handle). Each hook makes an API call via singleCompletionHandler, so if the user subsequently rejects the tool, those API calls were wasted. For tools that require explicit approval (file writes, command execution, etc.), consider moving the hook invocation to after user approval, or at minimum guarding it with a check for auto-approval settings to avoid unnecessary API spend.
Fix it with Roo Code or mention @roomote and request a fix.
There was a problem hiding this comment.
@roomote fix this to avoid unneccessary API spend
There was a problem hiding this comment.
Fixaroo
See task
Fixed the reported issues. Moved PreToolUse hook execution into the askApproval callback so hooks only fire after user approves the tool (avoiding wasted API calls on rejected tools). Also fixed PostToolUse hook to properly extract text from array-format ToolResultBlockParam.content. All tests and type checks pass.
…nt in PostToolUse - Moved PreToolUse hook execution into askApproval callback so hooks only fire after the user approves the tool, avoiding wasted API calls on rejected tools - Fixed PostToolUse hook to extract text from array-format ToolResultBlockParam.content instead of silently returning empty string
| // === PreToolUse Hook (fires after user approves to avoid wasting API calls on rejected tools) === | ||
| try { | ||
| const hooksManager = cline.providerRef.deref()?.getHooksManager() | ||
| if (hooksManager?.hasHooksForEvent("PreToolUse")) { | ||
| const matchingHooks = hooksManager.getMatchingHooks("PreToolUse", block.name) | ||
| if (matchingHooks.length > 0) { | ||
| const hookContext: HookContext = { | ||
| event: "PreToolUse", | ||
| toolName: block.name, | ||
| toolInput: block.nativeArgs || block.params, | ||
| } | ||
| const hookResults = await executeHooks(matchingHooks, hookContext, cline.apiConfiguration) | ||
| const hookOutput = formatHookResults(hookResults) | ||
| if (hookOutput) { | ||
| await cline.say("hook_output", `PreToolUse hook for ${block.name}:\n${hookOutput}`) | ||
| } | ||
| } | ||
| } | ||
| } catch (hookError) { | ||
| console.warn(`[presentAssistantMessage] PreToolUse hook error:`, hookError) | ||
| } |
There was a problem hiding this comment.
Moving the PreToolUse hook into the askApproval callback means it only fires for tools that actually call askApproval. Several tools bypass this callback entirely and call task.ask() directly -- notably ReadFileTool, AskFollowupQuestionTool, and ReadCommandOutputTool. A PreToolUse hook with "matcher": "read_file" (or similar) will silently never execute.
The previous location (before the switch (block.name) statement, inside if (!block.partial)) ran for all tools unconditionally. A possible fix: keep the hook invocation at that original central location but guard it with a check like cline.didRejectTool after askApproval returns, or run it in both places (inside askApproval for approval-gated tools, and at the original location for tools that skip approval).
Fix it with Roo Code or mention @roomote and request a fix.
There was a problem hiding this comment.
@roomote should have a ask for hook approval different from askApproval, to differentiate between hook and tool approval. Fix
There was a problem hiding this comment.
Fixaroo
See task
Separated hook approval from tool approval and moved PreToolUse hooks to the central location so they fire for ALL tools. Added new "hook" ClineAsk type with dedicated askHookApproval function. All tests and checks pass (33 hook tests, full monorepo lint + type-check).
… central location
- Added new "hook" ClineAsk type to differentiate hook approval from tool approval
- Moved PreToolUse hooks from inside askApproval callback to central location before
switch(block.name), so hooks fire for ALL tools including read_file,
ask_followup_question, and read_command_output
- Added askHookApproval function using cline.ask("hook", ...) for hook-specific approval
- Updated PostToolUse and Stop hooks to also use the new hook approval flow
packages/types/src/message.ts
Outdated
| "hook", | ||
| ] as const satisfies readonly ClineAsk[] |
There was a problem hiding this comment.
"hook" is added to idleAsks, but it behaves as an interactive approval prompt (the user approves/denies hook execution so the task can continue). The other idleAsks members (completion_result, api_req_failed, etc.) represent terminal or paused states. Because "hook" is here, Task.ts will emit TaskIdle after 2 seconds of waiting for hook approval, causing external API consumers to incorrectly believe the task is idle mid-execution. This should be in interactiveAsks instead.
| "hook", | |
| ] as const satisfies readonly ClineAsk[] | |
| "hook", | |
| ] as const satisfies readonly ClineAsk[] |
Fix it with Roo Code or mention @roomote and request a fix.
There was a problem hiding this comment.
Fixaroo
See task
Moved "hook" from idleAsks to interactiveAsks so hook approval no longer triggers false TaskIdle emissions. All tests (33), lint (14 packages), and type-check (14 packages) pass.
Related GitHub Issue
Closes: #11504
Description
This PR attempts to address Issue #11504 by implementing prompt-based hooks that allow a smaller/different model to step in at specific agent lifecycle events and provide read-only advisory output. Feedback and guidance are welcome.
Key implementation details:
PreToolUse(before tool execution, with optional tool name regex matcher),PostToolUse(after tool execution), andStop(whenattempt_completionis invoked).roo/hooks.json(project-level) and~/.roo/hooks.json(global user settings), using a Claude Code-compatible JSON formathook_outputsay messagessingleCompletionHandlerto send hook prompts to the configured API provider, executing hooks sequentiallyExample
.roo/hooks.json:{ "hooks": { "PreToolUse": [ { "matcher": "write_to_file|apply_diff", "prompt": "Review this code change for security issues" } ], "Stop": [ { "prompt": "Review the final result and suggest improvements" } ] } }Test Procedure
cd src && npx vitest run shared/__tests__/hooks.spec.tsandcd src && npx vitest run services/hooks/__tests__/HookExecutor.spec.tspresentAssistantMessage-unknown-tool.spec.tscontinues to passPre-Submission Checklist
.roo/hooks.jsonformat would be beneficialDocumentation Updates
.roo/hooks.jsonconfiguration format and supported hook events would be helpful for usersAdditional Notes
This is an initial implementation covering the core hook infrastructure. Future enhancements could include:
Start a new Roo Code Cloud session on this branch