NousResearch/hermes-agent

Windows console window flashing from spawned backend processes

Cluster 53539 · apps/desktop. Data as of

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

The short answer

0open issues
0open PRs
4items grouped
Known?
Yes. 4 related items grouped here, 1 issue 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
Nothing open on GitHub.

The members all concern console windows flashing on Windows when the desktop app or gateway spawns subprocesses (pythonw, ffmpeg, netstat, restart watcher). One also mentions a WebSocket 1006 disconnect tied to a backend restart loop; the cluster does not establish resolution status.

Open issues0

No open issues.

Open PRs0

No open pull requests.

Fixes on record

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

  • #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
  • #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()

1 PR closed without merging

From the close comments: superseded by another PR 1

  • #54342 fix(gateway): use pythonw.exe for restart watcher + add dedup and backoff · superseded by another PR → #54220, #54236, #53892, #54417
Closed issues · 1
  • Closed#53539[Desktop] Command windows keep flashing — WebSocket 1006 disconnect causes backend restart loop
How this cluster was grouped
  • Link evidence (structural links throughout): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
  • Pairwise link density 0.50: 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
#53892, #54417
Closed issues
1
Attempts
1 PR closed without merging
Fix link checked
28 Jun 2026

For your agent

The same facts, typed.