Summary
Every shell-form hook row (no args key) is passed to a shell before its own interpreter starts: sh -c on macOS and Linux, Git Bash on Windows (hooks doc, "Exec form and shell form"). Exec form (an args array, even empty) spawns the executable directly with no shell and substitutes ${CLAUDE_PLUGIN_ROOT} into command and each args element as plain strings. On this fleet 51 of the 52 always-on rows measured by the hook-performance program are shell form; only context-budget's node row is exec form. That row was the cheapest per-tool-call hook in the final run (81 ms against 1,360 ms and up for the bash-scripted rows), though it also does the least work, so the shell spawn's share is unmeasured.
Why it is bigger than the shebang hop (#3684)
Two spawns disappear per fire, not one: the Git Bash bash.exe that shell form starts, and the env the shebang adds. A peer session's host measurement adds a third cost: C:\Program Files\Git\usr\bin\bash.exe carries a signer certificate that expired 2026-05-05 and msys-2.0.dll is unsigned, and Microsoft documents that Defender real-time protection scans every launch of an unsigned binary (learn.microsoft.com, defender-endpoint troubleshoot-performance-issues). That fits the steady MsMpEng share the peer observed; exec form on a signed interpreter avoids the scan too. The machine-side remedy (a Defender performance recording, then a narrow process exclusion or a Git for Windows update with a re-signed bash) is outside this repo.
What blocks it
The hook-performance program deferred exec form on purpose (its constraint 8): upstream anthropics/claude-code#90495, open, last updated 2026-08-29, reports that on Windows exec-form args are dropped and the hook is still routed through bash.exe, crashing on stdin. The program's phase 3 live probe under claude --debug-file did show the context-budget exec-form row firing and returning ask on this host, which is one data point against the bug reproducing here, but it never exercised a bash-scripted hook in exec form.
Ask
- A live probe on this Windows host: one throwaway hook row in exec form (
"command": "bash", "args": ["${CLAUDE_PLUGIN_ROOT}/hooks/x.sh"]) under claude --debug-file, confirming the args arrive and the script reads its stdin payload. That decides whether #90495 applies.
- If it works: a paired fan-out harness run (
--runs 3, one host) on a representative row set, then a fleet sweep of hooks.json commands to exec form, one plugin per PR, every touched plugin bumped. Rows that need shell features (pipes, &&) stay shell form. The harness itself must learn to spawn exec-form rows the way Claude Code does, or its numbers will not reflect the change.
- If #90495 reproduces: subscribe to it and keep this issue open as the tracker.
Related
Summary
Every shell-form hook row (no
argskey) is passed to a shell before its own interpreter starts:sh -con macOS and Linux, Git Bash on Windows (hooks doc, "Exec form and shell form"). Exec form (anargsarray, even empty) spawns the executable directly with no shell and substitutes${CLAUDE_PLUGIN_ROOT}intocommandand eachargselement as plain strings. On this fleet 51 of the 52 always-on rows measured by the hook-performance program are shell form; only context-budget'snoderow is exec form. That row was the cheapest per-tool-call hook in the final run (81 ms against 1,360 ms and up for the bash-scripted rows), though it also does the least work, so the shell spawn's share is unmeasured.Why it is bigger than the shebang hop (#3684)
Two spawns disappear per fire, not one: the Git Bash
bash.exethat shell form starts, and theenvthe shebang adds. A peer session's host measurement adds a third cost:C:\Program Files\Git\usr\bin\bash.execarries a signer certificate that expired 2026-05-05 andmsys-2.0.dllis unsigned, and Microsoft documents that Defender real-time protection scans every launch of an unsigned binary (learn.microsoft.com, defender-endpoint troubleshoot-performance-issues). That fits the steadyMsMpEngshare the peer observed; exec form on a signed interpreter avoids the scan too. The machine-side remedy (a Defender performance recording, then a narrow process exclusion or a Git for Windows update with a re-signed bash) is outside this repo.What blocks it
The hook-performance program deferred exec form on purpose (its constraint 8): upstream anthropics/claude-code#90495, open, last updated 2026-08-29, reports that on Windows exec-form
argsare dropped and the hook is still routed throughbash.exe, crashing on stdin. The program's phase 3 live probe underclaude --debug-filedid show the context-budget exec-form row firing and returningaskon this host, which is one data point against the bug reproducing here, but it never exercised a bash-scripted hook in exec form.Ask
"command": "bash", "args": ["${CLAUDE_PLUGIN_ROOT}/hooks/x.sh"]) underclaude --debug-file, confirming the args arrive and the script reads its stdin payload. That decides whether #90495 applies.--runs 3, one host) on a representative row set, then a fleet sweep ofhooks.jsoncommands to exec form, one plugin per PR, every touched plugin bumped. Rows that need shell features (pipes,&&) stay shell form. The harness itself must learn to spawn exec-form rows the way Claude Code does, or its numbers will not reflect the change.Related
envshebang hop; subsumed by exec form on any row that adopts it)