Plugin-sourced hooks.json never fires, even though the plugin shows installed + enabled (no error, no registry entry evidence)
Claude Code version: 2.1.202 (macOS/Darwin)
Summary: A plugin's hooks.json (SessionStart, Stop, PreToolUse, PostToolUse, PreSkill hooks) never fires, despite the plugin being installed, enabled, and its cached hooks.json matching the source repo exactly. The plugin's skills/agents work normally — only the hooks are silently inert.
Setup (verified correct):
- Plugin
base-tools@claude-baseappears in~/.claude/plugins/installed_plugins.json(auto-installed as a dependency of another plugin,scope: user,auto: true) - Explicitly enabled in
~/.claude/settings.json→"enabledPlugins": {"base-tools@claude-base": true} - Cached
hooks.jsonis byte-identical to the plugin source, with standard${CLAUDE_PLUGIN_ROOT}/hooks/*.shcommand entries forSessionStart,Stop,PostToolUse(matcherWrite|Edit), 8xPreToolUse(matchersBash,Write|Edit), andPreSkill(matcher.*) - All hook scripts present on disk, correct executable permissions
Evidence they don't fire:
- Live repro: ran
echo test --no-verifyvia the Bash tool. APreToolUse/Bashhook in this plugin blocks any command containing--no-verify(exit 2 + stderr message). The command executed cleanly with no block. - A
PreSkillhook writes one JSON line per skill activation to${CLAUDE_PLUGIN_DATA}/base-tools/skills.jsonl. That file does not exist anywhere on disk despite many skill invocations across 11 sessions in the project. - Checked raw session transcripts (
~/.claude/projects/.../*.jsonl) for all 11 sessions in the project -- theSessionStarthook is supposed to inject a short primer into context. In every session, the first user message appears immediately after themode/permission-mode/file-history-snapshotbootstrap entries with zero injected content in between. - No
Stop-hook output appears at the end of a completed session (only aturn_durationsystem marker).
Expected: Hooks registered in an enabled plugin's hooks.json should fire per the documented hook-dispatch behavior -- no additional trust/approval step or restart is documented as required beyond install + enable.
Question: Does a plugin that is only transitively installed as a dependency (auto: true in installed_plugins.json, pulled in via another plugin's dependencies field) get its hooks.json registered into the session's hook dispatch the same way a directly-installed plugin does? That's the one structural difference here vs. a normal install, and it's not covered in the plugin hooks docs.
Showing cached comments. Read the full discussion on GitHub ↗
3 Comments
Additional isolating data point: I tested whether a hook defined directly in
.claude/settings.local.json's own"hooks"key (not via a plugin) fires correctly.Test hook —
PreToolUse/Bash, blocks any command containing a unique marker string:Running
echo ZZZTESTHOOKZZZwith this in place correctly blocked the command with the expected stderr message and exit 2 — the settings.json-level hook fired exactly as documented.This isolates the bug precisely: the hook dispatch engine itself works fine. The defect is specifically in whether/how a plugin-sourced
hooks.jsongets registered into that dispatch table — at least for a plugin that is only transitively installed as a dependency (auto: trueininstalled_plugins.json) rather than directly/explicitly installed by the user. That's the one structural difference from a normal settings.json hook, and from what I can tell it's the most likely fault line.Would still value confirmation either way — happy to run more diagnostics if useful (e.g. testing a directly user-installed plugin's hooks.json, not just a dependency-auto-installed one, to see if that distinction is actually where the break is).
Confirmed — reproduced on 2.1.233 (Linux).
I built a minimal plugin with a
hooks/hooks.jsondeclaring SessionStart + PreToolUse hooks, installed it from a local marketplace (both directly and as an auto-installed dependency of another plugin, matching yourauto: truesetup) — and the hooks fired fine. The trigger is thePreSkillentry:PreSkillisn't a supported hook event, and one unrecognized event name currently causes the entire hooks.json to be rejected, so all the valid hooks (SessionStart, Stop, PreToolUse, PostToolUse) silently never fire. Skills/agents are unaffected, and no error is shown at session start — the failure is only visible in--debuglogs and as "failed to load" inclaude plugin list.That's a bug on our side: an unrecognized event should be skipped (with a visible warning), not silently disable every hook in the file. It also breaks plugins written for newer Claude Code versions when run on older ones. We're tracking a fix.
Workaround: remove the
PreSkillentry from the plugin's hooks.json (or ask the plugin author to) — the remaining hooks will then register and fire.🤖 Generated with Claude Code
Another data point, this time on Cowork (cloud sessions on claude.ai) rather than the CLI, in case it helps pin the scope.
Setup. A plugin uploaded through claude.ai → Settings → Plugins → Add → Upload plugin, so it is a first-party "My Uploads" entry, not a git marketplace and not a transitive dependency. Its page in settings shows the enable toggle on, and renders all three of its hooks by name:
Pre-tool use: mcp__.*,Stop,Prompt submit.Observed. None of the three fire in a cloud session. Verified with an isolated one-shot scheduled run on 2026-09-10:
PreToolUsematcher ismcp__.*and the hook denies an MCP call when a precondition is unmet. Callingmcp__<server>__list_repsreturned the normal tool result, with no denial and no hook output.{tool_name, tool_input, transcript_path}payload, the hook script returns a well-formed{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"..."}}with exit 0.hooks/hooks.jsoncarries thePreToolUseblock verbatim and${CLAUDE_PLUGIN_ROOT}resolves.The one thing that looks like a lead. In the sandbox's own
~/.claude/plugins/synced/<org>_<user>/manifest.json, the entry reads:available, not installed/enabled — while the account UI shows the plugin enabled. There is no~/.claude/settings.jsonand noplugins/config.jsonin the environment at all, so there is nowhere anenabledPluginsmap could live. If the cloud sync writesavailablefor a plugin the account considers enabled, that would explain all three hook types being silently inert together, which is what we see.Same failure shape as this issue and as #92675, but on a surface neither of those covers, and with a concrete artifact (
installationPreference) that may be the discriminator. Happy to supply the plugin, the manifest, and the one-shot run's transcript.Why it matters on our side: the hooks in question are guardrails on outbound messages sent to real recipients on a client's behalf. A guardrail that is listed in the UI, ships correct JSON when run by hand, and never executes is worse than no guardrail, because it gets treated as coverage.