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
This commit is contained in:
Teknium
2026-04-25 06:55:35 -07:00
committed by GitHub
parent 97d54f0e4d
commit af22421e87
14 changed files with 166 additions and 0 deletions

View File

@@ -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 (<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);
})();

View File

@@ -8,6 +8,7 @@
"path": "/example",
"position": "after:skills"
},
"slots": ["sessions:top"],
"entry": "dist/index.js",
"api": "plugin_api.py"
}

View File

@@ -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 ``<PluginSlot name="...">`` 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.

View File

@@ -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 (
<div className="flex flex-col gap-6">
<PluginSlot name="analytics:top" />
{loading && !data && (
<div className="flex items-center justify-center py-24">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-primary border-t-transparent" />
@@ -409,6 +411,7 @@ export default function AnalyticsPage() {
</CardContent>
</Card>
)}
<PluginSlot name="analytics:bottom" />
</div>
);
}

View File

@@ -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 (
<div className="flex min-h-0 flex-1 flex-col gap-2 normal-case">
<PluginSlot name="chat:top" />
{mobileModelToolsPortal}
{banner && (
@@ -732,6 +734,7 @@ export default function ChatPage() {
</div>
)}
</div>
<PluginSlot name="chat:bottom" />
</div>
);
}

View File

@@ -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 (
<div className="flex flex-col gap-4">
<PluginSlot name="config:top" />
<Toast toast={toast} />
{/* ═══════════════ Header Bar ═══════════════ */}
@@ -505,6 +507,7 @@ export default function ConfigPage() {
</div>
</div>
)}
<PluginSlot name="config:bottom" />
</div>
);
}

View File

@@ -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 (
<div className="flex flex-col gap-6">
<PluginSlot name="cron:top" />
<Toast toast={toast} />
<DeleteConfirmDialog
@@ -346,6 +348,7 @@ export default function CronPage() {
</Card>
))}
</div>
<PluginSlot name="cron:bottom" />
</div>
);
}

View File

@@ -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",
)}
>
<PluginSlot name="docs:top" />
<iframe
title={t.app.nav.documentation}
src={HERMES_DOCS_URL}
@@ -49,6 +51,7 @@ export default function DocsPage() {
sandbox="allow-scripts allow-same-origin allow-popups allow-forms"
referrerPolicy="no-referrer-when-downgrade"
/>
<PluginSlot name="docs:bottom" />
</div>
);
}

View File

@@ -27,6 +27,7 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useI18n } from "@/i18n";
import { PluginSlot } from "@/plugins";
/* ------------------------------------------------------------------ */
/* Provider grouping */
@@ -511,6 +512,7 @@ export default function EnvPage() {
return (
<div className="flex flex-col gap-6">
<PluginSlot name="env:top" />
<Toast toast={toast} />
<DeleteConfirmDialog
@@ -610,6 +612,7 @@ export default function EnvPage() {
</Card>
);
})}
<PluginSlot name="env:bottom" />
</div>
);
}

View File

@@ -9,6 +9,7 @@ import { Label } from "@/components/ui/label";
import { FilterGroup, Segmented } from "@/components/ui/segmented";
import { useI18n } from "@/i18n";
import { usePageHeader } from "@/contexts/usePageHeader";
import { PluginSlot } from "@/plugins";
const FILES = ["agent", "errors", "gateway"] as const;
const LEVELS = ["ALL", "DEBUG", "INFO", "WARNING", "ERROR"] as const;
@@ -141,6 +142,7 @@ export default function LogsPage() {
return (
<div className="flex flex-col gap-4">
<PluginSlot name="logs:top" />
{/* ═══════════════ Filter toolbar ═══════════════ */}
<div
role="toolbar"
@@ -215,6 +217,7 @@ export default function LogsPage() {
</div>
</CardContent>
</Card>
<PluginSlot name="logs:bottom" />
</div>
);
}

View File

@@ -46,6 +46,7 @@ import { useSystemActions } from "@/contexts/useSystemActions";
import { useToast } from "@/hooks/useToast";
import { useI18n } from "@/i18n";
import { usePageHeader } from "@/contexts/usePageHeader";
import { PluginSlot } from "@/plugins";
import { isDashboardEmbeddedChatEnabled } from "@/lib/dashboard-flags";
const SOURCE_CONFIG: Record<string, { icon: typeof Terminal; color: string }> =
@@ -612,6 +613,7 @@ export default function SessionsPage() {
return (
<div className="flex flex-col gap-4">
<PluginSlot name="sessions:top" />
<Toast toast={toast} />
<DeleteConfirmDialog
@@ -834,6 +836,7 @@ export default function SessionsPage() {
)}
</>
)}
<PluginSlot name="sessions:bottom" />
</div>
);
}

View File

@@ -25,6 +25,7 @@ import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { useI18n } from "@/i18n";
import { usePageHeader } from "@/contexts/usePageHeader";
import { PluginSlot } from "@/plugins";
/* ------------------------------------------------------------------ */
/* Types & helpers */
@@ -251,6 +252,7 @@ export default function SkillsPage() {
return (
<div className="flex flex-col gap-4">
<PluginSlot name="skills:top" />
<Toast toast={toast} />
{/* ═══════════════ Filter panel + Content ═══════════════ */}
@@ -509,6 +511,7 @@ export default function SkillsPage() {
)}
</div>
</div>
<PluginSlot name="skills:bottom" />
</div>
);
}

View File

@@ -18,6 +18,7 @@ import React, { Fragment, useEffect, useState } from "react";
/** Slot locations the built-in shell renders. Plugins declaring any of
* these in their manifest's `slots` field get wired in automatically.
*
* Shell-wide slots:
* - `backdrop` — rendered inside `<Backdrop />`, above the noise layer
* - `header-left` — injected before the Hermes brand in the top bar
* - `header-right` — injected before the theme/language switchers
@@ -31,8 +32,31 @@ import React, { Fragment, useEffect, useState } from "react";
* - `overlay` — fixed-position layer above everything else;
* useful for chrome (scanlines, vignettes) the
* theme's customCSS can't achieve alone
*
* Page-scoped slots (rendered inside a specific built-in page — use these
* to inject widgets, cards, or toolbars into existing pages without
* overriding the whole route):
* - `sessions:top` — top of /sessions page (above session list)
* - `sessions:bottom` — bottom of /sessions page
* - `analytics:top` — top of /analytics page
* - `analytics:bottom` — bottom of /analytics page
* - `logs:top` — top of /logs page (above filter toolbar)
* - `logs:bottom` — bottom of /logs page (below log viewer)
* - `cron:top` — top of /cron page
* - `cron:bottom` — bottom of /cron page
* - `skills:top` — top of /skills page
* - `skills:bottom` — bottom of /skills page
* - `config:top` — top of /config page
* - `config:bottom` — bottom of /config page
* - `env:top` — top of /env (Keys) page
* - `env:bottom` — bottom of /env (Keys) page
* - `docs:top` — top of /docs page (above the docs iframe)
* - `docs:bottom` — bottom of /docs page
* - `chat:top` — top of /chat page (above the composer, when embedded chat is on)
* - `chat:bottom` — bottom of /chat page
*/
export const KNOWN_SLOT_NAMES = [
// Shell-wide
"backdrop",
"header-left",
"header-right",
@@ -43,6 +67,25 @@ export const KNOWN_SLOT_NAMES = [
"footer-left",
"footer-right",
"overlay",
// Page-scoped
"sessions:top",
"sessions:bottom",
"analytics:top",
"analytics:bottom",
"logs:top",
"logs:bottom",
"cron:top",
"cron:bottom",
"skills:top",
"skills:bottom",
"config:top",
"config:bottom",
"env:top",
"env:bottom",
"docs:top",
"docs:bottom",
"chat:top",
"chat:bottom",
] as const;
export type KnownSlotName = (typeof KNOWN_SLOT_NAMES)[number];

View File

@@ -552,6 +552,8 @@ window.__HERMES_PLUGINS__.registerSlot("my-plugin", "header-left", MyCrest);
#### Slot catalogue
**Shell-wide slots** (render anywhere in the app chrome):
| Slot | Location |
|------|----------|
| `backdrop` | Inside the `<Backdrop />` layer stack, above the noise layer. |
@@ -565,6 +567,35 @@ window.__HERMES_PLUGINS__.registerSlot("my-plugin", "header-left", MyCrest);
| `footer-right` | Footer cell content (replaces default). |
| `overlay` | Fixed-position layer above everything else. Useful for chrome (scanlines, vignettes) `customCSS` can't achieve alone. |
**Page-scoped slots** (render only on the named built-in page — use these to inject widgets, cards, or toolbars into an existing page without overriding the whole route):
| Slot | Where it renders |
|------|------------------|
| `sessions:top` / `sessions:bottom` | Top / bottom of the `/sessions` page. |
| `analytics:top` / `analytics:bottom` | Top / bottom of the `/analytics` page. |
| `logs:top` / `logs:bottom` | Top (above filter toolbar) / bottom (below log viewer) of `/logs`. |
| `cron:top` / `cron:bottom` | Top / bottom of the `/cron` page. |
| `skills:top` / `skills:bottom` | Top / bottom of the `/skills` page. |
| `config:top` / `config:bottom` | Top / bottom of the `/config` page. |
| `env:top` / `env:bottom` | Top / bottom of the `/env` (Keys) page. |
| `docs:top` / `docs:bottom` | Top (above the iframe) / bottom of `/docs`. |
| `chat:top` / `chat:bottom` | Top / bottom of `/chat` (only active when embedded chat is enabled). |
Example — add a banner card to the top of the Sessions page:
```javascript
function PinnedSessionsBanner() {
return React.createElement(Card, null,
React.createElement(CardContent, { className: "py-2 text-xs" },
"Pinned note injected by my-plugin"),
);
}
window.__HERMES_PLUGINS__.registerSlot("my-plugin", "sessions:top", PinnedSessionsBanner);
```
Combine page-scoped slots with `tab.hidden: true` if your plugin only augments existing pages and doesn't need a sidebar tab of its own.
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