{"slug": "your-claude-code-hook-doesn-t-fire-on-windows-and-nothing-tells-you-why", "title": "Your Claude Code hook doesn't fire on Windows, and nothing tells you why", "summary": "A developer discovered that Claude Code hooks using bash silently fail on Windows because Git Bash's bash.exe is not on the system PATH by default. The hook runner uses the OS shell and system PATH, unlike the in-session Bash tool which finds Git Bash through a different resolution path. Adding Git\\bin to PATH fixes the issue, but the default Windows setup causes all bash-invoking hooks to fail without any error or warning.", "body_md": "One of my plugins has a SessionStart hook. It loads the active brand kit into context, so a plain request already knows your colors and fonts without you re-explaining them every session.\n\nOn macOS it works. On Windows it does nothing. No error, no warning, no line in the transcript. It just silently isn't there.\n\nHere's the whole path, including the wrong turn, because the wrong turn is the interesting part.\n\nThe hook is declared like this:\n\n```\n{\n  \"hooks\": {\n    \"SessionStart\": [\n      {\n        \"matcher\": \"startup|resume\",\n        \"hooks\": [\n          {\n            \"type\": \"command\",\n            \"command\": \"bash \\\"${CLAUDE_PLUGIN_ROOT}/hooks/session-start.sh\\\"\",\n            \"async\": false\n          }\n        ]\n      }\n    ]\n  }\n}\n```\n\nNothing wrong with it. Plugin enabled, script present, script executable. On a Mac it fires every session.\n\nFirst I checked whether the hook was being invoked at all. Claude Code stores a record when a hook fires, so you can audit it. In the failing session:\n\n`SessionStart:startup`\n\nrecords: `PreToolUse:Bash`\n\nrecords: So SessionStart never ran, but a different hook ran twelve times. And a sibling session on the same app version *did* have a SessionStart record carrying the exact line my plugin emits. The hook works. It just wasn't invoked here.\n\nThat led to a reasonable theory: the matcher. `startup|resume`\n\ndoesn't match every session source (there are four: `startup`\n\n, `resume`\n\n, `clear`\n\n, `compact`\n\n), so maybe the desktop app creates sessions through a path that never produces a matching event.\n\nSo I dropped the matcher, which makes it fire on all four. Shipped it. Updated the Windows machine.\n\nStill nothing.\n\nThat's the useful part. The theory was plausible, it explained the evidence, and it was wrong. Zero records is equally consistent with \"the event never arrived\" and \"the hook ran and failed.\" I picked one without testing the other.\n\nThe command starts with `bash`\n\n. So the question was never whether the event arrived. It's whether `bash`\n\nresolves.\n\n```\n> where.exe bash\nINFO: Could not find files for the given pattern(s).\n```\n\nIt doesn't. Meanwhile:\n\n| Check | Result |\n|---|---|\nDefault shell (`COMSPEC` ) |\n`C:\\WINDOWS\\system32\\cmd.exe` |\n| Git Bash installed? | Yes, at `C:\\Program Files\\Git\\bin\\bash.exe`\n|\nIs `Git\\bin` on PATH? |\nNo. Only `Git\\cmd` is, which has `git.exe` but not `bash.exe`\n|\n\nSo Claude Code hands `bash \"...session-start.sh\"`\n\nto cmd.exe, cmd can't resolve bash, the command errors, the hook exits non-zero, and it gets dropped. Silently.\n\nBoth of these made me believe bash was fine. Both are actually confirmations.\n\n**The Bash tool works.** I can run bash commands in the session all day. But the tool finds its own Git Bash through a different resolution path than the hook runner, which uses the OS shell and the system PATH. \"bash works in my session\" tells you nothing about whether the hook can find it.\n\n**A different hook does fire.** My PreToolUse hook runs an `echo`\n\n. It works because `echo`\n\nis a cmd built-in and needs no bash at all. Only the bash-invoking hooks fail, which is all of them, in every plugin I have.\n\nGit for Windows puts `Git\\cmd`\n\non PATH, not `Git\\bin`\n\n. That's the standard install option. Adding `Git\\bin`\n\nis the option the installer explicitly warns you about, because it puts GNU `find`\n\nand `sort`\n\nahead of the Windows native ones and breaks other tooling.\n\nSo the default Windows setup is: bash is installed, and bash is not on PATH. Every plugin whose hooks shell out to bash silently does nothing for a large share of Windows users, with no error anywhere.\n\n**Add Git\\bin to PATH.** Works. It's also the thing Git warns about, and it's per machine, so it fixes you and nobody else.\n\n**Hardcode an absolute path.** No. There isn't a single correct one. macOS ships bash at `/bin/bash`\n\n, not `/usr/bin/bash`\n\n. Most Linux has `/usr/bin/bash`\n\n. Windows has a path that varies by installer. And cmd can't resolve a POSIX path anyway, so `/usr/bin/bash`\n\nis meaningless there.\n\n**Use sh instead.** Worse. There's no\n\n`sh`\n\non Windows either, both come from the same Git Bash layer. And my scripts use `set -euo pipefail`\n\n, and pipefail isn't POSIX, so running them under dash (which is `/bin/sh`\n\non Debian and Ubuntu) breaks them immediately. That trades a Windows problem for a Linux problem.**Put the condition in the command.** Tempting, but the hook command runs in cmd.exe on Windows and sh on Unix, and their conditional syntax has nothing in common. There's no single string that checks paths in both.\n\n**Use node.** This is the one that works. Node's Windows installer puts `node`\n\non PATH by default, which is exactly what Git's installer doesn't do for bash. So either port the hook to Node, or keep the bash scripts and add a small Node launcher that walks candidate locations and runs whichever exists:\n\n``` js\n// candidates, first hit wins\nconst candidates = [\n  'bash',                                   // on PATH (mac/linux, or Git\\bin on PATH)\n  '/bin/bash',                              // macOS, most Linux\n  '/usr/bin/bash',                          // most Linux\n  'C:\\\\Program Files\\\\Git\\\\bin\\\\bash.exe',  // default Git for Windows\n  'C:\\\\Program Files\\\\Git\\\\usr\\\\bin\\\\bash.exe',\n];\n```\n\nNot a guarantee. If node isn't on the hook runner's PATH either, you're back where you started. But it drops the dependency that's broken by default.\n\nThe PATH thing is a packaging quirk. The real problem is that **the hook failed and told nobody.**\n\nThe command errored. The plugin silently lost a feature. Nothing in the transcript, no warning, no log line, no exit code surfaced to the user or to me. From the outside it's indistinguishable from a hook that was never registered, which is exactly why I burned a day on the wrong theory.\n\nIf you're building a harness with hooks: surface the failures. A hook that dies quietly is worse than one that doesn't exist, because at least a missing hook is visible.\n\nAnd if you're writing plugins with hooks: run `where.exe bash`\n\non a Windows box before you assume anything.", "url": "https://wpnews.pro/news/your-claude-code-hook-doesn-t-fire-on-windows-and-nothing-tells-you-why", "canonical_source": "https://dev.to/localplugins/your-claude-code-hook-doesnt-fire-on-windows-and-nothing-tells-you-why-2dg6", "published_at": "2026-07-16 13:19:30+00:00", "updated_at": "2026-07-16 13:41:12.275068+00:00", "lang": "en", "topics": ["developer-tools", "large-language-models"], "entities": ["Claude Code", "Git Bash", "Windows", "macOS", "Git for Windows"], "alternates": {"html": "https://wpnews.pro/news/your-claude-code-hook-doesn-t-fire-on-windows-and-nothing-tells-you-why", "markdown": "https://wpnews.pro/news/your-claude-code-hook-doesn-t-fire-on-windows-and-nothing-tells-you-why.md", "text": "https://wpnews.pro/news/your-claude-code-hook-doesn-t-fire-on-windows-and-nothing-tells-you-why.txt", "jsonld": "https://wpnews.pro/news/your-claude-code-hook-doesn-t-fire-on-windows-and-nothing-tells-you-why.jsonld"}}