feat(desktop): contribution registry — namespaced areas, keybinds, palette

This commit is contained in:
Brooklyn Nicholson 2026-07-13 17:28:21 -04:00
parent 99ff67eb03
commit aefb36299a
12 changed files with 1047 additions and 0 deletions

View file

@ -0,0 +1,45 @@
/**
* The plugin-facing gateway event tap. The wiring fans every inbound gateway
* event through here BEFORE its own dispatch; plugins subscribe by type (or
* `'*'`) via `host.onEvent`. Listeners are isolated a throwing plugin can
* never break the app's event handling and emit is zero-cost when nobody
* listens.
*/
import type { RpcEvent } from '@/types/hermes'
export type GatewayEventListener = (event: RpcEvent) => void
const listeners = new Map<string, Set<GatewayEventListener>>()
/** Subscribe to gateway events by `type` (`'*'` = everything). Returns a disposer. */
export function onGatewayEvent(type: string, listener: GatewayEventListener): () => void {
const set = listeners.get(type) ?? new Set()
set.add(listener)
listeners.set(type, set)
return () => {
set.delete(listener)
if (set.size === 0) {
listeners.delete(type)
}
}
}
/** Fan an event to subscribers (wiring-side; call before app dispatch). */
export function emitGatewayEvent(event: RpcEvent): void {
if (listeners.size === 0) {
return
}
for (const type of [event.type, '*']) {
for (const listener of listeners.get(type) ?? []) {
try {
listener(event)
} catch (error) {
console.error('[plugins] gateway event listener failed', error)
}
}
}
}

View file

@ -0,0 +1,6 @@
export { Slot } from './react/slot'
export type { SlotProps } from './react/slot'
export { useContributions } from './react/use-contributions'
export { registry } from './registry'
export type { Contribution, ContributionSource } from './types'

View file

@ -0,0 +1,111 @@
/**
* The plugin authoring contract. A plugin is a file that default-exports a
* `HermesPlugin`; it never touches the registry directly it receives a
* scoped `PluginContext` whose `register` auto-tags provenance
* (`source: 'plugin:<id>'`) and namespaces the contribution id
* (`<id>:<localId>`), so authors write plain contributions and collisions
* between plugins are impossible.
*
* Bundled plugins live in `src/plugins/<name>/plugin.tsx` and are discovered
* by `discoverBundledPlugins()` (contrib/plugins.ts) no import, no registry
* edit. Runtime-fetched third-party plugins will drive the SAME contract
* through the plugin host loader (next phase); this is that seam.
*/
import { pluginRest, type PluginRestOptions, pluginSocket } from '@/hermes'
import { readKey, writeKey } from '@/lib/storage'
import { registry } from './registry'
import type { Contribution } from './types'
export type { PluginRestOptions } from '@/hermes'
/** A contribution as a plugin author writes it provenance + id scoping are
* the host's job, so those fields are off-limits here. */
export type PluginContribution = Omit<Contribution, 'source' | 'id'> & { id: string }
/** Namespaced JSON persistence (the VS Code `globalState` analog). Keys live
* under `hermes.plugin.<id>.` plugins can't read or clobber each other. */
export interface PluginStorage {
get<T>(key: string, fallback: T): T
set(key: string, value: unknown): void
remove(key: string): void
}
export interface PluginContext {
/** The resolved plugin source tag, e.g. `'plugin:cost-meter'`. */
readonly source: string
/** Register one contribution (id namespaced, source stamped). */
register: (c: PluginContribution) => () => void
/** Register several at once; the returned disposer removes all of them. */
registerMany: (cs: PluginContribution[]) => () => void
/** REST to this plugin's own backend namespace (`/api/plugins/<id>`); `path`
* is relative ('/board'). The sanctioned door for a plugin that ships a
* `plugin_api.py` profile-aware, namespace-scoped by construction. Use
* `host.request` for gateway JSON-RPC. */
rest: <T>(path: string, opts?: PluginRestOptions) => Promise<T>
/** Live twin of `rest`: a WebSocket to this plugin's own namespace
* ('/events'), JSON frames to `onMessage`, auto-reconnect, disposer
* returned. Resolves to a no-op on OAuth remotes treat it as an
* accelerator over your polling, never a replacement. */
socket: (path: string, onMessage: (data: unknown) => void) => () => void
/** Plugin-scoped persistence. */
storage: PluginStorage
}
export interface HermesPlugin {
/** Stable slug — becomes the `plugin:<id>` source and the id namespace. */
id: string
/** Human name for settings / about UI. */
name?: string
/** Registers on load when the user hasn't chosen (default true). Set false
* for opt-in plugins: they inventory in Settings Plugins, off until the
* user flips the switch. */
defaultEnabled?: boolean
/** Called once at load; wire contributions through `ctx`. */
register: (ctx: PluginContext) => void
}
function createPluginStorage(pluginId: string): PluginStorage {
const scoped = (key: string) => `hermes.plugin.${pluginId}.${key}`
return {
get(key, fallback) {
const raw = readKey(scoped(key))
if (raw === null) {
return fallback
}
try {
return JSON.parse(raw)
} catch {
return fallback
}
},
set: (key, value) => writeKey(scoped(key), JSON.stringify(value)),
remove: key => writeKey(scoped(key), null)
}
}
/** Build the scoped context handed to a plugin's `register`. `onDispose`
* receives every registration's disposer (the loader's unload/reload hook). */
export function createPluginContext(pluginId: string, onDispose?: (dispose: () => void) => void): PluginContext {
const source = `plugin:${pluginId}`
const scope = (c: PluginContribution): Contribution => ({ ...c, id: `${pluginId}:${c.id}`, source })
const track = (dispose: () => void) => {
onDispose?.(dispose)
return dispose
}
return {
source,
register: c => track(registry.register(scope(c))),
registerMany: cs => track(registry.registerMany(cs.map(scope))),
rest: <T>(path: string, opts?: PluginRestOptions) => pluginRest<T>(pluginId, path, opts),
socket: (path, onMessage) => track(pluginSocket(pluginId, path, onMessage)),
storage: createPluginStorage(pluginId)
}
}

