From af22421e87a741f65d28648fc8289c7c07fab145 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sat, 25 Apr 2026 06:55:35 -0700 Subject: [PATCH] feat(dashboard): page-scoped plugin slots for built-in pages (#15658) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 :top / :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 as the first child of its outer wrapper and 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 --- .../example-dashboard/dashboard/dist/index.js | 25 +++++++++++ .../example-dashboard/dashboard/manifest.json | 1 + tests/hermes_cli/test_web_server.py | 39 +++++++++++++++++ web/src/pages/AnalyticsPage.tsx | 3 ++ web/src/pages/ChatPage.tsx | 3 ++ web/src/pages/ConfigPage.tsx | 3 ++ web/src/pages/CronPage.tsx | 3 ++ web/src/pages/DocsPage.tsx | 3 ++ web/src/pages/EnvPage.tsx | 3 ++ web/src/pages/LogsPage.tsx | 3 ++ web/src/pages/SessionsPage.tsx | 3 ++ web/src/pages/SkillsPage.tsx | 3 ++ web/src/plugins/slots.ts | 43 +++++++++++++++++++ .../features/extending-the-dashboard.md | 31 +++++++++++++ 14 files changed, 166 insertions(+) diff --git a/plugins/example-dashboard/dashboard/dist/index.js b/plugins/example-dashboard/dashboard/dist/index.js index a54916be41..04092348ff 100644 --- a/plugins/example-dashboard/dashboard/dist/index.js +++ b/plugins/example-dashboard/dashboard/dist/index.js @@ -91,4 +91,29 @@ // Register this plugin — the dashboard picks it up automatically. window.__HERMES_PLUGINS__.register("example", ExamplePage); + + // ───────────────────────────────────────────────────────────────────── + // Page-scoped slot demo: inject a small banner at the top of /sessions. + // + // Built-in pages expose named slots (:top, :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 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); })(); diff --git a/plugins/example-dashboard/dashboard/manifest.json b/plugins/example-dashboard/dashboard/manifest.json index 2111bff5e7..95fce2f100 100644 --- a/plugins/example-dashboard/dashboard/manifest.json +++ b/plugins/example-dashboard/dashboard/manifest.json @@ -8,6 +8,7 @@ "path": "/example", "position": "after:skills" }, + "slots": ["sessions:top"], "entry": "dist/index.js", "api": "plugin_api.py" } diff --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py index e83f5bdeb3..e7b3b03305 100644 --- a/tests/hermes_cli/test_web_server.py +++ b/tests/hermes_cli/test_web_server.py @@ -1678,6 +1678,45 @@ class TestDashboardPluginManifestExtensions: entry = next(p for p in plugins if p["name"] == "mixed-slots") assert entry["slots"] == ["sidebar", "header-right"] + def test_page_scoped_slots_preserved(self, tmp_path, monkeypatch): + """Page-scoped slot names (e.g. ``sessions:top``) round-trip through + the manifest loader untouched. The backend has no allowlist — the + frontend ```` placements decide what actually + renders — but the loader must not mangle colons in slot names.""" + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + self._write_plugin(tmp_path, "page-slots", { + "name": "page-slots", + "label": "Page Slots", + "tab": {"path": "/page-slots", "hidden": True}, + "slots": [ + "sessions:top", + "analytics:bottom", + "logs:top", + "skills:bottom", + "config:top", + "env:bottom", + "docs:top", + "cron:bottom", + "chat:top", + ], + "entry": "dist/index.js", + }) + from hermes_cli import web_server + web_server._dashboard_plugins_cache = None + plugins = web_server._get_dashboard_plugins(force_rescan=True) + entry = next(p for p in plugins if p["name"] == "page-slots") + assert entry["slots"] == [ + "sessions:top", + "analytics:bottom", + "logs:top", + "skills:bottom", + "config:top", + "env:bottom", + "docs:top", + "cron:bottom", + "chat:top", + ] + # --------------------------------------------------------------------------- # /api/pty WebSocket — terminal bridge for the dashboard "Chat" tab. diff --git a/web/src/pages/AnalyticsPage.tsx b/web/src/pages/AnalyticsPage.tsx index ba30612170..63dd15e4a3 100644 --- a/web/src/pages/AnalyticsPage.tsx +++ b/web/src/pages/AnalyticsPage.tsx @@ -15,6 +15,7 @@ import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { usePageHeader } from "@/contexts/usePageHeader"; import { useI18n } from "@/i18n"; +import { PluginSlot } from "@/plugins"; const PERIODS = [ { label: "7d", days: 7 }, @@ -350,6 +351,7 @@ export default function AnalyticsPage() { return (
+ {loading && !data && (
@@ -409,6 +411,7 @@ export default function AnalyticsPage() { )} +
); } diff --git a/web/src/pages/ChatPage.tsx b/web/src/pages/ChatPage.tsx index 639c6324fa..80398104a1 100644 --- a/web/src/pages/ChatPage.tsx +++ b/web/src/pages/ChatPage.tsx @@ -32,6 +32,7 @@ import { useSearchParams } from "react-router-dom"; import { ChatSidebar } from "@/components/ChatSidebar"; import { usePageHeader } from "@/contexts/usePageHeader"; import { useI18n } from "@/i18n"; +import { PluginSlot } from "@/plugins"; function buildWsUrl( token: string, @@ -670,6 +671,7 @@ export default function ChatPage() { return (
+ {mobileModelToolsPortal} {banner && ( @@ -732,6 +734,7 @@ export default function ChatPage() {
)}
+
); } diff --git a/web/src/pages/ConfigPage.tsx b/web/src/pages/ConfigPage.tsx index 80cef29e4c..dcd387a922 100644 --- a/web/src/pages/ConfigPage.tsx +++ b/web/src/pages/ConfigPage.tsx @@ -39,6 +39,7 @@ import { Input } from "@/components/ui/input"; import { Badge } from "@/components/ui/badge"; import { useI18n } from "@/i18n"; import { usePageHeader } from "@/contexts/usePageHeader"; +import { PluginSlot } from "@/plugins"; /* ------------------------------------------------------------------ */ /* Helpers */ @@ -313,6 +314,7 @@ export default function ConfigPage() { return (
+ {/* ═══════════════ Header Bar ═══════════════ */} @@ -505,6 +507,7 @@ export default function ConfigPage() {
)} + ); } diff --git a/web/src/pages/CronPage.tsx b/web/src/pages/CronPage.tsx index 10fba6913e..63478fa74d 100644 --- a/web/src/pages/CronPage.tsx +++ b/web/src/pages/CronPage.tsx @@ -14,6 +14,7 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Select, SelectOption } from "@/components/ui/select"; import { useI18n } from "@/i18n"; +import { PluginSlot } from "@/plugins"; function formatTime(iso?: string | null): string { if (!iso) return "—"; @@ -149,6 +150,7 @@ export default function CronPage() { return (
+ ))}
+ ); } diff --git a/web/src/pages/DocsPage.tsx b/web/src/pages/DocsPage.tsx index 5861aeccc1..2e1a6491fa 100644 --- a/web/src/pages/DocsPage.tsx +++ b/web/src/pages/DocsPage.tsx @@ -4,6 +4,7 @@ import { useI18n } from "@/i18n"; import { usePageHeader } from "@/contexts/usePageHeader"; import { buttonVariants } from "@/components/ui/button"; import { cn } from "@/lib/utils"; +import { PluginSlot } from "@/plugins"; export const HERMES_DOCS_URL = "https://hermes-agent.nousresearch.com/docs/"; @@ -38,6 +39,7 @@ export default function DocsPage() { "pt-1 sm:pt-2", )} > +