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 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 ""

View File

@@ -12,6 +12,7 @@ from pathlib import Path
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

View File

@@ -122,6 +122,8 @@ from datetime import datetime, timezone
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

View File

@@ -5,6 +5,8 @@ import re
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}]"

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.
"""
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)

View File

@@ -27,6 +27,8 @@ import time
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)

View File

@@ -384,6 +384,12 @@ def _scan_gateway_pids(
# 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 _scan_gateway_pids(
encoding="utf-8",
errors="ignore",
timeout=10,
**_no_window,
)
except (OSError, subprocess.TimeoutExpired):
result = None
@@ -427,6 +434,7 @@ def _scan_gateway_pids(
encoding="utf-8",
errors="ignore",
timeout=15,
**_no_window,
)
except (OSError, subprocess.TimeoutExpired):
return []

View File

@@ -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 []

View File

@@ -621,16 +621,22 @@ class TestTerminatePid:
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):

View File

@@ -31,6 +31,8 @@ import time
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: