Files
hermes-agent/tui_gateway/methods_tools.py
T
Teknium 0d8a1575c5 refactor(mcp): keep the passive status RPC, drop the SDK contract and reason codes
mcp.servers.status now rides the shared _mcp_rpc decorator (profile scope, 4064,
5024 with the real message) instead of a hand-rolled try/finally with a blanket
except. Drop the _MCPConnectErrorText str subclass and reason taxonomy: the
existing status/error fields already carry the state, and a whitelist on the RPC
keeps error text out of the wire. The Desktop connections.health contribution
contract is held back until its consumer plugin is public. Tests trimmed to the
scope invariants (per-profile runtime visibility, scoped shutdown clears only its
own status, launch runtime never leaks into another profile).
2026-09-06 13:18:19 -07:00

1406 lines
69 KiB
Python

"""Tools & system / slash / insights / rollback / plugins / cron / skills / MCP JSON-RPC handlers.
Rebound onto server.py's globals at install time (``method_ctx.bind_module``), so
bodies reference server globals bare (``_ok``, ``_err``, ``_sessions``, ...).
Helper names must not collide with server.py's own (``_cmd_`` / ``_toolset_`` / ``_mcp_`` prefixes).
"""
import sys
from .method_ctx import HandlerRegistry, bind_module
_registry = HandlerRegistry()
method = _registry.method
_profile_scoped = _registry.profile_scoped
# ─── Shared helpers ──────────────────────────────────────────────────────────
def _profile_scoped_rpc(
fail_code: int, *, required=(), catch_resolve: bool = True, prefix: str = "",
scoped: bool = True, live_session: bool = False,
):
"""Wrap a handler body with the optional ``profile`` HERMES_HOME scope. Order: ``required``
params (4063 ``<key> required``) → ``live_session`` resolution via ``_sess`` (waits for the
agent build; body gets ``session`` as 3rd arg) → profile (4064 when its dir is missing) → body;
body exceptions become ``fail_code`` (``prefix`` + message). ``catch_resolve`` also maps
resolve-time exceptions to ``fail_code``; mcp.servers.* let them propagate to dispatch().
``scoped=False`` ignores ``profile``. The override is always reset afterwards."""
def deco(body):
def handler(rid, params: dict) -> dict:
for key, present in required:
if not present(params.get(key)):
return _err(rid, 4063, f"{key} required")
args = (rid, params)
if live_session:
session, err = _sess(params, rid)
if err:
return err
args = (rid, params, session)
token = None
if profile := _str_arg(params, "profile") if scoped else "":
try:
profile_dir = _tools_mod("hermes_cli.profiles").get_profile_dir(profile)
if not profile_dir or not profile_dir.is_dir():
return _err(rid, 4064, f"profile '{profile}' not found")
token = _tools_mod("hermes_constants").set_hermes_home_override(str(profile_dir))
except Exception as e:
if not catch_resolve:
raise
return _err(rid, fail_code, str(e))
try:
return body(*args)
except Exception as e:
return _err(rid, fail_code, f"{prefix}{e}")
finally:
_mcp_reset_profile(token)
handler.__doc__ = body.__doc__
return handler
return deco
def _guarded(fail_code: int, prefix: str = "", *, live_session: bool = False):
"""Body exceptions → ``_err(rid, fail_code, prefix + str(e))``; no profile scope. ``live_session``
resolves the session first and calls ``body(rid, params, session)``."""
return _profile_scoped_rpc(fail_code, prefix=prefix, scoped=False, live_session=live_session)
def _rpc(name: str, fail_code: int, prefix: str = "", *, live_session: bool = False):
"""``@method(name)`` + ``_guarded``."""
return lambda body: method(name)(_guarded(fail_code, prefix, live_session=live_session)(body))
def _scoped_rpc(name: str, fail_code: int = 5024, **kw):
"""``@method(name)`` + ``_profile_scoped_rpc`` (optional ``profile`` HERMES_HOME scope)."""
return lambda body: method(name)(_profile_scoped_rpc(fail_code, **kw)(body))
def _str_arg(params: dict, key: str) -> str:
return str(params.get(key) or "").strip()
def _tools_mod(module: str):
"""Deferred module import for one-liner bodies (startup budget: never import at load)."""
import importlib
return importlib.import_module(module)
_stripped = lambda v: bool(str(v or "").strip()) # noqa: E731 — required-param predicates
_nonempty = lambda v: not (v is None or str(v) == "") # noqa: E731
_NAME = (("name", _stripped),)
_NAME_SESSION = (("name", _stripped), ("session_id", _stripped))
def _mcp_rpc(name: str, required=_NAME):
"""mcp.servers.* contract: profile scope, ``required`` params (default ``name``), body errors → 5024,
profile-resolve errors propagate to dispatch()."""
return _scoped_rpc(f"mcp.servers.{name}", required=required, catch_resolve=False)
def _mcp_named_server(rid, params):
"""(name, servers, None) for a configured server, else (name, servers, 4064 error)."""
name, servers = _str_arg(params, "name"), _tools_mod("hermes_cli.mcp_config")._get_mcp_servers()
return name, servers, None if name in servers else _err(rid, 4064, f"server '{name}' not found")
def _busy_error(rid, session, cmd: str):
if session.get("running"):
return _err(rid, 4009, f"session busy — /interrupt the current turn before /{cmd}")
return None
def _session_key_or_err(rid, session, module: str, label: str):
"""(session_key, module, None) for the /goal and /loop managers, else (None, None, error):
4001 without a session/key, 5030 when ``module`` fails to import."""
if not session:
return None, None, _err(rid, 4001, "no active session")
if not (sid_key := session.get("session_key") or ""):
return None, None, _err(rid, 4001, "no session key")
try:
return sid_key, _tools_mod(module), None
except Exception as exc:
return None, None, _err(rid, 5030, f"{label} unavailable: {exc}")
def _user_turn_indices(session):
"""(history, indices of user-originated turns) minus ephemeral scaffolding. Call under history_lock."""
is_user = _tools_mod("agent.context_compressor").user_originated_turn_view
history = _history_without_ephemeral_scaffolding(session.get("history", []))
return history, [i for i, m in enumerate(history) if is_user(m) is not None]
def _rewind_prelude(rid, session, cmd: str, empty_msg: str):
"""Under history_lock: re-check busy, then (history, user_indices, None) or (None, None, error)."""
if busy := _busy_error(rid, session, cmd):
return None, None, busy
history, user_indices = _user_turn_indices(session)
if not user_indices:
return None, None, _err(rid, 4018, empty_msg)
return history, user_indices, None
def _rewind_or_err(rid, session, keep: int, value_err: tuple, fail_prefix: str, **kw):
"""``_rewind_active_session_history`` → (result, None); ValueError → ``value_err`` (code, prefix),
other exceptions → 5008 ``fail_prefix`` + message."""
try:
return _rewind_active_session_history(session, keep, **kw), None
except ValueError as exc:
return None, _err(rid, value_err[0], f"{value_err[1]}{exc}")
except Exception as exc:
return None, _err(rid, 5008, f"{fail_prefix}{exc}")
def _clip(text: str, n: int = 120) -> str:
return text[:n] + ("…" if len(text) > n else "")
def _exec_out(rid, output: str) -> dict:
"""command.dispatch display-only result."""
return _ok(rid, {"type": "exec", "output": output})
def _capture_run_kwargs(timeout: int) -> dict:
"""Shared captured-text subprocess.run kwargs: UTF-8 + lossy decode (non-UTF-8 child output must
not crash the gateway thread on Windows), no stdin, no console flash under the desktop parent."""
return dict(
capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=timeout,
stdin=subprocess.DEVNULL, creationflags=_tools_mod("hermes_cli._subprocess_compat").windows_hide_flags())
def _captured_exec(rid, cmd, timeout: int, *, on_result, timeout_err: tuple, fail_code: int,
shell: bool = False, env: "dict | None" = None) -> dict:
"""Run ``cmd`` captured (see ``_capture_run_kwargs``) and hand the CompletedProcess to
``on_result``; TimeoutExpired → ``timeout_err`` (code, message), other errors → ``fail_code``."""
try:
return on_result(subprocess.run(cmd, cwd=os.getcwd(), shell=shell, env=env, **_capture_run_kwargs(timeout)))
except subprocess.TimeoutExpired:
return _err(rid, *timeout_err)
except Exception as e:
return _err(rid, fail_code, str(e))
def _joined_output(r) -> str:
"""stdout + stderr of a CompletedProcess, non-empty parts only, newline-joined and stripped."""
return "\n".join(p for p in (r.stdout or "", r.stderr or "") if p).strip()
def _toolset_rows(params: dict, *, with_tools: bool) -> list[dict]:
toolsets = _tools_mod("toolsets")
session = _sessions.get(params.get("session_id", ""))
enabled = set((getattr(session["agent"], "enabled_toolsets", []) if session else _load_enabled_toolsets()) or [])
items = []
for name in sorted(toolsets.get_all_toolsets().keys()):
if info := toolsets.get_toolset_info(name):
row = {
"name": name, "description": info["description"], "tool_count": info["tool_count"],
"enabled": name in enabled if enabled else True}
if with_tools:
row["tools"] = info["resolved_tools"]
items.append(row)
return items
# ─── System / process ────────────────────────────────────────────────────────
@method("system.battery")
def _(rid, params: dict) -> dict:
"""Host battery for the status bar. Always resolves; ``available: false`` = no battery or read failed."""
try:
battery = _tools_mod("agent.battery")
batt = battery.read_battery()
return _ok(rid, {
"available": batt.available, "percent": batt.percent, "plugged": batt.plugged,
"category": battery.battery_category(batt)})
except Exception:
return _ok(rid, {"available": False, "percent": None, "plugged": None, "category": "dim"})
# One-expression handlers: name → (fail_code, payload builder(params)).
_SIMPLE_RPCS = {
# Session-scoped view of the background process registry (desktop status stack).
"process.stop": (5010, lambda params: {"killed": _tools_mod("tools.process_registry").process_registry.kill_all()}),
# Re-read ``~/.hermes/.env`` (CLI ``/reload`` parity); built agents keep their pool, ``/new`` resolves fresh.
"reload.env": (5015, lambda params: {"updated": int(_tools_mod("hermes_cli.config").reload_env())}),
"plugins.list": (5032, lambda params: {"plugins": [
{"name": n, "version": getattr(i, "version", "?"), "enabled": getattr(i, "enabled", True)}
for n, i in _tools_mod("hermes_cli.plugins").get_plugin_manager()._plugins.items()]}),
"tools.list": (5031, lambda params: {"toolsets": _toolset_rows(params, with_tools=True)}),
"toolsets.list": (5032, lambda params: {"toolsets": _toolset_rows(params, with_tools=False)}),
"agents.list": (5033, lambda params: {"processes": [
{"session_id": p["session_id"], "command": p["command"][:80], "status": p["status"], "uptime": p["uptime_seconds"]}
for p in _tools_mod("tools.process_registry").process_registry.list_sessions()]}),
}
for _name, (_code, _build) in _SIMPLE_RPCS.items():
# Look the builder up at call time: bind_module rebinds the table's lambdas onto server globals.
_rpc(_name, _code)(lambda rid, params, _n=_name: _ok(rid, _SIMPLE_RPCS[_n][1](params)))
del _name, _code, _build
_rpc("process.list", 5010, live_session=True)(
lambda rid, params, session: _ok(rid, {"processes": _session_processes(session)}))
@_rpc("process.kill", live_session=True, fail_code=5010)
def _(rid, params: dict, session) -> dict:
"""Kill ONE background process, scoped to the caller's session (unlike process.stop's kill_all)."""
proc_id = str(params.get("process_id") or "")
if not proc_id:
return _err(rid, 4012, "process_id required")
registry = _tools_mod("tools.process_registry").process_registry
proc = registry.get(proc_id)
if proc is None or str(getattr(proc, "session_key", "") or "") != str(session.get("session_key") or ""):
return _err(rid, 4044, f"no such process: {proc_id}")
return _ok(rid, registry.kill_process(proc_id))
def _mcp_reload_confirm_required() -> bool:
"""``approvals.mcp_reload_confirm`` from disk config; True (safe) on any failure."""
try:
cfg = _tools_mod("hermes_cli.config").load_config()
approvals = cfg.get("approvals") if isinstance(cfg, dict) else None
return bool(approvals.get("mcp_reload_confirm", True)) if isinstance(approvals, dict) else True
except Exception:
return True
@_rpc("reload.mcp", 5015)
def _(rid, params: dict) -> dict:
session = _sessions.get(params.get("session_id", ""))
# Prompt-cache invalidation gate: without confirm=true honour ``approvals.mcp_reload_confirm``
# (Ink prints ``message`` and re-invokes with confirm=true, or flips the config).
if not bool(params.get("confirm", False)) and _mcp_reload_confirm_required():
message = (
"⚠️ /reload-mcp invalidates the prompt cache (next message re-sends full input tokens). "
"Reply `/reload-mcp now` to proceed, or `/reload-mcp always` to proceed and "
"silence this prompt permanently.")
return _ok(rid, {"status": "confirm_required", "message": message})
if session and _session_uses_compute_host(session):
try:
ack = _get_compute_host_supervisor().reload_mcp(
str(params.get("session_id") or ""), request_id=f"reload-mcp-{rid}")
except Exception as exc:
return _err(rid, 5019, f"compute-host reload_mcp failed: {exc}")
return _ok(rid, {"status": "reloaded", "turn_isolation": True, "host_ack": ack})
_mcp_agent, _mcp_lifecycle, _mcp_discovery = (
_tools_mod("tools.mcp_tool_agent"), _tools_mod("tools.mcp_tool_lifecycle"), _tools_mod("tools.mcp_tool_discovery"))
global _mcp_reload_gen, _mcp_reload_loaded_rev
# Revision the CALLER wants loaded; empty on legacy clients / manual /reload-mcp
# (generation-only coalescing).
req_rev = str(params.get("rev") or "")
def _refresh_session_agent() -> None:
"""Rebuild THIS session's cached tool snapshot + push session.info (the agent never
re-reads the registry). Runs under _mcp_reload_lock so a concurrent reload can't
tear the registry down mid-refresh."""
if not session:
return
agent = session["agent"]
try: # enabled_override re-resolves toolsets so a server enabled in config this session is picked up
_mcp_agent.refresh_agent_mcp_tools(agent, enabled_override=_load_enabled_toolsets(), quiet_mode=True)
except Exception as _exc:
logger.warning("Failed to refresh cached agent tools after /reload-mcp: %s", _exc)
_emit("session.info", params.get("session_id", ""), _session_info(agent, session))
def _do_full_reload() -> None:
"""shutdown+discover+refresh under the lock, then mark a completed generation. Config
can change WHILE discover connects: re-hash and repeat until stable so the marked
generation matches what loaded."""
global _mcp_reload_gen, _mcp_reload_loaded_rev
loaded = _compute_mcp_rev()
for _ in range(_MCP_RELOAD_MAX_PASSES):
_mcp_lifecycle.shutdown_mcp_servers()
_mcp_agent.reprobe_tool_availability()
_mcp_discovery.discover_mcp_tools()
after = _compute_mcp_rev()
if after == loaded:
break
loaded = after
_refresh_session_agent()
_mcp_reload_loaded_rev = loaded
_mcp_reload_gen += 1
# LEADER (won the non-blocking acquire) runs the full reload. FOLLOWER waits, then — still
# holding the lock — coalesces only if a reload COMPLETED meanwhile (generation advanced
# ⇒ leader didn't throw) AND it loaded the requested revision; otherwise it re-runs.
if _mcp_reload_lock.acquire(blocking=False):
try:
_do_full_reload()
finally:
_mcp_reload_lock.release()
return _finish_reload(rid, params, coalesced=False)
gen_before = _mcp_reload_gen
with _mcp_reload_lock:
coalesced = _mcp_reload_gen > gen_before and (not req_rev or req_rev == _mcp_reload_loaded_rev)
_refresh_session_agent() if coalesced else _do_full_reload()
return _finish_reload(rid, params, coalesced=coalesced)
# ─── Command catalog / dispatch ──────────────────────────────────────────────
class _Catalog:
"""Accumulator for commands.catalog: ``pairs`` (every [key, desc]), ``canon`` (lowercase
key/alias → canonical key), ``commands`` (key → desktop meta) and ordered categories."""
def __init__(self) -> None:
self.pairs: list[list[str]] = []
self.canon: dict[str, str] = {}
self.commands: dict[str, dict[str, str | None]] = {}
self.cat_map: dict[str, list[list[str]]] = {} # insertion order = category order
def add(self, key: str, desc: str, cat: str) -> None:
self.canon[key.lower()] = key
self.pairs.append([key, desc])
self.cat_map.setdefault(cat, []).append([key, desc])
def _catalog_registry(cat: _Catalog) -> None:
commands = _tools_mod("hermes_cli.commands")
for cmd in commands.COMMAND_REGISTRY:
meta = commands.command_desktop_meta(cmd)
cat.commands.update({f"/{key}": dict(meta) for key in (cmd.name, *cmd.aliases)})
if cmd.name in _TUI_HIDDEN or cmd.gateway_only:
continue
cat.add(f"/{cmd.name}", commands._build_description(cmd), cmd.category)
for a in cmd.aliases:
cat.canon[f"/{a}".lower()] = f"/{cmd.name}"
for name, desc, category in _TUI_EXTRA:
# Registry command/alias wins over a colliding TUI extra (e.g. /compact, /sessions).
if name.lower() not in cat.canon:
cat.add(name, desc, category)
def _catalog_quick_commands(cat: _Catalog) -> None:
qcmds = _load_cfg().get("quick_commands", {}) or {}
if not (isinstance(qcmds, dict) and qcmds):
return
cat.cat_map.setdefault("User commands", []) # category exists even when every entry is malformed
for qname, qc in sorted(qcmds.items()):
if not isinstance(qc, dict):
continue
qtype = qc.get("type", "")
default_desc = {"exec": f"exec: {qc.get('command', '')}", "alias": f"alias → {qc.get('target', '')}"}
desc = str(qc.get("description") or default_desc.get(qtype, qtype or "quick command"))
cat.add(f"/{qname}", _clip(desc), "User commands")
def _catalog_plugin_commands(cat: _Catalog) -> None:
plugin_cmds = _tools_mod("hermes_cli.plugins").get_plugin_commands() or {}
if plugin_cmds:
cat.cat_map.setdefault("Plugin commands", [])
for pname, info in sorted(plugin_cmds.items()):
key = f"/{pname}"
if not isinstance(info, dict) or key.lower() in cat.canon:
continue
cat.add(key, _clip(str(info.get("description") or "Plugin command")), "Plugin commands")
mode = info.get("argument_mode")
if mode not in {"options", "text", "mixed"}:
mode = "text" if str(info.get("args_hint") or "").strip() else None
cat.commands[key] = {"argument_mode": mode, "desktop": None}
def _catalog_skills(cat: _Catalog, skills: dict[str, dict]) -> None:
"""Append skill pairs and fill ``skills`` = ``{key: {usage, origin}}`` (every consumer ranks by them)."""
usage, origin_of = _skill_usage_lookup()
for k, info in sorted(_tools_mod("agent.skill_commands").scan_skill_commands().items()):
cat.pairs.append([k, _clip(str(info.get("description", "Skill")))])
name = str(info.get("name") or k.lstrip("/"))
skills[k] = {"usage": usage(name), "origin": origin_of(name)}
@_rpc("commands.catalog", 5020)
def _(rid, params: dict) -> dict:
"""Registry-backed slash metadata, categorized, no aliases. Discovery failures land in ``warning``
(skills' message wins, then quick commands', then plugins')."""
cat = _Catalog()
_catalog_registry(cat)
warning = ""
try:
_catalog_quick_commands(cat)
except Exception as e:
warning = f"quick_commands discovery unavailable: {e}"
try:
_catalog_plugin_commands(cat)
except Exception as e:
warning = warning or f"plugin command discovery unavailable: {e}"
skills: dict[str, dict] = {}
try:
_catalog_skills(cat, skills)
except Exception as e:
warning = f"skill discovery unavailable: {e}"
return _ok(rid, {
"pairs": cat.pairs, "sub": {k: v[:] for k, v in _tools_mod("hermes_cli.commands").SUBCOMMANDS.items()},
"canon": cat.canon,
"commands": cat.commands,
"categories": [{"name": c, "pairs": rows} for c, rows in cat.cat_map.items()],
"skills": skills, "skill_count": len(skills), "warning": warning})
@method("cli.exec")
def _(rid, params: dict) -> dict:
"""Run `python -m hermes_cli.main` with argv; capture stdout/stderr (non-interactive only)."""
argv = params.get("argv", [])
if not isinstance(argv, list) or not all(isinstance(x, str) for x in argv):
return _err(rid, 4003, "argv must be list[str]")
hint = _cli_exec_blocked(argv)
if hint:
return _ok(rid, {"blocked": True, "hint": hint, "code": -1, "output": ""})
# Can drive the agent → needs provider credentials; tier-1 secrets still stripped.
return _captured_exec(
rid, [sys.executable, "-m", "hermes_cli.main", *argv], min(int(params.get("timeout", 240)), 600),
on_result=lambda r: _ok(rid, {
"blocked": False, "code": r.returncode, "output": (_joined_output(r) or "(no output)")[:48_000]}),
timeout_err=(5016, "cli.exec: timeout"), fail_code=5017,
env=hermes_subprocess_env(inherit_credentials=True))
@_rpc("command.resolve", 5012)
def _(rid, params: dict) -> dict:
r = _tools_mod("hermes_cli.commands").resolve_command(params.get("name", ""))
if r:
return _ok(rid, {"canonical": r.name, "description": r.description, "category": r.category})
return _err(rid, 4011, f"unknown command: {params.get('name')}")
# command.dispatch stages. Each takes (rid, params, session, name, arg) and
# returns a JSON-RPC envelope, or None to fall through to the next stage.
def _dispatch_quick(rid, params, session, name, arg):
qc = _load_cfg().get("quick_commands", {}).get(name)
if qc is None:
return None
if qc.get("type") == "exec":
# Sanitized env: the TUI server process holds every API key in os.environ.
env = _tools_mod("tools.environments.local").build_subprocess_env()
r = subprocess.run(qc.get("command", ""), shell=True, env=env, **_capture_run_kwargs(30))
output = _joined_output(r)[:4000]
output = _tools_mod("agent.redact").redact_sensitive_text(output) if output else output
if r.returncode != 0:
return _err(rid, 4018, output or f"quick command failed with exit code {r.returncode}")
return _exec_out(rid, output)
return _ok(rid, {"type": "alias", "target": qc.get("target", "")}) if qc.get("type") == "alias" else None
def _plugin_command_handler(name: str):
try:
return _tools_mod("hermes_cli.plugins").get_plugin_command_handler(name)
except Exception:
return None
def _run_plugin_command(handler, arg: str) -> str:
return str(_tools_mod("hermes_cli.plugins").resolve_plugin_command_result(handler(arg)) or "")
def _is_profile_skill_command(session: dict, base: str) -> bool:
"""True when ``/base`` is a skill command of the session's profile (HERMES_HOME bound to it so
get_skill_commands() sees its skills.external_dirs; nothing upstream binds it). False on failure."""
try:
hc = _tools_mod("hermes_constants")
profile_home = session.get("profile_home")
token = hc.set_hermes_home_override(profile_home) if profile_home else None
try:
return f"/{base}" in _tools_mod("agent.skill_commands").get_skill_commands()
finally:
if token is not None:
hc.reset_hermes_home_override(token)
except Exception:
return False
def _dispatch_plugin(rid, params, session, name, arg):
if handler := _plugin_command_handler(name):
with contextlib.suppress(Exception):
return _ok(rid, {"type": "plugin", "output": _run_plugin_command(handler, arg)})
return None
def _bundle_key_for(name: str):
"""Skill-bundle key for ``name`` when it is NOT a registry command; None otherwise / on failure."""
try:
if _tools_mod("hermes_cli.commands").resolve_command(name) is None:
return _tools_mod("agent.skill_bundles").resolve_bundle_command_key(name)
return None
except Exception:
return None
def _dispatch_bundle(rid, params, session, name, arg):
bundle_key = _bundle_key_for(name)
if bundle_key is None:
return None
bundles = _tools_mod("agent.skill_bundles")
try:
bundle_result = bundles.build_bundle_invocation_message(
bundle_key, arg, task_id=session.get("session_key", "") if session else "",
platform=_resolve_session_platform())
except Exception as exc:
return _err(rid, 4018, f"bundle dispatch failed: {exc}")
if not bundle_result:
return _err(rid, 4018, f"failed to load bundle: {bundle_key}")
msg, loaded_names, missing = bundle_result
bundle_name = bundles.get_skill_bundles().get(bundle_key, {}).get("name", bundle_key.lstrip("/"))
notice = f"⚡ Loading bundle: {bundle_name} ({len(loaded_names)} skills)"
notice += f"\nSkipped missing skills: {', '.join(missing)}" if missing else ""
# UIs render `display`, never `message`: the expanded body is model-facing scaffolding.
return _ok(rid, {"type": "send", "message": msg, "notice": notice, "display": _skill_scaffold_projection(msg)})
def _dispatch_skill(rid, params, session, name, arg):
with contextlib.suppress(Exception):
sc = _tools_mod("agent.skill_commands")
cmds, key = sc.scan_skill_commands(), f"/{name}"
if key in cmds:
msg = sc.build_skill_invocation_message(key, arg, task_id=session.get("session_key", "") if session else "")
if msg: # UIs render `display`, never `message`.
return _ok(rid, {
"type": "skill", "message": msg, "name": cmds[key].get("name", name),
"display": _skill_scaffold_projection(msg)})
return None
# Built-ins that queue onto _pending_input in the CLI; the TUI slash worker has no
# reader for that queue, so they are handled here and return a structured payload.
def _cmd_queue(rid, params, session, name, arg):
return _ok(rid, {"type": "send", "message": arg}) if arg else _err(rid, 4004, "usage: /queue <prompt>")
def _prompt_builtin(module: str, fn: str, kw: str = ""):
"""/learn, /plan, /init: submit ``module.fn(arg)`` as a normal turn (the live agent does the work)."""
def cmd(rid, params, session, name, arg):
build = getattr(_tools_mod(module), fn)
return _ok(rid, {"type": "send", "message": build(**{kw: arg}) if kw else build(arg)})
return cmd
_cmd_learn = _prompt_builtin("agent.learn_prompt", "build_learn_prompt")
_cmd_plan = _prompt_builtin("agent.plan_prompt", "build_plan_prompt")
_cmd_init = _prompt_builtin("hermes_cli.init_command", "build_init_prompt_for_cwd", kw="extra")
def _cmd_moa(rid, params, session, name, arg):
# One prompt through the default MoA preset, then restore the prior model (whole-session
# switching goes through the model picker).
try:
moa = _tools_mod("hermes_cli.moa_config")
if not arg:
return _err(rid, 4004, moa.moa_usage())
if not session:
return _err(rid, 4001, "no active session")
preset = moa.normalize_moa_config(_load_cfg().get("moa") or {})["default_preset"]
# Record the live identity for post-turn restore, then swap the agent's client in
# place: session["model_override"] alone never switches an already-built agent.
agent = session.get("agent")
# See #53444.
session["moa_one_shot_restore"] = {
"override": session.get("model_override"), "model": getattr(agent, "model", None),
"provider": getattr(agent, "provider", None)}
if agent is not None:
try: # persist_override=False: turn-scoped, never persist the MoA provider to config.yaml
_apply_model_switch(
params.get("session_id", ""), session, f"{preset} --provider moa",
confirm_expensive_model=False, pin_session_override=True, persist_override=False)
except Exception:
session.pop("moa_one_shot_restore", None)
raise
else: # lazy/fresh session: the override is consumed by the first build
session["model_override"] = {
"provider": "moa", "model": preset, "base_url": "moa://local",
"api_key": "moa-virtual-provider", "api_mode": "chat_completions"}
notice = f"MoA one-shot queued with preset {preset}; previous model will be restored after this turn."
return _ok(rid, {"type": "send", "notice": notice, "message": arg})
except Exception as exc:
return _err(rid, 5030, f"moa unavailable: {exc}")
def _cmd_focus(rid, params, session, name, arg):
# Display-only; routed through the config.set branch Ink uses so both surfaces share one state machine.
fv = _tools_mod("hermes_cli.focus_view")
display = _load_cfg().get("display")
display = display if isinstance(display, dict) else {}
action, target = fv.resolve_focus_arg(arg, cur := bool(display.get("focus_view", False)))
if action == "usage":
return _err(rid, 4004, "usage: /focus [on|off|status]")
if action == "status":
saved = display.get("focus_saved_tool_progress") or _load_tool_progress_mode()
return _exec_out(rid, fv.format_focus_status(cur, saved))
res = _methods["config.set"](
rid, {"key": "focus", "value": "on" if target else "off", "session_id": params.get("session_id", "")})
if "error" in res:
return res
tool_progress = (res.get("result") or {}).get("tool_progress") or "all"
return _exec_out(rid, fv.format_focus_toggle_message(bool(target), tool_progress))
def _cmd_retry(rid, params, session, name, arg):
if not session:
return _err(rid, 4001, "no active session to retry")
if busy := _busy_error(rid, session, "retry"):
return busy
cc = _tools_mod("agent.context_compressor")
with session["history_lock"]:
if busy := _busy_error(rid, session, "retry"):
return busy
if session.get("attached_images"):
return _err(rid, 4018, "retry cannot safely reconstruct or combine attached media")
history, user_indices, err = _rewind_prelude(rid, session, "retry", "no previous user message to retry")
if err:
return err
_prefix, live_view = cc.history_before_user_originated_turn(history, user_indices[-1])
try:
content = cc.retryable_user_text(live_view.get("content"))
except ValueError as exc:
return _err(rid, 4018, str(exc))
rewound, err = _rewind_or_err(
rid, session, len(user_indices) - 1, (4018, ""), "retry: failed to persist history: ", require_retryable=True)
if err:
return err
content = cc.retryable_user_text(rewound[1].get("content"))
return _ok(rid, {"type": "send", "message": content})
def _cmd_steer(rid, params, session, name, arg):
if not arg:
return _err(rid, 4004, "usage: /steer <prompt>")
agent = session.get("agent") if session else None
if agent and hasattr(agent, "steer"):
with contextlib.suppress(Exception):
if agent.steer(arg):
shown = f"{arg[:80]}{'...' if len(arg) > 80 else ''}"
return _exec_out(rid, f"⏩ Steer queued — arrives after the next tool call: {shown}")
return _ok(rid, {"type": "send", "message": arg}) # no active run: next-turn message
def _cmd_goal(rid, params, session, name, arg):
sid_key, goals, err = _session_key_or_err(rid, session, "hermes_cli.goals", "goals")
if err:
return err
try:
max_turns = int((_load_cfg().get("goals") or {}).get("max_turns", 20) or 20)
except Exception:
max_turns = 20
mgr = goals.GoalManager(session_id=sid_key, default_max_turns=max_turns)
lower = arg.strip().lower()
if not lower or lower == "status":
return _exec_out(rid, mgr.status_line())
if lower == "pause":
state = mgr.pause(reason="user-paused")
return _exec_out(rid, "No goal set." if state is None else f"⏸ Goal paused: {state.goal}")
if lower == "resume":
state = mgr.resume()
if state is None:
return _exec_out(rid, "No goal to resume.")
# Resume must restart work: `exec` is display-only, so return a `send`; `display`
# keeps model-facing scaffolding out of the transcript.
if not (prompt := mgr.next_continuation_prompt()):
return _exec_out(rid, f"▶ Goal resumed: {state.goal}")
notice = f"▶ Goal resumed: {state.goal}\nContinuing now — taking the next step."
return _ok(rid, {"type": "send", "notice": notice, "message": prompt, "display": "/goal resume"})
if lower in {"clear", "stop", "done"}:
had = mgr.has_goal()
mgr.clear()
return _exec_out(rid, "✓ Goal cleared." if had else "No active goal.")
# Remaining text = new goal. Client renders `notice`, submits `message`; the post-turn judge takes over.
try:
state = mgr.set(arg)
except ValueError as exc:
return _err(rid, 4004, f"invalid goal: {exc}")
notice = (
f"⊙ Goal set ({state.max_turns}-turn budget): {state.goal}\n"
"I'll keep working until the goal is done, you pause/clear it, or the budget is exhausted.\n"
"Controls: /goal status · /goal pause · /goal resume · /goal clear")
from hermes_cli.goals import goal_kick_prompt, last_user_message_from_db
kick = goal_kick_prompt(state.goal, last_user_message_from_db(getattr(mgr, "session_id", None)))
return _ok(rid, {"type": "send", "notice": notice, "message": kick})
def _cmd_loop(rid, params, session, name, arg):
sid_key, loops, err = _session_key_or_err(rid, session, "hermes_cli.loops", "loops")
if err:
return err
result = loops.dispatch_loop_command(loops.LoopManager(session_id=sid_key), arg)
output = result.get("output") or ""
if result.get("created"):
with contextlib.suppress(Exception):
if loops.goal_blocks_loop_tick(sid_key):
output += ("\nNote: an active /goal is driving this session — loop "
"wakeups defer until the goal finishes, pauses, or parks.")
return _exec_out(rid, output)
def _cmd_undo(rid, params, session, name, arg):
if not session:
# /undo [N]: back up N user turns (default 1), soft-delete the truncated rows on disk, and prefill
# the composer with the text of the user message we backed up to so it can be edited and
# resubmitted. N=1 is the Claude-Code-style single-step undo; /undo 3 backs up three user turns at
# once. See issue #21910.
return _err(rid, 4001, "no active session to undo")
if busy := _busy_error(rid, session, "undo"):
return busy
if not (session_key := session.get("session_key", "")):
return _err(rid, 4001, "no session key for undo")
arg_str = (arg or "").strip()
try:
n = max(int(arg_str.split()[0]), 1) if arg_str else 1
except (ValueError, IndexError):
return _err(rid, 4004, f"undo: invalid count {arg_str!r} — use /undo or /undo N")
with session["history_lock"]:
_history, user_indices, err = _rewind_prelude(rid, session, "undo", "no user messages to undo")
if err:
return err
turns_undone = min(n, len(user_indices))
rewound, err = _rewind_or_err(rid, session, len(user_indices) - turns_undone, (4004, "undo: "), "undo: ")
if err:
return err
active, live_view, rewound_count = rewound
target_text = _tools_mod("agent.message_content").flatten_message_text(live_view.get("content"))
# Notify memory providers (same hook /branch fires) with rewound=True so cached per-turn state invalidates.
agent = session.get("agent")
if agent is not None:
# See #6672 + #21910.
mm = getattr(agent, "_memory_manager", None)
for step in (
lambda: mm is not None and mm.on_session_switch(
session_key, parent_session_id="", reset=False, rewound=True),
lambda: hasattr(agent, "_invalidate_system_prompt") and agent._invalidate_system_prompt(),
lambda: hasattr(agent, "_last_flushed_db_idx") and setattr(agent, "_last_flushed_db_idx", len(active)),
):
with contextlib.suppress(Exception):
step()
turn_word = "turn" if turns_undone == 1 else "turns"
notice = f"↶ Undid {turns_undone} {turn_word} ({rewound_count} message(s)). Edit and resubmit, or send a new message."
return _ok(rid, {"type": "prefill", "message": target_text, "notice": notice})
def _is_snapshot_restore(arg: str) -> bool:
return (arg.split(maxsplit=1)[0].lower() if arg else "") in {"restore", "rewind"}
def _cmd_snapshot(rid, params, session, name, arg):
if not _is_snapshot_restore(arg):
return None
return _exec_out(
rid, "/snapshot restore is blocked in the TUI because it changes config/state on disk "
"while the live agent has cached settings. Run it in the classic CLI, then restart the TUI.")
def _cmd_compress(rid, params, session, name, arg):
if not session:
return _err(rid, 4001, "no active session to compress")
if busy := _busy_error(rid, session, "compress"):
return busy
sid = params.get("session_id", "")
if _session_uses_compute_host(session):
status, text = _compute_host_slash(sid, session, "compress", f"/{name}" + (f" {arg}" if arg else ""))
if status in {"failed", "rejected"}:
return _err(rid, 5019 if status == "failed" else 4009, text)
if status == "pending":
return _ok(rid, {"type": "exec", "status": "pending", "output": text})
return _exec_out(rid, text)
try:
output = _compress_live_with_feedback(sid, session, session["agent"], arg, snapshot_kwargs=True)
return _exec_out(rid, output)
except Exception as exc:
_tools_mod("agent.conversation_compression").finalize_context_engine_compression_notification(
session["agent"], committed=False)
return _err(rid, 5009, f"compress failed: {exc}")
_SLASH_BUILTINS = {
"queue": _cmd_queue, "q": _cmd_queue, "learn": _cmd_learn, "plan": _cmd_plan, "init": _cmd_init,
"moa": _cmd_moa, "focus": _cmd_focus, "retry": _cmd_retry, "steer": _cmd_steer, "goal": _cmd_goal,
"loop": _cmd_loop, "undo": _cmd_undo, "snapshot": _cmd_snapshot, "snap": _cmd_snapshot,
"compress": _cmd_compress, "compact": _cmd_compress}
@method("command.dispatch")
def _(rid, params: dict) -> dict:
name, arg = _resolve_name(params.get("name", "").lstrip("/")), params.get("arg", "")
session = _sessions.get(params.get("session_id", ""))
# Stage order is load-bearing: quick > plugin > bundle > skill > built-in.
stages = (_dispatch_quick, _dispatch_plugin, _dispatch_bundle, _dispatch_skill, _SLASH_BUILTINS.get(name))
for stage in filter(None, stages):
res = stage(rid, params, session, name, arg)
if res is not None:
if name in _SESSION_CONTROL_SLASHES and "error" not in res:
_publish_session_control_snapshot(params.get("session_id", ""), session)
return res
return _err(rid, 4018, f"not a quick/plugin/bundle/skill command: {name}")
@method("slash.exec")
def _(rid, params: dict) -> dict:
session, err = _sess_nowait(params, rid)
if err:
return err
cmd = params.get("command", "").strip()
if not cmd:
return _err(rid, 4004, "empty command")
# Skill/bundle and _PENDING_INPUT_COMMANDS must NOT reach the slash worker. Plugin
# commands also bypass it but return normal slash.exec output (TUI keeps the pager path).
parts = cmd.lstrip("/").split(maxsplit=1)
base = (parts[0] if parts else "").lower()
arg = parts[1] if len(parts) > 1 else ""
sid = params.get("session_id", "")
live_output = _live_slash_command_output(sid, session, base, arg)
if live_output is not None:
return _ok(rid, {"output": live_output or "(no output)"})
if base in _WORKER_BLOCKED_COMMANDS and _is_snapshot_restore(arg):
return _err(rid, 4018, "snapshot restore mutates live config/state; use command.dispatch for /snapshot restore")
# Pending-input built-ins route straight to command.dispatch (some clients fail the
# error-then-retry fallback); bundles go the same way under their resolved key.
target = base if base in _PENDING_INPUT_COMMANDS else _bundle_key_for(base)
if target is not None:
return _methods["command.dispatch"](rid, {"name": target.lstrip("/"), "arg": arg, "session_id": sid})
if _is_profile_skill_command(session, base):
return _err(rid, 4018, f"skill command: use command.dispatch for /{base}")
if plugin_handler := _plugin_command_handler(base) if base else None:
try:
return _ok(rid, {"output": _run_plugin_command(plugin_handler, arg) or "(no output)"})
except Exception as e:
return _ok(rid, {"output": f"Plugin command error: {e}"})
worker = session.get("slash_worker")
if not worker:
# slash.exec runs on the RPC pool: two concurrent commands could both see slash_worker=None
# and each fork a full MCP-fleet worker (the loser leaks). Serialize first-use spawn.
with _sessions_lock:
spawn_lock = session.setdefault("_slash_spawn_lock", threading.Lock())
with spawn_lock:
worker = session.get("slash_worker")
if not worker:
try:
worker = _SlashWorker(
session["session_key"], getattr(session.get("agent"), "model", _resolve_model()),
profile_home=session.get("profile_home"))
_attach_worker(sid, session, worker)
except Exception as e:
return _err(rid, 5030, f"slash worker start failed: {e}")
try:
payload = {"output": worker.run(cmd) or "(no output)"}
if warning := _mirror_slash_side_effects(sid, session, cmd):
payload["warning"] = warning
if base in _SESSION_CONTROL_SLASHES:
_publish_session_control_snapshot(sid, session)
return _ok(rid, payload)
except Exception as e:
with contextlib.suppress(Exception):
worker.close()
session["slash_worker"] = None
return _err(rid, 5030, str(e))
# ─── Insights / rollback / browser / config ──────────────────────────────────
@_rpc("insights.get", 5017)
def _(rid, params: dict) -> dict:
days = params.get("days", 30)
if (db := _get_db()) is None:
return _db_unavailable_error(rid, code=5017)
cutoff = time.time() - days * 86400
rows = [s for s in db.list_sessions_rich(limit=500, compact_rows=True) if (s.get("started_at") or 0) >= cutoff]
return _ok(rid, {"days": days, "sessions": len(rows), "messages": sum(s.get("message_count", 0) for s in rows)})
@_rpc("rollback.list", live_session=True, fail_code=5020)
def _(rid, params: dict, session) -> dict:
def go(mgr, cwd):
if not mgr.enabled:
return _ok(rid, {"enabled": False, "checkpoints": []})
keys = ("hash", "timestamp", "message")
rows = [{k: c.get(k, "") for k in keys} for c in mgr.list_checkpoints(cwd)]
return _ok(rid, {"enabled": True, "checkpoints": rows})
return _with_checkpoints(session, go)
@_rpc("rollback.restore", live_session=True, fail_code=5021)
def _(rid, params: dict, session) -> dict:
target, file_path = params.get("hash", ""), params.get("file_path", "")
if not target:
return _err(rid, 4014, "hash required")
# Full-history rollback mutates session history → rejected mid-turn (prompt.submit
# would drop the agent's output or clobber it). File-scoped only touches disk.
if not file_path and session.get("running"):
return _err(rid, 4009, "session busy — /interrupt the current turn before full rollback.restore")
def go(mgr, cwd):
result = mgr.restore(cwd, _resolve_checkpoint_hash(mgr, cwd, target), file_path=file_path or None)
if result.get("success") and not file_path:
removed = 0
with session["history_lock"]:
_history, user_indices = _user_turn_indices(session)
if user_indices:
try:
removed = _rewind_active_session_history(session, len(user_indices) - 1)[2]
except Exception as exc:
raise RuntimeError(f"checkpoint restored, but session history rewind failed: {exc}") from exc
result["history_removed"] = removed
return result
return _ok(rid, _with_checkpoints(session, go))
@_rpc("rollback.diff", live_session=True, fail_code=5022)
def _(rid, params: dict, session) -> dict:
if not (target := params.get("hash", "")):
return _err(rid, 4014, "hash required")
r = _with_checkpoints(session, lambda mgr, cwd: mgr.diff(cwd, _resolve_checkpoint_hash(mgr, cwd, target)))
raw = r.get("diff", "")[:4000]
payload = {"stat": r.get("stat", ""), "diff": raw}
if rendered := render_diff(raw, session.get("cols", 80)):
payload["rendered"] = rendered
return _ok(rid, payload)
@method("browser.manage")
def _(rid, params: dict) -> dict:
action = params.get("action", "status")
if action == "status":
url = _resolve_browser_cdp_url()
return _ok(rid, {"connected": bool(url), "url": url})
if action == "disconnect":
return _browser_disconnect(rid)
if action == "connect":
return _browser_connect(rid, params)
return _err(rid, 4015, f"unknown action: {action}")
@_rpc("config.show", 5030)
def _(rid, params: dict) -> dict:
cfg = _load_cfg()
api_key = _tools_mod("agent.secret_scope").get_secret("HERMES_API_KEY", "") or cfg.get("api_key", "")
masked = f"****{api_key[-4:]}" if len(api_key) > 4 else "(not set)"
base_url = os.environ.get("HERMES_BASE_URL", "") or cfg.get("base_url", "")
sections = [
{"title": "Model", "rows": [
["Model", _resolve_model()], ["Base URL", base_url or "(default)"], ["API Key", masked]]},
{"title": "Agent", "rows": [
["Max Turns", str(_cfg_max_turns(cfg, 500))],
["Toolsets", ", ".join(cfg.get("enabled_toolsets", [])) or "all"],
["Verbose", str(cfg.get("verbose", False))]]},
{"title": "Environment", "rows": [["Working Dir", os.getcwd()], ["Config File", str(_hermes_home / "config.yaml")]]},
]
return _ok(rid, {"sections": sections})
# ─── Tools / toolsets / agents ───────────────────────────────────────────────
@_rpc("tools.show", 5034)
def _(rid, params: dict) -> dict:
mt = _tools_mod("model_tools")
session = _sessions.get(params.get("session_id", ""))
enabled = getattr(session["agent"], "enabled_toolsets", None) if session else _load_enabled_toolsets()
# Pre-assembly list: /tools must also show tools deferred behind the tool_search bridge (as the CLI).
tools = mt.get_tool_definitions(enabled_toolsets=enabled, quiet_mode=True, skip_tool_search_assembly=True)
sections = {}
for tool in sorted(tools, key=lambda t: t["function"]["name"]):
name = tool["function"]["name"]
desc = str(tool["function"].get("description", "") or "").split("\n")[0]
if ". " in desc:
desc = desc[: desc.index(". ") + 1]
sections.setdefault(mt.get_toolset_for_tool(name) or "unknown", []).append({"name": name, "description": desc})
sections_out = [{"name": n, "tools": rows} for n, rows in sorted(sections.items())]
return _ok(rid, {"sections": sections_out, "total": len(tools)})
@_rpc("tools.configure", 5035)
def _(rid, params: dict) -> dict:
action = str(params.get("action", "") or "").strip().lower()
targets = [str(name).strip() for name in params.get("names", []) or [] if str(name).strip()]
if action not in {"disable", "enable"}:
return _err(rid, 4017, f"unknown tools action: {action}")
if not targets:
return _err(rid, 4018, "names required")
hc, tc = _tools_mod("hermes_cli.config"), _tools_mod("hermes_cli.tools_config")
cfg = hc.load_config()
valid_toolsets = {ts_key for ts_key, _, _ in tc.CONFIGURABLE_TOOLSETS} | tc._get_plugin_toolset_keys()
mcp_targets = [name for name in targets if ":" in name]
unknown = [name for name in targets if ":" not in name and name not in valid_toolsets]
toolset_targets = [name for name in targets if ":" not in name and name in valid_toolsets]
if toolset_targets:
tc._apply_toolset_change(cfg, "cli", toolset_targets, action)
missing_servers = tc._apply_mcp_change(cfg, mcp_targets, action) if mcp_targets else set()
hc.save_config(cfg)
sid = params.get("session_id", "")
session = _sessions.get(sid)
info = _reset_session_agent(sid, session) if session else None
enabled = sorted(tc._get_platform_tools(hc.load_config(), "cli", include_default_mcp_servers=False))
changed = [
name for name in targets
if name not in unknown and (":" not in name or name.split(":", 1)[0] not in missing_servers)]
return _ok(rid, {
"changed": changed, "enabled_toolsets": enabled, "info": info,
"missing_servers": sorted(missing_servers), "reset": bool(session), "unknown": unknown})
# ─── Cron / learning / skills ────────────────────────────────────────────────
@_scoped_rpc("cron.manage", 5023)
def _(rid, params: dict) -> dict:
"""cronjob() keys off HERMES_HOME, so ``profile`` reaches a per-profile cron store."""
cronjob = _tools_mod("tools.cronjob_tools").cronjob
action, jid = params.get("action", "list"), params.get("name", "")
if action == "list":
# Paused jobs are excluded by default (reads as deletion in a toggle UI) — forward the flag.
include_disabled = is_truthy_value(params.get("include_disabled", False))
result = json.loads(cronjob(action="list", include_disabled=include_disabled))
# ``scoped`` proves the profile scope was honored; older gateways omit it and clients
# keep the safe [bot:<name>] filter.
if profile := _str_arg(params, "profile"):
result["scoped"] = profile
return _ok(rid, result)
if action == "add":
# Optional repeat / continuity / deliver ('bot-chat[:name]'): None keeps each cronjob() default.
raw = cronjob(
action="create", name=jid, schedule=params.get("schedule", ""), prompt=params.get("prompt", ""),
repeat=int(params["repeat"]) if str(params.get("repeat", "")).strip().isdigit() else None,
continuity=is_truthy_value(params.get("continuity")) if params.get("continuity") is not None else None,
deliver=_str_arg(params, "deliver") or None)
return _ok(rid, json.loads(raw))
if action in {"remove", "pause", "resume"}:
return _ok(rid, json.loads(cronjob(action=action, job_id=jid)))
return _err(rid, 4016, f"unknown cron action: {action}")
@_rpc("learning.frames", 5000, "learning.frames failed: ")
def _(rid, params: dict) -> dict:
"""Pre-render the ``/journey`` timeline (frames + legend/summary metadata) so Ink walks it locally."""
try:
cols, rows, frames = (
int(params.get(k, d) or d) for k, d in (("cols", 80), ("rows", 24), ("frames", 48)))
except (TypeError, ValueError):
cols, rows, frames = 80, 24, 48
graph = _tools_mod("agent.learning_graph").build_learning_graph()
render_frames = _tools_mod("agent.learning_graph_render").render_frames
return _ok(rid, render_frames(graph, cols=max(20, cols), rows=max(10, rows), frames=frames))
def _learning_mutation(fn_name: str, arg_keys: tuple):
"""learning.* body: ``agent.learning_mutations.<fn_name>(*str(params[k]) for k in arg_keys)``."""
def body(rid, params: dict) -> dict:
fn = getattr(_tools_mod("agent.learning_mutations"), fn_name)
return _ok(rid, fn(*(str(params.get(k, "")) for k in arg_keys)))
return body
# detail → node content for an edit prefill; delete → skills archived (restorable), memories
# removed; edit → rewrite a node's content (SKILL.md or memory chunk).
for _name, _fn, _keys in (
("detail", "node_detail", ("id",)), ("delete", "delete_node", ("id",)), ("edit", "edit_node", ("id", "content")),
):
_rpc(f"learning.{_name}", 5000, f"learning.{_name} failed: ")(_learning_mutation(_fn, _keys))
del _name, _fn, _keys
def _skills_search(rid, params, query):
search, gh = _tools_mod("tools.skills_hub_search"), _tools_mod("tools.skills_hub_github")
raw = search.unified_search(query, search.create_source_router(gh.GitHubAuth()), source_filter="all", limit=20) or []
return _ok(rid, {"results": [{"name": r.name, "description": r.description} for r in raw]})
def _skills_install(rid, params, query):
quiet = _tools_mod("types").SimpleNamespace(print=lambda *a, **k: None)
_tools_mod("hermes_cli.skills_hub").do_install(query, skip_confirm=True, console=quiet)
return _ok(rid, {"installed": True, "name": query})
def _skills_browse(rid, params, query):
pg = int(params.get("page", 0) or 0) or (int(query) if query.isdigit() else 1)
browse = _tools_mod("hermes_cli.skills_hub").browse_skills
return _ok(rid, browse(page=pg, page_size=int(params.get("page_size", 20))))
_SKILLS_ACTIONS = {
"list": lambda rid, params, query: _ok(rid, {"skills": _tools_mod("hermes_cli.banner").get_available_skills()}),
"search": _skills_search, "install": _skills_install, "browse": _skills_browse,
"inspect": lambda rid, params, query: _ok(
rid, {"info": _tools_mod("hermes_cli.skills_hub").inspect_skill(query) or {}})}
def _run_action(rid, params: dict, table: dict, label: str, *extra) -> dict:
"""Dispatch ``params['action']`` (default ``list``) through ``table``; unknown → 4017."""
action = params.get("action", "list")
handler = table.get(action)
if handler is None:
return _err(rid, 4017, f"unknown {label} action: {action}")
return handler(rid, params, *extra)
@_scoped_rpc("skills.manage")
def _(rid, params: dict) -> dict:
"""list/install use the scoped profile's skills dir; search/browse/inspect hit the shared hub."""
return _run_action(rid, params, _SKILLS_ACTIONS, "skills", params.get("query", ""))
@_rpc("skills.reload", 5025)
def _(rid, params: dict) -> dict:
result = _tools_mod("agent.skill_commands").reload_skills()
added, removed = result.get("added") or [], result.get("removed") or []
lines = ["Reloading skills..."] + ([] if added or removed else ["No new skills detected."])
for label, items in (("Added skills:", added), ("Removed skills:", removed)):
if items:
lines.append(label)
lines.extend(f" - {item.get('name', '')}" for item in items)
lines.append(f"{int(result.get('total') or 0)} skill(s) available")
return _ok(rid, {"output": "\n".join(lines), "result": result})
# ─── MCP catalog + per-profile server lifecycle (mcp.servers.*) ─────────────
# Gateway mirrors of the dashboard REST surface (hermes_cli/web_routers/mcp.py) so a
# desktop plugin can manage MCP servers for ANY profile. Persistence: hermes_cli/mcp_config.py.
@_scoped_rpc("mcp.catalog")
def _(rid, params: dict) -> dict:
"""``{servers: [{name, description, installed, enabled, requires: [env keys], transport}]}`` per profile."""
mcp_catalog = _tools_mod("hermes_cli.mcp_catalog")
out = []
for entry in mcp_catalog.list_catalog():
try:
requires = [str(k) for k in (getattr(entry, "env_keys", None) or [])]
except Exception:
requires = []
transport = getattr(entry, "transport", None) # TransportSpec → its kind string
out.append({
"name": entry.name, "description": getattr(entry, "description", "") or "",
"installed": bool(mcp_catalog.is_installed(entry.name)),
"enabled": bool(mcp_catalog.is_enabled(entry.name)), "requires": requires,
"transport": str(getattr(transport, "kind", "") or transport or "stdio")})
return _ok(rid, {"servers": out})
@_mcp_rpc("list", required=())
def _(rid, params: dict) -> dict:
"""``{servers: [{name, transport, url, command, args, env (key names), auth, oauth_tokens_present,
enabled, tools}]}``"""
servers = _tools_mod("hermes_cli.mcp_config")._get_mcp_servers()
return _ok(rid, {"servers": [_mcp_summarize_server(name, cfg) for name, cfg in sorted(servers.items())]})
@_mcp_rpc("status", required=())
def _(rid, params: dict) -> dict:
"""``{servers: [{name, transport, tools, connected, disabled, status}], checked_at}`` from cached
runtime state; never connects, probes, or starts auth. Under a multiplexer the runtime view is the
scoped profile's; otherwise it is shown only when ``profile`` is the launch profile."""
import time
hc = _tools_mod("hermes_constants")
configured = _tools_mod("hermes_cli.mcp_config")._get_mcp_servers()
include_runtime = (_tools_mod("agent.secret_scope").is_multiplex_active()
or hc.hermes_home_key() == hc.hermes_home_key(hc.get_process_hermes_home()))
safe = ("name", "transport", "tools", "connected", "disabled", "status")
servers = _tools_mod("tools.mcp_tool_discovery").get_mcp_status(configured, include_runtime=include_runtime)
return _ok(rid, {"servers": [{k: e[k] for k in safe if k in e} for e in servers],
"checked_at": int(time.time() * 1000)})
@_mcp_rpc("add")
def _(rid, params: dict) -> dict:
"""Add ``name`` from ``preset`` (catalog id) and/or ``config`` (url/command/args/env/headers/auth/
tools); ``bearer_token`` goes to the profile's .env (only the header template persists). Dup → 4090."""
mc = _tools_mod("hermes_cli.mcp_config")
name, preset = _str_arg(params, "name"), _str_arg(params, "preset")
if name in mc._get_mcp_servers():
return _err(rid, 4090, f"server '{name}' already exists")
raw_cfg = params.get("config")
server_config: dict = dict(raw_cfg) if isinstance(raw_cfg, dict) else {}
if preset: # fills url/command/args when omitted; mutates server_config in place
mc._apply_mcp_preset(
name, preset_name=preset, url=server_config.get("url"), command=server_config.get("command"),
cmd_args=list(server_config.get("args") or []), server_config=server_config)
if not server_config.get("url") and not server_config.get("command"):
return _err(rid, 4063, "config must specify a 'url' (http) or 'command' (stdio), or a valid 'preset'")
if bearer_token := params.get("bearer_token"):
server_config["headers"] = mc._save_bearer_auth_token(name, str(bearer_token))
if not mc._save_mcp_server(name, server_config):
return _err(rid, 4001, f"server '{name}' rejected: suspicious command/args configuration")
saved = mc._get_mcp_servers().get(name, server_config)
return _ok(rid, {"ok": True, "name": name, "server": _mcp_summarize_server(name, saved)})
@_mcp_rpc("set_api_key", (*_NAME, ("value", _nonempty)))
def _(rid, params: dict) -> dict:
"""Secret → profile .env under ``env_var`` (default ``MCP_<NAME>_API_KEY``); config.yaml gets only
a ``${ENV}`` reference (Bearer header for http, ``env`` entry for stdio)."""
hc, mc = _tools_mod("hermes_cli.config"), _tools_mod("hermes_cli.mcp_config")
name, servers, err = _mcp_named_server(rid, params)
if err:
return err
value = params.get("value")
env_var = _str_arg(params, "env_var") or mc._env_key_for_server(name)
entry = servers[name]
if not isinstance(entry, dict):
return _err(rid, 4001, "malformed server config")
if entry.get("url"):
normalized = mc._strip_bearer_prefix(str(value))
if not normalized or normalized.lower() == "bearer":
return _err(rid, 4063, "value is not a valid credential")
hc.save_env_value(env_var, normalized)
is_default = env_var == mc._env_key_for_server(name)
entry["headers"] = (
mc._bearer_auth_headers(name) if is_default else {"Authorization": f"Bearer ${{{env_var}}}"})
else:
hc.save_env_value(env_var, str(value))
env_block = entry.get("env")
entry["env"] = env_block if isinstance(env_block, dict) else {}
entry["env"][env_var] = f"${{{env_var}}}"
cfg = hc.load_config()
cfg.setdefault("mcp_servers", {})[name] = entry
hc.save_config(cfg)
return _ok(rid, {"ok": True, "name": name, "env_var": env_var, "server": _mcp_summarize_server(name, entry)})
@_mcp_rpc("test")
def _(rid, params: dict) -> dict:
"""Connect, list tools, disconnect → ``{ok, tools, prompts, resources, oauth_needed,
oauth_tokens_present}`` (``{ok: false, error, tools: []...}`` on failure). RPC pool: cold npx blocks."""
mc = _tools_mod("hermes_cli.mcp_config")
name, servers, err = _mcp_named_server(rid, params)
if err:
return err
cfg = servers[name]
# An `auth: oauth` server serving tools/list anonymously would probe OK with no
# token — a false green. Require a token on disk for it.
needs_oauth_token = cfg.get("auth") == "oauth"
details: dict = {}
def failure(error: str, oauth_needed: bool, tokens_present) -> dict:
return _ok(rid, {"ok": False, "error": error, "tools": [], "oauth_needed": oauth_needed,
"oauth_tokens_present": tokens_present})
try:
tools = mc._probe_single_server(name, cfg, details=details)
token_present = mc._oauth_tokens_present(name) if needs_oauth_token else True
except Exception as exc:
return failure(str(exc), needs_oauth_token, mc._oauth_tokens_present(name) if needs_oauth_token else None)
if not token_present:
return failure("OAuth authentication required — no token found.", True, False)
return _ok(rid, {
"ok": True, "tools": [{"name": t, "description": d} for t, d in tools],
"prompts": details.get("prompts", 0), "resources": details.get("resources", 0),
"oauth_needed": needs_oauth_token, "oauth_tokens_present": True if needs_oauth_token else None})
@_mcp_rpc("remove")
def _(rid, params: dict) -> dict:
"""Remove a server from the profile's config.yaml → ``{ok: true, removed: true}``."""
name = _str_arg(params, "name")
if not _tools_mod("hermes_cli.mcp_config")._remove_mcp_server(name):
return _err(rid, 4064, f"server '{name}' not found")
return _ok(rid, {"ok": True, "removed": True})
@_mcp_rpc("oauth.start")
def _(rid, params: dict) -> dict:
"""Begin a session-backed OAuth flow → ``{ok, session_id, auth_url, flow: "pkce"}``; the client
opens ``auth_url`` and polls ``mcp.servers.oauth.poll``. With ``client_redirect_uri`` the CLIENT
hosts the loopback and relays the code via ``mcp.servers.oauth.callback`` (desktop and gateway
on different machines). Runs on the RPC pool (_LONG_HANDLERS)."""
client_redirect_uri = _str_arg(params, "client_redirect_uri") or None
try:
name, servers, err = _mcp_named_server(rid, params)
if err:
return err
cfg = dict(servers[name])
if not cfg.get("url"):
return _err(rid, 4001, "stdio servers authenticate via env keys, not OAuth")
if cfg.get("headers") and cfg.get("auth") != "oauth":
return _err(rid, 4001, "this server uses header/API-key auth, not OAuth")
cfg["auth"] = "oauth"
hermes_home = str(_tools_mod("hermes_constants").get_hermes_home().expanduser().resolve(strict=False))
result = _tools_mod("tui_gateway.mcp_oauth_sessions").start_flow(
hermes_home, name, cfg, client_redirect_uri=client_redirect_uri)
except ValueError as e:
return _err(rid, 4001, str(e))
return _ok(rid, {"ok": True, **{k: result[k] for k in ("session_id", "auth_url", "flow")}})
@_mcp_rpc("oauth.poll", _NAME_SESSION)
def _(rid, params: dict) -> dict:
"""Poll a flow → ``{ok, status: pending|approved|error, ...}``; ``approved`` persists tokens per profile."""
poll = _tools_mod("tui_gateway.mcp_oauth_sessions").poll_flow
return _ok(rid, {"ok": True, **poll(_str_arg(params, "session_id"), _str_arg(params, "name"))})
@_mcp_rpc("oauth.callback", _NAME_SESSION)
def _(rid, params: dict) -> dict:
"""Relay a client-captured redirect (``code``/``state``/``error``) into a ``client_redirect_uri`` flow."""
code, state, error = (str(params.get(k) or "") or None for k in ("code", "state", "error"))
deliver = _tools_mod("tui_gateway.mcp_oauth_sessions").deliver_callback_flow
return _ok(rid, deliver(
_str_arg(params, "session_id"), _str_arg(params, "name"), code=code, state=state, error=error))
# ─── Plugins ─────────────────────────────────────────────────────────────────
def _plugin_rows() -> list[dict]:
pc = _tools_mod("hermes_cli.plugins_cmd")
enabled, disabled = pc._get_enabled_set(), pc._get_disabled_set()
out = []
for name, version, desc, source, _dir, key in sorted(pc._discover_all_plugins()):
status = pc._plugin_status(name, enabled, disabled, key=key)
# Bundled backends/platforms/providers run without an explicit enable: report the
# truthful default instead of "not enabled" (reads as OFF).
if status == "not enabled" and source == "bundled" and pc._bundled_default_on(_dir):
status = "enabled"
# key = canonical registry key (names collide across category dirs); portable = Agent Plugins v1.
out.append({
"name": name, "key": key, "version": str(version or ""), "description": desc or "",
"source": source, "status": status, "portable": pc._is_portable_plugin_dir(_dir)})
return out
def _plugins_list(rid, params):
rows = _plugin_rows()
user_count = sum(1 for r in rows if r["source"] != "bundled")
return _ok(rid, {"plugins": rows, "user_count": user_count, "bundled_count": len(rows) - user_count})
def _plugins_toggle(rid, params):
# Prefer the canonical key — bare names are ambiguous across categories.
ident = (params.get("key") or params.get("name") or "").strip()
if not ident:
return _err(rid, 4019, "plugins.toggle requires a 'key' or 'name'")
toggle = _tools_mod("hermes_cli.plugins_cmd").dashboard_set_agent_plugin_enabled
result = toggle(ident, enabled=bool(params.get("enable")))
if not result.get("ok"):
return _err(rid, 5026, result.get("error") or "toggle failed")
row = next((r for r in _plugin_rows() if ident in (r["key"], r["name"])), None)
return _ok(rid, {"ok": True, "unchanged": bool(result.get("unchanged")), "name": ident, "plugin": row})
def _plugins_install(rid, params):
ident = (params.get("identifier") or params.get("repo") or "").strip()
if not ident:
return _err(rid, 4019, "plugins.install requires 'identifier' or 'repo'")
result = _tools_mod("hermes_cli.plugins_cmd").dashboard_install_plugin(
ident, force=bool(params.get("force")), enable=params.get("enable", True))
return _ok(rid, result) if result.get("ok") else _err(rid, 5026, result.get("error") or "install failed")
_PLUGINS_ACTIONS = {"list": _plugins_list, "toggle": _plugins_toggle, "install": _plugins_install}
@_scoped_rpc("plugins.manage", 5026, catch_resolve=False)
def _(rid, params: dict) -> dict:
"""TUI Plugins Hub backend (shares primitives with ``hermes plugins`` / the dashboard):
``list`` → {plugins, user_count, bundled_count}; ``toggle`` flips ``key``/``name`` per ``enable``;
``install`` git-clones ``identifier``/``repo`` (``force``, ``enable`` default True)."""
return _run_action(rid, params, _PLUGINS_ACTIONS, "plugins")
@method("shell.exec")
def _(rid, params: dict) -> dict:
cmd = params.get("command", "")
if not cmd:
return _err(rid, 4004, "empty command")
try:
approval = _tools_mod("tools.approval_detection")
is_hardline, hardline_desc = approval.detect_hardline_command(cmd)
if is_hardline:
return _err(rid, 4005, f"blocked (hardline): {hardline_desc}. Use the agent for dangerous commands.")
is_dangerous, _, desc = approval.detect_dangerous_command(cmd)
if is_dangerous:
return _err(rid, 4005, f"blocked: {desc}. Use the agent for dangerous commands.")
except ImportError:
return _err(rid, 5001, "shell.exec unavailable: approval safety module not importable")
return _captured_exec(
rid, cmd, 30, shell=True, fail_code=5003, timeout_err=(5002, "command timed out (30s)"),
on_result=lambda r: _ok(rid, {"stdout": r.stdout[-4000:], "stderr": r.stderr[-2000:], "code": r.returncode}))
def register(server) -> None:
"""Rebind this module's helpers + handlers onto ``server`` and register the handlers."""
bind_module(globals(), server, skip=("_",))