From 360b07794b6b674b78060d5552ebccc01e32a442 Mon Sep 17 00:00:00 2001 From: brooklyn! Date: Sun, 19 Jul 2026 00:02:17 -0400 Subject: [PATCH] docs(desktop): document the desktop plugin SDK (@hermes/plugin-sdk) (#67301) Add an end-to-end developer guide for extending the native Hermes Desktop app introduced in #60638: the HermesPlugin contract, PluginContext, every contribution area (panes, routes, sidebar nav, status/title bar, palette, keybinds, themes, composer, mount-scoped Contribute), the host API, the React Query + nanostores data layer, the UI kit + theme variables, the scoped ctx.rest/ctx.socket backend (plugin_api.py under /api/plugins/) and its separate enable gate, Settings/defaultEnabled/storage, bundled plugins, the security model, pitfalls, and a full reference. - Register the page in the sidebar under Extending -> Plugins. - Disambiguate from the unrelated web-dashboard plugin SDK from both directions, and add a map-table row + a desktop user-guide pointer. - Fill the gaps in the agent-facing hermes-desktop-plugins skill (ctx.rest/socket + backend, React Query, defaultEnabled, Contribute) and point it at the new reference so agents know the SDK when writing addons. --- skills/hermes-desktop-plugins/SKILL.md | 26 +- .../developer-guide/desktop-plugin-sdk.md | 640 ++++++++++++++++++ website/docs/developer-guide/plugins/index.md | 2 + website/docs/user-guide/desktop.md | 11 + .../features/extending-the-dashboard.md | 4 + website/sidebars.ts | 1 + 6 files changed, 682 insertions(+), 2 deletions(-) create mode 100644 website/docs/developer-guide/desktop-plugin-sdk.md diff --git a/skills/hermes-desktop-plugins/SKILL.md b/skills/hermes-desktop-plugins/SKILL.md index 277bd0d10cea..71af411b7052 100644 --- a/skills/hermes-desktop-plugins/SKILL.md +++ b/skills/hermes-desktop-plugins/SKILL.md @@ -15,8 +15,12 @@ metadata: Write plugins for the Hermes desktop app: statusbar items, layout panes, command-palette commands, keybinds, routes, and themes. A plugin is a single plain-JavaScript ESM file the app loads at runtime — no build step, no repo -changes. This skill does not cover backend plugins (`~/.hermes/plugins/`); -those are Python and documented separately. +changes. A plugin can also talk to its own Python backend namespace +(`ctx.rest`/`ctx.socket` → `/api/plugins/`); the general Python plugin +system (`~/.hermes/plugins/`) is otherwise documented separately. + +Full human reference (every export, area payloads, backend, security): +`website/docs/developer-guide/desktop-plugin-sdk.md`. ## When to Use @@ -79,6 +83,24 @@ The ONLY import surface is `@hermes/plugin-sdk` (plus `react` / (renders below Artifacts, lights up at the route) — and/or a `PALETTE_AREA` command calling `host.navigate('/my-page')`. - `ctx.storage.get/set/remove` — persistence namespaced to your plugin. +- Data: `useQuery`/`useMutation`/`useQueryClient`/`queryClient` (the app's ONE + React Query client — cache, dedupe, `refetchInterval`, invalidate like core; + never hand-roll a poll loop), plus `atom`/`computed` for plugin-local state. +- Backend: if the plugin ships a Python `plugin_api.py` (under + `~/.hermes/plugins//dashboard/`, manifest `"api": "plugin_api.py"`), reach + it with `ctx.rest('/path', { method?, body?, timeoutMs? })` and its live twin + `ctx.socket('/events', onMessage)` — both scoped to `/api/plugins/` by + construction (traversal rejected). `ctx.socket` is a **no-op on OAuth + remotes**, so always keep a polling fallback. The Python backend is imported + only when the plugin is in `plugins.enabled` in `config.yaml` (separate from + the in-app enable toggle). For gateway-wide data use `host.request` / + `host.onEvent` instead. +- `Contribute` (mount-scoped): render `jsx(Contribute, { area, id, children })` + inside a component so page-owned chrome (e.g. a titlebar control in + `TITLEBAR_AREAS.center`) leaves when the page unmounts — `ctx.register` is for + permanent contributions. +- `defaultEnabled: false` on the default export ships an opt-in plugin: it + inventories in Settings → Plugins, off until the user flips it on. - Users manage plugins in Settings → Plugins (enable/disable live, reveal folder). A disabled plugin stays disabled across restarts — don't fight it; the user turned you off. diff --git a/website/docs/developer-guide/desktop-plugin-sdk.md b/website/docs/developer-guide/desktop-plugin-sdk.md new file mode 100644 index 000000000000..efc4fb3e83ce --- /dev/null +++ b/website/docs/developer-guide/desktop-plugin-sdk.md @@ -0,0 +1,640 @@ +--- +sidebar_label: "Desktop Plugin SDK" +title: "Desktop Plugin SDK (@hermes/plugin-sdk)" +description: "Extend the native Hermes Desktop app — panes, pages, sidebar nav, status bar, palette commands, keybinds, themes, and a scoped backend namespace, with one import and no build step." +--- + +# Desktop Plugin SDK + +The native [Hermes Desktop](/user-guide/desktop) app is contribution-driven: every +surface in the window — panes, routes, sidebar nav, status-bar items, palette +entries, keybinds, themes — registers into one central registry. Core registers +its surfaces exactly the way a plugin does, so the plugin story is the real one, +not a bolted-on afterthought. + +A **desktop plugin** is a single ESM file that default-exports a `HermesPlugin`. +It imports one module — `@hermes/plugin-sdk` — and gets everything: the app's +live state, the gateway JSON-RPC door, a scoped REST/socket backend namespace, +React Query, and the app's own UI kit so plugin UI looks native by default. No +repo clone, no `npm run build`, no patching app source. Drop the file in +`$HERMES_HOME/desktop-plugins//plugin.js` and the app loads it within seconds +and hot-reloads every save. + +:::warning This is not the web-dashboard plugin SDK +"Plugin" means several unrelated things across Hermes. This page is the **native +desktop app** (`hermes desktop`) SDK — the `@hermes/plugin-sdk` module and +`$HERMES_HOME/desktop-plugins/`. The **web dashboard** (`hermes dashboard`) has +its own, unrelated plugin system on `window.__HERMES_PLUGIN_SDK__` with a +`manifest.json` — documented at +[Extending the Dashboard](/user-guide/features/extending-the-dashboard). Python +CLI/gateway plugins are documented at [Build a Hermes Plugin](/developer-guide/plugins). +The three do not share code, APIs, or delivery. Only the backend `plugin_api.py` +namespace (`/api/plugins/`) is shared between the desktop and dashboard SDKs. +::: + +## Mental model + +The SDK follows the VS Code module model. A plugin author imports exactly one +module and never touches app internals (they are lint-fenced out of a bundled +plugin, and fail to resolve in a disk plugin). Capability comes in tiers: + +- **`host.state.*`** — readonly views over the app's live state (nanostore + atoms): active session, cwd, gateway status, model, profile, viewport. +- **`host.*` actions** — curated safe verbs: toast, navigate, tail logs, + restart the gateway, subscribe to the gateway event stream. +- **`host.request`** — the gateway JSON-RPC door: sessions, config, skills, + cron — everything the app itself calls. +- **`ctx.rest` / `ctx.socket`** — your plugin's own backend namespace + (`/api/plugins/`) if you ship a `plugin_api.py`. +- **`ui.*`** — the design language: the app's real components, theme variables, + icons, and formatters, so your UI matches the app pixel-for-pixel. + +## Two delivery modes + +| Mode | Where | Who | Build step | +|------|-------|-----|------------| +| **Disk** (recommended) | `$HERMES_HOME/desktop-plugins//plugin.js` | users, agents | none — plain ESM, loaded uncompiled | +| **Bundled** | `apps/desktop/src/plugins//plugin.tsx` | in-tree, shipped with the app | the app's own Vite build | + +Both take the same `HermesPlugin` contract, appear in **Settings → Plugins**, and +enable/disable live. Everything on this page is written against the disk door +(what you and the agent write); [Bundled plugins](#bundled-plugins) notes the two +differences. No desktop plugins ship in the core tree today — reference demos +live in the companion +[`hermes-example-plugins`](https://github.com/NousResearch/hermes-example-plugins) +repo. + +## Quick start — your first plugin + +Create `$HERMES_HOME/desktop-plugins/hello/plugin.js` (that's `~/.hermes/...` +by default, or `~/.hermes/profiles//...` under a named profile). The folder +name must equal the plugin `id`. + +```javascript +// ~/.hermes/desktop-plugins/hello/plugin.js +import { host, haptic, useValue } from '@hermes/plugin-sdk' +import { jsx, jsxs } from 'react/jsx-runtime' + +function HelloPane() { + const gateway = useValue(host.state.gateway) + + return jsxs('div', { + className: 'flex h-full flex-col gap-2 p-3 text-sm', + children: [ + jsx('div', { className: 'font-medium', children: 'Hello, Hermes' }), + jsx('div', { + className: 'text-(--ui-text-tertiary)', + children: `gateway: ${gateway}` + }) + ] + }) +} + +export default { + id: 'hello', // must match the folder name + name: 'Hello', + register(ctx) { + ctx.register({ + id: 'pane', + area: 'panes', + title: 'hello', + data: { placement: 'right', width: '260px' }, + render: () => jsx(HelloPane, {}) + }) + ctx.register({ + id: 'chip', + area: 'statusBar.right', + order: 130, + render: () => + jsx('button', { + type: 'button', + className: 'px-1.5 text-[0.6875rem] text-(--ui-text-tertiary)', + onClick: () => { + haptic('tap') + host.notify({ kind: 'info', message: 'Hello from my plugin!' }) + }, + children: 'hello' + }) + }) + } +} +``` + +Save it. The app watches `desktop-plugins/`, loads the file within a few seconds, +and hot-reloads every later save in place. If it doesn't appear, run ⌘K → +**Reload desktop plugins**. If loading fails, a toast names the error — fix and +save again. + +:::note No JSX, no build +The disk file is loaded **uncompiled**, so JSX syntax will not parse. Write UI +with `jsx()` / `jsxs()` calls from `react/jsx-runtime` (or `React.createElement`). +The only importable specifiers are `@hermes/plugin-sdk`, `react`, and +`react/jsx-runtime` — everything else fails to resolve, on purpose. +::: + +## The plugin contract + +A plugin default-exports a `HermesPlugin`: + +```ts +interface HermesPlugin { + /** Stable slug — becomes the `plugin:` source and the id namespace. */ + id: string + /** Human name for Settings / about UI. Defaults to `id`. */ + name?: string + /** Registers on load when the user hasn't chosen (default true). Set false + * for opt-in plugins: they inventory in Settings ▸ Plugins, off until the + * user flips the switch. */ + defaultEnabled?: boolean + /** Called once at load; wire contributions through `ctx`. */ + register: (ctx: PluginContext) => void +} +``` + +`register` receives a **scoped** `PluginContext`. It never touches the registry +directly — the context auto-tags provenance (`source: 'plugin:'`) and +namespaces every contribution id (`:`), so two plugins can never +collide. + +```ts +interface PluginContext { + /** Resolved source tag, e.g. `'plugin:hello'`. */ + readonly source: string + /** Register one contribution (id namespaced, source stamped). Returns a disposer. */ + register: (c: PluginContribution) => () => void + /** Register several at once; the returned disposer removes all of them. */ + registerMany: (cs: PluginContribution[]) => () => void + /** REST to this plugin's own backend namespace (`/api/plugins/`). */ + rest: (path: string, opts?: PluginRestOptions) => Promise + /** Live WebSocket to this plugin's own namespace. Returns a disposer. */ + socket: (path: string, onMessage: (data: unknown) => void) => () => void + /** Plugin-scoped JSON persistence (keys live under `hermes.plugin..`). */ + storage: PluginStorage +} +``` + +A **contribution** is the one primitive every surface shares: + +```ts +interface Contribution { + id: string // you write the local id; the host namespaces it + area: string // WHERE it goes (a contribution-area constant) + title?: string + order?: number // sort within the area (lower = earlier) + when?: () => boolean // dynamic visibility; re-evaluated by the area + enabled?: boolean + render?: () => ReactNode // the component to mount + data?: unknown // area-specific payload (see the cookbook) +} +``` + +You provide `render`, `data`, or both, depending on the area. + +## Contribution areas — the cookbook + +Import the area constants from the SDK; each area has its own `data` payload. + +| Surface | `area` | You provide | +|---------|--------|-------------| +| Layout pane | `PANES_AREA` (`'panes'`) | `title` + `render` + `data: { placement, dock?, width?, height? }` | +| Full page | `ROUTES_AREA` | `data: { path }` + `render` | +| Sidebar nav | `SIDEBAR_NAV_AREA` | `data: { path, label, codicon }` | +| Status bar | `STATUSBAR_AREAS.left` / `.right` | `render` (or `data` as `StatusbarItem`) | +| Title bar | `TITLEBAR_AREAS.left` / `.center` / `.right` | `data` as `TitlebarTool`, or a mount-scoped `` | +| ⌘K palette | `PALETTE_AREA` | `data: PaletteContribution` | +| Keybind | `KEYBINDS_AREA` | `data: KeybindContribution` | +| Theme | `THEMES_AREA` | `data` as a `DesktopTheme` | +| Composer | `COMPOSER_AREAS.*` | render slots, or middleware / attachment providers | + +### Panes + +A pane is a tile in the layout tree. `placement` is the semantic role — the pane +stacks (as tabs) with existing panes of that role; the user can drag it anywhere +afterward. + +```javascript +ctx.register({ + id: 'pane', + area: 'panes', + title: 'my pane', + data: { placement: 'right', width: '260px' }, + render: () => jsx(MyPane, {}) +}) +``` + +`placement` is `'main' | 'left' | 'right' | 'top' | 'bottom'`. To land on a +specific **edge** instead of stacking, add a `dock` gesture — the same thing as +dragging onto a pane's drop chip: + +```javascript +// Below the conversation, 200px tall. +data: { + placement: 'bottom', + dock: { pane: 'workspace', pos: 'bottom' }, + height: '200px' +} +``` + +`dock.pane` is any pane id (`workspace` is the main thread; also `sessions`, +`terminal`, `files`, `review`, `logs`); `dock.pos` is +`'top' | 'bottom' | 'left' | 'right' | 'center'`. Declare a `width`/`height` so +the pane doesn't claim half the zone. + +### Pages and sidebar nav + +A route mounts a full page in the workspace pane, like any built-in view. Pair it +with a sidebar nav row (and/or a palette command) to make it reachable. + +```javascript +import { ROUTES_AREA, SIDEBAR_NAV_AREA } from '@hermes/plugin-sdk' + +ctx.registerMany([ + { + id: 'page', + area: ROUTES_AREA, + data: { path: '/my-page' }, + render: () => jsx(MyPage, {}) + }, + { + id: 'nav', + area: SIDEBAR_NAV_AREA, + data: { path: '/my-page', label: 'My Page', codicon: 'project' } + } +]) +``` + +`codicon` is a [VS Code codicon](https://microsoft.github.io/vscode-codicons/dist/codicon.html) +id. Navigate to a route from anywhere with `host.navigate('/my-page')`. + +### Status bar and title bar + +Status-bar items render into the left or right cluster of the bottom bar. +Simplest is a `render` function; for a plain button use `data` as a +`StatusbarItem` (`{ id, label?, icon?, detail?, variant?, menuItems?, … }`). + +```javascript +import { STATUSBAR_AREAS, TITLEBAR_AREAS } from '@hermes/plugin-sdk' + +ctx.register({ + id: 'count', + area: STATUSBAR_AREAS.right, + order: 120, + render: () => jsx(MyStatus, {}) +}) +``` + +Title-bar tools live in `TITLEBAR_AREAS.left | .center | .right` as `TitlebarTool` +data (`{ id, label, icon, active?, onSelect? }`). + +### Palette commands and keybinds + +```javascript +import { PALETTE_AREA, KEYBINDS_AREA } from '@hermes/plugin-sdk' + +ctx.registerMany([ + { + id: 'open', + area: PALETTE_AREA, + data: { + id: 'my-page.open', + label: 'Open My Page', + keywords: ['my', 'page'], + run: () => host.navigate('/my-page') + } + }, + { + id: 'refresh', + area: KEYBINDS_AREA, + data: { + id: 'my-page.refresh', + label: 'Refresh My Page', + category: 'My Plugin', + defaults: ['mod+shift+r'], + run: () => void doRefresh() + } + } +]) +``` + +Keybinds are user-rebindable in settings; `defaults` is just the initial binding. + +### Themes + +A theme contribution ships a full `DesktopTheme` as its `data` (name, label, +colors, …). It appears in the theme picker like a built-in. + +```javascript +import { THEMES_AREA } from '@hermes/plugin-sdk' + +ctx.register({ id: 'noir', area: THEMES_AREA, data: myDesktopTheme }) +``` + +### Composer extensions + +`COMPOSER_AREAS` (`top`, `bottom`, `leading`, `actions`, `attachments`, +`middleware`) let a plugin add controls around the message composer, provide an +attachment source, or transform a draft before it is sent (`ComposerMiddleware` +with a `handler(draft) => draft | null`). + +### Mount-scoped chrome (`Contribute`) + +`ctx.register` is for **permanent** contributions. When chrome should live and +die with a component that's already on screen (a page's own title-bar control +leaves when the page unmounts), render `` inside it instead: + +```javascript +import { Contribute, TITLEBAR_AREAS } from '@hermes/plugin-sdk' + +jsx(Contribute, { + area: TITLEBAR_AREAS.center, + id: 'my-page:switcher', // namespace with your slug + children: jsx(MySwitcher, {}) +}) +``` + +It registers on mount and disposes on unmount automatically. + +## Host API + +Everything on `host` is reachable from anywhere in a plugin. State atoms are +readonly — read with `.get()` in handlers, subscribe with `useValue(atom)` in +components. + +```ts +host.state.activeSessionId // ReadableAtom +host.state.cwd // ReadableAtom +host.state.gateway // ReadableAtom ('idle' | 'connecting' | 'open' | …) +host.state.model // ReadableAtom +host.state.profile // ReadableAtom +host.state.viewport // ReadableAtom<{ width, height, narrow }> + +host.notify({ kind, message, title?, detail?, action? }) // toast; returns id +host.notifyError(error, fallbackMessage) // toast an error +host.navigate('/route') // hash-route navigation +host.onEvent(type, fn) // gateway event stream ('*' = all); returns disposer +host.logs(...) // tail an app log file +host.status() // one-shot system status snapshot +host.restartGateway() // restart the backend gateway +host.request(method, params?) // gateway JSON-RPC — the real power +``` + +`host.request` is the same JSON-RPC the app itself uses (sessions, config, skills, +cron, kanban, …). `host.onEvent` streams live gateway events (message deltas, +session lifecycle, tool activity). Listeners are isolated — a throw in your +listener can't affect app dispatch. Every `host` door is async-safe: a sync throw +from an internal helper (e.g. no desktop bridge in a plain browser) becomes a +rejection your `.catch()` sees, never an error-boundary crash. + +## Data layer — React Query + nanostores + +Plugins share the app's single `QueryClient`, so plugin queries cache, dedupe, +poll, and invalidate exactly like core screens — never hand-roll a fetch loop. + +```javascript +import { useQuery, useMutation, useQueryClient, atom, computed, useValue } from '@hermes/plugin-sdk' + +function MyPanel() { + const { data, isLoading } = useQuery({ + queryKey: ['my-plugin', 'items'], + queryFn: () => host.request('my.list', {}) + }) + // … +} +``` + +For state shared between a trigger and its panel (or a poll loop), use `atom` / +`computed` — the same primitive `host.state` uses. Subscribe in the leaf that +renders the value with `useValue`. To invalidate a query from **outside** React +(e.g. a `ctx.socket` frame arriving), import the shared `queryClient`: + +```javascript +import { queryClient } from '@hermes/plugin-sdk' + +ctx.socket('/events', () => { + queryClient.invalidateQueries({ queryKey: ['my-plugin', 'items'] }) +}) +``` + +## The UI kit and theming + +Import the app's real components directly so your UI is native by default: + +> `Button`, `Input`, `Textarea`, `Select*`, `Switch`, `Checkbox`, +> `SegmentedControl`, `Tabs*`, `Dialog*`, `ConfirmDialog`, `DropdownMenu*`, +> `ContextMenu*`, `Popover*`, `Tip`/`Tooltip*`, `Badge`, `Kbd`/`KbdGroup`, +> `SearchField`, `ScrollArea`, `Separator`, `Skeleton`, `GlyphSpinner`, `Loader`, +> `EmptyState`, `ErrorState`, `CopyButton`, `StatusDot`, `LogView`, `Codicon`, +> `DecodeText`. + +Plus helpers: `cn` (class merge), `icons.*` (the app's lucide set), `haptic`, +`profileColor` / `profileColorSoft` (deterministic identity colors), the time +formatters `relativeTime` / `fmtDateTime` / `fmtDayTime` / `coarseElapsed`, +`useI18n` (localized copy — your plugin stays translatable), and +`evaluateRuntimeReadiness`. + +**Style with theme variables, never hardcoded colors.** Panes already sit on the +app's editor background — leave the background alone and use vars for everything +else: `var(--ui-text-secondary)`, `var(--ui-text-tertiary)`, +`var(--ui-text-quaternary)`, `var(--ui-stroke-secondary)`, `var(--ui-accent)`. +For canvas drawing, resolve them once with +`getComputedStyle(canvas).getPropertyValue('--ui-accent')`. This is what makes a +plugin reskin automatically with every theme. + +## A backend for your plugin + +If your plugin needs server-side work, ship a Python `plugin_api.py` and reach it +through `ctx.rest` / `ctx.socket` — a namespace scoped to your plugin **by +construction**. + +### The Python side + +Desktop plugins reuse the dashboard plugin backend mount. Put the backend in a +`dashboard/` subfolder of a regular Hermes plugin and declare it in a +`manifest.json`: + +``` +~/.hermes/plugins// +└── dashboard/ + ├── manifest.json # { "name": "", "api": "plugin_api.py" } + └── plugin_api.py # exports `router = APIRouter()` +``` + +```python +# plugin_api.py +from fastapi import APIRouter + +router = APIRouter() + +@router.get("/board") +async def board(): + return {"items": ["one", "two", "three"]} + +@router.post("/action") +async def action(body: dict): + return {"ok": True, "received": body} +``` + +Routes mount under `/api/plugins//` (`GET /api/plugins//board`, …). +Backend code runs inside the gateway process, so it can import from the +hermes-agent codebase directly (`hermes_state`, `hermes_cli.config`, …). See +[Extending the Dashboard → Backend API routes](/user-guide/features/extending-the-dashboard#backend-api-routes) +for the full backend reference — the mount is identical. + +:::caution The Python backend is gated separately +Enabling a plugin in the desktop **Settings → Plugins** panel is a renderer-side +choice; it does **not** import Python. A user plugin's `plugin_api.py` is +imported only when the plugin is in the `plugins.enabled` allow-list in +`config.yaml` (and not in `plugins.disabled`). Project plugins (`./.hermes/`) +never auto-import Python. This is a security boundary, not an oversight +(GHSA-mcfc-hp25-cjv7). +::: + +### Calling it from the plugin + +```javascript +register(ctx) { + // REST — namespace-relative path. + const load = () => ctx.rest('/board') // GET /api/plugins//board + const act = () => ctx.rest('/action', { method: 'POST', body: { go: true } }) + + // Live twin — a WebSocket to your own namespace. + const stop = ctx.socket('/events', frame => { + queryClient.invalidateQueries({ queryKey: [ctx.source, 'board'] }) + }) +} +``` + +`ctx.rest` is profile-aware and rejects path traversal (`..`) so you can never +address another plugin's API or a core route through it. `PluginRestOptions` is +`{ method?, body?, upload?: { filename, contentType?, bytes }, timeoutMs? }`. + +`ctx.socket` auto-reconnects with backoff until disposed. **It resolves to a no-op +on OAuth remotes** (single-use WS tickets are core-managed) — treat the socket as +an accelerator over polling, never a replacement. Every consumer needs a polling +fallback anyway, since any socket can drop. + +For gateway-wide data (not your own namespace), use `host.request` (JSON-RPC) and +`host.onEvent` (the gateway event stream) instead. + +## Settings, enable state, and storage + +Every plugin — enabled or not — inventories in **Settings → Plugins**, where the +user toggles it live (no app restart), reveals its folder, or rescans. The user's +choice is remembered: + +- No choice yet → the plugin's own `defaultEnabled` (default `true`). Set + `defaultEnabled: false` to ship an opt-in plugin that stays dark until the user + flips it on. +- Explicit choice → persisted and honored across restarts. A disabled plugin + stays disabled — don't fight it; the user turned you off. + +Persist your own state with `ctx.storage`, namespaced to your plugin +(`hermes.plugin..*`) so plugins can't read or clobber each other: + +```javascript +ctx.storage.set('lastTab', 'board') +const tab = ctx.storage.get('lastTab', 'summary') +ctx.storage.remove('lastTab') +``` + +## Bundled plugins + +A plugin can ship in-tree at `apps/desktop/src/plugins//plugin.tsx` (default +export a `HermesPlugin`). It's discovered by `discoverBundledPlugins()` at boot — +no import, no registry edit — and shares the exact inventory + live +enable/disable contract as a disk plugin. The two differences: + +1. It goes through the app's Vite build, so you can write **real JSX** and import + the SDK by its `@hermes/plugin-sdk` alias. +2. It's still lint-fenced to `@hermes/plugin-sdk` + `react` only — no `@/…` app + internals. + +No desktop plugins ship in the core tree today; the shipped app stays uncluttered +and demos live in the +[`hermes-example-plugins`](https://github.com/NousResearch/hermes-example-plugins) +companion repo. + +## Security model + +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 the loader provides is **error +isolation only**: a plugin can't crash the app (contributions are error-bounded, +listeners isolated), but it can do anything the app can. + +This is acceptable for **local** sources — a disk file can already run code on +your machine — which is why the disk door only loads local files you (or your +agent) wrote. The optional `integrity` (`sha256-…`) check only proves the bytes +match a hash; it does **not** sandbox. A future remote-source door will need a +real boundary (iframe/worker + CSP + capability gating) before it can land; do +not treat this pipeline as a trust boundary. + +## Pitfalls + +- **JSX won't parse in a disk plugin.** The file loads uncompiled — use `jsx()` / + `jsxs()` (or `React.createElement`), not JSX syntax. (Bundled plugins are built, + so JSX is fine there.) +- **Only three specifiers resolve:** `@hermes/plugin-sdk`, `react`, + `react/jsx-runtime`. Any other import surfaces an up-front load error. +- **Never hardcode colors** (`#000`, `black`, `rgb(...)`). Leave the background + alone; use theme variables (`var(--ui-*)`) for everything. +- **Reference only what you imported.** A component you forgot to import (e.g. + `StatusDot`) is a `ReferenceError` at render — double-check every identifier in + your `jsx()` calls appears in the import line. +- **Read state imperatively in handlers** (`$atom.get()`), never from a render + closure — rapid events will otherwise see stale values. Subscribe (`useValue`) + only in the leaf that renders the value. +- **Canvas panes must track their container** with a `ResizeObserver` and resize + the canvas (width/height attributes, not just CSS) — panes resize constantly. +- **Don't poll faster than a few seconds** with `host.request`; prefer + `host.onEvent` / `ctx.socket` and let React Query dedupe. +- **`ctx.socket` is a no-op on OAuth remotes.** Always have a polling fallback. + +## Reference + +### SDK exports at a glance + +| Category | Exports | +|----------|---------| +| Host | `host` (`.state.*`, `.notify`, `.notifyError`, `.navigate`, `.onEvent`, `.logs`, `.status`, `.restartGateway`, `.request`) | +| Plugin contract | `HermesPlugin`, `PluginContext`, `PluginContribution`, `PluginStorage`, `PluginRestOptions`, `Contribution` | +| Area constants | `PANES_AREA`, `ROUTES_AREA`, `SIDEBAR_NAV_AREA`, `STATUSBAR_AREAS`, `TITLEBAR_AREAS`, `PALETTE_AREA`, `KEYBINDS_AREA`, `THEMES_AREA`, `COMPOSER_AREAS` | +| Area payloads | `RouteContribution`, `SidebarNavContribution`, `StatusbarItem`, `TitlebarTool`, `PaletteContribution`, `KeybindContribution`, `ComposerMiddleware`, `ComposerAttachmentProvider` | +| React / state | `useValue`, `atom`, `computed`, `useQuery`, `useMutation`, `useQueryClient`, `queryClient`, `Contribute` | +| UI kit | `Button`, `Input`, `Textarea`, `Select*`, `Switch`, `Checkbox`, `SegmentedControl`, `Tabs*`, `Dialog*`, `ConfirmDialog`, `DropdownMenu*`, `ContextMenu*`, `Popover*`, `Tip`/`Tooltip*`, `Badge`, `Kbd`/`KbdGroup`, `SearchField`, `ScrollArea`, `Separator`, `Skeleton`, `GlyphSpinner`, `Loader`, `EmptyState`, `ErrorState`, `CopyButton`, `StatusDot`, `LogView`, `Codicon`, `DecodeText` | +| Helpers | `cn`, `icons`, `haptic`, `useI18n`, `profileColor`, `profileColorSoft`, `relativeTime`, `fmtDateTime`, `fmtDayTime`, `coarseElapsed`, `evaluateRuntimeReadiness` | + +The canonical, always-current export list is `apps/desktop/src/sdk/index.ts`. + +### Agents: the `hermes-desktop-plugins` skill + +When an agent writes a desktop plugin, it should load the bundled +**`hermes-desktop-plugins`** skill — it carries the same contract as this page in +agent-facing form, with a ready-to-copy `templates/plugin.js`. This page is the +human/developer reference; the skill is the working checklist. + +## Troubleshooting + +**My plugin doesn't appear.** Confirm the file is at +`$HERMES_HOME/desktop-plugins//plugin.js` and the folder name matches the +export `id`. Run ⌘K → **Reload desktop plugins**. Check the app for an error +toast naming the failure, and tail `hermes logs gui -f`. + +**"unsupported import" on load.** A disk plugin may only import +`@hermes/plugin-sdk`, `react`, and `react/jsx-runtime`. Remove any other import. + +**A `jsx` element renders nothing / throws `ReferenceError`.** An identifier used +in a `jsx()` call isn't imported. Add it to the import line. + +**`ctx.rest` returns 404.** The backend isn't mounted: confirm +`~/.hermes/plugins//dashboard/manifest.json` has `"api": "plugin_api.py"`, +that the plugin is in `plugins.enabled` in `config.yaml`, and restart the gateway +(backend routes mount at startup). Tail `~/.hermes/logs/errors.log` for +`Failed to load plugin API routes`. + +**`ctx.socket` never fires.** On an OAuth remote it's a no-op by design — use your +polling fallback. Otherwise verify the backend exposes the matching +`@router.websocket(...)` route under its namespace. + +**Colors look wrong after a theme switch.** You hardcoded a color. Replace it with +a `var(--ui-*)` theme variable. diff --git a/website/docs/developer-guide/plugins/index.md b/website/docs/developer-guide/plugins/index.md index 99fe800b4100..7e748b6c1e1c 100644 --- a/website/docs/developer-guide/plugins/index.md +++ b/website/docs/developer-guide/plugins/index.md @@ -15,6 +15,8 @@ Hermes has several distinct pluggable interfaces — some use Python `register_* | If you want to add… | Read | |---|---| | Custom tools, hooks, slash commands, skills, or CLI subcommands | **This guide** (the general plugin surface) | +| A **native desktop app** extension (panes, pages, status bar, palette, themes) | [Desktop Plugin SDK](/developer-guide/desktop-plugin-sdk) | +| A **web dashboard** extension (tabs, shell slots, themes) | [Extending the Dashboard](/user-guide/features/extending-the-dashboard) | | An **LLM / inference backend** (new provider) | [Model Provider Plugins](/developer-guide/model-provider-plugin) | | A **gateway channel** (Discord/Telegram/IRC/Teams/etc.) | [Adding Platform Adapters](/developer-guide/adding-platform-adapters) | | A **memory backend** (Honcho/Mem0/Supermemory/etc.) | [Memory Provider Plugins](/developer-guide/memory-provider-plugin) | diff --git a/website/docs/user-guide/desktop.md b/website/docs/user-guide/desktop.md index 9395ee2c9168..c8895bf4abb4 100644 --- a/website/docs/user-guide/desktop.md +++ b/website/docs/user-guide/desktop.md @@ -220,6 +220,17 @@ The remote gateway host is configured per [profile](./profiles.md), so each prof For the same setup from the web-dashboard angle, see [Web Dashboard → Connecting Hermes Desktop to a remote backend](./features/web-dashboard.md#connecting-hermes-desktop-to-a-remote-backend); the env vars are catalogued under [Environment Variables → Web Dashboard & Hermes Desktop](../reference/environment-variables.md#web-dashboard--hermes-desktop). +## Extending the desktop app + +The desktop app is contribution-driven — panes, pages, sidebar nav, status-bar +items, palette commands, keybinds, and themes all register through one SDK, and +you can add your own. A plugin is a single ESM file dropped in +`$HERMES_HOME/desktop-plugins//plugin.js`; the app loads it within seconds and +hot-reloads every save. Manage installed plugins live in **Settings → Plugins**. + +See [Desktop Plugin SDK](../developer-guide/desktop-plugin-sdk.md) for the full +reference. (This is separate from the [web dashboard plugin system](./features/extending-the-dashboard.md).) + ## Troubleshooting Boot logs land in `HERMES_HOME/logs/desktop.log` (it includes backend output and recent Python tracebacks) — check it first if the app reports a boot failure. You can also tail it from the CLI: diff --git a/website/docs/user-guide/features/extending-the-dashboard.md b/website/docs/user-guide/features/extending-the-dashboard.md index 79b84a73efb1..ad7f4273727e 100644 --- a/website/docs/user-guide/features/extending-the-dashboard.md +++ b/website/docs/user-guide/features/extending-the-dashboard.md @@ -16,6 +16,10 @@ All three are **drop-in at runtime**: no repo clone, no `npm run build`, no patc If you just want to use the dashboard, see [Web Dashboard](./web-dashboard). If you want to reskin the terminal CLI (not the web dashboard), see [Skins & Themes](./skins) — the CLI skin system is unrelated to dashboard themes. +:::note Not the desktop app +This page covers the **web dashboard** (`hermes dashboard`) plugin system — `window.__HERMES_PLUGIN_SDK__`, a `manifest.json`, and a pre-built JS bundle. The **native desktop app** (`hermes desktop`) has its own, unrelated SDK — `@hermes/plugin-sdk`, a single ESM file, no build step — documented at [Desktop Plugin SDK](/developer-guide/desktop-plugin-sdk). Only the backend `plugin_api.py` namespace (`/api/plugins/`) is shared between them. +::: + :::note How the pieces compose Themes and plugins are independent but synergistic. A theme can stand alone (just a YAML file). A plugin can stand alone (just a tab). Together they let you build a complete visual reskin with custom HUDs — the example `strike-freedom-cockpit` demo (lives in the `hermes-example-plugins` companion repo — see [Combined theme + plugin demo](#combined-theme--plugin-demo) for install steps) does exactly that. ::: diff --git a/website/sidebars.ts b/website/sidebars.ts index a6c9d28fd521..327b2296149d 100644 --- a/website/sidebars.ts +++ b/website/sidebars.ts @@ -744,6 +744,7 @@ const sidebars: SidebarsConfig = { link: {type: 'doc', id: 'developer-guide/plugins/index'}, items: [ 'developer-guide/plugin-llm-access', + 'developer-guide/desktop-plugin-sdk', 'developer-guide/memory-provider-plugin', 'developer-guide/context-engine-plugin', 'developer-guide/secret-source-plugin',