PreToolUse hooks intermittently not invoked at all (VSCode extension host, Windows)

Open 💬 1 comment Opened Jul 10, 2026 by JPBuildsRoot

Summary

PreToolUse hooks configured in .claude/settings.json are, in a live session running as a VSCode native extension, invoked for only a small fraction of matching tool calls — sometimes not at all across a run of several consecutive calls. This is not a hook-script bug: the exact same hook script, invoked directly (node script.cjs < payload.json), behaves correctly 100% of the time.

Environment

  • Host: Claude Code running as a VSCode native extension (per the extension's own system prompt: "You are running inside a VSCode native extension environment").
  • OS: Windows 11 Pro (build 10.0.26200).
  • Shell used for tool calls: Git Bash (MSYS2, MINGW64_NT-10.0-26200, bash 3.6.7).
  • Node: v22.22.2 (fnm-managed) for most tooling; system Node v24.15.0 also present — both checked, hook behaves identically under either.
  • Claude Code version: not determinable from within the sandboxed tool environment itself (claude --version isn't on the Bash tool's PATH in this host) — will confirm via the extension's own version display.
  • Repo: a large, real-world monorepo (~100k+ files) with hooks wired via .claude/settings.json (hooks.PreToolUse[], matcher patterns like "Bash|PowerShell", "Agent", "Edit|Write", each command pointing at a .cjs script).
  • Permission mode: bypassPermissions + skipDangerousModePermissionPrompt configured, if relevant to hook dispatch timing/order.

Hook contract (for reference)

Hook scripts read a JSON payload on stdin (tool_name, tool_input, cwd, session_id, ...), and communicate a decision purely via process exit code: 0 = allow, 2 = block (with a message on stderr surfaced back to the model). Our hooks are written fail-open (any internal script error exits 0), so a hook script bug can't explain a false "allow" via that path — the question is whether the harness invokes the script at all.

Steps to reproduce

  1. Configure a PreToolUse hook (matcher Bash|PowerShell) that blocks a specific command shape, e.g. git commit (exit 2 + stderr message on match, exit 0 otherwise).
  2. Confirm directly that the hook script is correct: pipe a matching payload into it by hand (node your-hook.cjs < payload.json) — it should reliably exit 2.
  3. In a live Claude Code session (same repo, same settings), have the model run the exact matching Bash command.
  4. Observe whether the command is actually blocked.

What we observed

We ran step 3 eight times in one live session, varying the shape (a compound command like cd <path> && git commit -m "...", several times; and once a bare, minimal command with zero compound structure — plain git commit -m "...", run directly against a real repo). 0/8 were blocked. Every one went straight through to git itself (each failing only for an unrelated, mundane reason — not a git repo, or nothing staged — never for being blocked by the hook).

Step 2 confirms the hook logic itself is correct — same script, piped directly, blocks every time. The bare-command case in step 3 argues against a pure "cold-start latency causes an upstream timeout to give up" explanation: it's the cheapest possible thing to evaluate (no compound-command parsing, trivial regex), and it still wasn't invoked. Directly-measured process-spawn latency for this hook script is only ~38–41ms (pure node-to-node) to ~130–250ms (git-bash-wrapped) — both far under any timeout we'd expect to matter.

Note on rigor: an earlier pass at reproducing this included a live test that turned out to be confounded by a bug in our own hook's regex (it didn't match the exact command shape we tested, independent of anything Claude Code did). We found and fixed that bug, then re-ran the reproduction above specifically to rule it out as an explanation — the 0/8 result is from after that fix, with the hook logic independently proven correct via direct invocation. We're flagging this so you know the evidence above has already had that confound removed, not to suggest the original report was solid — it wasn't, and we caught it.

Earlier in the same investigation (before finding that regex bug), we also ran two independent experiments with a minimal probe hook (unconditionally logs an invocation, no decision logic to have a bug of its own): 0/7 fires in one run, 0/4 in a second. Including for completeness/lower confidence, since the reproduction above supersedes it as the result we'd stand behind.

Hypotheses we considered and ruled out:

  • Wrong interpreter / bad shebang handling on Windows — ruled out: one confirmed-correct invocation earlier in the investigation showed the correct node.exe interpreter in argv.
  • Cold-start latency alone exceeding an internal timeout — weakened by the low measured spawn latency and the bare-command miss.
  • Only one hook firing when multiple hooks share a matcher — ruled out: zero hooks fired across three simultaneously-instrumented same-matcher hooks in one experiment.

PostToolUse hooks and native git hooks (a husky pre-push hook, invoked by git itself, not Claude Code) are reported reliable across repeated prior use in the same session/repo — not re-verified with the same rigor as the reproduction above, but suggestive that this may be specific to PreToolUse (or hooks gated on tool-call matchers generally) rather than the hook subsystem as a whole.

What we tried on our end, for context

We built a persistent background daemon so our hook scripts become thin clients of a warm, already-running process, removing per-invocation cold start as a possible contributing factor. It's fully implemented and tested. It does not fix this: a daemon can only make a hook faster once the harness actually invokes it, and the reproduction above shows the harness sometimes doesn't invoke it at all — regardless of how cheap the hook is to run. That's not something we can fix from the hook side.

Ask

  • Visibility into (or documentation of) what causes a configured PreToolUse hook to sometimes not be invoked at all in the VSCode extension host.
  • Whether hook dispatch failures/skips are logged anywhere accessible to the extension host or end user (we found none).
  • Whether this is a known issue, and if there's a recommended mitigation (a different hook lifecycle event that's more reliable, or a configuration flag we're missing).

Happy to share the exact hook configuration, more granular timing data, or run further instrumented experiments on request.

View original on GitHub ↗

This issue has 1 comment on GitHub. Read the full discussion on GitHub ↗