DEV Community

Your Claude Code hook doesn't fire on Windows, and nothing tells you why

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. On macOS it works. On Windows it does nothing. No error, no warning, no line in the transcript. It just silently isn't there.

Here's the whole path, including the wrong turn, because the wrong turn is the interesting part.

The symptom

The hook is declared like this:

{
  "hooks": {
    "SessionStart": [
      {
        "matcher": "startup|resume",
        "hooks": [
          {
            "type": "command",
            "command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/session-start.sh\"",
            "async": false
          }
        ]
      }
    ]
  }
}

Nothing wrong with it. Plugin enabled, script present, script executable. On a Mac it fires every session.

The wrong theory

First 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:

  • SessionStart:startup records: 0
  • PreToolUse:Bash records: 12

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.

That led to a reasonable theory: the matcher. startup|resume doesn't match every session source (there are four: startup, resume, clear, compact), so maybe the desktop app creates sessions through a path that never produces a matching event. So I dropped the matcher, which makes it fire on all four. Shipped it. Updated the Windows machine. Still nothing.

That'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.

The actual cause

The command starts with bash. So the question was never whether the event arrived. It's whether bash resolves.

> where.exe bash
INFO: Could not find files for the given pattern(s).

It doesn't. Meanwhile:

Check Result
Default shell (COMSPEC) C:\WINDOWS\system32\cmd.exe
Git Bash installed? Yes, at C:\Program Files\Git\bin\bash.exe
Is Git\bin on PATH? No. Only Git\cmd is, which has git.exe but not bash.exe

So Claude Code hands bash "...session-start.sh" to cmd.exe, cmd can't resolve bash, the command errors, the hook exits non-zero, and it gets dropped. Silently.

The two things that look like contradictions

Both of these made me believe bash was fine. Both are actually confirmations.

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.

A different hook does fire. My PreToolUse hook runs an echo. It works because echo is 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.

This is the default install, not an edge case

Git for Windows puts Git\cmd on PATH, not Git\bin. That's the standard install option. Adding Git\bin is the option the installer explicitly warns you about, because it puts GNU find and sort ahead of the Windows native ones and breaks other tooling.

So 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.

The fixes, including the ones that don't work

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.

Hardcode an absolute path. No. There isn't a single correct one. macOS ships bash at /bin/bash, not /usr/bin/bash. Most Linux has /usr/bin/bash. Windows has a path that varies by installer. And cmd can't resolve a POSIX path anyway, so /usr/bin/bash is meaningless there.

Use sh instead. Worse. There's no sh on Windows either, both come from the same Git Bash layer. And my scripts use set -euo pipefail, and pipefail isn't POSIX, so running them under dash (which is /bin/sh on 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.

Use node. This is the one that works. Node's Windows installer puts node on 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:

// candidates, first hit wins
const candidates = [
  'bash',                                     // on PATH (mac/linux, or Git\bin on PATH)
  '/bin/bash',                                // macOS, most Linux
  '/usr/bin/bash',                            // most Linux
  'C:\\Program Files\\Git\\bin\\bash.exe',    // default Git for Windows
  'C:\\Program Files\\Git\\usr\\bin\\bash.exe',
];

Not 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.

The actual bug

The PATH thing is a packaging quirk. The real problem is that the hook failed and told nobody. The 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.

If 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.

And if you're writing plugins with hooks: run where.exe bash on a Windows box before you assume anything.

Comments

No comments yet. Start the discussion.