mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-14 22:29:09 +08:00
Compare commits
3 Commits
fix/setup-
...
fix/model-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0d3d2a2631 | ||
|
|
e5647d7863 | ||
|
|
023b1bff11 |
@@ -796,6 +796,10 @@ delegation:
|
||||
# Raise to 2 to allow workers to spawn their own subagents.
|
||||
# Requires role="orchestrator" on intermediate agents.
|
||||
# orchestrator_enabled: true # Kill switch for role="orchestrator" children (default: true).
|
||||
# subagent_auto_approve: false # When a subagent hits a dangerous-command approval prompt, auto-deny (default: false)
|
||||
# or auto-approve "once" (true) instead of blocking on stdin.
|
||||
# The parent TUI owns stdin, so blocking would deadlock; non-interactive resolution is required.
|
||||
# Both choices emit a logger.warning audit line. Flip to true only for cron/batch pipelines.
|
||||
# inherit_mcp_toolsets: true # When explicit child toolsets are narrowed, also keep the parent's MCP toolsets (default: true). Set false for strict intersection.
|
||||
# model: "google/gemini-3-flash-preview" # Override model for subagents (empty = inherit parent)
|
||||
# provider: "openrouter" # Override provider for subagents (empty = inherit parent)
|
||||
|
||||
18
cli.py
18
cli.py
@@ -5270,24 +5270,22 @@ class HermesCLI:
|
||||
# Parse --provider and --global flags
|
||||
model_input, explicit_provider, persist_global = parse_model_flags(raw_args)
|
||||
|
||||
# Load providers for switch_model (picker path needs them below)
|
||||
user_provs = None
|
||||
custom_provs = None
|
||||
try:
|
||||
from hermes_cli.config import get_compatible_custom_providers, load_config
|
||||
cfg = load_config()
|
||||
user_provs = cfg.get("providers")
|
||||
custom_provs = get_compatible_custom_providers(cfg)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# No args at all: open prompt_toolkit-native picker modal
|
||||
if not model_input and not explicit_provider:
|
||||
model_display = self.model or "unknown"
|
||||
provider_display = get_label(self.provider) if self.provider else "unknown"
|
||||
|
||||
user_provs = None
|
||||
custom_provs = None
|
||||
try:
|
||||
from hermes_cli.config import get_compatible_custom_providers, load_config
|
||||
cfg = load_config()
|
||||
user_provs = cfg.get("providers")
|
||||
custom_provs = get_compatible_custom_providers(cfg)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
providers = list_authenticated_providers(
|
||||
current_provider=self.provider or "",
|
||||
|
||||
@@ -783,6 +783,15 @@ DEFAULT_CONFIG = {
|
||||
# warning log if out of range.
|
||||
"max_spawn_depth": 1, # depth cap (1 = flat [default], 2 = orchestrator→leaf, 3 = three-level)
|
||||
"orchestrator_enabled": True, # kill switch for role="orchestrator"
|
||||
# When a subagent hits a dangerous-command approval prompt, the parent's
|
||||
# prompt_toolkit TUI owns stdin — a thread-local input() call from the
|
||||
# subagent worker would deadlock the parent UI. To avoid the deadlock,
|
||||
# subagent threads ALWAYS resolve approvals non-interactively:
|
||||
# false (default) → auto-deny with a logger.warning audit line (safe)
|
||||
# true → auto-approve "once" with a logger.warning audit line
|
||||
# Flip to true only if you trust delegated work to run dangerous cmds
|
||||
# without human review (cron pipelines, batch automation, etc.).
|
||||
"subagent_auto_approve": False,
|
||||
},
|
||||
|
||||
# Ephemeral prefill messages file — JSON list of {role, content} dicts
|
||||
|
||||
@@ -831,9 +831,14 @@ def switch_model(
|
||||
requested=current_provider,
|
||||
target_model=new_model,
|
||||
)
|
||||
api_key = runtime.get("api_key", "")
|
||||
base_url = runtime.get("base_url", "")
|
||||
api_mode = runtime.get("api_mode", "")
|
||||
# If resolution fell through to "custom" (e.g. named custom provider like
|
||||
# "ollama-launch" that resolve_runtime_provider doesn't know), keep existing
|
||||
# credentials. Otherwise use the resolved values (picks up credential rotation,
|
||||
# base_url adjustments for OpenCode, etc.).
|
||||
if runtime.get("provider") != "custom":
|
||||
api_key = runtime.get("api_key", "")
|
||||
base_url = runtime.get("base_url", "")
|
||||
api_mode = runtime.get("api_mode", "")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -867,16 +872,31 @@ def switch_model(
|
||||
"message": f"Could not validate `{new_model}`: {e}",
|
||||
}
|
||||
|
||||
# Override rejection if model is in the user's saved provider config.
|
||||
# API /v1/models may not list cloud/aliased models even though the server supports them.
|
||||
if not validation.get("accepted"):
|
||||
msg = validation.get("message", "Invalid model")
|
||||
return ModelSwitchResult(
|
||||
success=False,
|
||||
new_model=new_model,
|
||||
target_provider=target_provider,
|
||||
provider_label=provider_label,
|
||||
is_global=is_global,
|
||||
error_message=msg,
|
||||
)
|
||||
override = False
|
||||
if user_providers:
|
||||
for up in user_providers:
|
||||
if isinstance(up, dict) and up.get("provider") == target_provider:
|
||||
cfg_models = up.get("models", [])
|
||||
if new_model in cfg_models or any(
|
||||
m.get("name") == new_model for m in cfg_models if isinstance(m, dict)
|
||||
):
|
||||
override = True
|
||||
break
|
||||
if override:
|
||||
validation = {"accepted": True, "persist": True, "recognized": False, "message": validation.get("message", "")}
|
||||
else:
|
||||
msg = validation.get("message", "Invalid model")
|
||||
return ModelSwitchResult(
|
||||
success=False,
|
||||
new_model=new_model,
|
||||
target_provider=target_provider,
|
||||
provider_label=provider_label,
|
||||
is_global=is_global,
|
||||
error_message=msg,
|
||||
)
|
||||
|
||||
# Apply auto-correction if validation found a closer match
|
||||
if validation.get("corrected_model"):
|
||||
|
||||
@@ -2571,8 +2571,8 @@ def validate_requested_model(
|
||||
)
|
||||
|
||||
return {
|
||||
"accepted": False,
|
||||
"persist": False,
|
||||
"accepted": True,
|
||||
"persist": True,
|
||||
"recognized": False,
|
||||
"message": message,
|
||||
}
|
||||
|
||||
@@ -2128,5 +2128,103 @@ class TestOrchestratorEndToEnd(unittest.TestCase):
|
||||
self.assertFalse(built_agents[2]["is_orchestrator_prompt"])
|
||||
|
||||
|
||||
class TestSubagentApprovalCallback(unittest.TestCase):
|
||||
"""Subagent worker threads must have a non-interactive approval callback
|
||||
installed so dangerous-command prompts don't fall back to input() and
|
||||
deadlock the parent's prompt_toolkit TUI.
|
||||
|
||||
Governed by delegation.subagent_auto_approve:
|
||||
false (default) → _subagent_auto_deny
|
||||
true → _subagent_auto_approve
|
||||
"""
|
||||
|
||||
def test_auto_deny_returns_deny(self):
|
||||
from tools.delegate_tool import _subagent_auto_deny
|
||||
self.assertEqual(
|
||||
_subagent_auto_deny("rm -rf /tmp/x", "dangerous"),
|
||||
"deny",
|
||||
)
|
||||
|
||||
def test_auto_approve_returns_once(self):
|
||||
from tools.delegate_tool import _subagent_auto_approve
|
||||
self.assertEqual(
|
||||
_subagent_auto_approve("rm -rf /tmp/x", "dangerous"),
|
||||
"once",
|
||||
)
|
||||
|
||||
@patch("tools.delegate_tool._load_config", return_value={})
|
||||
def test_getter_defaults_to_deny(self, _mock_cfg):
|
||||
from tools.delegate_tool import (
|
||||
_get_subagent_approval_callback,
|
||||
_subagent_auto_deny,
|
||||
)
|
||||
self.assertIs(_get_subagent_approval_callback(), _subagent_auto_deny)
|
||||
|
||||
@patch(
|
||||
"tools.delegate_tool._load_config",
|
||||
return_value={"subagent_auto_approve": False},
|
||||
)
|
||||
def test_getter_explicit_false_is_deny(self, _mock_cfg):
|
||||
from tools.delegate_tool import (
|
||||
_get_subagent_approval_callback,
|
||||
_subagent_auto_deny,
|
||||
)
|
||||
self.assertIs(_get_subagent_approval_callback(), _subagent_auto_deny)
|
||||
|
||||
@patch(
|
||||
"tools.delegate_tool._load_config",
|
||||
return_value={"subagent_auto_approve": True},
|
||||
)
|
||||
def test_getter_true_is_approve(self, _mock_cfg):
|
||||
from tools.delegate_tool import (
|
||||
_get_subagent_approval_callback,
|
||||
_subagent_auto_approve,
|
||||
)
|
||||
self.assertIs(_get_subagent_approval_callback(), _subagent_auto_approve)
|
||||
|
||||
@patch(
|
||||
"tools.delegate_tool._load_config",
|
||||
return_value={"subagent_auto_approve": "yes"},
|
||||
)
|
||||
def test_getter_truthy_string_is_approve(self, _mock_cfg):
|
||||
"""is_truthy_value accepts 'yes'/'1'/'true' as truthy."""
|
||||
from tools.delegate_tool import (
|
||||
_get_subagent_approval_callback,
|
||||
_subagent_auto_approve,
|
||||
)
|
||||
self.assertIs(_get_subagent_approval_callback(), _subagent_auto_approve)
|
||||
|
||||
def test_executor_initializer_installs_callback_in_worker(self):
|
||||
"""The initializer sets the callback on the worker thread's TLS,
|
||||
not the parent's — verifies the fix actually scopes to workers.
|
||||
"""
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from tools.terminal_tool import (
|
||||
set_approval_callback as _set_cb,
|
||||
_get_approval_callback,
|
||||
)
|
||||
from tools.delegate_tool import _subagent_auto_deny
|
||||
|
||||
# Parent thread has no callback.
|
||||
_set_cb(None)
|
||||
self.assertIsNone(_get_approval_callback())
|
||||
|
||||
seen = []
|
||||
|
||||
def worker():
|
||||
seen.append(_get_approval_callback())
|
||||
|
||||
with ThreadPoolExecutor(
|
||||
max_workers=1,
|
||||
initializer=_set_cb,
|
||||
initargs=(_subagent_auto_deny,),
|
||||
) as executor:
|
||||
executor.submit(worker).result()
|
||||
|
||||
self.assertEqual(seen, [_subagent_auto_deny])
|
||||
# Parent's callback slot is still empty (TLS isolates threads).
|
||||
self.assertIsNone(_get_approval_callback())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -33,6 +33,7 @@ from typing import Any, Dict, List, Optional
|
||||
|
||||
from toolsets import TOOLSETS
|
||||
from tools import file_state
|
||||
from tools.terminal_tool import set_approval_callback as _set_subagent_approval_cb
|
||||
from utils import base_url_hostname, is_truthy_value
|
||||
|
||||
|
||||
@@ -47,6 +48,64 @@ DELEGATE_BLOCKED_TOOLS = frozenset(
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Subagent approval callbacks
|
||||
# ---------------------------------------------------------------------------
|
||||
# Subagents run inside a ThreadPoolExecutor worker. The CLI's interactive
|
||||
# approval callback is stored in tools/terminal_tool.py's threading.local(),
|
||||
# so worker threads do NOT inherit it. Without a callback,
|
||||
# prompt_dangerous_approval() falls back to input() from the worker thread,
|
||||
# which deadlocks against the parent's prompt_toolkit TUI that owns stdin.
|
||||
#
|
||||
# Fix: install a non-interactive callback into every subagent worker thread
|
||||
# via ThreadPoolExecutor(initializer=_set_subagent_approval_cb, initargs=(cb,)).
|
||||
# The callback is chosen by the `delegation.subagent_auto_approve` config:
|
||||
# false (default) → _subagent_auto_deny (safe; matches leaf tool blocklist)
|
||||
# true → _subagent_auto_approve (opt-in YOLO for cron/batch)
|
||||
# Both emit a logger.warning for audit; gateway sessions are unaffected
|
||||
# because they resolve approvals via tools/approval.py's per-session queue,
|
||||
# not through these TLS callbacks.
|
||||
def _subagent_auto_deny(command: str, description: str, **kwargs) -> str:
|
||||
"""Auto-deny dangerous commands in subagent threads (safe default).
|
||||
|
||||
Returns 'deny' so the subagent sees a refusal it can recover from, and
|
||||
never calls input() (which would deadlock the parent TUI).
|
||||
"""
|
||||
logger.warning(
|
||||
"Subagent auto-denied dangerous command: %s (%s). "
|
||||
"Set delegation.subagent_auto_approve: true to allow.",
|
||||
command, description,
|
||||
)
|
||||
return "deny"
|
||||
|
||||
|
||||
def _subagent_auto_approve(command: str, description: str, **kwargs) -> str:
|
||||
"""Auto-approve dangerous commands in subagent threads (opt-in YOLO).
|
||||
|
||||
Only installed when delegation.subagent_auto_approve=true. Returns 'once'
|
||||
so the subagent proceeds without blocking the parent UI.
|
||||
"""
|
||||
logger.warning(
|
||||
"Subagent auto-approved dangerous command: %s (%s)",
|
||||
command, description,
|
||||
)
|
||||
return "once"
|
||||
|
||||
|
||||
def _get_subagent_approval_callback():
|
||||
"""Return the callback to install into subagent worker threads.
|
||||
|
||||
Config key: delegation.subagent_auto_approve (bool, default False).
|
||||
Reads via the same _load_config() path as the rest of delegate_task so
|
||||
priority is config.yaml > (no env override for this knob) > default.
|
||||
"""
|
||||
cfg = _load_config()
|
||||
val = cfg.get("subagent_auto_approve", False)
|
||||
if is_truthy_value(val):
|
||||
return _subagent_auto_approve
|
||||
return _subagent_auto_deny
|
||||
|
||||
# Build a description fragment listing toolsets available for subagents.
|
||||
# Excludes toolsets where ALL tools are blocked, composite/platform toolsets
|
||||
# (hermes-* prefixed), and scenario toolsets.
|
||||
@@ -1344,7 +1403,15 @@ def _run_single_child(
|
||||
# Run child with a hard timeout to prevent indefinite blocking
|
||||
# when the child's API call or tool-level HTTP request hangs.
|
||||
child_timeout = _get_child_timeout()
|
||||
_timeout_executor = ThreadPoolExecutor(max_workers=1)
|
||||
_timeout_executor = ThreadPoolExecutor(
|
||||
max_workers=1,
|
||||
# Install a non-interactive approval callback in the worker thread
|
||||
# so dangerous-command prompts from the subagent don't fall back to
|
||||
# input() and deadlock the parent's prompt_toolkit TUI.
|
||||
# Callback (deny vs approve) is governed by delegation.subagent_auto_approve.
|
||||
initializer=_set_subagent_approval_cb,
|
||||
initargs=(_get_subagent_approval_callback(),),
|
||||
)
|
||||
# Capture the worker thread so the timeout diagnostic can dump its
|
||||
# Python stack (see #14726 — 0-API-call hangs are opaque without it).
|
||||
_worker_thread_holder: Dict[str, Optional[threading.Thread]] = {"t": None}
|
||||
|
||||
@@ -712,6 +712,18 @@ def _apply_model_switch(sid: str, session: dict, raw_input: str) -> dict:
|
||||
current_base_url = str(runtime.get("base_url", "") or "")
|
||||
current_api_key = str(runtime.get("api_key", "") or "")
|
||||
|
||||
# Load user-defined providers so switch_model can resolve named custom
|
||||
# endpoints (e.g. "ollama-launch") and validate against saved model lists.
|
||||
user_provs = None
|
||||
custom_provs = None
|
||||
try:
|
||||
from hermes_cli.config import get_compatible_custom_providers, load_config
|
||||
cfg = load_config()
|
||||
user_provs = [{"provider": k, **v} for k, v in (cfg.get("providers") or {}).items()]
|
||||
custom_provs = get_compatible_custom_providers(cfg)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
result = switch_model(
|
||||
raw_input=model_input,
|
||||
current_provider=current_provider,
|
||||
@@ -720,6 +732,8 @@ def _apply_model_switch(sid: str, session: dict, raw_input: str) -> dict:
|
||||
current_api_key=current_api_key,
|
||||
is_global=persist_global,
|
||||
explicit_provider=explicit_provider,
|
||||
user_providers=user_provs,
|
||||
custom_providers=custom_provs,
|
||||
)
|
||||
if not result.success:
|
||||
raise ValueError(result.error_message or "model switch failed")
|
||||
|
||||
@@ -1,336 +0,0 @@
|
||||
---
|
||||
sidebar_position: 16
|
||||
title: "Dashboard Plugins"
|
||||
description: "Build custom tabs and extensions for the Hermes web dashboard"
|
||||
---
|
||||
|
||||
# Dashboard Plugins
|
||||
|
||||
Dashboard plugins let you add custom tabs to the web dashboard. A plugin can display its own UI, call the Hermes API, and optionally register backend endpoints — all without touching the dashboard source code.
|
||||
|
||||
## Quick Start
|
||||
|
||||
Create a plugin directory with a manifest and a JS file:
|
||||
|
||||
```bash
|
||||
mkdir -p ~/.hermes/plugins/my-plugin/dashboard/dist
|
||||
```
|
||||
|
||||
**manifest.json:**
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "my-plugin",
|
||||
"label": "My Plugin",
|
||||
"icon": "Sparkles",
|
||||
"version": "1.0.0",
|
||||
"tab": {
|
||||
"path": "/my-plugin",
|
||||
"position": "after:skills"
|
||||
},
|
||||
"entry": "dist/index.js"
|
||||
}
|
||||
```
|
||||
|
||||
**dist/index.js:**
|
||||
|
||||
```javascript
|
||||
(function () {
|
||||
var SDK = window.__HERMES_PLUGIN_SDK__;
|
||||
var React = SDK.React;
|
||||
var Card = SDK.components.Card;
|
||||
var CardHeader = SDK.components.CardHeader;
|
||||
var CardTitle = SDK.components.CardTitle;
|
||||
var CardContent = SDK.components.CardContent;
|
||||
|
||||
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);
|
||||
})();
|
||||
```
|
||||
|
||||
Refresh the dashboard — your tab appears in the navigation bar.
|
||||
|
||||
## Plugin Structure
|
||||
|
||||
Plugins live inside the standard `~/.hermes/plugins/` directory. The dashboard extension is a `dashboard/` subfolder:
|
||||
|
||||
```
|
||||
~/.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
|
||||
style.css # optional — custom CSS
|
||||
plugin_api.py # optional — backend API routes
|
||||
```
|
||||
|
||||
A single plugin can extend both the CLI/gateway (via `plugin.yaml` + `__init__.py`) and the dashboard (via `dashboard/`) from one directory.
|
||||
|
||||
## Manifest Reference
|
||||
|
||||
The `manifest.json` file describes your plugin to the dashboard:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "my-plugin",
|
||||
"label": "My Plugin",
|
||||
"description": "What this plugin does",
|
||||
"icon": "Sparkles",
|
||||
"version": "1.0.0",
|
||||
"tab": {
|
||||
"path": "/my-plugin",
|
||||
"position": "after:skills"
|
||||
},
|
||||
"entry": "dist/index.js",
|
||||
"css": "dist/style.css",
|
||||
"api": "plugin_api.py"
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Required | Description |
|
||||
|-------|----------|-------------|
|
||||
| `name` | Yes | Unique plugin identifier (lowercase, hyphens ok) |
|
||||
| `label` | Yes | Display name shown in the nav tab |
|
||||
| `description` | No | Short description |
|
||||
| `icon` | No | Lucide icon name (default: `Puzzle`) |
|
||||
| `version` | No | Semver version string |
|
||||
| `tab.path` | Yes | URL path for the tab (e.g. `/my-plugin`) |
|
||||
| `tab.position` | No | Where to insert the tab: `end` (default), `after:<tab>`, `before:<tab>` |
|
||||
| `entry` | Yes | Path to the JS bundle relative to `dashboard/` |
|
||||
| `css` | No | Path to a CSS file to inject |
|
||||
| `api` | No | Path to a Python file with FastAPI routes |
|
||||
|
||||
### Tab Position
|
||||
|
||||
The `position` field controls where your tab appears in the navigation:
|
||||
|
||||
- `"end"` — after all built-in tabs (default)
|
||||
- `"after:skills"` — after the Skills tab
|
||||
- `"before:config"` — before the Config tab
|
||||
- `"after:cron"` — after the Cron tab
|
||||
|
||||
The value after the colon is the path segment of the target tab (without the leading slash).
|
||||
|
||||
### Available Icons
|
||||
|
||||
Plugins can use any of these Lucide icon names:
|
||||
|
||||
`Activity`, `BarChart3`, `Clock`, `Code`, `Database`, `Eye`, `FileText`, `Globe`, `Heart`, `KeyRound`, `MessageSquare`, `Package`, `Puzzle`, `Settings`, `Shield`, `Sparkles`, `Star`, `Terminal`, `Wrench`, `Zap`
|
||||
|
||||
Unrecognized icon names fall back to `Puzzle`.
|
||||
|
||||
## Plugin SDK
|
||||
|
||||
Plugins don't bundle React or UI components — they use the SDK exposed on `window.__HERMES_PLUGIN_SDK__`. This avoids version conflicts and keeps plugin bundles tiny.
|
||||
|
||||
### SDK Contents
|
||||
|
||||
```javascript
|
||||
var SDK = window.__HERMES_PLUGIN_SDK__;
|
||||
|
||||
// React
|
||||
SDK.React // React instance
|
||||
SDK.hooks.useState // React hooks
|
||||
SDK.hooks.useEffect
|
||||
SDK.hooks.useCallback
|
||||
SDK.hooks.useMemo
|
||||
SDK.hooks.useRef
|
||||
SDK.hooks.useContext
|
||||
SDK.hooks.createContext
|
||||
|
||||
// API
|
||||
SDK.api // Hermes API client (getStatus, getSessions, etc.)
|
||||
SDK.fetchJSON // Raw fetch for custom endpoints — handles auth automatically
|
||||
|
||||
// UI Components (shadcn/ui style)
|
||||
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
|
||||
|
||||
// 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 translations
|
||||
SDK.useTheme // Current theme info
|
||||
```
|
||||
|
||||
### Using SDK.fetchJSON
|
||||
|
||||
For calling your plugin's backend API endpoints:
|
||||
|
||||
```javascript
|
||||
SDK.fetchJSON("/api/plugins/my-plugin/data")
|
||||
.then(function (result) {
|
||||
console.log(result);
|
||||
})
|
||||
.catch(function (err) {
|
||||
console.error("API call failed:", err);
|
||||
});
|
||||
```
|
||||
|
||||
`fetchJSON` automatically injects the session auth token, handles errors, and parses JSON.
|
||||
|
||||
### Using Existing API Methods
|
||||
|
||||
The `SDK.api` object has methods for all built-in Hermes endpoints:
|
||||
|
||||
```javascript
|
||||
// Fetch agent status
|
||||
SDK.api.getStatus().then(function (status) {
|
||||
console.log("Version:", status.version);
|
||||
});
|
||||
|
||||
// List sessions
|
||||
SDK.api.getSessions(10).then(function (resp) {
|
||||
console.log("Sessions:", resp.sessions.length);
|
||||
});
|
||||
```
|
||||
|
||||
## Backend API Routes
|
||||
|
||||
Plugins can register FastAPI routes by setting the `api` field in the manifest. Create a Python file that exports a `router`:
|
||||
|
||||
```python
|
||||
# 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}
|
||||
```
|
||||
|
||||
Routes are mounted at `/api/plugins/<name>/`, so the above becomes:
|
||||
- `GET /api/plugins/my-plugin/data`
|
||||
- `POST /api/plugins/my-plugin/action`
|
||||
|
||||
Plugin API routes bypass session token authentication since the dashboard server only binds to localhost.
|
||||
|
||||
### Accessing Hermes Internals
|
||||
|
||||
Backend routes can import from the hermes-agent codebase:
|
||||
|
||||
```python
|
||||
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()
|
||||
```
|
||||
|
||||
## Custom CSS
|
||||
|
||||
If your plugin needs custom styles, add a CSS file and reference it in the manifest:
|
||||
|
||||
```json
|
||||
{
|
||||
"css": "dist/style.css"
|
||||
}
|
||||
```
|
||||
|
||||
The CSS file is injected as a `<link>` tag when the plugin loads. Use specific class names to avoid conflicts with the dashboard's existing styles.
|
||||
|
||||
```css
|
||||
/* dist/style.css */
|
||||
.my-plugin-chart {
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-card);
|
||||
padding: 1rem;
|
||||
}
|
||||
```
|
||||
|
||||
You can use the dashboard's CSS custom properties (e.g. `--color-border`, `--color-foreground`) to match the active theme.
|
||||
|
||||
## Plugin Loading Flow
|
||||
|
||||
1. Dashboard loads — `main.tsx` exposes the SDK on `window.__HERMES_PLUGIN_SDK__`
|
||||
2. `App.tsx` calls `usePlugins()` which fetches `GET /api/dashboard/plugins`
|
||||
3. For each plugin: CSS `<link>` injected (if declared), JS `<script>` loaded
|
||||
4. Plugin JS calls `window.__HERMES_PLUGINS__.register(name, Component)`
|
||||
5. Dashboard adds the tab to navigation and mounts the component as a route
|
||||
|
||||
Plugins have up to 2 seconds to register after their script loads. If a plugin fails to load, the dashboard continues without it.
|
||||
|
||||
## Plugin Discovery
|
||||
|
||||
The dashboard scans these directories for `dashboard/manifest.json`:
|
||||
|
||||
1. **User plugins:** `~/.hermes/plugins/<name>/dashboard/manifest.json`
|
||||
2. **Bundled plugins:** `<repo>/plugins/<name>/dashboard/manifest.json`
|
||||
3. **Project plugins:** `./.hermes/plugins/<name>/dashboard/manifest.json` (only when `HERMES_ENABLE_PROJECT_PLUGINS` is set)
|
||||
|
||||
User plugins take precedence — if the same plugin name exists in multiple sources, the user version wins.
|
||||
|
||||
To force re-scanning after adding a new plugin without restarting the server:
|
||||
|
||||
```bash
|
||||
curl http://127.0.0.1:9119/api/dashboard/plugins/rescan
|
||||
```
|
||||
|
||||
## Plugin API Endpoints
|
||||
|
||||
| Endpoint | Method | Description |
|
||||
|----------|--------|-------------|
|
||||
| `/api/dashboard/plugins` | GET | List discovered plugins |
|
||||
| `/api/dashboard/plugins/rescan` | GET | Force re-scan for new plugins |
|
||||
| `/dashboard-plugins/<name>/<path>` | GET | Serve plugin static assets |
|
||||
| `/api/plugins/<name>/*` | * | Plugin-registered API routes |
|
||||
|
||||
## Example Plugin
|
||||
|
||||
The repository includes an example plugin at `plugins/example-dashboard/` that demonstrates:
|
||||
|
||||
- Using SDK components (Card, Badge, Button)
|
||||
- Calling a backend API route
|
||||
- Registering via `window.__HERMES_PLUGINS__.register()`
|
||||
|
||||
To try it, run `hermes dashboard` — the "Example" tab appears after Skills.
|
||||
|
||||
## Tips
|
||||
|
||||
- **No build step required** — write plain JavaScript IIFEs. If you prefer JSX, use any bundler (esbuild, Vite, webpack) targeting IIFE output with React as an external.
|
||||
- **Keep bundles small** — React and all UI components are provided by the SDK. Your bundle should only contain your plugin logic.
|
||||
- **Use theme variables** — reference `var(--color-*)` in CSS to automatically match whatever theme the user has selected.
|
||||
- **Test locally** — run `hermes dashboard --no-open` and use browser dev tools to verify your plugin loads and registers correctly.
|
||||
818
website/docs/user-guide/features/extending-the-dashboard.md
Normal file
818
website/docs/user-guide/features/extending-the-dashboard.md
Normal file
@@ -0,0 +1,818 @@
|
||||
---
|
||||
sidebar_position: 17
|
||||
title: "Extending the Dashboard"
|
||||
description: "Build themes and plugins for the Hermes web dashboard — palettes, typography, layouts, custom tabs, shell slots, and backend API routes"
|
||||
---
|
||||
|
||||
# Extending the Dashboard
|
||||
|
||||
The Hermes web dashboard (`hermes dashboard`) is built to be reskinned and extended without forking the codebase. Three layers are exposed:
|
||||
|
||||
1. **Themes** — YAML files that repaint the dashboard's palette, typography, layout, and per-component chrome. Drop a file in `~/.hermes/dashboard-themes/`; it appears in the theme switcher.
|
||||
2. **UI plugins** — a directory with `manifest.json` + a JavaScript bundle that registers a tab, replaces a built-in page, or injects components into named shell slots.
|
||||
3. **Backend plugins** — a Python file inside that plugin directory that exposes a FastAPI `router`; routes are mounted under `/api/plugins/<name>/` and called from the plugin's UI.
|
||||
|
||||
All three are **drop-in at runtime**: no repo clone, no `npm run build`, no patching the dashboard source. This page is the canonical reference for all three.
|
||||
|
||||
If you just want to use the dashboard, see [Web Dashboard](./web-dashboard). If you want to reskin the terminal CLI (not the web dashboard), see [Skins & Themes](./skins) — the CLI skin system is unrelated to dashboard themes.
|
||||
|
||||
:::note How the pieces compose
|
||||
Themes and plugins are independent but synergistic. A theme can stand alone (just a YAML file). A plugin can stand alone (just a tab). Together they let you build a complete visual reskin with custom HUDs — the bundled `strike-freedom-cockpit` demo does exactly that. See [Combined theme + plugin demo](#combined-theme--plugin-demo).
|
||||
:::
|
||||
|
||||
---
|
||||
|
||||
## Table of contents
|
||||
|
||||
- [Themes](#themes)
|
||||
- [Quick start — your first theme](#quick-start--your-first-theme)
|
||||
- [Palette, typography, layout](#palette-typography-layout)
|
||||
- [Layout variants](#layout-variants)
|
||||
- [Theme assets (images as CSS vars)](#theme-assets-images-as-css-vars)
|
||||
- [Component chrome overrides](#component-chrome-overrides)
|
||||
- [Color overrides](#color-overrides)
|
||||
- [Raw `customCSS`](#raw-customcss)
|
||||
- [Built-in themes](#built-in-themes)
|
||||
- [Full theme YAML reference](#full-theme-yaml-reference)
|
||||
- [Plugins](#plugins)
|
||||
- [Quick start — your first plugin](#quick-start--your-first-plugin)
|
||||
- [Directory layout](#directory-layout)
|
||||
- [Manifest reference](#manifest-reference)
|
||||
- [The Plugin SDK](#the-plugin-sdk)
|
||||
- [Shell slots](#shell-slots)
|
||||
- [Replacing built-in pages (`tab.override`)](#replacing-built-in-pages-taboverride)
|
||||
- [Slot-only plugins (`tab.hidden`)](#slot-only-plugins-tabhidden)
|
||||
- [Backend API routes](#backend-api-routes)
|
||||
- [Custom CSS per plugin](#custom-css-per-plugin)
|
||||
- [Plugin discovery & reload](#plugin-discovery--reload)
|
||||
- [Combined theme + plugin demo](#combined-theme--plugin-demo)
|
||||
- [API reference](#api-reference)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
|
||||
---
|
||||
|
||||
## Themes
|
||||
|
||||
Themes are YAML files stored in `~/.hermes/dashboard-themes/`. The file name doesn't matter (the theme's `name:` field is what the system uses), but convention is `<name>.yaml`. Every field is optional — missing keys fall back to the built-in `default` theme, so a theme can be as small as one color.
|
||||
|
||||
### Quick start — your first theme
|
||||
|
||||
```bash
|
||||
mkdir -p ~/.hermes/dashboard-themes
|
||||
```
|
||||
|
||||
```yaml
|
||||
# ~/.hermes/dashboard-themes/neon.yaml
|
||||
name: neon
|
||||
label: Neon
|
||||
description: Pure magenta on black
|
||||
|
||||
palette:
|
||||
background: "#000000"
|
||||
midground: "#ff00ff"
|
||||
```
|
||||
|
||||
Refresh the dashboard. Click the palette icon in the header and pick **Neon**. The background goes black, text and accents go magenta, and every derived color (card, border, muted, ring, etc.) is recomputed from that 2-color triplet via `color-mix()` in CSS.
|
||||
|
||||
That's the whole onboarding: one file, two colors. Everything below is optional refinement.
|
||||
|
||||
### Palette, typography, layout
|
||||
|
||||
These three blocks are the heart of a theme. Each is independent — override one, leave the others.
|
||||
|
||||
#### Palette (3-layer)
|
||||
|
||||
The palette is a triplet of color layers plus a warm-glow vignette color and a noise-grain multiplier. The dashboard's design-system cascade derives every shadcn-compatible token (card, popover, muted, border, primary, destructive, ring, etc.) from this triplet via CSS `color-mix()`. Overriding three colors cascades into the whole UI.
|
||||
|
||||
| Key | Description |
|
||||
|-----|-------------|
|
||||
| `palette.background` | Deepest canvas color — typically near-black. Drives the page background and card fill. |
|
||||
| `palette.midground` | Primary text and accent. Most UI chrome reads this (foreground text, button outlines, focus rings). |
|
||||
| `palette.foreground` | Top-layer highlight. The default theme sets this to white at alpha 0 (invisible); themes that want a bright accent on top can raise its alpha. |
|
||||
| `palette.warmGlow` | `rgba(...)` string used as the vignette color by `<Backdrop />`. |
|
||||
| `palette.noiseOpacity` | 0–1.2 multiplier on the grain overlay. Lower = softer, higher = grittier. |
|
||||
|
||||
Each layer accepts either `{hex: "#RRGGBB", alpha: 0.0–1.0}` or a bare hex string (alpha defaults to 1.0).
|
||||
|
||||
```yaml
|
||||
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
|
||||
```
|
||||
|
||||
#### Typography
|
||||
|
||||
| Key | Type | Description |
|
||||
|-----|------|-------------|
|
||||
| `fontSans` | string | CSS font-family stack for body copy (applied to `html`, `body`). |
|
||||
| `fontMono` | string | CSS font-family stack for code blocks, `<code>`, `.font-mono` utilities. |
|
||||
| `fontDisplay` | string | Optional heading/display stack. Falls back to `fontSans`. |
|
||||
| `fontUrl` | string | Optional external stylesheet URL. Injected as `<link rel="stylesheet">` in `<head>` on theme switch. Same URL is never injected twice. Works with Google Fonts, Bunny Fonts, self-hosted `@font-face` sheets — anything linkable. |
|
||||
| `baseSize` | string | Root font size — controls the rem scale. E.g. `"14px"`, `"16px"`. |
|
||||
| `lineHeight` | string | Default line-height. E.g. `"1.5"`, `"1.65"`. |
|
||||
| `letterSpacing` | string | Default letter-spacing. E.g. `"0"`, `"0.01em"`, `"-0.01em"`. |
|
||||
|
||||
```yaml
|
||||
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"
|
||||
```
|
||||
|
||||
#### Layout
|
||||
|
||||
| Key | Values | Description |
|
||||
|-----|--------|-------------|
|
||||
| `radius` | any CSS length (`"0"`, `"0.25rem"`, `"0.5rem"`, `"1rem"`, ...) | Corner-radius token. Maps to `--radius` and cascades into `--radius-sm/md/lg/xl` — every rounded element shifts together. |
|
||||
| `density` | `compact` \| `comfortable` \| `spacious` | Spacing multiplier applied as the `--spacing-mul` CSS var. `compact = 0.85×`, `comfortable = 1.0×` (default), `spacious = 1.2×`. Scales Tailwind's base spacing, so padding, gap, and space-between utilities all shift proportionally. |
|
||||
|
||||
```yaml
|
||||
layout:
|
||||
radius: "0"
|
||||
density: compact
|
||||
```
|
||||
|
||||
### Layout variants
|
||||
|
||||
`layoutVariant` picks the overall shell layout. Defaults to `"standard"` when absent.
|
||||
|
||||
| Variant | Behaviour |
|
||||
|---------|-----------|
|
||||
| `standard` | Single column, 1600px max-width (default). |
|
||||
| `cockpit` | Left sidebar rail (260px) + main content. Populated by plugins via the `sidebar` slot — see [Shell slots](#shell-slots). Without a plugin the rail shows a placeholder. |
|
||||
| `tiled` | Drops the max-width clamp so pages can use the full viewport width. |
|
||||
|
||||
```yaml
|
||||
layoutVariant: cockpit
|
||||
```
|
||||
|
||||
The current variant is exposed as `document.documentElement.dataset.layoutVariant`, so raw CSS in `customCSS` can target it via `:root[data-layout-variant="cockpit"] ...`.
|
||||
|
||||
### Theme assets (images as CSS vars)
|
||||
|
||||
Ship artwork URLs with a theme. Each named slot becomes a CSS var (`--theme-asset-<name>`) that the built-in shell and any plugin can read. The `bg` slot is automatically wired into the backdrop; other slots are plugin-facing.
|
||||
|
||||
```yaml
|
||||
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
|
||||
```
|
||||
|
||||
Values accept:
|
||||
|
||||
- Bare URLs — wrapped in `url(...)` automatically.
|
||||
- Pre-wrapped `url(...)`, `linear-gradient(...)`, `radial-gradient(...)` expressions — used as-is.
|
||||
- `"none"` — explicit opt-out.
|
||||
|
||||
Every asset is also emitted as `--theme-asset-<name>-raw` (the unwrapped URL), in case a plugin needs to pass it to `<img src>` instead of `background-image`.
|
||||
|
||||
Plugins read these with plain CSS or JS:
|
||||
|
||||
```javascript
|
||||
// In a plugin slot
|
||||
const hero = getComputedStyle(document.documentElement)
|
||||
.getPropertyValue("--theme-asset-hero").trim();
|
||||
```
|
||||
|
||||
### Component chrome overrides
|
||||
|
||||
`componentStyles` restyles individual shell components without writing CSS selectors. Each bucket's entries become CSS vars (`--component-<bucket>-<kebab-property>`) that the shell's shared components read. So `card:` overrides apply to every `<Card>`, `header:` to the app bar, etc.
|
||||
|
||||
```yaml
|
||||
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: {}
|
||||
```
|
||||
|
||||
Supported buckets: `card`, `header`, `footer`, `sidebar`, `tab`, `progress`, `badge`, `backdrop`, `page`.
|
||||
|
||||
Property names use camelCase (`clipPath`) and are emitted as kebab (`clip-path`). Values are plain CSS strings — anything CSS accepts (`clip-path`, `border-image`, `background`, `box-shadow`, `animation`, ...).
|
||||
|
||||
### Color overrides
|
||||
|
||||
Most themes won't need this — the 3-layer palette derives every shadcn token. Use `colorOverrides` when you want a specific accent the derivation won't produce (a softer destructive red for a pastel theme, a specific success green for a brand).
|
||||
|
||||
```yaml
|
||||
colorOverrides:
|
||||
primary: "#ffce3a"
|
||||
primaryForeground: "#05091a"
|
||||
accent: "#3fd3ff"
|
||||
ring: "#3fd3ff"
|
||||
destructive: "#ff3a5e"
|
||||
border: "rgba(64, 200, 255, 0.28)"
|
||||
```
|
||||
|
||||
Supported keys: `card`, `cardForeground`, `popover`, `popoverForeground`, `primary`, `primaryForeground`, `secondary`, `secondaryForeground`, `muted`, `mutedForeground`, `accent`, `accentForeground`, `destructive`, `destructiveForeground`, `success`, `warning`, `border`, `input`, `ring`.
|
||||
|
||||
Each key maps 1:1 to the `--color-<kebab>` CSS var (e.g. `primaryForeground` → `--color-primary-foreground`). Any key set here wins over the palette cascade for the active theme only — switching to another theme clears the overrides.
|
||||
|
||||
### Raw `customCSS`
|
||||
|
||||
For selector-level chrome that `componentStyles` can't express — pseudo-elements, animations, media queries, theme-scoped overrides — drop raw CSS into `customCSS`:
|
||||
|
||||
```yaml
|
||||
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;
|
||||
}
|
||||
```
|
||||
|
||||
The CSS is injected as a single scoped `<style data-hermes-theme-css>` tag on theme apply and cleaned up on theme switch. **Capped at 32 KiB per theme.**
|
||||
|
||||
### Built-in themes
|
||||
|
||||
Each built-in ships its own palette, typography, and layout — switching produces visible changes beyond color alone.
|
||||
|
||||
| Theme | Palette | Typography | Layout |
|
||||
|-------|---------|------------|--------|
|
||||
| **Hermes Teal** (`default`) | Dark teal + cream | System stack, 15px | 0.5rem radius, comfortable |
|
||||
| **Midnight** (`midnight`) | Deep blue-violet | Inter + JetBrains Mono, 14px | 0.75rem radius, comfortable |
|
||||
| **Ember** (`ember`) | Warm crimson + bronze | Spectral (serif) + IBM Plex Mono, 15px | 0.25rem radius, comfortable |
|
||||
| **Mono** (`mono`) | Grayscale | IBM Plex Sans + IBM Plex Mono, 13px | 0 radius, compact |
|
||||
| **Cyberpunk** (`cyberpunk`) | Neon green on black | Share Tech Mono everywhere, 14px | 0 radius, compact |
|
||||
| **Rosé** (`rose`) | Pink + ivory | Fraunces (serif) + DM Mono, 16px | 1rem radius, spacious |
|
||||
|
||||
Themes that reference Google Fonts (all except Hermes Teal) load the stylesheet on demand — the first time you switch to them a `<link>` tag is injected into `<head>`.
|
||||
|
||||
### Full theme YAML reference
|
||||
|
||||
Every knob in one file — copy and trim what you don't need:
|
||||
|
||||
```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 */
|
||||
```
|
||||
|
||||
Refresh the dashboard after creating the file. Switch themes live from the header bar — click the palette icon. Selection persists to `config.yaml` under `dashboard.theme` and is restored on reload.
|
||||
|
||||
---
|
||||
|
||||
## Plugins
|
||||
|
||||
A dashboard plugin is a directory with a `manifest.json`, a pre-built JS bundle, and optionally a CSS file and a Python file with FastAPI routes. Plugins live next to other Hermes plugins in `~/.hermes/plugins/<name>/` — the dashboard extension is a `dashboard/` subfolder inside that plugin directory, so one plugin can extend both the CLI/gateway and the dashboard from a single install.
|
||||
|
||||
Plugins don't bundle React or UI components. They use the **Plugin SDK** exposed on `window.__HERMES_PLUGIN_SDK__`. This keeps plugin bundles tiny (typically a few KB) and avoids version conflicts.
|
||||
|
||||
### Quick start — your first plugin
|
||||
|
||||
Create the directory structure:
|
||||
|
||||
```bash
|
||||
mkdir -p ~/.hermes/plugins/my-plugin/dashboard/dist
|
||||
```
|
||||
|
||||
Write the manifest:
|
||||
|
||||
```json
|
||||
// ~/.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"
|
||||
}
|
||||
```
|
||||
|
||||
Write the JS bundle (a plain IIFE — no build step needed):
|
||||
|
||||
```javascript
|
||||
// ~/.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);
|
||||
})();
|
||||
```
|
||||
|
||||
Refresh the dashboard — your tab appears in the nav bar, after **Skills**.
|
||||
|
||||
:::tip Skip React.createElement
|
||||
If you prefer JSX, use any bundler (esbuild, Vite, rollup) with React as an external and IIFE output. The only hard requirement is that the final file is a single JS file loadable via `<script>`. React is never bundled; it comes from `SDK.React`.
|
||||
:::
|
||||
|
||||
### Directory layout
|
||||
|
||||
```
|
||||
~/.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)
|
||||
```
|
||||
|
||||
A single plugin directory can carry three orthogonal extensions:
|
||||
|
||||
- `plugin.yaml` + `__init__.py` — CLI/gateway plugin ([see plugins page](./plugins)).
|
||||
- `dashboard/manifest.json` + `dashboard/dist/index.js` — dashboard UI plugin.
|
||||
- `dashboard/plugin_api.py` — dashboard backend routes.
|
||||
|
||||
None of them are required; include only the layers you need.
|
||||
|
||||
### Manifest reference
|
||||
|
||||
```json
|
||||
{
|
||||
"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"
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Required | Description |
|
||||
|-------|----------|-------------|
|
||||
| `name` | Yes | Unique plugin identifier. Lowercase, hyphens ok. Used in URLs and registration. |
|
||||
| `label` | Yes | Display name shown in the nav tab. |
|
||||
| `description` | No | Short description (shown in dashboard admin surfaces). |
|
||||
| `icon` | No | Lucide icon name. Defaults to `Puzzle`. Unknown names fall back to `Puzzle`. |
|
||||
| `version` | No | Semver string. Defaults to `0.0.0`. |
|
||||
| `tab.path` | Yes | URL path for the tab (e.g. `/my-plugin`). |
|
||||
| `tab.position` | No | Where to insert the tab. `"end"` (default), `"after:<path>"`, or `"before:<path>"` — value after the colon is the **path segment** of the target tab (no leading slash). Examples: `"after:skills"`, `"before:config"`. |
|
||||
| `tab.override` | No | Set to a built-in route path (`"/"`, `"/sessions"`, `"/config"`, ...) to **replace** that page instead of adding a new tab. See [Replacing built-in pages](#replacing-built-in-pages-taboverride). |
|
||||
| `tab.hidden` | No | When true, register the component and any slots without adding a tab to the nav. Used by slot-only plugins. See [Slot-only plugins](#slot-only-plugins-tabhidden). |
|
||||
| `slots` | No | Named shell slots this plugin populates. **Documentation aid only** — actual registration happens from the JS bundle via `registerSlot()`. Listing slots here makes discovery surfaces more informative. |
|
||||
| `entry` | Yes | Path to the JS bundle relative to `dashboard/`. Defaults to `dist/index.js`. |
|
||||
| `css` | No | Path to a CSS file to inject as a `<link>` tag. |
|
||||
| `api` | No | Path to a Python file with FastAPI routes. Mounted at `/api/plugins/<name>/`. |
|
||||
|
||||
#### Available icons
|
||||
|
||||
Plugins use Lucide icon names. The dashboard maps these by name — unknown names silently fall back to `Puzzle`.
|
||||
|
||||
Currently mapped: `Activity`, `BarChart3`, `Clock`, `Code`, `Database`, `Eye`, `FileText`, `Globe`, `Heart`, `KeyRound`, `MessageSquare`, `Package`, `Puzzle`, `Settings`, `Shield`, `Sparkles`, `Star`, `Terminal`, `Wrench`, `Zap`.
|
||||
|
||||
Need a different icon? Open a PR to `web/src/App.tsx`'s `ICON_MAP` — pure additive change.
|
||||
|
||||
### The Plugin SDK
|
||||
|
||||
Everything a plugin needs is on `window.__HERMES_PLUGIN_SDK__`. Plugins should never import React directly.
|
||||
|
||||
```javascript
|
||||
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
|
||||
```
|
||||
|
||||
#### Calling your plugin's backend
|
||||
|
||||
```javascript
|
||||
SDK.fetchJSON("/api/plugins/my-plugin/data")
|
||||
.then((data) => console.log(data))
|
||||
.catch((err) => console.error("API call failed:", err));
|
||||
```
|
||||
|
||||
`fetchJSON` injects the session auth token, surfaces errors as thrown exceptions, and parses JSON automatically.
|
||||
|
||||
#### Calling built-in Hermes endpoints
|
||||
|
||||
```javascript
|
||||
// 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));
|
||||
```
|
||||
|
||||
See [Web Dashboard → REST API](./web-dashboard#rest-api) for the full list.
|
||||
|
||||
### Shell slots
|
||||
|
||||
Slots let a plugin inject components into named locations of the app shell — the cockpit sidebar, the header, the footer, an overlay layer — without claiming a whole tab. Multiple plugins can populate the same slot; they render stacked in registration order.
|
||||
|
||||
Register from inside the plugin bundle:
|
||||
|
||||
```javascript
|
||||
window.__HERMES_PLUGINS__.registerSlot("my-plugin", "sidebar", MySidebar);
|
||||
window.__HERMES_PLUGINS__.registerSlot("my-plugin", "header-left", MyCrest);
|
||||
```
|
||||
|
||||
#### Slot catalogue
|
||||
|
||||
| Slot | Location |
|
||||
|------|----------|
|
||||
| `backdrop` | Inside the `<Backdrop />` layer stack, above the noise layer. |
|
||||
| `header-left` | Before the Hermes brand in the top bar. |
|
||||
| `header-right` | Before the theme/language switchers in the top bar. |
|
||||
| `header-banner` | Full-width strip below the nav. |
|
||||
| `sidebar` | Cockpit sidebar rail — **only rendered when `layoutVariant === "cockpit"`**. |
|
||||
| `pre-main` | Above the route outlet (inside `<main>`). |
|
||||
| `post-main` | Below the route outlet (inside `<main>`). |
|
||||
| `footer-left` | Footer cell content (replaces default). |
|
||||
| `footer-right` | Footer cell content (replaces default). |
|
||||
| `overlay` | Fixed-position layer above everything else. Useful for chrome (scanlines, vignettes) `customCSS` can't achieve alone. |
|
||||
|
||||
The shell only renders `<PluginSlot name="..." />` for the slots above. Additional names are accepted by the registry for nested plugin UIs — a plugin can expose its own slots via `SDK.components.PluginSlot`.
|
||||
|
||||
#### Re-registration and HMR
|
||||
|
||||
If the same `(plugin, slot)` pair is registered twice, the later call replaces the earlier one — this matches how React HMR expects plugin re-mounts to behave.
|
||||
|
||||
### Replacing built-in pages (`tab.override`)
|
||||
|
||||
Setting `tab.override` to a built-in route path makes the plugin's component replace that page instead of adding a new tab. Useful when a theme wants a custom home page (`/`) but wants to keep the rest of the dashboard intact.
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "my-home",
|
||||
"label": "Home",
|
||||
"tab": {
|
||||
"path": "/my-home",
|
||||
"override": "/",
|
||||
"position": "end"
|
||||
},
|
||||
"entry": "dist/index.js"
|
||||
}
|
||||
```
|
||||
|
||||
With `override` set:
|
||||
|
||||
- The original page component at `/` is removed from the router.
|
||||
- Your plugin renders at `/` instead.
|
||||
- No nav tab is added for `tab.path` (the override is the point).
|
||||
|
||||
Only one plugin can override a given path. If two plugins claim the same override, the first wins and the second is ignored with a dev-mode warning.
|
||||
|
||||
### Slot-only plugins (`tab.hidden`)
|
||||
|
||||
When `tab.hidden: true`, the plugin registers its component (for direct URL visits) and any slots, but never adds a tab to the navigation. Used by plugins that only exist to inject into slots — a header crest, a sidebar HUD, an overlay.
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "header-crest",
|
||||
"label": "Header Crest",
|
||||
"tab": {
|
||||
"path": "/header-crest",
|
||||
"position": "end",
|
||||
"hidden": true
|
||||
},
|
||||
"slots": ["header-left"],
|
||||
"entry": "dist/index.js"
|
||||
}
|
||||
```
|
||||
|
||||
The bundle still calls `register()` with a placeholder component (good practice in case someone hits the URL directly) and then `registerSlot()` to do the real work.
|
||||
|
||||
### Backend API routes
|
||||
|
||||
Plugins can register FastAPI routes by setting `api` in the manifest. Create the file and export a `router`:
|
||||
|
||||
```python
|
||||
# ~/.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}
|
||||
```
|
||||
|
||||
Routes are mounted under `/api/plugins/<name>/`, so the above becomes:
|
||||
|
||||
- `GET /api/plugins/my-plugin/data`
|
||||
- `POST /api/plugins/my-plugin/action`
|
||||
|
||||
Plugin API routes bypass session-token authentication since the dashboard server binds to localhost by default. **Don't expose the dashboard on a public interface with `--host 0.0.0.0` if you run untrusted plugins** — their routes become reachable too.
|
||||
|
||||
#### Accessing Hermes internals
|
||||
|
||||
Backend routes run inside the dashboard process, so they can import from the hermes-agent codebase directly:
|
||||
|
||||
```python
|
||||
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", {})}
|
||||
```
|
||||
|
||||
### Custom CSS per plugin
|
||||
|
||||
If your plugin needs styles beyond Tailwind classes and inline `style=`, add a CSS file and reference it in the manifest:
|
||||
|
||||
```json
|
||||
{
|
||||
"css": "dist/style.css"
|
||||
}
|
||||
```
|
||||
|
||||
The file is injected as a `<link>` tag on plugin load. Use specific class names to avoid conflicts with the dashboard's styles, and reference the dashboard's CSS vars to stay theme-aware:
|
||||
|
||||
```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);
|
||||
}
|
||||
```
|
||||
|
||||
The dashboard exposes every shadcn token as `--color-*` plus theme extras (`--theme-asset-*`, `--component-<bucket>-*`, `--radius`, `--spacing-mul`). Reference those and your plugin automatically reskins with the active theme.
|
||||
|
||||
### Plugin discovery & reload
|
||||
|
||||
The dashboard scans three directories for `dashboard/manifest.json`:
|
||||
|
||||
| Priority | Directory | Source label |
|
||||
|----------|-----------|--------------|
|
||||
| 1 (wins on conflict) | `~/.hermes/plugins/<name>/dashboard/` | `user` |
|
||||
| 2 | `<repo>/plugins/memory/<name>/dashboard/` | `bundled` |
|
||||
| 2 | `<repo>/plugins/<name>/dashboard/` | `bundled` |
|
||||
| 3 | `./.hermes/plugins/<name>/dashboard/` | `project` — only when `HERMES_ENABLE_PROJECT_PLUGINS` is set |
|
||||
|
||||
Discovery results are cached per dashboard process. After adding a new plugin, either:
|
||||
|
||||
```bash
|
||||
# Force a rescan without restart
|
||||
curl http://127.0.0.1:9119/api/dashboard/plugins/rescan
|
||||
```
|
||||
|
||||
…or restart `hermes dashboard`.
|
||||
|
||||
#### Plugin load lifecycle
|
||||
|
||||
1. Dashboard loads. `main.tsx` exposes the SDK on `window.__HERMES_PLUGIN_SDK__` and the registry on `window.__HERMES_PLUGINS__`.
|
||||
2. `App.tsx` calls `usePlugins()` → fetches `GET /api/dashboard/plugins`.
|
||||
3. For each manifest: CSS `<link>` is injected (if declared), then a `<script>` tag loads the JS bundle.
|
||||
4. The plugin's IIFE runs and calls `window.__HERMES_PLUGINS__.register(name, Component)` — and optionally `.registerSlot(name, slot, Component)` for each slot.
|
||||
5. The dashboard resolves the registered component against the manifest, adds the tab to navigation (unless `hidden`), and mounts the component as a route.
|
||||
|
||||
Plugins have up to **2 seconds** after their script loads to call `register()`. After that the dashboard stops waiting and finishes initial render. If a plugin later registers, it still appears — the nav is reactive.
|
||||
|
||||
If a plugin's script fails to load (404, syntax error, exception during IIFE), the dashboard logs a warning to the browser console and continues without it.
|
||||
|
||||
---
|
||||
|
||||
## Combined theme + plugin demo
|
||||
|
||||
The repo ships `plugins/strike-freedom-cockpit/` as a complete reskin demo. It pairs a theme YAML with a slot-only plugin to produce a cockpit-style HUD without forking the dashboard.
|
||||
|
||||
**What it demonstrates:**
|
||||
|
||||
- A full theme using palette, typography, `fontUrl`, `layoutVariant: cockpit`, `assets`, `componentStyles` (notched card corners, gradient backgrounds), `colorOverrides`, and `customCSS` (scanline overlay).
|
||||
- A slot-only plugin (`tab.hidden: true`) that registers into three slots:
|
||||
- `sidebar` — an MS-STATUS panel with live telemetry bars driven by `SDK.api.getStatus()`.
|
||||
- `header-left` — a faction crest that reads `--theme-asset-crest` from the active theme.
|
||||
- `footer-right` — a custom tagline replacing the default org line.
|
||||
- The plugin reads theme-supplied artwork via CSS vars, so swapping themes changes the hero/crest without plugin code changes.
|
||||
|
||||
**Install:**
|
||||
|
||||
```bash
|
||||
# Theme
|
||||
cp plugins/strike-freedom-cockpit/theme/strike-freedom.yaml \
|
||||
~/.hermes/dashboard-themes/
|
||||
|
||||
# Plugin
|
||||
cp -r plugins/strike-freedom-cockpit ~/.hermes/plugins/
|
||||
```
|
||||
|
||||
Open the dashboard, pick **Strike Freedom** from the theme switcher. The cockpit sidebar appears, the crest shows in the header, the tagline replaces the footer. Switch back to **Hermes Teal** and the plugin remains installed but invisible (the `sidebar` slot only renders under the `cockpit` layout variant).
|
||||
|
||||
Read the plugin source (`plugins/strike-freedom-cockpit/dashboard/dist/index.js`) to see how it reads CSS vars, guards against older dashboards without slot support, and registers three slots from one bundle.
|
||||
|
||||
---
|
||||
|
||||
## API reference
|
||||
|
||||
### Theme endpoints
|
||||
|
||||
| Endpoint | Method | Description |
|
||||
|----------|--------|-------------|
|
||||
| `/api/dashboard/themes` | GET | List available themes + active name. Built-ins return `{name, label, description}`; user themes also include a `definition` field with the full normalised theme object. |
|
||||
| `/api/dashboard/theme` | PUT | Set active theme. Body: `{"name": "midnight"}`. Persists to `config.yaml` under `dashboard.theme`. |
|
||||
|
||||
### Plugin endpoints
|
||||
|
||||
| Endpoint | Method | Description |
|
||||
|----------|--------|-------------|
|
||||
| `/api/dashboard/plugins` | GET | List discovered plugins (with manifests, minus internal fields). |
|
||||
| `/api/dashboard/plugins/rescan` | GET | Force re-scan the plugin directories without restarting. |
|
||||
| `/dashboard-plugins/<name>/<path>` | GET | Serve static assets from a plugin's `dashboard/` directory. Path traversal is blocked. |
|
||||
| `/api/plugins/<name>/*` | * | Plugin-registered backend routes. |
|
||||
|
||||
### SDK on `window`
|
||||
|
||||
| Global | Type | Provider |
|
||||
|--------|------|----------|
|
||||
| `window.__HERMES_PLUGIN_SDK__` | object | `registry.ts` — React, hooks, UI components, API client, utils. |
|
||||
| `window.__HERMES_PLUGINS__.register(name, Component)` | function | Register a plugin's main component. |
|
||||
| `window.__HERMES_PLUGINS__.registerSlot(name, slot, Component)` | function | Register into a named shell slot. |
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**My theme doesn't appear in the picker.**
|
||||
Check that the file is in `~/.hermes/dashboard-themes/` and ends in `.yaml` or `.yml`. Refresh the page. Run `curl http://127.0.0.1:9119/api/dashboard/themes` — your theme should be in the response. If the YAML has a parse error, the dashboard logs to `errors.log` under `~/.hermes/logs/`.
|
||||
|
||||
**My plugin's tab doesn't show up.**
|
||||
1. Check the manifest is at `~/.hermes/plugins/<name>/dashboard/manifest.json` (note the `dashboard/` subdirectory).
|
||||
2. `curl http://127.0.0.1:9119/api/dashboard/plugins/rescan` to force re-discovery.
|
||||
3. Open browser dev tools → Network — confirm `manifest.json`, `index.js`, and any CSS loaded without 404s.
|
||||
4. Open browser dev tools → Console — look for errors during the IIFE or `window.__HERMES_PLUGINS__ is undefined` (indicates the SDK didn't initialize, usually a React render crash earlier).
|
||||
5. Verify your bundle calls `window.__HERMES_PLUGINS__.register(...)` with the **same name** as `manifest.json:name`.
|
||||
|
||||
**Slot-registered components don't render.**
|
||||
The `sidebar` slot only renders when the active theme has `layoutVariant: cockpit`. Other slots always render. If you're registering into a slot with no hits, add `console.log` inside `registerSlot` to confirm the plugin bundle ran at all.
|
||||
|
||||
**Plugin backend routes return 404.**
|
||||
1. Confirm the manifest has `"api": "plugin_api.py"` pointing to an existing file inside `dashboard/`.
|
||||
2. Restart `hermes dashboard` — plugin API routes are mounted once at startup, **not** on rescan.
|
||||
3. Check that `plugin_api.py` exports a module-level `router = APIRouter()`. Other export names are not picked up.
|
||||
4. Tail `~/.hermes/logs/errors.log` for `Failed to load plugin <name> API routes` — import errors are logged there.
|
||||
|
||||
**Theme change drops my color overrides.**
|
||||
`colorOverrides` are scoped to the active theme and cleared on theme switch — that's by design. If you want overrides that persist, put them in your theme's YAML, not in the live switcher.
|
||||
|
||||
**Theme customCSS gets truncated.**
|
||||
The `customCSS` block is capped at 32 KiB per theme. Split large stylesheets across multiple themes, or switch to a plugin that injects a full stylesheet via its `css` field (no size cap).
|
||||
|
||||
**I want to ship a plugin on PyPI.**
|
||||
Dashboard plugins are installed by directory layout, not by pip entry point. The cleanest distribution path today is a git repo the user clones into `~/.hermes/plugins/`. A pip-based installer for dashboard plugins is not currently wired up.
|
||||
@@ -321,274 +321,27 @@ The frontend is built with React 19, TypeScript, Tailwind CSS v4, and shadcn/ui-
|
||||
|
||||
When you run `hermes update`, the web frontend is automatically rebuilt if `npm` is available. This keeps the dashboard in sync with code updates. If `npm` isn't installed, the update skips the frontend build and `hermes dashboard` will build it on first launch.
|
||||
|
||||
## Themes
|
||||
## Themes & plugins
|
||||
|
||||
Themes control the dashboard's visual presentation across three layers:
|
||||
The dashboard ships with six built-in themes and can be extended with user-defined themes, plugin tabs, and backend API routes — all drop-in, no repo clone needed.
|
||||
|
||||
- **Palette** — colors (background, text, accents, warm glow, noise)
|
||||
- **Typography** — font families, base size, line height, letter spacing
|
||||
- **Layout** — corner radius and density (spacing multiplier)
|
||||
**Switch themes live** from the header bar — click the palette icon next to the language switcher. Selection persists to `config.yaml` under `dashboard.theme` and is restored on page load.
|
||||
|
||||
Switch themes live from the header bar — click the palette icon next to the language switcher. Selection persists to `config.yaml` under `dashboard.theme` and is restored on page load.
|
||||
Built-in themes:
|
||||
|
||||
### Built-in themes
|
||||
| Theme | Character |
|
||||
|-------|-----------|
|
||||
| **Hermes Teal** (`default`) | Dark teal + cream, system fonts, comfortable spacing |
|
||||
| **Midnight** (`midnight`) | Deep blue-violet, Inter + JetBrains Mono |
|
||||
| **Ember** (`ember`) | Warm crimson + bronze, Spectral serif + IBM Plex Mono |
|
||||
| **Mono** (`mono`) | Grayscale, IBM Plex, compact |
|
||||
| **Cyberpunk** (`cyberpunk`) | Neon green on black, Share Tech Mono |
|
||||
| **Rosé** (`rose`) | Pink + ivory, Fraunces serif, spacious |
|
||||
|
||||
Each built-in ships its own palette, typography, and layout — switching produces visible changes beyond color alone.
|
||||
To build your own theme, add a plugin tab, inject into shell slots, or expose plugin-specific REST endpoints, see **[Extending the Dashboard](./extending-the-dashboard)** — the complete guide covers:
|
||||
|
||||
| Theme | Palette | Typography | Layout |
|
||||
|-------|---------|------------|--------|
|
||||
| **Hermes Teal** (`default`) | Dark teal + cream | System stack, 15px | 0.5rem radius, comfortable |
|
||||
| **Midnight** (`midnight`) | Deep blue-violet | Inter + JetBrains Mono, 14px | 0.75rem radius, comfortable |
|
||||
| **Ember** (`ember`) | Warm crimson / bronze | Spectral (serif) + IBM Plex Mono, 15px | 0.25rem radius, comfortable |
|
||||
| **Mono** (`mono`) | Grayscale | IBM Plex Sans + IBM Plex Mono, 13px | 0 radius, compact |
|
||||
| **Cyberpunk** (`cyberpunk`) | Neon green on black | Share Tech Mono everywhere, 14px | 0 radius, compact |
|
||||
| **Rosé** (`rose`) | Pink and ivory | Fraunces (serif) + DM Mono, 16px | 1rem radius, spacious |
|
||||
|
||||
Themes that reference Google Fonts (everything except Hermes Teal) load the stylesheet on demand — the first time you switch to them, a `<link>` tag is injected into `<head>`.
|
||||
|
||||
### Custom themes
|
||||
|
||||
Drop a YAML file in `~/.hermes/dashboard-themes/` and it appears in the picker automatically. The file can be as minimal as a name plus the fields you want to override — every missing field inherits a sane default.
|
||||
|
||||
Minimal example (colors only, bare hex shorthand):
|
||||
|
||||
```yaml
|
||||
# ~/.hermes/dashboard-themes/neon.yaml
|
||||
name: neon
|
||||
label: Neon
|
||||
description: Pure magenta on black
|
||||
colors:
|
||||
background: "#000000"
|
||||
midground: "#ff00ff"
|
||||
```
|
||||
|
||||
Full example (every knob):
|
||||
|
||||
```yaml
|
||||
# ~/.hermes/dashboard-themes/ocean.yaml
|
||||
name: ocean
|
||||
label: Ocean Deep
|
||||
description: Deep sea blues with coral accents
|
||||
|
||||
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, falls back to fontSans
|
||||
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" # 0 | 0.25rem | 0.5rem | 0.75rem | 1rem | any length
|
||||
density: comfortable # compact | comfortable | spacious
|
||||
|
||||
# Optional — pin individual shadcn tokens that would otherwise derive from
|
||||
# the palette. Any key listed here wins over the palette cascade.
|
||||
colorOverrides:
|
||||
destructive: "#ff6b6b"
|
||||
ring: "#ff6b6b"
|
||||
```
|
||||
|
||||
Refresh the dashboard after creating the file.
|
||||
|
||||
### Palette model
|
||||
|
||||
The palette is a 3-layer triplet — **background**, **midground**, **foreground** — plus a warm-glow rgba() string and a noise-opacity multiplier. Every shadcn token (card, muted, border, primary, popover, etc.) is derived from this triplet via CSS `color-mix()` in the dashboard's stylesheet, so overriding three colors cascades into the whole UI.
|
||||
|
||||
- `background` — deepest canvas color (typically near-black). The page background and card fill come from this.
|
||||
- `midground` — primary text and accent. Most UI chrome reads this.
|
||||
- `foreground` — top-layer highlight. In the default theme this is white at alpha 0 (invisible); themes that want a bright accent on top can raise its alpha.
|
||||
- `warmGlow` — rgba() vignette color used by the ambient backdrop.
|
||||
- `noiseOpacity` — 0–1.2 multiplier on the grain overlay. Lower = softer, higher = grittier.
|
||||
|
||||
Each layer accepts `{hex, alpha}` or a bare hex string (alpha defaults to 1.0).
|
||||
|
||||
### Typography model
|
||||
|
||||
| Key | Type | Description |
|
||||
|-----|------|-------------|
|
||||
| `fontSans` | string | CSS font-family stack for body copy (applied to `html`, `body`) |
|
||||
| `fontMono` | string | CSS font-family stack for code blocks, `<code>`, `.font-mono` utilities, dense readouts |
|
||||
| `fontDisplay` | string | Optional heading/display font stack. Falls back to `fontSans` |
|
||||
| `fontUrl` | string | Optional external stylesheet URL. Injected as `<link rel="stylesheet">` in `<head>` on theme switch. Same URL is never injected twice. Works with Google Fonts, Bunny Fonts, self-hosted `@font-face` sheets, anything you can link |
|
||||
| `baseSize` | string | Root font size — controls the rem scale for the whole dashboard. Example: `"14px"`, `"16px"` |
|
||||
| `lineHeight` | string | Default line-height, e.g. `"1.5"`, `"1.65"` |
|
||||
| `letterSpacing` | string | Default letter-spacing, e.g. `"0"`, `"0.01em"`, `"-0.01em"` |
|
||||
|
||||
### Layout model
|
||||
|
||||
| Key | Values | Description |
|
||||
|-----|--------|-------------|
|
||||
| `radius` | any CSS length | Corner-radius token. Cascades into `--radius-sm/md/lg/xl` so every rounded element shifts together. |
|
||||
| `density` | `compact` \| `comfortable` \| `spacious` | Spacing multiplier. Compact = 0.85×, comfortable = 1.0× (default), spacious = 1.2×. Scales Tailwind's base spacing, so padding, gap, and space-between utilities all shift proportionally. |
|
||||
|
||||
### Color overrides (optional)
|
||||
|
||||
Most themes won't need this — the 3-layer palette derives every shadcn token. But if you want a specific accent that the derivation won't produce (a softer destructive red for a pastel theme, a specific success green for a brand), pin individual tokens here.
|
||||
|
||||
Supported keys: `card`, `cardForeground`, `popover`, `popoverForeground`, `primary`, `primaryForeground`, `secondary`, `secondaryForeground`, `muted`, `mutedForeground`, `accent`, `accentForeground`, `destructive`, `destructiveForeground`, `success`, `warning`, `border`, `input`, `ring`.
|
||||
|
||||
Any key set here overrides the derived value for the active theme only — switching to another theme clears the overrides.
|
||||
|
||||
### Layout variants
|
||||
|
||||
`layoutVariant` selects the overall shell layout. Defaults to `standard`.
|
||||
|
||||
| Variant | Behaviour |
|
||||
|---------|-----------|
|
||||
| `standard` | Single column, 1600px max-width (default) |
|
||||
| `cockpit` | Left sidebar rail (260px) + main content. Populated by plugins via the `sidebar` slot |
|
||||
| `tiled` | Drops the max-width clamp so pages can use the full viewport |
|
||||
|
||||
```yaml
|
||||
layoutVariant: cockpit
|
||||
```
|
||||
|
||||
The current variant is exposed as `document.documentElement.dataset.layoutVariant` so custom CSS can target it via `:root[data-layout-variant="cockpit"]`.
|
||||
|
||||
### Theme assets
|
||||
|
||||
Ship artwork URLs with a theme. Each named slot becomes a CSS var (`--theme-asset-<name>`) that plugins and the built-in shell read; the `bg` slot is automatically wired into the backdrop.
|
||||
|
||||
```yaml
|
||||
assets:
|
||||
bg: "https://example.com/hero-bg.jpg" # full-viewport background
|
||||
hero: "/my-images/strike-freedom.png" # for plugin sidebars
|
||||
crest: "/my-images/crest.svg" # for header slot 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
|
||||
```
|
||||
|
||||
Values accept bare URLs (wrapped in `url(...)` automatically), pre-wrapped `url(...)`/`linear-gradient(...)`/`radial-gradient(...)` expressions, and `none`.
|
||||
|
||||
### Component chrome overrides
|
||||
|
||||
Themes can restyle individual shell components without writing CSS selectors via the `componentStyles` block. Each bucket's entries become CSS vars (`--component-<bucket>-<kebab-property>`) that the shell's shared components read — so `card:` overrides apply to every `<Card>`, `header:` to the app bar, etc.
|
||||
|
||||
```yaml
|
||||
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: {...}
|
||||
```
|
||||
|
||||
Supported buckets: `card`, `header`, `footer`, `sidebar`, `tab`, `progress`, `badge`, `backdrop`, `page`. Property names use camelCase (`clipPath`) and are emitted as kebab (`clip-path`). Values are plain CSS strings — anything CSS accepts (`clip-path`, `border-image`, `background`, `box-shadow`, animations, etc.).
|
||||
|
||||
### Custom CSS
|
||||
|
||||
For selector-level chrome that doesn't fit `componentStyles` — pseudo-elements, animations, media queries, theme-scoped overrides — drop raw CSS into the `customCSS` field:
|
||||
|
||||
```yaml
|
||||
customCSS: |
|
||||
: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;
|
||||
}
|
||||
```
|
||||
|
||||
The CSS is injected as a single scoped `<style data-hermes-theme-css>` tag on theme apply and cleaned up on theme switch. Capped at 32 KiB per theme.
|
||||
|
||||
## Dashboard plugins
|
||||
|
||||
Plugins live in `~/.hermes/plugins/<name>/dashboard/` (user) or repo `plugins/<name>/dashboard/` (bundled). Each ships a `manifest.json` plus a plain JS bundle that uses the plugin SDK exposed on `window.__HERMES_PLUGIN_SDK__`.
|
||||
|
||||
### Manifest
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "my-plugin",
|
||||
"label": "My Plugin",
|
||||
"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/index.css",
|
||||
"api": "api.py"
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Description |
|
||||
|-------|-------------|
|
||||
| `tab.path` | Route path the plugin component renders at |
|
||||
| `tab.position` | `end`, `after:<tab>`, or `before:<tab>` |
|
||||
| `tab.override` | When set to a built-in path (`/`, `/sessions`, etc.), this plugin replaces that page instead of adding a new tab |
|
||||
| `tab.hidden` | When true, register component + slots but skip the nav entry. Used by slot-only plugins |
|
||||
| `slots` | Shell slots this plugin populates (documentation aid; actual registration happens from the JS bundle) |
|
||||
|
||||
### Shell slots
|
||||
|
||||
Plugins inject components into named shell locations by calling `window.__HERMES_PLUGINS__.registerSlot(pluginName, slotName, Component)`. Multiple plugins can populate the same slot — they render stacked in registration order.
|
||||
|
||||
| Slot | Location |
|
||||
|------|----------|
|
||||
| `backdrop` | Inside the backdrop layer stack |
|
||||
| `header-left` | Before the Hermes brand in the top bar |
|
||||
| `header-right` | Before the theme/language switchers |
|
||||
| `header-banner` | Full-width strip below the nav |
|
||||
| `sidebar` | Cockpit sidebar rail (only rendered when `layoutVariant === "cockpit"`) |
|
||||
| `pre-main` | Above the route outlet |
|
||||
| `post-main` | Below the route outlet |
|
||||
| `footer-left` / `footer-right` | Footer cell content (replaces default) |
|
||||
| `overlay` | Fixed-position layer above everything else |
|
||||
|
||||
### Plugin SDK
|
||||
|
||||
Exposed on `window.__HERMES_PLUGIN_SDK__`:
|
||||
|
||||
- `React` + `hooks` (useState, useEffect, useCallback, useMemo, useRef, useContext, createContext)
|
||||
- `components` — Card, Badge, Button, Input, Label, Select, Separator, Tabs, **PluginSlot**
|
||||
- `api` — Hermes API client, plus raw `fetchJSON`
|
||||
- `utils` — `cn()`, `timeAgo()`, `isoTimeAgo()`
|
||||
- `useI18n` — i18n hook for multi-language plugins
|
||||
|
||||
### Demo: Strike Freedom Cockpit
|
||||
|
||||
`plugins/strike-freedom-cockpit/` ships a complete skin demo showing every extension point — cockpit layout variant, theme-supplied hero/crest assets, notched card corners via `componentStyles`, scanlines via `customCSS`, and a slot-only plugin that populates the sidebar, header, and footer. Copy the theme YAML into `~/.hermes/dashboard-themes/` and the plugin directory into `~/.hermes/plugins/` to try it.
|
||||
|
||||
### Theme API
|
||||
|
||||
| Endpoint | Method | Description |
|
||||
|----------|--------|-------------|
|
||||
| `/api/dashboard/themes` | GET | List available themes + active name. Built-ins return `{name, label, description}`; user themes also include a `definition` field with the full normalised theme object. |
|
||||
| `/api/dashboard/theme` | PUT | Set active theme. Body: `{"name": "midnight"}` |
|
||||
- Theme YAML schema — palette, typography, layout, assets, componentStyles, colorOverrides, customCSS
|
||||
- Layout variants — `standard`, `cockpit`, `tiled`
|
||||
- Plugin manifest, SDK, shell slots, backend FastAPI routes
|
||||
- A full combined theme-plus-plugin walkthrough (Strike Freedom cockpit demo)
|
||||
- Discovery, reload, and troubleshooting
|
||||
|
||||
@@ -81,7 +81,7 @@ const sidebars: SidebarsConfig = {
|
||||
label: 'Management',
|
||||
items: [
|
||||
'user-guide/features/web-dashboard',
|
||||
'user-guide/features/dashboard-plugins',
|
||||
'user-guide/features/extending-the-dashboard',
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user