Files
hermes-agent/gateway/slash_commands.py
T

1746 lines
78 KiB
Python

"""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 ``<section>.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} <name>"
# 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 <list|pause|resume> [name]\n"
" /platform list — show platform status\n"
" /platform pause <name> — stop retrying a failing platform\n"
" /platform resume <name> — 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 <prompt> — 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 <question> — 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 <id>, "
"reject <id>, approval <on|off>.")
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 <id>, reject <id>, diff <id>, approval <on|off>. "
"(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 <name>`, 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 "<id>"
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.<platform>.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 ``/<slug>`` 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 <name> --skill <s1> --skill <s2>`\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 `/<slug>` 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")