# NousResearch/hermes-agent · Windows gateway console window and launcher hardening

## Open now

- Nothing open on GitHub.

These issues and fixes concern visible console/cmd windows appearing during Windows gateway startup, restart, and subprocess spawning, particularly involving uv venvs, pythonw vs python.exe selection, scheduled task configuration, and stale pre-existing autostart launchers.

## TL;DR

- 21 member(s): 0 open issue(s), 10 closed issue(s), 0 open PR(s), 3 merged PR(s), 8 PR(s) closed without merging.
- A fix was merged to main in PR #45610. Verify it addresses your case.
- **Recommended:** read PR #45610 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `partially_resolved` · deciding PR(s): `pr-45610`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_PARTIAL`): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text.
- Members outside that structural core may belong to a different underlying problem.
- Pairwise link density `0.10`: the share of member pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. `1.00` means every pair is linked.

## Closed / duplicate issues

- #30308: hermes gateway start pops up cmd.exe console window on Windows (uv venv + pythonw shim issue)
- #38387: Windows gateway Scheduled Task can leave blank console window due to uv venv pythonw redirecting to python.exe
- #39646: Windows Gateway update/restart path relaunches console python.exe and shows conhost window
- #40696: \[Setup\]: Hermes Gateway Scheduled Task shows visible cmd window on Windows — with fix
- #45599: \[Windows\] Gateway does not survive reboot - .cmd wrapper killed by console control event + schtasks missing critical XML settings
- #51661: Windows: get_python_path() returns python.exe instead of pythonw.exe, causing visible console window on gateway restart after update
- #51715: Hindsight daemon fails silently on Windows with uv venv: _windows_gui_interpreter() has no fallback, ensure_running() times out in non-TTY
- #53173: \[Windows\] hermes update/doctor don't migrate pre-#45610 gateway autostart launchers — stale .cmd Scheduled Task/Startup entries respawn console windows & die at logon
- #54282: Windows: Use pythonw.exe for cron script execution to prevent console window flash
- #68457: Windows: uv venv launcher shim re-exec causes console window flashes on every subprocess spawn (SlashWorker, gateway restart watcher)

## Prior & competing PRs

- **#41028: fix(gateway): avoid Windows uv pythonw launcher console**. closed without merge · fix tier `suggested`
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `433db17c0`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#41184: fix(gateway): hide scheduled-task cmd wrapper on Windows**. closed without merge · fix tier `suggested`
  - maintainer direction: category `uncategorized`; implication `needs_llm`; refs `433db17c0a8d5581b4fb38289539fc1ee5cc7696`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#45610: fix(windows): harden gateway scheduled task**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `hermes_cli/gateway_windows.py`, `tests/hermes_cli/test_gateway_windows.py`
  - fix hunks:
  - `hermes_cli/gateway_windows.py`

    ```diff
    @@ -38,6 +38,7 @@
     import sys
     import time
     from pathlib import Path
    +from xml.sax.saxutils import escape
     
     # Short timeouts: schtasks occasionally wedges and we don't want to hang forever.
     _SCHTASKS_TIMEOUT_S = 15
    ```

    ```diff
    @@ -51,6 +52,9 @@
     
     _TASK_NAME_DEFAULT = "Hermes_Gateway"
     _TASK_DESCRIPTION = "Hermes Agent Gateway - Messaging Platform Integration"
    +_TASK_LOGON_DELAY = "PT30S"
    +_TASK_RESTART_INTERVAL = "PT1M"
    +_TASK_RESTART_COUNT = 999
     
     
     def _schtasks_encoding() -> str:
    ```

    ```diff
    @@ -358,12 +362,13 @@ def _build_gateway_cmd_script(
         lines.append(f'set "HERMES_HOME={hermes_home}"')
         lines.append('set "PYTHONIOENCODING=utf-8"')
         lines.append('set "HERMES_GATEWAY_DETACHED=1"')
    +    pythonw_path, venv_dir, extra_pythonpath = _resolve_detached_python(python_path)
         # VIRTUAL_ENV lets the gateway's own python detection find the venv
         # if someone imports hermes_constants-based logic during startup.
    -    venv_dir = str(Path(python_path).resolve().parent.parent)
         lines.append(f'set "VIRTUAL_ENV={venv_dir}"')
    +    pythonpath_entries = [str(Path(__file__).resolve().parent.parent), *extra_pythonpath]
    +    lines.append(f'set "PYTHONPATH={";".join([*pythonpath_entries, "%PYTHONPATH%"])}"')
     
    -    pythonw_path = _derive_venv_pythonw(python_path)
         prog_args = [pythonw_path, "-m", "hermes_cli.main"]
         if profile_arg:
             prog_args.extend(profile_arg.split())
    ```

    ```diff
    @@ -379,6 +384,78 @@ def _build_gateway_cmd_script(
         return "\r\n".join(lines) + "\r\n"
     
     
    +def _quote_vbs_string(value: str) -> str:
    +    """Quote a value as a VBScript double-quoted string literal.
    +
    +    VBScript escapes an embedded double-quote by doubling it. A newline cannot
    +    appear inside a literal, so refuse it (same guard as ``_quote_cmd_script_arg``).
    +    """
    +    if "\r" in value or "\n" in value:
    +        raise ValueError(f"refusing to quote VBScript value containing newline: {value!r}")
    +    return '"' + value.replace('"', '""') + '"'
    +
    +
    +def _build_gateway_vbs_script(
    +    python_path: str,
    +    working_dir: str,
    +    hermes_home: str,
    +    profile_arg: str,
    +) -> str:
    +    """Build a console-less ``gateway.vbs`` launcher (CRLF-terminated).
    +
    +    The Scheduled Task runs this through ``wscript.exe`` instead of ``cmd.exe``.
    +
    +    Why: issue #45599 root cause #1. Driving the gateway through ``cmd.exe``
    +    allocates a console, and 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``). Task Scheduler treats that
    +    code as a user cancel, so the ``RestartOnFailure`` policy never fires and the
    +    gateway silently disappears on every reboot.
    +
    +    ``wscript.exe`` and ``pythonw.exe`` are both GUI-subsystem executables with
    +    no console, so this launcher receives no console control events. It mirrors
    +    ``_build_gateway_cmd_script`` (same env + argv via ``_resolve_detached_python``)
    +    but sets the environment on the WScript.Shell process and ``Run``s pythonw
    +    directly — no cmd.exe anywhere in the chain.
    +    """
    +    pythonw_path, venv_dir, extra_pythonpath = _resolve_detached_python(python_path)
    +
    +    prog_args = [pythonw_path, "-m", "hermes_cli.main"]
    +    if profile_arg:
    +        prog_args.extend(profile_arg.split())
    +    prog_args.extend(["gateway", "run"])
    +    # list2cmdline gives CreateProcess-correct quoting for WScript.Shell.Run.
    +    command_line = subprocess.list2cmdline(prog_args)
    +
    +    repo_root = str(Path(__file__).resolve().parent.parent)
    +    static_pythonpath = os.pathsep.join([repo_root, *extra_pythonpath])
    +
    +    lines = [
    +        f"' {_TASK_DESCRIPTION}",
    +        "Option Explicit",
    +        "Dim sh, env, existing_pp",
    +        'Set sh = CreateObject("WScript.Shell")',
    +        'Set env = sh.Environment("PROCESS")',
    +        f"env.Item({_quote_vbs_string('HERMES_HOME')}) = {_quote_vbs_string(hermes_home)}",
    +        f"env.Item({_quote_vbs_string('PYTHONIOENCODING')}) = {_quote_vbs_string('utf-8')}",
    +        f"env.Item({_quote_vbs_string('HERMES_GATEWAY_DETACHED')}) = {_quote_vbs_string('1')}",
    +        f"env.Item({_quote_vbs_string('VIRTUAL_ENV')}) = {_quote_vbs_string(str(venv_dir))}",
    +        # Mirror the cmd wrapper's ``PYTHONPATH=<static>;%PYTHONPATH%``: chain onto
    +        # whatever PYTHONPATH the task environment already carries, at runtime.
    +        f"existing_pp = env.Item({_quote_vbs_string('PYTHONPATH')})",
    +        "If Len(existing_pp) > 0 Then",
    +        f"  env.Item({_quote_vbs_string('PYTHONPATH')}) = {_quote_vbs_string(static_pythonpath + os.pathsep)} & existing_pp",
    +        "Else",
    +        f"  env.Item({_quote_vbs_string('PYTHONPATH')}) = {_quote_vbs_string(static_pythonpath)}",
    +        "End If",
    +        f"sh.CurrentDirectory = {_quote_vbs_string(working_dir)}",
    +        # Window style 0 = hidden; bWaitOnReturn False = detached/async. pythonw is
    +        # GUI-subsystem so no console is ever created for the gateway either.
    +        f"sh.Run {_quote_vbs_string(command_line)}, 0, False",
    +    ]
    +    return "\r\n".join(lines) + "\r\n"
    +
    +
     def _build_startup_launcher(script_path: Path) -> str:
         """The tiny .cmd that goes in the Startup folder. Just minimizes and chains.
     
    ```

    ```diff
    @@ -425,6 +502,15 @@ def _write_task_script() -> Path:
         tmp = script_path.with_suffix(".tmp")
         tmp.write_text(content, encoding="utf-8", newline="")
         tmp.replace(script_path)
    +
    +    # Also render the console-less .vbs launcher the Scheduled Task runs via
    +    # wscript.exe (issue #45599 fix A). The .cmd above stays for the
    +    # Startup-folder fallback and direct /Run paths.
    +    vbs_content = _build_gateway_vbs_script(python_path, working_dir, hermes_home, profile_arg)
    +    vbs_path = script_path.with_suffix(".vbs")
    +    vbs_tmp = vbs_path.with_name(vbs_path.name + ".tmp")
    +    vbs_tmp.write_text(vbs_content, encoding="utf-8", newline="")
    +    vbs_tmp.replace(vbs_path)
         return script_path
     
     
    ```

    ```diff
    @@ -443,6 +529,74 @@ def _resolve_task_user() -> str | None:
         return f"{domain}\\{username}" if domain else username
     
     
    +def _build_scheduled_task_xml(task_name: str, launcher_path: Path, user: str | None) -> str:
    +    """Render a Task Scheduler XML definition with safe long-running defaults.
    +
    +    ``launcher_path`` is the console-less ``.vbs`` the task runs via
    +    ``wscript.exe`` — not the ``.cmd`` (see ``_build_gateway_vbs_script`` /
    +    issue #45599 root cause #1).
    +    """
    +    user_principal = f"\n      <UserId>{escape(user)}</UserId>" if user else ""
    +    return f"""<?xml version="1.0" encoding="UTF-16"?>
    +<Task version="1.4" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
    +  <RegistrationInfo>
    +    <Description>{escape(_TASK_DESCRIPTION)}</Description>
    +  </RegistrationInfo>
    +  <Triggers>
    +    <LogonTrigger>
    +      <Enabled>true</Enabled>
    +      <Delay>{_TASK_LOGON_DELAY}</Delay>
    +    </LogonTrigger>
    +  </Triggers>
    +  <Principals>
    +    <Principal id="Author">{user_principal}
    +      <LogonType>InteractiveToken</LogonType>
    +      <RunLevel>LeastPrivilege</RunLevel>
    +    </Principal>
    +  </Principals>
    +  <Settings>
    +    <MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
    +    <DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
    +    <StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
    +    <AllowHardTerminate>true</AllowHardTerminate>
    +    <StartWhenAvailable>true</StartWhenAvailable>
    +    <RunOnlyIfNetworkAvailable>false</RunOnlyIfNetworkAvailable>
    +    <IdleSettings>
    +      <StopOnIdleEnd>false</StopOnIdleEnd>
    +      <RestartOnIdle>false</RestartOnIdle>
    +    </IdleSettings>
    +    <AllowStartOnDemand>true</AllowStartOnDemand>
    +    <Enabled>true</Enabled>
    +    <Hidden>false</Hidden>
    +    <RunOnlyIfIdle>false</RunOnlyIfIdle>
    +    <WakeToRun>false</WakeToRun>
    +    <ExecutionTimeLimit>PT0S</ExecutionTimeLimit>
    +    <Priority>7</Priority>
    +    <RestartOnFailure>
    +      <Interval>{_TASK_RESTART_INTERVAL}</Interval>
    +      <Count>{_TASK_RESTART_COUNT}</Count>
    +    </RestartOnFailure>
    +  </Settings>
    +  <Actions Context="Author">
    +    <Exec>
    +      <Command>wscript.exe</Command>
    +      <Arguments>//B //Nologo "{escape(str(launcher_path))}"</Arguments>
    +    </Exec>
    +  </Actions>
    +</Task>
    +"""
    +
    +
    +def _write_scheduled_task_xml(task_name: str, launcher_path: Path, user: str | None) -> Path:
    +    xml_path = launcher_path.with_suffix(".task.xml")
    +    xml_path.write_text(
    +        _build_scheduled_task_xml(task_name, launcher_path, user),
    +        encoding="utf-16",
    +        newline="",
    +    )
    +    return xml_path
    +
    +
     def _install_scheduled_task(task_name: str, script_path: Path) -> tuple[bool, str]:
         """Create or replace the Scheduled Task. Returns (success, detail).
     
    ```

    ```diff
    @@ -451,8 +605,6 @@ def _install_scheduled_task(task_name: str, script_path: Path) -> tuple[bool, st
         preserves those stale triggers and can make the gateway relaunch every
         minute. Delete+create gives us a clean ONLOGON task every install.
         """
    -    quoted_script = _quote_schtasks_arg(str(script_path))
    -
         delete_code, delete_out, delete_err = _exec_schtasks(["/Delete", "/F", "/TN", task_name])
         delete_detail = (delete_err or delete_out or "").strip()
         if delete_code != 0 and delete_detail and "cannot find" not in delete_detail.lower():
    ```

    ```diff
    @@ -460,32 +612,28 @@ def _install_scheduled_task(task_name: str, script_path: Path) -> tuple[bool, st
                 return (False, f"schtasks /Delete failed (code {delete_code}): {delete_detail}")
             # Non-fatal: /Create /F below may still replace it. Keep the detail in
             # the final error if creation also fails.
    -    # password" variant; if that fails, retry without /RU /NP /IT.
    -    base = [
    -        "/Create",
    -        "/F",
    -        "/SC",
    -        "ONLOGON",
    -        "/RL",
    -        "LIMITED",
    -        "/TN",
    -        task_name,
    -        "/TR",
    -        quoted_script,
    -    ]
         user = _resolve_task_user()
    -    variants = []
    -    if user:
    -        variants.append([*base, "/RU", user, "/NP", "/IT"])
    +    # The Scheduled Task launches the console-less .vbs (issue #45599 fix A), not
    +    # the .cmd. The .cmd stays for the Startup-folder fallback and direct /Run.
    +    launcher_path = script_path.with_suffix(".vbs")
    +    xml_path = _write_scheduled_task_xml(task_name, launcher_path, user)
    +    base = ["/Create", "/F", "/TN", task_name, "/XML", str(xml_path)]
    +    variants = [[*base, "/RU", user, "/NP", "/IT"]] if user else []
         variants.append(base)
     
         last_code = 1
         last_err = ""
    -    for argv in variants:
    -        code, out, err = _exec_schtasks(argv)
    -        if code == 0:
    -            return (True, f"Created Scheduled Task {task_name!r}")
    -        last_code, last_err = code, (err or out or "")
    +    try:
    +        for argv in variants:
    +            code, out, err = _exec_schtasks(argv)
    +            if code == 0:
    +                return (True, f"Created Scheduled Task {task_name!r}")
    +            last_code, last_err = code, (err or out or "")
    +    finally:
    +        try:
    +            xml_path.unlink(missing_ok=True)
    +        except OSError:
    +            pass
         if delete_detail and "cannot find" not in delete_detail.lower():
             last_err = f"{last_err.strip()} (delete detail: {delete_detail})"
         return (False, f"schtasks /Create failed (code {last_code}): {last_err.strip()}")
    ```
  - `tests/hermes_cli/test_gateway_windows.py`

    ```diff
    @@ -190,7 +190,11 @@ def fake_install_startup_entry(path: Path) -> Path:
     
     def test_gateway_cmd_script_uses_pythonw_without_replace_or_start_churn(monkeypatch):
         """Scheduled Task wrapper should launch pythonw once and avoid replace loops."""
    -    monkeypatch.setattr(gateway_windows, "_derive_venv_pythonw", lambda exe: exe.replace("python.exe", "pythonw.exe"))
    +    monkeypatch.setattr(
    +        gateway_windows,
    +        "_resolve_detached_python",
    +        lambda exe: (exe.replace("python.exe", "pythonw.exe"), r"C:\\Hermes\\hermes-agent\\venv", []),
    +    )
     
         content = gateway_windows._build_gateway_cmd_script(
             r"C:\\Hermes\\hermes-agent\\venv\\Scripts\\python.exe",
    ```

    ```diff
    @@ -206,6 +210,41 @@ def test_gateway_cmd_script_uses_pythonw_without_replace_or_start_churn(monkeypa
         assert "exit /b 0" in content
     
     
    +def test_gateway_cmd_script_uses_uv_safe_base_pythonw(monkeypatch, tmp_path):
    +    """Scheduled Task wrapper should share the detached uv-venv workaround."""
    +    project = tmp_path / "project"
    +    scripts = project / "venv" / "Scripts"
    +    site_packages = project / "venv" / "Lib" / "site-packages"
    +    hermes_home = tmp_path / "hermes-home"
    +    base = tmp_path / "uv" / "python" / "cpython-3.11-windows-x86_64-none"
    +    scripts.mkdir(parents=True)
    +    site_packages.mkdir(parents=True)
    +    hermes_home.mkdir()
    +    base.mkdir(parents=True)
    +
    +    venv_python = scripts / "python.exe"
    +    venv_pythonw = scripts / "pythonw.exe"
    +    base_pythonw = base / "pythonw.exe"
    +    for exe in (venv_python, venv_pythonw, base_pythonw):
    +        exe.write_text("", encoding="utf-8")
    +    (project / "venv" / "pyvenv.cfg").write_text(
    +        f"home = {base}\nimplementation = CPython\nuv = 0.11.14\nversion_info = 3.11.15\n",
    +        encoding="utf-8",
    +    )
    +
    +    content = gateway_windows._build_gateway_cmd_script(
    +        str(venv_python),
    +        str(hermes_home),
    +        str(hermes_home),
    +        "",
    +    )
    +
    +    assert str(base_pythonw) in content
    +    assert f'set "VIRTUAL_ENV={project / "venv"}"' in content
    +    assert str(site_packages) in content
    +    assert str(venv_pythonw) not in content
    +
    +
     def test_elevated_gateway_command_uses_pythonw_hidden_console(monkeypatch):
         """UAC handoff should not leave a second elevated cmd.exe window open."""
         calls = []
    ```

    ```diff
    @@ -239,14 +278,18 @@ def test_install_scheduled_task_recreates_instead_of_change(monkeypatch, tmp_pat
         """Install must delete+create so stale minute-repeat task settings are not preserved."""
         calls = []
         script_path = tmp_path / "Hermes_Gateway_alice.cmd"
    +    xml_seen = {}
     
         monkeypatch.setattr(gateway_windows, "_assert_windows", lambda: None)
    +    monkeypatch.setattr(gateway_windows, "_resolve_task_user", lambda: r"DOMAIN\\alice")
     
         def fake_schtasks(args):
             calls.append(tuple(args))
             if args[0] == "/Delete":
                 return (0, "SUCCESS", "")
             if args[0] == "/Create":
    +            xml_path = Path(args[args.index("/XML") + 1])
    +            xml_seen["text"] = xml_path.read_text(encoding="utf-16")
                 return (0, "SUCCESS", "")
             raise AssertionError(f"unexpected schtasks args: {args}")
     
    ```

    ```diff
    @@ -257,8 +300,88 @@ def fake_schtasks(args):
         assert "/Change" not in [arg for call in calls for arg in call]
         assert calls[0][:4] == ("/Delete", "/F", "/TN", "Hermes_Gateway_alice")
         assert calls[1][0] == "/Create"
    -    assert "/SC" in calls[1]
    -    assert "ONLOGON" in calls[1]
    +    assert "/XML" in calls[1]
    +    assert "/SC" not in calls[1]
    +    assert "<Delay>PT30S</Delay>" in xml_seen["text"]
    +    assert "<StartWhenAvailable>true</StartWhenAvailable>" in xml_seen["text"]
    +    assert "<StopOnIdleEnd>false</StopOnIdleEnd>" in xml_seen["text"]
    +    assert "<DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>" in xml_seen["text"]
    +    assert "<StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>" in xml_seen["text"]
    +    assert "<ExecutionTimeLimit>PT0S</ExecutionTimeLimit>" in xml_seen["text"]
    +    assert "<RestartOnFailure>" in xml_seen["text"]
    +    assert "<Count>999</Count>" in xml_seen["text"]
    +    # Scheduled Task launches the console-less .vbs via wscript.exe, never cmd.exe
    +    # (issue #45599 fix A: no console -> no logon CTRL_CLOSE_EVENT / 0xC000013A).
    +    assert "<Command>wscript.exe</Command>" in xml_seen["text"]
    +    assert "//B //Nologo" in xml_seen["text"]
    +    assert "Hermes_Gateway_alice.vbs" in xml_seen["text"]
    +    assert "cmd.exe" not in xml_seen["text"]
    +
    +
    +def test_gateway_vbs_script_is_console_less(monkeypatch):
    +    """The .vbs launcher must avoid cmd.exe entirely and Run pythonw hidden
    +    (issue #45599 fix A: no console -> no logon CTRL_CLOSE_EVENT / 0xC000013A)."""
    +    monkeypatch.setattr(
    +        gateway_windows,
    +        "_resolve_detached_python",
    +        lambda exe: (r"C:\venv\Scripts\pythonw.exe", Path(r"C:\venv"), []),
    +    )
    +    content = gateway_windows._build_gateway_vbs_script(
    +        r"C:\venv\Scripts\python.exe",
    +        r"C:\Hermes",
    +        r"C:\Hermes",
    +        "--profile work",
    +    )
    +    assert "cmd.exe" not in content.lower()
    +    assert 'CreateObject("WScript.Shell")' in content
    +    assert "pythonw.exe" in content
    +    assert "hermes_cli.main" in content
    +    assert "gateway run" in content
    +    assert ", 0, False" in content  # hidden window, detached/async
    +    for var in ("HERMES_HOME", "PYTHONIOENCODING", "HERMES_GATEWAY_DETACHED", "VIRTUAL_ENV", "PYTHONPATH"):
    +        assert var in content
    +    assert "--profile" in content and "work" in content
    +    assert content.endswith("\r\n")
    +
    +
    +def test_gateway_vbs_script_quotes_spaced_paths(monkeypatch):
    +    """Spaced exe/dir paths stay correctly quoted through the VBScript literal."""
    +    monkeypatch.setattr(
    +        gateway_windows,
    +        "_resolve_detached_python",
    +        lambda exe: (r"C:\Program Files\Py\pythonw.exe", Path(r"C:\v env"), []),
    +    )
    +    content = gateway_windows._build_gateway_vbs_script(
    +        r"C:\Program Files\Py\python.exe",
    +        r"C:\work dir",
    +        r"C:\h home",
    +        "",
    +    )
    +    # list2cmdline quotes the spaced exe; _quote_vbs_string doubles those quotes.
    +    assert '""C:\\Program Files\\Py\\pythonw.exe""' in content
    +    assert 'sh.CurrentDirectory = "C:\\work dir"' in content
    +
    +
    +def test_gateway_vbs_script_pythonpath_chains_runtime_value(monkeypatch):
    +    """PYTHONPATH chains onto the task env's existing value, like ;%PYTHONPATH%."""
    +    monkeypatch.setattr(
    +        gateway_windows,
    +        "_resolve_detached_python",
    +        lambda exe: (r"C:\v\pythonw.exe", Path(r"C:\v"), [r"C:\v\Lib\site-packages"]),
    +    )
    +    content = gateway_windows._build_gateway_vbs_script(
    +        r"C:\v\python.exe", r"C:\w", r"C:\h", "",
    +    )
    +    assert 'existing_pp = env.Item("PYTHONPATH")' in content
    +    assert "If Len(existing_pp) > 0 Then" in content
    +    assert r"C:\v\Lib\site-packages" in content
    +
    +
    +def test_quote_vbs_string_doubles_quotes_and_rejects_newlines():
    +    assert gateway_windows._quote_vbs_string("plain") == '"plain"'
    +    assert gateway_windows._quote_vbs_string('a"b') == '"a""b"'
    +    with pytest.raises(ValueError):
    +        gateway_windows._quote_vbs_string("line1\nline2")
     
     
     def test_install_scheduled_task_success_start_now_uses_direct_spawn_not_task_run(monkeypatch, tmp_path, capsys):
    ```
