Files
hermes-agent/apps/desktop/src/contrib/plugins.ts
T
Teknium 366d8814b8 feat(desktop): bundle Bot Mode (hermes-bots) as a built-in, default-on plugin
Adopts the Hermes-Bot-Mode desktop plugin (NousResearch/Hermes-Bot-Mode)
into apps/desktop/src/plugins/hermes-bots/, registered by the bundled
vite glob and ON by default. It stays a pure @hermes/plugin-sdk consumer
in plain-ESM plugin.js form; users disable it live in Settings > Plugins.

- contrib/plugins.ts: bundled glob accepts plugin.js entries
- contrib/runtime-loader.ts: a disk/runtime copy of an id that ships
  bundled is skipped (standalone installs predating adoption cannot
  double-register)
- package.json: check:test:plugins runs the plugin's node:test suite in
  CI (138 tests)
- source: Hermes-Bot-Mode @ c19baba, incl. today's #107/#103/#99 merges
2026-08-16 18:30:53 -07:00

85 lines
3.0 KiB
TypeScript

/**
* Plugin discovery — both delivery modes:
*
* - BUNDLED: every `src/plugins/<name>/plugin.{js,ts,tsx}` default-exporting
* a `HermesPlugin` registers automatically (vite glob — drop a folder in).
* `hermes-bots` (Bot Mode) ships in-tree and is ON by default; other
* reference/demo plugins live in the companion `hermes-example-plugins`
* repo. `.js` entries are SDK-consumer plugins adopted from standalone
* repos — they keep the plain-ESM plugin.js form so the file stays
* loadable by older desktops' runtime door too.
* - RUNTIME: the on-disk doors (`<hermes home>/desktop-plugins/<name>/plugin.js`
* and the unified-package half `<hermes home>/plugins/<name>/desktop/plugin.js`)
* — the agent's/user's doors, watched + hot-reloaded by the runtime loader.
*/
import { createPluginContext, type HermesPlugin } from './plugin'
import { pluginActive, publishPlugin } from './plugins-store'
import { watchRuntimePlugins } from './runtime-loader'
const modules = import.meta.glob<{ default: HermesPlugin }>('../plugins/*/plugin.{js,ts,tsx}', { eager: true })
// One-shot init guard. Contributions themselves register by id (re-registering
// is idempotent), but the disk-door watcher setup below (watchRuntimePlugins)
// must NOT run twice — so discovery is guarded to a single pass, not re-run on
// HMR.
let loaded = false
export function discoverBundledPlugins(): void {
if (loaded) {
return
}
loaded = true
for (const [path, mod] of Object.entries(modules)) {
const plugin = mod.default
if (!plugin?.id || typeof plugin.register !== 'function') {
console.warn(`[plugins] ${path} has no valid default HermesPlugin export — skipped`)
continue
}
// 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,
description: plugin.description,
kind: 'bundled' as const
}
let disposers: (() => void)[] = []
const activate = () => {
disposers.forEach(dispose => dispose())
disposers = []
try {
plugin.register(createPluginContext(plugin.id, dispose => disposers.push(dispose)))
publishPlugin({ ...record, status: 'loaded' })
} catch (error) {
console.error(`[plugins] ${plugin.id} failed to register`, error)
publishPlugin({ ...record, status: 'error', error: error instanceof Error ? error.message : String(error) })
}
}
const deactivate = () => {
disposers.forEach(dispose => dispose())
disposers = []
}
publishPlugin({ ...record, status: 'disabled' }, { activate, deactivate })
if (pluginActive(plugin.id, plugin.defaultEnabled ?? true)) {
activate()
}
}
// The SELF-MAINTAINING disk door (fs-watched hot reloads, slow folder
// reconciliation) — the runtime loader pipeline's real, shipping consumer.
watchRuntimePlugins()
}