fix: drop bash-only `exec` from hooks so they run under PowerShell (#527, #569) (#572)

The three shared `command` fields in claude-codex-hooks.json used
`exec node "..."`. `exec` is a bash/zsh builtin with no PowerShell
equivalent. Some hosts run the `command` field through PowerShell on
Windows regardless of the PowerShell-native `commandWindows` field:
VS Code Copilot always does (it never reads `commandWindows`), and
native Claude Code launched from Git Bash was reported doing the same
(#569). PowerShell then fails on the first token with
CommandNotFoundException, so every SessionStart / SubagentStart /
UserPromptSubmit hook errors out on Windows.

Plain `node "..."` runs natively in both bash and PowerShell. Dropping
`exec` only gives up the POSIX optimization of replacing the wrapper
shell with node; the wrapper-process pileup that #461 added `exec` to
avoid is already handled by each hook's stdin self-exit guard
(#443/#477), so there is no regression on Codex/zsh.

Updated the hooks-windows regression test to assert the shell-agnostic
form instead of requiring `exec`.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sandeep pandey 2026-07-10 04:36:07 +05:30 committed by GitHub
parent 523e9dc051
commit 2ba0262111
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 15 additions and 5 deletions

View File

@ -6,7 +6,7 @@
"hooks": [
{
"type": "command",
"command": "exec node \"${CLAUDE_PLUGIN_ROOT}/hooks/ponytail-activate.js\"",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/ponytail-activate.js\"",
"commandWindows": "if (Get-Command node -ErrorAction SilentlyContinue) { node \"$env:CLAUDE_PLUGIN_ROOT\\hooks\\ponytail-activate.js\" }",
"timeout": 5,
"statusMessage": "Loading ponytail mode..."
@ -19,7 +19,7 @@
"hooks": [
{
"type": "command",
"command": "exec node \"${CLAUDE_PLUGIN_ROOT}/hooks/ponytail-subagent.js\"",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/ponytail-subagent.js\"",
"commandWindows": "if (Get-Command node -ErrorAction SilentlyContinue) { node \"$env:CLAUDE_PLUGIN_ROOT\\hooks\\ponytail-subagent.js\" }",
"timeout": 5,
"statusMessage": "Loading ponytail mode..."
@ -32,7 +32,7 @@
"hooks": [
{
"type": "command",
"command": "exec node \"${CLAUDE_PLUGIN_ROOT}/hooks/ponytail-mode-tracker.js\"",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/ponytail-mode-tracker.js\"",
"commandWindows": "if (Get-Command node -ErrorAction SilentlyContinue) { node \"$env:CLAUDE_PLUGIN_ROOT\\hooks\\ponytail-mode-tracker.js\" }",
"timeout": 5,
"statusMessage": "Tracking ponytail mode..."

View File

@ -53,13 +53,23 @@ test('shared hook commands avoid POSIX-only guard syntax', () => {
}
});
test('shared hook commands exec node instead of leaving a wrapper shell behind', () => {
// Issue #527 / #569: the shared `command` field must be shell-agnostic. `exec`
// is a bash/zsh builtin with no PowerShell equivalent, but some hosts run
// `command` through PowerShell on Windows regardless of the commandWindows
// field — VS Code Copilot always does (it never reads commandWindows), and
// native Claude Code launched from Git Bash was seen doing the same. `exec
// node ...` then dies on its first token with CommandNotFoundException, so
// every hook fails on Windows. Plain `node ...` runs natively in both bash and
// PowerShell. The wrapper-process pileup that #461 originally used `exec` to
// avoid is handled separately by each hook's stdin self-exit guard (#443/#477).
test('shared hook commands are shell-agnostic (no bash-only exec prefix)', () => {
const commands = commandHooks()
.map((h) => h.command)
.filter(Boolean);
assert.ok(commands.length > 0, 'expected at least one shared command entry');
for (const cmd of commands) {
assert.match(cmd, /^exec node\s+/, `command must replace the shell with node: ${cmd}`);
assert.doesNotMatch(cmd, /(^|\s)exec\s/, `command must not use the bash-only 'exec' builtin (breaks under PowerShell): ${cmd}`);
assert.match(cmd, /^node\s+/, `command must invoke node directly so it runs in both bash and PowerShell: ${cmd}`);
assert.doesNotMatch(cmd, /;\s*exit 0$/, `command must not leave a shell wrapper waiting on node: ${cmd}`);
}
});