Skip to content

feat: add prompt-based hooks for agent lifecycle events#11505

Draft
roomote[bot] wants to merge 5 commits intomainfrom
feature/prompt-based-hooks
Draft

feat: add prompt-based hooks for agent lifecycle events#11505
roomote[bot] wants to merge 5 commits intomainfrom
feature/prompt-based-hooks

Conversation

@roomote
Copy link
Contributor

@roomote roomote bot commented Feb 17, 2026

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:

  • Hook events supported: PreToolUse (before tool execution, with optional tool name regex matcher), PostToolUse (after tool execution), and Stop (when attempt_completion is invoked)
  • Configuration format: .roo/hooks.json (project-level) and ~/.roo/hooks.json (global user settings), using a Claude Code-compatible JSON format
  • Global hook model: Uses the active profile API configuration (tied to profile setup as requested)
  • Read-only hooks: No tool access. Hooks receive conversation context and produce advisory text injected into the conversation via hook_output say messages
  • File watching: HooksManager watches both project and global hooks.json files for changes and reloads automatically
  • Hook execution: Uses singleCompletionHandler to send hook prompts to the configured API provider, executing hooks sequentially

Example .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

  • 33 new tests added (18 for hook config validation, 15 for HookExecutor)
  • All tests pass: cd src && npx vitest run shared/__tests__/hooks.spec.ts and cd src && npx vitest run services/hooks/__tests__/HookExecutor.spec.ts
  • Existing tests unaffected: presentAssistantMessage-unknown-tool.spec.ts continues to pass
  • Full monorepo lint (14 packages) and type-check (14 packages) pass

Pre-Submission Checklist

  • Issue Linked: This PR is linked to an approved GitHub Issue
  • Scope: Changes are focused on prompt-based hooks implementation
  • Self-Review: Code has been self-reviewed
  • Testing: 33 new tests added covering validation and execution
  • Documentation Impact: Documentation for .roo/hooks.json format would be beneficial
  • Contribution Guidelines: Read and agreed

Documentation Updates

  • Yes, documentation for the .roo/hooks.json configuration format and supported hook events would be helpful for users

Additional Notes

This is an initial implementation covering the core hook infrastructure. Future enhancements could include:

  • UI-based hook configuration in the Settings panel
  • Per-hook model selection (currently uses active profile)
  • Additional hook events (e.g., PreRequest, PostResponse)
  • Hook output injection directly into conversation context (currently shown as advisory messages)

Start a new Roo Code Cloud session on this branch

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
@roomote
Copy link
Contributor Author

roomote bot commented Feb 17, 2026

Rooviewer Clock   See task

Re-reviewed after commit e8dc425. All four previously flagged issues are now resolved. No new issues found.

  • PostToolUse hook silently drops tool results when ToolResultBlockParam.content is in array format (returns empty string instead of extracting text blocks)
  • PreToolUse hooks fire before user tool-approval, wasting API calls if the user rejects the tool
  • PreToolUse hooks now only fire for tools that call askApproval; tools like read_file, ask_followup_question, and read_command_output bypass it and never trigger PreToolUse hooks
  • "hook" is added to idleAsks but should be in interactiveAsks -- causes TaskIdle to be emitted during hook approval, making external consumers incorrectly see the task as idle mid-execution
Previous reviews

Mention @roomote in a comment to request specific changes to this pull request or fix all unresolved issues.

Comment on lines 954 to 960
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 : ""
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

Suggested change
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.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@roomote fix this

Copy link
Contributor Author

@roomote roomote bot Feb 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixaroo Clock   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.

View commit | Revert commit

Comment on lines 680 to 702
// === 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)
}
}
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@roomote fix this to avoid unneccessary API spend

Copy link
Contributor Author

@roomote roomote bot Feb 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixaroo Clock   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.

View commit | Revert commit

…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
Comment on lines 530 to 550
// === 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)
}
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@roomote should have a ask for hook approval different from askApproval, to differentiate between hook and tool approval. Fix

Copy link
Contributor Author

@roomote roomote bot Feb 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixaroo Clock   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).

View commit | Revert commit

… 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
Comment on lines 58 to 59
"hook",
] as const satisfies readonly ClineAsk[]
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"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.

Suggested change
"hook",
] as const satisfies readonly ClineAsk[]
"hook",
] as const satisfies readonly ClineAsk[]

Fix it with Roo Code or mention @roomote and request a fix.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@roomote fix

Copy link
Contributor Author

@roomote roomote bot Feb 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixaroo Clock   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.

View commit | Revert commit

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[ENHANCEMENT] Allow prompt based hook

2 participants

Comments