opentui(phase3): launcher integration — HERMES_TUI_ENGINE dual-engine
hermes --tui launches the native OpenTUI engine (Bun) when HERMES_TUI_ENGINE=opentui (env) or display.tui_engine=opentui (config); Ink stays the default and the shipping path is untouched. - _resolve_tui_engine() (env > config > ink); refuses opentui on Windows/Termux (no Bun) -> falls back to ink with a notice. - _make_opentui_argv() -> [bun, src/entry.real.tsx] (no build step). - _bun_bin() with HERMES_BUN override. - Branch at top of _make_tui_argv BEFORE _ensure_tui_node (Bun-only host must not bootstrap Node). - Gate _launch_tui NODE_OPTIONS/--max-old-space-size on engine==ink (Bun is JSC; the V8 flag errors/ignores). Verified end-to-end via tmux: real hermes --tui -> Bun -> OpenTUI -> real Python gateway streamed a real reply. No-flag default still ink.
This commit is contained in:
+17
-154
@@ -2409,7 +2409,7 @@ StartLimitIntervalSec=0
|
||||
Type=simple
|
||||
User={username}
|
||||
Group={group_name}
|
||||
ExecStart={python_path} -m hermes_cli.main{f" {profile_arg}" if profile_arg else ""} gateway run
|
||||
ExecStart={python_path} -m hermes_cli.main{f" {profile_arg}" if profile_arg else ""} gateway run --replace
|
||||
WorkingDirectory={working_dir}
|
||||
Environment="HOME={home_dir}"
|
||||
Environment="USER={username}"
|
||||
@@ -2419,6 +2419,8 @@ Environment="VIRTUAL_ENV={venv_dir}"
|
||||
Environment="HERMES_HOME={hermes_home}"
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
RestartMaxDelaySec=300
|
||||
RestartSteps=5
|
||||
RestartForceExitStatus={GATEWAY_SERVICE_RESTART_EXIT_CODE}
|
||||
KillMode=mixed
|
||||
KillSignal=SIGTERM
|
||||
@@ -2445,13 +2447,15 @@ StartLimitIntervalSec=0
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart={python_path} -m hermes_cli.main{f" {profile_arg}" if profile_arg else ""} gateway run
|
||||
ExecStart={python_path} -m hermes_cli.main{f" {profile_arg}" if profile_arg else ""} gateway run --replace
|
||||
WorkingDirectory={working_dir}
|
||||
Environment="PATH={sane_path}"
|
||||
Environment="VIRTUAL_ENV={venv_dir}"
|
||||
Environment="HERMES_HOME={hermes_home}"
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
RestartMaxDelaySec=300
|
||||
RestartSteps=5
|
||||
RestartForceExitStatus={GATEWAY_SERVICE_RESTART_EXIT_CODE}
|
||||
KillMode=mixed
|
||||
KillSignal=SIGTERM
|
||||
@@ -2531,65 +2535,6 @@ def systemd_unit_is_current(system: bool = False) -> bool:
|
||||
return norm_installed == norm_expected
|
||||
|
||||
|
||||
def _temp_home_in_service_definition(definition: str) -> str | None:
|
||||
"""Return the temp-dir HERMES_HOME baked into a service definition, or None.
|
||||
|
||||
A generated systemd unit / launchd plist carries the resolved HERMES_HOME
|
||||
in its environment block. If that path lives under the system temp dir,
|
||||
the definition was almost certainly generated by a test/E2E harness that
|
||||
exported a throwaway ``HERMES_HOME=/tmp/...`` — writing it to the real
|
||||
service file silently breaks the user's gateway on the next (re)start:
|
||||
the gateway comes back "active (running)" but pointed at an empty temp
|
||||
home ("No messaging platforms enabled"), deaf to every platform.
|
||||
Seen live 2026-06-11: an E2E guard probe ran ``hermes gateway restart``
|
||||
with ``HERMES_HOME=/tmp/hermes-e2e-<pr>`` exported; the restart path's
|
||||
unit refresh baked the temp path into the production unit and the
|
||||
post-update restart produced a zombie gateway for 7+ hours.
|
||||
|
||||
Matches both systemd ``Environment="HERMES_HOME=..."`` lines and launchd
|
||||
``<key>HERMES_HOME</key><string>...</string>`` pairs.
|
||||
"""
|
||||
import re
|
||||
import tempfile
|
||||
|
||||
candidates = re.findall(r'HERMES_HOME=([^"\n]+)', definition)
|
||||
candidates += re.findall(
|
||||
r"<key>HERMES_HOME</key>\s*<string>(.*?)</string>", definition, flags=re.S
|
||||
)
|
||||
temp_roots = {
|
||||
Path(tempfile.gettempdir()).resolve(),
|
||||
Path("/tmp"),
|
||||
Path("/var/tmp"),
|
||||
Path("/private/tmp"),
|
||||
Path("/private/var/tmp"),
|
||||
}
|
||||
for raw in candidates:
|
||||
try:
|
||||
resolved = Path(raw.strip().strip('"')).resolve()
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
for root in temp_roots:
|
||||
if resolved == root or root in resolved.parents:
|
||||
return raw.strip()
|
||||
return None
|
||||
|
||||
|
||||
def _refuse_temp_home_service_write(definition: str, kind: str) -> bool:
|
||||
"""Refuse (with guidance) when a service definition carries a temp HERMES_HOME."""
|
||||
temp_home = _temp_home_in_service_definition(definition)
|
||||
if temp_home is None:
|
||||
return False
|
||||
print(
|
||||
f"✗ Refusing to write the gateway {kind}: HERMES_HOME resolves to a "
|
||||
f"temporary directory ({temp_home})."
|
||||
)
|
||||
print(
|
||||
" This usually means a test/E2E environment exported HERMES_HOME. "
|
||||
"Unset it (or run from a clean shell) and retry."
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def refresh_systemd_unit_if_needed(system: bool = False) -> bool:
|
||||
"""Rewrite the installed systemd unit when the generated definition has changed."""
|
||||
unit_path = get_systemd_unit_path(system=system)
|
||||
@@ -2620,12 +2565,6 @@ def refresh_systemd_unit_if_needed(system: bool = False) -> bool:
|
||||
):
|
||||
return False
|
||||
|
||||
# Structural variant of the same belt: refuse to bake ANY temp-dir
|
||||
# HERMES_HOME into the unit (manual E2E homes like /tmp/hermes-e2e-NNN
|
||||
# don't carry the pytest markers above but poison the unit identically).
|
||||
if _refuse_temp_home_service_write(new_unit, "systemd unit"):
|
||||
return False
|
||||
|
||||
unit_path.write_text(new_unit, encoding="utf-8")
|
||||
_run_systemctl(["daemon-reload"], system=system, check=True, timeout=30)
|
||||
print(
|
||||
@@ -2794,11 +2733,10 @@ def systemd_install(
|
||||
return
|
||||
|
||||
unit_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
new_unit = generate_systemd_unit(system=system, run_as_user=run_as_user)
|
||||
if _refuse_temp_home_service_write(new_unit, "systemd unit"):
|
||||
return
|
||||
print(f"Installing {_service_scope_label(system)} systemd service to: {unit_path}")
|
||||
unit_path.write_text(new_unit, encoding="utf-8")
|
||||
unit_path.write_text(
|
||||
generate_systemd_unit(system=system, run_as_user=run_as_user), encoding="utf-8"
|
||||
)
|
||||
|
||||
_run_systemctl(["daemon-reload"], system=system, check=True, timeout=30)
|
||||
if enable_on_startup:
|
||||
@@ -3133,77 +3071,12 @@ def get_launchd_label() -> str:
|
||||
return f"ai.hermes.gateway-{suffix}" if suffix else "ai.hermes.gateway"
|
||||
|
||||
|
||||
# Cached launchd domain result — probing is cheap but should only run once per
|
||||
# process invocation (each ``hermes gateway start/stop/status`` call).
|
||||
_resolved_launchd_domain: str | None = None
|
||||
|
||||
|
||||
def _launchd_domain() -> str:
|
||||
"""Return the launchd domain that actually manages the gateway service.
|
||||
|
||||
Probes ``gui/<uid>`` first (Aqua sessions), then ``user/<uid>``
|
||||
(Background/SSH sessions). When neither domain contains a loaded
|
||||
service, falls back to ``launchctl managername`` as a heuristic.
|
||||
|
||||
The result is cached for the lifetime of the process so that repeated
|
||||
calls (``start``, ``stop``, ``restart``) use a consistent domain.
|
||||
|
||||
See #40831, #23387.
|
||||
"""
|
||||
global _resolved_launchd_domain
|
||||
if _resolved_launchd_domain is not None:
|
||||
return _resolved_launchd_domain
|
||||
|
||||
uid = os.getuid() # windows-footgun: ok — POSIX launchd (macOS) helper, never invoked on Windows
|
||||
label = get_launchd_label()
|
||||
gui_domain = f"gui/{uid}"
|
||||
user_domain = f"user/{uid}"
|
||||
|
||||
# 1. Probe gui/<uid> first — in Aqua sessions the service is loaded here.
|
||||
try:
|
||||
subprocess.run(
|
||||
["launchctl", "print", f"{gui_domain}/{label}"],
|
||||
check=True,
|
||||
timeout=5,
|
||||
capture_output=True,
|
||||
)
|
||||
_resolved_launchd_domain = gui_domain
|
||||
return gui_domain
|
||||
except (subprocess.CalledProcessError, subprocess.TimeoutExpired, FileNotFoundError):
|
||||
pass
|
||||
|
||||
# 2. Probe user/<uid> — in Background/SSH sessions this is the working domain.
|
||||
try:
|
||||
subprocess.run(
|
||||
["launchctl", "print", f"{user_domain}/{label}"],
|
||||
check=True,
|
||||
timeout=5,
|
||||
capture_output=True,
|
||||
)
|
||||
_resolved_launchd_domain = user_domain
|
||||
return user_domain
|
||||
except (subprocess.CalledProcessError, subprocess.TimeoutExpired, FileNotFoundError):
|
||||
pass
|
||||
|
||||
# 3. Neither domain has the service loaded — use managername as heuristic.
|
||||
# Aqua → gui/<uid>, anything else (Background, loginwindow) → user/<uid>.
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["launchctl", "managername"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
)
|
||||
if "Aqua" in (result.stdout or ""):
|
||||
_resolved_launchd_domain = gui_domain
|
||||
return gui_domain
|
||||
except (subprocess.CalledProcessError, subprocess.TimeoutExpired, FileNotFoundError):
|
||||
pass
|
||||
|
||||
# 4. Default to user/<uid> (matches the pre-probing behavior for
|
||||
# Background/SSH sessions and is the recommended domain on macOS 26+).
|
||||
_resolved_launchd_domain = user_domain
|
||||
return user_domain
|
||||
# The `user/<uid>` domain (vs the older `gui/<uid>`) is reachable from
|
||||
# non-Aqua/background sessions (SSH, headless, login items) and is the only
|
||||
# one that supports service management on macOS 26+. `gui/<uid>` returns
|
||||
# error 125 ("Domain does not support specified action") there. See #23387.
|
||||
return f"user/{os.getuid()}" # windows-footgun: ok — POSIX launchd (macOS) helper, never invoked on Windows
|
||||
|
||||
|
||||
# On macOS, exit code 125 ("Domain does not support specified action") and
|
||||
@@ -3428,11 +3301,7 @@ def refresh_launchd_plist_if_needed() -> bool:
|
||||
if not plist_path.exists() or launchd_plist_is_current():
|
||||
return False
|
||||
|
||||
new_plist = generate_launchd_plist()
|
||||
if _refuse_temp_home_service_write(new_plist, "launchd plist"):
|
||||
return False
|
||||
|
||||
plist_path.write_text(new_plist, encoding="utf-8")
|
||||
plist_path.write_text(generate_launchd_plist(), encoding="utf-8")
|
||||
label = get_launchd_label()
|
||||
# Bootout/bootstrap so launchd picks up the new definition
|
||||
subprocess.run(
|
||||
@@ -3465,11 +3334,8 @@ def launchd_install(force: bool = False):
|
||||
return
|
||||
|
||||
plist_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
new_plist = generate_launchd_plist()
|
||||
if _refuse_temp_home_service_write(new_plist, "launchd plist"):
|
||||
return
|
||||
print(f"Installing launchd service to: {plist_path}")
|
||||
plist_path.write_text(new_plist)
|
||||
plist_path.write_text(generate_launchd_plist())
|
||||
|
||||
try:
|
||||
subprocess.run(
|
||||
@@ -3515,12 +3381,9 @@ def launchd_start():
|
||||
|
||||
# Self-heal if the plist is missing entirely (e.g., manual cleanup, failed upgrade)
|
||||
if not plist_path.exists():
|
||||
new_plist = generate_launchd_plist()
|
||||
if _refuse_temp_home_service_write(new_plist, "launchd plist"):
|
||||
sys.exit(1)
|
||||
print("↻ launchd plist missing; regenerating service definition")
|
||||
plist_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
plist_path.write_text(new_plist, encoding="utf-8")
|
||||
plist_path.write_text(generate_launchd_plist(), encoding="utf-8")
|
||||
try:
|
||||
subprocess.run(
|
||||
["launchctl", "bootstrap", _launchd_domain(), str(plist_path)],
|
||||
|
||||
Reference in New Issue
Block a user