Installing a plugin from any surface now makes its MCP servers and skills
usable in every open chat of that profile on the chat's next turn. There is
no Connect-now button, no /reload-mcp, no relaunch.
- hermes_cli/plugins_activation.py::load_and_go_live: after the forced plugin
rescan, connect the plugin's portable MCP servers one by one
(register_mcp_servers, the connector flow's call), then refresh that
profile's open chats and queue a turn note listing the servers, their
tools and the plugin's skills. activation gains live_now; deferred keeps
only Python tools and prompt sections.
- MCP tools are deferred behind tool_search / tool_call, so appending them
(preserve_prefix) leaves the model-facing tool array and the cached prompt
prefix unchanged; the note rides the existing one-shot turn-note channel,
so the system prompt stays byte-stable. /reload-mcp keeps its consent gate:
it is a full rebuild.
- tui_gateway/methods_tools.py: one session walk (_refresh_live_sessions)
shared by reload.mcp and plugin activation, filtered to the plugin's
profile home.
- hermes plugins install/enable (another process) asks the running Desktop /
dashboard backend to do the in-process half through the new
POST /api/dashboard/agent-plugins/activate, found via the host rendezvous
record and its session token.
- Desktop: the Connect-now toast and its strings are removed in all six
locales; the install toast says what went live ("3 tools connected ·
skill X ready") and warns per server that did not connect.
- Contract: PluginActivation.live_now; regenerated TS/OpenRPC.
- register_skill docstring: plugin skills are listed by skills_list.
* feat(desktop): catalog install card rows for plugin and skill targets
manage_catalog opens the same connection operation manage_connections does,
with rows of kind plugin/skill. The renderer collapsed every non-connector
kind to 'mcp', so those rows would have drawn as MCP servers with no name,
purpose or provenance.
- Contract: ConnectionTargetKind gains plugin/skill; ConnectionOperationTarget
types the catalog fields agreed in CATALOG-ROW-CONTRACT.md (display,
description, tier, platforms, repo, sha, subdir, scan, requirements,
has_desktop_half, target_profile, app_state, skill).
- Store: kinds map through a lookup; catalog rows carry a typed CatalogEntry.
- CatalogRow: kind glyph, name, kind/tier/platform pills, one-line purpose,
then Install / Advanced / Skip. Installing shows the backend's per-row
detail; installed shows "N tools · show names"; failed shows the reason
and Try again (approved again for that row only); skipped stays quiet.
- Advanced: a centred dialog with the Plugins-tab controls (halves, target
profile, enable, force, pin) plus read-only source and scan. Its Install
sends the values in the answer env; the row's Install sends env null.
- A missed tool.start restores the card on a manage_catalog row.
* fix(desktop): catalog row uses the app's sliding progress and one pill style
The installing row drew a full-width pulsing line, which reads as a finished
rule, not work in progress; it now uses the Progress primitive's sliding
indeterminate block (reduced motion honoured there). The kind pill was
all-caps monospace next to two sentence-case pills; all three now share one
shape, matching the V1 offer artboard, and the platform pill stays the one
coloured note. The Advanced modal's Source and Security tables share a fixed
label column, and requirements render as prose, not code.
* fix(desktop): a skill's Advanced shows only the controls a skill has
A skill has no agent/desktop halves, no enable step and no commit pin, so
its Advanced dialog offered three controls that do nothing. It now shows the
target profile, force reinstall, source, security and credentials, and sends
only target_profile, force and the credential values. The preparing line
takes the card's width instead of the whole chat column.
The #100302 sweep guard keeps the caret's own empty text node alive, but
normalizeComposerEditorDom has three more paths that can delete the node a
live caret is anchored in: the phantom tail-block removal, the trailing-br
removal, and the block unwrap. Per the DOM spec a boundary re-anchors in the
removed node's parent, so the caret reads as "valid" while its position has
drifted (the #88621 class: editor stays activeElement, printable keys stop
producing input events, clicking restores typing).
Normalization is text-preserving, so this snapshots the caret once — both as
the boundary (node, offset) and as a composerPlainText offset, the same units
the undo/redo caret save already uses — and re-establishes it via
placeCaretAtOffset only when the snapshotted anchor node no longer exists in
the editor. Hidden keep-alive composers are excluded: the snapshot is null
unless the selection is a single collapsed range inside this editor, so a
background repaint never steals the document selection the visible composer
owns. Skipped when the editor does not hold focus (document.activeElement).
The sweep guard from the previous commit stays: skipping the node is cheaper
than a remove-then-restore round-trip and keeps the caret node identity
stable through the empty-litter case.
Tests: the new invariant (caret restored when its anchor block is removed)
fails on the base commit without this change; a second test pins that a
still-valid selection passes through untouched. Composer suite 461/461,
tsc/eslint/prettier clean.
(cherry picked from commit 741265a88499c13f6a3086133067ece9ad8aa619)
* fix(mcp): ACP, `hermes tools` and the desktop connector card use the one enabled reader
#119567 left four readers of `mcp_servers.<name>.enabled` on their own rules.
@webtecnica's #119560 found the ACP one:
- `acp_adapter/session.py::_make_agent` used `is not False`, so an ACP
session kept a server with `enabled: "false"` or `enabled: 0` in its
toolset while the MCP client skipped it.
- `hermes tools` MCP picker (`tools_config_mcp._configure_mcp_tools_interactive`)
read `enabled: 0` as on and crashed on a non-dict entry.
- Desktop connector card (`connectors/data/join.ts`) and the MCP health sweep
(`store/mcp-health.ts`) used `enabled !== false`.
All four now call `mcp_server_enabled` (Python) or `serverEnabled` (renderer),
which share one case table.
Co-authored-by: webtecnica <webtecnica@gmail.com>
* chore: retrigger CI (zero-job dispatch failure, auto-heal)
---------
Co-authored-by: webtecnica <webtecnica@gmail.com>
The `enabled` key had four parsers. The MCP client (`_parse_boolish`) read
`enabled: 0` as on; the toolset resolver and editor (`_parse_enabled_flag`)
read it as off. The server list (`summarize_server`, `/api/mcp/servers`) read
any non-`False` value as on, so `enabled: "false"` showed on while the agent
skipped it. The catalog and `hermes mcp list` accepted only true/1/yes, so
`enabled: on` showed off while the server ran.
`tools/mcp_tool_common.py::mcp_server_enabled` is now the only reader, and
every surface calls it. `_parse_boolish` treats YAML numbers by truthiness
(0 off, other numbers on). Everything else keeps the client's semantics:
the off words are off, absent / null / junk stay on, with the existing
warning for junk.
The desktop MCP page mirrors the rule in `serverEnabled`
(`apps/desktop/src/lib/mcp-servers.ts`). One case table
(`mcp-enabled-cases.json`) drives the Python invariant test and the vitest
test, so the page and the runtime cannot drift apart again.
* fix(profiles): toggle MCP servers via the enabled key the runtime reads
The profile editor (profiles.describe / profiles.configure) tracked an MCP
server's on/off state through a `disabled` key on `mcp_servers.<name>`, but
every runtime resolver keys off `enabled` (enabled_mcp_server_names,
coding_context, oneshot, the gateway's tool-name resolver). Disabling a
server in the editor wrote `disabled: true` and the server stayed live at
runtime; a server with `enabled: false` in config showed as enabled in the
editor.
describe now reports `enabled` (default true) and still honours a legacy
`disabled: true`. _save_mcp_toggles writes `enabled: true/false` for every
server and drops the legacy `disabled` key, so old configs migrate on the
next save.
Tests pin describe's reading of `enabled`, `enabled: false`, legacy
`disabled: true` and an unset flag; configure's written `enabled` flags and
the removal of `disabled`; and that a server disabled through the editor is
excluded by enabled_mcp_server_names.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* test(profiles): trim MCP toggle tests to two invariants
The describe-only and configure-only tests are subsumed by the round trip:
configure writes the key the runtime resolver reads, and describe agrees
with that resolver.
* fix(config): migrate legacy MCP `disabled: true` to `enabled: false`
Older profile editors switched an MCP server off by writing `disabled: true`,
a key no runtime reader consults, so those servers kept running while the
editor showed them off. The editor now writes `enabled`, but configs saved
before the fix still carry the old key until the user saves again.
Config v46 converts each truthy `disabled` to `enabled: false` and drops the
key; a falsy `disabled` is inert and left alone. `hermes update` runs it for
every profile, so the runtime turns off what the user already turned off.
The runtime readers stay on one key; they do not learn `disabled`.
`disabled: true` wins over an explicit `enabled: true`: `hermes mcp add`
writes `enabled: true`, and the old editor only added `disabled`, so the pair
on disk means "the user turned this off in the editor".
---------
Co-authored-by: BowmanStephen <34071312+BowmanStephen@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
The setup toolset (empty until NS-964 registers request_catalog_install)
is reserved for the profile whose backend-written profile.yaml carries
role: setup. Two points enforce it:
- Grant: tui_gateway/server.py::_load_enabled_toolsets folds the in-scope
profile's role toolsets into all three return paths (configured CLI
toolsets, coding posture, HERMES_TUI_TOOLSETS pin), next to the
client-surface set. The pin keeps it too: an operator pin picks
configurable toolsets and must not strip the profile's own.
- Deny: model_tools._select_tool_names strips toolsets reserved for any
other role from every selection, including None/"all", a saved
platform_toolsets list, profiles.configure and the env pin. That is the
one point every surface's selection passes, so a default session cannot
get the tool by any route.
The role is read with read_profile_meta on get_hermes_home(), which under a
session's home override is that session's profile dir (no directory scan).
setup stays out of _HERMES_CORE_TOOLS, CONFIGURABLE_TOOLSETS and the
platform-native recovery loop (it has no tools, so the loop skips it).
Linear NS-963.
An empty catalog now legitimately triggers the 0.0.0 sentinel fallback (two requests per
site), so the fake answers one model and the one-request-per-site count still holds; every
request still carries the residency headers.
The ChatGPT Codex models endpoint gates entries on client_version against each
model's minimal_client_version. Hermes sent the "0.0.0" sentinel, which used to
return the whole account catalog; since the GPT-6 Sol/Luna rollout it returns a
frozen legacy list (astra + the 5.6 trio) while any version at or above the newest
minimal_client_version (0.155.0, 1.0.0, 99.0.0 alike, live 2026-09-22) returns
everything the account is entitled to. So the picker and the context-window probe
hid gpt-6-sol / gpt-6-luna that the vendor's own client showed for the same account.
Both request sites now go through fetch_codex_catalog_entries(): try the
newest-client URL first and fall back to the "0.0.0" sentinel only when that
answer is non-200 or empty (the backend used to reject out-of-sequence versions
that way). No local ~/.codex/models_cache.json is consulted: a missing or stale cache (this
host records 0.147.0, which hides gpt-6-sol at 0.155.0) must not decide what the
account can see.
Report by @Stone441 (#119412); supersedes #119420 by @KoNit-K, whose fix read the
compatibility version from the local `~/.codex` cache.
* feat: setup profile is minted by the backend and found by role, not by name
The guided onboarding runs in a profile the desktop used to create itself
(profiles.create with a soul, "already exists" treated as success) and
recognise by the literal "hermes-setup". The upcoming setup toolset grants
catalog installs to that profile, so the marker that grants it must be
written only by the backend.
- profile.yaml carries `role: setup`; read_profile_meta / write_profile_meta /
ProfileInfo know it; profiles.list and GET /api/profiles report it.
- hermes_cli/setup_profile.py: ensure (find by role, adopt a pre-role
hermes-setup dir, else clone default + soul + role) and reset (soul,
memories, skills back to the created state, in place). The soul text moves
here from the renderer.
- tui_gateway/methods_onboarding.py: onboarding.ensure_setup_profile and
onboarding.reset_setup_profile. Neither takes a name; profiles.create and
profiles.configure already reject `role` (unknown key, 4000).
- Copies never inherit the role: --clone-all, profile import, and a
distribution that ships profile.yaml drop it.
- setup.status for a named profile reports `ready` once the boot bootstrap
settled. Since one host backend serves every profile (#118246) the
desktop's setup-profile probe lands on this branch, which never set
`ready`, and the kickoff waited forever.
- Desktop: SETUP_PROFILE, ensureSetupProfile(profiles.create) and
composeSetupSoul are gone. store/setup-profile.ts holds the name the
backend returned (or the roster's role row after a relaunch); kickoff,
handoff and the build card use it. The dev reset calls the reset RPC.
* fix: write the setup soul as bytes so Windows keeps \n line endings
* refactor(desktop): drop the renderer's setup-profile store; the backend is the only owner
Kickoff reads the name straight from onboarding.ensure_setup_profile and records it on
$setupSession, which every later step already carries. The handoff recovery check reads
the roster row's role. No renderer module holds a setup-profile name or a fallback lookup.
`_cap_binds` was the last inline copy of the cap-clamped-to-window predicate that #119406
centralised; the banner still prints the raw configured cap. Plugin engines without the
helper report "not binding", as before when no cap was set. The threshold_tokens comment
and the new test module stop narrating the incident; the parametrize takes the whole agent
config so the `{}` failure path is spelled out rather than derived from a ternary.
#119406 made absent keys carry DEFAULT_CONFIG's value in the cache signature, but the
legacy bool branch still emitted None for max_snapshots/max_total_size_mb/max_file_size_mb
while `_checkpoint_agent_kwargs` builds that agent from DEFAULT_CONFIG — so migrating to
`checkpoints: {enabled: true}` rebuilt the cached agent for nothing. The dict/absent branches
were also re-implementing cfg_get's own semantics; one `cfg_get(cfg, section, key, default=…)`
covers absent section, non-dict section, absent key and explicit null. DEFAULT_CONFIG is
imported at module top (hermes_cli.config already is; gateway/restart.py sets the precedent).
OpenAI shipped gpt-6-sol / gpt-6-terra / gpt-6-luna as the successors of the
gpt-5.6 tier line (Sol and Luna live on OpenRouter + the Nous Portal today).
The curated aggregator catalogs (OPENROUTER_MODELS and the derived nous list,
plus the published website model-catalog.json) now carry the gpt-6 tiers and
their -pro variants instead of the 5.6 ones; the openai-api curated fallback
lists them ahead of 5.6.
Codex OAuth support mirrors the 5.6 + Astra contract for every gpt-6 tier:
curated fallback + forward-compat synthesis (from the 5.6 twin or 5.5),
272K advertised fallback, the opt-in -900k picker variants with the
live-verified 900K bump (still capped by the catalog's max_context_window),
dated-snapshot eligibility, wire-suffix stripping, the compaction auto-raise
on the base slug, and the gpt-5.6 effort ladder (max allowed, minimal
rejected). Pricing rows for gpt-6-sol / gpt-6-luna come from OpenAI's model
pages (272K whole-request tier like Astra); Terra has no published page yet
so it deliberately has none.
/model gpt keeps resolving to the flagship: "astra" joins the rank-0 suffix
set so gpt-6-astra sorts above gpt-6-sol.
`min(threshold_tokens_cap, context_length)` behind the same `cap is not None and cap > 0`
guard was written in both `_derive_trigger` and `_apply_threshold_tokens_cap`, contradicting
`_derive_trigger`'s "one place the trigger math lives". Both now call
`_effective_threshold_cap(context_length)`. The `getattr(self, "_config_threshold_percent",
...)` fallback was dead: `__init__` sets the attribute unconditionally and no bare-`__new__`
test instance reaches `_derive_trigger`. Behaviour-preserving; the cap tests still fail when
the helper is mutated to return None.
`_parse_compression_config` documents "Defaults here MUST match DEFAULT_CONFIG" because the
config-load failure path hands it `{}`. Every key had an inline fallback except the cap
added in #115986, so a broken config.yaml kept `threshold=0.50` but silently dropped the
256K cap — the pre-#115986 500K trigger on 1M-window models. An explicit null still
means ratio-only.
The agent-cache signature reads the raw config file (no DEFAULT_CONFIG merge) and mapped
an absent key to None. Since #115986 shipped compression.threshold_tokens=256K, the
documented opt-out (`hermes config set compression.threshold_tokens null`) produced the
same None as "never set", so the signature did not change and live gateway sessions
kept compacting at 256K until the process restarted (community report on #115986).
Absent keys now carry DEFAULT_CONFIG's value — what the agent was actually built with —
so absent == explicit default (no spurious rebuild) and absent != explicit null.
profiles.configure {enabled_toolsets} saved to tools.enabled_toolsets, a
key nothing reads. The session builder resolves toolsets from
platform_toolsets.cli (_load_enabled_toolsets → _get_platform_tools), so
a profile pinned to [web] still got the full default set. The editor
read the pin back from its own key and so reported the restriction as
applied.
The writer now goes through hermes_cli.tools_config._save_platform_tools,
the same function `hermes tools` uses, so the pin lands in
platform_toolsets.cli with the same allowed-for-platform filtering and
MCP-entry preservation. An empty selection removes the cli entry so the
platform default applies again. profiles.describe reads the pin from the
same key.
Tests are relationship tests: whatever the writer pins, the runtime
reader resolves, and describe reports; A→B→A round trip; empty pin
clears. Three of four fail on main.
The success toast after a Desktop plugin install said "Restart the gateway for the plugin to take
effect" with a button that restarts the messaging gateway. The Desktop chat talks to `hermes serve`,
a different process, so the button did nothing for the plugin's MCP servers and the user relaunched
the whole app to get them. Observed live on the x64 Windows desktop installing nvidia-app.
`plugins.manage install` now returns `activation.deferred.mcp_servers` (the plugin's servers not yet
connected) and `gateway_reloaded` (#119266), and the backend rescans plugins on the install path, so a
follow-up `reload.mcp` connects them in place. The toast now branches on that:
deferred.mcp_servers non-empty → "<name> installed. Its MCP server is not connected yet." + Connect now
→ reload.mcp {confirm: true, session_id?} → plugin list refresh
gateway_reloaded → "<name> installed and active." (no button)
otherwise → the existing restart toast, unchanged (native plugin the gateway missed)
`installAgentPlugin` surfaces the two fields as `deferredMcpServers` / `gatewayReloaded`. New i18n keys
in every locale. Two behaviour tests: deferred servers → Connect now fires reload.mcp with confirm:true;
no activation → restart toast still fires.
Verified on main a53b42ddea: install nvidia-app from the catalog, then `/reload-mcp now` (the same RPC
the button calls) → `MCP server 'nvidia-app' (HTTP): registered 12 tool(s)` 0.5 s later, no relaunch.
Applying a preset opens every pane it places and closes the ones it lists
as `resting`, through the panes' own stores so ⌃`/⌘J/⌘G/⌘B stay truthful.
The per-pane `revealOnPreset` flag is gone; a saved deck remembers which
of its panes were closed.
Basic was `sessions | workspace`, and picking it produced Focus — the
apply adopts every omitted pane back in as workspace tabs, so Basic →
Focus → Basic converged on one shape (in Advanced too). Basic is now
sessions + chat with the terminal resting as a rail under the chat and
review/files resting in a right column; Focus keeps files/review as tabs
behind the chat with the same rail (a terminal tab covered the
conversation, and a reveal on apply fronted it). Simple's two presets are
Basic and its mirror.
The mirror exposed a positional bug on main: `$fileBrowserOpen` collapsed
"the right edge" whatever sat there, so a resting file tree folded the
mirrored sidebar away — flip Default with ⌘\ and ⌘J hides the sessions
column. An edge now belongs to the pane on it (`sidebarSide()` /
`fileBrowserSide()` follow `$panesFlipped`; the tree-side bindings pick
their owner the same way). Sessions gains an opener to mirror its closer.
In Simple a closed terminal hides, rail and all (`bindToolPaneCollapse(…,
$rail)`); ⌃` is the door for the session.
The layout editor gets an Interface mode section above the templates —
Simple "For talking to Hermes. Sidebar and chat; no terminal, file or diff
panes." / Advanced "For developers. Terminal, files, diffs, statusbar and
layouts, as you set them." In Simple the shelf is Sidebar left / Sidebar
right and there is nothing to arrange or save. Settings → Appearance →
Window & layout carries the same control; rows whose value Simple decides
say so. ⌘K "Simple mode" and the rebindable `view.toggleSimpleMode` are the
guaranteed way back. First launch: Basic applies Simple, Elite Advanced.
Six locales, docs, and a Playwright round trip.
Simple: no statusbar, no profile rail (unless a second profile makes it
the only way to switch), terminal / files / review resting, product tool
view, inline diffs folded, thinking collapsed, session rows down to title,
preview and time. Titlebar keeps sidebar, Settings and the layout editor —
`TITLEBAR_FIXED_TOOLS` is the one table the buttons and the width
reservation both read, so a hidden tool releases its space. Sidebar rows
carry their own `tier` (Artifacts and Scheduled jobs are Advanced;
Capabilities and Messaging are how anyone sets Hermes up) and the list
filters once — nothing is passed down. The status stack keeps Todos,
Goal, Queue and preview chips; Subagents and Background processes are
Advanced, and the 5s subagent poll stops with them while the one-shot
hydrate stays. Edit mode arranges what Simple shows: force-revealing
toggle-hidden panes is an Advanced behaviour.
Hidden surfaces do not mount — no pane bodies, no xterm, no statusbar
polling — verified live over CDP.
Also in tree-split: a capped all-fixed run whose remaining tracks are
end-placed chrome anchors to its edge, so a bottom terminal whose column
⌘J folded stays at the bottom instead of floating to the top.
Two modes. Advanced is the app as it is — `policy.advanced` is `{}`, so
every read falls through to the user's own atom and nothing is written on
an existing install. Simple shadows a table of preferences:
effective(surface) = sessionReveal ?? policy[mode] ?? userPreference
Each preference store wraps its atom in `modeBound` and exports the
effective atom under the name everything already reads, so no consumer
learns the word "mode". A toggle pressed while shadowed lands in a
session layer that a restart or mode change clears — a mode is a default,
not a lock, and a reveal can never leak into the other mode's preference.
Inside `asLayoutIntent` a shadowed write is dropped instead: a layout pick
opening panes is not "show me this".
Tiers are the list-shaped half: an item tagged with a mode shows only
there, untagged shows everywhere, and the list filters once with
`shownInMode`. `$showsAdvancedChrome` is the same answer for single
elements.
Anthropic released Claude Opus 5.5. Both the OpenRouter catalog and the
Nous Portal /v1/models endpoint serve it (verified with a live max_tokens=16
completion on each route — echoed model matches, usage.cost billed).
- hermes_cli/models_catalog_static.py: opus-5.5 in OPENROUTER_MODELS, above
opus-5 and below the fable-5 flagship pair. The nous list is derived from
the OpenRouter tuple, so the Portal picker picks it up from the same edit.
- website/static/api/model-catalog.json: regenerated via
scripts/build_model_catalog.py.
Provider-agnostic metadata already resolves for the new slug — no edits
needed: DEFAULT_CONTEXT_LENGTHS key claude-opus-5 substring-matches to
1,000,000 (matches live OpenRouter metadata), and the claude-opus-5
reasoning-timeout prefix matches through the "." separator to the 240s
floor. Both routes bill via official_models_api live pricing, so no
_OFFICIAL_DOCS_PRICING snapshot entry.
A plugin that finished loading after an adapter connected never got its platform
handlers (slash commands, button callbacks, inbound transforms) registered until a
gateway restart, silently. Three pieces, one seam shared by every surface:
1. Discovery listener: PluginManager.on_plugin_loaded(cb) fires from INSIDE
discover_and_load for the plugins a sweep newly loaded (diff of the loaded set),
with a per-plugin activation summary (hermes_cli/plugins_activation.py):
activated_now {gateway_commands, gateway_transforms, hooks, callbacks} vs
deferred {tools, prompt, mcp_servers}. Every mid-run load path now performs a real
discover_plugins(force=True): CLI install/enable (via the gateway), Desktop/TUI
plugins.manage install/toggle/update, dashboard REST install, tool-triggered
force re-discovery, the new `reload-plugins` control-socket verb. A non-forced
discover_plugins() short-circuits on _discovered, which is why reload.mcp after
a mid-run install used to reload the OLD server set.
2. Idempotent re-wire: BasePlatformAdapter.rewire_plugin_handlers() runs only
factories not yet wired on the live native client (keyed (plugin, qualname);
a force reload hands back new function objects). Telegram hoists late handlers
ahead of core's catch-all filters.COMMAND / CallbackQueryHandler (PTB dispatches
the first match per group) and re-wires on the transient-init rebuild; Slack
dedupes register_slack_action_handler per AsyncApp. The gateway runner
subscribes per served profile and re-wires on the loop.
3. Scope limit + honest messaging: handlers only. Tools/prompt stay deferred to
the next session (prompt-cache invariant), MCP servers to mcp.reload; the CLI
hint and plugins.manage results (activation, gateway_reloaded,
restart_required only when no gateway answered) say exactly that.
* fix(plugins): portable MCP servers get a readable name so tool names fit the 64-char cap
A portable plugin's MCP server was named `<skill_namespace>__<server>`, i.e.
`agent-plugin-<slug>-<sha8>__<server>`. That prefix is right for plugin-data and skill names
(collision-free without coordination, and persisted on disk) but it costs ~40 chars of every
`mcp__<server>__<tool>` name. Providers cap function names at 64, so the registry clamped every
tool of the NVIDIA plugin to a hash-suffixed stub with the verb cut off:
`mcp__agent_plugin_hermes_nvidia_72eb26b1__nvidia_app__n_3fa9c2d1`.
Server names only need to be unique among loaded portable servers, and the loader already refuses
a clash. `portable_mcp_server_name(key, server)` = `<plugin-slug>__<server>`, collapsed to
`<plugin-slug>` when the two match (the one-server package). The loader and the desktop card's
server rows both call it (the card recomputed the f-string on its own before). Skill and plugin-data
namespaces are unchanged; nothing persisted refers to the server name, so no migration.
Tests: the existing portable-load test now pins the relationship (server name = plugin slug +
server; a long vendor tool name reaches the wire unclamped), and a new test covers the clash the
digest used to hide: two enabled packages folding to one slug, second server skipped, first served.
Both red on main. Docs: developer-guide/plugins/index.md.
* fix(plugins): a portable MCP server is named what its mcp.json calls it, nothing prepended
Drop the plugin segment too. A user's own config.yaml server named `nvidia-app` yields
`mcp__nvidia_app__<tool>`; a portable plugin's server of the same name now yields the same. Duplicates
are refused at load (config.yaml first, then first-loaded plugin) with a warning naming both owners.
Spike, isolated HERMES_HOME, real session: a portable plugin with server `acme-tools` and tool
`acme_client_get_driver_status_report` registers as
`mcp__acme_tools__acme_client_get_driver_status_report`; the model found it by tool_search, called it,
and reported that exact name. Same plugin on main: `mcp__agent_plugin_acme_tools_88e7456f__acme_tools__acme_153862de`.
Two portable Agent Plugins from NousResearch/hermes-nvidia, one per NVIDIA application, each in its
own subdir of the repo and pinned to 5023f3a4ea. Both declare the application they need through
the plugin.json extensions namespace: install is refused when the app is absent or too old
(NS-942), and the tools appear only while the app is running (NS-943). `platforms: [windows]` is
the catalog-level gate; the declaration is the per-server gate.
Proven live on two Windows machines (ARM64 laptop and x64 desktop): 12 NVIDIA App tools and
10 NVIDIA Broadcast tools registered from real sessions; the x64 desktop installed the package from
the desktop deep link and the model called the tools. The evidence rows are in the plugin repo.
The repository is private until release. The catalog CI clone step is expected to fail for these
two entries until the repo is flipped public; the static validation job passes (240 entries).
Review nits on the salvage, both on lines this stack added:
- ``apply_secure_dir_policy(path, *, home=None)``: both arguments are
path-likes, so a positional swap would silently read another home's
marker. The parameter is new here, there are no positional callers.
- the docstring credited ``apply_secure_dir_policy`` for the boot caller
that resolves its own home; the caller is ``get_scratch_dir`` (via
``export_scratch_tmp_env``), which the policy only forwards.
Also reworded the scratch-policy fixture comment that still claimed the
marker is read via ``get_hermes_home()``.
`get_scratch_dir(home)` now forwards the home it resolved into the permission
policy, so a caller that already knows its home (boot scratch setup,
`hermes doctor` for another profile) reads the `.managed` marker from that
home instead of re-consulting `get_hermes_home()`.
Three `TestScratchDirPermissionPolicy` cases modelled the opposite shape: they
put the marker in `HERMES_HOME` and then passed an unrelated base
(`tmp_path/cache/scratch`, outside that home) to `get_scratch_dir`, so the
scratch dir under test was never the one the marker governed. Align them with
the contract the fix documents — the scratch dir's own home carries the
marker — keeping the coverage each case had (marker file, empty marker,
unreadable marker ⇒ pre-existing mode untouched).
Add the invariant that replaces the coupling they dropped: a marker in the
effective home must not exempt a *different* home's scratch dir from the
policy. Red on the parent test file, green with the aligned cases.