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.
This commit is contained in:
Brooklyn Nicholson 2026-06-28 10:19:21 -05:00
parent 0c2e6c0049
commit eeca59f489
7 changed files with 78 additions and 16 deletions

View file

@ -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,

View file

@ -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')

View file

@ -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:

View file

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

View file

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

View file

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

View file

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