Download apps/desktop/src/api/plugins.ts from SaylorTwift/hermes-agent: direct link, hf CLI and curl.
- Browser
- Download file 5.65 kB
-
https://huggingface.co/SaylorTwift/hermes-agent/resolve/main/apps/desktop/src/api/plugins.ts
- Command line
-
hf download hf://SaylorTwift/hermes-agent/apps/desktop/src/api/plugins.ts
-
curl -L -o plugins.ts https://huggingface.co/SaylorTwift/hermes-agent/resolve/main/apps/desktop/src/api/plugins.ts
5.65 kB
| import { reconnectBackoffDelayMs } from '@hermes/shared' | |
| import type { HermesConnection } from '@/global' | |
| import { RECONNECT_ATTEMPT_TIMEOUT_MS, withTimeout } from '@/lib/with-timeout' | |
| import { getApiRequestConnection, getApiRequestProfile, hermesApi, profileScoped } from './client' | |
| /** Resolve the ACTIVE backend's connection descriptor, (connectionId, | |
| * profile)-scoped β mirroring how store/profile resolves $connection: a | |
| * registry agent's descriptor comes from getConnectionFor (its SOURCE | |
| * connection), everything else from the profile-keyed local pool. The | |
| * getConnectionFor bridge is optional (older Desktop mains); without it the | |
| * profile-scoped pool lookup is the best available answer. | |
| * | |
| * Both branches are IPC round-trips into the main process with no timeout of | |
| * their own (#93454) β a wedged main-process round-trip otherwise hangs | |
| * pluginSocket's connect() forever instead of falling back to the polling | |
| * fallback every consumer already has. Bound the same way store/gateway's | |
| * openSecondary bounds the same *For/plain pair. | |
| * | |
| * Exported for tests. */ | |
| export async function activeConnection(): Promise<HermesConnection> { | |
| const getConnectionFor = window.hermesDesktop.getConnectionFor | |
| const connectionId = getApiRequestConnection() | |
| const profile = getApiRequestProfile() | |
| if (connectionId && getConnectionFor) { | |
| return withTimeout( | |
| getConnectionFor({ connectionId, profile }), | |
| RECONNECT_ATTEMPT_TIMEOUT_MS, | |
| `Timed out connecting to profile "${profile}"` | |
| ) | |
| } | |
| return withTimeout( | |
| window.hermesDesktop.getConnection(profile), | |
| RECONNECT_ATTEMPT_TIMEOUT_MS, | |
| `Timed out connecting to profile "${profile}"` | |
| ) | |
| } | |
| /** Options for a plugin REST call β mirrors the app's own `hermesDesktop.api` | |
| * shape, minus the path (which is namespace-derived). */ | |
| export interface PluginRestOptions { | |
| method?: string | |
| body?: unknown | |
| /** Single-file multipart upload (see HermesApiRequest.upload). */ | |
| upload?: { filename: string; contentType?: string; bytes: ArrayBuffer } | |
| timeoutMs?: number | |
| } | |
| // Normalize `path` to a leading-slash suffix relative to `/api/plugins/<id>`. | |
| // The namespace is the boundary β reject `..` so a relative segment can't | |
| // normalize out into another plugin's API or a core route. Check the path | |
| // portion only (before any query/hash). | |
| function pluginPathSuffix(caller: string, path: string): string { | |
| const suffix = path.startsWith('/') ? path : `/${path}` | |
| if (suffix.split(/[?#]/, 1)[0].split('/').includes('..')) { | |
| throw new Error(`${caller}: illegal path traversal in "${path}"`) | |
| } | |
| return suffix | |
| } | |
| /** The plugin REST door. Every call is scoped BY CONSTRUCTION to the plugin's | |
| * own backend namespace β `path` is relative to `/api/plugins/<pluginId>` | |
| * ('/board' β `/api/plugins/kanban/board`), so a plugin can't address another | |
| * plugin's API or a core route through it. Profile-aware like every desktop | |
| * REST call. Broader reach (core endpoints, another namespace) is the future | |
| * declared-capability seam; today the namespace IS the boundary. */ | |
| export async function pluginRest<T>(pluginId: string, path: string, opts: PluginRestOptions = {}): Promise<T> { | |
| if (!window.hermesDesktop?.api) { | |
| throw new Error('Hermes desktop bridge unavailable') | |
| } | |
| const suffix = pluginPathSuffix('pluginRest', path) | |
| return hermesApi<T>({ | |
| path: `/api/plugins/${pluginId}${suffix}`, | |
| method: opts.method, | |
| body: opts.body, | |
| upload: opts.upload, | |
| timeoutMs: opts.timeoutMs, | |
| ...profileScoped() | |
| }) | |
| } | |
| /** The plugin WebSocket door β the live twin of `pluginRest`, scoped the same | |
| * way: `path` is relative to `/api/plugins/<pluginId>` ('/events' β the | |
| * plugin's own event stream). Token-mode backends auth via the same query | |
| * credential the app's own sockets use; OAuth remotes resolve null (callers | |
| * keep their polling fallback β every consumer must have one anyway, since a | |
| * socket can drop). Auto-reconnects with backoff until disposed. */ | |
| export function pluginSocket(pluginId: string, path: string, onMessage: (data: unknown) => void): () => void { | |
| const suffix = pluginPathSuffix('pluginSocket', path) | |
| let socket: null | WebSocket = null | |
| let disposed = false | |
| let attempt = 0 | |
| const connect = async () => { | |
| const connection = await activeConnection().catch(() => null) | |
| // No bridge / OAuth cookie auth (WS tickets are single-use, core-managed): | |
| // stay on the polling fallback rather than half-working. | |
| if (disposed || !connection || connection.authMode === 'oauth') { | |
| return | |
| } | |
| const base = connection.baseUrl.replace(/^http/, 'ws') | |
| const join = suffix.includes('?') ? '&' : '?' | |
| socket = new WebSocket( | |
| `${base}/api/plugins/${pluginId}${suffix}${join}token=${encodeURIComponent(connection.token)}` | |
| ) | |
| socket.onmessage = event => { | |
| attempt = 0 | |
| try { | |
| onMessage(JSON.parse(String(event.data))) | |
| } catch { | |
| // Non-JSON frame β plugin streams are JSON by contract; skip it. | |
| } | |
| } | |
| socket.onclose = () => { | |
| socket = null | |
| if (!disposed) { | |
| // Full-jitter exponential backoff: same rationale as the gateway | |
| // socket reconnect loops β an immediate-retry loop across many | |
| // desktop clients floods the gateway with connection attempts | |
| // during a restart. | |
| window.setTimeout(() => void connect(), reconnectBackoffDelayMs(attempt, { baseDelayMs: 500, capMs: 30_000 })) | |
| attempt += 1 | |
| } | |
| } | |
| } | |
| void connect() | |
| return () => { | |
| disposed = true | |
| socket?.close() | |
| } | |
| } | |