From a60b492e079995a3a04375b1da38cbf2f39f45b7 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sat, 8 Aug 2026 17:24:59 -0500 Subject: [PATCH 1/4] feat(gateway): key-addressed plugins.manage rows + portable MCP toolset fold-in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit plugins.manage list rows now carry the canonical registry key and a portable flag (Agent Plugins v1 plugin.json packages), and toggles address the key — bare names collide across category dirs (image_gen/fal vs video_gen/fal), so name-addressed toggles flipped both. Portable packages' in-memory MCP servers also fold into enabled_mcp_server_names(); without that their tools registered with the MCP runtime but never reached the model's schema. --- hermes_cli/plugins_cmd.py | 15 +++++++++++++++ hermes_cli/tools_config.py | 21 +++++++++++++++++++-- tui_gateway/methods_tools.py | 32 ++++++++++++++++++++++---------- 3 files changed, 56 insertions(+), 12 deletions(-) diff --git a/hermes_cli/plugins_cmd.py b/hermes_cli/plugins_cmd.py index bdc50e933980d..9d1c744c53cf7 100644 --- a/hermes_cli/plugins_cmd.py +++ b/hermes_cli/plugins_cmd.py @@ -1061,6 +1061,21 @@ def _read_manifest_info(d: Path, prefix: str): return name, version, description, key +def _is_portable_plugin_dir(dir_path) -> bool: + """True when *dir_path* is an Agent Plugins v1 package (``plugin.json`` + only — a native ``plugin.yaml`` takes precedence, matching the loader).""" + try: + d = Path(dir_path) + if not d.is_dir(): + return False + if (d / "plugin.yaml").exists() or (d / "plugin.yml").exists(): + return False + portable_file = d / "plugin.json" + return portable_file.exists() or portable_file.is_symlink() + except OSError: + return False + + def _scan_level( base: Path, source: str, diff --git a/hermes_cli/tools_config.py b/hermes_cli/tools_config.py index b1dd507ed1e10..78503935bdfde 100644 --- a/hermes_cli/tools_config.py +++ b/hermes_cli/tools_config.py @@ -2123,21 +2123,38 @@ def _parse_enabled_flag(value, default: bool = True) -> bool: def enabled_mcp_server_names(config: dict) -> Set[str]: - """Names of MCP servers globally enabled in config.yaml. + """Names of MCP servers globally enabled in config.yaml or by a plugin. Shared by the gateway/CLI platform resolver (``_get_platform_tools``) and the cron per-job toolset resolver (``cron.scheduler``) so every path agrees on MCP membership. A server is enabled unless its config sets an explicitly falsey ``enabled`` (per ``_parse_enabled_flag``: false/0/no/off) — a missing flag or an unrecognized value is treated as enabled. + + Portable Agent Plugins contribute MCP servers in-memory rather than via + ``config.yaml`` (see ``PluginManager.get_portable_mcp_servers``). Those are + included here so their tools fold into platform toolsets like native + servers do — the user's opt-in is enabling the plugin itself. Without this, + a portable server registers with the MCP runtime but its tools never reach + the model's schema. """ mcp_servers = (config or {}).get("mcp_servers") or {} - return { + names = { str(name) for name, server_cfg in mcp_servers.items() if isinstance(server_cfg, dict) and _parse_enabled_flag(server_cfg.get("enabled", True), default=True) } + try: + from hermes_cli.plugins import discover_plugins, get_plugin_manager + + discover_plugins() + portable = set(get_plugin_manager().get_portable_mcp_servers()) + # Native config wins on a name collision (mirrors _load_mcp_config). + names |= portable - set(mcp_servers) + except Exception: + logger.debug("Failed to include portable MCP servers", exc_info=True) + return names def _exempt_explicit_platform_native( diff --git a/tui_gateway/methods_tools.py b/tui_gateway/methods_tools.py index c1b56ee213a69..4012116b30941 100644 --- a/tui_gateway/methods_tools.py +++ b/tui_gateway/methods_tools.py @@ -1803,10 +1803,10 @@ def _(rid, params: dict) -> dict: agree on what's installed and what's enabled. Actions: - - ``list`` → {"plugins": [{name, version, description, source, - status}], "user_count": N, "bundled_count": M} - - ``toggle`` → flip ``name`` based on ``enable`` (bool). Returns the - refreshed row plus {"ok", "unchanged"}. + - ``list`` → {"plugins": [{name, key, version, description, source, + status, portable}], "user_count": N, "bundled_count": M} + - ``toggle`` → flip ``key`` (or ``name``) based on ``enable`` (bool). + Returns the refreshed row plus {"ok", "unchanged"}. """ action = params.get("action", "list") try: @@ -1814,6 +1814,7 @@ def _(rid, params: dict) -> dict: _discover_all_plugins, _get_disabled_set, _get_enabled_set, + _is_portable_plugin_dir, _plugin_status, ) @@ -1827,10 +1828,17 @@ def _(rid, params: dict) -> dict: out.append( { "name": name, + # Canonical registry key (e.g. ``image_gen/fal``). Names + # can collide across category dirs — both fal backends + # are named "fal" — so toggles must address the key. + "key": key, "version": str(version or ""), "description": desc or "", "source": source, "status": _plugin_status(name, enabled, disabled, key=key), + # Agent Plugins v1 package (plugin.json — the portable + # skills/MCP format) vs a native Hermes plugin. + "portable": _is_portable_plugin_dir(_dir), } ) return out @@ -1850,20 +1858,24 @@ def _(rid, params: dict) -> dict: if action == "toggle": from hermes_cli.plugins_cmd import dashboard_set_agent_plugin_enabled - name = (params.get("name") or "").strip() - if not name: - return _err(rid, 4019, "plugins.toggle requires a 'name'") + # Prefer the canonical key — bare names are ambiguous when two + # category plugins share one (image_gen/fal vs video_gen/fal). + ident = (params.get("key") or params.get("name") or "").strip() + if not ident: + return _err(rid, 4019, "plugins.toggle requires a 'key' or 'name'") enable = bool(params.get("enable")) - result = dashboard_set_agent_plugin_enabled(name, enabled=enable) + result = dashboard_set_agent_plugin_enabled(ident, enabled=enable) if not result.get("ok"): return _err(rid, 5026, result.get("error") or "toggle failed") - row = next((r for r in _rows() if r["name"] == name), None) + row = next( + (r for r in _rows() if ident in (r["key"], r["name"])), None + ) return _ok( rid, { "ok": True, "unchanged": bool(result.get("unchanged")), - "name": name, + "name": ident, "plugin": row, }, ) From c86da8397b6eaafafaa1dc81ae0f3e0c47f6cb3f Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sat, 8 Aug 2026 17:24:59 -0500 Subject: [PATCH 2/4] =?UTF-8?q?feat(desktop):=20agent=20plugins=20in=20Set?= =?UTF-8?q?tings=20=E2=86=92=20Plugins?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend plugins — native Hermes plugins and portable Agent Plugins v1 packages — were invisible in the desktop app. Settings → Plugins now lists them under the desktop (renderer) plugins with source/portable pills, enable/disable switches keyed by canonical registry key, and a live-filter search box, backed by a nanostore over plugins.manage. Categories other surfaces own (dashboard_auth/*, model-providers/*, platforms/*) are curated out renderer-side. --- .../src/app/settings/plugins-settings.tsx | 235 +++++++++++++++--- apps/desktop/src/i18n/en.ts | 14 +- apps/desktop/src/i18n/types.ts | 11 + apps/desktop/src/i18n/zh.ts | 14 +- apps/desktop/src/store/agent-plugins.ts | 110 ++++++++ 5 files changed, 345 insertions(+), 39 deletions(-) create mode 100644 apps/desktop/src/store/agent-plugins.ts diff --git a/apps/desktop/src/app/settings/plugins-settings.tsx b/apps/desktop/src/app/settings/plugins-settings.tsx index 11eb479d4cddd..311c1d320054d 100644 --- a/apps/desktop/src/app/settings/plugins-settings.tsx +++ b/apps/desktop/src/app/settings/plugins-settings.tsx @@ -1,5 +1,7 @@ import { useStore } from '@nanostores/react' +import { type ReactNode, useEffect, useState } from 'react' +import { useGatewayRequest } from '@/app/gateway/hooks/use-gateway-request' import { Button } from '@/components/ui/button' import { Codicon } from '@/components/ui/codicon' import { Switch } from '@/components/ui/switch' @@ -8,13 +10,37 @@ import { $pluginRecords, type PluginRecord, setPluginEnabled } from '@/contrib/p import { discoverRuntimePlugins } from '@/contrib/runtime-loader' import { useI18n } from '@/i18n' import { triggerHaptic } from '@/lib/haptics' -import { Package } from '@/lib/icons' +import { FolderOpen, Monitor, Package, RefreshCw } from '@/lib/icons' +import { normalize } from '@/lib/text' +import { + $agentPluginBusy, + $agentPlugins, + $agentPluginsError, + $agentPluginsStatus, + type AgentPluginRow, + loadAgentPlugins, + toggleAgentPlugin +} from '@/store/agent-plugins' import { notifyError } from '@/store/notifications' +import { $gatewayState } from '@/store/session' -import { EmptyState, ListRow, Pill, SectionHeading, SettingsContent } from './primitives' +import { EmptyState, ListRowSkeleton, Pill, SettingsContent, SettingsSection } from './primitives' const KIND_ORDER: Record = { disk: 0, runtime: 1, bundled: 2 } +// User-installed plugins first, bundled last — mirrors `hermes plugins list`. +const SOURCE_ORDER: Record = { user: 0, git: 0, project: 1, entrypoint: 2, bundled: 3 } + +// Plugin categories (by registry key prefix) that other surfaces own — same +// curation stance as desktop-slash-commands.ts. dashboard_auth/* only matters +// to `hermes dashboard`; model-providers/* are configured in Settings → +// Models; platforms/* are managed from Messaging. The plugin switch is not +// the user-facing control for any of them, so listing them here is noise. +const HIDDEN_KEY_PREFIXES = ['dashboard_auth/', 'model-providers/', 'platforms/'] + +const isDesktopRelevant = (row: AgentPluginRow) => + !HIDDEN_KEY_PREFIXES.some(prefix => row.key.startsWith(prefix)) + function reveal(file: string) { void window.hermesDesktop?.revealPath?.(file)?.catch(() => undefined) } @@ -43,14 +69,145 @@ async function revealPluginsDir() { } } +// Compact row: name + pills and a wrapping description on the left, controls +// pinned top-right. Same type scale as ListRow, without its wide control grid. +function PluginLine({ + title, + description, + controls +}: { + title: ReactNode + description?: ReactNode + controls: ReactNode +}) { + return ( +
+
+
+ {title} +
+ {description && ( +
+ {description} +
+ )} +
+
{controls}
+
+ ) +} + +function AgentPluginRowView({ row }: { row: AgentPluginRow }) { + const { t } = useI18n() + const p = t.settings.plugins + const { requestGateway } = useGatewayRequest() + const busy = useStore($agentPluginBusy) + + return ( + { + triggerHaptic('selection') + void toggleAgentPlugin(requestGateway, row.key, on, p.agent.toggleFailed(row.name)) + }} + /> + } + description={row.description || (row.version ? `v${row.version}` : undefined)} + title={ + <> + {row.name} + {p.agent.sources[row.source] ?? row.source} + {row.portable && {p.agent.portable}} + + } + /> + ) +} + +function AgentPluginsSection() { + const { t } = useI18n() + const p = t.settings.plugins + const { requestGateway } = useGatewayRequest() + const gatewayState = useStore($gatewayState) + const rows = useStore($agentPlugins) + const status = useStore($agentPluginsStatus) + const error = useStore($agentPluginsError) + const [query, setQuery] = useState('') + + useEffect(() => { + if (gatewayState !== 'open') { + return + } + + void loadAgentPlugins(requestGateway) + }, [gatewayState, requestGateway]) + + const needle = normalize(query) + + const sorted = rows + .filter(isDesktopRelevant) + .filter( + row => + !needle || + row.name.toLowerCase().includes(needle) || + row.key.toLowerCase().includes(needle) || + row.description.toLowerCase().includes(needle) + ) + .sort((a, b) => (SOURCE_ORDER[a.source] ?? 9) - (SOURCE_ORDER[b.source] ?? 9) || a.name.localeCompare(b.name)) + + return ( + +

+ {p.agent.blurb} +

+ + setQuery(event.target.value)} + placeholder={p.agent.search} + spellCheck={false} + value={query} + /> + + {status === 'loading' || status === 'idle' ? ( +
+ + + +
+ ) : status === 'error' ? ( + + ) : sorted.length === 0 ? ( + needle ? ( +

+ {p.agent.noMatches} +

+ ) : ( + + ) + ) : ( +
+ {sorted.map(row => ( + + ))} +
+ )} +
+ ) +} + function PluginRow({ record }: { record: PluginRecord }) { const { t } = useI18n() const p = t.settings.plugins return ( - + {record.file && ( - - - - {rows.length === 0 ? ( - - ) : ( -
- {rows.map(record => ( - - ))} +
+ +
- )} + + {rows.length === 0 ? ( + + ) : ( +
+ {rows.map(record => ( + + ))} +
+ )} + + + ) } diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index f897e4c1ae04f..3c5ce76723559 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -365,7 +365,19 @@ export const en: Translations = { disable: 'Disable', failed: 'failed', empty: 'No desktop plugins installed yet.', - kinds: { bundled: 'bundled', disk: 'on disk', runtime: 'runtime' } + kinds: { bundled: 'bundled', disk: 'on disk', runtime: 'runtime' }, + agent: { + title: 'Agent plugins', + blurb: + 'Run in the Hermes backend — tools, skills, MCP servers, hooks, and slash commands. Portable ones are Agent Plugins packages (skills + MCP bundles that work in other agents too). Toggles apply to new sessions.', + empty: 'No agent plugins installed yet.', + loadFailed: 'Could not load agent plugins', + portable: 'portable', + search: 'Search plugins…', + noMatches: 'No plugins match your search.', + toggleFailed: (name: string) => `Could not toggle ${name}`, + sources: { bundled: 'bundled', user: 'user', git: 'git', project: 'project', entrypoint: 'pip' } + } }, notifications: { title: 'Notifications', diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index 9693ab312e7a9..c68958353600f 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -317,6 +317,17 @@ export interface Translations { failed: string empty: string kinds: { bundled: string; disk: string; runtime: string } + agent: { + title: string + blurb: string + empty: string + loadFailed: string + portable: string + search: string + noMatches: string + toggleFailed: (name: string) => string + sources: Record + } } notifications: { title: string diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index 53d170e798ad1..92b7bf5cd109c 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -356,7 +356,19 @@ export const zh: Translations = { disable: '禁用', failed: '失败', empty: '尚未安装桌面插件。', - kinds: { bundled: '内置', disk: '磁盘', runtime: '运行时' } + kinds: { bundled: '内置', disk: '磁盘', runtime: '运行时' }, + agent: { + title: '智能体插件', + blurb: + '运行在 Hermes 后端——工具、技能、MCP 服务器、钩子和斜杠命令。「便携」插件是 Agent Plugins 标准包(技能 + MCP 组合,也可在其他智能体中使用)。开关在新会话中生效。', + empty: '尚未安装智能体插件。', + loadFailed: '无法加载智能体插件', + portable: '便携', + search: '搜索插件…', + noMatches: '没有匹配的插件。', + toggleFailed: (name: string) => `无法切换 ${name}`, + sources: { bundled: '内置', user: '用户', git: 'git', project: '项目', entrypoint: 'pip' } + } }, notifications: { title: '通知', diff --git a/apps/desktop/src/store/agent-plugins.ts b/apps/desktop/src/store/agent-plugins.ts new file mode 100644 index 0000000000000..b9422cfa24d7d --- /dev/null +++ b/apps/desktop/src/store/agent-plugins.ts @@ -0,0 +1,110 @@ +import { atom } from 'nanostores' + +import { notifyError } from '@/store/notifications' + +/** + * Feature store for backend (agent) plugins — the native Hermes plugins plus + * portable Agent Plugins v1 packages the backend discovers on disk. Settings + * renders this next to the desktop (renderer) plugin inventory so every plugin + * the user has is discoverable and toggleable from one page, whatever process + * it runs in. + * + * Backed by the gateway's `plugins.manage` RPC — the same list/toggle + * primitives `hermes plugins` and the dashboard use, so all surfaces agree on + * what's installed and what's enabled. Works against every backend topology + * (local spawn, SSH, URL+token) because it rides the session's own transport. + */ + +export interface AgentPluginRow { + name: string + /** Canonical registry key (e.g. `image_gen/fal`) — names can collide. */ + key: string + version: string + description: string + /** 'bundled' | 'user' | 'git' | 'project' | 'entrypoint' */ + source: string + status: 'enabled' | 'disabled' | 'not enabled' + /** Agent Plugins v1 package (portable skills/MCP format) vs native Hermes. */ + portable?: boolean +} + +export type AgentPluginsStatus = 'idle' | 'loading' | 'ready' | 'error' + +/** The recovering `requestGateway` from `useGatewayRequest`. */ +export type GatewayRequest = (method: string, params?: Record) => Promise + +export const $agentPlugins = atom([]) +export const $agentPluginsStatus = atom('idle') +export const $agentPluginsError = atom(null) +/** Key of the row whose toggle RPC is in flight (disables its switch). */ +export const $agentPluginBusy = atom(null) + +let inflight: Promise | null = null + +/** Fetch the backend plugin list. Always refetches (it's a cheap local disk + * scan on the backend); concurrent callers share one in-flight request. */ +export function loadAgentPlugins(request: GatewayRequest): Promise { + if (inflight) { + return inflight + } + + inflight = (async () => { + if ($agentPluginsStatus.get() !== 'ready') { + $agentPluginsStatus.set('loading') + } + + try { + const result = await request<{ plugins?: AgentPluginRow[] }>('plugins.manage', { action: 'list' }) + $agentPlugins.set(result?.plugins ?? []) + $agentPluginsStatus.set('ready') + $agentPluginsError.set(null) + } catch (e) { + $agentPluginsError.set(e instanceof Error ? e.message : String(e)) + $agentPluginsStatus.set('error') + } finally { + inflight = null + } + })() + + return inflight +} + +/** Flip a backend plugin on/off and patch the row from the RPC's refreshed + * copy. Addressed by canonical key — bare names collide (image_gen/fal vs + * video_gen/fal). Returns whether the toggle stuck. */ +export async function toggleAgentPlugin( + request: GatewayRequest, + key: string, + enable: boolean, + failMessage: string +): Promise { + $agentPluginBusy.set(key) + + try { + const result = await request<{ ok?: boolean; plugin?: AgentPluginRow | null }>('plugins.manage', { + action: 'toggle', + key, + enable + }) + + if (!result?.ok) { + throw new Error(failMessage) + } + + const refreshed = result.plugin + + if (refreshed) { + $agentPlugins.set($agentPlugins.get().map(row => (row.key === key ? { ...row, ...refreshed } : row))) + } else { + await loadAgentPlugins(request) + } + + return true + } catch (e) { + notifyError(e, failMessage) + + return false + } finally { + $agentPluginBusy.set(null) + } +} From ed9eee5dc9363b7c1f488e5590e7ede99b882236 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sat, 8 Aug 2026 17:34:20 -0500 Subject: [PATCH 3/4] fix(gateway): report bundled auto-loading plugins as enabled MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bundled backends/platforms/providers load without a plugins.enabled entry ('must just work'), but plugins.manage reported them 'not enabled' — clients rendered running plugins with an OFF switch. Surface the truthful default; explicit disable still wins. --- hermes_cli/plugins_cmd.py | 26 ++++++++++++++++++++++++++ tui_gateway/methods_tools.py | 14 +++++++++++++- 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/hermes_cli/plugins_cmd.py b/hermes_cli/plugins_cmd.py index 9d1c744c53cf7..968b775191ea3 100644 --- a/hermes_cli/plugins_cmd.py +++ b/hermes_cli/plugins_cmd.py @@ -1076,6 +1076,32 @@ def _is_portable_plugin_dir(dir_path) -> bool: return False +# Manifest kinds that are active-by-default when bundled: backends auto-load, +# platforms register lazily but are available out of the box, model providers +# run through providers/ discovery (see PluginManager.discover_and_load). +_BUNDLED_DEFAULT_ON_KINDS = frozenset({"backend", "platform", "model-provider"}) + + +def _bundled_default_on(dir_path) -> bool: + """True when a bundled plugin at *dir_path* is active without an explicit + ``plugins.enabled`` entry. Standalone/exclusive kinds stay opt-in, and + portable packages (``plugin.json``) have no kind at all.""" + manifest_file = Path(dir_path) / "plugin.yaml" + if not manifest_file.exists(): + manifest_file = Path(dir_path) / "plugin.yml" + if not manifest_file.exists(): + return False + try: + import yaml + + with open(manifest_file, encoding="utf-8") as f: + manifest = yaml.safe_load(f) or {} + kind = str(manifest.get("kind", "standalone")).strip().lower() + return kind in _BUNDLED_DEFAULT_ON_KINDS + except Exception: + return False + + def _scan_level( base: Path, source: str, diff --git a/tui_gateway/methods_tools.py b/tui_gateway/methods_tools.py index 4012116b30941..dc12130c6fd30 100644 --- a/tui_gateway/methods_tools.py +++ b/tui_gateway/methods_tools.py @@ -1811,6 +1811,7 @@ def _(rid, params: dict) -> dict: action = params.get("action", "list") try: from hermes_cli.plugins_cmd import ( + _bundled_default_on, _discover_all_plugins, _get_disabled_set, _get_enabled_set, @@ -1825,6 +1826,17 @@ def _(rid, params: dict) -> dict: for name, version, desc, source, _dir, key in sorted( _discover_all_plugins() ): + status = _plugin_status(name, enabled, disabled, key=key) + # Bundled backends/platforms/providers are active without an + # explicit enable (they "just work" — plugins.py). Reporting + # them "not enabled" reads as OFF in clients when they are in + # fact running; surface the truthful default instead. + if ( + status == "not enabled" + and source == "bundled" + and _bundled_default_on(_dir) + ): + status = "enabled" out.append( { "name": name, @@ -1835,7 +1847,7 @@ def _(rid, params: dict) -> dict: "version": str(version or ""), "description": desc or "", "source": source, - "status": _plugin_status(name, enabled, disabled, key=key), + "status": status, # Agent Plugins v1 package (plugin.json — the portable # skills/MCP format) vs a native Hermes plugin. "portable": _is_portable_plugin_dir(_dir), From 44790bc9c87d2e3a9a290a2e94777078468643bf Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sat, 8 Aug 2026 17:34:20 -0500 Subject: [PATCH 4/4] feat(desktop): plugin descriptions + open the agent plugins folder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HermesPlugin/PluginRecord gain a description one-liner (kanban gets one) shown in the inventory instead of the raw file path, and the agent plugins section can open the backend's plugins dir — path from config.get profile so it's profile-aware, local backends only since openDir mkdir-creates. --- .../src/app/settings/plugins-settings.tsx | 45 ++++++++++++++++++- apps/desktop/src/contrib/plugin.ts | 2 + apps/desktop/src/contrib/plugins-store.ts | 2 + apps/desktop/src/contrib/plugins.ts | 7 ++- apps/desktop/src/contrib/runtime-loader.ts | 1 + apps/desktop/src/plugins/kanban/plugin.tsx | 1 + 6 files changed, 55 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/app/settings/plugins-settings.tsx b/apps/desktop/src/app/settings/plugins-settings.tsx index 311c1d320054d..5cf841e9a2206 100644 --- a/apps/desktop/src/app/settings/plugins-settings.tsx +++ b/apps/desktop/src/app/settings/plugins-settings.tsx @@ -18,11 +18,12 @@ import { $agentPluginsError, $agentPluginsStatus, type AgentPluginRow, + type GatewayRequest, loadAgentPlugins, toggleAgentPlugin } from '@/store/agent-plugins' import { notifyError } from '@/store/notifications' -import { $gatewayState } from '@/store/session' +import { $connection, $gatewayState } from '@/store/session' import { EmptyState, ListRowSkeleton, Pill, SettingsContent, SettingsSection } from './primitives' @@ -69,6 +70,31 @@ async function revealPluginsDir() { } } +// Agent plugins live under the BACKEND's hermes home (profile-aware), so the +// path comes from the gateway — not from the renderer's local HERMES_HOME. +// Callers gate on a local connection: openDir mkdir-creates the path, which +// must never happen for a directory that belongs to a remote box. +async function revealAgentPluginsDir(request: GatewayRequest) { + try { + const result = await request<{ home?: string }>('config.get', { key: 'profile' }) + const home = (result?.home ?? '').trim() + + if (!home) { + notifyError('The backend did not report its home directory', 'Could not open the plugins folder') + + return + } + + const opened = await window.hermesDesktop?.openDir?.(`${home}/plugins`) + + if (opened && !opened.ok) { + notifyError(opened.error ?? 'unknown error', 'Could not open the plugins folder') + } + } catch (err) { + notifyError(err, 'Could not open the plugins folder') + } +} + // Compact row: name + pills and a wrapping description on the left, controls // pinned top-right. Same type scale as ListRow, without its wide control grid. function PluginLine({ @@ -133,6 +159,7 @@ function AgentPluginsSection() { const p = t.settings.plugins const { requestGateway } = useGatewayRequest() const gatewayState = useStore($gatewayState) + const connection = useStore($connection) const rows = useStore($agentPlugins) const status = useStore($agentPluginsStatus) const error = useStore($agentPluginsError) @@ -165,6 +192,20 @@ function AgentPluginsSection() { {p.agent.blurb}

+ {connection?.mode !== 'remote' && ( +
+ +
+ )} + setQuery(event.target.value)} @@ -229,7 +270,7 @@ function PluginRow({ record }: { record: PluginRecord }) { record.status === 'error' ? ( {record.error} ) : ( - (record.file ?? record.id) + (record.description ?? record.file ?? record.id) ) } title={ diff --git a/apps/desktop/src/contrib/plugin.ts b/apps/desktop/src/contrib/plugin.ts index 41789af769c5f..aa5d0f107ad72 100644 --- a/apps/desktop/src/contrib/plugin.ts +++ b/apps/desktop/src/contrib/plugin.ts @@ -93,6 +93,8 @@ export interface HermesPlugin { id: string /** Human name for settings / about UI. */ name?: string + /** One-liner for the settings inventory (what the plugin adds). */ + description?: string /** Registers on load when the user hasn't chosen (default true). Set false * for opt-in plugins: they inventory in Settings ▸ Plugins, off until the * user flips the switch. */ diff --git a/apps/desktop/src/contrib/plugins-store.ts b/apps/desktop/src/contrib/plugins-store.ts index 9e90a4cc0be6d..d7e7061722e7c 100644 --- a/apps/desktop/src/contrib/plugins-store.ts +++ b/apps/desktop/src/contrib/plugins-store.ts @@ -18,6 +18,8 @@ export interface PluginRecord { name: string kind: PluginKind status: PluginStatus + /** One-liner from the plugin's own metadata (what it adds). */ + description?: string /** Load/registration failure message (status 'error'). */ error?: string /** Absolute plugin.js path (disk plugins) — powers "Reveal in Finder". */ diff --git a/apps/desktop/src/contrib/plugins.ts b/apps/desktop/src/contrib/plugins.ts index d1cab8a32fba0..1679a0832dc92 100644 --- a/apps/desktop/src/contrib/plugins.ts +++ b/apps/desktop/src/contrib/plugins.ts @@ -40,7 +40,12 @@ export function discoverBundledPlugins(): void { // Same inventory + live-toggle contract as runtime plugins: each bundled // plugin publishes a record with activate/deactivate handles, and a // persisted disable survives boots by skipping registration here. - const record = { id: plugin.id, name: plugin.name ?? plugin.id, kind: 'bundled' as const } + const record = { + id: plugin.id, + name: plugin.name ?? plugin.id, + description: plugin.description, + kind: 'bundled' as const + } let disposers: (() => void)[] = [] const activate = () => { diff --git a/apps/desktop/src/contrib/runtime-loader.ts b/apps/desktop/src/contrib/runtime-loader.ts index 27e6e40b95e25..1fc5458a3a566 100644 --- a/apps/desktop/src/contrib/runtime-loader.ts +++ b/apps/desktop/src/contrib/runtime-loader.ts @@ -139,6 +139,7 @@ export async function loadRuntimePlugin( const record = { id: plugin.id, name: plugin.name ?? plugin.id, + description: plugin.description, kind: options.kind ?? 'disk', file: options.file } diff --git a/apps/desktop/src/plugins/kanban/plugin.tsx b/apps/desktop/src/plugins/kanban/plugin.tsx index 41eb03df9c5f6..605f58ad3c639 100644 --- a/apps/desktop/src/plugins/kanban/plugin.tsx +++ b/apps/desktop/src/plugins/kanban/plugin.tsx @@ -80,6 +80,7 @@ function KanbanCount() { const plugin: HermesPlugin = { id: 'kanban', name: 'Kanban', + description: 'Multi-agent task board — board page, sidebar entry, and a live in-flight count in the status bar.', defaultEnabled: false, register(ctx) { ctx.i18n.register(KANBAN_LOCALES)