a6ee31f55a
* feat(wisdom): add trusted publish and install foundation
* feat(wisdom): add private contribution loop
* feat(wisdom): add managed consumption workflows
* fix(wisdom): close cross-repository safety gaps
* fix(wisdom): align local package and lifecycle policy
* fix(wisdom): require explicit profile setup
* docs(wisdom): repin reconciled gateway head
* fix(wisdom): fence content downloads and approval receipts
* docs(wisdom): record generation-fenced downloads
* docs(wisdom): record unified delivery PR
* fix(ci): stop passing invalid classifier inputs
* docs(wisdom): remove internal requirements ledger
* feat(wisdom): localize dashboard and desktop copy
* feat(wisdom): complete local contribution and consumption UX
* style(wisdom): satisfy desktop lint
* chore(wisdom): refresh requirements pin
* test(dashboard): allow formatted profile copy
* test(wisdom): stabilize desktop interaction coverage
* fix(wisdom): surface dashboard action failures
* fix(wisdom): add repeatable Portal demo login
* feat(wisdom): add actionable skill notifications
* feat(wisdom): add notification install and update actions
* fix(wisdom): make Telegram skill alerts actionable
* fix(wisdom): always refresh demo Agent login
* feat(wisdom): embed Telegram notification actions
* fix(wisdom): preserve Telegram notifications after actions
* fix(wisdom): keep Telegram notification cards readable
* feat(wisdom): add Telegram candidate approval flow
* feat(wisdom): explain Telegram qualification reasons
* fix(wisdom): reconcile cross-surface candidate actions
* feat(telegram): add Collective Wisdom management command
* chore(wisdom): refresh Gateway contract pin
* chore(wisdom): advance Gateway contract pin
* feat(wisdom): align command UX across clients
* feat(slack): add Collective Wisdom management parity
* feat(wisdom): add security and professionalism reviews
* feat(wisdom): add first-time qualification guidance
* feat(wisdom): simplify qualification sharing choices
* feat(skills): add optional editorial metadata
* feat(wisdom): enrich legacy skill presentation
* fix(wisdom): harden review and update boundaries
* fix(wisdom): emit canonical review timestamps
* fix(wisdom): align with merged gateway and main
* wisdom: add agent-led sharing core (policy, evidence, schemas, templates, delivery, weekly job, share/install flows)
- hermes_wisdom/agent_led/: policy resolution (server > local > defaults),
7-day evidence builder that excludes bundled/hub/managed skills and
dismissed/handled/recently-suggested content hashes, strict pydantic
schemas for agent output with repair-or-reject, fixed copy templates
(Share / Teammate / Published / Update / Mute), idempotent retried
delivery ledger with stale-action resolution, weekly review job,
resumable Share and Install flows.
- prompts/: candidate review, recipient recommendation, share packaging.
- tests/wisdom/test_agent_led.py: 30 tests.
* wisdom: agent-led renderers and button action dispatcher
- render.py: Telegram HTML, Slack blocks, Desktop payload; editorial name
is the emphasized line, product label stays separate.
- actions.py: resolve opaque wa:<action>:<dedup> targets via the delivery
ledger; Not now -> dismissal, Mute -> fixed options, Share -> resumable
packaging flow, Install/Update -> plan command. Never publishes/installs.
* wisdom: CLI verbs, agent_led config default, conversational catalog skill
- hermes wisdom browse/review-week/act/share/dismiss/mute (all --json).
- wisdom.agent_led config block, default enabled.
- SKILL.md rewritten so natural-language catalog questions map to the CLI
verbs, share/install flows and fixed notification templates.
* wisdom: wire agent-led weekly review into gateway tick and Telegram buttons
- gateway housekeeping tick calls maybe_run_weekly_review with a home
channel sender when a Telegram adapter is available.
- Telegram: wa: callbacks resolved through the ledger (stale-safe), mute
duration keyboard, send_wisdom_agent_recommendation rich card + fallback.
* fix(wisdom): integrate local mediation and harden model and setup boundaries
* fix(wisdom): honor authoritative recommendation policy and defer on failure
* fix(wisdom): synchronize opaque suppression and recheck delivery preferences
* feat(wisdom): route weekly selection through the session-owned assessment queue
* fix(wisdom): prepare and submit the reviewed generated share package
* feat(wisdom): separate native Share preparation from publication consent
* feat(wisdom): sync native mute choices through a leased preference outbox
* feat(wisdom): bind native mute controls to durable preference choices
* feat(wisdom): add scoped desktop and dashboard notification settings
* fix(wisdom): revalidate feed recommendations before assessment and delivery
* fix(wisdom): persist validated delivery receipts before completing notices
* feat(wisdom): add private notification claim and receipt client
* Persist Wisdom send reservations and recover delivery acknowledgements
* Route legacy Wisdom controls through current native review
* Add typed private Wisdom operation outcome client
* fix(wisdom): make agent-led advice usable in the local demo
* fix(wisdom): keep requested consent outside proactive limits
* fix(wisdom): distinguish unavailable assessments and preserve digest text
* fix(wisdom): assess ongoing usefulness beyond the current task
* fix(wisdom): restore immediate qualification sharing controls
* fix(wisdom): separate qualification review from installation advice
* fix(wisdom): collapse review checklists and simplify sharing copy
* fix(wisdom): show compact sharing progress and publication receipts
* fix(wisdom): require credential prefixes rather than matching skill names
* fix(wisdom): finish package checks before presenting sharing consent
* fix(wisdom): scan local skills before qualification cards
* fix(wisdom): update moderation results on existing sharing cards
* fix(wisdom): keep sharing review accessible from receipt cards
* fix(wisdom): align mediated review cards and collapsible checks
* fix(wisdom): clarify clean security summary wording
* fix(wisdom): normalize consent plans and add explicit recheck
* fix(wisdom): keep install and update receipts concise
* fix(wisdom): collapse assessments and deduplicate operation cards
* fix(wisdom): restore private Portal review from native cards
* fix(wisdom): sync Portal publication to original consent card
* fix(wisdom): show local skill version on sharing cards
* fix(wisdom): skip agent recommendations for self-published versions
* fix(wisdom): simplify candidate notices and local-edit recovery copy
* feat(wisdom): submit locally reviewed packages with one confirmation
* feat(wisdom): expose safe receipt and outcome sync recovery
* wisdom: onboarding notice says detect and share, names the user's own skill
Copy review from the product owner on the first and returning
qualification notices (fixed delivery mode):
- the feature blurb now says the org enabled detection *and sharing*
- both notices say the detected skill is one the user created
- both close with an exclamation mark
Applied identically to hermes_wisdom.notice, the desktop and web i18n
strings, and the tests that assert the sentences.
* wisdom: one opener, no approval line, ask to share after the skill is shown
Product owner review of the candidate card.
- The Hermes written card now opens with the same sentence as the fixed card
("Your organisation has enabled Collective Wisdom, a feature designed to
automatically detect and share useful skills across all team members.")
instead of its own blurb, so there is one first time message.
- "Nothing is shared without your approval." removed from Telegram, Slack
and Desktop. The buttons already make the permission explicit.
- "Would you like to share?" no longer appears before the skill is named.
It is now the last line, after the skill name, description, why suggested
and the checks, and reads "Would you like to share it?" (matching the
agent led template wording).
Tests updated for the new order; proposalNotice removed from all desktop locales.
* wisdom: American spelling, organization
Product owner decision: user facing copy uses American spelling.
Changes "Your organisation" to "Your organization" in the chat notice,
the Hermes written card opener, the desktop and web strings, and the
tests that assert them. Identifiers such as nas_organisation:* and the
German and French locales are untouched.
* wisdom: candidate card copy round 4 (owner review)
Apply the product owner's round 4 copy decisions to the Hermes Collective
Wisdom candidate card on Telegram, Slack, Desktop and the shared views:
1. Hermes-written cards are titled "Hermes Collective Wisdom" instead of
the bare "Collective Wisdom".
2. The "Reusable skill ready to review" line is gone from the candidate
card (Telegram rich card and plain fallback, legacy agent-led share
template).
3. The skill name and description are labelled: "Skill name: <name>" and
"What it does: <description>" (Telegram, Slack, Desktop).
4. "Why suggested:" is now "Why others might benefit:".
5. A passing professionalism review reads "Safe to share at work ✓ (no
inappropriate content found)" with no per-check bullets and no "Pass";
a failed review reads "Needs a look before sharing at work (possible
inappropriate content)" and lists only the checks that flagged
something. Pending/unavailable wording is unchanged.
6. Telegram button toasts: "Will ask later...", "Preparing more
details...", "Sharing...".
7. Qualification reasons: "You used this skill consistently across many
days." and "You've really refined this skill."
8. prompts/wisdom_candidate_review.md asks for a compelling
editorial_name, a simple one_line_description and a compelling
why_coworkers_benefit under 300 characters; "Be concise and
convincing." becomes "Be concise and compelling: the goal is that the
user wants to share it."
Tests updated for the new strings; review_text() gains direct coverage.
* wisdom: re-apply owner copy after rebase
- Native share cards (advice_view/interaction_view): drop the approval line, ask "Would you like to share it?" as the last line after the checks
- Hermes-written completion card titled "Hermes Collective Wisdom"
- Qualification reasons use the owner wording (consistently across many days / really refined)
- American spelling (organization) in remaining English copy
- Desktop test asserts the current Share button; web test matches the returning notice
* fix(wisdom): pin reconciled Gateway and verify Unicode hash vectors
Pin Gateway 60cd2d6b613ae3cd4a6e65155d1142006d907e78 and byte-identical producer artifacts. Verify every content-order case and package-manifest binding. Validation: 186 focused Python tests, Ruff and contract verifier.
* fix(wisdom): reconcile optional SDK tests and frontend lint
* fix(wisdom): default to agent-written notification summaries
* fix(wisdom): restore deferred install review and browse controls
* feat(wisdom): inspect installed setup with exact package provenance
* feat(wisdom): run native-approved installed setup steps with durable evidence
* fix(wisdom): recover interrupted setup with explicit native consent
* feat(wisdom): hand native installs into guided setup review
* fix(wisdom): continue requested setup with fixed notification copy
* fix(wisdom): preserve setup while waiting for a session model
* fix(wisdom): expose canonical setup review controls on desktop
* fix(wisdom): resume setup after recorded automatic updates
* fix(wisdom): make missing setup prerequisites recheckable
* chore(wisdom): align Agent with verified Gateway contract
* fix(wisdom): stop guessing team slugs in portal links
* fix(wisdom): retire pending advice on account sign-out
* fix(wisdom): cancel advice after terminal account revocation
* fix(wisdom): fence feed responses across account sign-out
* fix(wisdom): checkpoint signed-out feed before reactivation
* fix(wisdom): link proactive advice to scoped notification settings
* fix(wisdom): coalesce queued publication recommendations by version
* fix(wisdom): keep package review navigation local and deferable
* fix(wisdom): reflect installed state in discovery controls
* fix(wisdom): show exact checks before command confirmation
* chore(wisdom): pin bounded analytics privacy contract
* chore(wisdom): pin retired legacy notification contract
* feat(wisdom): review publisher usage with exact sharing copy
* fix(wisdom): align discovery and review check summaries
* fix(wisdom): show expired consent before confirmation
* fix(wisdom): require fresh review for legacy install controls
* fix(wisdom): preserve review expiry across check toggles
* fix(wisdom): retain update policy in native install reviews
* fix(wisdom): surface failed native card edits
* fix(wisdom): persist local command approval reviews
* fix(wisdom): use saved approvals for messaging commands
* test(wisdom): provide scan result in setup handoff fixture
* test(wisdom): exercise Telegram approvals with saved review state
* fix(wisdom): retain suppression policy for offline deferral
* fix(wisdom): reconsider candidates after deferred suppression expires
* fix(wisdom): bind review checks and report verified readiness separately
* fix(wisdom): persist accepted publication intent and recover exact outcomes
* fix(sync): pin UTF-8 tree ordering across writers
* chore(wisdom): pin organisation-scoped Gateway authorization
* fix(wisdom): restrict consent delivery to user-facing sessions
* chore(wisdom): refresh reviewed Gateway contract pin
* fix(wisdom): preserve kept tools in Blank Slate exclusions
* test(auth): reset anonymous fixture with a profile-scoped cache
* fix(wisdom): gate local surfaces and work on current profile entitlement
* fix(wisdom): invalidate quiet tool cache on entitlement changes
* test(wisdom): authorize local consent gateway fixtures
* fix(wisdom): keep entitlement decoding free of native crypto imports
* test(wisdom): provide local entitlement to demo CLI subprocess
* ci: leave upstream workflow unchanged in Wisdom PR
* fix(wisdom): ship package and contracts in Nix wheels
---------
Co-authored-by: hbizi <36184542+hbizi@users.noreply.github.com>
1459 lines
72 KiB
Python
1459 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 pathlib import Path
|
|
|
|
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 _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:
|
|
if not commands.command_available(cmd):
|
|
continue
|
|
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}", 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, 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, str(info.get("description", "Skill"))])
|
|
name = str(info.get("name") or k.lstrip("/"))
|
|
skills[k] = {"usage": usage(name), "origin": origin_of(name)}
|
|
|
|
|
|
@_scoped_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()
|
|
if _tools_mod("hermes_cli.commands").command_available(k)
|
|
},
|
|
"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))
|
|
|
|
|
|
@_scoped_rpc("command.resolve", 5012)
|
|
def _(rid, params: dict) -> dict:
|
|
commands = _tools_mod("hermes_cli.commands")
|
|
r = commands.resolve_command(params.get("name", ""))
|
|
if r and commands.command_available(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):
|
|
with _session_profile_runtime_scope(session or {}):
|
|
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)
|
|
from hermes_cli.goal_command import dispatch_goal_command
|
|
result = dispatch_goal_command(
|
|
mgr, arg, authorize_gate=lambda: None,
|
|
last_user_message=goals.last_user_message_from_db(sid_key),
|
|
)
|
|
if result.error:
|
|
return _err(rid, 4004, result.output)
|
|
if not result.prompt:
|
|
return _exec_out(rid, result.output)
|
|
payload = {"type": "send", "notice": result.output, "message": result.prompt}
|
|
if not result.kickoff:
|
|
payload["notice"] += "\nContinuing now — taking the next step."
|
|
payload["display"] = "/goal resume"
|
|
return _ok(rid, payload)
|
|
|
|
|
|
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", ""))
|
|
|
|
commands = _tools_mod("hermes_cli.commands")
|
|
command = commands.resolve_command(name)
|
|
with _session_profile_runtime_scope(session or {}):
|
|
if command is not None and not commands.command_available(command):
|
|
return _err(rid, 4030, f"command unavailable: /{name}")
|
|
|
|
# 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 ""
|
|
commands = _tools_mod("hermes_cli.commands")
|
|
command = commands.resolve_command(base)
|
|
with _session_profile_runtime_scope(session):
|
|
if command is not None and not commands.command_available(command):
|
|
return _err(rid, 4030, f"command unavailable: /{base}")
|
|
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:
|
|
sid = params.get("session_id", "")
|
|
session = None
|
|
if sid:
|
|
session, err = _sess_nowait(params, rid)
|
|
if err:
|
|
return err
|
|
# The client sends session_id, not profile; the live session is authoritative.
|
|
home = (session or {}).get("profile_home")
|
|
scopes = _bind_build_profile_scopes(home) if home else None
|
|
try:
|
|
return _configure_session_tools(rid, params, sid, session)
|
|
finally:
|
|
if scopes is not None:
|
|
_release_build_profile_scopes(scopes)
|
|
|
|
|
|
def _configure_session_tools(rid, params: dict, sid: str, session) -> 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)
|
|
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.cancel", _NAME_SESSION)
|
|
def _(rid, params: dict) -> dict:
|
|
"""Cancel a flow owned by the resolved profile, waking its callback worker."""
|
|
home = str(_tools_mod("hermes_constants").get_hermes_home().expanduser().resolve(strict=False))
|
|
cancel = _tools_mod("tui_gateway.mcp_oauth_sessions").cancel_flow
|
|
return _ok(rid, cancel(_str_arg(params, "session_id"), _str_arg(params, "name"), home))
|
|
|
|
|
|
@_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")
|
|
cat = _tools_mod("hermes_cli.plugins_cmd_catalog")
|
|
enabled, disabled = pc._get_enabled_set(), pc._get_disabled_set()
|
|
pins = cat.catalog_pins() # powers the desktop's "Update to <pin>" affordance
|
|
ref_pins = pc._read_install_metadata() # ``--ref`` installs: pinned_sha so the desktop can show the pin
|
|
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.
|
|
# ``has_desktop_half``: the package also ships a Desktop UI half (``desktop/plugin.js``). The
|
|
# desktop app pairs its app-level copy of that half with this row so one package is ONE row.
|
|
_dir_path = Path(str(_dir)) if _dir else None
|
|
out.append({
|
|
"name": name, "key": key, "version": str(version or ""), "description": desc or "",
|
|
"source": source, "status": status, "portable": pc._is_portable_plugin_dir(_dir),
|
|
"install_dir": str(_dir_path) if _dir_path else "",
|
|
"has_desktop_half": bool(_dir_path and (_dir_path / "desktop" / "plugin.js").is_file()),
|
|
**cat.catalog_row_fields(_dir, pins),
|
|
**({"pinned_sha": sha} if (sha := pc.pinned_revision(name, ref_pins)) else {})})
|
|
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):
|
|
# ``catalog_name`` alone installs a curated entry at its pinned SHA (resolved server-side, kill list
|
|
# enforced, no bypass) — same contract as the dashboard endpoint.
|
|
ident = (params.get("identifier") or params.get("repo") or "").strip()
|
|
catalog_name = str(params.get("catalog_name") or "").strip()
|
|
if not ident and not catalog_name:
|
|
return _err(rid, 4019, "plugins.install requires 'identifier', 'repo', or 'catalog_name'")
|
|
result = _tools_mod("hermes_cli.plugins_cmd").dashboard_install_plugin(
|
|
ident, force=bool(params.get("force")), enable=params.get("enable", True), catalog_name=catalog_name or None,
|
|
ref=str(params.get("ref") or "").strip() or None)
|
|
return _ok(rid, result) if result.get("ok") else _err(rid, 5026, result.get("error") or "install failed")
|
|
|
|
|
|
def _plugins_update(rid, params):
|
|
"""Catalog installs only: re-pin to the current catalog SHA (non-catalog installs update via the CLI)."""
|
|
name = (params.get("name") or "").strip()
|
|
if not name:
|
|
return _err(rid, 4019, "plugins.update requires a 'name'")
|
|
pc, cat = _tools_mod("hermes_cli.plugins_cmd"), _tools_mod("hermes_cli.plugins_cmd_catalog")
|
|
target = pc._plugins_dir() / name
|
|
sidecar = cat.read_catalog_sidecar(target) if target.is_dir() else None
|
|
if not sidecar:
|
|
return _err(rid, 4020, f"'{name}' is not a catalog install — update it via the CLI")
|
|
try:
|
|
sha, changed = cat.repin_catalog_plugin(target, sidecar)
|
|
except pc.PluginOperationError as e:
|
|
return _err(rid, 4021, str(e))
|
|
return _ok(rid, {"ok": True, "unchanged": not changed, "sha": sha})
|
|
|
|
|
|
_PLUGINS_ACTIONS = {"list": _plugins_list, "toggle": _plugins_toggle, "install": _plugins_install,
|
|
"update": _plugins_update}
|
|
|
|
|
|
@_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`` or a curated ``catalog_name`` (``force``, ``enable``
|
|
default True); ``update`` re-pins a catalog install to the current catalog SHA."""
|
|
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=("_",))
|