Skip to content

fix: run one hook script per event in vibe and code - #346

Merged
shafty023 merged 3 commits into
mainfrom
fix/vibe-one-hook-per-event
Oct 26, 2026
Merged

shafty023 merged 3 commits into
mainfrom
fix/vibe-one-hook-per-event

Conversation

@shafty023

@shafty023 shafty023 commented Oct 26, 2026 •

Copy link
Copy Markdown
Collaborator

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.json and plugins/code/hooks/hooks.json (no hooks field in any .claude-plugin/plugin.json or .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):

Event Matcher Commands
PreToolUse Bash kill-guard.mjs, foreground-guard.mjs, merge-guard.mjs, shell-safety-guard.mjs
PreToolUse Agent agent-guard.mjs
PreToolUse SendMessage pause-guard.mjs
UserPromptSubmit none away-protocol.mjs

After:

{
  "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.mjs fails if the matcher and the route table (PRE_TOOL_USE_ROUTES) drift apart.

How vibe's dispatch works

  • Each guard file is now a module exporting one function from the hook event to null, { decision, reason } or { context } (agentGuard, foregroundGuard, killGuard, mergeGuard, shellSafetyGuard, pauseGuard, awayProtocol). Their existing pure helpers (killProblems, mergeProblem, holdReminder and the rest) are unchanged. command-segments.mjs stays shared.
  • pre-tool-use.mjs reads stdin once and routes by tool_name (Bash: kill, foreground, merge, shell-safety; Agent: agent; SendMessage: pause). user-prompt-submit.mjs runs away-protocol. Both use hook-dispatch.mjs to run and merge.
  • The per-guard stdin wrappers are deleted. kill-guard.mjs keeps its CLI entry because kill-guard.mjs own <pid> is a live recorder cited by guardrails.md, the fleet-orchestrator skill and watch.sh; run without own it 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 is deny > 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's additionalContext is kept, joined by a blank line ("When several hooks return additionalContext for the same event, Claude receives all of the values", same page). Today only deny and context are ever produced; the other levels are covered by unit tests.

Exit codes, stdout, failures

  • Output follows the documented contract: exit 0 with one JSON object on stdout, hookSpecificOutput.permissionDecision and permissionDecisionReason for PreToolUse, hookSpecificOutput.additionalContext for 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.
  • A single guard's result is byte-identical to the line its old script printed.
  • Failure behavior is preserved per guard: every old script wrapped its work in 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 with pre-tool-use: or user-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)

Event Before After
PreToolUse pretooluse-hook.sh (matcher Read|Bash|Write|Edit), pre-tool-use-hook.sh (all tools) pre-tool-use.sh (all tools)
SubagentStop loop-stop-hook.sh, subagent-stop-hook.sh subagent-stop.sh
  • The four handler scripts are unchanged and stay the units their Python and shell suites test directly. pre-tool-use.sh reads stdin once, runs pretooluse-hook.sh only for Read, Bash, Write and Edit (its old exact-list matcher) and pre-tool-use-hook.sh for every tool. subagent-stop.sh runs loop-stop-hook.sh then subagent-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: permissionDecision by deny > defer > ask > allow, a top-level decision: "block" over none, every reason, additionalContext and systemMessage kept, 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.
  • Failures keep each handler's effect. Exit 2 still blocks with that handler's stderr. Another nonzero exit contributes nothing, as before (its stdout was never parsed), and the dispatcher keeps that exit code when no other handler produced output, so the non-blocking error notice still shows.
  • Ordering: PreToolUse handlers still run in parallel (--parallel; they share no state). SubagentStop handlers now run in sequence, loop handler first. They ran in parallel before, but subagent-stop-hook.sh deletes the .agent-types/<agent_id> record that loop-stop-hook.sh reads, and both the script comment and the README already said the loop hook "runs before". The sequence removes that race.
  • stdin: each handler now reads the hook input from a pipe that dispatch.sh writes. Found while running the test on Linux: node's spawnSync input is a socket there, and pre-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.
  • Cost: on the idle second laptop, 20 PreToolUse calls averaged 50 to 70 ms for the two old commands in parallel and 80 to 90 ms for pre-tool-use.sh (one more bash, one jq to read tool_name, a temp dir). About 25 ms per tool call.

