Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license

This commit is contained in:
yilsem committed 2026-09-05 13:26:46 +03:00
commit 03634b1ca3
11340 files changed
+3442369

No files matched your search

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