Bug Description
When the same hook is defined in both .github/hooks/hooks.json and .claude/settings.json, VS Code fires the hook twice per tool call because computeHooks() in promptsServiceImpl.ts collects hooks from all DEFAULT_HOOK_FILE_PATHS and appends them by concatenation with no deduplication.
Reproduction Steps
- Create
.github/hooks/hooks.json:
{
"hooks": [
{
"event": "PreToolUse",
"matcher": "",
"hooks": [
{
"type": "command",
"command": "echo HOOK_FIRED >> /tmp/hook-log.txt"
}
]
}
]
}
- Create
.claude/settings.json:
{
"hooks": {
"PreToolUse": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "echo HOOK_FIRED >> /tmp/hook-log.txt"
}
]
}
]
}
}
- Start a Copilot Chat session and invoke any tool
- Check
/tmp/hook-log.txt — it has two HOOK_FIRED entries from a single tool call
Why This Matters
Cross-tool projects (e.g., dotnet/runtime#125889) need hooks in both paths to cover all AI coding tools:
.github/hooks/ — read by Copilot CLI, VS Code, Gemini CLI
.claude/settings.json — read by Claude Code, VS Code
Since VS Code reads both paths, users who maintain cross-tool compatibility get double-fired hooks. The chat.useClaudeHooks setting can work around this, but it also disables any Claude-only hooks the project may have.
Root Cause
In promptsServiceImpl.ts, computeHooks() (line ~1332-1342) iterates all discovered hook files and pushes every hook command into the collectedHooks Map without checking for duplicates:
for (const [hookType, { hooks: commands }] of hooks) {
for (const command of commands) {
let bucket = collectedHooks.get(hookType);
if (!bucket) {
bucket = [];
collectedHooks.set(hookType, bucket);
}
bucket.push(command); // No dedup check
}
}
The 5 discovery paths in DEFAULT_HOOK_FILE_PATHS (promptFileLocations.ts line 211-217) all feed into this same loop.
Suggested Fix
Add a deduplication step after all hooks are collected (line ~1365, before converting to immutable ChatRequestHooks). Two hooks could be considered identical if they resolve to the same effective command (via resolveEffectiveCommand()), cwd, env, and timeout. First occurrence wins, preserving the path priority order.
I am happy to submit a PR for this if the approach sounds right.
Cross-Tool Behavior Comparison
| Scenario |
Copilot CLI |
VS Code |
Claude Code |
Hook in .github/hooks/ only |
✅ fires |
✅ fires |
❌ not read |
Hook in .claude/settings.json only |
❌ not read |
✅ fires |
✅ fires |
| Hook in both files |
✅ once |
⚠️ twice |
✅ once |
Environment
- VS Code: Insiders (latest,
main branch at 896c13e)
- OS: Linux
- Tested with: Copilot CLI v1.0.10, Claude Code v2.1.81
- Source analysis of
hookSchema.ts, promptsServiceImpl.ts, promptFileLocations.ts
Bug Description
When the same hook is defined in both
.github/hooks/hooks.jsonand.claude/settings.json, VS Code fires the hook twice per tool call becausecomputeHooks()inpromptsServiceImpl.tscollects hooks from allDEFAULT_HOOK_FILE_PATHSand appends them by concatenation with no deduplication.Reproduction Steps
.github/hooks/hooks.json:{ "hooks": [ { "event": "PreToolUse", "matcher": "", "hooks": [ { "type": "command", "command": "echo HOOK_FIRED >> /tmp/hook-log.txt" } ] } ] }.claude/settings.json:{ "hooks": { "PreToolUse": [ { "matcher": "", "hooks": [ { "type": "command", "command": "echo HOOK_FIRED >> /tmp/hook-log.txt" } ] } ] } }/tmp/hook-log.txt— it has twoHOOK_FIREDentries from a single tool callWhy This Matters
Cross-tool projects (e.g., dotnet/runtime#125889) need hooks in both paths to cover all AI coding tools:
.github/hooks/— read by Copilot CLI, VS Code, Gemini CLI.claude/settings.json— read by Claude Code, VS CodeSince VS Code reads both paths, users who maintain cross-tool compatibility get double-fired hooks. The
chat.useClaudeHookssetting can work around this, but it also disables any Claude-only hooks the project may have.Root Cause
In
promptsServiceImpl.ts,computeHooks()(line ~1332-1342) iterates all discovered hook files and pushes every hook command into thecollectedHooksMap without checking for duplicates:The 5 discovery paths in
DEFAULT_HOOK_FILE_PATHS(promptFileLocations.tsline 211-217) all feed into this same loop.Suggested Fix
Add a deduplication step after all hooks are collected (line ~1365, before converting to immutable
ChatRequestHooks). Two hooks could be considered identical if they resolve to the same effective command (viaresolveEffectiveCommand()), cwd, env, and timeout. First occurrence wins, preserving the path priority order.I am happy to submit a PR for this if the approach sounds right.
Cross-Tool Behavior Comparison
.github/hooks/only.claude/settings.jsononlyEnvironment
mainbranch at896c13e)hookSchema.ts,promptsServiceImpl.ts,promptFileLocations.ts