fix(windows): stop terminal-window popups from background spawns (#53810)

* fix(windows): stop terminal-window popups from background spawns

Native-Windows desktop/gateway users saw cmd/conhost windows flash on
gateway restart, image paste, the dashboard Projects tree, voice notes,
and ~5 min after closing the app (detached cron). Two root causes:

- Console-subsystem exes (taskkill, schtasks, wmic, netstat, tasklist,
  agent-browser, git, ffmpeg, powershell, git-bash) spawned via raw
  subprocess allocate a fresh console when the launching process has
  none (pythonw desktop backend / detached gateway) - even with output
  captured.
- uv venv pythonw shims re-exec console python.exe, so Python children
  get a console regardless of how they're launched.

Fixes:
- Single hidden-spawn primitive (_subprocess_compat.run/.popen) that ORs
  CREATE_NO_WINDOW on Windows, no-op on POSIX. Route every Hermes-owned
  console-exe spawn through it.
- FreeConsole() catch-all in hermes_bootstrap: any Python child that
  exclusively owns an auto-allocated console detaches it at startup
  (GetConsoleProcessList()==1 gate leaves shared interactive consoles
  untouched).
- Replace PowerShell/wmic gateway PID scans with in-process psutil.
- Skip schtasks queries on non-interactive desktop restarts.
- Prefer native agent-browser .exe over .cmd shims.
- Guard test bans raw subprocess spawns of the Windows-only console
  tools repo-wide so the popup class can't regress.

* fix(windows): scope FreeConsole to background entry points; fix merge fallout

Console detach review (per #53810 feedback): GetConsoleProcessList()==1 can't
tell a uv pythonw->python phantom console apart from a user opening the
interactive CLI/TUI in its own fresh console (double-click, shortcut, ConPTY) —
both report a single attached process with a tty. Running FreeConsole() in the
import-time bootstrap therefore risked detaching a legitimately-interactive
terminal.

- Extract FreeConsole into explicit hermes_bootstrap.detach_orphan_console();
  remove it from apply_windows_utf8_bootstrap() (import side effect).
- Call it only from known background mains: gateway run, dashboard backend
  (start_server, what the desktop spawns), cron standalone, tui_gateway entry,
  slash worker. Interactive CLI/TUI never calls it.
- Behavior-contract tests: frees only when solo owner, leaves shared console,
  no-op without console / on POSIX, and asserts it's not an import side effect.

Merge fallout from origin/main (#53791):
- local.py: 3-way merge left a dangling **_popen_kwargs (NameError crashing
  every terminal init). _subprocess_compat.popen already hides the window, so
  drop it.
- discord adapter: merge stacked an undefined windows_hide_flags() onto the
  primitive call; drop the redundant arg.
- test_gateway: scan now goes psutil-first (zero spawn); rewrite the
  case-variant test to drive that production path.

* test(claw): mock _subprocess_compat.run seam for Windows process scan

claw.py's Windows tasklist/powershell scan routes through the hidden-spawn
primitive; the tests still patched claw_mod.subprocess, so on win32 the mock
was never hit and real spawns returned nothing. Patch the actual seam.
This commit is contained in:
brooklyn! 2026-06-27 16:02:24 -05:00 committed by GitHub
parent ef17cd204d
commit 5db1430af9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
33 changed files with 693 additions and 107 deletions

View file

@ -665,7 +665,9 @@ class VoiceReceiver:
f.write(pcm_data)
pcm_path = f.name
try:
subprocess.run(
from hermes_cli import _subprocess_compat
_subprocess_compat.run(
[
"ffmpeg", "-y", "-loglevel", "error",
"-f", "s16le",
@ -679,7 +681,6 @@ class VoiceReceiver:
check=True,
timeout=10,
stdin=subprocess.DEVNULL,
creationflags=windows_hide_flags(),
)
finally:
try:

View file

@ -78,8 +78,10 @@ def _kill_port_process(port: int) -> None:
"""Kill any process *listening* on the given TCP port (a stale bridge)."""
try:
if _IS_WINDOWS:
from hermes_cli import _subprocess_compat
# Use netstat to find the PID bound to this port, then taskkill
result = subprocess.run(
result = _subprocess_compat.run(
["netstat", "-ano", "-p", "TCP"],
capture_output=True, text=True, timeout=5,
)
@ -89,7 +91,7 @@ def _kill_port_process(port: int) -> None:
local_addr = parts[1]
if local_addr.endswith(f":{port}"):
try:
subprocess.run(
_subprocess_compat.run(
["taskkill", "/PID", parts[4], "/F"],
capture_output=True, timeout=5,
)
@ -207,11 +209,13 @@ def _write_bridge_pidfile(session_path: Path, pid: int) -> None:
def _terminate_bridge_process(proc, *, force: bool = False) -> None:
"""Terminate the bridge process using process-tree semantics where possible."""
if _IS_WINDOWS:
from hermes_cli import _subprocess_compat
cmd = ["taskkill", "/PID", str(proc.pid), "/T"]
if force:
cmd.append("/F")
try:
result = subprocess.run(
result = _subprocess_compat.run(
cmd,
capture_output=True,
text=True,