Skip to content

[BUG] Native Windows: hook relay command is shell-mangled, so no session event ever fires #773

Description

@tolia77

Description

WindowsProcessHost.shell_quote (process_host.py:228) quotes hook-registration arguments with subprocess.list2cmdline, i.e. for cmd.exe/CreateProcess. But hook commands are executed by the coding CLI's hook runner. Claude Code's hooks documentation states that in shell form the command string is passed to "sh -c on macOS and Linux, Git Bash on Windows, or PowerShell when Git Bash isn't installed" - a platform default, not inherited from the launching shell. So on a Windows host with Git Bash present (the usual case here, since bmad-loop requires git) the command goes through a POSIX shell. list2cmdline leaves a space-free path bare, and that shell then consumes every path separator as an escape:

registered: uv run --no-project python D:\PROGRAMMING\proj\.bmad-loop\bmad_loop_hook.py Stop
executed:   uv run --no-project python D:PROGRAMMINGproj.bmad-loopbmad_loop_hook.py Stop

The mangled name is resolved against the session cwd. In the main checkout that misses; inside a worktree it names nothing at all.

The result is that no hook event is ever written<state>/<run-id>/events/ stays empty for the life of the run — so the orchestrator never learns a session ended. Per signals.py's own docstring (quoted in #752): losing Stop events means every session stalls to session_timeout_min, "the loudest possible regression, delivered silently".

This is the cause side of the failure #752 describes from the resilience side. #752's incident was a relay that had never been registered (macOS); here the relay is registered correctly and still cannot execute. #494 is a different hook-relay defect (the event-write race), not this.

I know native Windows is not yet shipped. Filing because WindowsProcessHost is selected by the platform-default registry today (validate reports process host: WindowsProcessHost), so the seam is live, and the defect is a wrong assumption stated in the code rather than a missing feature.

Steps to reproduce

On native Windows (not WSL, Git Bash installed) with the claude adapter and scm.isolation = "worktree":

  1. bmad-loop run --story <key>
  2. Observe <state-dir>/<run-id>/events/ — it stays empty for the entire run.
  3. Every session runs to exactly limits.session_timeout_min and is then killed; the journal shows session-end status=timeout or session-rescued-post-kill regardless of what the session actually did.
  4. In the CLI transcript the hook reports:
Stop hook error: "uv run --no-project python D:\...\.bmad-loop\bmad_loop_hook.py Stop" exited 2 with:
can't open file '...\worktrees\<story>\PROGRAMMINGCOURSEWORK_UNI.bmad-loopbmad_loop_hook.py':
[Errno 2] No such file or directory

Minimal reproduction of the quoting itself:

import subprocess
p = r"D:\PROGRAMMING\proj\.bmad-loop\bmad_loop_hook.py"
subprocess.list2cmdline([p])   # -> bare, separators intact
$ sh -c "echo D:\PROGRAMMING\proj\.bmad-loop\bmad_loop_hook.py"
D:PROGRAMMINGproj.bmad-loopbmad_loop_hook.py

Expected behavior

The registered relay command executes, writes its event file, and the orchestrator advances as soon as the session ends.

Actual behavior

The relay never executes. events/ is empty, every session burns its full session_timeout_min and is killed. With review.on_timeout = "retry" this then discards a committed, verify-green story and re-drives dev from scratch — in my run one story was re-implemented three times before I found the cause. Raising session_timeout_min makes it strictly worse, since it lengthens each blind wait.

Over one 9-hour run: zero events written, three sessions ending at exactly the 90-minute ceiling.

Suggested fix

Normalise separators before quoting:

def shell_quote(self, arg: str) -> str:
    return subprocess.list2cmdline([arg.replace("\\", "/")])

Forward slashes are accepted by the Windows APIs and by Python, and are safe unquoted in both shells, so this stays correct if some CLI's runner uses cmd.exe. list2cmdline still double-quotes a path containing spaces, and both shells honour double quotes. shlex.quote also fixes the sh case but would break a cmd.exe runner, which seemed the wrong trade for a seam whose consumer varies by CLI — though if the real axis is the hook dialect rather than the OS, that is your call to make and may be the better shape.

There may be a cleaner fix than quoting at all. The same documentation describes an exec form: when args is set, "Claude Code resolves command as an executable on PATH and spawns it directly with args as the argument vector. There is no shell, so each args element is one argument exactly as written." Emitting the relay registration that way would remove shell quoting from this seam entirely rather than making it correct for more shells. It is a larger, Claude-dialect-specific change and I have not attempted it, but for a seam whose consumer varies per CLI it may be the better shape.

Scope: shell_quote has three callers (install.py:1064, probe.py:703, worktree_flow.py:1134), all building hook registrations. PosixProcessHost is untouched, so supported platforms are unaffected.

I have run this patch locally: bmad-loop validate is all-green, the generated command executes from a worktree cwd (exit 0), and run --dry-run / list / adapters behave normally. I could not run your test suite — it is not shipped in site-packages — so this is not offered as a validated PR.

Which area is this for?

CLI adapters & profiles

bmad-loop Version

0.11.1

Which coding CLI are you using?

Claude (claude)

Operating System

Windows

Note on AI assistance

This report was investigated and drafted with Claude Code (Claude Opus 5) while debugging a real run on my own project. Every command output, line reference, path and reproduction above was executed and verified on my machine rather than produced from the model's recollection; the root cause was found by inspecting the installed package and confirmed by executing the mangled and fixed commands through sh. Flagging it because your triage cost is lower if you know, and I would rather say so than not.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions