SDK плагинов десктопа
Нативное приложение Hermes Desktop построено на вкладах: каждая поверхность окна — панели, маршруты, боковая навигация, элементы строки состояния, пункты палитры, горячие клавиши, темы — регистрируется в одном общем реестре. Ядро регистрирует свои поверхности ровно так же, как это делает плагин, поэтому история с плагинами здесь настоящая, а не приделанная сбоку.
Плагин десктопа — это один ESM-файл, который экспортирует по умолчанию объект
HermesPlugin. Он импортирует единственный модуль @hermes/plugin-sdk и получает
всё: живое состояние приложения, дверь JSON-RPC к шлюзу, собственный
REST/сокет-неймспейс на бэкенде, React Query и UI-кит самого приложения, благодаря
которому интерфейс плагина по умолчанию выглядит родным. Ни клонирования репозитория,
ни npm run build, ни правки исходников приложения. Кладёте файл в
$HERMES_HOME/desktop-plugins/<id>/plugin.js — приложение подхватывает его за пару
секунд и перезагружает на лету после каждого сохранения.
Слово «плагин» в Hermes означает несколько несвязанных вещей. Эта страница — про SDK
нативного десктопного приложения (hermes desktop): модуль @hermes/plugin-sdk
и каталог $HERMES_HOME/desktop-plugins/. У веб-панели (hermes dashboard) своя,
никак не связанная система плагинов на window.__HERMES_PLUGIN_SDK__ с
manifest.json — она описана в разделе
Расширение панели. Python-плагины для
CLI и шлюза описаны в разделе Плагины Hermes.
Эти три системы не делят ни код, ни API, ни способ доставки. Общий у SDK десктопа и
панели только backend-неймспейс plugin_api.py (/api/plugins/<id>).
Как об этом думать
SDK повторяет модульную модель VS Code. Автор плагина импортирует ровно один модуль и никогда не лезет во внутренности приложения — линтер отгораживает их в собранном плагине, а в дисковом они просто не резолвятся. Возможности идут уровнями:
host.state.*— представления живого состояния приложения только на чтение (атомы nanostore): активная сессия, занятость хода по каждой сессии, рабочий каталог, статус сокета шлюза, модель, профиль, вьюпорт.gateway— это WebSocket, а не занятость хода.- Действия
host.*— отобранные безопасные глаголы: показать тост, перейти, читать логи, перезапустить шлюз, подписаться на поток событий шлюза. host.request— дверь JSON-RPC к шлюзу: сессии, конфигурация, навыки, cron — всё, что вызывает само приложение.captureGatewayFileDownload()— захватывает действие сохранения файла со шлюза непосредственно перед запуском REST-чтения; храните его рядом с полученными данными. Действие(storedPath, suggestedName) => Promise<void>сохраняет область подключения и профиля этого чтения, даже если пользователь до клика переключился на другой хост. Вызывайте его только по явному жесту пользователя «скачать» и с сохранённым на бэкенде путём к файлу — никогда с угаданным путём в workspace. Аутентифицированную потоковую передачу, нативный диалог сохранения и запасной путь для старых шлюзов берёт на себя Electron; плагин не получает учётные данные и не открывает удалённые пути черезfile://. Хост показывает те же тосты «Saved» / «Download failed», что и панель Files, а при отмене молчит; промис разрешается вместе с завершением сохранения и никогда не отклоняется.ctx.restиctx.socket— ваш собственный неймспейс на бэкенде (/api/plugins/<id>), если вы поставляетеplugin_api.py.ui.*— язык дизайна: настоящие компоненты приложения, переменные темы, иконки и форматтеры, чтобы ваш интерфейс совпадал с приложением до пикселя.
Два способа доставки
| Способ | Где | Кто пишет | Сборка |
|---|---|---|---|
| С диска (рекомендуется) | $HERMES_HOME/desktop-plugins/<id>/plugin.js | пользователи, агенты | не нужна — обычный ESM, грузится без компиляции |
| Единый пакет | $HERMES_HOME/plugins/<id>/desktop/plugin.js | плагины, которые попутно поставляют агентскую часть | не нужна — тот же дисковый конвейер |
| Встроенный | apps/desktop/src/plugins/<id>/plugin.tsx | внутри репозитория, поставляется с приложением | сборка Vite самого приложения |
Все три принимают один и тот же контракт HermesPlugin, попадают в Capabilities →
Plugins и включаются-выключаются на лету. Единый пакет — это просто дисковая дверь,
сканирующая папку внутри вашего агентского плагина, — см.
Один пакет, оба SDK. Вся эта страница написана про дисковый путь
— именно его пишете вы и ваш агент; про два отличия встроенного варианта сказано в
разделе Встроенные плагины. Radio поставляется как встроенный
плагин только на SDK и по умолчанию выключен. Включите его в Capabilities → Plugins —
получите бесплатные живые радиопотоки, поиск станций и управление воспроизведением в
строке состояния с волной, реагирующей на звук. Плагин пользуется обычным переключателем и
в выключенном состоянии не добавляет ничего. Демонстрационные примеры живут в соседнем
репозитории
hermes-example-plugins.
Быстрый старт — первый плагин
Создайте $HERMES_HOME/desktop-plugins/hello/plugin.js (по умолчанию это
~/.hermes/...). Десктопные плагины — уровня приложения: один корень для всех профилей, шлюзов
и удалённых машин, к которым подключается окно. Имя папки обязано совпадать с id плагина.
// ~/.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'
})
})
}
}
Сохраните файл. Приложение следит за desktop-plugins/, подхватывает его за несколько
секунд и дальше перезагружает на месте после каждого сохранения. Не появился — нажмите
⌘K → Reload desktop plugins. Если загрузка сорвалась, тост назовёт ошибку:
чините и сохраняйте снова.
Дисковый файл грузится без компиляции, поэтому синтаксис JSX не разберётся. Пишите
интерфейс вызовами jsx() и jsxs() из react/jsx-runtime (или через
React.createElement). Импортировать можно только @hermes/plugin-sdk, react и
react/jsx-runtime — всё остальное намеренно не резолвится.
Контракт плагина
Плагин экспортирует по умолчанию объект HermesPlugin:
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 приходит ограниченный PluginContext. Напрямую до реестра он не
достаёт: контекст сам проставляет происхождение (source: 'plugin:<id>') и добавляет
неймспейс к каждому id вклада (<id>:<localId>), так что два плагина столкнуться не
могут в принципе.
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
/** Own entry (+ sub-pages) under Settings → Plugins. Removed on disable/unload. */
registerSettingsPage: (page: PluginSettingsPage) => () => 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
/** Lines in the core pet's speech bubble, attributed to this plugin (see "Pet bubble"). */
pet: PluginPet
/** Plugin-scoped JSON persistence (keys live under `hermes.plugin.<id>.`). */
storage: PluginStorage
}
Вклад — та единственная примитивная сущность, которую делят все поверхности:
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)
}
В зависимости от области вы задаёте render, data или оба сразу.
Области вкладов — сборник рецептов
Константы областей импортируются из SDK; у каждой области своя полезная нагрузка data.
| Поверхность | area | Что задаёте |
|---|---|---|
| Панель в раскладке | PANES_AREA ('panes') | title + render + data: { placement, dock?, width?, height? } |
| Целая страница | ROUTES_AREA | data: { path } + render |
| Боковая навигация | SIDEBAR_NAV_AREA | data: { path, label, codicon } |
| Строка состояния | STATUSBAR_AREAS.left / .right | render (либо data как StatusbarItem) |
| Заголовок окна | TITLEBAR_AREAS.left / .center / .right | data как TitlebarTool либо <Contribute>, привязанный к монтированию |
| Шапка страницы | WORKSPACE_PAGE_HEADER_AREA | <WorkspacePageHeaderControl> внутри вашей страницы (в плитке с разделением отображается на месте) |
| Палитра ⌘K | PALETTE_AREA | data: PaletteContribution |
| Горячая клавиша | KEYBINDS_AREA | data: KeybindContribution |
| Тема | THEMES_AREA | data как DesktopTheme |
| Поле ввода | COMPOSER_AREAS.* | слоты рендера либо middleware и провайдеры вложений |
| Строки меню моделей | MODEL_MENU_ROW_AREA | data: ModelMenuRowContribution — ведущая иконка и замыкающий бейдж у каждой модели |
| Настройки оформления | APPEARANCE_AREAS.extra | render — контролы, добавляемые в Settings → Appearance |
| Страница настроек плагина | SETTINGS_PLUGINS_AREA ('settings.plugins') | Вызовите ctx.registerSettingsPage({ id, title, render, icon?, order?, children? }) — собственный пункт (с подстраницами) в Settings → Plugins |
Панели
Панель — плитка в дереве раскладки. Параметр placement задаёт смысловую роль:
панель встанет вкладкой к уже существующим панелям этой роли, а дальше пользователь
перетащит её куда захочет.
ctx.register({
id: 'pane',
area: 'panes',
title: 'my pane',
data: { placement: 'right', width: '260px' },
render: () => jsx(MyPane, {})
})
Значения placement — 'main' | 'left' | 'right' | 'top' | 'bottom'. Чтобы встать не
вкладкой, а к конкретному краю, добавьте жест dock — то же самое, что перетащить
панель на её drop-чип:
// Below the conversation, 200px tall.
data: {
placement: 'bottom',
dock: { pane: 'workspace', pos: 'bottom' },
height: '200px'
}
В dock.pane годится id любой панели (workspace — основная ветка обсуждения, есть
также sessions, terminal, files, review, logs), а dock.pos принимает
'top' | 'bottom' | 'left' | 'right' | 'center'. Задайте width или height, иначе
панель заберёт половину зоны.
Если закрыть единственную панель, которую поставляет плагин, сам плагин отключается — снова включить его можно в Capabilities → Plugins. Если плагин поставляет несколько панелей, закрытие одной убирает только её: остальные панели, команды и middleware плагина остаются активны. Reset layout возвращает закрытые панели-вклады на место.
Страницы и боковая навигация
Маршрут монтирует целую страницу в рабочую панель — как любой встроенный экран. Чтобы до неё можно было добраться, добавьте строку в боковую навигацию, команду в палитру или и то и другое.
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 — идентификатор кодикона VS Code.
Перейти на маршрут откуда угодно можно через host.navigate('/my-page').
Строка состояния и заголовок окна
Элементы строки состояния рисуются в левой или правой группе нижней панели. Проще всего
задать функцию render; для обычной кнопки достаточно data в виде StatusbarItem
({ id, label?, icon?, detail?, variant?, menuItems?, … }).
import { STATUSBAR_AREAS, TITLEBAR_AREAS } from '@hermes/plugin-sdk'
ctx.register({
id: 'count',
area: STATUSBAR_AREAS.right,
order: 120,
render: () => jsx(MyStatus, {})
})
Инструменты заголовка живут в TITLEBAR_AREAS.left | .center | .right как данные
TitlebarTool ({ id, label, icon, active?, onSelect? }).
Слоты заголовка — постоянные точки монтирования: зарегистрированный там компонент
остаётся смонтированным, пока пользователь ходит между чатом и полноэкранными страницами
(Capabilities, Messaging, Artifacts, страницы-вклады). Поэтому useEffect, который
добавляет глобальные побочные эффекты (тег <style>, атрибуты html[data-*],
MutationObserver), выполняет настройку один раз на регистрацию, а очистку — один раз
при dispose, и никогда посреди навигации.
Контролы, которые относятся к ОДНОЙ странице (переключатель досок Kanban), кладите в
WORKSPACE_PAGE_HEADER_AREA: эта область рисуется в строке заголовков вкладок рабочей
панели, пока страница на экране, а в остальное время пуста. Оберните контрол в
<WorkspacePageHeaderControl> (см. ниже) внутри собственной строки заголовка вашей
страницы. В рабочей панели он проецируется в шапку страницы; если страница открыта в
плитке разделённого маршрута, где шапки страницы нет, он рисуется на месте, куда вы его
поставили. Голый <Contribute area={WORKSPACE_PAGE_HEADER_AREA}> виден только в
рабочей панели.
Команды палитры и горячие клавиши
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()
}
}
])
Горячие клавиши пользователь может переназначить в настройках; defaults — лишь
начальная привязка.
Чтобы запустить собственное действие приложения (переключить панель браузера,
открыть Settings, сфокусировать поле ввода), никогда не отправляйте синтетическое
KeyboardEvent с его сочетанием клавиш: пользователь мог его переназначить. Вызывайте
ctx.runAction(id).
Темы
Вклад-тема поставляет полноценный DesktopTheme в поле data (имя, подпись, цвета и
прочее) и появляется в выборе тем наравне со встроенными.
import { THEMES_AREA } from '@hermes/plugin-sdk'
ctx.register({ id: 'noir', area: THEMES_AREA, data: myDesktopTheme })
Регистрация темы добавляет её в список, но не выбирает. Прочитать текущее оформление
(theme, themeName, availableThemes, resolvedMode) и сменить его (setTheme,
setMode, previewTheme) прямо из компонента даёт useTheme():
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>
))
}
Когда переключение идёт не из рендера — подключился шлюз, пришло событие сокета,
сработал любой колбэк host.onEvent, — компонента, на который можно повесить хук,
просто нет. Там применяйте requestTheme(name). Неизвестное имя не подменяется скином
по умолчанию, а отклоняется, так что возвращаемое значение заодно работает проверкой
доступности, а опечатка в имени не сбросит чужое оформление втихую:
import { host, requestTheme } from '@hermes/plugin-sdk'
host.onEvent('gateway.ready', () => {
if (!requestTheme('noir')) {
host.notifyError('Connected, but the noir theme is not installed.')
}
})
Оба пути сохраняются по профилю, поэтому переключение из плагина закрепляется ровно так
же, как ручной выбор. Чтобы подкрасить активную тему, а не заменить её, возьмите
setAccentOverride(hex) и сбросьте его в ctx.onDispose — разобранный пример есть в
официальном плагине Accent Picker
(заодно это полноценный устанавливаемый дисковый плагин).
Стилизация переключения чата — data-session-switching
Открытие чата размещает его транскрипт по шагам: сессия загружается, строки встают на
место, а восстановленная позиция прокрутки устаканивается ещё через несколько кадров.
Тема, которой нужно скрыть (или приглушить) этот процесс, опирается на один
документированный атрибут и не следит ни за маршрутом, ни за DOM: ядро ставит
data-session-switching="true" на корень поверхности чата ([data-chat-surface]) с кадра,
когда началось переключение, и до тех пор, пока строки нового транскрипта не окажутся на
экране, а его прокрутка не устаканится, после чего снимает атрибут.
/* Hide the transcript while it is being placed, fade it in when it lands. */
:root[data-hermes-theme="noir"] [data-chat-surface] [data-slot="aui_thread-viewport"] {
transition: opacity 0.12s ease-out;
}
:root[data-hermes-theme="noir"] [data-chat-surface][data-session-switching] [data-slot="aui_thread-viewport"] {
opacity: 0;
transition: none;
}
- На каждую поверхность отдельно. У основного чата и у каждой плитки свой
[data-chat-surface]; атрибут отмечает только ту поверхность, что переключается. Чтобы сузить выбор до основной панели, добавьте[data-composer-target="main"]. - Всегда снимается. Его держат собственные фазы загрузки и восстановления прокрутки в ядре: фаза загрузки кончается, когда транскрипт пришёл или возобновление сдалось, фаза восстановления — когда прокрутка устаканилась (за ограниченное число кадров) или при первом вводе пользователя с прокруткой, клавиатурой или указателем, а обе — при размонтировании. Тема, скрывающая под ним содержимое, не может оставить чат скрытым навсегда. Совершенно новый пустой черновик атрибут не ставит.
- Контракт — сам атрибут. Опирайтесь на
[data-session-switching]и хукиdata-chat-surface/data-slot; внутренние имена классов контрактом не являются и меняются без предупреждения. Не переключайте атрибут сами и не воспроизводите его слушателем маршрута илиMutationObserver(правило 8 каталога).
Это заменяет паттерн installSwitchFade из t3-code-theme (слушатель focus-store плюс
цикл requestAnimationFrame, который опрашивал строки транскрипта и позицию прокрутки,
а затем переключал собственный атрибут на корне): вся миграция сводится к CSS выше.
Расширения поля ввода
Области COMPOSER_AREAS (top, bottom, underside, leading, actions,
attachments, middleware) позволяют плагину добавить элементы вокруг поля ввода
сообщения, стать источником вложений или преобразовать черновик перед отправкой
(ComposerMiddleware с handler(draft) => draft | null). top — полоса-баннер над
полем ввода, bottom — строка под сеткой поля; обе находятся внутри оформления
композера. underside — плавающая полоса ПОД всем композером, без собственного
оформления: место для пилюли-подсказки или статусной строки, которая должна стоять за
рамкой поля ввода (плагин next-prompt рисует там свою пилюлю «next prompt»).
API черновика — чтение и запись живого поля ввода
Всё, чего не умеют области композера, — вставить текст В поле ввода, заменить то, что
там уже есть, прочитать текущий черновик или отправить его — делается через
host.composer. Это поддерживаемая дверь. Лезть в DOM ProseMirror, искать
[data-composer-target] или генерировать синтетические InputEvent — это вне
поверхности плагинов (правило каталога 8), и такой код ломается, как только меняется
разметка приложения. Адресация: null — композер, в котором пользователь сейчас
печатает; id сессии (хранимый или рантаймовый) — композер этой сессии, в основной
панели или в плитке; 'new' — свежий черновик, у которого ещё нет id сессии.
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)
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
}
Арбитраж. Каждый глагол закрыт по умолчанию (fail-closed) относительно своего
адреса: на запрос отвечает только смонтированный композер, которому принадлежит эта
сессия (её плитка или основная панель, если она показывает эту сессию); на null
отвечает только поверхность, на которую сейчас указывает шина фокуса приложения; на
'new' — только основная панель, пока в ней не открыта сессия, и к активному
композеру такой запрос не проваливается. Нет точной поверхности — null/false, а не
рассылка в ту панель, которая случайно смонтирована. Запись идёт через собственный путь
отрисовки приложения, поэтому токены @-ссылок и /-команд превращаются в чипы, а
результат байт в байт совпадает с тем, что пользователь получил бы вставкой. Это
разовые действия по инициативе пользователя с теми же полномочиями, что и набор текста:
после них ни один плагин черновиком не «владеет», поэтому при отключении снимать
нечего. Плагину, которому нужно постоянное присутствие рядом с полем ввода, нужен
слот COMPOSER_AREAS.
Плагин, работающий с несколькими сессиями, хранит состояние по сессиям у себя (какую сессию сейчас правит его панель) и передаёт сюда этот id; шина гарантирует, что запись одного плагина никогда не попадёт в композер другой сессии.
Уход от DOM-вмешательства (придержанные плагины каталога, из-за которых появился этот API и его соседи; строка pet-wallet использует дверь пузыря питомца):
| Плагин | Было | Стало |
|---|---|---|
| next-prompt (#120660) | window.dispatchEvent(new CustomEvent('hermes:composer-insert', …)) + hermes:composer-focus через setTimeout; поиск видимой цели по [data-composer-target]/[data-pane-hidden] | await host.composer.insertText(null, suggestion.text, { mode: 'block' }), затем host.composer.focus(null), если пилюля забрала каретку |
| prompt-snippets (#116030) | то же событие hermes:composer-insert; запасной путь через [data-slot="composer-input"]/textContent ProseMirror + синтетический InputEvent; surfaceEditorEl().focus() | host.composer.insertText(sid, text, { mode: 'block' }); запасной путь заменяет setDraft(sid, (await getDraft(sid) ?? '') + '\n' + text); host.composer.focus(sid) — sid = host.state.focusedSessionId.get() |
| prompt-enhancer (#116031) | обходит дочерние узлы редактора для сериализации, пересобирает DOM чипов, replaceChildren + синтетический InputEvent | const draft = await host.composer.getDraft(sid) → преобразование → await host.composer.setDraft(sid, enhanced) (чипы собирает приложение); откат — ещё один setDraft |
| memory-review (#115966) | host.request('slash.exec', { session_id, command }) для /memory … — уже только через SDK | по желанию: host.composer.insertText(sid, '/memory pending', { mode: 'prefix' }), чтобы подставить команду пользователю, а не выполнять её |
| intelligent-tool-break (#115964) | кнопка «Message» только показывает тост «type /break» (в композер ничего не пишется) | host.composer.setDraft(host.state.focusedSessionId.get(), '/break '), затем host.composer.focus(null) возвращает задуманное поведение |
| pet-wallet (#135178) | document.querySelector('canvas[aria-label$=" pet"]'), чтобы найти питомца ядра; оверлей position:fixed; z-index:9999 на document.body, следующий за ним каждый кадр; слушатели указателя на уровне всего документа в фазе захвата | ctx.pet.say(text, { id: 'balance', tone?, ttlMs? }) — пузырь ядра показывает строку над питомцем, в окне и в отдельном окне, с подписью имени плагина; ctx.pet.visible сообщает, когда питомца нет, и можно откатиться на чип в строке состояния |
| browser-toggle | document.body.dispatchEvent(new KeyboardEvent('keydown', { key: 'L', ctrlKey: true, shiftKey: true, … })), чтобы сымитировать встроенное сочетание клавиш панели браузера; молча перестаёт работать, когда пользователь переназначает или снимает его | ctx.runAction('view.showBrowser') — запускает тот же обработчик, что сочетание клавиш и пункт палитры, независимо от привязки (запуск действий приложения) |
sessionId в таблице — id, к которому привязан интерфейс плагина; для рендера слота
композера это host.state.focusedSessionId.get().
Строки сессий — декорации и API списка сессий
SESSION_ROW_AREAS (leading, trailing) позволяют плагину украсить строки сессий в
боковой панели. Зарегистрируйте data-вклад, чей render({ sessionId }) возвращает
небольшой элемент (бейдж, цветную метку, тег) или null для строк, которые не ваши, —
на остальных строках регистрация ничего не стоит:
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
})
Сочетайте это с API списка сессий: он пишет в те же хранилища, что и собственные контролы приложения (так что действие плагина и ручной клик никогда не разойдутся):
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
Id — это ХРАНИМЫЕ id сессий: живой id разрешается в корень её долговечной цепочки
(lineage root), поэтому закрепления и цвета переживают смену id при сжатии, а слоты
декорации строк передают в ваш рендер тот же долговечный id (_lineage_root_id ?? id),
никогда не живой. Разрешение идёт по строкам, которые это окно уже загрузило; id, не
совпавший ни с одной загруженной строкой, записывается как есть, поэтому для сессии,
которая могла уехать за пределы списка, передавайте долговечный id слота (а не
запомненный живой id). reorder принимает те же id и внутри сопоставляет каждый с живым
id его строки — хранилище порядка Recents, как и путь перетаскивания, ключуется живым id.
pin(id, true, index) ставит закрепление на эту позицию в списке Pinned (цель броска
между двумя закреплёнными); без index оно добавляется в конец, как ⇧-клик по строке.
Арбитраж. Глаголы — разовые правки пользовательских данных по инициативе
пользователя: побеждает последняя запись, ровно как если бы кликнул сам пользователь, и
результатом потом ни один плагин не владеет. Вклады-слоты монтируются ВСЕ (в порядке
регистрации, без правила «первый побеждает»), каждый в собственном error boundary:
плагин, который бросил исключение или вернул null для строки, не может подавить
декорацию другого плагина на ней, а две декорации на одной строке рисуются рядом.
Раскладку строки, жесты и заголовок держит ядро — слоты дополняют, но не заменяют.
Снятие. Глаголам оно не нужно. Вклады-слоты снимает disposer из ctx.register
(при отключении или перезагрузке они пропадают, и строка перерисовывается без
декорации).
Миграция для придержанных плагинов каталога:
- drag-to-pin-session — замените обход
__reactFiber$*радиonTogglePin/onReorderSessions/session._lineage_root_idна id слота строки (render: ({ sessionId }) => …вSESSION_ROW_AREAS.leadingдаёт долговечный id каждой строки), затемhost.sessions.pin(sessionId, true, dropIndex)для броска в раздел Pinned,host.sessions.pin(sessionId, false)для броска обратно в Recents,host.sessions.reorderPinned(ids)для перетаскивания внутри раздела Pinned иhost.sessions.reorder([])для пути «сбросить ручной порядок». - better-session-appearance — замените запись в
localStoragehermes.desktop.sessionColorsи выуженный из fiberonChangeнаhost.sessions.setColor(sessionId, hex)(nullсбрасывает), а глиф для каждой строки рисуйте черезSESSION_ROW_AREAS.leading, а не подменяя статусную точку строки (долговечный id, который плагин брал из_lineage_root_id, — этоsessionIdслота).
COMPOSER_AREAS.modelPill переопределяет подпись пилюли модели: провайдер
({ label: (ctx: ComposerModelPillContext) => string | null }) получает
{ model, reasoningEffort, compact } и возвращает текст для показа либо null, чтобы
слово досталось следующему провайдеру (а затем подписи ядра). Оформление пилюли,
точка закрепления и меню остаются; меняется только подпись. Это санкционированная
замена тому, что плагины сегодня делают переписыванием текста через MutationObserver.
Провайдеры подписи пилюли модели
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
})
Арбитраж. Провайдеры опрашиваются в порядке реестра, и побеждает первая непустая
строка; следующие провайдеры не вызываются. Всё остальное считается отказом, и
спрашивают следующего: null, '', строка из одних пробелов и любое нестроковое
значение (объект, массив или число никогда не рендерятся — подпись вставляется прямо в
JSX). Провайдер, который бросает исключение, тоже считается отказавшимся: ошибка
глотается, и пилюля переходит к следующему провайдеру, а затем к подписи ядра, так что
сломанный плагин не может оставить пилюлю пустой. reasoningEffort всегда string
('', если у модели нет уровня усилия, но никогда не undefined). В компактном
(плавающем) режиме пилюля рисует только шеврон, и ни один провайдер не вызывается.
label() пересчитывается только при изменении реестра, модели, уровня усилия или
флага compact.
Снятие. Провайдер — обычный data-вклад: ctx.register возвращает его disposer, а
загрузчик убирает его при отключении или перезагрузке плагина, и тогда возвращается
подпись ядра. В ctx.onDispose отменять нечего.
Миграция compact-reasoning-label. Раньше плагин находил пилюлю через
[data-slot="composer-root"] button span.truncate, регуляркой срезал хвостовое слово
уровня усилия из span.textContent и повторял этот проход из MutationObserver на всё
body плюс setInterval раз в 1 с. В текущих сборках подпись ядра слова уровня усилия
уже не содержит (у уровня есть собственный ReasoningPill), так что срезать нечего;
санкционированный способ — вычислять подпись из контекста, а не править отрисованный
текст:
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.
}
CSS для видимости reasoning-пилюли, который плагин тоже внедрял, своего хука не имеет; он понадобится, только если приложение когда-нибудь начнёт прятать эту подпись на узкой ширине.
Декорации строк меню моделей
MODEL_MENU_ROW_AREA ставит метку у каждой модели внутри родного меню моделей — того, что
открывает пилюля поля ввода, и любой другой поверхности, рисующей ModelCatalogMenu.
Вклад поставляет decorate(row); ядро рисует то, что она вернула, в двух фиксированных
слотах строки: ведущая иконка перед названием модели и замыкающий бейдж после
собственных чипов ядра. Разметка строки, название, звёздочка, подменю и клик остаются за
ядром.
import { MODEL_MENU_ROW_AREA, type ModelMenuRowContribution } from '@hermes/plugin-sdk'
interface ModelMenuRowContext {
provider: string // provider slug: 'anthropic', 'openrouter', …
model: string // the model id the row commits
label: string // the display name core paints on the row
}
interface ModelMenuRowDecoration {
icon?: ReactNode // element (<img>, <svg>, a component) or short text, drawn in a 1rem box
badge?: string // plain text chip
}
ctx.register({
area: MODEL_MENU_ROW_AREA,
id: 'provider-marks',
data: {
decorate: ({ provider }) => {
const src = PROVIDER_ICONS[provider] // data: URL of an SVG mark
return src ? { icon: <img alt="" src={src} /> } : null
}
} satisfies ModelMenuRowContribution
})
Арбитраж. Декораторы выполняются в порядке реестра, по каждому слоту отдельно:
первый вернувший пригодную icon заполняет слот иконки, первый вернувший пригодный badge —
слот бейджа, поэтому плагин с иконками и плагин с бейджем цены уживаются на одной строке.
null (или нечто непригодное) означает отказ. Иконкой считается только элемент React или
непустая строка, бейджем — только непустая строка; всё остальное игнорируется, а не
рисуется. Декоратор, который бросил исключение, тоже отказывается, а компонент иконки,
упавший при рендере, гасит только свой слот (он стоит в собственной границе ошибок): сломанный
плагин не может обрушить меню. decorate() перезапускается, только когда меняются реестр или
provider/model/label строки, поэтому держите её чистым поиском.
Снятие. Обычный вклад-данные: disposer от ctx.register (а также отключение или
перезагрузка плагина) убирает его, и строки перерисовываются без декораций.
Миграция t3-code-theme. Его метки провайдеров рисовал в открытом меню MutationObserver:
он находил строки в DOM меню и писал на них изображения-маски. Те же метки даёт
decorate({ provider }), возвращающая
{ icon: <img alt="" src={providerSvgDataUrl(provider)} /> } — без чтения DOM, и строка
продолжает работать, когда меняется разметка меню.
Настройки оформления
APPEARANCE_AREAS.extra рендерит вклады в конце Settings → Appearance, после
встроенных разделов. Это шов для плагина, который раньше внедрял узлы на эту страницу
или управлял её виджетами через внутренности React.
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
})
Арбитраж: монтируется каждая регистрация, в порядке реестра, каждая в своём error
boundary: вклад, бросивший исключение, сворачивается в инлайновую карточку ошибки с
его id (и кнопкой Retry), а остальная страница (и карточки других плагинов)
продолжает рисоваться. Слот монтируется только на верхнеуровневой странице Appearance,
не на подстраницах из deep link (settings/appearance/<section>), и правила «первый
побеждает» нет — подавить друг друга плагины здесь не могут.
Снятие: регистрацией владеет загрузчик плагинов; отключение или перезагрузка плагина
её утилизирует, и карточка исчезает при следующем рендере. На стороне приложения ничего
не сохраняется, так что в ctx.onDispose чистить нечего.
Для выбора цвета берите собственную сетку образцов приложения — ColorSwatches (уже
экспорт SDK) рисует ровно то же, что рейка профилей и диалог проекта, но с вашим
onChange; передайте ей PROFILE_SWATCHES, а для цветов сессий сочетайте с
host.sessions.setColor(id, color).
Миграции для плагинов, из-за которых появился этот слот:
- better-session-appearance — замените обход fiber, выуживающий из подменю
Appearance
{ onChange, swatches }, и внедрениеclearBtn.after(...)/host.appendChild(panel)в выпадающее меню приложения на одинctx.register({ area: APPEARANCE_AREAS.extra, id: 'rules', render }), чья карточка рисует<ColorSwatches swatches={PROFILE_SWATCHES} value onChange />плюс свои контролы жирности/глифа/авто-правил; уберите запись атрибутаdata-better-session-appearanceи переопределенияmax-heightу выпадающего меню. - hermes-appearance-hub — смонтируйте контролы текстуры бумаги / шрифта /
вводного текста как карточку
APPEARANCE_AREAS.extraвместо меню в строке состояния, которое лезет в Settings; значения настроек по-прежнему идут черезhost.settings(ключи из белого списка) иTHEMES_AREA.
Страницы настроек плагина (Settings → Plugins)
Settings → Plugins — единый дом для предпочтений плагинов, устроенный как настройки AddOns в WoW: у каждого плагина с настройками свой пункт в рейке Settings, а выбор этого пункта раскрывает его подстраницы. Не стройте для настроек ни диалог предпочтений, ни панель, ни строку боковой панели. Зарегистрируйте страницу:
ctx.registerSettingsPage?.({
id: 'settings', // unique within your plugin
title: 'Weather', // rail label + breadcrumb
icon: 'cloud', // codicon name; a plug when omitted
order: 0, // ascending; ties sort by title
render: () => jsx(General, {}), // the entry's landing page
children: [ // optional sub-pages, listed in this order
{ id: 'units', title: 'Units', render: () => jsx(Units, {}) },
{ id: 'alerts', title: 'Alerts', render: () => jsx(Alerts, {}) }
]
})
- Страница живёт, пока жив плагин. Отключение или выгрузка убирает её, как и любую другую
регистрацию
ctx, а возвращённый disposer убирает её раньше. - Собирайте страницу из примитивов настроек (
ToggleRow,ListRow,SegmentedControl,Select*) и сохраняйте черезctx.storage, чтобы она выглядела как родные Settings ядра. Каждая страница рисуется в собственной границе ошибок. registerSettingsPageпоявился недавно;?.позволяет плагину загружаться на старых хостах. Там то же самое записывается развёрнуто:ctx.register({ area: SETTINGS_PLUGINS_AREA, id, title, render, data: { icon, children } }).- Deep link:
host.navigate(pluginSettingsHref('<your-plugin-id>', 'units'))(/settings?tab=plugins&plugin=<id>&ppage=<sub-page>). Идентификаторы подстраниц ваши: ни один не зарезервирован. - Агентские плагины получают страницу автоматически.
config_schemaвplugin.yamlрисуется как форма в Settings → Plugins и сохраняется черезplugins.manage settingsдля профиля, на который указывает селектор области Settings (/settings?tab=plugins&agent=<key>). Шестерёнка в строке плагина на Capabilities → Plugins открывает эту страницу для профиля, выбранного в Capabilities. В едином пакете (агентская половина плюсdesktop/plugin.js), если десктопная половина тоже регистрирует страницу, форма схемы появляется подстраницей Agent settings этого же пункта, так что у пакета один пункт.
src/plugins/hello-runtime/plugin.runtime.js — полный runtime-пример: одна страница и две
подстраницы на ctx.storage.
Встраивание внешнего содержимого
Для любого внешнего веб-содержимого (режим чтения, дашборды, документация) берите из
SDK <SandboxedFrame src title />. Он рисует песочничный iframe с позицией приложения
для гостевого содержимого: непрозрачное происхождение, по умолчанию allow-scripts,
no-referrer, ленивая загрузка. Никогда не монтируйте сырой Electron <webview>: он
попадает в persist:-раздел превью приложения и делит с приложением cookies и
хранилище.
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>
}
Пропсы — явный белый список, а не ComponentProps<'iframe'>: allow (делегирование
Permissions-Policy — отдало бы стороннему сайту доступ к микрофону и камере, который
есть у приложения), srcdoc, name, allowFullScreen, csp, credentialless и все
прочие атрибуты iframe пропсами не являются, и ничего не распыляется на элемент, так что
до DOM они не доберутся даже через приведение типов.
Арбитраж (белый список, а не чёрный): добавить вызывающий код может только токены
allow-scripts, allow-forms, allow-downloads, allow-pointer-lock,
allow-orientation-lock, allow-presentation. Всё остальное — allow-same-origin,
allow-top-navigation*, allow-popups*, allow-modals,
allow-storage-access-by-user-activation и любой неизвестный примитиву токен —
отбрасывается без учёта регистра, даже если передан; опустевший набор откатывается к
позиции по умолчанию (allow-scripts), потому что фрейм без атрибута sandbox
полностью привилегирован. loading="lazy" и referrerPolicy="no-referrer" пропсами не
являются. Непрозрачное происхождение и ЕСТЬ изоляция: гостевое содержимое не достаёт ни
до приложения, ни до его хранилища, ни до preload-моста.
Снятие: это обычный React-элемент — размонтирование вашей панели или страницы убирает фрейм и его realm; на стороне приложения ничего не регистрируется.
Миграция для rss-reader (#115972): замените заглушечную цепочку /preview → 501 →
host.openWorkspace('rss-browser') → пустой RssBrowserFrame → ctx.os.openExternal
на <SandboxedFrame src={article.url} title={article.title} /> внутри страницы рабочей
области; уберите оставшийся CSS .rss-browser-frame-host webview.
Директивы транскрипта — инлайн-компоненты, к которым обращается модель
TRANSCRIPT_DIRECTIVE_AREA делает областью вклада сам транскрипт. Зарегистрируйте
именованную директиву — и агент сможет отрисовать ваш компонент прямо внутри
сообщения ассистента, написав абзац вида ::name{key="value"}:
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 })
}
})
Правила, которые хост соблюдает ради безопасности поверхности:
- Директива обязана занимать весь абзац целиком —
::nameпосреди прозы остаётся прозой, так что компонент плагина не может перехватить обычный текст. - Атрибуты — это недоверенный вывод модели (пары
key="value", только строки). Проверяйте свои поля сами; на мусоре не рендерьте ничего вместо того, чтобы гадать. - Незанятая директива (для этого имени не зарегистрирован плагин) рендерится как обычный абзац, каким и была всегда, — при выключенном плагине ничего не ломается.
- Рендер обёрнут в error boundary вклада: исключение деградирует до инлайновой чип-ошибки, а не убивает сообщение целиком.
- При совпадении имени побеждает первая регистрация; давайте смелым именам
неймспейс со своим слагом (
myplugin-board, а неboard).
Ядро поставляет одну директиву как опорный пример: ::preview{file="…"}
рендерит HTML-файл из workspace прямо внутри сообщения — песочничный
srcdoc-iframe с непрозрачным происхождением (скрипты выполняются, виджет
полностью интерактивен; выхода в приложение, его хранилище или мост нет). Фрейм
подстраивает размер под содержимое (высота — живая, ширина берётся из
внутреннего размаха содержимого, прижат влево в потоке сообщения), а тема-прелюдия
передаёт документу разрешённые токены приложения (--foreground,
--muted-foreground, --accent, --border, --card), шрифт приложения и
прозрачный фон — поэтому HTML в форме виджета выглядит родным, а полноценная
страница сохраняет собственный дизайн. Не-HTML цели и удалённые шлюзы откатываются
к классической карточке превью. Расскажите агенту о своей директиве в навыке — так
он и научится её выдавать.
Превьюшные виджеты умеют ещё и отвечать. Внутри фрейма
window.hermes.send('get-price eth') (или декларативно —
<button data-hermes-send="get-price eth">, без единого скрипта) передаёт этот
промпт агенту как ход пользователя, за кадром: пузырь в транскрипте не появляется,
видимый ответ — это обновление самого виджета. Ход при этом настоящий: он будит
агента, подчиняется правилам steer/queue поля ввода и сохраняется (с типом
hidden), так что resume и БД сессии хранят полную запись. Пробелы по краям промпта срезаются,
длина ограничена 500 символами (window.hermes.maxLength), а отправка троттлится до одного
промпта в секунду на фрейм. Ничего не усекается и не пропадает молча: send() возвращает Promise, который
разрешается значением { ok: true }, когда промпт дошёл до поля ввода чата, или
{ ok: false, error }, где error — too_long (вместе с maxLength), throttled
(вместе с retryAfterMs), invalid или undelivered (ни одно видимое поле ввода его не
приняло). Проверяйте результат, прежде чем показывать виджет как сохранённый.
Оформление, живущее с компонентом (Contribute)
ctx.register предназначен для постоянных вкладов. Когда элемент должен появляться
и исчезать вместе с уже отрисованным компонентом — например, собственный контрол
страницы в её шапке должен уходить вместе со страницей, — рисуйте внутри неё
<Contribute>:
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, {})
})
Регистрация произойдёт при монтировании, снятие — при размонтировании, автоматически.
Для контрола шапки страницы берите WorkspacePageHeaderControl. Он выбирает размещение
по тому, где рисуется страница: в рабочей панели вносит вклад в
WORKSPACE_PAGE_HEADER_AREA, а везде в другом месте (плитка разделённого маршрута)
рисует свои дочерние элементы на месте. Поставьте его туда, где контрол должен стоять
при отображении на месте:
import { WorkspacePageHeaderControl } from '@hermes/plugin-sdk'
jsx(WorkspacePageHeaderControl, {
id: 'my-page:switcher', // namespace with your slug
children: jsx(MySwitcher, {})
})
WorkspacePageHeaderControl появился в этом релизе. Старые сборки десктопа его не
экспортируют, а именованный импорт отсутствующего экспорта SDK не даёт модулю плагина
загрузиться. Плагин, который должен работать и на старых сборках, либо определяет
наличие через импорт неймспейса (import * as sdk from '@hermes/plugin-sdk', затем
sdk.WorkspacePageHeaderControl ?? …), либо остаётся на сыром Contribute выше.
Видимость и порядок боковой навигации (SIDEBAR_NAV_PREFS_AREA)
Плагин прячет или переставляет верхние строки навигации в боковой панели, внося
предпочтение, а не записывая настройку. Ядро при рендере сливает все вклады
sidebarNav.prefs и применяет результат к строкам, которые иначе показало бы; сам
список по умолчанию никогда не меняется.
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
})
Id навигации — собственные id строк. Строки ядра: new-session, capabilities,
messaging, artifacts, cron (тип SidebarNavId; artifacts и cron рисуются
только в режиме Advanced). Id строки-вклада — её зарегистрированный id в
SIDEBAR_NAV_AREA, которому ctx.register добавляет неймспейс ${pluginId}:${id}:
плагин kanban, зарегистрировавший { id: 'kanban-nav', area: SIDEBAR_NAV_AREA },
называет эту строку в hide/order как 'kanban:kanban-nav'.
Арбитраж. Скрытые строки — объединение hide всех вкладов (ни один плагин не
может вернуть строку, скрытую другим; hide сильнее order), за исключением
capabilities: строка со вкладкой Plugins — это путь пользователя к выключателю самого
плагина, поэтому её можно переместить, но нельзя спрятать. Вклады применяются в порядке
области в реестре — сначала наименьший order, затем порядок регистрации, — и
побеждает order первого из них: следующие вклады расставляют только ещё не
расставленные id, а строки, не названные ни в одном order, сохраняют свой относительный
порядок по умолчанию после названных. Неизвестные id ни на что не влияют.
Снятие. Вклад живёт в реестре, поэтому отключение или перезагрузка плагина
утилизирует его, и строки сразу возвращаются — чистить нечего. Именно поэтому это не
глагол host.sidebar.hide(): host — синглтон, который не может приписать запись
конкретному плагину, сохранённое предпочтение пережило бы плагин, а два плагина
перезаписывали бы порядок друг друга.
Сохранять выбор пользователя — задача плагина, в собственном ctx.storage: при
register прочитайте сохранённые предпочтения и внесите их, а при каждой правке
сохраните, утилизируйте и внесите заново (повторная регистрация того же id заменяет
вклад).
// 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)
Разделы сессий (Pinned, Recents, Cron jobs) сюда не входят — только строки навигации.
API хоста
Всё, что висит на host, доступно из любого места плагина. Атомы состояния доступны
только на чтение: в обработчиках читайте через .get(), в компонентах подписывайтесь
через useValue(atom).
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 — это соединение WebSocket, а не признак того, идёт ли ход чата.
Сессия может быть в разгаре хода, пока сокет open; другая сессия в этот момент может
простаивать. Отключайте элементы поля ввода или действия плагина по занятости хода
сфокусированной сессии (host.state.busyBySession[sessionId] или view.$busy этой
сессии) — никогда по gateway и никогда по общему процессному флагу занятости.
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>
ctx.pet.say(text, { id?, tone?, ttlMs? }) // line in the core pet's speech bubble → disposer
ctx.pet.clear(id?) // drop one line (or all of yours)
ctx.pet.visible // ReadableAtom<boolean> — is a pet on screen?
ctx.runAction('view.showBrowser') // run a built-in app action → { ok: true } | { ok: false, reason, error }
ctx.listActions() // [{ id, label, category }] — the ids runAction accepts
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.i18n.registerAppLocale(id, { endonym?, rtl?, translations? }) // add a whole UI language (language pack); returns disposer
host.i18n.languageOptions() // [{ id, endonym, rtl, source }] — what the language switcher lists
host.request — тот же JSON-RPC, которым пользуется само приложение (сессии,
конфигурация, навыки, cron, kanban и прочее). host.requestProfile принимает дескриптор
из host.profileRoutes() и проводит этот RPC через точный источник реестра и профиль,
не переключая активный чат или шлюз. Перегрузка «только профиль» сохранена лишь для
единственно-локальной/легаси-топологии; плагинам, работающим с реестром, стоит
передавать дескриптор — иначе два источника с одинаковым именем профиля могут
столкнуться.
Вызов, который может холодно запустить бэкенд профиля из пула, по умолчанию
дозванивается с фоновым приоритетом, а фоновые дозвоны никогда не получают слот,
который пул держит свободным для действий пользователя. Когда вызов И ЕСТЬ действие
пользователя (сохранение, нажатие кнопки, открытие диалога), передавайте
host.requestProfile(route, method, params, undefined, { spawnPriority: 'foreground' });
иначе при пуле, заполненном тёплыми бэкендами, он прождёт 30-секундный таймаут слота и
упадёт. Для опроса и прогрева списка оставляйте фоновый приоритет по умолчанию.
host.openWorkspace(id, { render, title?, minWidth?, onClose? }) пристыковывает
отрисованный плагином вид как вкладку в основную рабочую зону — ту же центральную
область, где живут плитки сессий и превью, — и показывает её. Повторный вызов с тем же
id обновляет содержимое на месте и выводит вкладку на передний план вместо того, чтобы
открыть дубликат. Закрытие вкладки (её контролом Close или ⌘W) снимает регистрацию и
вызывает ваш onClose; возвращённый disposer закрывает её программно. Определяйте
поддержку через typeof host.openWorkspace === 'function' и на старых сборках десктопа
откатывайтесь к обычной панели-вкладу — групповые чат-комнаты Bot Mode служат опорным
примером (перехват главного окна, если доступен, иначе — вид внутри панели).
host.paneVisibility(paneId) возвращает атом только на чтение, равный true, пока
панель-вклад действительно на экране: присутствует в дереве раскладки, не закрыта и не
скрыта, её зона не свёрнута, и она держит активный слот вкладки своей зоны (единственная
панель в своей зоне тоже считается). id — это id панели в неймспейсе вклада,
<pluginId>:<paneId>. Атомы мемоизируются по id, так что вызывать функцию прямо в
рендере безопасно. Используйте её, чтобы регистрировать сопутствующий интерфейс только
пока ваша панель видна — опорный пример: панель Cronjobs режима Bot Mode регистрируется,
пока вкладку сайдбара держит панель Bots, и снимает регистрацию, когда пользователь
переключается обратно на Sessions. На старых сборках десктопа проверяйте поддержку через
typeof host.paneVisibility === 'function' и откатывайтесь к режиму «всегда
зарегистрировано».
host.profileRoutes() перечисляет все зарегистрированные источники в текущем реестре
подключений. SSH-источники с подключением по требованию отдают безучётный (без
учётных данных) стартовый маршрут default, не открывая туннель, — так плагин может стать
первым, кто до них дозвонится; SSH remoteProfile остаётся бэкенд-targetProfile
маршрута. connectionId — это идентичность маршрутизации в реестре; сочетайте его с
profile для ключей и хранения. Endpoint, токен, SSH-хост/ключ и прочие сырые поля
подключения никогда не пересекают границу IPC плагина. profile — локальный для
источника маршрут, используемый для запросов; targetProfile — бэкенд-профиль Hermes,
который этот маршрут обслуживает. Они расходятся, когда маршрут явно отображается на
другой бэкенд-профиль (например, при переопределении SSH remoteProfile или
легаси-алиасе URL по профилю). Это разделение сохраняет идентичность бэкенда, не
раскрывая секреты подключения.
Плагинам, работающим с профилями, доступны и полноценные методы: profiles.list
(каждый профиль плюс его самая свежая беседа как last_session; передайте
include_sessions: false, чтобы пропустить прощупывание БД по каждому профилю;
передайте preferred_session_ids: { имяПрофиля: sessionId } для точного, с проверкой
существования, поиска одной закреплённой сессии на профиль — каждая указанная строка
получает сводку preferred_session, которая разрешает скрытые строки и цепочки сжатия
до их живого кончика, либо null, если id окончательно исчез; более старые шлюзы
игнорируют параметр и опускают поле) и profiles.create (name, description,
clone_from, clone_all, no_skills, soul, опционально закрепление model +
provider) — ws-двойники REST-маршрутов панели /api/profiles.
host.state.busy — это живой ход сфокусированного чата (думает и стримит).
host.state.awaitingResponse остаётся true от отправки до первой порции ответа
ассистента. Оба следят за тем чатом, на который реально смотрит пользователь: за
плиткой сфокусированной сессии, если фокус держит она, иначе — за основным чатом
рабочей области (тот же сигнал читает пульс занятости в строке состояния). Подпишитесь
в компоненте:
const busy = useValue(host.state.busy)
Для детализации на уровне токенов слушайте host.onEvent (message.start,
message.delta, message.complete).
host.onEvent отдаёт живой поток событий шлюза: дельты сообщений, жизненный цикл
сессий, активность инструментов. Слушатели изолированы, поэтому исключение в вашем не
влияет на диспетчеризацию в приложении. Каждая дверь host безопасна для асинхронного
кода: синхронное исключение из внутреннего хелпера — скажем, при отсутствии моста
десктопа в обычном браузере — превращается в отклонённый промис, который поймает ваш
.catch(), а не в падение error boundary.
ctx.os — отобранная дверь в ОС: все способы, которыми плагин выходит за пределы окна
приложения, собраны в одном неймспейсе, привязанном к вашему плагину. ctx.os.notify
отправляет нативное уведомление ОС — тот же Electron-конвейер, которым пользуются
собственные оповещения приложения об одобрениях и завершении хода. Срабатывает, только
пока пользователь не смотрит на Hermes (приложение в фоне или не в фокусе); для тоста
внутри приложения, пока пользователь смотрит на него, берите host.notify. Пользователь
может заглушить уведомления на конкретном устройстве в Settings ▸ Notifications ▸
«Plugin notifications», а повторы от одного плагина троттлятся — относитесь к этому как
к сигналу о по-настоящему заметных событиях, а не как к логу.
Богатое представление и активация (расширяет исходную дверь ctx.os):
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 совместим с deep link: hermes://index-network/intent/1 и хеш-путь
/index-network/intent/1 ведут к одному и тому же маршруту внутри приложения (и тот же
URL hermes://… работает как deep link ОС). Кнопки действий рендерятся только в
подписанных сборках macOS; в остальных случаях активирует клик по телу уведомления.
Навигация происходит только по клику пользователя — никогда из фонового события самого
по себе.
Остальные двери (openExternal, revealPath, writeClipboard) при недоступности
возможности (старая оболочка десктопа, обычный браузер) разрешаются в false, а не
бросают исключение, — ветвитесь по результату, а не проверяйте наличие моста.
Пузырь питомца — ctx.pet
У питомца ядра (маскот petdex, в окне или выведенный в собственное окно ОС) есть
пузырь с репликами. ctx.pet позволяет плагину поместить в него короткую строку, так что
искать питомца в DOM приложения или рисовать поверх него собственный плавающий оверлей не придётся:
register(ctx) {
// Shows "DeepSeek ¥12.40 left" over the pet for 8 s, labelled "Pet Wallet".
const dispose = ctx.pet.say('DeepSeek ¥12.40 left', { id: 'balance', ttlMs: 8000 })
// Same id → replaces the line in place (a refreshed balance, a countdown).
ctx.pet.say('DeepSeek ¥11.90 left', { id: 'balance' })
// Tones: 'info' (default), 'wait' (clock glyph), 'error' (alert glyph).
ctx.pet.say('Codex 5h quota at 90%', { id: 'quota', tone: 'wait' })
dispose() // remove early, or
ctx.pet.clear('quota') // by id, or ctx.pet.clear() for all of yours
// No pet on screen? Fall back to your own status-bar chip or pane.
const visible = ctx.pet.visible.get()
}
interface PluginPet {
say(text: string, options?: { id?: string; tone?: 'info' | 'wait' | 'error'; ttlMs?: number }): () => void
clear(id?: string): void
visible: ReadableAtom<boolean>
}
Что хост гарантирует, чтобы вам не приходилось:
- Простой текст. Управляющие символы и bidi-переопределения вырезаются, пробельные символы схлопываются в одну строку, а длина ограничена 120 символами. Текст рисуется как текстовый узел; разметка показывается буквально.
- С указанием автора. Имя вашего плагина (из инвентаря Plugins) печатается над строкой, чтобы пользователь отличал ваши слова от слов питомца.
- Недолговечность. Каждая строка гаснет через
ttlMs(по умолчанию 6 с, значение зажимается в диапазон 1–30 с). Чтобы держать значение на экране, вызывайтеsayс тем жеidснова. От одного плагина живых строк не больше 3 (самая старая вытесняется), а пузырь показывает самую новую строку от любого плагина. - Ограничение частоты. 10 вызовов
sayна плагин за 10 с; лишние вызовы отбрасываются с предупреждением в консоли и возвращают disposer-пустышку. - Сначала ядро. Когда агент упёрся в ошибку или ждёт пользователя, побеждает собственный статусный пузырь ядра; ваша строка покажется снова, когда он погаснет (если не истекла).
- Настройка питомца у пользователя главнее. Если питомец не выбран или выключен,
ничего не показывается.
ctx.pet.visibleпозволяет ветвиться по этому признаку. - Убирается вместе с вами. Отключение, выгрузка или горячая перезагрузка плагина снимает все его строки, что ещё висят.
В окне пузырь появляется только для строк плагинов (статус агента приложение показывает само). В выведенном оверлее строки плагинов делят пузырь со статусными строками ядра. Оверлей — отдельное окно, которое не загружает код плагинов; главное окно присылает ему актуальные строки вместе с остальным состоянием питомца.
Ввод указателем по питомцу (перетаскивание, вывод в окно по shift-клику, клик по оверлею) принадлежит хосту, и хука для плагинов у него нет. Для действий используйте команду палитры, элемент строки состояния или собственную панель.
Запуск действий приложения — ctx.runAction
Собственные команды приложения — те, что стоят за сочетаниями клавиш и палитрой команд, — можно запускать по id. Плагин получает ровно тот обработчик, который запускает сочетание, так что если пользователь переназначит (или снимет) это сочетание в Settings ▸ Keyboard Shortcuts, для вас ничего не изменится:
import { TITLEBAR_AREAS } from '@hermes/plugin-sdk'
import { jsx } from 'react/jsx-runtime'
register(ctx) {
ctx.register({
id: 'browser',
area: TITLEBAR_AREAS.right,
render: () => jsx('button', { onClick: () => ctx.runAction('view.showBrowser'), children: 'Browser' })
})
// Results are values, never exceptions.
const result = ctx.runAction('nav.settings')
if (!result.ok) console.log(result.reason, result.error)
// Discover what is allowed (labels are localized, same as the shortcuts panel).
for (const { id, label } of ctx.listActions()) console.log(id, label)
}
interface PluginContext {
runAction(id: PluginAppActionId): PluginRunActionResult
listActions(): Array<{ id: PluginAppActionId; label: string; category: string }>
}
type PluginRunActionResult =
| { ok: true }
| { ok: false; reason: 'unknown' | 'denied' | 'unavailable'; error: string }
Разрешены только действия просмотра и навигации. PluginAppActionId — типизированное
объединение; PLUGIN_APP_ACTIONS — тот же список во время выполнения:
| Id | Что делает |
|---|---|
view.showBrowser | Переключает панель браузера |
view.showFiles | Показывает файловый браузер |
view.toggleSidebar | Переключает левую боковую панель |
view.toggleRightSidebar | Переключает правую сторону |
view.toggleReview | Переключает панель ревью (git) |
view.toggleStatusbar | Переключает строку состояния |
view.findInPage | Открывает поиск по странице |
composer.focus | Фокусирует поле ввода |
session.new | Начинает новый чат |
session.focusSearch | Фокусирует поиск по сессиям |
conversation.scrollPageUp / conversation.scrollPageDown | Прокручивает транскрипт на страницу |
nav.commandPalette | Переключает палитру команд |
nav.settings, nav.profiles, nav.capabilities, nav.messaging, nav.artifacts, nav.cron, nav.agents | Открывают соответствующую страницу |
keybinds.openPanel | Открывает Settings ▸ Keyboard Shortcuts |
Всё остальное отклоняется: { ok: false, reason: 'denied' } для настоящего действия
приложения, которого нет в списке (архивация, закрепление, смена профиля, модели или
рассуждения, терминалы, новое окно, открытие папки, HUD, закрытие вкладки и т. д.), и
reason: 'unknown' для несуществующего id. В обоих случаях ничего не запускается, а в лог
пишется предупреждение [plugin:<id>] runAction: …. reason: 'unavailable' означает, что
оболочка приложения, владеющая обработчиками, ещё не смонтирована (самый ранний старт);
повторите из действия пользователя. Разрушительные и привилегированные операции (удаление
сессий, выход из аккаунта, подтверждения, смена модели или провайдера, обновления, выход
из приложения) не относятся к действиям приложения, которые плагин может запускать.
На старых хостах определяйте поддержку так: ctx.runAction?.('view.showBrowser').
Настройки оформления десктопа — host.settings
host.settings — поддерживаемая дверь к небольшому набору локальных настроек
оформления десктопа, которые плагин может разделять с нативной страницей Settings.
Каждый ключ привязан к тому же атому хранилища и сеттеру, которыми пользуется сама
страница Settings, поэтому запись из плагина — это ровно клик пользователя по этому
контролу: действует сразу, сохраняется через существующую схему хранения настройки, и
побеждает последняя запись (после неё значением ни один плагин не «владеет», для set
снимать нечего).
type DesktopSettingValues = {
'backdrop.v1': boolean
chatTextScale: 90 | 100 | 110 | 125 | 150 | 175 // percent; Appearance → Chat Text Size
'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
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)
}
chatTextScale — размер текста чата, выбранный пользователем (по умолчанию 110). Он
масштабирует текст транскрипта и поля ввода (и его межстрочный интервал) через
CSS-переменную хоста --chat-text-scale, так что плагин или тема, которым нужен более
крупный или мелкий текст для чтения, ставят тот же пресет, который выбрал бы пользователь;
геометрия панелей, межстрочные отступы строк и хром остаются за ядром. Принимаются только
шесть пресетов: число вне пресетов (112, '125') вызывает исключение, а не округляется,
чтобы опечатка не сбросила размер пользователя молча. Как и любой ключ здесь, это
предпочтение пользователя, а не переопределение плагина: пишите его по явному действию
пользователя в вашем интерфейсе (никогда в register), а читайте и подписывайтесь, чтобы
подстроить собственный рендер.
const dispose = host.settings.subscribe('chatTextScale', pct => setMyFontScale(pct / 100))
ctx.onDispose(dispose)
Арбитраж: белый список выше закрыт. Неизвестный ключ или значение вне типа ключа
бросает исключение синхронно (Unsupported desktop setting: … /
Invalid value for desktop setting: …), и ничего не записывается. host.settings
никогда не трогает localStorage напрямую, поэтому обойти схему или миграцию
хранилища не может. Для поддержки старых сборок десктопа проверяйте наличие
host.settings.
Что намеренно не является ключом и почему:
| Что нужно | Что взять вместо | Почему не сырой ключ |
|---|---|---|
карта горячих клавиш (hermes.desktop.keybinds) | вклад KEYBINDS_AREA | запись сырой карты переназначит сочетания всех остальных плагинов; область сливает вклады по плагинам и снимается вместе с плагином |
| запись активной темы / режима | THEMES_AREA (зарегистрируйте тему, выберет её пользователь) | выбор темы идёт по окну/профилю и арбитрируется приложением, это не плоская настройка |
pluginDecisions (вкл/выкл десктопных плагинов) | вкладка Plugins приложения (вид только на чтение — отдельный хук SDK) | плагин, переключающий состояние другого плагина, — это плагины, мешающие друг другу |
| ширина чата и поля ввода, интервал между ходами, геометрия строк сессий | пока ничего: ключами они станут, только когда появятся как настройки оформления ядра (ширина чата: #55287) | раскладка принадлежит хосту, а контракт геометрии на стороне плагина превратил бы каждую тему в контракт раскладки |
toolView.technical, embed-mode, titlebarAppActions, translucency.v2, user-bubble-transparency.v1, hermesDesktop.zoom.* | ключи-продолжения после аудита каждого хранилища | некоторые из них управляют главным процессом или оформлением окна; каждому нужна своя защита и проверка владения, прежде чем плагин сможет туда писать |
Миграция — hermes-appearance-hub, который сегодня делает
localStorage.setItem('hermes.desktop.sessionListDensity', id), а затем
window.dispatchEvent(new StorageEvent('storage', …)), чтобы разбудить хранилище
приложения (readSimpleKey/writeSimpleKey, readBoolKey/writeBoolKey):
// 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
Чтения превращаются в host.settings.get(key); его MutationObserver на
переключателе intro-splash страницы Settings — в
host.settings.subscribe('intro-splash.v1', fn) (disposer → ctx.onDispose).
prompt-snippets читает localStorage.getItem('hermes.desktop.keybinds'), чтобы
подстраховать своё сочетание клавиш, — это строка про карту горячих клавиш из таблицы
выше: внесите сочетание по умолчанию через KEYBINDS_AREA, а переопределение
пользователя держите в ctx.storage, а не в карте приложения.
Типизированный мост возможностей — host.skills, host.toolsets, host.profiles, host.pluginDecisions
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()
Это обёртки над теми же функциями модуля api/*, которые вызывает страница
Capabilities (GET /api/skills, PUT /api/skills/toggle, GET /api/tools/toolsets,
PUT /api/tools/toolsets/<name>, GET /api/profiles), с той же привязкой к профилю,
что и у страницы. Не передали profile — действие применяется к активному профилю
приложения; передайте имя или маршрут { connectionId, profile }, чтобы настроить
другой профиль, не переключая профиль переднего плана. Нового арбитража здесь нет:
каждый вызов и так доступен через host.request, польза — в типизации и привязке к
профилю, так что прекращайте вызывать window.hermesDesktop.api напрямую.
host.pluginDecisions отражает карту включения/отключения плагинов приложения (id
плагина → true/false; отсутствующий id значит, что пользователь не выбирал, и
действует собственный defaultEnabled плагина). Он только на чтение по замыслу:
set() позволил бы одному плагину переключать состояние другого — ровно «плагины,
ломающие функциональность друг друга», — а host — синглтон модуля, который не может
определить, какой плагин его вызывает, чтобы ограничить запись собственным id
вызывающего. У объекта нет set во время выполнения, а не только в типах, и каждое
значение, которое он отдаёт (get(), .value, аргумент колбэков subscribe/listen),
— это замороженная копия: присваивание в неё бросает исключение, а не протекает в
карту, которую приложение читает и сохраняет. Включение и отключение плагинов остаётся
во вкладке Plugins приложения; ссылайтесь на неё через
host.navigate('/capabilities?tab=plugins').
Снятие: глаголы — разовые действия по инициативе пользователя, которые пишут то же
состояние бэкенда, что и страница, так что после них ничем не владеют и снимать нечего.
subscribe() на host.pluginDecisions возвращает свой disposer — зарегистрируйте его
через ctx.onDispose, чтобы отключённый или перезагруженный плагин перестал слушать.
Миграция (better-capabilities):
// 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
Языковые пакеты — host.i18n.registerAppLocale / ctx.i18n.registerAppLocale
ctx.i18n.register локализует строки ВАШЕГО плагина. Языковой пакет делает обратное:
добавляет (или расширяет) язык для ВСЕГО приложения — каждую подпись ядра, диалог и
подсказку, — так что польский пользователь видит польский десктоп. Регистрация — это
частичный каталог, который сливается поверх встроенного каталога для этого id (или
английского для нового языка); всё, что пакет пропустил, откатывается по ключам, но
никогда не показывается как сырой ключ. Переключатель сразу перечисляет язык его
эндонимом (без флагов: языки — не страны), <html dir> следует за rtl, а
display.language остаётся тем, что выбрал пользователь: регистрация не равна выбору.
export default {
id: 'hermes-lang-pl',
register(ctx) {
// Attributed to this plugin and dropped on unload/disable.
ctx.i18n.registerAppLocale('pl', {
endonym: 'Polski',
englishName: 'Polish', // search-only
rtl: false,
translations: {
// Nested like en.ts…
common: { save: 'Zapisz', cancel: 'Anuluj' },
// …or flat dotted keys (what a .desktop.yaml pack flattens to).
'catalog.results': '{0} wyników'
}
})
}
}
host.i18n.registerAppLocale(id, { endonym?, englishName?, rtl?, translations? }): () => void
host.i18n.languageOptions(): LanguageOption[] // bundled ∪ registered ∪ backend i18n.languages
Там, где в английском каталоге стоит функция (results: n => `${n} results` ), пакет
даёт простую строку с ПОЗИЦИОННЫМИ плейсхолдерами {0}, {1} в порядке аргументов, а
слияние оборачивает её в ту же форму вызова. Полный набор ключей опубликован в
locales/_keys.desktop.json (перегенерируйте командой npm run i18n:keys в
apps/desktop и закоммитьте файл; CI привязывает его к en.ts); именно с ним
hermes plugins validate сверяет <lang>.desktop.yaml пакета.
host.i18n.registerAppLocale — тот же вызов для кода, до которого ctx не дотянуться;
он возвращает disposer, передайте его в ctx.onDispose. Предпочитайте форму через ctx.
Пакету, который заодно поставляет строки ядра (Python) и TUI, десктопный код вообще не
нужен: объявите provides_locales: [pl] в plugin.yaml вместе с locales/pl.yaml,
pl.tui.yaml, pl.desktop.yaml, и шлюз отдаёт десктопный файл через
i18n.catalog {lang, surface: 'desktop'}; приложение подтягивает его в тот же реестр
(источник backend), когда display.language называет этот язык, и подтягивает заново
при смене профиля.
Слой данных — React Query и nanostores
Плагины пользуются единым QueryClient приложения, поэтому их запросы кэшируются,
дедуплицируются, опрашиваются и инвалидируются ровно так же, как экраны ядра. Не
изобретайте циклы с fetch.
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', {})
})
// …
}
Для состояния, общего между триггером и его панелью (или циклом опроса), берите atom и
computed — ту же примитивную сущность, на которой стоит host.state. Подписывайтесь
через useValue только в том листе дерева, который значение рисует. Чтобы
инвалидировать запрос вне React — например, когда пришёл кадр из ctx.socket, —
импортируйте общий queryClient:
import { queryClient } from '@hermes/plugin-sdk'
ctx.socket('/events', () => {
queryClient.invalidateQueries({ queryKey: ['my-plugin', 'items'] })
})
UI-кит и темы
Импортируйте настоящие компоненты приложения — тогда интерфейс сразу выглядит родным:
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.
Начиная с этого изменения loop у DecodeText включается явно: по умолчанию текст
расшифровывается один раз и замирает, так что на индикаторах прогресса, которые должны
перемешиваться постоянно, передавайте loop сами.
Плюс хелперы: cn (склейка классов), icons.* (набор lucide, который использует
приложение), haptic, profileColor и profileColorSoft (детерминированные цвета
личности), форматтеры времени relativeTime, fmtDateTime, fmtDayTime,
coarseElapsed, useI18n (локализованные строки — ваш плагин остаётся переводимым) и
evaluateRuntimeReadiness.
Стилизуйте переменными темы, а не захардкоженными цветами. Панели и так стоят на
редакторском фоне приложения: фон не трогайте, остальное задавайте переменными —
var(--ui-text-secondary), var(--ui-text-tertiary), var(--ui-text-quaternary),
var(--ui-stroke-secondary), var(--ui-accent). Для рисования на canvas разрешите их
один раз через getComputedStyle(canvas).getPropertyValue('--ui-accent'). Именно
благодаря этому плагин сам перекрашивается вместе с каждой темой.
Бэкенд для плагина
Нужна серверная работа — поставьте Python-файл plugin_api.py и обращайтесь к нему
через ctx.rest и ctx.socket: неймспейс ограничен вашим плагином по построению.
Один пакет, оба SDK
Функции, которой нужен и десктопный интерфейс, и агентская часть (Python-плагин, его
backend-маршруты, навыки), не обязательно поставляться как две взаимозависимые
установки. Положите desktop/plugin.js внутрь агентского пакета. Когда пакет попадает в
любой локальный корень plugins/ (домашний каталог по умолчанию или профиль), главный процесс
Electron копирует эту половину в $HERMES_HOME/desktop-plugins/<id>/ рядом с маркером
.hermes-package.json, а рендерер грузит её через тот же самый конвейер, что и отдельную
дисковую дверь (включая горячую перезагрузку):
~/.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
Половина desktop/plugin.js — обычный дисковый плагин: тот же контракт, те же
импорты, тот же ctx.rest('/…'), достающий до лежащего рядом plugin_api.py.
Установить, поделиться или удалить фичу — значит работать с одной папкой: копия в корне
приложения обновляется, когда меняется исходный plugin.js (hermes plugins update или
Rescan), и удаляется, когда папка пакета исчезает. Именно копия делает десктопную половину
уровнем приложения: она существует в одном экземпляре, сколько бы профилей ни несли пакет,
и никогда не появляется и не пропадает при переключении селектора профиля в Capabilities.
Рендерер сам plugins/ никогда не сканирует. Маркер хранит имя пакета и его происхождение
(sidecar каталога или git-remote) — именно этим пользуется кнопка Install here на странице
Plugins, чтобы установить агентскую половину в другой профиль. Копия готовится рядом с целью и
переименовывается на место, так что прерванное копирование (временная блокировка файла, падение
посреди копирования) никогда не оставляет наполовину записанную папку; оставшаяся
desktop-plugins/<id>/ без маркера и без plugin.js считается таким повреждением и заменяется при
следующем Rescan, а папка без маркера, в которой plugin.js есть, — это отдельный плагин,
установленный вами вручную, и она никогда не перезаписывается.
Разработка единого пакета
Приложение загружает копию из desktop-plugins/<id>/, а не ваш пакет. Установленный
пакет (из каталога, по Git URL, по пути file://) обновляется, только когда его исходный
plugin.js новее копии и что-то об этом просит: Rescan в Capabilities → Plugins,
установка или обновление через приложение либо перезапуск. Поэтому правка
~/.hermes/plugins/<id>/desktop/plugin.js на месте ничего не меняет на экране, пока этого
не случится. Разрабатывайте из рабочей копии, подключённой ссылкой в plugins/:
ln -s ~/src/my-plugin ~/.hermes/plugins/my-plugin
Копия связанного пакета помечается "linked": true. Приложение следит за
desktop/plugin.js в вашей рабочей копии; каждое сохранение заново синхронизирует копию
(по содержимому, а не по mtime, так что git stash pop тоже считается) и горячо
перезагружает плагин. Установки из каталога и по Git сохраняют правило mtime.
hermes plugins doctor <path-or-id> предупреждает, если копия отличается от вашего
исходника, и называет найденную копию:
WARN: Desktop runs a stale copy of desktop/plugin.js (~/.hermes/desktop-plugins/my-plugin);
your edits are not loaded. Refresh it with Capabilities → Plugins → Rescan …
Два переключателя включения по-прежнему нужны, и это намеренно, оба по умолчанию
выключены: десктопная половина ставится «по согласию» — значится в Capabilities →
Plugins, но остаётся отключённой, пока пользователь не переключит её сам, — совпадая
с воротами plugins.enabled Python-половины в config.yaml (граница безопасности
описана ниже). Пакет, брошенный в ~/.hermes/plugins, инертен на всех поверхностях,
пока пользователь явно не решит иначе. Десктопная половина мягко деградирует, когда
backend-половина выключена — ctx.rest возвращает ошибки, а не падает.
Копирование локально для машины, на которой работает десктопное приложение. При работе
с удалённым бэкендом ~/.hermes/plugins на удалённой машине недоступен как файловая
система — таким способом десктопную половину дают только локально установленные пакеты.
Для удалённого бэкенда диалог установки клонирует десктопную половину отдельно в
desktop-plugins/, так же как для репозитория, содержащего только десктопный плагин.
Если агентскую половину пакета установили на удалённом хосте без такого клона, на
странице Plugins его десктопная половина значится как unavailable (remote backend),
а не как ожидающая копия, и подсказка отправляет к Install from Git с отмеченной
целью Desktop.
Распространение через install-ссылку
Поставляйте репозиторий плагина (агентскую половину, десктопную или обе) и ссылайтесь
на него схемой hermes:// — обычным <a> на сайте или в README:
<a href="hermes://plugin/install?repo=owner/repo&enable=1">Install in Hermes</a>
Пользователь увидит диалог подтверждения (id репозитория, ссылки на источник,
прощупывание того, что репозиторий поставляет) и выберет компоненты до того, как
что-либо установится — deep link никогда не устанавливает автоматически. force=1
заменяет существующую установку; dev-сборки используют hermes-dev://. Полный
справочник по ссылкам:
Ссылки для установки в один клик.
Сторона Python
Плагины десктопа переиспользуют точку монтирования бэкенда плагинов панели. Положите
бэкенд в подпапку dashboard/ обычного плагина Hermes и объявите его в manifest.json:
~/.hermes/plugins/<id>/
└── dashboard/
├── manifest.json # { "name": "<id>", "api": "plugin_api.py" }
└── plugin_api.py # exports `router = APIRouter()`
# 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}
Маршруты монтируются под /api/plugins/<id>/ (GET /api/plugins/<id>/board и так
далее). Код бэкенда работает внутри процесса шлюза, поэтому может импортировать
кодовую базу hermes-agent напрямую (hermes_state, hermes_cli.config и прочее).
Полный справочник по бэкенду — в разделе
Расширение панели → Маршруты API бэкенда;
точка монтирования там та же самая.
Включение плагина в панели Capabilities → Plugins — это выбор на стороне рендерера, и
Python он не импортирует. Файл plugin_api.py пользовательского плагина
импортируется, только если плагин попал в список разрешённых plugins.enabled в
config.yaml и не попал в plugins.disabled. Проектные плагины (./.hermes/) Python
не импортируют автоматически никогда. Это граница безопасности, а не недосмотр
(GHSA-mcfc-hp25-cjv7).
Отправка событий в десктопную половину
Бэкенд работает внутри процесса шлюза, поэтому может отправить обновление своей
десктопной половине через глобальный поток событий приложения — тот самый, на который
подписывается host.onEvent:
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
// 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): имя на проводе всегда
plugin.<plugin_id>.<event>. plugin_id — имя плагина в каталоге
([a-z0-9_-]{1,64}, без точек: это неймспейс, и чужой плагин им не назвать); event —
ГОЛОЕ имя с точками ("feed.updated", а не "plugin.rss-reader.feed.updated") из
сегментов [A-Za-z0-9_-], поэтому "", "../x" или "a..b" бросают ValueError,
а не оставляют десктопную половину ждать имени, которое никто не отправляет. payload —
JSON-словарь (или не передан → {}), доставляется как payload события; кадр, как и
любое глобальное событие, несёт session_id: "". Доставка по принципу
«отправил и забыл» (зависший клиент пропускается и ваш обработчик не тормозит). Куда
событие дойдёт, зависит от процесса, в котором выполняется вызов:
| Где выполняется вызов | Куда доходит |
|---|---|
hermes serve (бэкенд десктопа): роутеры plugin_api.py, слэш-команды плагинов, инструменты и хуки в ходе агента | во все подключённые окна десктопа |
дочерний compute-host процесс dashboard.turn_isolation (инструменты/хуки изолированного хода) | ретранслируется по каналу хоста в hermes serve, затем во все окна |
stdio TUI (hermes в терминале) | в клиент этого терминала |
hermes gateway run (платформы сообщений), hermes chat, cron, hermes plugins validate | никуда — к этому процессу не подключён клиент десктопа; вызов логируется и ничего не делает |
Пользуйтесь этим вместо импорта внутренностей tui_gateway.server; для кадров
своего неймспейса с полезной нагрузкой, подогнанной под каждое подключение, более
богатой дверью остаётся ctx.socket('/events').
Миграция (rss-reader): уберите очередь ~/.hermes/rss-reader/commands.jsonl,
GET /commands и опрос ctx.rest('/commands') раз в 3 с — Python-сторона вызывает
broadcast_plugin_event('rss-reader', 'feed.updated', payload) там, где раньше ставила
в очередь, а десктопная сторона заменяет таймер на
host.onEvent('plugin.rss-reader.feed.updated', fn) внутри register(ctx).
Как звать его из плагина
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 учитывает профиль и отвергает выход за пределы пути (..), так что достучаться
через него до API другого плагина или до маршрута ядра невозможно. Тип
PluginRestOptions — это { method?, body?, upload?: { filename, contentType?, bytes }, timeoutMs? }.
ctx.socket сам переподключается с нарастающей паузой, пока его не закроют. На
удалённых установках с OAuth он превращается в пустышку — одноразовые билеты WS там
управляются ядром. Считайте сокет ускорителем поверх опроса, а не заменой ему. Запасной
опрос всё равно нужен каждому потребителю: любой сокет может отвалиться.
Для данных всего шлюза, а не вашего неймспейса, берите host.request (JSON-RPC) и
host.onEvent (поток событий шлюза).
Настройки, состояние включения и хранилище
Каждый плагин, включённый или нет, значится в Capabilities → Plugins: пользователь переключает его на лету без перезапуска приложения, открывает его папку или запускает пересканирование. Собственные предпочтения плагина живут в Settings → Plugins (страницы настроек плагина). Сделанный выбор запоминается:
- Выбора ещё не было → работает собственный
defaultEnabledплагина (по умолчаниюtrue). ПоставьтеdefaultEnabled: false, чтобы плагин ставился «по согласию» и молчал, пока пользователь его не включит. - Выбор сделан явно → он сохраняется и уважается после перезапусков. Отключённый плагин остаётся отключённым; не боритесь с этим — вас выключили осознанно.
Своё состояние храните через ctx.storage, оно живёт в неймспейсе плагина
(hermes.plugin.<id>.*), так что плагины не прочитают и не затрут данные друг друга:
ctx.storage.set('lastTab', 'board')
const tab = ctx.storage.get('lastTab', 'summary')
ctx.storage.remove('lastTab')
Встроенные плагины
Плагин может поставляться внутри дерева, в apps/desktop/src/plugins/<id>/plugin.tsx
(экспорт по умолчанию — HermesPlugin). При загрузке его находит
discoverBundledPlugins() — без импортов и правок реестра, — и контракт учёта и
включения-выключения на лету у него ровно тот же, что у дискового. Отличий два:
- Он проходит через сборку Vite самого приложения, поэтому можно писать настоящий
JSX и импортировать SDK по алиасу
@hermes/plugin-sdk. - Линтер всё равно ограничивает его только
@hermes/plugin-sdkиreact— никаких внутренностей приложения через@/….
В основном дереве сегодня десктопных плагинов не поставляется: приложение остаётся
чистым, а демонстрации живут в соседнем репозитории
hermes-example-plugins.
Модель безопасности
Загруженный плагин выполняется как ESM в реальности рендерера с полными правами
приложения: единственный экземпляр React, весь SDK (RPC к шлюзу через host.request,
ctx.rest, хранилище, navigate) и нативный мост window.hermesDesktop (файлы, git,
терминал, установки). Изоляция, которую даёт загрузчик, — это только изоляция
ошибок: уронить приложение плагин не может (вклады ограничены обработкой ошибок,
слушатели изолированы, register(), бросивший исключение, откатывается, а ошибка
показывается в строке плагина), но сделать он может всё, что может само приложение.
Неймспейсы хранилища плагинов — договорённость, а не стена.
Для локальных источников это приемлемо — файл на диске и так может выполнить код на
вашей машине, — поэтому дисковая дверь грузит только локальные файлы, написанные вами
или вашим агентом. Для установок из
каталога доверие даёт приём
в каталог — человек проверил ровно тот закреплённый коммит, — а страхуют его две
«растяжки»: линт desktop surface при приёме и белый список импортов загрузчика
(только @hermes/plugin-sdk и react*; статический или динамический import чего-то
другого, включая URL https:, срывает загрузку). Ни то ни другое песочницей не
является. Будущая дверь для удалённых источников потребует настоящей границы (iframe
или воркер плюс CSP и выдача возможностей) — до тех пор не считайте этот конвейер
границей доверия.
Грабли
- JSX в дисковом плагине не разберётся. Файл грузится без компиляции — пишите
jsx()иjsxs()(илиReact.createElement), а не синтаксис JSX. Встроенные плагины собираются, там JSX уместен. - Резолвятся ровно три спецификатора:
@hermes/plugin-sdk,react,react/jsx-runtime. Любой другой импорт вылезет ошибкой ещё при загрузке. - Не хардкодьте цвета (
#000,black,rgb(...)). Фон не трогайте, всё остальное задавайте переменными темы (var(--ui-*)). - Ссылайтесь только на то, что импортировали. Забытый импорт компонента (скажем,
StatusDot) дастReferenceErrorпри рендере — сверьте, что каждый идентификатор из вашихjsx()есть в строке импорта. - В обработчиках читайте состояние императивно (
$atom.get()), а не из замыкания рендера, иначе на частых событиях вы увидите устаревшие значения. Подписывайтесь (useValue) только в листе, который значение рисует. - Панели с canvas обязаны следить за контейнером через
ResizeObserverи менять размер холста (атрибуты width и height, а не только CSS) — панели меняют размер постоянно. - Не опрашивайте
host.requestчаще, чем раз в несколько секунд; беритеhost.onEventиctx.socket, а дедупликацию оставьте React Query. - Голые глобальные вызовы не отслеживаются.
window.setInterval,window.addEventListener, добавленный вами<style>— хост их не видит, поэтому они переживают отключение и каждую горячую перезагрузку (ES-модули нельзя выгрузить, и цикл правок на лету копит живые копии). Беритеctx.setTimeout/ctx.setInterval/ctx.addEventListener, а всё прочее подвязывайте кctx.onDispose. Состояние на уровне модуля сбрасывайте сами. - На вычисление модуля даётся 10 с.
awaitверхнего уровня, который так и не завершился (ждёт шлюз, который не поднят), срывает загрузку сimport timed out, а не подвешивает сканирование плагинов; ожидание делайте внутриregister(). - Один id — один файл. Две папки, экспортирующие один и тот же
id(отдельная установка рядом с копией из единого пакета), грузятся по правилу «первый побеждает» в порядке имён папок; у второй в Capabilities ▸ Plugins в её строке будетduplicate id. ctx.socketна удалённых установках с OAuth не работает. Всегда держите запасной опрос.
Справочник
Что экспортирует SDK
| Категория | Экспорты |
|---|---|
| Хост | host (.state.*, .settings, .notify, .notifyError, .navigate, .onEvent, .logs, .status, .restartGateway, .request, .composer, .sessions, .skills, .toolsets, .profiles, .pluginDecisions) |
| Контракт плагина | HermesPlugin, PluginContext, PluginContribution, PluginStorage, PluginOs, PluginPet, PetSayOptions, PetMessageTone, PluginAppActionId, PluginAppActionInfo, PluginRunActionResult, PLUGIN_APP_ACTIONS, PluginRestOptions, PluginNativeNotificationInput, PluginNotificationAction, HermesOpenTarget, Contribution |
| Константы областей | PANES_AREA, ROUTES_AREA, SIDEBAR_NAV_AREA, STATUSBAR_AREAS, TITLEBAR_AREAS, WORKSPACE_PAGE_HEADER_AREA, PALETTE_AREA, KEYBINDS_AREA, THEMES_AREA, COMPOSER_AREAS, MODEL_MENU_ROW_AREA, SESSION_ROW_AREAS, SIDEBAR_NAV_PREFS_AREA, APPEARANCE_AREAS, SETTINGS_PLUGINS_AREA |
| Полезная нагрузка областей | PluginSettingsPage, PluginSettingsSubpage (+ pluginSettingsHref), RouteContribution, SidebarNavContribution, StatusbarItem, TitlebarTool, PaletteContribution, KeybindContribution, ComposerMiddleware, ComposerAttachmentProvider, SessionRowSlotContribution, SidebarNavPrefsContribution |
| React и состояние | useValue, atom, computed, useQuery, useMutation, useQueryClient, queryClient, Contribute, WorkspacePageHeaderControl |
| Темы | useTheme, requestTheme, setAccentOverride, $accentOverride, retintTheme, themeHue, DesktopTheme, DesktopThemeColors, плюс математика OKLCH (hexToOklch, oklchToHex, oklchToSrgb255, mixOklab, maxChroma, hueDelta, normalizeHex) и измерения в sRGB (contrastRatio — `number |
| UI-кит | 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 |
| Хелперы | cn, icons, haptic, useI18n, profileColor, profileColorSoft, relativeTime, fmtDateTime, fmtDayTime, coarseElapsed, evaluateRuntimeReadiness, catalogProviderMatches |
Канонический и всегда актуальный список экспортов — apps/desktop/src/sdk/index.ts.
Агентам: навык hermes-desktop-plugins
Когда десктопный плагин пишет агент, ему стоит загрузить встроенный навык
hermes-desktop-plugins: там тот же контракт, что и на этой странице, но в форме
для агента и с готовым к копированию templates/plugin.js. Эта страница — справочник
для человека, навык — рабочий чек-лист.
Разбор проблем
Плагин не появился. Проверьте, что файл лежит по пути
$HERMES_HOME/desktop-plugins/<id>/plugin.js и имя папки совпадает с экспортируемым
id. Нажмите ⌘K → Reload desktop plugins. Посмотрите, нет ли в приложении тоста с
ошибкой, и почитайте hermes logs gui -f.
При загрузке «unsupported import». Дисковый плагин может импортировать только
@hermes/plugin-sdk, react и react/jsx-runtime. Уберите лишний импорт.
Элемент jsx ничего не рисует или бросает ReferenceError. Идентификатор,
использованный в вызове jsx(), не импортирован. Добавьте его в строку импорта.
ctx.rest отвечает 404. Бэкенд не смонтирован: убедитесь, что в
~/.hermes/plugins/<id>/dashboard/manifest.json прописано "api": "plugin_api.py", что
плагин указан в plugins.enabled в config.yaml, и перезапустите шлюз — маршруты
бэкенда монтируются при старте. Поищите в ~/.hermes/logs/errors.log строку
Failed to load plugin <id> API routes.
ctx.socket не срабатывает. На удалённой установке с OAuth это задумано как
пустышка — используйте запасной опрос. В остальных случаях проверьте, что бэкенд
отдаёт соответствующий маршрут @router.websocket(...) в своём неймспейсе.
После смены темы цвета поехали. Вы захардкодили цвет. Замените его переменной темы
var(--ui-*).