Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license
This commit is contained in:
commit
03634b1ca3
11340 files changed
+3442369
No files matched your search
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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, WorkspaceMode } from './types'
|
||||
@@ -0,0 +1,104 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { dispatchPluginNativeNotification } from '@/store/native-notifications'
|
||||
|
||||
import { createPluginContext } from './plugin'
|
||||
|
||||
vi.mock('@/store/native-notifications', () => ({ dispatchPluginNativeNotification: vi.fn() }))
|
||||
|
||||
describe('createPluginContext.onDispose', () => {
|
||||
it('collects arbitrary cleanups so the host runs them on deactivate', () => {
|
||||
const disposers: Array<() => void> = []
|
||||
const ctx = createPluginContext('demo', dispose => disposers.push(dispose))
|
||||
|
||||
let cleaned = false
|
||||
ctx.onDispose(() => {
|
||||
cleaned = true
|
||||
})
|
||||
|
||||
// The cleanup is tracked alongside contribution/socket disposers, so the
|
||||
// loader's deactivate (which runs every collected disposer) tears it down.
|
||||
expect(disposers).toHaveLength(1)
|
||||
disposers.forEach(dispose => dispose())
|
||||
expect(cleaned).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('createPluginContext.os', () => {
|
||||
it('dispatches a native notification attributed to the plugin', () => {
|
||||
const ctx = createPluginContext('demo')
|
||||
ctx.os.notify({ body: 'b', title: 't' })
|
||||
expect(dispatchPluginNativeNotification).toHaveBeenCalledWith('demo', { body: 'b', title: 't' })
|
||||
})
|
||||
|
||||
it('resolves false (never throws) when the desktop bridge is missing', async () => {
|
||||
const ctx = createPluginContext('demo')
|
||||
|
||||
// jsdom has no window.hermesDesktop — the exact older-shell/browser case.
|
||||
await expect(ctx.os.openExternal('https://example.com')).resolves.toBe(false)
|
||||
await expect(ctx.os.revealPath('/tmp')).resolves.toBe(false)
|
||||
await expect(ctx.os.writeClipboard('hi')).resolves.toBe(false)
|
||||
// The pickers answer with a path, so their "unavailable" is null.
|
||||
await expect(ctx.os.pickSavePath()).resolves.toBeNull()
|
||||
await expect(ctx.os.pickOpenPath()).resolves.toBeNull()
|
||||
})
|
||||
|
||||
it('file pickers return the chosen path, and null on cancel', async () => {
|
||||
const bridge = {
|
||||
selectPaths: vi.fn().mockResolvedValue(['/tmp/board.tar.gz']),
|
||||
selectSavePath: vi.fn().mockResolvedValue('/tmp/out.tar.gz')
|
||||
}
|
||||
|
||||
;(window as unknown as { hermesDesktop: unknown }).hermesDesktop = bridge
|
||||
|
||||
try {
|
||||
const ctx = createPluginContext('demo')
|
||||
|
||||
await expect(ctx.os.pickSavePath({ title: 'Save' })).resolves.toBe('/tmp/out.tar.gz')
|
||||
expect(bridge.selectSavePath).toHaveBeenCalledWith({ title: 'Save' })
|
||||
|
||||
await expect(ctx.os.pickOpenPath({ title: 'Open' })).resolves.toBe('/tmp/board.tar.gz')
|
||||
expect(bridge.selectPaths).toHaveBeenCalledWith({ multiple: false, title: 'Open' })
|
||||
|
||||
// Cancel: the save dialog resolves null, the open dialog an empty list.
|
||||
bridge.selectSavePath.mockResolvedValue(null)
|
||||
bridge.selectPaths.mockResolvedValue([])
|
||||
await expect(ctx.os.pickSavePath()).resolves.toBeNull()
|
||||
await expect(ctx.os.pickOpenPath()).resolves.toBeNull()
|
||||
} finally {
|
||||
delete (window as unknown as { hermesDesktop?: unknown }).hermesDesktop
|
||||
}
|
||||
})
|
||||
|
||||
it('file pickers degrade to null on an older shell that lacks them', async () => {
|
||||
;(window as unknown as { hermesDesktop: unknown }).hermesDesktop = {}
|
||||
|
||||
try {
|
||||
const ctx = createPluginContext('demo')
|
||||
await expect(ctx.os.pickSavePath()).resolves.toBeNull()
|
||||
await expect(ctx.os.pickOpenPath()).resolves.toBeNull()
|
||||
} finally {
|
||||
delete (window as unknown as { hermesDesktop?: unknown }).hermesDesktop
|
||||
}
|
||||
})
|
||||
|
||||
it('routes through the bridge and turns a bridge throw into false', async () => {
|
||||
const bridge = {
|
||||
openExternal: vi.fn().mockResolvedValue(undefined),
|
||||
revealPath: vi.fn().mockResolvedValue(true),
|
||||
writeClipboard: vi.fn().mockRejectedValue(new Error('nope'))
|
||||
}
|
||||
|
||||
;(window as unknown as { hermesDesktop: unknown }).hermesDesktop = bridge
|
||||
|
||||
try {
|
||||
const ctx = createPluginContext('demo')
|
||||
await expect(ctx.os.openExternal('https://example.com')).resolves.toBe(true)
|
||||
expect(bridge.openExternal).toHaveBeenCalledWith('https://example.com')
|
||||
await expect(ctx.os.revealPath('/tmp')).resolves.toBe(true)
|
||||
await expect(ctx.os.writeClipboard('hi')).resolves.toBe(false)
|
||||
} finally {
|
||||
delete (window as unknown as { hermesDesktop?: unknown }).hermesDesktop
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,220 @@
|
||||
/**
|
||||
* 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 { createPluginI18n, type PluginI18n } from '@/i18n'
|
||||
import { readKey, writeKey } from '@/lib/storage'
|
||||
import { dispatchPluginNativeNotification, type PluginNativeNotificationInput } from '@/store/native-notifications'
|
||||
|
||||
import { registry } from './registry'
|
||||
import type { Contribution } from './types'
|
||||
|
||||
export type { PluginRestOptions } from '@/hermes'
|
||||
export type { HermesOpenTarget } from '@/lib/hermes-open-target'
|
||||
export type { PluginNativeNotificationInput, PluginNotificationAction } from '@/store/native-notifications'
|
||||
|
||||
/** 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
|
||||
}
|
||||
|
||||
/** The curated OS door — every way a plugin reaches outside the app window,
|
||||
* in one attributed namespace instead of the raw `window.hermesDesktop`
|
||||
* bridge. Every member resolves a result instead of throwing when the
|
||||
* capability can't apply (no Electron shell, older desktop build), so
|
||||
* callers branch on the return value rather than sniffing the bridge. */
|
||||
export interface PluginOs {
|
||||
/** Native OS notification (Electron), attributed to this plugin. Gated by
|
||||
* Settings ▸ Notifications ▸ "Plugin notifications" and fires only while
|
||||
* the user is away from Hermes — use `host.notify` for the in-app toast.
|
||||
* Throttled per plugin; reserve it for genuinely notable events.
|
||||
* Supports `icon`, `activate` (e.g. `hermes://index-network/intent/1`),
|
||||
* action buttons, and renderer `onActivate` / `onAction` callbacks. */
|
||||
notify: (input: PluginNativeNotificationInput) => void
|
||||
/** Open a URL with the OS default handler (browser, mail client, custom
|
||||
* schemes like `spotify:`). Resolves false when the shell can't. */
|
||||
openExternal: (url: string) => Promise<boolean>
|
||||
/** Reveal a path in the OS file manager (Finder / Explorer). Resolves
|
||||
* false when unavailable. */
|
||||
revealPath: (path: string) => Promise<boolean>
|
||||
/** Native save dialog. Resolves the chosen path, or null on cancel /
|
||||
* when unavailable. The path is on the BACKEND's filesystem, so hand it
|
||||
* to a `rest` call rather than trying to write it from the renderer. */
|
||||
pickSavePath: (options?: PluginFileDialogOptions) => Promise<null | string>
|
||||
/** Native open dialog, single file. Resolves the chosen path, or null on
|
||||
* cancel / when unavailable. */
|
||||
pickOpenPath: (options?: PluginFileDialogOptions) => Promise<null | string>
|
||||
/** Write text to the system clipboard. Resolves false when unavailable. */
|
||||
writeClipboard: (text: string) => Promise<boolean>
|
||||
}
|
||||
|
||||
export interface PluginFileDialogOptions {
|
||||
defaultPath?: string
|
||||
filters?: Array<{ extensions: string[]; name: string }>
|
||||
title?: string
|
||||
}
|
||||
|
||||
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
|
||||
/** Register an arbitrary cleanup to run on unload/disable — for side effects
|
||||
* that aren't contributions or sockets (store subscriptions, timers). Runs
|
||||
* alongside every other disposer when the plugin deactivates. */
|
||||
onDispose: (fn: () => void) => 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
|
||||
/** The curated OS door: native notification, open-external, reveal-in-file-
|
||||
* manager, clipboard — attributed to this plugin, result-shaped (never
|
||||
* throws for a missing capability). */
|
||||
os: PluginOs
|
||||
/** Plugin-scoped persistence. */
|
||||
storage: PluginStorage
|
||||
/** Plugin-scoped i18n: ship + register locale bundles under this plugin,
|
||||
* resolved against the app's active locale — no core `en.ts` edit. */
|
||||
i18n: PluginI18n
|
||||
}
|
||||
|
||||
export interface HermesPlugin {
|
||||
/** Stable slug — becomes the `plugin:<id>` source and the id namespace. */
|
||||
id: string
|
||||
/** Human name for settings / about UI. */
|
||||
name?: string
|
||||
/** One-liner for the settings inventory (what the plugin adds). */
|
||||
description?: 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)
|
||||
}
|
||||
}
|
||||
|
||||
// Never throws for a missing capability: the renderer can outlive an older
|
||||
// Electron shell (or run in a plain browser), so every door degrades to a
|
||||
// false result the plugin can branch on.
|
||||
function createPluginOs(pluginId: string): PluginOs {
|
||||
const attempt = async (run: (bridge: NonNullable<typeof window.hermesDesktop>) => Promise<boolean>) => {
|
||||
const bridge = typeof window === 'undefined' ? undefined : window.hermesDesktop
|
||||
|
||||
if (!bridge) {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
return await run(bridge)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Same shape as `attempt`, for the pickers that answer with a path.
|
||||
const attemptPath = async (run: (bridge: NonNullable<typeof window.hermesDesktop>) => Promise<null | string>) => {
|
||||
const bridge = typeof window === 'undefined' ? undefined : window.hermesDesktop
|
||||
|
||||
if (!bridge) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
return await run(bridge)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
notify: input => dispatchPluginNativeNotification(pluginId, input),
|
||||
openExternal: url =>
|
||||
attempt(async bridge => {
|
||||
await bridge.openExternal(url)
|
||||
|
||||
return true
|
||||
}),
|
||||
pickOpenPath: options =>
|
||||
attemptPath(async bridge => {
|
||||
const picked = await bridge.selectPaths?.({ ...options, multiple: false })
|
||||
|
||||
return picked?.[0] ?? null
|
||||
}),
|
||||
pickSavePath: options => attemptPath(async bridge => (await bridge.selectSavePath?.(options)) ?? null),
|
||||
revealPath: path => attempt(async bridge => (bridge.revealPath ? bridge.revealPath(path) : false)),
|
||||
writeClipboard: text => attempt(bridge => bridge.writeClipboard(text))
|
||||
}
|
||||
}
|
||||
|
||||
/** 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))),
|
||||
onDispose: fn => void track(fn),
|
||||
rest: <T>(path: string, opts?: PluginRestOptions) => pluginRest<T>(pluginId, path, opts),
|
||||
socket: (path, onMessage) => track(pluginSocket(pluginId, path, onMessage)),
|
||||
os: createPluginOs(pluginId),
|
||||
storage: createPluginStorage(pluginId),
|
||||
i18n: createPluginI18n(pluginId, track)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* 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
|
||||
/** One-liner from the plugin's own metadata (what it adds). */
|
||||
description?: string
|
||||
/** 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' })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* Plugin discovery — both delivery modes:
|
||||
*
|
||||
* - BUNDLED: every `src/plugins/<name>/plugin.{js,ts,tsx}` default-exporting
|
||||
* a `HermesPlugin` registers automatically (vite glob — drop a folder in).
|
||||
* `hermes-bots` (Bot Mode) ships in-tree and is ON by default; other
|
||||
* reference/demo plugins live in the companion `hermes-example-plugins`
|
||||
* repo. `.js` entries are SDK-consumer plugins adopted from standalone
|
||||
* repos — they keep the plain-ESM plugin.js form so the file stays
|
||||
* loadable by older desktops' runtime door too.
|
||||
* - RUNTIME: the on-disk doors (`<hermes home>/desktop-plugins/<name>/plugin.js`
|
||||
* and the unified-package half `<hermes home>/plugins/<name>/desktop/plugin.js`)
|
||||
* — the agent's/user's doors, watched + hot-reloaded by the runtime loader.
|
||||
*/
|
||||
|
||||
import { createPluginContext, type HermesPlugin } from './plugin'
|
||||
import { pluginActive, publishPlugin } from './plugins-store'
|
||||
import { watchRuntimePlugins } from './runtime-loader'
|
||||
|
||||
const modules = import.meta.glob<{ default: HermesPlugin }>('../plugins/*/plugin.{js,ts,tsx}', { eager: true })
|
||||
|
||||
// One-shot init guard. Contributions themselves register by id (re-registering
|
||||
// is idempotent), but the disk-door watcher setup below (watchRuntimePlugins)
|
||||
// must NOT run twice — so discovery is guarded to a single pass, not re-run on
|
||||
// HMR.
|
||||
let loaded = false
|
||||
|
||||
export function discoverBundledPlugins(): void {
|
||||
if (loaded) {
|
||||
return
|
||||
}
|
||||
|
||||
loaded = true
|
||||
|
||||
for (const [path, mod] of Object.entries(modules)) {
|
||||
const plugin = mod.default
|
||||
|
||||
if (!plugin?.id || typeof plugin.register !== 'function') {
|
||||
console.warn(`[plugins] ${path} has no valid default HermesPlugin export — skipped`)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
// Same inventory + live-toggle contract as runtime plugins: each bundled
|
||||
// plugin publishes a record with activate/deactivate handles, and a
|
||||
// persisted disable survives boots by skipping registration here.
|
||||
const record = {
|
||||
id: plugin.id,
|
||||
name: plugin.name ?? plugin.id,
|
||||
description: plugin.description,
|
||||
kind: 'bundled' as const
|
||||
}
|
||||
|
||||
let disposers: (() => void)[] = []
|
||||
|
||||
const activate = () => {
|
||||
disposers.forEach(dispose => dispose())
|
||||
disposers = []
|
||||
|
||||
try {
|
||||
plugin.register(createPluginContext(plugin.id, dispose => disposers.push(dispose)))
|
||||
publishPlugin({ ...record, status: 'loaded' })
|
||||
} catch (error) {
|
||||
console.error(`[plugins] ${plugin.id} failed to register`, error)
|
||||
publishPlugin({ ...record, status: 'error', error: error instanceof Error ? error.message : String(error) })
|
||||
}
|
||||
}
|
||||
|
||||
const deactivate = () => {
|
||||
disposers.forEach(dispose => dispose())
|
||||
disposers = []
|
||||
}
|
||||
|
||||
publishPlugin({ ...record, status: 'disabled' }, { activate, deactivate })
|
||||
|
||||
if (pluginActive(plugin.id, plugin.defaultEnabled ?? true)) {
|
||||
activate()
|
||||
}
|
||||
}
|
||||
|
||||
// The SELF-MAINTAINING disk door (fs-watched hot reloads, slow folder
|
||||
// reconciliation) — the runtime loader pipeline's real, shipping consumer.
|
||||
watchRuntimePlugins()
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { createElement, 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'
|
||||
}
|
||||
|
||||
interface ContribRenderProps {
|
||||
render: () => ReactNode
|
||||
}
|
||||
|
||||
/** Mount a contribution callback as a component so its hooks and errors belong
|
||||
* to the contribution, not to whichever host surface happened to call it. */
|
||||
export function ContribRender({ render }: ContribRenderProps) {
|
||||
return createElement(render)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { useState } from 'react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { registry } from '../registry'
|
||||
|
||||
import { Slot } from './slot'
|
||||
|
||||
const disposers: Array<() => void> = []
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
disposers.splice(0).forEach(dispose => dispose())
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('Slot contribution render boundary', () => {
|
||||
it('hot-mounts and replaces hook-using plugin renders without changing the host hook order', () => {
|
||||
const area = 'test.plugin-hook-boundary'
|
||||
const id = 'stateful-plugin'
|
||||
|
||||
function FirstPluginRender() {
|
||||
const [count, setCount] = useState(0)
|
||||
|
||||
return <button onClick={() => setCount(value => value + 1)}>first:{count}</button>
|
||||
}
|
||||
|
||||
function ReloadedPluginRender() {
|
||||
const [count, setCount] = useState(10)
|
||||
const [suffix] = useState('ready')
|
||||
|
||||
return (
|
||||
<button onClick={() => setCount(value => value + 1)}>
|
||||
reloaded:{count}:{suffix}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
render(<Slot area={area} />)
|
||||
|
||||
act(() => {
|
||||
disposers.push(registry.register({ area, id, render: FirstPluginRender, source: 'disk' }))
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'first:0' }))
|
||||
expect(screen.getByRole('button', { name: 'first:1' })).toBeTruthy()
|
||||
|
||||
act(() => {
|
||||
disposers.push(registry.register({ area, id, render: ReloadedPluginRender, source: 'disk' }))
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'reloaded:10:ready' }))
|
||||
expect(screen.getByRole('button', { name: 'reloaded:11:ready' })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('lets the contribution boundary contain a render-time plugin failure', () => {
|
||||
const area = 'test.plugin-error-boundary'
|
||||
const id = 'broken-plugin'
|
||||
|
||||
vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
||||
render(<Slot area={area} />)
|
||||
|
||||
act(() => {
|
||||
disposers.push(
|
||||
registry.register({
|
||||
area,
|
||||
id,
|
||||
render: () => {
|
||||
throw new Error('plugin render failed')
|
||||
},
|
||||
source: 'disk'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
expect(screen.getByRole('button', { name: id })).toBeTruthy()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,26 @@
|
||||
import { ContribBoundary, ContribRender } 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 && <ContribRender render={c.render} />}
|
||||
</ContribBoundary>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
@@ -0,0 +1,431 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { HermesReadDirResult } from '@/global'
|
||||
import type * as HermesModule from '@/hermes'
|
||||
|
||||
import { $pluginRecords, publishPlugin, setPluginEnabled } from './plugins-store'
|
||||
import { discoverRuntimePlugins, loadRuntimePlugin, watchRuntimePlugins } from './runtime-loader'
|
||||
|
||||
// getStatus would supply the connected backend's hermes_home — a REMOTE path in
|
||||
// remote mode. The disk scanner must NOT derive the plugin root from it (#66899).
|
||||
const getStatus = vi.fn(async () => ({ hermes_home: '/remote/box/.hermes' }))
|
||||
|
||||
vi.mock('@/hermes', async importActual => ({
|
||||
...(await importActual<typeof HermesModule>()),
|
||||
getStatus: () => getStatus()
|
||||
}))
|
||||
|
||||
const desktopPluginsRoot = vi.fn<() => Promise<string>>()
|
||||
const agentPluginsRoot = vi.fn<() => Promise<string>>()
|
||||
const readDir = vi.fn<(path: string) => Promise<HermesReadDirResult>>()
|
||||
const readFileText = vi.fn<(path: string) => Promise<{ text: string; truncated?: boolean }>>()
|
||||
const readPluginSource = vi.fn<(path: string) => Promise<{ text: string; truncated?: boolean }>>()
|
||||
const watchDirectory = vi.fn<(path: string) => Promise<{ id: string }>>()
|
||||
const watchPreviewFile = vi.fn<(path: string) => Promise<{ id: string }>>()
|
||||
const stopPreviewFileWatch = vi.fn<(id: string) => Promise<boolean>>()
|
||||
const onPreviewFileChanged = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
desktopPluginsRoot.mockReset()
|
||||
agentPluginsRoot.mockReset()
|
||||
readDir.mockReset()
|
||||
readFileText.mockReset()
|
||||
readPluginSource.mockReset()
|
||||
watchDirectory.mockReset()
|
||||
watchPreviewFile.mockReset()
|
||||
stopPreviewFileWatch.mockReset()
|
||||
stopPreviewFileWatch.mockResolvedValue(true)
|
||||
onPreviewFileChanged.mockReset()
|
||||
getStatus.mockClear()
|
||||
;(window as unknown as { hermesDesktop: unknown }).hermesDesktop = {
|
||||
agentPluginsRoot,
|
||||
desktopPluginsRoot,
|
||||
onPreviewFileChanged,
|
||||
readDir,
|
||||
readFileText,
|
||||
stopPreviewFileWatch,
|
||||
watchDirectory,
|
||||
watchPreviewFile
|
||||
}
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
delete (window as unknown as { hermesDesktop?: unknown }).hermesDesktop
|
||||
})
|
||||
|
||||
describe('scanDiskPlugins (#66899)', () => {
|
||||
it('scans the Electron-resolved local roots, never the backend hermes_home', async () => {
|
||||
desktopPluginsRoot.mockResolvedValue('/local/.hermes/desktop-plugins')
|
||||
agentPluginsRoot.mockResolvedValue('/local/.hermes/plugins')
|
||||
readDir.mockResolvedValue({ entries: [] })
|
||||
|
||||
await discoverRuntimePlugins()
|
||||
|
||||
expect(desktopPluginsRoot).toHaveBeenCalled()
|
||||
expect(readDir).toHaveBeenCalledWith('/local/.hermes/desktop-plugins')
|
||||
expect(readDir).toHaveBeenCalledWith('/local/.hermes/plugins')
|
||||
// The remote backend's hermes_home must never feed the local plugin scan.
|
||||
expect(getStatus).not.toHaveBeenCalled()
|
||||
expect(readDir).not.toHaveBeenCalledWith('/remote/box/.hermes/desktop-plugins')
|
||||
})
|
||||
|
||||
it('no-ops when the resolvers yield no local root', async () => {
|
||||
desktopPluginsRoot.mockResolvedValue('')
|
||||
agentPluginsRoot.mockResolvedValue('')
|
||||
|
||||
await discoverRuntimePlugins()
|
||||
|
||||
expect(readDir).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('treats a package without a Desktop half as metadata, not a throwing file read', async () => {
|
||||
desktopPluginsRoot.mockResolvedValue('/local/.hermes/desktop-plugins')
|
||||
agentPluginsRoot.mockResolvedValue('/local/.hermes/plugins')
|
||||
readDir.mockImplementation(async dir => {
|
||||
if (dir === '/local/.hermes/plugins') {
|
||||
return { entries: [{ isDirectory: true, name: 'my-feature', path: '/local/.hermes/plugins/my-feature' }] }
|
||||
}
|
||||
|
||||
if (dir === '/local/.hermes/plugins/my-feature') {
|
||||
return {
|
||||
entries: [{ isDirectory: false, name: 'plugin.yaml', path: '/local/.hermes/plugins/my-feature/plugin.yaml' }]
|
||||
}
|
||||
}
|
||||
|
||||
return { entries: [] }
|
||||
})
|
||||
|
||||
await discoverRuntimePlugins()
|
||||
|
||||
expect(readDir).toHaveBeenCalledWith('/local/.hermes/plugins/my-feature')
|
||||
expect(readDir).not.toHaveBeenCalledWith('/local/.hermes/plugins/my-feature/desktop')
|
||||
expect(readFileText).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('a DIRECTORY named plugin.js is not a plugin entry (metadata walk rejects it)', async () => {
|
||||
desktopPluginsRoot.mockResolvedValue('/local/.hermes/desktop-plugins')
|
||||
agentPluginsRoot.mockResolvedValue('')
|
||||
readDir.mockImplementation(async dir => {
|
||||
if (dir === '/local/.hermes/desktop-plugins') {
|
||||
return { entries: [{ isDirectory: true, name: 'odd', path: '/local/.hermes/desktop-plugins/odd' }] }
|
||||
}
|
||||
|
||||
if (dir === '/local/.hermes/desktop-plugins/odd') {
|
||||
// A folder literally named plugin.js — must resolve to "no entry".
|
||||
return {
|
||||
entries: [{ isDirectory: true, name: 'plugin.js', path: '/local/.hermes/desktop-plugins/odd/plugin.js' }]
|
||||
}
|
||||
}
|
||||
|
||||
return { entries: [] }
|
||||
})
|
||||
|
||||
await discoverRuntimePlugins()
|
||||
|
||||
expect(readFileText).not.toHaveBeenCalled()
|
||||
expect($pluginRecords.get().odd).toBeUndefined()
|
||||
})
|
||||
|
||||
it('still scans the standalone root when agentPluginsRoot is absent (older shell)', async () => {
|
||||
delete (window.hermesDesktop as unknown as { agentPluginsRoot?: unknown }).agentPluginsRoot
|
||||
desktopPluginsRoot.mockResolvedValue('/local/.hermes/desktop-plugins')
|
||||
readDir.mockResolvedValue({ entries: [] })
|
||||
|
||||
await discoverRuntimePlugins()
|
||||
|
||||
expect(readDir).toHaveBeenCalledWith('/local/.hermes/desktop-plugins')
|
||||
expect(readDir).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('loads a unified desktop half OPT-IN: inventoried but not activated by default', async () => {
|
||||
desktopPluginsRoot.mockResolvedValue('/local/.hermes/desktop-plugins')
|
||||
agentPluginsRoot.mockResolvedValue('/local/.hermes/plugins')
|
||||
let desktopEntryPresent = true
|
||||
|
||||
readDir.mockImplementation(async dir => {
|
||||
if (dir === '/local/.hermes/plugins') {
|
||||
return { entries: [{ isDirectory: true, name: 'uni', path: '/local/.hermes/plugins/uni' }] }
|
||||
}
|
||||
|
||||
if (dir === '/local/.hermes/plugins/uni') {
|
||||
return {
|
||||
entries: [{ isDirectory: true, name: 'desktop', path: '/local/.hermes/plugins/uni/desktop' }]
|
||||
}
|
||||
}
|
||||
|
||||
if (dir === '/local/.hermes/plugins/uni/desktop') {
|
||||
return {
|
||||
entries: desktopEntryPresent
|
||||
? [
|
||||
{
|
||||
isDirectory: false,
|
||||
name: 'plugin.js',
|
||||
path: '/local/.hermes/plugins/uni/desktop/plugin.js'
|
||||
}
|
||||
]
|
||||
: []
|
||||
}
|
||||
}
|
||||
|
||||
return { entries: [] }
|
||||
})
|
||||
|
||||
const register = vi.fn()
|
||||
|
||||
;(globalThis as unknown as { __uniRegister: unknown }).__uniRegister = register
|
||||
readFileText.mockResolvedValue({
|
||||
text: 'export default { id: "uni", register: globalThis.__uniRegister }'
|
||||
})
|
||||
watchPreviewFile.mockResolvedValue({ id: 'w-uni' })
|
||||
|
||||
// The loader evaluates plugins via blob-URL import(), which vite's module
|
||||
// runner can't resolve in tests — reroute to a data: URL, which node's
|
||||
// native ESM loader handles.
|
||||
const createObjectURL = vi
|
||||
.spyOn(URL, 'createObjectURL')
|
||||
.mockImplementation(
|
||||
blob =>
|
||||
`data:text/javascript;base64,${Buffer.from((blob as unknown as { parts: string[] }).parts.join('')).toString('base64')}`
|
||||
)
|
||||
|
||||
const revokeObjectURL = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => undefined)
|
||||
const RealBlob = globalThis.Blob
|
||||
vi.stubGlobal(
|
||||
'Blob',
|
||||
class {
|
||||
parts: string[]
|
||||
constructor(parts: string[]) {
|
||||
this.parts = parts
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
try {
|
||||
await discoverRuntimePlugins()
|
||||
|
||||
// Inventoried for Settings → Plugins, but the root's opt-in posture wins:
|
||||
// ~/.hermes/plugins stays installed-but-inert until the user toggles it.
|
||||
expect($pluginRecords.get().uni).toMatchObject({ kind: 'disk', status: 'disabled' })
|
||||
expect(register).not.toHaveBeenCalled()
|
||||
|
||||
// The user's explicit enable still activates it.
|
||||
await setPluginEnabled('uni', true)
|
||||
expect(register).toHaveBeenCalledTimes(1)
|
||||
expect($pluginRecords.get().uni.status).toBe('loaded')
|
||||
|
||||
// Removing only desktop/plugin.js (while the Python package folder
|
||||
// remains) unloads the previous Desktop registration instead of leaving
|
||||
// a live ghost behind.
|
||||
desktopEntryPresent = false
|
||||
await discoverRuntimePlugins()
|
||||
expect($pluginRecords.get().uni).toBeUndefined()
|
||||
expect(stopPreviewFileWatch).toHaveBeenCalledWith('w-uni')
|
||||
} finally {
|
||||
createObjectURL.mockRestore()
|
||||
revokeObjectURL.mockRestore()
|
||||
vi.stubGlobal('Blob', RealBlob)
|
||||
delete (globalThis as unknown as { __uniRegister?: unknown }).__uniRegister
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('watchRuntimePlugins dir watch (#66899)', () => {
|
||||
it('watches both Electron-resolved local roots, never the backend hermes_home', async () => {
|
||||
desktopPluginsRoot.mockResolvedValue('/local/.hermes/desktop-plugins')
|
||||
agentPluginsRoot.mockResolvedValue('/local/.hermes/plugins')
|
||||
readDir.mockResolvedValue({ entries: [] })
|
||||
watchDirectory.mockResolvedValue({ id: 'watch-1' })
|
||||
|
||||
watchRuntimePlugins()
|
||||
// Drain the async scan + startDirWatches chains.
|
||||
await vi.waitFor(() => expect(watchDirectory).toHaveBeenCalledTimes(2))
|
||||
|
||||
expect(watchDirectory).toHaveBeenCalledWith('/local/.hermes/desktop-plugins')
|
||||
expect(watchDirectory).toHaveBeenCalledWith('/local/.hermes/plugins')
|
||||
expect(watchDirectory).not.toHaveBeenCalledWith('/remote/box/.hermes/desktop-plugins')
|
||||
expect(getStatus).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('plugin source reads (512 KiB preview-cap bug)', () => {
|
||||
const blobToDataUrl = () => {
|
||||
const createObjectURL = vi
|
||||
.spyOn(URL, 'createObjectURL')
|
||||
.mockImplementation(
|
||||
blob =>
|
||||
`data:text/javascript;base64,${Buffer.from((blob as unknown as { parts: string[] }).parts.join('')).toString('base64')}`
|
||||
)
|
||||
|
||||
const revokeObjectURL = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => undefined)
|
||||
const RealBlob = globalThis.Blob
|
||||
vi.stubGlobal(
|
||||
'Blob',
|
||||
class {
|
||||
parts: string[]
|
||||
constructor(parts: string[]) {
|
||||
this.parts = parts
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
return () => {
|
||||
createObjectURL.mockRestore()
|
||||
revokeObjectURL.mockRestore()
|
||||
vi.stubGlobal('Blob', RealBlob)
|
||||
}
|
||||
}
|
||||
|
||||
/** Two-level standalone-root listing the metadata-walk probe needs:
|
||||
* the root lists the package folder, the folder lists plugin.js. */
|
||||
const standaloneRootWith = (name: string) => {
|
||||
const folder = `/local/.hermes/desktop-plugins/${name}`
|
||||
|
||||
readDir.mockImplementation(async dir => {
|
||||
if (dir === '/local/.hermes/desktop-plugins') {
|
||||
return { entries: [{ isDirectory: true, name, path: folder }] }
|
||||
}
|
||||
|
||||
if (dir === folder) {
|
||||
return { entries: [{ isDirectory: false, name: 'plugin.js', path: `${folder}/plugin.js` }] }
|
||||
}
|
||||
|
||||
return { entries: [] }
|
||||
})
|
||||
}
|
||||
|
||||
it('loads the full source via readPluginSource when the shell offers it', async () => {
|
||||
;(window.hermesDesktop as unknown as { readPluginSource: unknown }).readPluginSource = readPluginSource
|
||||
desktopPluginsRoot.mockResolvedValue('/local/.hermes/desktop-plugins')
|
||||
agentPluginsRoot.mockResolvedValue('')
|
||||
standaloneRootWith('big')
|
||||
// The preview read would truncate this source — it must never be used.
|
||||
readFileText.mockResolvedValue({ text: '// first 512 KiB only', truncated: true })
|
||||
|
||||
const register = vi.fn()
|
||||
|
||||
;(globalThis as unknown as { __bigRegister: unknown }).__bigRegister = register
|
||||
readPluginSource.mockResolvedValue({
|
||||
text: 'export default { id: "big", register: globalThis.__bigRegister }'
|
||||
})
|
||||
watchPreviewFile.mockResolvedValue({ id: 'w-big' })
|
||||
|
||||
const restore = blobToDataUrl()
|
||||
|
||||
try {
|
||||
await discoverRuntimePlugins()
|
||||
|
||||
// The EVALUATED source came from the full read, not the truncated preview.
|
||||
expect(readPluginSource).toHaveBeenCalledWith('/local/.hermes/desktop-plugins/big/plugin.js')
|
||||
expect(register).toHaveBeenCalledTimes(1)
|
||||
expect($pluginRecords.get().big).toMatchObject({ kind: 'disk', status: 'loaded' })
|
||||
} finally {
|
||||
restore()
|
||||
delete (globalThis as unknown as { __bigRegister?: unknown }).__bigRegister
|
||||
}
|
||||
})
|
||||
|
||||
it('older shell without readPluginSource: a truncated preview read fails LOUDLY, never evaluates', async () => {
|
||||
desktopPluginsRoot.mockResolvedValue('/local/.hermes/desktop-plugins')
|
||||
agentPluginsRoot.mockResolvedValue('')
|
||||
standaloneRootWith('huge')
|
||||
// 512 KiB window of a larger file — parses fine, but is NOT the plugin.
|
||||
readFileText.mockResolvedValue({
|
||||
text: 'export default { id: "huge", register: () => { throw new Error("must never evaluate") } }',
|
||||
truncated: true
|
||||
})
|
||||
watchPreviewFile.mockResolvedValue({ id: 'w-huge' })
|
||||
|
||||
const restore = blobToDataUrl()
|
||||
|
||||
try {
|
||||
await discoverRuntimePlugins()
|
||||
|
||||
// No live plugin — an error inventory row names the folder instead.
|
||||
expect($pluginRecords.get().huge).toMatchObject({
|
||||
kind: 'disk',
|
||||
status: 'error',
|
||||
file: '/local/.hermes/desktop-plugins/huge/plugin.js'
|
||||
})
|
||||
expect($pluginRecords.get().huge.error).toMatch(/512 KiB/)
|
||||
} finally {
|
||||
restore()
|
||||
}
|
||||
})
|
||||
|
||||
it('older shell, small plugin (not truncated): still loads through readFileText', async () => {
|
||||
desktopPluginsRoot.mockResolvedValue('/local/.hermes/desktop-plugins')
|
||||
agentPluginsRoot.mockResolvedValue('')
|
||||
standaloneRootWith('small')
|
||||
|
||||
const register = vi.fn()
|
||||
|
||||
;(globalThis as unknown as { __smallRegister: unknown }).__smallRegister = register
|
||||
readFileText.mockResolvedValue({
|
||||
text: 'export default { id: "small", register: globalThis.__smallRegister }'
|
||||
})
|
||||
watchPreviewFile.mockResolvedValue({ id: 'w-small' })
|
||||
|
||||
const restore = blobToDataUrl()
|
||||
|
||||
try {
|
||||
await discoverRuntimePlugins()
|
||||
|
||||
expect(register).toHaveBeenCalledTimes(1)
|
||||
expect($pluginRecords.get().small).toMatchObject({ kind: 'disk', status: 'loaded' })
|
||||
} finally {
|
||||
restore()
|
||||
delete (globalThis as unknown as { __smallRegister?: unknown }).__smallRegister
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('bundled-shadowed disk copies', () => {
|
||||
it('skips a disk copy of a bundled plugin but publishes a visible inventory row', async () => {
|
||||
// The bundled twin is already registered (build-time glob).
|
||||
publishPlugin({ id: 'hermes-bots', name: 'Bot Mode', kind: 'bundled', status: 'loaded' })
|
||||
|
||||
// Same blob→data: URL reroute as the opt-in test above.
|
||||
const createObjectURL = vi
|
||||
.spyOn(URL, 'createObjectURL')
|
||||
.mockImplementation(
|
||||
blob =>
|
||||
`data:text/javascript;base64,${Buffer.from((blob as unknown as { parts: string[] }).parts.join('')).toString('base64')}`
|
||||
)
|
||||
|
||||
const revokeObjectURL = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => undefined)
|
||||
const RealBlob = globalThis.Blob
|
||||
vi.stubGlobal(
|
||||
'Blob',
|
||||
class {
|
||||
parts: string[]
|
||||
constructor(parts: string[]) {
|
||||
this.parts = parts
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
try {
|
||||
const id = await loadRuntimePlugin(
|
||||
'export default { id: "hermes-bots", name: "Bot Mode", register() {} }',
|
||||
'hermes-bots',
|
||||
{ file: '/local/.hermes/desktop-plugins/hermes-bots/plugin.js' }
|
||||
)
|
||||
|
||||
// Skipped — the bundled copy stays the only live registration...
|
||||
expect(id).toBeNull()
|
||||
expect($pluginRecords.get()['hermes-bots']).toMatchObject({ kind: 'bundled', status: 'loaded' })
|
||||
|
||||
// ...but the stale folder is DISCOVERABLE: an inventory row names it,
|
||||
// carries its path (reveal/delete affordance), and can never activate.
|
||||
expect($pluginRecords.get()['hermes-bots:disk-shadowed']).toMatchObject({
|
||||
kind: 'disk',
|
||||
status: 'disabled',
|
||||
file: '/local/.hermes/desktop-plugins/hermes-bots/plugin.js'
|
||||
})
|
||||
} finally {
|
||||
createObjectURL.mockRestore()
|
||||
revokeObjectURL.mockRestore()
|
||||
vi.stubGlobal('Blob', RealBlob)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,606 @@
|
||||
/**
|
||||
* 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 the two on-disk doors — `<hermes home>/desktop-plugins/<name>/plugin.js`
|
||||
* and the unified agent-plugin half `<hermes home>/plugins/<name>/desktop/
|
||||
* plugin.js` — the doors 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 { installPluginSdk, sdkImportMap } from '@/sdk/runtime'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
|
||||
import { createPluginContext, type HermesPlugin } from './plugin'
|
||||
import { $pluginRecords, dropPlugin, pluginActive, type PluginKind, publishPlugin } from './plugins-store'
|
||||
|
||||
interface LoadOptions {
|
||||
/** Root-level default-enable CAP: `false` ships the plugin opt-in (inventory
|
||||
* row, off until the user toggles) even if the plugin says otherwise. The
|
||||
* unified agent-plugin root sets this so `~/.hermes/plugins` keeps its
|
||||
* installed-but-inert posture (GHSA-mcfc-hp25-cjv7) on the desktop side too. */
|
||||
defaultEnabled?: boolean
|
||||
/** 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`)
|
||||
}
|
||||
|
||||
// A disk/runtime copy of a plugin that now ships BUNDLED (e.g. a
|
||||
// standalone install of hermes-bots predating its adoption in-tree) must
|
||||
// not register a second time: contributions would double up and the two
|
||||
// copies would fight over storage. The bundled copy wins; the disk copy
|
||||
// is skipped — but VISIBLY: a silent skip left the stale folder
|
||||
// undiscoverable while (on shells without the bundled twin) the same
|
||||
// folder actively breaks the feature it shadows. The inventory row
|
||||
// carries the file path so Settings → Plugins can reveal it for deletion.
|
||||
if ($pluginRecords.get()[plugin.id]?.kind === 'bundled') {
|
||||
console.info(`[plugins] ${origin} skipped — "${plugin.id}" already ships bundled with the app`)
|
||||
publishPlugin({
|
||||
id: `${plugin.id}:disk-shadowed`,
|
||||
name: `${plugin.name ?? plugin.id} (stale disk copy)`,
|
||||
description: `Shadowed by the bundled "${plugin.id}" plugin — this folder is no longer used and can be deleted.`,
|
||||
kind: options.kind ?? 'disk',
|
||||
file: options.file,
|
||||
status: 'disabled'
|
||||
})
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const record = {
|
||||
id: plugin.id,
|
||||
name: plugin.name ?? plugin.id,
|
||||
description: plugin.description,
|
||||
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. A root-level
|
||||
// `defaultEnabled: false` caps the plugin's own default: the user's explicit
|
||||
// enable still wins, a plugin can't self-enable past its root's posture.
|
||||
if (pluginActive(plugin.id, (plugin.defaultEnabled ?? true) && (options.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 — TWO roots, one pipeline:
|
||||
// - `<hermes home>/desktop-plugins/<name>/plugin.js` — the standalone door
|
||||
// (agent- or user-written desktop-only plugins);
|
||||
// - `<hermes home>/plugins/<name>/desktop/plugin.js` — the desktop HALF of a
|
||||
// unified agent-plugin package: the same installed folder that carries the
|
||||
// Python plugin (plugin.yaml / plugin.json) ships its desktop UI beside it,
|
||||
// so one feature is ONE install instead of two co-dependent plugins.
|
||||
// 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;
|
||||
// - each root is fs-watched too (watchDirectory IPC), so new folders load +
|
||||
// removed ones unload on the change tick; older Electron shells without
|
||||
// watchDirectory fall back to the slow visible-tab poll.
|
||||
// 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 DiskRoot {
|
||||
/** Root-level enable posture, forwarded to the loader (see LoadOptions). */
|
||||
defaultEnabled?: boolean
|
||||
dir: string
|
||||
/** Path segments below each scanned package folder. Discovery walks
|
||||
* directory metadata to this file instead of throwing a content read for
|
||||
* every ordinary package that has no Desktop half. */
|
||||
entrySegments: readonly string[]
|
||||
}
|
||||
|
||||
/** Both scan roots, resolved fresh each pass (Electron-local, never the
|
||||
* backend's hermes_home — #66899). `agentPluginsRoot` is optional: older
|
||||
* shells predate it and the unified-package half simply doesn't scan. */
|
||||
async function diskRoots(): Promise<DiskRoot[]> {
|
||||
const desktop = window.hermesDesktop
|
||||
|
||||
if (!desktop) {
|
||||
return []
|
||||
}
|
||||
|
||||
const roots: DiskRoot[] = []
|
||||
const standalone = await desktop.desktopPluginsRoot?.()
|
||||
|
||||
if (standalone) {
|
||||
roots.push({ dir: standalone, entrySegments: ['plugin.js'] })
|
||||
}
|
||||
|
||||
const unified = await desktop.agentPluginsRoot?.()
|
||||
|
||||
if (unified) {
|
||||
// Opt-in by default: `~/.hermes/plugins` is installed-but-inert until the
|
||||
// user allowlists the Python half (plugins.enabled), so the desktop half
|
||||
// matches that posture — inventoried in Settings → Plugins, off until
|
||||
// toggled. The standalone desktop-plugins door keeps its default-on trust.
|
||||
roots.push({ defaultEnabled: false, dir: unified, entrySegments: ['desktop', 'plugin.js'] })
|
||||
}
|
||||
|
||||
return roots
|
||||
}
|
||||
|
||||
interface DiskPlugin {
|
||||
/** Root posture, forwarded on every (re)load of this entry. */
|
||||
defaultEnabled?: boolean
|
||||
file: string
|
||||
/** Loaded plugin id (null while broken — kept so a fixing save reloads). */
|
||||
id: null | string
|
||||
/** Origin label (folder name) — the toast/inventory name for load errors. */
|
||||
origin: string
|
||||
watchId: null | string
|
||||
}
|
||||
|
||||
/** Live disk plugins keyed by ENTRY FILE path — unique across both roots
|
||||
* (folder names alone can collide between them). */
|
||||
const disk = new Map<string, DiskPlugin>()
|
||||
let watching = false
|
||||
let scanning = false
|
||||
|
||||
/** Drop a folder-named error record — unless that name is the live plugin id
|
||||
* of ANOTHER disk entry (two roots can carry same-named folders; a broken one
|
||||
* must not clobber its healthy namesake's inventory row). */
|
||||
function dropOriginRecord(origin: string, except: DiskPlugin): void {
|
||||
for (const other of disk.values()) {
|
||||
if (other !== except && other.id === origin) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
dropPlugin(origin)
|
||||
}
|
||||
|
||||
/** A plugin source that could not be read in FULL. Evaluating a truncated
|
||||
* file is never acceptable — half a module can still parse. */
|
||||
class PluginSourceOversizeError extends Error {}
|
||||
|
||||
/** Read a plugin entry file in full. Prefers the dedicated readPluginSource
|
||||
* IPC (16 MiB cap, no truncation). Older shells predate it and only offer
|
||||
* the preview read, which silently truncates at 512 KiB — there the read
|
||||
* fails loudly instead of handing a partial file to the evaluator. */
|
||||
async function readPluginSourceText(file: string): Promise<string> {
|
||||
const desktop = window.hermesDesktop!
|
||||
|
||||
if (desktop.readPluginSource) {
|
||||
return (await desktop.readPluginSource(file)).text
|
||||
}
|
||||
|
||||
const result = await desktop.readFileText(file)
|
||||
|
||||
if (result.truncated) {
|
||||
throw new PluginSourceOversizeError(
|
||||
"plugin.js exceeds this shell's 512 KiB read limit — update Hermes Desktop to load larger plugins"
|
||||
)
|
||||
}
|
||||
|
||||
return result.text
|
||||
}
|
||||
|
||||
/** Returns false when the entry file could not be read (vanished mid-read) so
|
||||
* the caller can reconcile/unload the registration instead of retaining a
|
||||
* live ghost for a missing entry. */
|
||||
async function loadDiskPlugin(entry: DiskPlugin): Promise<boolean> {
|
||||
const prevId = entry.id
|
||||
|
||||
try {
|
||||
const text = await readPluginSourceText(entry.file)
|
||||
|
||||
const id = await loadRuntimePlugin(text, entry.origin, {
|
||||
defaultEnabled: entry.defaultEnabled,
|
||||
file: entry.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)
|
||||
}
|
||||
|
||||
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 !== entry.origin) {
|
||||
dropOriginRecord(entry.origin, entry)
|
||||
}
|
||||
|
||||
return true
|
||||
} catch (error) {
|
||||
// An oversize source is a REAL failure the user must see (the silent
|
||||
// shape was the bug: a truncated file evaluated as a syntax error, or
|
||||
// worse, as half a plugin). It is a completed read of an existing file,
|
||||
// so report it and keep the registration (true) — everything else is a
|
||||
// file vanishing mid-read, where false lets the caller reconcile/unload.
|
||||
if (error instanceof PluginSourceOversizeError) {
|
||||
console.error(`[plugins] ${entry.origin}: ${error.message}`)
|
||||
notifyError(error, `Plugin "${entry.origin}" failed to load`)
|
||||
publishPlugin({
|
||||
id: entry.origin,
|
||||
name: entry.origin,
|
||||
kind: 'disk',
|
||||
file: entry.file,
|
||||
status: 'error',
|
||||
error: error.message
|
||||
})
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveDiskPluginEntry(
|
||||
desktop: Window['hermesDesktop'],
|
||||
folderPath: string,
|
||||
segments: readonly string[]
|
||||
): Promise<string | null> {
|
||||
let currentDir = folderPath
|
||||
|
||||
for (let index = 0; index < segments.length; index += 1) {
|
||||
const { entries } = await desktop.readDir(currentDir)
|
||||
const entry = entries.find(candidate => candidate.name === segments[index])
|
||||
|
||||
if (!entry) {
|
||||
return null
|
||||
}
|
||||
|
||||
const last = index === segments.length - 1
|
||||
|
||||
if (last) {
|
||||
return entry.isDirectory ? null : entry.path
|
||||
}
|
||||
|
||||
if (!entry.isDirectory) {
|
||||
return null
|
||||
}
|
||||
|
||||
currentDir = entry.path
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
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 roots = await diskRoots()
|
||||
|
||||
if (roots.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const seen = new Set<string>()
|
||||
|
||||
for (const root of roots) {
|
||||
let entries
|
||||
|
||||
try {
|
||||
;({ entries } = await desktop.readDir(root.dir))
|
||||
} catch {
|
||||
continue // Root missing (no plugins yet) — the poll/watch reconciles.
|
||||
}
|
||||
|
||||
for (const dir of entries.filter(e => e.isDirectory)) {
|
||||
let file: string | null
|
||||
|
||||
try {
|
||||
file = await resolveDiskPluginEntry(desktop, dir.path, root.entrySegments)
|
||||
} catch {
|
||||
continue // Folder changed during the metadata walk; the next tick reconciles.
|
||||
}
|
||||
|
||||
if (!file) {
|
||||
continue // Ordinary agent package with no Desktop half — not an error.
|
||||
}
|
||||
|
||||
seen.add(file)
|
||||
|
||||
if (disk.has(file)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const record: DiskPlugin = {
|
||||
defaultEnabled: root.defaultEnabled,
|
||||
file,
|
||||
id: null,
|
||||
origin: dir.name,
|
||||
watchId: null
|
||||
}
|
||||
|
||||
disk.set(file, record)
|
||||
|
||||
if (!(await loadDiskPlugin(record))) {
|
||||
disk.delete(file)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
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 [file, record] of disk) {
|
||||
if (seen.has(file)) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (record.id) {
|
||||
unloadRuntimePlugin(record.id)
|
||||
dropPlugin(record.id)
|
||||
}
|
||||
|
||||
dropOriginRecord(record.origin, record)
|
||||
|
||||
if (record.watchId) {
|
||||
void desktop.stopPreviewFileWatch(record.watchId)
|
||||
}
|
||||
|
||||
disk.delete(file)
|
||||
}
|
||||
} catch {
|
||||
// No plugin roots (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,
|
||||
* fs-watched folder reconciliation (poll fallback on older shells). Idempotent. */
|
||||
export function watchRuntimePlugins(): void {
|
||||
const desktop = window.hermesDesktop
|
||||
|
||||
if (watching || !desktop) {
|
||||
return
|
||||
}
|
||||
|
||||
watching = true
|
||||
|
||||
const dirWatchIds = new Set<string>()
|
||||
const watchedDirs = new Set<string>()
|
||||
|
||||
desktop.onPreviewFileChanged(({ id }) => {
|
||||
// Directory tick: a plugin folder appeared or vanished — reconcile.
|
||||
if (dirWatchIds.has(id)) {
|
||||
void scanDiskPlugins()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
for (const record of disk.values()) {
|
||||
if (record.watchId === id) {
|
||||
void loadDiskPlugin(record).then(readable => {
|
||||
if (!readable) {
|
||||
void scanDiskPlugins()
|
||||
}
|
||||
})
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// True only when EVERY root is fs-watched — a partially watched set keeps
|
||||
// the poll alive so unwatched roots still reconcile new/removed folders.
|
||||
const startDirWatches = async (): Promise<boolean> => {
|
||||
if (!desktop.watchDirectory) {
|
||||
return false
|
||||
}
|
||||
|
||||
const roots = await diskRoots()
|
||||
|
||||
if (roots.length === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
let all = true
|
||||
|
||||
for (const root of roots) {
|
||||
if (watchedDirs.has(root.dir)) {
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
dirWatchIds.add((await desktop.watchDirectory(root.dir)).id)
|
||||
watchedDirs.add(root.dir)
|
||||
} catch {
|
||||
// Dir missing or unwatchable — the poll covers it and retries here.
|
||||
all = false
|
||||
}
|
||||
}
|
||||
|
||||
return all
|
||||
}
|
||||
|
||||
void scanDiskPlugins()
|
||||
void startDirWatches().then(watched => {
|
||||
if (watched) {
|
||||
return
|
||||
}
|
||||
|
||||
const timer = window.setInterval(() => {
|
||||
if (document.visibilityState !== 'visible') {
|
||||
return
|
||||
}
|
||||
|
||||
void scanDiskPlugins()
|
||||
|
||||
// A root may have appeared since — upgrade to the watches and retire
|
||||
// this poll once every root is covered.
|
||||
void startDirWatches().then(upgraded => {
|
||||
if (upgraded) {
|
||||
window.clearInterval(timer)
|
||||
}
|
||||
})
|
||||
}, DISK_POLL_MS)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
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 & {})
|
||||
|
||||
/**
|
||||
* Which workspace surface the window is looking at. `'sessions'` is the classic
|
||||
* session workspace; `'bots'` means Bot Mode has one exact bot selected,
|
||||
* identified by an opaque owner key. Consumers ADAPT to this — it steers where
|
||||
* the `+` routes — but it never decides whether a pane renders.
|
||||
*/
|
||||
export type WorkspaceMode = 'sessions' | 'bots'
|
||||
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
Reference in new issue
Block a user