View file

@ -0,0 +1,124 @@
/**
* PLUGIN INVENTORY the reactive record of every desktop plugin the app
* knows about (bundled `src/plugins/*`, the in-repo runtime example, the
* `<hermes home>/desktop-plugins/*` disk door incl. agent-written ones),
* plus the persisted DISABLED set. The settings "Plugins" page renders this;
* the loaders publish into it and consult the disabled set before
* registering. Enable/disable is live: each record carries the loader's own
* activate/deactivate handles, so toggling never needs an app reload.
*/
import { atom } from 'nanostores'
export type PluginKind = 'bundled' | 'disk' | 'runtime'
export type PluginStatus = 'disabled' | 'error' | 'loaded'
export interface PluginRecord {
id: string
name: string
kind: PluginKind
status: PluginStatus
/** Load/registration failure message (status 'error'). */
error?: string
/** Absolute plugin.js path (disk plugins) — powers "Reveal in Finder". */
file?: string
}
// Explicit user enable/disable choices, id -> boolean. ABSENCE means "no
// choice" — the plugin falls back to its own `defaultEnabled`. This is what
// lets an opt-in plugin ship off-by-default: absence ≠ enabled anymore.
const DECISIONS_KEY = 'hermes.desktop.pluginDecisions.v2'
const LEGACY_DISABLED_KEY = 'hermes.desktop.disabledPlugins.v1'
function loadDecisions(): Record<string, boolean> {
try {
const raw = window.localStorage.getItem(DECISIONS_KEY)
if (raw) {
return JSON.parse(raw) as Record<string, boolean>
}
// Migrate the v1 disabled-set: each disabled id becomes an explicit `false`.
const legacy = window.localStorage.getItem(LEGACY_DISABLED_KEY)
if (legacy) {
return Object.fromEntries((JSON.parse(legacy) as string[]).map(id => [id, false]))
}
} catch {
// Nonfatal — fall through to no choices.
}
return {}
}
export const $pluginDecisions = atom<Record<string, boolean>>(loadDecisions())
/** Whether a plugin should register: the user's explicit choice if any, else
* the plugin's own default (true for ordinary plugins, false for opt-in). */
export function pluginActive(id: string, defaultEnabled = true): boolean {
const decisions = $pluginDecisions.get()
return id in decisions ? decisions[id] : defaultEnabled
}
function saveDecisions(next: Record<string, boolean>) {
$pluginDecisions.set(next)
try {
window.localStorage.setItem(DECISIONS_KEY, JSON.stringify(next))
} catch {
// Nonfatal.
}
}
export const $pluginRecords = atom<Record<string, PluginRecord>>({})
/** Loader-owned lifecycle controls for a plugin (activate/deactivate). */
interface PluginHandle {
activate: () => Promise<void> | void
deactivate: () => void
}
/** Loader-owned lifecycle handles, keyed by plugin id. */
const handles = new Map<string, PluginHandle>()
/** Publish/refresh a plugin's record + its activate/deactivate handles. */
export function publishPlugin(record: PluginRecord, handle?: PluginHandle): void {
$pluginRecords.set({ ...$pluginRecords.get(), [record.id]: record })
if (handle) {
handles.set(record.id, handle)
}
}
export function patchPlugin(id: string, patch: Partial<PluginRecord>): void {
const current = $pluginRecords.get()[id]
if (current) {
$pluginRecords.set({ ...$pluginRecords.get(), [id]: { ...current, ...patch } })
}
}
export function dropPlugin(id: string): void {
const { [id]: _dropped, ...rest } = $pluginRecords.get()
$pluginRecords.set(rest)
handles.delete(id)
}
/** Live toggle: deactivate + remember, or forget + reactivate. */
export async function setPluginEnabled(id: string, enabled: boolean): Promise<void> {
saveDecisions({ ...$pluginDecisions.get(), [id]: enabled })
const handle = handles.get(id)
if (!handle) {
return
}
if (enabled) {
await handle.activate()
} else {
handle.deactivate()
patchPlugin(id, { status: 'disabled' })
}
}

