diff --git a/packages/desktop/src/main/index.ts b/packages/desktop/src/main/index.ts index 345e996..80c0979 100644 --- a/packages/desktop/src/main/index.ts +++ b/packages/desktop/src/main/index.ts @@ -28,16 +28,22 @@ function quitApp() { app.quit() } +function loginItemOptions() { + return { + path: process.execPath, + args: ['--hidden'], + } +} + function getOpenAtLogin(): boolean { - return app.getLoginItemSettings().openAtLogin + return app.getLoginItemSettings(loginItemOptions()).openAtLogin } function setOpenAtLogin(openAtLogin: boolean) { app.setLoginItemSettings({ + ...loginItemOptions(), openAtLogin, openAsHidden: true, - path: process.execPath, - args: ['--hidden'], }) } diff --git a/packages/desktop/src/main/webui-server.ts b/packages/desktop/src/main/webui-server.ts index c5bbc34..f535097 100644 --- a/packages/desktop/src/main/webui-server.ts +++ b/packages/desktop/src/main/webui-server.ts @@ -228,14 +228,12 @@ export async function startWebUiServer(port = DEFAULT_PORT): Promise { // setup is a #!/bin/sh wrapper, not a python interpreter, so detection // resolves to /bin/sh and the bridge crashes (exit code 2) immediately. const isWin = process.platform === 'win32' + const bundledPython = isWin + ? join(pythonDir(), 'python.exe') + : join(pythonDir(), 'bin', 'python3') const bundledPythonNoWindow = isWin ? join(pythonDir(), 'pythonw.exe') - : join(pythonDir(), 'bin', 'python3') - const bundledPython = isWin && existsSync(bundledPythonNoWindow) - ? bundledPythonNoWindow - : isWin - ? join(pythonDir(), 'python.exe') - : join(pythonDir(), 'bin', 'python3') + : bundledPython const bridgePort = await getFreeTcpPort() const workerPortBase = await getFreeTcpPortInRange(20000, 59000) const loginShellPath = await getLoginShellPath() @@ -255,6 +253,9 @@ export async function startWebUiServer(port = DEFAULT_PORT): Promise { NODE_ENV: 'production', HERMES_DESKTOP: 'true', HERMES_BIN: hermesBin(), + // The bridge and its per-profile workers need working stdout/stderr for + // ready handshakes. Use python.exe on Windows and hide windows at the + // process creation layer instead of switching the bridge to pythonw.exe. HERMES_AGENT_BRIDGE_PYTHON: bundledPython, HERMES_AGENT_CLI_PYTHON: existsSync(bundledPythonNoWindow) ? bundledPythonNoWindow : bundledPython, HERMES_AGENT_ROOT: pythonDir(), diff --git a/packages/server/src/services/hermes/agent-bridge/hermes_bridge.py b/packages/server/src/services/hermes/agent-bridge/hermes_bridge.py index 657b052..a142263 100755 --- a/packages/server/src/services/hermes/agent-bridge/hermes_bridge.py +++ b/packages/server/src/services/hermes/agent-bridge/hermes_bridge.py @@ -71,6 +71,40 @@ def _hidden_subprocess_kwargs() -> dict[str, int]: return {"creationflags": getattr(subprocess, "CREATE_NO_WINDOW", 0)} +def _install_windows_hidden_subprocess_defaults() -> None: + """Hide console windows for subprocesses launched inside desktop bridge runs. + + The desktop bridge itself must keep stdout/stderr pipes for readiness and + worker handshakes, so it runs under python.exe. On Windows that means any + nested console executable, including git.exe from context expansion, can + flash a window unless the child process is created with CREATE_NO_WINDOW. + """ + if os.name != "nt": + return + if os.environ.get("HERMES_DESKTOP", "").strip().lower() != "true": + return + if getattr(subprocess, "_hermes_hidden_defaults_installed", False): + return + + original_popen = subprocess.Popen + create_no_window = getattr(subprocess, "CREATE_NO_WINDOW", 0) or 0x08000000 + + class HiddenPopen(original_popen): # type: ignore[misc, valid-type] + def __init__(self, *args: Any, **kwargs: Any) -> None: + flags = kwargs.get("creationflags", 0) or 0 + try: + kwargs["creationflags"] = int(flags) | create_no_window + except Exception: + kwargs["creationflags"] = create_no_window + super().__init__(*args, **kwargs) + + subprocess.Popen = HiddenPopen # type: ignore[assignment] + subprocess._hermes_hidden_defaults_installed = True # type: ignore[attr-defined] + + +_install_windows_hidden_subprocess_defaults() + + def _process_exists(pid: int) -> bool: if pid <= 0: return False diff --git a/tests/server/agent-bridge-profile-env.test.ts b/tests/server/agent-bridge-profile-env.test.ts index 4a79e32..ff97280 100644 --- a/tests/server/agent-bridge-profile-env.test.ts +++ b/tests/server/agent-bridge-profile-env.test.ts @@ -106,6 +106,74 @@ print(json.dumps({ }) }) +describe('agent bridge Windows desktop subprocess defaults', () => { + it('adds CREATE_NO_WINDOW to nested subprocesses without replacing existing flags', async () => { + const result = await runBridgeProbe(String.raw` +import importlib.util +import json +import os +import sys + +spec = importlib.util.spec_from_file_location("hermes_bridge", os.environ["BRIDGE_PATH"]) +bridge = importlib.util.module_from_spec(spec) +sys.modules["hermes_bridge"] = bridge +spec.loader.exec_module(bridge) + +original_os_name = bridge.os.name +original_popen = bridge.subprocess.Popen +original_create_no_window = getattr(bridge.subprocess, "CREATE_NO_WINDOW", None) +original_installed = getattr(bridge.subprocess, "_hermes_hidden_defaults_installed", None) + +class FakePopen: + calls = [] + + def __init__(self, *args, **kwargs): + FakePopen.calls.append({"args": args, "kwargs": kwargs}) + +try: + bridge.os.name = "nt" + bridge.os.environ["HERMES_DESKTOP"] = "true" + bridge.subprocess.Popen = FakePopen + bridge.subprocess.CREATE_NO_WINDOW = 0x08000000 + if hasattr(bridge.subprocess, "_hermes_hidden_defaults_installed"): + delattr(bridge.subprocess, "_hermes_hidden_defaults_installed") + + bridge._install_windows_hidden_subprocess_defaults() + bridge.subprocess.Popen(["git", "status"], creationflags=0x00000200) + flags = FakePopen.calls[0]["kwargs"]["creationflags"] +finally: + bridge.os.name = original_os_name + bridge.subprocess.Popen = original_popen + if original_create_no_window is None: + try: + delattr(bridge.subprocess, "CREATE_NO_WINDOW") + except AttributeError: + pass + else: + bridge.subprocess.CREATE_NO_WINDOW = original_create_no_window + if original_installed is None: + try: + delattr(bridge.subprocess, "_hermes_hidden_defaults_installed") + except AttributeError: + pass + else: + bridge.subprocess._hermes_hidden_defaults_installed = original_installed + +print(json.dumps({ + "flags": flags, + "has_create_no_window": bool(flags & 0x08000000), + "kept_existing_flag": bool(flags & 0x00000200), +})) +`) + + expect(result).toEqual({ + flags: 0x08000200, + has_create_no_window: true, + kept_existing_flag: true, + }) + }) +}) + describe('agent bridge profile environment', () => { it('runs agent calls with the requested profile HERMES_HOME and restores the bridge home', async () => { const profileHome = join(tempDir, 'profiles', 'work')