feat(desktop): plugin manager, runtime loader, and plugins settings

This commit is contained in:
Brooklyn Nicholson 2026-07-13 17:28:21 -04:00
parent aefb36299a
commit 7ed7170960
4 changed files with 151 additions and 1 deletions

View file

@ -6,7 +6,7 @@ import { Tip } from '@/components/ui/tooltip'
import { getHermesConfigDefaults, getHermesConfigRecord, saveHermesConfig } from '@/hermes'
import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
import { Archive, Bell, Download, Globe, Info, KeyRound, RefreshCw, Settings2, Upload, Wrench, Zap } from '@/lib/icons'
import { Archive, Bell, Download, Globe, Info, KeyRound, Package, RefreshCw, Settings2, Upload, Wrench, Zap } from '@/lib/icons'
import { notifyError } from '@/store/notifications'
import { useRouteEnumParam } from '../hooks/use-route-enum-param'
@ -22,6 +22,7 @@ import { SECTIONS } from './constants'
import { GatewaySettings } from './gateway-settings'
import { KEYS_VIEWS, KeysSettings, type KeysView } from './keys-settings'
import { NotificationsSettings } from './notifications-settings'
import { PluginsSettings } from './plugins-settings'
import { PROVIDER_VIEWS, ProvidersSettings, type ProviderView } from './providers-settings'
import { SessionsSettings } from './sessions-settings'
import type { SettingsPageProps, SettingsView as SettingsViewId } from './types'
@ -32,6 +33,7 @@ const SETTINGS_VIEWS: readonly SettingsViewId[] = [
'gateway',
'keys',
'notifications',
'plugins',
'sessions',
'about'
]
@ -186,6 +188,13 @@ export function SettingsView({ onClose, onConfigSaved, onMainModelChanged }: Set
label: t.settings.nav.apiKeys,
onSelect: () => setActiveView('keys')
},
{
active: activeView === 'plugins',
icon: Package,
id: 'plugins',
label: t.settings.nav.plugins,
onSelect: () => setActiveView('plugins')
},
{
active: activeView === 'sessions',
icon: Archive,
@ -259,6 +268,8 @@ export function SettingsView({ onClose, onConfigSaved, onMainModelChanged }: Set
<KeysSettings view={keysView} />
) : activeView === 'notifications' ? (
<NotificationsSettings />
) : activeView === 'plugins' ? (
<PluginsSettings />
) : (
<SessionsSettings />
)}

View file

@ -0,0 +1,124 @@
import { useStore } from '@nanostores/react'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { Switch } from '@/components/ui/switch'
import { Tip } from '@/components/ui/tooltip'
import { $pluginRecords, type PluginRecord, setPluginEnabled } from '@/contrib/plugins-store'
import { discoverRuntimePlugins } from '@/contrib/runtime-loader'
import { getStatus } from '@/hermes'
import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
import { Package } from '@/lib/icons'
import { notifyError } from '@/store/notifications'
import { EmptyState, ListRow, Pill, SectionHeading, SettingsContent } from './primitives'
const KIND_ORDER: Record<PluginRecord['kind'], number> = { disk: 0, runtime: 1, bundled: 2 }
function reveal(file: string) {
void window.hermesDesktop?.revealPath?.(file)?.catch(() => undefined)
}
async function revealPluginsDir() {
try {
const { hermes_home } = await getStatus()
// openDir (not reveal): the door often doesn't exist on first use, and
// showItemInFolder on a missing path silently no-ops (esp. Windows).
const result = await window.hermesDesktop?.openDir?.(`${hermes_home}/desktop-plugins`)
if (result && !result.ok) {
notifyError(result.error ?? 'unknown error', 'Could not open the plugins folder')
}
} catch (err) {
notifyError(err, 'Could not resolve the plugins folder')
}
}
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">
{record.file && (
<Tip label={p.reveal}>
<Button onClick={() => reveal(record.file!)} size="icon" variant="ghost">
<Codicon name="folder-opened" size="0.85rem" />
</Button>
</Tip>
)}
<Switch
aria-label={`${record.status === 'disabled' ? p.enable : p.disable} ${record.name}`}
checked={record.status !== 'disabled'}
onCheckedChange={on => {
triggerHaptic('selection')
void setPluginEnabled(record.id, on)
}}
/>
</div>
}
description={
record.status === 'error' ? (
<span className="text-(--ui-danger,#f87171)">{record.error}</span>
) : (
record.file ?? record.id
)
}
title={
<span className="flex items-center gap-2">
{record.name}
<Pill>{p.kinds[record.kind]}</Pill>
{record.status === 'error' && <Pill tone="primary">{p.failed}</Pill>}
</span>
}
/>
)
}
export function PluginsSettings() {
const { t } = useI18n()
const p = t.settings.plugins
const records = useStore($pluginRecords)
const rows = Object.values(records).sort(
(a, b) => KIND_ORDER[a.kind] - KIND_ORDER[b.kind] || a.name.localeCompare(b.name)
)
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>
<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>
)}
</SettingsContent>
)
}

View file

@ -9,6 +9,7 @@ export type SettingsView =
| 'gateway'
| 'keys'
| 'notifications'
| 'plugins'
| 'providers'
| 'sessions'
| `config:${string}`

View file

@ -0,0 +1,14 @@
# Bundled plugins
Drop a `<name>/plugin.{ts,tsx}` here that default-exports a `HermesPlugin` and
it registers automatically at boot (vite glob in `../contrib/plugins.ts`), with
the same inventory + live enable/disable contract as runtime plugins.
None ship in-tree today — reference/demo plugins (the counter example, the
gateway-pill 1:1 rebuild, the runtime-loader hello world) live in the companion
[`hermes-example-plugins`](https://github.com/NousResearch/hermes-example-plugins)
repo so the shipped app stays uncluttered.
User- and agent-authored plugins load at runtime from
`$HERMES_HOME/desktop-plugins/<name>/plugin.js` (the disk door) — see the
`hermes-desktop-plugins` skill.