View file

@ -0,0 +1,74 @@
/**
* Plugin discovery both delivery modes:
*
* - BUNDLED: every `src/plugins/<name>/plugin.{ts,tsx}` default-exporting a
* `HermesPlugin` registers automatically (vite glob drop a folder in).
* None ship in-tree today; reference/demo plugins live in the companion
* `hermes-example-plugins` repo.
* - RUNTIME: the on-disk door (`<hermes home>/desktop-plugins/<name>/plugin.js`)
* the agent's/user's door, 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.{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, 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()
}

View file

@ -0,0 +1,59 @@
import type { ReactNode } from 'react'
import { ErrorBoundary } from '@/components/error-boundary'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { ErrorState } from '@/components/ui/error-state'
import { Tip } from '@/components/ui/tooltip'
interface ContribBoundaryProps {
children: ReactNode
/** Contribution key, shown in the fallback + console tag. */
id: string
/** `chip` = inline bar item (tiny fallback); `pane` = zone body. */
variant?: 'chip' | 'pane'
}
/**
* The blast wall between a contribution's `render()` and the app. Plugin
* code throwing during render (bad import, undefined component, logic bug)
* degrades to a small inline error in ITS slot the surrounding bar/zone,
* other plugins, and the app keep working. Every surface that mounts
* contribution renders wraps them in this.
*
* The pane fallback uses the app's canonical `ErrorState` (same icon/title/body
* as the React boundary and dialog errors) so a crashed contribution reads like
* every other failure, not a raw stack dump.
*/
export function ContribBoundary({ children, id, variant = 'pane' }: ContribBoundaryProps) {
return (
<ErrorBoundary
fallback={({ error, reset }) =>
variant === 'chip' ? (
<Tip label={`${id}: ${error.message}`}>
<button
className="inline-flex items-center gap-1 rounded px-1.5 text-[0.6875rem] text-destructive transition-colors hover:bg-(--chrome-action-hover)"
onClick={reset}
type="button"
>
<Codicon name="warning" size="0.7rem" />
{id}
</button>
</Tip>
) : (
<div className="grid h-full place-items-center p-6">
<ErrorState description={error.message} title={`${id}” failed to render`}>
<Button className="justify-self-center" onClick={reset} size="sm" variant="outline">
<Codicon name="refresh" size="0.8rem" />
Retry
</Button>
</ErrorState>
</div>
)
}
label={`contrib:${id}`}
>
{children}
</ErrorBoundary>
)
}

