mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-28 06:51:16 +08:00
* 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
196 lines
6.7 KiB
TypeScript
196 lines
6.7 KiB
TypeScript
/**
|
|
* Plugin slot registry.
|
|
*
|
|
* Plugins can inject components into named locations in the app shell
|
|
* (header-left, sidebar, backdrop, etc.) by calling
|
|
* `window.__HERMES_PLUGINS__.registerSlot(pluginName, slotName, Component)`
|
|
* from their JS bundle. Multiple plugins can populate the same slot — they
|
|
* render stacked in registration order.
|
|
*
|
|
* The canonical slot names are documented in `KNOWN_SLOT_NAMES` below. The
|
|
* registry accepts any string so plugin ecosystems can define their own
|
|
* slots; the shell only renders `<PluginSlot name="..." />` for the slots
|
|
* it knows about.
|
|
*/
|
|
|
|
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
|
|
* - `header-banner` — injected below the top nav bar, full-width
|
|
* - `sidebar` — the cockpit sidebar rail (only rendered when
|
|
* `layoutVariant === "cockpit"`)
|
|
* - `pre-main` — rendered above the route outlet (inside `<main>`)
|
|
* - `post-main` — rendered below the route outlet (inside `<main>`)
|
|
* - `footer-left` — replaces the left footer cell content
|
|
* - `footer-right` — replaces the right footer cell content
|
|
* - `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",
|
|
"header-banner",
|
|
"sidebar",
|
|
"pre-main",
|
|
"post-main",
|
|
"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];
|
|
|
|
type SlotListener = () => void;
|
|
|
|
interface SlotEntry {
|
|
plugin: string;
|
|
component: React.ComponentType;
|
|
}
|
|
|
|
/** Map<slotName, SlotEntry[]>. Entries are appended in registration order. */
|
|
const _slotRegistry: Map<string, SlotEntry[]> = new Map();
|
|
const _slotListeners: Set<SlotListener> = new Set();
|
|
|
|
function _notifySlots() {
|
|
for (const fn of _slotListeners) {
|
|
try {
|
|
fn();
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Register a component for a slot. Called by plugin bundles via
|
|
* `window.__HERMES_PLUGINS__.registerSlot(...)`.
|
|
*
|
|
* 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. */
|
|
export function registerSlot(
|
|
plugin: string,
|
|
slot: string,
|
|
component: React.ComponentType,
|
|
): void {
|
|
const existing = _slotRegistry.get(slot) ?? [];
|
|
const filtered = existing.filter((e) => e.plugin !== plugin);
|
|
filtered.push({ plugin, component });
|
|
_slotRegistry.set(slot, filtered);
|
|
_notifySlots();
|
|
}
|
|
|
|
/** Read current entries for a slot. Returns a copy so callers can't mutate
|
|
* registry state. */
|
|
export function getSlotEntries(slot: string): SlotEntry[] {
|
|
return (_slotRegistry.get(slot) ?? []).slice();
|
|
}
|
|
|
|
/** Subscribe to registry changes. Returns an unsubscribe function. */
|
|
export function onSlotRegistered(fn: SlotListener): () => void {
|
|
_slotListeners.add(fn);
|
|
return () => {
|
|
_slotListeners.delete(fn);
|
|
};
|
|
}
|
|
|
|
/** Clear a specific plugin's slot registrations. Useful for HMR /
|
|
* plugin reload flows — not wired in by default. */
|
|
export function unregisterPluginSlots(plugin: string): void {
|
|
let changed = false;
|
|
for (const [slot, entries] of _slotRegistry.entries()) {
|
|
const kept = entries.filter((e) => e.plugin !== plugin);
|
|
if (kept.length !== entries.length) {
|
|
changed = true;
|
|
if (kept.length === 0) _slotRegistry.delete(slot);
|
|
else _slotRegistry.set(slot, kept);
|
|
}
|
|
}
|
|
if (changed) _notifySlots();
|
|
}
|
|
|
|
interface PluginSlotProps {
|
|
/** Slot identifier (e.g. `"sidebar"`, `"header-left"`). */
|
|
name: string;
|
|
/** Optional content rendered when no plugins have claimed the slot.
|
|
* Useful for built-in defaults the plugin would replace. */
|
|
fallback?: React.ReactNode;
|
|
}
|
|
|
|
/** Render all components registered for a given slot, stacked in order.
|
|
*
|
|
* Component re-renders when the slot registry changes so plugins that
|
|
* arrive after initial mount show up without a manual refresh. */
|
|
export function PluginSlot({ name, fallback }: PluginSlotProps) {
|
|
const [entries, setEntries] = useState<SlotEntry[]>(() => getSlotEntries(name));
|
|
|
|
useEffect(() => {
|
|
// Pick up anything registered between the initial `useState` call
|
|
// and the first effect tick, then subscribe for future changes.
|
|
setEntries(getSlotEntries(name));
|
|
const unsub = onSlotRegistered(() => setEntries(getSlotEntries(name)));
|
|
return unsub;
|
|
}, [name]);
|
|
|
|
if (entries.length === 0) {
|
|
return fallback ? React.createElement(Fragment, null, fallback) : null;
|
|
}
|
|
|
|
return React.createElement(
|
|
Fragment,
|
|
null,
|
|
...entries.map((entry) =>
|
|
React.createElement(entry.component, { key: entry.plugin }),
|
|
),
|
|
);
|
|
}
|