diff --git a/hermes_cli/gateway.py b/hermes_cli/gateway.py index c2b9fa3314..dd8fdc5c78 100644 --- a/hermes_cli/gateway.py +++ b/hermes_cli/gateway.py @@ -196,7 +196,6 @@ def _get_parent_pid(pid: int) -> int | None: return None try: import psutil # type: ignore - return psutil.Process(pid).ppid() or None except ImportError: pass @@ -275,7 +274,6 @@ def _wait_for_pid_exit(pid: int, timeout: float) -> bool: return True # ``os.kill(pid, 0)`` hard-kills on Windows (TerminateProcess); use _pid_exists instead. from gateway.status import _pid_exists - deadline = time.monotonic() + max(timeout, 0.0) while True: if not _pid_exists(pid): @@ -313,7 +311,6 @@ def _probe_loop_tick_socket(pid: int, home: Path | None, timeout: float = 1.0) - """Ping the loop-tick witness socket: True answered, False node present but silent, None no node (not evidence).""" try: from gateway.shutdown_watchdog import get_loop_tick_socket_path - path = get_loop_tick_socket_path(home, pid) if not path.is_socket(): return None @@ -392,7 +389,6 @@ def probe_gateway_loop_liveness( stale_budget = DEFAULT_LOOP_LIVENESS_STALE_AFTER_S try: from gateway.shutdown_watchdog import get_loop_heartbeat_path - path = get_loop_heartbeat_path(home) mtime = path.stat().st_mtime payload = json.loads(path.read_text(encoding="utf-8")) @@ -453,7 +449,6 @@ def _escalate_wedged_gateway(pid: int, *, term_grace: float = 5.0, kill_wait: fl busy gateway bypasses the cron drain floor and SIGKILLs live work. True once the PID is gone. """ from gateway.status import get_process_start_time - expected_start_time = get_process_start_time(pid) try: terminate_pid(pid, force=False) @@ -617,7 +612,6 @@ def _windows_process_listing() -> str | None: hides the console window this windowless pythonw backend would otherwise flash. """ from hermes_cli._subprocess_compat import bounded_probe_run - wmic_path = shutil.which("wmic") result = None if wmic_path is not None: @@ -668,7 +662,6 @@ def find_gateway_pids(exclude_pids: set | None = None, all_profiles: bool = Fals if not all_profiles: try: from gateway.status import get_running_pid - _append_unique_pid(pids, get_running_pid(), _exclude) except Exception: pass @@ -847,7 +840,6 @@ def _capture_gateway_argv(pid: int) -> list[str] | None: # Never respawn an unrelated process the scan happened to report. try: from gateway.status import looks_like_gateway_command_line - if not looks_like_gateway_command_line(" ".join(argv)): return None except Exception: @@ -900,7 +892,6 @@ def _spawn_gateway_restart_watcher(old_pid: int, run_argv: list[str]) -> bool: if sys.platform == "win32": try: from hermes_cli.gateway_windows import windowless_gateway_restart_spec - run_argv, respawn_cwd, respawn_env_overlay = windowless_gateway_restart_spec(list(run_argv)) except Exception: # Fall back to the original argv: a visible window beats a failed respawn. @@ -915,9 +906,7 @@ def _spawn_gateway_restart_watcher(old_pid: int, run_argv: list[str]) -> bool: import sys import time from hermes_cli._subprocess_compat import ( - _WINDOWS_GATEWAY_BREAKAWAY_ENV, - windows_detach_flags, - windows_detach_flags_without_breakaway, + _WINDOWS_GATEWAY_BREAKAWAY_ENV, windows_detach_flags, windows_detach_flags_without_breakaway, ) pid = int(sys.argv[1]) @@ -951,10 +940,7 @@ def _spawn_gateway_restart_watcher(old_pid: int, run_argv: list[str]) -> bool: # Windows needs explicit creationflags. CREATE_BREAKAWAY_FROM_JOB is critical: the watcher may # itself sit inside a job object (Electron/Tauri parent) and without breakaway the respawned # gateway dies when that job tears down. See _subprocess_compat.windows_detach_flags(). - _popen_kwargs = {{ - "stdout": _stdio_target, - "stderr": _stdio_target, - }} + _popen_kwargs = {{"stdout": _stdio_target, "stderr": _stdio_target}} # Anchor at the stable working dir and overlay the env (VIRTUAL_ENV / PYTHONPATH / # HERMES_HOME) the windowless base interpreter needs to import hermes_cli. Empty on POSIX. if _respawn_cwd: @@ -967,19 +953,13 @@ def _spawn_gateway_restart_watcher(old_pid: int, run_argv: list[str]) -> bool: # Stamp the breakaway state exactly like gateway_windows._spawn_detached so the # respawned gateway's exit-diag / lifecycle records show whether it escaped the # parent Job Object (a job-teardown kill is otherwise indistinguishable). - _popen_kwargs["env"] = {{ - **_base_env, _WINDOWS_GATEWAY_BREAKAWAY_ENV: "1", - }} + _popen_kwargs["env"] = {{**_base_env, _WINDOWS_GATEWAY_BREAKAWAY_ENV: "1"}} subprocess.Popen(cmd, **_popen_kwargs) except OSError: # CREATE_BREAKAWAY_FROM_JOB is rejected with ERROR_ACCESS_DENIED when the parent's # job object refuses breakaway; retry without it (mirrors _spawn_detached). - _popen_kwargs["creationflags"] = ( - windows_detach_flags_without_breakaway() - ) - _popen_kwargs["env"] = {{ - **_base_env, _WINDOWS_GATEWAY_BREAKAWAY_ENV: "0", - }} + _popen_kwargs["creationflags"] = windows_detach_flags_without_breakaway() + _popen_kwargs["env"] = {{**_base_env, _WINDOWS_GATEWAY_BREAKAWAY_ENV: "0"}} subprocess.Popen(cmd, **_popen_kwargs) else: if _respawn_env_overlay: @@ -1004,8 +984,7 @@ def _spawn_gateway_restart_watcher(old_pid: int, run_argv: list[str]) -> bool: # Parent job object rejected CREATE_BREAKAWAY_FROM_JOB; retry without it (Windows only — # ``start_new_session=True`` cannot raise OSError on POSIX). fallback_kwargs: dict = ( - {"creationflags": windows_detach_flags_without_breakaway()} - if sys.platform == "win32" + {"creationflags": windows_detach_flags_without_breakaway()} if sys.platform == "win32" else {"start_new_session": True} ) try: @@ -2503,7 +2482,6 @@ def launchd_gateway_labels_for_install() -> list[str]: names can't map to a service suffix are skipped; uninstalled profiles are harmless to include. """ import re as _re - from hermes_cli.profiles import list_profiles root_label: list[str] = [] profile_labels: list[str] = [] @@ -4172,7 +4150,6 @@ def named_profile_served_by_running_multiplexer(profile_name: str | None = None) try: from gateway.status import _pid_exists, _pid_from_record, _read_pid_record - rec = _read_pid_record(default_root / "gateway.pid") if not rec: return False @@ -4181,12 +4158,10 @@ def named_profile_served_by_running_multiplexer(profile_name: str | None = None) return False from gateway.config import _env_multiplex_profiles_override - cfg_path = default_root / "config.yaml" cfg = {} if cfg_path.exists(): from hermes_cli.config import read_user_config_raw - cfg = read_user_config_raw(cfg_path) env_multiplex = _env_multiplex_profiles_override() @@ -4205,7 +4180,6 @@ def named_profile_served_by_running_multiplexer(profile_name: str | None = None) raw_allowlist = gateway_cfg.get("multiplex_profile_allowlist") from gateway.config import _normalize_multiplex_profile_allowlist from hermes_cli.profiles import normalize_profile_name - profile_allowlist = _normalize_multiplex_profile_allowlist(raw_allowlist) return profile_allowlist is None or normalize_profile_name(suffix) in profile_allowlist except Exception: @@ -4295,7 +4269,6 @@ def _guard_existing_gateway_process_conflict(replace: bool = False) -> None: return try: from gateway.status import get_running_pid - pid = get_running_pid() except Exception: logger.debug("Existing-gateway process probe failed", exc_info=True) @@ -4305,7 +4278,6 @@ def _guard_existing_gateway_process_conflict(replace: bool = False) -> None: # belongs to another profile (user switched profiles while the old gateway still runs). try: from gateway.status import _read_pid_record, _pid_record_belongs_to_current_profile - stale = _read_pid_record() if stale is not None and not _pid_record_belongs_to_current_profile(stale): logger.warning( @@ -4392,7 +4364,6 @@ def _absorb_windows_console_controls() -> None: # control events (CTRL_CLOSE/CTRL_LOGOFF included), as background services should. try: import ctypes - ctypes.windll.kernel32.SetConsoleCtrlHandler(None, 1) # type: ignore[attr-defined] except (OSError, AttributeError): pass @@ -4411,7 +4382,6 @@ def _make_exit_diag(): return try: from hermes_constants import get_hermes_home as _ghh - log_dir = _ghh() / "logs" log_dir.mkdir(parents=True, exist_ok=True) line = { @@ -4434,12 +4404,10 @@ def _respawn_storm_backoff() -> None: """ try: from gateway.status import record_start_and_check_storm - _max_starts = 5 _win = 120.0 try: from hermes_cli.config import load_config - _cfg = load_config() _gw = _cfg.get("gateway") if isinstance(_cfg, dict) else None _rs = _gw.get("respawn_storm") if isinstance(_gw, dict) else None @@ -4467,7 +4435,6 @@ def _respawn_storm_backoff() -> None: # Tell the startup watchdog the backoff sleep is intentional, not a parked deadlock. try: from gateway.startup_watchdog import kick_startup_watchdog - kick_startup_watchdog(extra_s=_storm.backoff_s) except Exception: pass @@ -4505,7 +4472,6 @@ def run_gateway(verbose: int = 0, quiet: bool = False, replace: bool = False, fo pass # best-effort; don't block gateway startup from gateway.run import start_gateway - print("┌─────────────────────────────────────────────────────────┐") print("│ ⚕ Hermes Gateway Starting... │") print("├─────────────────────────────────────────────────────────┤") @@ -4519,7 +4485,6 @@ def run_gateway(verbose: int = 0, quiet: bool = False, replace: bool = False, fo import atexit as _atexit import traceback as _traceback - _exit_diag = _make_exit_diag() _exit_diag( "gateway.start", replace=replace, argv=sys.argv, stdin_is_tty=stdin_is_tty, @@ -4534,7 +4499,6 @@ def run_gateway(verbose: int = 0, quiet: bool = False, replace: bool = False, fo # Mirror gateway.run.main()'s wedge-proof exit: bypass Python finalization so non-daemon # threads (in-flight cron jobs) can't delay a /restart by minutes. from gateway.run import _exit_after_graceful_shutdown - _exit_after_graceful_shutdown(code) success = False @@ -4675,7 +4639,6 @@ def _all_platforms() -> list[dict]: hidden on Windows: python-olm has no wheel or native build (use WSL).""" try: from hermes_cli.plugins import discover_plugins - discover_plugins() except Exception as e: logger.debug("plugin discovery failed during platform enumeration: %s", e) @@ -4709,7 +4672,6 @@ def _platform_status(platform: dict) -> str: try: if entry.is_connected is not None: from gateway.config import PlatformConfig - configured = bool(entry.is_connected(PlatformConfig(enabled=True))) else: configured = bool(entry.check_fn()) @@ -4765,7 +4727,6 @@ def _runtime_health_lines() -> list[str]: elif gateway_state == "draining": action = "restart" if state.get("restart_requested") else "shutdown" from gateway.status import parse_active_agents - count = parse_active_agents(state.get("active_agents")) lines.append(f"⏳ Gateway draining for {action} ({count} active agent(s))") elif gateway_state == "stopped" and exit_reason: @@ -4925,7 +4886,6 @@ def _prompt_allowlist_var(var: dict, platform_key: str, auto_owner_user_id) -> s def _setup_standard_platform(platform: dict): """Interactive setup for Telegram, Discord, or Slack.""" from hermes_cli.setup_hidden_env import is_setup_hidden_env as _is_setup_hidden_env - emoji, label, token_var = platform["emoji"], platform["label"], platform["token_var"] _print_setup_header(f"{emoji} {label}") @@ -4993,7 +4953,6 @@ def _setup_standard_platform(platform: dict): def _running_under_s6() -> bool: from hermes_cli.service_manager import detect_service_manager - return detect_service_manager() == "s6" @@ -5004,7 +4963,6 @@ def _systemd_unit_installed() -> bool: def _is_service_installed() -> bool: - """Check if the gateway is installed as a system service.""" return _installed_service_kind() is not None @@ -5068,8 +5026,6 @@ def _setup_weixin(): print_info(" Cancelled.") return - import asyncio - try: credentials = asyncio.run(qr_login(str(get_hermes_home()))) except KeyboardInterrupt: @@ -5086,20 +5042,18 @@ def _setup_weixin(): account_id = credentials.get("account_id", "") user_id = credentials.get("user_id", "") - base_url = credentials.get("base_url", "") - save_env_value("WEIXIN_ACCOUNT_ID", account_id) save_env_value("WEIXIN_TOKEN", credentials.get("token", "")) - if base_url: - save_env_value("WEIXIN_BASE_URL", base_url) + if credentials.get("base_url", ""): + save_env_value("WEIXIN_BASE_URL", credentials.get("base_url", "")) save_env_value( "WEIXIN_CDN_BASE_URL", get_env_value("WEIXIN_CDN_BASE_URL") or "https://novac2c.cdn.weixin.qq.com/c2c" ) print() access_choices = [ - "Use DM pairing approval (recommended)", "Allow all direct messages", - "Only allow listed user IDs", "Disable direct messages", + "Use DM pairing approval (recommended)", "Allow all direct messages", "Only allow listed user IDs", + "Disable direct messages", ] access_idx = prompt_choice(" How should direct messages be authorized?", access_choices, 0) if access_idx == 2: @@ -5153,15 +5107,11 @@ def _setup_qqbot(): return print() - method_choices = [ - "Scan QR code to add bot automatically (recommended)", - "Enter existing App ID and App Secret manually", - ] + method_choices = ["Scan QR code to add bot automatically (recommended)", "Enter existing App ID and App Secret manually"] credentials = None if prompt_choice(" How would you like to set up QQ Bot?", method_choices, 0) == 0: try: from gateway.platforms.qqbot import qr_register - credentials = qr_register() except KeyboardInterrupt: print() @@ -5193,10 +5143,7 @@ def _setup_qqbot(): user_openid = credentials.get("user_openid", "") print() - access_choices = [ - "Use DM pairing approval (recommended)", "Allow all direct messages", - "Only allow listed user OpenIDs", - ] + access_choices = ["Use DM pairing approval (recommended)", "Allow all direct messages", "Only allow listed user OpenIDs"] access_idx = prompt_choice(" How should direct messages be authorized?", access_choices, 0) if access_idx == 0: save_env_value("QQ_ALLOW_ALL_USERS", "false") @@ -5278,7 +5225,6 @@ def _setup_signal(): print_info(" Testing connection...") try: import httpx - resp = httpx.get(f"{url.rstrip('/')}/api/v1/check", timeout=10.0) if resp.status_code == 200: print_success(" signal-cli daemon is reachable!") @@ -5338,7 +5284,6 @@ def _setup_signal(): def _builtin_setup_fn(key: str): """Resolve a built-in platform's setup function; late-bound to dodge the hermes_cli.setup cycle.""" from hermes_cli import setup as _s - return { # telegram/discord/slack/whatsapp/dingtalk/feishu/wecom setup_fns come from their plugins. "bluebubbles": _s._setup_bluebubbles, @@ -5551,7 +5496,6 @@ def _wizard_post_setup() -> None: reason, home = "wsl", "" elif is_termux(): from hermes_constants import display_hermes_home as _dhh - reason, home = "termux", _dhh() else: reason, home = "unsupported", "" @@ -5617,7 +5561,6 @@ def _dispatch_all_via_service_manager_if_s6(action: str) -> bool: A bare pkill is seen by s6-supervise as a crash and restarted ~1s later; the service manager flips ``want up``/``want down`` correctly. ``start --all`` is not a CLI surface.""" from hermes_cli.service_manager import (detect_service_manager, get_service_manager) - if detect_service_manager() != "s6" or action not in ("stop", "restart"): return False mgr = get_service_manager() @@ -5705,7 +5648,6 @@ def _block_until_terminated() -> None: pause() else: # pragma: no cover - non-Unix fallback, not exercised in the s6 image import threading - threading.Event().wait() @@ -5730,18 +5672,16 @@ def _stop_installed_service(system: bool) -> bool: if kind is None: return False # SystemScopeRequiresRootError is a RuntimeError and must propagate from systemd_stop. - swallow = (subprocess.CalledProcessError, RuntimeError) if kind == "windows" else subprocess.CalledProcessError try: _service_call(kind, "stop", system) return True - except swallow: + except (subprocess.CalledProcessError, *((RuntimeError,) if kind == "windows" else ())): return False def _refuse_from_inside_gateway(verb: str, reason: str) -> None: """Refuse self-targeting stop/restart/uninstall from inside the gateway process (#92560).""" from tools.process_registry import _is_supervised_gateway_process - if _is_supervised_gateway_process(): print_error( f"Refusing to {verb} the gateway from inside the gateway process.\n" @@ -6013,7 +5953,6 @@ def _cmd_restart(args): linger_ok, _detail = get_systemd_linger_status() if linger_ok is not True: import getpass - _print_lines( "", "⚠ Cannot restart gateway as a service — linger is not enabled.", " The gateway user service requires linger to function on headless servers.", "",