// The dashboard can be served either at the root of its host (e.g. // https://kanban.tilos.com/) or under a URL prefix when reverse-proxied // (e.g. https://mission-control.tilos.com/hermes/). The Python backend // injects ``window.__HERMES_BASE_PATH__`` into index.html based on the // incoming ``X-Forwarded-Prefix`` header so the SPA can address its own // ``/api/...`` and ``/dashboard-plugins/...`` URLs correctly without a // rebuild. Empty string means "served at root". function readBasePath(): string { if (typeof window === "undefined") return ""; const raw = window.__HERMES_BASE_PATH__ ?? ""; if (!raw) return ""; // Normalise: ensure leading slash, strip trailing slash. const withLead = raw.startsWith("/") ? raw : `/${raw}`; return withLead.replace(/\/+$/, ""); } export const HERMES_BASE_PATH = readBasePath(); const BASE = HERMES_BASE_PATH; import type { DashboardTheme } from "@/themes/types"; // Ephemeral session token for protected endpoints. // Injected into index.html by the server — never fetched via API. declare global { interface Window { __HERMES_SESSION_TOKEN__?: string; __HERMES_BASE_PATH__?: string; /** Server-injected flag: ``true`` when the dashboard's OAuth gate is * engaged (public bind, no ``--insecure``). Toggles the SPA's * WS-upgrade path from legacy ``?token=`` to single-use ``?ticket=`` * fetched via :func:`getWsTicket`. */ __HERMES_AUTH_REQUIRED__?: boolean; } } let _sessionToken: string | null = null; const SESSION_HEADER = "X-Hermes-Session-Token"; function setSessionHeader(headers: Headers, token: string): void { if (!headers.has(SESSION_HEADER)) { headers.set(SESSION_HEADER, token); } } export async function fetchJSON( url: string, init?: RequestInit, options?: FetchJSONOptions, ): Promise { // Inject the session token into all /api/ requests. const headers = new Headers(init?.headers); const token = window.__HERMES_SESSION_TOKEN__; if (token) { setSessionHeader(headers, token); } const res = await fetch(`${BASE}${url}`, { ...init, headers, // ``credentials: 'include'`` so the cookie-auth path (gated mode) works // for any fetch routed through here. Loopback mode is unaffected — the // server doesn't read cookies and the legacy session-token header is // already attached above. credentials: init?.credentials ?? "include", }); if (res.status === 401) { // Phase 6: the gated middleware emits a structured envelope so the // SPA can full-page-navigate to /login on session expiry. Parse it, // and only redirect on the known error codes — domain-level 401s // (e.g. "you don't have permission to read this monitor") bubble // up as regular errors so callers can handle them. let body: { error?: string; login_url?: string } = {}; try { body = await res.clone().json(); } catch { /* non-JSON 401 — let it fall through */ } if ( (body.error === "unauthenticated" || body.error === "session_expired") && body.login_url ) { // Preserve where the user was so /auth/callback can land them back // after re-auth. The gate's login_url already carries a ``next=`` // built from the request path, but the SPA may be deep inside a // SPA route the gate never saw — e.g. a hash route or a client-side // /sessions/ deep link. Save the current location as a // fallback the post-login handler can read. try { sessionStorage.setItem( "hermes.lastLocation", window.location.pathname + window.location.search, ); } catch { /* SSR / privacy mode — ignore */ } window.location.assign(body.login_url); // Never resolve — the page is about to unload. return new Promise(() => {}); } // Loopback mode: ``_SESSION_TOKEN`` rotates on every server restart // (``hermes update``, ``hermes gateway restart``, etc.). A tab kept // open across the restart holds the OLD token in // ``window.__HERMES_SESSION_TOKEN__`` from the previous HTML render, // so every fetch returns 401. The HTML is served ``Cache-Control: // no-store`` so a reload picks up the freshly-injected token. Trigger // that reload once on the first stale-token 401 — gated mode is // handled above, so reaching here in gated mode means a real // middleware failure that should not reload-loop. if (!window.__HERMES_AUTH_REQUIRED__ && !options?.allowUnauthorized) { let alreadyReloaded = false; try { alreadyReloaded = sessionStorage.getItem("hermes.tokenReloadAttempted") === "1"; } catch { /* SSR / privacy mode — fall through to throw */ } if (!alreadyReloaded) { try { sessionStorage.setItem("hermes.tokenReloadAttempted", "1"); } catch { /* SSR / privacy mode — best effort */ } window.location.reload(); return new Promise(() => {}); } } } if (res.ok) { // Clear the stale-token reload guard: a successful 2xx proves the // current ``window.__HERMES_SESSION_TOKEN__`` is valid, so the next // 401 — if any — should be allowed to trigger its own reload cycle. try { sessionStorage.removeItem("hermes.tokenReloadAttempted"); } catch { /* SSR / privacy mode — ignore */ } } if (!res.ok) { const text = await res.text().catch(() => res.statusText); throw new Error(`${res.status}: ${text}`); } return res.json(); } /** Encode a plugin registry key for URL paths (preserves `/` segment separators). */ function pluginPath(name: string): string { return name.split("/").map(encodeURIComponent).join("/"); } async function getSessionToken(): Promise { if (_sessionToken) return _sessionToken; const injected = window.__HERMES_SESSION_TOKEN__; if (injected) { _sessionToken = injected; return _sessionToken; } throw new Error("Session token not available — page must be served by the Hermes dashboard server"); } /** * Fetch a single-use ticket for a WebSocket upgrade in gated mode. * * The dashboard's gated-mode WS auth (``hermes_cli.web_server._ws_auth_ok``) * rejects the legacy ``?token=<_SESSION_TOKEN>`` path and only accepts * ``?ticket=`` consumed against the in-memory ticket store. Browsers * can't set ``Authorization`` on a WS upgrade, so this round-trip via the * authenticated REST endpoint is the bridge from cookie auth to WS auth. * * Tickets are single-use and TTL=30s — every WS connect attempt must * fetch a fresh ticket. */ export async function getWsTicket(): Promise<{ ticket: string; ttl_seconds: number }> { const res = await fetch(`${BASE}/api/auth/ws-ticket`, { method: "POST", credentials: "include", }); if (!res.ok) { throw new Error(`/api/auth/ws-ticket: HTTP ${res.status}`); } return res.json(); } /** * Resolve the auth query-param pair (``[name, value]``) for a WebSocket * connect. In gated mode mints a fresh single-use ticket; in loopback * mode returns the injected session token. */ export async function buildWsAuthParam(): Promise<[string, string]> { if (window.__HERMES_AUTH_REQUIRED__) { const { ticket } = await getWsTicket(); return ["ticket", ticket]; } const token = window.__HERMES_SESSION_TOKEN__ ?? ""; return ["token", token]; } export const api = { getStatus: () => fetchJSON("/api/status"), /** * Identity probe for the dashboard auth gate (Phase 7). * * Returns the verified Session as JSON when gated mode is active and a * valid cookie is attached. Loopback mode is unaffected — the endpoint * still exists but is never useful there (no Session, no cookie). The * AuthWidget component swallows 401s from this call: if the gate isn't * engaged, /api/auth/me returns 401 and the widget renders nothing. * * ``allowUnauthorized`` is load-bearing: in loopback mode this endpoint * 401s by design, and fetchJSON's default loopback behaviour treats a * 401 as a rotated session token and full-page-reloads to pick up a * fresh one. Because every *other* dashboard request succeeds (and so * clears the one-shot reload guard), that turns this expected 401 into * an infinite reload loop. Opting out keeps the 401 a plain throw the * widget can catch. */ getAuthMe: () => fetchJSON("/api/auth/me", undefined, { allowUnauthorized: true, }), logout: () => fetch(`${BASE}/auth/logout`, { method: "POST", credentials: "include", }).then((r) => { // /auth/logout returns 302 → /login. Follow that with a full-page // navigation rather than letting fetch() opaquely consume the // redirect — the SPA needs to leave the protected area. window.location.assign("/login"); return r; }), getSessions: (limit = 20, offset = 0) => fetchJSON(`/api/sessions?limit=${limit}&offset=${offset}`), getSessionMessages: (id: string) => fetchJSON(`/api/sessions/${encodeURIComponent(id)}/messages`), getSessionLatestDescendant: (id: string) => fetchJSON( `/api/sessions/${encodeURIComponent(id)}/latest-descendant`, ), deleteSession: (id: string) => fetchJSON<{ ok: boolean }>(`/api/sessions/${encodeURIComponent(id)}`, { method: "DELETE", }), renameSession: (id: string, title: string) => fetchJSON<{ ok: boolean; title: string }>( `/api/sessions/${encodeURIComponent(id)}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ title }), }, ), getSessionStats: () => fetchJSON("/api/sessions/stats"), exportSessionUrl: (id: string) => `/api/sessions/${encodeURIComponent(id)}/export`, pruneSessions: (older_than_days: number, source?: string) => fetchJSON<{ ok: boolean; removed: number }>("/api/sessions/prune", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ older_than_days, source }), }), getLogs: (params: { file?: string; lines?: number; level?: string; component?: string }) => { const qs = new URLSearchParams(); if (params.file) qs.set("file", params.file); if (params.lines) qs.set("lines", String(params.lines)); if (params.level && params.level !== "ALL") qs.set("level", params.level); if (params.component && params.component !== "all") qs.set("component", params.component); return fetchJSON(`/api/logs?${qs.toString()}`); }, getAnalytics: (days: number) => fetchJSON(`/api/analytics/usage?days=${days}`), getModelsAnalytics: (days: number) => fetchJSON(`/api/analytics/models?days=${days}`), getConfig: () => fetchJSON>("/api/config"), getDefaults: () => fetchJSON>("/api/config/defaults"), getSchema: () => fetchJSON<{ fields: Record; category_order: string[] }>("/api/config/schema"), getModelInfo: () => fetchJSON("/api/model/info"), getModelOptions: () => fetchJSON("/api/model/options"), getAuxiliaryModels: () => fetchJSON("/api/model/auxiliary"), setModelAssignment: (body: ModelAssignmentRequest) => fetchJSON("/api/model/set", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }), saveConfig: (config: Record) => fetchJSON<{ ok: boolean }>("/api/config", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ config }), }), getConfigRaw: () => fetchJSON<{ yaml: string }>("/api/config/raw"), saveConfigRaw: (yaml_text: string) => fetchJSON<{ ok: boolean }>("/api/config/raw", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ yaml_text }), }), getEnvVars: () => fetchJSON>("/api/env"), setEnvVar: (key: string, value: string) => fetchJSON<{ ok: boolean }>("/api/env", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ key, value }), }), deleteEnvVar: (key: string) => fetchJSON<{ ok: boolean }>("/api/env", { method: "DELETE", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ key }), }), revealEnvVar: async (key: string) => { const token = await getSessionToken(); return fetchJSON<{ key: string; value: string }>("/api/env/reveal", { method: "POST", headers: { "Content-Type": "application/json", [SESSION_HEADER]: token, }, body: JSON.stringify({ key }), }); }, // Cron jobs getCronJobs: (profile = "all") => fetchJSON(`/api/cron/jobs?profile=${encodeURIComponent(profile)}`), createCronJob: (job: { prompt: string; schedule: string; name?: string; deliver?: string }, profile = "default") => fetchJSON(`/api/cron/jobs?profile=${encodeURIComponent(profile)}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(job), }), pauseCronJob: (id: string, profile = "default") => fetchJSON(`/api/cron/jobs/${encodeURIComponent(id)}/pause?profile=${encodeURIComponent(profile)}`, { method: "POST" }), updateCronJob: ( id: string, updates: { prompt?: string; schedule?: string; name?: string; deliver?: string }, profile = "default", ) => fetchJSON( `/api/cron/jobs/${encodeURIComponent(id)}?profile=${encodeURIComponent(profile)}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ updates }), }, ), resumeCronJob: (id: string, profile = "default") => fetchJSON(`/api/cron/jobs/${encodeURIComponent(id)}/resume?profile=${encodeURIComponent(profile)}`, { method: "POST" }), triggerCronJob: (id: string, profile = "default") => fetchJSON(`/api/cron/jobs/${encodeURIComponent(id)}/trigger?profile=${encodeURIComponent(profile)}`, { method: "POST" }), deleteCronJob: (id: string, profile = "default") => fetchJSON<{ ok: boolean }>(`/api/cron/jobs/${encodeURIComponent(id)}?profile=${encodeURIComponent(profile)}`, { method: "DELETE" }), // Profiles (minimal) getProfiles: () => fetchJSON<{ profiles: ProfileInfo[] }>("/api/profiles"), createProfile: (body: { name: string; clone_from_default: boolean }) => fetchJSON<{ ok: boolean; name: string; path: string }>("/api/profiles", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }), renameProfile: (name: string, newName: string) => fetchJSON<{ ok: boolean; name: string; path: string }>( `/api/profiles/${encodeURIComponent(name)}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ new_name: newName }), }, ), deleteProfile: (name: string) => fetchJSON<{ ok: boolean }>( `/api/profiles/${encodeURIComponent(name)}`, { method: "DELETE" }, ), getProfileSetupCommand: (name: string) => fetchJSON<{ command: string }>( `/api/profiles/${encodeURIComponent(name)}/setup-command`, ), getProfileSoul: (name: string) => fetchJSON<{ content: string; exists: boolean }>( `/api/profiles/${encodeURIComponent(name)}/soul`, ), updateProfileSoul: (name: string, content: string) => fetchJSON<{ ok: boolean }>( `/api/profiles/${encodeURIComponent(name)}/soul`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ content }), }, ), // Skills & Toolsets getSkills: () => fetchJSON("/api/skills"), toggleSkill: (name: string, enabled: boolean) => fetchJSON<{ ok: boolean }>("/api/skills/toggle", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name, enabled }), }), getToolsets: () => fetchJSON("/api/tools/toolsets"), // Session search (FTS5) searchSessions: (q: string) => fetchJSON(`/api/sessions/search?q=${encodeURIComponent(q)}`), // OAuth provider management getOAuthProviders: () => fetchJSON("/api/providers/oauth"), disconnectOAuthProvider: async (providerId: string) => { const token = await getSessionToken(); return fetchJSON<{ ok: boolean; provider: string }>( `/api/providers/oauth/${encodeURIComponent(providerId)}`, { method: "DELETE", headers: { [SESSION_HEADER]: token }, }, ); }, startOAuthLogin: async (providerId: string) => { const token = await getSessionToken(); return fetchJSON( `/api/providers/oauth/${encodeURIComponent(providerId)}/start`, { method: "POST", headers: { "Content-Type": "application/json", [SESSION_HEADER]: token, }, body: "{}", }, ); }, submitOAuthCode: async (providerId: string, sessionId: string, code: string) => { const token = await getSessionToken(); return fetchJSON( `/api/providers/oauth/${encodeURIComponent(providerId)}/submit`, { method: "POST", headers: { "Content-Type": "application/json", [SESSION_HEADER]: token, }, body: JSON.stringify({ session_id: sessionId, code }), }, ); }, pollOAuthSession: (providerId: string, sessionId: string) => fetchJSON( `/api/providers/oauth/${encodeURIComponent(providerId)}/poll/${encodeURIComponent(sessionId)}`, ), cancelOAuthSession: async (sessionId: string) => { const token = await getSessionToken(); return fetchJSON<{ ok: boolean }>( `/api/providers/oauth/sessions/${encodeURIComponent(sessionId)}`, { method: "DELETE", headers: { [SESSION_HEADER]: token }, }, ); }, // Messaging platforms (gateway channels) getMessagingPlatforms: () => fetchJSON<{ platforms: MessagingPlatform[] }>("/api/messaging/platforms"), updateMessagingPlatform: (id: string, body: MessagingPlatformUpdate) => fetchJSON<{ ok: boolean; platform: string }>( `/api/messaging/platforms/${encodeURIComponent(id)}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }, ), testMessagingPlatform: (id: string) => fetchJSON( `/api/messaging/platforms/${encodeURIComponent(id)}/test`, { method: "POST" }, ), // Gateway / update actions restartGateway: () => fetchJSON("/api/gateway/restart", { method: "POST" }), updateHermes: () => fetchJSON("/api/hermes/update", { method: "POST" }), getActionStatus: (name: string, lines = 200) => fetchJSON( `/api/actions/${encodeURIComponent(name)}/status?lines=${lines}`, ), // Dashboard plugins getPlugins: () => fetchJSON("/api/dashboard/plugins"), rescanPlugins: () => fetchJSON<{ ok: boolean; count: number }>("/api/dashboard/plugins/rescan"), getPluginsHub: () => fetchJSON("/api/dashboard/plugins/hub"), installAgentPlugin: (body: AgentPluginInstallRequest) => fetchJSON("/api/dashboard/agent-plugins/install", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ ...body }), }), enableAgentPlugin: (name: string) => fetchJSON<{ ok: boolean; name: string; unchanged?: boolean }>( `/api/dashboard/agent-plugins/${pluginPath(name)}/enable`, { method: "POST" }, ), disableAgentPlugin: (name: string) => fetchJSON<{ ok: boolean; name: string; unchanged?: boolean }>( `/api/dashboard/agent-plugins/${pluginPath(name)}/disable`, { method: "POST" }, ), updateAgentPlugin: (name: string) => fetchJSON( `/api/dashboard/agent-plugins/${pluginPath(name)}/update`, { method: "POST" }, ), removeAgentPlugin: (name: string) => fetchJSON<{ ok: boolean; name: string }>( `/api/dashboard/agent-plugins/${pluginPath(name)}`, { method: "DELETE" }, ), savePluginProviders: (body: PluginProvidersPutRequest) => fetchJSON<{ ok: boolean }>("/api/dashboard/plugin-providers", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }), setPluginVisibility: (name: string, hidden: boolean) => fetchJSON<{ ok: boolean; name: string; hidden: boolean }>( `/api/dashboard/plugins/${pluginPath(name)}/visibility`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ hidden }), }, ), // Dashboard themes getThemes: () => fetchJSON("/api/dashboard/themes"), setTheme: (name: string) => fetchJSON<{ ok: boolean; theme: string }>("/api/dashboard/theme", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name }), }), // ── Admin: MCP servers ────────────────────────────────────────────── getMcpServers: () => fetchJSON<{ servers: McpServer[] }>("/api/mcp/servers"), addMcpServer: (body: McpServerCreate) => fetchJSON("/api/mcp/servers", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }), removeMcpServer: (name: string) => fetchJSON<{ ok: boolean }>(`/api/mcp/servers/${encodeURIComponent(name)}`, { method: "DELETE", }), testMcpServer: (name: string) => fetchJSON( `/api/mcp/servers/${encodeURIComponent(name)}/test`, { method: "POST" }, ), setMcpServerEnabled: (name: string, enabled: boolean) => fetchJSON<{ ok: boolean; name: string; enabled: boolean }>( `/api/mcp/servers/${encodeURIComponent(name)}/enabled`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ enabled }), }, ), getMcpCatalog: () => fetchJSON<{ entries: McpCatalogEntry[]; diagnostics: McpCatalogDiagnostic[] }>( "/api/mcp/catalog", ), installMcpCatalogEntry: ( name: string, env: Record = {}, enable = true, ) => fetchJSON<{ ok: boolean; name: string; background: boolean; action?: string }>( "/api/mcp/catalog/install", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name, env, enable }), }, ), // ── Admin: Pairing ────────────────────────────────────────────────── getPairing: () => fetchJSON("/api/pairing"), approvePairing: (platform: string, code: string) => fetchJSON<{ ok: boolean; user: PairingUser }>("/api/pairing/approve", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ platform, code }), }), revokePairing: (platform: string, user_id: string) => fetchJSON<{ ok: boolean }>("/api/pairing/revoke", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ platform, user_id }), }), clearPendingPairing: () => fetchJSON<{ ok: boolean; cleared: number }>("/api/pairing/clear-pending", { method: "POST", }), // ── Admin: Webhooks ───────────────────────────────────────────────── getWebhooks: () => fetchJSON("/api/webhooks"), createWebhook: (body: WebhookCreate) => fetchJSON("/api/webhooks", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }), deleteWebhook: (name: string) => fetchJSON<{ ok: boolean }>(`/api/webhooks/${encodeURIComponent(name)}`, { method: "DELETE", }), setWebhookEnabled: (name: string, enabled: boolean) => fetchJSON<{ ok: boolean; name: string; enabled: boolean }>( `/api/webhooks/${encodeURIComponent(name)}/enabled`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ enabled }), }, ), // ── Admin: Credential pool ────────────────────────────────────────── getCredentialPool: () => fetchJSON<{ providers: CredentialPoolProvider[] }>("/api/credentials/pool"), addCredentialPoolEntry: ( provider: string, api_key: string, label?: string, ) => fetchJSON<{ ok: boolean; provider: string; count: number }>( "/api/credentials/pool", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ provider, api_key, label }), }, ), removeCredentialPoolEntry: (provider: string, index: number) => fetchJSON<{ ok: boolean; provider: string; count: number }>( `/api/credentials/pool/${encodeURIComponent(provider)}/${index}`, { method: "DELETE" }, ), // ── Admin: Memory provider ────────────────────────────────────────── getMemory: () => fetchJSON("/api/memory"), setMemoryProvider: (provider: string) => fetchJSON<{ ok: boolean; active: string }>("/api/memory/provider", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ provider }), }), resetMemory: (target: "all" | "memory" | "user") => fetchJSON<{ ok: boolean; deleted: string[] }>("/api/memory/reset", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ target }), }), // ── Admin: Gateway lifecycle ──────────────────────────────────────── startGateway: () => fetchJSON("/api/gateway/start", { method: "POST" }), stopGateway: () => fetchJSON("/api/gateway/stop", { method: "POST" }), // ── Admin: Operations ─────────────────────────────────────────────── runDoctor: () => fetchJSON("/api/ops/doctor", { method: "POST" }), runSecurityAudit: () => fetchJSON("/api/ops/security-audit", { method: "POST" }), runBackup: (output?: string) => fetchJSON("/api/ops/backup", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ output }), }), runImport: (archive: string) => fetchJSON("/api/ops/import", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ archive }), }), getHooks: () => fetchJSON("/api/ops/hooks"), createHook: (body: HookCreate) => fetchJSON<{ ok: boolean; event: string; command: string; approved: boolean }>( "/api/ops/hooks", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }, ), deleteHook: (event: string, command: string) => fetchJSON<{ ok: boolean }>("/api/ops/hooks", { method: "DELETE", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ event, command }), }), getSystemStats: () => fetchJSON("/api/system/stats"), // ── Admin: Curator ────────────────────────────────────────────────── getCurator: () => fetchJSON("/api/curator"), setCuratorPaused: (paused: boolean) => fetchJSON<{ ok: boolean; paused: boolean }>("/api/curator/paused", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ paused }), }), runCurator: () => fetchJSON("/api/curator/run", { method: "POST" }), // ── Admin: Portal ─────────────────────────────────────────────────── getPortal: () => fetchJSON("/api/portal"), // ── Admin: Diagnostics (backgrounded) ─────────────────────────────── runPromptSize: () => fetchJSON("/api/ops/prompt-size", { method: "POST" }), runDump: () => fetchJSON("/api/ops/dump", { method: "POST" }), runConfigMigrate: () => fetchJSON("/api/ops/config-migrate", { method: "POST" }), getCheckpoints: () => fetchJSON("/api/ops/checkpoints"), pruneCheckpoints: () => fetchJSON("/api/ops/checkpoints/prune", { method: "POST" }), // ── Admin: Skills hub ─────────────────────────────────────────────── installSkillFromHub: (identifier: string) => fetchJSON("/api/skills/hub/install", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ identifier }), }), uninstallSkillFromHub: (name: string) => fetchJSON("/api/skills/hub/uninstall", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name }), }), updateSkillsFromHub: () => fetchJSON("/api/skills/hub/update", { method: "POST" }), searchSkillsHub: (q: string, source = "all", limit = 20) => fetchJSON<{ results: SkillHubResult[] }>( `/api/skills/hub/search?q=${encodeURIComponent(q)}&source=${encodeURIComponent(source)}&limit=${limit}`, ), }; /** Identity payload returned by ``GET /api/auth/me`` (Phase 7). * * Returned by the dashboard's gated middleware when a valid session cookie * is attached. ``email`` and ``display_name`` are empty strings under the * Nous Portal contract V1 (the access token has no email/name claims — * see Contract Anchor C4 in the plan). The AuthWidget surfaces a * truncated ``user_id`` instead. */ export interface AuthMeResponse { user_id: string; email: string; display_name: string; org_id: string; provider: string; expires_at: number; } export interface ActionResponse { name: string; ok: boolean; pid: number | null; error?: string; message?: string; update_command?: string; } export interface SessionStoreStats { total: number; active_store: number; archived: number; messages: number; by_source: Record; } export interface SkillHubResult { name: string; description: string; source: string; identifier: string; trust_level: string; repo: string | null; tags: string[]; } // ── Admin types ─────────────────────────────────────────────────────── export interface McpServer { name: string; transport: "http" | "stdio" | "unknown"; url: string | null; command: string | null; args: string[]; env: Record; auth: string | null; enabled: boolean; tools: string[] | null; } export interface McpCatalogEntry { name: string; description: string; source: string; transport: "http" | "stdio"; auth_type: "api_key" | "oauth" | "none"; required_env: Array<{ name: string; prompt: string; required: boolean }>; needs_install: boolean; installed: boolean; enabled: boolean; } export interface McpCatalogDiagnostic { name: string; kind: string; message: string; } export interface McpServerCreate { name: string; url?: string; command?: string; args?: string[]; env?: Record; auth?: string; } export interface McpTestResult { ok: boolean; error?: string; tools: Array<{ name: string; description: string }>; } export interface MessagingPlatformEnvVar { key: string; required: boolean; is_set: boolean; redacted_value: string | null; description: string; prompt: string; url: string | null; is_password: boolean; advanced: boolean; } export interface MessagingPlatform { id: string; name: string; description: string; docs_url: string; enabled: boolean; configured: boolean; gateway_running: boolean; /** * "connected" | "disabled" | "not_configured" | "pending_restart" | * "gateway_stopped" | "disconnected" | "fatal" | string */ state: string; error_code: string | null; error_message: string | null; updated_at: string | null; home_channel: { platform: string; chat_id: string; name: string; thread_id?: string } | null; env_vars: MessagingPlatformEnvVar[]; } export interface MessagingPlatformUpdate { enabled?: boolean; env?: Record; clear_env?: string[]; } export interface MessagingPlatformTestResult { ok: boolean; state: string; message: string; } export interface PairingUser { platform: string; user_id: string; user_name?: string; code?: string; age_minutes?: number; } export interface PairingResponse { pending: PairingUser[]; approved: PairingUser[]; } export interface WebhookRoute { name: string; description: string; events: string[]; deliver: string; deliver_only: boolean; prompt: string; skills: string[]; created_at: string | null; url: string; secret_set: boolean; enabled: boolean; } export interface WebhooksResponse { enabled: boolean; base_url: string; subscriptions: WebhookRoute[]; } export interface WebhookCreate { name: string; description?: string; events?: string[]; prompt?: string; skills?: string[]; deliver?: string; deliver_only?: boolean; deliver_chat_id?: string; } export interface CredentialPoolEntry { index: number; id: string | null; label: string | null; auth_type: string | null; source: string | null; priority: number; last_status: string | null; request_count: number; token_preview: string; has_refresh: boolean; } export interface CredentialPoolProvider { provider: string; entries: CredentialPoolEntry[]; } export interface MemoryProviderInfo { name: string; description: string; configured: boolean; } export interface MemoryStatus { active: string; providers: MemoryProviderInfo[]; builtin_files: { memory: number; user: number }; } export interface HookEntry { event: string; matcher: string | null; command: string | null; timeout: number | null; allowed: boolean; approved_at?: string | null; executable?: boolean; } export interface HooksResponse { hooks: HookEntry[]; valid_events: string[]; } export interface HookCreate { event: string; command: string; matcher?: string; timeout?: number; approve?: boolean; } export interface SystemStats { os: string; os_release: string; os_version: string; platform: string; arch: string; hostname: string; python_version: string; python_impl: string; hermes_version: string; cpu_count: number | null; psutil: boolean; cpu_percent?: number; load_avg?: number[]; uptime_seconds?: number; memory?: { total: number; available: number; used: number; percent: number }; disk?: { total: number; used: number; free: number; percent: number }; process?: { pid: number; rss: number; create_time: number; num_threads: number }; } export interface CuratorStatus { enabled: boolean; paused: boolean; interval_hours: number | null; last_run_at: string | null; min_idle_hours: number | null; stale_after_days: number | null; archive_after_days: number | null; } export interface PortalFeature { label: string; state: string; } export interface PortalStatus { logged_in: boolean; portal_url: string | null; inference_url: string | null; provider: string; subscription_url: string; features: PortalFeature[]; } export interface CheckpointSession { session: string; files: number; bytes: number; } export interface CheckpointsResponse { sessions: CheckpointSession[]; total_bytes: number; } /** Per-call overrides for {@link fetchJSON}. */ interface FetchJSONOptions { /** When true, a 401 response is surfaced as a normal thrown error rather * than triggering the loopback stale-token page reload. Use for probes * whose 401 is an expected signal (e.g. /api/auth/me in non-gated mode) * rather than evidence of a rotated session token. */ allowUnauthorized?: boolean; } export interface ActionStatusResponse { exit_code: number | null; lines: string[]; name: string; pid: number | null; running: boolean; } export interface PlatformStatus { error_code?: string; error_message?: string; state: string; updated_at: string; } export interface StatusResponse { active_sessions: number; /** Phase 7: ``true`` when the dashboard's OAuth gate is engaged * (public bind, no ``--insecure``). Read alongside ``auth_providers`` * to render a "gated / loopback" badge. */ auth_required?: boolean; /** Phase 7: registered ``DashboardAuthProvider`` names (e.g. ``["nous"]``). * Empty in loopback mode; empty + ``auth_required=true`` is a * fail-closed state (the dashboard will refuse to bind). */ auth_providers?: string[]; config_path: string; config_version: number; env_path: string; gateway_exit_reason: string | null; gateway_health_url: string | null; gateway_pid: number | null; gateway_platforms: Record; gateway_running: boolean; gateway_state: string | null; gateway_updated_at: string | null; hermes_home: string; latest_config_version: number; release_date: string; version: string; } export interface SessionInfo { id: string; source: string | null; model: string | null; title: string | null; started_at: number; ended_at: number | null; last_active: number; is_active: boolean; message_count: number; tool_call_count: number; input_tokens: number; output_tokens: number; preview: string | null; parent_session_id?: string | null; } export interface SessionLatestDescendantResponse { requested_session_id: string; session_id: string; path: string[]; changed: boolean; } export interface PaginatedSessions { sessions: SessionInfo[]; total: number; limit: number; offset: number; } export interface EnvVarInfo { is_set: boolean; redacted_value: string | null; description: string; url: string | null; category: string; is_password: boolean; tools: string[]; advanced: boolean; } export interface SessionMessage { role: "user" | "assistant" | "system" | "tool"; content: string | null; tool_calls?: Array<{ id: string; function: { name: string; arguments: string }; }>; tool_name?: string; tool_call_id?: string; timestamp?: number; } export interface SessionMessagesResponse { session_id: string; messages: SessionMessage[]; } export interface LogsResponse { file: string; lines: string[]; } export interface AnalyticsDailyEntry { day: string; input_tokens: number; output_tokens: number; cache_read_tokens: number; reasoning_tokens: number; estimated_cost: number; actual_cost: number; sessions: number; api_calls: number; } export interface AnalyticsModelEntry { model: string; input_tokens: number; output_tokens: number; estimated_cost: number; sessions: number; api_calls: number; } export interface AnalyticsSkillEntry { skill: string; view_count: number; manage_count: number; total_count: number; percentage: number; last_used_at: number | null; } export interface AnalyticsSkillsSummary { total_skill_loads: number; total_skill_edits: number; total_skill_actions: number; distinct_skills_used: number; } export interface AnalyticsResponse { daily: AnalyticsDailyEntry[]; by_model: AnalyticsModelEntry[]; totals: { total_input: number; total_output: number; total_cache_read: number; total_reasoning: number; total_estimated_cost: number; total_actual_cost: number; total_sessions: number; total_api_calls: number; }; skills: { summary: AnalyticsSkillsSummary; top_skills: AnalyticsSkillEntry[]; }; } export interface ProfileInfo { name: string; path: string; is_default: boolean; model: string | null; provider: string | null; has_env: boolean; skill_count: number; } export interface ModelsAnalyticsModelEntry { model: string; provider: string; input_tokens: number; output_tokens: number; cache_read_tokens: number; reasoning_tokens: number; estimated_cost: number; actual_cost: number; sessions: number; api_calls: number; tool_calls: number; last_used_at: number; avg_tokens_per_session: number; capabilities: { supports_tools?: boolean; supports_vision?: boolean; supports_reasoning?: boolean; context_window?: number; max_output_tokens?: number; model_family?: string; }; } export interface ModelsAnalyticsResponse { models: ModelsAnalyticsModelEntry[]; totals: { distinct_models: number; total_input: number; total_output: number; total_cache_read: number; total_reasoning: number; total_estimated_cost: number; total_actual_cost: number; total_sessions: number; total_api_calls: number; }; period_days: number; } export interface CronJob { id: string; profile?: string | null; profile_name?: string | null; hermes_home?: string | null; is_default_profile?: boolean; name?: string | null; prompt?: string | null; script?: string | null; schedule?: { kind?: string; expr?: string; display?: string }; schedule_display?: string | null; enabled: boolean; state?: string | null; deliver?: string | null; last_run_at?: string | null; next_run_at?: string | null; last_error?: string | null; } export interface SkillInfo { name: string; description: string; category: string; enabled: boolean; } export interface ToolsetInfo { name: string; label: string; description: string; enabled: boolean; configured: boolean; tools: string[]; } export interface SessionSearchResult { session_id: string; snippet: string; role: string | null; source: string | null; model: string | null; session_started: number | null; } export interface SessionSearchResponse { results: SessionSearchResult[]; } // ── Model info types ────────────────────────────────────────────────── export interface ModelInfoResponse { model: string; provider: string; auto_context_length: number; config_context_length: number; effective_context_length: number; capabilities: { supports_tools?: boolean; supports_vision?: boolean; supports_reasoning?: boolean; context_window?: number; max_output_tokens?: number; model_family?: string; }; } // ── Model options / assignment types ────────────────────────────────── export interface ModelOptionProvider { name: string; slug: string; models?: string[]; total_models?: number; is_current?: boolean; is_user_defined?: boolean; source?: string; warning?: string; } export interface ModelOptionsResponse { model?: string; provider?: string; providers?: ModelOptionProvider[]; } export interface AuxiliaryTaskAssignment { task: string; provider: string; model: string; base_url: string; } export interface AuxiliaryModelsResponse { tasks: AuxiliaryTaskAssignment[]; main: { provider: string; model: string }; } export interface ModelAssignmentRequest { scope: "main" | "auxiliary"; provider: string; model: string; /** For auxiliary: task slot name, "" for all, "__reset__" to reset all. */ task?: string; } export interface ModelAssignmentResponse { ok: boolean; scope?: string; provider?: string; model?: string; tasks?: string[]; reset?: boolean; } // ── OAuth provider types ──────────────────────────────────────────────── export interface OAuthProviderStatus { logged_in: boolean; source?: string | null; source_label?: string | null; token_preview?: string | null; expires_at?: string | null; has_refresh_token?: boolean; last_refresh?: string | null; error?: string; } export interface OAuthProvider { id: string; name: string; /** "pkce" (browser redirect + paste code), "device_code" (show code + URL), * or "external" (delegated to a separate CLI like Claude Code or Qwen). */ flow: "pkce" | "device_code" | "external"; cli_command: string; docs_url: string; status: OAuthProviderStatus; } export interface OAuthProvidersResponse { providers: OAuthProvider[]; } /** Discriminated union — the shape of /start depends on the flow. */ export type OAuthStartResponse = | { session_id: string; flow: "pkce"; auth_url: string; expires_in: number; } | { session_id: string; flow: "device_code"; user_code: string; verification_url: string; expires_in: number; poll_interval: number; }; export interface OAuthSubmitResponse { ok: boolean; status: "approved" | "error"; message?: string; } export interface OAuthPollResponse { session_id: string; status: "pending" | "approved" | "denied" | "expired" | "error"; error_message?: string | null; expires_at?: number | null; } // ── Dashboard theme types ────────────────────────────────────────────── export interface DashboardThemeSummary { description: string; label: string; name: string; /** Full theme definition for user themes; undefined for built-ins * (which the frontend already has locally). */ definition?: DashboardTheme; } export interface DashboardThemesResponse { active: string; themes: DashboardThemeSummary[]; } // ── Dashboard plugin types ───────────────────────────────────────────── export interface PluginManifestResponse { name: string; label: string; description: string; icon: string; version: string; tab: { path: string; position?: string; override?: string; hidden?: boolean; }; slots?: string[]; entry: string; css?: string | null; has_api: boolean; source: string; } export interface HubAgentPluginRow { name: string; version: string; description: string; source: string; runtime_status: "disabled" | "enabled" | "inactive"; has_dashboard_manifest: boolean; dashboard_manifest: PluginManifestResponse | null; path: string; can_remove: boolean; can_update_git: boolean; auth_required: boolean; auth_command: string; user_hidden: boolean; } export interface PluginsHubProviders { memory_provider: string; memory_options: Array<{ name: string; description: string }>; context_engine: string; context_options: Array<{ name: string; description: string }>; } export interface PluginsHubResponse { plugins: HubAgentPluginRow[]; orphan_dashboard_plugins: PluginManifestResponse[]; providers: PluginsHubProviders; } export interface AgentPluginInstallRequest { identifier: string; force?: boolean; enable?: boolean; } export interface AgentPluginInstallResponse { ok: boolean; plugin_name?: string; warnings?: string[]; missing_env?: string[]; after_install_path?: string | null; enabled?: boolean; error?: string; } export interface AgentPluginUpdateResponse { ok: boolean; name?: string; output?: string; unchanged?: boolean; error?: string; } export interface PluginProvidersPutRequest { memory_provider?: string; context_engine?: string; }