Files
hermes-agent/hermes_cli/plugins_activation.py
alt-glitch c07501ec41 fix: two plugins installed together both go live with their MCP tools
The onboarding install card installs its rows concurrently, and each install
runs load_and_go_live: a forced plugin rediscovery, then a read of the plugin's
MCP server configs and skills, then an MCP connect. A forced rediscovery
unloads every plugin first (PluginManager.unload clears _portable_mcp_servers,
_plugin_skills and each server's liveness declaration) and only then loads them
again. When NVIDIA App and NVIDIA Broadcast finished installing together, the
Broadcast pass unloaded the manager while the NVIDIA App go-live was reading
it: NVIDIA App went live with no MCP servers and no skill (the card row said
"Installed" with no tool count, and the build chat had no NVIDIA App tools
until the app restarted).

load_and_go_live now runs one at a time in the process (_GO_LIVE_LOCK), so a
second install's rediscovery cannot run while the first reads or connects.
The connect is inside that lock because it reads the server's liveness
declaration (tools/mcp_tool_transport.py::_live_endpoint), which a forced pass
also clears. The reads share the manager's discovery lock with the pass that
produced them, for forced passes that do not go through load_and_go_live, and
connect_plugin_mcp takes the server configs read there instead of reading the
manager again. The MCP connect stays outside the discovery lock, so chats that
call discover_plugins() are not held for the length of a connect.
2026-09-23 12:45:32 +05:30

221 lines
12 KiB
Python

"""Mid-run plugin activation: what a just-loaded plugin does NOW vs next session (#87770).
One seam for every surface: ``PluginManager.on_plugin_loaded`` fires with one
:func:`plugin_activation_summary` per loaded plugin. Gateway handlers (slash commands, transform hooks,
platform callbacks) are live as soon as the plugin loads; portable MCP servers and skills go live in
the open chats of the plugin's profile (:func:`load_and_go_live`; MCP tools are deferred behind
tool_search, so the cached prompt prefix is unchanged); Python tools and system-prompt sections stay
deferred to the next session (prompt-cache invariant, same as ``/skills install``).
:func:`activate_plugin_now` is what the install / enable / update surfaces call after a successful
config or tree change.
"""
from __future__ import annotations
import logging
import threading
from pathlib import Path
from typing import Any, Dict, List, Optional
logger = logging.getLogger(__name__)
_GO_LIVE_LOCK = threading.Lock()
# Hooks the gateway consults per inbound/outbound message: live as soon as the registry holds them.
_GATEWAY_TRANSFORM_HOOKS = frozenset({
"transform_llm_output", "transform_tool_result", "transform_terminal_output", "pre_gateway_dispatch",
"gateway_platform_event", "pre_command",
})
def plugin_activation_summary(manager: Any, plugin_key: str) -> Dict[str, Any]:
"""``{name, key, activated_now: {kind: [names]}, deferred: {kind: [names]}}`` for one loaded plugin,
read from what it actually registered (ownership ledger + handler registries), not from what its
manifest promises. Keys appear only when non-empty.
``activated_now``: ``gateway_commands`` (slash names), ``gateway_transforms`` / ``hooks`` (hook names),
``callbacks`` (platforms with a ``register_platform_handler`` factory / Slack action ids).
``deferred``: ``tools`` (tool names; next session), ``prompt`` (section ids; next session),
``mcp_servers`` (the plugin's mcp.json server names exactly as registered; until ``mcp.reload``)."""
loaded = manager._plugins.get(plugin_key)
manifest = getattr(loaded, "manifest", None)
name = getattr(manifest, "name", None) or plugin_key
regs = [r for r in manager._ownership_ledger.get(plugin_key, []) if getattr(r, "active", True)]
kinds: Dict[str, List[str]] = {}
for reg in regs:
kinds.setdefault(reg.kind, []).append(str(reg.key))
now: Dict[str, List[str]] = {}
if kinds.get("command"):
now["gateway_commands"] = sorted(kinds["command"])
hooks = set(kinds.get("hook", ()))
if hooks & _GATEWAY_TRANSFORM_HOOKS:
now["gateway_transforms"] = sorted(hooks & _GATEWAY_TRANSFORM_HOOKS)
if hooks - _GATEWAY_TRANSFORM_HOOKS:
now["hooks"] = sorted(hooks - _GATEWAY_TRANSFORM_HOOKS)
callbacks = sorted(platform for platform, factories in manager._platform_handler_factories.items()
if any(plugin == name for _f, plugin in factories))
callbacks += [f"slack:{a}" for a in kinds.get("slack_action_handler", ())]
if callbacks:
now["callbacks"] = callbacks
deferred: Dict[str, List[str]] = {}
tools = sorted(set(kinds.get("tool", ())) | set(getattr(loaded, "tools_registered", None) or ())
| set(getattr(manifest, "provides_tools", None) or ()))
if tools:
deferred["tools"] = tools
if kinds.get("system_prompt_section"):
deferred["prompt"] = sorted(kinds["system_prompt_section"])
servers = sorted(set(kinds.get("portable_mcp", ())) | {
s for s, owner in manager._portable_mcp_server_plugins.items() if owner == plugin_key})
if servers:
deferred["mcp_servers"] = servers
return {"name": name, "key": plugin_key, "activated_now": now, "deferred": deferred}
def activation_summaries(manager: Any) -> List[Dict[str, Any]]:
"""One summary per loaded (non-deferred-platform, non-errored) plugin — the ``on_plugin_loaded`` payload."""
out = []
for key, loaded in list(manager._plugins.items()):
if getattr(loaded, "deferred", False) or getattr(loaded, "error", None):
continue
out.append(plugin_activation_summary(manager, key))
return out
def find_activation(summaries: Optional[List[Dict[str, Any]]], name: str) -> Optional[Dict[str, Any]]:
"""The summary for ``name`` (manifest name, canonical key, or bare leaf of the key)."""
for entry in summaries or ():
key = str(entry.get("key") or "")
if name in (entry.get("name"), key, key.rsplit("/", 1)[-1]):
return entry
return None
def activate_plugin_now(name: str, *, in_process: bool = True) -> Dict[str, Any]:
"""After an install/enable/update: load the plugin in THIS process (so ``on_plugin_loaded``
subscribers here — the TUI/Desktop server — see it), connect its MCP servers and hand them plus
its skills to the open chats of this profile (:func:`load_and_go_live`), and nudge the running
gateway to load it too over its control socket so live adapters re-wire their handlers. A caller
in another process (``hermes plugins install``) passes ``in_process=False``; the running Desktop /
dashboard backend is then asked to do the in-process half (:func:`notify_serve_backend`). Never raises.
Returns ``{"gateway_reloaded": bool, "activation": summary | None, "restart_required": bool}``.
``activation.live_now`` lists what is usable in open chats now; ``activation.deferred`` what waits
for the next session. ``restart_required`` is True only when no gateway answered (old gateway, not
running)."""
from hermes_constants import get_hermes_home
activation: Optional[Dict[str, Any]] = load_and_go_live(name) if in_process else None
if not in_process:
activation = (notify_serve_backend(name, Path(get_hermes_home())) or {}).get("activation")
answer = None
try:
from gateway.control_socket import reload_gateway_plugins
from hermes_constants import get_default_hermes_root
home = Path(get_hermes_home())
answer = reload_gateway_plugins(home)
if answer is None:
root = Path(get_default_hermes_root())
if root.resolve() != home.resolve(): # a served secondary: the multiplexer's socket
answer = reload_gateway_plugins(root, profile_home=home)
except Exception:
logger.debug("gateway reload-plugins nudge for %r failed", name, exc_info=True)
reloaded = bool(answer and answer.get("reloaded"))
if reloaded and activation is None:
activation = find_activation((answer or {}).get("activations"), name)
return {"gateway_reloaded": reloaded, "activation": activation, "restart_required": not reloaded}
def load_and_go_live(name: str) -> Optional[Dict[str, Any]]:
"""Force-rediscover plugins in THIS process under the caller's profile scope, connect ``name``'s
MCP servers and hand them (and its skills) to this profile's open chats with a turn note. Returns
the activation summary with ``live_now: {mcp_servers, skills}``; ``deferred`` then keeps only what
waits for the next session (Python ``tools``, ``prompt``). None when the plugin did not load."""
# A forced rediscovery unloads every plugin before it loads them again, which drops their server
# configs, skills and liveness declarations for the length of the pass. Two installs finishing
# together (one card, two rows) each go live; the second one's pass must not run while the first
# reads or connects, or the first plugin comes up with no tools. One go-live at a time.
with _GO_LIVE_LOCK:
return _go_live(name)
def _go_live(name: str) -> Optional[Dict[str, Any]]:
from hermes_cli.plugins_activation_live import connect_plugin_mcp, live_notice, plugin_skills
try:
from hermes_cli.plugins import _join_background_discovery, get_plugin_manager
_join_background_discovery()
manager = get_plugin_manager()
# Other forced passes (a reload-plugins verb, the dashboard) do not take the go-live lock, so the
# reads share the discovery lock with the pass that produced them.
with manager._discovery_lock:
manager.discover_and_load(force=True)
activation = find_activation(activation_summaries(manager), name)
portable = manager.get_portable_mcp_servers()
skills = plugin_skills(activation["key"]) if activation else []
except Exception:
logger.debug("in-process plugin reload after change to %r failed", name, exc_info=True)
return None
if activation is None:
return None
servers = connect_plugin_mcp(activation, portable)
activation["live_now"] = {"mcp_servers": servers, "skills": skills}
activation["deferred"] = {k: v for k, v in (activation.get("deferred") or {}).items() if k != "mcp_servers"}
import sys
server = sys.modules.get("tui_gateway.server") # loaded == this process hosts chats
note = live_notice(activation)
if server is not None and (servers or note):
from hermes_constants import get_hermes_home
try:
server.refresh_plugin_sessions(Path(get_hermes_home()), note)
except Exception:
logger.warning("open chats were not refreshed after activating %r", name, exc_info=True)
return activation
def notify_serve_backend(name: str, home: Path) -> Optional[Dict[str, Any]]:
"""Ask the running Desktop / dashboard backend (``hermes serve``, found through its host record) to
run :func:`load_and_go_live` for ``name`` in ``home``. None when no backend answers. Never raises."""
try:
import json
import urllib.request
from gateway import host_rendezvous as hr
record = hr.read_record(hr.ROLE_SERVE)
if record is None or not record.port or not hr.record_token_is_consistent(record):
return None
token = hr.read_token(hr.ROLE_SERVE)
request = urllib.request.Request(
f"http://{hr.dial_host(record)}:{record.port}/api/dashboard/agent-plugins/activate",
data=json.dumps({"name": name, "home": str(home)}).encode("utf-8"), method="POST",
headers={"Content-Type": "application/json", "X-Hermes-Session-Token": token})
with urllib.request.urlopen(request, timeout=60) as response: # noqa: S310 — loopback http
return json.loads(response.read().decode("utf-8"))
except Exception:
logger.debug("serve backend activation for %r failed", name, exc_info=True)
return None
def activation_hint(result: Dict[str, Any]) -> str:
"""One honest sentence for CLI surfaces from an :func:`activate_plugin_now` result."""
act = result.get("activation") or {}
live = act.get("live_now") or {}
connected = [s for s in live.get("mcp_servers") or () if s.get("connected")]
lines = []
if connected or live.get("skills"):
tools = sum(len(s.get("tools") or ()) for s in connected)
lines.append(f"Live in open chats now: {tools} MCP tools, {len(live.get('skills') or ())} skills.")
lines.extend(f"MCP server {s['name']} not connected: {s.get('error') or 'unknown error'}"
for s in live.get("mcp_servers") or () if not s.get("connected"))
if not result.get("gateway_reloaded"):
if lines: # live in open chats; a messaging gateway that starts later loads it at boot
return "\n".join(lines)
return "\n".join([*lines, "Restart the gateway for the plugin to take effect:\n hermes gateway restart"])
now, deferred = act.get("activated_now") or {}, act.get("deferred") or {}
parts = []
if now:
parts.append("active in the running gateway now: " + ", ".join(sorted(now)))
if deferred:
labels: Dict[str, str] = {"tools": "tools (next session)", "prompt": "system prompt (next session)",
"mcp_servers": "MCP servers (next session)"}
parts.append("deferred: " + ", ".join(labels.get(k, k) for k in sorted(deferred)))
if not parts:
return "\n".join([*lines, "Gateway reloaded plugins; nothing of this plugin needs a session or restart."])
return "\n".join([*lines, "Gateway reloaded plugins — " + "; ".join(parts) + "."])