Merge pull request #82044 from NousResearch/bb/agent-plugins
Surface agent plugins in the desktop app's Settings → Plugins
This commit is contained in:
commit
3898e646e5
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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". */
|
||||
|
|
|
|||
|
|
@ -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 = () => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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: '通知',
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
)
|
||||
|
|
|
|||
Loading…
Reference in New Issue