"""Gateway slash-command handlers for GatewayRunner. The in-session slash commands dispatched from ``_handle_message``, lifted out of ``gateway/run.py`` into a mixin so every ``self._handle_*_command`` reference keeps working via the MRO. Cohesive clusters live in sibling mixins this class inherits: ``slash_commands_model`` (/model, /reasoning, /fast, ...), ``slash_commands_session`` (/new, /resume, /branch, /compress, ...), ``slash_commands_status`` (/status, /context, /usage, ...) and ``slash_commands_goals`` (/goal, /loop, /heartbeat, ...). This module keeps the shared helpers plus the remaining one-off commands. run.py helpers (``_hermes_home``, ``_load_gateway_config``, ...) are imported lazily inside handler bodies — a deferred ``from gateway.run import ...`` avoids the import cycle. """ from __future__ import annotations import asyncio import dataclasses import inspect import logging import os import re import shlex import sys import time from datetime import datetime from pathlib import Path from typing import Optional, Union from agent.i18n import t from gateway.config import HomeChannel, Platform, PlatformConfig, persist_home_channel from gateway.platforms.base import EphemeralReply, MessageEvent from gateway.session import AsyncSessionStore from gateway.slash_commands_goals import GatewayGoalCommandsMixin from gateway.slash_commands_model import ( # noqa: F401 — _model_switch_skew_guard re-exported for tests GatewayModelCommandsMixin, _model_switch_skew_guard, ) from gateway.slash_commands_session import GatewaySessionCommandsMixin from gateway.slash_commands_status import GatewayStatusCommandsMixin from hermes_cli.config import atomic_config_write, cfg_get from utils import atomic_json_write, is_truthy_value logger = logging.getLogger("gateway.run") # /rollback result keys -> i18n line for files the safe restore left alone. _ROLLBACK_SKIP_LINES = ( ("skipped_user_edits", "gateway.rollback.kept_user_edits"), ("skipped_oversize", "gateway.rollback.kept_oversize"), ("failed_deletes", "gateway.rollback.failed_deletes"), ) # /busy input modes -> (status-card behavior, set-confirmation behavior). _BUSY_MODE_BEHAVIOR = { "queue": ("queues for next turn", "Messages will be queued for the next turn while Hermes is busy."), "steer": ( "steers into current run (after next tool call)", "Messages will be steered into the current run (after the next tool call).", ), "interrupt": ("interrupts current run", "Messages will interrupt the current run while Hermes is busy."), } # /diff argument -> diff mode (unknown args leave the mode unchanged). _DIFF_MODE_BY_ARG = { "staged": "staged", "--staged": "staged", "cached": "staged", "--cached": "staged", "all": "all", "--all": "all", "head": "all", "session": "session", } # /voice subcommand -> stored mode (None = auto-TTS disabled), confirmation i18n key. _VOICE_MODE_BY_ARG = { "on": ("voice_only", "gateway.voice.enabled_voice_only"), "enable": ("voice_only", "gateway.voice.enabled_voice_only"), "off": ("off", "gateway.voice.disabled_text"), "disable": ("off", "gateway.voice.disabled_text"), "tts": ("all", "gateway.voice.tts_enabled"), } def _nested_dict(root: dict, *keys: str) -> dict: """Walk/create ``root[k1][k2]...`` as dicts, replacing any non-dict value on the path.""" current = root for k in keys: if not isinstance(current.get(k), dict): current[k] = {} current = current[k] return current def _restart_notify_payload(event: MessageEvent) -> dict: """Requester routing info so the new gateway process can notify them once back online.""" source = event.source notify_data = { "platform": source.platform.value if source.platform else None, "chat_id": source.chat_id, "chat_type": source.chat_type, } if source.delivered_via_upstream_relay is True: notify_data["delivered_via_upstream_relay"] = True if source.user_id: notify_data["user_id"] = source.user_id if source.scope_id: notify_data["scope_id"] = source.scope_id if source.thread_id: notify_data["thread_id"] = source.thread_id if event.message_id: notify_data["message_id"] = event.message_id return notify_data _WINDOWS_UPDATE_HELPER = """ import os, subprocess, sys output_path = sys.argv[1] exit_code_path = sys.argv[2] cmd = sys.argv[3:] env = dict(os.environ) env["PYTHONUNBUFFERED"] = "1" with open(output_path, "wb") as f: proc = subprocess.Popen(cmd, stdout=f, stderr=subprocess.STDOUT, env=env) rc = proc.wait(timeout=3600) with open(exit_code_path, "w", encoding="utf-8") as f: f.write(str(rc)) """.strip() def _spawn_detached_update(hermes_cmd, output_path, exit_code_path) -> None: """Spawn ``hermes update --gateway`` detached so it survives the gateway restart it may trigger. setsid is portable (works where ``systemd-run --user`` lacks a D-Bus session); ``--gateway`` enables file-based IPC for interactive prompts so the gateway forwards them instead of skipping; PYTHONUNBUFFERED lets the gateway stream output in near-real-time. Windows has no setsid chain: an inline Python helper runs the command, redirects both outputs to the same file and writes the exit code. It invokes the updater as a module under this interpreter rather than through hermes_cmd (venv\Scripts\hermes.exe): the shim launcher holds its own file open for the whole run, and the update has to replace it. """ import shutil import subprocess if sys.platform == "win32": from hermes_cli._subprocess_compat import windows_detach_popen_kwargs subprocess.Popen( [ sys.executable, "-c", _WINDOWS_UPDATE_HELPER, str(output_path), str(exit_code_path), sys.executable, "-m", "hermes_cli.main", "update", "--gateway", ], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, **windows_detach_popen_kwargs(), ) return hermes_cmd_str = " ".join(shlex.quote(part) for part in hermes_cmd) update_cmd = ( f"PYTHONUNBUFFERED=1 {hermes_cmd_str} update --gateway" f" > {shlex.quote(str(output_path))} 2>&1; " # Avoid `status=$?`: `status` is read-only in zsh and this template is reused in # macOS/zsh operator wrappers, so keep it zsh-safe even though bash runs it here. f"rc=$?; printf '%s' \"$rc\" > {shlex.quote(str(exit_code_path))}" ) setsid_bin = shutil.which("setsid") # Preferred: setsid creates a new session, fully detached; fallback start_new_session=True # calls os.setsid() in the child. argv = [setsid_bin, "bash", "-c", update_cmd] if setsid_bin else ["bash", "-c", update_cmd] subprocess.Popen(argv, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True) def _home_thread_from_source(source) -> Optional[str]: """The thread id /sethome should persist on the home target, or None. Slack thread-per-message keying stamps a top-level message's own id as ``source.thread_id`` (a session key, not a location); persisting it would pin HOME to that ephemeral thread. A thread id equal to the message's own id is synthetic and dropped; a real thread (id = parent's) is kept. """ thread_id = getattr(source, "thread_id", None) if not thread_id: return None if ( getattr(source, "platform", None) == Platform.SLACK and getattr(source, "message_id", None) and str(thread_id) == str(source.message_id) ): return None return str(thread_id) class GatewaySlashCommandsMixin( GatewayModelCommandsMixin, GatewaySessionCommandsMixin, GatewayStatusCommandsMixin, GatewayGoalCommandsMixin, ): """In-session slash-command handlers for GatewayRunner (plus the helpers the sibling mixins share).""" async_session_store: AsyncSessionStore # ------------------------------------------------------------------ shared helpers def _cached_agent_for(self, session_key: str): """Peek the cached AIAgent for *session_key* without evicting it, or None. Cache entries are ``(agent, signature, ...)`` tuples; bare agents (test doubles) are accepted too. Lock/cache may be absent on fixtures that skip ``__init__``. """ cache = getattr(self, "_agent_cache", None) if cache is None: return None lock = getattr(self, "_agent_cache_lock", None) try: if lock is not None: with lock: entry = cache.get(session_key) else: entry = cache.get(session_key) except Exception: return None if isinstance(entry, (tuple, list)): return entry[0] if entry else None return entry or None def _resident_agent_for(self, session_key: str): """The live running agent for *session_key*, else the cached one, else None. The pending sentinel (a run that is starting) never counts as a usable agent. """ from gateway.run import _AGENT_PENDING_SENTINEL agent = self._running_agents.get(session_key) if agent is not None and agent is not _AGENT_PENDING_SENTINEL: return agent return self._cached_agent_for(session_key) @staticmethod def _session_db_unavailable_reply() -> str: from hermes_state import format_session_db_unavailable return format_session_db_unavailable(prefix=t("gateway.shared.session_db_unavailable_prefix")) def _reply_metadata(self, event: MessageEvent): """Thread/reply metadata for an outbound send anchored on *event*.""" return self._thread_metadata_for_source(event.source, self._reply_anchor_for_event(event)) def _adapter_and_key_for(self, event: MessageEvent): """``(adapter, session_key)`` for the event's source, either None when no source.""" if not event.source: return None, None return self.adapters.get(event.source.platform), self._session_key_for_source(event.source) def _telegramized_command_reply(self, event: MessageEvent, text: str) -> str: from gateway.run import _telegramize_command_mentions return _telegramize_command_mentions( text, getattr(getattr(event, "source", None), "platform", None) ) def _checkpoint_manager(self): """A CheckpointManager from gateway config, or None when checkpoints are disabled.""" from gateway.run import _checkpoint_agent_kwargs, _load_gateway_config from tools.checkpoint_manager import CheckpointManager cp_kwargs = _checkpoint_agent_kwargs(_load_gateway_config()) if not cp_kwargs["checkpoints_enabled"]: return None return CheckpointManager( enabled=True, max_snapshots=cp_kwargs["checkpoint_max_snapshots"], max_total_size_mb=cp_kwargs["checkpoint_max_total_size_mb"], max_file_size_mb=cp_kwargs["checkpoint_max_file_size_mb"], ) def _write_approval_setter(self, section: str, session_key: str): """``set_mode_fn`` for /memory and /skills: persist ``
.write_approval``. Write-back round-trip: raw read is correct (merged defaults must not be persisted back to the user's file). The new setting must take effect next message, so the cached agent is dropped. """ from gateway.run import _gateway_config_home from hermes_cli.config import read_user_config_raw config_path = _gateway_config_home() / "config.yaml" def _set_approval(enabled: bool): user_config = read_user_config_raw(config_path) user_config.setdefault(section, {})["write_approval"] = bool(enabled) atomic_config_write(config_path, user_config) self._evict_cached_agent(session_key) return _set_approval async def _deliver_approval_confirmation(self, event: MessageEvent, confirmation_text: str, verb: str): """Return *confirmation_text* for normal delivery, or push it on native-streaming adapters. Native-streaming adapters (WeCom msgtype:"stream") need the confirmation sent directly with control-lane metadata (reliable proactive send, not the finalized reply stream). Everyone else returns text for normal delivery. (``is not True``: mocks auto-create attrs.) """ source = event.source adapter = self.adapters.get(source.platform) if adapter: adapter.resume_typing_for_chat(source.chat_id) # agent is about to continue if getattr(adapter, "SUPPORTS_NATIVE_STREAMING", False) is not True: return confirmation_text if adapter: try: await adapter.send( source.chat_id, confirmation_text, reply_to=event.message_id, metadata={"is_approval_prompt": True, "force_proactive_send": True}, ) except Exception as exc: logger.warning( "Failed to send /%s confirmation to %s: %s", verb, source.chat_id, exc, exc_info=True, ) return None def _typed_command_prefix_for(self, platform) -> str: """Return the prefix users can always type to reach Hermes commands. Adapter ``typed_command_prefix`` capability (default "/"). Slack and Matrix use "!" because typed "/" is blocked/reserved there; their adapters rewrite "!command" to "/command". """ adapter = self.adapters.get(platform) if getattr(self, "adapters", None) else None return getattr(adapter, "typed_command_prefix", "/") if adapter is not None else "/" async def _handle_profile_command(self, event: MessageEvent) -> str: """Handle /profile — show the profile serving this source and its home. On a multiplexed gateway the process-level active profile is the multiplexer's own, so it would read "default" in every chat. With ``multiplex_profiles`` on, report ``source.profile`` and resolve home under that profile's runtime scope (like the scoped /reset banner); when off the stamp is ignored, mirroring ``_run_agent``. """ from hermes_constants import display_hermes_home from hermes_cli.slash_exec import CommandContext, execute_command multiplexed = getattr( getattr(self, "config", None), "multiplex_profiles", False ) source = getattr(event, "source", None) profile_name = "" display = "" if multiplexed: profile_name = (getattr(source, "profile", "") or "").strip() try: from gateway.run import _profile_runtime_scope profile_home = self._resolve_profile_home_for_source(source) with _profile_runtime_scope(profile_home): display = display_hermes_home() except Exception: display = display_hermes_home() # Shared executor resolves process-level fallbacks; the multiplexed # per-source overrides (when any) ride in via options. reply = execute_command( "profile", CommandContext( surface="gateway", options={"profile_name": profile_name, "home_display": display}, ), ) lines = [ t("gateway.profile.header", profile=reply.data["profile"]), t("gateway.profile.home", home=reply.data["home"]), ] return "\n".join(lines) async def _handle_whoami_command(self, event: MessageEvent) -> str: """Handle /whoami — show the user's slash command access on this scope. Always allowed (slash_access floor). Reports platform, DM-vs-group scope, tier, and the commands the user can actually run here. """ from gateway.slash_access import policy_for_source as _policy_for_source source = event.source policy = _policy_for_source(self.config, source) platform = source.platform.value if source and source.platform else "?" chat_type = (source.chat_type if source else "") or "dm" scope = "DM" if chat_type.lower() in {"dm", "direct", "private", ""} else "group/channel" user_id = (source.user_id if source else None) or "?" head = f"**You** — {platform} ({scope})\nUser ID: `{user_id}`\n" if not policy.enabled: return head + "Tier: unrestricted (no admin list configured for this scope)\nSlash commands: all available" if policy.is_admin(user_id): return head + "Tier: **admin**\nSlash commands: all available" # Non-admin user: show what's actually reachable. Floor first (mirrors # slash_access._ALWAYS_ALLOWED_FOR_USERS), then operator additions, deduped in order. runnable = list(dict.fromkeys(["help", "whoami"] + sorted(policy.user_allowed_commands))) runnable_str = ", ".join(f"/{c}" for c in runnable) if runnable else "(none)" return head + f"Tier: user\nSlash commands you can run: {runnable_str}" async def _handle_kanban_command(self, event: MessageEvent) -> str: """Handle /kanban — delegate to the shared kanban CLI. DB work runs in a thread pool to keep the event loop responsive. Reads and mutations are allowed while an agent runs: the board is profile-agnostic and never touches agent state. """ from hermes_cli.kanban import run_slash text = (event.text or "").strip() # Strip the leading "/kanban" (with or without slash), leaving args. if text.startswith("/"): text = text.lstrip("/") if text.startswith("kanban"): text = text[len("kanban"):].lstrip() tokens = shlex.split(text) if text else [] requested_board = None action = None i = 0 while i < len(tokens): tok = tokens[i] if tok == "--board": if i + 1 >= len(tokens): break requested_board = tokens[i + 1] i += 2 continue if tok.startswith("--board="): requested_board = tok.split("=", 1)[1] i += 1 continue action = tok break is_create = action == "create" try: output = await asyncio.to_thread(run_slash, text) except Exception as exc: # pragma: no cover - defensive return t("gateway.kanban.error_prefix", error=exc) # Auto-subscribe on create. Parse the task id from the CLI's standard success line ("Created # t_abcd (ready, assignee=...)"). If the user passed --json we don't subscribe; they're # clearly scripting and can call /kanban notify-subscribe explicitly. if is_create and output: m = re.search(r"Created\s+(t_[0-9a-f]+)\b", output) if m: task_id = m.group(1) try: if await self._kanban_auto_subscribe(event, task_id, requested_board): output = ( output.rstrip() + "\n" + t("gateway.kanban.subscribed_suffix", task_id=task_id) ) except Exception as exc: logger.warning("kanban create auto-subscribe failed: %s", exc) # Gateway messages have practical length caps; truncate long # listings to keep the UX reasonable. if len(output) > 3800: output = output[:3800] + "\n" + t("gateway.kanban.truncated_suffix") return output or t("gateway.kanban.no_output") async def _kanban_auto_subscribe(self, event: MessageEvent, task_id: str, requested_board) -> bool: """Subscribe the event's chat to *task_id* notifications (notify+wake). False when the source has no platform/chat to route back to.""" source = event.source platform = getattr(source, "platform", None) platform_str = (platform.value if hasattr(platform, "value") else str(platform or "")).lower() chat_id = str(getattr(source, "chat_id", "") or "") chat_type = str(getattr(source, "chat_type", "") or "") or None thread_id = str(getattr(source, "thread_id", "") or "") user_id = str(getattr(source, "user_id", "") or "") or None # Also persist the stable alt id (Signal UUID, Feishu union_id): build_session_key keys the # participant on ``user_id_alt or user_id``, so a replayed wake rebuilds the same session # key only when the alt id survives the round-trip. user_id_alt = str(getattr(source, "user_id_alt", "") or "") or None delivery_metadata = self._reply_metadata(event) or None if isinstance(delivery_metadata, dict): chat_type = str(getattr(source, "chat_type", "") or "") if chat_type: delivery_metadata.setdefault("chat_type", chat_type) if not (platform_str and chat_id): return False def _sub(): from hermes_cli import kanban_db as _kb conn = _kb.connect(board=requested_board) try: _kb.add_notify_sub( conn, task_id=task_id, platform=platform_str, chat_id=chat_id, chat_type=chat_type, thread_id=thread_id or None, user_id=user_id, user_id_alt=user_id_alt, notifier_profile=getattr(self, "_kanban_notifier_profile", None) or self._active_profile_name(), # Subscribing from chat: deliver the passive message and wake the destination agent. delivery_mode="notify+wake", delivery_metadata=delivery_metadata, ) finally: conn.close() await asyncio.to_thread(_sub) return True async def _handle_stop_command(self, event: MessageEvent) -> Union[str, EphemeralReply]: """Handle /stop command - interrupt a running agent. When an agent is truly hung (blocked thread that never checks _interrupt_requested), the early intercept in _handle_message() handles /stop before this method is reached; this handler fires only via normal dispatch (no running agent) or as a fallback, and force-cleans the session lock in all cases. The session is preserved so the user can continue. """ from gateway.run import _AGENT_PENDING_SENTINEL, _INTERRUPT_REASON_STOP source = event.source session_entry = await self.async_session_store.get_or_create_session(source) session_key = session_entry.session_key async def _stop(key: str, invalidation_reason: str) -> None: await self._interrupt_and_clear_session( key, source, interrupt_reason=_INTERRUPT_REASON_STOP, invalidation_reason=invalidation_reason, ) agent = self._running_agents.get(session_key) if agent is _AGENT_PENDING_SENTINEL: # Force-clean the sentinel so the session is unlocked. await _stop(session_key, "stop_command_pending") logger.info("STOP (pending) for session %s — sentinel cleared", session_key) return EphemeralReply(t("gateway.stop.stopped_pending")) if agent: # Force-clean the session lock so a truly hung agent doesn't keep it locked forever. await _stop(session_key, "stop_command_handler") return EphemeralReply(t("gateway.stop.stopped")) # No run under the caller's own key. In a per-user thread (thread_sessions_per_user=True) a # run another user started lives under a different key, yet authorized users must still be # able to /stop it: fall back to sibling runs in this thread, gated on authorization. sibling_keys = self._sibling_thread_run_keys(source, session_key) if sibling_keys and self._is_user_authorized(source): for sibling_key in sibling_keys: await _stop(sibling_key, "stop_command_thread_sibling") logger.info( "STOP (thread sibling) by %s — interrupted %d run(s) in thread: %s", session_key, len(sibling_keys), ", ".join(sibling_keys), ) return EphemeralReply(t("gateway.stop.stopped")) # No running agent anywhere for this scope. A platform status indicator can still be stuck — # e.g. Slack's persistent assistant.threads.setStatus survives a gateway restart or a turn # that died without a final send. adapter = getattr(self, "adapters", {}).get(source.platform) if adapter and hasattr(adapter, "_stop_typing_with_metadata"): try: await adapter._stop_typing_with_metadata(source.chat_id, self._reply_metadata(event)) except Exception: logger.debug( "Failed to clear typing on /stop with no active agent", exc_info=True, ) return t("gateway.stop.no_active") async def _handle_platform_command(self, event: MessageEvent) -> str: """Handle ``/platform list|pause|resume [name]`` — inspect and manually control failed/paused gateway adapters (pause stops the reconnect watcher; resume re-queues for retry). """ text = (getattr(event, "content", "") or "").strip() # Strip the leading "/platform" (or "/PLATFORM") token if present parts = text.split(maxsplit=2) if parts and parts[0].lower().lstrip("/").startswith("platform"): parts = parts[1:] action = (parts[0] if parts else "list").lower() target = parts[1].lower() if len(parts) > 1 else "" failed = getattr(self, "_failed_platforms", {}) or {} if action == "list": connected = sorted(p.value for p in self.adapters) lines = ["**Gateway platforms**", "Connected: " + (", ".join(connected) if connected else "(none)")] for p, info in failed.items(): if info.get("paused"): reason = info.get("pause_reason") or "paused" lines.append(f" · {p.value} — PAUSED ({reason}). Resume with `/platform resume {p.value}`.") else: lines.append(f" · {p.value} — retrying (attempt {info.get('attempts', 0)})") if not failed: lines.append("Failed/paused: (none)") return "\n".join(lines) if action in {"pause", "resume"}: if not target: return f"Usage: /platform {action} " # Resolve platform name (case-insensitive, value match) platform = next((p for p in Platform.__members__.values() if p.value.lower() == target), None) if platform is None: return f"Unknown platform: {target}" if action == "pause": if platform not in failed: return ( f"{platform.value} is not in the retry queue " f"(it's either connected or not enabled)." ) if failed[platform].get("paused"): return f"{platform.value} is already paused." self._pause_failed_platform(platform, reason="paused via /platform pause") return ( f"✓ {platform.value} paused. " f"Resume with `/platform resume {platform.value}` or " f"`hermes gateway restart` to reset." ) # action == "resume" if platform not in failed: return ( f"{platform.value} is not in the retry queue — " f"nothing to resume." ) if not failed[platform].get("paused"): return ( f"{platform.value} is already retrying — " f"no resume needed." ) self._resume_paused_platform(platform) return f"✓ {platform.value} resumed — retrying on next watcher tick." return ( "Usage: /platform [name]\n" " /platform list — show platform status\n" " /platform pause — stop retrying a failing platform\n" " /platform resume — re-queue a paused platform" ) async def _handle_restart_command(self, event: MessageEvent) -> Union[str, EphemeralReply]: """Handle /restart command - drain active work, then restart the gateway.""" from gateway.run import _hermes_home # Idempotency check: if the previous gateway process recorded this same /restart (platform + # update_id) and we see it *again*, it's a redelivery from PTB's graceful-shutdown get_updates # ACK failing on the way out. Ignoring it prevents a loop where every fresh gateway re-restarts. if self._is_stale_restart_redelivery(event): logger.info( "Ignoring redelivered /restart (platform=%s, update_id=%s) — " "already processed by a previous gateway instance.", event.source.platform.value if event.source and event.source.platform else "?", event.platform_update_id, ) return "" if self._restart_requested or self._draining: count = self._running_agent_count() if count: return t("gateway.draining", count=count) return EphemeralReply(t("gateway.restart.in_progress")) # Save the requester's routing info so the new gateway process can # notify them once it comes back online. try: notify_data = _restart_notify_payload(event) try: self._restart_command_source = dataclasses.replace( event.source, message_id=str(event.message_id) if event.message_id is not None else event.source.message_id, ) except Exception: self._restart_command_source = event.source await asyncio.to_thread( atomic_json_write, _hermes_home / ".restart_notify.json", notify_data, indent=None, ) except Exception as e: logger.debug("Failed to write restart notify file: %s", e) # Record the triggering platform + update_id in a dedicated dedup marker. Unlike # .restart_notify.json (unlinked once the new gateway sends its notification) this persists # so a delayed Telegram redelivery is still detectable. Overwritten on every /restart. try: dedup_data = { "platform": event.source.platform.value if event.source.platform else None, "requested_at": time.time(), } if event.platform_update_id is not None: dedup_data["update_id"] = event.platform_update_id await asyncio.to_thread( atomic_json_write, _hermes_home / ".restart_last_processed.json", dedup_data, indent=None, ) except Exception as e: logger.debug("Failed to write restart dedup marker: %s", e) active_agents = self._running_agent_count() # Under a service manager (systemd/launchd) or Docker/Podman, exit 75 so the supervisor / # restart policy restarts us — detached setsid+bash fails there (systemd KillMode=mixed kills # the cgroup; tini exits with the gateway). The explicit marker covers ``sudo env -i`` wrappers. from gateway.restart import ( is_container_restart_context, is_gateway_supervisor_process, ) _under_service = is_gateway_supervisor_process() _in_container = is_container_restart_context() if _under_service or _in_container: self.request_restart(detached=False, via_service=True) else: self.request_restart(detached=True, via_service=False) if active_agents: return t("gateway.draining", count=active_agents) return EphemeralReply(t("gateway.restart.restarting")) async def _handle_version_command(self, event: MessageEvent) -> str: """Handle /version — show the running Hermes Agent version.""" from hermes_cli.slash_exec import CommandContext, execute_command return execute_command("version", CommandContext(surface="gateway")).text async def _handle_help_command(self, event: MessageEvent) -> str: """Handle /help command - list available commands.""" from hermes_cli.slash_exec import CommandContext, execute_command reply = execute_command("help", CommandContext(surface="gateway")) return self._telegramized_command_reply(event, reply.text) async def _handle_commands_command(self, event: MessageEvent) -> str: from hermes_cli.slash_exec import CommandContext, execute_command # Page size is a surface parameter (Telegram messages are shorter). page_size = 15 if event.source.platform == Platform.TELEGRAM else 20 reply = execute_command( "commands", CommandContext( surface="gateway", args=event.get_command_args(), options={"page_size": page_size}, ), ) return self._telegramized_command_reply(event, reply.text) async def _handle_set_home_command(self, event: MessageEvent) -> str: """Handle /sethome command -- set the current chat as the platform's home channel.""" from gateway.run import _home_target_env_var, _home_thread_env_var source = event.source platform_name = source.platform.value if source.platform else "unknown" chat_id = source.chat_id chat_name = source.chat_name or chat_id if source.platform is None: return t("gateway.set_home.save_failed", error="Missing logical platform") via_relay = getattr(source, "delivered_via_upstream_relay", False) is True if via_relay: adapter_for_source = getattr(self, "_adapter_for_source", None) relay_adapter = adapter_for_source(source) if callable(adapter_for_source) else None fronts_platform = getattr(relay_adapter, "fronts_platform", None) if ( source.platform in {None, Platform.LOCAL, Platform.RELAY} or not getattr(source, "user_id", None) or not callable(fronts_platform) or not fronts_platform(source.platform) ): return t( "gateway.set_home.save_failed", error="Relay does not authenticate this logical home target", ) thread_id = _home_thread_from_source(source) home = HomeChannel( platform=source.platform, chat_id=str(chat_id), name=chat_name, thread_id=str(thread_id) if thread_id else None, user_id=( str(source.user_id) if getattr(source, "user_id", None) else None ), scope_id=( str(source.scope_id) if getattr(source, "scope_id", None) else None ), ) # config.yaml is canonical because it can persist the authenticated # logical-target provenance required by Relay after a restart. try: persist_home_channel(home, enabled_if_new=not via_relay) except Exception as e: return t("gateway.set_home.save_failed", error=e) # Preserve legacy home env vars for existing cron/setup consumers. env_key = _home_target_env_var(platform_name) thread_env_key = _home_thread_env_var(platform_name) try: from hermes_cli.config import save_env_value save_env_value(env_key, str(chat_id)) save_env_value(thread_env_key, str(thread_id or "")) except Exception as e: logger.warning("Home config saved but legacy env persistence failed: %s", e) # Keep the running gateway config in sync too. The pre-restart # notification path reads self.config before the process reloads config. platform_config = self.config.platforms.setdefault( source.platform, PlatformConfig(enabled=not via_relay), ) platform_config.home_channel = home return t("gateway.set_home.success", name=chat_name, chat_id=chat_id) async def _handle_voice_command(self, event: MessageEvent) -> str: """Handle /voice [on|off|tts|channel|leave|status] command.""" args = event.get_command_args().strip().lower() chat_id = event.source.chat_id # Voice state belongs to the (bot, chat) pair: resolve the adapter that # received the command and key the mode by its owning profile so two # multiplexed bots in one chat keep independent /voice state (#75198). voice_key = self._voice_key_for_source(event.source) adapter = self._adapter_for_source(event.source) def _set_mode(mode: str) -> None: self._voice_mode[voice_key] = mode self._save_voice_modes() if adapter: if mode == "off": self._set_adapter_auto_tts_disabled(adapter, chat_id, disabled=True) else: self._set_adapter_auto_tts_enabled(adapter, chat_id, enabled=True) if args in _VOICE_MODE_BY_ARG: mode, reply_key = _VOICE_MODE_BY_ARG[args] _set_mode(mode) return t(reply_key) elif args in {"channel", "join"}: return await self._handle_voice_channel_join(event) elif args == "leave": return await self._handle_voice_channel_leave(event) elif args == "status": mode = self._voice_mode.get(voice_key, "off") labels = { "off": t("gateway.voice.label_off"), "voice_only": t("gateway.voice.label_voice_only"), "all": t("gateway.voice.label_all"), } # Append voice channel info if connected guild_id = self._get_guild_id(event) if guild_id and hasattr(adapter, "get_voice_channel_info"): info = adapter.get_voice_channel_info(guild_id) if info: lines = [ t("gateway.voice.status_mode", label=labels.get(mode, mode)), t("gateway.voice.status_channel", channel=info['channel_name']), t("gateway.voice.status_participants", count=info['member_count']), ] for m in info["members"]: status = t("gateway.voice.speaking") if m.get("is_speaking") else "" lines.append(t("gateway.voice.status_member", name=m['display_name'], status=status)) return "\n".join(lines) return t("gateway.voice.status_mode", label=labels.get(mode, mode)) else: # Toggle: off → on, on/all → off if self._voice_mode.get(voice_key, "off") == "off": _set_mode("voice_only") toggle_line = t("gateway.voice.enabled_short") else: _set_mode("off") toggle_line = t("gateway.voice.disabled_short") # Bare /voice still toggles, but append an explainer so users discover the # on/off/tts/status subcommands (and, on Discord, live voice-channel join/leave). The # toggle result is shown first via the {toggle} placeholder. supports_voice_channels = adapter is not None and hasattr( adapter, "join_voice_channel" ) channels = ( t("gateway.voice.help_channels") if supports_voice_channels else "" ) return t("gateway.voice.help", toggle=toggle_line, channels=channels) async def _handle_rollback_command(self, event: MessageEvent) -> str: """Handle /rollback command — list or restore filesystem checkpoints.""" from tools.checkpoint_manager import format_checkpoint_list mgr = self._checkpoint_manager() if mgr is None: return t("gateway.rollback.not_enabled") from tools.terminal_scope import terminal_env as _tenv cwd = _tenv("TERMINAL_CWD", str(Path.home())) arg = event.get_command_args().strip() # --all / --force: classic full restore, overwriting user edits too. restore_all = False arg_parts = [] for tok in arg.split(): if tok.lower() in ("--all", "--force"): restore_all = True else: arg_parts.append(tok) arg = " ".join(arg_parts) if not arg: checkpoints = mgr.list_checkpoints(cwd) return format_checkpoint_list(checkpoints, cwd) # Restore by number or hash checkpoints = mgr.list_checkpoints(cwd) if not checkpoints: return t("gateway.rollback.none_found", cwd=cwd) target_hash = None try: idx = int(arg) - 1 if 0 <= idx < len(checkpoints): target_hash = checkpoints[idx]["hash"] else: return t("gateway.rollback.invalid_number", max=len(checkpoints)) except ValueError: target_hash = arg result = mgr.restore(cwd, target_hash, safe=not restore_all) if result["success"]: msg = t( "gateway.rollback.restored", hash=result["restored_to"], reason=result["reason"], ) for result_key, i18n_key in _ROLLBACK_SKIP_LINES: files = result.get(result_key) or [] if files: shown = ", ".join(files[:5]) more = f" (+{len(files) - 5})" if len(files) > 5 else "" msg += "\n" + t(i18n_key, files=shown + more) return msg return t("gateway.rollback.restore_failed", error=result["error"]) async def _handle_diff_command(self, event: MessageEvent) -> str: """Handle /diff — show git changes in the working directory. Diff body is truncated hard here (chat is not a pager); platform senders clamp further. """ args = event.get_command_args().strip() stat_only = False mode = "working" for arg in args.split(): low = arg.lower() if low in ("--stat", "stat"): stat_only = True else: mode = _DIFF_MODE_BY_ARG.get(low, mode) from tools.terminal_scope import terminal_env as _tenv cwd = _tenv("TERMINAL_CWD", str(Path.home())) if mode == "session": return await self._gateway_session_diff(cwd, stat_only) from tools.working_diff import collect_working_diff result = await asyncio.to_thread(collect_working_diff, cwd, mode) if not result.get("success"): return t("gateway.diff.failed", error=result.get("error", "Could not generate diff")) return self._render_diff_result(result, stat_only) async def _gateway_session_diff(self, cwd: str, stat_only: bool) -> str: """Cumulative checkpoint-baseline diff for /diff session (gateway).""" mgr = self._checkpoint_manager() if mgr is None: return t("gateway.diff.not_enabled") result = await asyncio.to_thread(mgr.session_diff, cwd) if not result.get("success"): return t("gateway.diff.failed", error=result.get("error", "Could not generate diff")) return self._render_diff_result(result, stat_only) def _render_diff_result(self, result: dict, stat_only: bool) -> str: """Render a working/session diff result: stat block, untracked list, fenced (truncated) diff.""" stat = result.get("stat", "") diff = result.get("diff", "") untracked = result.get("untracked", []) if result.get("empty") or (not stat and not diff and not untracked): return t("gateway.diff.no_changes") out: list[str] = [] if stat: out.append(f"```\n{stat}\n```") if untracked: shown = "\n".join(f"+ {rel}" for rel in untracked[:15]) more = f"\n... and {len(untracked) - 15} more" if len(untracked) > 15 else "" out.append(f"**Untracked:**\n```\n{shown}{more}\n```") if not stat_only and diff: out.append(self._fenced_truncated_diff(diff)) return "\n\n".join(out) @staticmethod def _fenced_truncated_diff(diff: str, max_lines: int = 60, max_chars: int = 3000) -> str: """Fence a diff body, truncating to messaging-friendly size.""" diff_lines = diff.splitlines() truncated = False if len(diff_lines) > max_lines: diff = "\n".join(diff_lines[:max_lines]) truncated = True if len(diff) > max_chars: diff = diff[:max_chars] truncated = True note = "" if truncated: note = ( f"\n... (truncated — {len(diff_lines)} lines total; " "use /diff --stat for a summary)" ) return f"```diff\n{diff}{note}\n```" def _track_background_task(self, coro) -> None: """Fire-and-forget *coro*, keeping a strong ref in ``_background_tasks`` until it finishes.""" task = asyncio.create_task(coro) self._background_tasks.add(task) task.add_done_callback(self._background_tasks.discard) async def _handle_background_command(self, event: MessageEvent) -> str: """Handle /bg — run a prompt in a background thread with its own session; the result is sent to the same chat without touching the active session's history. """ prompt = event.get_command_args().strip() if not prompt: return t("gateway.background.usage") source = event.source task_id = f"bg_{datetime.now().strftime('%H%M%S')}_{os.urandom(3).hex()}" event_message_id = self._reply_anchor_for_event(event) # Forward image/audio attachments so the background agent can see them. media_urls = list(event.media_urls) if event.media_urls else [] media_types = list(event.media_types) if event.media_types else [] # Fire-and-forget the background task self._track_background_task( self._run_background_task( prompt, source, task_id, event_message_id=event_message_id, media_urls=media_urls, media_types=media_types, ) ) preview = prompt[:60] + ("..." if len(prompt) > 60 else "") return t("gateway.background.started", preview=preview, task_id=task_id) async def _handle_btw_command(self, event: MessageEvent) -> str: """Handle /btw — answer a side question via a one-shot auxiliary LLM call on a transcript snapshot; live history is never touched (alternation + prompt cache intact, current turn keeps running). Unlike /bg, which spawns a fresh contextless session. """ question = event.get_command_args().strip() if not question: return t("gateway.btw.usage") source = event.source session_entry = await self.async_session_store.get_or_create_session(source) history = await self.async_session_store.load_transcript(session_entry.session_id) if not history: return t("gateway.btw.no_history") try: model, runtime_kwargs = self._resolve_session_agent_runtime( source=source, ) except Exception: model, runtime_kwargs = None, {} if not runtime_kwargs.get("api_key"): return t("gateway.btw.no_provider") main_runtime = { "model": model, "provider": runtime_kwargs.get("provider"), "base_url": runtime_kwargs.get("base_url"), "api_key": runtime_kwargs.get("api_key"), "api_mode": runtime_kwargs.get("api_mode"), } history_snapshot = list(history) # Prefer the cache-parity fork when a live cached AIAgent exists: it replays the snapshot # against the warm provider prefix cache, giving FULL context at cache-read prices. With no # cached agent the cache is cold anyway — answer_side_question's digest fallback handles it. try: parent_agent = self._cached_agent_for(self._session_key_for_source(source)) except Exception: parent_agent = None _thread_metadata = self._reply_metadata(event) adapter = self._adapter_for_source(source) preview = question[:60] + ("..." if len(question) > 60 else "") async def _run_side_question() -> None: from agent.side_question import answer_side_question try: answer = await asyncio.to_thread( answer_side_question, question, history_snapshot, parent_agent=parent_agent, main_runtime=main_runtime, ) except Exception as e: logger.warning("/btw side question failed: %s", e) if adapter is not None: await adapter.send( source.chat_id, t("gateway.btw.failed", preview=preview, error=str(e)), metadata=_thread_metadata, ) return if adapter is not None: await adapter.send( source.chat_id, t("gateway.btw.answer", preview=preview, answer=answer or ""), metadata=_thread_metadata, ) self._track_background_task(_run_side_question()) return t("gateway.btw.started", preview=preview) async def _handle_memory_command(self, event: MessageEvent) -> str: """Handle /memory — review pending memory writes + toggle the approval gate. Entries are small enough to review inline, so the full flow works on every platform. """ from hermes_cli.write_approval_commands import handle_pending_subcommand from tools import write_approval as wa from tools.memory_tool import load_on_disk_store raw_args = event.get_command_args().strip() args = raw_args.split() if raw_args else [] _set_approval = self._write_approval_setter("memory", self._session_key_for_source(event.source)) # Apply approved writes against a fresh on-disk store (the gateway has # no long-lived agent; the store persists to the same MEMORY/USER.md). # load_on_disk_store() honors the user's configured char limits. store = load_on_disk_store() out = handle_pending_subcommand( wa.MEMORY, args, memory_store=store, set_mode_fn=_set_approval, ) if out is None: out = ("Unknown /memory subcommand. Use: pending, approve , " "reject , approval .") return out async def _handle_skills_command(self, event: MessageEvent) -> str: """Handle /skills on the gateway — pending skill-write review only (hub stays CLI-only). Gated by ``skills.write_approval`` but still answers when staged writes exist after the gate is off (never stranded). ``diff`` is truncated for chat. """ from hermes_cli.write_approval_commands import handle_pending_subcommand from tools import write_approval as wa raw_args = event.get_command_args().strip() args = raw_args.split() if raw_args else [] gate_on = wa.write_approval_enabled(wa.SKILLS) wants_toggle = bool(args) and args[0].lower() in {"approval", "mode"} if not gate_on and not wants_toggle and wa.pending_count(wa.SKILLS) == 0: return ("Skill write approval is off (skills.write_approval). " "Enable it with /skills approval on, then review staged " "writes here with /skills pending.") out = handle_pending_subcommand( wa.SKILLS, args, set_mode_fn=self._write_approval_setter("skills", self._session_key_for_source(event.source)), ) if out is None: return ("Unknown /skills subcommand on this platform. Use: pending, " "approve , reject , diff , approval . " "(Search/install are CLI-only.)") # Chat bubbles can't hold a full skill diff — truncate and point at the pending JSON file # (NOT `hermes skills diff `, which diffs a bundled skill against its stock version). if args and args[0].lower() == "diff" and len(out) > 3000: pending_id = args[1] if len(args) > 1 else "" out = (out[:3000] + "\n… (truncated — full diff in " f"~/.hermes/pending/skills/{pending_id}.json)") return out async def _handle_approvals_command(self, event: MessageEvent) -> str: """Show or persist the profile-wide dangerous-command approval mode.""" from gateway.slash_access import policy_for_source from hermes_cli.approval_mode import run_approval_mode_command requested = event.get_command_args().strip() or None # This mutates profile-wide security policy. The central slash gate can # allow selected commands to non-admin users, so enforce admin again at # this side-effect boundary. Unconfigured policies remain unrestricted. policy = policy_for_source(self.config, event.source) if requested and not policy.is_admin(event.source.user_id): return "Only gateway admins can change the persistent approval mode." result = run_approval_mode_command(requested) # Approval checks load config dynamically; do not evict the cached agent # or alter its system prompt/tool schema (prompt-cache prefix is sacred). return result.message async def _handle_yolo_command(self, event: MessageEvent) -> Union[str, EphemeralReply]: """Handle /yolo — toggle dangerous command approval bypass for this session only.""" from tools.approval import ( disable_session_yolo, enable_session_yolo, is_session_yolo_enabled, ) session_key = self._session_key_for_source(event.source) current = is_session_yolo_enabled(session_key) if current: disable_session_yolo(session_key) return EphemeralReply(t("gateway.yolo.disabled")) else: enable_session_yolo(session_key) return EphemeralReply(t("gateway.yolo.enabled")) async def _handle_verbose_command(self, event: MessageEvent) -> str: """Handle /verbose command — cycle tool progress display mode. Gated by ``display.tool_progress_command`` (default off). Cycles off → new → all → verbose per *current platform*, saved to ``display.platforms..tool_progress``. """ from gateway.run import _gateway_config_home, _load_gateway_config, _platform_config_key config_path = _gateway_config_home() / "config.yaml" platform_key = _platform_config_key(event.source.platform) try: user_config = _load_gateway_config() gate_enabled = is_truthy_value( cfg_get(user_config, "display", "tool_progress_command"), default=False, ) except Exception: gate_enabled = False if not gate_enabled: return t("gateway.verbose.not_enabled") # Cycle mode (per-platform). cycle = ["off", "new", "all", "verbose", "log"] # Read current effective mode for this platform via the resolver from gateway.display_config import resolve_display_setting current = resolve_display_setting(user_config, platform_key, "tool_progress", "all") if current not in cycle: current = "all" new_mode = cycle[(cycle.index(current) + 1) % len(cycle)] description = t(f"gateway.verbose.mode_{new_mode}") try: _nested_dict(user_config, "display", "platforms", platform_key)["tool_progress"] = new_mode atomic_config_write(config_path, user_config) return f"{description}\n" + t("gateway.verbose.saved_suffix", platform=platform_key) except Exception as e: logger.warning("Failed to save tool_progress mode: %s", e) return f"{description}\n" + t("gateway.verbose.save_failed", error=e) async def _handle_busy_command(self, event: MessageEvent) -> Union[str, EphemeralReply]: """Handle /busy — control what happens when messaging while Hermes is working.""" arg = event.get_command_args().strip().lower() if not arg or arg == "status": mode = self._effective_busy_input_mode(event.source) behavior = _BUSY_MODE_BEHAVIOR.get(mode, _BUSY_MODE_BEHAVIOR["interrupt"])[0] return EphemeralReply( f"**Busy input mode: `{mode}`" + "\n" f"Messages while busy: _{behavior}_" + "\n" f"Change with `/busy queue`, `/busy steer`, or `/busy interrupt`." ) if arg not in _BUSY_MODE_BEHAVIOR: return EphemeralReply( f"Unknown mode `{arg}`. Use `/busy queue`, `/busy steer`, or `/busy interrupt`." ) # Persist before mutate from cli import save_config_value if save_config_value("display.busy_input_mode", arg): profile_name = self._busy_profile_name_for_source(event.source) if profile_name: from gateway.run import _load_gateway_runtime_config self._snapshot_profile_busy_modes( profile_name, _load_gateway_runtime_config(), ) else: self._busy_input_mode = arg # busy_input_mode is also the source of truth for the text mode — re-derive it so the # adapter refresh below doesn't keep a stale value and keep interrupting. self._busy_text_mode = self._load_busy_text_mode() adapter = self._adapter_for_source(event.source) if adapter is not None: adapter._busy_text_mode = self._effective_busy_text_mode(event.source) behavior = _BUSY_MODE_BEHAVIOR[arg][1] return EphemeralReply( f"Busy input mode set to **`{arg}`** (saved)." + "\n" f"_{behavior}_" ) return EphemeralReply( f"Busy input mode could not be saved to config. Mode unchanged." ) async def _handle_footer_command(self, event: MessageEvent) -> str: """Handle /footer command — toggle the runtime-metadata footer.""" from gateway.run import _gateway_config_home, _load_gateway_config, _platform_config_key, _resolve_gateway_model from gateway.runtime_footer import resolve_footer_config config_path = _gateway_config_home() / "config.yaml" platform_key = _platform_config_key(event.source.platform) arg = "" try: text = (getattr(event, "message", None) or "").strip() if text.startswith("/"): parts = text.split(None, 1) if len(parts) > 1: arg = parts[1].strip().lower() except Exception: arg = "" try: user_config: dict = _load_gateway_config() except Exception as e: return t("gateway.config_read_failed", error=e) effective = resolve_footer_config(user_config, platform_key) if arg in {"status", "?"}: state = t("gateway.footer.state_on") if effective["enabled"] else t("gateway.footer.state_off") fields = ", ".join(effective.get("fields") or []) return t( "gateway.footer.status", state=state, fields=fields, platform=platform_key, ) if arg in {"on", "enable", "true", "1"}: new_state = True elif arg in {"off", "disable", "false", "0"}: new_state = False elif arg == "": new_state = not effective["enabled"] else: return t("gateway.footer.usage") try: _nested_dict(user_config, "display", "runtime_footer")["enabled"] = new_state atomic_config_write(config_path, user_config) except Exception as e: logger.warning("Failed to save runtime_footer.enabled: %s", e) return t("gateway.config_save_failed", error=e) state = t("gateway.footer.state_on") if new_state else t("gateway.footer.state_off") example = "" if new_state: # Show a preview using current agent state if available. from gateway.runtime_footer import format_runtime_footer preview = format_runtime_footer( model=_resolve_gateway_model(user_config) or None, context_tokens=0, context_length=None, fields=effective.get("fields") or ["model", "context_pct", "cwd"], ) if preview: example = t("gateway.footer.example_line", preview=preview) return t("gateway.footer.saved", state=state, example=example) async def _handle_reload_mcp_command(self, event: MessageEvent) -> Optional[str]: """Handle /reload-mcp — reconnect MCP servers and rebuild the cached agent. Reloading invalidates the provider prompt cache (tool schemas live in the system prompt), so it routes through slash-confirm; "Always Approve" persists ``approvals.mcp_reload_confirm: false``. """ source = event.source session_key = self._session_key_for_source(source) # Read the gate fresh from disk so a prior "always" click takes # effect on the next invocation without restarting the gateway. user_config = self._read_user_config() approvals = user_config.get("approvals") if isinstance(user_config, dict) else None confirm_required = True if isinstance(approvals, dict): confirm_required = bool(approvals.get("mcp_reload_confirm", True)) if not confirm_required: return await self._execute_mcp_reload(event) # Route through slash-confirm. The primitive sends the prompt and stores the resume handler; # the button/text response triggers ``_resolve_slash_confirm`` which invokes the handler # with the chosen outcome. async def _on_confirm(choice: str) -> Optional[str]: if choice == "cancel": return t("gateway.reload_mcp.cancelled") if choice == "always": # Persist the opt-out and run the reload. try: from cli import save_config_value save_config_value("approvals.mcp_reload_confirm", False) logger.info( "User opted out of /reload-mcp confirmation (session=%s)", session_key, ) except Exception as exc: logger.warning("Failed to persist mcp_reload_confirm=false: %s", exc) # once / always → run the reload result = await self._execute_mcp_reload(event) if choice == "always": return f"{result}\n\n" + t("gateway.reload_mcp.always_followup") return result prompt_message = t("gateway.reload_mcp.confirm_prompt") return await self._request_slash_confirm( event=event, command="reload-mcp", title="/reload-mcp", message=prompt_message, handler=_on_confirm, ) async def _handle_reload_skills_command(self, event: MessageEvent) -> str: """Handle /reload-skills — rescan skills dir, queue a note for next turn. Skills are invoked at runtime, not baked into the system prompt, so this does NOT clear the prompt cache. Added/removed skills go into ``_pending_skills_reload_notes[session_key]``, prepended to the NEXT user message — nothing out-of-band, so alternation is preserved. """ try: from agent.skill_commands import reload_skills # _run_in_executor_with_context, not a bare hop: the rescan walks # get_hermes_home()/skills, a contextvar override under multiplex. result = await self._run_in_executor_with_context(reload_skills) added = result.get("added", []) # [{"name", "description"}, ...] removed = result.get("removed", []) # [{"name", "description"}, ...] total = result.get("total", 0) # Let adapters refresh platform-side state that cached the skill list at startup (today: # Discord /skill autocomplete — otherwise new skills stay invisible and deleted ones # error). Adapters without refresh_skill_group are skipped; the in-process reload suffices. for adapter in list(self.adapters.values()): refresh = getattr(adapter, "refresh_skill_group", None) if not callable(refresh): continue try: maybe = refresh() if inspect.isawaitable(maybe): await maybe except Exception as exc: logger.warning( "Adapter %s refresh_skill_group raised: %s", getattr(adapter, "name", adapter), exc, ) lines = [t("gateway.reload_skills.header")] if not added and not removed: lines.append(t("gateway.reload_skills.no_new")) lines.append(t("gateway.reload_skills.total", count=total)) return "\n".join(lines) def _fmt_line(item: dict) -> str: nm = item.get("name", "") desc = item.get("description", "") if desc: return t("gateway.reload_skills.item_with_desc", name=nm, desc=desc) return t("gateway.reload_skills.item_no_desc", name=nm) # Queue a one-shot note for the next user turn in this session too. Format matches how # the system prompt renders pre-existing skills (`` - name: description``) so the # model reads the diff in the same shape as its original skill catalog. sections = ["[USER INITIATED SKILLS RELOAD:"] for i18n_key, note_header, items in ( ("gateway.reload_skills.added_header", "Added Skills:", added), ("gateway.reload_skills.removed_header", "Removed Skills:", removed), ): if items: lines.append(t(i18n_key)) lines.extend(_fmt_line(item) for item in items) sections.extend(["", note_header]) sections.extend(_fmt_line(item) for item in items) lines.append(t("gateway.reload_skills.total", count=total)) sections.append("") sections.append("Use skills_list to see the updated catalog.]") note = "\n".join(sections) session_key = self._session_key_for_source(event.source) if not hasattr(self, "_pending_skills_reload_notes"): self._pending_skills_reload_notes = {} if session_key: self._pending_skills_reload_notes[session_key] = note return "\n".join(lines) except Exception as e: logger.warning("Skills reload failed: %s", e) return t("gateway.reload_skills.failed", error=e) async def _handle_bundles_command(self, event: MessageEvent) -> str: """Handle /bundles — list installed skill bundles (mirrors the CLI handler). Bundles are loaded by invoking their own ``/`` command, not by this one. """ from hermes_cli.slash_exec import CommandContext, execute_command reply = execute_command("bundles", CommandContext(surface="gateway")) if "error" in reply.data: logger.warning("Bundles command unavailable: %s", reply.data["error"]) return reply.text bundles = reply.data["bundles"] if not bundles: return ( "No skill bundles installed.\n" "Create one on the host with:\n" " `hermes bundles create --skill --skill `\n" f"Directory: `{reply.data['dir']}`" ) lines = [f"**Skill Bundles** ({len(bundles)} installed):", ""] for info in bundles: skill_count = len(info.get("skills", [])) desc = info.get("description") or f"Load {skill_count} skills" lines.append( f"• `/{info['slug']}` — {desc} _({skill_count} skills)_" ) for s in info.get("skills", []): lines.append(f" · {s}") lines.append("") lines.append("Invoke a bundle with `/` to load all its skills.") return "\n".join(lines) def _blocking_approval_or_stale(self, event: MessageEvent, stale_key: str, none_key: str): """``(session_key, None)`` when an agent thread is blocked on approval, else the reply to send. A pending-approvals entry with no blocked thread is a stale prompt: drop it and say so. """ from tools.approval import has_blocking_approval session_key = self._session_key_for_source(event.source) if has_blocking_approval(session_key): return session_key, None if session_key in self._pending_approvals: self._pending_approvals.pop(session_key) return session_key, t(stale_key) return session_key, t(none_key) async def _handle_approve_command(self, event: MessageEvent) -> Optional[str]: """Handle /approve command — unblock waiting agent thread(s). Agent threads block inside tools/approval.py; signalling the event resumes them so the command executes inline — same flow as the CLI's synchronous approval. """ from tools.approval import resolve_gateway_approval session_key, stale = self._blocking_approval_or_stale(event, "gateway.approval_expired", "gateway.approve.no_pending") if stale: return stale # Parse args: support "all", "all session", "all always", "session", "always" args = event.get_command_args().strip().lower().split() resolve_all = "all" in args remaining = [a for a in args if a != "all"] if any(a in {"always", "permanent", "permanently"} for a in remaining): choice = "always" elif any(a in {"session", "ses"} for a in remaining): choice = "session" else: choice = "once" count = resolve_gateway_approval(session_key, choice, resolve_all=resolve_all) if not count: return t("gateway.approve.no_pending") plural = "plural" if count > 1 else "singular" confirmation_text = t(f"gateway.approve.{choice}_{plural}", count=count) logger.info("User approved %d dangerous command(s) via /approve (%s)", count, choice) return await self._deliver_approval_confirmation(event, confirmation_text, "approve") async def _handle_deny_command(self, event: MessageEvent) -> str: """Handle /deny command — reject pending dangerous command(s). Signals blocked thread(s) with a 'deny' result so they get a definitive BLOCKED message, as in the CLI. ``/deny`` denies the oldest; ``/deny all`` denies everything. """ from tools.approval import resolve_gateway_approval session_key, stale = self._blocking_approval_or_stale(event, "gateway.deny.stale", "gateway.deny.no_pending") if stale: return stale # Parse args: a leading "all" token denies every pending command; # anything after it (or the whole arg string when "all" is absent) is # captured verbatim as the optional deny reason relayed to the agent. raw_args = event.get_command_args().strip() tokens = raw_args.split() resolve_all = bool(tokens) and tokens[0].lower() == "all" reason = raw_args[len(tokens[0]):].strip() if resolve_all else raw_args # Cap to a sane one-liner; the agent only needs a short hint. if reason: reason = reason[:280].strip() count = resolve_gateway_approval( session_key, "deny", resolve_all=resolve_all, reason=reason or None, ) if not count: return t("gateway.deny.no_pending") logger.info( "User denied %d dangerous command(s) via /deny%s", count, " (with reason)" if reason else "", ) if reason: if count > 1: confirmation_text = t("gateway.deny.denied_reason_plural", count=count, reason=reason) else: confirmation_text = t("gateway.deny.denied_reason_singular", reason=reason) elif count > 1: confirmation_text = t("gateway.deny.denied_plural", count=count) else: confirmation_text = t("gateway.deny.denied_singular") return await self._deliver_approval_confirmation(event, confirmation_text, "deny") async def _handle_debug_command(self, event: MessageEvent) -> str: """Handle /debug — upload debug report (summary only) and return paste URLs. Uploads ONLY the summary (system info + log tails), never full logs, to protect privacy; use ``hermes debug share`` from the CLI for full uploads. """ from hermes_cli.debug import ( _capture_dump, collect_debug_report, upload_to_pastebin, _schedule_auto_delete, _GATEWAY_PRIVACY_NOTICE, _best_effort_sweep_expired_pastes, ) # Run blocking I/O (dump capture, log reads, uploads) in a thread. def _collect_and_upload(): _best_effort_sweep_expired_pastes() dump_text = _capture_dump() report = collect_debug_report(log_lines=200, dump_text=dump_text) urls = {} try: urls["Report"] = upload_to_pastebin(report) except Exception as exc: return t("gateway.debug.upload_failed", error=exc) # Schedule auto-deletion after 6 hours _schedule_auto_delete(list(urls.values())) lines = [_GATEWAY_PRIVACY_NOTICE, "", t("gateway.debug.header"), ""] label_width = max(len(k) for k in urls) for label, url in urls.items(): lines.append(f"`{label:<{label_width}}` {url}") lines.append("") lines.append(t("gateway.debug.auto_delete")) lines.append(t("gateway.debug.full_logs_hint")) lines.append(t("gateway.debug.share_hint")) return "\n".join(lines) # _run_in_executor_with_context, not a bare hop: this collects the profile's logs/config off # ``get_hermes_home()`` and uploads them to a public paste. Losing the contextvar override # would publish the DEFAULT profile's diagnostics from another profile's chat. return await self._run_in_executor_with_context(_collect_and_upload) async def _handle_update_command(self, event: MessageEvent) -> str: """Handle /update command — update Hermes Agent to the latest version. Spawns ``hermes update`` detached (``setsid``) so it survives the gateway restart it may trigger; marker files let this or the next gateway process notify the user on completion. """ from gateway.run import _hermes_home, _resolve_hermes_bin import json from hermes_cli.config import is_managed, format_managed_message # Block non-messaging platforms (API server, webhooks, ACP) platform = event.source.platform _allowed = self._UPDATE_ALLOWED_PLATFORMS # Plugin platforms with allow_update_command=True are also allowed if platform not in _allowed: try: from gateway.platform_registry import platform_registry entry = platform_registry.get(platform.value) if not entry or not entry.allow_update_command: return t("gateway.update.platform_not_messaging") except Exception: return t("gateway.update.platform_not_messaging") if is_managed(): return f"✗ {format_managed_message('update Hermes Agent')}" project_root = Path(__file__).parent.parent.resolve() git_dir = project_root / '.git' if not git_dir.exists(): return t("gateway.update.not_git_repo") hermes_cmd = _resolve_hermes_bin() if not hermes_cmd: return t("gateway.update.hermes_cmd_not_found") pending_path = _hermes_home / ".update_pending.json" output_path = _hermes_home / ".update_output.txt" exit_code_path = _hermes_home / ".update_exit_code" session_key = self._session_key_for_source(event.source) pending = { "platform": event.source.platform.value, "chat_id": event.source.chat_id, "chat_type": event.source.chat_type, "user_id": event.source.user_id, "session_key": session_key, "timestamp": datetime.now().isoformat(), } if event.source.thread_id: pending["thread_id"] = event.source.thread_id if event.message_id: pending["message_id"] = event.message_id _tmp_pending = pending_path.with_suffix(".tmp") _tmp_pending.write_text(json.dumps(pending), encoding="utf-8") _tmp_pending.replace(pending_path) exit_code_path.unlink(missing_ok=True) try: _spawn_detached_update(hermes_cmd, output_path, exit_code_path) except Exception as e: pending_path.unlink(missing_ok=True) exit_code_path.unlink(missing_ok=True) return t("gateway.update.start_failed", error=e) self._schedule_update_notification_watch() return t("gateway.update.starting")