Skip to content

fix(hooks): invoke viz hooks in shell form so Windows uses Git Bash (#27) - #29

Open
ZayanKhan-12 wants to merge 3 commits into
datadog-labs:mainfrom
ZayanKhan-12:fix/issue-27-windows-hook-invocation
Open

ZayanKhan-12 wants to merge 3 commits into
datadog-labs:mainfrom
ZayanKhan-12:fix/issue-27-windows-hook-invocation

Conversation

@ZayanKhan-12

@ZayanKhan-12 ZayanKhan-12 commented Sep 14, 2026

Copy link
Copy Markdown

Fixes #27.

The fix

hooks/hooks.json, two lines:

-"hooks": [{ "type": "command", "command": "bash", "args": ["${CLAUDE_PLUGIN_ROOT}/viz/hooks/session_end.sh"] }]
+"hooks": [{ "type": "command", "command": "\"${CLAUDE_PLUGIN_ROOT}\"/viz/hooks/session_end.sh" }]

Passing args is what puts a hook in exec form, where Claude Code resolves command against PATH and spawns it directly with no shell. That is why bare bash becomes the WSL launcher on Windows, exactly as the report diagnosed.

Dropping args switches to shell form, where the string goes to sh -c on macOS and Linux and to Git Bash on Windows — which resolves the plugin path correctly. This is the form the plugin docs show for ${CLAUDE_PLUGIN_ROOT} script hooks.

Shell form executes the file itself rather than handing it to an interpreter, so both scripts go to mode 755. At 644 they exit 126 with "Permission denied" — verified, not assumed.

forward.sh has the same bug

The report is about SessionEnd, but PostToolUse carried the byte-identical exec-form invocation and is just as broken on Windows. It only escaped notice because it fires on visualization tool calls rather than on every exit.

It needed the opposite treatment from session_end.sh, though. Off macOS forward.sh is not a no-op — it calls bail unsupported_os, which is what tells the model the chart is available at the sandbox URL. So it has to keep working on Windows, not stop being registered. Both hooks are fixed the same way; only session_end.sh is a true no-op.

On "registered unconditionally"

I could not fix cause 1 as described, and I think the premise doesn't hold — flagging it rather than quietly skipping it.

There is no OS conditional for hook registration. The if field takes permission-rule syntax and, per the hooks reference, "Only evaluated on tool events: PreToolUse, PostToolUse, PostToolUseFailure, PermissionRequest, and PermissionDenied. On other events, a hook with if set never runs." Putting if on SessionEnd would disable the hook on macOS too — the one platform it exists for.

So a macOS-only hook can only be made cheap and silent, not unregistered. With cause 2 fixed it is: the shell starts, uname misses Darwin, the script exits 0 with no output. Cause 1 stops being a bug once cause 2 is fixed. If you'd rather not spawn a shell at all on non-macOS, that needs a Claude Code-side feature.

Verified on Windows

.github/workflows/hooks.yaml runs the suite on macOS, Linux and Windows. All four jobs green.

The job that matters is test (windows-latest, native plugin root). Git Bash reports $PWD in Unix form, so an ordinary Windows job would never see the path shape that caused this bug. That job injects CLAUDE_PLUGIN_ROOT as GitHub's native workspace path instead, so the hook receives a real D:\... root:

shell: C:\Program Files\Git\bin\bash.EXE --noprofile --norc -e -o pipefail {0}
env:
  CLAUDE_PLUGIN_ROOT: D:\a\claude-code-plugin\claude-code-plugin

hooks run clean on this host
  ok   SessionEnd exits 0 on MINGW64_NT-10.0-26100
  ok   SessionEnd prints nothing

the non-macOS no-op is reachable and silent
  ok   SessionEnd is a silent no-op when uname reports MINGW64_NT-10.0
  ok   SessionEnd is a silent no-op when uname reports MSYS_NT-10.0
  ok   SessionEnd is a silent no-op when uname reports CYGWIN_NT-10.0
  ok   SessionEnd is a silent no-op when uname reports Linux

PostToolUse still falls back off macOS
  ok   PostToolUse exits 0 off macOS
  ok   PostToolUse returns the sandbox-URL fallback off macOS

14 passed, 0 failed

Full run: https://github.com/ZayanKhan-12/claude-code-plugin/actions/runs/34900090384

Worth reporting: the first Windows run went red, and it caught a real bug — in the test harness, while every behavioural check already passed. The harness recovered the script path with sed, and sed treats backslashes in the replacement as escapes, so D:\a\... became D:<BEL>.... That is the same class of bug as #27 itself, reproduced inside the test written to catch it. Fixed in the third commit by substituting with bash parameter expansion, and by resolving the file checks against the checkout rather than the injected root — path shape is what the behavioural runs are for. The mode assertion now reads git ls-files -s instead of testing -x, because git doesn't materialise permission bits on Windows, so -x says nothing there while the recorded mode is what actually ships.

Tests

tests/hooks_test.sh — POSIX-ish bash, jq the only dependency, no network, never opens the ddviz socket, runs with DO_NOT_TRACK=1 so a local run can't post to intake.

Run against the pre-fix config it reports 11 passed, 1 failed, and the one failure is the exec-form check. That is deliberate. The harness understands both hook forms, so it invokes the old config the way Claude Code actually would; macOS was never broken and the suite says so instead of manufacturing a failure. The single red line is the real defect.

CLAUDE.md

Added, since there wasn't one. The part that matters is the hook rule above — shell form, mode 755, and why if can't gate SessionEnd — so this doesn't get reintroduced. It also records what I had to work out from the code and would have wanted stated:

  • forward.sh's off-macOS bail is load-bearing, not dead code; don't "optimize" it into an early exit.
  • forward.sh's template.ddviz_telemetry.sh fallback is live in the internal source tree, so it looks dead here but isn't.
  • Skill scripts are a different mechanism — SKILL.md runs them through the Bash tool, which already uses Git Bash on Windows. They correctly keep bash "<path>" and mode 644, and this change deliberately leaves them alone.
  • DO_NOT_TRACK=1 belongs in anything exercising these scripts.

Noted from CONTRIBUTING that this repo is regenerated from an internal source, so I kept the fix itself to two lines and a mode change. The tests, CI and CLAUDE.md are separable — happy to split them into their own PR if that's easier to carry across, or drop the CI workflow if you'd rather own that yourself.

🤖 Generated with Claude Code

https://claude.ai/code/session_01UdFDRJ6HXM45hua8JnXomE

…atadog-labs#27)

The SessionEnd hook failed on every exit on Windows, printing:

  SessionEnd hook [bash ${CLAUDE_PLUGIN_ROOT}/viz/hooks/session_end.sh]
  failed: Hook cancelled

Passing `args` puts a hook in exec form, where Claude Code resolves
`command` against PATH and spawns it with no shell. On a default Windows
install `bash` on PATH is C:\Windows\System32\bash.exe -- the WSL
launcher, not Git Bash. It consumes the Windows ${CLAUDE_PLUGIN_ROOT} as
escape sequences, so the script is never found and the hook fails even
though its only job off macOS is to do nothing.

Drop `args` and name the script in the command string instead. Shell form
hands that string to `sh -c` on macOS and Linux and to Git Bash on
Windows, which resolves the path correctly. Shell form executes the file
directly, so both scripts become mode 755; at 644 they would exit 126.

This covers forward.sh too. It had the identical exec-form invocation and
is just as broken on Windows -- it only escaped notice because it fires on
visualization tool calls rather than on every exit. Unlike session_end.sh
it has real work to do off macOS (telling the model the chart is at the
sandbox URL), so it has to keep working, not stop being registered.

The issue also asks for the hook not to be registered off macOS. That is
not expressible: `if` takes permission-rule syntax and is only evaluated
on tool events, so on SessionEnd a hook with `if` set never runs at all.
A macOS-only hook can only be made cheap and silent, which it now is.

tests/hooks_test.sh covers the wiring: hooks.json parses, no hook uses a
bare interpreter in exec form, every referenced script exists and is
executable, and both hooks exit 0 and stay silent with `uname` stubbed to
MINGW64_NT, MSYS_NT, CYGWIN_NT and Linux. Against the pre-fix config it
fails only the exec-form check -- macOS was never broken, and the suite
says so rather than manufacturing a failure.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UdFDRJ6HXM45hua8JnXomE
@ZayanKhan-12
ZayanKhan-12 requested a review from a team as a code owner September 14, 2026 21:35
datadog-labs#27 is a Windows-only failure, so the suite is only worth much if it
actually runs there. The matrix job covers all three platforms; the
windows-latest job runs it under Git Bash, which is the shell Claude Code
uses for shell-form hooks on Windows.

A second Windows job injects CLAUDE_PLUGIN_ROOT as ${{ github.workspace }},
a native backslash path. Git Bash reports $PWD in Unix form, so without
this the Windows job would never see the path shape that caused the bug --
a Windows path reaching a hook is the whole failure mode.

Scoped by paths so it only fires on hook, viz or test changes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UdFDRJ6HXM45hua8JnXomE
The first CI run went red on both Windows jobs while every behavioural
check passed. The failures were in the harness, not the hooks.

Two problems, both in how the suite recovered a script path from the
command string:

sed treats backslashes in the *replacement* as escapes, so substituting a
native ${CLAUDE_PLUGIN_ROOT} of D:\a\... produced D:^G^Laude-code-plugin
-- \a became BEL. That is the same class of bug as datadog-labs#27 itself, reproduced
inside the test written to catch it. Bash parameter expansion substitutes
literally, so use that.

The existence and mode checks were also resolving against the injected
plugin root, which conflates two different questions. Whether a Windows
path survives the hop into the hook is what the behavioural runs cover;
these two checks are about the files. Resolve them against the checkout
instead, and take the mode from `git ls-files -s` rather than a working
tree -x test: git does not materialise permission bits on Windows, so -x
says nothing there, while the recorded mode is what actually ships.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UdFDRJ6HXM45hua8JnXomE
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

SessionEnd viz hook fails on every exit on Windows (registered unconditionally, and bare bash resolves to WSL)

2 participants