/ hermes_cli / plugins.py
plugins.py
   1  """
   2  Hermes Plugin System
   3  ====================
   4  
   5  Discovers, loads, and manages plugins from four sources:
   6  
   7  1. **Bundled plugins** – ``<repo>/plugins/<name>/`` (shipped with hermes-agent;
   8     ``memory/`` and ``context_engine/`` subdirs are excluded — they have their
   9     own discovery paths)
  10  2. **User plugins**   – ``~/.hermes/plugins/<name>/``
  11  3. **Project plugins** – ``./.hermes/plugins/<name>/`` (opt-in via
  12     ``HERMES_ENABLE_PROJECT_PLUGINS``)
  13  4. **Pip plugins**     – packages that expose the ``hermes_agent.plugins``
  14     entry-point group.
  15  
  16  Later sources override earlier ones on name collision, so a user or project
  17  plugin with the same name as a bundled plugin replaces it.
  18  
  19  Each directory plugin must contain a ``plugin.yaml`` manifest **and** an
  20  ``__init__.py`` with a ``register(ctx)`` function.
  21  
  22  Lifecycle hooks
  23  ---------------
  24  Plugins may register callbacks for any of the hooks in ``VALID_HOOKS``.
  25  The agent core calls ``invoke_hook(name, **kwargs)`` at the appropriate
  26  points.
  27  
  28  Tool registration
  29  -----------------
  30  ``PluginContext.register_tool()`` delegates to ``tools.registry.register()``
  31  so plugin-defined tools appear alongside the built-in tools.
  32  """
  33  
  34  from __future__ import annotations
  35  
  36  import asyncio
  37  import importlib
  38  import importlib.metadata
  39  import importlib.util
  40  import inspect
  41  import logging
  42  import os
  43  import sys
  44  import threading
  45  import types
  46  from dataclasses import dataclass, field
  47  from pathlib import Path
  48  from typing import Any, Callable, Dict, List, Optional, Set, Union
  49  
  50  from hermes_constants import get_hermes_home
  51  from utils import env_var_enabled
  52  from hermes_cli.config import cfg_get
  53  
  54  
  55  def get_bundled_plugins_dir() -> Path:
  56      """Locate the bundled ``plugins/`` directory.
  57  
  58      Honours ``HERMES_BUNDLED_PLUGINS`` (set by the Nix wrapper / packaged
  59      installs) so read-only store paths are consulted first.  Falls back to
  60      the in-repo path used during development.
  61      """
  62      env_override = os.getenv("HERMES_BUNDLED_PLUGINS")
  63      if env_override:
  64          return Path(env_override)
  65      return Path(__file__).resolve().parent.parent / "plugins"
  66  
  67  try:
  68      import yaml
  69  except ImportError:  # pragma: no cover – yaml is optional at import time
  70      yaml = None  # type: ignore[assignment]
  71  
  72  logger = logging.getLogger(__name__)
  73  
  74  # ---------------------------------------------------------------------------
  75  # Constants
  76  # ---------------------------------------------------------------------------
  77  
  78  VALID_HOOKS: Set[str] = {
  79      "pre_tool_call",
  80      "post_tool_call",
  81      "transform_terminal_output",
  82      "transform_tool_result",
  83      "pre_llm_call",
  84      "post_llm_call",
  85      "pre_api_request",
  86      "post_api_request",
  87      "on_session_start",
  88      "on_session_end",
  89      "on_session_finalize",
  90      "on_session_reset",
  91      "subagent_stop",
  92      # Gateway pre-dispatch hook. Fired once per incoming MessageEvent
  93      # after the internal-event guard but BEFORE auth/pairing and agent
  94      # dispatch. Plugins may return a dict to influence flow:
  95      #   {"action": "skip",    "reason": "..."}  -> drop message (no reply)
  96      #   {"action": "rewrite", "text": "..."}    -> replace event.text, continue
  97      #   {"action": "allow"}  /  None             -> normal dispatch
  98      # Kwargs: event: MessageEvent, gateway: GatewayRunner, session_store.
  99      "pre_gateway_dispatch",
 100      # Approval lifecycle hooks. Fired by tools/approval.py when a dangerous
 101      # command needs user approval -- fires BOTH for CLI-interactive prompts
 102      # and for gateway/ACP approvals (Telegram, Discord, Slack, TUI, etc.).
 103      # Observers only: return values are ignored. Plugins cannot veto or
 104      # pre-answer an approval from these hooks (use pre_tool_call to block
 105      # a tool before it reaches approval).
 106      #
 107      # Kwargs for pre_approval_request:
 108      #   command: str, description: str, pattern_key: str, pattern_keys: list[str],
 109      #   session_key: str, surface: "cli" | "gateway"
 110      # Kwargs for post_approval_response: same as above plus
 111      #   choice: "once" | "session" | "always" | "deny" | "timeout"
 112      "pre_approval_request",
 113      "post_approval_response",
 114  }
 115  
 116  ENTRY_POINTS_GROUP = "hermes_agent.plugins"
 117  
 118  _NS_PARENT = "hermes_plugins"
 119  
 120  
 121  def _env_enabled(name: str) -> bool:
 122      """Return True when an env var is set to a truthy opt-in value."""
 123      return env_var_enabled(name)
 124  
 125  
 126  def _get_disabled_plugins() -> set:
 127      """Read the disabled plugins list from config.yaml.
 128  
 129      Kept for backward compat and explicit deny-list semantics. A plugin
 130      name in this set will never load, even if it appears in
 131      ``plugins.enabled``.
 132      """
 133      try:
 134          from hermes_cli.config import load_config
 135          config = load_config()
 136          disabled = cfg_get(config, "plugins", "disabled", default=[])
 137          return set(disabled) if isinstance(disabled, list) else set()
 138      except Exception:
 139          return set()
 140  
 141  
 142  def _get_enabled_plugins() -> Optional[set]:
 143      """Read the enabled-plugins allow-list from config.yaml.
 144  
 145      Plugins are opt-in by default — only plugins whose name appears in
 146      this set are loaded. Returns:
 147  
 148      * ``None`` — the key is missing or malformed. Callers should treat
 149        this as "nothing enabled yet" (the opt-in default); the first
 150        ``migrate_config`` run populates the key with a grandfathered set
 151        of currently-installed user plugins so existing setups don't
 152        break on upgrade.
 153      * ``set()`` — an empty list was explicitly set; nothing loads.
 154      * ``set(...)`` — the concrete allow-list.
 155      """
 156      try:
 157          from hermes_cli.config import load_config
 158          config = load_config()
 159          plugins_cfg = config.get("plugins")
 160          if not isinstance(plugins_cfg, dict):
 161              return None
 162          if "enabled" not in plugins_cfg:
 163              return None
 164          enabled = plugins_cfg.get("enabled")
 165          if not isinstance(enabled, list):
 166              return None
 167          return set(enabled)
 168      except Exception:
 169          return None
 170  
 171  
 172  # ---------------------------------------------------------------------------
 173  # Data classes
 174  # ---------------------------------------------------------------------------
 175  
 176  _VALID_PLUGIN_KINDS: Set[str] = {"standalone", "backend", "exclusive", "platform"}
 177  
 178  
 179  @dataclass
 180  class PluginManifest:
 181      """Parsed representation of a plugin.yaml manifest."""
 182  
 183      name: str
 184      version: str = ""
 185      description: str = ""
 186      author: str = ""
 187      requires_env: List[Union[str, Dict[str, Any]]] = field(default_factory=list)
 188      provides_tools: List[str] = field(default_factory=list)
 189      provides_hooks: List[str] = field(default_factory=list)
 190      source: str = ""        # "user", "project", or "entrypoint"
 191      path: Optional[str] = None
 192      # Plugin kind — see plugins.py module docstring for semantics.
 193      # ``standalone`` (default): hooks/tools of its own; opt-in via
 194      #                           ``plugins.enabled``.
 195      # ``backend``: pluggable backend for an existing core tool (e.g.
 196      #              image_gen). Built-in (bundled) backends auto-load;
 197      #              user-installed still gated by ``plugins.enabled``.
 198      # ``exclusive``: category with exactly one active provider (memory).
 199      #              Selection via ``<category>.provider`` config key; the
 200      #              category's own discovery system handles loading and the
 201      #              general scanner skips these.
 202      # ``platform``: gateway messaging platform adapter (e.g. IRC). Bundled
 203      #              platform plugins auto-load so every shipped platform is
 204      #              available out of the box; user-installed platform plugins
 205      #              in ~/.hermes/plugins/ still gated by ``plugins.enabled``
 206      #              (untrusted code).
 207      kind: str = "standalone"
 208      # Registry key — path-derived, used by ``plugins.enabled``/``disabled``
 209      # lookups and by ``hermes plugins list``. For a flat plugin at
 210      # ``plugins/disk-cleanup/`` the key is ``disk-cleanup``; for a nested
 211      # category plugin at ``plugins/image_gen/openai/`` the key is
 212      # ``image_gen/openai``. When empty, falls back to ``name``.
 213      key: str = ""
 214  
 215  
 216  @dataclass
 217  class LoadedPlugin:
 218      """Runtime state for a single loaded plugin."""
 219  
 220      manifest: PluginManifest
 221      module: Optional[types.ModuleType] = None
 222      tools_registered: List[str] = field(default_factory=list)
 223      hooks_registered: List[str] = field(default_factory=list)
 224      commands_registered: List[str] = field(default_factory=list)
 225      enabled: bool = False
 226      error: Optional[str] = None
 227  
 228  
 229  # ---------------------------------------------------------------------------
 230  # PluginContext  – handed to each plugin's ``register()`` function
 231  # ---------------------------------------------------------------------------
 232  
 233  class PluginContext:
 234      """Facade given to plugins so they can register tools and hooks."""
 235  
 236      def __init__(self, manifest: PluginManifest, manager: "PluginManager"):
 237          self.manifest = manifest
 238          self._manager = manager
 239  
 240      # -- tool registration --------------------------------------------------
 241  
 242      def register_tool(
 243          self,
 244          name: str,
 245          toolset: str,
 246          schema: dict,
 247          handler: Callable,
 248          check_fn: Callable | None = None,
 249          requires_env: list | None = None,
 250          is_async: bool = False,
 251          description: str = "",
 252          emoji: str = "",
 253      ) -> None:
 254          """Register a tool in the global registry **and** track it as plugin-provided."""
 255          from tools.registry import registry
 256  
 257          registry.register(
 258              name=name,
 259              toolset=toolset,
 260              schema=schema,
 261              handler=handler,
 262              check_fn=check_fn,
 263              requires_env=requires_env,
 264              is_async=is_async,
 265              description=description,
 266              emoji=emoji,
 267          )
 268          self._manager._plugin_tool_names.add(name)
 269          logger.debug("Plugin %s registered tool: %s", self.manifest.name, name)
 270  
 271      # -- message injection --------------------------------------------------
 272  
 273      def inject_message(self, content: str, role: str = "user") -> bool:
 274          """Inject a message into the active conversation.
 275  
 276          If the agent is idle (waiting for user input), this starts a new turn.
 277          If the agent is running, this interrupts and injects the message.
 278  
 279          This enables plugins (e.g. remote control viewers, messaging bridges)
 280          to send messages into the conversation from external sources.
 281  
 282          Returns True if the message was queued successfully.
 283          """
 284          cli = self._manager._cli_ref
 285          if cli is None:
 286              logger.warning("inject_message: no CLI reference (not available in gateway mode)")
 287              return False
 288  
 289          msg = content if role == "user" else f"[{role}] {content}"
 290  
 291          if getattr(cli, "_agent_running", False):
 292              # Agent is mid-turn — interrupt with the message
 293              cli._interrupt_queue.put(msg)
 294          else:
 295              # Agent is idle — queue as next input
 296              cli._pending_input.put(msg)
 297          return True
 298  
 299      # -- CLI command registration --------------------------------------------
 300  
 301      def register_cli_command(
 302          self,
 303          name: str,
 304          help: str,
 305          setup_fn: Callable,
 306          handler_fn: Callable | None = None,
 307          description: str = "",
 308      ) -> None:
 309          """Register a CLI subcommand (e.g. ``hermes honcho ...``).
 310  
 311          The *setup_fn* receives an argparse subparser and should add any
 312          arguments/sub-subparsers.  If *handler_fn* is provided it is set
 313          as the default dispatch function via ``set_defaults(func=...)``."""
 314          self._manager._cli_commands[name] = {
 315              "name": name,
 316              "help": help,
 317              "description": description,
 318              "setup_fn": setup_fn,
 319              "handler_fn": handler_fn,
 320              "plugin": self.manifest.name,
 321          }
 322          logger.debug("Plugin %s registered CLI command: %s", self.manifest.name, name)
 323  
 324      # -- slash command registration -------------------------------------------
 325  
 326      def register_command(
 327          self,
 328          name: str,
 329          handler: Callable,
 330          description: str = "",
 331          args_hint: str = "",
 332      ) -> None:
 333          """Register a slash command (e.g. ``/lcm``) available in CLI and gateway sessions.
 334  
 335          The handler signature is ``fn(raw_args: str) -> str | None``.
 336          It may also be an async callable — the gateway dispatch handles both.
 337  
 338          Unlike ``register_cli_command()`` (which creates ``hermes <subcommand>``
 339          terminal commands), this registers in-session slash commands that users
 340          invoke during a conversation.
 341  
 342          ``args_hint`` is an optional short string (e.g. ``"<file>"`` or
 343          ``"dias:7 formato:json"``) used by gateway adapters to surface the
 344          command with an argument field — for example Discord's native slash
 345          command picker. Plugin commands without ``args_hint`` register as
 346          parameterless in Discord and still accept trailing text when invoked
 347          as free-form chat.
 348  
 349          Names conflicting with built-in commands are rejected with a warning.
 350          """
 351          clean = name.lower().strip().lstrip("/").replace(" ", "-")
 352          if not clean:
 353              logger.warning(
 354                  "Plugin '%s' tried to register a command with an empty name.",
 355                  self.manifest.name,
 356              )
 357              return
 358  
 359          # Reject if it conflicts with a built-in command
 360          try:
 361              from hermes_cli.commands import resolve_command
 362              if resolve_command(clean) is not None:
 363                  logger.warning(
 364                      "Plugin '%s' tried to register command '/%s' which conflicts "
 365                      "with a built-in command. Skipping.",
 366                      self.manifest.name, clean,
 367                  )
 368                  return
 369          except Exception:
 370              pass  # If commands module isn't available, skip the check
 371  
 372          self._manager._plugin_commands[clean] = {
 373              "handler": handler,
 374              "description": description or "Plugin command",
 375              "plugin": self.manifest.name,
 376              "args_hint": (args_hint or "").strip(),
 377          }
 378          logger.debug("Plugin %s registered command: /%s", self.manifest.name, clean)
 379  
 380      # -- tool dispatch -------------------------------------------------------
 381  
 382      def dispatch_tool(self, tool_name: str, args: dict, **kwargs) -> str:
 383          """Dispatch a tool call through the registry, with parent agent context.
 384  
 385          This is the public interface for plugin slash commands that need to call
 386          tools like ``delegate_task`` without reaching into framework internals.
 387          The parent agent (if available) is resolved automatically — plugins never
 388          need to access the agent directly.
 389  
 390          Args:
 391              tool_name: Registry name of the tool (e.g. ``"delegate_task"``).
 392              args: Tool arguments dict (same as what the model would pass).
 393              **kwargs: Extra keyword args forwarded to the registry dispatch.
 394  
 395          Returns:
 396              JSON string from the tool handler (same format as model tool calls).
 397          """
 398          from tools.registry import registry
 399  
 400          # Wire up parent agent context when available (CLI mode).
 401          # In gateway mode _cli_ref is None — tools degrade gracefully
 402          # (workspace hints fall back to TERMINAL_CWD, no spinner).
 403          if "parent_agent" not in kwargs:
 404              cli = self._manager._cli_ref
 405              agent = getattr(cli, "agent", None) if cli else None
 406              if agent is not None:
 407                  kwargs["parent_agent"] = agent
 408  
 409          return registry.dispatch(tool_name, args, **kwargs)
 410  
 411      # -- context engine registration -----------------------------------------
 412  
 413      def register_context_engine(self, engine) -> None:
 414          """Register a context engine to replace the built-in ContextCompressor.
 415  
 416          Only one context engine plugin is allowed. If a second plugin tries
 417          to register one, it is rejected with a warning.
 418  
 419          The engine must be an instance of ``agent.context_engine.ContextEngine``.
 420          """
 421          if self._manager._context_engine is not None:
 422              logger.warning(
 423                  "Plugin '%s' tried to register a context engine, but one is "
 424                  "already registered. Only one context engine plugin is allowed.",
 425                  self.manifest.name,
 426              )
 427              return
 428          # Defer the import to avoid circular deps at module level
 429          from agent.context_engine import ContextEngine
 430          if not isinstance(engine, ContextEngine):
 431              logger.warning(
 432                  "Plugin '%s' tried to register a context engine that does not "
 433                  "inherit from ContextEngine. Ignoring.",
 434                  self.manifest.name,
 435              )
 436              return
 437          self._manager._context_engine = engine
 438          logger.info(
 439              "Plugin '%s' registered context engine: %s",
 440              self.manifest.name, engine.name,
 441          )
 442  
 443      # -- image gen provider registration ------------------------------------
 444  
 445      def register_image_gen_provider(self, provider) -> None:
 446          """Register an image generation backend.
 447  
 448          ``provider`` must be an instance of
 449          :class:`agent.image_gen_provider.ImageGenProvider`. The
 450          ``provider.name`` attribute is what ``image_gen.provider`` in
 451          ``config.yaml`` matches against when routing ``image_generate``
 452          tool calls.
 453          """
 454          from agent.image_gen_provider import ImageGenProvider
 455          from agent.image_gen_registry import register_provider
 456  
 457          if not isinstance(provider, ImageGenProvider):
 458              logger.warning(
 459                  "Plugin '%s' tried to register an image_gen provider that does "
 460                  "not inherit from ImageGenProvider. Ignoring.",
 461                  self.manifest.name,
 462              )
 463              return
 464          register_provider(provider)
 465          logger.info(
 466              "Plugin '%s' registered image_gen provider: %s",
 467              self.manifest.name, provider.name,
 468          )
 469  
 470      # -- platform adapter registration ---------------------------------------
 471  
 472      def register_platform(
 473          self,
 474          name: str,
 475          label: str,
 476          adapter_factory: Callable,
 477          check_fn: Callable,
 478          validate_config: Callable | None = None,
 479          required_env: list | None = None,
 480          install_hint: str = "",
 481          **entry_kwargs: Any,
 482      ) -> None:
 483          """Register a gateway platform adapter.
 484  
 485          The adapter_factory receives a ``PlatformConfig`` and returns a
 486          ``BasePlatformAdapter`` subclass instance.  The gateway calls
 487          ``check_fn()`` before instantiation to verify dependencies.
 488  
 489          Extra keyword arguments are forwarded to ``PlatformEntry`` (e.g.
 490          ``setup_fn``, ``emoji``, ``allowed_users_env``, ``platform_hint``).
 491          Unknown keys raise TypeError from the dataclass constructor.
 492  
 493          Example::
 494  
 495              ctx.register_platform(
 496                  name="irc",
 497                  label="IRC",
 498                  adapter_factory=lambda cfg: IRCAdapter(cfg),
 499                  check_fn=lambda: True,
 500                  emoji="💬",
 501                  setup_fn=irc_interactive_setup,
 502              )
 503          """
 504          from gateway.platform_registry import platform_registry, PlatformEntry
 505  
 506          entry_kwargs.setdefault("plugin_name", self.manifest.name)
 507          entry = PlatformEntry(
 508              name=name,
 509              label=label,
 510              adapter_factory=adapter_factory,
 511              check_fn=check_fn,
 512              validate_config=validate_config,
 513              required_env=required_env or [],
 514              install_hint=install_hint,
 515              source="plugin",
 516              **entry_kwargs,
 517          )
 518          platform_registry.register(entry)
 519          self._manager._plugin_platform_names.add(name)
 520          logger.debug(
 521              "Plugin %s registered platform: %s",
 522              self.manifest.name,
 523              name,
 524          )
 525  
 526      # -- hook registration --------------------------------------------------
 527  
 528      def register_hook(self, hook_name: str, callback: Callable) -> None:
 529          """Register a lifecycle hook callback.
 530  
 531          Unknown hook names produce a warning but are still stored so
 532          forward-compatible plugins don't break.
 533          """
 534          if hook_name not in VALID_HOOKS:
 535              logger.warning(
 536                  "Plugin '%s' registered unknown hook '%s' "
 537                  "(valid: %s)",
 538                  self.manifest.name,
 539                  hook_name,
 540                  ", ".join(sorted(VALID_HOOKS)),
 541              )
 542          self._manager._hooks.setdefault(hook_name, []).append(callback)
 543          logger.debug("Plugin %s registered hook: %s", self.manifest.name, hook_name)
 544  
 545      # -- skill registration -------------------------------------------------
 546  
 547      def register_skill(
 548          self,
 549          name: str,
 550          path: Path,
 551          description: str = "",
 552      ) -> None:
 553          """Register a read-only skill provided by this plugin.
 554  
 555          The skill becomes resolvable as ``'<plugin_name>:<name>'`` via
 556          ``skill_view()``.  It does **not** enter the flat
 557          ``~/.hermes/skills/`` tree and is **not** listed in the system
 558          prompt's ``<available_skills>`` index — plugin skills are
 559          opt-in explicit loads only.
 560  
 561          Raises:
 562              ValueError: if *name* contains ``':'`` or invalid characters.
 563              FileNotFoundError: if *path* does not exist.
 564          """
 565          from agent.skill_utils import _NAMESPACE_RE
 566  
 567          if ":" in name:
 568              raise ValueError(
 569                  f"Skill name '{name}' must not contain ':' "
 570                  f"(the namespace is derived from the plugin name "
 571                  f"'{self.manifest.name}' automatically)."
 572              )
 573          if not name or not _NAMESPACE_RE.match(name):
 574              raise ValueError(
 575                  f"Invalid skill name '{name}'. Must match [a-zA-Z0-9_-]+."
 576              )
 577          if not path.exists():
 578              raise FileNotFoundError(f"SKILL.md not found at {path}")
 579  
 580          qualified = f"{self.manifest.name}:{name}"
 581          self._manager._plugin_skills[qualified] = {
 582              "path": path,
 583              "plugin": self.manifest.name,
 584              "bare_name": name,
 585              "description": description,
 586          }
 587          logger.debug(
 588              "Plugin %s registered skill: %s",
 589              self.manifest.name, qualified,
 590          )
 591  
 592  
 593  # ---------------------------------------------------------------------------
 594  # PluginManager
 595  # ---------------------------------------------------------------------------
 596  
 597  class PluginManager:
 598      """Central manager that discovers, loads, and invokes plugins."""
 599  
 600      def __init__(self) -> None:
 601          self._plugins: Dict[str, LoadedPlugin] = {}
 602          self._hooks: Dict[str, List[Callable]] = {}
 603          self._plugin_tool_names: Set[str] = set()
 604          self._plugin_platform_names: Set[str] = set()
 605          self._cli_commands: Dict[str, dict] = {}
 606          self._context_engine = None  # Set by a plugin via register_context_engine()
 607          self._plugin_commands: Dict[str, dict] = {}  # Slash commands registered by plugins
 608          self._discovered: bool = False
 609          self._cli_ref = None  # Set by CLI after plugin discovery
 610          # Plugin skill registry: qualified name → metadata dict.
 611          self._plugin_skills: Dict[str, Dict[str, Any]] = {}
 612  
 613      # -----------------------------------------------------------------------
 614      # Public
 615      # -----------------------------------------------------------------------
 616  
 617      def discover_and_load(self, force: bool = False) -> None:
 618          """Scan all plugin sources and load each plugin found.
 619  
 620          When ``force`` is true, clear cached discovery state first so config
 621          changes or newly-added bundled backends become visible in long-lived
 622          sessions without requiring a full agent restart.
 623          """
 624          if self._discovered and not force:
 625              return
 626          if force:
 627              self._plugins.clear()
 628              self._hooks.clear()
 629              self._plugin_tool_names.clear()
 630              self._cli_commands.clear()
 631              self._plugin_commands.clear()
 632              self._plugin_skills.clear()
 633              self._context_engine = None
 634          self._discovered = True
 635  
 636          manifests: List[PluginManifest] = []
 637  
 638          # 1. Bundled plugins (<repo>/plugins/<name>/)
 639          #
 640          # Repo-shipped plugins live next to hermes_cli/. Two layouts are
 641          # supported (see ``_scan_directory`` for details):
 642          #
 643          #   - flat: ``plugins/disk-cleanup/plugin.yaml`` (standalone)
 644          #   - category: ``plugins/image_gen/openai/plugin.yaml`` (backend)
 645          #
 646          # ``memory/`` and ``context_engine/`` are skipped at the top level —
 647          # they have their own discovery systems. ``platforms/`` is a category
 648          # holding platform adapters (scanned one level deeper below).
 649          repo_plugins = get_bundled_plugins_dir()
 650          manifests.extend(
 651              self._scan_directory(
 652                  repo_plugins,
 653                  source="bundled",
 654                  skip_names={"memory", "context_engine", "platforms"},
 655              )
 656          )
 657          manifests.extend(
 658              self._scan_directory(repo_plugins / "platforms", source="bundled")
 659          )
 660  
 661          # 2. User plugins (~/.hermes/plugins/)
 662          user_dir = get_hermes_home() / "plugins"
 663          manifests.extend(self._scan_directory(user_dir, source="user"))
 664  
 665          # 3. Project plugins (./.hermes/plugins/)
 666          if _env_enabled("HERMES_ENABLE_PROJECT_PLUGINS"):
 667              project_dir = Path.cwd() / ".hermes" / "plugins"
 668              manifests.extend(self._scan_directory(project_dir, source="project"))
 669  
 670          # 4. Pip / entry-point plugins
 671          manifests.extend(self._scan_entry_points())
 672  
 673          # Load each manifest (skip user-disabled plugins).
 674          # Later sources override earlier ones on key collision — user
 675          # plugins take precedence over bundled, project plugins take
 676          # precedence over user. Dedup here so we only load the final
 677          # winner. Keys are path-derived (``image_gen/openai``,
 678          # ``disk-cleanup``) so ``tts/openai`` and ``image_gen/openai``
 679          # don't collide even when both manifests say ``name: openai``.
 680          disabled = _get_disabled_plugins()
 681          enabled = _get_enabled_plugins()  # None = opt-in default (nothing enabled)
 682          winners: Dict[str, PluginManifest] = {}
 683          for manifest in manifests:
 684              winners[manifest.key or manifest.name] = manifest
 685          for manifest in winners.values():
 686              lookup_key = manifest.key or manifest.name
 687  
 688              # Explicit disable always wins (matches on key or on legacy
 689              # bare name for back-compat with existing user configs).
 690              if lookup_key in disabled or manifest.name in disabled:
 691                  loaded = LoadedPlugin(manifest=manifest, enabled=False)
 692                  loaded.error = "disabled via config"
 693                  self._plugins[lookup_key] = loaded
 694                  logger.debug("Skipping disabled plugin '%s'", lookup_key)
 695                  continue
 696  
 697              # Exclusive plugins (memory providers) have their own
 698              # discovery/activation path. The general loader records the
 699              # manifest for introspection but does not load the module.
 700              if manifest.kind == "exclusive":
 701                  loaded = LoadedPlugin(manifest=manifest, enabled=False)
 702                  loaded.error = (
 703                      "exclusive plugin — activate via <category>.provider config"
 704                  )
 705                  self._plugins[lookup_key] = loaded
 706                  logger.debug(
 707                      "Skipping '%s' (exclusive, handled by category discovery)",
 708                      lookup_key,
 709                  )
 710                  continue
 711  
 712              # Built-in backends auto-load — they ship with hermes and must
 713              # just work. Selection among them (e.g. which image_gen backend
 714              # services calls) is driven by ``<category>.provider`` config,
 715              # enforced by the tool wrapper.
 716              #
 717              # Bundled platform plugins (gateway adapters like IRC) auto-load
 718              # for the same reason: every platform Hermes ships must be
 719              # available out of the box without the user having to opt in.
 720              if manifest.source == "bundled" and manifest.kind in ("backend", "platform"):
 721                  self._load_plugin(manifest)
 722                  continue
 723  
 724              # Everything else (standalone, user-installed backends,
 725              # entry-point plugins) is opt-in via plugins.enabled.
 726              # Accept both the path-derived key and the legacy bare name
 727              # so existing configs keep working.
 728              is_enabled = (
 729                  enabled is not None
 730                  and (lookup_key in enabled or manifest.name in enabled)
 731              )
 732              if not is_enabled:
 733                  loaded = LoadedPlugin(manifest=manifest, enabled=False)
 734                  loaded.error = (
 735                      "not enabled in config (run `hermes plugins enable {}` to activate)"
 736                      .format(lookup_key)
 737                  )
 738                  self._plugins[lookup_key] = loaded
 739                  logger.debug(
 740                      "Skipping '%s' (not in plugins.enabled)", lookup_key
 741                  )
 742                  continue
 743              self._load_plugin(manifest)
 744  
 745          if manifests:
 746              logger.info(
 747                  "Plugin discovery complete: %d found, %d enabled",
 748                  len(self._plugins),
 749                  sum(1 for p in self._plugins.values() if p.enabled),
 750              )
 751  
 752      # -----------------------------------------------------------------------
 753      # Directory scanning
 754      # -----------------------------------------------------------------------
 755  
 756      def _scan_directory(
 757          self,
 758          path: Path,
 759          source: str,
 760          skip_names: Optional[Set[str]] = None,
 761      ) -> List[PluginManifest]:
 762          """Read ``plugin.yaml`` manifests from subdirectories of *path*.
 763  
 764          Supports two layouts, mixed freely:
 765  
 766          * **Flat** — ``<root>/<plugin-name>/plugin.yaml``. Key is
 767            ``<plugin-name>`` (e.g. ``disk-cleanup``).
 768          * **Category** — ``<root>/<category>/<plugin-name>/plugin.yaml``,
 769            where the ``<category>`` directory itself has no ``plugin.yaml``.
 770            Key is ``<category>/<plugin-name>`` (e.g. ``image_gen/openai``).
 771            Depth is capped at two segments.
 772  
 773          *skip_names* is an optional allow-list of names to ignore at the
 774          top level (kept for back-compat; the current call sites no longer
 775          pass it now that categories are first-class).
 776          """
 777          return self._scan_directory_level(
 778              path, source, skip_names=skip_names, prefix="", depth=0
 779          )
 780  
 781      def _scan_directory_level(
 782          self,
 783          path: Path,
 784          source: str,
 785          *,
 786          skip_names: Optional[Set[str]],
 787          prefix: str,
 788          depth: int,
 789      ) -> List[PluginManifest]:
 790          """Recursive implementation of :meth:`_scan_directory`.
 791  
 792          ``prefix`` is the category path already accumulated ("" at root,
 793          "image_gen" one level in). ``depth`` is the recursion depth; we
 794          cap at 2 so ``<root>/a/b/c/`` is ignored.
 795          """
 796          manifests: List[PluginManifest] = []
 797          if not path.is_dir():
 798              return manifests
 799  
 800          for child in sorted(path.iterdir()):
 801              if not child.is_dir():
 802                  continue
 803              if depth == 0 and skip_names and child.name in skip_names:
 804                  continue
 805              manifest_file = child / "plugin.yaml"
 806              if not manifest_file.exists():
 807                  manifest_file = child / "plugin.yml"
 808  
 809              if manifest_file.exists():
 810                  manifest = self._parse_manifest(
 811                      manifest_file, child, source, prefix
 812                  )
 813                  if manifest is not None:
 814                      manifests.append(manifest)
 815                  continue
 816  
 817              # No manifest at this level. If we're still within the depth
 818              # cap, treat this directory as a category namespace and recurse
 819              # one level in looking for children with manifests.
 820              if depth >= 1:
 821                  logger.debug("Skipping %s (no plugin.yaml, depth cap reached)", child)
 822                  continue
 823  
 824              sub_prefix = f"{prefix}/{child.name}" if prefix else child.name
 825              manifests.extend(
 826                  self._scan_directory_level(
 827                      child,
 828                      source,
 829                      skip_names=None,
 830                      prefix=sub_prefix,
 831                      depth=depth + 1,
 832                  )
 833              )
 834  
 835          return manifests
 836  
 837      def _parse_manifest(
 838          self,
 839          manifest_file: Path,
 840          plugin_dir: Path,
 841          source: str,
 842          prefix: str,
 843      ) -> Optional[PluginManifest]:
 844          """Parse a single ``plugin.yaml`` into a :class:`PluginManifest`.
 845  
 846          Returns ``None`` on parse failure (logs a warning).
 847          """
 848          try:
 849              if yaml is None:
 850                  logger.warning("PyYAML not installed – cannot load %s", manifest_file)
 851                  return None
 852              data = yaml.safe_load(manifest_file.read_text()) or {}
 853  
 854              name = data.get("name", plugin_dir.name)
 855              key = f"{prefix}/{plugin_dir.name}" if prefix else name
 856  
 857              raw_kind = data.get("kind", "standalone")
 858              if not isinstance(raw_kind, str):
 859                  raw_kind = "standalone"
 860              kind = raw_kind.strip().lower()
 861              if kind not in _VALID_PLUGIN_KINDS:
 862                  logger.warning(
 863                      "Plugin %s: unknown kind '%s' (valid: %s); treating as 'standalone'",
 864                      key, raw_kind, ", ".join(sorted(_VALID_PLUGIN_KINDS)),
 865                  )
 866                  kind = "standalone"
 867  
 868              # Auto-coerce user-installed memory providers to kind="exclusive"
 869              # so they're routed to plugins/memory discovery instead of being
 870              # loaded by the general PluginManager (which has no
 871              # register_memory_provider on PluginContext). Mirrors the
 872              # heuristic in plugins/memory/__init__.py:_is_memory_provider_dir.
 873              # Bundled memory providers are already skipped via skip_names.
 874              if kind == "standalone" and "kind" not in data:
 875                  init_file = plugin_dir / "__init__.py"
 876                  if init_file.exists():
 877                      try:
 878                          source_text = init_file.read_text(errors="replace")[:8192]
 879                          if (
 880                              "register_memory_provider" in source_text
 881                              or "MemoryProvider" in source_text
 882                          ):
 883                              kind = "exclusive"
 884                              logger.debug(
 885                                  "Plugin %s: detected memory provider, "
 886                                  "treating as kind='exclusive'",
 887                                  key,
 888                              )
 889                      except Exception:
 890                          pass
 891  
 892              return PluginManifest(
 893                  name=name,
 894                  version=str(data.get("version", "")),
 895                  description=data.get("description", ""),
 896                  author=data.get("author", ""),
 897                  requires_env=data.get("requires_env", []),
 898                  provides_tools=data.get("provides_tools", []),
 899                  provides_hooks=data.get("provides_hooks", []),
 900                  source=source,
 901                  path=str(plugin_dir),
 902                  kind=kind,
 903                  key=key,
 904              )
 905          except Exception as exc:
 906              logger.warning("Failed to parse %s: %s", manifest_file, exc)
 907              return None
 908  
 909      # -----------------------------------------------------------------------
 910      # Entry-point scanning
 911      # -----------------------------------------------------------------------
 912  
 913      def _scan_entry_points(self) -> List[PluginManifest]:
 914          """Check ``importlib.metadata`` for pip-installed plugins."""
 915          manifests: List[PluginManifest] = []
 916          try:
 917              eps = importlib.metadata.entry_points()
 918              # Python 3.12+ returns a SelectableGroups; earlier returns dict
 919              if hasattr(eps, "select"):
 920                  group_eps = eps.select(group=ENTRY_POINTS_GROUP)
 921              elif isinstance(eps, dict):
 922                  group_eps = eps.get(ENTRY_POINTS_GROUP, [])
 923              else:
 924                  group_eps = [ep for ep in eps if ep.group == ENTRY_POINTS_GROUP]
 925  
 926              for ep in group_eps:
 927                  manifest = PluginManifest(
 928                      name=ep.name,
 929                      source="entrypoint",
 930                      path=ep.value,
 931                      key=ep.name,
 932                  )
 933                  manifests.append(manifest)
 934          except Exception as exc:
 935              logger.debug("Entry-point scan failed: %s", exc)
 936  
 937          return manifests
 938  
 939      # -----------------------------------------------------------------------
 940      # Loading
 941      # -----------------------------------------------------------------------
 942  
 943      def _load_plugin(self, manifest: PluginManifest) -> None:
 944          """Import a plugin module and call its ``register(ctx)`` function."""
 945          loaded = LoadedPlugin(manifest=manifest)
 946  
 947          try:
 948              if manifest.source in ("user", "project", "bundled"):
 949                  module = self._load_directory_module(manifest)
 950              else:
 951                  module = self._load_entrypoint_module(manifest)
 952  
 953              loaded.module = module
 954  
 955              # Call register()
 956              register_fn = getattr(module, "register", None)
 957              if register_fn is None:
 958                  loaded.error = "no register() function"
 959                  logger.warning("Plugin '%s' has no register() function", manifest.name)
 960              else:
 961                  ctx = PluginContext(manifest, self)
 962                  register_fn(ctx)
 963                  loaded.tools_registered = [
 964                      t for t in self._plugin_tool_names
 965                      if t not in {
 966                          n
 967                          for name, p in self._plugins.items()
 968                          for n in p.tools_registered
 969                      }
 970                  ]
 971                  loaded.hooks_registered = list(
 972                      {
 973                          h
 974                          for h, cbs in self._hooks.items()
 975                          if cbs  # non-empty
 976                      }
 977                      - {
 978                          h
 979                          for name, p in self._plugins.items()
 980                          for h in p.hooks_registered
 981                      }
 982                  )
 983                  loaded.commands_registered = [
 984                      c for c in self._plugin_commands
 985                      if self._plugin_commands[c].get("plugin") == manifest.name
 986                  ]
 987                  loaded.enabled = True
 988  
 989          except Exception as exc:
 990              loaded.error = str(exc)
 991              logger.warning("Failed to load plugin '%s': %s", manifest.name, exc)
 992  
 993          self._plugins[manifest.key or manifest.name] = loaded
 994  
 995      def _load_directory_module(self, manifest: PluginManifest) -> types.ModuleType:
 996          """Import a directory-based plugin as ``hermes_plugins.<slug>``.
 997  
 998          The module slug is derived from ``manifest.key`` so category-namespaced
 999          plugins (``image_gen/openai``) import as
1000          ``hermes_plugins.image_gen__openai`` without colliding with any
1001          future ``tts/openai``.
1002          """
1003          plugin_dir = Path(manifest.path)  # type: ignore[arg-type]
1004          init_file = plugin_dir / "__init__.py"
1005          if not init_file.exists():
1006              raise FileNotFoundError(f"No __init__.py in {plugin_dir}")
1007  
1008          # Ensure the namespace parent package exists
1009          if _NS_PARENT not in sys.modules:
1010              ns_pkg = types.ModuleType(_NS_PARENT)
1011              ns_pkg.__path__ = []  # type: ignore[attr-defined]
1012              ns_pkg.__package__ = _NS_PARENT
1013              sys.modules[_NS_PARENT] = ns_pkg
1014  
1015          key = manifest.key or manifest.name
1016          slug = key.replace("/", "__").replace("-", "_")
1017          module_name = f"{_NS_PARENT}.{slug}"
1018          spec = importlib.util.spec_from_file_location(
1019              module_name,
1020              init_file,
1021              submodule_search_locations=[str(plugin_dir)],
1022          )
1023          if spec is None or spec.loader is None:
1024              raise ImportError(f"Cannot create module spec for {init_file}")
1025  
1026          module = importlib.util.module_from_spec(spec)
1027          module.__package__ = module_name
1028          module.__path__ = [str(plugin_dir)]  # type: ignore[attr-defined]
1029          sys.modules[module_name] = module
1030          spec.loader.exec_module(module)
1031          return module
1032  
1033      def _load_entrypoint_module(self, manifest: PluginManifest) -> types.ModuleType:
1034          """Load a pip-installed plugin via its entry-point reference."""
1035          eps = importlib.metadata.entry_points()
1036          if hasattr(eps, "select"):
1037              group_eps = eps.select(group=ENTRY_POINTS_GROUP)
1038          elif isinstance(eps, dict):
1039              group_eps = eps.get(ENTRY_POINTS_GROUP, [])
1040          else:
1041              group_eps = [ep for ep in eps if ep.group == ENTRY_POINTS_GROUP]
1042  
1043          for ep in group_eps:
1044              if ep.name == manifest.name:
1045                  return ep.load()
1046  
1047          raise ImportError(
1048              f"Entry point '{manifest.name}' not found in group '{ENTRY_POINTS_GROUP}'"
1049          )
1050  
1051      # -----------------------------------------------------------------------
1052      # Hook invocation
1053      # -----------------------------------------------------------------------
1054  
1055      def invoke_hook(self, hook_name: str, **kwargs: Any) -> List[Any]:
1056          """Call all registered callbacks for *hook_name*.
1057  
1058          Each callback is wrapped in its own try/except so a misbehaving
1059          plugin cannot break the core agent loop.
1060  
1061          Returns a list of non-``None`` return values from callbacks.
1062  
1063          For ``pre_llm_call``, callbacks may return a dict describing
1064          context to inject into the current turn's user message::
1065  
1066              {"context": "recalled text..."}
1067              "recalled text..."          # plain string, equivalent
1068  
1069          Context is ALWAYS injected into the user message, never the
1070          system prompt.  This preserves the prompt cache prefix — the
1071          system prompt stays identical across turns so cached tokens
1072          are reused.  All injected context is ephemeral — never
1073          persisted to session DB.
1074          """
1075          callbacks = self._hooks.get(hook_name, [])
1076          results: List[Any] = []
1077          for cb in callbacks:
1078              try:
1079                  ret = cb(**kwargs)
1080                  if ret is not None:
1081                      results.append(ret)
1082              except Exception as exc:
1083                  logger.warning(
1084                      "Hook '%s' callback %s raised: %s",
1085                      hook_name,
1086                      getattr(cb, "__name__", repr(cb)),
1087                      exc,
1088                  )
1089          return results
1090  
1091      # -----------------------------------------------------------------------
1092      # Introspection
1093      # -----------------------------------------------------------------------
1094  
1095      def list_plugins(self) -> List[Dict[str, Any]]:
1096          """Return a list of info dicts for all discovered plugins."""
1097          result: List[Dict[str, Any]] = []
1098          for key, loaded in sorted(self._plugins.items()):
1099              result.append(
1100                  {
1101                      "name": loaded.manifest.name,
1102                      "key": loaded.manifest.key or loaded.manifest.name,
1103                      "kind": loaded.manifest.kind,
1104                      "version": loaded.manifest.version,
1105                      "description": loaded.manifest.description,
1106                      "source": loaded.manifest.source,
1107                      "enabled": loaded.enabled,
1108                      "tools": len(loaded.tools_registered),
1109                      "hooks": len(loaded.hooks_registered),
1110                      "commands": len(loaded.commands_registered),
1111                      "error": loaded.error,
1112                  }
1113              )
1114          return result
1115  
1116      # -----------------------------------------------------------------------
1117      # Plugin skill lookups
1118      # -----------------------------------------------------------------------
1119  
1120      def find_plugin_skill(self, qualified_name: str) -> Optional[Path]:
1121          """Return the ``Path`` to a plugin skill's SKILL.md, or ``None``."""
1122          entry = self._plugin_skills.get(qualified_name)
1123          return entry["path"] if entry else None
1124  
1125      def list_plugin_skills(self, plugin_name: str) -> List[str]:
1126          """Return sorted bare names of all skills registered by *plugin_name*."""
1127          prefix = f"{plugin_name}:"
1128          return sorted(
1129              e["bare_name"]
1130              for qn, e in self._plugin_skills.items()
1131              if qn.startswith(prefix)
1132          )
1133  
1134      def remove_plugin_skill(self, qualified_name: str) -> None:
1135          """Remove a stale registry entry (silently ignores missing keys)."""
1136          self._plugin_skills.pop(qualified_name, None)
1137  
1138  
1139  # ---------------------------------------------------------------------------
1140  # Module-level singleton & convenience functions
1141  # ---------------------------------------------------------------------------
1142  
1143  _plugin_manager: Optional[PluginManager] = None
1144  
1145  
1146  def get_plugin_manager() -> PluginManager:
1147      """Return (and lazily create) the global PluginManager singleton."""
1148      global _plugin_manager
1149      if _plugin_manager is None:
1150          _plugin_manager = PluginManager()
1151      return _plugin_manager
1152  
1153  
1154  def discover_plugins(force: bool = False) -> None:
1155      """Discover and load all plugins.
1156  
1157      Default behavior is idempotent. Pass ``force=True`` to rescan plugin
1158      manifests and reload state in the current process.
1159      """
1160      get_plugin_manager().discover_and_load(force=force)
1161  
1162  
1163  def invoke_hook(hook_name: str, **kwargs: Any) -> List[Any]:
1164      """Invoke a lifecycle hook on all loaded plugins.
1165  
1166      Returns a list of non-``None`` return values from plugin callbacks.
1167      """
1168      return get_plugin_manager().invoke_hook(hook_name, **kwargs)
1169  
1170  
1171  
1172  def get_pre_tool_call_block_message(
1173      tool_name: str,
1174      args: Optional[Dict[str, Any]],
1175      task_id: str = "",
1176      session_id: str = "",
1177      tool_call_id: str = "",
1178  ) -> Optional[str]:
1179      """Check ``pre_tool_call`` hooks for a blocking directive.
1180  
1181      Plugins that need to enforce policy (rate limiting, security
1182      restrictions, approval workflows) can return::
1183  
1184          {"action": "block", "message": "Reason the tool was blocked"}
1185  
1186      from their ``pre_tool_call`` callback.  The first valid block
1187      directive wins.  Invalid or irrelevant hook return values are
1188      silently ignored so existing observer-only hooks are unaffected.
1189      """
1190      hook_results = invoke_hook(
1191          "pre_tool_call",
1192          tool_name=tool_name,
1193          args=args if isinstance(args, dict) else {},
1194          task_id=task_id,
1195          session_id=session_id,
1196          tool_call_id=tool_call_id,
1197      )
1198  
1199      for result in hook_results:
1200          if not isinstance(result, dict):
1201              continue
1202          if result.get("action") != "block":
1203              continue
1204          message = result.get("message")
1205          if isinstance(message, str) and message:
1206              return message
1207  
1208      return None
1209  
1210  
1211  def _ensure_plugins_discovered(force: bool = False) -> PluginManager:
1212      """Return the global manager after ensuring plugin discovery has run.
1213  
1214      Pass ``force=True`` to rescan in the current process.
1215      """
1216      manager = get_plugin_manager()
1217      manager.discover_and_load(force=force)
1218      return manager
1219  
1220  
1221  def get_plugin_context_engine():
1222      """Return the plugin-registered context engine, or None."""
1223      return _ensure_plugins_discovered()._context_engine
1224  
1225  
1226  def get_plugin_command_handler(name: str) -> Optional[Callable]:
1227      """Return the handler for a plugin-registered slash command, or ``None``."""
1228      entry = _ensure_plugins_discovered()._plugin_commands.get(name)
1229      return entry["handler"] if entry else None
1230  
1231  
1232  _PLUGIN_COMMAND_AWAIT_TIMEOUT_SECS = 30.0
1233  
1234  
1235  def resolve_plugin_command_result(result: Any) -> Any:
1236      """Resolve a plugin command return value, awaiting async handlers when needed.
1237  
1238      Sync CLI/TUI dispatch sites call plugin handlers from plain functions.
1239      If a handler is async, await it directly when no loop is running; if
1240      we're already inside an active loop, run it in a helper thread with its
1241      own loop so the caller still gets a concrete result synchronously. The
1242      threaded path is bounded by a 30s timeout so a hung async handler cannot
1243      wedge the terminal indefinitely.
1244      """
1245      if not inspect.isawaitable(result):
1246          return result
1247  
1248      try:
1249          asyncio.get_running_loop()
1250      except RuntimeError:
1251          return asyncio.run(result)
1252  
1253      outcome: Dict[str, Any] = {}
1254      failure: Dict[str, BaseException] = {}
1255      done = threading.Event()
1256  
1257      def _runner() -> None:
1258          try:
1259              outcome["value"] = asyncio.run(result)
1260          except BaseException as exc:  # pragma: no cover - re-raised below
1261              failure["exc"] = exc
1262          finally:
1263              done.set()
1264  
1265      thread = threading.Thread(
1266          target=_runner,
1267          name="hermes-plugin-command-await",
1268          daemon=True,
1269      )
1270      thread.start()
1271      if not done.wait(timeout=_PLUGIN_COMMAND_AWAIT_TIMEOUT_SECS):
1272          raise TimeoutError(
1273              "Plugin command async handler did not complete within "
1274              f"{_PLUGIN_COMMAND_AWAIT_TIMEOUT_SECS:.0f}s"
1275          )
1276      if "exc" in failure:
1277          raise failure["exc"]
1278      return outcome.get("value")
1279  
1280  
1281  def get_plugin_commands() -> Dict[str, dict]:
1282      """Return the full plugin commands dict (name → {handler, description, plugin}).
1283  
1284      Triggers idempotent plugin discovery so callers can use plugin commands
1285      before any explicit discover_plugins() call.
1286      """
1287      return _ensure_plugins_discovered()._plugin_commands
1288  
1289  
1290  def get_plugin_toolsets() -> List[tuple]:
1291      """Return plugin toolsets as ``(key, label, description)`` tuples.
1292  
1293      Used by the ``hermes tools`` TUI so plugin-provided toolsets appear
1294      alongside the built-in ones and can be toggled on/off per platform.
1295      """
1296      manager = get_plugin_manager()
1297      if not manager._plugin_tool_names:
1298          return []
1299  
1300      try:
1301          from tools.registry import registry
1302      except Exception:
1303          return []
1304  
1305      # Group plugin tool names by their toolset
1306      toolset_tools: Dict[str, List[str]] = {}
1307      toolset_plugin: Dict[str, LoadedPlugin] = {}
1308      for tool_name in manager._plugin_tool_names:
1309          entry = registry.get_entry(tool_name)
1310          if not entry:
1311              continue
1312          ts = entry.toolset
1313          toolset_tools.setdefault(ts, []).append(entry.name)
1314  
1315      # Map toolsets back to the plugin that registered them
1316      for _name, loaded in manager._plugins.items():
1317          for tool_name in loaded.tools_registered:
1318              entry = registry.get_entry(tool_name)
1319              if entry and entry.toolset in toolset_tools:
1320                  toolset_plugin.setdefault(entry.toolset, loaded)
1321  
1322      result = []
1323      for ts_key in sorted(toolset_tools):
1324          plugin = toolset_plugin.get(ts_key)
1325          label = f"🔌 {ts_key.replace('_', ' ').title()}"
1326          if plugin and plugin.manifest.description:
1327              desc = plugin.manifest.description
1328          else:
1329              desc = ", ".join(sorted(toolset_tools[ts_key]))
1330          result.append((ts_key, label, desc))
1331  
1332      return result