View file

@ -0,0 +1,51 @@
/**
* Mount-scoped contribution the "reverse portal" companion to `Slot`.
*
* `ctx.register` is for PERMANENT contributions (registered at plugin load,
* alive for the plugin's lifetime). `<Contribute>` is for chrome that belongs
* to a mounted surface: while the owning component is mounted, `children`
* render inside the target area's Slot; on unmount the contribution disposes
* itself. No route sniffing, no `when()` polling React's lifecycle IS the
* visibility contract (a page's titlebar control leaves with the page).
*
* Children stay LIVE: they're projected through a nanostore the registered
* render subscribes to, so caller state flows into the slot on every render
* without re-registering (which would churn every other slot).
*/
import { useStore } from '@nanostores/react'
import { atom, type WritableAtom } from 'nanostores'
import { type ReactNode, useEffect, useRef } from 'react'
import { registry } from '../registry'
function ProjectedNode({ $node }: { $node: WritableAtom<ReactNode> }) {
return <>{useStore($node)}</>
}
export interface ContributeProps {
/** Target area id (e.g. `titleBar.center`). */
area: string
/** Stable contribution id — namespace it (`kanban:board-switcher`). */
id: string
order?: number
children: ReactNode
}
export function Contribute({ area, children, id, order }: ContributeProps) {
const $node = useRef<WritableAtom<ReactNode>>(null!)
$node.current ??= atom<ReactNode>(null)
// Push the latest children into the projection after every render.
useEffect(() => {
$node.current.set(children)
})
useEffect(
() => registry.register({ area, id, order, render: () => <ProjectedNode $node={$node.current} /> }),
[area, id, order]
)
return null
}

View file

@ -0,0 +1,26 @@
import { ContribBoundary } from './boundary'
import { useContributions } from './use-contributions'
export interface SlotProps {
/** Area id whose contributions render inline, in order. */
area: string
}
/** Renders a bar area: ordered inline items `[...core, ...plugin]`. */
export function Slot({ area }: SlotProps) {
const items = useContributions(area)
if (items.length === 0) {
return null
}
return (
<>
{items.map(c => (
<ContribBoundary id={c.id} key={`${c.source ?? 'core'}:${c.id}`} variant="chip">
{c.render?.()}
</ContribBoundary>
))}
</>
)
}

View file

@ -0,0 +1,14 @@
import { useCallback, useSyncExternalStore } from 'react'
import { registry } from '../registry'
import type { Contribution } from '../types'
/** Subscribe to the resolved contributions for an area. The subscription is
* scoped to `area`, so a slot re-renders only when ITS area mutates a
* statusbar registration never re-renders a titlebar (or panes) slot. */
export function useContributions(area: string): readonly Contribution[] {
const subscribe = useCallback((onChange: () => void) => registry.subscribeArea(area, onChange), [area])
const getSnapshot = useCallback(() => registry.getArea(area), [area])
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot)
}

View file

