{"repo":"NousResearch/hermes-agent","public_id":"11323","display_label":"macOS launchd gateway integration bugs and fixes","summary":"A cluster of bug reports and fixes centered on macOS launchd domain handling for the gateway service, covering issues with plist generation, domain selection (gui/ vs user/ vs system), bootstrap/bootout sequencing, and stale or conflicting service states across macOS versions.","subsystem":"gateway","counts":{"members":41,"open_issues":2,"open_issues_distinct":2,"open_prs":6,"closed_issues":12,"merged_prs":5,"attempt_prs":22},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":40581},"resolution":{"state":"partially_resolved","by":"pr-40581,pr-43663,pr-52984,pr-54159,pr-65502"},"freshness":{"state":"verified","last_verified_at":"2026-07-16T12:08:57+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.04546341463414633},"fix_edges":[{"issue_number":23387,"pr_number":40581,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":40831,"pr_number":43663,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":42006,"pr_number":54159,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":42524,"pr_number":52984,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":42995,"pr_number":43663,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":64778,"pr_number":65502,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":11323,"title":"[Bug]: hermes gateway start/install fails on macOS with launchctl bootstrap exit status 5 (Input/output error)","url":null,"state":"closed"},{"kind":"issue","number":23387,"title":"[Bug]: macOS 26.4.1: hermes gateway start fails — launchctl kickstart gui/502 returns error 125 (Domain does not support specified action)","url":null,"state":"closed"},{"kind":"issue","number":23389,"title":"[Bug]: macOS 26.4.1: hermes gateway start fails — launchctl kickstart gui/502 returns error 125 (Domain does not support specified action)","url":null,"state":"closed"},{"kind":"issue","number":30586,"title":"macOS: launchd domain hardcodes gui/ — breaks gateway and setup for SSH-only / non-console users","url":null,"state":"closed"},{"kind":"issue","number":40831,"title":"macOS 26: PR #40581 hardcodes user/<uid> launchd domain, breaking Aqua sessions where gateway is loaded under gui/<uid>","url":null,"state":"closed"},{"kind":"issue","number":41676,"title":"macOS launchctl fallback gateway not recognized as healthy, causing repeated --replace restarts","url":null,"state":"closed"},{"kind":"issue","number":42006,"title":"macOS: launchd_restart missing bootout before bootstrap, gateway falls back to detached after update","url":null,"state":"closed"},{"kind":"issue","number":42376,"title":"[Bug]: hermes gateway restart/install generates plist with LimitLoadToSessionType that breaks launchctl bootstrap on macOS 26.5.1","url":null,"state":"open"},{"kind":"issue","number":42524,"title":"macOS 26: gateway start/restart refreshes LaunchAgent but launchctl exits 5 and falls back to detached process","url":null,"state":"closed"},{"kind":"issue","number":42995,"title":"[Bug]: MacOS 27+launchd cannot manage the gateway on this macOS version (launchctl exit 5).","url":null,"state":"closed"},{"kind":"issue","number":44612,"title":"[Bug]: macOS update causes Gateway service conflict and launchd failure (Exit code 78)","url":null,"state":"closed"},{"kind":"issue","number":44683,"title":"Bug: on macOS 26.5.1 gateway can go silent while launchd still looks loaded; restart falls back to unmanaged background process","url":null,"state":"closed"},{"kind":"issue","number":55441,"title":"macOS gateway status/restart targets stale ai.hermes.gateway while canonical launchd service is running","url":null,"state":"open"},{"kind":"issue","number":64778,"title":"Gateway self-detaches from launchd on macOS (setsid → PPID 1) → orphaned gateways / KeepAlive churn / config silently not applied","url":null,"state":"closed"},{"kind":"pr","number":11368,"title":"fix(launchd): fallback to 'load -w' when 'bootstrap' fails with exit 5","url":null,"state":"open","close_reason":{"refs":[{"kind":"commit","value":"6b21a935af24b7b3c4ee2370598471aa8978a243","number":0}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":19159,"title":"fix(gateway): support macOS system LaunchDaemons","url":null,"state":"open","close_reason":{"refs":[{"kind":"commit","value":"e2a1a2bf13fe","number":0},{"kind":"commit","value":"a8f404b29fa","number":0},{"kind":"pr","value":"#30586","number":30586}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":23615,"title":"fix(gateway): handle launchctl error 5/125 on macOS 26.4+ gracefully","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"59c273ba3ae27fb7f4a882e4c56b82675fea9623","number":0},{"kind":"commit","value":"3606307339ee2003d59eb9fcf04b95ea80170b3a","number":0},{"kind":"commit","value":"59c273ba3ae27fb7f4a882e4c56b82675fea9623","number":0},{"kind":"pr","value":"#23387","number":23387}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":23763,"title":"fix(gateway): guide around unsupported launchd domains","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"3606307339ee2003d59eb9fcf04b95ea80170b3a","number":0},{"kind":"pr","value":"#23763","number":23763},{"kind":"pr","value":"#23387","number":23387},{"kind":"pr","value":"#23615","number":23615},{"kind":"pr","value":"#23387","number":23387},{"kind":"pr","value":"#40581","number":40581},{"kind":"pr","value":"#23387","number":23387}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":24275,"title":"fix(gateway): support macOS Background launchd sessions","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#40581","number":40581},{"kind":"pr","value":"#23387","number":23387}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":34218,"title":"fix(gateway): handle launchctl error 125/5 on macOS 26","url":null,"state":"closed","close_reason":{"category":"implemented_elsewhere","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":40581,"title":"fix(gateway): user launchd domain + Background session, detached fallback (macOS 26)","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","tests/hermes_cli/test_gateway_service.py"],"fix_hunks":[{"file":"hermes_cli/gateway.py","hunks":["@@ -3000,7 +3000,112 @@ def get_launchd_label() -> str:\n \n \n def _launchd_domain() -> str:\n-    return f\"gui/{os.getuid()}\"  # windows-footgun: ok — POSIX launchd (macOS) helper, never invoked on Windows\n+    # The `user/<uid>` domain (vs the older `gui/<uid>`) is reachable from\n+    # non-Aqua/background sessions (SSH, headless, login items) and is the only\n+    # one that supports service management on macOS 26+. `gui/<uid>` returns\n+    # error 125 (\"Domain does not support specified action\") there. See #23387.\n+    return f\"user/{os.getuid()}\"  # windows-footgun: ok — POSIX launchd (macOS) helper, never invoked on Windows\n+\n+\n+# On macOS, exit code 125 (\"Domain does not support specified action\") and\n+# 3/113 (\"Could not find service\") all mean the job isn't currently loaded in\n+# the target domain, so start/restart should re-bootstrap the plist and retry.\n+_LAUNCHD_JOB_UNLOADED_EXIT_CODES = frozenset({3, 113, 125})\n+\n+# When even a fresh bootstrap can't manage the domain, launchctl returns 5\n+# (\"Input/output error\") or a persistent 125. On those hosts launchd cannot\n+# supervise the gateway at all, so we degrade to a detached background process\n+# (the documented `nohup hermes gateway run` workaround). See #23387.\n+_LAUNCHCTL_DOMAIN_UNSUPPORTED_CODES = frozenset({5, 125})\n+\n+\n+def _launchd_error_indicates_unloaded(exc: subprocess.CalledProcessError) -> bool:\n+    \"\"\"True when launchctl failed because the job isn't loaded (retry bootstrap).\"\"\"\n+    return exc.returncode in _LAUNCHD_JOB_UNLOADED_EXIT_CODES\n+\n+\n+def _launchctl_domain_unsupported(returncode: int) -> bool:\n+    \"\"\"True when launchctl can't manage the domain even after a fresh bootstrap.\n+\n+    Codes 5 and 125 persist on macOS hosts where neither `gui/<uid>` nor\n+    `user/<uid>` supports service management; treat these as \"launchd\n+    unavailable\" and degrade gracefully to a detached process.\n+    \"\"\"\n+    return returncode in _LAUNCHCTL_DOMAIN_UNSUPPORTED_CODES\n+\n+\n+def _gateway_run_command() -> list[str]:\n+    \"\"\"Build the `python -m hermes_cli.main [--profile X] gateway run --replace` argv.\n+\n+    Profile-aware: honors the active HERMES_HOME via `_profile_arg()` so the\n+    detached fallback launches into the same profile as the CLI invocation.\n+    \"\"\"\n+    cmd = [get_python_path(), \"-m\", \"hermes_cli.main\"]\n+    profile_arg = _profile_arg()\n+    if profile_arg:\n+        cmd.extend(profile_arg.split())\n+    cmd.extend([\"gateway\", \"run\", \"--replace\"])\n+    return cmd\n+\n+\n+def _spawn_detached_gateway() -> bool:\n+    \"\"\"Launch the gateway as a detached background process (launchd fallback).\n+\n+    Used when launchctl can no longer bootstrap/kickstart the gateway on\n+    macOS 26+ (issue #23387). Mirrors the `nohup hermes gateway run --replace`\n+    workaround but keeps it CLI-managed: stdout/stderr go to the profile's\n+    gateway logs and the PID is tracked via the gateway.pid file that\n+    `run_gateway` writes, so stop/status/restart keep working.\n+    \"\"\"\n+    from hermes_cli._subprocess_compat import windows_detach_popen_kwargs\n+\n+    log_dir = get_hermes_home() / \"logs\"\n+    log_dir.mkdir(parents=True, exist_ok=True)\n+    out_path = log_dir / \"gateway.log\"\n+    err_path = log_dir / \"gateway.error.log\"\n+    try:\n+        out = open(out_path, \"ab\")\n+        err = open(err_path, \"ab\")\n+    except OSError:\n+        return False\n+    try:\n+        with out, err:\n+            subprocess.Popen(\n+                _gateway_run_command(),\n+                stdin=subprocess.DEVNULL,\n+                stdout=out,\n+                stderr=err,\n+                **windows_detach_popen_kwargs(),\n+            )\n+    except OSError:\n+        return False\n+    return True\n+\n+\n+def _launchd_fallback_to_detached(reason: str, *, exit_on_failure: bool = True) -> bool:\n+    \"\"\"Start the gateway detached when launchd can't manage it, with guidance.\n+\n+    Returns True if the detached gateway was launched. When it can't be\n+    launched, prints the manual workaround and (by default) exits non-zero so\n+    the failure surfaces instead of silently doing nothing.\n+    \"\"\"\n+    from hermes_constants import display_hermes_home as _dhh\n+\n+    print(f\"⚠ launchd cannot manage the gateway on this macOS version ({reason}).\")\n+    if _spawn_detached_gateway():\n+        print(\"✓ Started gateway as a background process instead\")\n+        print(\"  It will NOT auto-start at login or auto-restart on crash.\")\n+        print(f\"  Logs: {_dhh()}/logs/gateway.log\")\n+        print(\"  Stop it with: hermes gateway stop\")\n+        return True\n+    print_error(\"Failed to start the gateway as a background process.\")\n+    print(\n+        f\"  Try manually: nohup hermes gateway run --replace \"\n+        f\"> {_dhh()}/logs/gateway.log 2>&1 &\"\n+    )\n+    if exit_on_failure:\n+        sys.exit(1)\n+    return False\n \n \n def generate_launchd_plist() -> str:","@@ -3077,6 +3182,12 @@ def generate_launchd_plist() -> str:\n         <key>HERMES_HOME</key>\n         <string>{hermes_home}</string>\n     </dict>\n+\n+    <key>LimitLoadToSessionType</key>\n+    <array>\n+        <string>Aqua</string>\n+        <string>Background</string>\n+    </array>\n     \n     <key>RunAtLoad</key>\n     <true/>","@@ -3154,11 +3265,17 @@ def launchd_install(force: bool = False):\n     print(f\"Installing launchd service to: {plist_path}\")\n     plist_path.write_text(generate_launchd_plist())\n \n-    subprocess.run(\n-        [\"launchctl\", \"bootstrap\", _launchd_domain(), str(plist_path)],\n-        check=True,\n-        timeout=30,\n-    )\n+    try:\n+        subprocess.run(\n+            [\"launchctl\", \"bootstrap\", _launchd_domain(), str(plist_path)],\n+            check=True,\n+            timeout=30,\n+        )\n+    except subprocess.CalledProcessError as e:\n+        if not _launchctl_domain_unsupported(e.returncode):\n+            raise\n+        _launchd_fallback_to_detached(f\"launchctl bootstrap exit {e.returncode}\")\n+        return\n \n     print()\n     print(\"✓ Service installed and loaded!\")","@@ -3195,16 +3312,22 @@ def launchd_start():\n         print(\"↻ launchd plist missing; regenerating service definition\")\n         plist_path.parent.mkdir(parents=True, exist_ok=True)\n         plist_path.write_text(generate_launchd_plist(), encoding=\"utf-8\")\n-        subprocess.run(\n-            [\"launchctl\", \"bootstrap\", _launchd_domain(), str(plist_path)],\n-            check=True,\n-            timeout=30,\n-        )\n-        subprocess.run(\n-            [\"launchctl\", \"kickstart\", f\"{_launchd_domain()}/{label}\"],\n-            check=True,\n-            timeout=30,\n-        )\n+        try:\n+            subprocess.run(\n+                [\"launchctl\", \"bootstrap\", _launchd_domain(), str(plist_path)],\n+                check=True,\n+                timeout=30,\n+            )\n+            subprocess.run(\n+                [\"launchctl\", \"kickstart\", f\"{_launchd_domain()}/{label}\"],\n+                check=True,\n+                timeout=30,\n+            )\n+        except subprocess.CalledProcessError as e:\n+            if not _launchctl_domain_unsupported(e.returncode):\n+                raise\n+            _launchd_fallback_to_detached(f\"launchctl exit {e.returncode}\")\n+            return\n         print(\"✓ Service started\")\n         return\n ","@@ -3216,19 +3339,28 @@ def launchd_start():\n             timeout=30,\n         )\n     except subprocess.CalledProcessError as e:\n-        if e.returncode not in {3, 113}:\n+        if not _launchd_error_indicates_unloaded(e):\n             raise\n+        # Job not loaded in this domain — re-bootstrap the plist and retry.\n         print(\"↻ launchd job was unloaded; reloading service definition\")\n-        subprocess.run(\n-            [\"launchctl\", \"bootstrap\", _launchd_domain(), str(plist_path)],\n-            check=True,\n-            timeout=30,\n-        )\n-        subprocess.run(\n-            [\"launchctl\", \"kickstart\", f\"{_launchd_domain()}/{label}\"],\n-            check=True,\n-            timeout=30,\n-        )\n+        try:\n+            subprocess.run(\n+                [\"launchctl\", \"bootstrap\", _launchd_domain(), str(plist_path)],\n+                check=True,\n+                timeout=30,\n+            )\n+            subprocess.run(\n+                [\"launchctl\", \"kickstart\", f\"{_launchd_domain()}/{label}\"],\n+                check=True,\n+                timeout=30,\n+            )\n+        except subprocess.CalledProcessError as e2:\n+            # Even a fresh bootstrap can't manage the domain on this host —\n+            # degrade to a detached background process (issue #23387).\n+            if not _launchctl_domain_unsupported(e2.returncode):\n+                raise\n+            _launchd_fallback_to_detached(f\"launchctl exit {e2.returncode}\")\n+            return\n     print(\"✓ Service started\")\n \n ","@@ -3250,8 +3382,13 @@ def launchd_stop():\n     try:\n         subprocess.run([\"launchctl\", \"bootout\", target], check=True, timeout=90)\n     except subprocess.CalledProcessError as e:\n-        if e.returncode in {3, 113}:\n-            pass  # Already unloaded — nothing to stop.\n+        # Job already unloaded (3/113/125), or the domain can't be managed at\n+        # all (5/125, macOS 26+ detached-fallback process, issue #23387) — in\n+        # both cases just fall through to the PID-based kill below.\n+        if _launchd_error_indicates_unloaded(e) or _launchctl_domain_unsupported(\n+            e.returncode\n+        ):\n+            pass\n         else:\n             raise\n     _wait_for_gateway_exit(timeout=10.0, force_after=5.0)","@@ -3335,17 +3472,29 @@ def launchd_restart():\n         subprocess.run([\"launchctl\", \"kickstart\", \"-k\", target], check=True, timeout=90)\n         print(\"✓ Service restarted\")\n     except subprocess.CalledProcessError as e:\n-        if e.returncode not in {3, 113}:\n+        if not _launchd_error_indicates_unloaded(e):\n+            # Not a \"job unloaded\" code. If the domain is fundamentally\n+            # unmanageable (error 5), degrade to detached; the old process was\n+            # already drained/terminated above. Otherwise re-raise.\n+            if _launchctl_domain_unsupported(e.returncode):\n+                _launchd_fallback_to_detached(f\"launchctl kickstart exit {e.returncode}\")\n+                return\n             raise\n         # Job not loaded — bootstrap and start fresh\n         print(\"↻ launchd job was unloaded; reloading\")\n         plist_path = get_launchd_plist_path()\n-        subprocess.run(\n-            [\"launchctl\", \"bootstrap\", _launchd_domain(), str(plist_path)],\n-            check=True,\n-            timeout=30,\n-        )\n-        subprocess.run([\"launchctl\", \"kickstart\", target], check=True, timeout=30)\n+        try:\n+            subprocess.run(\n+                [\"launchctl\", \"bootstrap\", _launchd_domain(), str(plist_path)],\n+                check=True,\n+                timeout=30,\n+            )\n+            subprocess.run([\"launchctl\", \"kickstart\", target], check=True, timeout=30)\n+        except subprocess.CalledProcessError as e2:\n+            if not _launchctl_domain_unsupported(e2.returncode):\n+                raise\n+            _launchd_fallback_to_detached(f\"launchctl exit {e2.returncode}\")\n+            return\n         print(\"✓ Service restarted\")\n \n "]},{"file":"tests/hermes_cli/test_gateway_service.py","hunks":["@@ -679,6 +679,162 @@ def test_launchd_status_reports_local_stale_plist_when_unloaded(self, tmp_path,\n         assert \"stale\" in output.lower()\n         assert \"not loaded\" in output.lower()\n \n+    def test_launchd_domain_uses_user_domain(self):\n+        # The user/<uid> domain (not gui/<uid>) is the one reachable from\n+        # non-Aqua/background sessions on macOS 26+ (issue #23387).\n+        assert gateway_cli._launchd_domain() == f\"user/{os.getuid()}\"\n+\n+    def test_launchctl_domain_unsupported_recognizes_macos26_codes(self):\n+        # Codes that persist after a fresh bootstrap → launchd truly unavailable.\n+        assert gateway_cli._launchctl_domain_unsupported(5) is True\n+        assert gateway_cli._launchctl_domain_unsupported(125) is True\n+        assert gateway_cli._launchctl_domain_unsupported(3) is False\n+        assert gateway_cli._launchctl_domain_unsupported(113) is False\n+        assert gateway_cli._launchctl_domain_unsupported(0) is False\n+\n+    def test_launchd_start_reloads_on_kickstart_exit_code_125(self, tmp_path, monkeypatch):\n+        \"\"\"Exit code 125 means the job is absent from the domain → bootstrap recovery.\"\"\"\n+        plist_path = tmp_path / \"ai.hermes.gateway.plist\"\n+        plist_path.write_text(gateway_cli.generate_launchd_plist(), encoding=\"utf-8\")\n+        label = gateway_cli.get_launchd_label()\n+\n+        calls = []\n+        domain = gateway_cli._launchd_domain()\n+        target = f\"{domain}/{label}\"\n+\n+        def fake_run(cmd, check=False, **kwargs):\n+            if cmd and cmd[0] == \"launchctl\":\n+                calls.append(cmd)\n+            if cmd == [\"launchctl\", \"kickstart\", target] and calls.count(cmd) == 1:\n+                raise gateway_cli.subprocess.CalledProcessError(\n+                    125, cmd, stderr=\"Domain does not support specified action\"\n+                )\n+            return SimpleNamespace(returncode=0, stdout=\"\", stderr=\"\")\n+\n+        monkeypatch.setattr(gateway_cli, \"get_launchd_plist_path\", lambda: plist_path)\n+        monkeypatch.setattr(gateway_cli.subprocess, \"run\", fake_run)\n+\n+        gateway_cli.launchd_start()\n+\n+        assert calls == [\n+            [\"launchctl\", \"kickstart\", target],\n+            [\"launchctl\", \"bootstrap\", domain, str(plist_path)],\n+            [\"launchctl\", \"kickstart\", target],\n+        ]\n+\n+    def test_launchd_start_falls_back_to_detached_when_rebootstrap_fails(self, tmp_path, monkeypatch, capsys):\n+        \"\"\"If even a fresh bootstrap can't manage the domain, spawn detached.\"\"\"\n+        plist_path = tmp_path / \"ai.hermes.gateway.plist\"\n+        plist_path.write_text(gateway_cli.generate_launchd_plist(), encoding=\"utf-8\")\n+        label = gateway_cli.get_launchd_label()\n+        target = f\"{gateway_cli._launchd_domain()}/{label}\"\n+\n+        monkeypatch.setattr(gateway_cli, \"get_launchd_plist_path\", lambda: plist_path)\n+        monkeypatch.setattr(gateway_cli, \"refresh_launchd_plist_if_needed\", lambda: False)\n+\n+        def fake_run(cmd, check=False, **kwargs):\n+            if cmd == [\"launchctl\", \"kickstart\", target]:\n+                # First kickstart: job not loaded (125). After bootstrap also\n+                # fails, this won't be reached again.\n+                raise gateway_cli.subprocess.CalledProcessError(\n+                    125, cmd, stderr=\"Domain does not support specified action\"\n+                )\n+            if cmd[:2] == [\"launchctl\", \"bootstrap\"]:\n+                raise gateway_cli.subprocess.CalledProcessError(\n+                    5, cmd, stderr=\"Input/output error\"\n+                )\n+            return SimpleNamespace(returncode=0, stdout=\"\", stderr=\"\")\n+\n+        monkeypatch.setattr(gateway_cli.subprocess, \"run\", fake_run)\n+\n+        spawned = []\n+        monkeypatch.setattr(\n+            gateway_cli, \"_spawn_detached_gateway\", lambda: spawned.append(True) or True\n+        )\n+\n+        gateway_cli.launchd_start()\n+\n+        assert spawned == [True]\n+        assert \"background process\" in capsys.readouterr().out.lower()\n+\n+    def test_launchd_install_falls_back_to_detached_on_bootstrap_5(self, tmp_path, monkeypatch, capsys):\n+        \"\"\"macOS bootstrap error 5 should spawn a detached gateway, not crash.\"\"\"\n+        plist_path = tmp_path / \"ai.hermes.gateway.plist\"\n+        monkeypatch.setattr(gateway_cli, \"get_launchd_plist_path\", lambda: plist_path)\n+\n+        def fake_run(cmd, check=False, **kwargs):\n+            if cmd[:2] == [\"launchctl\", \"bootstrap\"]:\n+                raise gateway_cli.subprocess.CalledProcessError(\n+                    5, cmd, stderr=\"Input/output error\"\n+                )\n+            return SimpleNamespace(returncode=0, stdout=\"\", stderr=\"\")\n+\n+        monkeypatch.setattr(gateway_cli.subprocess, \"run\", fake_run)\n+\n+        spawned = []\n+        monkeypatch.setattr(\n+            gateway_cli, \"_spawn_detached_gateway\", lambda: spawned.append(True) or True\n+        )\n+\n+        gateway_cli.launchd_install(force=True)\n+\n+        assert spawned == [True]\n+        assert \"Service installed and loaded\" not in capsys.readouterr().out\n+\n+    def test_launchd_restart_falls_back_to_detached_on_error_5(self, monkeypatch, capsys):\n+        \"\"\"kickstart -k error 5 (domain unmanageable) should relaunch detached.\"\"\"\n+        target = f\"{gateway_cli._launchd_domain()}/{gateway_cli.get_launchd_label()}\"\n+\n+        monkeypatch.setattr(gateway_cli, \"_get_restart_drain_timeout\", lambda: 5.0)\n+        monkeypatch.setattr(gateway_cli, \"_request_gateway_self_restart\", lambda pid: False)\n+        monkeypatch.setattr(gateway_cli, \"_wait_for_gateway_exit\", lambda timeout, force_after=None: True)\n+        monkeypatch.setattr(gateway_cli, \"terminate_pid\", lambda pid, force=False: None)\n+        monkeypatch.setattr(\"gateway.status.get_running_pid\", lambda: 321)\n+\n+        def fake_run(cmd, check=False, **kwargs):\n+            if cmd == [\"launchctl\", \"kickstart\", \"-k\", target]:\n+                raise gateway_cli.subprocess.CalledProcessError(\n+                    5, cmd, stderr=\"Input/output error\"\n+                )\n+            return SimpleNamespace(returncode=0, stdout=\"\", stderr=\"\")\n+\n+        monkeypatch.setattr(gateway_cli.subprocess, \"run\", fake_run)\n+\n+        spawned = []\n+        monkeypatch.setattr(\n+            gateway_cli, \"_spawn_detached_gateway\", lambda: spawned.append(True) or True\n+        )\n+\n+        gateway_cli.launchd_restart()\n+\n+        assert spawned == [True]\n+\n+    def test_launchd_stop_tolerates_domain_unsupported_bootout(self, monkeypatch, capsys):\n+        \"\"\"bootout exit 125 (macOS 26) must fall through to PID-based kill, not raise.\"\"\"\n+        def fake_run(cmd, check=False, **kwargs):\n+            if \"bootout\" in cmd:\n+                raise gateway_cli.subprocess.CalledProcessError(\n+                    125, cmd, stderr=\"Domain does not support specified action\"\n+                )\n+            return SimpleNamespace(returncode=0, stdout=\"\", stderr=\"\")\n+\n+        monkeypatch.setattr(gateway_cli.subprocess, \"run\", fake_run)\n+        monkeypatch.setattr(gateway_cli, \"_wait_for_gateway_exit\", lambda **kw: None)\n+\n+        gateway_cli.launchd_stop()\n+\n+        assert \"stopped\" in capsys.readouterr().out.lower()\n+\n+    def test_launchd_fallback_exits_when_spawn_fails(self, monkeypatch, capsys):\n+        \"\"\"If the detached spawn fails, surface the manual workaround and exit 1.\"\"\"\n+        monkeypatch.setattr(gateway_cli, \"_spawn_detached_gateway\", lambda: False)\n+\n+        with pytest.raises(SystemExit) as exc:\n+            gateway_cli._launchd_fallback_to_detached(\"test reason\")\n+        assert exc.value.code == 1\n+        out = capsys.readouterr().out\n+        assert \"nohup hermes gateway run\" in out\n+\n \n class TestGatewayServiceDetection:\n     def test_supports_systemd_services_requires_systemctl_binary(self, monkeypatch):","@@ -1629,6 +1785,14 @@ def test_launchd_plist_includes_profile(self, tmp_path, monkeypatch):\n         assert \"<string>--profile</string>\" in plist\n         assert \"<string>mybot</string>\" in plist\n \n+    def test_launchd_plist_supports_aqua_and_background_sessions(self):\n+        # macOS 26+ only loads the agent in non-Aqua sessions when the plist\n+        # opts into Background as well (issue #23387).\n+        plist = gateway_cli.generate_launchd_plist()\n+        assert \"<key>LimitLoadToSessionType</key>\" in plist\n+        assert \"<string>Aqua</string>\" in plist\n+        assert \"<string>Background</string>\" in plist\n+\n     def test_launchd_plist_path_uses_real_user_home_not_profile_home(self, tmp_path, monkeypatch):\n         profile_dir = tmp_path / \".hermes\" / \"profiles\" / \"orcha\"\n         profile_dir.mkdir(parents=True)\n"]}]},{"kind":"pr","number":40878,"title":"fix(gateway): probe macOS launchd domain (#40831)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#43663","number":43663},{"kind":"pr","value":"#40831","number":40831},{"kind":"pr","value":"#23387","number":23387},{"kind":"pr","value":"#40581","number":40581}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":40983,"title":"fix(gateway): detect Aqua session for launchd domain (#40831)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#43663","number":43663},{"kind":"pr","value":"#40878","number":40878}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":41687,"title":"fix(gateway): skip duplicate spawn when background gateway is already running","url":null,"state":"open","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":42009,"title":"fix(gateway): add bootout before bootstrap in launchd_restart","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#46736","number":46736}],"category":"superseded_by_pr","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":42010,"title":"fix(gateway): boot out stale launchd registration before restart bootstrap","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"c4719aa51","number":0},{"kind":"pr","value":"#54159","number":54159},{"kind":"pr","value":"#42006","number":42006},{"kind":"pr","value":"#42009","number":42009},{"kind":"pr","value":"#46736","number":46736}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":42395,"title":"fix(gateway): remove LimitLoadToSessionType from launchd plist to fix macOS 26.5+ bootstrap","url":null,"state":"open","close_reason":{"refs":[{"kind":"pr","value":"#42376","number":42376}],"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":42464,"title":"fix(gateway): remove LimitLoadToSessionType from launchd plist (#42376)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#42395","number":42395},{"kind":"pr","value":"#42376","number":42376}],"category":"superseded_by_pr","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":42567,"title":"fix(macos): clearly distinguish launchd supervision from detached fal…","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#52984","number":52984},{"kind":"pr","value":"#52983","number":52983}],"category":"uncategorized","implication":"needs_llm"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":42588,"title":"fix(gateway): try launchctl start before detached fallback on exit 5 (#42524)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#52984","number":52984},{"kind":"pr","value":"#42567","number":42567}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":42902,"title":"fix(gateway): select macOS launchd domain from manager","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#43663","number":43663},{"kind":"pr","value":"#40878","number":40878}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":43181,"title":"fix(gateway): recover unloaded launchd job during update","url":null,"state":"closed","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":43305,"title":"fix(gateway): detect the active launchd domain (#42995)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#43663","number":43663},{"kind":"pr","value":"#40878","number":40878},{"kind":"pr","value":"#40878","number":40878}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":43663,"title":"fix(gateway): probe macOS launchd domain instead of hardcoding user/<uid> (#40831)","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","tests/hermes_cli/test_gateway_service.py"],"fix_hunks":[{"file":"hermes_cli/gateway.py","hunks":["@@ -3067,12 +3067,77 @@ def get_launchd_label() -> str:\n     return f\"ai.hermes.gateway-{suffix}\" if suffix else \"ai.hermes.gateway\"\n \n \n+# Cached launchd domain result — probing is cheap but should only run once per\n+# process invocation (each ``hermes gateway start/stop/status`` call).\n+_resolved_launchd_domain: str | None = None\n+\n+\n def _launchd_domain() -> str:\n-    # The `user/<uid>` domain (vs the older `gui/<uid>`) is reachable from\n-    # non-Aqua/background sessions (SSH, headless, login items) and is the only\n-    # one that supports service management on macOS 26+. `gui/<uid>` returns\n-    # error 125 (\"Domain does not support specified action\") there. See #23387.\n-    return f\"user/{os.getuid()}\"  # windows-footgun: ok — POSIX launchd (macOS) helper, never invoked on Windows\n+    \"\"\"Return the launchd domain that actually manages the gateway service.\n+\n+    Probes ``gui/<uid>`` first (Aqua sessions), then ``user/<uid>``\n+    (Background/SSH sessions).  When neither domain contains a loaded\n+    service, falls back to ``launchctl managername`` as a heuristic.\n+\n+    The result is cached for the lifetime of the process so that repeated\n+    calls (``start``, ``stop``, ``restart``) use a consistent domain.\n+\n+    See #40831, #23387.\n+    \"\"\"\n+    global _resolved_launchd_domain\n+    if _resolved_launchd_domain is not None:\n+        return _resolved_launchd_domain\n+\n+    uid = os.getuid()  # windows-footgun: ok — POSIX launchd (macOS) helper, never invoked on Windows\n+    label = get_launchd_label()\n+    gui_domain = f\"gui/{uid}\"\n+    user_domain = f\"user/{uid}\"\n+\n+    # 1. Probe gui/<uid> first — in Aqua sessions the service is loaded here.\n+    try:\n+        subprocess.run(\n+            [\"launchctl\", \"print\", f\"{gui_domain}/{label}\"],\n+            check=True,\n+            timeout=5,\n+            capture_output=True,\n+        )\n+        _resolved_launchd_domain = gui_domain\n+        return gui_domain\n+    except (subprocess.CalledProcessError, subprocess.TimeoutExpired, FileNotFoundError):\n+        pass\n+\n+    # 2. Probe user/<uid> — in Background/SSH sessions this is the working domain.\n+    try:\n+        subprocess.run(\n+            [\"launchctl\", \"print\", f\"{user_domain}/{label}\"],\n+            check=True,\n+            timeout=5,\n+            capture_output=True,\n+        )\n+        _resolved_launchd_domain = user_domain\n+        return user_domain\n+    except (subprocess.CalledProcessError, subprocess.TimeoutExpired, FileNotFoundError):\n+        pass\n+\n+    # 3. Neither domain has the service loaded — use managername as heuristic.\n+    #    Aqua → gui/<uid>, anything else (Background, loginwindow) → user/<uid>.\n+    try:\n+        result = subprocess.run(\n+            [\"launchctl\", \"managername\"],\n+            capture_output=True,\n+            text=True,\n+            timeout=5,\n+        )\n+        if \"Aqua\" in (result.stdout or \"\"):\n+            _resolved_launchd_domain = gui_domain\n+            return gui_domain\n+    except (subprocess.CalledProcessError, subprocess.TimeoutExpired, FileNotFoundError):\n+        pass\n+\n+    # 4. Default to user/<uid> (matches the pre-probing behavior for\n+    #    Background/SSH sessions and is the recommended domain on macOS 26+).\n+    _resolved_launchd_domain = user_domain\n+    return user_domain\n \n \n # On macOS, exit code 125 (\"Domain does not support specified action\") and"]},{"file":"tests/hermes_cli/test_gateway_service.py","hunks":["@@ -495,7 +495,10 @@ def fake_run(cmd, check=False, **kwargs):\n         label = gateway_cli.get_launchd_label()\n         domain = gateway_cli._launchd_domain()\n         assert \"--replace\" in plist_path.read_text(encoding=\"utf-8\")\n-        assert calls[:2] == [\n+        # The calls list includes launchctl print probes from _launchd_domain()\n+        # before the bootout/bootstrap calls. Filter to only bootout/bootstrap.\n+        service_calls = [c for c in calls if \"bootout\" in c or \"bootstrap\" in c]\n+        assert service_calls[:2] == [\n             [\"launchctl\", \"bootout\", f\"{domain}/{label}\"],\n             [\"launchctl\", \"bootstrap\", domain, str(plist_path)],\n         ]","@@ -679,10 +682,22 @@ def test_launchd_status_reports_local_stale_plist_when_unloaded(self, tmp_path,\n         assert \"stale\" in output.lower()\n         assert \"not loaded\" in output.lower()\n \n-    def test_launchd_domain_uses_user_domain(self):\n+    def test_launchd_domain_uses_user_domain(self, monkeypatch):\n         # The user/<uid> domain (not gui/<uid>) is the one reachable from\n         # non-Aqua/background sessions on macOS 26+ (issue #23387).\n-        assert gateway_cli._launchd_domain() == f\"user/{os.getuid()}\"\n+        # When gui/<uid> fails to probe and user/<uid> succeeds,\n+        # _launchd_domain() must return user/<uid>.\n+        gateway_cli._resolved_launchd_domain = None\n+        monkeypatch.setattr(os, \"getuid\", lambda: 501)\n+        label = gateway_cli.get_launchd_label()\n+\n+        def fake_run(cmd, check=False, **kwargs):\n+            if \"print\" in cmd and \"gui/\" in \" \".join(cmd):\n+                raise subprocess.CalledProcessError(1, cmd, stderr=\"Domain error\")\n+            return SimpleNamespace(returncode=0, stdout=\"\", stderr=\"\")\n+\n+        monkeypatch.setattr(gateway_cli.subprocess, \"run\", fake_run)\n+        assert gateway_cli._launchd_domain() == \"user/501\"\n \n     def test_launchctl_domain_unsupported_recognizes_macos26_codes(self):\n         # Codes that persist after a fresh bootstrap → launchd truly unavailable.","@@ -836,6 +851,114 @@ def test_launchd_fallback_exits_when_spawn_fails(self, monkeypatch, capsys):\n         assert \"nohup hermes gateway run\" in out\n \n \n+class TestLaunchdDomainDetection:\n+    \"\"\"Regression tests for _launchd_domain() probing (#40831).\n+\n+    The function must detect which launchd domain actually contains (or can\n+    manage) the service, rather than hardcoding ``user/<uid>`` or ``gui/<uid>``.\n+    \"\"\"\n+\n+    def _reset_domain_cache(self):\n+        \"\"\"Clear any cached domain result between tests.\"\"\"\n+        gateway_cli._resolved_launchd_domain = None\n+\n+    def test_prefers_gui_domain_when_service_loaded_there(self, monkeypatch):\n+        \"\"\"In an Aqua session where the service is loaded under gui/<uid>,\n+        _launchd_domain() must return ``gui/<uid>`` — not ``user/<uid>``.\"\"\"\n+        self._reset_domain_cache()\n+        monkeypatch.setattr(os, \"getuid\", lambda: 501)\n+        label = gateway_cli.get_launchd_label()\n+\n+        run_calls = []\n+\n+        def fake_run(cmd, check=False, **kwargs):\n+            run_calls.append(cmd)\n+            return SimpleNamespace(returncode=0, stdout=\"\", stderr=\"\")\n+\n+        monkeypatch.setattr(gateway_cli.subprocess, \"run\", fake_run)\n+\n+        domain = gateway_cli._launchd_domain()\n+        assert domain == f\"gui/501\"\n+        # Should have probed gui first\n+        assert run_calls[0] == [\"launchctl\", \"print\", f\"gui/501/{label}\"]\n+\n+    def test_falls_back_to_user_domain_when_gui_fails(self, monkeypatch):\n+        \"\"\"In a Background/SSH session where gui/<uid> fails but user/<uid>\n+        works, _launchd_domain() must return ``user/<uid>``.\"\"\"\n+        self._reset_domain_cache()\n+        monkeypatch.setattr(os, \"getuid\", lambda: 501)\n+        label = gateway_cli.get_launchd_label()\n+\n+        run_calls = []\n+\n+        def fake_run(cmd, check=False, **kwargs):\n+            run_calls.append(cmd)\n+            if \"print\" in cmd and \"gui/\" in \" \".join(cmd):\n+                raise subprocess.CalledProcessError(1, cmd, stderr=\"Domain error\")\n+            return SimpleNamespace(returncode=0, stdout=\"\", stderr=\"\")\n+\n+        monkeypatch.setattr(gateway_cli.subprocess, \"run\", fake_run)\n+\n+        domain = gateway_cli._launchd_domain()\n+        assert domain == f\"user/501\"\n+        # Should have tried gui first, then user\n+        assert len(run_calls) >= 2\n+\n+    def test_uses_managername_heuristic_when_both_probe_fail(self, monkeypatch):\n+        \"\"\"When neither domain contains a loaded service, use\n+        ``launchctl managername`` as a tiebreaker: Aqua -> gui, else -> user.\"\"\"\n+        self._reset_domain_cache()\n+        monkeypatch.setattr(os, \"getuid\", lambda: 501)\n+        label = gateway_cli.get_launchd_label()\n+\n+        def fake_run(cmd, check=False, **kwargs):\n+            if \"print\" in cmd:\n+                raise subprocess.CalledProcessError(1, cmd, stderr=\"not found\")\n+            if \"managername\" in cmd:\n+                return SimpleNamespace(returncode=0, stdout=\"Aqua\\n\", stderr=\"\")\n+            return SimpleNamespace(returncode=0, stdout=\"\", stderr=\"\")\n+\n+        monkeypatch.setattr(gateway_cli.subprocess, \"run\", fake_run)\n+\n+        domain = gateway_cli._launchd_domain()\n+        assert domain == f\"gui/501\"\n+\n+    def test_managername_background_selects_user_domain(self, monkeypatch):\n+        \"\"\"When managername is Background (non-Aqua), use user/<uid>.\"\"\"\n+        self._reset_domain_cache()\n+        monkeypatch.setattr(os, \"getuid\", lambda: 501)\n+\n+        def fake_run(cmd, check=False, **kwargs):\n+            if \"print\" in cmd:\n+                raise subprocess.CalledProcessError(1, cmd, stderr=\"not found\")\n+            if \"managername\" in cmd:\n+                return SimpleNamespace(returncode=0, stdout=\"Background\\n\", stderr=\"\")\n+            return SimpleNamespace(returncode=0, stdout=\"\", stderr=\"\")\n+\n+        monkeypatch.setattr(gateway_cli.subprocess, \"run\", fake_run)\n+\n+        domain = gateway_cli._launchd_domain()\n+        assert domain == f\"user/501\"\n+\n+    def test_caches_result_across_calls(self, monkeypatch):\n+        \"\"\"Domain detection should run once and cache the result.\"\"\"\n+        self._reset_domain_cache()\n+        monkeypatch.setattr(os, \"getuid\", lambda: 501)\n+\n+        run_count = [0]\n+\n+        def fake_run(cmd, check=False, **kwargs):\n+            run_count[0] += 1\n+            return SimpleNamespace(returncode=0, stdout=\"\", stderr=\"\")\n+\n+        monkeypatch.setattr(gateway_cli.subprocess, \"run\", fake_run)\n+\n+        d1 = gateway_cli._launchd_domain()\n+        d2 = gateway_cli._launchd_domain()\n+        assert d1 == d2\n+        assert run_count[0] == 1  # Only probed once\n+\n+\n class TestGatewayServiceDetection:\n     def test_supports_systemd_services_requires_systemctl_binary(self, monkeypatch):\n         monkeypatch.setattr(gateway_cli, \"is_linux\", lambda: True)\n"]}]},{"kind":"pr","number":44620,"title":"Fix launchd plist refresh overlap on macOS","url":null,"state":"open","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":46736,"title":"fix(gateway): bootout stale launchd registration before bootstrap on restart","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#42009","number":42009},{"kind":"pr","value":"#42010","number":42010},{"kind":"pr","value":"#42914","number":42914}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":52984,"title":"fix(macos): distinguish launchd supervision from detached fallback in gateway status (#42524)","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","tests/hermes_cli/test_gateway_service.py"],"fix_hunks":[{"file":"hermes_cli/gateway.py","hunks":["@@ -138,16 +138,22 @@ def _get_service_pids() -> set:\n                 timeout=5,\n             )\n             if result.returncode == 0:\n-                # Output: \"PID\\tStatus\\tLabel\" header, then one data line\n-                for line in result.stdout.strip().splitlines():\n-                    parts = line.split()\n-                    if len(parts) >= 3 and parts[2] == label:\n-                        try:\n-                            pid = int(parts[0])\n-                            if pid > 0:\n-                                pids.add(pid)\n-                        except ValueError:\n-                            pass\n+                # Try plist format first (macOS 26+): \"PID\" = <N>;\n+                pid = _parse_launchd_pid_from_list_output(result.stdout)\n+                if pid is not None and pid > 0:\n+                    pids.add(pid)\n+                else:\n+                    # Fall back to legacy tab-separated format:\n+                    # \"PID\\tStatus\\tLabel\"\n+                    for line in result.stdout.strip().splitlines():\n+                        parts = line.split()\n+                        if len(parts) >= 3 and parts[2] == label:\n+                            try:\n+                                pid = int(parts[0])\n+                                if pid > 0:\n+                                    pids.add(pid)\n+                            except ValueError:\n+                                pass\n         except (FileNotFoundError, subprocess.TimeoutExpired):\n             pass\n ","@@ -1129,7 +1135,37 @@ def _recover_pending_systemd_restart(\n     return False\n \n \n+def _parse_launchd_pid_from_list_output(output: str) -> int | None:\n+    \"\"\"Extract the PID from ``launchctl list <label>`` output.\n+\n+    When launchd is actively supervising a process, the output includes a\n+    ``\"PID\" = <number>;`` line.  When the service definition is only *registered*\n+    but not running (macOS 26+ with an unmanageable domain, fallback active),\n+    the output lacks a PID field entirely.  Returns ``None`` when no PID is\n+    found or the PID is non-positive (e.g. ``-1`` for a recently-crashed service).\n+    \"\"\"\n+    for line in output.splitlines():\n+        stripped = line.strip()\n+        if stripped.startswith('\"PID\"') or stripped.startswith(\"PID\"):\n+            parts = stripped.split(\"=\", 1)\n+            if len(parts) == 2:\n+                val = parts[1].strip().rstrip(\";\").strip('\"')\n+                try:\n+                    pid = int(val)\n+                    return pid if pid > 0 else None\n+                except ValueError:\n+                    return None\n+    return None\n+\n+\n def _probe_launchd_service_running() -> bool:\n+    \"\"\"Return True when launchd is actively supervising the gateway process.\n+\n+    ``launchctl list <label>`` returns exit 0 whenever the service definition is\n+    registered with launchd — even when ``state = not running`` (macOS 26+).\n+    We additionally require a PID in the output to confirm launchd is actually\n+    managing a live process, not just holding a static definition.\n+    \"\"\"\n     if not get_launchd_plist_path().exists():\n         return False\n     try:","@@ -1141,7 +1177,9 @@ def _probe_launchd_service_running() -> bool:\n         )\n     except subprocess.TimeoutExpired:\n         return False\n-    return result.returncode == 0\n+    if result.returncode != 0:\n+        return False\n+    return _parse_launchd_pid_from_list_output(result.stdout) is not None\n \n \n def get_gateway_runtime_snapshot(system: bool = False) -> GatewayRuntimeSnapshot:","@@ -1235,12 +1273,23 @@ def _print_gateway_process_mismatch(snapshot: GatewayRuntimeSnapshot) -> None:\n     if not snapshot.has_process_service_mismatch:\n         return\n     print()\n-    print(\n-        \"⚠ Gateway process is running for this profile, but the service is not active\"\n-    )\n-    print(f\"  PID(s): {_format_gateway_pids(snapshot.gateway_pids, limit=None)}\")\n-    print(\"  This is usually a manual foreground/tmux/nohup run, so `hermes gateway`\")\n-    print(\"  can refuse to start another copy until this process stops.\")\n+    # Distinguish the managed detached fallback (macOS launchd exit-5 path)\n+    # from a genuinely manual foreground/tmux/nohup run.\n+    if _launchd_unsupported_marker_exists():\n+        print(\n+            \"⚠ Gateway is running as a detached fallback process — \"\n+            \"launchd cannot supervise it\"\n+        )\n+        print(f\"  PID(s): {_format_gateway_pids(snapshot.gateway_pids, limit=None)}\")\n+        print(\"  Auto-start at login and auto-restart on crash are NOT available.\")\n+        print(\"  Stop it with: hermes gateway stop\")\n+    else:\n+        print(\n+            \"⚠ Gateway process is running for this profile, but the service is not active\"\n+        )\n+        print(f\"  PID(s): {_format_gateway_pids(snapshot.gateway_pids, limit=None)}\")\n+        print(\"  This is usually a manual foreground/tmux/nohup run, so `hermes gateway`\")\n+        print(\"  can refuse to start another copy until this process stops.\")\n \n \n def _print_other_profiles_gateway_status() -> None:","@@ -3404,6 +3453,47 @@ def _launchctl_domain_unsupported(returncode: int) -> bool:\n     return returncode in _LAUNCHCTL_DOMAIN_UNSUPPORTED_CODES\n \n \n+# ── launchd unsupported marker ─────────────────────────────────────────────\n+# When launchd can't manage the domain on this host (error 5/125, macOS 26+),\n+# we write a persistent marker so `launchd_status()` can explain that launchd\n+# supervision is unavailable regardless of whether a fallback process is\n+# currently running.  The marker is cleared when bootstrap/kickstart succeeds,\n+# so an OS update that fixes the underlying issue allows automatic recovery.\n+\n+\n+def _launchd_unsupported_marker_path() -> Path:\n+    return get_hermes_home() / \".gateway-launchd-unsupported\"\n+\n+\n+def _write_launchd_unsupported_marker() -> None:\n+    \"\"\"Persist that launchd cannot supervise the gateway on this host.\"\"\"\n+    import json\n+    from datetime import datetime, timezone\n+\n+    try:\n+        _launchd_unsupported_marker_path().write_text(\n+            json.dumps({\n+                \"written_at\": datetime.now(timezone.utc).isoformat(),\n+                \"reason\": \"launchd domain unsupported (exit 5/125)\",\n+            }),\n+            encoding=\"utf-8\",\n+        )\n+    except OSError:\n+        pass\n+\n+\n+def _clear_launchd_unsupported_marker() -> None:\n+    \"\"\"Clear the unsupported marker when launchd bootstrap succeeds.\"\"\"\n+    try:\n+        _launchd_unsupported_marker_path().unlink(missing_ok=True)\n+    except OSError:\n+        pass\n+\n+\n+def _launchd_unsupported_marker_exists() -> bool:\n+    return _launchd_unsupported_marker_path().exists()\n+\n+\n def _gateway_run_command() -> list[str]:\n     \"\"\"Build the `python -m hermes_cli.main [--profile X] gateway run --replace` argv.\n ","@@ -3461,6 +3551,7 @@ def _launchd_fallback_to_detached(reason: str, *, exit_on_failure: bool = True)\n     \"\"\"\n     from hermes_constants import display_hermes_home as _dhh\n \n+    _write_launchd_unsupported_marker()\n     print(f\"⚠ launchd cannot manage the gateway on this macOS version ({reason}).\")\n     if _spawn_detached_gateway():\n         print(\"✓ Started gateway as a background process instead\")","@@ -3702,6 +3793,7 @@ def launchd_install(force: bool = False):\n \n     print()\n     print(\"✓ Service installed and loaded!\")\n+    _clear_launchd_unsupported_marker()\n     print()\n     print(\"Next steps:\")\n     print(\"  hermes gateway status             # Check status\")","@@ -3755,6 +3847,7 @@ def launchd_start():\n             _launchd_fallback_to_detached(f\"launchctl exit {e.returncode}\")\n             return\n         print(\"✓ Service started\")\n+        _clear_launchd_unsupported_marker()\n         return\n \n     refresh_launchd_plist_if_needed()","@@ -3788,6 +3881,7 @@ def launchd_start():\n             _launchd_fallback_to_detached(f\"launchctl exit {e2.returncode}\")\n             return\n     print(\"✓ Service started\")\n+    _clear_launchd_unsupported_marker()\n \n \n def launchd_stop():","@@ -3883,6 +3977,7 @@ def launchd_restart():\n         pid = get_running_pid()\n         if pid is not None and _request_gateway_self_restart(pid):\n             print(\"✓ Service restart requested\")\n+            _clear_launchd_unsupported_marker()\n             return\n         if pid is not None:\n             # Announce the drain BEFORE waiting on it. This wait can run for","@@ -3907,6 +4002,7 @@ def launchd_restart():\n                     )\n         subprocess.run([\"launchctl\", \"kickstart\", \"-k\", target], check=True, timeout=90)\n         print(\"✓ Service restarted\")\n+        _clear_launchd_unsupported_marker()\n     except subprocess.CalledProcessError as e:\n         if not _launchd_error_indicates_unloaded(e):\n             # Not a \"job unloaded\" code. If the domain is fundamentally","@@ -3932,6 +4028,7 @@ def launchd_restart():\n             _launchd_fallback_to_detached(f\"launchctl exit {e2.returncode}\")\n             return\n         print(\"✓ Service restarted\")\n+        _clear_launchd_unsupported_marker()\n \n \n def launchd_status(deep: bool = False):","@@ -3944,12 +4041,35 @@ def launchd_status(deep: bool = False):\n             text=True,\n             timeout=10,\n         )\n-        loaded = result.returncode == 0\n-        loaded_output = result.stdout\n+        service_listed = result.returncode == 0\n+        list_output = result.stdout\n     except subprocess.TimeoutExpired:\n-        loaded = False\n-        loaded_output = \"\"\n+        service_listed = False\n+        list_output = \"\"\n+\n+    # Determine whether launchd is actively supervising a process.\n+    # ``launchctl list`` returns exit 0 whenever the service definition is\n+    # registered — even when ``state = not running`` (macOS 26+ with an\n+    # unmanageable domain).  A PID in the output confirms a live process.\n+    launchd_pid = _parse_launchd_pid_from_list_output(list_output) if service_listed else None\n \n+    # Hermes PID tracking — may be a detached fallback process spawned when\n+    # launchd cannot manage the domain on this host.\n+    from gateway.status import get_running_pid\n+    fallback_pid = get_running_pid(cleanup_stale=False)\n+\n+    # Avoid double-counting: when launchd IS supervising, fallback_pid and\n+    # launchd_pid point at the same process (the gateway writes both the\n+    # launchd PID and the Hermes PID file).\n+    if launchd_pid is not None and fallback_pid == launchd_pid:\n+        fallback_pid = None\n+\n+    # Persistent marker written when launchd bootstrap/kickstart fails with\n+    # exit 5/125 on this host.  Lets us explain *why* launchd can't supervise\n+    # even when no fallback process is currently running.\n+    launchd_unsupported = _launchd_unsupported_marker_exists()\n+\n+    # ── Report ──\n     print(f\"Launchd plist: {plist_path}\")\n     if launchd_plist_is_current():\n         print(\"✓ Service definition matches the current Hermes install\")","@@ -3957,13 +4077,33 @@ def launchd_status(deep: bool = False):\n         print(\"⚠ Service definition is stale relative to the current Hermes install\")\n         print(\"  Run: hermes gateway start\")\n \n-    if loaded:\n-        print(\"✓ Gateway service is loaded\")\n-        print(loaded_output)\n+    if service_listed:\n+        if launchd_pid is not None:\n+            print(f\"✓ Gateway is supervised by launchd (PID {launchd_pid})\")\n+            print(\"  Auto-start at login and auto-restart on crash are available.\")\n+            if launchd_unsupported:\n+                print(\"  (launchd domain was previously unavailable but is now working)\")\n+        elif launchd_unsupported:\n+            print(\"⚠ Gateway service is registered but launchd is not supervising it\")\n+            print(\"  launchd cannot manage the gateway on this macOS version.\")\n+            if fallback_pid:\n+                print(f\"✓ Detached fallback process is running (PID {fallback_pid})\")\n+                print(\"  Cron jobs will fire. Stop with: hermes gateway stop\")\n+            else:\n+                print(\"✗ No fallback process is running\")\n+                print(\"  Run: hermes gateway start\")\n+            print(\"  ⚠ Auto-start at login and auto-restart on crash are NOT available.\")\n+        else:\n+            print(\"✓ Gateway service is registered with launchd\")\n+            print(list_output)\n+            if fallback_pid:\n+                print(f\"  Detached gateway process is running (PID {fallback_pid})\")\n     else:\n         print(\"✗ Gateway service is not loaded\")\n         print(\"  Service definition exists locally but launchd has not loaded it.\")\n         print(\"  Run: hermes gateway start\")\n+        if fallback_pid:\n+            print(f\"  Note: a detached gateway process is running (PID {fallback_pid})\")\n \n     if deep:\n         log_file = get_hermes_home() / \"logs\" / \"gateway.log\""]},{"file":"tests/hermes_cli/test_gateway_service.py","hunks":["@@ -993,6 +993,8 @@ def fake_run(cmd, check=False, **kwargs):\n \n         assert spawned == [True]\n         assert \"background process\" in capsys.readouterr().out.lower()\n+        # Verify the unsupported marker was written so status can explain why\n+        assert gateway_cli._launchd_unsupported_marker_exists()\n \n     def test_launchd_install_falls_back_to_detached_on_bootstrap_5(self, tmp_path, monkeypatch, capsys):\n         \"\"\"macOS bootstrap error 5 should spawn a detached gateway, not crash.\"\"\"","@@ -1028,6 +1030,7 @@ def fake_run(cmd, check=False, **kwargs):\n \n         assert spawned == [True]\n         assert \"Service installed and loaded\" not in capsys.readouterr().out\n+        assert gateway_cli._launchd_unsupported_marker_exists()\n \n     def test_launchd_restart_falls_back_to_detached_on_error_5(self, monkeypatch, capsys):\n         \"\"\"kickstart -k error 5 (domain unmanageable) should relaunch detached.\"\"\"","@@ -1056,6 +1059,7 @@ def fake_run(cmd, check=False, **kwargs):\n         gateway_cli.launchd_restart()\n \n         assert spawned == [True]\n+        assert gateway_cli._launchd_unsupported_marker_exists()\n \n     def test_launchd_stop_tolerates_domain_unsupported_bootout(self, monkeypatch, capsys):\n         \"\"\"bootout exit 125 (macOS 26) must fall through to PID-based kill, not raise.\"\"\"","@@ -1082,6 +1086,177 @@ def test_launchd_fallback_exits_when_spawn_fails(self, monkeypatch, capsys):\n         assert exc.value.code == 1\n         out = capsys.readouterr().out\n         assert \"nohup hermes gateway run\" in out\n+        # Marker is still written so status knows launchd is unavailable\n+        assert gateway_cli._launchd_unsupported_marker_exists()\n+\n+    # ── PID parsing ──────────────────────────────────────────────────────\n+\n+    def test_parse_launchd_pid_from_list_output_with_pid(self):\n+        output = '{\\n    \"PID\" = 12345;\\n    \"Label\" = \"ai.hermes.gateway\";\\n}'\n+        assert gateway_cli._parse_launchd_pid_from_list_output(output) == 12345\n+\n+    def test_parse_launchd_pid_from_list_output_without_pid(self):\n+        output = '{\\n    \"Label\" = \"ai.hermes.gateway\";\\n    \"OnDemand\" = true;\\n}'\n+        assert gateway_cli._parse_launchd_pid_from_list_output(output) is None\n+\n+    def test_parse_launchd_pid_from_list_output_empty(self):\n+        assert gateway_cli._parse_launchd_pid_from_list_output(\"\") is None\n+\n+    def test_parse_launchd_pid_from_list_output_unquoted_pid(self):\n+        \"\"\"Older macOS versions may output PID without quotes.\"\"\"\n+        output = \"{\\n    PID = 99999;\\n}\"\n+        assert gateway_cli._parse_launchd_pid_from_list_output(output) == 99999\n+\n+    def test_parse_launchd_pid_from_list_output_negative_pid_returns_none(self):\n+        \"\"\"PID = -1 (recently-crashed service sentinel) must return None.\"\"\"\n+        output = '{\\n    \"PID\" = -1;\\n    \"Label\" = \"ai.hermes.gateway\";\\n}'\n+        assert gateway_cli._parse_launchd_pid_from_list_output(output) is None\n+\n+    # ── Probe requires PID ───────────────────────────────────────────────\n+\n+    def test_probe_launchd_service_running_false_without_pid_in_output(self, tmp_path, monkeypatch):\n+        \"\"\"launchctl list returns 0 but no PID → not actually running.\"\"\"\n+        plist_path = tmp_path / \"ai.hermes.gateway.plist\"\n+        plist_path.write_text(gateway_cli.generate_launchd_plist(), encoding=\"utf-8\")\n+        monkeypatch.setattr(gateway_cli, \"get_launchd_plist_path\", lambda: plist_path)\n+        monkeypatch.setattr(\n+            gateway_cli.subprocess,\n+            \"run\",\n+            lambda *args, **kwargs: SimpleNamespace(\n+                returncode=0,\n+                stdout='{\\n    \"Label\" = \"ai.hermes.gateway\";\\n}',\n+                stderr=\"\",\n+            ),\n+        )\n+        assert gateway_cli._probe_launchd_service_running() is False\n+\n+    def test_probe_launchd_service_running_true_with_pid_in_output(self, tmp_path, monkeypatch):\n+        \"\"\"launchctl list returns 0 with PID → actually running.\"\"\"\n+        plist_path = tmp_path / \"ai.hermes.gateway.plist\"\n+        plist_path.write_text(gateway_cli.generate_launchd_plist(), encoding=\"utf-8\")\n+        monkeypatch.setattr(gateway_cli, \"get_launchd_plist_path\", lambda: plist_path)\n+        monkeypatch.setattr(\n+            gateway_cli.subprocess,\n+            \"run\",\n+            lambda *args, **kwargs: SimpleNamespace(\n+                returncode=0,\n+                stdout='{\\n    \"PID\" = 55555;\\n    \"Label\" = \"ai.hermes.gateway\";\\n}',\n+                stderr=\"\",\n+            ),\n+        )\n+        assert gateway_cli._probe_launchd_service_running() is True\n+\n+    # ── Unsupport marker lifecycle ───────────────────────────────────────\n+\n+    def test_launchd_unsupported_marker_write_and_clear(self, tmp_path, monkeypatch):\n+        monkeypatch.setattr(gateway_cli, \"get_hermes_home\", lambda: tmp_path)\n+        assert not gateway_cli._launchd_unsupported_marker_exists()\n+        gateway_cli._write_launchd_unsupported_marker()\n+        assert gateway_cli._launchd_unsupported_marker_exists()\n+        gateway_cli._clear_launchd_unsupported_marker()\n+        assert not gateway_cli._launchd_unsupported_marker_exists()\n+\n+    def test_launchd_start_clears_unsupported_marker_on_bootstrap_success(self, tmp_path, monkeypatch, capsys):\n+        \"\"\"When bootstrap succeeds (OS update fixes the issue), clear the marker.\"\"\"\n+        plist_path = tmp_path / \"ai.hermes.gateway.plist\"\n+        monkeypatch.setattr(gateway_cli, \"get_launchd_plist_path\", lambda: plist_path)\n+        # Pre-seed the marker as if a previous fallback wrote it\n+        monkeypatch.setattr(gateway_cli, \"get_hermes_home\", lambda: tmp_path)\n+        # Bypass the temp-home service write guard (added on main after PR #42567)\n+        monkeypatch.setattr(gateway_cli, \"_refuse_temp_home_service_write\", lambda d, k: False)\n+        gateway_cli._write_launchd_unsupported_marker()\n+        assert gateway_cli._launchd_unsupported_marker_exists()\n+\n+        # Simulate a bootstrap that succeeds\n+        def fake_run(cmd, check=False, **kwargs):\n+            return SimpleNamespace(returncode=0, stdout=\"\", stderr=\"\")\n+        monkeypatch.setattr(gateway_cli.subprocess, \"run\", fake_run)\n+\n+        gateway_cli.launchd_install(force=True)\n+\n+        assert \"Service installed and loaded\" in capsys.readouterr().out\n+        assert not gateway_cli._launchd_unsupported_marker_exists()\n+\n+    # ── launchd_status with active supervision ───────────────────────────\n+\n+    def test_launchd_status_reports_supervised_when_pid_present(self, tmp_path, monkeypatch, capsys):\n+        \"\"\"When launchd is actively supervising, report it clearly.\"\"\"\n+        plist_path = tmp_path / \"ai.hermes.gateway.plist\"\n+        plist_path.write_text(gateway_cli.generate_launchd_plist(), encoding=\"utf-8\")\n+        monkeypatch.setattr(gateway_cli, \"get_launchd_plist_path\", lambda: plist_path)\n+\n+        def fake_run(cmd, capture_output=False, text=False, timeout=None, check=False, **kwargs):\n+            if isinstance(cmd, list) and cmd[:2] == [\"launchctl\", \"list\"]:\n+                return SimpleNamespace(\n+                    returncode=0,\n+                    stdout='{\\n    \"PID\" = 77777;\\n    \"Label\" = \"ai.hermes.gateway\";\\n}',\n+                    stderr=\"\",\n+                )\n+            return SimpleNamespace(returncode=0, stdout=\"\", stderr=\"\")\n+        monkeypatch.setattr(gateway_cli.subprocess, \"run\", fake_run)\n+        # No fallback PID — when launchd supervises, get_running_pid returns\n+        # the same PID; launchd_status deduplicates it.\n+        monkeypatch.setattr(\"gateway.status.get_running_pid\", lambda cleanup_stale=False: 77777)\n+\n+        gateway_cli.launchd_status()\n+\n+        out = capsys.readouterr().out\n+        assert \"supervised by launchd\" in out\n+        assert \"Auto-start at login\" in out\n+\n+    def test_launchd_status_reports_fallback_when_unsupported_and_pid_running(self, tmp_path, monkeypatch, capsys):\n+        \"\"\"When the unsupported marker exists and a fallback PID is running.\"\"\"\n+        plist_path = tmp_path / \"ai.hermes.gateway.plist\"\n+        plist_path.write_text(gateway_cli.generate_launchd_plist(), encoding=\"utf-8\")\n+        monkeypatch.setattr(gateway_cli, \"get_launchd_plist_path\", lambda: plist_path)\n+\n+        def fake_run(cmd, capture_output=False, text=False, timeout=None, check=False, **kwargs):\n+            if isinstance(cmd, list) and cmd[:2] == [\"launchctl\", \"list\"]:\n+                return SimpleNamespace(\n+                    returncode=0,\n+                    stdout='{\\n    \"Label\" = \"ai.hermes.gateway\";\\n    \"OnDemand\" = true;\\n}',\n+                    stderr=\"\",\n+                )\n+            return SimpleNamespace(returncode=0, stdout=\"\", stderr=\"\")\n+        monkeypatch.setattr(gateway_cli.subprocess, \"run\", fake_run)\n+        monkeypatch.setattr(\"gateway.status.get_running_pid\", lambda cleanup_stale=False: 88888)\n+        # Pre-seed the unsupported marker\n+        monkeypatch.setattr(gateway_cli, \"get_hermes_home\", lambda: tmp_path)\n+        gateway_cli._write_launchd_unsupported_marker()\n+\n+        gateway_cli.launchd_status()\n+\n+        out = capsys.readouterr().out\n+        assert \"cannot manage the gateway on this macos version\" in out.lower()\n+        assert \"Detached fallback process is running\" in out\n+        assert \"PID 88888\" in out\n+        assert \"NOT available\" in out\n+\n+    def test_launchd_status_reports_fallback_unavailable_when_unsupported_no_pid(self, tmp_path, monkeypatch, capsys):\n+        \"\"\"Unsupported marker exists but no fallback process is running.\"\"\"\n+        plist_path = tmp_path / \"ai.hermes.gateway.plist\"\n+        plist_path.write_text(gateway_cli.generate_launchd_plist(), encoding=\"utf-8\")\n+        monkeypatch.setattr(gateway_cli, \"get_launchd_plist_path\", lambda: plist_path)\n+\n+        def fake_run(cmd, capture_output=False, text=False, timeout=None, check=False, **kwargs):\n+            if isinstance(cmd, list) and cmd[:2] == [\"launchctl\", \"list\"]:\n+                return SimpleNamespace(\n+                    returncode=0,\n+                    stdout='{\\n    \"Label\" = \"ai.hermes.gateway\";\\n    \"OnDemand\" = true;\\n}',\n+                    stderr=\"\",\n+                )\n+            return SimpleNamespace(returncode=0, stdout=\"\", stderr=\"\")\n+        monkeypatch.setattr(gateway_cli.subprocess, \"run\", fake_run)\n+        monkeypatch.setattr(\"gateway.status.get_running_pid\", lambda cleanup_stale=False: None)\n+        monkeypatch.setattr(gateway_cli, \"get_hermes_home\", lambda: tmp_path)\n+        gateway_cli._write_launchd_unsupported_marker()\n+\n+        gateway_cli.launchd_status()\n+\n+        out = capsys.readouterr().out\n+        assert \"cannot manage the gateway on this macos version\" in out.lower()\n+        assert \"No fallback process is running\" in out\n+        assert \"NOT available\" in out\n \n \n class TestLaunchdDomainDetection:\n"]}]},{"kind":"pr","number":54159,"title":"fix(gateway): bootout stale launchd registration before bootstrap on restart (#42006)","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","infographic/launchd-bootout-42006/infographic.png","tests/hermes_cli/test_gateway_service.py"],"fix_hunks":[{"file":"hermes_cli/gateway.py","hunks":["@@ -4082,6 +4082,11 @@ def launchd_restart():\n         print(\"↻ launchd job was unloaded; reloading\")\n         plist_path = get_launchd_plist_path()\n         try:\n+            subprocess.run(\n+                [\"launchctl\", \"bootout\", target],\n+                check=False,\n+                timeout=90,\n+            )\n             subprocess.run(\n                 [\"launchctl\", \"bootstrap\", _launchd_domain(), str(plist_path)],\n                 check=True,"]},{"file":"tests/hermes_cli/test_gateway_service.py","hunks":["@@ -1077,6 +1077,46 @@ def fake_run(cmd, check=False, **kwargs):\n         assert spawned == [True]\n         assert gateway_cli._launchd_unsupported_marker_exists()\n \n+    def test_launchd_restart_boots_out_stale_registration_before_bootstrap(\n+        self, tmp_path, monkeypatch\n+    ):\n+        plist_path = tmp_path / \"ai.hermes.gateway.plist\"\n+        plist_path.write_text(gateway_cli.generate_launchd_plist(), encoding=\"utf-8\")\n+        label = gateway_cli.get_launchd_label()\n+        domain = gateway_cli._launchd_domain()\n+        target = f\"{domain}/{label}\"\n+\n+        monkeypatch.setattr(gateway_cli, \"get_launchd_plist_path\", lambda: plist_path)\n+        monkeypatch.setattr(gateway_cli, \"_get_restart_drain_timeout\", lambda: 5.0)\n+        monkeypatch.setattr(gateway_cli, \"_request_gateway_self_restart\", lambda pid: False)\n+        monkeypatch.setattr(\n+            gateway_cli, \"_wait_for_gateway_exit\", lambda timeout, force_after=None: True\n+        )\n+        monkeypatch.setattr(gateway_cli, \"terminate_pid\", lambda pid, force=False: None)\n+        monkeypatch.setattr(\"gateway.status.get_running_pid\", lambda: 321)\n+\n+        calls = []\n+\n+        def fake_run(cmd, check=False, **kwargs):\n+            if cmd and cmd[0] == \"launchctl\":\n+                calls.append(cmd)\n+            if cmd == [\"launchctl\", \"kickstart\", \"-k\", target]:\n+                raise gateway_cli.subprocess.CalledProcessError(\n+                    3, cmd, stderr=\"Could not find service\"\n+                )\n+            return SimpleNamespace(returncode=0, stdout=\"\", stderr=\"\")\n+\n+        monkeypatch.setattr(gateway_cli.subprocess, \"run\", fake_run)\n+\n+        gateway_cli.launchd_restart()\n+\n+        assert calls == [\n+            [\"launchctl\", \"kickstart\", \"-k\", target],\n+            [\"launchctl\", \"bootout\", target],\n+            [\"launchctl\", \"bootstrap\", domain, str(plist_path)],\n+            [\"launchctl\", \"kickstart\", target],\n+        ]\n+\n     def test_launchd_stop_tolerates_domain_unsupported_bootout(self, monkeypatch, capsys):\n         \"\"\"bootout exit 125 (macOS 26) must fall through to PID-based kill, not raise.\"\"\"\n         def fake_run(cmd, check=False, **kwargs):\n"]}]},{"kind":"pr","number":55531,"title":"fix(gateway): prefer canonical launchd label","url":null,"state":"open","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":65105,"title":"fix(gateway): preserve external supervisor ownership","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#65502","number":65502}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":65502,"title":"fix(gateway): preserve external supervisor ownership (salvage #65105)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/restart.py","gateway/slash_commands.py","hermes_cli/gateway.py","hermes_cli/main.py","hermes_cli/subcommands/gateway.py","scripts/release.py","tests/gateway/test_restart_service_detection.py","tests/hermes_cli/test_gateway_external_supervisor.py","tests/hermes_cli/test_subcommands_profile_gateway.py","website/docs/reference/cli-commands.md"]}],"trust":{"default_tier":"verified","includes_suggested":false}}