Расширение дашборда
Веб-дашборд Hermes (hermes dashboard) задуман так, чтобы его можно было перекрасить и расширить, не форкая кодовую базу. Открыто три слоя:
- Темы — YAML-файлы, которые перекрашивают палитру дашборда, типографику, макет и оформление отдельных компонентов. Кладёте файл в
~/.hermes/dashboard-themes/— и он появляется в переключателе тем. - UI-плагины — каталог с
manifest.jsonи JavaScript-бандлом, который регистрирует вкладку, заменяет встроенную страницу, дополняет её через слоты уровня страницы или подставляет компоненты в именованные слоты оболочки. - Backend-плагины — Python-файл внутри каталога плагина, отдающий FastAPI-
router; маршруты монтируются под/api/plugins/<name>/и вызываются из UI плагина.
Все три подключаются на лету в рантайме: ни клонирования репозитория, ни npm run build, ни правки исходников дашборда. Эта страница — каноническая справка по всем трём.
Если вам просто нужно пользоваться дашбордом, смотрите Веб-дашборд. А если хочется перекрасить терминальный CLI (не веб-дашборд), загляните в Скины и темы — система скинов CLI никак не связана с темами дашборда.
Эта страница описывает систему плагинов веб-дашборда (hermes dashboard) — window.__HERMES_PLUGIN_SDK__, manifest.json и собранный JS-бандл. У нативного десктопного приложения (hermes desktop) свой, не связанный с этим SDK — @hermes/plugin-sdk, единый ESM-файл, без этапа сборки — он описан на странице SDK десктопных плагинов. Между ними общий только backend-namespace plugin_api.py (/api/plugins/<name>).
Темы и плагины независимы, но усиливают друг друга. Тема работает сама по себе (просто YAML-файл). Плагин тоже самодостаточен (просто вкладка). Вместе они дают полный визуальный рескин с собственными HUD’ами — демо strike-freedom-cockpit (живёт в сопутствующем репозитории hermes-example-plugins, шаги установки — в разделе Совместное демо: тема + плагин) делает ровно это.
Содержание
- Темы
- Плагины
- Быстрый старт — первый плагин
- Структура каталога
- Справочник по манифесту
- Plugin SDK
- Слоты оболочки
- Замена встроенных страниц (
tab.override) - Дополнение встроенных страниц (слоты уровня страницы)
- Плагины только из слотов (
tab.hidden) - Backend-маршруты API
- Собственный CSS для плагина
- Обнаружение и перезагрузка плагинов
- Совместное демо: тема + плагин
- Справочник API
- Решение проблем
Темы
Темы — это YAML-файлы в ~/.hermes/dashboard-themes/. Имя файла роли не играет (система ориентируется на поле name: темы), но по соглашению используют <name>.yaml. Любое поле необязательно — пропущенные ключи откатываются к встроенной теме default, так что тема может быть размером с один цвет.
Быстрый старт — первая тема
mkdir -p ~/.hermes/dashboard-themes
# ~/.hermes/dashboard-themes/neon.yaml
name: neon
label: Neon
description: Pure magenta on black
palette:
background: "#000000"
midground: "#ff00ff"
Обновите дашборд. Нажмите иконку палитры в шапке и выберите Neon. Фон становится чёрным, текст и акценты — пурпурными, а все производные цвета (карточка, граница, приглушённый, кольцо и так далее) пересчитываются из этой тройки через color-mix() в CSS.
Вот и всё знакомство: один файл, два цвета. Дальше — необязательная доводка.
Палитра, типографика, макет
Эти три блока — сердце темы. Каждый независим: переопределите один, остальные оставьте как есть.
Палитра (3 слоя)
Палитра — это тройка цветовых слоёв плюс цвет тёплого свечения для виньетки и множитель зернистости. Каскад дизайн-системы дашборда выводит из этой тройки каждый совместимый с shadcn токен (карточка, поповер, приглушённый, граница, primary, destructive, кольцо и прочие) через CSS color-mix(). Переопределили три цвета — изменения каскадом расходятся по всему интерфейсу.
| Ключ | Описание |
|---|---|
palette.background | Самый глубокий цвет холста — обычно близкий к чёрному. Задаёт фон страницы и заливку карточек. |
palette.midground | Основной текст и акцент. Большая часть оформления берёт его (текст переднего плана, контуры кнопок, кольца фокуса). |
palette.foreground | Подсветка верхнего слоя. В теме default это белый с альфой 0 (невидимый); темы, которым нужен яркий акцент поверх, поднимают альфу. |
palette.warmGlow | Строка rgba(...), которую <Backdrop /> использует как цвет виньетки. |
palette.noiseOpacity | Множитель 0–1.2 для зернистого оверлея. Меньше — мягче, больше — грубее. |
Каждый слой принимает либо {hex: "#RRGGBB", alpha: 0.0–1.0}, либо голую hex-строку (альфа по умолчанию 1.0).
palette:
background:
hex: "#05091a"
alpha: 1.0
midground: "#d8f0ff" # bare hex, alpha = 1.0
foreground:
hex: "#ffffff"
alpha: 0 # invisible top layer
warmGlow: "rgba(255, 199, 55, 0.24)"
noiseOpacity: 0.7
Типографика
| Ключ | Тип | Описание |
|---|---|---|
fontSans | string | CSS-стек font-family для основного текста (применяется к html, body). |
fontMono | string | CSS-стек font-family для блоков кода, <code>, утилит .font-mono. |
fontDisplay | string | Необязательный стек для заголовков/дисплея. Откатывается к fontSans. |
fontUrl | string | Необязательный URL внешней таблицы стилей. При смене темы вставляется как <link rel="stylesheet"> в <head>. Один и тот же URL никогда не вставляется дважды. Работает с Google Fonts, Bunny Fonts, самохостящимися листами @font-face — со всем, на что можно сослаться. |
baseSize | string | Корневой размер шрифта — задаёт шкалу rem. Например, "14px", "16px". |
lineHeight | string | Межстрочный интервал по умолчанию. Например, "1.5", "1.65". |
letterSpacing | string | Межбуквенный интервал по умолчанию. Например, "0", "0.01em", "-0.01em". |
typography:
fontSans: '"Orbitron", "Eurostile", "Impact", sans-serif'
fontMono: '"Share Tech Mono", ui-monospace, monospace'
fontDisplay: '"Orbitron", "Eurostile", sans-serif'
fontUrl: "https://fonts.googleapis.com/css2?family=Orbitron:wght@400;500;600;700&family=Share+Tech+Mono&display=swap"
baseSize: "14px"
lineHeight: "1.5"
letterSpacing: "0.04em"
Смена шрифта из UI (без YAML)
В переключателе тем в шапке дашборда под списком тем есть секция
Font. Выберите там любой шрифт — он перекроет основной шрифт текущей
темы. Выбор не зависит от темы и переживает её переключения (хранится в
config.yaml под ключом dashboard.font). Чтобы снять переопределение и
вернуться к собственному fontSans активной темы, выберите Theme default.
В переключателе — выверенный каталог (системные стеки плюс набор семейств
Google Fonts: sans / serif / mono). Свободный ввод URL шрифта он намеренно
не принимает: таблица стилей шрифта вставляется через <link>, поэтому
каталог удерживает набор источников фиксированным. Под полностью свой шрифт
задайте fontSans + fontUrl в YAML темы, как показано выше. Шрифт fontMono
темы (блоки кода, терминал) переопределение из UI не трогает никогда.
Макет
| Ключ | Значения | Описание |
|---|---|---|
radius | любая CSS-длина ("0", "0.25rem", "0.5rem", "1rem", …) | Токен радиуса скругления. Привязан к --radius и каскадом задаёт --radius-sm/md/lg/xl — все скруглённые элементы смещаются разом. |
density | compact | comfortable | spacious | Множитель отступов, выставляемый через CSS-переменную --spacing-mul. compact = 0.85×, comfortable = 1.0× (по умолчанию), spacious = 1.2×. Масштабирует базовые отступы Tailwind, поэтому padding, gap и утилиты space-between смещаются пропорционально. |
layout:
radius: "0"
density: compact
Варианты макета
layoutVariant выбирает общий макет оболочки. Когда поля нет, берётся "standard".
| Вариант | Поведение |
|---|---|
standard | Одна колонка, максимальная ширина 1600px (по умолчанию). |
cockpit | Левая боковая рейка (260px) плюс основной контент. Наполняется плагинами через слот sidebar — см. Слоты оболочки. Без плагина рейка показывает заглушку. |
tiled | Снимает ограничение по максимальной ширине, чтобы страницы могли занимать всю ширину вьюпорта. |
layoutVariant: cockpit
Текущий вариант доступен как document.documentElement.dataset.layoutVariant, так что сырой CSS в customCSS может целиться в него через :root[data-layout-variant="cockpit"] ....
Ассеты темы (картинки как CSS-переменные)
Поставляйте URL артов вместе с темой. Каждый именованный слот превращается в CSS-переменную (--theme-asset-<name>), которую читают встроенная оболочка и любой плагин. Слот bg автоматически подключается к фону; остальные слоты предназначены для плагинов.
assets:
bg: "https://example.com/hero-bg.jpg" # auto-wired into <Backdrop />
hero: "/my-images/strike-freedom.png" # for plugin sidebars
crest: "/my-images/crest.svg" # for header-left plugins
logo: "/my-images/logo.png"
sidebar: "/my-images/rail.png"
header: "/my-images/header-art.png"
custom:
scanLines: "/my-images/scanlines.png" # → --theme-asset-custom-scanLines
Допустимые значения:
- Голые URL — автоматически оборачиваются в
url(...). - Заранее обёрнутые выражения
url(...),linear-gradient(...),radial-gradient(...)— используются как есть. "none"— явный отказ.
Каждый ассет дополнительно отдаётся как --theme-asset-<name>-raw (необёрнутый URL) — на случай, если плагину нужно передать его в <img src>, а не в background-image.
Плагины читают эти значения обычным CSS или JS:
// In a plugin slot
const hero = getComputedStyle(document.documentElement)
.getPropertyValue("--theme-asset-hero").trim();
Переопределение оформления компонентов
componentStyles меняет стиль отдельных компонентов оболочки без написания CSS-селекторов. Записи каждого блока превращаются в CSS-переменные (--component-<bucket>-<kebab-property>), которые читают общие компоненты оболочки. Так card: применяется к каждому <Card>, header: — к панели приложения и так далее.
componentStyles:
card:
clipPath: "polygon(12px 0, 100% 0, 100% calc(100% - 12px), calc(100% - 12px) 100%, 0 100%, 0 12px)"
background: "linear-gradient(180deg, rgba(10, 22, 52, 0.85), rgba(5, 9, 26, 0.92))"
boxShadow: "inset 0 0 0 1px rgba(64, 200, 255, 0.28)"
header:
background: "linear-gradient(180deg, rgba(16, 32, 72, 0.95), rgba(5, 9, 26, 0.9))"
tab:
clipPath: "polygon(6px 0, 100% 0, calc(100% - 6px) 100%, 0 100%)"
sidebar: {}
backdrop: {}
footer: {}
progress: {}
badge: {}
page: {}
Поддерживаемые блоки: card, header, footer, sidebar, tab, progress, badge, backdrop, page.
Имена свойств пишутся в camelCase (clipPath) и выводятся в kebab (clip-path). Значения — обычные CSS-строки, всё, что принимает CSS (clip-path, border-image, background, box-shadow, animation, …).
Переопределение цветов
Большинству тем это не понадобится — палитра из трёх слоёв выводит каждый токен shadcn. Используйте colorOverrides, когда нужен конкретный акцент, которого вывод не даст (более мягкий destructive-красный для пастельной темы, конкретный success-зелёный под бренд).
colorOverrides:
primary: "#ffce3a"
primaryForeground: "#05091a"
accent: "#3fd3ff"
ring: "#3fd3ff"
destructive: "#ff3a5e"
border: "rgba(64, 200, 255, 0.28)"
Поддерживаемые ключи: card, cardForeground, popover, popoverForeground, primary, primaryForeground, secondary, secondaryForeground, muted, mutedForeground, accent, accentForeground, destructive, destructiveForeground, success, warning, border, input, ring.
Каждый ключ ложится 1:1 на CSS-переменную --color-<kebab> (например, primaryForeground → --color-primary-foreground). Любой заданный здесь ключ перебивает каскад палитры — но только для активной темы; переключение на другую тему сбрасывает переопределения.
Сырой customCSS
Для оформления на уровне селекторов, которое componentStyles не выразит — псевдоэлементы, анимации, медиазапросы, переопределения в рамках темы — кладите сырой CSS в customCSS:
customCSS: |
/* Scanline overlay — only visible when cockpit variant is active. */
:root[data-layout-variant="cockpit"] body::before {
content: "";
position: fixed;
inset: 0;
pointer-events: none;
z-index: 100;
background: repeating-linear-gradient(to bottom,
transparent 0px, transparent 2px,
rgba(64, 200, 255, 0.035) 3px, rgba(64, 200, 255, 0.035) 4px);
mix-blend-mode: screen;
}
CSS вставляется одним тегом <style data-hermes-theme-css> при применении темы и убирается при переключении. Лимит — 32 KiB на тему.
Встроенные темы
У каждой встроенной темы своя палитра, типографика и макет — переключение даёт заметные изменения помимо одного лишь цвета.
| Тема | Палитра | Типографика | Макет |
|---|---|---|---|
Hermes Teal (default) | Тёмный бирюзовый + кремовый | Системный стек, 15px | radius 0.5rem, comfortable |
Hermes Teal (Large) (default-large) | Как у default | Системный стек, 18px, line-height 1.65 | radius 0.5rem, spacious |
Midnight (midnight) | Глубокий сине-фиолетовый | Inter + JetBrains Mono, 14px | radius 0.75rem, comfortable |
Ember (ember) | Тёплый багряный + бронза | Spectral (serif) + IBM Plex Mono, 15px | radius 0.25rem, comfortable |
Mono (mono) | Оттенки серого | IBM Plex Sans + IBM Plex Mono, 13px | radius 0, compact |
Cyberpunk (cyberpunk) | Неоновый зелёный на чёрном | Share Tech Mono везде, 14px | radius 0, compact |
Rosé (rose) | Розовый + слоновая кость | Fraunces (serif) + DM Mono, 16px | radius 1rem, spacious |
Темы со шрифтами Google Fonts (все, кроме Hermes Teal) подгружают таблицу стилей по запросу — при первом переключении на них в <head> вставляется тег <link>.
Полный справочник YAML темы
Все настройки в одном файле — копируйте и вырезайте ненужное:
# ~/.hermes/dashboard-themes/ocean.yaml
name: ocean
label: Ocean Deep
description: Deep sea blues with coral accents
# 3-layer palette (accepts {hex, alpha} or bare hex)
palette:
background:
hex: "#0a1628"
alpha: 1.0
midground:
hex: "#a8d0ff"
alpha: 1.0
foreground:
hex: "#ffffff"
alpha: 0.0
warmGlow: "rgba(255, 107, 107, 0.35)"
noiseOpacity: 0.7
typography:
fontSans: "Poppins, system-ui, sans-serif"
fontMono: "Fira Code, ui-monospace, monospace"
fontDisplay: "Poppins, system-ui, sans-serif" # optional
fontUrl: "https://fonts.googleapis.com/css2?family=Poppins:wght@400;500;600&family=Fira+Code:wght@400;500&display=swap"
baseSize: "15px"
lineHeight: "1.6"
letterSpacing: "-0.003em"
layout:
radius: "0.75rem"
density: comfortable
layoutVariant: standard # standard | cockpit | tiled
assets:
bg: "https://example.com/ocean-bg.jpg"
hero: "/my-images/kraken.png"
crest: "/my-images/anchor.svg"
logo: "/my-images/logo.png"
custom:
pattern: "/my-images/waves.svg"
componentStyles:
card:
boxShadow: "inset 0 0 0 1px rgba(168, 208, 255, 0.18)"
header:
background: "linear-gradient(180deg, rgba(10, 22, 40, 0.95), rgba(5, 9, 26, 0.9))"
colorOverrides:
destructive: "#ff6b6b"
ring: "#ff6b6b"
customCSS: |
/* Any additional selector-level tweaks */
После создания файла обновите дашборд. Темы переключаются вживую из шапки — нажмите иконку палитры. Выбор сохраняется в config.yaml под ключом dashboard.theme и восстанавливается при перезагрузке.
Плагины
Плагин дашборда — это каталог с manifest.json, заранее собранным JS-бандлом и, по желанию, CSS-файлом и Python-файлом с FastAPI-маршрутами. Плагины лежат рядом с другими плагинами Hermes в ~/.hermes/plugins/<name>/ — расширение дашборда находится в подпапке dashboard/ внутри каталога плагина, поэтому один плагин может из единственной установки расширять и CLI/шлюз, и дашборд.
Плагины не тащат с собой React или UI-компоненты. Они используют Plugin SDK, выставленный на window.__HERMES_PLUGIN_SDK__. За счёт этого бандлы плагинов остаются крошечными (обычно несколько КБ) и не конфликтуют по версиям.
Быстрый старт — первый плагин
Создайте структуру каталогов:
mkdir -p ~/.hermes/plugins/my-plugin/dashboard/dist
Напишите манифест:
// ~/.hermes/plugins/my-plugin/dashboard/manifest.json
{
"name": "my-plugin",
"label": "My Plugin",
"icon": "Sparkles",
"version": "1.0.0",
"tab": {
"path": "/my-plugin",
"position": "after:skills"
},
"entry": "dist/index.js"
}
Напишите JS-бандл (простой IIFE — шаг сборки не нужен):
// ~/.hermes/plugins/my-plugin/dashboard/dist/index.js
(function () {
"use strict";
const SDK = window.__HERMES_PLUGIN_SDK__;
const { React } = SDK;
const { Card, CardHeader, CardTitle, CardContent } = SDK.components;
function MyPage() {
return React.createElement(Card, null,
React.createElement(CardHeader, null,
React.createElement(CardTitle, null, "My Plugin"),
),
React.createElement(CardContent, null,
React.createElement("p", { className: "text-sm text-muted-foreground" },
"Hello from my custom dashboard tab.",
),
),
);
}
window.__HERMES_PLUGINS__.register("my-plugin", MyPage);
})();
Обновите дашборд — ваша вкладка появится в навигационной панели, после Skills.
Если вам ближе JSX, возьмите любой сборщик (esbuild, Vite, rollup) с React как external и выводом в IIFE. Единственное жёсткое требование — итоговый файл должен быть одним JS-файлом, загружаемым через <script>. React никогда не бандлится; он приходит из SDK.React.
Структура каталога
~/.hermes/plugins/my-plugin/
├── plugin.yaml # optional — existing CLI/gateway plugin manifest
├── __init__.py # optional — existing CLI/gateway hooks
└── dashboard/ # dashboard extension
├── manifest.json # required — tab config, icon, entry point
├── dist/
│ ├── index.js # required — pre-built JS bundle (IIFE)
│ └── style.css # optional — custom CSS
└── plugin_api.py # optional — backend API routes (FastAPI)
Один каталог плагина может нести три ортогональных расширения:
plugin.yaml+__init__.py— плагин CLI/шлюза (см. страницу плагинов).dashboard/manifest.json+dashboard/dist/index.js— UI-плагин дашборда.dashboard/plugin_api.py— backend-маршруты дашборда.
Ни одно из них не обязательно; включайте только нужные слои.
Справочник по манифесту
{
"name": "my-plugin",
"label": "My Plugin",
"description": "What this plugin does",
"icon": "Sparkles",
"version": "1.0.0",
"tab": {
"path": "/my-plugin",
"position": "after:skills",
"override": "/",
"hidden": false
},
"slots": ["sidebar", "header-left"],
"entry": "dist/index.js",
"css": "dist/style.css",
"api": "plugin_api.py"
}
| Поле | Обязательно | Описание |
|---|---|---|
name | Да | Уникальный идентификатор плагина. Нижний регистр, дефисы допустимы. Используется в URL и при регистрации. |
label | Да | Отображаемое имя на вкладке навигации. |
description | Нет | Краткое описание (показывается в админских поверхностях дашборда). |
icon | Нет | Имя иконки Lucide. По умолчанию Puzzle. Неизвестные имена откатываются к Puzzle. |
version | Нет | Строка semver. По умолчанию 0.0.0. |
tab.path | Да | URL-путь вкладки (например, /my-plugin). |
tab.position | Нет | Куда вставить вкладку. "end" (по умолчанию), "after:<path>" или "before:<path>" — значение после двоеточия это сегмент пути целевой вкладки (без ведущего слеша). Примеры: "after:skills", "before:config". |
tab.override | Нет | Задайте путь встроенного маршрута ("/", "/sessions", "/config", …), чтобы заменить эту страницу вместо добавления новой вкладки. См. Замена встроенных страниц. |
tab.hidden | Нет | Когда true, регистрирует компонент и любые слоты, но не добавляет вкладку в навигацию. Используется плагинами только из слотов. См. Плагины только из слотов. |
slots | Нет | Именованные слоты оболочки, которые наполняет плагин. Только подсказка для документации — реальная регистрация идёт из JS-бандла через registerSlot(). Перечисление слотов здесь делает поверхности обнаружения информативнее. |
entry | Да | Путь к JS-бандлу относительно dashboard/. По умолчанию dist/index.js. |
css | Нет | Путь к CSS-файлу, который вставляется тегом <link>. |
api | Нет | Путь к Python-файлу с FastAPI-маршрутами. Монтируется в /api/plugins/<name>/. |
Доступные иконки
Плагины используют имена иконок Lucide. Дашборд сопоставляет их по имени — неизвестные имена тихо откатываются к Puzzle.
Сейчас сопоставлены: Activity, BarChart3, Clock, Code, Database, Eye, FileText, Globe, Heart, KeyRound, MessageSquare, Package, Puzzle, Settings, Shield, Sparkles, Star, Terminal, Wrench, Zap.
Нужна другая иконка? Откройте PR к ICON_MAP в web/src/App.tsx — изменение чисто аддитивное.
Plugin SDK
Всё, что нужно плагину, лежит на window.__HERMES_PLUGIN_SDK__. Импортировать React напрямую плагину не следует.
const SDK = window.__HERMES_PLUGIN_SDK__;
// React + hooks
SDK.React // the React instance
SDK.hooks.useState
SDK.hooks.useEffect
SDK.hooks.useCallback
SDK.hooks.useMemo
SDK.hooks.useRef
SDK.hooks.useContext
SDK.hooks.createContext
// UI components (shadcn/ui primitives)
SDK.components.Card
SDK.components.CardHeader
SDK.components.CardTitle
SDK.components.CardContent
SDK.components.Badge
SDK.components.Button
SDK.components.Input
SDK.components.Label
SDK.components.Select
SDK.components.SelectOption
SDK.components.Separator
SDK.components.Tabs
SDK.components.TabsList
SDK.components.TabsTrigger
SDK.components.PluginSlot // render a named slot (useful for nested plugin UIs)
// Hermes API client + raw fetcher
SDK.api // typed client — getStatus, getSessions, getConfig, ...
SDK.fetchJSON // raw fetch for custom endpoints (plugin-registered routes)
// Utilities
SDK.utils.cn // Tailwind class merger (clsx + twMerge)
SDK.utils.timeAgo // "5m ago" from unix timestamp
SDK.utils.isoTimeAgo // "5m ago" from ISO string
// Hooks
SDK.useI18n // i18n hook for multi-language plugins
Вызов backend вашего плагина
SDK.fetchJSON("/api/plugins/my-plugin/data")
.then((data) => console.log(data))
.catch((err) => console.error("API call failed:", err));
fetchJSON подставляет токен авторизации сессии, отдаёт ошибки выброшенными исключениями и сам парсит JSON.
Вызов встроенных эндпоинтов Hermes
// Agent status
SDK.api.getStatus().then((s) => console.log("Version:", s.version));
// Recent sessions
SDK.api.getSessions(10).then((resp) => console.log(resp.sessions.length));
Полный список — в Веб-дашборд → REST API.
Слоты оболочки
Слоты дают плагину подставлять компоненты в именованные места оболочки приложения — боковую панель cockpit, шапку, подвал, оверлейный слой — не занимая целую вкладку. Один и тот же слот могут наполнять несколько плагинов; они рендерятся стопкой в порядке регистрации.
Регистрация идёт изнутри бандла плагина:
window.__HERMES_PLUGINS__.registerSlot("my-plugin", "sidebar", MySidebar);
window.__HERMES_PLUGINS__.registerSlot("my-plugin", "header-left", MyCrest);
Каталог слотов
Слоты уровня оболочки (рендерятся где угодно в оформлении приложения):
| Слот | Расположение |
|---|---|
backdrop | Внутри стека слоёв <Backdrop />, над слоем шума. |
header-left | Перед брендом Hermes в верхней панели. |
header-right | Перед переключателями темы и языка в верхней панели. |
header-banner | Полоса на всю ширину под навигацией. |
sidebar | Боковая рейка cockpit — рендерится только при layoutVariant === "cockpit". |
pre-main | Над аутлетом маршрута (внутри <main>). |
post-main | Под аутлетом маршрута (внутри <main>). |
footer-left | Содержимое ячейки подвала (заменяет дефолтное). |
footer-right | Содержимое ячейки подвала (заменяет дефолтное). |
overlay | Слой с фиксированной позицией поверх всего. Удобно для оформления (сканлайны, виньетки), которого customCSS в одиночку не добьётся. |
Слоты уровня страницы (рендерятся только на названной встроенной странице — используйте, чтобы добавить виджеты, карточки или тулбары на существующую страницу, не переопределяя весь маршрут):
| Слот | Где рендерится |
|---|---|
sessions:top / sessions:bottom | Верх / низ страницы /sessions. |
analytics:top / analytics:bottom | Верх / низ страницы /analytics. |
logs:top / logs:bottom | Верх (над панелью фильтров) / низ (под просмотрщиком логов) страницы /logs. |
cron:top / cron:bottom | Верх / низ страницы /cron. |
skills:top / skills:bottom | Верх / низ страницы /skills. |
config:top / config:bottom | Верх / низ страницы /config. |
env:top / env:bottom | Верх / низ страницы /env (Keys). |
docs:top / docs:bottom | Верх (над iframe) / низ страницы /docs. |
chat:top / chat:bottom | Верх / низ /chat (активны только когда включён встроенный чат). |
Пример — добавить карточку-баннер в верх страницы Sessions:
function PinnedSessionsBanner() {
return React.createElement(Card, null,
React.createElement(CardContent, { className: "py-2 text-xs" },
"Pinned note injected by my-plugin"),
);
}
window.__HERMES_PLUGINS__.registerSlot("my-plugin", "sessions:top", PinnedSessionsBanner);
Сочетайте слоты уровня страницы с tab.hidden: true, если ваш плагин только дополняет существующие страницы и ему не нужна собственная вкладка в боковой панели.
Оболочка рендерит <PluginSlot name="..." /> только для перечисленных выше слотов. Дополнительные имена реестр принимает для вложенных UI плагинов — плагин может выставить собственные слоты через SDK.components.PluginSlot.
Повторная регистрация и HMR
Если одна и та же пара (plugin, slot) зарегистрирована дважды, поздний вызов заменяет ранний — это совпадает с тем, как React HMR ожидает перемонтирование плагинов.
Замена встроенных страниц (tab.override)
Если задать tab.override равным пути встроенного маршрута, компонент плагина заменяет ту страницу вместо добавления новой вкладки. Удобно, когда теме нужна своя главная страница (/), а остальной дашборд хочется оставить нетронутым.
{
"name": "my-home",
"label": "Home",
"tab": {
"path": "/my-home",
"override": "/",
"position": "end"
},
"entry": "dist/index.js"
}
Когда override задан:
- Исходный компонент страницы на
/убирается из роутера. - Вместо него на
/рендерится ваш плагин. - Для
tab.pathвкладка в навигации не добавляется (в этом и смысл override).
Перебить заданный путь может только один плагин. Если override на один и тот же путь заявляют два плагина, побеждает первый, а второй игнорируется с предупреждением в dev-режиме.
Если нужно лишь добавить карточку или тулбар на существующую страницу, не забирая её целиком, используйте слоты уровня страницы.
Дополнение встроенных страниц (слоты уровня страницы)
Полная замена через tab.override — тяжёлый ход: ваш плагин теперь владеет всей страницей, включая будущие обновления, которые мы для неё выпустим. Чаще всего вам просто хочется добавить баннер, карточку или тулбар на существующую страницу. Для этого и нужны слоты уровня страницы.
Каждая встроенная страница выставляет слоты <page>:top и <page>:bottom, которые рендерятся вверху и внизу её области контента. Плагин наполняет слот вызовом registerSlot() — встроенная страница продолжает работать как обычно, а ваш компонент рендерится рядом.
Доступные слоты: sessions:*, analytics:*, logs:*, cron:*, skills:*, config:*, env:*, docs:*, chat:* (у каждого есть :top и :bottom). Полный каталог — в разделе Слоты оболочки → Каталог слотов.
Минимальный пример — закрепить баннер в верху страницы Sessions:
// ~/.hermes/plugins/session-notes/dashboard/manifest.json
{
"name": "session-notes",
"label": "Session Notes",
"tab": { "path": "/session-notes", "hidden": true },
"slots": ["sessions:top"],
"entry": "dist/index.js"
}
// ~/.hermes/plugins/session-notes/dashboard/dist/index.js
(function () {
const SDK = window.__HERMES_PLUGIN_SDK__;
const { React } = SDK;
const { Card, CardContent } = SDK.components;
function Banner() {
return React.createElement(Card, null,
React.createElement(CardContent, { className: "py-2 text-xs" },
"Remember to label important sessions before archiving."),
);
}
// Placeholder for the hidden tab.
window.__HERMES_PLUGINS__.register("session-notes", function () { return null; });
// The real work.
window.__HERMES_PLUGINS__.registerSlot("session-notes", "sessions:top", Banner);
})();
Ключевые моменты:
tab.hidden: trueдержит плагин вне боковой панели — у него нет отдельной страницы.- Поле манифеста
slots— только документация. Реальная привязка происходит в JS-бандле черезregisterSlot(). - Один и тот же слот уровня страницы могут заявить несколько плагинов. Они рендерятся стопкой в порядке регистрации.
- Нулевой след, когда ни один плагин не регистрируется: встроенная страница рендерится в точности как раньше.
Эталонный плагин (example-dashboard в hermes-example-plugins) поставляется с живым демо, которое вставляет баннер в sessions:top — установите его, чтобы увидеть приём от начала до конца.
Плагины только из слотов (tab.hidden)
При tab.hidden: true плагин регистрирует свой компонент (для прямых заходов по URL) и любые слоты, но никогда не добавляет вкладку в навигацию. Так делают плагины, существующие только ради подстановки в слоты — герб в шапке, HUD в боковой панели, оверлей.
{
"name": "header-crest",
"label": "Header Crest",
"tab": {
"path": "/header-crest",
"position": "end",
"hidden": true
},
"slots": ["header-left"],
"entry": "dist/index.js"
}
Бандл всё равно вызывает register() с компонентом-заглушкой (хорошая практика на случай, если кто-то зайдёт по URL напрямую), а затем registerSlot() для основной работы.
Backend-маршруты API
Плагины могут регистрировать FastAPI-маршруты, задав api в манифесте. Создайте файл и экспортируйте router:
# ~/.hermes/plugins/my-plugin/dashboard/plugin_api.py
from fastapi import APIRouter
router = APIRouter()
@router.get("/data")
async def get_data():
return {"items": ["one", "two", "three"]}
@router.post("/action")
async def do_action(body: dict):
return {"ok": True, "received": body}
Маршруты монтируются под /api/plugins/<name>/, поэтому код выше превращается в:
GET /api/plugins/my-plugin/dataPOST /api/plugins/my-plugin/action
Маршруты API плагинов проходят через обычный механизм авторизации дашборда — неаутентифицированные запросы получают 401 ещё до вызова маршрута плагина, а запросы к маршрутам отключённого плагина отклоняются на этапе обработки запроса. И всё же не выставляйте дашборд на публичный интерфейс через --host 0.0.0.0, если запускаете недоверенные плагины — аутентифицированная сессия сможет добраться и до их маршрутов.
Доступ к внутренностям Hermes
Backend-маршруты выполняются внутри процесса дашборда, поэтому могут импортировать прямо из кодовой базы hermes-agent:
from fastapi import APIRouter
from hermes_state import SessionDB
from hermes_cli.config import load_config
router = APIRouter()
@router.get("/session-count")
async def session_count():
db = SessionDB()
try:
count = len(db.list_sessions(limit=9999))
return {"count": count}
finally:
db.close()
@router.get("/config-snapshot")
async def config_snapshot():
cfg = load_config()
return {"model": cfg.get("model", {})}
Собственный CSS для плагина
Если плагину нужны стили помимо классов Tailwind и инлайнового style=, добавьте CSS-файл и сошлитесь на него в манифесте:
{
"css": "dist/style.css"
}
Файл вставляется тегом <link> при загрузке плагина. Используйте специфичные имена классов, чтобы не конфликтовать со стилями дашборда, и ссылайтесь на CSS-переменные дашборда, чтобы оставаться в курсе темы:
/* dist/style.css */
.my-plugin-chart {
border: 1px solid var(--color-border);
background: var(--color-card);
color: var(--color-card-foreground);
padding: 1rem;
}
.my-plugin-chart:hover {
border-color: var(--color-ring);
}
Дашборд выставляет каждый токен shadcn как --color-* плюс дополнения темы (--theme-asset-*, --component-<bucket>-*, --radius, --spacing-mul). Ссылайтесь на них — и ваш плагин автоматически перекрашивается под активную тему.
Обнаружение и перезагрузка плагинов
Дашборд сканирует на наличие dashboard/manifest.json три каталога:
| Приоритет | Каталог | Метка источника |
|---|---|---|
| 1 (выигрывает при конфликте) | ~/.hermes/plugins/<name>/dashboard/ | user |
| 2 | <repo>/plugins/memory/<name>/dashboard/ | bundled |
| 2 | <repo>/plugins/<name>/dashboard/ | bundled |
| 3 | ./.hermes/plugins/<name>/dashboard/ | project — только когда задана HERMES_ENABLE_PROJECT_PLUGINS |
Результаты обнаружения кэшируются на процесс дашборда. После добавления нового плагина либо:
# Force a rescan without restart
curl http://127.0.0.1:9119/api/dashboard/plugins/rescan
…либо перезапустите hermes dashboard.
Жизненный цикл загрузки плагина
- Дашборд загружается.
main.tsxвыставляет SDK наwindow.__HERMES_PLUGIN_SDK__, а реестр — наwindow.__HERMES_PLUGINS__. App.tsxвызываетusePlugins()→ запрашиваетGET /api/dashboard/plugins.- Для каждого манифеста: вставляется
<link>для CSS (если объявлен), затем тег<script>загружает JS-бандл. - IIFE плагина выполняется и вызывает
window.__HERMES_PLUGINS__.register(name, Component)— и, по желанию,.registerSlot(name, slot, Component)для каждого слота. - Дашборд сопоставляет зарегистрированный компонент с манифестом, добавляет вкладку в навигацию (кроме случая
hidden) и монтирует компонент как маршрут.
У плагинов есть до 2 секунд после загрузки их скрипта, чтобы вызвать register(). После этого дашборд перестаёт ждать и завершает первичный рендер. Если плагин зарегистрируется позже, он всё равно появится — навигация реактивна.
Если скрипт плагина не загрузился (404, синтаксическая ошибка, исключение во время IIFE), дашборд пишет предупреждение в консоль браузера и продолжает без него.
Совместное демо: тема + плагин
Плагин strike-freedom-cockpit (сопутствующий репозиторий hermes-example-plugins) — полноценное демо рескина. Он соединяет YAML темы со слот-плагином, чтобы получить HUD в стиле cockpit без форка дашборда.
Что он демонстрирует:
- Полную тему с использованием палитры, типографики,
fontUrl,layoutVariant: cockpit,assets,componentStyles(карточки со срезанными углами, градиентные фоны),colorOverridesиcustomCSS(оверлей со сканлайнами). - Слот-плагин (
tab.hidden: true), который регистрируется в три слота:sidebar— панель MS-STATUS с живыми полосами телеметрии на данных отSDK.api.getStatus().header-left— герб фракции, читающий--theme-asset-crestиз активной темы.footer-right— собственный слоган, заменяющий дефолтную строку организации.
- Плагин читает поставляемые темой арты через CSS-переменные, поэтому смена темы меняет hero/crest без правок кода плагина.
Установка:
git clone https://github.com/NousResearch/hermes-example-plugins.git
# Theme
cp hermes-example-plugins/strike-freedom-cockpit/theme/strike-freedom.yaml \
~/.hermes/dashboard-themes/
# Plugin
cp -r hermes-example-plugins/strike-freedom-cockpit ~/.hermes/plugins/
Откройте дашборд, выберите Strike Freedom в переключателе тем. Появляется боковая панель cockpit, в шапке показывается герб, слоган заменяет подвал. Переключитесь обратно на Hermes Teal — плагин остаётся установленным, но невидимым (слот sidebar рендерится только при варианте макета cockpit).
Прочитайте исходник плагина (strike-freedom-cockpit/dashboard/dist/index.js в сопутствующем репозитории), чтобы увидеть, как он читает CSS-переменные, защищается от старых дашбордов без поддержки слотов и регистрирует три слота из одного бандла.
Справочник API
Эндпоинты тем
| Эндпоинт | Метод | Описание |
|---|---|---|
/api/dashboard/themes | GET | Список доступных тем + имя активной. Встроенные возвращают {name, label, description}; пользовательские темы дополнительно включают поле definition с полным нормализованным объектом темы. |
/api/dashboard/theme | PUT | Установить активную тему. Тело: {"name": "midnight"}. Сохраняется в config.yaml под ключом dashboard.theme. |
Эндпоинты плагинов
| Эндпоинт | Метод | Описание |
|---|---|---|
/api/dashboard/plugins | GET | Список обнаруженных плагинов (с манифестами, без внутренних полей). |
/api/dashboard/plugins/rescan | GET | Принудительно пересканировать каталоги плагинов без перезапуска. |
/dashboard-plugins/<name>/<path> | GET | Отдаёт статические ассеты из каталога dashboard/ плагина. Обход путей заблокирован. |
/api/plugins/<name>/* | * | Backend-маршруты, зарегистрированные плагином. |
SDK на window
| Глобал | Тип | Провайдер |
|---|---|---|
window.__HERMES_PLUGIN_SDK__ | object | registry.ts — React, хуки, UI-компоненты, клиент API, утилиты. |
window.__HERMES_PLUGINS__.register(name, Component) | function | Регистрирует главный компонент плагина. |
window.__HERMES_PLUGINS__.registerSlot(name, slot, Component) | function | Регистрирует в именованный слот оболочки. |
Решение проблем
Моя тема не появляется в переключателе.
Проверьте, что файл лежит в ~/.hermes/dashboard-themes/ и заканчивается на .yaml или .yml. Обновите страницу. Выполните curl http://127.0.0.1:9119/api/dashboard/themes — ваша тема должна быть в ответе. Если в YAML ошибка парсинга, дашборд пишет в errors.log под ~/.hermes/logs/.
Вкладка моего плагина не показывается.
- Проверьте, что манифест лежит в
~/.hermes/plugins/<name>/dashboard/manifest.json(обратите внимание на подкаталогdashboard/). - Выполните
curl http://127.0.0.1:9119/api/dashboard/plugins/rescan, чтобы принудительно переобнаружить. - Откройте dev-инструменты браузера → Network — убедитесь, что
manifest.json,index.jsи любой CSS загрузились без 404. - Откройте dev-инструменты браузера → Console — поищите ошибки во время IIFE или
window.__HERMES_PLUGINS__ is undefined(признак того, что SDK не инициализировался, обычно из-за более раннего краша рендера React). - Убедитесь, что ваш бандл вызывает
window.__HERMES_PLUGINS__.register(...)с тем же именем, что иmanifest.json:name.
Компоненты, зарегистрированные в слотах, не рендерятся.
Слот sidebar рендерится только когда у активной темы layoutVariant: cockpit. Остальные слоты рендерятся всегда. Если регистрируетесь в слот без попаданий, добавьте console.log внутрь registerSlot, чтобы подтвердить, что бандл плагина вообще запустился.
Backend-маршруты плагина возвращают 404.
- Подтвердите, что в манифесте есть
"api": "plugin_api.py"и он указывает на существующий файл внутриdashboard/. - Перезапустите
hermes dashboard— маршруты API плагинов монтируются один раз при старте, а не на пересканировании. - Проверьте, что
plugin_api.pyэкспортируетrouter = APIRouter()на уровне модуля. Другие имена экспорта не подхватываются. - Просмотрите
~/.hermes/logs/errors.logна предметFailed to load plugin <name> API routes— ошибки импорта пишутся туда.
Смена темы сбрасывает мои переопределения цветов.
colorOverrides привязаны к активной теме и сбрасываются при переключении — так задумано. Если хотите устойчивые переопределения, кладите их в YAML своей темы, а не в живой переключатель.
customCSS темы обрезается.
Блок customCSS ограничен 32 KiB на тему. Разбейте крупные таблицы стилей на несколько тем или перейдите на плагин, который вставляет полную таблицу стилей через поле css (без ограничения по размеру).
Хочу выложить плагин на PyPI.
Плагины дашборда устанавливаются по структуре каталогов, а не по pip entry point. Сейчас самый чистый путь дистрибуции — git-репозиторий, который пользователь клонирует в ~/.hermes/plugins/. Pip-установщик для плагинов дашборда пока не подключён.