Repository navigation
fix: run one hook script per event in vibe and code - #346
Merged
Merged
Conversation
- Register one PreToolUse command (pre-tool-use.mjs, matcher Bash|Agent|SendMessage) and one UserPromptSubmit command (user-prompt-submit.mjs) instead of seven per-guard commands - Turn each guard into a module the event script routes to by tool name; hook-dispatch.mjs merges results deny > defer > ask > allow, joins every winning reason and keeps every context - A throwing guard still fails open under its own name on stderr while the others decide; scripts always exit 0 - Keep kill-guard.mjs as the `own <pid>` recorder only - Fail CI when any plugin registers more than one hook handler for an event (tools/plugin-manifests); the code plugin's two PreToolUse and two SubagentStop handlers are held at that count, shrink-only - Bump vibe to 1.0.82 Testing: node --test over every plugins test file (793/795; the two cl-sweep black-box failures were EPIPE under load and pass alone), plugin-manifests vitest and tsc, pytest plugins/, ruff, pyright, old vs new parity harness on 19 payloads Risks: guards now run sequentially in one process, so a gh pr merge check waits on gh before the others report
- Register pre-tool-use.sh for PreToolUse and subagent-stop.sh for SubagentStop instead of two commands each - pre-tool-use.sh runs pretooluse-hook.sh for Read, Bash, Write and Edit (its old matcher) and pre-tool-use-hook.sh for every tool, in parallel - subagent-stop.sh runs loop-stop-hook.sh, then subagent-stop-hook.sh, the order both scripts and the README already assumed - dispatch.sh passes one handler's output through unchanged and merges several: deny > defer > ask > allow or block wins with every reason, context and system message kept; exit 2 still blocks - Drop the repo check's grandfather entry; it has no exceptions - Bump code to 2.0.2 Testing: node --test plugins/code/hooks/dispatch.test.mjs (real payloads against each handler run alone, merge and failure cases), code hook pytest and shell suites, plugin-manifests vitest, full pytest plugins/, ruff, pyright Risks: about 25 ms more per tool call (50 to 70 ms before, 80 to 90 ms after, 20 runs on an idle machine)
- On Linux, node's spawnSync input is a socket, which pre-tool-use-hook.sh cannot open as /dev/stdin, so the handler run alone wrote no sentinel in CI Testing: node --test plugins/code/hooks/dispatch.test.mjs on macOS and in a node:22-bookworm container Risks: None identified
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Purpose
Ships vibe 1.0.82 and code 2.0.2. Every plugin in the repo now registers exactly one hook script per event, and a repository check with no exceptions fails CI when any plugin registers a second command for an event. One commit per plugin.
Inventory before the change: hooks are registered only in
plugins/vibe/hooks/hooks.jsonandplugins/code/hooks/hooks.json(nohooksfield in any.claude-plugin/plugin.jsonor.codex-plugin/plugin.json, no Codex hook config, no skill that installs hooks). vibe had six PreToolUse commands across three matchers. code had two PreToolUse commands and two SubagentStop commands. Every other event in both plugins already had one.vibe hooks.json
Before (a610ad0):
Bashkill-guard.mjs,foreground-guard.mjs,merge-guard.mjs,shell-safety-guard.mjsAgentagent-guard.mjsSendMessagepause-guard.mjsaway-protocol.mjsAfter:
{ "hooks": { "PreToolUse": [ { "matcher": "Bash|Agent|SendMessage", "hooks": [{ "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/pre-tool-use.mjs\"" }] } ], "UserPromptSubmit": [ { "hooks": [{ "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/user-prompt-submit.mjs\"" }] } ] } }The matcher lists exactly the routed tools, so Read, Edit and every other tool still start no node process.
pre-tool-use.test.mjsfails if the matcher and the route table (PRE_TOOL_USE_ROUTES) drift apart.How vibe's dispatch works
null,{ decision, reason }or{ context }(agentGuard,foregroundGuard,killGuard,mergeGuard,shellSafetyGuard,pauseGuard,awayProtocol). Their existing pure helpers (killProblems,mergeProblem,holdReminderand the rest) are unchanged.command-segments.mjsstays shared.pre-tool-use.mjsreads stdin once and routes bytool_name(Bash: kill, foreground, merge, shell-safety; Agent: agent; SendMessage: pause).user-prompt-submit.mjsrunsaway-protocol. Both usehook-dispatch.mjsto run and merge.kill-guard.mjskeeps its CLI entry becausekill-guard.mjs own <pid>is a live recorder cited byguardrails.md, the fleet-orchestrator skill andwatch.sh; run withoutownit now prints usage and exits 1 (it used to read a hook event).Merge precedence
Highest decision wins in the order
deny > defer > ask > allow, which is the order Claude Code itself applies when several PreToolUse hooks disagree ("When multiple PreToolUse hooks return different decisions, precedence isdeny>defer>ask>allow", hooks reference, PreToolUse decision control). So the merged result is the decision the old parallel commands produced. The winning decision carries every reason given for it, one per line in guard order, rather than only the first: the old setup showed Claude each refusing hook's reason, and dropping all but one would hide a second violation in the same command. Every guard'sadditionalContextis kept, joined by a blank line ("When several hooks returnadditionalContextfor the same event, Claude receives all of the values", same page). Today onlydenyand context are ever produced; the other levels are covered by unit tests.Exit codes, stdout, failures
hookSpecificOutput.permissionDecisionandpermissionDecisionReasonfor PreToolUse,hookSpecificOutput.additionalContextfor context ("your stdout counts as JSON output only when you print one JSON object"; exit 2 and other codes described under Exit codes on the same page). No guard used exit 2 before, and none does now.try/catch, wrote<guard-name>: <message>to stderr and exited 0 with no stdout, which fails open. Each guard still does exactly that under its old name, and the other guards on the same call still run and decide. Unreadable stdin fails open withpre-tool-use:oruser-prompt-submit:on stderr, which is what each old script did for the same input. Neither script ever exits nonzero.code plugin (2.0.2)
pretooluse-hook.sh(matcherRead|Bash|Write|Edit),pre-tool-use-hook.sh(all tools)pre-tool-use.sh(all tools)loop-stop-hook.sh,subagent-stop-hook.shsubagent-stop.shpre-tool-use.shreads stdin once, runspretooluse-hook.shonly for Read, Bash, Write and Edit (its old exact-list matcher) andpre-tool-use-hook.shfor every tool.subagent-stop.shrunsloop-stop-hook.shthensubagent-stop-hook.sh.hooks/dispatch.sh(bash 3.2 and jq, like the handlers) runs them and prints one result. A single handler's output passes through byte for byte. Several JSON outputs merge:permissionDecisionbydeny > defer > ask > allow, a top-leveldecision: "block"over none, every reason,additionalContextandsystemMessagekept, other fields from the first handler.{}contributes nothing (printed only when it is all there is). Plain text stdout is printed when no handler produced JSON and moved to stderr otherwise; on these events Claude Code reads plain stdout into the debug log only.--parallel; they share no state). SubagentStop handlers now run in sequence, loop handler first. They ran in parallel before, butsubagent-stop-hook.shdeletes the.agent-types/<agent_id>record thatloop-stop-hook.shreads, and both the script comment and the README already said the loop hook "runs before". The sequence removes that race.dispatch.shwrites. Found while running the test on Linux: node'sspawnSyncinputis a socket there, andpre-tool-use-hook.sh($(</dev/stdin)) cannot open a socket as/dev/stdin, so run alone it silently wrote no sentinel. Whether Claude Code's own hook stdin on Linux is a socket is unverified; the dispatcher's pipe works either way. The test feeds payloads from a file so both arms read the same way.pre-tool-use.sh(one more bash, one jq to readtool_name, a temp dir). About 25 ms per tool call.Prevention
The check counts handlers per event across every matcher group and every hook source a plugin ships: each
hooks.jsonin the plugin tree (covers Claude'shooks/hooks.jsonand any Codexhooks.json) and ahooksfield in either manifest, given as a path, a list of paths, or inline. It covers every plugin and has no exception list.Tests
pre-tool-use.mjsoruser-prompt-submit.mjs, since the per-guard entry points are gone, and the "is registered in hooks.json" assertions now check the guard is in its route.hooks/pre-tool-use.test.mjsfeeds real hook payloads and asserts exact stdout, stderr and exit code for Bash, Agent, SendMessage, a non-matching tool, aconstructortool name, and UserPromptSubmit; one Bash command that two guards refuse (one deny with both reasons in guard order); a throwing guard (merge-guard with nogh) alongside a refusing one; unreadable stdin; the precedence table; and hooks.json's one command per event.vibe_contract_text.hook_guard_routedthat the event's one command routes that guard for that tool. The 1.0.81 version pin moved to the new 1.0.82 contract.plugins/code/hooks/dispatch.test.mjsruns each code handler alone (as its own hooks.json command did) and the event script on identical fixtures, with real payloads: a credential deny, a workspace allow and a Write in a session (identical stdout, identical sentinel), an Agent call (sentinel and spawn event only, no learnings handler), a session-less call, a loop agent where both SubagentStop handlers block (one block, both reasons and messages, loop first), and an agent outside the loop ({}as before). Stand-in handlers cover precedence, byte-for-byte passthrough, plain text,{}, exit 2 and exit 1 under/bin/bash3.2.Commands run:
Picking it up
After merge:
claude plugin marketplace update closedloop-ai, thenclaude plugin update vibe@closedloop-aiandclaude plugin update code@closedloop-ai(claude plugin listshould show vibe 1.0.82 and code 2.0.2), then restart the Claude session. Hooks load at session start, so a running session keeps the old hooks until it restarts.