NousResearch/hermes-agent

Windows console/terminal flash suppression fixes

Cluster 24073 · apps/desktop. Data as of

2 open items. Fix merged in #54417 on 28 Jun 2026. Check it covers your case.

The short answer

0open issues
2open PRs
45items grouped
Known?
Yes. 45 related items grouped here, 24 issues already closed.
Fix event
A fix was merged to main in PR #54417. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this unresolved. That is separate from the fix event.
Still open
0 issues and 2 PRs.

This cluster is focused on a single theme: preventing unwanted console window popups and flashes on Windows when background subprocesses are spawned. Members include bug reports, feature requests, and numerous incremental fixes targeting subprocess creation flags like CREATE_NO_WINDOW and CREATE_BREAKAWAY_FROM_JOB across various spawn sites (git, ffmpeg, netstat, gateway, etc.).

Open issues0

No open issues.

Open PRs2

  • PR#75224fix: hide WhatsApp adapter helper subprocesses on Windows
  • PR#108309fix(windows): add CREATE_BREAKAWAY_FROM_JOB to windows_hide_flags()

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

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

    merged to main

    files: apps/desktop/electron/main.cjs, cron/scheduler.py, gateway/run.py, gateway/status.py, hermes_bootstrap.py, hermes_cli/_subprocess_compat.py, hermes_cli/claw.py, hermes_cli/clipboard.py, hermes_cli/gateway.py, hermes_cli/gateway_windows.py, hermes_cli/main.py, hermes_cli/web_server.py, hermes_constants.py, plugins/platforms/discord/adapter.py, plugins/platforms/whatsapp/adapter.py, tests/gateway/test_status.py, tests/hermes_cli/test_claw.py, tests/hermes_cli/test_gateway.py, tests/hermes_cli/test_gateway_windows.py, tests/hermes_cli/test_web_server.py (+13 more)

    Show the diff
    • apps/desktop/electron/main.cjs
      @@ -1565,18 +1565,17 @@ function readVenvHome(venvRoot) {
       function getNoConsoleVenvPython(venvRoot) {
         if (!IS_WINDOWS) return getVenvPython(venvRoot)
       
      -  // Prefer the venv's own pythonw shim — it carries pyvenv.cfg / site-packages
      -  // wiring. Falling back to the base uv/python.org pythonw.exe skips the venv
      -  // and breaks imports (yaml, hermes_cli, …) even when PYTHONPATH is patched.
      -  const venvPythonw = path.join(venvRoot, 'Scripts', 'pythonw.exe')
      -  if (fileExists(venvPythonw)) return venvPythonw
      -
      +  // uv venv launchers can re-exec console python.exe, which allocates conhost /
      +  // Windows Terminal. Use base pythonw directly and provide imports via env.
         const baseHome = readVenvHome(venvRoot)
         if (baseHome) {
           const basePythonw = path.join(baseHome, 'pythonw.exe')
           if (fileExists(basePythonw)) return basePythonw
         }
       
      +  const venvPythonw = path.join(venvRoot, 'Scripts', 'pythonw.exe')
      +  if (fileExists(venvPythonw)) return venvPythonw
      +
         return venvPythonw
       }
       
      @@ -2797,7 +2796,7 @@ function createPythonBackend(root, label, dashboardArgs, options = {}) {
           args: ['-m', 'hermes_cli.main', ...dashboardArgs],
           env: buildDesktopBackendEnv({
             hermesHome: HERMES_HOME,
      -      pythonPathEntries: [root],
      +      pythonPathEntries: [root, ...getVenvSitePackagesEntries(venvRoot)],
             venvRoot
           }),
           root,
      @@ -2821,7 +2820,7 @@ function createActiveBackend(dashboardArgs) {
           args: ['-m', 'hermes_cli.main', ...dashboardArgs],
           env: buildDesktopBackendEnv({
             hermesHome: HERMES_HOME,
      -      pythonPathEntries: [ACTIVE_HERMES_ROOT],
      +      pythonPathEntries: [ACTIVE_HERMES_ROOT, ...getVenvSitePackagesEntries(VENV_ROOT)],
             venvRoot: VENV_ROOT
           }),
           root: ACTIVE_HERMES_ROOT,
    • cron/scheduler.py
      @@ -3046,4 +3046,11 @@ def _on_done(_f: concurrent.futures.Future) -> None:
       
       
       if __name__ == "__main__":
      +    # Standalone background scheduler: drop any console a uv pythonw→python
      +    # re-exec auto-allocated. No-op on POSIX / when run in-gateway.
      +    try:
      +        import hermes_bootstrap
      +        hermes_bootstrap.detach_orphan_console()
      +    except Exception:
      +        pass
           tick(verbose=True)
    • gateway/run.py
      @@ -18593,6 +18593,13 @@ def restart_signal_handler():
       
       def main():
           """CLI entry point for the gateway."""
      +    # Background daemon: drop any console auto-allocated by a uv pythonw→python
      +    # re-exec so no terminal lingers. No-op on POSIX / when already detached.
      +    try:
      +        hermes_bootstrap.detach_orphan_console()
      +    except Exception:
      +        pass
      +
           # Force UTF-8 stdio on Windows — gateway logs and startup banner would
           # otherwise UnicodeEncodeError on cp1252 consoles.  No-op on POSIX.
           try:
    • gateway/status.py
      @@ -81,8 +81,10 @@ def terminate_pid(pid: int, *, force: bool = False) -> None:
           because os.kill(..., SIGTERM) is not equivalent to a tree-killing hard stop.
           """
           if force and _IS_WINDOWS:
      +        from hermes_cli import _subprocess_compat
      +
               try:
      -            result = subprocess.run(
      +            result = _subprocess_compat.run(
                       ["taskkill", "/PID", str(pid), "/T", "/F"],
                       capture_output=True,
                       text=True,
    • hermes_bootstrap.py
      @@ -80,6 +80,26 @@ def apply_windows_utf8_bootstrap() -> bool:
           os.environ.setdefault("PYTHONUTF8", "1")
           os.environ.setdefault("PYTHONIOENCODING", "utf-8")
       
      +    # Python's platform.win32_ver()/platform.platform() can shell out to
      +    # ``cmd.exe /c ver`` on Windows.  In pythonw-launched background processes
      +    # that still creates a visible terminal handoff on machines where Windows
      +    # Terminal is the default console host.  Disable that subprocess path early.
      +    try:
      +        import platform
      +
      +        def _no_subprocess_syscmd_ver(
      +            system: str = "",
      +            release: str = "",
      +            version: str = "",
      +            *_args,
      +            **_kwargs,
      +        ) -> tuple[str, str, str]:
      +            return system or "Windows", release, version
      +
      +        platform._syscmd_ver = _no_subprocess_syscmd_ver  # type: ignore[attr-defined]
      +    except Exception:
      +        pass
      +
           # 2. Reconfigure the current process's stdio to UTF-8.  Needed
           #    because os.environ changes don't retroactively rebind sys.stdout
           #    — those were bound at interpreter startup based on the console
      @@ -122,6 +142,44 @@ def apply_windows_utf8_bootstrap() -> bool:
           return True
       
       
      +def detach_orphan_console() -> bool:
      +    """Free a console window that was auto-allocated for this process alone.
      +
      +    Background-only entry points (gateway daemon, dashboard backend, cron
      +    runner, TUI/desktop stdio backends) call this explicitly. uv-created venvs
      +    ship a ``Scripts\\pythonw.exe`` redirector that re-execs the *base* console
      +    ``python.exe``; that re-exec allocates its own conhost/Windows Terminal
      +    window even though the launcher wanted no console. We drop it so nothing
      +    lingers.
      +
      +    This is NOT wired into the import-time bootstrap on purpose: the discriminator
      +    (``GetConsoleProcessList() == 1``) cannot tell a phantom console apart from a
      +    user who deliberately opened the *interactive* CLI/TUI in its own fresh
      +    console (double-click, Start-menu shortcut, a ConPTY), since both report a
      +    single attached process with a tty. Intent is only knowable from the entry
      +    point — so only known-background mains call this, never the interactive CLI.
      +
      +    A properly detached daemon (``DETACHED_PROCESS``) has no console at all, so
      +    ``GetConsoleWindow()`` is NULL and this is a no-op. Returns True iff a console
      +    was actually freed. No-op (returns False) on non-Windows.
      +    """
      +    if not _IS_WINDOWS:
      +        return False
      +    try:
      +        import ctypes
      +
      +        kernel32 = ctypes.windll.kernel32
      +        if not kernel32.GetConsoleWindow():
      +            return False
      +        buf = (ctypes.c_uint * 4)()
      +        if kernel32.GetConsoleProcessList(buf, 4) == 1:
      +            kernel32.FreeConsole()
      +            return True
      +    except Exception:
      +        pass
      +    return False
      +
      +
       def harden_import_path(src_root: str | None = None) -> None:
           """Stop a package in the current directory from shadowing Hermes modules.
       
    • hermes_cli/_subprocess_compat.py
      @@ -28,12 +28,15 @@
       from __future__ import annotations
       
       import shutil
      +import subprocess
       import sys
       from typing import Sequence
       
       __all__ = [
           "IS_WINDOWS",
           "resolve_node_command",
      +    "run",
      +    "popen",
           "windows_detach_flags",
           "windows_detach_flags_without_breakaway",
           "windows_hide_flags",
      @@ -201,6 +204,44 @@ def windows_hide_flags() -> int:
           return _CREATE_NO_WINDOW
       
       
      +# -----------------------------------------------------------------------------
      +# The single chokepoint for spawning a process without a console window.
      +# -----------------------------------------------------------------------------
      +
      +
      +def _no_window(kwargs: dict) -> dict:
      +    """OR ``CREATE_NO_WINDOW`` into ``creationflags`` on Windows (no-op on POSIX).
      +
      +    Merges rather than overwrites, so a caller that needs detach semantics can
      +    pass ``creationflags=windows_detach_flags()`` and still go through here —
      +    ``CREATE_NO_WINDOW`` is already part of that bundle, so the OR is idempotent.
      +    """
      +    if IS_WINDOWS:
      +        kwargs["creationflags"] = kwargs.get("creationflags", 0) | _CREATE_NO_WINDOW
      +    return kwargs
      +
      +
      +def run(cmd, **kwargs):
      +    """``subprocess.run`` that never flashes a console window on Windows.
      +
      +    This is the primitive every Hermes spawn of a *console-subsystem* program
      +    (``taskkill``, ``schtasks``, ``agent-browser``, ``git-bash``, version
      +    probes, …) must use. Routing through one function makes "no visible
      +    terminal" structural instead of a per-call-site rule that gets forgotten —
      +    which is exactly how cron-driven and future spawns leaked windows before.
      +
      +    Python child processes are additionally covered by the ``FreeConsole``
      +    catch-all in :mod:`hermes_bootstrap`, but native exes can't run that, so the
      +    spawn-time flag here is the only thing that helps them.
      +    """
      +    return subprocess.run(cmd, **_no_window(kwargs))
      +
      +
      +def popen(cmd, **kwargs):
      +    """``subprocess.Popen`` counterpart of :func:`run` — see its docstring."""
      +    return subprocess.Popen(cmd, **_no_window(kwargs))
      +
      +
       def windows_detach_popen_kwargs() -> dict:
           """Return a dict of Popen kwargs that detach a child on Windows and
           fall back to the POSIX equivalent (``start_new_session=True``) on
    • hermes_cli/claw.py
      @@ -77,9 +77,11 @@ def _detect_openclaw_processes() -> list[str]:
       
           # -- process scan ------------------------------------------------------
           if sys.platform == "win32":
      +        from hermes_cli import _subprocess_compat
      +
               try:
                   for exe in ("openclaw.exe", "clawd.exe"):
      -                result = subprocess.run(
      +                result = _subprocess_compat.run(
                           ["tasklist", "/FI", f"IMAGENAME eq {exe}"],
                           capture_output=True, text=True, timeout=5,
                       )
      @@ -93,7 +95,7 @@ def _detect_openclaw_processes() -> list[str]:
                       'Where-Object { $_.CommandLine -match "openclaw|clawd" } | '
                       'Select-Object -First 1 ProcessId'
                   )
      -            result = subprocess.run(
      +            result = _subprocess_compat.run(
                       ["powershell", "-NoProfile", "-Command", ps_cmd],
                       capture_output=True, text=True, timeout=5,
                   )
    • hermes_cli/clipboard.py
      @@ -198,7 +198,9 @@ def _macos_osascript(dest: Path) -> bool:
       
       
       def _run_powershell(exe: str, script: str, timeout: int) -> subprocess.CompletedProcess:
      -    return subprocess.run(
      +    from hermes_cli import _subprocess_compat
      +
      +    return _subprocess_compat.run(
               [exe, "-NoProfile", "-NonInteractive", "-Command", script],
               capture_output=True, text=True, timeout=timeout,
           )
      @@ -254,9 +256,11 @@ def _powershell_save_image(exe: str, dest: Path, *, timeout: int, label: str) ->
       
       def _find_powershell() -> str | None:
           """Return the first available PowerShell executable, or None."""
      +    from hermes_cli import _subprocess_compat
      +
           for name in ("powershell", "pwsh"):
               try:
      -            r = subprocess.run(
      +            r = _subprocess_compat.run(
                       [name, "-NoProfile", "-NonInteractive", "-Command", "echo ok"],
                       capture_output=True, text=True, timeout=5,
                   )
    • hermes_cli/gateway.py
      @@ -380,6 +380,24 @@ def _matches_gateway_runtime(command: str) -> bool:
       
           try:
               if is_windows():
      +            try:
      +                import psutil  # type: ignore
      +
      +                for proc in psutil.process_iter(["pid", "cmdline"]):
      +                    pid = int(proc.info.get("pid") or 0)
      +                    if pid == os.getpid() or pid in exclude_pids:
      +                        continue
      +                    command = " ".join(proc.info.get("cmdline") or [])
      +                    if _matches_gateway_runtime(command) and (
      +                        all_profiles or _matches_current_profile(command)
      +                    ):
      +                        _append_unique_pid(pids, pid, exclude_pids)
      +                return _filter_venv_launcher_stubs(pids) if len(pids) > 1 else pids
      +            except Exception:
      +                pass
      +
      +            from hermes_cli import _subprocess_compat
      +
                   # Prefer wmic when present (fast, stable output format).  On
                   # modern Windows 11 / Win 10 late builds, wmic has been
                   # removed as part of the WMIC deprecation — fall back to
      @@ -390,7 +408,7 @@ def _matches_gateway_runtime(command: str) -> bool:
                   result = None
                   if wmic_path is not None:
                       try:
      -                    result = subprocess.run(
      +                    result = _subprocess_compat.run(
                               [
                                   wmic_path,
                                   "process",
      @@ -421,7 +439,7 @@ def _matches_gateway_runtime(command: str) -> bool:
                           "}"
                       )
                       try:
      -                    result = subprocess.run(
      +                    result = _subprocess_compat.run(
                               [powershell, "-NoProfile", "-Command", ps_cmd],
                               capture_output=True,
                               text=True,
      @@ -6632,7 +6650,6 @@ def _gateway_command_inner(args):
                   # path that can be reaped with the old gateway process.  If the
                   # Windows backend raises, intentionally preserve the existing
                   # generic failure fallback below.
      -            service_configured = gateway_windows.is_installed()
                   try:
                       gateway_windows.restart()
                       return
    • hermes_cli/gateway_windows.py
      @@ -39,10 +39,10 @@
       from pathlib import Path
       from xml.sax.saxutils import escape
       
      +from hermes_cli import _subprocess_compat
       from hermes_cli._subprocess_compat import (
           windows_detach_flags,
           windows_detach_flags_without_breakaway,
      -    windows_hide_flags,
       )
       
       # Short timeouts: schtasks occasionally wedges and we don't want to hang forever.
      @@ -157,7 +157,7 @@ def _exec_schtasks(args: list[str]) -> tuple[int, str, str]:
           if schtasks is None:
               return (1, "", "schtasks.exe not found on PATH")
           try:
      -        proc = subprocess.run(
      +        proc = _subprocess_compat.run(
                   [schtasks, *args],
                   capture_output=True,
                   text=True,
      @@ -168,10 +168,6 @@ def _exec_schtasks(args: list[str]) -> tuple[int, str, str]:
                   encoding=_schtasks_encoding(),
                   errors="replace",
                   timeout=_SCHTASKS_TIMEOUT_S,
      -            # CREATE_NO_WINDOW avoids a flashing console window when the CLI
      -            # is itself hosted in a TUI. See tools/browser_tool.py for the
      -            # same pattern and the windows-subprocess-sigint-storm.md ref.
      -            creationflags=windows_hide_flags(),
               )
               return (proc.returncode, proc.stdout or "", proc.stderr or "")
           except subprocess.TimeoutExpired:
      @@ -1605,7 +1601,17 @@ def stop() -> None:
               drained = _drain_gateway_pid(pid, _windows_stop_drain_timeout())
       
           stopped_any = drained
      -    if is_task_registered():
      +    has_service_artifact = (
      +        get_task_script_path().exists()
      +        or get_task_script_path().with_suffix(".vbs").exists()
      +        or get_startup_entry_path().exists()
      +        or _legacy_startup_entry_path().exists()
      +    )
      +    if (
      +        has_service_artifact
      +        and os.getenv("HERMES_NONINTERACTIVE") != "1"
      +        and is_task_registered()
      +    ):
               code, _out, err = _exec_schtasks(["/End", "/TN", get_task_name()])
               # schtasks returns nonzero when the task isn't currently running — don't treat that as an error.
               if code == 0:
      @@ -1673,7 +1679,8 @@ def restart() -> None:
       
           # Give Windows a moment to release the listening port.
           time.sleep(1.0)
      -    start()
      +    pid = _spawn_detached()
      +    _report_gateway_start(f"direct spawn (PID {pid})")
       
           if not _wait_for_gateway_ready(timeout_s=15.0):
               raise RuntimeError(
    • hermes_cli/main.py
      @@ -5730,6 +5730,8 @@ def _find_stale_dashboard_pids(
       
           try:
               if sys.platform == "win32":
      +            from hermes_cli import _subprocess_compat
      +
                   # wmic may emit text in the system code page (for example cp936
                   # on zh-CN systems), not UTF-8. In text mode, subprocess output
                   # decoding depends on Python's configuration (locale-dependent
      @@ -5737,7 +5739,7 @@ def _find_stale_dashboard_pids(
                   # here is errors="ignore": it prevents a reader-thread
                   # UnicodeDecodeError from leaving result.stdout=None and turning
                   # the later .split() into an AttributeError (#17049).
      -            result = subprocess.run(
      +            result = _subprocess_compat.run(
                       ["wmic", "process", "get", "ProcessId,CommandLine", "/FORMAT:LIST"],
                       capture_output=True,
                       text=True,
      @@ -5977,9 +5979,11 @@ def _kill_stale_dashboard_processes(
           failed: list[tuple[int, str]] = []
       
           if sys.platform == "win32":
      +        from hermes_cli import _subprocess_compat
      +
               for pid in pids:
                   try:
      -                result = subprocess.run(
      +                result = _subprocess_compat.run(
                           ["taskkill", "/PID", str(pid), "/F"],
                           capture_output=True,
                           text=True,
    • hermes_cli/web_server.py
      @@ -2481,16 +2481,52 @@ def _record_completed_action(name: str, message: str, exit_code: int = 1) -> Non
           _ACTION_RESULTS[name] = {"exit_code": exit_code, "pid": None}
       
       
      -def _dashboard_spawn_executable() -> str:
      -    """Prefer pythonw.exe for detached dashboard actions on Windows."""
      +def _dashboard_spawn_details() -> Tuple[str, Dict[str, str]]:
      +    """Return (executable, env overlay) for detached dashboard actions.
      +
      +    On Windows this mirrors the gateway's uv-safe detached launcher logic so
      +    action spawns do not regress to console python.exe (which creates a visible
      +    terminal window). Non-Windows callsites get the current interpreter and no
      +    env overlay.
      +    """
           if sys.platform != "win32":
      -        return sys.executable
      +        return sys.executable, {}
      +
           exe = sys.executable
      -    if exe.lower().endswith("python.exe"):
      -        pythonw = os.path.join(os.path.dirname(exe), "pythonw.exe")
      -        if os.path.isfile(pythonw):
      -            return pythonw
      -    return exe
      +    try:
      +        from hermes_cli.gateway_windows import _resolve_detached_python
      +
      +        venv_root = os.environ.get("VIRTUAL_ENV", "").strip()
      +        if not venv_root:
      +            for candidate in (PROJECT_ROOT / "venv", PROJECT_ROOT / ".venv"):
      +                if (candidate / "Scripts" / "python.exe").exists():
      +                    venv_root = str(candidate)
      +                    break
      +        probe_exe = (
      +            os.path.join(venv_root, "Scripts", "python.exe")
      +            if venv_root
      +            else exe
      +        )
      +        windowless_exe, venv_dir, extra_pythonpath = _resolve_detached_python(probe_exe)
      +        env_overlay: Dict[str, str] = {}
      +        if venv_dir:
      +            env_overlay["VIRTUAL_ENV"] = str(venv_dir)
      +            site_packages = Path(venv_dir) / "Lib" / "site-packages"
      +            if site_packages.exists() and str(site_packages) not in extra_pythonpath:
      +                extra_pythonpath = [*extra_pythonpath, str(site_packages)]
      +        if extra_pythonpath:
      +            existing = os.environ.get("PYTHONPATH", "")
      +            env_overlay["PYTHONPATH"] = os.pathsep.join(
      +                [*extra_pythonpath, existing] if existing else list(extra_pythonpath)
      +            )
      +        return windowless_exe, env_overlay
      +    except Exception:
      +        # Best-effort fallback: sibling pythonw keeps the legacy no-console path.
      +        if exe.lower().endswith("python.exe"):
      +            pythonw = os.path.join(os.path.dirname(exe), "pythonw.exe")
      +            if os.path.isfile(pythonw):
      +                return pythonw, {}
      +    return exe, {}
       
       
       def _spawn_hermes_action(subcommand: List[str], name: str) -> subprocess.Popen:
      @@ -2507,15 +2543,20 @@ def _spawn_hermes_action(subcommand: List[str], name: str) -> subprocess.Popen:
               f"\n=== {name} started {time.strftime('%Y-%m-%d %H:%M:%S')} ===\n".encode()
           )
       
      -    cmd = [_dashboard_spawn_executable(), "-m", "hermes_cli.main", *subcommand]
      +    spawn_executable, spawn_env_overlay = _dashboard_spawn_details()
      +    cmd = [spawn_executable, "-m", "hermes_cli.main", *subcommand]
       
           popen_kwargs: Dict[str, Any] = {
               "cwd": str(PROJECT_ROOT),
               "stdin": subprocess.DEVNULL,
               "stdout": log_file,
               "stderr": subprocess.STDOUT,
      -        "env": {**os.environ, "HERMES_NONINTERACTIVE": "1"},
      +        "env": {**os.environ, "HERMES_NONINTERACTIVE": "1", **spawn_env_overlay},
           }
      +    log_file.write(f"spawn executable: {spawn_executable}\n".encode())
      +    if spawn_env_overlay:
      +        keys = ",".join(sorted(spawn_env_overlay.keys()))
      +        log_file.write(f"spawn env overlay keys: {keys}\n".encode())
           if sys.platform == "win32":
               popen_kwargs["creationflags"] = windows_detach_flags()
           else:
      @@ -13547,6 +13588,15 @@ def start_server(
           — used when a profile alias (``<profile> dashboard``) routes to the
           machine dashboard.
           """
      +    # Desktop spawns this backend via a no-console venv python; a uv
      +    # pythonw→python re-exec can still auto-allocate a console. Drop it.
      +    # No-op on POSIX and when launched from an interactive shell.
      +    try:
      +        import hermes_bootstrap
      +        hermes_bootstrap.detach_orphan_console()
      +    except Exception:
      +        pass
      +
           import uvicorn
       
           try:
    • hermes_constants.py
      @@ -561,12 +561,15 @@ def agent_browser_runnable(path: str | None) -> bool:
               return False
           import subprocess
       
      +    from hermes_cli import _subprocess_compat
      +
           try:
      -        result = subprocess.run(
      +        result = _subprocess_compat.run(
                   [path, "--version"],
                   capture_output=True,
                   timeout=10,
                   env=with_hermes_node_path(),
      +            stdin=subprocess.DEVNULL,
               )
           except (OSError, subprocess.TimeoutExpired, ValueError):
               return False
    • plugins/platforms/discord/adapter.py
      @@ -665,7 +665,9 @@ def pcm_to_wav(pcm_data: bytes, output_path: str,
                   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 @@ def pcm_to_wav(pcm_data: bytes, output_path: str,
                       check=True,
                       timeout=10,
                       stdin=subprocess.DEVNULL,
      -                creationflags=windows_hide_flags(),
                   )
               finally:
                   try:
    • plugins/platforms/whatsapp/adapter.py
      @@ -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,
    • tests/gateway/test_status.py
      @@ -618,20 +618,25 @@ def no_proc(self, *args, **kwargs):
       
       class TestTerminatePid:
           def test_force_uses_taskkill_on_windows(self, monkeypatch):
      +        from hermes_cli import _subprocess_compat
      +
               calls = []
               monkeypatch.setattr(status, "_IS_WINDOWS", True)
      +        monkeypatch.setattr(_subprocess_compat, "IS_WINDOWS", True)
       
      -        def fake_run(cmd, capture_output=False, text=False, timeout=None):
      -            calls.append((cmd, capture_output, text, timeout))
      +        def fake_run(cmd, capture_output=False, text=False, timeout=None, **kwargs):
      +            calls.append((cmd, capture_output, text, timeout, kwargs))
                   return SimpleNamespace(returncode=0, stdout="", stderr="")
       
               monkeypatch.setattr(status.subprocess, "run", fake_run)
       
               status.terminate_pid(123, force=True)
       
      -        assert calls == [
      -            (["taskkill", "/PID", "123", "/T", "/F"], True, True, 10)
      -        ]
      +        assert len(calls) == 1
      +        cmd, capture_output, text, timeout, kwargs = calls[0]
      +        assert cmd == ["taskkill", "/PID", "123", "/T", "/F"]
      +        assert (capture_output, text, timeout) == (True, True, 10)
      +        assert kwargs["creationflags"] & 0x08000000
       
           def test_force_falls_back_to_sigterm_when_taskkill_missing(self, monkeypatch):
               calls = []
    • tests/hermes_cli/test_claw.py
      @@ -725,8 +725,9 @@ def test_detects_systemd_service(self):
           def test_returns_match_on_windows_when_openclaw_exe_running(self):
               with patch.object(claw_mod, "sys") as mock_sys:
                   mock_sys.platform = "win32"
      -            with patch.object(claw_mod, "subprocess") as mock_subprocess:
      -                mock_subprocess.run.side_effect = [
      +            # Windows scans go through the hidden-spawn primitive (no console flash).
      +            with patch("hermes_cli._subprocess_compat.run") as mock_run:
      +                mock_run.side_effect = [
                           MagicMock(returncode=0, stdout="openclaw.exe                 1234 Console    1     45,056 K\n"),
                       ]
                       result = claw_mod._detect_openclaw_processes()
      @@ -736,8 +737,8 @@ def test_returns_match_on_windows_when_openclaw_exe_running(self):
           def test_returns_match_on_windows_when_node_exe_has_openclaw_in_cmdline(self):
               with patch.object(claw_mod, "sys") as mock_sys:
                   mock_sys.platform = "win32"
      -            with patch.object(claw_mod, "subprocess") as mock_subprocess:
      -                mock_subprocess.run.side_effect = [
      +            with patch("hermes_cli._subprocess_compat.run") as mock_run:
      +                mock_run.side_effect = [
                           MagicMock(returncode=0, stdout=""),  # tasklist openclaw.exe
                           MagicMock(returncode=0, stdout=""),  # tasklist clawd.exe
                           MagicMock(returncode=0, stdout="1234\n"),  # PowerShell
      @@ -749,8 +750,8 @@ def test_returns_match_on_windows_when_node_exe_has_openclaw_in_cmdline(self):
           def test_returns_empty_on_windows_when_nothing_found(self):
               with patch.object(claw_mod, "sys") as mock_sys:
                   mock_sys.platform = "win32"
      -            with patch.object(claw_mod, "subprocess") as mock_subprocess:
      -                mock_subprocess.run.side_effect = [
      +            with patch("hermes_cli._subprocess_compat.run") as mock_run:
      +                mock_run.side_effect = [
                           MagicMock(returncode=0, stdout=""),
                           MagicMock(returncode=0, stdout=""),
                           MagicMock(returncode=0, stdout=""),
    • tests/hermes_cli/test_gateway.py
      @@ -887,23 +887,20 @@ def test_reap_unsupervised_orphans_returns_false_when_none_found(monkeypatch):
       
       
       def test_scan_gateway_pids_detects_windows_hermes_exe_case_variants(monkeypatch):
      +    # Windows scan now goes through psutil first (no console spawn). A
      +    # uppercase ``Hermes.EXE gateway run`` must still match case-insensitively.
      +    import psutil
      +
           monkeypatch.setattr(gateway, "is_windows", lambda: True)
           monkeypatch.setattr(gateway, "_get_ancestor_pids", lambda: set())
      -    monkeypatch.setattr(gateway.shutil, "which", lambda name: "wmic.exe" if name == "wmic" else None)
      -
      -    def fake_run(cmd, **kwargs):
      -        if cmd[:4] == ["wmic.exe", "process", "get", "ProcessId,CommandLine"]:
      -            return SimpleNamespace(
      -                returncode=0,
      -                stdout=(
      -                    "CommandLine=C:\\Program Files\\Hermes\\Hermes.EXE gateway run --replace\n"
      -                    "ProcessId=2468\n\n"
      -                ),
      -                stderr="",
      -            )
      -        raise AssertionError(f"Unexpected command: {cmd}")
       
      -    monkeypatch.setattr(gateway.subprocess, "run", fake_run)
      +    proc = SimpleNamespace(
      +        info={
      +            "pid": 2468,
      +            "cmdline": ["C:\\Program Files\\Hermes\\Hermes.EXE", "gateway", "run", "--replace"],
      +        }
      +    )
      +    monkeypatch.setattr(psutil, "process_iter", lambda attrs=None: [proc])
       
           assert gateway._scan_gateway_pids(set(), all_profiles=True) == [2468]
       
    • tests/hermes_cli/test_gateway_windows.py
      @@ -29,6 +29,31 @@ def test_schtasks_fallback_does_not_hide_unknown_errors():
           assert gateway_windows._should_fall_back(1, "ERROR: The system cannot find the file specified.") is False
       
       
      +def test_noninteractive_stop_skips_schtasks_query(monkeypatch, tmp_path):
      +    """Desktop-triggered restarts must not invoke schtasks.exe.
      +
      +    schtasks is a console-subsystem binary; on Windows Terminal default hosts it
      +    can visibly pop a terminal even for `/Query`. Noninteractive desktop actions
      +    already stop the known gateway PID directly, so service-manager probing is
      +    unnecessary.
      +    """
      +
      +    script = tmp_path / "Hermes_Gateway.cmd"
      +    script.write_text("", encoding="utf-8")
      +
      +    monkeypatch.setenv("HERMES_NONINTERACTIVE", "1")
      +    monkeypatch.setattr(gateway_windows, "_assert_windows", lambda: None)
      +    monkeypatch.setattr(gateway_windows, "get_task_script_path", lambda: script)
      +    monkeypatch.setattr(gateway_windows, "get_startup_entry_path", lambda: tmp_path / "Hermes_Gateway.vbs")
      +    monkeypatch.setattr(gateway_windows, "_legacy_startup_entry_path", lambda: tmp_path / "Hermes_Gateway.cmd")
      +    monkeypatch.setattr(gateway_windows, "is_task_registered", lambda: pytest.fail("must not call schtasks"))
      +    monkeypatch.setattr("gateway.status.get_running_pid", lambda: None)
      +    monkeypatch.setattr(gateway_windows, "_collect_gateway_stop_pids", lambda pid=None: [])
      +    monkeypatch.setattr(gateway_windows, "_force_terminate_known_gateway_pids", lambda pids: 0)
      +
      +    gateway_windows.stop()
      +
      +
       def test_schtasks_encoding_falls_back_to_utf8(monkeypatch):
           """A broken/empty locale must not leave us without a decoder (issue #38172)."""
       
      @@ -112,6 +137,23 @@ def test_build_gateway_argv_uses_base_pythonw_for_uv_venv_launcher(monkeypatch,
           assert str(site_packages) in env_overlay["PYTHONPATH"].split(gateway_windows.os.pathsep)
       
       
      +def test_restart_relaunches_directly_without_start_service_probe(monkeypatch):
      +    calls = []
      +
      +    monkeypatch.setattr(gateway_windows, "_assert_windows", lambda: None)
      +    monkeypatch.setattr(gateway_windows, "stop", lambda: calls.append("stop"))
      +    monkeypatch.setattr(gateway_windows, "_wait_for_gateway_absent", lambda *a, **k: True)
      +    monkeypatch.setattr(gateway_windows.time, "sleep", lambda *_a, **_k: None)
      +    monkeypatch.setattr(gateway_windows, "_spawn_detached", lambda: 4321)
      +    monkeypatch.setattr(gateway_windows, "_report_gateway_start", lambda via: calls.append(("report", via)))
      +    monkeypatch.setattr(gateway_windows, "_wait_for_gateway_ready", lambda *a, **k: [4321])
      +    monkeypatch.setattr(gateway_windows, "start", lambda: pytest.fail("restart must not call start()"))
      +
      +    gateway_windows.restart()
      +
      +    assert calls == ["stop", ("report", "direct spawn (PID 4321)")]
      +
      +
       class TestStableWindowsGatewayWorkingDir:
           def test_stable_gateway_working_dir_uses_hermes_home(self, tmp_path, monkeypatch):
               home = tmp_path / ".hermes"
    • tests/hermes_cli/test_web_server.py
      @@ -215,6 +215,75 @@ def test_falls_back_to_random_token(self, monkeypatch):
               assert ws._SESSION_TOKEN and len(ws._SESSION_TOKEN) >= 32
       
       
      +class TestDashboardActionSpawnDetails:
      +    def test_windows_uses_uv_safe_pythonw_with_env_overlay(self, monkeypatch):
      +        import hermes_cli.web_server as ws
      +
      +        monkeypatch.setattr(ws.sys, "platform", "win32")
      +        monkeypatch.setattr(ws.sys, "executable", r"C:\venv\Scripts\python.exe")
      +        monkeypatch.setenv("PYTHONPATH", r"C:\existing")
      +        monkeypatch.setattr(
      +            "hermes_cli.gateway_windows._resolve_detached_python",
      +            lambda _exe: (
      +                r"C:\base\pythonw.exe",
      +                Path(r"C:\venv"),
      +                [r"C:\venv\Lib\site-packages"],
      +            ),
      +        )
      +
      +        executable, env_overlay = ws._dashboard_spawn_details()
      +
      +        assert executable == r"C:\base\pythonw.exe"
      +        assert env_overlay["VIRTUAL_ENV"] == str(Path(r"C:\venv"))
      +        assert env_overlay["PYTHONPATH"] == os.pathsep.join(
      +            [r"C:\venv\Lib\site-packages", r"C:\existing"]
      +        )
      +
      +    def test_windows_falls_back_to_sibling_pythonw_when_resolver_fails(self, monkeypatch):
      +        import hermes_cli.web_server as ws
      +
      +        exe = "C:/venv/Scripts/python.exe"
      +        expected = "C:/venv/Scripts/pythonw.exe"
      +
      +        monkeypatch.setattr(ws.sys, "platform", "win32")
      +        monkeypatch.setattr(ws.sys, "executable", exe)
      +        monkeypatch.setattr(
      +            "hermes_cli.gateway_windows._resolve_detached_python",
      +            lambda _exe: (_ for _ in ()).throw(RuntimeError("boom")),
      +        )
      +        monkeypatch.setattr(ws.os.path, "isfile", lambda candidate: candidate == expected)
      +
      +        executable, env_overlay = ws._dashboard_spawn_details()
      +
      +        assert executable == expected
      +        assert env_overlay == {}
      +
      +    def test_windows_resolves_venv_from_project_when_virtual_env_missing(self, monkeypatch, tmp_path):
      +        import hermes_cli.web_server as ws
      +
      +        project = tmp_path / "project"
      +        scripts = project / "venv" / "Scripts"
      +        site_packages = project / "venv" / "Lib" / "site-packages"
      +        scripts.mkdir(parents=True)
      +        site_packages.mkdir(parents=True)
      +        (scripts / "python.exe").write_text("", encoding="utf-8")
      +
      +        monkeypatch.delenv("VIRTUAL_ENV", raising=False)
      +        monkeypatch.setattr(ws.sys, "platform", "win32")
      +        monkeypatch.setattr(ws.sys, "executable", r"C:\base\pythonw.exe")
      +        monkeypatch.setattr(ws, "PROJECT_ROOT", project)
      +        monkeypatch.setattr(
      +            "hermes_cli.gateway_windows._resolve_detached_python",
      +            lambda exe: (r"C:\base\pythonw.exe", project / "venv", []),
      +        )
      +
      +        executable, env_overlay = ws._dashboard_spawn_details()
      +
      +        assert executable == r"C:\base\pythonw.exe"
      +        assert env_overlay["VIRTUAL_ENV"] == str(project / "venv")
      +        assert str(site_packages) in env_overlay["PYTHONPATH"].split(os.pathsep)
      +
      +
       # ---------------------------------------------------------------------------
       # web_server tests (FastAPI endpoints)
       # ---------------------------------------------------------------------------
    • tests/test_hermes_bootstrap.py
      @@ -21,6 +21,7 @@
       
       import io
       import os
      +import platform
       import subprocess
       import sys
       import textwrap
      @@ -231,6 +232,82 @@ def reconfigure(self, **kwargs):
               hb.apply_windows_utf8_bootstrap()
       
       
      +class TestWindowsPlatformProbeGuard:
      +    def test_windows_bootstrap_disables_platform_syscmd_subprocess(self):
      +        hb = _fresh_import()
      +        hb._IS_WINDOWS = True
      +        hb._bootstrap_applied = False
      +
      +        original = getattr(platform, "_syscmd_ver", None)
      +        try:
      +            hb.apply_windows_utf8_bootstrap()
      +
      +            assert platform._syscmd_ver("Windows", "", "") == ("Windows", "", "")
      +        finally:
      +            if original is not None:
      +                platform._syscmd_ver = original
      +
      +
      +class TestDetachOrphanConsole:
      +    """detach_orphan_console() frees a solo-owned console (the uv pythonw→python
      +    phantom) but leaves a shared interactive console attached, and is a pure
      +    no-op on POSIX. It is intentionally NOT run at import time."""
      +
      +    def test_noop_on_posix(self):
      +        hb = _fresh_import()
      +        hb._IS_WINDOWS = False
      +        assert hb.detach_orphan_console() is False
      +
      +    def test_not_called_at_import_time(self):
      +        # The FreeConsole catch-all must be opt-in per background entry point,
      +        # never an import side effect (would detach the interactive CLI/TUI).
      +        import pathlib
      +        src = pathlib.Path(_fresh_import().__file__).read_text(encoding="utf-8")
      +        body = src.split("def detach_orphan_console")[0]
      +        assert "FreeConsole" not in body, (
      +            "FreeConsole must live only inside detach_orphan_console(), not in "
      +            "apply_windows_utf8_bootstrap() / module import path"
      +        )
      +
      +    def _fake_ctypes(self, monkeypatch, window, nproc):
      +        import ctypes
      +
      +        class _K:
      +            def __init__(self):
      +                self.freed = False
      +            def GetConsoleWindow(self):
      +                return window
      +            def GetConsoleProcessList(self, buf, n):
      +                return nproc
      +            def FreeConsole(self):
      +                self.freed = True
      +
      +        k = _K()
      +        monkeypatch.setattr(ctypes, "windll", type("_W", (), {"kernel32": k})(), raising=False)
      +        return k
      +
      +    def test_frees_when_solo_owner(self, monkeypatch):
      +        hb = _fresh_import()
      +        hb._IS_WINDOWS = True
      +        k = self._fake_ctypes(monkeypatch, window=1, nproc=1)
      +        assert hb.detach_orphan_console() is True
      +        assert k.freed is True
      +
      +    def test_leaves_shared_console_attached(self, monkeypatch):
      +        hb = _fresh_import()
      +        hb._IS_WINDOWS = True
      +        k = self._fake_ctypes(monkeypatch, window=1, nproc=2)
      +        assert hb.detach_orphan_console() is False
      +        assert k.freed is False
      +
      +    def test_noop_without_console(self, monkeypatch):
      +        hb = _fresh_import()
      +        hb._IS_WINDOWS = True
      +        k = self._fake_ctypes(monkeypatch, window=0, nproc=1)
      +        assert hb.detach_orphan_console() is False
      +        assert k.freed is False
      +
      +
       class TestEntryPointsImportBootstrap:
           """Every Hermes entry point must import hermes_bootstrap as its
           first non-docstring import.  We check this by scanning source files
    • tests/test_hermes_constants.py
      @@ -1,6 +1,8 @@
       """Tests for hermes_constants module."""
       
       import os
      +import subprocess
      +import sys
       from pathlib import Path
       
       import pytest
      @@ -612,6 +614,30 @@ def test_npx_fallback_form_accepted(self):
               assert agent_browser_runnable("/usr/local/bin/npx agent-browser") is True
       
       
      +class TestAgentBrowserRunnableWindows:
      +    def test_windows_validation_hides_console_subprocess(self, tmp_path, monkeypatch):
      +        from hermes_cli import _subprocess_compat
      +
      +        exe = tmp_path / "agent-browser-win32-x64.exe"
      +        exe.write_text("", encoding="utf-8")
      +        exe.chmod(0o755)
      +        captured = {}
      +
      +        def fake_run(args, **kwargs):
      +            captured["args"] = args
      +            captured.update(kwargs)
      +            return type("Result", (), {"returncode": 0})()
      +
      +        monkeypatch.setattr(sys, "platform", "win32")
      +        monkeypatch.setattr(_subprocess_compat, "IS_WINDOWS", True)
      +        monkeypatch.setattr(subprocess, "run", fake_run)
      +
      +        assert agent_browser_runnable(str(exe)) is True
      +        assert captured["args"] == [str(exe), "--version"]
      +        assert "creationflags" in captured
      +        assert captured["stdin"] is subprocess.DEVNULL
      +
      +
       class TestGetHermesDir:
           """Tests for ``get_hermes_dir(new_subpath, old_name)``.
       
    • tests/test_no_visible_console_spawns.py
      @@ -0,0 +1,121 @@
      +"""Enforcement for the "no visible terminal on Windows" invariant.
      +
      +Windows console-subsystem programs (``taskkill``, ``schtasks``, ``agent-browser``,
      +``git-bash`` …) pop a console window unless spawned with ``CREATE_NO_WINDOW``.
      +Relying on each call site to remember the flag is how cron-driven and future
      +spawns leaked terminal windows. The durable fix is a single chokepoint —
      +``hermes_cli._subprocess_compat.run`` / ``.popen`` — that always injects the
      +flag on Windows, plus the ``FreeConsole`` catch-all in ``hermes_bootstrap`` for
      +Python children.
      +
      +These tests pin both halves of that contract:
      +
      +1. The primitive actually injects ``CREATE_NO_WINDOW`` (and merges, so detach
      +   callers still work).
      +2. No source file spawns a known console exe with a *raw* ``subprocess`` call,
      +   which would bypass the primitive and reintroduce the window.
      +"""
      +
      +from __future__ import annotations
      +
      +import re
      +from pathlib import Path
      +
      +import pytest
      +
      +from hermes_cli import _subprocess_compat
      +
      +REPO_ROOT = Path(__file__).resolve().parent.parent
      +_CREATE_NO_WINDOW = 0x08000000
      +
      +
      +class TestPrimitiveInjectsNoWindow:
      +    def test_run_injects_create_no_window_on_windows(self, monkeypatch):
      +        captured: dict = {}
      +        monkeypatch.setattr(_subprocess_compat, "IS_WINDOWS", True)
      +        monkeypatch.setattr(
      +            _subprocess_compat.subprocess, "run", lambda cmd, **kw: captured.update(kw) or "ok"
      +        )
      +
      +        _subprocess_compat.run(["taskkill"], timeout=5)
      +
      +        assert captured["creationflags"] & _CREATE_NO_WINDOW
      +
      +    def test_popen_injects_create_no_window_on_windows(self, monkeypatch):
      +        captured: dict = {}
      +        monkeypatch.setattr(_subprocess_compat, "IS_WINDOWS", True)
      +        monkeypatch.setattr(
      +            _subprocess_compat.subprocess, "Popen", lambda cmd, **kw: captured.update(kw) or "ok"
      +        )
      +
      +        _subprocess_compat.popen(["agent-browser"])
      +
      +        assert captured["creationflags"] & _CREATE_NO_WINDOW
      +
      +    def test_merges_with_existing_detach_flags(self, monkeypatch):
      +        captured: dict = {}
      +        monkeypatch.setattr(_subprocess_compat, "IS_WINDOWS", True)
      +        monkeypatch.setattr(
      +            _subprocess_compat.subprocess, "run", lambda cmd, **kw: captured.update(kw) or "ok"
      +        )
      +
      +        detach = _subprocess_compat.windows_detach_flags()
      +        _subprocess_compat.run(["x"], creationflags=detach)
      +
      +        assert captured["creationflags"] & _CREATE_NO_WINDOW
      +        assert captured["creationflags"] & detach == detach
      +
      +    def test_no_op_on_posix(self, monkeypatch):
      +        captured: dict = {}
      +        monkeypatch.setattr(_subprocess_compat, "IS_WINDOWS", False)
      +        monkeypatch.setattr(
      +            _subprocess_compat.subprocess, "run", lambda cmd, **kw: captured.update(kw) or "ok"
      +        )
      +
      +        _subprocess_compat.run(["x"])
      +
      +        assert "creationflags" not in captured
      +
      +
      +# Windows-only console tools — they have no POSIX use, so a raw ``subprocess``
      +# spawn is unambiguously a Windows path that flashes a terminal. Banning them
      +# repo-wide is a pure win (cross-platform tools like git/ffmpeg/node are NOT
      +# listed: they have legitimate foreground/POSIX uses a blanket ban would break;
      +# their Windows-background call sites are routed through the primitive instead).
      +# ``_subprocess_compat.run/.popen`` calls never match these (different prefix).
      +_WINDOWS_ONLY_CONSOLE_EXES = ("taskkill", "schtasks", "wmic", "netstat", "tasklist")
      +_RAW_CONSOLE_SPAWNS = [
      +    re.compile(rf"""subprocess\.(?:run|Popen|call)\(\s*\[\s*["']{exe}["']""")
      +    for exe in _WINDOWS_ONLY_CONSOLE_EXES
      +]
      +
      +# The primitive itself is allowed to call raw subprocess — it IS the chokepoint.
      +_ALLOWED = {REPO_ROOT / "hermes_cli" / "_subprocess_compat.py"}
      +
      +
      +# Dev/CI tooling that never ships to a user's Windows desktop, where a flashing
      +# console is irrelevant and importing hermes_cli would be inappropriate.
      +_SKIP_DIRS = {"tests", "node_modules", ".venv", "venv", "scripts"}
      +
      +
      +def _python_sources():
      +    for path in REPO_ROOT.rglob("*.py"):
      +        if _SKIP_DIRS & set(path.parts):
      +            continue
      +        if path in _ALLOWED:
      +            continue
      +        yield path
      +
      +
      [redacted:email]("pattern", _RAW_CONSOLE_SPAWNS, ids=_WINDOWS_ONLY_CONSOLE_EXES)
      +def test_no_raw_console_exe_spawns(pattern):
      +    offenders = [
      +        str(path.relative_to(REPO_ROOT))
      +        for path in _python_sources()
      +        if pattern.search(path.read_text(encoding="utf-8", errors="ignore"))
      +    ]
      +
      +    assert not offenders, (
      +        "Console-subsystem exe spawned via raw subprocess (flashes a terminal on "
      +        f"Windows). Route through hermes_cli._subprocess_compat.run/.popen instead: {offenders}"
      +    )
    • tests/tools/test_browser_hardening.py
      @@ -89,6 +89,28 @@ def mock_exists(self):
               with pytest.raises(FileNotFoundError, match="cached"):
                   bt._find_agent_browser()
       
      +    def test_windows_prefers_native_agent_browser_exe_over_cmd_shim(self, tmp_path, monkeypatch):
      +        import tools.browser_tool as bt
      +
      +        repo = tmp_path / "repo"
      +        native = repo / "node_modules" / "agent-browser" / "bin" / "agent-browser-win32-x64.exe"
      +        cmd = repo / "node_modules" / ".bin" / "agent-browser.cmd"
      +        native.parent.mkdir(parents=True)
      +        cmd.parent.mkdir(parents=True)
      +        native.write_text("", encoding="utf-8")
      +        cmd.write_text("", encoding="utf-8")
      +
      +        def fake_which(command, path=None):
      +            return str(cmd) if path == str(cmd.parent) else None
      +
      +        monkeypatch.setattr(bt.sys, "platform", "win32")
      +        monkeypatch.setattr(bt.shutil, "which", fake_which)
      +        monkeypatch.setattr(bt, "agent_browser_runnable", lambda path: True)
      +        monkeypatch.setattr(bt, "_merge_browser_path", lambda path: "")
      +        monkeypatch.setattr(bt, "__file__", str(repo / "tools" / "browser_tool.py"))
      +
      +        assert bt._find_agent_browser() == str(native)
      +
       
       # ---------------------------------------------------------------------------
       # Caching: _get_command_timeout
    • tests/tools/test_windows_native_support.py
      @@ -188,11 +188,13 @@ class TestTerminatePidRoutingOnWindows:
       
           def test_force_uses_taskkill_on_windows(self, monkeypatch):
               from gateway import status
      +        from hermes_cli import _subprocess_compat
       
               captured = {}
       
               def fake_run(args, **kwargs):
                   captured["args"] = args
      +            captured.update(kwargs)
                   result = MagicMock()
                   result.returncode = 0
                   result.stderr = ""
      @@ -200,6 +202,7 @@ def fake_run(args, **kwargs):
                   return result
       
               monkeypatch.setattr(status, "_IS_WINDOWS", True)
      +        monkeypatch.setattr(_subprocess_compat, "IS_WINDOWS", True)
               monkeypatch.setattr(status.subprocess, "run", fake_run)
               status.terminate_pid(12345, force=True)
       
      @@ -208,6 +211,7 @@ def fake_run(args, **kwargs):
               assert "12345" in captured["args"]
               assert "/T" in captured["args"]
               assert "/F" in captured["args"]
      +        assert captured["creationflags"] & 0x08000000
       
           def test_force_taskkill_failure_raises_oserror(self, monkeypatch):
               from gateway import status
    • tools/browser_tool.py
      @@ -68,7 +68,7 @@
       from hermes_constants import agent_browser_runnable, get_hermes_home
       from utils import env_int, is_truthy_value
       from hermes_cli.config import DEFAULT_CONFIG, cfg_get
      -from hermes_cli._subprocess_compat import windows_hide_flags
      +from hermes_cli import _subprocess_compat
       
       try:
           from tools.website_policy import check_website_access
      @@ -905,23 +905,19 @@ def _run_tmp(cmd: str, cmd_args: List[str]) -> Dict[str, Any]:
                   #   fileno=1 (stderr dup'd onto stdout at the OS level).
                   # * close_fds=True → block inheritance of every other handle.
                   #   (Default on POSIX; must be explicit on Windows for stdio.)
      +            # CREATE_NO_WINDOW is applied by _subprocess_compat.popen. We do NOT
      +            # add CREATE_NEW_PROCESS_GROUP: on Python 3.11 Windows it interacts
      +            # with asyncio's ProactorEventLoop such that the subprocess creation
      +            # cancels the running loop task, surfacing as KeyboardInterrupt in
      +            # app.run() and tearing down the CLI mid-turn (diag:
      +            # "asyncio.CancelledError → KeyboardInterrupt").
                   _popen_extra: dict = {}
                   if os.name == "nt":
      -                # CREATE_NO_WINDOW → don't attach a console (cmd.exe would
      -                # otherwise briefly allocate one for the .cmd shim).
      -                # Do NOT add CREATE_NEW_PROCESS_GROUP: on Python 3.11 Windows
      -                # it interacts with asyncio's ProactorEventLoop such that the
      -                # subprocess creation cancels the running loop task, which
      -                # surfaces as KeyboardInterrupt in app.run() and tears down
      -                # the CLI mid-turn. The agent thread's subprocess spawn
      -                # unwound MainThread's prompt_toolkit loop that way — see
      -                # diag log: "asyncio.CancelledError → KeyboardInterrupt".
      -                _popen_extra["creationflags"] = windows_hide_flags()
                       _popen_extra["close_fds"] = True
                       _si = subprocess.STARTUPINFO()
                       _si.dwFlags |= subprocess.STARTF_USESTDHANDLES
                       _popen_extra["startupinfo"] = _si
      -            proc = subprocess.Popen(
      +            proc = _subprocess_compat.popen(
                       full, stdout=stdout_fd, stderr=stderr_fd,
                       stdin=subprocess.DEVNULL, env=browser_env,
                       **_popen_extra,
      @@ -1942,6 +1938,12 @@ def _find_agent_browser() -> str:
           repo_root = Path(__file__).parent.parent
           local_bin_dir = repo_root / "node_modules" / ".bin"
           if local_bin_dir.is_dir():
      +        if sys.platform == "win32":
      +            native = repo_root / "node_modules" / "agent-browser" / "bin" / "agent-browser-win32-x64.exe"
      +            if native.exists() and agent_browser_runnable(str(native)):
      +                _cached_agent_browser = str(native)
      +                _agent_browser_resolved = True
      +                return _cached_agent_browser
               local_which = shutil.which("agent-browser", path=str(local_bin_dir))
               if local_which and agent_browser_runnable(local_which):
                   _cached_agent_browser = local_which
      @@ -2191,17 +2193,16 @@ def _run_browser_command(
                   # three explicit handles (no leaked parent-console handles to
                   # confuse the Rust binary's daemon-spawn), and close_fds=True to
                   # block inheritance of everything else.
      +            # CREATE_NO_WINDOW via _subprocess_compat.popen; NO
      +            # CREATE_NEW_PROCESS_GROUP (cancels asyncio loop task on Python 3.11
      +            # Windows → KeyboardInterrupt in CLI MainThread).
                   _popen_extra: dict = {}
                   if os.name == "nt":
      -                # See matching block at the other Popen site — CREATE_NO_WINDOW
      -                # only, NO CREATE_NEW_PROCESS_GROUP (cancels asyncio loop task
      -                # on Python 3.11 Windows → KeyboardInterrupt in CLI MainThread).
      -                _popen_extra["creationflags"] = windows_hide_flags()
                       _popen_extra["close_fds"] = True
                       _si = subprocess.STARTUPINFO()
                       _si.dwFlags |= subprocess.STARTF_USESTDHANDLES
                       _popen_extra["startupinfo"] = _si
      -            proc = subprocess.Popen(
      +            proc = _subprocess_compat.popen(
                       cmd_parts,
                       stdout=stdout_fd,
                       stderr=stderr_fd,
    • tools/environments/local.py
      @@ -13,7 +13,7 @@
       from pathlib import Path
       
       from tools.environments.base import BaseEnvironment, _pipe_stdin
      -from hermes_cli._subprocess_compat import windows_hide_flags
      +from hermes_cli import _subprocess_compat
       
       _IS_WINDOWS = platform.system() == "Windows"
       
      @@ -738,9 +738,7 @@ def _run_bash(self, cmd_string: str, *, login: bool = False,
       
               _popen_cwd = self.cwd
       
      -        _popen_kwargs = {"creationflags": windows_hide_flags()} if _IS_WINDOWS else {}
      -
      -        proc = subprocess.Popen(
      +        proc = _subprocess_compat.popen(
                   args,
                   text=True,
                   env=run_env,
      @@ -751,7 +749,6 @@ def _run_bash(self, cmd_string: str, *, login: bool = False,
                   stdin=subprocess.PIPE if stdin_data is not None else subprocess.DEVNULL,
                   start_new_session=True,
                   cwd=_popen_cwd,
      -            **_popen_kwargs,
               )
               if not _IS_WINDOWS:
                   try:
    • tools/process_registry.py
      @@ -42,6 +42,7 @@
       
       _IS_WINDOWS = platform.system() == "Windows"
       from tools.environments.local import _find_shell, _resolve_safe_cwd, _sanitize_subprocess_env
      +from hermes_cli import _subprocess_compat
       from hermes_cli._subprocess_compat import windows_hide_flags
       from dataclasses import dataclass, field
       from typing import Any, Dict, List, Optional
      @@ -569,12 +570,11 @@ def _terminate_host_pid(cls, pid: int, expected_start: Optional[int] = None) ->
                   return
               if _IS_WINDOWS:
                   try:
      -                subprocess.run(
      +                _subprocess_compat.run(
                           ["taskkill", "/PID", str(pid), "/T", "/F"],
                           capture_output=True,
                           text=True,
                           timeout=10,
      -                    creationflags=windows_hide_flags(),
                           stdin=subprocess.DEVNULL,
                       )
                   except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
    • tools/transcription_tools.py
      @@ -37,6 +37,7 @@
       from typing import Optional, Dict, Any
       from urllib.parse import urljoin
       
      +from hermes_cli import _subprocess_compat
       from utils import is_truthy_value
       from tools.managed_tool_gateway import resolve_managed_tool_gateway
       from tools.tool_backend_helpers import (
      @@ -485,7 +486,7 @@ def _terminate_command_stt_process_tree(proc: subprocess.Popen) -> None:
       
           if os.name == "nt":
               try:
      -            subprocess.run(
      +            _subprocess_compat.run(
                       ["taskkill", "/F", "/T", "/PID", str(proc.pid)],
                       stdout=subprocess.DEVNULL,
                       stderr=subprocess.DEVNULL,
      @@ -556,7 +557,7 @@ def _run_command_stt(command: str, timeout: float) -> subprocess.CompletedProces
           else:
               popen_kwargs["start_new_session"] = True
       
      -    proc = subprocess.Popen(command, **popen_kwargs, stdin=subprocess.DEVNULL)
      +    proc = _subprocess_compat.popen(command, **popen_kwargs, stdin=subprocess.DEVNULL)
           try:
               stdout, stderr = proc.communicate(timeout=timeout)
           except subprocess.TimeoutExpired as exc:
      @@ -1187,7 +1188,7 @@ def _prepare_local_audio(file_path: str, work_dir: str) -> tuple[Optional[str],
           command = [ffmpeg, "-y", "-i", file_path, converted_path]
       
           try:
      -        subprocess.run(command, check=True, capture_output=True, text=True, timeout=300, stdin=subprocess.DEVNULL)
      +        _subprocess_compat.run(command, check=True, capture_output=True, text=True, timeout=300, stdin=subprocess.DEVNULL)
               return converted_path, None
           except subprocess.TimeoutExpired:
               logger.error("ffmpeg conversion timed out for %s", file_path)
      @@ -1233,9 +1234,9 @@ def _transcribe_local_command(file_path: str, model_name: str) -> Dict[str, Any]
                   # User-provided templates (env var) may contain shell syntax; auto-detected commands are safe for list mode.
                   use_shell = bool(os.getenv(LOCAL_STT_COMMAND_ENV, "").strip())
                   if use_shell:
      -                subprocess.run(command, shell=True, check=True, capture_output=True, text=True, timeout=300, stdin=subprocess.DEVNULL)
      +                _subprocess_compat.run(command, shell=True, check=True, capture_output=True, text=True, timeout=300, stdin=subprocess.DEVNULL)
                   else:
      -                subprocess.run(shlex.split(command), check=True, capture_output=True, text=True, timeout=300, stdin=subprocess.DEVNULL)
      +                _subprocess_compat.run(shlex.split(command), check=True, capture_output=True, text=True, timeout=300, stdin=subprocess.DEVNULL)
                   
       
                   txt_files = sorted(Path(output_dir).glob("*.txt"))
    • tools/tts_tool.py
      @@ -52,6 +52,7 @@
       from typing import Callable, Dict, Any, Optional
       from urllib.parse import urljoin
       
      +from hermes_cli import _subprocess_compat
       from hermes_constants import display_hermes_home
       
       logger = logging.getLogger(__name__)
      @@ -714,7 +715,7 @@ def _terminate_command_tts_process_tree(proc: subprocess.Popen) -> None:
       
           if os.name == "nt":
               try:
      -            subprocess.run(
      +            _subprocess_compat.run(
                       ["taskkill", "/F", "/T", "/PID", str(proc.pid)],
                       stdout=subprocess.DEVNULL,
                       stderr=subprocess.DEVNULL,
      @@ -772,7 +773,7 @@ def _run_command_tts(command: str, timeout: float) -> subprocess.CompletedProces
           else:
               popen_kwargs["start_new_session"] = True
       
      -    proc = subprocess.Popen(command, **popen_kwargs, stdin=subprocess.DEVNULL)
      +    proc = _subprocess_compat.popen(command, **popen_kwargs, stdin=subprocess.DEVNULL)
           try:
               stdout, stderr = proc.communicate(timeout=timeout)
           except subprocess.TimeoutExpired as exc:
      @@ -905,7 +906,7 @@ def _convert_to_opus(mp3_path: str) -> Optional[str]:
       
           ogg_path = mp3_path.rsplit(".", 1)[0] + ".ogg"
           try:
      -        result = subprocess.run(
      +        result = _subprocess_compat.run(
                   ["ffmpeg", "-i", mp3_path, "-acodec", "libopus",
                    "-ac", "1", "-b:a", "64k", "-vbr", "off", ogg_path, "-y"],
                   capture_output=True, timeout=30,
      @@ -1776,7 +1777,7 @@ def _generate_gemini_tts(text: str, output_path: str, tts_config: Dict[str, Any]
                       ]
                   else:
                       cmd = [ffmpeg, "-i", wav_path, "-y", "-loglevel", "error", output_path]
      -            result = subprocess.run(cmd, capture_output=True, timeout=30, stdin=subprocess.DEVNULL)
      +            result = _subprocess_compat.run(cmd, capture_output=True, timeout=30, stdin=subprocess.DEVNULL)
                   if result.returncode != 0:
                       stderr = result.stderr.decode("utf-8", errors="ignore")[:300]
                       raise RuntimeError(f"ffmpeg conversion failed: {stderr}")
      @@ -1859,7 +1860,7 @@ def _generate_neutts(text: str, output_path: str, tts_config: Dict[str, Any]) ->
               "--device", device,
           ]
       
      -    result = subprocess.run(cmd, capture_output=True, text=True, timeout=120, stdin=subprocess.DEVNULL)
      +    result = _subprocess_compat.run(cmd, capture_output=True, text=True, timeout=120, stdin=subprocess.DEVNULL)
           if result.returncode != 0:
               stderr = result.stderr.strip()
               # Filter out the "OK:" line from stderr
      @@ -1871,7 +1872,7 @@ def _generate_neutts(text: str, output_path: str, tts_config: Dict[str, Any]) ->
               ffmpeg = shutil.which("ffmpeg")
               if ffmpeg:
                   conv_cmd = [ffmpeg, "-i", wav_path, "-y", "-loglevel", "error", output_path]
      -            subprocess.run(conv_cmd, check=True, timeout=30, stdin=subprocess.DEVNULL, creationflags=windows_hide_flags())
      +            _subprocess_compat.run(conv_cmd, check=True, timeout=30, stdin=subprocess.DEVNULL)
                   os.remove(wav_path)
               else:
                   # No ffmpeg — just rename the WAV to the expected path
      @@ -1938,7 +1939,7 @@ def _resolve_piper_voice_path(voice: str, download_dir: Path) -> str:
           import sys as _sys
           logger.info("[Piper] Downloading voice '%s' to %s (first use)", voice, download_dir)
           try:
      -        result = subprocess.run(
      +        result = _subprocess_compat.run(
                   [_sys.executable, "-m", "piper.download_voices", voice,
                    "--download-dir", str(download_dir)],
                   capture_output=True, text=True, timeout=300,
      @@ -2050,7 +2051,7 @@ def _generate_piper_tts(text: str, output_path: str, tts_config: Dict[str, Any])
               ffmpeg = shutil.which("ffmpeg")
               if ffmpeg:
                   conv_cmd = [ffmpeg, "-i", wav_path, "-y", "-loglevel", "error", output_path]
      -            subprocess.run(conv_cmd, check=True, timeout=30, stdin=subprocess.DEVNULL, creationflags=windows_hide_flags())
      +            _subprocess_compat.run(conv_cmd, check=True, timeout=30, stdin=subprocess.DEVNULL)
                   try:
                       os.remove(wav_path)
                   except OSError:
      @@ -2116,7 +2117,7 @@ def _generate_kittentts(text: str, output_path: str, tts_config: Dict[str, Any])
               ffmpeg = shutil.which("ffmpeg")
               if ffmpeg:
                   conv_cmd = [ffmpeg, "-i", wav_path, "-y", "-loglevel", "error", output_path]
      -            subprocess.run(conv_cmd, check=True, timeout=30, stdin=subprocess.DEVNULL, creationflags=windows_hide_flags())
      +            _subprocess_compat.run(conv_cmd, check=True, timeout=30, stdin=subprocess.DEVNULL)
                   os.remove(wav_path)
               else:
                   # No ffmpeg — rename the WAV to the expected path
      @@ -2812,7 +2813,6 @@ def _check(importer, label):
       # Registry
       # ---------------------------------------------------------------------------
       from tools.registry import registry, tool_error
      -from hermes_cli._subprocess_compat import windows_hide_flags
       
       TTS_SCHEMA = {
           "name": "text_to_speech",
    • tui_gateway/entry.py
      @@ -260,6 +260,13 @@ def join_mcp_discovery(timeout: float | None = None) -> bool:
       
       
       def main():
      +    # Stdio backend spawned by Node/Electron: drop any console a uv
      +    # pythonw→python re-exec auto-allocated. No-op on POSIX.
      +    try:
      +        hermes_bootstrap.detach_orphan_console()
      +    except Exception:
      +        pass
      +
           _install_sidecar_publisher()
       
           # MCP tool discovery — runs in a background daemon thread so a slow or
    • tui_gateway/git_probe.py
      @@ -46,7 +46,9 @@ def run_git(cwd: str, *args: str) -> str:
           if not cwd:
               return ""
           try:
      -        result = subprocess.run(
      +        from hermes_cli import _subprocess_compat
      +
      +        result = _subprocess_compat.run(
                   ["git", "-C", cwd, *args],
                   capture_output=True,
                   text=True,
    • tui_gateway/slash_worker.py
      @@ -93,6 +93,14 @@ def _run(cli: HermesCLI, command: str) -> str:
       
       
       def main():
      +    # Stdio worker spawned by the gateway: drop any console a uv pythonw→python
      +    # re-exec auto-allocated. No-op on POSIX.
      +    try:
      +        import hermes_bootstrap
      +        hermes_bootstrap.detach_orphan_console()
      +    except Exception:
      +        pass
      +
           p = argparse.ArgumentParser(add_help=False)
           p.add_argument("--session-key", required=True)
           p.add_argument("--model", default="")
  • #53892 fix(windows): hide backend console-flash legs main missed (Electron base-pythonw, ffmpeg, netstat)

    merged to main

    files: apps/desktop/electron/main.cjs, apps/desktop/electron/windows-child-process.test.cjs, plugins/platforms/discord/adapter.py, plugins/platforms/whatsapp/adapter.py, tests/test_windows_subprocess_no_window_flags.py, tools/transcription_tools.py, tools/tts_tool.py

    Show the diff
    • apps/desktop/electron/main.cjs
      @@ -1572,19 +1572,17 @@ function readVenvHome(venvRoot) {
       function getNoConsoleVenvPython(venvRoot) {
         if (!IS_WINDOWS) return getVenvPython(venvRoot)
       
      -  // Prefer the venv's own pythonw shim — it carries pyvenv.cfg / site-packages
      -  // wiring. Falling back to the base uv/python.org pythonw.exe skips the venv
      -  // and breaks imports (yaml, hermes_cli, …) even when PYTHONPATH is patched.
      -  const venvPythonw = path.join(venvRoot, 'Scripts', 'pythonw.exe')
      -  if (fileExists(venvPythonw)) return venvPythonw
      -
      +  // The venv's ``Scripts\pythonw.exe`` is a uv launcher shim that re-execs the
      +  // base console ``python.exe``, allocating a conhost/Windows Terminal window
      +  // that CREATE_NO_WINDOW can't suppress. Use the base ``pythonw.exe`` directly;
      +  // callers put the venv site-packages on PYTHONPATH so imports still resolve.
         const baseHome = readVenvHome(venvRoot)
         if (baseHome) {
           const basePythonw = path.join(baseHome, 'pythonw.exe')
           if (fileExists(basePythonw)) return basePythonw
         }
       
      -  return venvPythonw
      +  return path.join(venvRoot, 'Scripts', 'pythonw.exe')
       }
       
       function toNoConsolePython(pythonPath) {
      @@ -2847,7 +2845,7 @@ function createPythonBackend(root, label, dashboardArgs, options = {}) {
           args: ['-m', 'hermes_cli.main', ...dashboardArgs],
           env: buildDesktopBackendEnv({
             hermesHome: HERMES_HOME,
      -      pythonPathEntries: [root],
      +      pythonPathEntries: [root, ...getVenvSitePackagesEntries(venvRoot)],
             venvRoot
           }),
           root,
      @@ -2871,7 +2869,7 @@ function createActiveBackend(dashboardArgs) {
           args: ['-m', 'hermes_cli.main', ...dashboardArgs],
           env: buildDesktopBackendEnv({
             hermesHome: HERMES_HOME,
      -      pythonPathEntries: [ACTIVE_HERMES_ROOT],
      +      pythonPathEntries: [ACTIVE_HERMES_ROOT, ...getVenvSitePackagesEntries(VENV_ROOT)],
             venvRoot: VENV_ROOT
           }),
           root: ACTIVE_HERMES_ROOT,
    • apps/desktop/electron/windows-child-process.test.cjs
      @@ -53,6 +53,22 @@ test('desktop background child processes opt into hidden Windows consoles', () =
         assert.match(source, /args: \['-m', 'hermes_cli\.main', \.\.\.dashboardArgs\]/)
       })
       
      +test('getNoConsoleVenvPython prefers base pythonw over the uv re-exec shim', () => {
      +  const source = readElectronFile('main.cjs')
      +  const body = source.slice(
      +    source.indexOf('function getNoConsoleVenvPython(venvRoot)'),
      +    source.indexOf('function getVenvSitePackagesEntries(venvRoot)')
      +  )
      +
      +  // The venv Scripts\pythonw.exe re-execs a console python.exe (flashes a
      +  // conhost); the base pythonw must be resolved first so it never runs.
      +  const baseIdx = body.indexOf('basePythonw')
      +  const shimIdx = body.indexOf("'Scripts', 'pythonw.exe'")
      +  assert.notEqual(baseIdx, -1, 'base pythonw resolution missing')
      +  assert.notEqual(shimIdx, -1, 'venv shim fallback missing')
      +  assert.ok(baseIdx < shimIdx, 'base pythonw must be preferred before the venv Scripts shim')
      +})
      +
       test('intentional or interactive desktop child processes stay documented', () => {
         const source = readElectronFile('main.cjs')
       
    • plugins/platforms/discord/adapter.py
      @@ -665,6 +665,8 @@ def pcm_to_wav(pcm_data: bytes, output_path: str,
                   f.write(pcm_data)
                   pcm_path = f.name
               try:
      +            from hermes_cli._subprocess_compat import windows_hide_flags
      +
                   subprocess.run(
                       [
                           "ffmpeg", "-y", "-loglevel", "error",
      @@ -679,6 +681,7 @@ def pcm_to_wav(pcm_data: bytes, output_path: str,
                       check=True,
                       timeout=10,
                       stdin=subprocess.DEVNULL,
      +                creationflags=windows_hide_flags(),
                   )
               finally:
                   try:
    • plugins/platforms/whatsapp/adapter.py
      @@ -78,10 +78,13 @@ 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._subprocess_compat import windows_hide_flags
      +
                   # Use netstat to find the PID bound to this port, then taskkill
                   result = subprocess.run(
                       ["netstat", "-ano", "-p", "TCP"],
                       capture_output=True, text=True, timeout=5,
      +                creationflags=windows_hide_flags(),
                   )
                   for line in result.stdout.splitlines():
                       parts = line.split()
      @@ -92,6 +95,7 @@ def _kill_port_process(port: int) -> None:
                                   subprocess.run(
                                       ["taskkill", "/PID", parts[4], "/F"],
                                       capture_output=True, timeout=5,
      +                                creationflags=windows_hide_flags(),
                                   )
                               except subprocess.SubprocessError:
                                   pass
    • tests/test_windows_subprocess_no_window_flags.py
      @@ -261,3 +261,41 @@ def fake_run(cmd, **kwargs):
           assert skill_preprocessing.run_inline_shell("echo ok", cwd=None, timeout=5) == "ok"
           assert captured[0][0] == ["bash", "-c", "echo ok"]
           assert captured[0][1]["creationflags"] == _CREATE_NO_WINDOW
      +
      +
      +def test_tts_opus_conversion_hides_ffmpeg_window(monkeypatch, tmp_path):
      +    from tools import tts_tool
      +
      +    captured = []
      +
      +    def fake_run(cmd, **kwargs):
      +        captured.append((cmd, kwargs))
      +        return _Completed(returncode=0)
      +
      +    monkeypatch.setattr(tts_tool, "_has_ffmpeg", lambda: True)
      +    monkeypatch.setattr(tts_tool, "windows_hide_flags", lambda: _CREATE_NO_WINDOW)
      +    monkeypatch.setattr(tts_tool.subprocess, "run", fake_run)
      +
      +    tts_tool._convert_to_opus(str(tmp_path / "v.mp3"))
      +
      +    assert captured[0][0][0] == "ffmpeg"
      +    assert captured[0][1]["creationflags"] == _CREATE_NO_WINDOW
      +
      +
      +def test_local_stt_audio_prep_hides_ffmpeg_window(monkeypatch, tmp_path):
      +    from tools import transcription_tools
      +
      +    captured = []
      +
      +    def fake_run(cmd, **kwargs):
      +        captured.append((cmd, kwargs))
      +        return _Completed(returncode=0)
      +
      +    monkeypatch.setattr(transcription_tools, "_find_ffmpeg_binary", lambda: "ffmpeg")
      +    monkeypatch.setattr(transcription_tools, "windows_hide_flags", lambda: _CREATE_NO_WINDOW)
      +    monkeypatch.setattr(transcription_tools.subprocess, "run", fake_run)
      +
      +    transcription_tools._prepare_local_audio(str(tmp_path / "in.m4a"), str(tmp_path))
      +
      +    assert captured[0][0][0] == "ffmpeg"
      +    assert captured[0][1]["creationflags"] == _CREATE_NO_WINDOW
    • tools/transcription_tools.py
      @@ -37,6 +37,7 @@
       from typing import Optional, Dict, Any
       from urllib.parse import urljoin
       
      +from hermes_cli._subprocess_compat import windows_hide_flags
       from utils import is_truthy_value
       from tools.managed_tool_gateway import resolve_managed_tool_gateway
       from tools.tool_backend_helpers import (
      @@ -1187,7 +1188,7 @@ def _prepare_local_audio(file_path: str, work_dir: str) -> tuple[Optional[str],
           command = [ffmpeg, "-y", "-i", file_path, converted_path]
       
           try:
      -        subprocess.run(command, check=True, capture_output=True, text=True, timeout=300, stdin=subprocess.DEVNULL)
      +        subprocess.run(command, check=True, capture_output=True, text=True, timeout=300, stdin=subprocess.DEVNULL, creationflags=windows_hide_flags())
               return converted_path, None
           except subprocess.TimeoutExpired:
               logger.error("ffmpeg conversion timed out for %s", file_path)
      @@ -1233,9 +1234,9 @@ def _transcribe_local_command(file_path: str, model_name: str) -> Dict[str, Any]
                   # User-provided templates (env var) may contain shell syntax; auto-detected commands are safe for list mode.
                   use_shell = bool(os.getenv(LOCAL_STT_COMMAND_ENV, "").strip())
                   if use_shell:
      -                subprocess.run(command, shell=True, check=True, capture_output=True, text=True, timeout=300, stdin=subprocess.DEVNULL)
      +                subprocess.run(command, shell=True, check=True, capture_output=True, text=True, timeout=300, stdin=subprocess.DEVNULL, creationflags=windows_hide_flags())
                   else:
      -                subprocess.run(shlex.split(command), check=True, capture_output=True, text=True, timeout=300, stdin=subprocess.DEVNULL)
      +                subprocess.run(shlex.split(command), check=True, capture_output=True, text=True, timeout=300, stdin=subprocess.DEVNULL, creationflags=windows_hide_flags())
                   
       
                   txt_files = sorted(Path(output_dir).glob("*.txt"))
    • tools/tts_tool.py
      @@ -52,6 +52,7 @@
       from typing import Callable, Dict, Any, Optional
       from urllib.parse import urljoin
       
      +from hermes_cli._subprocess_compat import windows_hide_flags
       from hermes_constants import display_hermes_home
       
       logger = logging.getLogger(__name__)
      @@ -910,6 +911,7 @@ def _convert_to_opus(mp3_path: str) -> Optional[str]:
                    "-ac", "1", "-b:a", "64k", "-vbr", "off", ogg_path, "-y"],
                   capture_output=True, timeout=30,
                   stdin=subprocess.DEVNULL,
      +            creationflags=windows_hide_flags(),
               )
               if result.returncode != 0:
                   logger.warning("ffmpeg conversion failed with return code %d: %s", 
      @@ -1776,7 +1778,7 @@ def _generate_gemini_tts(text: str, output_path: str, tts_config: Dict[str, Any]
                       ]
                   else:
                       cmd = [ffmpeg, "-i", wav_path, "-y", "-loglevel", "error", output_path]
      -            result = subprocess.run(cmd, capture_output=True, timeout=30, stdin=subprocess.DEVNULL)
      +            result = subprocess.run(cmd, capture_output=True, timeout=30, stdin=subprocess.DEVNULL, creationflags=windows_hide_flags())
                   if result.returncode != 0:
                       stderr = result.stderr.decode("utf-8", errors="ignore")[:300]
                       raise RuntimeError(f"ffmpeg conversion failed: {stderr}")
      @@ -1871,7 +1873,7 @@ def _generate_neutts(text: str, output_path: str, tts_config: Dict[str, Any]) ->
               ffmpeg = shutil.which("ffmpeg")
               if ffmpeg:
                   conv_cmd = [ffmpeg, "-i", wav_path, "-y", "-loglevel", "error", output_path]
      -            subprocess.run(conv_cmd, check=True, timeout=30, stdin=subprocess.DEVNULL)
      +            subprocess.run(conv_cmd, check=True, timeout=30, stdin=subprocess.DEVNULL, creationflags=windows_hide_flags())
                   os.remove(wav_path)
               else:
                   # No ffmpeg — just rename the WAV to the expected path
      @@ -2050,7 +2052,7 @@ def _generate_piper_tts(text: str, output_path: str, tts_config: Dict[str, Any])
               ffmpeg = shutil.which("ffmpeg")
               if ffmpeg:
                   conv_cmd = [ffmpeg, "-i", wav_path, "-y", "-loglevel", "error", output_path]
      -            subprocess.run(conv_cmd, check=True, timeout=30, stdin=subprocess.DEVNULL)
      +            subprocess.run(conv_cmd, check=True, timeout=30, stdin=subprocess.DEVNULL, creationflags=windows_hide_flags())
                   try:
                       os.remove(wav_path)
                   except OSError:
      @@ -2116,7 +2118,7 @@ def _generate_kittentts(text: str, output_path: str, tts_config: Dict[str, Any])
               ffmpeg = shutil.which("ffmpeg")
               if ffmpeg:
                   conv_cmd = [ffmpeg, "-i", wav_path, "-y", "-loglevel", "error", output_path]
      -            subprocess.run(conv_cmd, check=True, timeout=30, stdin=subprocess.DEVNULL)
      +            subprocess.run(conv_cmd, check=True, timeout=30, stdin=subprocess.DEVNULL, creationflags=windows_hide_flags())
                   os.remove(wav_path)
               else:
                   # No ffmpeg — rename the WAV to the expected path
  • #54236 fix(windows): hide console-window flash on backend git/gh/wmic/bash subprocess spawns

    merged to main

    files: agent/coding_context.py, agent/context_references.py, agent/shell_hooks.py, agent/skill_preprocessing.py, gateway/status.py, hermes_cli/copilot_auth.py, hermes_cli/gateway.py, hermes_cli/main.py, tests/gateway/test_status.py, tests/test_windows_subprocess_no_window_flags.py, tui_gateway/git_probe.py, tui_gateway/server.py

    Show the diff
    • agent/coding_context.py
      @@ -60,6 +60,8 @@
       from pathlib import Path
       from typing import Any, Optional
       
      +from hermes_cli._subprocess_compat import IS_WINDOWS, windows_hide_flags
      +
       logger = logging.getLogger("hermes.coding_context")
       
       CODING_TOOLSET = "coding"
      @@ -647,12 +649,14 @@ def _enabled_mcp_servers(config: Optional[dict[str, Any]]) -> list[str]:
       
       
       def _git(cwd: Path, *args: str) -> str:
      +    _popen_kwargs = {"creationflags": windows_hide_flags()} if IS_WINDOWS else {}
           try:
               out = subprocess.run(
                   ["git", "-C", str(cwd), *args],
                   capture_output=True,
                   text=True,
                   timeout=_GIT_TIMEOUT,
      +            **_popen_kwargs,
               )
           except (OSError, subprocess.SubprocessError):
               return ""
    • agent/context_references.py
      @@ -12,6 +12,7 @@
       from typing import Awaitable, Callable
       
       from agent.model_metadata import estimate_tokens_rough
      +from hermes_cli._subprocess_compat import IS_WINDOWS, windows_hide_flags
       
       _QUOTED_REFERENCE_VALUE = r'(?:`[^`\n]+`|"[^"\n]+"|\'[^\'\n]+\')'
       REFERENCE_PATTERN = re.compile(
      @@ -290,6 +291,7 @@ def _expand_git_reference(
           args: list[str],
           label: str,
       ) -> tuple[str | None, str | None]:
      +    _popen_kwargs = {"creationflags": windows_hide_flags()} if IS_WINDOWS else {}
           try:
               result = subprocess.run(
                   ["git", *args],
      @@ -298,6 +300,7 @@ def _expand_git_reference(
                   text=True,
                   timeout=30,
                   stdin=subprocess.DEVNULL,
      +            **_popen_kwargs,
               )
           except subprocess.TimeoutExpired:
               return f"{ref.raw}: git command timed out (30s)", None
      @@ -483,6 +486,7 @@ def _iter_visible_entries(path: Path, cwd: Path, limit: int) -> list[Path]:
       
       
       def _rg_files(path: Path, cwd: Path, limit: int) -> list[Path] | None:
      +    _popen_kwargs = {"creationflags": windows_hide_flags()} if IS_WINDOWS else {}
           try:
               result = subprocess.run(
                   ["rg", "--files", str(path.relative_to(cwd))],
      @@ -491,6 +495,7 @@ def _rg_files(path: Path, cwd: Path, limit: int) -> list[Path] | None:
                   text=True,
                   timeout=10,
                   stdin=subprocess.DEVNULL,
      +            **_popen_kwargs,
               )
           except (FileNotFoundError, OSError, subprocess.TimeoutExpired):
               return None
    • agent/shell_hooks.py
      @@ -122,6 +122,8 @@
       from pathlib import Path
       from typing import Any, Callable, Dict, Iterator, List, Optional, Set, Tuple
       
      +from hermes_cli._subprocess_compat import IS_WINDOWS, windows_hide_flags
      +
       try:
           import fcntl  # POSIX only; Windows falls back to best-effort without flock.
       except ImportError:  # pragma: no cover
      @@ -441,6 +443,7 @@ def _spawn(spec: ShellHookSpec, stdin_json: str) -> Dict[str, Any]:
               return result
       
           t0 = time.monotonic()
      +    _popen_kwargs = {"creationflags": windows_hide_flags()} if IS_WINDOWS else {}
           try:
               proc = subprocess.run(
                   argv,
      @@ -449,6 +452,7 @@ def _spawn(spec: ShellHookSpec, stdin_json: str) -> Dict[str, Any]:
                   timeout=spec.timeout,
                   text=True,
                   shell=False,
      +            **_popen_kwargs,
               )
           except subprocess.TimeoutExpired:
               result["timed_out"] = True
    • agent/skill_preprocessing.py
      @@ -5,6 +5,8 @@
       import subprocess
       from pathlib import Path
       
      +from hermes_cli._subprocess_compat import IS_WINDOWS, windows_hide_flags
      +
       logger = logging.getLogger(__name__)
       
       # Matches ${HERMES_SKILL_DIR} / ${HERMES_SESSION_ID} tokens in SKILL.md.
      @@ -66,6 +68,7 @@ def run_inline_shell(command: str, cwd: Path | None, timeout: int) -> str:
           Failures return a short ``[inline-shell error: ...]`` marker instead of
           raising, so one bad snippet can't wreck the whole skill message.
           """
      +    _popen_kwargs = {"creationflags": windows_hide_flags()} if IS_WINDOWS else {}
           try:
               completed = subprocess.run(
                   ["bash", "-c", command],
      @@ -75,6 +78,7 @@ def run_inline_shell(command: str, cwd: Path | None, timeout: int) -> str:
                   timeout=max(1, int(timeout)),
                   check=False,
                   stdin=subprocess.DEVNULL,
      +            **_popen_kwargs,
               )
           except subprocess.TimeoutExpired:
               return f"[inline-shell timeout after {timeout}s: {command}]"
    • gateway/status.py
      @@ -81,12 +81,18 @@ def terminate_pid(pid: int, *, force: bool = False) -> None:
           because os.kill(..., SIGTERM) is not equivalent to a tree-killing hard stop.
           """
           if force and _IS_WINDOWS:
      +        # CREATE_NO_WINDOW: terminate_pid runs from the windowless pythonw.exe
      +        # gateway/desktop backend, so a bare taskkill spawn would flash a
      +        # conhost window on every force-kill.
      +        from hermes_cli._subprocess_compat import windows_hide_flags
      +
               try:
                   result = subprocess.run(
                       ["taskkill", "/PID", str(pid), "/T", "/F"],
                       capture_output=True,
                       text=True,
                       timeout=10,
      +                creationflags=windows_hide_flags(),
                   )
               except FileNotFoundError:
                   os.kill(pid, signal.SIGTERM)
    • hermes_cli/copilot_auth.py
      @@ -27,6 +27,8 @@
       from pathlib import Path
       from typing import Optional
       
      +from hermes_cli._subprocess_compat import IS_WINDOWS, windows_hide_flags
      +
       logger = logging.getLogger(__name__)
       
       # OAuth device code flow constants (same client ID as opencode/Copilot CLI)
      @@ -130,6 +132,7 @@ def _try_gh_cli_token() -> Optional[str]:
           clean_env = {k: v for k, v in os.environ.items()
                        if k not in {"GITHUB_TOKEN", "GH_TOKEN"}}
       
      +    _popen_kwargs = {"creationflags": windows_hide_flags()} if IS_WINDOWS else {}
           for gh_path in _gh_cli_candidates():
               cmd = [gh_path, "auth", "token"]
               if hostname:
      @@ -141,6 +144,7 @@ def _try_gh_cli_token() -> Optional[str]:
                       text=True,
                       timeout=5,
                       env=clean_env,
      +                **_popen_kwargs,
                   )
               except (FileNotFoundError, subprocess.TimeoutExpired) as exc:
                   logger.debug("gh CLI token lookup failed (%s): %s", gh_path, exc)
    • hermes_cli/gateway.py
      @@ -384,6 +384,12 @@ def _matches_gateway_runtime(command: str) -> bool:
                   # removed as part of the WMIC deprecation — fall back to
                   # PowerShell's Get-CimInstance.  Any OSError here (FileNotFoundError
                   # on missing wmic) trips the fallback.
      +            # Hide the console window: this scan runs inside the windowless
      +            # pythonw.exe gateway/desktop backend, so a bare wmic/powershell
      +            # spawn would flash a conhost window on every watchdog probe.
      +            from hermes_cli._subprocess_compat import windows_hide_flags
      +
      +            _no_window = {"creationflags": windows_hide_flags()}
                   wmic_path = shutil.which("wmic")
                   used_fallback = False
                   result = None
      @@ -402,6 +408,7 @@ def _matches_gateway_runtime(command: str) -> bool:
                               encoding="utf-8",
                               errors="ignore",
                               timeout=10,
      +                        **_no_window,
                           )
                       except (OSError, subprocess.TimeoutExpired):
                           result = None
      @@ -427,6 +434,7 @@ def _matches_gateway_runtime(command: str) -> bool:
                               encoding="utf-8",
                               errors="ignore",
                               timeout=15,
      +                        **_no_window,
                           )
                       except (OSError, subprocess.TimeoutExpired):
                           return []
    • hermes_cli/main.py
      @@ -5825,6 +5825,11 @@ def _find_stale_dashboard_pids(
                   # here is errors="ignore": it prevents a reader-thread
                   # UnicodeDecodeError from leaving result.stdout=None and turning
                   # the later .split() into an AttributeError (#17049).
      +            # CREATE_NO_WINDOW hides the conhost flash: this scan can run from
      +            # the windowless pythonw.exe desktop/gateway backend during an
      +            # update, where a bare wmic spawn would pop a console window.
      +            from hermes_cli._subprocess_compat import windows_hide_flags
      +
                   result = subprocess.run(
                       ["wmic", "process", "get", "ProcessId,CommandLine", "/FORMAT:LIST"],
                       capture_output=True,
      @@ -5832,6 +5837,7 @@ def _find_stale_dashboard_pids(
                       timeout=10,
                       encoding="utf-8",
                       errors="ignore",
      +                creationflags=windows_hide_flags(),
                   )
                   if result.returncode != 0 or result.stdout is None:
                       return []
    • tests/gateway/test_status.py
      @@ -621,16 +621,22 @@ def test_force_uses_taskkill_on_windows(self, monkeypatch):
               calls = []
               monkeypatch.setattr(status, "_IS_WINDOWS", True)
       
      -        def fake_run(cmd, capture_output=False, text=False, timeout=None):
      -            calls.append((cmd, capture_output, text, timeout))
      +        def fake_run(cmd, capture_output=False, text=False, timeout=None, creationflags=0):
      +            calls.append((cmd, capture_output, text, timeout, creationflags))
                   return SimpleNamespace(returncode=0, stdout="", stderr="")
       
               monkeypatch.setattr(status.subprocess, "run", fake_run)
       
               status.terminate_pid(123, force=True)
       
      +        # taskkill is spawned with the no-window flag so the windowless
      +        # pythonw.exe backend doesn't flash a conhost window on force-kill.
      +        # windows_hide_flags() is 0 on the POSIX test host (a valid no-op
      +        # creationflags value); on real Windows it is CREATE_NO_WINDOW.
      +        from hermes_cli._subprocess_compat import windows_hide_flags
      +
               assert calls == [
      -            (["taskkill", "/PID", "123", "/T", "/F"], True, True, 10)
      +            (["taskkill", "/PID", "123", "/T", "/F"], True, True, 10, windows_hide_flags())
               ]
       
           def test_force_falls_back_to_sigterm_when_taskkill_missing(self, monkeypatch):
    • tests/test_windows_subprocess_no_window_flags.py
      @@ -0,0 +1,263 @@
      +from __future__ import annotations
      +
      +import subprocess
      +from pathlib import Path
      +from types import SimpleNamespace
      +
      +
      +_CREATE_NO_WINDOW = 0x08000000
      +
      +
      +class _Completed:
      +    def __init__(self, stdout: str | bytes = "ok\n", returncode: int = 0):
      +        self.stdout = stdout
      +        self.stderr = ""
      +        self.returncode = returncode
      +
      +
      +def test_tui_gateway_git_probe_hides_git_windows(monkeypatch):
      +    from tui_gateway import git_probe
      +
      +    captured = []
      +
      +    def fake_run(cmd, **kwargs):
      +        captured.append((cmd, kwargs))
      +        return _Completed(stdout="main\n")
      +
      +    monkeypatch.setattr(git_probe, "IS_WINDOWS", True)
      +    monkeypatch.setattr(git_probe, "windows_hide_flags", lambda: _CREATE_NO_WINDOW)
      +    monkeypatch.setattr(git_probe.subprocess, "run", fake_run)
      +
      +    assert git_probe.run_git("C:/repo", "branch", "--show-current") == "main"
      +
      +    assert captured == [
      +        (
      +            ["git", "-C", "C:/repo", "branch", "--show-current"],
      +            {
      +                "capture_output": True,
      +                "text": True,
      +                "encoding": "utf-8",
      +                "errors": "replace",
      +                "timeout": git_probe._GIT_TIMEOUT,
      +                "check": False,
      +                "stdin": subprocess.DEVNULL,
      +                "creationflags": _CREATE_NO_WINDOW,
      +            },
      +        )
      +    ]
      +
      +
      +def test_tui_gateway_fuzzy_file_listing_hides_git_windows(monkeypatch):
      +    from hermes_cli import _subprocess_compat
      +    from tui_gateway import server
      +
      +    captured = []
      +
      +    def fake_run(cmd, **kwargs):
      +        captured.append((cmd, kwargs))
      +        if cmd[-1] == "--show-toplevel":
      +            return _Completed(stdout=b"C:/repo\n")
      +        return _Completed(stdout=b"src/main.py\0README.md\0")
      +
      +    monkeypatch.setattr(_subprocess_compat, "IS_WINDOWS", True)
      +    monkeypatch.setattr(_subprocess_compat, "windows_hide_flags", lambda: _CREATE_NO_WINDOW)
      +    monkeypatch.setattr(server.subprocess, "run", fake_run)
      +    server._fuzzy_cache.clear()
      +
      +    assert server._list_repo_files("C:/repo") == ["src/main.py", "README.md"]
      +
      +    assert [kwargs["creationflags"] for _, kwargs in captured] == [
      +        _CREATE_NO_WINDOW,
      +        _CREATE_NO_WINDOW,
      +    ]
      +
      +
      +def test_coding_context_git_hides_git_windows(monkeypatch):
      +    from agent import coding_context
      +
      +    captured = []
      +
      +    def fake_run(cmd, **kwargs):
      +        captured.append((cmd, kwargs))
      +        return _Completed(stdout="clean\n")
      +
      +    monkeypatch.setattr(coding_context, "IS_WINDOWS", True)
      +    monkeypatch.setattr(coding_context, "windows_hide_flags", lambda: _CREATE_NO_WINDOW)
      +    monkeypatch.setattr(coding_context.subprocess, "run", fake_run)
      +
      +    assert coding_context._git(Path("C:/repo"), "status", "--short") == "clean"
      +    assert captured[0][1]["creationflags"] == _CREATE_NO_WINDOW
      +
      +
      +def test_context_reference_git_and_rg_hide_windows(monkeypatch):
      +    from agent import context_references
      +
      +    captured = []
      +
      +    def fake_run(cmd, **kwargs):
      +        captured.append((cmd, kwargs))
      +        if cmd[0] == "rg":
      +            return _Completed(stdout="src/main.py\n")
      +        return _Completed(stdout="diff --git a/src/main.py b/src/main.py\n")
      +
      +    monkeypatch.setattr(context_references, "IS_WINDOWS", True)
      +    monkeypatch.setattr(context_references, "windows_hide_flags", lambda: _CREATE_NO_WINDOW)
      +    monkeypatch.setattr(context_references.subprocess, "run", fake_run)
      +
      +    ref = context_references.ContextReference(
      +        raw="@diff",
      +        kind="diff",
      +        target="",
      +        start=0,
      +        end=5,
      +    )
      +    warning, block = context_references._expand_git_reference(
      +        ref,
      +        Path("C:/repo"),
      +        ["diff"],
      +        "git diff",
      +    )
      +    assert warning is None
      +    assert block is not None
      +    assert "git diff" in block
      +    assert context_references._rg_files(Path("C:/repo/src"), Path("C:/repo"), 10) == [
      +        Path("src/main.py")
      +    ]
      +
      +    assert [kwargs["creationflags"] for _, kwargs in captured] == [
      +        _CREATE_NO_WINDOW,
      +        _CREATE_NO_WINDOW,
      +    ]
      +
      +
      +def test_copilot_gh_cli_probe_hides_gh_windows(monkeypatch):
      +    from hermes_cli import copilot_auth
      +
      +    captured = []
      +
      +    def fake_run(cmd, **kwargs):
      +        captured.append((cmd, kwargs))
      +        return _Completed(stdout="gho_from_cli\n")
      +
      +    monkeypatch.setattr(copilot_auth, "IS_WINDOWS", True)
      +    monkeypatch.setattr(copilot_auth, "windows_hide_flags", lambda: _CREATE_NO_WINDOW)
      +    monkeypatch.setattr(copilot_auth, "_gh_cli_candidates", lambda: ["gh"])
      +    monkeypatch.setattr(copilot_auth.subprocess, "run", fake_run)
      +
      +    assert copilot_auth._try_gh_cli_token() == "gho_from_cli"
      +    assert captured[0][0] == ["gh", "auth", "token"]
      +    assert captured[0][1]["creationflags"] == _CREATE_NO_WINDOW
      +
      +
      +def test_gateway_pid_scan_hides_wmic_and_powershell_windows(monkeypatch):
      +    from hermes_cli import gateway
      +    from hermes_cli import _subprocess_compat
      +
      +    captured = []
      +
      +    def fake_run(cmd, **kwargs):
      +        captured.append((cmd, kwargs))
      +        if cmd[0] == "wmic":
      +            return _Completed(stdout="", returncode=1)
      +        return _Completed(stdout="CommandLine=hermes gateway\nProcessId=123\n")
      +
      +    monkeypatch.setattr(gateway, "is_windows", lambda: True)
      +    monkeypatch.setattr(gateway.shutil, "which", lambda name: name)
      +    monkeypatch.setattr(_subprocess_compat, "IS_WINDOWS", True)
      +    monkeypatch.setattr(_subprocess_compat, "windows_hide_flags", lambda: _CREATE_NO_WINDOW)
      +    monkeypatch.setattr(gateway.subprocess, "run", fake_run)
      +
      +    assert gateway._scan_gateway_pids(set()) == [123]
      +    assert [kwargs["creationflags"] for _, kwargs in captured] == [
      +        _CREATE_NO_WINDOW,
      +        _CREATE_NO_WINDOW,
      +    ]
      +
      +
      +def test_stale_dashboard_windows_scan_hides_wmic(monkeypatch):
      +    from hermes_cli import main
      +    from hermes_cli import _subprocess_compat
      +
      +    captured = []
      +
      +    def fake_run(cmd, **kwargs):
      +        captured.append((cmd, kwargs))
      +        return _Completed(stdout="CommandLine=hermes dashboard\nProcessId=123\n")
      +
      +    monkeypatch.setattr(main.sys, "platform", "win32")
      +    monkeypatch.setattr(_subprocess_compat, "IS_WINDOWS", True)
      +    monkeypatch.setattr(_subprocess_compat, "windows_hide_flags", lambda: _CREATE_NO_WINDOW)
      +    monkeypatch.setattr(main.subprocess, "run", fake_run)
      +
      +    assert main._find_stale_dashboard_pids() == [123]
      +    assert captured[0][1]["creationflags"] == _CREATE_NO_WINDOW
      +
      +
      +def test_gateway_force_kill_hides_taskkill_window(monkeypatch):
      +    from gateway import status
      +    from hermes_cli import _subprocess_compat
      +
      +    captured = []
      +
      +    def fake_run(cmd, **kwargs):
      +        captured.append((cmd, kwargs))
      +        return _Completed(stdout="")
      +
      +    monkeypatch.setattr(status, "_IS_WINDOWS", True)
      +    monkeypatch.setattr(_subprocess_compat, "IS_WINDOWS", True)
      +    monkeypatch.setattr(_subprocess_compat, "windows_hide_flags", lambda: _CREATE_NO_WINDOW)
      +    monkeypatch.setattr(status.subprocess, "run", fake_run)
      +
      +    status.terminate_pid(123, force=True)
      +
      +    assert captured == [
      +        (
      +            ["taskkill", "/PID", "123", "/T", "/F"],
      +            {
      +                "capture_output": True,
      +                "text": True,
      +                "timeout": 10,
      +                "creationflags": _CREATE_NO_WINDOW,
      +            },
      +        )
      +    ]
      +
      +
      +def test_shell_hooks_hide_hook_command_windows(monkeypatch):
      +    from agent import shell_hooks
      +
      +    captured = []
      +
      +    def fake_run(cmd, **kwargs):
      +        captured.append((cmd, kwargs))
      +        return SimpleNamespace(returncode=0, stdout="{}", stderr="")
      +
      +    monkeypatch.setattr(shell_hooks, "IS_WINDOWS", True)
      +    monkeypatch.setattr(shell_hooks, "windows_hide_flags", lambda: _CREATE_NO_WINDOW)
      +    monkeypatch.setattr(shell_hooks.subprocess, "run", fake_run)
      +
      +    result = shell_hooks._spawn(
      +        shell_hooks.ShellHookSpec(event="post_tool_call", command="hook-bin --flag"),
      +        "{}",
      +    )
      +
      +    assert result["returncode"] == 0
      +    assert captured[0][1]["creationflags"] == _CREATE_NO_WINDOW
      +
      +
      +def test_inline_skill_shell_hides_bash_window(monkeypatch):
      +    from agent import skill_preprocessing
      +
      +    captured = []
      +
      +    def fake_run(cmd, **kwargs):
      +        captured.append((cmd, kwargs))
      +        return SimpleNamespace(returncode=0, stdout="ok\n", stderr="")
      +
      +    monkeypatch.setattr(skill_preprocessing, "IS_WINDOWS", True)
      +    monkeypatch.setattr(skill_preprocessing, "windows_hide_flags", lambda: _CREATE_NO_WINDOW)
      +    monkeypatch.setattr(skill_preprocessing.subprocess, "run", fake_run)
      +
      +    assert skill_preprocessing.run_inline_shell("echo ok", cwd=None, timeout=5) == "ok"
      +    assert captured[0][0] == ["bash", "-c", "echo ok"]
      +    assert captured[0][1]["creationflags"] == _CREATE_NO_WINDOW
    • tui_gateway/git_probe.py
      @@ -31,6 +31,8 @@
       from collections.abc import Iterable
       from concurrent.futures import ThreadPoolExecutor
       
      +from hermes_cli._subprocess_compat import IS_WINDOWS, windows_hide_flags
      +
       _GIT_TIMEOUT = 1.5
       _WARM_WORKERS = 8
       
      @@ -45,14 +47,18 @@ def run_git(cwd: str, *args: str) -> str:
           """``git -C <cwd> <args>`` → stripped stdout, or ``""`` on any failure."""
           if not cwd:
               return ""
      +    _popen_kwargs = {"creationflags": windows_hide_flags()} if IS_WINDOWS else {}
           try:
               result = subprocess.run(
                   ["git", "-C", cwd, *args],
                   capture_output=True,
                   text=True,
      +            encoding="utf-8",
      +            errors="replace",
                   timeout=_GIT_TIMEOUT,
                   check=False,
                   stdin=subprocess.DEVNULL,
      +            **_popen_kwargs,
               )
               return result.stdout.strip() if result.returncode == 0 else ""
           except Exception:
    • tui_gateway/server.py
      @@ -11668,6 +11668,9 @@ def _list_repo_files(root: str) -> list[str]:
                   return cached[1]
       
           files: list[str] = []
      +    from hermes_cli._subprocess_compat import windows_hide_flags
      +
      +    _creationflags = windows_hide_flags()
           try:
               top_result = subprocess.run(
                   ["git", "-C", root, "rev-parse", "--show-toplevel"],
      @@ -11675,6 +11678,7 @@ def _list_repo_files(root: str) -> list[str]:
                   timeout=2.0,
                   check=False,
                   stdin=subprocess.DEVNULL,
      +            creationflags=_creationflags,
               )
               if top_result.returncode == 0:
                   top = top_result.stdout.decode("utf-8", "replace").strip()
      @@ -11693,6 +11697,7 @@ def _list_repo_files(root: str) -> list[str]:
                       timeout=2.0,
                       check=False,
                       stdin=subprocess.DEVNULL,
      +                creationflags=_creationflags,
                   )
                   if list_result.returncode == 0:
                       for p in list_result.stdout.decode("utf-8", "replace").split("\0"):
  • #54417 fix(windows): cover remaining console-flash spawn legs

    merged to main

    files: gateway/run.py, gateway/status.py, hermes_cli/gateway.py, hermes_constants.py, tests/gateway/test_restart_drain.py, tests/gateway/test_status.py, tests/test_hermes_constants.py, tests/test_windows_subprocess_no_window_flags.py, tests/tools/test_browser_chromium_check.py, tests/tools/test_browser_homebrew_paths.py, tools/browser_tool.py, tui_gateway/server.py

    Show the diff
    • gateway/run.py
      @@ -5501,6 +5501,19 @@ def _alive(p):
                   # run as a self-restart loop guard and the gateway stays stopped.
                   watcher_env.pop("_HERMES_GATEWAY", None)
                   project_root = Path(__file__).resolve().parent.parent
      +            watcher_python = sys.executable
      +            try:
      +                # Prefer a real GUI-subsystem interpreter for the watcher
      +                # itself.  With uv venvs, ``python.exe`` can re-exec the base
      +                # console interpreter and flash even when the Popen carries
      +                # CREATE_NO_WINDOW; pythonw.exe avoids console allocation.
      +                from hermes_cli.gateway_windows import _resolve_detached_python
      +
      +                watcher_python, _watcher_venv_dir, _watcher_site_packages = (
      +                    _resolve_detached_python(sys.executable)
      +                )
      +            except Exception:
      +                watcher_python = sys.executable
                   venv_dir = Path(watcher_env.get("VIRTUAL_ENV") or project_root / "venv")
                   site_packages = venv_dir / "Lib" / "site-packages"
                   if site_packages.exists():
      @@ -5510,7 +5523,7 @@ def _alive(p):
                           pythonpath.append(watcher_env["PYTHONPATH"])
                       watcher_env["PYTHONPATH"] = os.pathsep.join(dict.fromkeys(pythonpath))
                   subprocess.Popen(
      -                [sys.executable, "-c", watcher, str(current_pid), str(restart_after_s), *cmd_argv],
      +                [watcher_python, "-c", watcher, str(current_pid), str(restart_after_s), *cmd_argv],
                       stdout=subprocess.DEVNULL,
                       stderr=subprocess.DEVNULL,
                       env=watcher_env,
    • gateway/status.py
      @@ -171,17 +171,18 @@ def _read_process_cmdline(pid: int) -> Optional[str]:
               if raw:
                   return raw.replace(b"\x00", b" ").decode("utf-8", errors="ignore").strip()
       
      -    try:
      -        result = subprocess.run(
      -            ["ps", "-p", str(pid), "-o", "command="],
      -            capture_output=True,
      -            text=True,
      -            timeout=5,
      -        )
      -        if result.returncode == 0 and result.stdout.strip():
      -            return result.stdout.strip()
      -    except (OSError, subprocess.TimeoutExpired):
      -        pass
      +    if not _IS_WINDOWS:
      +        try:
      +            result = subprocess.run(
      +                ["ps", "-p", str(pid), "-o", "command="],
      +                capture_output=True,
      +                text=True,
      +                timeout=5,
      +            )
      +            if result.returncode == 0 and result.stdout.strip():
      +                return result.stdout.strip()
      +        except (OSError, subprocess.TimeoutExpired):
      +            pass
       
           # Windows fallback: psutil (already used by _pid_exists)
           try:
    • hermes_cli/gateway.py
      @@ -180,7 +180,11 @@ def _get_parent_pid(pid: int) -> int | None:
               pass
           except Exception:
               return None
      -    # Fallback: shell out to ps (POSIX only — bare ``ps`` doesn't exist on Windows).
      +    # Fallback: shell out to ps (POSIX only).  Git Bash installs ``ps.exe`` on
      +    # Windows; running it from the windowless desktop/gateway backend flashes a
      +    # console, and psutil above is the authoritative Windows path anyway.
      +    if is_windows():
      +        return None
           if not shutil.which("ps"):
               return None
           try:
    • hermes_constants.py
      @@ -317,11 +317,14 @@ def node_tool_runnable(path: str | None) -> bool:
           import subprocess
       
           try:
      +        from hermes_cli._subprocess_compat import windows_hide_flags
      +
               result = subprocess.run(
                   [path, "--version"],
                   capture_output=True,
                   timeout=10,
                   env=with_hermes_node_path(),
      +            creationflags=windows_hide_flags(),
               )
           except (OSError, subprocess.TimeoutExpired, ValueError):
               return False
      @@ -562,11 +565,14 @@ def agent_browser_runnable(path: str | None) -> bool:
           import subprocess
       
           try:
      +        from hermes_cli._subprocess_compat import windows_hide_flags
      +
               result = subprocess.run(
                   [path, "--version"],
                   capture_output=True,
                   timeout=10,
                   env=with_hermes_node_path(),
      +            creationflags=windows_hide_flags(),
               )
           except (OSError, subprocess.TimeoutExpired, ValueError):
               return False
    • tests/gateway/test_restart_drain.py
      @@ -353,6 +353,49 @@ def fake_popen(cmd, **kwargs):
           assert kwargs["stderr"] is subprocess.DEVNULL
       
       
      [redacted:email]
      +async def test_windows_detached_restart_uses_pythonw_for_watcher(monkeypatch, tmp_path):
      +    runner, _adapter = make_restart_runner()
      +    popen_calls = []
      +    venv_dir = tmp_path / "venv"
      +    site_packages = venv_dir / "Lib" / "site-packages"
      +    site_packages.mkdir(parents=True)
      +
      +    monkeypatch.setattr(gateway_run.sys, "platform", "win32")
      +    monkeypatch.setattr(gateway_run.sys, "executable", r"C:\venv\Scripts\python.exe")
      +    monkeypatch.setattr(gateway_run, "_resolve_hermes_bin", lambda: ["hermes"])
      +    monkeypatch.setattr(gateway_run.os, "getpid", lambda: 321)
      +    monkeypatch.setenv("VIRTUAL_ENV", str(venv_dir))
      +
      +    import hermes_cli._subprocess_compat as subprocess_compat
      +    import hermes_cli.gateway_windows as gateway_windows
      +
      +    monkeypatch.setattr(
      +        gateway_windows,
      +        "_resolve_detached_python",
      +        lambda _python: (r"C:\Python311\pythonw.exe", venv_dir, [str(site_packages)]),
      +    )
      +    monkeypatch.setattr(
      +        subprocess_compat,
      +        "windows_detach_popen_kwargs",
      +        lambda: {"creationflags": 0x08000008},
      +    )
      +
      +    def fake_popen(cmd, **kwargs):
      +        popen_calls.append((cmd, kwargs))
      +        return MagicMock()
      +
      +    monkeypatch.setattr(subprocess, "Popen", fake_popen)
      +
      +    await runner._launch_detached_restart_command()
      +
      +    assert len(popen_calls) == 1
      +    cmd, kwargs = popen_calls[0]
      +    assert cmd[0] == r"C:\Python311\pythonw.exe"
      +    assert cmd[-3:] == ["hermes", "gateway", "restart"]
      +    assert kwargs["creationflags"] == 0x08000008
      +
      +
       # ── Shutdown notification tests ──────────────────────────────────────
       
       
    • tests/gateway/test_status.py
      @@ -2,6 +2,7 @@
       
       import json
       import os
      +import sys
       from pathlib import Path
       from types import SimpleNamespace
       
      @@ -1351,6 +1352,7 @@ class TestReadProcessCmdlinePsFallback:
       
           def test_ps_fallback_when_proc_unavailable(self, monkeypatch):
               monkeypatch.setattr(status.Path, "read_bytes", lambda self: (_ for _ in ()).throw(FileNotFoundError))
      +        monkeypatch.setattr(status, "_IS_WINDOWS", False)
               monkeypatch.setattr(
                   status.subprocess, "run",
                   lambda args, **kwargs: SimpleNamespace(returncode=0, stdout="/usr/libexec/bluetoothuserd\n"),
      @@ -1360,6 +1362,7 @@ def test_ps_fallback_when_proc_unavailable(self, monkeypatch):
       
           def test_ps_fallback_returns_none_on_failure(self, monkeypatch):
               monkeypatch.setattr(status.Path, "read_bytes", lambda self: (_ for _ in ()).throw(FileNotFoundError))
      +        monkeypatch.setattr(status, "_IS_WINDOWS", False)
               monkeypatch.setattr(
                   status.subprocess, "run",
                   lambda args, **kwargs: SimpleNamespace(returncode=1, stdout=""),
      @@ -1381,6 +1384,7 @@ def fake_read_bytes(self):
       
           def test_ps_fallback_used_when_proc_returns_empty(self, monkeypatch):
               monkeypatch.setattr(status.Path, "read_bytes", lambda self: b"")
      +        monkeypatch.setattr(status, "_IS_WINDOWS", False)
               monkeypatch.setattr(
                   status.subprocess, "run",
                   lambda args, **kwargs: SimpleNamespace(returncode=0, stdout="python hermes_cli/main.py gateway run\n"),
      @@ -1388,6 +1392,34 @@ def test_ps_fallback_used_when_proc_returns_empty(self, monkeypatch):
               result = status._read_process_cmdline(12345)
               assert "hermes_cli/main.py" in result
       
      +    def test_windows_skips_ps_fallback_and_uses_psutil(self, monkeypatch):
      +        monkeypatch.setattr(status.Path, "read_bytes", lambda self: (_ for _ in ()).throw(FileNotFoundError))
      +        monkeypatch.setattr(status, "_IS_WINDOWS", True)
      +        ps_calls = []
      +        monkeypatch.setattr(
      +            status.subprocess,
      +            "run",
      +            lambda args, **kwargs: ps_calls.append((args, kwargs)) or SimpleNamespace(returncode=0, stdout="ps should not run\n"),
      +        )
      +
      +        class _Proc:
      +            def __init__(self, pid):
      +                self.pid = pid
      +
      +            def cmdline(self):
      +                return ["pythonw.exe", "-m", "hermes_cli.main", "gateway", "run"]
      +
      +        monkeypatch.setitem(
      +            sys.modules,
      +            "psutil",
      +            SimpleNamespace(Process=_Proc),
      +        )
      +
      +        result = status._read_process_cmdline(12345)
      +
      +        assert result == "pythonw.exe -m hermes_cli.main gateway run"
      +        assert ps_calls == []
      +
       
       class TestCorruptStatusFiles:
           """A status / pid file holding non-UTF-8 (binary) bytes must read as
    • tests/test_hermes_constants.py
      @@ -2,6 +2,7 @@
       
       import os
       from pathlib import Path
      +from types import SimpleNamespace
       
       import pytest
       
      @@ -611,6 +612,43 @@ def test_npx_fallback_form_accepted(self):
               assert agent_browser_runnable("npx agent-browser") is True
               assert agent_browser_runnable("/usr/local/bin/npx agent-browser") is True
       
      +    def test_version_probe_uses_windows_hide_flags(self, tmp_path, monkeypatch):
      +        good = self._stub(tmp_path, "agent-browser", "#!/bin/sh\necho hi\n")
      +        captured = []
      +
      +        def fake_run(cmd, **kwargs):
      +            captured.append((cmd, kwargs))
      +            return SimpleNamespace(returncode=0)
      +
      +        import hermes_cli._subprocess_compat as subprocess_compat
      +        import subprocess as subprocess_mod
      +
      +        monkeypatch.setattr(subprocess_compat, "windows_hide_flags", lambda: 0x08000000)
      +        monkeypatch.setattr(subprocess_mod, "run", fake_run)
      +
      +        assert agent_browser_runnable(str(good)) is True
      +        assert captured[0][0] == [str(good), "--version"]
      +        assert captured[0][1]["creationflags"] == 0x08000000
      +
      +
      +    def test_node_tool_probe_uses_windows_hide_flags(self, tmp_path, monkeypatch):
      +        good = self._stub(tmp_path, "node", "#!/bin/sh\necho v22\n")
      +        captured = []
      +
      +        def fake_run(cmd, **kwargs):
      +            captured.append((cmd, kwargs))
      +            return SimpleNamespace(returncode=0)
      +
      +        import hermes_cli._subprocess_compat as subprocess_compat
      +        import subprocess as subprocess_mod
      +
      +        monkeypatch.setattr(subprocess_compat, "windows_hide_flags", lambda: 0x08000000)
      +        monkeypatch.setattr(subprocess_mod, "run", fake_run)
      +
      +        assert node_tool_runnable(str(good)) is True
      +        assert captured[0][0] == [str(good), "--version"]
      +        assert captured[0][1]["creationflags"] == 0x08000000
      +
       
       class TestGetHermesDir:
           """Tests for ``get_hermes_dir(new_subpath, old_name)``.
    • tests/test_windows_subprocess_no_window_flags.py
      @@ -299,3 +299,29 @@ def fake_run(cmd, **kwargs):
       
           assert captured[0][0][0] == "ffmpeg"
           assert captured[0][1]["creationflags"] == _CREATE_NO_WINDOW
      +
      +def test_tui_slash_worker_hides_python_window(monkeypatch):
      +    from tui_gateway import server
      +
      +    captured = []
      +
      +    class _Proc:
      +        stdin = SimpleNamespace()
      +        stdout = []
      +        stderr = []
      +
      +    def fake_popen(cmd, **kwargs):
      +        captured.append((cmd, kwargs))
      +        return _Proc()
      +
      +    monkeypatch.setattr(server.subprocess, "Popen", fake_popen)
      +    monkeypatch.setattr(server.threading, "Thread", lambda *a, **k: SimpleNamespace(start=lambda: None))
      +
      +    import hermes_cli._subprocess_compat as subprocess_compat
      +
      +    monkeypatch.setattr(subprocess_compat, "windows_hide_flags", lambda: _CREATE_NO_WINDOW)
      +
      +    server._SlashWorker("session-key", "model-x")
      +
      +    assert captured[0][0][:3] == [server.sys.executable, "-m", "tui_gateway.slash_worker"]
      +    assert captured[0][1]["creationflags"] == _CREATE_NO_WINDOW
    • tests/tools/test_browser_chromium_check.py
      @@ -76,7 +76,7 @@ class TestCheckBrowserRequirementsChromium:
       
           def test_local_mode_with_chromium_returns_true(self, monkeypatch, tmp_path):
               monkeypatch.setattr(bt, "_is_camofox_mode", lambda: False)
      -        monkeypatch.setattr(bt, "_find_agent_browser", lambda: "/usr/local/bin/agent-browser")
      +        monkeypatch.setattr(bt, "_find_agent_browser", lambda **_kw: "/usr/local/bin/agent-browser")
               monkeypatch.setattr(bt, "_requires_real_termux_browser_install", lambda _: False)
               monkeypatch.setattr(bt, "_get_cloud_provider", lambda: None)
               monkeypatch.setenv("PLAYWRIGHT_BROWSERS_PATH", str(tmp_path))
      @@ -93,7 +93,7 @@ def provider_name(self):
                       return "browserbase"
       
               monkeypatch.setattr(bt, "_is_camofox_mode", lambda: False)
      -        monkeypatch.setattr(bt, "_find_agent_browser", lambda: "/usr/local/bin/agent-browser")
      +        monkeypatch.setattr(bt, "_find_agent_browser", lambda **_kw: "/usr/local/bin/agent-browser")
               monkeypatch.setattr(bt, "_requires_real_termux_browser_install", lambda _: False)
               monkeypatch.setattr(bt, "_get_cloud_provider", lambda: FakeProvider())
               # Point chromium search at an empty dir — should not matter for cloud.
      @@ -102,6 +102,23 @@ def provider_name(self):
       
               assert bt.check_browser_requirements() is True
       
      +    def test_startup_check_uses_lightweight_agent_browser_lookup(self, monkeypatch, tmp_path):
      +        seen = []
      +
      +        def fake_find_agent_browser(**kwargs):
      +            seen.append(kwargs)
      +            return "/usr/local/bin/agent-browser"
      +
      +        monkeypatch.setattr(bt, "_is_camofox_mode", lambda: False)
      +        monkeypatch.setattr(bt, "_find_agent_browser", fake_find_agent_browser)
      +        monkeypatch.setattr(bt, "_requires_real_termux_browser_install", lambda _: False)
      +        monkeypatch.setattr(bt, "_get_cloud_provider", lambda: None)
      +        monkeypatch.setenv("PLAYWRIGHT_BROWSERS_PATH", str(tmp_path))
      +        (tmp_path / "chromium-1208").mkdir()
      +
      +        assert bt.check_browser_requirements() is True
      +        assert seen == [{"validate": False}]
      +
           def test_camofox_mode_does_not_require_chromium(self, monkeypatch, tmp_path):
               monkeypatch.setattr(bt, "_is_camofox_mode", lambda: True)
               # Even with no chromium on disk, camofox drives its own backend.
    • tests/tools/test_browser_homebrew_paths.py
      @@ -222,7 +222,7 @@ def test_termux_requires_real_agent_browser_install_not_npx_fallback(self, monke
               monkeypatch.setenv("PREFIX", "/data/data/com.termux/files/usr")
               monkeypatch.setattr("tools.browser_tool._is_camofox_mode", lambda: False)
               monkeypatch.setattr("tools.browser_tool._get_cloud_provider", lambda: None)
      -        monkeypatch.setattr("tools.browser_tool._find_agent_browser", lambda: "npx agent-browser")
      +        monkeypatch.setattr("tools.browser_tool._find_agent_browser", lambda **_kw: "npx agent-browser")
       
               assert check_browser_requirements() is False
       
      @@ -231,7 +231,7 @@ class TestRunBrowserCommandTermuxFallback:
           def test_termux_local_mode_rejects_bare_npx_fallback(self, monkeypatch):
               monkeypatch.setenv("TERMUX_VERSION", "0.118.3")
               monkeypatch.setenv("PREFIX", "/data/data/com.termux/files/usr")
      -        monkeypatch.setattr("tools.browser_tool._find_agent_browser", lambda: "npx agent-browser")
      +        monkeypatch.setattr("tools.browser_tool._find_agent_browser", lambda **_kw: "npx agent-browser")
               monkeypatch.setattr("tools.browser_tool._get_cloud_provider", lambda: None)
       
               result = _run_browser_command("task-1", "navigate", ["https://example.com"])
    • tools/browser_tool.py
      @@ -2002,7 +2002,15 @@ def _get_session_info(task_id: Optional[str] = None) -> Dict[str, str]:
       
       
       
      -def _find_agent_browser() -> str:
      +def _agent_browser_candidate_present(path: str | None) -> bool:
      +    if not path:
      +        return False
      +    if " " in path and path.split()[0].endswith("npx"):
      +        return True
      +    return os.path.exists(path) and (os.name == "nt" or os.access(path, os.X_OK))
      +
      +
      +def _find_agent_browser(*, validate: bool = True) -> str:
           """
           Find the agent-browser CLI executable.
       
      @@ -2041,7 +2049,11 @@ def _find_agent_browser() -> str:
       
           # Check if it's in PATH (global install)
           which_result = shutil.which("agent-browser")
      -    if which_result and agent_browser_runnable(which_result):
      +    if which_result and (
      +        agent_browser_runnable(which_result) if validate else _agent_browser_candidate_present(which_result)
      +    ):
      +        if not validate:
      +            return which_result
               _cached_agent_browser = which_result
               _agent_browser_resolved = True
               return which_result
      @@ -2051,7 +2063,11 @@ def _find_agent_browser() -> str:
           extended_path = _merge_browser_path("")
           if extended_path:
               which_result = shutil.which("agent-browser", path=extended_path)
      -        if which_result and agent_browser_runnable(which_result):
      +        if which_result and (
      +            agent_browser_runnable(which_result) if validate else _agent_browser_candidate_present(which_result)
      +        ):
      +            if not validate:
      +                return which_result
                   _cached_agent_browser = which_result
                   _agent_browser_resolved = True
                   return which_result
      @@ -2068,7 +2084,11 @@ def _find_agent_browser() -> str:
           local_bin_dir = repo_root / "node_modules" / ".bin"
           if local_bin_dir.is_dir():
               local_which = shutil.which("agent-browser", path=str(local_bin_dir))
      -        if local_which and agent_browser_runnable(local_which):
      +        if local_which and (
      +            agent_browser_runnable(local_which) if validate else _agent_browser_candidate_present(local_which)
      +        ):
      +            if not validate:
      +                return local_which
                   _cached_agent_browser = local_which
                   _agent_browser_resolved = True
                   return _cached_agent_browser
      @@ -2078,10 +2098,15 @@ def _find_agent_browser() -> str:
           if not npx_path and extended_path:
               npx_path = shutil.which("npx", path=extended_path)
           if npx_path:
      +        if not validate:
      +            return "npx agent-browser"
               _cached_agent_browser = "npx agent-browser"
               _agent_browser_resolved = True
               return _cached_agent_browser
       
      +    if not validate:
      +        raise FileNotFoundError("agent-browser CLI not found")
      +
           # Nothing found — try lazy installation before giving up.
           try:
               from hermes_cli.dep_ensure import ensure_dependency
      @@ -4206,8 +4231,12 @@ def check_browser_requirements() -> bool:
               return True
       
           # The agent-browser CLI is required for local launch and cloud-provider flows.
      +    # Tool-schema assembly runs during Desktop startup; do not execute
      +    # ``agent-browser --version`` here, because Windows .cmd shims route through
      +    # cmd.exe and can flash a console before the user invokes any browser tool.
      +    # Actual browser execution paths still validate the candidate before use.
           try:
      -        browser_cmd = _find_agent_browser()
      +        browser_cmd = _find_agent_browser(validate=False)
           except FileNotFoundError:
               return False
       
    • tui_gateway/server.py
      @@ -278,6 +278,8 @@ def __init__(self, session_key: str, model: str):
                   argv += ["--model", model]
       
               self._closed = False
      +        from hermes_cli._subprocess_compat import windows_hide_flags
      +
               self.proc = subprocess.Popen(
                   argv,
                   stdin=subprocess.PIPE,
      @@ -289,6 +291,7 @@ def __init__(self, session_key: str, model: str):
                   # slash_worker runs the Hermes agent → needs provider credentials.
                   # Tier-1 secrets (gateway/GitHub/infra) are still stripped (#29157).
                   env=hermes_subprocess_env(inherit_credentials=[redacted:secret-assignment]
      +            creationflags=windows_hide_flags(),
               )
               threading.Thread(target=self._drain_stdout, daemon=True).start()
               threading.Thread(target=self._drain_stderr, daemon=True).start()

15 PRs closed without merging

From the close comments: superseded by another PR 5 · done elsewhere 4 · duplicate 3 · closed without comment 2 · other 1

  • #42605 fix(terminal): prevent Windows cmd flash (#42544) · done elsewhere → aa2ae36
  • #44105 fix(windows): hide console-window flash on gateway-runtime subprocess spawns · superseded by another PR
  • #49931 fix(terminal): bypass Git Bash wrapper to prevent console flashes on Windows · closed without comment
  • #50183 fix(terminal): suppress console window flash on Windows via CREATE_NO_WINDOW (#49851, #42544) · done elsewhere → aa2ae36, #54220, #42544, #49851
  • #53119 fix: add CREATE_NO_WINDOW to Windows subprocess Popen calls to prevent cmd.exe flashing · superseded by another PR → #54220, #54236, #53892, #54417
Show 10 more
  • #53123 fix(windows): suppress console-window flashes from gh/git probes in desktop gateway · done elsewhere → #53844, #53791, #53810, #53829
  • #53291 fix: suppress console window flashes on Windows via global subprocess patch (#53282) · superseded by another PR → #54220, #54236, #53892, #54417
  • #53344 fix(desktop): avoid uv venv pythonw launcher console flash on Windows · closed without comment
  • #53879 fix(windows): hide backend subprocess windows by default · superseded by another PR → #54220, #54236, #53892, #54417
  • #54253 fix(win32): add CREATE_NO_WINDOW to all background subprocess calls · superseded by another PR → cb982ad
  • #55613 fix(cli): add CREATE_BREAKAWAY_FROM_JOB to windows_hide_flags() · other → 0dbf639, aa2ae36, #70205
  • #55667 fix(cli): add CREATE_BREAKAWAY_FROM_JOB to windows_hide_flags() · duplicate → #55613, #55604
  • #55692 fix(cli): include breakaway flag for hidden Windows subprocesses · duplicate → #55613, #55604, #55667
  • #55753 fix(windows): add CREATE_BREAKAWAY_FROM_JOB to windows_hide_flags() · duplicate → #55613, #55604, #55667, #55692
  • #64081 fix: add CREATE_NO_WINDOW to env_probe and lazy_deps subprocess calls on Windows · done elsewhere → aa2ae36
Closed issues · 24
  • Closed#24073[Feature]: Hidden Terminal exec on Windows Native
  • Closed#42544Windows: terminal tool flashes cmd window on every command execution
  • Closed#43848[Feature]: Set the "CREATE_NO_WINDOW" flag for Window terminal process
  • Closed#49851Windows: terminal tool creates visible bash/cmd windows that steal focus
  • Closed#52310[Bug]: Black console window flashes briefly whenever terminal spawns bash on Windows
  • Closed#52788bug(desktop): CMD console window flashes on every terminal command on Windows
  • Closed#52981[Bug]: Windows 11 Hermes Desktop — 频繁弹出黑色控制台窗口闪烁(git.exe / gh.exe / cmd.exe)
  • Closed#52982[Bug]: Windows 11 Hermes Desktop — 频繁弹出黑色控制台窗口闪烁(git.exe / gh.exe / cmd.exe)
  • Closed#53014Windows: terminal 工具强制使用 bash 导致频繁弹窗
  • Closed#53016[Bug] Windows desktop app flashes cmd console window intermittently after update
  • Closed#53053[Bug]: Subprocess calls flash black cmd.exe windows on Windows
  • Closed#53273Windows Desktop GUI: CREATE_NO_WINDOW insufficient — cmd windows flash on every terminal() call
  • Closed#53282Windows: Console window flashes on every subprocess call (missing CREATE_NO_WINDOW)
  • Closed#53342[Bug]: The Hermes desktop client on Windows keeps flickering black command prompt windows nonstop, making the program completely inoperable. This is a critical blocking bug!
  • Closed#53424[Bug] Windows: rapid conhost.exe window flashing due to missing CREATE_NO_WINDOW in subprocess calls
  • Closed#53751Windows 10 desktop app triggers infinite PowerShell flash loop on launch
  • Closed#54323windows_hide_flags() missing CREATE_BREAKAWAY_FROM_JOB — Desktop app console flash persists after #54236
  • Closed#54506Command prompt keep blinking at screen when desktop app is open
  • Closed#55604windows_hide_flags() missing CREATE_BREAKAWAY_FROM_JOB — cmd.exe windows flash in Electron desktop apps (Hermes One, etc.)
  • Closed#55627[Bug]: OpenConsole.exe / conhost.exe / git.exe 进程无限闪烁弹窗 — Windows Electron 桌面版
  • Closed#55633[Bug]: OpenConsole.exe / conhost.exe / git.exe processes infinitely flash and spawn — Windows Electron Desktop
  • Closed#56503Windows subprocess console flashes — request for official fix
  • Closed#56747[Windows] Blank terminal console windows flash when running agent or tools on desktop GUI
  • Closed#63698[Bug]: Console windows flash/appear despite windows_hide_console: true
How this cluster was grouped
  • Link evidence (partly structural links): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text.
  • Members outside that structural core may belong to a different underlying problem.
  • Pairwise link density 0.04: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. 1.00 means every pair is linked.

Pick up a Lab task

Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.

Open the Lab

At a glance

Subsystem
apps/desktop
Merged PRs
#53810, #53892, #54236, #54417
Closed issues
24
Attempts
15 PRs closed without merging
Fix link checked
28 Jun 2026

For your agent

The same facts, typed.