fix(windows): hide console-window flash on backend git/gh/wmic/bash subprocess spawns

The Windows desktop GUI runs its backend headless via pythonw.exe. Several
auxiliary subprocess sites that run inside that windowless backend spawned
console-subsystem children (git, gh, wmic, powershell, bash, rg, taskkill)
WITHOUT CREATE_NO_WINDOW, so Windows allocated a fresh conhost per call and
flashed a black window on screen — sometimes continuously (the dashboard
Projects-tree git probe alone fired ~118 spawns in 60s on startup).

The terminal tool, cron, browser, code_execution, and gateway-spawn paths
already carry windows_hide_flags(); these auxiliary probe/scan/launcher legs
were missed. Wire the existing helper into them:

- tui_gateway/git_probe.py: run_git (+ encoding=utf-8/errors=replace, fixes the
  cp950 UnicodeDecodeError on CJK paths from the same site)
- agent/coding_context.py: _git (per-turn git status/log/diff)
- agent/context_references.py: _run_git + _rg_files (@file/@ref resolution)
- hermes_cli/copilot_auth.py: gh auth token probe (auxiliary provider:auto)
- hermes_cli/gateway.py: wmic + PowerShell Get-CimInstance PID scan
- hermes_cli/main.py: wmic stale-dashboard PID scan
- gateway/status.py: taskkill /T /F force-kill

windows_hide_flags() returns 0 on POSIX, so every changed call is a no-op on
Linux/macOS (verified: real git/rg probes still work; Windows-simulated calls
all pass creationflags=CREATE_NO_WINDOW).

Scoped to the windowless-backend paths that cause the reported flashing. The
Electron updater-handoff leg (main.cjs windowsHide:false) and the
interactive-CLI banner probes (cli.py) are intentionally NOT touched here —
the former needs a Windows-tested change of its own, the latter runs in a
visible console anyway.

Tracking: #54220
Refs: #53178 #53631 #53781 #53957 #49602 #52982 #53424 #53053 #53016
This commit is contained in:
Teknium 2026-06-28 05:01:59 -07:00
parent f25f235722
commit cb982ad997
10 changed files with 56 additions and 3 deletions

View file

@ -60,6 +60,8 @@ from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Any, Optional from typing import Any, Optional
from hermes_cli._subprocess_compat import IS_WINDOWS, windows_hide_flags
logger = logging.getLogger("hermes.coding_context") logger = logging.getLogger("hermes.coding_context")
CODING_TOOLSET = "coding" 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: def _git(cwd: Path, *args: str) -> str:
_popen_kwargs = {"creationflags": windows_hide_flags()} if IS_WINDOWS else {}
try: try:
out = subprocess.run( out = subprocess.run(
["git", "-C", str(cwd), *args], ["git", "-C", str(cwd), *args],
capture_output=True, capture_output=True,
text=True, text=True,
timeout=_GIT_TIMEOUT, timeout=_GIT_TIMEOUT,
**_popen_kwargs,
) )
except (OSError, subprocess.SubprocessError): except (OSError, subprocess.SubprocessError):
return "" return ""

View file

