From eeca59f489194a4ce288b31de12b46c20d05cd48 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sun, 28 Jun 2026 10:19:21 -0500 Subject: [PATCH] fix(windows): hide remaining backend console-flash legs missed on main main (cb982ad99) wired windows_hide_flags() into the auxiliary git/gh/wmic/ bash/powershell/taskkill legs but left two it didn't reach, plus the Electron backend-launch leg it explicitly deferred. Cover them the same way: - apps/desktop/electron/main.cjs: getNoConsoleVenvPython resolves the BASE pythonw.exe instead of the venv Scripts\pythonw.exe shim, which re-execs a console python.exe and flashes a conhost the desktop backend can't suppress. Both backend creators put the venv site-packages on PYTHONPATH so imports still resolve under the base interpreter. (main's commit said this Electron leg "needs a Windows-tested change of its own".) - tools/tts_tool.py, tools/transcription_tools.py, plugins/platforms/discord: ffmpeg conversions (voice notes / TTS / STT) via windows_hide_flags(). - plugins/platforms/whatsapp: netstat + taskkill bridge-port cleanup via windows_hide_flags(). All no-ops on POSIX. Tests assert the base-pythonw preference and the ffmpeg legs pass CREATE_NO_WINDOW. --- apps/desktop/electron/main.cjs | 16 ++++---- .../electron/windows-child-process.test.cjs | 16 ++++++++ plugins/platforms/discord/adapter.py | 3 ++ plugins/platforms/whatsapp/adapter.py | 4 ++ ...test_windows_subprocess_no_window_flags.py | 38 +++++++++++++++++++ tools/transcription_tools.py | 7 ++-- tools/tts_tool.py | 10 +++-- 7 files changed, 78 insertions(+), 16 deletions(-) diff --git a/apps/desktop/electron/main.cjs b/apps/desktop/electron/main.cjs index 8fc05254ca5..c873a4bc915 100644 --- a/apps/desktop/electron/main.cjs +++ b/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, diff --git a/apps/desktop/electron/windows-child-process.test.cjs b/apps/desktop/electron/windows-child-process.test.cjs index 0194464d641..0a91272fac4 100644 --- a/apps/desktop/electron/windows-child-process.test.cjs +++ b/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') diff --git a/plugins/platforms/discord/adapter.py b/plugins/platforms/discord/adapter.py index f5c83aede45..16aa512461a 100644 --- a/plugins/platforms/discord/adapter.py +++ b/plugins/platforms/discord/adapter.py @@ -665,6 +665,8 @@ class VoiceReceiver: 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 @@ class VoiceReceiver: check=True, timeout=10, stdin=subprocess.DEVNULL, + creationflags=windows_hide_flags(), ) finally: try: diff --git a/plugins/platforms/whatsapp/adapter.py b/plugins/platforms/whatsapp/adapter.py index dc4361213e5..afa0d2e9d8a 100644 --- a/plugins/platforms/whatsapp/adapter.py +++ b/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 diff --git a/tests/test_windows_subprocess_no_window_flags.py b/tests/test_windows_subprocess_no_window_flags.py index dacf4c985f6..3f877bc32bb 100644 --- a/tests/test_windows_subprocess_no_window_flags.py +++ b/tests/test_windows_subprocess_no_window_flags.py @@ -261,3 +261,41 @@ def test_inline_skill_shell_hides_bash_window(monkeypatch): 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 diff --git a/tools/transcription_tools.py b/tools/transcription_tools.py index d0712c81e1e..49f8cbaca22 100644 --- a/tools/transcription_tools.py +++ b/tools/transcription_tools.py @@ -37,6 +37,7 @@ from pathlib import Path 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")) diff --git a/tools/tts_tool.py b/tools/tts_tool.py index d803086983e..b71ebfa8275 100644 --- a/tools/tts_tool.py +++ b/tools/tts_tool.py @@ -52,6 +52,7 @@ from pathlib import Path 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