Skip to content

fix: pass Haiku prompt via stdin to avoid cmd.exe quoting (v1.2.1)#10

Merged
JonyanDunh merged 1 commit intomainfrom
fix/windows-haiku-stdin-v1.2.1
Apr 11, 2026
Merged

fix: pass Haiku prompt via stdin to avoid cmd.exe quoting (v1.2.1)#10
JonyanDunh merged 1 commit intomainfrom
fix/windows-haiku-stdin-v1.2.1

Conversation

@JonyanDunh
Copy link
Copy Markdown
Owner

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

  • spawnSync('claude', ['-p', '--model', 'haiku', '--no-session-persistence', promptText], {
  • input: '',
  • spawnSync('claude', ['-p', '--model', 'haiku', '--no-session-persistence'], {
  • input: promptText,
    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:

  • Default path: `os.tmpdir() + '/claude-code-watchdog.log'` (cross-platform)
  • Override: `CLAUDE_CODE_WATCHDOG_LOG_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

  • New `test/log.test.js` (5 tests): default disabled, all 5 log levels mirror to file when enabled, `debug()` is file-only, 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).

Test plan

  • 80-test suite passes locally
  • Haiku path verified with mock CLI via `test/stop-hook-haiku.test.js` (all verdict branches)
  • File logging path verified with 5 new tests
  • Manifests bumped to 1.2.1 (plugin.json + marketplace.json)
  • CI will confirm across 9 matrix jobs (ubuntu/macos/windows × Node 18/20/22)

Co-Authored-By: Claude Opus 4.6 (1M context) noreply@anthropic.com

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>
@JonyanDunh JonyanDunh merged commit 44937be into main Apr 11, 2026
11 checks passed
@JonyanDunh JonyanDunh deleted the fix/windows-haiku-stdin-v1.2.1 branch April 11, 2026 01:22
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.

1 participant