From aefb36299a6f6ff5eb3586ca92c9395c6debbddb Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 13 Jul 2026 17:28:21 -0400 Subject: [PATCH] =?UTF-8?q?feat(desktop):=20contribution=20registry=20?= =?UTF-8?q?=E2=80=94=20namespaced=20areas,=20keybinds,=20palette?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/desktop/src/contrib/events.ts | 45 +++ apps/desktop/src/contrib/index.ts | 6 + apps/desktop/src/contrib/plugin.ts | 111 ++++++ apps/desktop/src/contrib/plugins-store.ts | 124 +++++++ apps/desktop/src/contrib/plugins.ts | 74 ++++ apps/desktop/src/contrib/react/boundary.tsx | 59 ++++ apps/desktop/src/contrib/react/contribute.tsx | 51 +++ apps/desktop/src/contrib/react/slot.tsx | 26 ++ .../src/contrib/react/use-contributions.ts | 14 + apps/desktop/src/contrib/registry.ts | 162 +++++++++ apps/desktop/src/contrib/runtime-loader.ts | 332 ++++++++++++++++++ apps/desktop/src/contrib/types.ts | 43 +++ 12 files changed, 1047 insertions(+) create mode 100644 apps/desktop/src/contrib/events.ts create mode 100644 apps/desktop/src/contrib/index.ts create mode 100644 apps/desktop/src/contrib/plugin.ts create mode 100644 apps/desktop/src/contrib/plugins-store.ts create mode 100644 apps/desktop/src/contrib/plugins.ts create mode 100644 apps/desktop/src/contrib/react/boundary.tsx create mode 100644 apps/desktop/src/contrib/react/contribute.tsx create mode 100644 apps/desktop/src/contrib/react/slot.tsx create mode 100644 apps/desktop/src/contrib/react/use-contributions.ts create mode 100644 apps/desktop/src/contrib/registry.ts create mode 100644 apps/desktop/src/contrib/runtime-loader.ts create mode 100644 apps/desktop/src/contrib/types.ts diff --git a/apps/desktop/src/contrib/events.ts b/apps/desktop/src/contrib/events.ts new file mode 100644 index 000000000000..e76ddff7ff95 --- /dev/null +++ b/apps/desktop/src/contrib/events.ts @@ -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>() + +/** 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) + } + } + } +} diff --git a/apps/desktop/src/contrib/index.ts b/apps/desktop/src/contrib/index.ts new file mode 100644 index 000000000000..a5faa2f232b5 --- /dev/null +++ b/apps/desktop/src/contrib/index.ts @@ -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' diff --git a/apps/desktop/src/contrib/plugin.ts b/apps/desktop/src/contrib/plugin.ts new file mode 100644 index 000000000000..c8a34d59df78 --- /dev/null +++ b/apps/desktop/src/contrib/plugin.ts @@ -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:'`) and namespaces the contribution id + * (`:`), so authors write plain contributions and collisions + * between plugins are impossible. + * + * Bundled plugins live in `src/plugins//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 & { id: string } + +/** Namespaced JSON persistence (the VS Code `globalState` analog). Keys live + * under `hermes.plugin..` — plugins can't read or clobber each other. */ +export interface PluginStorage { + get(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/`); `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: (path: string, opts?: PluginRestOptions) => Promise + /** 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:` 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: (path: string, opts?: PluginRestOptions) => pluginRest(pluginId, path, opts), + socket: (path, onMessage) => track(pluginSocket(pluginId, path, onMessage)), + storage: createPluginStorage(pluginId) + } +} diff --git a/apps/desktop/src/contrib/plugins-store.ts b/apps/desktop/src/contrib/plugins-store.ts new file mode 100644 index 000000000000..9e90a4cc0be6 --- /dev/null +++ b/apps/desktop/src/contrib/plugins-store.ts @@ -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 + * `/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 { + try { + const raw = window.localStorage.getItem(DECISIONS_KEY) + + if (raw) { + return JSON.parse(raw) as Record + } + + // 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>(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) { + $pluginDecisions.set(next) + + try { + window.localStorage.setItem(DECISIONS_KEY, JSON.stringify(next)) + } catch { + // Nonfatal. + } +} + +export const $pluginRecords = atom>({}) + +/** Loader-owned lifecycle controls for a plugin (activate/deactivate). */ +interface PluginHandle { + activate: () => Promise | void + deactivate: () => void +} + +/** Loader-owned lifecycle handles, keyed by plugin id. */ +const handles = new Map() + +/** 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): 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 { + 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' }) + } +} diff --git a/apps/desktop/src/contrib/plugins.ts b/apps/desktop/src/contrib/plugins.ts new file mode 100644 index 000000000000..d1cab8a32fba --- /dev/null +++ b/apps/desktop/src/contrib/plugins.ts @@ -0,0 +1,74 @@ +/** + * Plugin discovery — both delivery modes: + * + * - BUNDLED: every `src/plugins//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 (`/desktop-plugins//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() +} diff --git a/apps/desktop/src/contrib/react/boundary.tsx b/apps/desktop/src/contrib/react/boundary.tsx new file mode 100644 index 000000000000..570ba6c8f4c3 --- /dev/null +++ b/apps/desktop/src/contrib/react/boundary.tsx @@ -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 ( + + variant === 'chip' ? ( + + + + ) : ( +
+ + + +
+ ) + } + label={`contrib:${id}`} + > + {children} +
+ ) +} diff --git a/apps/desktop/src/contrib/react/contribute.tsx b/apps/desktop/src/contrib/react/contribute.tsx new file mode 100644 index 000000000000..4526e3cdbfed --- /dev/null +++ b/apps/desktop/src/contrib/react/contribute.tsx @@ -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). `` 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 }) { + 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>(null!) + + $node.current ??= atom(null) + + // Push the latest children into the projection after every render. + useEffect(() => { + $node.current.set(children) + }) + + useEffect( + () => registry.register({ area, id, order, render: () => }), + [area, id, order] + ) + + return null +} diff --git a/apps/desktop/src/contrib/react/slot.tsx b/apps/desktop/src/contrib/react/slot.tsx new file mode 100644 index 000000000000..802cc0bf817b --- /dev/null +++ b/apps/desktop/src/contrib/react/slot.tsx @@ -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 => ( + + {c.render?.()} + + ))} + + ) +} diff --git a/apps/desktop/src/contrib/react/use-contributions.ts b/apps/desktop/src/contrib/react/use-contributions.ts new file mode 100644 index 000000000000..a7486ec1cb8f --- /dev/null +++ b/apps/desktop/src/contrib/react/use-contributions.ts @@ -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) +} diff --git a/apps/desktop/src/contrib/registry.ts b/apps/desktop/src/contrib/registry.ts new file mode 100644 index 000000000000..1a8f1f2e1c6e --- /dev/null +++ b/apps/desktop/src/contrib/registry.ts @@ -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() + private snapshot = new Map() + private areaListeners = new Map>() + private globalListeners = new Set() + + /** 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() + 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() diff --git a/apps/desktop/src/contrib/runtime-loader.ts b/apps/desktop/src/contrib/runtime-loader.ts new file mode 100644 index 000000000000..bf1fe2ca9dc4 --- /dev/null +++ b/apps/desktop/src/contrib/runtime-loader.ts @@ -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 `/desktop-plugins//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-` — 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 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() + + 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 { + 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-`) — 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 { + 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: `/desktop-plugins//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() +let watching = false +let scanning = false + +async function loadDiskPlugin(name: string, file: string): Promise { + 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 { + 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() + + 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) +} diff --git a/apps/desktop/src/contrib/types.ts b/apps/desktop/src/contrib/types.ts new file mode 100644 index 000000000000..c46ee3e9929e --- /dev/null +++ b/apps/desktop/src/contrib/types.ts @@ -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 ``; a dock renders them as stacked/tabbed panes via + * ``. 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 +}