- **#52239: fix(gateway,windows): respawn gateway windowless after GUI update (no persistent console window)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `hermes_cli/gateway.py`, `hermes_cli/gateway_windows.py`, `tests/tools/test_windows_native_support.py`
  - fix hunks:
  - `hermes_cli/gateway.py`

    ```diff
    @@ -5,6 +5,7 @@
     """
     
     import asyncio
    +import json
     import logging
     import os
     import shlex
    ```

    ```diff
    @@ -717,6 +718,38 @@ def _spawn_gateway_restart_watcher(old_pid: int, run_argv: list[str]) -> bool:
             windows_detach_popen_kwargs,
         )
     
    +    # On Windows the incoming ``run_argv`` leads with the venv's console
    +    # ``python.exe`` (from ``get_python_path()``).  Respawning the gateway
    +    # with that interpreter — even under CREATE_NO_WINDOW — leaves a
    +    # persistent console window, because uv's venv launcher re-execs the
    +    # base console interpreter, which allocates its own conhost.  Rewrite
    +    # the argv to the windowless ``pythonw.exe`` (mirroring the clean-start
    +    # ``_spawn_detached`` path) and capture the cwd + env overlay the base
    +    # interpreter needs to resolve imports without the venv launcher.
    +    # No-op on POSIX.  See gateway_windows.windowless_gateway_restart_spec.
    +    respawn_cwd = ""
    +    respawn_env_overlay: dict[str, str] = {}
    +    if sys.platform == "win32":
    +        try:
    +            from hermes_cli.gateway_windows import (
    +                windowless_gateway_restart_spec,
    +            )
    +
    +            run_argv, respawn_cwd, respawn_env_overlay = (
    +                windowless_gateway_restart_spec(list(run_argv))
    +            )
    +        except Exception:
    +            # Best-effort: if the rewrite fails for any reason, fall back to
    +            # the original argv.  A visible window is worse than nothing, but
    +            # a failed respawn is worse still — keep the gateway coming back.
    +            respawn_cwd = ""
    +            respawn_env_overlay = {}
    +
    +    # Serialized as JSON literals embedded in the watcher source so the
    +    # inner respawn can apply cwd= / env= without extra argv plumbing.
    +    respawn_cwd_literal = json.dumps(respawn_cwd)
    +    respawn_env_literal = json.dumps(respawn_env_overlay)
    +
         watcher = textwrap.dedent(
             """
             import os
    ```

    ```diff
    @@ -726,6 +759,8 @@ def _spawn_gateway_restart_watcher(old_pid: int, run_argv: list[str]) -> bool:
     
             pid = int(sys.argv[1])
             cmd = sys.argv[2:]
    +        _respawn_cwd = {respawn_cwd_literal}
    +        _respawn_env_overlay = {respawn_env_literal}
             deadline = time.monotonic() + 120
             while time.monotonic() < deadline:
                 # ``os.kill(pid, 0)`` is not a no-op on Windows — use the
    ```

    ```diff
    @@ -742,10 +777,18 @@ def _spawn_gateway_restart_watcher(old_pid: int, run_argv: list[str]) -> bool:
             # been spawned inside a job object (Electron/Tauri parent), and
             # without breakaway the respawned gateway would die when that job
             # tears down. See _subprocess_compat.windows_detach_flags().
    -        _popen_kwargs = {
    +        _popen_kwargs = {{
                 "stdout": subprocess.DEVNULL,
                 "stderr": subprocess.DEVNULL,
    -        }
    +        }}
    +        # Anchor the respawned gateway at the stable working dir and overlay
    +        # the env (VIRTUAL_ENV / PYTHONPATH / HERMES_HOME) the windowless
    +        # base interpreter needs to import hermes_cli.  Empty on POSIX, where
    +        # the venv python resolves imports without help.
    +        if _respawn_cwd:
    +            _popen_kwargs["cwd"] = _respawn_cwd
    +        if _respawn_env_overlay:
    +            _popen_kwargs["env"] = {{**os.environ, **_respawn_env_overlay}}
             if sys.platform == "win32":
                 _CREATE_NEW_PROCESS_GROUP = 0x00000200
                 _DETACHED_PROCESS = 0x00000008
    ```

    ```diff
    @@ -772,7 +815,10 @@ def _spawn_gateway_restart_watcher(old_pid: int, run_argv: list[str]) -> bool:
                 _popen_kwargs["start_new_session"] = True
                 subprocess.Popen(cmd, **_popen_kwargs)
             """
    -    ).strip()
    +    ).strip().format(
    +        respawn_cwd_literal=respawn_cwd_literal,
    +        respawn_env_literal=respawn_env_literal,
    +    )
     
         watcher_argv = [
             sys.executable,
    ```
  - `hermes_cli/gateway_windows.py`

    ```diff
    @@ -753,6 +753,77 @@ def _build_gateway_argv() -> tuple[list[str], str, dict[str, str]]:
         return argv, working_dir, env_overlay
     
     
    +def windowless_gateway_restart_spec(
    +    run_argv: list[str],
    +) -> tuple[list[str], str, dict[str, str]]:
    +    """Rewrite a console-``python.exe`` gateway argv into a windowless one.
    +
    +    The post-update restart paths build their respawn command from
    +    ``get_python_path()`` which returns the venv's console ``python.exe``.
    +    On Windows — especially with uv-created venvs — launching that
    +    interpreter (even with ``CREATE_NO_WINDOW``) leaves a persistent
    +    console window: ``venv\\Scripts\\python.exe`` is a launcher shim that
    +    re-execs the *base* console interpreter, which allocates its own
    +    conhost.  ``CREATE_NO_WINDOW`` cannot suppress that second window.
    +    See ``_resolve_detached_python`` for the gory details.
    +
    +    This mirrors what ``_build_gateway_argv`` / ``_spawn_detached`` do for
    +    a clean start: swap the interpreter for the windowless ``pythonw.exe``
    +    (base interpreter for uv venvs) and return the cwd + env overlay
    +    (VIRTUAL_ENV, PYTHONPATH) the base interpreter needs to resolve the
    +    ``hermes_cli`` package without the venv launcher's site config.
    +
    +    Returns ``(new_argv, working_dir, env_overlay)``.  ``new_argv``
    +    preserves every argument after the interpreter (``-m hermes_cli.main
    +    [--profile X] gateway run [--replace]``) verbatim.  On non-Windows, or
    +    if ``run_argv`` doesn't start with a resolvable python, the argv is
    +    returned unchanged with an empty overlay.
    +    """
    +    if not run_argv:
    +        return run_argv, "", {}
    +    if sys.platform != "win32":
    +        return run_argv, "", {}
    +
    +    from hermes_cli.config import get_hermes_home
    +    from hermes_cli.gateway import PROJECT_ROOT
    +
    +    python_exe = run_argv[0]
    +    rest = run_argv[1:]
    +
    +    # Only rewrite when the leading token actually looks like a python
    +    # interpreter we can find a windowless sibling for.  If a caller passed
    +    # something else (a captured argv whose argv[0] is already pythonw, or a
    +    # non-python launcher), leave it alone.
    +    try:
    +        windowless_python, venv_dir, extra_pythonpath = _resolve_detached_python(
    +            python_exe
    +        )
    +    except Exception:
    +        return run_argv, "", {}
    +
    +    new_argv = [windowless_python, *rest]
    +
    +    working_dir = _stable_gateway_working_dir(PROJECT_ROOT)
    +    project_root = str(PROJECT_ROOT)
    +    try:
    +        hermes_home = str(Path(get_hermes_home()).resolve())
    +    except Exception:
    +        hermes_home = ""
    +
    +    env_overlay: dict[str, str] = {
    +        "PYTHONIOENCODING": "utf-8",
    +        "HERMES_GATEWAY_DETACHED": "1",
    +        "VIRTUAL_ENV": str(venv_dir),
    +    }
    +    if hermes_home:
    +        env_overlay["HERMES_HOME"] = hermes_home
    +    _prepend_pythonpath(
    +        env_overlay,
    +        [project_root, *extra_pythonpath] if extra_pythonpath else [project_root],
    +    )
    +    return new_argv, working_dir, env_overlay
    +
    +
     def _spawn_detached(script_path: Path | None = None) -> int:
         """Launch the gateway as a fully detached background process.
     
    ```
  - `tests/tools/test_windows_native_support.py`

    ```diff
    @@ -15,6 +15,7 @@
     import signal
     import sys
     from pathlib import Path
    +from unittest import mock
     from unittest.mock import MagicMock
     
     import pytest
    ```

    ```diff
    @@ -1007,3 +1008,116 @@ def test_launch_detached_profile_gateway_restart_outer_popen_has_access_denied_f
                 "CreateProcess and retry without the breakaway bit, matching "
                 "gateway_windows._spawn_detached's fallback pattern."
             )
    +
    +    def test_watcher_rewrites_console_python_to_windowless(self):
    +        """The post-update respawn must NOT relaunch the gateway with the
    +        venv's console ``python.exe``.
    +
    +        Regression for the "terminal window stays open permanently after a
    +        GUI update" report: ``_gateway_run_args_for_profile`` builds the
    +        respawn argv from ``get_python_path()`` (console ``python.exe``).
    +        On Windows, launching that interpreter — even under
    +        CREATE_NO_WINDOW — leaves a persistent console window because uv's
    +        venv launcher re-execs the base console interpreter. The watcher
    +        must route the argv through
    +        ``gateway_windows.windowless_gateway_restart_spec`` so it becomes
    +        ``pythonw.exe`` with the cwd + PYTHONPATH overlay the base
    +        interpreter needs.
    +
    +        Static check: the watcher build (in ``_spawn_gateway_restart_watcher``)
    +        must invoke the rewrite helper and thread the cwd / env overlay into
    +        the inlined respawn ``Popen``.
    +        """
    +        root = Path(__file__).resolve().parents[2]
    +        text = (root / "hermes_cli" / "gateway.py").read_text(encoding="utf-8")
    +        assert "windowless_gateway_restart_spec" in text, (
    +            "_spawn_gateway_restart_watcher must rewrite the respawn argv via "
    +            "gateway_windows.windowless_gateway_restart_spec so the gateway "
    +            "comes back as windowless pythonw.exe, not console python.exe."
    +        )
    +        marker = "watcher = textwrap.dedent("
    +        idx = text.find(marker)
    +        end = text.find(".strip()", idx)
    +        block = text[idx:end]
    +        # The inlined respawn must apply the cwd + env overlay the base
    +        # interpreter needs — without them the windowless pythonw can't
    +        # import hermes_cli.
    +        assert '_popen_kwargs["cwd"]' in block, (
    +            "Inlined respawn must set cwd from the windowless spec so the "
    +            "base interpreter starts in the stable gateway working dir."
    +        )
    +        assert '_popen_kwargs["env"]' in block, (
    +            "Inlined respawn must overlay env (VIRTUAL_ENV / PYTHONPATH / "
    +            "HERMES_HOME) so the windowless base pythonw resolves hermes_cli."
    +        )
    +
    +
    +class TestWindowlessGatewayRestartSpec:
    +    """gateway_windows.windowless_gateway_restart_spec — the helper that
    +    converts a console-python gateway argv into a windowless pythonw one."""
    +
    +    def test_noop_on_non_windows(self):
    +        import hermes_cli.gateway_windows as gw
    +
    +        argv = ["/path/venv/bin/python", "-m", "hermes_cli.main", "gateway", "run"]
    +        with mock.patch.object(gw.sys, "platform", "linux"):
    +            new_argv, cwd, env = gw.windowless_gateway_restart_spec(list(argv))
    +        assert new_argv == argv
    +        assert cwd == ""
    +        assert env == {}
    +
    +    def test_empty_argv_is_safe(self):
    +        import hermes_cli.gateway_windows as gw
    +
    +        new_argv, cwd, env = gw.windowless_gateway_restart_spec([])
    +        assert new_argv == []
    +        assert cwd == ""
    +        assert env == {}
    +
    +    def test_windows_rewrites_to_pythonw_and_preserves_tail(self):
    +        """On Windows the interpreter is swapped for its windowless sibling
    +        while every subsequent argument is preserved verbatim."""
    +        import hermes_cli.gateway_windows as gw
    +
    +        # Pre-import on the (Linux) host so the function's lazy
    +        # ``from hermes_cli.gateway import PROJECT_ROOT`` resolves from
    +        # sys.modules instead of re-importing under the win32 platform
    +        # patch below — a fresh import would run gateway/status.py's
    +        # ``if sys.platform == "win32": import msvcrt`` branch and crash on
    +        # Linux CI with ModuleNotFoundError.
    +        import hermes_cli.config  # noqa: F401
    +        import hermes_cli.gateway  # noqa: F401
    +
    +        argv = [
    +            "C:/venv/Scripts/python.exe",
    +            "-m",
    +            "hermes_cli.main",
    +            "--profile",
    +            "work",
    +            "gateway",
    +            "run",
    +            "--replace",
    +        ]
    +
    +        def fake_resolve(python_exe):
    +            return ("C:/base/pythonw.exe", Path("C:/venv"), ["C:/venv/Lib/site-packages"])
    +
    +        # Mock get_hermes_home too: the real one calls Path.resolve(), which
    +        # consults sysconfig and raises ModuleNotFoundError under the win32
    +        # platform patch on a Linux host.
    +        with mock.patch.object(gw.sys, "platform", "win32"), mock.patch.object(
    +            gw, "_resolve_detached_python", side_effect=fake_resolve
    +        ), mock.patch.object(
    +            gw, "_stable_gateway_working_dir", return_value="C:/hermes"
    +        ), mock.patch(
    +            "hermes_cli.config.get_hermes_home", return_value="C:/hermes"
    +        ):
    +            new_argv, cwd, env = gw.windowless_gateway_restart_spec(list(argv))
    +
    +        assert new_argv[0] == "C:/base/pythonw.exe"
    +        # Everything after the interpreter is byte-for-byte preserved.
    +        assert new_argv[1:] == argv[1:]
    +        assert cwd == "C:/hermes"
    +        assert env["VIRTUAL_ENV"] == str(Path("C:/venv"))
    +        assert "PYTHONPATH" in env
    +        assert "site-packages" in env["PYTHONPATH"]
    ```
