feat: dashboard plugin system — extend the web UI with custom tabs
Add a plugin system that lets plugins add new tabs to the dashboard.
Plugins live in ~/.hermes/plugins/<name>/dashboard/ alongside any
existing CLI/gateway plugin code.
Plugin structure:
plugins/<name>/dashboard/
manifest.json # name, label, icon, tab config, entry point
dist/index.js # pre-built JS bundle (IIFE, uses SDK globals)
plugin_api.py # optional FastAPI router mounted at /api/plugins/<name>/
Backend (hermes_cli/web_server.py):
- Plugin discovery: scans plugins/*/dashboard/manifest.json from user,
bundled, and project plugin directories
- GET /api/dashboard/plugins — returns discovered plugin manifests
- GET /api/dashboard/plugins/rescan — force re-discovery
- GET /dashboard-plugins/<name>/<path> — serves plugin static assets
with path traversal protection
- Optional API route mounting: imports plugin_api.py and mounts its
router under /api/plugins/<name>/
- Plugin API routes bypass session token auth (localhost-only)
Frontend (web/src/plugins/):
- Plugin SDK exposed on window.__HERMES_PLUGIN_SDK__ — provides React,
hooks, UI components (Card, Badge, Button, etc.), API client,
fetchJSON, theme/i18n hooks, and utilities
- Plugin registry on window.__HERMES_PLUGINS__.register(name, Component)
- usePlugins() hook: fetches manifests, loads JS/CSS, resolves components
- App.tsx dynamically adds nav items and routes for discovered plugins
- Icon resolution via static map of 20 common Lucide icons (no tree-
shaking penalty — bundle only +5KB over baseline)
Example plugin (plugins/example-dashboard/):
- Demonstrates SDK usage: Card components, backend API call, SDK reference
- Backend route: GET /api/plugins/example/hello
Tested: plugin discovery, static serving, API routes, path traversal
blocking, unknown plugin 404, bundle size (400KB vs 394KB baseline).
2026-04-16 03:10:28 -07:00
|
|
|
/**
|
|
|
|
|
* Example Dashboard Plugin
|
|
|
|
|
*
|
|
|
|
|
* Demonstrates how to build a dashboard plugin using the Hermes Plugin SDK.
|
|
|
|
|
* No build step needed — this is a plain IIFE that uses globals from the SDK.
|
|
|
|
|
*/
|
|
|
|
|
(function () {
|
|
|
|
|
"use strict";
|
|
|
|
|
|
|
|
|
|
const SDK = window.__HERMES_PLUGIN_SDK__;
|
|
|
|
|
const { React } = SDK;
|
|
|
|
|
const { Card, CardHeader, CardTitle, CardContent, Badge, Button } = SDK.components;
|
|
|
|
|
const { useState, useEffect } = SDK.hooks;
|
|
|
|
|
const { cn } = SDK.utils;
|
|
|
|
|
|
|
|
|
|
function ExamplePage() {
|
|
|
|
|
const [greeting, setGreeting] = useState(null);
|
|
|
|
|
const [loading, setLoading] = useState(false);
|
|
|
|
|
|
|
|
|
|
function fetchGreeting() {
|
|
|
|
|
setLoading(true);
|
|
|
|
|
SDK.fetchJSON("/api/plugins/example/hello")
|
|
|
|
|
.then(function (data) { setGreeting(data.message); })
|
|
|
|
|
.catch(function () { setGreeting("(backend not available)"); })
|
|
|
|
|
.finally(function () { setLoading(false); });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return React.createElement("div", { className: "flex flex-col gap-6" },
|
|
|
|
|
// Header card
|
|
|
|
|
React.createElement(Card, null,
|
|
|
|
|
React.createElement(CardHeader, null,
|
|
|
|
|
React.createElement("div", { className: "flex items-center gap-3" },
|
|
|
|
|
React.createElement(CardTitle, { className: "text-lg" }, "Example Plugin"),
|
|
|
|
|
React.createElement(Badge, { variant: "outline" }, "v1.0.0"),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
React.createElement(CardContent, { className: "flex flex-col gap-4" },
|
|
|
|
|
React.createElement("p", { className: "text-sm text-muted-foreground" },
|
|
|
|
|
"This is an example dashboard plugin. It demonstrates using the Plugin SDK to build ",
|
|
|
|
|
"custom tabs with React components, connect to backend API routes, and integrate with ",
|
|
|
|
|
"the existing Hermes UI system.",
|
|
|
|
|
),
|
|
|
|
|
React.createElement("div", { className: "flex items-center gap-3" },
|
|
|
|
|
React.createElement(Button, {
|
|
|
|
|
onClick: fetchGreeting,
|
|
|
|
|
disabled: loading,
|
|
|
|
|
className: cn(
|
|
|
|
|
"inline-flex items-center gap-2 border border-border bg-background/40 px-4 py-2",
|
|
|
|
|
"text-sm font-courier transition-colors hover:bg-foreground/10 cursor-pointer",
|
|
|
|
|
),
|
|
|
|
|
}, loading ? "Loading..." : "Call Backend API"),
|
|
|
|
|
greeting && React.createElement("span", {
|
|
|
|
|
className: "text-sm font-courier text-muted-foreground",
|
|
|
|
|
}, greeting),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
|
|
|
|
|
// Info card about the SDK
|
|
|
|
|
React.createElement(Card, null,
|
|
|
|
|
React.createElement(CardHeader, null,
|
|
|
|
|
React.createElement(CardTitle, { className: "text-base" }, "Plugin SDK Reference"),
|
|
|
|
|
),
|
|
|
|
|
React.createElement(CardContent, null,
|
|
|
|
|
React.createElement("div", { className: "grid gap-3 text-sm" },
|
|
|
|
|
React.createElement("div", { className: "flex flex-col gap-1 border border-border p-3" },
|
|
|
|
|
React.createElement("span", { className: "font-medium" }, "window.__HERMES_PLUGIN_SDK__.React"),
|
|
|
|
|
React.createElement("span", { className: "text-muted-foreground text-xs" }, "React instance — use instead of importing react"),
|
|
|
|
|
),
|
|
|
|
|
React.createElement("div", { className: "flex flex-col gap-1 border border-border p-3" },
|
|
|
|
|
React.createElement("span", { className: "font-medium" }, "window.__HERMES_PLUGIN_SDK__.hooks"),
|
|
|
|
|
React.createElement("span", { className: "text-muted-foreground text-xs" }, "useState, useEffect, useCallback, useMemo, useRef, useContext, createContext"),
|
|
|
|
|
),
|
|
|
|
|
React.createElement("div", { className: "flex flex-col gap-1 border border-border p-3" },
|
|
|
|
|
React.createElement("span", { className: "font-medium" }, "window.__HERMES_PLUGIN_SDK__.components"),
|
|
|
|
|
React.createElement("span", { className: "text-muted-foreground text-xs" }, "Card, Badge, Button, Input, Label, Select, Separator, Tabs, etc."),
|
|
|
|
|
),
|
|
|
|
|
React.createElement("div", { className: "flex flex-col gap-1 border border-border p-3" },
|
|
|
|
|
React.createElement("span", { className: "font-medium" }, "window.__HERMES_PLUGIN_SDK__.api"),
|
|
|
|
|
React.createElement("span", { className: "text-muted-foreground text-xs" }, "Hermes API client — getStatus(), getSessions(), etc."),
|
|
|
|
|
),
|
|
|
|
|
React.createElement("div", { className: "flex flex-col gap-1 border border-border p-3" },
|
|
|
|
|
React.createElement("span", { className: "font-medium" }, "window.__HERMES_PLUGIN_SDK__.utils"),
|
|
|
|
|
React.createElement("span", { className: "text-muted-foreground text-xs" }, "cn(), timeAgo(), isoTimeAgo()"),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Register this plugin — the dashboard picks it up automatically.
|
|
|
|
|
window.__HERMES_PLUGINS__.register("example", ExamplePage);
|
feat(dashboard): page-scoped plugin slots for built-in pages (#15658)
* fix(terminal): three-layer defense against watch_patterns notification spam
Background processes that stack notify_on_complete=True with watch_patterns
can flood the user with duplicate, delayed notifications — matches deliver
asynchronously via the completion queue and continue arriving minutes after
the process has exited. The docstring warning against this (PR #12113) has
proven insufficient; agents still misuse the combination.
Three layered defenses, each sufficient on its own:
1. Mutual exclusion (terminal_tool.py): When both flags are set on a
background process, drop watch_patterns with a warning. notify_on_complete
wins because 'let me know when it's done' is the more useful signal and
fires exactly once. Extracted as _resolve_notification_flag_conflict() so
the rule is testable in isolation.
2. Suppress-after-exit (process_registry.py): _check_watch_patterns() now
bails the moment session.exited is True. Post-exit chunks (buffered reads
draining after the process is gone) no longer produce notifications. This
is the fix flagged as future work in session 20260418_020302_79881c.
3. Global circuit breaker (process_registry.py): Per-session rate limits don't
catch the sibling-flood case — N concurrent processes can each stay under
8/10s and still collectively spam. New WATCH_GLOBAL_MAX_PER_WINDOW=15 cap
trips a 30-second cooldown across ALL sessions, emits a single
watch_overflow_tripped event, silently counts dropped events, and emits a
watch_overflow_released summary when the cooldown ends.
Also updates the tool schema + docstring to document the new behavior.
Tests: 8 new tests covering all three fixes (suppress-after-exit x2,
mutual-exclusion resolver x4, global breaker trip/cooldown/release x2).
All 60 tests across test_watch_patterns.py, test_notify_on_complete.py,
test_terminal_tool.py pass.
Real-world trigger: self-inflicted in session 20260425_051924 — three
concurrent hermes-sweeper review subprocesses each set watch_patterns=
['failed validation', 'errored'] AND notify_on_complete=True, then iterated
over multiple items, producing enough matches per process to defeat the
per-session cap while staying under the global cap that didn't yet exist.
* fix(terminal): aggressive 1-per-15s watch_patterns rate limit + strike-3 promotion
Per Teknium's direction, the watch_patterns rate limit is now much more
aggressive and self-healing.
## New rule — per session
- HARD cap: 1 watch-match notification per 15 seconds per process.
- Any match arriving inside the cooldown window is dropped and counts as
ONE strike for that window (many drops in the same window still = 1 strike).
- After 3 consecutive strike windows, watch_patterns is permanently disabled
for the session and the session is auto-promoted to notify_on_complete
semantics — exactly one notification when the process actually exits.
- A cooldown window that expires with zero drops resets the consecutive
strike counter — healthy cadence is forgiven.
## Schema + docstring rewritten
The tool schema description now gives the model explicit guidance:
- notify_on_complete is 'the right choice for almost every long-running task'
- watch_patterns is for RARE one-shot signals on LONG-LIVED processes
- Do NOT use watch_patterns with loops/batch jobs — error patterns fire every
iteration and will hit the strike limit fast
- Mutual exclusion is stated on both parameter descriptions
- 1/15s cooldown and 3-strike promotion are stated in the watch_patterns
description so the model sees the contract every turn
## Removed
- WATCH_MAX_PER_WINDOW (8/10s) and WATCH_OVERLOAD_KILL_SECONDS (45) — the
new 1/15s limit subsumes both; keeping them would double-count.
- _watch_window_hits / _watch_window_start / _watch_overload_since fields
on ProcessSession. Replaced by _watch_last_emit_at / _watch_cooldown_until
/ _watch_strike_candidate / _watch_consecutive_strikes.
## Kept
- Global circuit breaker across all sessions (15/10s → 30s cooldown) as a
secondary safety net for concurrent siblings. Still valuable when 20
short-lived processes each fire once — none individually violates the
per-session limit.
- Suppress-after-exit guard.
- Mutual exclusion resolver at the tool entry point.
## Tests
- 6 new tests in TestPerSessionRateLimit covering: first match delivers,
second in cooldown suppressed, multi-drop = single strike, 3 strikes
disables + promotes, clean window resets counter, suppressed count
carried to next emit.
- Global circuit breaker tests rewritten to use fresh sessions instead of
hacking removed per-window fields.
- 50/50 watch_patterns + notify_on_complete tests pass.
- 60/60 including test_terminal_tool.py pass.
* feat(dashboard): page-scoped plugin slots for built-in pages
Dashboard plugins can now inject components into specific built-in
pages (Sessions, Analytics, Logs, Cron, Skills, Config, Env, Docs,
Chat) without overriding the whole route.
Previously, plugins could only:
1. Add new tabs (tab.path)
2. Replace whole built-in pages (tab.override)
3. Inject into global shell slots (header-*, footer-*, pre-main, ...)
None of those let a plugin add a banner, card, or widget to an
existing page. The new <page>:top / <page>:bottom slots close that
gap, reusing the existing registerSlot() API.
Changes
- web/src/plugins/slots.ts: 18 new KNOWN_SLOT_NAMES entries
(sessions:top, sessions:bottom, analytics:top, ..., chat:bottom),
grouped under "Shell-wide" vs "Page-scoped" in the docblock
- web/src/pages/*: each built-in page now renders
<PluginSlot name="<page>:top" />
as the first child of its outer wrapper and
<PluginSlot name="<page>:bottom" />
as the last child -- zero visual cost when no plugin registers
- plugins/example-dashboard: registers a demo banner into
sessions:top via registerSlot(), with matching slots entry in
the manifest -- so freshly-setup users can see what page-scoped
slots look like without writing any plugin code
- website/docs: new "Page-scoped slots" table in the plugin
authoring guide, with a worked example
- tests/hermes_cli/test_web_server.py: round-trip test for
colon-bearing slot names (sessions:top, analytics:bottom, ...)
Validation
- npm run build: clean (tsc -b + vite build, 2761 modules)
- scripts/run_tests.sh tests/hermes_cli/test_web_server.py::TestDashboardPluginManifestExtensions: 5/5 pass
2026-04-25 06:55:35 -07:00
|
|
|
|
|
|
|
|
// ─────────────────────────────────────────────────────────────────────
|
|
|
|
|
// Page-scoped slot demo: inject a small banner at the top of /sessions.
|
|
|
|
|
//
|
|
|
|
|
// Built-in pages expose named slots (<page>:top, <page>:bottom) that
|
|
|
|
|
// plugins can populate without overriding the whole route. The
|
|
|
|
|
// manifest lists the slots we use in its `slots` array so the shell
|
|
|
|
|
// knows to render <PluginSlot name="sessions:top" /> there.
|
|
|
|
|
// ─────────────────────────────────────────────────────────────────────
|
|
|
|
|
function SessionsTopBanner() {
|
|
|
|
|
return React.createElement(Card, {
|
|
|
|
|
className: "border-dashed",
|
|
|
|
|
},
|
|
|
|
|
React.createElement(CardContent, { className: "flex items-center gap-3 py-2" },
|
|
|
|
|
React.createElement(Badge, { variant: "outline" }, "Example"),
|
|
|
|
|
React.createElement("span", {
|
|
|
|
|
className: "text-xs text-muted-foreground",
|
|
|
|
|
}, "This banner was injected into the Sessions page by the example plugin via the ",
|
|
|
|
|
React.createElement("code", { className: "font-courier" }, "sessions:top"),
|
|
|
|
|
" slot."),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
window.__HERMES_PLUGINS__.registerSlot("example", "sessions:top", SessionsTopBanner);
|
feat: dashboard plugin system — extend the web UI with custom tabs
Add a plugin system that lets plugins add new tabs to the dashboard.
Plugins live in ~/.hermes/plugins/<name>/dashboard/ alongside any
existing CLI/gateway plugin code.
Plugin structure:
plugins/<name>/dashboard/
manifest.json # name, label, icon, tab config, entry point
dist/index.js # pre-built JS bundle (IIFE, uses SDK globals)
plugin_api.py # optional FastAPI router mounted at /api/plugins/<name>/
Backend (hermes_cli/web_server.py):
- Plugin discovery: scans plugins/*/dashboard/manifest.json from user,
bundled, and project plugin directories
- GET /api/dashboard/plugins — returns discovered plugin manifests
- GET /api/dashboard/plugins/rescan — force re-discovery
- GET /dashboard-plugins/<name>/<path> — serves plugin static assets
with path traversal protection
- Optional API route mounting: imports plugin_api.py and mounts its
router under /api/plugins/<name>/
- Plugin API routes bypass session token auth (localhost-only)
Frontend (web/src/plugins/):
- Plugin SDK exposed on window.__HERMES_PLUGIN_SDK__ — provides React,
hooks, UI components (Card, Badge, Button, etc.), API client,
fetchJSON, theme/i18n hooks, and utilities
- Plugin registry on window.__HERMES_PLUGINS__.register(name, Component)
- usePlugins() hook: fetches manifests, loads JS/CSS, resolves components
- App.tsx dynamically adds nav items and routes for discovered plugins
- Icon resolution via static map of 20 common Lucide icons (no tree-
shaking penalty — bundle only +5KB over baseline)
Example plugin (plugins/example-dashboard/):
- Demonstrates SDK usage: Card components, backend API call, SDK reference
- Backend route: GET /api/plugins/example/hello
Tested: plugin discovery, static serving, API routes, path traversal
blocking, unknown plugin 404, bundle size (400KB vs 394KB baseline).
2026-04-16 03:10:28 -07:00
|
|
|
})();
|