fix: pass Haiku prompt via stdin to avoid cmd.exe quoting (v1.2.1)#10
Merged
JonyanDunh merged 1 commit intomainfrom Apr 11, 2026
Merged
fix: pass Haiku prompt via stdin to avoid cmd.exe quoting (v1.2.1)#10JonyanDunh merged 1 commit intomainfrom
JonyanDunh merged 1 commit intomainfrom
Conversation
v1.2.0 broke on native Windows Claude Code: /watchdog:start would run
forever, always returning AMBIGUOUS verdicts, regardless of the real
file-change status. Diagnosed from actual Windows log output:
askHaiku verdict: AMBIGUOUS (raw head: Hey! I'm ready to help. What
would you like to work on today? I've got context about your...)
The Haiku classifier was getting no prompt at all and falling into
Claude Code's interactive greeting mode.
## Root cause
lib/judge.js spawned the headless Haiku subprocess like this:
spawnSync('claude', ['-p', '--model', 'haiku',
'--no-session-persistence', promptText], { shell: isWindows, ... })
On Windows, `shell: true` is required because `claude` resolves to a
`.cmd` shim via PATHEXT, which means Node forwards the whole command
line through `cmd.exe`. cmd.exe's quoting rules cannot round-trip a
string containing CJK characters, newlines, embedded double quotes,
AND JSON braces — which is exactly what the classifier prompt is. The
promptText got shredded before reaching the child claude process, so
claude saw no positional prompt and read nothing from stdin (we had
it closed with `input: ''`), so it fell back to interactive greeting
mode.
On Linux/macOS (`shell: false`), Node passes the argv array straight
to execve with zero shell interpretation, so the same code path was
fine there. This is why it shipped green in CI but broke on the first
real Windows test.
## Fix
Pass the prompt via stdin instead of argv:
spawnSync('claude', ['-p', '--model', 'haiku',
'--no-session-persistence'], { input: promptText, ... })
cmd.exe now has no idea what the prompt contains — stdin is piped
through Node directly into the child process, bypassing the shell
entirely. Same code path across Linux/macOS/Windows. The `claude -p`
CLI reads stdin when no positional prompt arg is present (this is
why the original bash version needed `< /dev/null` to *avoid* stdin).
## Also in this release
- **Windows `findClaudePid` fast path**: `lib/claude-pid.js` now does
ONE PowerShell call that walks the ancestry in-process (WMI loop +
in-memory traversal) instead of one PowerShell cold-start per level.
The naive per-level walk was blowing past Claude Code's internal
hook timeout on Windows with ~20 PowerShell spawns × ~2 s each.
- **Haiku 30 s timeout**: `lib/judge.js` adds `timeout: 30000` to the
spawnSync call so a hung `claude -p` aborts cleanly instead of
dragging the hook past Claude Code's timeout and leaving the user
with a stuck loop.
- **Dropped `claudeCliAvailable()` pre-flight**: it was an extra
~2-3 s PowerShell cold-start on Windows for zero value. If the
CLI is missing, the real subprocess call below fails with ENOENT,
which we now catch and classify as `CLI_MISSING` directly.
- **Optional file logging**: set `CLAUDE_CODE_WATCHDOG_LOG_ENABLED=1`
to mirror every log call to a file (default
`os.tmpdir() + '/claude-code-watchdog.log'`, override with
`CLAUDE_CODE_WATCHDOG_LOG_FILE`). Cross-platform. A new `debug()`
level is file-only (never touches stderr) and is used liberally
through hot paths for trace-style diagnostics — including
findClaudePid timing, Haiku subprocess timing, verdict head, state
file path, and total hook latency per fire. This is exactly how
the Windows diagnosis was made.
Diagnostic trace calls added to: hooks/stop-hook.js,
scripts/setup-watchdog.js, scripts/stop-watchdog.js,
lib/claude-pid.js, lib/judge.js. All no-ops when the env var is
unset (short-circuit on the LOG_ENABLED constant at load time).
## Tests
- New `test/log.test.js` (5 tests) covers the file logging path:
default disabled, all five log levels mirror to file when enabled,
`debug()` is file-only even when enabled, multi-process append
safety, log-write failures don't crash the caller.
- Total: **80 tests, 78 active + 2 skipped-inside-Claude-Code, 0 fail**
(up from 75 in v1.2.0).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The bug
v1.2.0 was broken on native Windows Claude Code. `/watchdog:start` would run forever, always returning `AMBIGUOUS` verdicts. Diagnosed from the real Windows log (after enabling the new `CLAUDE_CODE_WATCHDOG_LOG_ENABLED=1`):
```
askHaiku verdict: AMBIGUOUS (raw head: Hey! I'm ready to help. What would you like to work on today? I've got context about your...)
```
The headless Haiku classifier was getting no prompt at all and falling into Claude Code's interactive greeting mode.
Root cause: cmd.exe quoting destroyed the prompt
`lib/judge.js` spawned Haiku like this:
```js
spawnSync('claude', ['-p', '--model', 'haiku', '--no-session-persistence', promptText], {
shell: isWindows,
})
```
On Windows, `shell: true` is required because `claude` resolves to a `.cmd` shim via PATHEXT, which means Node forwards the whole command line through cmd.exe. cmd.exe's quoting rules cannot round-trip a string containing CJK characters, newlines, embedded double quotes, AND JSON braces — which is exactly what the classifier prompt is. The `promptText` got shredded before reaching the child claude process, so claude saw no positional prompt and read nothing from stdin (we had it closed with `input: ''`), so it fell back to interactive greeting mode.
On Linux/macOS (`shell: false`), Node passes the argv array straight to `execve` with zero shell interpretation, so the same code path was fine there. This is why it shipped green in CI but broke on the first real Windows test.
The fix (one line)
Pass the prompt via stdin instead of argv:
```diff
shell: isWindows,
})
```
cmd.exe now has no idea what the prompt contains — stdin is piped through Node directly into the child process, bypassing the shell entirely. Same code path across Linux/macOS/Windows.
Also in this release
Windows `findClaudePid` fast path
`lib/claude-pid.js` now does ONE PowerShell call that walks the ancestry in-process (WMI loop + in-memory traversal) instead of one PowerShell cold-start per level. The naive per-level walk was blowing past Claude Code's internal hook timeout on Windows with ~20 PowerShell spawns × ~2 s each = 40 s per hook.
Haiku 30 s timeout
`lib/judge.js` adds `timeout: 30000` to the `spawnSync` call so a hung `claude -p` aborts cleanly instead of dragging the hook past Claude Code's internal hook timeout.
Dropped pre-flight CLI probe
Removed the `claudeCliAvailable()` check — it was an extra ~2-3 s PowerShell cold-start on Windows for zero value. If the CLI is missing, the real subprocess call below fails with `ENOENT`, which we now catch and classify as `CLI_MISSING` directly.
Optional file logging
Set `CLAUDE_CODE_WATCHDOG_LOG_ENABLED=1` to mirror every log call to a file:
A new `debug()` level is file-only (never touches stderr) and is used liberally through hot paths for trace-style diagnostics — `findClaudePid` timing, Haiku subprocess timing, verdict head, state file path, total hook latency per fire. This is exactly how the Windows diagnosis was made — I told the user to set the env var, re-ran the failing `/watchdog:start`, and the log told us claude was replying with "Hey! I'm ready to help" across every iteration.
Tests
Test plan
Co-Authored-By: Claude Opus 4.6 (1M context) noreply@anthropic.com