* feat(i18n): layered catalogs — plugin packs and user overlay over bundled locales
* feat(tui): i18n layer — en catalog, nanostore runtime, RPC pack loader, _keys.tui.json emitter
ui-tui/src/i18n/: en.ts (facade over topical siblings under en/), types.ts
(Translations + dotted TranslationKey derived from en), runtime.ts ($locale/
$catalog atoms, translateFrom active→en→key, pack merge with string→fn
wrapping for {0}/{1} placeholders), loader.ts (display.language →
i18n.catalog {lang, surface:'tui'}, English when the method is missing),
useT()/useLocale() hooks, t() for non-React code. useConfigSync feeds the
loader from the existing config.get full hydration. `npm run i18n:keys`
writes locales/_keys.tui.json (sorted flat key list) and runs before build.
* feat(plugins): provides_locales manifest field, ctx.register_locale/register_locale_dir, manifest-only language packs
* chore(tui): split en catalog siblings by lane (slash sibling)
* feat(plugins): validate language packs — parse, text-only, key-subset WARN against en / _keys exports
* feat(tui_gateway): i18n.languages / i18n.catalog RPC + regenerated contracts
* feat(config): display.language accepts any supported_languages() id, refuses unknown ids with the list
* docs(i18n): language packs user guide, pluggable display.language, plugin developer section, AGENTS notes
* feat(plugins): report language-pack layers in the mid-run activation summary
* feat(tui): wire status bar, composer placeholders, hotkey help and approval/clarify/confirm prompts through i18n
StatusRule maps compared state values (ready/running…/summoning) to catalog
text at render via displayStatus(); hotkeys()/placeholder() resolve lazily so
a pack that arrives after boot applies. Catalog grows to 81 keys.
* feat(desktop): pluggable app locales — registry, host.i18n.registerAppLocale, backend packs, keys emitter
- Locale widens to string (BundledLocale keeps the union); TRANSLATIONS stays
the bundled record and every consumer resolves through the registry.
- src/i18n/registry.ts: registerAppLocale(id, {endonym, rtl, translations})
layers partial packs (nested or flat dotted) over bundled/en via
mergeTranslations; a string over a function-valued en entry becomes a
positional {0}/{1} formatter; $appLocaleVersion bumps so translators
re-render; per-source disposers + replaceAppLocaleSource for atomic swaps.
- Backend packs: i18n.languages + i18n.catalog {surface:'desktop'} feed the
registry as source 'backend' (method-not-found is silent); re-synced on
socket open, display.language change and profile switch. A saved pack-only
language is promoted once its pack registers.
- SDK: host.i18n.registerAppLocale / languageOptions; ctx.i18n.registerAppLocale
tracked for unload. Docs in the desktop plugin SDK guide + skill reference.
- Language switcher lists bundled ∪ registered ∪ backend, endonym-only; RTL
from the registry (applyDocumentLocale takes rtl).
- npm run i18n:keys emits locales/_keys.desktop.json (wired into build).
* i18n(cli): route /topup + /subscription copy through t() (cli.billing.*, cli.subscription.*)
Module-level copy tables and modal choice tuples in cli_billing_mixin.py froze
English at import, before display.language was known. They are now key tables /
builder functions evaluated at call time; every user-facing line in the /usage
balance block, /subscription and the five /topup screens reads the catalog.
Choice VALUES stay English identifiers. Fragment-assembled status lines
(Plan: … → cancels · $x left · renews …) become full templates.
* i18n(gateway): exec-approval card contract + base/run/run_busy/run_inbound replies through t()
- base_exec_approval: EA_* English constants stay; add ea_header_text()/ea_reason_label_text()/
ea_smart_deny_line_text()/ea_default_reason_text()/ea_action_labels()/approval_timed_out_notice()
accessors; deadline + timed-out notice resolve via gateway.exec_approval.*
- BasePlatformAdapter._EA_HEADER/_EA_REASON_LABEL/_EA_SMART_DENY_LINE/_EA_ACTION_LABELS become
properties (adapters still shadow them with markup class attrs)
- run.py: provider error replies table holds catalog keys; _CONTEXT_OVERFLOW_REPLY -> _context_overflow_reply()
- run_busy/run_inbound: typed approval + slash-confirm matchers accept English ∪ approval.inputs.* (t())
- locales/en.yaml: gateway.exec_approval/busy/errors/... namespaces
* i18n(cli): wire modal, loops, agent-setup mixins through t() (cli.* keys)
* i18n(platforms): route Slack, Matrix and Feishu user-facing text through t()
Exec-approval markup overrides (_EA_HEADER/_EA_REASON_LABEL/_EA_SMART_DENY_LINE/
_EA_ACTION_LABELS) become per-call properties over the shared
gateway.exec_approval.* contract keys, so Slack's 3000-char section budget
measures the resolved template. Slack _APPROVAL_DECISIONS/_CONFIRM_DECISIONS,
Feishu _APPROVAL_LABEL_MAP and Matrix _EA_LEGEND/_EA_TYPED_HINT turn into
key tables resolved at click time; the Matrix typed hints become whole
sentences per offered tier instead of spliced fragments. Slack button labels
are cut to 75 chars and select placeholders to 150 after translation; the
model-facing clarify fallback answer ('choice N') stays English while the
card copy localizes.
locales/en.yaml gains the gateway.exec_approval.* contract keys plus the
platform.shared.* / platform.slack.* / platform.matrix.* / platform.feishu.*
namespaces (and the keys for the other adapters wired in follow-up commits).
* i18n(gateway): run_turn / run_turn_runner / approval-settle copy through t()
- status hints, proxy errors, background task notices, progress heartbeats, session info lines
- tool progress chrome (tool_head/tool_pending/tool_preview/tool_verbose) shared by base.format_tool_event
- run_turn_runner:1406 Chinese clarify placeholder -> gateway.clarify.native_stream_placeholder (zh text kept in zh.yaml)
- _UNEXPECTED_SILENCE_REPLY/_CLARIFY_EXPIRED_NOTICE -> accessor functions
* i18n(platforms): route Google Chat and Teams user-facing text through t()
Google Chat clarify card, typing placeholder, orphan-card labels and the whole
/setup-files reply set (module constants become platform.google_chat.setup_files.*
keys resolved at reply time). The attachment-fallback notice that shipped
hardcoded in Spanish is keyed with an English en value; es.yaml carries the
original Spanish text for those four keys.
Teams approval card header/reason use the gateway.exec_approval.* contract,
_APPROVAL_LABELS becomes a key table resolved at click time, and the meeting
summary writer resolves its section headings/fallbacks per render.
* i18n(platforms): route LINE, WeCom, email, DingTalk, IRC and Home Assistant text through t()
LINE default copy constants become catalog keys resolved in __init__ (the
LINE_*_TEXT / extra.* operator overrides still win); the busy-ack bypass
matcher keys on the leading emoji marker only, so it keeps firing once the
gateway busy heads are localized. WeCom media size/format notices that shipped
hardcoded in Chinese are keyed with English en values and zh.yaml carries the
original Chinese text. DingTalk emotion bubbles resolve per send.
* i18n(cli): route /model switch output and -q status lines through t() (cli.model.*, cli.single_query.*)
Switch-summary labels shared with the gateway reuse gateway.model.* keys
(provider/context/max-output/capabilities/prompt-caching); CLI-only variants
(glyph or no-backtick forms) live under cli.model.*. The hand-padded /model usage
block becomes a (form, description-key) table padded at render time so the
command syntax stays fixed while descriptions translate. -q 'Error:' reuses
gateway.model.error_prefix.
* i18n(cli): route TUI panel/hint/placeholder copy through t() (cli.tui.*)
_APPROVAL_CHOICE_LABELS and _TUI_MODAL_HINTS become key tables resolved at
render time; vault/sudo panel bodies are one catalog value per panel split on
newline; inline plurals use <key>_one/<key>_other. Adds the cli.* namespace
(shared/tui/voice/render/subagents/dock) to locales/en.yaml.
* i18n(cli): voice/wake-word CLI copy through t() (cli.voice.*)
RuntimeError texts raised in _voice_start_recording are human copy (callers
print {e}) and are keyed; the Termux requirement-check match stays English.
Wake state ids stay internal, only their labels localize.
* i18n(cli): live-work dock, subagent monitor and render copy through t()
cli.subagents.* / cli.dock.* / cli.render.*; count fragments pluralize via
_one/_other keys, verdict table holds keys resolved at paint time so width
clipping measures the translated text.
* i18n(gateway): unauthorized/pairing, voice, topics, shutdown, startup, notifications, kanban pings through t()
* i18n(cli): move tips + composer placeholders into the catalog (tips.tNNN / tips.placeholder.pNN)
get_random_tip()/get_random_composer_placeholder() pick a key from the English catalog
(the parity baseline, probed once per process) and resolve it through t() for the active
language, so language packs translate tips like any other string. Also lands the cli.*
en.yaml namespace consumed by the CLI info/help/error-copy wiring in the next commit.
* i18n(cli): wire chat-turn + session mixins through t(); kanban log trimmer matches t() output
* test(cli): assert TUI/dock/voice copy via t(key); prove labels resolve at render time
Pinned-English assertions in the approval-UI, live-work dock and voice tests now
go through the catalog. New test swaps the catalog after import and checks the
approval panel + hint row follow it (the reason _APPROVAL_CHOICE_LABELS and
_TUI_MODAL_HINTS became key tables).
* i18n(cli): route CLI info/help/error copy through t() (cli.* namespace)
cli_info_mixin: /help consumes CommandDef.describe() (added to commands.py: slash.<name>.description
with fallback to .description), section titles/skill/quick-command headers, /tools, /toolsets,
/usage labels, /context, /whoami, /insights, /gateway status, tool-progress labels, bang-shell
denials, MCP config-watch + /reload-mcp confirm/reload lines, /reload-skills, and the session-store
warning all read the catalog at call time (module-level label tables became functions so the
active language is honoured after startup). cli.py: worktree cleanup, tirith warning, show_config
(labels re-padded at print time), quick/plugin/skill slash-command errors, ambiguous-command hint,
stdin error, gateway start, profile warning. cli_chat_error_copy / cli_unknown_command /
cli_output / cli_init_mixin: chat error panel copy, did-you-mean lines, n-more / yes-no prompt
(localized affirmative initial alongside 'y'), unknown-toolsets warning.
* i18n(w1a): wire agent display/explainers/approval + slash registry/help through t()
- hermes_cli/commands.py: CommandDef.describe() resolves slash.<name>.description
at call time; category labels via slash.category.*; help/alias/usage suffixes
via slash.shared.*; gateway_help_lines and commands_platforms/slash_exec use them.
- agent/display.py: display.verb.* resolved at call time via get_tool_verb();
bridge/spinner/thinking-verb/cute-row/failure/preview/diff text via display.*.
- agent/turn_explainers.py: exit-reason / persistence-cause tables become call-time
lookups (explainer.exit.*, explainer.persistence.*, explainer.file_mutation.*).
- agent/background_review.py, session_activity.py, context_breakdown.py,
status_output.py: review summaries, iteration progress, context notices.
- tools/approval.py, approval_context.py: approval.summary.*, approval.noun.*,
approval.window.* pluralized keys.
- gateway/slash_commands*.py: remaining raw strings (busy, whoami, platform,
bundles, memory, skills, approvals, set_home, diff, update, debug, profile,
heartbeat, refine, review, subgoal, loop, retry, compress codex path, save,
sessions, model guard/errors, agents rows, topup, login). HISTORY_UNREADABLE
keeps its English constant; callers use history_unreadable() ->
gateway.shared.history_unreadable.
- locales/en.yaml: new approval/display/explainer/slash blocks + gateway leftovers.
* i18n(telegram): route adapter chat copy through t()
Approval card (header/reason/smart-deny as HTML-escaped properties), inline
button labels, callback toasts (cut at Telegram's 200-char cap), model/choice
pickers, clarify/update/slash-confirm prompts, gmail-triage labels and the
inbound-media failure notice now come from the catalog. _UNAUTHORIZED is a
lazy _unauthorized() so the import no longer binds a language. The command
menu carries a language+payload fingerprint (forum scopes re-register on
change) and BotCommand descriptions are cut at 256.
Adds gateway.exec_approval.* (WAVE2 contract), platform.telegram.*,
platform.discord.* and the slash.*.description keys the Discord table shares
with the CLI registry to locales/en.yaml.
* i18n(gateway/platforms): whatsapp_cloud, yuanbao, weixin, signal, api_server copy through t()
- whatsapp_cloud: clarify list/buttons, approve/deny + slash-confirm labels via platform.whatsapp.* (t()-then-truncate at 20/24/72 caps); _EA_HEADER becomes a property wrapping ea_header_text()
- yuanbao: SLOW_RESPONSE_MESSAGE -> slow_response_message() (platform.yuanbao.slow_response_notice; zh keeps the original text); cron-wrapper markers centralized as module constants for strip_cron_wrapper
- api_server: PROVIDER_AUTH_FAILED_LABEL/PROVIDER_RATE_LIMITED_LABEL stay English for run.py matchers; user_text() renders via t()
- signal/_format_wait, weixin voice caption, openai_routes transformed notice
- run_turn: second _UNEXPECTED_SILENCE_REPLY consumer -> accessor
* i18n(discord): route adapter chat copy through t()
Native slash-command table becomes _NATIVE_SLASH_COMMAND_SPECS holding catalog
keys; _native_slash_commands() resolves descriptions, parameter descriptions
and Choice names for the active language, each cut at Discord's 100-char cap,
and the app-command sync fingerprint now includes get_language() so a
display.language change re-syncs. Exec-approval card (gateway.exec_approval.*
contract), slash-confirm / clarify / update views, model+choice pickers,
thread creation, forum titles, voice acks, the response-truncation notice,
the unauthorized-slash security alert and the media upload-size notices all
read from platform.discord.*. Decorator-declared button labels are relabelled
in __init__ (80-char cap); embed titles cut at 256, select placeholders at
150, option label/description at 100. _UNAUTHORIZED is a lazy _unauthorized().
* i18n(cli): wire status-bar, stream, terminal mixins + terminal_input through t(); rename kwargs that shadow t(key)
* i18n: wire hermes_cli/cli_commands_mixin.py slash-command copy through t()
- 431 new leaves under cli.commands.<cmd>.* in locales/en.yaml; 12 rows reuse
existing gateway.* keys (rollback, diff, resume, branch, btw, model, reasoning)
via a _gt() helper so CLI and gateway replies stay identical.
- Module-level English tables (_BUSY_MODE_*, _REASONING_TOGGLES, _HATCH_PROGRESS,
_DIFF_LABELS, _LOCAL_ENGINE_LINES) become call-time catalog lookups keyed by id.
- Verb tables (Enabling/Disabling, Paused/Resumed/Triggered, planned/done,
Updating/Generating) are one full template per variant; plurals use
<key>_one/<key>_other via _tn(); hand-padded column labels (/snapshot list)
translate the value and re-pad at the call site.
- Multi-line usage blocks are single catalog values split with _lines().
- Model-facing system notes and DB-stored reasons stay English (EXCLUDED).
* tests: assert /handoff, /worktree, /login CLI copy via t(key) instead of pinned English
* test(i18n): pin Telegram/Discord adapter catalog wiring
Lazy unauthorized notice, exec-approval contract keys, HTML escaping before
Telegram <b> wrapping, 200-char toast / 256-char BotCommand caps, Discord
100-char app-command text and 80-char button caps, and language-bearing
command-menu fingerprints on both platforms.
* i18n: reconcile cli.shared on/off vs enabled/disabled after lane merge
* i18n: describe() in TUI-gateway slash listings; localize TUI exit resume hint
* i18n(tr): translate bundled catalog + tui pack
* i18n(ja): translate bundled catalog + tui pack
* i18n(ko): translate bundled catalog + tui pack
* i18n(zh): translate bundled catalog + tui pack
* i18n(fr): translate bundled catalog + tui pack
* i18n(af): translate bundled catalog + tui pack
* i18n(uk): translate bundled catalog + tui pack
* i18n(ar): translate bundled catalog + tui pack
* i18n(pt): translate bundled catalog + tui pack
* i18n(it): translate bundled catalog + tui pack
* i18n(es): translate bundled catalog + tui pack
* i18n(zh-hant): translate bundled catalog + tui pack
* i18n(ru): translate bundled catalog + tui pack
* i18n(hu): translate bundled catalog + tui pack
* i18n(hu): translate pre-existing English-valued leftovers (kanban wake, /context, /status, fast labels)
* i18n(de): translate bundled catalog + tui pack
* i18n(ga): translate bundled catalog + tui pack
* test(i18n): fixture matches _normalize_lang(lang, home) signature
* i18n(tui): scaffold userMessages/slashCmd en siblings
* i18n(tui): wire secure prompts + content tables
* feat(tui): i18n — wire billing, subscription, connection-setup and journey overlays
Adds en siblings billing.ts / subscription.ts / connection.ts (namespaces
billing, subscription, connection, journey) and routes every user-facing
literal in billingOverlay, subscriptionOverlay, connectionSetupOverlay and
journey through useT()/messages(). Module-level label tables became lazy
(scopeStillDeniedResult(), verbOf(T, action)); auto-reload rows dispatch on
stable ids instead of label text. Regenerates locales/_keys.tui.json.
* i18n(tui): wire slash ops/wake replies
* i18n(tui): wire pickers (modelPicker, activeSessionSwitcher, petPicker)
* i18n(tui): wire slash core/debug/setup replies
* i18n(tui): wire hubs (agents overlay/panel/controls, skills, plugins)
* i18n(tui): wire slash session/topup/subscription replies
* i18n(tui): wire chat bits (branding, thinking, messageLine, loaders, todo, queued, banner, entry)
* i18n(tui): register t3 siblings (pickers, hubs, secure, content, chatBits) and regenerate keys
* i18n(tui): wire userMessages copy through the userMessages namespace
* i18n(tui): lazy-copy test for userMessages, regenerate _keys.tui.json
* i18n(tui): wire session/gateway/lib text through the TUI catalog (lane t2)
Adds en siblings session.ts, gatewayMsg.ts, libText.ts (namespaces session,
gatewayMsg, libText) and routes user-facing literals in app/{useMainApp,
useSessionLifecycle,useInputHandlers,turnController,createServerRequestHandler,
setupHandoff,createGatewayEventHandler}.ts, gatewayClient displayed reasons,
lib/*, domain/*, hooks/* through t()/messages(). Status-bar state values that
code compares against, backend-matched strings, log lines, model-bound text,
and machine 'error:' prefixes stay literal. Regenerates locales/_keys.tui.json
(232 keys).
* i18n: serve bundled locales/<lang>.tui.yaml under overlay/packs; TUI pack parity test; regen _keys.tui.json (1250)
* i18n: translate pre-existing English stubs in bundled locales (424 leaves, 14 locales)
* tui: i18n-export-en script (English templates for pack translators)
* docs(i18n): bundled TUI packs are the bottom layer of the tui surface
* i18n(ru): translate TUI pack
* i18n(ar): translate TUI pack
* i18n(es): translate TUI pack
* i18n(pt): translate TUI pack
* i18n(ko): translate TUI pack
* i18n(de): translate TUI pack
* i18n(ja): translate TUI pack
* i18n(fr): translate TUI pack
* i18n(tr): translate TUI pack
* i18n(it): translate TUI pack
* i18n(zh): translate TUI pack
* i18n(zh-hant): translate TUI pack
* i18n(hu): translate TUI pack
* i18n(uk): translate TUI pack
1,169 missing keys translated; 81 pre-existing kept byte-identical. Parity OK missing=0 extra=0 placeholder_mismatch=0 empty=0.
Deliberately identical to en: chatBits.branding.mcpSummary ({0} MCP), chatBits.thinking.agentsHint ((/agents)), session.main.voiceStt (◉ STT), session.main.voiceTtsSuffix ( [tts]), slashCmd.core.help.tuiSection (TUI), slashCmd.core.history.hermesTag (Hermes #{0}), slashCmd.debug.heapdump.heapPath (heapdump: {0}), slashCmd.debug.mem.rss (rss), subscription.stepUp.title (Remote Spending — product feature name, as in core catalog), content.faces.* (glyph-only kaomoji).
* i18n(ga): translate TUI pack
* i18n(af): translate TUI pack
* plugin_guard: locale catalogs in language packs step down the agent-config family
A translated status line such as "Updating AGENTS.md" in locales/<lang>.yaml is UI text the loader
reads as a string leaf; it cannot edit a file. The bundled en.yaml itself tripped agent_config_mod
at critical, making any faithful language pack uninstallable. Injection shapes keep full severity.
* plugin_validate_locales: read key exports with utf-8-sig (Windows footgun lint)
* i18n(relay): route relay adapter prompt copy through t(); drop dead import-bound approval header
Adds platform.relay.* (5 keys) to en and all 16 bundled locales, reusing the sibling platform
translations for the confirm buttons and the Other option.
* ci: fix TUI import order, MDX table pipe, main's overflow-warning wording in all locales; fresh-install fixture carries the i18n kernel
- ui-tui/src/i18n/en.ts: perfectionist/sort-imports (slash before slashCmd)
- docs plugins/index.md: escape the | inside the provides_locales table cell (MDX parsed <id> as JSX)
- display.notice.uncompressed_context_overflow: adopt main's wording (names compression.enabled: false
and /compact) in en + 16 locales; the guardrail test pins that phrase
- tests/scripts/test_fresh_source_install.py: the installer tail now resolves CLI text through
agent.i18n, so the fixture tree carries the i18n kernel + en.yaml (not the agent runtime)
* docs(desktop-plugin-sdk): double-backtick the template-literal example (MDX evaluated ${n})
* test(e2e): display.language is validated against the live language set; exclude it from the arbitrary-string set property
* commands: keep the localized COMMANDS/COMMANDS_BY_CATEGORY module __getattr__ after the compat block removal
* build: never write locales/_keys.*.json from the desktop/TUI builds; regenerate the committed desktop key export
The desktop build regenerated locales/_keys.desktop.json in the checkout, so a
hermes update that rebuilt the app left the tree dirty (Desktop update E2E:
'M locales/_keys.desktop.json'). The key exports are committed artifacts pinned
to en.ts by apps/desktop/scripts/i18n-keys.test.mjs and ui-tui i18n:keys:check;
builds read them, never write them. Regenerated after main's new desktop strings.
* test: unbreak two main-red timing tests the PR merge-ref inherits
- test_local_runtime racing fake publishes the modern state record (legacy pid-only
records are rejected since 65ff3ad353; main has been red on this test since)
- test_run_progress_topics ManyProgressLinesAgent waits for the first bubble instead of
a fixed 0.35s, which a loaded CI runner does not always meet
* chore(i18n): regenerate desktop key catalog for main's new strings (model pricing, copy changelog)
* test(e2e): torture-chamber fd monitor confirms a deleted sidecar is still held before calling it a leak
SQLite's WAL last-close unlinks -shm before closing its descriptor (unixShmUnmap, then
unixShmPurge), so a healthy close shows a (deleted) -shm for microseconds; the 20ms poll
occasionally caught that window on the short-lived opener and failed the episode.
* chore(i18n): regenerate desktop key catalog for main's telemetry/consent strings
* chore(i18n): regenerate desktop key catalog after main sync
---------
Co-authored-by: Teknium <teknium@nousresearch.com>
1873 lines
95 KiB
Python
1873 lines
95 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 contextlib
|
|
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`` runtime 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 scope is the same home + secret + terminal composition a turn binds
|
|
(``_session_profile_runtime_scope``), not HERMES_HOME alone: these bodies read config.yaml,
|
|
whose ``${VAR}`` refs (``config._env_ref_lookup``) and the MCP probe's own header/env
|
|
interpolation resolve through ``get_secret`` — with only the home bound they read plain
|
|
``os.environ``, i.e. the launch profile's values, so ``mcp.servers.test`` for a secondary
|
|
reported green against the default profile's token (or the literal placeholder). External
|
|
sources are hydrated first (the requested profile may never have been served in this process)."""
|
|
|
|
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)
|
|
scope = contextlib.nullcontext()
|
|
if scoped:
|
|
# _profile_home is the ONE resolver: it registers the served home (flipping this
|
|
# process to fail-closed multi-profile hosting) and answers None for the launch
|
|
# profile, which then binds its own scope once multiplexing is active.
|
|
profile = _str_arg(params, "profile")
|
|
try:
|
|
try:
|
|
home = _profile_home(profile)
|
|
except ProfileUnavailableError:
|
|
return _err(rid, 4064, f"profile '{profile}' not found")
|
|
scope = _session_profile_runtime_scope({"profile_home": str(home) if home else None})
|
|
except Exception as e:
|
|
if not catch_resolve:
|
|
raise
|
|
return _err(rid, fail_code, str(e))
|
|
try:
|
|
with scope:
|
|
return body(*args)
|
|
except ProfileUnavailableError:
|
|
raise # a body that binds ``profile`` itself (_session_home_scope) reports 4064 via dispatch
|
|
except Exception as e:
|
|
return _err(rid, fail_code, f"{prefix}{e}")
|
|
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_server_rows():
|
|
config_servers = _tools_mod("hermes_cli.mcp_config")._get_mcp_servers()
|
|
return _tools_mod("tui_gateway.mcp_rpc_helpers").server_configs_with_sources(config_servers)
|
|
|
|
|
|
def _mcp_named_server(rid, params):
|
|
"""(name, servers, None) for a configured server, else (name, servers, 4064 error)."""
|
|
name, servers = _str_arg(params, "name"), _mcp_server_rows()[0]
|
|
return name, servers, None if name in servers else _err(rid, 4064, f"server '{name}' not found")
|
|
|
|
|
|
def _mcp_plugin_write_error(rid, name: str, plugins: dict):
|
|
plugin = plugins.get(name)
|
|
if plugin is not None:
|
|
return _err(rid, 4090, f"server '{name}' is provided by plugin '{plugin}' and cannot be modified")
|
|
return None
|
|
|
|
|
|
def _mcp_config_server_or_error(rid, params):
|
|
name, servers, err = _mcp_named_server(rid, params)
|
|
if err:
|
|
return name, servers, err
|
|
return name, servers, _mcp_plugin_write_error(rid, name, _mcp_server_rows()[1])
|
|
|
|
|
|
def _busy_error(rid, session, cmd: str):
|
|
if session.get("running"):
|
|
return _err(rid, 4009, busy_message(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 _session_toolsets(session) -> tuple:
|
|
"""``(enabled, disabled)`` a session's read-back RPCs must reflect: the live agent's sets once built,
|
|
else what that session's profile would build with — ``_load_enabled_toolsets`` under the session's
|
|
profile scope, the same read ``_build_agent``/``_refresh_live_sessions`` make and the key
|
|
``profiles.configure`` pins. A session whose agent is not built yet (``session.create`` with no
|
|
prompt) otherwise read back as "everything enabled" (#117977). ``None`` = all toolsets.
|
|
No session → the launch profile's config."""
|
|
agent = session.get("agent") if session else None
|
|
if agent is not None:
|
|
return getattr(agent, "enabled_toolsets", None), getattr(agent, "disabled_toolsets", None)
|
|
with _session_profile_runtime_scope(session or {}):
|
|
return _load_enabled_toolsets(_resolve_agent_platform(_session_source(session))), _load_disabled_toolsets()
|
|
|
|
|
|
def _toolset_rows(params: dict, *, with_tools: bool) -> list[dict]:
|
|
toolsets = _tools_mod("toolsets")
|
|
session = _sessions.get(params.get("session_id", ""))
|
|
enabled = set(_session_toolsets(session)[0] 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(source="process.stop")}),
|
|
# 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
|
|
|
|
|
|
def _refresh_live_sessions(home=None, *, preserve_prefix: bool = False, note: str = "") -> None:
|
|
"""Rebuild live sessions' cached tool snapshots from the registry and push session.info (agents
|
|
never re-read the registry). The MCP pool is process-global, so refreshing only the requester
|
|
would leave sibling sessions on stale tools until /new — and a request without a resolvable
|
|
session_id (desktop passes ``activeSessionId ?? undefined``) would refresh nothing while still
|
|
answering "reloaded". ``enabled_override`` re-resolves toolsets so a server enabled this session
|
|
(config or a just-installed plugin) is in the session's ``tool_call`` scope.
|
|
|
|
``home``: only sessions of that profile home (a session with no ``profile_home`` belongs to the
|
|
launch home). ``preserve_prefix``: append-only rebuild inside a live conversation. ``note``: queued
|
|
for each session's next turn on the one-shot turn-note channel (``agent/turn_context.py``)."""
|
|
from hermes_constants import hermes_home_key
|
|
want = hermes_home_key(home) if home is not None else None
|
|
with _sessions_lock:
|
|
live = [(sid, sess) for sid, sess in _sessions.items() if sess.get("agent") is not None and (
|
|
want is None or hermes_home_key(sess.get("profile_home") or get_process_hermes_home()) == want)]
|
|
refresh = _tools_mod("tools.mcp_tool_agent").refresh_agent_mcp_tools
|
|
for sid, sess in live:
|
|
agent = sess["agent"]
|
|
try:
|
|
with _session_profile_runtime_scope(sess):
|
|
enabled = _load_enabled_toolsets(getattr(agent, "platform", None))
|
|
disabled = _load_disabled_toolsets()
|
|
refresh(agent, enabled_override=enabled, disabled_override=disabled,
|
|
quiet_mode=True, preserve_prefix=preserve_prefix)
|
|
except Exception as _exc:
|
|
logger.warning("Failed to refresh cached agent tools (session %s): %s", sid, _exc)
|
|
if note:
|
|
prior = getattr(agent, "_gateway_turn_context_notes", "") or ""
|
|
agent._gateway_turn_context_notes = f"{prior}\n\n{note}" if prior else note
|
|
_emit("session.info", sid, _session_info(agent, sess))
|
|
|
|
|
|
def refresh_plugin_sessions(home, note: str) -> None:
|
|
"""A plugin just went live in ``home``: append its MCP tools to that profile's open chats (deferred
|
|
behind tool_search, so the model-facing tool array is unchanged) and queue ``note`` for their next
|
|
turn. Called by ``hermes_cli.plugins_activation_live``."""
|
|
_refresh_live_sessions(home, preserve_prefix=True, note=note)
|
|
|
|
|
|
@_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:
|
|
"""Runs under _mcp_reload_lock so a concurrent reload can't tear the registry down mid-refresh."""
|
|
_refresh_live_sessions()
|
|
|
|
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
|
|
# The launch profile is a profile too: its servers' connect-time credential reads (stdio
|
|
# child env, ``${VAR}`` header refs) go through ``get_secret``, which fails closed once this
|
|
# process multiplexes — an unscoped rediscovery parked every launch-profile stdio server
|
|
# with UnscopedSecretError while the RPC still answered "reloaded" (#113746).
|
|
with _session_profile_runtime_scope({"profile_home": None}):
|
|
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
|
|
# The unscoped shutdown tore down every profile's servers, but discover_mcp_tools() above
|
|
# only rebuilt the launch profile's overlay; a secondary-profile session refreshed against
|
|
# that registry would lose its MCP tools until its own reload.
|
|
with _sessions_lock:
|
|
homes = {sess.get("profile_home") for sess in _sessions.values() if sess.get("agent") is not None}
|
|
for home in sorted(homes - {None}):
|
|
try:
|
|
with _session_profile_runtime_scope({"profile_home": home}):
|
|
_mcp_discovery.discover_mcp_tools()
|
|
except Exception as _exc:
|
|
logger.warning("MCP rediscovery failed for profile %s: %s", home, _exc)
|
|
_refresh_session_agent()
|
|
_mcp_reload_loaded_rev = loaded
|
|
_mcp_reload_gen += 1
|
|
|
|
# LEADER (won the non-blocking acquire) runs the full reload. FOLLOWER waits, then — still
|
|
# holding the lock — coalesces only if a reload COMPLETED meanwhile (generation advanced
|
|
# ⇒ leader didn't throw) AND it loaded the requested revision; otherwise it re-runs.
|
|
if _mcp_reload_lock.acquire(blocking=False):
|
|
try:
|
|
_do_full_reload()
|
|
finally:
|
|
_mcp_reload_lock.release()
|
|
return _finish_reload(rid, params, coalesced=False)
|
|
gen_before = _mcp_reload_gen
|
|
with _mcp_reload_lock:
|
|
coalesced = _mcp_reload_gen > gen_before and (not req_rev or req_rev == _mcp_reload_loaded_rev)
|
|
_refresh_session_agent() if coalesced else _do_full_reload()
|
|
return _finish_reload(rid, params, coalesced=coalesced)
|
|
|
|
|
|
# ─── Command catalog / dispatch ──────────────────────────────────────────────
|
|
class _Catalog:
|
|
"""Accumulator for commands.catalog: ``pairs`` (every [key, desc]), ``canon`` (lowercase
|
|
key/alias → canonical key), ``commands`` (key → desktop meta) and ordered categories."""
|
|
|
|
def __init__(self) -> None:
|
|
self.pairs: list[list[str]] = []
|
|
self.canon: dict[str, str] = {}
|
|
self.commands: dict[str, dict[str, str | None]] = {}
|
|
self.cat_map: dict[str, list[list[str]]] = {} # insertion order = category order
|
|
|
|
def add(self, key: str, desc: str, cat: str) -> None:
|
|
self.canon[key.lower()] = key
|
|
self.pairs.append([key, desc])
|
|
self.cat_map.setdefault(cat, []).append([key, desc])
|
|
|
|
|
|
def _catalog_registry(cat: _Catalog) -> None:
|
|
commands = _tools_mod("hermes_cli.commands")
|
|
for cmd in commands.COMMAND_REGISTRY:
|
|
meta = commands.command_desktop_meta(cmd)
|
|
cat.commands.update({f"/{key}": dict(meta) for key in (cmd.name, *cmd.aliases)})
|
|
if cmd.name in _TUI_HIDDEN or cmd.gateway_only:
|
|
continue
|
|
cat.add(f"/{cmd.name}", commands._build_description(cmd), cmd.category)
|
|
for a in cmd.aliases:
|
|
cat.canon[f"/{a}".lower()] = f"/{cmd.name}"
|
|
for name, desc, category in _TUI_EXTRA:
|
|
# Registry command/alias wins over a colliding TUI extra (e.g. /compact, /sessions).
|
|
if name.lower() not in cat.canon:
|
|
cat.add(name, desc, category)
|
|
|
|
|
|
def _catalog_quick_commands(cat: _Catalog) -> None:
|
|
qcmds = _load_cfg().get("quick_commands", {}) or {}
|
|
if not (isinstance(qcmds, dict) and qcmds):
|
|
return
|
|
cat.cat_map.setdefault("User commands", []) # category exists even when every entry is malformed
|
|
for qname, qc in sorted(qcmds.items()):
|
|
if not isinstance(qc, dict):
|
|
continue
|
|
qtype = qc.get("type", "")
|
|
default_desc = {"exec": f"exec: {qc.get('command', '')}", "alias": f"alias → {qc.get('target', '')}"}
|
|
desc = str(qc.get("description") or default_desc.get(qtype, qtype or "quick command"))
|
|
cat.add(f"/{qname}", 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]) -> str:
|
|
"""Append skill pairs and fill ``skills`` = ``{key: {usage, origin}}`` (every consumer ranks by them).
|
|
Returns the one-line notice for skills whose name is a built-in command (no ``/<name>`` entry;
|
|
``agent.skill_commands`` guard), ``""`` when none."""
|
|
usage, origin_of = _skill_usage_lookup()
|
|
sc = _tools_mod("agent.skill_commands")
|
|
for k, info in sorted(sc.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)}
|
|
names = sorted(s["name"] for s in _tools_mod("tools.skills_tool")._find_all_skills())
|
|
return "; ".join(filter(None, map(sc.skill_command_collision_note, names)))
|
|
|
|
|
|
@_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'); only with no failure does it carry
|
|
the built-in-name collision notice for skills that have no ``/<name>`` (empty when none). Quick
|
|
command, plugin command and skill discovery are all home-keyed, so every loader runs bound to the
|
|
calling session's profile and workspace (``_completion_cwd``: its record, else the cwd a new
|
|
session would be seeded with) so project-local skills register for the repo the session is
|
|
actually in (#114359); a session-less draft is bound to ``params['profile']`` (#124651), and an
|
|
unknown profile is 4064 like ``complete.slash`` — never a launch-profile palette."""
|
|
cat = _Catalog()
|
|
_catalog_registry(cat)
|
|
warning = ""
|
|
skills: dict[str, dict] = {}
|
|
with _session_home_scope(_sessions.get(params.get("session_id", "")), cwd=_completion_cwd(params),
|
|
profile=params.get("profile")):
|
|
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}"
|
|
try:
|
|
collision_note = _catalog_skills(cat, skills) # always runs: skills must list even when a loader failed
|
|
warning = warning or collision_note
|
|
except Exception as e:
|
|
warning = f"skill discovery unavailable: {e}"
|
|
return _ok(rid, {
|
|
"pairs": cat.pairs, "sub": {k: v[:] for k, v in _tools_mod("hermes_cli.commands").SUBCOMMANDS.items()},
|
|
"canon": cat.canon,
|
|
"commands": cat.commands,
|
|
"categories": [{"name": c, "pairs": rows} for c, rows in cat.cat_map.items()],
|
|
"skills": skills, "skill_count": len(skills), "warning": warning})
|
|
|
|
|
|
@method("cli.exec")
|
|
def _(rid, params: dict) -> dict:
|
|
"""Run `python -m hermes_cli.main` with argv; capture stdout/stderr (non-interactive only)."""
|
|
argv = params.get("argv", [])
|
|
if not isinstance(argv, list) or not all(isinstance(x, str) for x in argv):
|
|
return _err(rid, 4003, "argv must be list[str]")
|
|
hint = _cli_exec_blocked(argv)
|
|
if hint:
|
|
return _ok(rid, {"blocked": True, "hint": hint, "code": -1, "output": ""})
|
|
# Same-interpreter re-exec: ambient PYTHONPATH must survive the env factory's
|
|
# Hermes-owned strip (no-boot-through-venv).
|
|
_compat = _tools_mod("hermes_cli._subprocess_compat")
|
|
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=_compat.restore_ambient_pythonpath(hermes_subprocess_env(inherit_credentials=True)))
|
|
|
|
|
|
@_rpc("command.resolve", 5012)
|
|
def _(rid, params: dict) -> dict:
|
|
r = _tools_mod("hermes_cli.commands").resolve_command(params.get("name", ""))
|
|
if r:
|
|
return _ok(rid, {"canonical": r.name, "description": r.describe(), "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, session=None) -> str:
|
|
"""Run a plugin slash-command handler under the session's ``HERMES_SESSION_*`` binding.
|
|
|
|
Plugin handlers read ``get_session_env()`` for the chat/session they serve; these RPCs run on
|
|
the socket/worker thread where nothing upstream binds it (only the turn path does), so a handler
|
|
saw ``""`` or the launch process's inherited values. Same class as the messaging gateway's
|
|
#108698; ``_set_session_context`` is the turn path's own seam."""
|
|
plugins = _tools_mod("hermes_cli.plugins")
|
|
tokens = _set_session_context(session.get("session_key", "") or "", cwd=str(session.get("cwd") or "")) if session else []
|
|
try:
|
|
return str(plugins.resolve_plugin_command_result(handler(arg)) or "")
|
|
finally:
|
|
_clear_session_context(tokens)
|
|
|
|
|
|
@contextlib.contextmanager
|
|
def _session_home_scope(session, cwd: str | None = None, profile: str | None = None):
|
|
"""Bind HERMES_HOME and the logical cwd to the session for the block.
|
|
|
|
Skill/bundle/quick-command resolution is home-keyed (``skills.external_dirs``, ``skill-bundles/``,
|
|
``quick_commands`` all live in the profile's config/home); nothing upstream of these RPC handlers
|
|
binds it, so an unscoped call resolves against the launch profile (#110695). Project-local skills
|
|
are cwd-keyed (``find_project_root`` reads the session-bound cwd first): these RPCs run on the socket
|
|
thread with no session context, where the terminal scope resolves a placeholder ``terminal.cwd`` to
|
|
``$HOME`` and no project skill ever registers or dispatches (#114359). ``cwd`` overrides the session
|
|
record (a session-less catalog request binds the workspace a new session would be seeded with).
|
|
``profile`` scopes a session-less call (a Desktop draft names its rail-selected profile) (#124651)."""
|
|
hc = _tools_mod("hermes_constants")
|
|
rc = _tools_mod("agent.runtime_cwd")
|
|
profile_home = session.get("profile_home") if session else None
|
|
if not session and profile:
|
|
profile_home = str(_profile_home(profile) or "") or None
|
|
cwd = cwd or (str(session.get("cwd") or "") if session else "")
|
|
token = hc.set_hermes_home_override(profile_home) if profile_home else None
|
|
cwd_token = rc.set_session_cwd(cwd) if cwd else None
|
|
try:
|
|
yield
|
|
finally:
|
|
if cwd_token is not None:
|
|
rc.reset_session_cwd(cwd_token)
|
|
if token is not None:
|
|
hc.reset_hermes_home_override(token)
|
|
|
|
|
|
def _profile_skill_command(session: dict, base: str) -> bool | None:
|
|
"""True when ``/base`` is a skill of the session profile.
|
|
|
|
False when the scan succeeded and it is not. None when the scan raised —
|
|
callers must not treat that as "not a skill". Fail-open sends the command to
|
|
the slash worker, which ok-replies the loading banner and drops the prompt.
|
|
"""
|
|
try:
|
|
with _session_home_scope(session):
|
|
return f"/{base}" in _tools_mod("agent.skill_commands").get_skill_commands()
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
_SKILL_WORKER_REFUSED = "skill command refused before process: /"
|
|
|
|
|
|
def _skill_dispatch_or_refuse(rid, sid, base, arg):
|
|
"""Return command.dispatch's directive, or a hard error. Never an ok banner."""
|
|
dispatched = _methods["command.dispatch"](rid, {"name": base, "arg": arg, "session_id": sid})
|
|
if "error" in dispatched or (dispatched.get("result") or {}).get("type"):
|
|
return dispatched
|
|
return _err(rid, 4018, f"skill command: use command.dispatch for /{base}")
|
|
|
|
|
|
def _worker_refused_skill(exc: BaseException) -> bool:
|
|
return _SKILL_WORKER_REFUSED in str(exc)
|
|
|
|
|
|
def _is_registry_command(base: str) -> bool:
|
|
"""True when ``base`` is a built-in the slash worker may still run.
|
|
|
|
Skill auto-registration skips names that collide with the registry, so a
|
|
built-in cannot be the skill whose prompt the worker would drop.
|
|
"""
|
|
try:
|
|
return _tools_mod("hermes_cli.commands").resolve_command(base) is not None
|
|
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, session)})
|
|
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.get_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,
|
|
count_switch=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"))
|
|
_tui_model_friction("retry", session)
|
|
return _ok(rid, {"type": "send", "message": content})
|
|
|
|
|
|
def _tui_model_friction(signal, session, turns=1):
|
|
from hermes_cli.observability.shared_metrics_model import record_model_friction
|
|
record_model_friction(
|
|
signal, session_id=session.get("session_key"), agent=session.get("agent"),
|
|
hermes_home=session.get("profile_home"), turns=turns)
|
|
|
|
|
|
def _cmd_steer(rid, params, session, name, arg):
|
|
if not arg:
|
|
return _err(rid, 4004, "usage: /steer <prompt>")
|
|
shown = f"{arg[:80]}{'...' if len(arg) > 80 else ''}"
|
|
# An idle agent still accepts steer(), but nothing drains it until the NEXT turn's pre-API
|
|
# drain, which splices it after whatever tool row is newest (#64578). Idle → a normal message.
|
|
if not (session and session.get("running")):
|
|
return _ok(rid, {"type": "send", "message": arg, "notice": f"No agent running; sent as next turn: {shown}"})
|
|
agent = session.get("agent")
|
|
if agent and hasattr(agent, "steer"):
|
|
with contextlib.suppress(Exception):
|
|
if agent.steer(arg):
|
|
return _exec_out(rid, f"⏩ Steer queued — arrives after the next tool call: {shown}")
|
|
return _ok(rid, {"type": "send", "message": arg}) # turn still building / steer refused: 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()
|
|
_tui_model_friction("undo", session, turns_undone)
|
|
turn_word = "turn" if turns_undone == 1 else "turns"
|
|
notice = f"↶ Undid {turns_undone} {turn_word} ({rewound_count} message(s)). Edit and resubmit, or send a new message."
|
|
return _ok(rid, {"type": "prefill", "message": target_text, "notice": notice})
|
|
|
|
|
|
def _is_snapshot_restore(arg: str) -> bool:
|
|
return (arg.split(maxsplit=1)[0].lower() if arg else "") in {"restore", "rewind"}
|
|
|
|
|
|
def _cmd_snapshot(rid, params, session, name, arg):
|
|
if not _is_snapshot_restore(arg):
|
|
return None
|
|
return _exec_out(
|
|
rid, "/snapshot restore is blocked in the TUI because it changes config/state on disk "
|
|
"while the live agent has cached settings. Run it in the classic CLI, then restart the TUI.")
|
|
|
|
|
|
def _cmd_compress(rid, params, session, name, arg):
|
|
if not session:
|
|
return _err(rid, 4001, "no active session to compress")
|
|
if busy := _busy_error(rid, session, "compress"):
|
|
return busy
|
|
sid = params.get("session_id", "")
|
|
if _session_uses_compute_host(session):
|
|
status, text = _compute_host_slash(sid, session, "compress", f"/{name}" + (f" {arg}" if arg else ""))
|
|
if status in {"failed", "rejected"}:
|
|
return _err(rid, 5019 if status == "failed" else 4009, text)
|
|
if status == "pending":
|
|
return _ok(rid, {"type": "exec", "status": "pending", "output": text})
|
|
return _exec_out(rid, text)
|
|
try:
|
|
output = _compress_live_with_feedback(sid, session, session["agent"], arg, snapshot_kwargs=True)
|
|
return _exec_out(rid, output)
|
|
except Exception as exc:
|
|
_tools_mod("agent.conversation_compression").finalize_context_engine_compression_notification(
|
|
session["agent"], committed=False)
|
|
return _err(rid, 5009, f"compress failed: {exc}")
|
|
|
|
|
|
_SLASH_BUILTINS = {
|
|
"queue": _cmd_queue, "q": _cmd_queue, "learn": _cmd_learn, "plan": _cmd_plan, "init": _cmd_init,
|
|
"moa": _cmd_moa, "focus": _cmd_focus, "retry": _cmd_retry, "steer": _cmd_steer, "goal": _cmd_goal,
|
|
"loop": _cmd_loop, "undo": _cmd_undo, "snapshot": _cmd_snapshot, "snap": _cmd_snapshot,
|
|
"compress": _cmd_compress, "compact": _cmd_compress}
|
|
|
|
@method("command.dispatch")
|
|
def _(rid, params: dict) -> dict:
|
|
name, arg = _resolve_name(params.get("name", "").lstrip("/")), params.get("arg", "")
|
|
session = _sessions.get(params.get("session_id", ""))
|
|
|
|
# Stage order is load-bearing: quick > plugin > bundle > skill > built-in. One home binding
|
|
# around the whole loop: the routing guard (``_profile_skill_command``) and the stages
|
|
# must resolve against the SAME profile or a secondary-only skill is routed here and then
|
|
# not found (#110695).
|
|
stages = (_dispatch_quick, _dispatch_plugin, _dispatch_bundle, _dispatch_skill, _SLASH_BUILTINS.get(name))
|
|
with _session_home_scope(session):
|
|
for stage in filter(None, stages):
|
|
res = stage(rid, params, session, name, arg)
|
|
if res is not None:
|
|
if name in _SESSION_CONTROL_SLASHES and "error" not in res:
|
|
_publish_session_control_snapshot(params.get("session_id", ""), session)
|
|
return res
|
|
return _err(rid, 4018, f"not a quick/plugin/bundle/skill command: {name}")
|
|
|
|
|
|
@method("slash.exec")
|
|
def _(rid, params: dict) -> dict:
|
|
session, err = _sess_nowait(params, rid)
|
|
if err:
|
|
return err
|
|
cmd = params.get("command", "").strip()
|
|
if not cmd:
|
|
return _err(rid, 4004, "empty command")
|
|
# Skill/bundle and _PENDING_INPUT_COMMANDS must NOT reach the slash worker. Plugin
|
|
# commands also bypass it but return normal slash.exec output (TUI keeps the pager path).
|
|
parts = cmd.lstrip("/").split(maxsplit=1)
|
|
base = (parts[0] if parts else "").lower()
|
|
arg = parts[1] if len(parts) > 1 else ""
|
|
sid = params.get("session_id", "")
|
|
live_output = _live_slash_command_output(sid, session, base, arg)
|
|
if live_output is not None:
|
|
return _ok(rid, {"output": live_output or "(no output)"})
|
|
if base in _WORKER_BLOCKED_COMMANDS and _is_snapshot_restore(arg):
|
|
return _err(rid, 4018, "snapshot restore mutates live config/state; use command.dispatch for /snapshot restore")
|
|
# Pending-input built-ins route straight to command.dispatch (some clients fail the
|
|
# error-then-retry fallback); bundles go the same way under their resolved key.
|
|
with _session_home_scope(session): # a secondary-only bundle must route too (#110695)
|
|
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})
|
|
# Recognized skills keep the 4018 gate so clients command.dispatch. A scan
|
|
# exception must not fail open into the worker: return the dispatch payload
|
|
# (or a hard error) here, or the loading banner swallows the prompt.
|
|
skill_hit = _profile_skill_command(session, base)
|
|
if skill_hit is True:
|
|
return _err(rid, 4018, f"skill command: use command.dispatch for /{base}")
|
|
if skill_hit is None:
|
|
dispatched = _skill_dispatch_or_refuse(rid, sid, base, arg)
|
|
# A built-in cannot collide with a skill slug, so the worker may still
|
|
# run it. Anything else might be the skill the scan failed to see.
|
|
if (dispatched.get("result") or {}).get("type") or not _is_registry_command(base):
|
|
return dispatched
|
|
if plugin_handler := _plugin_command_handler(base) if base else None:
|
|
try:
|
|
return _ok(rid, {"output": _run_plugin_command(plugin_handler, arg, session) 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"),
|
|
provider=getattr(session.get("agent"), "provider", None) or None)
|
|
_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:
|
|
if _worker_refused_skill(e):
|
|
# Refused before process_command; the worker is still healthy.
|
|
return _skill_dispatch_or_refuse(rid, sid, base, arg)
|
|
with contextlib.suppress(Exception):
|
|
worker.close()
|
|
session["slash_worker"] = None
|
|
return _err(rid, 5030, str(e))
|
|
|
|
|
|
# ─── Insights / rollback / browser / config ──────────────────────────────────
|
|
@_scoped_rpc("insights.get", 5017)
|
|
def _(rid, params: dict) -> dict:
|
|
days = params.get("days", 30)
|
|
# ``profile`` selects that profile's store; the launch handle is never the fallback for a
|
|
# scoped call (a foreign first touch used to pin the process-wide handle, #102526).
|
|
with _profile_db(params) as 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)})
|
|
|
|
|
|
@_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, busy_message("rollback restore"))
|
|
|
|
def go(mgr, cwd):
|
|
if reason := _container_checkpoint_refusal(session, mgr, cwd):
|
|
return {"success": False, "error": reason}
|
|
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")
|
|
|
|
def go(mgr, cwd):
|
|
# Host tree vs host checkpoint is not this session's diff either (same refusal as /rollback diff).
|
|
if reason := _container_checkpoint_refusal(session, mgr, cwd):
|
|
return _err(rid, 5022, reason)
|
|
r = 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)
|
|
return _with_checkpoints(session, go)
|
|
|
|
|
|
def _container_checkpoint_refusal(session, mgr, cwd) -> str | None:
|
|
"""Why host checkpoints are off limits for a container-backed session, else ``None``.
|
|
|
|
Classifies with the identity and scopes a turn binds (prompt_turn.py): the session key is the
|
|
tool-call task id, the session context drives the terminal registry lookup, and the profile
|
|
scope supplies the terminal policy; otherwise a cached launch-profile environment or the launch
|
|
config would answer for another profile's session."""
|
|
task_id = session.get("session_key") or "default"
|
|
tokens = _set_session_context(task_id, cwd=cwd)
|
|
try:
|
|
with _session_profile_runtime_scope(session):
|
|
return mgr.unsupported_backend_reason(task_id)
|
|
finally:
|
|
_clear_session_context(tokens)
|
|
|
|
|
|
@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}")
|
|
|
|
|
|
@_scoped_rpc("config.show", 5030)
|
|
def _(rid, params: dict) -> dict:
|
|
cfg = _load_cfg()
|
|
get_secret = _tools_mod("agent.secret_scope").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 = get_secret("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(_active_config_path())]]},
|
|
]
|
|
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, disabled = _session_toolsets(session)
|
|
# 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, disabled_toolsets=disabled, 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)
|
|
try:
|
|
return _configure_session_tools(rid, params, sid, session)
|
|
finally:
|
|
_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)
|
|
plugins = _mcp_server_rows()[1]
|
|
for target in mcp_targets:
|
|
server_name = target.split(":", 1)[0]
|
|
if err := _mcp_plugin_write_error(rid, server_name, plugins):
|
|
return err
|
|
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.describe()} 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:
|
|
# Bound like ``commands.catalog``: an unbound rescan runs against the launch env, reports the session's
|
|
# project skills as "Removed" and republishes a registry without them (#114359).
|
|
with _session_home_scope(_sessions.get(params.get("session_id", "")), cwd=_completion_cwd(params),
|
|
profile=params.get("profile")):
|
|
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 "",
|
|
"connector_slug": getattr(entry, "connector_slug", None),
|
|
"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, plugins = _mcp_server_rows()
|
|
return _ok(rid, {"servers": [
|
|
_mcp_summarize_server(name, cfg, plugins[name]) 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, plugins = _mcp_server_rows()
|
|
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}, "source": "plugin" if plugins[e["name"]] is not None else "config",
|
|
"plugin": plugins[e["name"]]} 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")
|
|
servers, plugins = _mcp_server_rows()
|
|
if err := _mcp_plugin_write_error(rid, name, plugins):
|
|
return err
|
|
if name in 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 {}
|
|
# Explicit url/command wins. Otherwise a desktop catalog id is resolved
|
|
# before the CLI preset registry — that registry raises, and the wrapper
|
|
# turns the raise into 5024 before the 4063 check below can run.
|
|
catalog = _tools_mod("hermes_cli.mcp_catalog")
|
|
entry = None
|
|
if preset and not (server_config.get("url") or server_config.get("command")):
|
|
entry = catalog.get_entry(preset)
|
|
if entry is not None:
|
|
for key, value in catalog._build_server_config(entry, install_dir=None).items():
|
|
server_config.setdefault(key, value)
|
|
else:
|
|
try:
|
|
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)
|
|
except ValueError:
|
|
return _err(rid, 4063, f"Unknown MCP catalog entry or preset: {preset}")
|
|
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))
|
|
saved_ok = mc._save_mcp_server(name, server_config)
|
|
source = "catalog" if entry is not None else ("url" if server_config.get("url") else "local")
|
|
catalog.record_mcp_install(source, entry.name if entry else None, "success" if saved_ok else "failed")
|
|
if not saved_ok:
|
|
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_config_server_or_error(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 err := _mcp_plugin_write_error(rid, name, _mcp_server_rows()[1]):
|
|
return err
|
|
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_config_server_or_error(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``/``iss``) into a ``client_redirect_uri`` flow."""
|
|
code, state, error, iss = (str(params.get(k) or "") or None for k in ("code", "state", "error", "iss"))
|
|
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,
|
|
iss=iss))
|
|
|
|
|
|
# ─── Plugins ─────────────────────────────────────────────────────────────────
|
|
def _plugin_server_rows(
|
|
plugin_dir: Path | None, key: str, *, portable: bool,
|
|
catalog_titles: dict[str, str] | None = None,
|
|
) -> list[dict]:
|
|
if not portable or plugin_dir is None:
|
|
return []
|
|
package = _tools_mod("hermes_cli.agent_plugins").load_agent_plugin(plugin_dir, plugin_dir)
|
|
namespace = package.manifest.get("extensions", {}).get("com.nousresearch.hermes", {})
|
|
declared = namespace.get("servers", {})
|
|
if not isinstance(declared, dict):
|
|
return []
|
|
server_name_for = _tools_mod("hermes_cli.plugins_manifest").portable_mcp_server_name
|
|
liveness = _tools_mod("tools.mcp_liveness")
|
|
core = _tools_mod("tools.mcp_tool_common")._core
|
|
resolve_key = _tools_mod("tools.mcp_tool_scope")._resolve_server_key
|
|
# The server sentence's app name: the curated catalog title when the package is a catalog
|
|
# install, else the manifest name, else the server slug the declaration carries — a raw
|
|
# slug reads like an error code (#119975). *catalog_titles* is pre-resolved by the caller
|
|
# (one live-catalog resolution per listing): a per-plugin ``get_live_catalog_entry`` would
|
|
# re-resolve the whole catalog once per installed plugin.
|
|
display_name = str(package.manifest.get("name") or "") or None
|
|
sidecar = _tools_mod("hermes_cli.plugins_cmd_catalog").catalog_install_record(plugin_dir)
|
|
if sidecar:
|
|
title = (catalog_titles or {}).get(str(sidecar.get("catalog_name") or ""))
|
|
if title:
|
|
display_name = title
|
|
rows = []
|
|
for name in sorted(declared):
|
|
internal_name = server_name_for(key, name)
|
|
connection_key = resolve_key(internal_name)
|
|
server = core._servers.get(connection_key)
|
|
connected = server is not None and (server.session is not None or server._is_recycled_stdio())
|
|
if connected:
|
|
rows.append({"name": name, "state": "connected", "sentence": ""})
|
|
continue
|
|
decl = _tools_mod("hermes_platform.declaration").lookup(internal_name)
|
|
status = liveness.status(internal_name)
|
|
if decl is None or status is None:
|
|
rows.append({"name": name, "state": "unknown", "sentence": ""})
|
|
continue
|
|
rows.append({
|
|
"name": name,
|
|
"state": status.state,
|
|
"sentence": liveness.describe(decl, status.availability, status.state, display_name),
|
|
})
|
|
return rows
|
|
|
|
|
|
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
|
|
versions = cat.catalog_versions()
|
|
titles = cat.catalog_titles() # server-sentence display names: ONE live-catalog resolution
|
|
ref_pins = pc._read_install_metadata() # ``--ref`` installs: pinned_sha so the desktop can show the pin
|
|
out = []
|
|
active = pc._category_active_names()
|
|
for name, version, desc, source, _dir, key in sorted(pc._discover_all_plugins()):
|
|
# Bundled backends/platforms/providers and the live memory provider run without an explicit
|
|
# enable: _plugin_status reports the truthful default instead of "not enabled" (reads as OFF).
|
|
status = pc._plugin_status(name, enabled, disabled, key=key, source=source, dir_path=_dir, active=active)
|
|
# 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
|
|
portable = pc._is_portable_plugin_dir(_dir)
|
|
out.append({
|
|
"name": name, "key": key, "version": str(version or ""), "description": desc or "",
|
|
"source": source, "status": status, "portable": portable,
|
|
"install_dir": str(_dir_path) if _dir_path else "",
|
|
"has_desktop_half": bool(_dir_path and (_dir_path / "desktop" / "plugin.js").is_file()),
|
|
# Manifest ``config_schema`` + current values: the Plugins hub renders these as a form.
|
|
"settings_schema": _tools_mod("hermes_cli.plugins_settings").plugin_settings_fields(key, _dir_path),
|
|
"servers": _plugin_server_rows(_dir_path, key, portable=portable, catalog_titles=titles),
|
|
**cat.catalog_row_fields(_dir, pins, versions),
|
|
**({"pinned_sha": sha} if (sha := pc.pinned_revision(name, ref_pins)) else {})})
|
|
return out
|
|
|
|
|
|
# Latest ``on_plugin_loaded`` summaries by plugin name — the TUI server's own subscription, so an
|
|
# install/toggle/update result reports what the load actually activated (#87770). One listener per manager.
|
|
_plugin_activations: dict = {}
|
|
_plugin_activation_subscribed: set = set()
|
|
|
|
|
|
def _ensure_plugin_activation_listener() -> None:
|
|
from hermes_cli.plugins import get_plugin_manager
|
|
manager = get_plugin_manager()
|
|
if manager.scope_key in _plugin_activation_subscribed:
|
|
return
|
|
_plugin_activation_subscribed.add(manager.scope_key)
|
|
|
|
def _on_loaded(summaries) -> None:
|
|
for entry in summaries:
|
|
_plugin_activations[entry["name"]] = entry
|
|
_plugin_activations[entry["key"]] = entry
|
|
manager.on_plugin_loaded(_on_loaded)
|
|
|
|
|
|
def _with_activation(result: dict, name: str) -> dict:
|
|
"""Fill ``activation`` from this process's listener when the core returned none (the core's own
|
|
copy carries ``live_now``, which the listener's load-time summary cannot)."""
|
|
if result.get("activation"):
|
|
return result
|
|
for key in (name, result.get("plugin_name"), result.get("name")):
|
|
if key and key in _plugin_activations:
|
|
result["activation"] = _plugin_activations[key]
|
|
break
|
|
return result
|
|
|
|
|
|
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'")
|
|
_ensure_plugin_activation_listener()
|
|
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")
|
|
# The toggle resolves a bare leaf / manifest name to the canonical key it wrote; report that key.
|
|
key = result.get("name") or ident
|
|
row = next((r for r in _plugin_rows() if key in (r["key"], r["name"])), None)
|
|
return _ok(rid, _with_activation({
|
|
"ok": True, "unchanged": bool(result.get("unchanged")),
|
|
"restart_required": bool(result.get("restart_required")),
|
|
"gateway_reloaded": bool(result.get("gateway_reloaded")), "activation": result.get("activation"),
|
|
"name": key, "plugin": row}, key))
|
|
|
|
|
|
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'")
|
|
_ensure_plugin_activation_listener()
|
|
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)
|
|
if not result.get("ok"):
|
|
return _err(rid, 5026, result.get("error") or "install failed")
|
|
return _ok(rid, _with_activation(result, str(result.get("plugin_name") or "")) if result.get("enabled") else result)
|
|
|
|
|
|
def _plugins_update(rid, params):
|
|
"""Catalog installs only: re-pin to the current catalog SHA (non-catalog installs update via the CLI).
|
|
A pin that widens the plugin (new tools/hooks/deps/capabilities/Desktop half) answers
|
|
``{ok: false, consent_required: true, delta, delta_lines}`` with nothing changed; the client shows the
|
|
delta and retries with ``accept_capabilities: true``."""
|
|
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.catalog_install_record(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:
|
|
result = cat.repin_catalog_plugin(
|
|
target, sidecar, consent_cb=(lambda _delta: True) if params.get("accept_capabilities") else None)
|
|
except cat.RepinConsentRequired as e:
|
|
return _ok(rid, {"ok": False, "consent_required": True, "name": e.name, "sha": e.sha, "delta": e.delta,
|
|
"delta_lines": cat.surface_delta_lines(e.delta), "error": str(e)})
|
|
except pc.PluginOperationError as e:
|
|
return _err(rid, 4021, str(e))
|
|
payload = {"ok": True, "unchanged": not result.changed, "sha": result.sha, "name": result.installed_name,
|
|
"warnings": list(result.warnings)}
|
|
if result.changed:
|
|
_ensure_plugin_activation_listener()
|
|
activate = _tools_mod("hermes_cli.plugins_activation").activate_plugin_now
|
|
payload = _with_activation({**payload, **activate(result.installed_name)}, result.installed_name)
|
|
return _ok(rid, payload)
|
|
|
|
|
|
def _plugins_remove(rid, params):
|
|
"""Uninstall a user install (``<HERMES_HOME>/plugins/<name>``) — the same core as ``hermes plugins
|
|
remove`` and the dashboard; bundled plugins and paths outside the plugins dir are refused there."""
|
|
name = (params.get("name") or "").strip()
|
|
if not name:
|
|
return _err(rid, 4019, "plugins.remove requires a 'name'")
|
|
result = _tools_mod("hermes_cli.plugins_cmd").dashboard_remove_user_plugin(name)
|
|
return _ok(rid, result) if result.get("ok") else _err(rid, 5026, result.get("error") or "remove failed")
|
|
|
|
|
|
def _plugins_settings(rid, params):
|
|
"""Write manifest-declared settings (``values`` = ``{key: value}``) through the same writer as
|
|
``ctx.set_config``; secrets are refused here (the client stores them via the ``.env`` route)."""
|
|
key = (params.get("key") or params.get("name") or "").strip()
|
|
values = params.get("values")
|
|
if not key or not isinstance(values, dict):
|
|
return _err(rid, 4019, "plugins.settings requires a 'key' and a 'values' mapping")
|
|
pc = _tools_mod("hermes_cli.plugins_cmd")
|
|
found = next((p for p in pc._discover_all_plugins() if key in (p[5], p[0])), None)
|
|
if found is None:
|
|
return _err(rid, 4020, f"plugin '{key}' not found")
|
|
_name, _version, _desc, _source, plugin_dir, canonical = found
|
|
try:
|
|
written = _tools_mod("hermes_cli.plugins_settings").save_plugin_settings(
|
|
canonical, Path(str(plugin_dir)) if plugin_dir else None, values)
|
|
except (ValueError, PermissionError) as e:
|
|
return _err(rid, 4021, str(e))
|
|
row = next((r for r in _plugin_rows() if r["key"] == canonical), None)
|
|
return _ok(rid, {"ok": True, "name": canonical, "written": written, "plugin": row})
|
|
|
|
|
|
def _plugins_onboarding(rid, params):
|
|
"""Catalog plugins curated for the onboarding card that this OS runs, each with its app state."""
|
|
return _ok(rid, {"onboarding": _tools_mod("hermes_cli.plugin_catalog_presence").onboarding_entries()})
|
|
|
|
|
|
_PLUGINS_ACTIONS = {"list": _plugins_list, "onboarding": _plugins_onboarding, "toggle": _plugins_toggle, "install": _plugins_install,
|
|
"update": _plugins_update, "remove": _plugins_remove, "settings": _plugins_settings}
|
|
|
|
|
|
@_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; ``remove`` deletes
|
|
a user install by ``name``; ``settings`` writes manifest-declared ``values`` for ``key``."""
|
|
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")
|
|
|
|
def done(result):
|
|
redact = _tools_mod("agent.redact").redact_sensitive_text
|
|
# Unlike the interactive CLI, this output crosses the RPC boundary and can be persisted
|
|
# in the transcript. Redact before tailing so a credential crossing the slice boundary
|
|
# cannot survive as two unmatched fragments.
|
|
stdout = redact(result.stdout or "", force=True, redact_url_credentials=True)[-4000:]
|
|
stderr = redact(result.stderr or "", force=True, redact_url_credentials=True)[-2000:]
|
|
return _ok(rid, {"stdout": stdout, "stderr": stderr, "code": result.returncode})
|
|
|
|
# shell=True preserves the user-facing !cmd grammar (pipes, redirects and interpolation).
|
|
# The child must not inherit credentials held by the long-lived gateway process.
|
|
env = _tools_mod("tools.environments.local").build_subprocess_env()
|
|
return _captured_exec(
|
|
rid, cmd, 30, shell=True, env=env, fail_code=5003,
|
|
timeout_err=(5002, "command timed out (30s)"), on_result=done)
|
|
|
|
|
|
def register(server) -> None:
|
|
"""Rebind this module's helpers + handlers onto ``server`` and register the handlers."""
|
|
bind_module(globals(), server, skip=("_",))
|