diff --git a/cli.py b/cli.py index 0683838d84b2..6e5052fc20e4 100644 --- a/cli.py +++ b/cli.py @@ -2749,10 +2749,12 @@ def _termux_example_image_path(filename: str = "cat.png") -> str: "/storage/emulated/0", "/storage/self/primary", ] + # Termux/Android roots are POSIX paths — join with literal forward + # slashes so the hint stays correct even when this renders on Windows. for root in candidates: if os.path.isdir(root): - return os.path.join(root, "Pictures", filename) - return os.path.join("~/storage/shared", "Pictures", filename) + return f"{root}/Pictures/{filename}" + return f"~/storage/shared/Pictures/{filename}" def _split_path_input(raw: str) -> tuple[str, str]: @@ -2823,6 +2825,16 @@ def _resolve_attachment_path(raw_path: str) -> Path | None: expanded = unquote(parsed.path or "") if parsed.netloc and os.name == "nt": expanded = f"//{parsed.netloc}{expanded}" + elif ( + os.name == "nt" + and len(expanded) >= 3 + and expanded[0] == "/" + and expanded[1].isalpha() + and expanded[2] == ":" + ): + # file:///C:/... parses to path "/C:/..." — drop the + # leading slash so it resolves as a drive-letter path. + expanded = expanded[1:] except Exception: expanded = token expanded = os.path.expandvars(os.path.expanduser(expanded)) diff --git a/gateway/status.py b/gateway/status.py index 9b8a1b6f83c2..23ff8c41ea54 100644 --- a/gateway/status.py +++ b/gateway/status.py @@ -337,9 +337,12 @@ def _command_line_belongs_to_profile(command: str, profile_home: Path) -> bool: explicit ``HERMES_HOME=``) on its argv; the default/root gateway runs bare with no profile flag. """ - command_lc = command.lower() + # Normalize separators before the substring match: on Windows, + # str(Path) renders backslashes while a HERMES_HOME= value on the argv + # may carry forward slashes (Git Bash, JSON configs) — and vice versa. + command_lc = command.lower().replace("\\", "/") profile_name = _profile_name_for_home(profile_home) - home_lc = str(profile_home).lower() + home_lc = str(profile_home).lower().replace("\\", "/") if profile_name is not None and profile_name != "default": profile_lc = profile_name.lower() diff --git a/hermes_cli/banner.py b/hermes_cli/banner.py index 217eb2bb9656..e2782db5b225 100644 --- a/hermes_cli/banner.py +++ b/hermes_cli/banner.py @@ -41,7 +41,14 @@ def cprint(text: str): """Print ANSI-colored text through prompt_toolkit's renderer.""" from prompt_toolkit import print_formatted_text as _pt_print from prompt_toolkit.formatted_text import ANSI as _PT_ANSI - _pt_print(_PT_ANSI(text)) + try: + _pt_print(_PT_ANSI(text)) + except Exception: + # prompt_toolkit needs a real console. On Windows, a redirected or + # absent stdout (pythonw.exe, CI, `hermes ... > file`) raises + # NoConsoleScreenBufferError from its Win32Output — display helpers + # must never crash the caller over that, so degrade to plain print. + print(text) # ========================================================================= diff --git a/hermes_cli/browser_connect.py b/hermes_cli/browser_connect.py index 7ed4f2e4da46..71fd6783bb94 100644 --- a/hermes_cli/browser_connect.py +++ b/hermes_cli/browser_connect.py @@ -4,6 +4,7 @@ import os import platform +import posixpath import shlex import shutil import subprocess @@ -90,7 +91,10 @@ def add_windows_install_paths( for _, group in install_groups: for base in filter(None, bases): for parts in group: - add(os.path.join(base, *parts)) + # Only called with WSL ``/mnt/c/...`` bases — those are + # POSIX paths regardless of the host OS, so join with + # posixpath (os.path.join would emit backslashes on nt). + add(posixpath.join(base, *parts)) if system == "Darwin": for app in _DARWIN_APPS: diff --git a/hermes_cli/gateway.py b/hermes_cli/gateway.py index 10b638d8e834..c74c9e764a5e 100644 --- a/hermes_cli/gateway.py +++ b/hermes_cli/gateway.py @@ -346,7 +346,9 @@ def _scan_gateway_pids( looks_like_gateway_runtime_command_line, ) current_home = str(get_hermes_home().resolve()) - current_home_lc = current_home.lower() + # Forward slashes on both sides of the HERMES_HOME= match — see + # gateway.status._command_line_belongs_to_profile, which this mirrors. + current_home_lc = current_home.lower().replace("\\", "/") current_profile_arg = _profile_arg(current_home) current_profile_name = ( current_profile_arg.split()[-1] if current_profile_arg else "" @@ -354,7 +356,7 @@ def _scan_gateway_pids( current_profile_name_lc = current_profile_name.lower() def _matches_current_profile(command: str) -> bool: - command_lc = command.lower() + command_lc = command.lower().replace("\\", "/") if current_profile_name: return ( f"--profile {current_profile_name_lc}" in command_lc @@ -2655,7 +2657,15 @@ def generate_systemd_unit(system: bool = False, run_as_user: str | None = None) path_entries = _build_service_path_dirs() resolved_node = shutil.which("node") if resolved_node: - resolved_node_dir = str(Path(resolved_node).resolve().parent) + # Use the directory where ``node`` is *found on PATH*, NOT the + # symlink's resolved target. ``~/.local/bin/node`` is often a symlink + # into a specific profile's node install (e.g. profiles/jarvis/node/ + # bin/node); calling .resolve() here would chase that symlink and bake + # one profile's node path into *every* profile's service unit. That + # cross-profile leak makes systemd_unit_is_current() perpetually false, + # so each gateway rewrites its unit + daemon-reload on every boot. Using + # the symlink's own parent keeps the generated unit profile-agnostic. + resolved_node_dir = str(Path(resolved_node).parent) if resolved_node_dir not in path_entries: path_entries.append(resolved_node_dir) @@ -3588,6 +3598,86 @@ def _launchctl_bootstrap( ) +def _launchd_reload_log_path() -> Path: + """Path the launchd reload watchdog tails for persistent-orphan detection.""" + return get_hermes_home() / "logs" / "launchd-reload.log" + + +def _append_launchd_reload_log(message: str) -> None: + """Append a timestamped line to the launchd reload log (best-effort).""" + path = _launchd_reload_log_path() + try: + path.parent.mkdir(parents=True, exist_ok=True) + from datetime import datetime as _dt + + stamp = _dt.now().astimezone().strftime("%Y-%m-%d %H:%M:%S %z") + with path.open("a", encoding="utf-8") as fh: + fh.write(f"[{stamp}] {message}\n") + except OSError: + pass + + +def _launchctl_label_registered(label: str) -> bool: + """True when ``launchctl list