The cherry-picked fix scoped host.onEvent disposers into the runtime loader's per-plugin disposer list. This finishes the class: - apps/desktop/src/contrib/plugins.ts: the bundled loader's activate() wraps register() in the same trackGatewayEventDisposers scope, so a Capabilities > Plugins disable/re-enable cycle no longer strands a bundled plugin's gateway listeners either (same accumulate-per-toggle mechanism as the disk hot reload). - apps/desktop/src/contrib/plugin.ts: PluginContext.onEvent — a tracked door for subscriptions made AFTER register() returns (timers, socket callbacks), where the register-time scope cannot see them and a bare host.onEvent still needs hand-wiring to ctx.onDispose. - apps/desktop/src/sdk/index.ts + website desktop-plugin-sdk doc: state the contract plugin authors can now rely on. - Tests: one ctx.onEvent invariant (red on base: TypeError) in plugin.test.ts; the contributor's events.test.ts control case dropped (green on base, no invariant) to keep the fix at two tests. Why: unloadRuntimePlugin / deactivate only run ctx-tracked disposers; anything a plugin subscribes outside track() survives every reload, and one relay marker then opens N sessions (#112366). Fixes #112366 Co-authored-by: Kevin Rajan <7121943+kvnloo@users.noreply.github.com>
89 lines
3.2 KiB
TypeScript
89 lines
3.2 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 { trackGatewayEventDisposers } from './events'
|
|
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 {
|
|
trackGatewayEventDisposers(
|
|
dispose => disposers.push(dispose),
|
|
() => 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()
|
|
}
|