Files
hermes-agent/apps/desktop/src/store/agent-plugins.ts
T
Teknium 7e1bfeab88 fix(desktop): read-only keyless plugin rows + backend contract v6
Rework of the salvaged #82828 compatibility layer: keep the crash guards
(optional key, safe filter/search, synthetic React row identity) but drop
the name-addressed toggle fallback — bare names collide across category
dirs (image_gen/fal vs video_gen/fal), which is exactly why the backend
moved to key-addressed toggles (a60b492e07). Keyless rows from a
pre-contract backend now render with a disabled switch and an 'update
your backend' tooltip instead of resurrecting the collision-prone
protocol.

Bump DESKTOP_BACKEND_CONTRACT / REQUIRED_BACKEND_CONTRACT to 6 so the
existing skew toast surfaces the real remedy (one-click backend update)
on session open.
2026-08-10 01:13:08 -07:00

115 lines
3.8 KiB
TypeScript

import { atom } from 'nanostores'
import { notifyError } from '@/store/notifications'
/**
* Feature store for backend (agent) plugins — the native Hermes plugins plus
* portable Agent Plugins v1 packages the backend discovers on disk. Settings
* renders this next to the desktop (renderer) plugin inventory so every plugin
* the user has is discoverable and toggleable from one page, whatever process
* it runs in.
*
* Backed by the gateway's `plugins.manage` RPC — the same list/toggle
* primitives `hermes plugins` and the dashboard use, so all surfaces agree on
* what's installed and what's enabled. Works against every backend topology
* (local spawn, SSH, URL+token) because it rides the session's own transport.
*/
export interface AgentPluginRow {
name: string
/** Canonical registry key (e.g. `image_gen/fal`) — absent on legacy backends. */
key?: string
version: string
description: string
/** 'bundled' | 'user' | 'git' | 'project' | 'entrypoint' */
source: string
status: 'enabled' | 'disabled' | 'not enabled'
/** Agent Plugins v1 package (portable skills/MCP format) vs native Hermes. */
portable?: boolean
}
export type AgentPluginsStatus = 'idle' | 'loading' | 'ready' | 'error'
/** The recovering `requestGateway` from `useGatewayRequest`. */
export type GatewayRequest = <T>(method: string, params?: Record<string, unknown>) => Promise<T>
export const $agentPlugins = atom<AgentPluginRow[]>([])
export const $agentPluginsStatus = atom<AgentPluginsStatus>('idle')
export const $agentPluginsError = atom<string | null>(null)
/** Best available address of the row whose toggle RPC is in flight. */
export const $agentPluginBusy = atom<string | null>(null)
let inflight: Promise<void> | null = null
/** Fetch the backend plugin list. Always refetches (it's a cheap local disk
* scan on the backend); concurrent callers share one in-flight request. */
export function loadAgentPlugins(request: GatewayRequest): Promise<void> {
if (inflight) {
return inflight
}
inflight = (async () => {
if ($agentPluginsStatus.get() !== 'ready') {
$agentPluginsStatus.set('loading')
}
try {
const result = await request<{ plugins?: AgentPluginRow[] }>('plugins.manage', { action: 'list' })
$agentPlugins.set(result?.plugins ?? [])
$agentPluginsStatus.set('ready')
$agentPluginsError.set(null)
} catch (e) {
$agentPluginsError.set(e instanceof Error ? e.message : String(e))
$agentPluginsStatus.set('error')
} finally {
inflight = null
}
})()
return inflight
}
/** Flip a backend plugin on/off and patch the row from the RPC's refreshed
* copy. Addressed by canonical key ONLY — bare names collide across category
* dirs (image_gen/fal vs video_gen/fal), which is exactly why the backend
* moved to key-addressed toggles. Rows without a key (pre-contract-v6
* backends) render read-only instead of falling back to the collision-prone
* name protocol; the backend-contract skew toast points the user at the
* update. Returns whether the toggle stuck. */
export async function toggleAgentPlugin(
request: GatewayRequest,
key: string,
enable: boolean,
failMessage: string
): Promise<boolean> {
$agentPluginBusy.set(key)
try {
const result = await request<{ ok?: boolean; plugin?: AgentPluginRow | null }>('plugins.manage', {
action: 'toggle',
key,
enable
})
if (!result?.ok) {
throw new Error(failMessage)
}
const refreshed = result.plugin
if (refreshed) {
$agentPlugins.set($agentPlugins.get().map(row => (row.key === key ? { ...row, ...refreshed } : row)))
} else {
await loadAgentPlugins(request)
}
return true
} catch (e) {
notifyError(e, failMessage)
return false
} finally {
$agentPluginBusy.set(null)
}
}