Plugin hooks.json entries using exec form (command + args array) are silently ignored
Status Fixed / completed
Reported on v2.1.59
Maintainer reply ✓ Yes — bcherny
Activity 4 comments · opened Jul 13, 2026 · closed Aug 20, 2026
💡 Likely answer: A maintainer (bcherny, collaborator)
responded on this thread — see the highlighted reply below.
Environment
- Claude Code 2.1.59, Windows 11 Pro (10.0.26200), Node v22.20.0
- Plugin installed from a local marketplace (
claude plugin marketplace add <path>+claude plugin install)
Observed
A plugin's hooks/hooks.json entry registered in exec form never executes — no hook fires, no error or warning is surfaced (nothing in --debug, plugin shows enabled):
{ "type": "command", "command": "node", "args": ["${CLAUDE_PLUGIN_ROOT}/scripts/entry.mjs", "SessionStart"], "timeout": 5 }
Rewriting the same entry in string form makes it fire on every event as expected:
{ "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/entry.mjs\" SessionStart", "timeout": 5 }
Repro
- Create a minimal plugin whose hook script appends a line to a file.
- Register any hook (e.g. SessionStart) in exec form as above; install; run
claude -p "reply OK"→ file untouched. - Switch the same entry to string form; reinstall; rerun → file written.
Expected
Either exec form executes (the plugins reference recommends command+args for ${CLAUDE_PLUGIN_ROOT} paths that may contain spaces), or invalid/unsupported hook entries produce a validation warning instead of being dropped silently.
Found while building a hooks-based plugin (https://github.com/StakeholderSensei/polygraph); happy to provide the full probe transcripts.
Showing cached comments. Read the full discussion on GitHub ↗
3 Comments
This is a good bug to keep framed as a validation/evidence problem, not just a command parser mismatch.
The risky behavior is the silent drop: from the plugin author's view, the hook is enabled, the config is present, and the session continues, but the runtime has no hook execution record. That makes it hard for a plugin to know whether its safety/audit/control hook actually ran.
A regression shape I would want for the fix:
The invariant is: a declared hook should end in one of three observable states — executed, explicitly skipped by policy, or rejected as invalid. It should not disappear into the transcript gap between "plugin enabled" and "nothing happened."
From adjacent AgentFolio/SATP work, the portable lesson is that tool/hook authority and execution evidence should be linked but separate. A minimal receipt for hooks could be as small as:
Then plugin authors and downstream auditors can distinguish "the hook ran and allowed the event" from "the hook was never invoked." That distinction matters most for security hooks, but it also makes ordinary plugin debugging much less ambiguous.
Boundary note: architecture/testing feedback only. I did not reproduce this report or validate Claude Code internals, Windows behavior, the polygraph plugin, implementation correctness, security impact, or production readiness.
Agreed — the validation/evidence framing is the right one. The parser mismatch is incidental; the defect that actually cost debugging time is that a declared hook can end in a fourth, unobservable state.
One more data point supporting the "rejected as invalid before the session" layer:
claude plugin validatepasses an exec-formhooks/hooks.jsontoday (2.1.59) with no hook-related warning — so the invalid-for-this-runtime config survives every checkpoint (validate → install → enable → SessionStart) with zero signal at each. Repro:validate→ "Validation passed" (only an unrelated author warning); session runs; hook never executes; nothing in--debug.Your three-observable-states invariant (executed / explicitly skipped / rejected-as-invalid) would have surfaced this in seconds instead of requiring a bisection of the hook config against a working plugin's format. The minimal hook-receipt shape you sketch would also cover the adjacent failure modes we can't currently distinguish from the outside: matcher didn't match, command spawn failed, timeout killed it, or config was silently dropped — today all four look identical to a plugin author (nothing happened).
For what it's worth we hit this while building a plugin whose whole premise is exactly that distinction (evidence of execution vs. absence of evidence), which made the irony sharp. Happy to share full probe transcripts (Windows 11, 2.1.59, string-form vs exec-form side-by-side) if useful.
I couldn't reproduce this on 2.1.233 (Linux): a plugin
hooks/hooks.jsonSessionStart entry in exec form —{ "type": "command", "command": "node", "args": ["${CLAUDE_PLUGIN_ROOT}/scripts/entry.mjs", "SessionStart"], "timeout": 5 }— fires correctly onclaude -p, with${CLAUDE_PLUGIN_ROOT}substituted per-argument.The explanation is version skew: you were on 2.1.59, and the exec form (
argsarray) for hooks was added in 2.1.139 — see the changelog ("Added hookargs: string[]field (exec form) that spawns the command directly without a shell"). On versions before that, theargsfield wasn't recognized, which matches the silent no-op you saw.Please update (
claude update) and exec form should work as the plugins reference describes. Closing — reply here if it still doesn't work on a current version and we'll reopen.🤖 Generated with Claude Code