- **#70205: fix(windows): hidden-console daemons — extend the parent-console flash fix to every detached spawn path**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `gateway/run.py`, `hermes_cli/_subprocess_compat.py`, `hermes_cli/gateway.py`, `hermes_cli/gateway_windows.py`, `hermes_cli/main.py`, `hermes_cli/web_server.py`, `tests/gateway/test_restart_drain.py`, `tests/hermes_cli/test_gateway_windows.py`, `tests/tools/test_windows_native_support.py`
- **#30312: fix(gateway): resolve uv-managed pythonw for Windows scheduled-task wrapper**. closed without merge
  - maintainer direction: category `stale_abandoned`; implication `active`
- **#30314: fix(gateway): use _resolve_detached_python for uv venvs in .cmd generation**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `433db17c0a8d5581b4fb38289539fc1ee5cc7696`, `#45610`
- **#38605: fix(gateway): resolve uv pythonw for task launcher**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `433db17c0a8d5581b4fb38289539fc1ee5cc7696`, `#45610`, `#52239`
- **#53321: fix(windows): reconcile stale pre-#45610 cmd.exe gateway autostart launchers**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `#53321`, `#81362`, `#80569`
- **#58152: fix(gateway): launch restart watcher windowless on Windows (empty python.exe console after GUI update)**. closed without merge
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `0dbf639bc8`, `#70205`, `#70205`
- **#68472: fix(windows): prevent helper process console flashes**. closed without merge
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `0dbf639bc8`, `#70205`, `#54220`, `#56747`

## Fix status

- Merged to main in PR #45610 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-07-23`.
- Subsystem: `agent/pet`