@ -0,0 +1,162 @@
import { atom } from 'nanostores'
import type { Contribution } from './types'
/** Bumped on every registry mutation the reactive hook for non-React
* consumers (nanostores `computed`s, memo deps) that read `registry.getArea`
* imperatively. React trees should keep using `useContributions`. */
export const $registryVersion = atom(0)
type Listener = () => void
const EMPTY: readonly Contribution[] = Object.freeze([])
/**
* The one registry every area reads from. Keyed by namespaced area id so the
* same primitive resolves at any depth of the recursive Area scene graph
* (`statusBar.right`, `rightColumn.panel`, `capabilities.detail.actions`, ...).
*
* Snapshots are cached per area and only invalidated on mutation so the value
* is referentially stable for `useSyncExternalStore` (no render loops).
*
* Invalidation is AREA-SCOPED: mutating one area only clears that area's
* snapshot and only notifies subscribers of that area, so registering a
* statusbar item can never re-render a titlebar slot (or any other unrelated
* area). `registerMany`/`removeMany` collapse a batch into one notification
* per touched area. A global listener channel (`subscribe`) still fires on
* every mutation for engines that react to any change (pane adoption).
*
* Note: cache invalidation is mutation-driven, so a purely dynamic `when()`
* that flips without a register/remove won't re-resolve on its own -- fine for
* the current surfaces (no dynamic `when` is in use); revisit if we need
* reactive `when`.
*/
class ContributionRegistry {
private byArea = new Map<string, Contribution[]>()
private snapshot = new Map<string, readonly Contribution[]>()
private areaListeners = new Map<string, Set<Listener>>()
private globalListeners = new Set<Listener>()
/** Register one contribution. Returns a disposer that removes it. */
register = (c: Contribution): (() => void) => this.registerMany([c])
/** Register several at once. Returns a disposer that removes all of them.
* A batch touches each affected area exactly once no per-item churn. */
registerMany = (cs: Contribution[]): (() => void) => {
cs.forEach(c => this.put(c))
this.invalidate(cs.map(c => c.area))
return () => this.removeMany(cs.map(c => ({ area: c.area, id: c.id })))
}
/** Resolved, sorted, filtered entries for an area. Stable ref until mutated. */
getArea = (area: string): readonly Contribution[] => {
const cached = this.snapshot.get(area)
if (cached) {
return cached
}
const raw = this.byArea.get(area)
const resolved: readonly Contribution[] =
!raw || raw.length === 0
? EMPTY
: raw
.filter(c => c.enabled !== false && (c.when ? c.when() : true))
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
this.snapshot.set(area, resolved)
return resolved
}
/** Subscribe to ANY registry mutation (engines that react to every change,
* e.g. pane adoption). React trees should prefer `subscribeArea`. */
subscribe = (fn: Listener): (() => void) => {
this.globalListeners.add(fn)
return () => {
this.globalListeners.delete(fn)
}
}
/** Subscribe to mutations of ONE area only the leaf-level channel behind
* `useContributions`, so a slot re-renders solely for its own area. */
subscribeArea = (area: string, fn: Listener): (() => void) => {
const set = this.areaListeners.get(area) ?? new Set<Listener>()
set.add(fn)
this.areaListeners.set(area, set)
return () => {
set.delete(fn)
if (set.size === 0) {
this.areaListeners.delete(area)
}
}
}
private removeMany(entries: { area: string; id: string }[]) {
const changed: string[] = []
for (const { area, id } of entries) {
if (this.take(area, id)) {
changed.push(area)
}
}
if (changed.length) {
this.invalidate(changed)
}
}
/** Insert/replace an entry without notifying (batchable). */
private put(c: Contribution) {
const list = this.byArea.get(c.area) ?? []
this.byArea.set(c.area, [...list.filter(e => e.id !== c.id), c])
}
/** Remove an entry without notifying (batchable). Returns whether it existed. */
private take(area: string, id: string): boolean {
const list = this.byArea.get(area)
if (!list) {
return false
}
const next = list.filter(e => e.id !== id)
if (next.length === list.length) {
return false
}
if (next.length) {
this.byArea.set(area, next)
} else {
this.byArea.delete(area)
}
return true
}
private invalidate(areas: readonly string[]) {
const unique = new Set(areas)
for (const area of unique) {
this.snapshot.delete(area)
const listeners = this.areaListeners.get(area)
if (listeners) {
listeners.forEach(l => l())
}
}
// Global listeners fire once per batch, regardless of how many areas moved.
this.globalListeners.forEach(l => l())
$registryVersion.set($registryVersion.get() + 1)
}
}
export const registry = new ContributionRegistry()

View file

