Skip to content

feat(windows): wrap .wolf hook commands in wscript+VBS to hide console flash#42

Open
mann1x wants to merge 1 commit into
cytostack:mainfrom
mann1x:feature/windows-wscript-hooks
Open

feat(windows): wrap .wolf hook commands in wscript+VBS to hide console flash#42
mann1x wants to merge 1 commit into
cytostack:mainfrom
mann1x:feature/windows-wscript-hooks

Conversation

@mann1x
Copy link
Copy Markdown

@mann1x mann1x commented May 23, 2026

Summary

On Windows, every .claude/settings.json hook that OpenWolf installs
(SessionStart, PreToolUse × 2, PostToolUse × 2, Stop — six
per project) currently uses the bare form:

"command": "node \"$CLAUDE_PROJECT_DIR/.wolf/hooks/post-write.js\""

When Claude Code fires the hook, it spawns this without
windowsHide: true. node.exe is a console-subsystem binary, so
Windows allocates a fresh console window for every hook fire —
visible as a brief black flash. Under active editor use this stacks
to multiple flashes per second (every Edit/Write triggers
PreToolUse + PostToolUse). It steals focus from the editor and is
unusable in practice.

Anthropic acknowledged the upstream gap
(anthropics/claude-code#19012)
and closed it as not planned. The standard workaround — adopted
this PR — is a tiny VBS wrapper invoked via wscript.exe.
wscript.exe is a windows-subsystem host so the OS never allocates
a console for it; WScript.Shell.Run(cmd, 0, True) then launches
the underlying node "..." with SW_HIDE and waits for completion,
propagating the exit code so the Claude Code hook contract is
preserved.

The fix

A single new helper buildHookCommand(scriptName) (in
src/utils/hook-command.ts) decides per-platform whether to wrap:

// POSIX or no VBS asset present:
node "$CLAUDE_PROJECT_DIR/.wolf/hooks/post-write.js"

// Windows + VBS asset present:
wscript //nologo "<install>/dist/assets/hook-runner.vbs" node "$CLAUDE_PROJECT_DIR/.wolf/hooks/post-write.js"

Both init.ts and update.ts route their six hook-command strings
through this helper, so a openwolf init or openwolf update after
this lands rewrites existing projects' .claude/settings.json to
the wscript-wrapped form (replaceOpenWolfHooks() already filters by
.wolf/hooks/ substring so the swap is in-place and idempotent).

Cross-platform safety

  • POSIX hosts: byte-identical to today. buildHookCommand
    returns the existing node "..." shape when
    process.platform !== 'win32'.
  • Windows installs missing the VBS asset (older OpenWolf dist):
    same fallback — the helper returns the bare node "..." shape
    if findHookRunnerVbs() can't locate the asset.
  • Existing projects on a fresh OpenWolf install: the next
    openwolf init / openwolf update swap; until then they keep
    flashing as today (no regression).

Files touched

 assets/hook-runner.vbs            |  38 +++++++  (new)
 scripts/copy-hook-runner.mjs      |  20 +++++  (new — build step)
 src/utils/hook-command.ts         |  62 +++++++++  (new — helper)
 src/cli/init.ts                   |  44 +++-----  (route through helper)
 src/cli/update.ts                 |  41 +++-----  (route through helper)
 package.json                      |   2 +-      (build appends the copy step)
 6 files changed, 195 insertions(+), 94 deletions(-)

assets/hook-runner.vbs

If WScript.Arguments.Count < 1 Then WScript.Quit(1)
Dim cmd, i : cmd = ""
For i = 0 To WScript.Arguments.Count - 1
  If i > 0 Then cmd = cmd & " "
  cmd = cmd & """" & WScript.Arguments(i) & """"
Next
Set sh = CreateObject("WScript.Shell")
WScript.Quit(sh.Run(cmd, 0, True))
  • 0 — SW_HIDE: never show a window
  • True — wait for completion, propagate exit code
  • Args are re-quoted so paths with spaces (C:\Program Files\nodejs\node.exe)
    survive the round-trip through WScript.Arguments → cmd string

scripts/copy-hook-runner.mjs

Tiny ESM build step that copies assets/hook-runner.vbs
dist/assets/hook-runner.vbs next to the compiled CLI. Already in
the dist/ glob shipped via package.json files so npm pack
picks it up without further configuration.

src/utils/hook-command.ts

Two exports:

  • findHookRunnerVbs(): string | null — resolves the VBS path at
    runtime relative to the compiled module location (dist/src/utils/
    dist/assets/)
  • buildHookCommand(scriptName, platform?, vbsPath?) — emits the
    per-platform command string; both platform and vbsPath are
    injectable for tests

Verification

  • Build: tsc clean; node scripts/copy-hook-runner.mjs copies
    the VBS into dist/assets/; npm pack ships it inside the
    tarball under the dist/ glob
  • Smoke (Linux): buildHookCommand('post-write.js', 'linux')
    returns the unchanged node "$CLAUDE_PROJECT_DIR/.wolf/hooks/post-write.js"
  • Smoke (Windows 10 22H2 / pandorum): buildHookCommand('post-write.js')
    on the deployed install returns
    wscript //nologo "C:/Users/manni/AppData/Roaming/npm/node_modules/openwolf/dist/assets/hook-runner.vbs" node "$CLAUDE_PROJECT_DIR/.wolf/hooks/post-write.js"
  • End-to-end: ran openwolf init on three real Windows projects;
    .claude/settings.json now contains the wscript-wrapped form for
    all six hooks per project; zero console flashes during an
    extended Claude Code session, operator-confirmed
  • Companion PR: fix(windows): eliminate console-window flashes across all three sources (spawn audit + cmd-shim bypass + wscript wrapper) caliber-ai-org/ai-setup#222 ships the same VBS
    wrapper shape for Caliber's hook commands; the combined deploy on
    pandorum eliminates every Windows-flash source observed during
    the audit

Compatibility

  • replaceOpenWolfHooks() matches on .wolf/hooks/ substring so
    it identifies both the old bare-node and new wscript-wrapped
    shapes as OpenWolf-owned and rewrites in place — no stale
    duplicates after upgrade
  • User-authored hooks (any entry whose command doesn't contain
    .wolf/hooks/) are preserved untouched, same as today
  • Tests: existing init/update tests already exercise the
    replaceOpenWolfHooks path — they continue to pass because the
    helper is platform-gated and they run on Linux

Status

Ready for review. The change is empirically validated end-to-end on
Windows 10 22H2 (pandorum, three real OpenWolf-managed projects,
extended Claude Code session — zero flashes from .wolf/ hooks).
The companion Caliber PR
(caliber-ai-org/ai-setup#222)
ships the same VBS-wrapper shape for Caliber's hook commands and
has CI green across the 6-cell Linux/Windows × Node 20/22/25
matrix; combined deploy was the configuration that produced the
zero-flash result.

…e flash

On Windows, Claude Code spawns each .claude/settings.json hook command via
its parent shell. The node.exe used by the bare `node "..."` form is a
console-subsystem binary, so even with CREATE_NO_WINDOW set by Claude Code's
spawn, a brief black console window flashes onscreen for every PostToolUse,
SessionStart, etc. fire — repeatedly throughout a session.

Wrap the command in `wscript //nologo "<vbs>" node "..."`. `wscript.exe` is
a windows-subsystem host, so the OS never allocates a console for it;
WScript.Shell.Run(cmd, 0, True) then invokes the underlying `node "..."`
with SW_HIDE and waits for completion, propagating the exit code so Claude
Code's hook contract is preserved.

POSIX hosts get the unchanged bare form — buildHookCommand falls back to
`node "$CLAUDE_PROJECT_DIR/.wolf/hooks/<x>.js"` when platform !== 'win32'
or the VBS asset isn't found.

Changes:
- assets/hook-runner.vbs — universal SW_HIDE wrapper (re-quotes args)
- scripts/copy-hook-runner.mjs — build step copies VBS to dist/assets/
- src/utils/hook-command.ts — buildHookCommand() + findHookRunnerVbs()
- src/cli/init.ts, src/cli/update.ts — call buildHookCommand() per hook
- package.json — build appends `node scripts/copy-hook-runner.mjs`

Existing projects need an `openwolf init` (or `openwolf update`) re-run for
the new command form to land in their .claude/settings.json; the
replaceOpenWolfHooks() matcher (".wolf/hooks/" substring) handles the
upgrade in place without touching user-authored hooks.

Companion to the Caliber wscript+VBS wrapper landed in
caliber-ai-org/ai-setup#fix/windows-hide-spawn — same approach, same VBS
shape, validated empirically on pandorum where zero flashes remained after
both wrappers were active.
@mann1x mann1x marked this pull request as ready for review May 23, 2026 13:31
@cytostack cytostack self-assigned this May 26, 2026
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.

3 participants