NAME

Langertha::Raider::Hooks - Internal runner of Claude Code hooks from settings and plugins

VERSION

version 0.504

SYNOPSIS

# Internal to Langertha-Raider -- no API promise.
my $hooks = Langertha::Raider::Hooks->from_context( $app->context, loop => $loop )
  or return;                                       # not in the Claude layout

my $r = await $hooks->run_f( PreToolUse => {
  session_id      => $sid,
  transcript_path => $journal_file,
  permission_mode => 'default',
  tool_name       => 'edit_file',                  # raider name and arguments;
  tool_input      => { path => 'lib/A.pm', ... },  # the hooks see Edit and file_path
  tool_use_id     => $call_id,
});
# $r is a Langertha::Raider::Hooks::Result

my @late = $hooks->take_async_results;             # async hooks done since last time
$hooks->shutdown;                                  # end of session: stop async hooks

DESCRIPTION

Internal module. Its interface may change without notice.

Runs the hooks of a Claude Code project the way Claude Code does (its hooks reference, https://code.claude.com/docs/en/hooks): the hooks of the settings files ("hooks" in Langertha::Raider::Context::Settings) and of every installed and enabled plugin (hooks/hooks.json and the manifest's hooks, "hooks" in Langertha::Raider::Context::Plugins), unless the settings say disableAllHooks. This module runs them and merges what they say into a Langertha::Raider::Hooks::Result; acting on it (denying a tool call, adding context, ...) is the caller's business -- Langertha::Raider::Application (hooks, tool_hook, run_prompt) and Langertha::Raider::Plugin::ClaudeHooks in the Claude layout, as ADR 0022 records.

Selection

Per event the matcher groups in configuration order -- settings files (user, project, local), then plugins in "plugins" in Langertha::Raider::Context::Plugins order. A group's matcher is tested against one field of the event's input: the tool name for PreToolUse, PostToolUse, PostToolUseFailure, PermissionRequest and PermissionDenied, the source for SessionStart (startup, resume, clear, compact), the agent_type for SubagentStart/SubagentStop, the reason for SessionEnd, the trigger for PreCompact/PostCompact, the notification_type for Notification. Other events (UserPromptSubmit, Stop, ...) ignore the matcher. Like Claude Code: *, empty or missing matches all; a matcher of only letters, digits, _, -, spaces, , and | is a list of exact names split at | or ,; anything else is a regular expression, unanchored (Claude Code uses JavaScript's; the common syntax is the same in Perl). An invalid expression skips the group and is reported in "errors" in Langertha::Raider::Hooks::Result, never fatal.

Tool names are matched in Claude Code's spelling: the raider tool is turned into its Claude name with "claude_name" in Langertha::Raider::ToolNames (edit_file is Edit), an mcp__* name stays, a raider-only tool (perl_eval) keeps its raider name.

Only type: command runs; a http, mcp_tool, prompt or agent hook is skipped and reported. A hook with an if rule runs on tool events only (as in Claude Code); the rule is checked by "if_matcher" when one is set, otherwise the hook runs (the rule is a filter, the hook still decides). The same command (with the same args) from several settings files runs once; a plugin's copy runs separately.

Running a hook

Each hook is a process of its own session (setsid, no terminal) in "root", all hooks of an event in parallel, on "loop" (one raid, one loop: ADR 0016). ${CLAUDE_PROJECT_DIR}, and for plugin hooks ${CLAUDE_PLUGIN_ROOT} and ${CLAUDE_PLUGIN_DATA}, are replaced in command and every element of args, and set in the environment (CLAUDE_PLUGIN_* removed for settings hooks). With args the command is started directly (exec form, looked up on PATH, no shell); without, it runs with sh -c (shell form; shell: bash uses bash, powershell is not supported). The data directory is created before a plugin hook runs. ${user_config.*} is not supported and stays as written.

The event's input is written to stdin as JSON: what the caller passes, plus hook_event_name and cwd ("root" unless given -- the application gives its working root, a subagent its own, ADR 0032). For tool events tool_name is turned into the Claude name and tool_input into the Claude form ("claude_input" in Langertha::Raider::ToolNames); the file_path of Read, Write, Edit and MultiEdit is made absolute against that cwd, as Claude Code hands it.

timeout is in seconds; the default is 600, 30 on UserPromptSubmit. A hook over its time gets TERM (its process group), KILL two seconds later; its output is dropped and it is a non-blocking error. async: true starts the hook and does not wait: no timeout, no decision; when it ends, its additionalContext and systemMessage are in "take_async_results". asyncRewake runs like async (raider has no idle session to wake).

onFailure: "block" makes a failed hook -- one that cannot start, exits other than 0 or 2 (even with a JSON answer), prints invalid JSON or times out -- do what exit 2 does on its event; not on Stop, SubagentStop, TaskCompleted, TeammateIdle and async hooks, as in Claude Code.

Reading the answer

As Claude Code reads it:

  • stdout that is one JSON object ({ ... }, whitespace around) is the hook's answer; stdout that is not is plain text; { ... } that does not parse is invalid. An async hook may also print its object on a line of its own.

  • Exit 0: the JSON fields apply; plain text is context on UserPromptSubmit and SessionStart; invalid JSON is a non-blocking error.

  • Exit 2: blocks -- PreToolUse is denied, a blocking event (UserPromptSubmit, Stop, SubagentStop, PostToolUse, PostToolUseFailure, PreCompact, ...) is blocked; stderr is the reason unless the JSON gives one. The JSON is still read but cannot undo the block. On events that cannot block (SessionStart, SubagentStart, ...) exit 2 is a non-blocking error. On Stop, SubagentStop and a plugin's UserPromptSubmit, an exit 2 with empty stdout whose stderr says No such file or directory is a non-blocking error (a missing script).

  • Any other exit: with a valid JSON object the fields decide (Claude Code ignores the exit code then); otherwise a non-blocking error with the first line of stderr.

  • hookSpecificOutput must name the event in hookEventName, else the answer is a non-blocking error.

Merging

  • continue: false wins over everything: "stopped" in Langertha::Raider::Hooks::Result.

  • PreToolUse: deny > ask > allow; the reasons of the winning decision in configuration order. defer (Claude Code's -p mode only) is not supported and counts as no decision. The deprecated top-level decision approve/block counts as allow/deny.

  • updatedInput replaces the whole input (Claude Code's rule). The hooks of one event run in parallel on the same input, so two of them cannot build on each other: the last one in configuration order wins, and when several hooks return different inputs that is reported in "errors" in Langertha::Raider::Hooks::Result. It is translated back with "raider_input" in Langertha::Raider::ToolNames; a file_path that is still the absolute form of the original relative path gets the original back. It never applies to a denied call.

  • additionalContext, systemMessage and plain-text context are collected, never merged away; decision: "block" on a blocking event sets "blocked" in Langertha::Raider::Hooks::Result with its reason.

Task events and the words of a denial

TaskCreated and TaskCompleted are fired by the application when the task tools (task_create, task_update) create a task or complete one, with task_id, task_subject and task_description in the input and no matcher. Exit 2 (or decision: "block") vetoes the change; the model gets TaskCreated hook error: STDERR as the tool's error result. They are blocking events here, so "blocked" in Langertha::Raider::Hooks::Result tells (ADR 0022, addendum).

What the model is told when a PreToolUse hook denies is "deny_messages" in Langertha::Raider::Hooks::Result: PreToolUse:Bash hook error: STDERR for exit 2, the permissionDecisionReason as it stands for a JSON denial.

Briefing loaded natively

The briefing plugin's briefing-preload rewrites an Agent call's prompt on PreToolUse (and answers SubagentStart for Codex) to put the skills an agent file declares under briefing.skills in front of the task. When raider loads those skills itself ("native_briefing"), running the hook as well would put every skill body into the prompt twice -- or deny a spawn raider would brief correctly, as the hook resolves skills through its own copy of the lookup. Raider's own loading wins: with "native_briefing" the hooks whose command is briefing-preload are skipped (reported as skipped) on PreToolUse for Agent and on SubagentStart. Its SessionStart check still runs.

loop

The IO::Async::Loop the hooks run on -- the raid's loop. Required.

root

The working directory: the hook processes run there, and it is the cwd of an input that names none, the base of its relative tool paths. Required.

project_dir

CLAUDE_PROJECT_DIR: the project root. Defaults to "root".

settings

The Langertha::Raider::Context::Settings whose hooks run, or undef.

plugins

The Langertha::Raider::Context::Plugins whose hooks run, or undef.

native_briefing

True when raider loads briefing.skills of agent files itself; then the briefing-preload hooks are skipped where they would load them a second time (see "Briefing loaded natively"). Default false.

if_matcher

Optional code ref that decides a hook's if rule (Claude Code's permission rule syntax, Bash(git *)) for a tool call:

if_matcher => sub { my ( $rule, $call ) = @_; ... }   # $call: { name, source, arguments }, raider form

True runs the hook, false skips it, undef (the rule cannot be decided) runs it marked if_unchecked, as Claude Code runs a hook whose rule it cannot evaluate. Without one, a hook with an if rule runs on every tool call its group matches, marked the same way. The application passes "rule_matches" in Langertha::Raider::Permissions.

groups

The matcher groups per event, { Event => [ { matcher, hooks, source, plugin }, ... ] }, plugin being { name, key, root, data_dir } for a plugin's group and undef otherwise. Built from "settings" and "plugins"; empty when the settings say disableAllHooks.

config_errors

Problems found while collecting "groups" (a broken plugin hooks file), one line each.

from_context

my $hooks = Langertha::Raider::Hooks->from_context( $context, loop => $loop, %more );

A runner for a Langertha::Raider::Context: its root, project directory, settings and plugins. undef outside the Claude layout (no settings). %more goes to new ("native_briefing", "if_matcher").

has_hooks

return unless $hooks->has_hooks('PostToolUse');

True when any group is configured for the event (whether its matcher matches is not checked). Cheap; lets the caller skip building the input.

run_f

my $result = await $hooks->run_f( $event => \%input, tool_source => 'engine:1' );

tool_source (optional) is where a tool call runs, as the gate sees it; it only reaches "if_matcher", never the hooks. Runs the hooks of $event that match \%input (see "DESCRIPTION") and resolves to a Langertha::Raider::Hooks::Result once every hook that is not async has finished. \%input holds the event's fields in raider form: session_id, transcript_path, permission_mode, ... and per event tool_name/tool_input/tool_use_id/tool_response, prompt, source, stop_hook_active, agent_id/agent_type, last_assistant_message. JSON booleans for booleans. The future never fails: every problem ends up in the result's errors. With nothing to run it is done at once with an empty result.

run

my $result = $hooks->run( $event => \%input );

"run_f", waited for on "loop".

take_async_results

for my $r ($hooks->take_async_results) { ... $r->context, $r->system_messages ... }

A Langertha::Raider::Hooks::Result for every async hook that ended since the last call, in the order they ended; each carries only that hook's additionalContext and systemMessage (an async hook decides nothing), its run report and errors. Claude Code delivers these on the next turn.

running_async

The number of async hooks still running.

shutdown

await $hooks->shutdown;

Stops every async hook still running (TERM to its process group, KILL after two seconds, as claude -p does at the end); they end as cancelled and show up in "take_async_results". Returns a future that is done when they have ended.

SEE ALSO

SUPPORT

Issues

Please report bugs and feature requests at https://github.com/Getty/langertha-raider/issues.

IRC

Join #langertha on irc.perl.org or message Getty directly.

CONTRIBUTING

Contributions are welcome! Please fork the repository and submit a pull request.

AUTHOR

Torsten Raudssus <torsten@raudssus.de> https://raudssus.de/

COPYRIGHT AND LICENSE

This software is copyright (c) 2026 by Torsten Raudssus.

This is free software; you can redistribute it and/or modify it under the same terms as the Perl 5 programming language system itself.

https://raudssus.de/