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.