Plugin hook deduplication drops hooks when multiple plugins use the same command template
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
- Create two plugins, each with a
hooks.jsonregistering the same event - Both use
bash ${CLAUDE_PLUGIN_ROOT}/hook.shas the command - Enable both plugins
- 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.
This issue has 3 comments on GitHub. Read the full discussion on GitHub ↗