Prevention

Prevention
- Class: a plugin registers several hook commands for one event instead of one script that routes internally
- Instances: fixed plugins/vibe/hooks/hooks.json (PreToolUse 6) and plugins/code/hooks/hooks.json (PreToolUse 2, SubagentStop 2) | none left
- Search: every hooks.json under plugins/, the hooks field of every .claude-plugin and .codex-plugin manifest, grep for hooks/ references in skills
- Rung: 3, guard test via tools/plugin-manifests/src/hook-registrations.ts, run by the existing Plugin Manifest Versions CI job (npm test), plus a one-line rule in AGENTS.md
- Proof: with both a610ad0a hooks.json files restored, the repository test fails with three lines: "plugins/code: PreToolUse registers 2 hook handlers (hooks/hooks.json), allowed 1; ...", the same for code SubagentStop, and "plugins/vibe: PreToolUse registers 6 hook handlers ..."; with this branch's files it passes
- Why not higher: hook configs are JSON with no type system or schema owned by this repo; a test over every plugin's config is the strongest check that sees them
- Follow-up: none
- Event recorded: no log defined

The check counts handlers per event across every matcher group and every hook source a plugin ships: each hooks.json in the plugin tree (covers Claude's hooks/hooks.json and any Codex hooks.json) and a hooks field in either manifest, given as a path, a list of paths, or inline. It covers every plugin and has no exception list.

Tests

  • Every existing guard test passes against the modules. Function-level assertions are unchanged. The tests that spawned a guard script now spawn pre-tool-use.mjs or user-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.
  • New hooks/pre-tool-use.test.mjs feeds real hook payloads and asserts exact stdout, stderr and exit code for Bash, Agent, SendMessage, a non-matching tool, a constructor tool name, and UserPromptSubmit; one Bash command that two guards refuse (one deny with both reasons in guard order); a throwing guard (merge-guard with no gh) alongside a refusing one; unreadable stdin; the precedence table; and hooks.json's one command per event.
  • The vibe Python contract tests (1.0.76 to 1.0.81) read hooks.json for each guard; they now assert through vibe_contract_text.hook_guard_routed that 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.
  • New plugins/code/hooks/dispatch.test.mjs runs 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/bash 3.2.
  • vibe parity harness (not committed): ran the a610ad0 scripts as the old hooks.json registered them and the new script on 19 payloads (each Bash refusal, the two-guard case, gh unavailable, a long foreground wait outside a session, Agent, SendMessage, Read and three prompts). Every case: exit 0, identical stdout (the two-guard case equals the old reasons joined), identical stderr. Summed latency 2452 ms old (slowest parallel script per case) vs 2280 ms new.

Commands run:

node --test <every plugins/**/*.test.{mjs,cjs,js}>   793/795 at the vibe commit; the 2 failures are cl-sweep black-box tests (EPIPE under load), pass rerun alone, and import nothing from hooks/
node --test plugins/code/hooks/dispatch.test.mjs plugins/vibe/hooks/*.test.mjs   35/35
pytest code hook suites (pretooluse, pre_tool_use_telemetry, subagent_stop) 58/58; plugins/code/hooks/tests/*.sh all exit 0
cd tools/plugin-manifests && npm run typecheck && npm test   12/12
uv run pytest plugins/vibe   437/437
uv run pytest plugins/   2491 passed, 3 skipped (an earlier run had 7 failures in code/test_install_script.py, a different subset each run, also on a610ad0a)
uv run ruff check .   clean
uv run pyright   0 errors

Picking it up

After merge: claude plugin marketplace update closedloop-ai, then claude plugin update vibe@closedloop-ai and claude plugin update code@closedloop-ai (claude plugin list should 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.

- 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)
@shafty023 shafty023 changed the title fix(vibe): run one hook script per event fix: run one hook script per event in vibe and code Oct 26, 2026
- 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
@shafty023
shafty023 merged commit 978ba94 into main Oct 26, 2026
10 checks passed
@shafty023
shafty023 deleted the fix/vibe-one-hook-per-event branch October 26, 2026 17:57
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.

1 participant