From 7ed71709602c2955954bbd91329cdbe1b566cd99 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 13 Jul 2026 17:28:21 -0400 Subject: [PATCH] feat(desktop): plugin manager, runtime loader, and plugins settings --- apps/desktop/src/app/settings/index.tsx | 13 +- .../src/app/settings/plugins-settings.tsx | 124 ++++++++++++++++++ apps/desktop/src/app/settings/types.ts | 1 + apps/desktop/src/plugins/README.md | 14 ++ 4 files changed, 151 insertions(+), 1 deletion(-) create mode 100644 apps/desktop/src/app/settings/plugins-settings.tsx create mode 100644 apps/desktop/src/plugins/README.md diff --git a/apps/desktop/src/app/settings/index.tsx b/apps/desktop/src/app/settings/index.tsx index 9c7c4dd0b4e1..6f6662056cd4 100644 --- a/apps/desktop/src/app/settings/index.tsx +++ b/apps/desktop/src/app/settings/index.tsx @@ -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 ) : activeView === 'notifications' ? ( + ) : activeView === 'plugins' ? ( + ) : ( )} diff --git a/apps/desktop/src/app/settings/plugins-settings.tsx b/apps/desktop/src/app/settings/plugins-settings.tsx new file mode 100644 index 000000000000..17bc213940e6 --- /dev/null +++ b/apps/desktop/src/app/settings/plugins-settings.tsx @@ -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 = { 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 ( + + {record.file && ( + + + + )} + { + triggerHaptic('selection') + void setPluginEnabled(record.id, on) + }} + /> + + } + description={ + record.status === 'error' ? ( + {record.error} + ) : ( + record.file ?? record.id + ) + } + title={ + + {record.name} + {p.kinds[record.kind]} + {record.status === 'error' && {p.failed}} + + } + /> + ) +} + +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 ( + + +

{p.blurb}

+ +
+ + +
+ + {rows.length === 0 ? ( + + ) : ( +
+ {rows.map(record => ( + + ))} +
+ )} +
+ ) +} diff --git a/apps/desktop/src/app/settings/types.ts b/apps/desktop/src/app/settings/types.ts index 1b6509ef1a72..f3637c37a001 100644 --- a/apps/desktop/src/app/settings/types.ts +++ b/apps/desktop/src/app/settings/types.ts @@ -9,6 +9,7 @@ export type SettingsView = | 'gateway' | 'keys' | 'notifications' + | 'plugins' | 'providers' | 'sessions' | `config:${string}` diff --git a/apps/desktop/src/plugins/README.md b/apps/desktop/src/plugins/README.md new file mode 100644 index 000000000000..adcad66e2307 --- /dev/null +++ b/apps/desktop/src/plugins/README.md @@ -0,0 +1,14 @@ +# Bundled plugins + +Drop a `/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//plugin.js` (the disk door) — see the +`hermes-desktop-plugins` skill.