Suppress unwanted terminal popups on Windows startup

NousResearch/hermes-agent · cluster 54364

Data as of

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

The short answer

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

These issues and PRs address Windows Desktop startup behavior that eagerly spawns agent-browser processes, causing visible console or terminal window flashes. Fixes cover multiple spawn code paths to avoid these background probes.

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.

  • #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: done elsewhere 1

  • #54366 fix(browser): avoid eager agent-browser probe · done elsewhere → #54417, #54220
Closed issues · 2
  • Closed#54364Desktop startup eagerly runs agent-browser --version on Windows
  • Closed#54375Desktop startup should not eagerly run agent-browser --version on Windows
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.39: 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

Merged PRs
#54417
Closed issues
2
Attempts
1 PR closed without merging
Fix link checked
28 Jun 2026

For your agent

The same facts, typed.