Расширение дашборда

Веб-дашборд Hermes (hermes dashboard) задуман так, чтобы его можно было перекрасить и расширить, не форкая кодовую базу. Открыто три слоя:

  1. Темы — YAML-файлы, которые перекрашивают палитру дашборда, типографику, макет и оформление отдельных компонентов. Кладёте файл в ~/.hermes/dashboard-themes/ — и он появляется в переключателе тем.
  2. UI-плагины — каталог с manifest.json и JavaScript-бандлом, который регистрирует вкладку, заменяет встроенную страницу, дополняет её через слоты уровня страницы или подставляет компоненты в именованные слоты оболочки.
  3. 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, шаги установки — в разделе Совместное демо: тема + плагин) делает ровно это.


Содержание


Темы

Темы — это 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

Типографика

КлючТипОписание
fontSansstringCSS-стек font-family для основного текста (применяется к html, body).
fontMonostringCSS-стек font-family для блоков кода, <code>, утилит .font-mono.
fontDisplaystringНеобязательный стек для заголовков/дисплея. Откатывается к fontSans.
fontUrlstringНеобязательный URL внешней таблицы стилей. При смене темы вставляется как <link rel="stylesheet"> в <head>. Один и тот же URL никогда не вставляется дважды. Работает с Google Fonts, Bunny Fonts, самохостящимися листами @font-face — со всем, на что можно сослаться.
baseSizestringКорневой размер шрифта — задаёт шкалу rem. Например, "14px", "16px".
lineHeightstringМежстрочный интервал по умолчанию. Например, "1.5", "1.65".
letterSpacingstringМежбуквенный интервал по умолчанию. Например, "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 — все скруглённые элементы смещаются разом.
densitycompact | 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)Тёмный бирюзовый + кремовыйСистемный стек, 15pxradius 0.5rem, comfortable
Hermes Teal (Large) (default-large)Как у defaultСистемный стек, 18px, line-height 1.65radius 0.5rem, spacious
Midnight (midnight)Глубокий сине-фиолетовыйInter + JetBrains Mono, 14pxradius 0.75rem, comfortable
Ember (ember)Тёплый багряный + бронзаSpectral (serif) + IBM Plex Mono, 15pxradius 0.25rem, comfortable
Mono (mono)Оттенки серогоIBM Plex Sans + IBM Plex Mono, 13pxradius 0, compact
Cyberpunk (cyberpunk)Неоновый зелёный на чёрномShare Tech Mono везде, 14pxradius 0, compact
Rosé (rose)Розовый + слоновая костьFraunces (serif) + DM Mono, 16pxradius 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.

совет
Без React.createElement

Если вам ближе 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/data
  • POST /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.

Жизненный цикл загрузки плагина

  1. Дашборд загружается. main.tsx выставляет SDK на window.__HERMES_PLUGIN_SDK__, а реестр — на window.__HERMES_PLUGINS__.
  2. App.tsx вызывает usePlugins() → запрашивает GET /api/dashboard/plugins.
  3. Для каждого манифеста: вставляется <link> для CSS (если объявлен), затем тег <script> загружает JS-бандл.
  4. IIFE плагина выполняется и вызывает window.__HERMES_PLUGINS__.register(name, Component) — и, по желанию, .registerSlot(name, slot, Component) для каждого слота.
  5. Дашборд сопоставляет зарегистрированный компонент с манифестом, добавляет вкладку в навигацию (кроме случая 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/themesGETСписок доступных тем + имя активной. Встроенные возвращают {name, label, description}; пользовательские темы дополнительно включают поле definition с полным нормализованным объектом темы.
/api/dashboard/themePUTУстановить активную тему. Тело: {"name": "midnight"}. Сохраняется в config.yaml под ключом dashboard.theme.

Эндпоинты плагинов

ЭндпоинтМетодОписание
/api/dashboard/pluginsGETСписок обнаруженных плагинов (с манифестами, без внутренних полей).
/api/dashboard/plugins/rescanGETПринудительно пересканировать каталоги плагинов без перезапуска.
/dashboard-plugins/<name>/<path>GETОтдаёт статические ассеты из каталога dashboard/ плагина. Обход путей заблокирован.
/api/plugins/<name>/**Backend-маршруты, зарегистрированные плагином.

SDK на window

ГлобалТипПровайдер
window.__HERMES_PLUGIN_SDK__objectregistry.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/.

Вкладка моего плагина не показывается.

  1. Проверьте, что манифест лежит в ~/.hermes/plugins/<name>/dashboard/manifest.json (обратите внимание на подкаталог dashboard/).
  2. Выполните curl http://127.0.0.1:9119/api/dashboard/plugins/rescan, чтобы принудительно переобнаружить.
  3. Откройте dev-инструменты браузера → Network — убедитесь, что manifest.json, index.js и любой CSS загрузились без 404.
  4. Откройте dev-инструменты браузера → Console — поищите ошибки во время IIFE или window.__HERMES_PLUGINS__ is undefined (признак того, что SDK не инициализировался, обычно из-за более раннего краша рендера React).
  5. Убедитесь, что ваш бандл вызывает window.__HERMES_PLUGINS__.register(...) с тем же именем, что и manifest.json:name.

Компоненты, зарегистрированные в слотах, не рендерятся. Слот sidebar рендерится только когда у активной темы layoutVariant: cockpit. Остальные слоты рендерятся всегда. Если регистрируетесь в слот без попаданий, добавьте console.log внутрь registerSlot, чтобы подтвердить, что бандл плагина вообще запустился.

Backend-маршруты плагина возвращают 404.

  1. Подтвердите, что в манифесте есть "api": "plugin_api.py" и он указывает на существующий файл внутри dashboard/.
  2. Перезапустите hermes dashboard — маршруты API плагинов монтируются один раз при старте, а не на пересканировании.
  3. Проверьте, что plugin_api.py экспортирует router = APIRouter() на уровне модуля. Другие имена экспорта не подхватываются.
  4. Просмотрите ~/.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-установщик для плагинов дашборда пока не подключён.

перевод и редактура:

обновлено

⌕ ESC