* fix(windows): stop terminal-window popups from background spawns
Native-Windows desktop/gateway users saw cmd/conhost windows flash on
gateway restart, image paste, the dashboard Projects tree, voice notes,
and ~5 min after closing the app (detached cron). Two root causes:
- Console-subsystem exes (taskkill, schtasks, wmic, netstat, tasklist,
agent-browser, git, ffmpeg, powershell, git-bash) spawned via raw
subprocess allocate a fresh console when the launching process has
none (pythonw desktop backend / detached gateway) - even with output
captured.
- uv venv pythonw shims re-exec console python.exe, so Python children
get a console regardless of how they're launched.
Fixes:
- Single hidden-spawn primitive (_subprocess_compat.run/.popen) that ORs
CREATE_NO_WINDOW on Windows, no-op on POSIX. Route every Hermes-owned
console-exe spawn through it.
- FreeConsole() catch-all in hermes_bootstrap: any Python child that
exclusively owns an auto-allocated console detaches it at startup
(GetConsoleProcessList()==1 gate leaves shared interactive consoles
untouched).
- Replace PowerShell/wmic gateway PID scans with in-process psutil.
- Skip schtasks queries on non-interactive desktop restarts.
- Prefer native agent-browser .exe over .cmd shims.
- Guard test bans raw subprocess spawns of the Windows-only console
tools repo-wide so the popup class can't regress.
* fix(windows): scope FreeConsole to background entry points; fix merge fallout
Console detach review (per #53810 feedback): GetConsoleProcessList()==1 can't
tell a uv pythonw->python phantom console apart from a user opening the
interactive CLI/TUI in its own fresh console (double-click, shortcut, ConPTY) —
both report a single attached process with a tty. Running FreeConsole() in the
import-time bootstrap therefore risked detaching a legitimately-interactive
terminal.
- Extract FreeConsole into explicit hermes_bootstrap.detach_orphan_console();
remove it from apply_windows_utf8_bootstrap() (import side effect).
- Call it only from known background mains: gateway run, dashboard backend
(start_server, what the desktop spawns), cron standalone, tui_gateway entry,
slash worker. Interactive CLI/TUI never calls it.
- Behavior-contract tests: frees only when solo owner, leaves shared console,
no-op without console / on POSIX, and asserts it's not an import side effect.
Merge fallout from origin/main (#53791):
- local.py: 3-way merge left a dangling **_popen_kwargs (NameError crashing
every terminal init). _subprocess_compat.popen already hides the window, so
drop it.
- discord adapter: merge stacked an undefined windows_hide_flags() onto the
primitive call; drop the redundant arg.
- test_gateway: scan now goes psutil-first (zero spawn); rewrite the
case-variant test to drive that production path.
* test(claw): mock _subprocess_compat.run seam for Windows process scan
claw.py's Windows tasklist/powershell scan routes through the hidden-spawn
primitive; the tests still patched claw_mod.subprocess, so on win32 the mock
was never hit and real spawns returned nothing. Patch the actual seam.
* fix(windows): harden gateway scheduled task
* fix(windows): launch gateway scheduled task via console-less wscript
The Scheduled Task ran the gateway through cmd.exe, which allocates a
console. During logon Windows broadcasts CTRL_CLOSE_EVENT to console
process groups, reaping cmd.exe and the half-initialized gateway with
STATUS_CONTROL_C_EXIT (0xC000013A) - which Task Scheduler treats as a
user cancel, so RestartOnFailure never fires and the gateway vanishes on
every reboot (issue #45599 root cause #1).
Add a console-less .vbs launcher (wscript.exe -> pythonw.exe, both
GUI-subsystem) mirroring the gateway.cmd env + argv, and point the task
action at it. The .cmd stays for the Startup-folder fallback and /Run.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Jeff <jeffrobodie@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Assert _exec_schtasks passes an explicit encoding and errors="replace" to
subprocess.run, and that _schtasks_encoding falls back to utf-8 when the
locale lookup is empty or raises (#38172).
Sessions now survive `hermes gateway stop` / `restart` on native Windows.
Previously the gateway died on schtasks `/End` + os.kill SIGTERM without
ever running the drain loop, so the v0.13.0 session-resume feature (#21192)
silently broke on Windows: `resume_pending=True` was never written, and
the next boot started with a blank conversation history (issue #33778).
Root cause is twofold and the reporter only identified half of it:
1. `hermes_cli/gateway_windows.py::stop()` did not write the
`planned_stop_marker` before signalling. The reporter caught this.
2. The bigger reason: `asyncio.add_signal_handler` raises
NotImplementedError for SIGTERM/SIGINT on Windows, so even if the
marker had been written, the gateway's existing SIGTERM handler
(which is what calls `runner.stop()` and the `mark_resume_pending`
loop) was never invoked. Writing the marker would have been
necessary-but-insufficient.
The fix has two parts:
* gateway/run.py: new `_run_planned_stop_watcher` daemon thread polls
for the planned-stop marker file every 0.5s. When the marker appears
it `loop.call_soon_threadsafe(shutdown_signal_handler, None)` — the
same shutdown path a real SIGTERM would have driven, including the
pre-drain `mark_resume_pending` writes (run.py:5977) and graceful
drain wait. The existing signal handler already accepts
`received_signal=None` and falls through to
`consume_planned_stop_marker_for_self()`, so no handler changes
needed. Runs on every platform as cheap belt-and-suspenders.
* hermes_cli/gateway_windows.py: `stop()` now writes the marker for
the running gateway PID and waits up to `agent.restart_drain_timeout`
(default 30s) for the PID to exit cleanly. On clean drain, the kill
sweep is non-forceful; on timeout, escalates to
`kill_gateway_processes(force=True)` which routes to taskkill /T /F
per `references/windows-native-support.md`.
Validation:
* 7 new tests in tests/gateway/test_planned_stop_watcher.py covering:
marker→handler dispatch, no-marker idle, already-draining skip,
not-yet-running skip, stop_event responsiveness, fire-once
semantics, error tolerance.
* 8 new tests in tests/hermes_cli/test_gateway_windows.py covering:
marker-before-kill ordering, clean-drain skips force-kill,
drain-timeout escalates to force=True, no-pid-skips-drain,
invalid-pid handling, fast-exit success, timeout failure,
marker-write-failure tolerance.
* E2E (Linux, detached orphan): write_planned_stop_marker(pid) +
`_drain_gateway_pid(pid, 5.0)` returns True in 0.5s after the
victim sees the marker and exits. Tested with a double-forked
subprocess so the test parent isn't holding it as a zombie.
* Targeted: tests/gateway/{restart_drain,restart_resume_pending,
signal,signal_format,status,shutdown_forensics,approve_deny_commands,
planned_stop_watcher} + tests/hermes_cli/{gateway_windows,
gateway_service} → 519/519.
What was wrong with the reporter's claim (for future archaeology): they
described the symptom as "no `resume_pending=True` written to
`sessions.json`" — but Hermes uses `state.db` (SQLite), not
`sessions.json`, and `mark_resume_pending` is called regardless of
the marker (the marker only affects exit code 0 vs 1 for systemd
revival semantics). The real session-loss path is the missing drain
on Windows, not a missing marker. Both halves are fixed here.
Closes#33778.
Linux/macOS CI runners don't have ctypes.windll, so the elevated-gateway
test fails at module load. Adding raising=False lets monkeypatch install
the mock attribute without first requiring it to exist.
Preserve Windows profile install decisions across UAC handoff, avoid visible console windows by launching via pythonw, make repeated install/start idempotent, recreate stale Scheduled Tasks, and separate start-now from login auto-start behavior. Add Windows gateway regression coverage and systemd setup tests for the shared install flow.