mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-08 11:47:09 +08:00
Compare commits
4 Commits
fix/setup-
...
fix/stop-i
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2579245ae2 | ||
|
|
5401a0080d | ||
|
|
e5647d7863 | ||
|
|
023b1bff11 |
@@ -318,6 +318,13 @@ class ContextCompressor(ContextEngine):
|
||||
int(context_length * self.threshold_percent),
|
||||
MINIMUM_CONTEXT_LENGTH,
|
||||
)
|
||||
# Recalculate token budgets for the new context length so the
|
||||
# compressor stays calibrated after a model switch (e.g. 200K → 32K).
|
||||
target_tokens = int(self.threshold_tokens * self.summary_target_ratio)
|
||||
self.tail_token_budget = target_tokens
|
||||
self.max_summary_tokens = min(
|
||||
int(context_length * 0.05), _SUMMARY_TOKENS_CEILING,
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1103,7 +1103,7 @@ class GatewayRunner:
|
||||
if override_runtime.get("api_key"):
|
||||
logger.debug(
|
||||
"Session model override (fast): session=%s config_model=%s -> override_model=%s provider=%s",
|
||||
(resolved_session_key or "")[:30], model, override_model,
|
||||
resolved_session_key or "", model, override_model,
|
||||
override_runtime.get("provider"),
|
||||
)
|
||||
return override_model, override_runtime
|
||||
@@ -1111,12 +1111,12 @@ class GatewayRunner:
|
||||
# resolution and apply model/provider from the override on top.
|
||||
logger.debug(
|
||||
"Session model override (no api_key, fallback): session=%s config_model=%s override_model=%s",
|
||||
(resolved_session_key or "")[:30], model, override_model,
|
||||
resolved_session_key or "", model, override_model,
|
||||
)
|
||||
else:
|
||||
logger.debug(
|
||||
"No session model override: session=%s config_model=%s override_keys=%s",
|
||||
(resolved_session_key or "")[:30], model,
|
||||
resolved_session_key or "", model,
|
||||
list(self._session_model_overrides.keys())[:5] if self._session_model_overrides else "[]",
|
||||
)
|
||||
|
||||
@@ -1687,7 +1687,7 @@ class GatewayRunner:
|
||||
continue
|
||||
try:
|
||||
agent.interrupt(reason)
|
||||
logger.debug("Interrupted running agent for session %s during shutdown", session_key[:20])
|
||||
logger.debug("Interrupted running agent for session %s during shutdown", session_key)
|
||||
except Exception as e:
|
||||
logger.debug("Failed interrupting agent during shutdown: %s", e)
|
||||
|
||||
@@ -1859,7 +1859,7 @@ class GatewayRunner:
|
||||
logger.warning(
|
||||
"Auto-suspended stuck session %s (active across %d "
|
||||
"consecutive restarts — likely a stuck loop)",
|
||||
session_key[:30], counts[session_key],
|
||||
session_key, counts[session_key],
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
@@ -2681,7 +2681,7 @@ class GatewayRunner:
|
||||
except Exception as _e:
|
||||
logger.debug(
|
||||
"mark_resume_pending failed for %s: %s",
|
||||
_sk[:20], _e,
|
||||
_sk, _e,
|
||||
)
|
||||
self._interrupt_running_agents(
|
||||
_INTERRUPT_REASON_GATEWAY_RESTART if self._restart_requested else _INTERRUPT_REASON_GATEWAY_SHUTDOWN
|
||||
@@ -3347,7 +3347,7 @@ class GatewayRunner:
|
||||
logger.warning(
|
||||
"Evicting stale _running_agents entry for %s "
|
||||
"(age: %.0fs, idle: %.0fs, timeout: %.0fs)%s",
|
||||
_quick_key[:30], _stale_age, _stale_idle,
|
||||
_quick_key, _stale_age, _stale_idle,
|
||||
_raw_stale_timeout, _stale_detail,
|
||||
)
|
||||
self._invalidate_session_run_generation(
|
||||
@@ -3383,7 +3383,7 @@ class GatewayRunner:
|
||||
interrupt_reason=_INTERRUPT_REASON_STOP,
|
||||
invalidation_reason="stop_command",
|
||||
)
|
||||
logger.info("STOP for session %s — agent interrupted, session lock released", _quick_key[:20])
|
||||
logger.info("STOP for session %s — agent interrupted, session lock released", _quick_key)
|
||||
return "⚡ Stopped. You can continue this session."
|
||||
|
||||
# /reset and /new must bypass the running-agent guard so they
|
||||
@@ -3449,7 +3449,7 @@ class GatewayRunner:
|
||||
try:
|
||||
accepted = running_agent.steer(steer_text)
|
||||
except Exception as exc:
|
||||
logger.warning("Steer failed for session %s: %s", _quick_key[:20], exc)
|
||||
logger.warning("Steer failed for session %s: %s", _quick_key, exc)
|
||||
return f"⚠️ Steer failed: {exc}"
|
||||
if accepted:
|
||||
preview = steer_text[:60] + ("..." if len(steer_text) > 60 else "")
|
||||
@@ -3532,7 +3532,7 @@ class GatewayRunner:
|
||||
)
|
||||
|
||||
if event.message_type == MessageType.PHOTO:
|
||||
logger.debug("PRIORITY photo follow-up for session %s — queueing without interrupt", _quick_key[:20])
|
||||
logger.debug("PRIORITY photo follow-up for session %s — queueing without interrupt", _quick_key)
|
||||
adapter = self.adapters.get(source.platform)
|
||||
if adapter:
|
||||
merge_pending_message_event(adapter._pending_messages, _quick_key, event)
|
||||
@@ -3552,7 +3552,7 @@ class GatewayRunner:
|
||||
logger.debug(
|
||||
"Telegram follow-up arrived %.2fs after run start for %s — queueing without interrupt",
|
||||
time.time() - _started_at,
|
||||
_quick_key[:20],
|
||||
_quick_key,
|
||||
)
|
||||
adapter = self.adapters.get(source.platform)
|
||||
if adapter:
|
||||
@@ -3570,7 +3570,7 @@ class GatewayRunner:
|
||||
if event.get_command() == "stop":
|
||||
# Force-clean the sentinel so the session is unlocked.
|
||||
self._release_running_agent_state(_quick_key)
|
||||
logger.info("HARD STOP (pending) for session %s — sentinel cleared", _quick_key[:20])
|
||||
logger.info("HARD STOP (pending) for session %s — sentinel cleared", _quick_key)
|
||||
return "⚡ Force-stopped. The agent was still starting — session unlocked."
|
||||
# Queue the message so it will be picked up after the
|
||||
# agent starts.
|
||||
@@ -3592,10 +3592,10 @@ class GatewayRunner:
|
||||
else f"⏳ Gateway is {self._status_action_gerund()} and is not accepting another turn right now."
|
||||
)
|
||||
if self._busy_input_mode == "queue":
|
||||
logger.debug("PRIORITY queue follow-up for session %s", _quick_key[:20])
|
||||
logger.debug("PRIORITY queue follow-up for session %s", _quick_key)
|
||||
self._queue_or_replace_pending_event(_quick_key, event)
|
||||
return None
|
||||
logger.debug("PRIORITY interrupt for session %s", _quick_key[:20])
|
||||
logger.debug("PRIORITY interrupt for session %s", _quick_key)
|
||||
running_agent.interrupt(event.text)
|
||||
if _quick_key in self._pending_messages:
|
||||
self._pending_messages[_quick_key] += "\n" + event.text
|
||||
@@ -4593,7 +4593,7 @@ class GatewayRunner:
|
||||
if not self._is_session_run_current(_quick_key, run_generation):
|
||||
logger.info(
|
||||
"Discarding stale agent result for %s — generation %d is no longer current",
|
||||
_quick_key[:20] if _quick_key else "?",
|
||||
_quick_key or "?",
|
||||
run_generation,
|
||||
)
|
||||
_stale_adapter = self.adapters.get(source.platform)
|
||||
@@ -4644,7 +4644,7 @@ class GatewayRunner:
|
||||
except Exception as _e:
|
||||
logger.debug(
|
||||
"clear_resume_pending failed for %s: %s",
|
||||
session_key[:20], _e,
|
||||
session_key, _e,
|
||||
)
|
||||
|
||||
# Surface error details when the agent failed silently (final_response=None)
|
||||
@@ -5291,7 +5291,7 @@ class GatewayRunner:
|
||||
interrupt_reason=_INTERRUPT_REASON_STOP,
|
||||
invalidation_reason="stop_command_pending",
|
||||
)
|
||||
logger.info("STOP (pending) for session %s — sentinel cleared", session_key[:20])
|
||||
logger.info("STOP (pending) for session %s — sentinel cleared", session_key)
|
||||
return "⚡ Stopped. The agent hadn't started yet — you can continue this session."
|
||||
if agent:
|
||||
# Force-clean the session lock so a truly hung agent doesn't
|
||||
@@ -8798,7 +8798,7 @@ class GatewayRunner:
|
||||
if reason:
|
||||
logger.info(
|
||||
"Invalidated run generation for %s → %d (%s)",
|
||||
session_key[:20],
|
||||
session_key,
|
||||
generation,
|
||||
reason,
|
||||
)
|
||||
@@ -9205,7 +9205,7 @@ class GatewayRunner:
|
||||
if not _run_still_current():
|
||||
logger.info(
|
||||
"Discarding stale proxy stream for %s — generation %d is no longer current",
|
||||
session_key[:20] if session_key else "?",
|
||||
session_key or "?",
|
||||
run_generation or 0,
|
||||
)
|
||||
return {
|
||||
@@ -9269,7 +9269,7 @@ class GatewayRunner:
|
||||
if not _run_still_current():
|
||||
logger.info(
|
||||
"Discarding stale proxy result for %s — generation %d is no longer current",
|
||||
session_key[:20] if session_key else "?",
|
||||
session_key or "?",
|
||||
run_generation or 0,
|
||||
)
|
||||
return {
|
||||
@@ -9711,7 +9711,7 @@ class GatewayRunner:
|
||||
)
|
||||
logger.debug(
|
||||
"run_agent resolved: model=%s provider=%s session=%s",
|
||||
model, runtime_kwargs.get("provider"), (session_key or "")[:30],
|
||||
model, runtime_kwargs.get("provider"), session_key or "",
|
||||
)
|
||||
except Exception as exc:
|
||||
return {
|
||||
@@ -10322,7 +10322,7 @@ class GatewayRunner:
|
||||
):
|
||||
logger.info(
|
||||
"Skipping stale agent promotion for %s — generation %s is no longer current",
|
||||
(session_key or "")[:20],
|
||||
session_key or "",
|
||||
run_generation,
|
||||
)
|
||||
return
|
||||
@@ -10469,7 +10469,7 @@ class GatewayRunner:
|
||||
logger.info(
|
||||
"Backup interrupt detected for session %s "
|
||||
"(monitor task state: %s)",
|
||||
session_key[:20],
|
||||
session_key,
|
||||
"done" if interrupt_monitor.done() else "running",
|
||||
)
|
||||
_backup_agent.interrupt(_bp_text)
|
||||
@@ -10529,7 +10529,7 @@ class GatewayRunner:
|
||||
logger.info(
|
||||
"Backup interrupt detected for session %s "
|
||||
"(monitor task state: %s)",
|
||||
session_key[:20],
|
||||
session_key,
|
||||
"done" if interrupt_monitor.done() else "running",
|
||||
)
|
||||
_backup_agent.interrupt(_bp_text)
|
||||
@@ -10631,7 +10631,7 @@ class GatewayRunner:
|
||||
if _is_control_interrupt_message(interrupt_message):
|
||||
logger.info(
|
||||
"Ignoring control interrupt message for session %s: %s",
|
||||
session_key[:20] if session_key else "?",
|
||||
session_key or "?",
|
||||
interrupt_message,
|
||||
)
|
||||
else:
|
||||
@@ -10675,7 +10675,7 @@ class GatewayRunner:
|
||||
if self._draining and (pending_event or pending):
|
||||
logger.info(
|
||||
"Discarding pending follow-up for session %s during gateway %s",
|
||||
session_key[:20] if session_key else "?",
|
||||
session_key or "?",
|
||||
self._status_action_label(),
|
||||
)
|
||||
pending_event = None
|
||||
@@ -10732,7 +10732,7 @@ class GatewayRunner:
|
||||
try:
|
||||
logger.info(
|
||||
"Queued follow-up for session %s: final stream delivery not confirmed; sending first response before continuing.",
|
||||
session_key[:20] if session_key else "?",
|
||||
session_key or "?",
|
||||
)
|
||||
await adapter.send(
|
||||
source.chat_id,
|
||||
@@ -10744,7 +10744,7 @@ class GatewayRunner:
|
||||
elif first_response:
|
||||
logger.info(
|
||||
"Queued follow-up for session %s: skipping resend because final streamed delivery was confirmed.",
|
||||
session_key[:20] if session_key else "?",
|
||||
session_key or "?",
|
||||
)
|
||||
# Release deferred bg-review notifications now that the
|
||||
# first response has been delivered. Pop from the
|
||||
@@ -10879,7 +10879,7 @@ class GatewayRunner:
|
||||
if not _is_empty_sentinel and (_streamed or _previewed):
|
||||
logger.info(
|
||||
"Suppressing normal final send for session %s: final delivery already confirmed (streamed=%s previewed=%s).",
|
||||
session_key[:20] if session_key else "?",
|
||||
session_key or "?",
|
||||
_streamed,
|
||||
_previewed,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -867,8 +867,7 @@ def setup_model_provider(config: dict, *, quick: bool = False):
|
||||
)
|
||||
else:
|
||||
_selected_vision_model = prompt(" Vision model (blank = use main/custom default)").strip()
|
||||
if _selected_vision_model:
|
||||
save_env_value("AUXILIARY_VISION_MODEL", _selected_vision_model)
|
||||
save_env_value("AUXILIARY_VISION_MODEL", _selected_vision_model)
|
||||
print_success(
|
||||
f"Vision configured with {_base_url}"
|
||||
+ (f" ({_selected_vision_model})" if _selected_vision_model else "")
|
||||
|
||||
10
run_agent.py
10
run_agent.py
@@ -5137,6 +5137,8 @@ class AIAgent:
|
||||
# response.incomplete instead of response.completed).
|
||||
self._codex_streamed_text_parts: list = []
|
||||
for attempt in range(max_stream_retries + 1):
|
||||
if self._interrupt_requested:
|
||||
raise InterruptedError("Agent interrupted before Codex stream retry")
|
||||
collected_output_items: list = []
|
||||
try:
|
||||
with active_client.responses.stream(**api_kwargs) as stream:
|
||||
@@ -6306,6 +6308,14 @@ class AIAgent:
|
||||
|
||||
try:
|
||||
for _stream_attempt in range(_max_stream_retries + 1):
|
||||
# Check for interrupt before each retry attempt. Without
|
||||
# this, /stop closes the HTTP connection (outer poll loop),
|
||||
# but the retry loop opens a FRESH connection — negating the
|
||||
# interrupt entirely. On slow providers (ollama-cloud) each
|
||||
# retry can block for the full stream-read timeout (120s+),
|
||||
# causing multi-minute delays between /stop and response.
|
||||
if self._interrupt_requested:
|
||||
raise InterruptedError("Agent interrupted before stream retry")
|
||||
try:
|
||||
if self.api_mode == "anthropic_messages":
|
||||
self._try_refresh_anthropic_client_credentials()
|
||||
|
||||
@@ -847,6 +847,32 @@ class TestTokenBudgetTailProtection:
|
||||
assert isinstance(pruned, int)
|
||||
|
||||
|
||||
class TestUpdateModelBudgets:
|
||||
"""Regression: update_model() must recalculate token budgets."""
|
||||
|
||||
def test_tail_budget_recalculated(self):
|
||||
"""tail_token_budget must change after switching to a different context length."""
|
||||
from unittest.mock import patch
|
||||
with patch("agent.context_compressor.get_model_context_length", return_value=200_000):
|
||||
comp = ContextCompressor("model-a", threshold_percent=0.50, quiet_mode=True)
|
||||
old_tail = comp.tail_token_budget
|
||||
old_max_summary = comp.max_summary_tokens
|
||||
|
||||
comp.update_model("model-b", context_length=32_000)
|
||||
assert comp.tail_token_budget != old_tail, "tail_token_budget should change"
|
||||
assert comp.tail_token_budget < old_tail, "smaller context → smaller budget"
|
||||
assert comp.max_summary_tokens != old_max_summary, "max_summary_tokens should change"
|
||||
|
||||
def test_budgets_proportional(self):
|
||||
"""Budgets should be proportional to context_length after update."""
|
||||
from unittest.mock import patch
|
||||
with patch("agent.context_compressor.get_model_context_length", return_value=100_000):
|
||||
comp = ContextCompressor("model-a", threshold_percent=0.50, quiet_mode=True)
|
||||
comp.update_model("model-b", context_length=10_000)
|
||||
assert comp.tail_token_budget == int(comp.threshold_tokens * comp.summary_target_ratio)
|
||||
assert comp.max_summary_tokens == min(int(10_000 * 0.05), 4000)
|
||||
|
||||
|
||||
class TestTruncateToolCallArgsJson:
|
||||
"""Regression tests for #11762.
|
||||
|
||||
|
||||
@@ -527,20 +527,3 @@ def test_offer_launch_chat_manual_fallback_when_unresolvable(monkeypatch, capsys
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "Run 'hermes chat' manually" in captured.out
|
||||
|
||||
|
||||
class TestVisionModelBlankGuard:
|
||||
"""Regression: blank vision model input must not overwrite existing .env value."""
|
||||
|
||||
def test_save_env_value_overwrites_with_empty(self, tmp_path, monkeypatch):
|
||||
"""Proves save_env_value has no internal guard — caller must guard."""
|
||||
env_path = tmp_path / ".env"
|
||||
env_path.write_text("AUXILIARY_VISION_MODEL=custom-model\n")
|
||||
monkeypatch.setattr("hermes_cli.config.get_env_path", lambda: env_path)
|
||||
|
||||
from hermes_cli.config import save_env_value
|
||||
save_env_value("AUXILIARY_VISION_MODEL", "")
|
||||
|
||||
# save_env_value DOES overwrite — this is by design.
|
||||
# The guard must be at the call site in setup.py.
|
||||
assert "AUXILIARY_VISION_MODEL=\n" in env_path.read_text()
|
||||
|
||||
162
tests/run_agent/test_stream_interrupt_retry.py
Normal file
162
tests/run_agent/test_stream_interrupt_retry.py
Normal file
@@ -0,0 +1,162 @@
|
||||
"""Tests that /stop interrupts streaming retry loops immediately.
|
||||
|
||||
When the agent is interrupted during a streaming API call, the outer poll
|
||||
loop closes the HTTP connection. The inner `_call()` thread sees a
|
||||
connection error and enters its retry loop. Before this fix, the retry
|
||||
loop would open a FRESH connection without checking `_interrupt_requested`,
|
||||
making /stop take multiple retry cycles × read-timeout to actually stop
|
||||
(510+ seconds observed on slow ollama-cloud providers).
|
||||
|
||||
The fix adds an `_interrupt_requested` check at the top of the retry loop
|
||||
so the agent exits immediately instead of retrying.
|
||||
"""
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _make_agent(**kwargs):
|
||||
"""Create a minimal AIAgent for streaming tests."""
|
||||
from run_agent import AIAgent
|
||||
|
||||
defaults = dict(
|
||||
api_key="test-key",
|
||||
base_url="https://example.com/v1",
|
||||
model="test/model",
|
||||
quiet_mode=True,
|
||||
skip_context_files=True,
|
||||
skip_memory=True,
|
||||
)
|
||||
defaults.update(kwargs)
|
||||
agent = AIAgent(**defaults)
|
||||
agent.api_mode = "chat_completions"
|
||||
return agent
|
||||
|
||||
|
||||
class TestStreamInterruptBeforeRetry:
|
||||
"""Verify _interrupt_requested is checked before each streaming retry."""
|
||||
|
||||
@pytest.mark.filterwarnings(
|
||||
"ignore::pytest.PytestUnhandledThreadExceptionWarning"
|
||||
)
|
||||
@patch("run_agent.AIAgent._create_request_openai_client")
|
||||
@patch("run_agent.AIAgent._close_request_openai_client")
|
||||
def test_interrupt_prevents_stream_retry(self, mock_close, mock_create):
|
||||
"""When _interrupt_requested is set during a transient stream error,
|
||||
the retry loop must NOT retry — it should raise InterruptedError
|
||||
immediately instead of opening a fresh connection."""
|
||||
import httpx
|
||||
|
||||
attempt_count = [0]
|
||||
|
||||
def fail_once_then_interrupt(*args, **kwargs):
|
||||
attempt_count[0] += 1
|
||||
if attempt_count[0] == 1:
|
||||
# First attempt: simulate normal failure, then set interrupt
|
||||
# (as if /stop arrived while the retry loop processes the error)
|
||||
agent._interrupt_requested = True
|
||||
raise httpx.ConnectError("connection reset by /stop")
|
||||
# Should never reach here — the interrupt check should fire first
|
||||
raise httpx.ConnectError("unexpected retry — interrupt not checked!")
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_client.chat.completions.create.side_effect = fail_once_then_interrupt
|
||||
mock_create.return_value = mock_client
|
||||
|
||||
agent = _make_agent()
|
||||
agent._interrupt_requested = False
|
||||
|
||||
with pytest.raises(InterruptedError, match="interrupted"):
|
||||
agent._interruptible_streaming_api_call({})
|
||||
|
||||
# Only 1 attempt should have been made — the interrupt should prevent retry
|
||||
assert attempt_count[0] == 1, (
|
||||
f"Expected 1 attempt but got {attempt_count[0]}. "
|
||||
"The retry loop retried despite _interrupt_requested being set."
|
||||
)
|
||||
|
||||
@pytest.mark.filterwarnings(
|
||||
"ignore::pytest.PytestUnhandledThreadExceptionWarning"
|
||||
)
|
||||
@patch("run_agent.AIAgent._create_request_openai_client")
|
||||
@patch("run_agent.AIAgent._close_request_openai_client")
|
||||
def test_interrupt_before_first_attempt(self, mock_close, mock_create):
|
||||
"""If _interrupt_requested is already set when the streaming call
|
||||
starts, it should exit immediately without making any API call."""
|
||||
mock_client = MagicMock()
|
||||
mock_create.return_value = mock_client
|
||||
|
||||
agent = _make_agent()
|
||||
agent._interrupt_requested = True # Pre-set before call
|
||||
|
||||
with pytest.raises(InterruptedError, match="interrupted"):
|
||||
agent._interruptible_streaming_api_call({})
|
||||
|
||||
# No API call should have been made at all
|
||||
assert mock_client.chat.completions.create.call_count == 0
|
||||
|
||||
@patch("run_agent.AIAgent._create_request_openai_client")
|
||||
@patch("run_agent.AIAgent._close_request_openai_client")
|
||||
def test_normal_retry_still_works_without_interrupt(self, mock_close, mock_create):
|
||||
"""Without an interrupt, transient errors should still retry normally."""
|
||||
import httpx
|
||||
|
||||
attempts = [0]
|
||||
|
||||
def fail_twice_then_succeed(*args, **kwargs):
|
||||
attempts[0] += 1
|
||||
if attempts[0] <= 2:
|
||||
raise httpx.ConnectError("transient failure")
|
||||
# Third attempt succeeds
|
||||
chunks = [
|
||||
SimpleNamespace(
|
||||
choices=[
|
||||
SimpleNamespace(
|
||||
index=0,
|
||||
delta=SimpleNamespace(
|
||||
content="ok",
|
||||
tool_calls=None,
|
||||
reasoning_content=None,
|
||||
reasoning=None,
|
||||
),
|
||||
finish_reason=None,
|
||||
)
|
||||
],
|
||||
model="test/model",
|
||||
usage=None,
|
||||
),
|
||||
SimpleNamespace(
|
||||
choices=[
|
||||
SimpleNamespace(
|
||||
index=0,
|
||||
delta=SimpleNamespace(
|
||||
content=None,
|
||||
tool_calls=None,
|
||||
reasoning_content=None,
|
||||
reasoning=None,
|
||||
),
|
||||
finish_reason="stop",
|
||||
)
|
||||
],
|
||||
model="test/model",
|
||||
usage=None,
|
||||
),
|
||||
]
|
||||
stream = MagicMock()
|
||||
stream.__iter__ = MagicMock(return_value=iter(chunks))
|
||||
stream.response = MagicMock()
|
||||
stream.response.headers = {}
|
||||
return stream
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_client.chat.completions.create.side_effect = fail_twice_then_succeed
|
||||
mock_create.return_value = mock_client
|
||||
|
||||
agent = _make_agent()
|
||||
agent._interrupt_requested = False
|
||||
|
||||
# Should succeed on the third attempt
|
||||
result = agent._interruptible_streaming_api_call({})
|
||||
assert result is not None
|
||||
assert attempts[0] == 3
|
||||
@@ -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}
|
||||
|
||||
@@ -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