Files
hermes-agent/tui_gateway/methods_tools.py
T

1606 lines
72 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):
"""Wrap a handler body with the optional ``profile`` HERMES_HOME scope.
Order: ``required`` params checked first (4063 ``<key> required``), then the
profile resolved (4064 when its dir is missing), then the body; body exceptions
become ``fail_code`` (message prefixed with ``prefix``). ``catch_resolve`` also maps
resolve-time exceptions to ``fail_code`` (cron/skills/catalog); mcp.servers.* let
them propagate to dispatch(). The override is always reset afterwards.
``scoped=False`` (see ``_guarded``) ignores ``profile`` entirely.
"""
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")
profile = str(params.get("profile") or "").strip() if scoped else ""
token = None
if profile:
try:
from hermes_cli.profiles import get_profile_dir
from hermes_constants import set_hermes_home_override
profile_dir = get_profile_dir(profile)
if not profile_dir or not profile_dir.is_dir():
return _err(rid, 4064, f"profile '{profile}' not found")
token = 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(rid, params)
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 = ""):
"""Handler body exceptions → ``_err(rid, fail_code, prefix + str(e))``."""
return _profile_scoped_rpc(fail_code, prefix=prefix, scoped=False)
def _live_session_guarded(fail_code: int):
"""Resolve the session via ``_sess`` (waits for the agent build) and call
``body(rid, params, session)``; body exceptions → ``fail_code``."""
def deco(body):
def handler(rid, params: dict) -> dict:
session, err = _sess(params, rid)
if err:
return err
try:
return body(rid, params, session)
except Exception as e:
return _err(rid, fail_code, str(e))
handler.__doc__ = body.__doc__
return handler
return deco
def _stripped(v) -> bool:
return bool(str(v or "").strip())
def _nonempty(v) -> bool:
return not (v is None or str(v) == "")
_NAME = (("name", _stripped),)
_NAME_SESSION = (("name", _stripped), ("session_id", _stripped))
def _mcp_server_scoped(body):
"""mcp.servers.* contract: ``name`` required, profile scope, body errors → 5024."""
return _profile_scoped_rpc(5024, required=_NAME, catch_resolve=False)(body)
def _mcp_named_server(rid, params):
"""(name, servers, None) for a configured server, else (name, servers, 4064 error)."""
from hermes_cli.mcp_config import _get_mcp_servers
name = str(params.get("name") or "").strip()
servers = _get_mcp_servers()
err = None if name in servers else _err(rid, 4064, f"server '{name}' not found")
return name, servers, err
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):
"""(session_key, None) or (None, 4001 error) for the /goal and /loop managers."""
if not session:
return None, _err(rid, 4001, "no active session")
sid_key = session.get("session_key") or ""
if not sid_key:
return None, _err(rid, 4001, "no session key")
return sid_key, None
def _user_turn_indices(session):
"""(history, indices of user-originated turns) minus ephemeral scaffolding. Call under history_lock."""
from agent.context_compressor import user_originated_turn_view
history = _history_without_ephemeral_scaffolding(session.get("history", []))
return history, [i for i, m in enumerate(history) if user_originated_turn_view(m) is not None]
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:
"""subprocess.run kwargs shared by cli.exec / shell.exec / quick commands: captured
text, UTF-8 + lossy decode (non-UTF-8 child output must not crash the gateway thread
on locale-mismatched Windows), no stdin, no console flash under the desktop parent."""
from hermes_cli._subprocess_compat import windows_hide_flags
return dict(
capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=timeout,
stdin=subprocess.DEVNULL, creationflags=windows_hide_flags())
def _captured_exec(rid, cmd, timeout: int, *, on_result, timeout_err: tuple, fail_code: int, **kw) -> 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(), **kw, **_capture_run_kwargs(timeout)))
except subprocess.TimeoutExpired:
return _err(rid, *timeout_err)
except Exception as e:
return _err(rid, fail_code, str(e))
def _toolset_rows(params: dict, *, with_tools: bool) -> list[dict]:
from toolsets import get_all_toolsets, get_toolset_info
session = _sessions.get(params.get("session_id", ""))
enabled = (
set(getattr(session["agent"], "enabled_toolsets", []) or []) if session else set(_load_enabled_toolsets() or [])
)
items = []
for name in sorted(get_all_toolsets().keys()):
info = get_toolset_info(name)
if not info:
continue
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:
from agent.battery import battery_category, read_battery
batt = read_battery()
return _ok(
rid,
{
"available": batt.available,
"percent": batt.percent,
"plugged": batt.plugged,
"category": battery_category(batt)})
except Exception:
return _ok(rid, {"available": False, "percent": None, "plugged": None, "category": "dim"})
@method("process.stop")
@_guarded(5010)
def _(rid, params: dict) -> dict:
from tools.process_registry import process_registry
return _ok(rid, {"killed": process_registry.kill_all()})
@method("process.list")
@_live_session_guarded(5010)
def _(rid, params: dict, session) -> dict:
"""Session-scoped view of the background process registry (desktop status stack)."""
return _ok(rid, {"processes": _session_processes(session)})
@method("process.kill")
@_live_session_guarded(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")
from tools.process_registry import process_registry
proc = process_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, process_registry.kill_process(proc_id))
def _mcp_reload_confirm_required() -> bool:
"""``approvals.mcp_reload_confirm`` from disk config; True (safe) on any failure."""
try:
from hermes_cli.config import load_config
cfg = 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
@method("reload.mcp")
@_guarded(5015)
def _(rid, params: dict) -> dict:
session = _sessions.get(params.get("session_id", ""))
# /reload-mcp invalidates the prompt cache: without confirm=true, honour
# ``approvals.mcp_reload_confirm`` (default true) by returning confirm_required;
# 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})
from tools.mcp_tool import shutdown_mcp_servers, discover_mcp_tools, reprobe_tool_availability
def _refresh_session_agent() -> None:
"""Rebuild THIS session's cached tool snapshot from the live registry and push
session.info (the agent never re-reads the registry itself; mirrors
gateway/run.py::_execute_mcp_reload). 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:
from tools.mcp_tool import refresh_agent_mcp_tools
# enabled_override re-resolves toolsets so a server enabled in config this session is picked up.
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))
global _mcp_reload_gen, _mcp_reload_loaded_rev
# Revision the CALLER wants loaded (the mcp_rev its poll observed); empty on
# legacy clients / manual /reload-mcp, which coalesce on generation alone.
req_rev = str(params.get("rev") or "")
def _do_full_reload() -> None:
"""shutdown+discover+refresh under the lock, then mark a completed generation.
The lock spans the refresh too, else a second reload could tear the registry
down mid-rebuild. Config can change WHILE discover connects: re-hash after
discovery 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):
shutdown_mcp_servers()
reprobe_tool_availability()
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 snapshots
# the generation, 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 the full reload so a
# failed/stale leader never leaves a follower acking an unloaded revision.
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:
leader_completed = _mcp_reload_gen > gen_before
rev_satisfied = not req_rev or req_rev == _mcp_reload_loaded_rev
if leader_completed and rev_satisfied:
_refresh_session_agent()
coalesced = True
else:
_do_full_reload()
coalesced = False
return _finish_reload(rid, params, coalesced=coalesced)
@method("reload.env")
@_guarded(5015)
def _(rid, params: dict) -> dict:
"""Re-read ``~/.hermes/.env`` (classic CLI ``/reload`` parity). Already-built agents
keep their credential pool / provider routing; ``/new`` resolves fresh."""
from hermes_cli.config import reload_env
return _ok(rid, {"updated": int(reload_env())})
# ─── 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 bucket(self, cat: str) -> list[list[str]]:
return self.cat_map.setdefault(cat, [])
def add(self, key: str, desc: str, cat: str) -> None:
self.canon[key.lower()] = key
self.pairs.append([key, desc])
self.bucket(cat).append([key, desc])
def _catalog_registry(cat: _Catalog) -> None:
from hermes_cli.commands import COMMAND_REGISTRY, _build_description, command_desktop_meta
for cmd in COMMAND_REGISTRY:
meta = command_desktop_meta(cmd)
for key in (cmd.name, *cmd.aliases):
cat.commands[f"/{key}"] = dict(meta)
if cmd.name in _TUI_HIDDEN or cmd.gateway_only:
continue
cat.add(f"/{cmd.name}", _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.bucket("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', '')}"
}.get(qtype, qtype or "quick command")
cat.add(f"/{qname}", _clip(str(qc.get("description") or default_desc)), "User commands")
def _catalog_plugin_commands(cat: _Catalog) -> None:
from hermes_cli.plugins import get_plugin_commands
plugin_cmds = get_plugin_commands() or {}
if plugin_cmds:
cat.bucket("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}}`` (usage + origin ride
along — not a second RPC — because every catalog consumer also ranks by them)."""
from agent.skill_commands import scan_skill_commands
usage, origin_of = _skill_usage_lookup()
for k, info in sorted(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)}
@method("commands.catalog")
@_guarded(5020)
def _(rid, params: dict) -> dict:
"""Registry-backed slash metadata for the TUI — categorized, no aliases. Discovery
failures land in ``warning`` (skills' message wins, then quick commands', then plugins')."""
from hermes_cli.commands import SUBCOMMANDS
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 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": ""})
def done(r):
out = "\n".join(p for p in (r.stdout or "", r.stderr or "") if p).strip() or "(no output)"
return _ok(rid, {"blocked": False, "code": r.returncode, "output": out[:48_000]})
# 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=done, timeout_err=(5016, "cli.exec: timeout"), fail_code=5017,
env=hermes_subprocess_env(inherit_credentials=True))
@method("command.resolve")
@_guarded(5012)
def _(rid, params: dict) -> dict:
from hermes_cli.commands import resolve_command
r = 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):
qcmds = _load_cfg().get("quick_commands", {})
if name not in qcmds:
return None
qc = qcmds[name]
if qc.get("type") == "exec":
# Sanitized env: the TUI server process holds every API key in os.environ.
from tools.environments.local import build_subprocess_env
sanitized_env = build_subprocess_env()
r = subprocess.run(qc.get("command", ""), shell=True, env=sanitized_env, **_capture_run_kwargs(30))
output = "\n".join(p for p in (r.stdout or "", r.stderr or "") if p).strip()[:4000]
if output:
from agent.redact import redact_sensitive_text
output = redact_sensitive_text(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)
if qc.get("type") == "alias":
return _ok(rid, {"type": "alias", "target": qc.get("target", "")})
return None
def _plugin_command_handler(name: str):
try:
from hermes_cli.plugins import get_plugin_command_handler
return get_plugin_command_handler(name)
except Exception:
return None
def _run_plugin_command(handler, arg: str) -> str:
from hermes_cli.plugins import resolve_plugin_command_result
return str(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 is bound
to that profile so get_skill_commands() sees its skills.external_dirs: dispatch() runs on
the pool and nothing upstream binds the override. False on any failure."""
try:
from agent.skill_commands import get_skill_commands
from hermes_constants import reset_hermes_home_override, set_hermes_home_override
profile_home = session.get("profile_home")
token = set_hermes_home_override(profile_home) if profile_home else None
try:
return f"/{base}" in get_skill_commands()
finally:
if token is not None:
reset_hermes_home_override(token)
except Exception:
return False
def _dispatch_plugin(rid, params, session, name, arg):
handler = _plugin_command_handler(name)
if handler:
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:
from agent.skill_bundles import resolve_bundle_command_key
from hermes_cli.commands import resolve_command
return resolve_bundle_command_key(name) if resolve_command(name) is None else 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
from agent.skill_bundles import build_bundle_invocation_message, get_skill_bundles
try:
bundle_result = 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 = get_skill_bundles().get(bundle_key, {}).get("name", bundle_key.lstrip("/"))
notice = f"⚡ Loading bundle: {bundle_name} ({len(loaded_names)} skills)"
if missing:
notice += f"\nSkipped missing skills: {', '.join(missing)}"
# 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):
try:
from agent.skill_commands import scan_skill_commands, build_skill_invocation_message
cmds = scan_skill_commands()
key = f"/{name}"
if key in cmds:
msg = build_skill_invocation_message(key, arg, task_id=session.get("session_key", "") if session else "")
if msg:
# UIs render `display`, never `message`.
display = _skill_scaffold_projection(msg)
return _ok(rid, {"type": "skill", "message": msg, "name": cmds[key].get("name", name), "display": display})
except Exception:
pass
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):
if not arg:
return _err(rid, 4004, "usage: /queue <prompt>")
return _ok(rid, {"type": "send", "message": arg})
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 — authors the skill via skill_manage, saves the plan, generates AGENTS.md)."""
def cmd(rid, params, session, name, arg):
import importlib
build = getattr(importlib.import_module(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 (MoA presets = virtual "Mixture of Agents" provider).
try:
from hermes_cli.moa_config import moa_usage, normalize_moa_config
if not arg:
return _err(rid, 4004, moa_usage())
if not session:
return _err(rid, 4001, "no active session")
sid = params.get("session_id", "")
preset = 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")
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(
sid, session, f"{preset} --provider moa", confirm_expensive_model=False,
pin_session_override=True, persist_override=False)
except Exception as exc:
session.pop("moa_one_shot_restore", None)
return _err(rid, 5030, f"moa unavailable: {exc}")
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.
from hermes_cli.focus_view import format_focus_status, format_focus_toggle_message, resolve_focus_arg
display = _load_cfg().get("display")
display = display if isinstance(display, dict) else {}
cur = bool(display.get("focus_view", False))
action, target = resolve_focus_arg(arg, cur)
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, 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
output = format_focus_toggle_message(bool(target), (res.get("result") or {}).get("tool_progress") or "all")
return _exec_out(rid, output)
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
from agent.context_compressor import history_before_user_originated_turn, retryable_user_text
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 = _user_turn_indices(session)
if not user_indices:
return _err(rid, 4018, "no previous user message to retry")
_prefix, live_view = history_before_user_originated_turn(history, user_indices[-1])
try:
content = retryable_user_text(live_view.get("content"))
except ValueError as exc:
return _err(rid, 4018, str(exc))
try:
_active, durable_live_view, _rewound_count = _rewind_active_session_history(
session, len(user_indices) - 1, require_retryable=True)
except ValueError as exc:
return _err(rid, 4018, str(exc))
except Exception as exc:
return _err(rid, 5008, f"retry: failed to persist history: {exc}")
content = retryable_user_text(durable_live_view.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"):
try:
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}")
except Exception:
pass
return _ok(rid, {"type": "send", "message": arg}) # no active run: next-turn message
def _cmd_goal(rid, params, session, name, arg):
sid_key, err = _session_key_or_err(rid, session)
if err:
return err
try:
from hermes_cli.goals import GoalManager
except Exception as exc:
return _err(rid, 5030, f"goals unavailable: {exc}")
try:
max_turns = int((_load_cfg().get("goals") or {}).get("max_turns", 20) or 20)
except Exception:
max_turns = 20
mgr = GoalManager(session_id=sid_key, default_max_turns=max_turns)
lower = arg.strip().lower()
if not arg.strip() or lower == "status":
return _exec_out(rid, mgr.status_line())
if lower == "pause":
state = mgr.pause(reason="user-paused")
out = "No goal set." if state is None else f"⏸ Goal paused: {state.goal}"
return _exec_out(rid, out)
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` with the
# continuation prompt; `display` keeps model-facing scaffolding out of the transcript.
prompt = mgr.next_continuation_prompt()
if not 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")
return _ok(rid, {"type": "send", "notice": notice, "message": state.goal})
def _cmd_loop(rid, params, session, name, arg):
sid_key, err = _session_key_or_err(rid, session)
if err:
return err
try:
from hermes_cli.loops import LoopManager, dispatch_loop_command
except Exception as exc:
return _err(rid, 5030, f"loops unavailable: {exc}")
result = dispatch_loop_command(LoopManager(session_id=sid_key), arg)
output = result.get("output") or ""
if result.get("created"):
with contextlib.suppress(Exception):
from hermes_cli.loops import goal_blocks_loop_tick
if 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:
return _err(rid, 4001, "no active session to undo")
if busy := _busy_error(rid, session, "undo"):
return busy
session_key = session.get("session_key", "")
if not 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")
from agent.message_content import flatten_message_text
with session["history_lock"]:
if busy := _busy_error(rid, session, "undo"):
return busy
_history, user_indices = _user_turn_indices(session)
if not user_indices:
return _err(rid, 4018, "no user messages to undo")
turns_undone = min(n, len(user_indices))
try:
active, live_view, rewound_count = _rewind_active_session_history(session, len(user_indices) - turns_undone)
except ValueError as exc:
return _err(rid, 4004, f"undo: {exc}")
except Exception as exc:
return _err(rid, 5008, f"undo: {exc}")
target_text = 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:
mm = getattr(agent, "_memory_manager", None)
if mm is not None:
with contextlib.suppress(Exception):
mm.on_session_switch(session_key, parent_session_id="", reset=False, rewound=True)
if hasattr(agent, "_invalidate_system_prompt"):
with contextlib.suppress(Exception):
agent._invalidate_system_prompt()
if hasattr(agent, "_last_flushed_db_idx"):
with contextlib.suppress(Exception):
agent._last_flushed_db_idx = len(active)
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 _cmd_snapshot(rid, params, session, name, arg):
subcommand = arg.split(maxsplit=1)[0].lower() if arg else ""
if subcommand not in {"restore", "rewind"}:
return None
output = (
"/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."
)
return _exec_out(rid, output)
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
from agent.conversation_compression import finalize_context_engine_compression_notification
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)
payload = {"type": "exec", "status": "pending", "output": text} if status == "pending" else {"type": "exec", "output": text}
return _ok(rid, payload)
try:
output = _compress_live_with_feedback(sid, session, session["agent"], arg, snapshot_kwargs=True)
return _exec_out(rid, output)
except Exception as exc:
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 = params.get("name", "").lstrip("/"), params.get("arg", "")
name = _resolve_name(name)
session = _sessions.get(params.get("session_id", ""))
# Stage order is load-bearing: quick > plugin > bundle > skill > built-in.
for stage in (_dispatch_quick, _dispatch_plugin, _dispatch_bundle, _dispatch_skill):
res = stage(rid, params, session, name, arg)
if res is not None:
return res
builtin = _SLASH_BUILTINS.get(name)
if builtin is not None:
res = builtin(rid, params, session, name, arg)
if res is not None:
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 _PENDING_INPUT_COMMANDS:
# Route straight to command.dispatch: some clients fail the error-then-retry fallback ("empty command").
return _methods["command.dispatch"](rid, {"name": base, "arg": arg, "session_id": sid})
if base in _WORKER_BLOCKED_COMMANDS:
subcommand = arg.split(maxsplit=1)[0].lower() if arg else ""
if subcommand in {"restore", "rewind"}:
return _err(rid, 4018, "snapshot restore mutates live config/state; use command.dispatch for /snapshot restore")
bundle_key = _bundle_key_for(base)
if bundle_key is not None:
return _methods["command.dispatch"](rid, {"name": bundle_key.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}")
plugin_handler = _plugin_command_handler(base) if base else None
if plugin_handler:
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 _attach_worker
# loser leaks). Serialize first-use spawn per session.
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:
output = worker.run(cmd)
warning = _mirror_slash_side_effects(sid, session, cmd)
payload = {"output": output or "(no output)"}
if warning:
payload["warning"] = warning
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 ──────────────────────────────────
@method("insights.get")
@_guarded(5017)
def _(rid, params: dict) -> dict:
days = params.get("days", 30)
db = _get_db()
if 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)})
@method("rollback.list")
@_live_session_guarded(5020)
def _(rid, params: dict, session) -> dict:
def go(mgr, cwd):
if not mgr.enabled:
return _ok(rid, {"enabled": False, "checkpoints": []})
rows = [
{"hash": c.get("hash", ""), "timestamp": c.get("timestamp", ""), "message": c.get("message", "")}
for c in mgr.list_checkpoints(cwd)]
return _ok(rid, {"enabled": True, "checkpoints": rows})
return _with_checkpoints(session, go)
@method("rollback.restore")
@_live_session_guarded(5021)
def _(rid, params: dict, session) -> dict:
target = params.get("hash", "")
file_path = 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):
resolved = _resolve_checkpoint_hash(mgr, cwd, target)
result = mgr.restore(cwd, resolved, 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:
_active, _live_view, removed = _rewind_active_session_history(session, len(user_indices) - 1)
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))
@method("rollback.diff")
@_live_session_guarded(5022)
def _(rid, params: dict, session) -> dict:
target = params.get("hash", "")
if not target:
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}
rendered = render_diff(raw, session.get("cols", 80))
if rendered:
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}")
@method("plugins.list")
@_guarded(5032)
def _(rid, params: dict) -> dict:
from hermes_cli.plugins import get_plugin_manager
rows = [
{"name": n, "version": getattr(i, "version", "?"), "enabled": getattr(i, "enabled", True)}
for n, i in get_plugin_manager()._plugins.items()]
return _ok(rid, {"plugins": rows})
@method("config.show")
@_guarded(5030)
def _(rid, params: dict) -> dict:
cfg = _load_cfg()
model = _resolve_model()
from agent.secret_scope import get_secret
api_key = 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", "")
agent_rows = [
["Max Turns", str(_cfg_max_turns(cfg, 500))],
["Toolsets", ", ".join(cfg.get("enabled_toolsets", [])) or "all"],
["Verbose", str(cfg.get("verbose", False))]]
sections = [
{"title": "Model", "rows": [["Model", model], ["Base URL", base_url or "(default)"], ["API Key", masked]]},
{"title": "Agent", "rows": agent_rows},
{"title": "Environment", "rows": [["Working Dir", os.getcwd()], ["Config File", str(_hermes_home / "config.yaml")]]},
]
return _ok(rid, {"sections": sections})
# ─── Tools / toolsets / agents ───────────────────────────────────────────────
@method("tools.list")
@_guarded(5031)
def _(rid, params: dict) -> dict:
return _ok(rid, {"toolsets": _toolset_rows(params, with_tools=True)})
@method("toolsets.list")
@_guarded(5032)
def _(rid, params: dict) -> dict:
return _ok(rid, {"toolsets": _toolset_rows(params, with_tools=False)})
@method("tools.show")
@_guarded(5034)
def _(rid, params: dict) -> dict:
from model_tools import get_toolset_for_tool, get_tool_definitions
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 = 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(get_toolset_for_tool(name) or "unknown", []).append({"name": name, "description": desc})
sections_out = [{"name": name, "tools": rows} for name, rows in sorted(sections.items())]
return _ok(rid, {"sections": sections_out, "total": len(tools)})
@method("tools.configure")
@_guarded(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")
from hermes_cli.config import load_config, save_config
from hermes_cli.tools_config import (
CONFIGURABLE_TOOLSETS, _apply_mcp_change, _apply_toolset_change, _get_platform_tools,
_get_plugin_toolset_keys)
cfg = load_config()
valid_toolsets = {ts_key for ts_key, _, _ in CONFIGURABLE_TOOLSETS} | _get_plugin_toolset_keys()
toolset_targets = [name for name in targets if ":" not in name]
mcp_targets = [name for name in targets if ":" in name]
unknown = [name for name in toolset_targets if name not in valid_toolsets]
toolset_targets = [name for name in toolset_targets if name in valid_toolsets]
if toolset_targets:
_apply_toolset_change(cfg, "cli", toolset_targets, action)
missing_servers = _apply_mcp_change(cfg, mcp_targets, action) if mcp_targets else set()
save_config(cfg)
sid = params.get("session_id", "")
session = _sessions.get(sid)
info = _reset_session_agent(sid, session) if session else None
enabled = sorted(_get_platform_tools(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})
@method("agents.list")
@_guarded(5033)
def _(rid, params: dict) -> dict:
from tools.process_registry import process_registry
rows = [
{"session_id": p["session_id"], "command": p["command"][:80], "status": p["status"], "uptime": p["uptime_seconds"]}
for p in process_registry.list_sessions()]
return _ok(rid, {"processes": rows})
# ─── Cron / learning / skills ────────────────────────────────────────────────
@method("cron.manage")
@_profile_scoped_rpc(5023)
def _(rid, params: dict) -> dict:
"""cronjob() keys off HERMES_HOME, so the optional ``profile`` scope reaches a
per-profile cron store even when that profile runs its own gateway."""
from tools.cronjob_tools import 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.
result = json.loads(
cronjob(action="list", include_disabled=is_truthy_value(params.get("include_disabled", False)))
)
# ``scoped`` proves the profile scope was honored: new clients treat every job as that
# profile's; older gateways omit it and clients keep the safe [bot:<name>] filter.
profile = str(params.get("profile") or "").strip()
if 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(params.get("deliver") or "").strip() 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}")
@method("learning.frames")
@_guarded(5000, "learning.frames failed: ")
def _(rid, params: dict) -> dict:
"""Pre-render the ``/journey`` timeline: ``frames`` (reveal 0→1) plus legend/summary/
bucket metadata so Ink walks the tree locally. Shares its renderer with ``hermes journey``."""
try:
cols = int(params.get("cols", 80) or 80)
rows = int(params.get("rows", 24) or 24)
frames = int(params.get("frames", 48) or 48)
except (TypeError, ValueError):
cols, rows, frames = 80, 24, 48
from agent.learning_graph import build_learning_graph
from agent.learning_graph_render import render_frames
return _ok(rid, render_frames(build_learning_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:
import agent.learning_mutations as mutations
return _ok(rid, getattr(mutations, fn_name)(*(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 _rpc, _fn, _keys in (
("detail", "node_detail", ("id",)), ("delete", "delete_node", ("id",)), ("edit", "edit_node", ("id", "content")),
):
method(f"learning.{_rpc}")(_guarded(5000, f"learning.{_rpc} failed: ")(_learning_mutation(_fn, _keys)))
del _rpc, _fn, _keys
class _QuietConsole:
def print(self, *a, **k):
pass
def _skills_list(rid, params, query):
from hermes_cli.banner import get_available_skills
return _ok(rid, {"skills": get_available_skills()})
def _skills_search(rid, params, query):
from tools.skills_hub import GitHubAuth, create_source_router, unified_search
raw = unified_search(query, create_source_router(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):
from hermes_cli.skills_hub import do_install
do_install(query, skip_confirm=True, console=_QuietConsole())
return _ok(rid, {"installed": True, "name": query})
def _skills_browse(rid, params, query):
from hermes_cli.skills_hub import browse_skills
pg = int(params.get("page", 0) or 0) or (int(query) if query.isdigit() else 1)
return _ok(rid, browse_skills(page=pg, page_size=int(params.get("page_size", 20))))
def _skills_inspect(rid, params, query):
from hermes_cli.skills_hub import inspect_skill
return _ok(rid, {"info": inspect_skill(query) or {}})
_SKILLS_ACTIONS = {
"list": _skills_list, "search": _skills_search, "install": _skills_install, "browse": _skills_browse,
"inspect": _skills_inspect}
@method("skills.manage")
@_profile_scoped_rpc(5024)
def _(rid, params: dict) -> dict:
"""list/install use the scoped profile's skills dir; search/browse/inspect hit the shared hub."""
action = params.get("action", "list")
handler = _SKILLS_ACTIONS.get(action)
if handler is None:
return _err(rid, 4017, f"unknown skills action: {action}")
return handler(rid, params, params.get("query", ""))
@method("skills.reload")
@_guarded(5025)
def _(rid, params: dict) -> dict:
from agent.skill_commands import reload_skills
result = reload_skills()
added = result.get("added") or []
removed = result.get("removed") or []
lines = ["Reloading skills..."]
if not added and not removed:
lines.append("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.
@method("mcp.catalog")
@_profile_scoped_rpc(5024)
def _(rid, params: dict) -> dict:
"""``{servers: [{name, description, installed, enabled, requires: [env keys], transport}]}``
— the `hermes mcp` menu with per-profile state, so UIs know which entries need setup."""
from hermes_cli import 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})
@method("mcp.servers.list")
@_profile_scoped_rpc(5024, catch_resolve=False)
def _(rid, params: dict) -> dict:
"""``{servers: [{name, transport, url, command, args, env (key names only),
auth, oauth_tokens_present, enabled, tools}]}`` for the scoped profile."""
from hermes_cli.mcp_config import _get_mcp_servers
servers = _get_mcp_servers()
return _ok(rid, {"servers": [_mcp_summarize_server(name, cfg) for name, cfg in sorted(servers.items())]})
@method("mcp.servers.add")
@_mcp_server_scoped
def _(rid, params: dict) -> dict:
"""Add ``name`` with EITHER ``preset`` (catalog id) or ``config`` (url/command/args/env/
headers/auth/tools). ``bearer_token`` goes to the profile's .env; only the
``Authorization`` header template is persisted. Duplicate names → 4090."""
from hermes_cli.mcp_config import _apply_mcp_preset, _get_mcp_servers, _save_bearer_auth_token, _save_mcp_server
name = str(params.get("name") or "").strip()
if name in _get_mcp_servers():
return _err(rid, 4090, f"server '{name}' already exists")
preset = str(params.get("preset") or "").strip()
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
_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'")
bearer_token = params.get("bearer_token")
if bearer_token:
server_config["headers"] = _save_bearer_auth_token(name, str(bearer_token))
if not _save_mcp_server(name, server_config):
return _err(rid, 4001, f"server '{name}' rejected: suspicious command/args configuration")
saved = _get_mcp_servers().get(name, server_config)
return _ok(rid, {"ok": True, "name": name, "server": _mcp_summarize_server(name, saved)})
@method("mcp.servers.set_api_key")
@_profile_scoped_rpc(5024, required=(("name", _stripped), ("value", _nonempty)), catch_resolve=False)
def _(rid, params: dict) -> dict:
"""Secret → profile .env under ``env_var`` (default ``MCP_<NAME>_API_KEY``); config.yaml
gets a reference: ``Authorization: Bearer ${ENV}`` header (http) or ``env: {VAR: "${ENV}"}``
(stdio), matching ``cmd_mcp_configure`` / ``_save_bearer_auth_token``."""
from hermes_cli.config import load_config, save_config, save_env_value
from hermes_cli.mcp_config import _bearer_auth_headers, _env_key_for_server, _strip_bearer_prefix
name, servers, err = _mcp_named_server(rid, params)
if err:
return err
value = params.get("value")
env_var = str(params.get("env_var") or "").strip() or _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 = _strip_bearer_prefix(str(value))
if not normalized or normalized.lower() == "bearer":
return _err(rid, 4063, "value is not a valid credential")
save_env_value(env_var, normalized)
if env_var == _env_key_for_server(name):
entry["headers"] = _bearer_auth_headers(name)
else:
entry["headers"] = {"Authorization": f"Bearer ${{{env_var}}}"}
else:
save_env_value(env_var, str(value))
env_block = entry.get("env")
entry["env"] = env_block = env_block if isinstance(env_block, dict) else {}
env_block[env_var] = f"${{{env_var}}}"
cfg = load_config()
cfg.setdefault("mcp_servers", {})[name] = entry
save_config(cfg)
return _ok(rid, {"ok": True, "name": name, "env_var": env_var, "server": _mcp_summarize_server(name, entry)})
@method("mcp.servers.test")
@_mcp_server_scoped
def _(rid, params: dict) -> dict:
"""Connect, list tools, disconnect. Success: ``{ok, tools, prompts, resources, oauth_needed,
oauth_tokens_present}``; failure: ``{ok: false, error, tools: [], oauth_needed, ...}``.
Runs on the RPC pool (_LONG_HANDLERS): a cold stdio `npx` spawn can block for seconds."""
from hermes_cli.mcp_config import _oauth_tokens_present, _probe_single_server
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 = _probe_single_server(name, cfg, details=details)
token_present = _oauth_tokens_present(name) if needs_oauth_token else True
except Exception as exc:
return failure(str(exc), needs_oauth_token, _oauth_tokens_present(name) if needs_oauth_token else None)
if not token_present:
return failure("OAuth authentication required — no token found.", True, False)
payload = {
"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}
return _ok(rid, payload)
@method("mcp.servers.remove")
@_mcp_server_scoped
def _(rid, params: dict) -> dict:
"""Remove a server from the profile's config.yaml → ``{ok: true, removed: true}``."""
from hermes_cli.mcp_config import _remove_mcp_server
name = str(params.get("name") or "").strip()
if not _remove_mcp_server(name):
return _err(rid, 4064, f"server '{name}' not found")
return _ok(rid, {"ok": True, "removed": True})
@method("mcp.servers.oauth.start")
@_mcp_server_scoped
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`` until ``approved``.
A background worker drives the ``hermes mcp login`` machinery with a loopback
listener. With ``client_redirect_uri`` the CLIENT hosts the loopback and relays the
code via ``mcp.servers.oauth.callback`` — the only flow that works when desktop and
gateway are on different machines. Runs on the RPC pool (_LONG_HANDLERS)."""
client_redirect_uri = str(params.get("client_redirect_uri") or "").strip() or None
try:
from hermes_constants import get_hermes_home
from tui_gateway import mcp_oauth_sessions
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(get_hermes_home().expanduser().resolve(strict=False))
result = 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, "session_id": result["session_id"], "auth_url": result["auth_url"], "flow": result["flow"]})
def _oauth_flow_ids(params: dict) -> tuple[str, str]:
"""(session_id, name) as stripped strings."""
return str(params.get("session_id") or "").strip(), str(params.get("name") or "").strip()
@method("mcp.servers.oauth.poll")
@_profile_scoped_rpc(5024, required=_NAME_SESSION, catch_resolve=False)
def _(rid, params: dict) -> dict:
"""Poll a flow → ``{ok, status: pending|approved|error, error_message?, auth_url?, tools?}``.
On ``approved`` tokens persist for that server/profile (profile scope applies here too)."""
from tui_gateway import mcp_oauth_sessions
result = mcp_oauth_sessions.poll_flow(*_oauth_flow_ids(params))
return _ok(rid, {"ok": True, **result})
@method("mcp.servers.oauth.callback")
@_profile_scoped_rpc(5024, required=_NAME_SESSION, catch_resolve=False)
def _(rid, params: dict) -> dict:
"""Relay a client-captured redirect (``code``/``state``/``error``) into a flow started with
``client_redirect_uri``. ``{ok: true}`` once accepted (state verified), else ``{ok: false, error_message}``."""
from tui_gateway import mcp_oauth_sessions
code, state, error = (str(params.get(k) or "") or None for k in ("code", "state", "error"))
session_id, name = _oauth_flow_ids(params)
return _ok(rid, mcp_oauth_sessions.deliver_callback_flow(session_id, name, code=code, state=state, error=error))
# ─── Plugins ─────────────────────────────────────────────────────────────────
def _plugin_rows() -> list[dict]:
from hermes_cli.plugins_cmd import (
_bundled_default_on, _discover_all_plugins, _get_disabled_set, _get_enabled_set,
_is_portable_plugin_dir, _plugin_status)
enabled = _get_enabled_set()
disabled = _get_disabled_set()
out = []
for name, version, desc, source, _dir, key in sorted(_discover_all_plugins()):
status = _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 _bundled_default_on(_dir):
status = "enabled"
# key = canonical registry key (``image_gen/fal``; names collide across category dirs);
# portable = Agent Plugins v1 package vs native Hermes plugin.
out.append({
"name": name, "key": key, "version": str(version or ""), "description": desc or "",
"source": source, "status": status, "portable": _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):
from hermes_cli.plugins_cmd import dashboard_set_agent_plugin_enabled
# 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'")
result = dashboard_set_agent_plugin_enabled(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):
from hermes_cli.plugins_cmd import dashboard_install_plugin
ident = (params.get("identifier") or params.get("repo") or "").strip()
if not ident:
return _err(rid, 4019, "plugins.install requires 'identifier' or 'repo'")
result = dashboard_install_plugin(ident, force=bool(params.get("force")), enable=params.get("enable", True))
if not result.get("ok"):
return _err(rid, 5026, result.get("error") or "install failed")
return _ok(rid, result)
_PLUGINS_ACTIONS = {"list": _plugins_list, "toggle": _plugins_toggle, "install": _plugins_install}
@method("plugins.manage")
@_profile_scoped_rpc(5026, catch_resolve=False)
def _(rid, params: dict) -> dict:
"""TUI Plugins Hub backend (shares primitives with ``hermes plugins`` / the dashboard).
- ``list`` → {plugins: [{name, key, version, description, source, status, portable}], user_count, bundled_count}
- ``toggle`` → flip ``key`` (or ``name``) per ``enable``; returns the row + {ok, unchanged}
- ``install`` → git-clone ``identifier``/``repo`` into ~/.hermes/plugins/ (``force``, ``enable`` default True)
Optional ``profile`` scopes HERMES_HOME (mcp.servers.* contract)."""
action = params.get("action", "list")
handler = _PLUGINS_ACTIONS.get(action)
if handler is None:
return _err(rid, 4017, f"unknown plugins action: {action}")
return handler(rid, params)
@method("shell.exec")
def _(rid, params: dict) -> dict:
cmd = params.get("command", "")
if not cmd:
return _err(rid, 4004, "empty command")
try:
from tools.approval import detect_dangerous_command, detect_hardline_command
is_hardline, hardline_desc = 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 = 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=("_",))