feat(desktop): plugin SDK surface — rest door, socket, react-query, UI kit

This commit is contained in:
Brooklyn Nicholson 2026-07-13 17:28:21 -04:00
parent 29b8cacfab
commit 99ff67eb03
2 changed files with 282 additions and 0 deletions

View file

@ -0,0 +1,228 @@
/**
* @hermes/plugin-sdk THE plugin language. The vscode-module model: plugin
* authors import exactly one module and get everything they never touch
* `@/…` internals (lint-fenced) and never need codebase access.
*
* Two delivery modes, one surface:
* - bundled (`src/plugins/<name>/`): the import resolves here via alias;
* - runtime-fetched (plugin host, next phase): the loader injects this same
* object as `window.__HERMES_PLUGIN_SDK__` and maps the import to it, so a
* published plugin builds against the types with the SDK marked external.
*
* Capability tiers (WoW-style):
* - `host.state.*` READONLY app state (nanostore atoms; `.get()` or
* subscribe; `useValue` in React).
* - `host.*` actions curated, safe verbs (toast, haptic).
* - `host.request` the gateway JSON-RPC door; the plugin's real power,
* and the future seam for per-plugin capability grants.
* - `ui.*` the design language, so plugin UI looks native by default.
*/
import { atom, type ReadableAtom } from 'nanostores'
import { $narrowViewport } from '@/components/pane-shell/tree/store'
import { onGatewayEvent } from '@/contrib/events'
import { getLogs, getStatus } from '@/hermes'
import { $gateway } from '@/store/gateway'
import { notify, notifyError } from '@/store/notifications'
import { $activeGatewayProfile } from '@/store/profile'
import { $activeSessionId, $currentCwd, $currentModel, $gatewayState } from '@/store/session'
import { runGatewayRestart } from '@/store/system-actions'
// -- state: readonly views over the app's live atoms -------------------------
const readonlyAtom = <T>(atomLike: ReadableAtom<T>): ReadableAtom<T> => atomLike
/** Window geometry + the app's responsive posture, one readonly rect. */
export interface ViewportRect {
width: number
height: number
/** Below the app's sidebar-collapse breakpoint (rails become overlays). */
narrow: boolean
}
const readViewport = (): ViewportRect => ({
width: typeof window === 'undefined' ? 0 : window.innerWidth,
height: typeof window === 'undefined' ? 0 : window.innerHeight,
narrow: $narrowViewport.get()
})
const $viewport = atom<ViewportRect>(readViewport())
if (typeof window !== 'undefined') {
const refresh = () => $viewport.set(readViewport())
window.addEventListener('resize', refresh)
$narrowViewport.listen(refresh)
}
export const host = {
state: {
/** Runtime id of the active chat session (null on a fresh draft). */
activeSessionId: readonlyAtom<null | string>($activeSessionId),
/** Active workspace cwd ('' when detached). */
cwd: readonlyAtom<string>($currentCwd),
/** Gateway socket state: 'idle' | 'connecting' | 'open' | …. */
gateway: readonlyAtom<string>($gatewayState),
/** Current main model slug. */
model: readonlyAtom<string>($currentModel),
/** Profile the live gateway is routed to. */
profile: readonlyAtom<string>($activeGatewayProfile),
/** Window geometry ({ width, height, narrow }). */
viewport: readonlyAtom<ViewportRect>($viewport)
},
/** Toast into the app's notification stack. */
notify,
notifyError,
// NOTE: every host door is async-safe — wrapped so a sync throw from an
// internal helper (e.g. no desktop bridge in a plain browser) becomes a
// rejection a plugin's .catch() sees, never an error-boundary crash.
/** Tail an app log file (`agent` / `errors` / `gateway` / `gui` / …). */
logs: async (...args: Parameters<typeof getLogs>) => getLogs(...args),
/** Navigate the app router (hash routes, e.g. '/command-center?section=system'). */
navigate: (path: string) => {
window.location.hash = path.startsWith('#') ? path : `#${path}`
},
/** HEAR the gateway stream (message deltas, session lifecycle, tool
* activity, ) by event type `'*'` for everything. Returns a disposer.
* Listeners are isolated; a throw can't affect app dispatch. */
onEvent: onGatewayEvent,
/** Restart the backend gateway (progress surfaces in the core statusbar). */
restartGateway: async () => runGatewayRestart(),
/** One-shot system status snapshot (platforms, versions, …). */
status: async () => getStatus(),
/** Gateway JSON-RPC sessions, config, skills, cron, kanban, everything
* the app itself uses. Lazy: resolves the LIVE socket per call. */
request: async <T>(method: string, params: Record<string, unknown> = {}): Promise<T> => {
const gateway = $gateway.get()
if (!gateway) {
throw new Error('Hermes gateway unavailable')
}
return gateway.request<T>(method, params)
}
}
// -- react bridge -------------------------------------------------------------
// Every contribution surface, plugin-reachable: register keybinds, palette
// commands, routes, themes, panes, composer extensions, and bar items with
// the same area ids + payload types core uses.
export { COMPOSER_AREAS, type ComposerAttachmentProvider, type ComposerMiddleware } from '@/app/chat/composer/contrib'
// -- ui: the design language --------------------------------------------------
export { PALETTE_AREA, type PaletteContribution } from '@/app/command-palette/contrib'
export { type RouteContribution, ROUTES_AREA, SIDEBAR_NAV_AREA, type SidebarNavContribution } from '@/app/routes'
export type { StatusbarItem } from '@/app/shell/statusbar-controls'
export type { TitlebarTool } from '@/app/shell/titlebar-controls'
export { StatusDot, type StatusTone } from '@/components/status-dot'
export { Badge } from '@/components/ui/badge'
export { Button } from '@/components/ui/button'
export { Checkbox } from '@/components/ui/checkbox'
export { Codicon } from '@/components/ui/codicon'
export { ConfirmDialog } from '@/components/ui/confirm-dialog'
export {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger
} from '@/components/ui/context-menu'
export { CopyButton } from '@/components/ui/copy-button'
export { DecodeText } from '@/components/ui/decode-text'
export {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger
} from '@/components/ui/dialog'
export {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
export { EmptyState } from '@/components/ui/empty-state'
export { ErrorState } from '@/components/ui/error-state'
export { GlyphSpinner } from '@/components/ui/glyph-spinner'
export { Input } from '@/components/ui/input'
export { Kbd, KbdGroup } from '@/components/ui/kbd'
/** The app's canonical loader (animated curves; `lemniscate-bloom` for long
* page loads) the same one every core page uses. */
export { Loader, type LoaderType } from '@/components/ui/loader'
export { LogView } from '@/components/ui/log-view'
export { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
export { ScrollArea } from '@/components/ui/scroll-area'
export { SearchField } from '@/components/ui/search-field'
export { SegmentedControl } from '@/components/ui/segmented-control'
export { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
export { Separator } from '@/components/ui/separator'
export { Skeleton } from '@/components/ui/skeleton'
export { Switch } from '@/components/ui/switch'
export { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
export { Textarea } from '@/components/ui/textarea'
export { Tip, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
export type { GatewayEventListener } from '@/contrib/events'
export type { HermesPlugin, PluginContext, PluginContribution, PluginRestOptions, PluginStorage } from '@/contrib/plugin'
// -- contracts ----------------------------------------------------------------
/** Mount-scoped contribution: while the rendering component is mounted, its
* children render in the target area's slot; unmount disposes it. Use for
* page-owned chrome (a page's titlebar control leaves with the page)
* `ctx.register` stays the door for permanent contributions. Namespace the
* id with your plugin slug (`kanban:board-switcher`). */
export { Contribute, type ContributeProps } from '@/contrib/react/contribute'
export type { Contribution } from '@/contrib/types'
/** Localized copy — plugins reuse the app's strings (and stay translatable). */
export { useI18n } from '@/i18n'
export { triggerHaptic as haptic } from '@/lib/haptics'
/** The app's lucide icon set (RefreshCw, LayoutDashboard, Activity, …). */
export * as icons from '@/lib/icons'
export { type KeybindContribution, KEYBINDS_AREA } from '@/lib/keybinds/actions'
/** The app's deterministic identity color for a name (profiles, assignees,
* authors) + its translucent tag fill so plugin-rendered identities read
* the same hue as everywhere else. */
export { profileColor, profileColorSoft } from '@/lib/profile-color'
/** The shared client itself, for invalidation OUTSIDE React (e.g. a
* `ctx.socket` frame invalidating a query). Inside components keep using
* `useQueryClient`. */
export { queryClient } from '@/lib/query-client'
export const PANES_AREA = 'panes'
export const STATUSBAR_AREAS = { left: 'statusBar.left', right: 'statusBar.right' } as const
export const TITLEBAR_AREAS = { center: 'titleBar.center', left: 'titleBar.left', right: 'titleBar.right' } as const
/** The app's own gateway-readiness evaluation (setup.status +
* setup.runtime_check, reconciled) pass `host.request`. Don't hand-roll
* readiness from raw RPC shapes. */
export { evaluateRuntimeReadiness, type RuntimeReadinessResult } from '@/lib/runtime-readiness'
/** Canonical time formatting every timestamp/age string in the app comes
* from these (localized `Intl` under the hood). Don't hand-roll "Xm ago". */
export { coarseElapsed, fmtDateTime, fmtDayTime, relativeTime } from '@/lib/time'
export { cn } from '@/lib/utils'
export { THEMES_AREA } from '@/themes/user-themes'
export type { RpcEvent, StatusResponse } from '@/types/hermes'
/** Subscribe a component to a `host.state` atom. */
export { useStore as useValue } from '@nanostores/react'
/** The app's data-fetching layer. Plugins share the ONE QueryClient mounted at
* the app root, so their queries cache, dedupe, poll (`refetchInterval`), and
* invalidate exactly like core screens no hand-rolled atoms or polls. */
export { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
/** Plugin-local reactive state (share between a trigger and its panel, poll
* loops, cross-component signals) the same primitive `host.state` uses. */
export { atom, computed } from 'nanostores'

View file

@ -0,0 +1,54 @@
/**
* Runtime SDK injection the other half of the vscode-module model. Bundled
* plugins resolve `@hermes/plugin-sdk` through the vite alias; RUNTIME-loaded
* plugins (disk / fetched) import the same specifier and get the same object:
* the loader rewrites bare specifiers to shim modules that re-export the
* live namespaces installed here. React ships as the app's singletons
* a second React instance would break hooks.
*/
import * as React from 'react'
import * as jsxDevRuntime from 'react/jsx-dev-runtime'
import * as jsxRuntime from 'react/jsx-runtime'
import * as sdk from './index'
const GLOBALS = {
__HERMES_PLUGIN_SDK__: sdk,
__HERMES_REACT__: React,
__HERMES_REACT_JSX__: jsxRuntime,
__HERMES_REACT_JSX_DEV__: jsxDevRuntime
} as const
export function installPluginSdk(): void {
Object.assign(globalThis, GLOBALS)
}
/** Build a shim ESM blob that re-exports a global namespace's live members.
* Export names come from the namespace itself, so the list can't drift. */
function shimUrl(globalKey: keyof typeof GLOBALS): string {
const names = Object.keys(GLOBALS[globalKey]).filter(name => name !== 'default' && /^[A-Za-z_$][\w$]*$/.test(name))
const source =
`const m = globalThis.${globalKey};\n` +
`export default m.default ?? m;\n` +
// Guard the destructuring: `export const { } = m` is a syntax error, so
// only emit it when the namespace actually has named exports.
(names.length ? `export const { ${names.join(', ')} } = m;\n` : '')
return URL.createObjectURL(new Blob([source], { type: 'text/javascript' }))
}
let cached: Record<string, string> | null = null
/** Specifier -> shim URL map for the runtime loader (longest keys first). */
export function sdkImportMap(): Record<string, string> {
cached ??= {
'@hermes/plugin-sdk': shimUrl('__HERMES_PLUGIN_SDK__'),
'react/jsx-dev-runtime': shimUrl('__HERMES_REACT_JSX_DEV__'),
'react/jsx-runtime': shimUrl('__HERMES_REACT_JSX__'),
react: shimUrl('__HERMES_REACT__')
}
return cached
}