fix(desktop): detach the Windows packaged launch and swallow launcher Ctrl-C

Two launcher defects in cmd_gui's packaged launch path:

- On Windows the packaged Desktop was spawned with a console-inheriting
  subprocess.run, so closing the launching shell sent CTRL_CLOSE_EVENT
  down the process group and killed the app with it, while Electron/Node
  stdout flooded (and, under cp936, mojibaked) the parent terminal.
  Spawn it detached instead — CREATE_NEW_PROCESS_GROUP|DETACHED_PROCESS
  (+ CREATE_BREAKAWAY_FROM_JOB with a no-breakaway fallback), DEVNULL
  stdio — and exit 0 immediately, like the bundled launcher and
  gateway_windows._spawn_detached. macOS/Linux keep the foreground run.

- On the foreground platforms, Ctrl-C in the attached terminal raised a
  raw KeyboardInterrupt traceback out of the CLI instead of a clean
  close. Catch it and exit 0 with a short message.

Co-authored-by: briandevans <252620095+briandevans@users.noreply.github.com>
Co-authored-by: webtecnica <75556242+webtecnica@users.noreply.github.com>
This commit is contained in:
Hermes Agent
2026-09-25 11:39:42 -05:00
committed by brooklyn!
parent 283de37b6b
commit 3ddf82fe24

View File

@@ -1549,10 +1549,60 @@ def cmd_gui(args: argparse.Namespace):
if deferred_entry is not None:
env = deferred_entry.child_env(env)
pass_fds = deferred_entry.pass_fds
with desktop_console_output(source_mode=source_mode) as streams:
launch_result = subprocess.run(
launch_command, cwd=desktop_dir, env=env, check=False, pass_fds=pass_fds, **streams
if not source_mode and sys.platform == "win32":
# Windows: detach the packaged Desktop from the parent console + process
# group, then return immediately (#58275). A console-inheriting
# subprocess.run dies with the launching shell (CTRL_CLOSE_EVENT fans
# out to the process group) and floods the parent terminal — under
# cp936, mojibake — with Electron/Node stdout. Mirrors the bundled
# launcher (_launch_bundled_desktop) and gateway_windows._spawn_detached.
# macOS/Linux keep the foreground run below: those launches are
# expected to stay attached to the terminal, and the desktop_console
# drain is a Windows-only concern.
from hermes_cli._subprocess_compat import (
windows_detach_flags,
windows_detach_flags_without_breakaway,
)
popen_kwargs = dict(
cwd=desktop_dir,
env=env,
stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
close_fds=True,
)
try:
subprocess.Popen(launch_command, creationflags=windows_detach_flags(), **popen_kwargs)
except OSError as exc:
# Only recover from a denied job breakaway (the parent's job object
# lacks JOB_OBJECT_LIMIT_BREAKAWAY_OK), which surfaces as
# ERROR_ACCESS_DENIED (winerror == 5). Re-raise every other spawn
# failure (bad argv/env, missing exe) so it stays a clear, single
# error instead of being masked by a doomed second attempt.
if getattr(exc, "winerror", None) != 5:
raise
subprocess.Popen(
launch_command,
creationflags=windows_detach_flags_without_breakaway(),
**popen_kwargs,
)
if deferred_entry is not None:
deferred_entry.finish()
desktop_launch_notice("✓ Hermes Desktop launched in a detached window; you can close this shell.")
sys.exit(0)
with desktop_console_output(source_mode=source_mode) as streams:
try:
launch_result = subprocess.run(
launch_command, cwd=desktop_dir, env=env, check=False, pass_fds=pass_fds, **streams
)
except KeyboardInterrupt:
# Ctrl-C in the terminal the launcher is attached to is the user
# closing the Desktop, not a launcher crash. Exit cleanly instead
# of dumping a KeyboardInterrupt traceback from subprocess.run
# (#59848).
print("\n✓ Hermes Desktop closed.")
sys.exit(0)
if deferred_entry is not None:
deferred_entry.finish()
sys.exit(launch_result.returncode)