Plugin hook deduplication drops hooks when multiple plugins use the same command template

Resolved 💬 3 comments Opened Mar 1, 2026 by mr-lee Closed Mar 3, 2026

This is a re-report of #16954 which was auto-closed without resolution. The bug is confirmed and reproducible in v2.1.53.

Problem

When multiple plugins register hooks for the same event (e.g., UserPromptSubmit), Claude Code deduplicates them by raw command template string before expanding ${CLAUDE_PLUGIN_ROOT}. Since each plugin's hooks.json uses ${CLAUDE_PLUGIN_ROOT} as its base path, two plugins with the same relative script path (e.g., bash ${CLAUDE_PLUGIN_ROOT}/hook.sh) produce identical template strings - and only one survives dedup.

Root cause

In getMatchingHooks, hooks are deduplicated via:

new Map(hooks.map(h => [h.hook.command, h])).values()

This maps by the unexpanded command string. Two plugins registering:

  • Plugin A: bash ${CLAUDE_PLUGIN_ROOT}/hook.sh
  • Plugin B: bash ${CLAUDE_PLUGIN_ROOT}/hook.sh

...produce the same map key, so only the last one wins - even though they resolve to completely different directories at runtime.

Reproduction

  1. Create two plugins, each with a hooks.json registering the same event
  2. Both use bash ${CLAUDE_PLUGIN_ROOT}/hook.sh as the command
  3. Enable both plugins
  4. Only one plugin's hook fires for that event

Debug logs confirm: "Matched 1 unique hooks (2 before deduplication)" - 2 matchers collapsed to 1.

Expected behavior

Hooks from different plugins should be treated as distinct even if their relative paths match, since ${CLAUDE_PLUGIN_ROOT} expands to different directories per plugin.

Workaround

Give each plugin a unique script filename so the command templates differ after dedup.

View original on GitHub ↗

This issue has 3 comments on GitHub. Read the full discussion on GitHub ↗