Review finding (MAJOR-2): with dashboard.turn_isolation the plugin's agent-side code (tools, hooks) runs in the compute-host child, where _live_transports is empty and _broadcast_global_event dropped the frame at logger.debug. The child now registers its _HostTransport as a live transport for the life of run_host, so a session-less broadcast rides the existing host pipe; the parent bridge (_relay_compute_host_rpc) recognises an event frame with no session_id and fans it out via _broadcast_global_event instead of write_json, which would have dropped it on hermes serve's stdio. Minor: the late `from tui_gateway.server import …` could raise ImportError in a plugin-only process despite the "safe from any handler" docstring — caught and logged as a warning. Docs: the SDK page now tables exactly which process each call site runs in and what it reaches (hermes serve / compute-host child / stdio TUI / gateway run + chat + cron = nobody).
1593 lines
80 KiB
Markdown
1593 lines
80 KiB
Markdown
---
|
|
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.md) 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/<id>/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.md). Python
|
|
CLI/gateway plugins are documented at [Build a Hermes Plugin](./plugins/index.md).
|
|
The three do not share code, APIs, or delivery. Only the backend `plugin_api.py`
|
|
namespace (`/api/plugins/<id>`) 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, per-session turn-busy, cwd, gateway socket status,
|
|
model, profile, viewport. `gateway` is the WebSocket, not turn-busy.
|
|
- **`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.
|
|
- **`captureGatewayFileDownload()`** — capture a gateway file-save action
|
|
immediately before starting a REST read, and retain it alongside the returned
|
|
data. The action `(storedPath, suggestedName) => Promise<void>` keeps that
|
|
read's connection/profile scope even if the user switches hosts before
|
|
clicking. Invoke only on an explicit user download gesture, using the
|
|
backend's persisted file path, never a guessed workspace path. Electron handles
|
|
authenticated streaming, the native save dialog, and older-gateway fallback;
|
|
plugins never receive credentials or open remote paths with `file://`. The
|
|
host shows the same "Saved" / "Download failed" toasts as the Files panel and
|
|
stays quiet on cancel; the promise settles when the save does and never
|
|
rejects.
|
|
- **`ctx.rest` / `ctx.socket`** — your plugin's own backend namespace
|
|
(`/api/plugins/<id>`) 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/<id>/plugin.js` | users, agents | none — plain ESM, loaded uncompiled |
|
|
| **Unified package** | `$HERMES_HOME/plugins/<id>/desktop/plugin.js` | plugins that also ship agent-side code | none — same disk pipeline |
|
|
| **Bundled** | `apps/desktop/src/plugins/<id>/plugin.tsx` | in-tree, shipped with the app | the app's own Vite build |
|
|
|
|
All three take the same `HermesPlugin` contract, appear in **Capabilities → Plugins**,
|
|
and enable/disable live. A unified package is just the disk door scanning inside
|
|
your agent plugin's folder — see
|
|
[One package, both SDKs](#one-package-both-sdks). Everything on this page is
|
|
written against the disk door (what you and the agent write);
|
|
[Bundled plugins](#bundled-plugins) notes the two
|
|
differences. Radio ships as a bundled SDK-only plugin, off by default. Enable it
|
|
in **Capabilities → Plugins** for free live streams, station search, and status-bar
|
|
playback controls with an audio-reactive waveform. It uses the existing plugin
|
|
toggle and contributes nothing while disabled. 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). Desktop plugins are app-level — one root for every profile, gateway,
|
|
or remote machine the window connects to. 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:<id>` 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 Capabilities ▸ 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:<id>'`) and
|
|
namespaces every contribution id (`<id>:<localId>`), 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/<id>`). */
|
|
rest: <T>(path: string, opts?: PluginRestOptions) => Promise<T>
|
|
/** Live WebSocket to this plugin's own namespace. Returns a disposer. */
|
|
socket: (path: string, onMessage: (data: unknown) => void) => () => void
|
|
/** Gateway event stream by type (`'*'` = all). Tracked: removed on unload/reload/disable. */
|
|
onEvent: (type: string, listener: (event: GatewayEvent) => void) => () => void
|
|
/** Any other cleanup to run on unload/reload/disable (store subscriptions, injected DOM). */
|
|
onDispose: (fn: () => void) => void
|
|
/** Scoped timers and DOM listeners — cleared with the plugin. Each returns a disposer. */
|
|
setTimeout: (fn: () => void, ms: number) => () => void
|
|
setInterval: (fn: () => void, ms: number) => () => void
|
|
addEventListener: (target: EventTarget, type: string, listener: EventListener, options?: AddEventListenerOptions | boolean) => () => void
|
|
/** The curated OS door: native notification, open-external, reveal-in-file-manager, clipboard. */
|
|
os: PluginOs
|
|
/** Plugin-scoped JSON persistence (keys live under `hermes.plugin.<id>.`). */
|
|
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 `<Contribute>` |
|
|
| Page header | `WORKSPACE_PAGE_HEADER_AREA` | `render` via a mount-scoped `<Contribute>` inside your page |
|
|
| ⌘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 |
|
|
| Appearance settings | `APPEARANCE_AREAS.extra` | `render` — controls appended to Settings → Appearance |
|
|
|
|
### 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.
|
|
|
|
Closing the only pane contributed by a plugin disables that plugin, which can
|
|
be re-enabled from **Capabilities → Plugins**. When a plugin contributes multiple
|
|
panes, closing one dismisses only that pane and leaves the plugin's other panes,
|
|
commands, and middleware active. **Reset layout** restores dismissed contributed
|
|
panes.
|
|
|
|
### 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? }`).
|
|
|
|
Title-bar slots are **permanent mount points**: a component you register there
|
|
stays mounted while the user moves between the chat and full pages (Capabilities,
|
|
Messaging, Artifacts, contributed routes), so a `useEffect` that injects global
|
|
side effects (a `<style>` tag, `html[data-*]` attributes, a `MutationObserver`)
|
|
runs its setup once per registration and its cleanup once at dispose — never
|
|
mid-navigation.
|
|
|
|
Controls that belong to ONE page (the Kanban board switcher) go in
|
|
`WORKSPACE_PAGE_HEADER_AREA` instead: it renders in the workspace panel's
|
|
tab-header row while that page is on screen and is empty otherwise. Register it
|
|
with a mount-scoped `<Contribute>` (below) so it leaves with the page.
|
|
|
|
### 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 })
|
|
```
|
|
|
|
Registering a theme lists it; it does not select it. `useTheme()` reads the
|
|
painted appearance (`theme`, `themeName`, `availableThemes`, `resolvedMode`) and
|
|
changes it (`setTheme`, `setMode`, `previewTheme`) from a component:
|
|
|
|
```javascript
|
|
import { Button, useTheme } from '@hermes/plugin-sdk'
|
|
|
|
function ThemePicker() {
|
|
const { availableThemes, setTheme, themeName } = useTheme()
|
|
|
|
return availableThemes.map(t => (
|
|
<Button key={t.name} disabled={t.name === themeName} onClick={() => setTheme(t.name)}>
|
|
{t.label}
|
|
</Button>
|
|
))
|
|
}
|
|
```
|
|
|
|
A switch driven by something other than a render — a gateway connecting, a
|
|
socket event, any `host.onEvent` callback — has no component to hang the hook
|
|
on. Use `requestTheme(name)` there. An unresolvable name is refused rather than
|
|
coerced to the default skin, so the return value doubles as the availability
|
|
check and a wrong name can never silently reset someone's appearance:
|
|
|
|
```javascript
|
|
import { host, requestTheme } from '@hermes/plugin-sdk'
|
|
|
|
host.onEvent('gateway.ready', () => {
|
|
if (!requestTheme('noir')) {
|
|
host.notifyError('Connected, but the noir theme is not installed.')
|
|
}
|
|
})
|
|
```
|
|
|
|
Both doors persist per profile, so a plugin-driven switch sticks exactly like a
|
|
manual pick. To tint the *active* theme rather than replace it, use
|
|
`setAccentOverride(hex)` and clear it in `ctx.onDispose` — the standalone
|
|
[Accent Picker](https://github.com/NousResearch/hermes-desktop-accent-picker)
|
|
plugin is the worked example (it is also a complete, installable disk plugin).
|
|
|
|
### Composer extensions
|
|
|
|
`COMPOSER_AREAS` (`top`, `bottom`, `underside`, `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`). `top` is a
|
|
banner strip above the input and `bottom` a row below the input grid, both
|
|
inside the composer chrome; `underside` is the floating strip BELOW the whole
|
|
composer with no chrome of its own — the seat for a suggestion pill or a status
|
|
hint that should sit outside the input frame (the next-prompt plugin renders its
|
|
"next prompt" pill there).
|
|
|
|
### Composer draft API — read and write the live input
|
|
|
|
For everything the composer areas can't do — put text INTO the input, replace
|
|
what's there, read the current draft, or send it — use `host.composer`. This
|
|
is the supported door; reaching for the ProseMirror DOM, `[data-composer-target]`
|
|
lookups, or synthetic `InputEvent`s is out of the plugin surface (catalog rule 8)
|
|
and breaks the moment the app's markup moves. Addressing: `null` = the composer
|
|
the user is typing in; a session id (stored or runtime) = that session's
|
|
composer, in the primary pane or a tile; `'new'` = the fresh draft that has no
|
|
session id yet.
|
|
|
|
```javascript
|
|
import { host } from '@hermes/plugin-sdk'
|
|
|
|
// Append to the active composer (modes: 'block' | 'inline' | 'prefix';
|
|
// 'prefix' seats a slash command at the start). Acknowledged like setDraft:
|
|
// true when a mounted surface applied the text, false when the text is blank
|
|
// or no live surface answers for the address.
|
|
const inserted = await host.composer.insertText(null, 'draft note', { mode: 'inline' })
|
|
|
|
// Replace a session's whole draft — '@'-ref and '/command' tokens hydrate
|
|
// into chips exactly like an official paste. False when no mounted surface
|
|
// answers (an unmounted session is never half-written).
|
|
const ok = await host.composer.setDraft('sess-1', 'plan:\n- @file:src/app.ts')
|
|
|
|
// Read the live draft: the mounted surface's in-DOM text (unsaved keystrokes
|
|
// included), falling back to the debounced persisted stash. Null when nothing
|
|
// holds it.
|
|
const text = await host.composer.getDraft('sess-1')
|
|
|
|
// Send as if the user typed + pressed Enter. Fail-closed like the app's own
|
|
// panels: no visible surface for the address → false, never a broadcast.
|
|
const sent = host.composer.submit('sess-1', 'ship it')
|
|
|
|
// Put the caret in a composer (same addressing). insertText/setDraft already
|
|
// focus a visible surface they paint; use this to return the caret after a
|
|
// plugin popover closes or from a "go to input" keybind.
|
|
host.composer.focus(null)
|
|
```
|
|
|
|
```ts
|
|
host.composer: {
|
|
getDraft(sessionId: string | null): Promise<string | null>
|
|
setDraft(sessionId: string | null, text: string): Promise<boolean>
|
|
insertText(sessionId: string | null, text: string, opts?: { mode?: 'block' | 'inline' | 'prefix' }): Promise<boolean>
|
|
submit(sessionId: string | null, text: string): boolean
|
|
focus(sessionId: string | null): void
|
|
}
|
|
```
|
|
|
|
**Arbitration.** Every verb is fail-closed on its address: a request is
|
|
answered only by the mounted composer that owns that session (its tile, or the
|
|
primary pane when it shows that session); `null` is answered only by the surface
|
|
the app's focus bus currently routes to; `'new'` only by the primary pane while it
|
|
shows no session — it never falls through to the active composer. No exact
|
|
surface → `null`/`false`, never
|
|
a broadcast into whichever pane happens to be mounted. Writes go through the
|
|
app's own paint path, so `@`-ref / `/`-command tokens hydrate as chips and the
|
|
result is byte-for-byte what the user would get by pasting. These are discrete,
|
|
user-triggered actions with the same authority as typing — no plugin "owns" the
|
|
draft afterwards, so there is **nothing to tear down** on disable; a plugin that
|
|
wants a persistent presence around the input uses a `COMPOSER_AREAS` slot instead.
|
|
|
|
A multi-session plugin keeps its per-session state on its side (which session
|
|
its panel is editing) and passes that id here; the bus guarantees one
|
|
plugin write can never land in another session's composer.
|
|
|
|
**Migrating off DOM reach-in** (the held catalog plugins that motivated this API):
|
|
|
|
| Plugin | Was | Now |
|
|
|---|---|---|
|
|
| next-prompt (#120660) | `window.dispatchEvent(new CustomEvent('hermes:composer-insert', …))` + a `setTimeout` `hermes:composer-focus`; `[data-composer-target]`/`[data-pane-hidden]` scan for the visible target | `await host.composer.insertText(null, suggestion.text, { mode: 'block' })`, then `host.composer.focus(null)` if the pill lost the caret |
|
|
| prompt-snippets (#116030) | same `hermes:composer-insert` event; `[data-slot="composer-input"]`/ProseMirror `textContent` + synthetic `InputEvent` fallback; `surfaceEditorEl().focus()` | `host.composer.insertText(sid, text, { mode: 'block' })`; `setDraft(sid, (await getDraft(sid) ?? '') + '\n' + text)` replaces the fallback; `host.composer.focus(sid)` — `sid = host.state.focusedSessionId.get()` |
|
|
| prompt-enhancer (#116031) | walks the editor's child nodes to serialize, rebuilds chip DOM, `replaceChildren` + synthetic `InputEvent` | `const draft = await host.composer.getDraft(sid)` → transform → `await host.composer.setDraft(sid, enhanced)` (chips hydrate app-side); revert is another `setDraft` |
|
|
| memory-review (#115966) | `host.request('slash.exec', { session_id, command })` for `/memory …` — already SDK-only | optional: `host.composer.insertText(sid, '/memory pending', { mode: 'prefix' })` to seat the command for the user instead of executing it |
|
|
| intelligent-tool-break (#115964) | "Message" button only toasts "type /break" (no composer write) | `host.composer.setDraft(host.state.focusedSessionId.get(), '/break ')` then `host.composer.focus(null)` restores the intended behaviour |
|
|
|
|
`sessionId` in the table is the id the plugin's UI is bound to; for a composer
|
|
slot render it is `host.state.focusedSessionId.get()`.
|
|
|
|
### Session rows — decorations + the session list API
|
|
|
|
`SESSION_ROW_AREAS` (`leading`, `trailing`) let a plugin decorate sidebar
|
|
session rows. Register a `data` contribution whose `render({ sessionId })`
|
|
returns a small element (a badge, a swatch, a tag) or `null` for rows you don't
|
|
own — registering costs nothing on every other row:
|
|
|
|
```ts
|
|
import { SESSION_ROW_AREAS, type SessionRowSlotContribution } from '@hermes/plugin-sdk'
|
|
|
|
ctx.register({
|
|
area: SESSION_ROW_AREAS.trailing,
|
|
id: 'my-tag',
|
|
data: {
|
|
render: ({ sessionId }) => (owned.has(sessionId) ? <span className="my-tag">★</span> : null)
|
|
} satisfies SessionRowSlotContribution
|
|
})
|
|
```
|
|
|
|
Pair it with the session list API, which writes the same stores the app's own
|
|
controls write (so a plugin action and a hand click can never disagree):
|
|
|
|
```ts
|
|
host.sessions.pin(storedSessionId: string, pinned?: boolean, index?: number): void
|
|
host.sessions.reorder(storedSessionIds: string[]): void // Recents; [] = clear manual order → default sort
|
|
host.sessions.reorderPinned(storedSessionIds: string[]): void // Pinned section; omitted pins keep their slot
|
|
host.sessions.setColor(storedSessionId: string, color: string | null): void
|
|
```
|
|
|
|
Ids are STORED session ids: a live id is resolved to its durable lineage root,
|
|
so pins and colours survive compression's id rotation — and the row-decoration
|
|
slots hand your render that same durable id (`_lineage_root_id ?? id`), never
|
|
the live one. Resolution goes through the rows this window has loaded; an id
|
|
that matches no loaded row is written as given, so pass the slot's durable id
|
|
(not a live id you remembered) for a session that may have scrolled out of the
|
|
list. `reorder` accepts the same ids and maps each to its row's live id
|
|
internally — the Recents order store is keyed by the live id, like the drag
|
|
path. `pin(id, true, index)` slots the pin at that position in the
|
|
Pinned list (a drop target between two pins); without `index` it appends, like
|
|
the row's ⇧-click.
|
|
|
|
**Arbitration.** The verbs are discrete user-triggered edits of user data —
|
|
last write wins, exactly as if the user had clicked, and no plugin owns the
|
|
result afterwards. Slot contributions are ALL mounted (registration order, not
|
|
first-wins), each inside its own error boundary: a plugin that throws or
|
|
returns `null` for a row cannot suppress another plugin's decoration on it, and
|
|
two decorations on one row render side by side. Core keeps the row's layout,
|
|
gestures and title — slots augment, never replace.
|
|
|
|
**Teardown.** The verbs need none. Slot contributions are removed by the
|
|
`ctx.register` disposer (disable/reload drops them and the row re-renders
|
|
without the decoration).
|
|
|
|
Migration for the held catalog plugins:
|
|
|
|
- **drag-to-pin-session** — replace the `__reactFiber$*` walk for `onTogglePin`
|
|
/ `onReorderSessions` / `session._lineage_root_id` with the row's slot id
|
|
(`render: ({ sessionId }) => …` under `SESSION_ROW_AREAS.leading` gives you
|
|
the durable id per row), then `host.sessions.pin(sessionId, true, dropIndex)`
|
|
for a drop into the Pinned section, `host.sessions.pin(sessionId, false)` for
|
|
a drop back into Recents, `host.sessions.reorderPinned(ids)` for a drag
|
|
within the Pinned section, and `host.sessions.reorder([])` for its
|
|
"reset manual order" path.
|
|
- **better-session-appearance** — replace the `localStorage`
|
|
`hermes.desktop.sessionColors` write and the fiber-harvested `onChange` with
|
|
`host.sessions.setColor(sessionId, hex)` (`null` clears), and render its
|
|
per-row glyph through `SESSION_ROW_AREAS.leading` instead of mutating the
|
|
row's status dot (the durable id it needed from `_lineage_root_id` is the
|
|
slot's `sessionId`).
|
|
|
|
`COMPOSER_AREAS.modelPill` overrides the model pill's **label** — a provider
|
|
(`{ label: (ctx: ComposerModelPillContext) => string | null }`) receives
|
|
`{ model, reasoningEffort, compact }` and returns the text to show, or `null` to
|
|
let the next provider (then the core label) win. The pill keeps its chrome, pin
|
|
dot, and menu; only the label changes — the sanctioned replacement for the
|
|
MutationObserver text-rewriting plugins do today.
|
|
|
|
#### Model pill label providers
|
|
|
|
```ts
|
|
import { COMPOSER_AREAS, type ComposerModelPillContext, type ComposerModelPillProvider } from '@hermes/plugin-sdk'
|
|
|
|
interface ComposerModelPillContext {
|
|
model: string // the model slug the pill would show
|
|
reasoningEffort: string // the session's live effort level, '' when the model has none
|
|
compact: boolean // floating-composer mode: chevron only, providers are NOT consulted
|
|
}
|
|
interface ComposerModelPillProvider {
|
|
label: (ctx: ComposerModelPillContext) => string | null
|
|
}
|
|
|
|
ctx.register({
|
|
area: COMPOSER_AREAS.modelPill,
|
|
id: 'my-label',
|
|
data: { label: ({ model, reasoningEffort }) => reasoningEffort ? `${model} · ${reasoningEffort}` : null } satisfies ComposerModelPillProvider
|
|
})
|
|
```
|
|
|
|
**Arbitration.** Providers are consulted in registry order and the *first
|
|
non-empty string wins*; later providers are not called. Anything else declines
|
|
and the next provider is asked: `null`, `''`, a whitespace-only string, and any
|
|
non-string value (an object, array or number is never rendered — the label is
|
|
placed straight into JSX). A provider that **throws** also declines — the error
|
|
is swallowed and the pill falls through to the next provider, then to the core
|
|
label, so a broken plugin can never blank the pill. `reasoningEffort` is always
|
|
a `string` (`''` when the model has no effort level, never `undefined`). In
|
|
compact (floating) mode the pill renders only the chevron and no provider is
|
|
called. `label()` is re-evaluated only when the registry, the model, the effort
|
|
level or the compact flag changes.
|
|
|
|
**Teardown.** The provider is an ordinary data contribution: `ctx.register`
|
|
returns its disposer and the loader drops it when the plugin is disabled or
|
|
reloaded, at which point the core label is restored. There is nothing to undo
|
|
in `ctx.onDispose`.
|
|
|
|
**Migrating compact-reasoning-label.** The plugin used to find the pill via
|
|
`[data-slot="composer-root"] button span.truncate`, regex-strip a trailing
|
|
effort word from `span.textContent`, and re-run that sweep from a body-wide
|
|
`MutationObserver` plus a 1 s `setInterval`. On current builds the core label no
|
|
longer contains the effort word (the level has its own `ReasoningPill`), so the
|
|
strip is a no-op; the sanctioned shape is to compute the label from the context
|
|
instead of editing rendered text:
|
|
|
|
```js
|
|
register(ctx) {
|
|
ctx.register({
|
|
area: COMPOSER_AREAS.modelPill,
|
|
id: 'compact-reasoning-label',
|
|
// Decline (null) whenever there is nothing to change so the core label wins.
|
|
data: { label: ({ model }) => shorten(model) ?? null }
|
|
})
|
|
// No MutationObserver, no setInterval, no injected <style>: the contribution is
|
|
// disposed with the plugin.
|
|
}
|
|
```
|
|
|
|
The reasoning-pill visibility CSS the plugin also injected has no hook; it is
|
|
only needed if the app ever hides that label at narrow widths.
|
|
|
|
### Appearance settings
|
|
|
|
`APPEARANCE_AREAS.extra` renders contributions at the end of **Settings →
|
|
Appearance**, after the built-in sections. It is the seam for a plugin that
|
|
used to inject nodes into that page or drive its widgets through React
|
|
internals.
|
|
|
|
```ts
|
|
APPEARANCE_AREAS = { extra: 'appearance.extra' } as const
|
|
|
|
ctx.register({
|
|
area: APPEARANCE_AREAS.extra,
|
|
id: 'session-colour-rules', // unique within your plugin
|
|
render: () => <MyAppearanceCard /> // any React tree; SDK hooks allowed
|
|
})
|
|
```
|
|
|
|
*Arbitration:* every registration mounts, in registry order, each inside its
|
|
own error boundary — a contribution that throws collapses to an inline error
|
|
card naming its `id` (with Retry) and the rest of the page (and other plugins'
|
|
cards) keep rendering. The slot mounts on the top-level Appearance page only,
|
|
not on deep-link subpages (`settings/appearance/<section>`), and there is no
|
|
"first wins" — plugins cannot suppress each other here.
|
|
|
|
*Teardown:* the registration is owned by the plugin loader; disabling or
|
|
reloading the plugin disposes it and the card disappears on the next render.
|
|
Nothing persists app-side, so there is nothing to clean up in `ctx.onDispose`.
|
|
|
|
For colour picking use the app's own swatch grid — `ColorSwatches` (already an
|
|
SDK export) renders exactly what the profile rail and project dialog render,
|
|
with your own `onChange`; feed it `PROFILE_SWATCHES` and pair it with
|
|
`host.sessions.setColor(id, color)` for session colours.
|
|
|
|
Migrations for the plugins that motivated this slot:
|
|
|
|
* **better-session-appearance** — replace the fiber walk that harvests the
|
|
Appearance submenu's `{ onChange, swatches }` and the `clearBtn.after(...)` /
|
|
`host.appendChild(panel)` injection into the app dropdown with one
|
|
`ctx.register({ area: APPEARANCE_AREAS.extra, id: 'rules', render })` whose
|
|
card renders `<ColorSwatches swatches={PROFILE_SWATCHES} value onChange />`
|
|
plus its bold/glyph/auto-rule controls; drop the `data-better-session-appearance`
|
|
attribute writes and the dropdown `max-height` overrides.
|
|
* **hermes-appearance-hub** — mount its paper-texture / font / intro-copy
|
|
controls as an `APPEARANCE_AREAS.extra` card instead of a status-bar menu
|
|
that reaches into Settings; the settings *values* still go through
|
|
`host.settings` (allowlisted keys) and `THEMES_AREA`.
|
|
|
|
### Embedding external content
|
|
|
|
Use the SDK's `<SandboxedFrame src title />` for any external web content
|
|
(reader views, dashboards, docs). It renders a sandboxed iframe with the app's
|
|
guest-content posture: opaque origin, `allow-scripts` by default, `no-referrer`,
|
|
lazy loading. Never mount a raw Electron `<webview>`: it lands on the app's
|
|
`persist:` preview partition, sharing the app's cookies and storage.
|
|
|
|
```ts
|
|
interface SandboxedFrameProps {
|
|
src: string // absolute http(s): or data: URL; any other scheme renders nothing (console.warn)
|
|
title: string // required — an untitled frame is unlabelled in the a11y tree
|
|
sandbox?: string // extra tokens; filtered through the allowlist below
|
|
className?: string; style?: CSSProperties
|
|
onLoad?, onError?: ReactEventHandler<HTMLIFrameElement>
|
|
ref?: Ref<HTMLIFrameElement>
|
|
}
|
|
```
|
|
|
|
The props are an explicit allowlist, not `ComponentProps<'iframe'>`: `allow`
|
|
(Permissions-Policy delegation — would hand a third-party site the mic/camera
|
|
grant the app holds), `srcdoc`, `name`, `allowFullScreen`, `csp`,
|
|
`credentialless` and every other iframe attribute are not props and nothing is
|
|
spread onto the element, so they cannot reach the DOM even through a cast.
|
|
|
|
*Arbitration (allowlist, not blocklist):* the only tokens a caller may add are
|
|
`allow-scripts`, `allow-forms`, `allow-downloads`, `allow-pointer-lock`,
|
|
`allow-orientation-lock`, `allow-presentation`. Everything else —
|
|
`allow-same-origin`, `allow-top-navigation*`, `allow-popups*`, `allow-modals`,
|
|
`allow-storage-access-by-user-activation`, and any token the primitive does not
|
|
know — is dropped case-insensitively even if passed; an emptied set falls back
|
|
to the default posture (`allow-scripts`), because a frame with **no** `sandbox`
|
|
attribute is fully privileged. `loading="lazy"` and
|
|
`referrerPolicy="no-referrer"` are not props. The opaque origin IS the
|
|
containment: guest content cannot reach the app, its storage, or the preload
|
|
bridge.
|
|
|
|
*Teardown:* it is a plain React element — unmounting your pane/page removes the
|
|
frame and its realm; nothing is registered app-side.
|
|
|
|
Migration for **rss-reader** (#115972): replace the stubbed `/preview` → 501 →
|
|
`host.openWorkspace('rss-browser')` → empty `RssBrowserFrame` → `ctx.os.openExternal`
|
|
chain with `<SandboxedFrame src={article.url} title={article.title} />` inside
|
|
the workspace page; drop the leftover `.rss-browser-frame-host webview` CSS.
|
|
|
|
### Transcript directives — inline components the model addresses
|
|
|
|
`TRANSCRIPT_DIRECTIVE_AREA` makes the transcript itself a contribution area.
|
|
Register a named directive and the agent can render your component inline in
|
|
an assistant message by emitting a paragraph of the form `::name{key="value"}`:
|
|
|
|
```javascript
|
|
import { TRANSCRIPT_DIRECTIVE_AREA } from '@hermes/plugin-sdk'
|
|
|
|
ctx.register({
|
|
id: 'task-card',
|
|
area: TRANSCRIPT_DIRECTIVE_AREA,
|
|
data: {
|
|
name: 'task', // the model writes ::task{id="BB-12"}
|
|
render: ({ attrs, streaming }) => jsx(TaskCard, { taskId: attrs.id, streaming })
|
|
}
|
|
})
|
|
```
|
|
|
|
Rules the host enforces so the surface stays safe:
|
|
|
|
- The directive must be the **entire paragraph** — `::name` mid-prose stays
|
|
prose, so plugin components can never hijack running text.
|
|
- Attributes are **untrusted model output** (`key="value"` pairs, string-only).
|
|
Validate your own fields; render nothing on garbage rather than guessing.
|
|
- An **unclaimed** directive (no plugin registered for the name) renders as
|
|
the plain paragraph it always was — nothing breaks when a plugin is off.
|
|
- Renders are wrapped in the contribution error boundary: a throw degrades to
|
|
an inline error chip, never a dead message.
|
|
- First registration wins on a name collision; namespace adventurous names
|
|
with your slug (`myplugin-board`, not `board`).
|
|
|
|
Core ships one directive as the reference consumer: `::preview{file="…"}`
|
|
renders the workspace HTML file **live inside the message** — a sandboxed
|
|
`srcdoc` iframe with an opaque origin (scripts run and the widget is fully
|
|
interactive; no reach into the app, its storage, or the bridge). The frame
|
|
sizes itself to the content (height live, width adopted from the content's
|
|
intrinsic span, flush left in the message flow), and a theme prelude hands
|
|
the document the app's resolved tokens (`--foreground`, `--muted-foreground`,
|
|
`--accent`, `--border`, `--card`), the app font, and a transparent
|
|
background — so widget-shaped HTML reads as native while a full page keeps
|
|
its own design. Non-HTML targets and remote gateways fall back to the
|
|
classic preview card. Tell the agent about your directive in a skill (that's
|
|
how it learns to emit it).
|
|
|
|
Previewed widgets can also **talk back**. Inside the frame,
|
|
`window.hermes.send('get-price eth')` (or a declarative
|
|
`<button data-hermes-send="get-price eth">` — no script needed) hands that
|
|
prompt to the agent as a user turn, off-screen: no bubble takes up the
|
|
transcript, the widget updating is the visible response. The turn is still
|
|
real — it wakes the agent, rides the composer's steer/queue rules, and
|
|
persists (typed `hidden`) so resume and the session DB keep the full record.
|
|
Prompts are trimmed, capped at 500 chars, and throttled to one per second
|
|
per frame.
|
|
|
|
### 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 header control
|
|
leaves when the page unmounts), render `<Contribute>` inside it instead:
|
|
|
|
```javascript
|
|
import { Contribute, WORKSPACE_PAGE_HEADER_AREA } from '@hermes/plugin-sdk'
|
|
|
|
jsx(Contribute, {
|
|
area: WORKSPACE_PAGE_HEADER_AREA,
|
|
id: 'my-page:switcher', // namespace with your slug
|
|
children: jsx(MySwitcher, {})
|
|
})
|
|
```
|
|
|
|
It registers on mount and disposes on unmount automatically.
|
|
|
|
### Sidebar nav visibility and order (`SIDEBAR_NAV_PREFS_AREA`)
|
|
|
|
A plugin hides or re-orders the sidebar's top nav rows by **contributing a
|
|
preference**, not by writing a setting. Core merges every `sidebarNav.prefs`
|
|
contribution at render and applies the result to the rows it would otherwise
|
|
show; the default list itself never changes.
|
|
|
|
```ts
|
|
import { SIDEBAR_NAV_PREFS_AREA, type SidebarNavPrefsContribution } from '@hermes/plugin-sdk'
|
|
|
|
// Payload (`data`) of a sidebarNav.prefs contribution
|
|
interface SidebarNavPrefsContribution {
|
|
hide?: string[] // rows to drop
|
|
order?: string[] // rows to place first, in this order
|
|
}
|
|
|
|
ctx.register({
|
|
id: 'prefs',
|
|
area: SIDEBAR_NAV_PREFS_AREA,
|
|
data: { hide: ['cron'], order: ['capabilities', 'new-session'] } satisfies SidebarNavPrefsContribution
|
|
})
|
|
```
|
|
|
|
Nav ids are the rows' own ids. Core rows: `new-session`, `capabilities`,
|
|
`messaging`, `artifacts`, `cron` (the `SidebarNavId` type; `artifacts` and
|
|
`cron` only render in Advanced mode). A contributed row's id is its
|
|
**registered** `SIDEBAR_NAV_AREA` id, which `ctx.register` namespaces to
|
|
`${pluginId}:${id}` — a plugin that registered `{ id: 'kanban-nav', area:
|
|
SIDEBAR_NAV_AREA }` as `kanban` names that row `'kanban:kanban-nav'` in
|
|
`hide`/`order`.
|
|
|
|
**Arbitration.** Hidden rows are the **union** of every contribution's `hide`
|
|
(no plugin can un-hide another's row; hide beats order), except
|
|
`capabilities`: the row hosting the Plugins tab is the user's path to a
|
|
plugin's own off-switch, so it can be moved but never hidden. Contributions
|
|
apply in the registry's area order — **lowest `order`, then registration** —
|
|
and the first one's `order` wins: later contributions place only ids not yet
|
|
placed, rows no order names keep their default relative order after the
|
|
named ones. Unknown ids are inert.
|
|
|
|
**Teardown.** The contribution lives in the registry, so disabling or reloading
|
|
the plugin disposes it and the rows come straight back — nothing to clear.
|
|
This is why it is not a `host.sidebar.hide()` verb: `host` is a singleton that
|
|
cannot attribute a write, a persisted preference would outlive the plugin, and
|
|
two plugins would overwrite each other's order.
|
|
|
|
**Persisting the user's choice** is the plugin's job, in its own
|
|
`ctx.storage`: read the saved prefs on `register`, contribute them, and on every
|
|
edit save + dispose + re-contribute (re-registering the same `id` replaces it).
|
|
|
|
```ts
|
|
// sidebar-manager: replaces `[data-sbm-off] { display:none }` + re-parenting <li>s
|
|
let dispose = () => {}
|
|
const apply = (prefs: SidebarNavPrefsContribution) => {
|
|
dispose()
|
|
dispose = ctx.register({ id: 'prefs', area: SIDEBAR_NAV_PREFS_AREA, data: prefs })
|
|
}
|
|
apply(ctx.storage.get('navPrefs', {}))
|
|
// in the editor's onChange:
|
|
ctx.storage.set('navPrefs', next); apply(next)
|
|
```
|
|
|
|
Session sections (Pinned, Recents, Cron jobs) are not covered — nav rows only.
|
|
|
|
## 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<string | null>
|
|
host.state.awaitingResponse // ReadableAtom<boolean> true until the first assistant payload
|
|
host.state.busy // ReadableAtom<boolean> focused chat is working after a send
|
|
host.state.busyBySession // ReadableAtom<Record<string, boolean>> runtime id → mid-turn
|
|
host.state.focusedSessionId // ReadableAtom<string | null> (runtime id of the FOCUSED session — tile-aware; prefer for session.* RPC)
|
|
host.state.focusedSessionProfile // ReadableAtom<string> (owner profile of the focused chat — prefer over `profile` for per-bot/profile readouts)
|
|
host.state.focusedStoredSessionId // ReadableAtom<string | null> (durable id — navigation / session-list matching)
|
|
host.state.focusedUsage // ReadableAtom<UsageStats | null> (live streamed usage of the focused session, no RPC needed)
|
|
host.state.cwd // ReadableAtom<string>
|
|
host.state.gateway // ReadableAtom<string> socket state ('idle' | 'connecting' | 'open' | …)
|
|
host.state.model // ReadableAtom<string>
|
|
host.state.profile // ReadableAtom<string>
|
|
host.state.viewport // ReadableAtom<{ width, height, narrow }>
|
|
```
|
|
|
|
`host.state.gateway` is the WebSocket connection, not whether a chat turn is
|
|
running. A session can be mid-turn while the socket is `open`; another session
|
|
can be idle at the same time. Disable composer or plugin actions from the
|
|
**focused session's** turn-busy (`host.state.busyBySession[sessionId]`, or that
|
|
session's `view.$busy`) — never from `gateway`, and never from a process-global
|
|
busy flag.
|
|
|
|
```ts
|
|
host.notify({ kind, message, title?, detail?, action? }) // toast; returns id
|
|
host.notifyError(error, fallbackMessage) // toast an error
|
|
ctx.os.notify({ title, body?, silent?, icon?, activate?, onActivate?, actions? })
|
|
// native OS notification (attributed to your plugin)
|
|
ctx.os.openExternal(url) // OS default handler (browser, mail, spotify:) → Promise<boolean>
|
|
ctx.os.revealPath(path) // reveal in Finder / Explorer → Promise<boolean>
|
|
ctx.os.writeClipboard(text) // system clipboard → Promise<boolean>
|
|
host.navigate('/route') // hash-route navigation
|
|
host.openSession(id, { profile?, intent? }) // open a stored session core-style;
|
|
// profile: soft-swap to that profile's backend first
|
|
// intent: 'in-place' (default) | 'stack' | 'tab' | 'window'
|
|
host.newChat(profile?) // fresh chat draft, optionally in another profile
|
|
host.openWorkspace(id, { render, title?, minWidth?, onClose? })
|
|
// dock a plugin-rendered tab into the MAIN
|
|
// workspace zone and reveal it; returns a disposer
|
|
host.paneVisibility(paneId) // ReadableAtom<boolean> — is a contributed pane
|
|
// actually on screen (its zone's active tab)?
|
|
host.onEvent(type, fn) // gateway event stream ('*' = all); returns disposer.
|
|
// Calls made during register() are retired with the
|
|
// plugin; elsewhere prefer ctx.onEvent (always tracked)
|
|
host.logs(...) // tail an app log file
|
|
host.status() // one-shot system status snapshot
|
|
host.restartGateway() // restart the backend gateway
|
|
host.profileRoutes() // [{ profile, targetProfile, connectionId, mode }]
|
|
host.requestProfile<T>(route, method, params?, timeoutMs?, { spawnPriority? }) // registry-routed RPC; no foreground swap
|
|
host.requestProfile<T>(profile, method, params?) // legacy v1/local overload
|
|
host.request<T>(method, params?) // active-gateway JSON-RPC — the real power
|
|
host.sessions.pin(storedSessionId, pinned?, index?) // pin/unpin (default pinned=true); index = slot in Pinned;
|
|
// same store the row's ⇧-click / drop writes
|
|
host.sessions.reorder(ids) // replace the manual Recents order (what a drag persists); [] resets
|
|
host.sessions.reorderPinned(ids) // permute the Pinned section (the pinned drag path)
|
|
host.sessions.setColor(storedSessionId, color | null) // per-session colour override; null clears
|
|
host.skills.list(profile?) // every skill for the scope (Capabilities endpoints)
|
|
host.skills.setEnabled(name, on, profile?) // enable/disable a skill — the Capabilities toggle
|
|
host.toolsets.list(profile?) // toolsets + enabled state
|
|
host.toolsets.setEnabled(name, on, profile?)// enable/disable a toolset
|
|
host.profiles.list(scope?: ProfileScope) // the profile list the profile rail reads
|
|
host.pluginDecisions // READ-ONLY atom: this window's plugin on/off decisions (frozen copies)
|
|
```
|
|
|
|
`host.request` is the same JSON-RPC the app itself uses (sessions, config, skills,
|
|
cron, kanban, …). `host.requestProfile` accepts a descriptor from
|
|
`host.profileRoutes()` and routes that RPC through its exact registry source and
|
|
profile without changing the active chat or gateway. The profile-only overload is
|
|
retained only for the sole-local/legacy topology; registry-aware plugins should pass
|
|
the descriptor so two sources exposing the same profile name cannot collide.
|
|
|
|
A call that may cold-start a pooled profile backend dials at background priority by
|
|
default, and background dials never get the slot the pool keeps free for user actions.
|
|
When the call IS a user action (a save, a button press, a dialog opening), pass
|
|
`host.requestProfile(route, method, params, undefined, { spawnPriority: 'foreground' })`;
|
|
otherwise, with the pool full of warm backends, it waits out the 30-second slot timeout
|
|
and fails. Keep the background default for polling and roster warming.
|
|
|
|
`host.openWorkspace(id, { render, title?, minWidth?, onClose? })` docks a
|
|
plugin-rendered view into the **main workspace zone** — the same center area
|
|
session tiles and previews use — as a tab, and reveals it. Re-calling it with
|
|
the same `id` refreshes the content in place and re-fronts the tab instead of
|
|
opening a duplicate. Closing the tab (the tab's Close control or ⌘W) tears the
|
|
registration down and fires your `onClose`; the returned disposer closes it
|
|
programmatically. Feature-detect it (`typeof host.openWorkspace ===
|
|
'function'`) and fall back to a regular contributed pane on older desktop
|
|
builds — Bot Mode's group-chat rooms are the reference consumer (main-window
|
|
takeover when available, in-panel view otherwise).
|
|
|
|
`host.paneVisibility(paneId)` returns a readonly reactive atom that is `true`
|
|
while a contributed pane is actually on screen: present in the layout tree,
|
|
not dismissed or hidden, its zone un-minimized, and holding its zone's active
|
|
tab slot (a lone pane in its own zone counts). The id is the
|
|
contribution-scoped pane id, `<pluginId>:<paneId>`. Atoms are memoized per id,
|
|
so calling it in render is safe. Use it to register companion UI only while
|
|
your pane is visible — Bot Mode's Cronjobs pane is the reference consumer: it
|
|
registers while the Bots pane holds the sidebar tab and unregisters when the
|
|
user tabs back to Sessions. Feature-detect on older desktops
|
|
(`typeof host.paneVisibility === 'function'`) and fall back to
|
|
always-registered behavior.
|
|
|
|
`host.profileRoutes()` inventories every registered source in the current connection
|
|
registry. Connect-on-demand SSH sources expose a credential-free `default` seed
|
|
route without opening a tunnel, so a plugin can be the first caller that dials them;
|
|
an SSH `remoteProfile` remains the route's backend `targetProfile`. `connectionId`
|
|
is the registry routing identity;
|
|
pair it with `profile` for keys and persistence. Endpoint, token, SSH host/key, and
|
|
other raw connection fields never cross the plugin IPC boundary. `profile` is the
|
|
source-local route used
|
|
for requests; `targetProfile` is the backend Hermes profile served by that route.
|
|
They differ when a route explicitly maps to another backend profile (for example an
|
|
SSH `remoteProfile` override or a legacy per-profile URL alias). This distinction
|
|
preserves backend identity without exposing connection secrets.
|
|
|
|
Profile-shaped plugins get first-class methods too:
|
|
`profiles.list` (each profile + its most recent conversation as
|
|
`last_session`; pass `include_sessions: false` to skip the per-profile DB
|
|
probe; pass `preferred_session_ids: { profileName: sessionId }` for an
|
|
exact, existence-checked lookup of one pinned session per profile — each
|
|
named row gains a `preferred_session` summary that resolves hidden rows
|
|
and compression lineages to their live tip, or `null` when the id is
|
|
definitively gone; older gateways ignore the param and omit the field)
|
|
and `profiles.create` (`name`, `description`, `clone_from`,
|
|
`clone_all`, `no_skills`, `soul`, optional `model` + `provider` pin) — the
|
|
ws twins of the dashboard's `/api/profiles` REST routes.
|
|
`host.state.busy` is the focused chat's live turn (thinking and streaming).
|
|
`host.state.awaitingResponse` stays true from send until the first assistant
|
|
payload. Both follow the chat the user is actually looking at — the focused
|
|
session tile when one holds focus, else the primary workspace chat (the same
|
|
signal the statusbar's busy pulse reads). Subscribe in a component:
|
|
|
|
```javascript
|
|
const busy = useValue(host.state.busy)
|
|
```
|
|
|
|
For token-level detail, listen with `host.onEvent` (`message.start`,
|
|
`message.delta`, `message.complete`).
|
|
|
|
`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.
|
|
|
|
`ctx.os` is the curated OS door — every way a plugin reaches outside the app
|
|
window, in one namespace attributed to your plugin. `ctx.os.notify` posts a
|
|
**native OS notification** — the same Electron pipeline the app's own
|
|
approval/turn alerts use. It fires only while the user is away from Hermes
|
|
(backgrounded / unfocused); use `host.notify` for the in-app toast when
|
|
they're looking at the app. Users can silence it per device under Settings ▸
|
|
Notifications ▸ "Plugin notifications", and repeats from the same plugin are
|
|
throttled, so treat it as a signal for genuinely notable events — not a log.
|
|
|
|
Rich presentation + activation (extends the original `ctx.os` door):
|
|
|
|
```ts
|
|
ctx.os.notify({
|
|
title: 'New match found',
|
|
body: 'Someone matched your signal',
|
|
icon: '/abs/path/to/icon.png', // Electron Notification icon
|
|
// Body click → focus Hermes + navigate. Same vocabulary as OS deep links:
|
|
activate: 'hermes://index-network/intent/1',
|
|
// or: activate: '/index-network/intent/1'
|
|
// or: activate: { path: '/index-network/intent/1' }
|
|
onActivate: () => focusLocalState('1'), // optional renderer callback
|
|
actions: [
|
|
{ id: 'open', label: 'Open', activate: 'hermes://index-network/intent/1' },
|
|
{ id: 'dismiss', label: 'Dismiss', onAction: () => dismiss('1') },
|
|
],
|
|
})
|
|
```
|
|
|
|
`activate` is deeplink-compatible: `hermes://index-network/intent/1` and the
|
|
hash path `/index-network/intent/1` resolve to the same in-app route (and the
|
|
same `hermes://…` URL works as an OS deep link). Action buttons only render on
|
|
signed macOS builds; elsewhere the body click still activates. Navigation only
|
|
happens on user click — never from a background event alone.
|
|
|
|
The other doors (`openExternal`, `revealPath`, `writeClipboard`) resolve
|
|
`false` instead of throwing when the capability isn't available (older desktop
|
|
shell, plain browser) — branch on the result rather than sniffing the bridge.
|
|
|
|
### Desktop appearance settings — `host.settings`
|
|
|
|
`host.settings` is the supported door for the small set of Desktop-local
|
|
appearance preferences plugins may share with the native Settings page. Every
|
|
key is bound to the store atom + setter the Settings page itself uses, so a
|
|
plugin write is exactly a user click on that control: it takes effect at once,
|
|
persists through the preference's existing storage schema, and the last write
|
|
wins (no plugin "owns" the value afterwards, nothing to tear down for `set`).
|
|
|
|
```ts
|
|
type DesktopSettingValues = {
|
|
'backdrop.v1': boolean
|
|
'composerPopout.gesturesEnabled': boolean
|
|
'intro-splash.v1': boolean
|
|
'reasoning.collapsedByDefault': boolean
|
|
sessionListDensity: 'compact' | 'comfortable' | 'detailed'
|
|
tabStripDefault: 'auto' | 'always' | 'never'
|
|
}
|
|
host.settings.get<K extends DesktopSettingKey>(key: K): DesktopSettingValues[K]
|
|
host.settings.set<K extends DesktopSettingKey>(key: K, value: DesktopSettingValues[K]): void
|
|
host.settings.subscribe<K extends DesktopSettingKey>(key: K, fn: (value: DesktopSettingValues[K]) => void): () => void
|
|
```
|
|
|
|
```ts
|
|
register(ctx) {
|
|
host.settings.set('sessionListDensity', 'detailed')
|
|
|
|
// subscribe emits the current value now, then after every native or plugin write.
|
|
const dispose = host.settings.subscribe('backdrop.v1', enabled => { /* … */ })
|
|
// Teardown rule: `host` is a module singleton and cannot tell which plugin
|
|
// subscribed, so YOU retire the listener — otherwise it outlives a disable/reload.
|
|
ctx.onDispose(dispose)
|
|
}
|
|
```
|
|
|
|
Arbitration: the allowlist above is closed. An unknown key or a value outside
|
|
the key's type throws **synchronously** (`Unsupported desktop setting: …` /
|
|
`Invalid value for desktop setting: …`) and nothing is written — `host.settings`
|
|
never touches `localStorage` directly, so it cannot bypass a store's schema or
|
|
migration. Feature-detect `host.settings` when supporting older Desktop builds.
|
|
|
|
Deliberately **not** keys, and why:
|
|
|
|
| Wanted | Use instead | Why not a raw key |
|
|
|--------|-------------|-------------------|
|
|
| keybind map (`hermes.desktop.keybinds`) | `KEYBINDS_AREA` contribution | a raw map write rebinds every other plugin's shortcuts; the area merges per plugin and is torn down with it |
|
|
| active theme / mode record | `THEMES_AREA` (register a theme; the user selects it) | theme selection is per window/profile and arbitrated by the app, not a flat preference |
|
|
| `pluginDecisions` (desktop plugin on/off) | the app's Plugins tab (a read-only view is a separate SDK hook) | a plugin toggling another plugin's enable state is plugins interfering with each other |
|
|
| `toolView.technical`, `embed-mode`, `titlebarAppActions`, `translucency.v2`, `user-bubble-transparency.v1`, `hermesDesktop.zoom.*` | follow-up keys after each store is audited | some drive the main process or window chrome; each needs its own guard and ownership review before it becomes plugin-writable |
|
|
|
|
Migration — `hermes-appearance-hub`, which today does
|
|
`localStorage.setItem('hermes.desktop.sessionListDensity', id)` followed by
|
|
`window.dispatchEvent(new StorageEvent('storage', …))` to wake the app's store
|
|
(`readSimpleKey`/`writeSimpleKey`, `readBoolKey`/`writeBoolKey`):
|
|
|
|
```ts
|
|
// before
|
|
localStorage.setItem('hermes.desktop.backdrop.v1', String(on))
|
|
window.dispatchEvent(new StorageEvent('storage', { key: 'hermes.desktop.backdrop.v1', newValue: String(on) }))
|
|
// after — the store notifies its own subscribers; no synthetic StorageEvent
|
|
host.settings.set('backdrop.v1', on)
|
|
host.settings.set('sessionListDensity', id) // was hermes.desktop.sessionListDensity
|
|
host.settings.set('tabStripDefault', id) // was hermes.desktop.tabStripDefault
|
|
host.settings.set('reasoning.collapsedByDefault', on) // was hermes.desktop.reasoning.collapsedByDefault
|
|
host.settings.set('composerPopout.gesturesEnabled', on)
|
|
host.settings.set('intro-splash.v1', mode !== 'off') // replaces clicking #setting-field-appearance.intro-splash
|
|
```
|
|
|
|
Reads become `host.settings.get(key)`; its `MutationObserver` on the Settings
|
|
page's intro-splash switch becomes `host.settings.subscribe('intro-splash.v1', fn)`
|
|
(disposer → `ctx.onDispose`). `prompt-snippets` reads
|
|
`localStorage.getItem('hermes.desktop.keybinds')` to back up its shortcut — that
|
|
is the keybind-map row above: contribute the default through `KEYBINDS_AREA` and
|
|
keep the user's override in `ctx.storage`, not in the app's map.
|
|
|
|
### Typed capabilities bridge — `host.skills`, `host.toolsets`, `host.profiles`, `host.pluginDecisions`
|
|
|
|
```ts
|
|
type ProfileScope = undefined | null | string | { connectionId?: null | string; profile?: null | string }
|
|
|
|
host.skills.list(profile?: ProfileScope): Promise<SkillInfo[]>
|
|
host.skills.setEnabled(name: string, enabled: boolean, profile?: ProfileScope): Promise<{ ok: boolean; name: string; enabled: boolean }>
|
|
host.toolsets.list(profile?: ProfileScope): Promise<ToolsetInfo[]>
|
|
host.toolsets.setEnabled(name: string, enabled: boolean, profile?: ProfileScope): Promise<{ ok: boolean; name: string; enabled: boolean }>
|
|
host.profiles.list(scope?: ProfileScope): Promise<{ profiles: ProfileInfo[] }>
|
|
host.pluginDecisions: ReadableAtom<Record<string, boolean>> // get() / subscribe() / listen() — no set()
|
|
```
|
|
|
|
These wrap the **same `api/*` module functions the Capabilities page calls**
|
|
(`GET /api/skills`, `PUT /api/skills/toggle`, `GET /api/tools/toolsets`,
|
|
`PUT /api/tools/toolsets/<name>`, `GET /api/profiles`) with the page's profile
|
|
scoping. Omit `profile` to act on the app-wide active profile; pass a name or a
|
|
`{ connectionId, profile }` route to configure another profile without swapping
|
|
the foreground one. Nothing new is arbitrated: every call is already reachable
|
|
through `host.request` — the value is typing plus profile scoping, so stop
|
|
calling `window.hermesDesktop.api` raw.
|
|
|
|
`host.pluginDecisions` mirrors the app's plugin enable/disable map (plugin id →
|
|
`true`/`false`; an absent id means the user never chose and the plugin's own
|
|
`defaultEnabled` applies). It is **read-only by design**: a `set()` would let one
|
|
plugin flip another plugin's enable state — exactly "plugins messing with each
|
|
other's functionality" — and `host` is a module singleton that cannot tell which
|
|
plugin is calling to restrict a writer to the caller's own id. The object has no
|
|
`set` at runtime, not just in the types, and every value it hands out (`get()`,
|
|
`.value`, the argument to `subscribe`/`listen` callbacks) is a **frozen copy** —
|
|
assigning into it throws instead of leaking into the map the app reads and
|
|
persists. Enabling/disabling plugins stays in the
|
|
app's Plugins tab; link to it with `host.navigate('/capabilities?tab=plugins')`.
|
|
|
|
Teardown: the verbs are discrete user-triggered actions that write the same
|
|
backend state the page writes, so nothing is owned afterwards and there is
|
|
nothing to tear down. A `subscribe()` on `host.pluginDecisions` returns its
|
|
disposer — register it with `ctx.onDispose` so a disabled or reloaded plugin
|
|
stops listening.
|
|
|
|
Migration (better-capabilities):
|
|
|
|
```ts
|
|
// before // after
|
|
desktopApi({ path: '/api/skills' }) host.skills.list()
|
|
desktopApi({ path: '/api/skills/toggle', method: 'PUT', host.skills.setEnabled(name, enabled)
|
|
body: { name, enabled } })
|
|
desktopApi({ path: '/api/tools/toolsets' }) host.toolsets.list()
|
|
desktopApi({ path: `/api/tools/toolsets/${name}`, host.toolsets.setEnabled(name, enabled)
|
|
method: 'PUT', body: { enabled } })
|
|
desktopApi({ path: '/api/profiles' }) host.profiles.list()
|
|
JSON.parse(localStorage.getItem( host.pluginDecisions.get()
|
|
'hermes.desktop.pluginDecisions.v2')) ctx.onDispose(host.pluginDecisions.subscribe(fn))
|
|
localStorage.setItem('hermes.desktop.pluginDecisions.v2') // declined — host.navigate('/capabilities?tab=plugins')
|
|
row.querySelector('[data-slot="switch"]').click() // same: the app's Plugins tab owns the toggle
|
|
```
|
|
|
|
## 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`.
|
|
|
|
`DecodeText`'s `loop` is opt-in as of this change — it decodes once and holds by default, so pass `loop` explicitly on progress surfaces that should keep scrambling.
|
|
|
|
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**.
|
|
|
|
### One package, both SDKs {#one-package-both-sdks}
|
|
|
|
A feature that needs a desktop UI **and** agent-side code (a Python plugin, its
|
|
backend routes, skills) doesn't have to ship as two co-dependent installs. Put a
|
|
`desktop/plugin.js` inside the agent package. When the package lands in any
|
|
local `plugins/` root (default home or a profile), the Electron main process
|
|
copies that half into `$HERMES_HOME/desktop-plugins/<id>/` beside a
|
|
`.hermes-package.json` marker, and the renderer loads it through the exact same
|
|
pipeline as the standalone disk door (hot reload included):
|
|
|
|
```
|
|
~/.hermes/plugins/<id>/ # ONE installable folder
|
|
├── plugin.yaml # the agent half: tools, hooks, commands
|
|
├── skills/…
|
|
├── dashboard/
|
|
│ ├── manifest.json # { "name": "<id>", "api": "plugin_api.py" }
|
|
│ └── plugin_api.py # backend routes → /api/plugins/<id>/
|
|
└── desktop/
|
|
└── plugin.js # the desktop half: panes, commands, ctx.rest
|
|
```
|
|
|
|
The `desktop/plugin.js` half is an ordinary disk plugin — same contract, same
|
|
imports, same `ctx.rest('/…')` reaching the `plugin_api.py` sitting beside it.
|
|
Installing, sharing, or removing the feature is one folder: the app-root copy
|
|
is refreshed when the source `plugin.js` changes (`hermes plugins update`, or
|
|
**Rescan**) and removed when the package folder disappears. The copy is what
|
|
makes the desktop half **app-level**: it exists once, however many profiles
|
|
carry the package, and it never appears or disappears when the user switches
|
|
the Capabilities profile selector. The renderer never scans `plugins/` itself.
|
|
The marker records the package name and its origin (catalog sidecar or git
|
|
remote), which is what the **Install here** button on the Plugins page uses to
|
|
install the agent half into another profile. The copy is staged beside the
|
|
target and renamed into place, so an interrupted copy (a transient file lock, a
|
|
crash mid-copy) never leaves a half-written folder behind; a leftover
|
|
`desktop-plugins/<id>/` that has no marker and no `plugin.js` is treated as
|
|
such damage and replaced on the next **Rescan**, while a marker-less folder
|
|
that *does* hold a `plugin.js` is a standalone plugin you installed by hand and
|
|
is never overwritten.
|
|
|
|
Two enable switches still apply, on purpose, and both default to **off**: the
|
|
desktop half ships opt-in — it inventories in **Capabilities → Plugins** but stays
|
|
disabled until the user toggles it — matching the Python half's
|
|
`plugins.enabled` gate in `config.yaml` (the security boundary below). Dropping
|
|
a package into `~/.hermes/plugins` is inert on every surface until the user
|
|
says otherwise. The desktop half degrades gracefully when the backend half is
|
|
off — `ctx.rest` returns errors, not crashes.
|
|
|
|
:::note
|
|
The copy is local to the machine the desktop app runs on. Against a remote
|
|
backend, the remote box's `~/.hermes/plugins` is not reachable as a filesystem —
|
|
only locally installed packages contribute a desktop half this way. For a
|
|
remote backend the install dialog clones the desktop half separately into
|
|
`desktop-plugins/`, the same as a desktop-only repo. A package whose agent half
|
|
was installed on the remote host without that clone shows its Desktop half as
|
|
**unavailable (remote backend)** on the Plugins page — not as a pending copy —
|
|
and the tooltip points at **Install from Git** with the Desktop target checked.
|
|
:::
|
|
|
|
### Distributing with an install link {#install-link}
|
|
|
|
Ship your plugin repo (agent half, desktop half, or both) and link to it with
|
|
the `hermes://` scheme — a plain anchor on your website or README:
|
|
|
|
```html
|
|
<a href="hermes://plugin/install?repo=owner/repo&enable=1">Install in Hermes</a>
|
|
```
|
|
|
|
The user gets a confirmation dialog (repo id, source links, a probe of what
|
|
the repo ships) and picks components before anything is installed — deep links
|
|
never auto-install. `force=1` replaces an existing install; dev builds use
|
|
`hermes-dev://`. Full link reference:
|
|
[One-click install links](../user-guide/features/plugins.md#one-click-install-links-desktop).
|
|
|
|
### 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/<id>/
|
|
└── dashboard/
|
|
├── manifest.json # { "name": "<id>", "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/<id>/` (`GET /api/plugins/<id>/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.md#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 **Capabilities → 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).
|
|
:::
|
|
|
|
#### Pushing events to your desktop half
|
|
|
|
Your backend runs inside the gateway process, so it can push an update to your
|
|
own desktop half over the app's global event stream — the same stream
|
|
`host.onEvent` subscribes to:
|
|
|
|
```python
|
|
from hermes_cli.plugin_events import broadcast_plugin_event
|
|
|
|
broadcast_plugin_event("rss-reader", "feed.updated", {"count": 3})
|
|
# → event "plugin.rss-reader.feed.updated" reaches every connected desktop client
|
|
```
|
|
|
|
```javascript
|
|
// register(ctx): the subscription is retired with the plugin
|
|
host.onEvent('plugin.rss-reader.feed.updated', ({ payload }) => refreshFeeds(payload))
|
|
```
|
|
|
|
`broadcast_plugin_event(plugin_id, event, payload=None)`: the wire name is
|
|
always `plugin.<plugin_id>.<event>`. `plugin_id` is your catalog name
|
|
(`[a-z0-9_-]{1,64}`, no dots — it is the namespace and can't spell another
|
|
plugin's); `event` is the BARE dotted name (`"feed.updated"`, not
|
|
`"plugin.rss-reader.feed.updated"`), segments of `[A-Za-z0-9_-]`, so `""`,
|
|
`"../x"` or `"a..b"` raise `ValueError` instead of stranding the desktop half
|
|
on a name nobody emits. `payload` is a JSON dict (or omitted → `{}`), delivered
|
|
as the event's `payload`; the frame carries `session_id: ""` like every global
|
|
event. Delivery is fire-and-forget (a wedged client is skipped, never stalling
|
|
your handler). Where it lands depends on the process the call runs in:
|
|
|
|
| Caller runs in | Reaches |
|
|
|---|---|
|
|
| `hermes serve` (the Desktop backend): `plugin_api.py` routers, plugin slash commands, tools and hooks in the agent turn | every connected Desktop window |
|
|
| the `dashboard.turn_isolation` compute-host child (tools/hooks of an isolated turn) | relayed over the host pipe to `hermes serve`, then every window |
|
|
| the stdio TUI (`hermes` in a terminal) | that terminal's client |
|
|
| `hermes gateway run` (messaging platforms), `hermes chat`, cron, `hermes plugins validate` | nobody — no Desktop client is attached to that process; the call is a logged no-op |
|
|
|
|
Use this instead of importing `tui_gateway.server` internals; for plugin-scoped frames
|
|
with a payload tailored per connection, `ctx.socket('/events')` remains the
|
|
richer door.
|
|
|
|
Migration (rss-reader): drop the `~/.hermes/rss-reader/commands.jsonl` queue,
|
|
`GET /commands` and the 3 s `ctx.rest('/commands')` poll — the Python side
|
|
calls `broadcast_plugin_event('rss-reader', 'feed.updated', payload)` where it
|
|
used to enqueue, and the desktop side replaces the timer with
|
|
`host.onEvent('plugin.rss-reader.feed.updated', fn)` inside `register(ctx)`.
|
|
|
|
### Calling it from the plugin
|
|
|
|
```javascript
|
|
register(ctx) {
|
|
// REST — namespace-relative path.
|
|
const load = () => ctx.rest('/board') // GET /api/plugins/<id>/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 **Capabilities → 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.<id>.*`) 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/<id>/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`) and the `window.hermesDesktop` native bridge
|
|
(files, git, terminal, installs). The isolation the loader provides is **error
|
|
isolation only**: a plugin can't crash the app (contributions are error-bounded,
|
|
listeners isolated, a throwing `register()` is rolled back and reported on the
|
|
plugin's row), but it can do anything the app can. Plugin storage namespaces
|
|
are a convention, not a wall.
|
|
|
|
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. For [catalog](../user-guide/features/plugin-catalog.md#trust-model)
|
|
installs the trust comes from admission — a human reviewed the exact pinned
|
|
commit — backed by two tripwires: the `desktop surface` lint at admission and
|
|
the loader's import allowlist (`@hermes/plugin-sdk` and `react*` only; a static
|
|
or dynamic `import` of anything else, including `https:` URLs, fails the load).
|
|
Neither is a 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.
|
|
- **Bare globals are not tracked.** `window.setInterval`, `window.addEventListener`,
|
|
a `<style>` you append — the host never sees them, so they survive disable and
|
|
every hot-reload (ES modules can't be unloaded; a hot-edit loop stacks live
|
|
copies). Use `ctx.setTimeout` / `ctx.setInterval` / `ctx.addEventListener`, and
|
|
wire anything else to `ctx.onDispose`. Module-scope state is yours to reset.
|
|
- **Module evaluation has a 10 s deadline.** A top-level `await` that never
|
|
settles (waiting for a gateway that isn't up) fails the load as `import timed
|
|
out` instead of stalling the plugin scan; do the waiting inside `register()`.
|
|
- **One id, one file.** Two folders exporting the same `id` (a standalone install
|
|
beside a unified-package copy) load first-wins in folder-name order; the later
|
|
one shows `duplicate id` on its own row in Capabilities ▸ Plugins.
|
|
- **`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.*`, `.settings`, `.notify`, `.notifyError`, `.navigate`, `.onEvent`, `.logs`, `.status`, `.restartGateway`, `.request`, `.composer`, `.sessions`, `.skills`, `.toolsets`, `.profiles`, `.pluginDecisions`) |
|
|
| Plugin contract | `HermesPlugin`, `PluginContext`, `PluginContribution`, `PluginStorage`, `PluginOs`, `PluginRestOptions`, `PluginNativeNotificationInput`, `PluginNotificationAction`, `HermesOpenTarget`, `Contribution` |
|
|
| Area constants | `PANES_AREA`, `ROUTES_AREA`, `SIDEBAR_NAV_AREA`, `STATUSBAR_AREAS`, `TITLEBAR_AREAS`, `WORKSPACE_PAGE_HEADER_AREA`, `PALETTE_AREA`, `KEYBINDS_AREA`, `THEMES_AREA`, `COMPOSER_AREAS`, `SESSION_ROW_AREAS`, `SIDEBAR_NAV_PREFS_AREA`, `APPEARANCE_AREAS` |
|
|
| Area payloads | `RouteContribution`, `SidebarNavContribution`, `StatusbarItem`, `TitlebarTool`, `PaletteContribution`, `KeybindContribution`, `ComposerMiddleware`, `ComposerAttachmentProvider`, `SessionRowSlotContribution`, `SidebarNavPrefsContribution` |
|
|
| React / state | `useValue`, `atom`, `computed`, `useQuery`, `useMutation`, `useQueryClient`, `queryClient`, `Contribute` |
|
|
| Theming | `useTheme`, `requestTheme`, `setAccentOverride`, `$accentOverride`, `retintTheme`, `themeHue`, `DesktopTheme`, `DesktopThemeColors`, plus OKLCH math (`hexToOklch`, `oklchToHex`, `oklchToSrgb255`, `mixOklab`, `maxChroma`, `hueDelta`, `normalizeHex`) and sRGB measures (`contrastRatio` — `number | null`, null for unparseable input — `readableOn`) |
|
|
| 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`, `SandboxedFrame` |
|
|
| 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/<id>/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/<id>/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 <id> 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.
|