{"repo":"NousResearch/hermes-agent","public_id":"30308","display_label":"Windows gateway console window and launcher hardening","summary":"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.","subsystem":"agent/pet","counts":{"members":21,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":10,"merged_prs":3,"attempt_prs":8},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":45610},"resolution":{"state":"partially_resolved","by":"pr-45610"},"freshness":{"state":"verified","last_verified_at":"2026-07-23T18:13:15+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.0954285714285714},"fix_edges":[{"issue_number":30308,"pr_number":45610,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":35692,"pr_number":45610,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":38387,"pr_number":45610,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":38387,"pr_number":52239,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":42116,"pr_number":70205,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":45599,"pr_number":45610,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":51661,"pr_number":70205,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":54528,"pr_number":70205,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":62734,"pr_number":70205,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":68457,"pr_number":70205,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false}],"members":[{"kind":"issue","number":30308,"title":"hermes gateway start pops up cmd.exe console window on Windows (uv venv + pythonw shim issue)","url":null,"state":"closed"},{"kind":"issue","number":38387,"title":"Windows gateway Scheduled Task can leave blank console window due to uv venv pythonw redirecting to python.exe","url":null,"state":"closed"},{"kind":"issue","number":39646,"title":"Windows Gateway update/restart path relaunches console python.exe and shows conhost window","url":null,"state":"closed"},{"kind":"issue","number":40696,"title":"[Setup]: Hermes Gateway Scheduled Task shows visible cmd window on Windows — with fix","url":null,"state":"closed"},{"kind":"issue","number":45599,"title":"[Windows] Gateway does not survive reboot - .cmd wrapper killed by console control event + schtasks missing critical XML settings","url":null,"state":"closed"},{"kind":"issue","number":51661,"title":"Windows: get_python_path() returns python.exe instead of pythonw.exe, causing visible console window on gateway restart after update","url":null,"state":"closed"},{"kind":"issue","number":51715,"title":"Hindsight daemon fails silently on Windows with uv venv: _windows_gui_interpreter() has no fallback, ensure_running() times out in non-TTY","url":null,"state":"closed"},{"kind":"issue","number":53173,"title":"[Windows] hermes update/doctor don't migrate pre-#45610 gateway autostart launchers — stale .cmd Scheduled Task/Startup entries respawn console windows & die at logon","url":null,"state":"closed"},{"kind":"issue","number":54282,"title":"Windows: Use pythonw.exe for cron script execution to prevent console window flash","url":null,"state":"closed"},{"kind":"issue","number":68457,"title":"Windows: uv venv launcher shim re-exec causes console window flashes on every subprocess spawn (SlashWorker, gateway restart watcher)","url":null,"state":"closed"},{"kind":"pr","number":30312,"title":"fix(gateway): resolve uv-managed pythonw for Windows scheduled-task wrapper","url":null,"state":"closed","close_reason":{"category":"stale_abandoned","implication":"active"},"merged":false},{"kind":"pr","number":30314,"title":"fix(gateway): use _resolve_detached_python for uv venvs in .cmd generation","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"433db17c0a8d5581b4fb38289539fc1ee5cc7696","number":0},{"kind":"pr","value":"#45610","number":45610}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":38605,"title":"fix(gateway): resolve uv pythonw for task launcher","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"433db17c0a8d5581b4fb38289539fc1ee5cc7696","number":0},{"kind":"pr","value":"#45610","number":45610},{"kind":"pr","value":"#52239","number":52239}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":41028,"title":"fix(gateway): avoid Windows uv pythonw launcher console","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"433db17c0","number":0}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":41184,"title":"fix(gateway): hide scheduled-task cmd wrapper on Windows","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"433db17c0a8d5581b4fb38289539fc1ee5cc7696","number":0}],"category":"uncategorized","implication":"needs_llm"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":45610,"title":"fix(windows): harden gateway scheduled task","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["hermes_cli/gateway_windows.py","tests/hermes_cli/test_gateway_windows.py"],"fix_hunks":[{"file":"hermes_cli/gateway_windows.py","hunks":["@@ -38,6 +38,7 @@\n import sys\n import time\n from pathlib import Path\n+from xml.sax.saxutils import escape\n \n # Short timeouts: schtasks occasionally wedges and we don't want to hang forever.\n _SCHTASKS_TIMEOUT_S = 15","@@ -51,6 +52,9 @@\n \n _TASK_NAME_DEFAULT = \"Hermes_Gateway\"\n _TASK_DESCRIPTION = \"Hermes Agent Gateway - Messaging Platform Integration\"\n+_TASK_LOGON_DELAY = \"PT30S\"\n+_TASK_RESTART_INTERVAL = \"PT1M\"\n+_TASK_RESTART_COUNT = 999\n \n \n def _schtasks_encoding() -> str:","@@ -358,12 +362,13 @@ def _build_gateway_cmd_script(\n     lines.append(f'set \"HERMES_HOME={hermes_home}\"')\n     lines.append('set \"PYTHONIOENCODING=utf-8\"')\n     lines.append('set \"HERMES_GATEWAY_DETACHED=1\"')\n+    pythonw_path, venv_dir, extra_pythonpath = _resolve_detached_python(python_path)\n     # VIRTUAL_ENV lets the gateway's own python detection find the venv\n     # if someone imports hermes_constants-based logic during startup.\n-    venv_dir = str(Path(python_path).resolve().parent.parent)\n     lines.append(f'set \"VIRTUAL_ENV={venv_dir}\"')\n+    pythonpath_entries = [str(Path(__file__).resolve().parent.parent), *extra_pythonpath]\n+    lines.append(f'set \"PYTHONPATH={\";\".join([*pythonpath_entries, \"%PYTHONPATH%\"])}\"')\n \n-    pythonw_path = _derive_venv_pythonw(python_path)\n     prog_args = [pythonw_path, \"-m\", \"hermes_cli.main\"]\n     if profile_arg:\n         prog_args.extend(profile_arg.split())","@@ -379,6 +384,78 @@ def _build_gateway_cmd_script(\n     return \"\\r\\n\".join(lines) + \"\\r\\n\"\n \n \n+def _quote_vbs_string(value: str) -> str:\n+    \"\"\"Quote a value as a VBScript double-quoted string literal.\n+\n+    VBScript escapes an embedded double-quote by doubling it. A newline cannot\n+    appear inside a literal, so refuse it (same guard as ``_quote_cmd_script_arg``).\n+    \"\"\"\n+    if \"\\r\" in value or \"\\n\" in value:\n+        raise ValueError(f\"refusing to quote VBScript value containing newline: {value!r}\")\n+    return '\"' + value.replace('\"', '\"\"') + '\"'\n+\n+\n+def _build_gateway_vbs_script(\n+    python_path: str,\n+    working_dir: str,\n+    hermes_home: str,\n+    profile_arg: str,\n+) -> str:\n+    \"\"\"Build a console-less ``gateway.vbs`` launcher (CRLF-terminated).\n+\n+    The Scheduled Task runs this through ``wscript.exe`` instead of ``cmd.exe``.\n+\n+    Why: issue #45599 root cause #1. Driving the gateway through ``cmd.exe``\n+    allocates a console, and during logon Windows broadcasts ``CTRL_CLOSE_EVENT``\n+    to console process groups — reaping cmd.exe and the half-initialized gateway\n+    with ``STATUS_CONTROL_C_EXIT`` (``0xC000013A``). Task Scheduler treats that\n+    code as a user cancel, so the ``RestartOnFailure`` policy never fires and the\n+    gateway silently disappears on every reboot.\n+\n+    ``wscript.exe`` and ``pythonw.exe`` are both GUI-subsystem executables with\n+    no console, so this launcher receives no console control events. It mirrors\n+    ``_build_gateway_cmd_script`` (same env + argv via ``_resolve_detached_python``)\n+    but sets the environment on the WScript.Shell process and ``Run``s pythonw\n+    directly — no cmd.exe anywhere in the chain.\n+    \"\"\"\n+    pythonw_path, venv_dir, extra_pythonpath = _resolve_detached_python(python_path)\n+\n+    prog_args = [pythonw_path, \"-m\", \"hermes_cli.main\"]\n+    if profile_arg:\n+        prog_args.extend(profile_arg.split())\n+    prog_args.extend([\"gateway\", \"run\"])\n+    # list2cmdline gives CreateProcess-correct quoting for WScript.Shell.Run.\n+    command_line = subprocess.list2cmdline(prog_args)\n+\n+    repo_root = str(Path(__file__).resolve().parent.parent)\n+    static_pythonpath = os.pathsep.join([repo_root, *extra_pythonpath])\n+\n+    lines = [\n+        f\"' {_TASK_DESCRIPTION}\",\n+        \"Option Explicit\",\n+        \"Dim sh, env, existing_pp\",\n+        'Set sh = CreateObject(\"WScript.Shell\")',\n+        'Set env = sh.Environment(\"PROCESS\")',\n+        f\"env.Item({_quote_vbs_string('HERMES_HOME')}) = {_quote_vbs_string(hermes_home)}\",\n+        f\"env.Item({_quote_vbs_string('PYTHONIOENCODING')}) = {_quote_vbs_string('utf-8')}\",\n+        f\"env.Item({_quote_vbs_string('HERMES_GATEWAY_DETACHED')}) = {_quote_vbs_string('1')}\",\n+        f\"env.Item({_quote_vbs_string('VIRTUAL_ENV')}) = {_quote_vbs_string(str(venv_dir))}\",\n+        # Mirror the cmd wrapper's ``PYTHONPATH=<static>;%PYTHONPATH%``: chain onto\n+        # whatever PYTHONPATH the task environment already carries, at runtime.\n+        f\"existing_pp = env.Item({_quote_vbs_string('PYTHONPATH')})\",\n+        \"If Len(existing_pp) > 0 Then\",\n+        f\"  env.Item({_quote_vbs_string('PYTHONPATH')}) = {_quote_vbs_string(static_pythonpath + os.pathsep)} & existing_pp\",\n+        \"Else\",\n+        f\"  env.Item({_quote_vbs_string('PYTHONPATH')}) = {_quote_vbs_string(static_pythonpath)}\",\n+        \"End If\",\n+        f\"sh.CurrentDirectory = {_quote_vbs_string(working_dir)}\",\n+        # Window style 0 = hidden; bWaitOnReturn False = detached/async. pythonw is\n+        # GUI-subsystem so no console is ever created for the gateway either.\n+        f\"sh.Run {_quote_vbs_string(command_line)}, 0, False\",\n+    ]\n+    return \"\\r\\n\".join(lines) + \"\\r\\n\"\n+\n+\n def _build_startup_launcher(script_path: Path) -> str:\n     \"\"\"The tiny .cmd that goes in the Startup folder. Just minimizes and chains.\n ","@@ -425,6 +502,15 @@ def _write_task_script() -> Path:\n     tmp = script_path.with_suffix(\".tmp\")\n     tmp.write_text(content, encoding=\"utf-8\", newline=\"\")\n     tmp.replace(script_path)\n+\n+    # Also render the console-less .vbs launcher the Scheduled Task runs via\n+    # wscript.exe (issue #45599 fix A). The .cmd above stays for the\n+    # Startup-folder fallback and direct /Run paths.\n+    vbs_content = _build_gateway_vbs_script(python_path, working_dir, hermes_home, profile_arg)\n+    vbs_path = script_path.with_suffix(\".vbs\")\n+    vbs_tmp = vbs_path.with_name(vbs_path.name + \".tmp\")\n+    vbs_tmp.write_text(vbs_content, encoding=\"utf-8\", newline=\"\")\n+    vbs_tmp.replace(vbs_path)\n     return script_path\n \n ","@@ -443,6 +529,74 @@ def _resolve_task_user() -> str | None:\n     return f\"{domain}\\\\{username}\" if domain else username\n \n \n+def _build_scheduled_task_xml(task_name: str, launcher_path: Path, user: str | None) -> str:\n+    \"\"\"Render a Task Scheduler XML definition with safe long-running defaults.\n+\n+    ``launcher_path`` is the console-less ``.vbs`` the task runs via\n+    ``wscript.exe`` — not the ``.cmd`` (see ``_build_gateway_vbs_script`` /\n+    issue #45599 root cause #1).\n+    \"\"\"\n+    user_principal = f\"\\n      <UserId>{escape(user)}</UserId>\" if user else \"\"\n+    return f\"\"\"<?xml version=\"1.0\" encoding=\"UTF-16\"?>\n+<Task version=\"1.4\" xmlns=\"http://schemas.microsoft.com/windows/2004/02/mit/task\">\n+  <RegistrationInfo>\n+    <Description>{escape(_TASK_DESCRIPTION)}</Description>\n+  </RegistrationInfo>\n+  <Triggers>\n+    <LogonTrigger>\n+      <Enabled>true</Enabled>\n+      <Delay>{_TASK_LOGON_DELAY}</Delay>\n+    </LogonTrigger>\n+  </Triggers>\n+  <Principals>\n+    <Principal id=\"Author\">{user_principal}\n+      <LogonType>InteractiveToken</LogonType>\n+      <RunLevel>LeastPrivilege</RunLevel>\n+    </Principal>\n+  </Principals>\n+  <Settings>\n+    <MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>\n+    <DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>\n+    <StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>\n+    <AllowHardTerminate>true</AllowHardTerminate>\n+    <StartWhenAvailable>true</StartWhenAvailable>\n+    <RunOnlyIfNetworkAvailable>false</RunOnlyIfNetworkAvailable>\n+    <IdleSettings>\n+      <StopOnIdleEnd>false</StopOnIdleEnd>\n+      <RestartOnIdle>false</RestartOnIdle>\n+    </IdleSettings>\n+    <AllowStartOnDemand>true</AllowStartOnDemand>\n+    <Enabled>true</Enabled>\n+    <Hidden>false</Hidden>\n+    <RunOnlyIfIdle>false</RunOnlyIfIdle>\n+    <WakeToRun>false</WakeToRun>\n+    <ExecutionTimeLimit>PT0S</ExecutionTimeLimit>\n+    <Priority>7</Priority>\n+    <RestartOnFailure>\n+      <Interval>{_TASK_RESTART_INTERVAL}</Interval>\n+      <Count>{_TASK_RESTART_COUNT}</Count>\n+    </RestartOnFailure>\n+  </Settings>\n+  <Actions Context=\"Author\">\n+    <Exec>\n+      <Command>wscript.exe</Command>\n+      <Arguments>//B //Nologo \"{escape(str(launcher_path))}\"</Arguments>\n+    </Exec>\n+  </Actions>\n+</Task>\n+\"\"\"\n+\n+\n+def _write_scheduled_task_xml(task_name: str, launcher_path: Path, user: str | None) -> Path:\n+    xml_path = launcher_path.with_suffix(\".task.xml\")\n+    xml_path.write_text(\n+        _build_scheduled_task_xml(task_name, launcher_path, user),\n+        encoding=\"utf-16\",\n+        newline=\"\",\n+    )\n+    return xml_path\n+\n+\n def _install_scheduled_task(task_name: str, script_path: Path) -> tuple[bool, str]:\n     \"\"\"Create or replace the Scheduled Task. Returns (success, detail).\n ","@@ -451,8 +605,6 @@ def _install_scheduled_task(task_name: str, script_path: Path) -> tuple[bool, st\n     preserves those stale triggers and can make the gateway relaunch every\n     minute. Delete+create gives us a clean ONLOGON task every install.\n     \"\"\"\n-    quoted_script = _quote_schtasks_arg(str(script_path))\n-\n     delete_code, delete_out, delete_err = _exec_schtasks([\"/Delete\", \"/F\", \"/TN\", task_name])\n     delete_detail = (delete_err or delete_out or \"\").strip()\n     if delete_code != 0 and delete_detail and \"cannot find\" not in delete_detail.lower():","@@ -460,32 +612,28 @@ def _install_scheduled_task(task_name: str, script_path: Path) -> tuple[bool, st\n             return (False, f\"schtasks /Delete failed (code {delete_code}): {delete_detail}\")\n         # Non-fatal: /Create /F below may still replace it. Keep the detail in\n         # the final error if creation also fails.\n-    # password\" variant; if that fails, retry without /RU /NP /IT.\n-    base = [\n-        \"/Create\",\n-        \"/F\",\n-        \"/SC\",\n-        \"ONLOGON\",\n-        \"/RL\",\n-        \"LIMITED\",\n-        \"/TN\",\n-        task_name,\n-        \"/TR\",\n-        quoted_script,\n-    ]\n     user = _resolve_task_user()\n-    variants = []\n-    if user:\n-        variants.append([*base, \"/RU\", user, \"/NP\", \"/IT\"])\n+    # The Scheduled Task launches the console-less .vbs (issue #45599 fix A), not\n+    # the .cmd. The .cmd stays for the Startup-folder fallback and direct /Run.\n+    launcher_path = script_path.with_suffix(\".vbs\")\n+    xml_path = _write_scheduled_task_xml(task_name, launcher_path, user)\n+    base = [\"/Create\", \"/F\", \"/TN\", task_name, \"/XML\", str(xml_path)]\n+    variants = [[*base, \"/RU\", user, \"/NP\", \"/IT\"]] if user else []\n     variants.append(base)\n \n     last_code = 1\n     last_err = \"\"\n-    for argv in variants:\n-        code, out, err = _exec_schtasks(argv)\n-        if code == 0:\n-            return (True, f\"Created Scheduled Task {task_name!r}\")\n-        last_code, last_err = code, (err or out or \"\")\n+    try:\n+        for argv in variants:\n+            code, out, err = _exec_schtasks(argv)\n+            if code == 0:\n+                return (True, f\"Created Scheduled Task {task_name!r}\")\n+            last_code, last_err = code, (err or out or \"\")\n+    finally:\n+        try:\n+            xml_path.unlink(missing_ok=True)\n+        except OSError:\n+            pass\n     if delete_detail and \"cannot find\" not in delete_detail.lower():\n         last_err = f\"{last_err.strip()} (delete detail: {delete_detail})\"\n     return (False, f\"schtasks /Create failed (code {last_code}): {last_err.strip()}\")"]},{"file":"tests/hermes_cli/test_gateway_windows.py","hunks":["@@ -190,7 +190,11 @@ def fake_install_startup_entry(path: Path) -> Path:\n \n def test_gateway_cmd_script_uses_pythonw_without_replace_or_start_churn(monkeypatch):\n     \"\"\"Scheduled Task wrapper should launch pythonw once and avoid replace loops.\"\"\"\n-    monkeypatch.setattr(gateway_windows, \"_derive_venv_pythonw\", lambda exe: exe.replace(\"python.exe\", \"pythonw.exe\"))\n+    monkeypatch.setattr(\n+        gateway_windows,\n+        \"_resolve_detached_python\",\n+        lambda exe: (exe.replace(\"python.exe\", \"pythonw.exe\"), r\"C:\\\\Hermes\\\\hermes-agent\\\\venv\", []),\n+    )\n \n     content = gateway_windows._build_gateway_cmd_script(\n         r\"C:\\\\Hermes\\\\hermes-agent\\\\venv\\\\Scripts\\\\python.exe\",","@@ -206,6 +210,41 @@ def test_gateway_cmd_script_uses_pythonw_without_replace_or_start_churn(monkeypa\n     assert \"exit /b 0\" in content\n \n \n+def test_gateway_cmd_script_uses_uv_safe_base_pythonw(monkeypatch, tmp_path):\n+    \"\"\"Scheduled Task wrapper should share the detached uv-venv workaround.\"\"\"\n+    project = tmp_path / \"project\"\n+    scripts = project / \"venv\" / \"Scripts\"\n+    site_packages = project / \"venv\" / \"Lib\" / \"site-packages\"\n+    hermes_home = tmp_path / \"hermes-home\"\n+    base = tmp_path / \"uv\" / \"python\" / \"cpython-3.11-windows-x86_64-none\"\n+    scripts.mkdir(parents=True)\n+    site_packages.mkdir(parents=True)\n+    hermes_home.mkdir()\n+    base.mkdir(parents=True)\n+\n+    venv_python = scripts / \"python.exe\"\n+    venv_pythonw = scripts / \"pythonw.exe\"\n+    base_pythonw = base / \"pythonw.exe\"\n+    for exe in (venv_python, venv_pythonw, base_pythonw):\n+        exe.write_text(\"\", encoding=\"utf-8\")\n+    (project / \"venv\" / \"pyvenv.cfg\").write_text(\n+        f\"home = {base}\\nimplementation = CPython\\nuv = 0.11.14\\nversion_info = 3.11.15\\n\",\n+        encoding=\"utf-8\",\n+    )\n+\n+    content = gateway_windows._build_gateway_cmd_script(\n+        str(venv_python),\n+        str(hermes_home),\n+        str(hermes_home),\n+        \"\",\n+    )\n+\n+    assert str(base_pythonw) in content\n+    assert f'set \"VIRTUAL_ENV={project / \"venv\"}\"' in content\n+    assert str(site_packages) in content\n+    assert str(venv_pythonw) not in content\n+\n+\n def test_elevated_gateway_command_uses_pythonw_hidden_console(monkeypatch):\n     \"\"\"UAC handoff should not leave a second elevated cmd.exe window open.\"\"\"\n     calls = []","@@ -239,14 +278,18 @@ def test_install_scheduled_task_recreates_instead_of_change(monkeypatch, tmp_pat\n     \"\"\"Install must delete+create so stale minute-repeat task settings are not preserved.\"\"\"\n     calls = []\n     script_path = tmp_path / \"Hermes_Gateway_alice.cmd\"\n+    xml_seen = {}\n \n     monkeypatch.setattr(gateway_windows, \"_assert_windows\", lambda: None)\n+    monkeypatch.setattr(gateway_windows, \"_resolve_task_user\", lambda: r\"DOMAIN\\\\alice\")\n \n     def fake_schtasks(args):\n         calls.append(tuple(args))\n         if args[0] == \"/Delete\":\n             return (0, \"SUCCESS\", \"\")\n         if args[0] == \"/Create\":\n+            xml_path = Path(args[args.index(\"/XML\") + 1])\n+            xml_seen[\"text\"] = xml_path.read_text(encoding=\"utf-16\")\n             return (0, \"SUCCESS\", \"\")\n         raise AssertionError(f\"unexpected schtasks args: {args}\")\n ","@@ -257,8 +300,88 @@ def fake_schtasks(args):\n     assert \"/Change\" not in [arg for call in calls for arg in call]\n     assert calls[0][:4] == (\"/Delete\", \"/F\", \"/TN\", \"Hermes_Gateway_alice\")\n     assert calls[1][0] == \"/Create\"\n-    assert \"/SC\" in calls[1]\n-    assert \"ONLOGON\" in calls[1]\n+    assert \"/XML\" in calls[1]\n+    assert \"/SC\" not in calls[1]\n+    assert \"<Delay>PT30S</Delay>\" in xml_seen[\"text\"]\n+    assert \"<StartWhenAvailable>true</StartWhenAvailable>\" in xml_seen[\"text\"]\n+    assert \"<StopOnIdleEnd>false</StopOnIdleEnd>\" in xml_seen[\"text\"]\n+    assert \"<DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>\" in xml_seen[\"text\"]\n+    assert \"<StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>\" in xml_seen[\"text\"]\n+    assert \"<ExecutionTimeLimit>PT0S</ExecutionTimeLimit>\" in xml_seen[\"text\"]\n+    assert \"<RestartOnFailure>\" in xml_seen[\"text\"]\n+    assert \"<Count>999</Count>\" in xml_seen[\"text\"]\n+    # Scheduled Task launches the console-less .vbs via wscript.exe, never cmd.exe\n+    # (issue #45599 fix A: no console -> no logon CTRL_CLOSE_EVENT / 0xC000013A).\n+    assert \"<Command>wscript.exe</Command>\" in xml_seen[\"text\"]\n+    assert \"//B //Nologo\" in xml_seen[\"text\"]\n+    assert \"Hermes_Gateway_alice.vbs\" in xml_seen[\"text\"]\n+    assert \"cmd.exe\" not in xml_seen[\"text\"]\n+\n+\n+def test_gateway_vbs_script_is_console_less(monkeypatch):\n+    \"\"\"The .vbs launcher must avoid cmd.exe entirely and Run pythonw hidden\n+    (issue #45599 fix A: no console -> no logon CTRL_CLOSE_EVENT / 0xC000013A).\"\"\"\n+    monkeypatch.setattr(\n+        gateway_windows,\n+        \"_resolve_detached_python\",\n+        lambda exe: (r\"C:\\venv\\Scripts\\pythonw.exe\", Path(r\"C:\\venv\"), []),\n+    )\n+    content = gateway_windows._build_gateway_vbs_script(\n+        r\"C:\\venv\\Scripts\\python.exe\",\n+        r\"C:\\Hermes\",\n+        r\"C:\\Hermes\",\n+        \"--profile work\",\n+    )\n+    assert \"cmd.exe\" not in content.lower()\n+    assert 'CreateObject(\"WScript.Shell\")' in content\n+    assert \"pythonw.exe\" in content\n+    assert \"hermes_cli.main\" in content\n+    assert \"gateway run\" in content\n+    assert \", 0, False\" in content  # hidden window, detached/async\n+    for var in (\"HERMES_HOME\", \"PYTHONIOENCODING\", \"HERMES_GATEWAY_DETACHED\", \"VIRTUAL_ENV\", \"PYTHONPATH\"):\n+        assert var in content\n+    assert \"--profile\" in content and \"work\" in content\n+    assert content.endswith(\"\\r\\n\")\n+\n+\n+def test_gateway_vbs_script_quotes_spaced_paths(monkeypatch):\n+    \"\"\"Spaced exe/dir paths stay correctly quoted through the VBScript literal.\"\"\"\n+    monkeypatch.setattr(\n+        gateway_windows,\n+        \"_resolve_detached_python\",\n+        lambda exe: (r\"C:\\Program Files\\Py\\pythonw.exe\", Path(r\"C:\\v env\"), []),\n+    )\n+    content = gateway_windows._build_gateway_vbs_script(\n+        r\"C:\\Program Files\\Py\\python.exe\",\n+        r\"C:\\work dir\",\n+        r\"C:\\h home\",\n+        \"\",\n+    )\n+    # list2cmdline quotes the spaced exe; _quote_vbs_string doubles those quotes.\n+    assert '\"\"C:\\\\Program Files\\\\Py\\\\pythonw.exe\"\"' in content\n+    assert 'sh.CurrentDirectory = \"C:\\\\work dir\"' in content\n+\n+\n+def test_gateway_vbs_script_pythonpath_chains_runtime_value(monkeypatch):\n+    \"\"\"PYTHONPATH chains onto the task env's existing value, like ;%PYTHONPATH%.\"\"\"\n+    monkeypatch.setattr(\n+        gateway_windows,\n+        \"_resolve_detached_python\",\n+        lambda exe: (r\"C:\\v\\pythonw.exe\", Path(r\"C:\\v\"), [r\"C:\\v\\Lib\\site-packages\"]),\n+    )\n+    content = gateway_windows._build_gateway_vbs_script(\n+        r\"C:\\v\\python.exe\", r\"C:\\w\", r\"C:\\h\", \"\",\n+    )\n+    assert 'existing_pp = env.Item(\"PYTHONPATH\")' in content\n+    assert \"If Len(existing_pp) > 0 Then\" in content\n+    assert r\"C:\\v\\Lib\\site-packages\" in content\n+\n+\n+def test_quote_vbs_string_doubles_quotes_and_rejects_newlines():\n+    assert gateway_windows._quote_vbs_string(\"plain\") == '\"plain\"'\n+    assert gateway_windows._quote_vbs_string('a\"b') == '\"a\"\"b\"'\n+    with pytest.raises(ValueError):\n+        gateway_windows._quote_vbs_string(\"line1\\nline2\")\n \n \n def test_install_scheduled_task_success_start_now_uses_direct_spawn_not_task_run(monkeypatch, tmp_path, capsys):\n"]}]},{"kind":"pr","number":52239,"title":"fix(gateway,windows): respawn gateway windowless after GUI update (no persistent console window)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["hermes_cli/gateway.py","hermes_cli/gateway_windows.py","tests/tools/test_windows_native_support.py"],"fix_hunks":[{"file":"hermes_cli/gateway.py","hunks":["@@ -5,6 +5,7 @@\n \"\"\"\n \n import asyncio\n+import json\n import logging\n import os\n import shlex","@@ -717,6 +718,38 @@ def _spawn_gateway_restart_watcher(old_pid: int, run_argv: list[str]) -> bool:\n         windows_detach_popen_kwargs,\n     )\n \n+    # On Windows the incoming ``run_argv`` leads with the venv's console\n+    # ``python.exe`` (from ``get_python_path()``).  Respawning the gateway\n+    # with that interpreter — even under CREATE_NO_WINDOW — leaves a\n+    # persistent console window, because uv's venv launcher re-execs the\n+    # base console interpreter, which allocates its own conhost.  Rewrite\n+    # the argv to the windowless ``pythonw.exe`` (mirroring the clean-start\n+    # ``_spawn_detached`` path) and capture the cwd + env overlay the base\n+    # interpreter needs to resolve imports without the venv launcher.\n+    # No-op on POSIX.  See gateway_windows.windowless_gateway_restart_spec.\n+    respawn_cwd = \"\"\n+    respawn_env_overlay: dict[str, str] = {}\n+    if sys.platform == \"win32\":\n+        try:\n+            from hermes_cli.gateway_windows import (\n+                windowless_gateway_restart_spec,\n+            )\n+\n+            run_argv, respawn_cwd, respawn_env_overlay = (\n+                windowless_gateway_restart_spec(list(run_argv))\n+            )\n+        except Exception:\n+            # Best-effort: if the rewrite fails for any reason, fall back to\n+            # the original argv.  A visible window is worse than nothing, but\n+            # a failed respawn is worse still — keep the gateway coming back.\n+            respawn_cwd = \"\"\n+            respawn_env_overlay = {}\n+\n+    # Serialized as JSON literals embedded in the watcher source so the\n+    # inner respawn can apply cwd= / env= without extra argv plumbing.\n+    respawn_cwd_literal = json.dumps(respawn_cwd)\n+    respawn_env_literal = json.dumps(respawn_env_overlay)\n+\n     watcher = textwrap.dedent(\n         \"\"\"\n         import os","@@ -726,6 +759,8 @@ def _spawn_gateway_restart_watcher(old_pid: int, run_argv: list[str]) -> bool:\n \n         pid = int(sys.argv[1])\n         cmd = sys.argv[2:]\n+        _respawn_cwd = {respawn_cwd_literal}\n+        _respawn_env_overlay = {respawn_env_literal}\n         deadline = time.monotonic() + 120\n         while time.monotonic() < deadline:\n             # ``os.kill(pid, 0)`` is not a no-op on Windows — use the","@@ -742,10 +777,18 @@ def _spawn_gateway_restart_watcher(old_pid: int, run_argv: list[str]) -> bool:\n         # been spawned inside a job object (Electron/Tauri parent), and\n         # without breakaway the respawned gateway would die when that job\n         # tears down. See _subprocess_compat.windows_detach_flags().\n-        _popen_kwargs = {\n+        _popen_kwargs = {{\n             \"stdout\": subprocess.DEVNULL,\n             \"stderr\": subprocess.DEVNULL,\n-        }\n+        }}\n+        # Anchor the respawned gateway at the stable working dir and overlay\n+        # the env (VIRTUAL_ENV / PYTHONPATH / HERMES_HOME) the windowless\n+        # base interpreter needs to import hermes_cli.  Empty on POSIX, where\n+        # the venv python resolves imports without help.\n+        if _respawn_cwd:\n+            _popen_kwargs[\"cwd\"] = _respawn_cwd\n+        if _respawn_env_overlay:\n+            _popen_kwargs[\"env\"] = {{**os.environ, **_respawn_env_overlay}}\n         if sys.platform == \"win32\":\n             _CREATE_NEW_PROCESS_GROUP = 0x00000200\n             _DETACHED_PROCESS = 0x00000008","@@ -772,7 +815,10 @@ def _spawn_gateway_restart_watcher(old_pid: int, run_argv: list[str]) -> bool:\n             _popen_kwargs[\"start_new_session\"] = True\n             subprocess.Popen(cmd, **_popen_kwargs)\n         \"\"\"\n-    ).strip()\n+    ).strip().format(\n+        respawn_cwd_literal=respawn_cwd_literal,\n+        respawn_env_literal=respawn_env_literal,\n+    )\n \n     watcher_argv = [\n         sys.executable,"]},{"file":"hermes_cli/gateway_windows.py","hunks":["@@ -753,6 +753,77 @@ def _build_gateway_argv() -> tuple[list[str], str, dict[str, str]]:\n     return argv, working_dir, env_overlay\n \n \n+def windowless_gateway_restart_spec(\n+    run_argv: list[str],\n+) -> tuple[list[str], str, dict[str, str]]:\n+    \"\"\"Rewrite a console-``python.exe`` gateway argv into a windowless one.\n+\n+    The post-update restart paths build their respawn command from\n+    ``get_python_path()`` which returns the venv's console ``python.exe``.\n+    On Windows — especially with uv-created venvs — launching that\n+    interpreter (even with ``CREATE_NO_WINDOW``) leaves a persistent\n+    console window: ``venv\\\\Scripts\\\\python.exe`` is a launcher shim that\n+    re-execs the *base* console interpreter, which allocates its own\n+    conhost.  ``CREATE_NO_WINDOW`` cannot suppress that second window.\n+    See ``_resolve_detached_python`` for the gory details.\n+\n+    This mirrors what ``_build_gateway_argv`` / ``_spawn_detached`` do for\n+    a clean start: swap the interpreter for the windowless ``pythonw.exe``\n+    (base interpreter for uv venvs) and return the cwd + env overlay\n+    (VIRTUAL_ENV, PYTHONPATH) the base interpreter needs to resolve the\n+    ``hermes_cli`` package without the venv launcher's site config.\n+\n+    Returns ``(new_argv, working_dir, env_overlay)``.  ``new_argv``\n+    preserves every argument after the interpreter (``-m hermes_cli.main\n+    [--profile X] gateway run [--replace]``) verbatim.  On non-Windows, or\n+    if ``run_argv`` doesn't start with a resolvable python, the argv is\n+    returned unchanged with an empty overlay.\n+    \"\"\"\n+    if not run_argv:\n+        return run_argv, \"\", {}\n+    if sys.platform != \"win32\":\n+        return run_argv, \"\", {}\n+\n+    from hermes_cli.config import get_hermes_home\n+    from hermes_cli.gateway import PROJECT_ROOT\n+\n+    python_exe = run_argv[0]\n+    rest = run_argv[1:]\n+\n+    # Only rewrite when the leading token actually looks like a python\n+    # interpreter we can find a windowless sibling for.  If a caller passed\n+    # something else (a captured argv whose argv[0] is already pythonw, or a\n+    # non-python launcher), leave it alone.\n+    try:\n+        windowless_python, venv_dir, extra_pythonpath = _resolve_detached_python(\n+            python_exe\n+        )\n+    except Exception:\n+        return run_argv, \"\", {}\n+\n+    new_argv = [windowless_python, *rest]\n+\n+    working_dir = _stable_gateway_working_dir(PROJECT_ROOT)\n+    project_root = str(PROJECT_ROOT)\n+    try:\n+        hermes_home = str(Path(get_hermes_home()).resolve())\n+    except Exception:\n+        hermes_home = \"\"\n+\n+    env_overlay: dict[str, str] = {\n+        \"PYTHONIOENCODING\": \"utf-8\",\n+        \"HERMES_GATEWAY_DETACHED\": \"1\",\n+        \"VIRTUAL_ENV\": str(venv_dir),\n+    }\n+    if hermes_home:\n+        env_overlay[\"HERMES_HOME\"] = hermes_home\n+    _prepend_pythonpath(\n+        env_overlay,\n+        [project_root, *extra_pythonpath] if extra_pythonpath else [project_root],\n+    )\n+    return new_argv, working_dir, env_overlay\n+\n+\n def _spawn_detached(script_path: Path | None = None) -> int:\n     \"\"\"Launch the gateway as a fully detached background process.\n "]},{"file":"tests/tools/test_windows_native_support.py","hunks":["@@ -15,6 +15,7 @@\n import signal\n import sys\n from pathlib import Path\n+from unittest import mock\n from unittest.mock import MagicMock\n \n import pytest","@@ -1007,3 +1008,116 @@ def test_launch_detached_profile_gateway_restart_outer_popen_has_access_denied_f\n             \"CreateProcess and retry without the breakaway bit, matching \"\n             \"gateway_windows._spawn_detached's fallback pattern.\"\n         )\n+\n+    def test_watcher_rewrites_console_python_to_windowless(self):\n+        \"\"\"The post-update respawn must NOT relaunch the gateway with the\n+        venv's console ``python.exe``.\n+\n+        Regression for the \"terminal window stays open permanently after a\n+        GUI update\" report: ``_gateway_run_args_for_profile`` builds the\n+        respawn argv from ``get_python_path()`` (console ``python.exe``).\n+        On Windows, launching that interpreter — even under\n+        CREATE_NO_WINDOW — leaves a persistent console window because uv's\n+        venv launcher re-execs the base console interpreter. The watcher\n+        must route the argv through\n+        ``gateway_windows.windowless_gateway_restart_spec`` so it becomes\n+        ``pythonw.exe`` with the cwd + PYTHONPATH overlay the base\n+        interpreter needs.\n+\n+        Static check: the watcher build (in ``_spawn_gateway_restart_watcher``)\n+        must invoke the rewrite helper and thread the cwd / env overlay into\n+        the inlined respawn ``Popen``.\n+        \"\"\"\n+        root = Path(__file__).resolve().parents[2]\n+        text = (root / \"hermes_cli\" / \"gateway.py\").read_text(encoding=\"utf-8\")\n+        assert \"windowless_gateway_restart_spec\" in text, (\n+            \"_spawn_gateway_restart_watcher must rewrite the respawn argv via \"\n+            \"gateway_windows.windowless_gateway_restart_spec so the gateway \"\n+            \"comes back as windowless pythonw.exe, not console python.exe.\"\n+        )\n+        marker = \"watcher = textwrap.dedent(\"\n+        idx = text.find(marker)\n+        end = text.find(\".strip()\", idx)\n+        block = text[idx:end]\n+        # The inlined respawn must apply the cwd + env overlay the base\n+        # interpreter needs — without them the windowless pythonw can't\n+        # import hermes_cli.\n+        assert '_popen_kwargs[\"cwd\"]' in block, (\n+            \"Inlined respawn must set cwd from the windowless spec so the \"\n+            \"base interpreter starts in the stable gateway working dir.\"\n+        )\n+        assert '_popen_kwargs[\"env\"]' in block, (\n+            \"Inlined respawn must overlay env (VIRTUAL_ENV / PYTHONPATH / \"\n+            \"HERMES_HOME) so the windowless base pythonw resolves hermes_cli.\"\n+        )\n+\n+\n+class TestWindowlessGatewayRestartSpec:\n+    \"\"\"gateway_windows.windowless_gateway_restart_spec — the helper that\n+    converts a console-python gateway argv into a windowless pythonw one.\"\"\"\n+\n+    def test_noop_on_non_windows(self):\n+        import hermes_cli.gateway_windows as gw\n+\n+        argv = [\"/path/venv/bin/python\", \"-m\", \"hermes_cli.main\", \"gateway\", \"run\"]\n+        with mock.patch.object(gw.sys, \"platform\", \"linux\"):\n+            new_argv, cwd, env = gw.windowless_gateway_restart_spec(list(argv))\n+        assert new_argv == argv\n+        assert cwd == \"\"\n+        assert env == {}\n+\n+    def test_empty_argv_is_safe(self):\n+        import hermes_cli.gateway_windows as gw\n+\n+        new_argv, cwd, env = gw.windowless_gateway_restart_spec([])\n+        assert new_argv == []\n+        assert cwd == \"\"\n+        assert env == {}\n+\n+    def test_windows_rewrites_to_pythonw_and_preserves_tail(self):\n+        \"\"\"On Windows the interpreter is swapped for its windowless sibling\n+        while every subsequent argument is preserved verbatim.\"\"\"\n+        import hermes_cli.gateway_windows as gw\n+\n+        # Pre-import on the (Linux) host so the function's lazy\n+        # ``from hermes_cli.gateway import PROJECT_ROOT`` resolves from\n+        # sys.modules instead of re-importing under the win32 platform\n+        # patch below — a fresh import would run gateway/status.py's\n+        # ``if sys.platform == \"win32\": import msvcrt`` branch and crash on\n+        # Linux CI with ModuleNotFoundError.\n+        import hermes_cli.config  # noqa: F401\n+        import hermes_cli.gateway  # noqa: F401\n+\n+        argv = [\n+            \"C:/venv/Scripts/python.exe\",\n+            \"-m\",\n+            \"hermes_cli.main\",\n+            \"--profile\",\n+            \"work\",\n+            \"gateway\",\n+            \"run\",\n+            \"--replace\",\n+        ]\n+\n+        def fake_resolve(python_exe):\n+            return (\"C:/base/pythonw.exe\", Path(\"C:/venv\"), [\"C:/venv/Lib/site-packages\"])\n+\n+        # Mock get_hermes_home too: the real one calls Path.resolve(), which\n+        # consults sysconfig and raises ModuleNotFoundError under the win32\n+        # platform patch on a Linux host.\n+        with mock.patch.object(gw.sys, \"platform\", \"win32\"), mock.patch.object(\n+            gw, \"_resolve_detached_python\", side_effect=fake_resolve\n+        ), mock.patch.object(\n+            gw, \"_stable_gateway_working_dir\", return_value=\"C:/hermes\"\n+        ), mock.patch(\n+            \"hermes_cli.config.get_hermes_home\", return_value=\"C:/hermes\"\n+        ):\n+            new_argv, cwd, env = gw.windowless_gateway_restart_spec(list(argv))\n+\n+        assert new_argv[0] == \"C:/base/pythonw.exe\"\n+        # Everything after the interpreter is byte-for-byte preserved.\n+        assert new_argv[1:] == argv[1:]\n+        assert cwd == \"C:/hermes\"\n+        assert env[\"VIRTUAL_ENV\"] == str(Path(\"C:/venv\"))\n+        assert \"PYTHONPATH\" in env\n+        assert \"site-packages\" in env[\"PYTHONPATH\"]\n"]}]},{"kind":"pr","number":53321,"title":"fix(windows): reconcile stale pre-#45610 cmd.exe gateway autostart launchers","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#53321","number":53321},{"kind":"pr","value":"#81362","number":81362},{"kind":"pr","value":"#80569","number":80569}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":58152,"title":"fix(gateway): launch restart watcher windowless on Windows (empty python.exe console after GUI update)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"0dbf639bc8","number":0},{"kind":"pr","value":"#70205","number":70205},{"kind":"pr","value":"#70205","number":70205}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":68472,"title":"fix(windows): prevent helper process console flashes","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"0dbf639bc8","number":0},{"kind":"pr","value":"#70205","number":70205},{"kind":"pr","value":"#54220","number":54220},{"kind":"pr","value":"#56747","number":56747}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":70205,"title":"fix(windows): hidden-console daemons — extend the parent-console flash fix to every detached spawn path","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["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"]}],"trust":{"default_tier":"verified","includes_suggested":false}}