@ -12,6 +12,7 @@ from pathlib import Path
from typing import Awaitable, Callable from typing import Awaitable, Callable
from agent.model_metadata import estimate_tokens_rough 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]+\')' _QUOTED_REFERENCE_VALUE = r'(?:`[^`\n]+`|"[^"\n]+"|\'[^\'\n]+\')'
REFERENCE_PATTERN = re.compile( REFERENCE_PATTERN = re.compile(
@ -290,6 +291,7 @@ def _expand_git_reference(
args: list[str], args: list[str],
label: str, label: str,
) -> tuple[str | None, str | None]: ) -> tuple[str | None, str | None]:
_popen_kwargs = {"creationflags": windows_hide_flags()} if IS_WINDOWS else {}
try: try:
result = subprocess.run( result = subprocess.run(
["git", *args], ["git", *args],
@ -298,6 +300,7 @@ def _expand_git_reference(
text=True, text=True,
timeout=30, timeout=30,
stdin=subprocess.DEVNULL, stdin=subprocess.DEVNULL,
**_popen_kwargs,
) )
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
return f"{ref.raw}: git command timed out (30s)", None 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: def _rg_files(path: Path, cwd: Path, limit: int) -> list[Path] | None:
_popen_kwargs = {"creationflags": windows_hide_flags()} if IS_WINDOWS else {}
try: try:
result = subprocess.run( result = subprocess.run(
["rg", "--files", str(path.relative_to(cwd))], ["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, text=True,
timeout=10, timeout=10,
stdin=subprocess.DEVNULL, stdin=subprocess.DEVNULL,
**_popen_kwargs,
) )
except (FileNotFoundError, OSError, subprocess.TimeoutExpired): except (FileNotFoundError, OSError, subprocess.TimeoutExpired):
return None return None

View file

@ -122,6 +122,8 @@ from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from typing import Any, Callable, Dict, Iterator, List, Optional, Set, Tuple from typing import Any, Callable, Dict, Iterator, List, Optional, Set, Tuple
from hermes_cli._subprocess_compat import IS_WINDOWS, windows_hide_flags
try: try:
import fcntl # POSIX only; Windows falls back to best-effort without flock. import fcntl # POSIX only; Windows falls back to best-effort without flock.
except ImportError: # pragma: no cover except ImportError: # pragma: no cover
@ -441,6 +443,7 @@ def _spawn(spec: ShellHookSpec, stdin_json: str) -> Dict[str, Any]:
return result return result
t0 = time.monotonic() t0 = time.monotonic()
_popen_kwargs = {"creationflags": windows_hide_flags()} if IS_WINDOWS else {}
try: try:
proc = subprocess.run( proc = subprocess.run(
argv, argv,
@ -449,6 +452,7 @@ def _spawn(spec: ShellHookSpec, stdin_json: str) -> Dict[str, Any]:
timeout=spec.timeout, timeout=spec.timeout,
text=True, text=True,
shell=False, shell=False,
**_popen_kwargs,
) )
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
result["timed_out"] = True result["timed_out"] = True

View file

@ -5,6 +5,8 @@ import re
import subprocess import subprocess
from pathlib import Path from pathlib import Path
from hermes_cli._subprocess_compat import IS_WINDOWS, windows_hide_flags
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Matches ${HERMES_SKILL_DIR} / ${HERMES_SESSION_ID} tokens in SKILL.md. # 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 Failures return a short ``[inline-shell error: ...]`` marker instead of
raising, so one bad snippet can't wreck the whole skill message. raising, so one bad snippet can't wreck the whole skill message.
""" """
_popen_kwargs = {"creationflags": windows_hide_flags()} if IS_WINDOWS else {}
try: try:
completed = subprocess.run( completed = subprocess.run(
["bash", "-c", command], ["bash", "-c", command],
@ -75,6 +78,7 @@ def run_inline_shell(command: str, cwd: Path | None, timeout: int) -> str:
timeout=max(1, int(timeout)), timeout=max(1, int(timeout)),
check=False, check=False,
stdin=subprocess.DEVNULL, stdin=subprocess.DEVNULL,
**_popen_kwargs,
) )
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
return f"[inline-shell timeout after {timeout}s: {command}]" return f"[inline-shell timeout after {timeout}s: {command}]"

View file

@ -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. because os.kill(..., SIGTERM) is not equivalent to a tree-killing hard stop.
""" """
if force and _IS_WINDOWS: 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: try:
result = subprocess.run( result = subprocess.run(
["taskkill", "/PID", str(pid), "/T", "/F"], ["taskkill", "/PID", str(pid), "/T", "/F"],
capture_output=True, capture_output=True,
text=True, text=True,
timeout=10, timeout=10,
creationflags=windows_hide_flags(),
) )
except FileNotFoundError: except FileNotFoundError:
os.kill(pid, signal.SIGTERM) os.kill(pid, signal.SIGTERM)

View file

@ -27,6 +27,8 @@ import time
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
from hermes_cli._subprocess_compat import IS_WINDOWS, windows_hide_flags
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# OAuth device code flow constants (same client ID as opencode/Copilot CLI) # 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() clean_env = {k: v for k, v in os.environ.items()
if k not in {"GITHUB_TOKEN", "GH_TOKEN"}} 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(): for gh_path in _gh_cli_candidates():
cmd = [gh_path, "auth", "token"] cmd = [gh_path, "auth", "token"]
if hostname: if hostname:
@ -141,6 +144,7 @@ def _try_gh_cli_token() -> Optional[str]:
text=True, text=True,
timeout=5, timeout=5,
env=clean_env, env=clean_env,
**_popen_kwargs,
) )
except (FileNotFoundError, subprocess.TimeoutExpired) as exc: except (FileNotFoundError, subprocess.TimeoutExpired) as exc:
logger.debug("gh CLI token lookup failed (%s): %s", gh_path, exc) logger.debug("gh CLI token lookup failed (%s): %s", gh_path, exc)