@ -0,0 +1,332 @@
/**
* Runtime plugin loader plugins as CODE, not registry edits, loaded after
* build time. The pipeline every non-bundled plugin takes:
*
* source (plain ESM js) -> [integrity check] -> bare-specifier rewrite
* (`@hermes/plugin-sdk` / `react*` -> live shim blobs, see sdk/runtime.ts)
* -> blob `import()` -> validate default HermesPlugin -> register(ctx)
*
* Loading the same plugin id again disposes the previous registrations first
* (agent rewrites a plugin file -> clean reload). Failures toast + log; a
* broken plugin can never take the app down.
*
* Sources today: the in-repo runtime example (`?raw`, proves the pipeline)
* and `<hermes home>/desktop-plugins/<name>/plugin.js` on disk the door the
* agent writes through.
*
* SECURITY this is NOT a capability boundary. A loaded plugin is evaluated
* as ESM in the renderer realm with FULL app authority: the React singleton,
* the whole SDK (`host.request` gateway RPC, `ctx.rest`, storage, `navigate`).
* The isolation here is *error* isolation only (ContribBoundary, isolated
* listeners) a plugin can't crash the app, but it can do anything the app
* can. That's acceptable for local sources (disk files can already run code),
* and `integrity` only proves the bytes match a hash it does NOT sandbox.
* A remote source (https + allowlist) must NOT reuse this pipeline as-is:
* it needs a real boundary (iframe/worker + CSP + capability gating) before
* it can land. The `{ integrity }` option is the transport seam, not the
* trust seam.
*/
import { getStatus } from '@/hermes'
import { installPluginSdk, sdkImportMap } from '@/sdk/runtime'
import { notifyError } from '@/store/notifications'
import { createPluginContext, type HermesPlugin } from './plugin'
import { dropPlugin, pluginActive, type PluginKind, publishPlugin } from './plugins-store'
interface LoadOptions {
/** Absolute plugin.js path (disk plugins) — recorded for reveal/inventory. */
file?: string
/** `sha256-<base64>` — verified against the source before evaluation. */
integrity?: string
/** Inventory bucket; the disk door is the default runtime source. */
kind?: PluginKind
}
/** Live runtime plugins: id -> disposers (unload/reload support). */
const loaded = new Map<string, (() => void)[]>()
// Matches the specifier of a static `from '…'`, a side-effect `import '…'`, or
// a dynamic `import('…')` — anchored to import/export syntax so a bare string
// literal or comment (e.g. `notify('react')`) is never touched.
const importSpecifierRe = () => /(from\s*|import\s*\(\s*|import\s+)(['"])([^'"]+)\2/g
/** Rewrite ONLY mapped import specifiers (@hermes/plugin-sdk, react*) to their
* live shim blob URLs never occurrences inside strings/comments. */
function rewriteSpecifiers(source: string): string {
const map = sdkImportMap()
return source.replace(importSpecifierRe(), (whole, pre, quote, spec) =>
map[spec] ? `${pre}${quote}${map[spec]}${quote}` : whole
)
}
/** Bare import specifiers the loader can't resolve (not relative/URL, not in
* the SDK map). Surfaced up-front so they don't fail as a cryptic native
* "Failed to resolve module specifier" from the blob import. */
function unsupportedImports(source: string): string[] {
const map = sdkImportMap()
const bare = new Set<string>()
for (const m of source.matchAll(importSpecifierRe())) {
const spec = m[3]
// Skip relative/absolute (./ ../ /) and any URL scheme (blob: http(s):).
if (spec && !/^[./]/.test(spec) && !/^[a-z][a-z0-9+.-]*:/i.test(spec) && !map[spec]) {
bare.add(spec)
}
}
return [...bare]
}
async function verifyIntegrity(source: string, integrity: string): Promise<boolean> {
const [algo, expected] = integrity.split('-', 2)
if (algo !== 'sha256' || !expected) {
return false
}
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(source))
// Standard SRI base64 (`sha256-<base64>`) — a base64url-encoded hash won't match.
const actual = btoa(String.fromCharCode(...new Uint8Array(digest)))
return actual === expected
}
export function unloadRuntimePlugin(id: string): void {
loaded.get(id)?.forEach(dispose => dispose())
loaded.delete(id)
}
/** Evaluate + register one runtime plugin. Returns its id, or null on failure. */
export async function loadRuntimePlugin(source: string, origin: string, options: LoadOptions = {}): Promise<null | string> {
installPluginSdk()
try {
if (options.integrity && !(await verifyIntegrity(source, options.integrity))) {
throw new Error(`integrity check failed for ${origin}`)
}
const unsupported = unsupportedImports(source)
if (unsupported.length > 0) {
throw new Error(
`unsupported import${unsupported.length > 1 ? 's' : ''}: ${unsupported.join(', ')}` +
`runtime plugins may only import @hermes/plugin-sdk and react`
)
}
const url = URL.createObjectURL(new Blob([rewriteSpecifiers(source)], { type: 'text/javascript' }))
let mod: { default?: HermesPlugin }
try {
mod = await import(/* @vite-ignore */ url)
} finally {
URL.revokeObjectURL(url)
}
const plugin = mod.default
if (!plugin?.id || typeof plugin.register !== 'function') {
throw new Error(`${origin} has no valid default HermesPlugin export`)
}
const record = {
id: plugin.id,
name: plugin.name ?? plugin.id,
kind: options.kind ?? 'disk',
file: options.file
}
const activate = () => {
// Reload = dispose the previous incarnation, then register fresh.
unloadRuntimePlugin(plugin.id)
const disposers: (() => void)[] = []
plugin.register(createPluginContext(plugin.id, dispose => disposers.push(dispose)))
loaded.set(plugin.id, disposers)
publishPlugin({ ...record, status: 'loaded' })
}
publishPlugin({ ...record, status: 'disabled' }, { activate, deactivate: () => unloadRuntimePlugin(plugin.id) })
// A disabled plugin still inventories (settings shows it, toggle
// reactivates via the handle above) — it just never registers.
if (pluginActive(plugin.id, plugin.defaultEnabled ?? true)) {
activate()
}
return plugin.id
} catch (error) {
console.error(`[plugins] runtime load failed (${origin})`, error)
notifyError(error, `Plugin "${origin}" failed to load`)
publishPlugin({
id: origin,
name: origin,
kind: options.kind ?? 'disk',
file: options.file,
status: 'error',
error: error instanceof Error ? error.message : String(error)
})
return null
}
}
// ---------------------------------------------------------------------------
// The on-disk plugin door: `<hermes home>/desktop-plugins/<name>/plugin.js`
// (agent- or user-written). SELF-MAINTAINING — no reload ceremony:
// - each plugin.js is fs-watched (the preview watcher IPC, debounced in
// main): saving the file hot-reloads the plugin in place;
// - a slow visible-tab poll of the directory picks up new folders (load +
// watch) and removed ones (unload + unwatch).
// Panes land via placement adoption and STAY where the user drags them —
// the tree treats not-yet-loaded pane ids as hidden, so boot and reload are
// collapse -> appear, never a placeholder flash.
// ---------------------------------------------------------------------------
const DISK_POLL_MS = 5_000
interface DiskPlugin {
file: string
/** Loaded plugin id (null while broken — kept so a fixing save reloads). */
id: null | string
watchId: null | string
}
const disk = new Map<string, DiskPlugin>()
let watching = false
let scanning = false
async function loadDiskPlugin(name: string, file: string): Promise<void> {
const desktop = window.hermesDesktop!
const entry = disk.get(name)
const prevId = entry?.id
try {
const { text } = await desktop.readFileText(file)
const id = await loadRuntimePlugin(text, name, { file })
// A hot-edit that changes `plugin.id`: loadRuntimePlugin only disposes the
// NEW id, so unload the previous incarnation here or its contributions +
// inventory row orphan.
if (id && prevId && prevId !== id) {
unloadRuntimePlugin(prevId)
dropPlugin(prevId)
}
if (entry) {
entry.id = id ?? entry.id
}
// A fixing save under a different plugin id — drop the folder-named
// error record so the inventory shows one row, not a ghost.
if (id && id !== name) {
dropPlugin(name)
}
} catch {
// File vanished mid-read — the next scan reconciles.
}
}
async function scanDiskPlugins(): Promise<void> {
const desktop = window.hermesDesktop
// Re-entrancy guard: the 5s poll must not overlap a slow in-flight scan
// (reads/loads can exceed the interval).
if (!desktop || scanning) {
return
}
scanning = true
try {
const { hermes_home } = await getStatus()
const { entries } = await desktop.readDir(`${hermes_home}/desktop-plugins`)
const seen = new Set<string>()
for (const dir of entries.filter(e => e.isDirectory)) {
seen.add(dir.name)
if (disk.has(dir.name)) {
continue
}
const file = `${dir.path}/plugin.js`
try {
await desktop.readFileText(file)
} catch {
continue // No plugin.js (yet) — not a plugin folder.
}
const record: DiskPlugin = { file, id: null, watchId: null }
disk.set(dir.name, record)
await loadDiskPlugin(dir.name, file)
try {
record.watchId = (await desktop.watchPreviewFile(file)).id
} catch {
// Unwatchable — the poll still reconciles new folders; edits need a
// manual "Reload desktop plugins".
}
}
// Folder deleted -> plugin gone, cleanly (inventory row included).
for (const [name, record] of disk) {
if (seen.has(name)) {
continue
}
if (record.id) {
unloadRuntimePlugin(record.id)
dropPlugin(record.id)
}
dropPlugin(name)
if (record.watchId) {
void desktop.stopPreviewFileWatch(record.watchId)
}
disk.delete(name)
}
} catch {
// No desktop-plugins dir (or no gateway yet) — nothing to reconcile.
} finally {
scanning = false
}
}
/** Manual rescan (the ⌘K "Reload desktop plugins" fallback). */
export const discoverRuntimePlugins = scanDiskPlugins
/** Start the self-maintaining disk door: initial scan, per-file hot reload,
* slow folder reconciliation while the window is visible. Idempotent. */
export function watchRuntimePlugins(): void {
const desktop = window.hermesDesktop
if (watching || !desktop) {
return
}
watching = true
desktop.onPreviewFileChanged(({ id }) => {
for (const [name, record] of disk) {
if (record.watchId === id) {
void loadDiskPlugin(name, record.file)
return
}
}
})
void scanDiskPlugins()
window.setInterval(() => {
if (document.visibilityState === 'visible') {
void scanDiskPlugins()
}
}, DISK_POLL_MS)
}

View file

@ -0,0 +1,43 @@
import type { ReactNode } from 'react'
/**
* Where a contribution came from. `'core'` is the app's own default UI;
* anything else is a plugin/extension id (e.g. `'plugin:kanban'`). This is the
* provenance tag that drives precedence and, later, the trust/capability gate
* (WoW-style taint: plugin-sourced contributions can be blocked from privileged
* actions unless granted).
*/
export type ContributionSource = 'core' | (string & {})
/**
* The single, uniform primitive every surface consumes. A bar renders these as
* inline items via `<Slot>`; a dock renders them as stacked/tabbed panes via
* `<PaneHost>`. Same shape either way -- the host decides how to present them.
*/
export interface Contribution {
/** Stable id, unique within its area. Re-registering the same id replaces it. */
id: string
/** Namespaced area id this contribution targets, e.g. `'secondarySidebar'`. */
area: string
/** Provenance; defaults to `'core'` when omitted. */
source?: ContributionSource
/** Human label (pane tab / header). Optional for bar items. */
title?: string
/** Ascending sort key within the area; ties keep insertion order. */
order?: number
/** Dynamic visibility predicate. Omit for always-on.
* NOTE: evaluated when the area's snapshot is (re)built i.e. on a
* register/remove in that area, NOT reactively. A `when()` that flips on
* external state won't re-resolve on its own; trigger a registry mutation
* (or don't rely on it flipping without one). */
when?: () => boolean
/** Soft disable without unregistering. `false` hides it. */
enabled?: boolean
/** Renders the contribution's content (UI contributions). */
render?: () => ReactNode
/**
* Declarative payload for data contributions (Family B): layout presets,
* themes, commands anything consumed by an engine rather than rendered.
*/
data?: unknown
}