Identical hook commands registered in settings and in a plugin's hooks.json are not deduplicated — the hook executes once per source, concurrently
Environment
- Claude Code 2.1.204 / 2.1.205 / 2.1.206 (reproduced on all three; native install, macOS 15 / Darwin 25.5.0)
- Any hook event (verified: UserPromptSubmit, PreToolUse, PostToolUse)
Expected behavior
The hooks documentation (https://code.claude.com/docs/en/hooks, "Hook handler fields") says:
All matching hooks run in parallel, and identical handlers are deduplicated automatically. Command hooks are deduplicated by command string and args, and HTTP hooks are deduplicated by URL.
So a command hook registered with the byte-identical command string in two places should execute once per event.
Actual behavior
Deduplication only works within a source kind. Across the settings ↔ plugin boundary (and between two different plugins) the identical command survives dedup and executes once per registration — concurrently, with byte-identical stdin:
| Registration sources for the identical command string | Executions per event |
|---|---|
| project settings.json + project settings.local.json | 1 (deduplicated ✓) |
| project settings.json + one plugin hooks/hooks.json | 2 |
| project settings.json + two plugins' hooks/hooks.json | 3 |
Tracer evidence (one UserPromptSubmit event; the sha is a hash of the hook's stdin — all copies receive identical payloads, spawned ~1-4 ms apart):
1783645170.291127 pid=29885 event=UserPromptSubmit sha=3e032e9d7a44
1783645170.292530 pid=29888 event=UserPromptSubmit sha=3e032e9d7a44
1783645170.293753 pid=29889 event=UserPromptSubmit sha=3e032e9d7a44
Minimal reproduction
Self-contained script (creates a temp project + a local throwaway marketplace/plugin, prints execution counts, cleans up):
#!/bin/bash
# Repro: identical hook commands from settings vs plugin sources are not deduplicated.
# Installs a throwaway plugin at USER scope (cleaned up at the end).
set -euo pipefail
DIR="$(mktemp -d)/hook-dedup-repro"
mkdir -p "$DIR/.claude"
cd "$DIR"
# 1. A tracer hook that logs one line per execution and hashes its stdin.
cat > .claude/tracer.sh <<'EOF'
#!/bin/bash
payload=$(cat)
sha=$(printf '%s' "$payload" | shasum -a 256 | cut -c1-12)
echo "$(date +%s) pid=$$ sha=$sha" >> "$(dirname "$0")/tracer.log"
echo "tracer-ok"
EOF
chmod +x .claude/tracer.sh
# 2. Register it once in project settings.
cat > .claude/settings.json <<'EOF'
{
"hooks": {
"UserPromptSubmit": [{"hooks": [{"type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/tracer.sh"}]}]
}
}
EOF
# 3. A local marketplace with one plugin registering the BYTE-IDENTICAL command.
mkdir -p market/.claude-plugin market/dup-plugin/.claude-plugin market/dup-plugin/hooks
cat > market/.claude-plugin/marketplace.json <<'EOF'
{"name": "hook-dedup-repro", "owner": {"name": "repro"},
"plugins": [{"name": "dup-plugin", "source": "./dup-plugin", "description": "registers the same hook command as project settings"}]}
EOF
cat > market/dup-plugin/.claude-plugin/plugin.json <<'EOF'
{"name": "dup-plugin", "version": "0.0.1", "description": "hook dedup repro"}
EOF
cat > market/dup-plugin/hooks/hooks.json <<'EOF'
{
"hooks": {
"UserPromptSubmit": [{"hooks": [{"type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/tracer.sh"}]}]
}
}
EOF
cleanup() {
claude plugin uninstall dup-plugin >/dev/null 2>&1 || true
claude plugin marketplace remove hook-dedup-repro >/dev/null 2>&1 || true
}
trap cleanup EXIT
# 4. Baseline: settings registration only -> expect 1 execution.
rm -f .claude/tracer.log
echo 'Reply with the single word: baseline' | claude -p --model haiku >/dev/null
echo "--- baseline (settings only): $(wc -l < .claude/tracer.log | tr -d ' ') execution(s)"
cat .claude/tracer.log
# 5. Add the plugin registering the identical command -> expect dedup to 1, observe 2.
claude plugin marketplace add ./market >/dev/null
claude plugin install dup-plugin@hook-dedup-repro >/dev/null
rm -f .claude/tracer.log
echo 'Reply with the single word: duplicated' | claude -p --model haiku >/dev/null
echo "--- settings + plugin (identical command): $(wc -l < .claude/tracer.log | tr -d ' ') execution(s)"
cat .claude/tracer.log
Output on 2.1.206:
--- baseline (settings only): 1 execution(s)
1783645723 pid=10458 sha=68e162ad8830
--- settings + plugin (identical command): 2 execution(s)
1783645728 pid=27026 sha=a13a4fa28f06
1783645728 pid=27036 sha=a13a4fa28f06
Why (from reading the bundled dispatch code, 2.1.206)
getMatchingHooks does deduplicate command hooks — via a Map keyed by a helper that computes:
`${entry.pluginRoot ?? entry.skillRoot ?? ""}\x00${shell}\x00${command}\x00${args}...`
Settings-sourced entries carry no pluginRoot, so their key prefix is ""; plugin-sourced entries are stamped with their plugin's install path by the plugin-hooks loader. The identical command string therefore produces different Map keys for settings vs plugin (and for plugin A vs plugin B), so all copies survive the "unique hooks" pass and are all dispatched.
The root prefix looks deliberate as far as it protects ${CLAUDE_PLUGIN_ROOT}-relative commands — two plugins shipping the same template string must NOT be collapsed (that was the bug family of #16954 / #29724 / #23281 / #21659, where dedup keyed on the raw string dropped distinct plugin hooks). But as implemented it also defeats the documented dedup for commands that don't reference any per-plugin variable, e.g. a hook framework CLI invoked identically from settings and from plugins (uvx capt-hook run PostToolUse in our case).
A dedup key based on the command string after resolving per-entry variables (${CLAUDE_PLUGIN_ROOT} etc.) would fix both directions: identical resolved commands collapse, template-identical but differently-rooted commands stay distinct. Alternatively, if per-source execution is intended behavior, the docs sentence above should be scoped ("...deduplicated within each configuration source").
Impact
Side-effectful hooks run N× concurrently with identical payloads. In our setup a PostToolUse hook that mirrors memory-file writes into a git-backed notes store ran 3× (project settings + two plugins that each ensure the hook framework is registered) and created triplicate records; every other hook (context injection, guards) also ran 2-3×, multiplying latency and token noise.
Related (not duplicates)
- #16954, #29724, #23281, #21659 — the opposite failure mode of the same subsystem (over-eager dedup on raw template strings dropping distinct plugin hooks), apparently addressed by the pluginRoot-prefixed key that causes this report.
- #10871, #24115, #64022 — other multi-execution reports with different root causes (same file double-loaded; marketplace+cache double-load; multi-agent re-registration).
This report is specifically: no dedup across settings ↔ plugin (and plugin ↔ plugin) sources for byte-identical commands on 2.1.2xx, contradicting the docs.