View file

@ -384,6 +384,12 @@ def _scan_gateway_pids(
# removed as part of the WMIC deprecation — fall back to # removed as part of the WMIC deprecation — fall back to
# PowerShell's Get-CimInstance. Any OSError here (FileNotFoundError # PowerShell's Get-CimInstance. Any OSError here (FileNotFoundError
# on missing wmic) trips the fallback. # 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") wmic_path = shutil.which("wmic")
used_fallback = False used_fallback = False
result = None result = None
@ -402,6 +408,7 @@ def _scan_gateway_pids(
encoding="utf-8", encoding="utf-8",
errors="ignore", errors="ignore",
timeout=10, timeout=10,
**_no_window,
) )
except (OSError, subprocess.TimeoutExpired): except (OSError, subprocess.TimeoutExpired):
result = None result = None
@ -427,6 +434,7 @@ def _scan_gateway_pids(
encoding="utf-8", encoding="utf-8",
errors="ignore", errors="ignore",
timeout=15, timeout=15,
**_no_window,
) )
except (OSError, subprocess.TimeoutExpired): except (OSError, subprocess.TimeoutExpired):
return [] return []

View file

@ -5825,6 +5825,11 @@ def _find_stale_dashboard_pids(
# here is errors="ignore": it prevents a reader-thread # here is errors="ignore": it prevents a reader-thread
# UnicodeDecodeError from leaving result.stdout=None and turning # UnicodeDecodeError from leaving result.stdout=None and turning
# the later .split() into an AttributeError (#17049). # 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( result = subprocess.run(
["wmic", "process", "get", "ProcessId,CommandLine", "/FORMAT:LIST"], ["wmic", "process", "get", "ProcessId,CommandLine", "/FORMAT:LIST"],
capture_output=True, capture_output=True,
@ -5832,6 +5837,7 @@ def _find_stale_dashboard_pids(
timeout=10, timeout=10,
encoding="utf-8", encoding="utf-8",
errors="ignore", errors="ignore",
creationflags=windows_hide_flags(),
) )
if result.returncode != 0 or result.stdout is None: if result.returncode != 0 or result.stdout is None:
return [] return []

View file

@ -621,16 +621,22 @@ class TestTerminatePid:
calls = [] calls = []
monkeypatch.setattr(status, "_IS_WINDOWS", True) monkeypatch.setattr(status, "_IS_WINDOWS", True)
def fake_run(cmd, capture_output=False, text=False, timeout=None): def fake_run(cmd, capture_output=False, text=False, timeout=None, creationflags=0):
calls.append((cmd, capture_output, text, timeout)) calls.append((cmd, capture_output, text, timeout, creationflags))
return SimpleNamespace(returncode=0, stdout="", stderr="") return SimpleNamespace(returncode=0, stdout="", stderr="")
monkeypatch.setattr(status.subprocess, "run", fake_run) monkeypatch.setattr(status.subprocess, "run", fake_run)
status.terminate_pid(123, force=True) 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 == [ 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): def test_force_falls_back_to_sigterm_when_taskkill_missing(self, monkeypatch):

View file

@ -31,6 +31,8 @@ import time
from collections.abc import Iterable from collections.abc import Iterable
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
from hermes_cli._subprocess_compat import IS_WINDOWS, windows_hide_flags
_GIT_TIMEOUT = 1.5 _GIT_TIMEOUT = 1.5
_WARM_WORKERS = 8 _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.""" """``git -C <cwd> <args>`` → stripped stdout, or ``""`` on any failure."""
if not cwd: if not cwd:
return "" return ""
_popen_kwargs = {"creationflags": windows_hide_flags()} if IS_WINDOWS else {}
try: try:
result = subprocess.run( result = subprocess.run(
["git", "-C", cwd, *args], ["git", "-C", cwd, *args],
capture_output=True, capture_output=True,
text=True, text=True,
encoding="utf-8",
errors="replace",
timeout=_GIT_TIMEOUT, timeout=_GIT_TIMEOUT,
check=False, check=False,
stdin=subprocess.DEVNULL, stdin=subprocess.DEVNULL,
**_popen_kwargs,
) )
return result.stdout.strip() if result.returncode == 0 else "" return result.stdout.strip() if result.returncode == 0 else ""
except Exception: except Exception: