Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license

This commit is contained in:
yilsem committed 2026-09-05 13:26:46 +03:00
commit 03634b1ca3
11340 files changed
+3442369

No files matched your search

+64
View File
@@ -0,0 +1,64 @@
import { useSyncExternalStore } from "react";
import { Spinner } from "@nous-research/ui/ui/components/spinner";
import {
getPluginComponent,
getPluginLoadError,
onPluginRegistered,
} from "./registry";
import { useI18n } from "@/i18n";
import { cn } from "@/lib/utils";
import type { Translations } from "@/i18n/types";
/** Renders a plugin tab once its bundle has called `register()`. */
export function PluginPage({ name }: { name: string }) {
const { t } = useI18n();
// Subscribe in render (via useSyncExternalStore) so we never miss
// `register()` if the script loads before a useEffect would run.
const Component = useSyncExternalStore(
(onChange) => onPluginRegistered(onChange),
() => getPluginComponent(name) ?? null,
() => null,
);
const loadError = useSyncExternalStore(
(onChange) => onPluginRegistered(onChange),
() => getPluginLoadError(name) ?? null,
() => null,
);
if (Component) {
return <Component />;
}
if (loadError) {
const message = formatPluginError(loadError, t);
return (
<div
className={cn(
"max-w-lg p-4",
"font-mondwest text-sm tracking-[0.08em] text-text-secondary",
)}
role="alert"
>
{message}
</div>
);
}
return (
<div
className={cn(
"flex items-center gap-2 p-4",
"font-mondwest text-sm tracking-[0.1em] text-text-tertiary",
)}
>
<Spinner className="shrink-0" />
<span>{t.common.loading}</span>
</div>
);
}
function formatPluginError(code: string, t: Translations): string {
if (code === "LOAD_FAILED") return t.common.pluginLoadFailed;
if (code === "NO_REGISTER") return t.common.pluginNotRegistered;
return code;
}
+6
View File
@@ -0,0 +1,6 @@
export { exposePluginSDK, getPluginComponent, onPluginRegistered, getRegisteredCount } from "./registry";
export { PluginPage } from "./PluginPage";
export { usePlugins } from "./usePlugins";
export { PluginSlot, KNOWN_SLOT_NAMES, registerSlot, getSlotEntries, onSlotRegistered, unregisterPluginSlots } from "./slots";
export type { KnownSlotName } from "./slots";
export type { PluginManifest, RegisteredPlugin } from "./types";
+46
View File
@@ -0,0 +1,46 @@
/**
* Smoke test for the plugin SDK surface additions.
*
* Verifies that `exposePluginSDK()` writes the new dialog/toast primitives to
* `window.__HERMES_PLUGIN_SDK__`. Each new key is checked individually so a
* regression in one helper doesn't mask the others.
*
* Companion to the additive PR "feat(plugins): expose Dialog/ConfirmDialog/
* Toast/useToast/useConfirmDelete on the plugin SDK". See issue #50547.
*/
import { beforeEach, describe, expect, it } from "vitest";
import { exposePluginSDK } from "./registry";
describe("plugin SDK dialog/toast surface", () => {
beforeEach(() => {
// Reset window between tests so exposePluginSDK() writes fresh.
(globalThis as unknown as { window: Record<string, unknown> }).window = {
__HERMES_PLUGINS__: undefined,
__HERMES_PLUGIN_SDK__: undefined,
};
});
it("exposes Dialog + subcomponents on components", () => {
exposePluginSDK();
const sdk = (globalThis as unknown as { window: { __HERMES_PLUGIN_SDK__: { components: Record<string, unknown>; hooks: Record<string, unknown> } } }).window.__HERMES_PLUGIN_SDK__;
expect(sdk.components.Dialog).toBeDefined();
expect(sdk.components.DialogContent).toBeDefined();
expect(sdk.components.DialogHeader).toBeDefined();
expect(sdk.components.DialogTitle).toBeDefined();
expect(sdk.components.DialogDescription).toBeDefined();
expect(sdk.components.DialogFooter).toBeDefined();
expect(sdk.components.DialogClose).toBeDefined();
expect(sdk.components.ConfirmDialog).toBeDefined();
expect(sdk.components.Toast).toBeDefined();
});
it("exposes useToast and useConfirmDelete on hooks", () => {
exposePluginSDK();
const sdk = (globalThis as unknown as { window: { __HERMES_PLUGIN_SDK__: { components: Record<string, unknown>; hooks: Record<string, unknown> } } }).window.__HERMES_PLUGIN_SDK__;
expect(typeof sdk.hooks.useToast).toBe("function");
expect(typeof sdk.hooks.useConfirmDelete).toBe("function");
// Original React hooks still present (no accidental removal).
expect(typeof sdk.hooks.useState).toBe("function");
expect(typeof sdk.hooks.useCallback).toBe("function");
});
});
+192
View File
@@ -0,0 +1,192 @@
/**
* Dashboard Plugin SDK + Registry
*
* Exposes React, UI components, hooks, and utilities on the window so
* that plugin bundles can use them without bundling their own copies.
*
* Plugins call window.__HERMES_PLUGINS__.register(name, Component)
* to register their tab component.
*/
import React, {
useState,
useEffect,
useCallback,
useMemo,
useRef,
useContext,
createContext,
} from "react";
import { api, fetchJSON, authedFetch, buildWsUrl, buildWsAuthParam } from "@/lib/api";
import { cn, timeAgo, isoTimeAgo } from "@/lib/utils";
import { Badge } from "@nous-research/ui/ui/components/badge";
import { Button } from "@nous-research/ui/ui/components/button";
import { Checkbox } from "@nous-research/ui/ui/components/checkbox";
import { ConfirmDialog } from "@nous-research/ui/ui/components/confirm-dialog";
import {
Dialog, DialogClose, DialogContent, DialogDescription,
DialogFooter, DialogHeader, DialogTitle,
} from "@nous-research/ui/ui/components/dialog";
import { Toast } from "@nous-research/ui/ui/components/toast";
import { useConfirmDelete } from "@nous-research/ui/hooks/use-confirm-delete";
import { useToast } from "@nous-research/ui/hooks/use-toast";
import { Select, SelectOption } from "@nous-research/ui/ui/components/select";
import { Card, CardHeader, CardTitle, CardContent } from "@nous-research/ui/ui/components/card";
import { Input } from "@nous-research/ui/ui/components/input";
import { Label } from "@nous-research/ui/ui/components/label";
import { Separator } from "@nous-research/ui/ui/components/separator";
import { Tabs, TabsList, TabsTrigger } from "@nous-research/ui/ui/components/tabs";
import { useI18n } from "@/i18n";
import { registerSlot, PluginSlot } from "./slots";
// ---------------------------------------------------------------------------
// Plugin registry — plugins call register() to add their component.
// ---------------------------------------------------------------------------
type RegistryListener = () => void;
const _registered: Map<string, React.ComponentType> = new Map();
const _loadErrors: Map<string, string> = new Map();
const _listeners: Set<RegistryListener> = new Set();
function _notify() {
for (const fn of _listeners) {
try { fn(); } catch { /* ignore */ }
}
}
/** Re-run registry subscribers (e.g. after a plugin script onload, or dev HMR re-inject). */
export function notifyPluginRegistry() {
_notify();
}
/** Register a plugin component. Called by plugin JS bundles. */
function registerPlugin(name: string, component: React.ComponentType) {
_loadErrors.delete(name);
_registered.set(name, component);
_notify();
}
/** Get a registered component by plugin name. */
export function getPluginComponent(name: string): React.ComponentType | undefined {
return _registered.get(name);
}
export function getPluginLoadError(name: string): string | undefined {
return _loadErrors.get(name);
}
export function setPluginLoadError(name: string, message: string) {
_loadErrors.set(name, message);
_notify();
}
/** Subscribe to registry changes (returns unsubscribe fn). */
export function onPluginRegistered(fn: RegistryListener): () => void {
_listeners.add(fn);
return () => _listeners.delete(fn);
}
/** Get current count of registered plugins. */
export function getRegisteredCount(): number {
return _registered.size;
}
// ---------------------------------------------------------------------------
// Expose SDK + registry on window
// ---------------------------------------------------------------------------
/**
* Version of the plugin SDK contract (see ``plugins/sdk.d.ts``). Bump the
* major on any backwards-incompatible change to the exposed surface;
* additive changes (new optional fields / helpers) don't require a bump.
* Exposed at runtime as ``window.__HERMES_PLUGIN_SDK__.sdkVersion`` so a
* plugin (or a future host-side compatibility gate) can read it.
*/
export const SDK_CONTRACT_VERSION = "1.1.0";
// Window globals for the plugin SDK are declared in ``plugins/sdk.d.ts`` —
// the single source of truth for the public contract. Don't redeclare them
// here (duplicate ambient declarations with differing modifiers conflict).
export function exposePluginSDK() {
window.__HERMES_PLUGINS__ = {
register: registerPlugin,
registerSlot,
};
window.__HERMES_PLUGIN_SDK__ = {
// Contract version of the plugin SDK surface (see plugins/sdk.d.ts).
// Bump on backwards-incompatible changes; additive changes don't need it.
sdkVersion: SDK_CONTRACT_VERSION,
// React core — plugins use these instead of importing react
React,
hooks: {
useState,
useEffect,
useCallback,
useMemo,
useRef,
useContext,
createContext,
// useToast returns { showToast, toast } where toast is the current
// visible toast (or null) and showToast(message, 'success'|'error')
// replaces it. useConfirmDelete<TId>({ onDelete }) returns the state
// machine (requestDelete / confirm / cancel / isOpen / isDeleting /
// pendingId) for single-id delete confirmations.
useToast,
useConfirmDelete,
},
// Hermes API client
api,
// Raw fetchJSON for plugin-specific JSON endpoints
fetchJSON,
// Authenticated fetch for non-JSON endpoints (uploads / blob downloads).
// Handles loopback-token vs gated-cookie auth so plugins never read
// window.__HERMES_SESSION_TOKEN__ directly.
authedFetch,
// Build a ws(s):// URL with the correct auth param for the active mode
// (single-use ticket in gated mode, token in loopback). Use this for any
// plugin WebSocket instead of hand-assembling the URL.
buildWsUrl,
// Lower-level: resolve just the [authParamName, authParamValue] pair, for
// plugins that need to build the WS URL themselves.
buildWsAuthParam,
// UI components — Nous DS where available, shadcn/ui primitives elsewhere.
components: {
Card,
CardHeader,
CardTitle,
CardContent,
Badge,
Button,
Checkbox,
ConfirmDialog,
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
Input,
Label,
Select,
SelectOption,
Separator,
Tabs,
TabsList,
TabsTrigger,
Toast,
PluginSlot,
},
// Utilities
utils: { cn, timeAgo, isoTimeAgo },
// Hooks
useI18n,
};
}
+184
View File
@@ -0,0 +1,184 @@
/**
* Hermes Dashboard Plugin SDK — typed contract (SPIKE)
* ====================================================
*
* This is the public type surface for ``window.__HERMES_PLUGIN_SDK__`` and
* ``window.__HERMES_PLUGINS__``, the globals the dashboard host exposes to
* plugin bundles (see ``web/src/plugins/registry.ts::exposePluginSDK``).
*
* STATUS: spike. This file documents the contract and gives plugin authors
* (in-repo IIFEs and external bundles alike) editor types without bundling
* their own copies of React / the API client. It is intentionally a
* hand-authored ambient declaration rather than ``typeof
* window.__HERMES_PLUGIN_SDK__`` because:
* 1. The runtime object is assembled from many internal modules
* (``@/lib/api``, ``@nous-research/ui``, …). Deriving the type would
* leak those internal import paths into the public contract and couple
* external plugins to the host's internal module layout.
* 2. A hand-authored contract is the *versioned API boundary* — changing
* it is a deliberate act, visible in review, not an accidental
* consequence of refactoring an internal helper.
*
* Versioning: bump ``HermesPluginSDK["sdkVersion"]`` (and the
* ``SDK_CONTRACT_VERSION`` const the host exposes) on any
* backwards-incompatible change to this surface. Additive changes
* (new optional fields, new helpers) don't require a major bump.
*
* OPEN QUESTIONS for productionising this spike (do not block the auth fix):
* - Ship as a published ``@hermes/dashboard-plugin-sdk`` types package, or
* keep in-repo and copy into external plugin repos?
* - Should the host assert at runtime that a plugin's declared
* ``manifest.sdk_version`` is compatible before executing it?
* - The ``components`` map is typed loosely as ``Record<string,
* ComponentType>`` here; do we want exact per-component prop types
* (pulls @nous-research/ui types into the contract) or is the loose
* shape the right boundary for external authors?
*/
import type { ComponentType } from "react";
// ---------------------------------------------------------------------------
// Auth-relevant helpers (the surface this PR adds/sanctions)
// ---------------------------------------------------------------------------
/**
* JSON ``fetch`` for dashboard ``/api/...`` endpoints. Handles auth in both
* modes (loopback session-token header / gated cookie), throws
* ``Error("<status>: <body>")`` on non-2xx, and triggers the global
* 401 → /login redirect in gated mode. Use for all JSON plugin endpoints.
*/
export type FetchJSON = <T = unknown>(
url: string,
init?: RequestInit,
options?: { allowUnauthorized?: boolean },
) => Promise<T>;
/**
* Authenticated ``fetch`` for NON-JSON endpoints (uploads via ``FormData``,
* binary/blob downloads). Same auth handling as ``fetchJSON`` but returns
* the raw ``Response``, does not parse, does not throw on non-2xx, and does
* not run the 401 redirect. Plugins MUST use this (or ``fetchJSON``) instead
* of calling ``fetch`` with a hand-read ``window.__HERMES_SESSION_TOKEN__``.
*/
export type AuthedFetch = (url: string, init?: RequestInit) => Promise<Response>;
/**
* Build an absolute ``ws(s)://`` URL for a dashboard WebSocket endpoint with
* the correct auth query param for the active mode (single-use ``ticket`` in
* gated OAuth mode, ``token`` in loopback). Plugins MUST use this for any
* WebSocket instead of hand-assembling the URL + reading the session token.
*/
export type BuildWsUrl = (
path: string,
params?: Record<string, string>,
) => Promise<string>;
/** Lower-level: just the ``[authParamName, authParamValue]`` pair. */
export type BuildWsAuthParam = () => Promise<[string, string]>;
// ---------------------------------------------------------------------------
// Registry surface (window.__HERMES_PLUGINS__)
// ---------------------------------------------------------------------------
export interface PluginRegistry {
/** Register the plugin's main tab component by manifest name. */
register(name: string, component: ComponentType<Record<string, never>>): void;
/** Register a component into a named host slot. */
registerSlot(slot: string, name: string, component: ComponentType): void;
}
// ---------------------------------------------------------------------------
// SDK surface (window.__HERMES_PLUGIN_SDK__)
// ---------------------------------------------------------------------------
export interface HermesPluginSDK {
/** Contract version of this SDK surface (see SDK_CONTRACT_VERSION). */
readonly sdkVersion: string;
/** React core — use instead of importing/bundling react. */
React: typeof import("react").default;
hooks: {
useState: typeof import("react").useState;
useEffect: typeof import("react").useEffect;
useCallback: typeof import("react").useCallback;
useMemo: typeof import("react").useMemo;
useRef: typeof import("react").useRef;
useContext: typeof import("react").useContext;
createContext: typeof import("react").createContext;
/**
* Toast feedback. Returns ``{ showToast, toast }`` where ``toast`` is the
* current visible toast (or null) and ``showToast(message, 'success' | 'error')``
* replaces it. Mount the ``Toast`` component (also on ``components``) somewhere
* in your plugin's tree to render the visible toast.
*/
useToast: () => {
showToast: (message: string, type: "success" | "error") => void;
toast: { message: string; type: "success" | "error" } | null;
};
/**
* Single-id delete-confirm state machine. Pass ``onDelete(id)`` (an async
* function). Returns ``{ requestDelete, confirm, cancel, isOpen, isDeleting,
* pendingId }``. Pair with the ``ConfirmDialog`` primitive (passed as
* ``open={isOpen}`` etc.) or any custom dialog.
*/
useConfirmDelete: <TId,>(opts: { onDelete: (id: TId) => Promise<void> }) => {
requestDelete: (id: TId) => void;
confirm: () => Promise<void>;
cancel: () => void;
isOpen: boolean;
isDeleting: boolean;
pendingId: TId | null;
};
};
/**
* Typed convenience client for core dashboard endpoints. Typed permissively
* at the boundary (methods vary in arity and return type — most return
* ``Promise<T>``, a few return a URL string synchronously); plugins call the
* specific methods they need. See ``web/src/lib/api.ts`` for the concrete shape.
*/
api: Record<string, (...args: never[]) => unknown>;
/** JSON fetch with host auth handling. */
fetchJSON: FetchJSON;
/** Authenticated raw fetch for uploads / blob downloads. */
authedFetch: AuthedFetch;
/** Build an auth'd WebSocket URL for the active mode. */
buildWsUrl: BuildWsUrl;
/** Resolve just the WS auth query-param pair. */
buildWsAuthParam: BuildWsAuthParam;
/**
* Shared UI primitives (Nous DS / shadcn). Typed permissively at the
* boundary: the host's concrete components (some of which require props like
* ``active``/``value``/``name``) must be assignable here, and external plugin
* authors render them dynamically without the host's internal prop types.
* ``ComponentType<never>`` accepts any component regardless of its prop
* requirements (props are contravariant).
*/
components: Record<string, ComponentType<never>>;
utils: {
cn: (...classes: Array<string | false | null | undefined>) => string;
/** Relative-time formatter. Accepts an epoch-ms number. */
timeAgo: (ts: number) => string;
/** Relative-time formatter for an ISO-8601 string. */
isoTimeAgo: (iso: string) => string;
};
/**
* i18n hook. Returns the host's i18n context value; typed loosely at the
* boundary so the contract doesn't couple to the host's internal
* ``I18nContextValue`` shape. Plugins typically call ``useI18n().t(...)``.
*/
useI18n: () => unknown;
}
declare global {
interface Window {
__HERMES_PLUGIN_SDK__?: HermesPluginSDK;
__HERMES_PLUGINS__?: PluginRegistry;
}
}
export {};
+200
View File
@@ -0,0 +1,200 @@
/**
* 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` — optional full-viewport background decoration;
* mounted behind shell chrome at z-0
* - `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
* - `plugins:top` — top of /plugins page
* - `plugins:bottom` — bottom of /plugins 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",
"plugins:top",
"plugins: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 }),
),
);
}
+37
View File
@@ -0,0 +1,37 @@
/** Types for the dashboard plugin system. */
import type { ComponentType } from "react";
export interface PluginManifest {
name: string;
label: string;
description: string;
icon: string;
version: string;
tab: {
path: string;
/** "end", "after:<pathSegment>", "before:<pathSegment>" (e.g. "after:skills" → after `/skills`) */
position?: string;
/** When set to a built-in route path, this plugin replaces that page instead of adding a new tab. */
override?: string;
/** When true, the plugin may register without a sidebar tab (slot-only, etc.). */
hidden?: boolean;
};
/** Declared for discovery; actual slots use registerSlot in the plugin bundle. */
slots?: string[];
entry: string;
css?: string | null;
has_api: boolean;
/**
* Optional Subresource Integrity hash (e.g. "sha384-..."). When set,
* the browser will refuse to execute the plugin bundle if its hash
* does not match. This protects against tampered plugin delivery.
*/
integrity?: string;
source: string;
}
export interface RegisteredPlugin {
manifest: PluginManifest;
component: ComponentType;
}
+144
View File
@@ -0,0 +1,144 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import {
getCachedManifests,
cacheManifests,
canSeedLoadedFromCache,
MANIFEST_CACHE_KEY,
} from "./usePlugins";
import type { PluginManifest } from "./types";
function makeStorage(): Storage {
const store = new Map<string, string>();
return {
getItem(key: string) {
return store.get(key) ?? null;
},
setItem(key: string, value: string) {
store.set(key, value);
},
removeItem(key: string) {
store.delete(key);
},
clear() {
store.clear();
},
get length() {
return store.size;
},
key(index: number) {
return Array.from(store.keys())[index] ?? null;
},
} as Storage;
}
const exampleManifest: PluginManifest = {
name: "test",
label: "Test",
description: "A test plugin",
icon: "Puzzle",
version: "1.0.0",
tab: { path: "/test" },
entry: "index.js",
has_api: false,
source: "local",
};
describe("plugin manifest cache helpers", () => {
let storage: Storage;
beforeEach(() => {
storage = makeStorage();
vi.stubGlobal("sessionStorage", storage);
});
afterEach(() => {
vi.unstubAllGlobals();
});
it("getCachedManifests returns null when nothing is cached", () => {
expect(getCachedManifests()).toBeNull();
});
it("getCachedManifests returns null for invalid JSON", () => {
storage.setItem(MANIFEST_CACHE_KEY, "not-json");
expect(getCachedManifests()).toBeNull();
});
it("getCachedManifests returns null for non-array JSON", () => {
storage.setItem(MANIFEST_CACHE_KEY, JSON.stringify({ foo: "bar" }));
expect(getCachedManifests()).toBeNull();
});
it("getCachedManifests returns null for scalar JSON", () => {
storage.setItem(MANIFEST_CACHE_KEY, JSON.stringify(42));
expect(getCachedManifests()).toBeNull();
});
it("getCachedManifests returns a valid manifest array", () => {
const list: PluginManifest[] = [exampleManifest];
cacheManifests(list);
expect(getCachedManifests()).toEqual(list);
});
it("cacheManifests overwrites a previous cache on refresh", () => {
const first: PluginManifest[] = [exampleManifest];
cacheManifests(first);
expect(getCachedManifests()).toEqual(first);
const second: PluginManifest[] = [
{ ...exampleManifest, name: "updated", label: "Updated" },
];
cacheManifests(second);
expect(getCachedManifests()).toEqual(second);
});
it("cacheManifests swallows storage errors", () => {
const badStorage = makeStorage();
badStorage.setItem = () => {
throw new Error("QuotaExceededError");
};
vi.stubGlobal("sessionStorage", badStorage);
expect(() => cacheManifests([exampleManifest])).not.toThrow();
});
});
describe("canSeedLoadedFromCache (loading seed gate)", () => {
it("returns false when there is no cache (first visit keeps loading=true)", () => {
expect(canSeedLoadedFromCache(null)).toBe(false);
});
it("returns true for an empty cached list", () => {
expect(canSeedLoadedFromCache([])).toBe(true);
});
it("returns true when no cached manifest overrides /chat", () => {
const list: PluginManifest[] = [
exampleManifest,
{
...exampleManifest,
name: "other",
tab: { path: "/other", override: "/skills" },
},
];
expect(canSeedLoadedFromCache(list)).toBe(true);
});
it("returns false when a cached manifest overrides /chat — loading must stay true so App.tsx's pluginsLoading gate keeps the persistent chat host unmounted", () => {
const list: PluginManifest[] = [
exampleManifest,
{
...exampleManifest,
name: "chat-replacer",
tab: { path: "/chat-alt", override: "/chat" },
},
];
expect(canSeedLoadedFromCache(list)).toBe(false);
});
it("tolerates malformed cached entries missing a tab object", () => {
const malformed = [
{ ...exampleManifest, tab: undefined },
] as unknown as PluginManifest[];
expect(canSeedLoadedFromCache(malformed)).toBe(true);
});
});
+192
View File
@@ -0,0 +1,192 @@
/**
* usePlugins hook — discovers and loads dashboard plugins.
*
* 1. Fetches plugin manifests from GET /api/dashboard/plugins
* 2. Injects CSS <link> tags for plugins that declare css
* 3. Loads plugin JS bundles via <script> tags
* 4. Waits for plugins to call register() and resolves them
*/
import { useState, useEffect, useRef } from "react";
import { api, HERMES_BASE_PATH } from "@/lib/api";
import type { PluginManifest, RegisteredPlugin } from "./types";
import {
getPluginComponent,
onPluginRegistered,
notifyPluginRegistry,
setPluginLoadError,
} from "./registry";
export const MANIFEST_CACHE_KEY = "hermes:plugin-manifests";
export function getCachedManifests(): PluginManifest[] | null {
try {
const raw = sessionStorage.getItem(MANIFEST_CACHE_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? (parsed as PluginManifest[]) : null;
} catch {
return null;
}
}
export function cacheManifests(manifests: PluginManifest[]): void {
try {
sessionStorage.setItem(MANIFEST_CACHE_KEY, JSON.stringify(manifests));
} catch {
// sessionStorage unavailable (private browsing, storage full, etc.)
}
}
/**
* Whether it is safe to skip the initial plugin-loading gate for a set of
* cached manifests.
*
* App.tsx waits on `pluginsLoading` before mounting the persistent ChatPage
* host: if a plugin overrides /chat (`tab.override === "/chat"`), mounting
* the built-in chat first would spawn a PTY and then yank it out from under
* the user when the plugin resolves. That gate is load-bearing — so we may
* only seed `loading = false` from the cache when no cached manifest
* declares a /chat override. Manifests are still seeded either way; only
* the loading flag stays conservative.
*/
export function canSeedLoadedFromCache(
cached: PluginManifest[] | null,
): boolean {
if (cached === null) return false;
return !cached.some((m) => m.tab?.override === "/chat");
}
export function usePlugins() {
// Lazy initialisers run once at mount — safe to read sessionStorage here.
// This avoids the "cannot access ref during render" lint error that would
// occur if we stored the cached value in a useRef and read .current in the
// useState initial value expression.
const [manifests, setManifests] = useState<PluginManifest[]>(
() => getCachedManifests() ?? [],
);
const [plugins, setPlugins] = useState<RegisteredPlugin[]>([]);
// Start loading=false when the cache has manifests so plugin routes are
// registered synchronously on the first render after a refresh.
// The catch-all in App.tsx is only a safety net for the very first visit
// (no cache yet). On subsequent visits this flag starts false immediately.
//
// Exception: if any cached manifest overrides /chat we must keep
// loading=true — App.tsx's pluginsLoading gate around the persistent
// ChatPage host is load-bearing (see canSeedLoadedFromCache).
const [loading, setLoading] = useState<boolean>(
() => !canSeedLoadedFromCache(getCachedManifests()),
);
const loadedScripts = useRef<Set<string>>(new Set());
// Always re-fetch in the background to keep the cache fresh.
// This handles: new plugins added, plugins removed, manifest changes.
// setManifests(list) will update routes if the server list differs from cache.
useEffect(() => {
api
.getPlugins()
.then((list) => {
cacheManifests(list);
setManifests(list);
if (list.length === 0) setLoading(false);
})
.catch(() => setLoading(false));
}, []);
// Load plugin assets when manifests arrive.
useEffect(() => {
if (manifests.length === 0) return;
const injectedScripts: HTMLScriptElement[] = [];
for (const manifest of manifests) {
// Inject CSS if specified.
if (manifest.css) {
const cssUrl = `${HERMES_BASE_PATH}/dashboard-plugins/${manifest.name}/${manifest.css}`;
if (!document.querySelector(`link[href="${cssUrl}"]`)) {
const link = document.createElement("link");
link.rel = "stylesheet";
link.href = cssUrl;
document.head.appendChild(link);
}
}
// Load JS bundle. In dev, cache-bust so Vite HMR can clear the
// in-memory registry while the browser would otherwise never
// re-execute a previously cached <script> URL.
const baseUrl = `${HERMES_BASE_PATH}/dashboard-plugins/${manifest.name}/${manifest.entry}`;
const scriptSrc = import.meta.env.DEV
? `${baseUrl}?hermes_dv=${Date.now()}`
: baseUrl;
if (!import.meta.env.DEV) {
if (loadedScripts.current.has(baseUrl)) continue;
loadedScripts.current.add(baseUrl);
}
const script = document.createElement("script");
script.setAttribute("data-hermes-plugin", manifest.name);
script.src = scriptSrc;
script.async = true;
// SRI integrity verification — defense against compromised plugin
// delivery. Plugin manifests can declare an integrity hash
// (e.g. "sha384-...") which the browser verifies before executing.
// Without this, a man-in-the-middle or compromised plugin server
// can substitute the JS bundle silently. Opt-in: when no integrity
// is declared in the manifest, behavior is unchanged.
if (manifest.integrity && typeof manifest.integrity === "string") {
script.integrity = manifest.integrity;
script.crossOrigin = "anonymous";
}
script.onerror = () => {
setPluginLoadError(manifest.name, "LOAD_FAILED");
console.warn(
`[plugins] Failed to load ${manifest.name} from ${scriptSrc} (open Network tab)`,
);
};
script.onload = () => {
notifyPluginRegistry();
queueMicrotask(() => {
if (getPluginComponent(manifest.name)) return;
setPluginLoadError(manifest.name, "NO_REGISTER");
});
};
document.body.appendChild(script);
injectedScripts.push(script);
}
// Give plugins a moment to load and register, then stop loading state.
const timeout = setTimeout(() => setLoading(false), 2000);
return () => {
clearTimeout(timeout);
if (import.meta.env.DEV) {
for (const el of injectedScripts) {
el.remove();
}
}
};
}, [manifests]);
// Listen for plugin registrations and resolve them against manifests.
useEffect(() => {
function resolvePlugins() {
const resolved: RegisteredPlugin[] = [];
for (const manifest of manifests) {
const component = getPluginComponent(manifest.name);
if (component) {
resolved.push({ manifest, component });
}
}
setPlugins(resolved);
// If all plugins registered, stop loading early.
if (resolved.length === manifests.length && manifests.length > 0) {
setLoading(false);
}
}
resolvePlugins();
const unsub = onPluginRegistered(resolvePlugins);
return unsub;
}, [manifests]);
return { plugins, manifests, loading };
}