Plugin-sourced hooks.json never fires, even though the plugin shows installed + enabled (no error, no registry entry evidence)

Status Open
Reported on v2.1.202
Maintainer reply ✓ Yes — bcherny
Activity 4 comments · opened Jul 9, 2026
💡 Likely answer: A maintainer (bcherny, collaborator) responded on this thread — see the highlighted reply below.

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-base appears 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.json is byte-identical to the plugin source, with standard ${CLAUDE_PLUGIN_ROOT}/hooks/*.sh command entries for SessionStart, Stop, PostToolUse (matcher Write|Edit), 8x PreToolUse (matchers Bash, Write|Edit), and PreSkill (matcher .*)
  • All hook scripts present on disk, correct executable permissions

Evidence they don't fire:

  1. Live repro: ran echo test --no-verify via the Bash tool. A PreToolUse/Bash hook in this plugin blocks any command containing --no-verify (exit 2 + stderr message). The command executed cleanly with no block.
  2. A PreSkill hook 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.
  3. Checked raw session transcripts (~/.claude/projects/.../*.jsonl) for all 11 sessions in the project -- the SessionStart hook is supposed to inject a short primer into context. In every session, the first user message appears immediately after the mode/permission-mode/file-history-snapshot bootstrap entries with zero injected content in between.
  4. No Stop-hook output appears at the end of a completed session (only a turn_duration system 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.

View original on GitHub ↗

3 Comments

dkkplk · 3 months ago

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:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [{"type": "command", "command": "... grep -q ZZZTESTHOOKZZZ ... exit 2 ..."}]
      }
    ]
  }
}

Running echo ZZZTESTHOOKZZZ with 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.json gets registered into that dispatch table — at least for a plugin that is only transitively installed as a dependency (auto: true in installed_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).

bcherny collaborator · 1 month ago

Confirmed — reproduced on 2.1.233 (Linux).

I built a minimal plugin with a hooks/hooks.json declaring SessionStart + PreToolUse hooks, installed it from a local marketplace (both directly and as an auto-installed dependency of another plugin, matching your auto: true setup) — and the hooks fired fine. The trigger is the PreSkill entry: PreSkill isn'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 --debug logs and as "failed to load" in claude 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 PreSkill entry from the plugin's hooks.json (or ask the plugin author to) — the remaining hooks will then register and fire.

🤖 Generated with Claude Code

TheAviv · 1 month ago

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:

  1. The PreToolUse matcher is mcp__.* and the hook denies an MCP call when a precondition is unmet. Calling mcp__<server>__list_reps returned the normal tool result, with no denial and no hook output.
  2. Satisfying the precondition and calling again returned a byte-identical result, i.e. the two calls are indistinguishable — the signature of a hook that never runs, rather than one whose decision is discarded.
  3. Run standalone against the exact {tool_name, tool_input, transcript_path} payload, the hook script returns a well-formed {"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"..."}} with exit 0.
  4. The synced copy in the sandbox is complete and current: hooks/hooks.json carries the PreToolUse block 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:

{ "name": "answering-aviv-gate", "version": "0004", "installationPreference": "available" }

available, not installed/enabled — while the account UI shows the plugin enabled. There is no ~/.claude/settings.json and no plugins/config.json in the environment at all, so there is nowhere an enabledPlugins map could live. If the cloud sync writes available for 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.

Showing cached comments. Read the full discussion on GitHub ↗