mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-22 16:25:58 +00:00
feat(desktop): contribution registry — namespaced areas, keybinds, palette
This commit is contained in:
parent
99ff67eb03
commit
aefb36299a
12 changed files with 1047 additions and 0 deletions
45
apps/desktop/src/contrib/events.ts
Normal file
45
apps/desktop/src/contrib/events.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
6
apps/desktop/src/contrib/index.ts
Normal file
6
apps/desktop/src/contrib/index.ts
Normal 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'
|
||||
111
apps/desktop/src/contrib/plugin.ts
Normal file
111
apps/desktop/src/contrib/plugin.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
124
apps/desktop/src/contrib/plugins-store.ts
Normal file
124
apps/desktop/src/contrib/plugins-store.ts
Normal 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' })
|
||||
}
|
||||
}
|
||||
74
apps/desktop/src/contrib/plugins.ts
Normal file
74
apps/desktop/src/contrib/plugins.ts
Normal 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()
|
||||
}
|
||||
59
apps/desktop/src/contrib/react/boundary.tsx
Normal file
59
apps/desktop/src/contrib/react/boundary.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
51
apps/desktop/src/contrib/react/contribute.tsx
Normal file
51
apps/desktop/src/contrib/react/contribute.tsx
Normal 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
|
||||
}
|
||||
26
apps/desktop/src/contrib/react/slot.tsx
Normal file
26
apps/desktop/src/contrib/react/slot.tsx
Normal 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>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
14
apps/desktop/src/contrib/react/use-contributions.ts
Normal file
14
apps/desktop/src/contrib/react/use-contributions.ts
Normal 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)
|
||||
}
|
||||
162
apps/desktop/src/contrib/registry.ts
Normal file
162
apps/desktop/src/contrib/registry.ts
Normal 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()
|
||||
332
apps/desktop/src/contrib/runtime-loader.ts
Normal file
332
apps/desktop/src/contrib/runtime-loader.ts
Normal 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)
|
||||
}
|
||||
43
apps/desktop/src/contrib/types.ts
Normal file
43
apps/desktop/src/contrib/types.ts
Normal 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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue