Files
hermes-agent/web/src/plugins/usePlugins.ts
T
kshitij c0c45ab68a fix(plugins): keep loading gate when cached manifests include a /chat override
The sessionStorage seed set loading=false whenever any cache existed, which
defeats App.tsx's load-bearing pluginsLoading gate: with a cached manifest
that declares tab.override === "/chat", the persistent ChatPage host must
NOT mount before plugins resolve, or it spawns a PTY and gets yanked when
the override plugin takes over the route.

Seed loading=false from the cache only when no cached manifest overrides
/chat (canSeedLoadedFromCache); manifests are still seeded either way so
plugin routes register synchronously on refresh. Adds focused tests for
the gate, including the /chat-override case.
2026-08-03 18:48:04 +05:30

193 lines
7.0 KiB
TypeScript

/**
* 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 };
}