Merge pull request #82044 from NousResearch/bb/agent-plugins

Surface agent plugins in the desktop app's Settings → Plugins
This commit is contained in:
brooklyn! 2026-08-08 17:41:38 -05:00 committed by GitHub
commit 3898e646e5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 494 additions and 54 deletions

View File

@ -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,38 @@ 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,
type GatewayRequest,
loadAgentPlugins,
toggleAgentPlugin
} from '@/store/agent-plugins'
import { notifyError } from '@/store/notifications'
import { $connection, $gatewayState } from '@/store/session'
import { EmptyState, ListRow, Pill, SectionHeading, SettingsContent } from './primitives'
import { EmptyState, ListRowSkeleton, Pill, SettingsContent, SettingsSection } from './primitives'
const KIND_ORDER: Record<PluginRecord['kind'], number> = { disk: 0, runtime: 1, bundled: 2 }
// User-installed plugins first, bundled last — mirrors `hermes plugins list`.
const SOURCE_ORDER: Record<string, number> = { 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 +70,185 @@ 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({
title,
description,
controls
}: {
title: ReactNode
description?: ReactNode
controls: ReactNode
}) {
return (
<div className="flex items-start gap-3 py-2">
<div className="min-w-0 flex-1 pr-4">
<div className="flex flex-wrap items-center gap-2 text-[length:var(--conversation-text-font-size)] font-medium text-foreground">
{title}
</div>
{description && (
<div className="mt-0.5 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) break-words text-(--ui-text-tertiary)">
{description}
</div>
)}
</div>
<div className="flex shrink-0 items-center gap-2">{controls}</div>
</div>
)
}
function AgentPluginRowView({ row }: { row: AgentPluginRow }) {
const { t } = useI18n()
const p = t.settings.plugins
const { requestGateway } = useGatewayRequest()
const busy = useStore($agentPluginBusy)
return (
<PluginLine
controls={
<Switch
aria-label={`${row.status === 'enabled' ? p.disable : p.enable} ${row.name}`}
checked={row.status === 'enabled'}
disabled={busy === row.key}
onCheckedChange={on => {
triggerHaptic('selection')
void toggleAgentPlugin(requestGateway, row.key, on, p.agent.toggleFailed(row.name))
}}
/>
}
description={row.description || (row.version ? `v${row.version}` : undefined)}
title={
<>
<span>{row.name}</span>
<Pill>{p.agent.sources[row.source] ?? row.source}</Pill>
{row.portable && <Pill tone="primary">{p.agent.portable}</Pill>}
</>
}
/>
)
}
function AgentPluginsSection() {
const { t } = useI18n()
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)
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 (
<SettingsSection icon={Package} meta={status === 'ready' ? p.count(sorted.length) : undefined} title={p.agent.title}>
<p className="mb-2 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
{p.agent.blurb}
</p>
{connection?.mode !== 'remote' && (
<div className="mb-2 flex items-center gap-3">
<Button
onClick={() => void revealAgentPluginsDir(requestGateway)}
size="sm"
type="button"
variant="textStrong"
>
<FolderOpen className="size-3.5" />
<span>{p.openFolder}</span>
</Button>
</div>
)}
<input
className="mb-2 w-full rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) px-3 py-1.5 text-[length:var(--conversation-caption-font-size)] outline-none placeholder:text-(--ui-text-tertiary) focus:border-(--ui-stroke-secondary)"
onChange={event => setQuery(event.target.value)}
placeholder={p.agent.search}
spellCheck={false}
value={query}
/>
{status === 'loading' || status === 'idle' ? (
<div>
<ListRowSkeleton />
<ListRowSkeleton />
<ListRowSkeleton />
</div>
) : status === 'error' ? (
<EmptyState description={error ?? undefined} title={p.agent.loadFailed} />
) : sorted.length === 0 ? (
needle ? (
<p className="text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
{p.agent.noMatches}
</p>
) : (
<EmptyState title={p.agent.empty} />
)
) : (
<div>
{sorted.map(row => (
<AgentPluginRowView key={row.key || row.name} row={row} />
))}
</div>
)}
</SettingsSection>
)
}
function PluginRow({ record }: { record: PluginRecord }) {
const { t } = useI18n()
const p = t.settings.plugins
return (
<ListRow
action={
<div className="flex items-center justify-end gap-2">
<PluginLine
controls={
<>
{record.file && (
<Tip label={p.reveal}>
<Button onClick={() => reveal(record.file!)} size="icon" variant="ghost">
@ -66,21 +264,21 @@ function PluginRow({ record }: { record: PluginRecord }) {
void setPluginEnabled(record.id, on)
}}
/>
</div>
</>
}
description={
record.status === 'error' ? (
<span className="text-(--ui-danger,#f87171)">{record.error}</span>
) : (
(record.file ?? record.id)
(record.description ?? record.file ?? record.id)
)
}
title={
<span className="flex items-center gap-2">
{record.name}
<>
<span>{record.name}</span>
<Pill>{p.kinds[record.kind]}</Pill>
{record.status === 'error' && <Pill tone="primary">{p.failed}</Pill>}
</span>
</>
}
/>
)
@ -97,36 +295,40 @@ export function PluginsSettings() {
return (
<SettingsContent>
<SectionHeading icon={Package} meta={p.count(rows.length)} title={p.title} />
<p className="mb-4 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">{p.blurb}</p>
<SettingsSection icon={Monitor} meta={p.count(rows.length)} title={p.title}>
<p className="mb-2 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">{p.blurb}</p>
<div className="mb-4 flex items-center gap-2">
<Button onClick={() => void revealPluginsDir()} size="sm" variant="outline">
<Codicon name="folder-opened" size="0.8rem" />
{p.openFolder}
</Button>
<Button
onClick={() => {
triggerHaptic('selection')
void discoverRuntimePlugins()
}}
size="sm"
variant="outline"
>
<Codicon name="refresh" size="0.8rem" />
{p.rescan}
</Button>
</div>
{rows.length === 0 ? (
<EmptyState title={p.empty} />
) : (
<div className="divide-y divide-(--ui-stroke-tertiary)">
{rows.map(record => (
<PluginRow key={record.id} record={record} />
))}
<div className="mb-2 flex items-center gap-3">
<Button onClick={() => void revealPluginsDir()} size="sm" type="button" variant="textStrong">
<FolderOpen className="size-3.5" />
<span>{p.openFolder}</span>
</Button>
<Button
onClick={() => {
triggerHaptic('selection')
void discoverRuntimePlugins()
}}
size="sm"
type="button"
variant="textStrong"
>
<RefreshCw className="size-3.5" />
<span>{p.rescan}</span>
</Button>
</div>
)}
{rows.length === 0 ? (
<EmptyState title={p.empty} />
) : (
<div>
{rows.map(record => (
<PluginRow key={record.id} record={record} />
))}
</div>
)}
</SettingsSection>
<AgentPluginsSection />
</SettingsContent>
)
}

View File

@ -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. */

View File

@ -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". */

View File

@ -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 = () => {

View File

@ -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
}

View File

@ -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',

View File

@ -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<string, string>
}
}
notifications: {
title: string

View File

@ -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: '通知',

View File

@ -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)

View File

@ -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 = <T>(method: string, params?: Record<string, unknown>) => Promise<T>
export const $agentPlugins = atom<AgentPluginRow[]>([])
export const $agentPluginsStatus = atom<AgentPluginsStatus>('idle')
export const $agentPluginsError = atom<string | null>(null)
/** Key of the row whose toggle RPC is in flight (disables its switch). */
export const $agentPluginBusy = atom<string | null>(null)
let inflight: Promise<void> | 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<void> {
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<boolean> {
$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)
}
}

View File

@ -1061,6 +1061,47 @@ 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
# 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,

View File

@ -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(

View File

@ -1803,17 +1803,19 @@ 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:
from hermes_cli.plugins_cmd import (
_bundled_default_on,
_discover_all_plugins,
_get_disabled_set,
_get_enabled_set,
_is_portable_plugin_dir,
_plugin_status,
)
@ -1824,13 +1826,31 @@ 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,
# 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),
"status": status,
# 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 +1870,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,
},
)