mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-28 18:19:28 +00:00
feat(ui-tui): self-authored widgets — user-widget loader, hot-load, skill
Hermes can write its own widgets: a loader discovers $HERMES_HOME/tui-widgets/*.mjs, fs.watch hot-loads them the moment they land (no restart), and a tui-widgets skill teaches the agent the contract and the openWidget-at-register auto-open recipe. Load/error/remove events announce themselves in the transcript; a lazy intro skeleton covers the first paint.
This commit is contained in:
parent
76b58d6127
commit
ca0b1b130c
11 changed files with 493 additions and 2 deletions
107
skills/productivity/tui-widgets/SKILL.md
Normal file
107
skills/productivity/tui-widgets/SKILL.md
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
---
|
||||
name: tui-widgets
|
||||
description: Author live widget apps for the Hermes TUI dock.
|
||||
version: 1.0.0
|
||||
author: Hermes Agent
|
||||
license: MIT
|
||||
metadata:
|
||||
hermes:
|
||||
tags: [tui, widgets, sdk, ui]
|
||||
category: productivity
|
||||
---
|
||||
|
||||
# TUI Widgets Skill
|
||||
|
||||
Author widget apps for the Hermes TUI (`hermes --tui`): glanceable ambient
|
||||
panels docked above the status bar, or modal overlays that own the keyboard.
|
||||
Widgets are plain ESM files the TUI loads at startup — no build step, no
|
||||
repo changes. This skill does not cover desktop-app or web-dashboard
|
||||
widgets.
|
||||
|
||||
## When to Use
|
||||
|
||||
- The user asks for a live panel in the TUI (ticker, clock, countdown,
|
||||
status card, API-backed readout).
|
||||
- The user wants a custom modal tool (picker, calculator, viewer) bound to
|
||||
a slash command.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- The TUI must be in use (`hermes --tui`). Widgets do not render in the
|
||||
classic CLI or messaging platforms.
|
||||
- Network-backed widgets need whatever credentials their API needs; fetch
|
||||
failures must land as an error phase, never a crash.
|
||||
|
||||
## How to Run
|
||||
|
||||
1. Use `write_file` to create `~/.hermes/tui-widgets/<name>.mjs` (see
|
||||
`templates/clock.mjs` for a complete working widget).
|
||||
2. If the TUI is running it hot-loads the file within ~a second (the
|
||||
widgets directory is watched); `/widgets-reload` forces a rescan.
|
||||
3. The widget's id becomes its slash command automatically (`/<id>`), with
|
||||
its `help` in the `/` completion popover. No other registration exists.
|
||||
|
||||
## Quick Reference
|
||||
|
||||
A widget file default-exports `register(sdk)`:
|
||||
|
||||
```js
|
||||
export default function register(sdk) {
|
||||
const { Box, Text, defineWidgetApp, h } = sdk
|
||||
|
||||
defineWidgetApp({
|
||||
id: 'clock', // slash command name
|
||||
help: 'live clock in the dock', // `/` completion metadata
|
||||
mode: 'ambient', // 'ambient' docks; 'modal' takes input
|
||||
init: arg => ({ label: arg.trim() || 'UTC' }), // null = print usage
|
||||
reduce: (state, { ch, key }) => (key.escape || ch === 'q' ? null : state),
|
||||
render: ({ state, t }) => h(sdk.Dialog, { width: 24 }, h(Text, { color: t.color.label }, state.label))
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
`sdk` contents: `defineWidgetApp`, `openWidget`, `updateWidget`, `isCtrl`,
|
||||
`React`, `h` (createElement — no JSX in .mjs), components `Box`, `Text`,
|
||||
`Dialog`, `Overlay`, `WidgetGrid`, `GridAreas`.
|
||||
|
||||
Contract essentials:
|
||||
|
||||
- `mode: 'ambient'` — docks above the status bar, captures no input, the
|
||||
command toggles it; `render` returns a CARD (usually `Dialog`), never
|
||||
`Overlay`.
|
||||
- `mode: 'modal'` (default) — owns every keypress; `reduce` returns next
|
||||
state, the same reference to swallow a key, or `null` to close; `render`
|
||||
wraps content in `Overlay` for placement.
|
||||
- Async data: fire the fetch from `init`, land results with
|
||||
`sdk.updateWidget(app, fn)` — it no-ops if the widget was closed, so a
|
||||
late reply can never resurrect it.
|
||||
- Animation: own a timer inside a component via `React.useState` +
|
||||
`React.useEffect` (see the template); keep intervals ≥ 250ms.
|
||||
- Colors: ALWAYS theme tones (`t.color.primary/label/muted/ok/error/…`),
|
||||
never hardcoded hexes — widgets must survive `/skin` and light/dark.
|
||||
|
||||
## Procedure
|
||||
|
||||
1. Pick `id`, `mode`, and the state shape; keep state serializable.
|
||||
2. Write the file from the template; wire data via `init` + `updateWidget`.
|
||||
3. `/<id>` to launch (hot-loaded on write); relaunch `/<id>` to dismiss an
|
||||
ambient widget.
|
||||
4. Iterate: edit the file — it hot-reloads on save (last-writer-wins, the
|
||||
fresh definition shadows the old one). Relaunch `/<id>` to remount.
|
||||
|
||||
## Pitfalls
|
||||
|
||||
- No JSX and no bare imports in `.mjs` — everything comes from the `sdk`
|
||||
parameter; `h(...)` builds elements.
|
||||
- Don't ship a modal without a close path (`Esc`/`q` returning `null`).
|
||||
- Ambient widgets must stay small (≤ ~6 rows) — the dock sits between the
|
||||
transcript and the status bar.
|
||||
- A thrown `register()` is logged and skipped; check
|
||||
`~/.hermes/logs/tui_gateway_crash.log` if a widget never appears.
|
||||
|
||||
## Verification
|
||||
|
||||
Run `/widgets-reload` — the transcript line must list the file under
|
||||
`loaded:`. Then `/<id>`: an ambient widget appears docked right, above the
|
||||
status bar, while the composer keeps accepting input; `/<id>` again removes
|
||||
it.
|
||||
51
skills/productivity/tui-widgets/templates/clock.mjs
Normal file
51
skills/productivity/tui-widgets/templates/clock.mjs
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
/**
|
||||
* Reference user widget: a live clock docked above the status bar.
|
||||
* Copy to ~/.hermes/tui-widgets/clock.mjs, then `/widgets-reload` and `/clock`.
|
||||
*/
|
||||
export default function register(sdk) {
|
||||
const { Box, Dialog, React, Text, defineWidgetApp, h } = sdk
|
||||
|
||||
function Face({ label, t }) {
|
||||
const [now, setNow] = React.useState(() => new Date())
|
||||
|
||||
React.useEffect(() => {
|
||||
const id = setInterval(() => setNow(new Date()), 1000)
|
||||
|
||||
return () => clearInterval(id)
|
||||
}, [])
|
||||
|
||||
return h(
|
||||
Box,
|
||||
{ columnGap: 1, flexDirection: 'row' },
|
||||
h(Text, { bold: true, color: t.color.label }, label),
|
||||
h(Text, { color: t.color.text }, now.toLocaleTimeString('en-GB', { hour12: false, timeZone: label === 'local' ? undefined : label }))
|
||||
)
|
||||
}
|
||||
|
||||
defineWidgetApp({
|
||||
id: 'clock',
|
||||
help: 'live clock in the dock (arg: IANA timezone)',
|
||||
mode: 'ambient',
|
||||
usage: 'usage: /clock [timezone] e.g. /clock UTC · /clock Asia/Tokyo',
|
||||
|
||||
init(arg) {
|
||||
const label = arg.trim() || 'local'
|
||||
|
||||
try {
|
||||
new Date().toLocaleTimeString('en-GB', { timeZone: label === 'local' ? undefined : label })
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
return { label }
|
||||
},
|
||||
|
||||
reduce(state, { ch, key }) {
|
||||
return key.escape || ch === 'q' ? null : state
|
||||
},
|
||||
|
||||
render({ state, t }) {
|
||||
return h(Dialog, { width: 30 }, h(Face, { label: state.label, t }))
|
||||
}
|
||||
})
|
||||
}
|
||||
67
ui-tui/src/__tests__/userWidgets.test.ts
Normal file
67
ui-tui/src/__tests__/userWidgets.test.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import { mkdtemp, rm, writeFile } from 'fs/promises'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { getOverlayState, resetOverlayState } from '../app/overlayStore.js'
|
||||
import { launchWidget } from '../sdk/host.js'
|
||||
import { getWidgetApp } from '../sdk/registry.js'
|
||||
import { loadUserWidgets } from '../sdk/userWidgets.js'
|
||||
|
||||
const WIDGET = `
|
||||
export default function register(sdk) {
|
||||
sdk.defineWidgetApp({
|
||||
id: 'test-user-widget',
|
||||
help: 'from disk',
|
||||
mode: 'ambient',
|
||||
init: arg => ({ arg }),
|
||||
reduce: state => state,
|
||||
render: ({ state, t }) => sdk.h(sdk.Text, { color: t.color.label }, state.arg)
|
||||
})
|
||||
}
|
||||
`
|
||||
|
||||
beforeEach(() => resetOverlayState())
|
||||
|
||||
describe('user widget loading', () => {
|
||||
it('missing directory is a clean no-op', async () => {
|
||||
const result = await loadUserWidgets(join(tmpdir(), 'definitely-missing-widgets-dir'))
|
||||
|
||||
expect(result).toEqual({ added: [], errors: [], loaded: [], removed: [] })
|
||||
})
|
||||
|
||||
it('loads .mjs from disk, registers, dispatches, and reports broken files', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'tui-widgets-'))
|
||||
|
||||
await writeFile(join(dir, 'good.mjs'), WIDGET)
|
||||
await writeFile(join(dir, 'broken.mjs'), 'export default 42')
|
||||
await writeFile(join(dir, 'ignored.txt'), 'not a widget')
|
||||
|
||||
const result = await loadUserWidgets(dir)
|
||||
|
||||
expect(result.loaded).toEqual(['good.mjs'])
|
||||
expect(result.added).toEqual(['test-user-widget'])
|
||||
expect(result.errors).toMatchObject([{ file: 'broken.mjs' }])
|
||||
|
||||
// Registered like any built-in: catalog metadata + launchable.
|
||||
expect(getWidgetApp('test-user-widget')).toMatchObject({ help: 'from disk', mode: 'ambient' })
|
||||
expect(launchWidget('test-user-widget', 'hi')).toBeNull()
|
||||
expect(getOverlayState().ambient).toMatchObject([{ appId: 'test-user-widget', state: { arg: 'hi' } }])
|
||||
})
|
||||
|
||||
it('a deleted file unregisters its apps on the next scan', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'tui-widgets-'))
|
||||
const file = join(dir, 'gone.mjs')
|
||||
|
||||
await writeFile(file, WIDGET.replace('test-user-widget', 'soon-gone'))
|
||||
await loadUserWidgets(dir)
|
||||
expect(getWidgetApp('soon-gone')).toBeDefined()
|
||||
|
||||
await rm(file)
|
||||
const result = await loadUserWidgets(dir)
|
||||
|
||||
expect(result.removed).toEqual(['soon-gone'])
|
||||
expect(getWidgetApp('soon-gone')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
|
@ -1,6 +1,8 @@
|
|||
import { parseSlashCommand } from '../domain/slash.js'
|
||||
import type { SlashExecResponse } from '../gatewayTypes.js'
|
||||
import { asCommandDispatch, rpcErrorMessage } from '../lib/rpc.js'
|
||||
import { launchWidget } from '../sdk/host.js'
|
||||
import { getWidgetApp } from '../sdk/registry.js'
|
||||
|
||||
import type { SlashHandlerContext } from './interfaces.js'
|
||||
import { findSlashCommand } from './slash/registry.js'
|
||||
|
|
@ -45,6 +47,19 @@ export function createSlashHandler(ctx: SlashHandlerContext): (cmd: string) => b
|
|||
return true
|
||||
}
|
||||
|
||||
// Registry-first fallback: widget apps registered AFTER the static
|
||||
// command table was built (user widgets from $HERMES_HOME/tui-widgets,
|
||||
// /widgets-reload) dispatch straight off the live registry.
|
||||
if (getWidgetApp(parsed.name)) {
|
||||
const err = launchWidget(parsed.name, parsed.arg)
|
||||
|
||||
if (err) {
|
||||
sys(err)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
if (catalog?.canon) {
|
||||
const needle = `/${parsed.name}`.toLowerCase()
|
||||
const exact = Object.entries(catalog.canon).find(([alias]) => alias.toLowerCase() === needle)?.[1]
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { terminalBackgroundHex } from '@hermes/ink'
|
|||
import { formatBytes, performHeapDump } from '../../../lib/memory.js'
|
||||
import { launchWidget } from '../../../sdk/host.js'
|
||||
import { listWidgetApps } from '../../../sdk/registry.js'
|
||||
import { loadUserWidgets } from '../../../sdk/userWidgets.js'
|
||||
import { detectLightMode } from '../../../theme.js'
|
||||
import { getUiState } from '../../uiStore.js'
|
||||
import type { SlashCommand } from '../types.js'
|
||||
|
|
@ -28,6 +29,21 @@ export const widgetAppCommands: SlashCommand[] = listWidgetApps().map(app => ({
|
|||
export const debugCommands: SlashCommand[] = [
|
||||
...widgetAppCommands,
|
||||
|
||||
{
|
||||
help: 'rescan $HERMES_HOME/tui-widgets and (re)register user widget apps',
|
||||
name: 'widgets-reload',
|
||||
run: (_arg, ctx) => {
|
||||
void loadUserWidgets().then(({ errors, loaded }) => {
|
||||
const parts = [
|
||||
loaded.length ? `loaded: ${loaded.join(', ')}` : 'no user widgets found',
|
||||
...errors.map(e => `${e.file}: ${e.message}`)
|
||||
]
|
||||
|
||||
ctx.transcript.sys(`widgets — ${parts.join(' · ')}`)
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
help: 'write a V8 heap snapshot + memory diagnostics (see HERMES_HEAPDUMP_DIR)',
|
||||
name: 'heapdump',
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ import { asRpcResult, rpcErrorMessage } from '../lib/rpc.js'
|
|||
import { terminalParityHints } from '../lib/terminalParity.js'
|
||||
import { buildToolTrailLine, formatAbandonedClarify, sameToolTrailGroup, toolTrailLabel } from '../lib/text.js'
|
||||
import { estimatedMsgHeight, messageHeightKey } from '../lib/virtualHeights.js'
|
||||
import { onUserWidgets } from '../sdk/userWidgets.js'
|
||||
import type { Msg, PanelSection, SlashCatalog } from '../types.js'
|
||||
|
||||
import { createGatewayEventHandler } from './createGatewayEventHandler.js'
|
||||
|
|
@ -435,6 +436,26 @@ export function useMainApp(gw: GatewayClient) {
|
|||
|
||||
const sys = useCallback((text: string) => appendMessage({ role: 'system', text }), [appendMessage])
|
||||
|
||||
// Hot-loaded user widgets announce themselves — a silently-registered
|
||||
// widget is indistinguishable from a failed one. Errors surface too.
|
||||
useEffect(
|
||||
() =>
|
||||
onUserWidgets(({ added, errors, removed }) => {
|
||||
for (const id of added) {
|
||||
sys(`widget /${id} is live — type /${id} to open`)
|
||||
}
|
||||
|
||||
for (const id of removed) {
|
||||
sys(`widget /${id} removed (file deleted)`)
|
||||
}
|
||||
|
||||
for (const err of errors) {
|
||||
sys(`widget ${err.file} failed to load: ${err.message}`)
|
||||
}
|
||||
}),
|
||||
[sys]
|
||||
)
|
||||
|
||||
const page = useCallback(
|
||||
(text: string, title?: string) => patchOverlayState({ pager: { lines: text.split('\n'), offset: 0, title } }),
|
||||
[]
|
||||
|
|
|
|||
|
|
@ -1,5 +1,11 @@
|
|||
/** Reference apps. Importing this module registers them (defineWidgetApp
|
||||
* runs at module load) — appLayout imports it once at startup. */
|
||||
* runs at module load) — appLayout imports it once at startup. User widgets
|
||||
* from $HERMES_HOME/tui-widgets ride the same import (async, non-fatal). */
|
||||
import { loadUserWidgets, watchUserWidgets } from '../userWidgets.js'
|
||||
|
||||
void loadUserWidgets()
|
||||
watchUserWidgets()
|
||||
|
||||
export { dialogTestApp } from './dialogTest.js'
|
||||
export { gridTestApp } from './gridTest.js'
|
||||
export { GRID_STREAM_COUNT, type GridTestState } from './gridTestState.js'
|
||||
|
|
|
|||
|
|
@ -164,8 +164,10 @@ export function AmbientDock(): ReactNode {
|
|||
|
||||
const ctx = { cols: stdout?.columns ?? 80, rows: stdout?.rows ?? 24, t: t as never }
|
||||
|
||||
// paddingRight keeps card borders off the terminal's last column — an
|
||||
// exact-edge border char trips pending-wrap and reads as a clipped border.
|
||||
return (
|
||||
<Box columnGap={1} flexDirection="row" justifyContent="flex-end" width="100%">
|
||||
<Box columnGap={1} flexDirection="row" justifyContent="flex-end" paddingRight={2} width="100%">
|
||||
{overlay.ambient.map(active => (
|
||||
<Box key={active.appId}>{renderApp(active, ctx)}</Box>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -46,3 +46,4 @@ export type { Theme, ThemeColors } from '../theme.js'
|
|||
export { ActiveWidgetSlot, closeWidget, dispatchWidgetInput, launchWidget, openWidget, updateWidget } from './host.js'
|
||||
export { defineWidgetApp, getWidgetApp, listWidgetApps } from './registry.js'
|
||||
export { type ActiveWidget, isCtrl, type WidgetApp, type WidgetInput, type WidgetRenderCtx } from './types.js'
|
||||
export { loadUserWidgets, type UserWidgetLoadResult, widgetSdk, type WidgetSdk } from './userWidgets.js'
|
||||
|
|
|
|||
|
|
@ -12,6 +12,9 @@ export function defineWidgetApp<S>(app: WidgetApp<S>): WidgetApp<S> {
|
|||
|
||||
export const getWidgetApp = (id: string): undefined | WidgetApp<never> => apps.get(id)
|
||||
|
||||
/** Unregister (user-widget file deleted). Built-ins never call this. */
|
||||
export const removeWidgetApp = (id: string): boolean => apps.delete(id)
|
||||
|
||||
/** All registered apps, id-sorted — the registry IS the catalog: slash
|
||||
* commands and `/` completions derive from it, nothing is hardcoded. */
|
||||
export const listWidgetApps = (): WidgetApp<never>[] => [...apps.values()].sort((a, b) => a.id.localeCompare(b.id))
|
||||
|
|
|
|||
202
ui-tui/src/sdk/userWidgets.ts
Normal file
202
ui-tui/src/sdk/userWidgets.ts
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
import { watch } from 'fs'
|
||||
import { readdir } from 'fs/promises'
|
||||
import { homedir } from 'os'
|
||||
import { dirname, join } from 'path'
|
||||
import { pathToFileURL } from 'url'
|
||||
|
||||
import { Box, Text } from '@hermes/ink'
|
||||
import * as React from 'react'
|
||||
|
||||
import { Dialog, Overlay } from '../components/overlay.js'
|
||||
import { GridAreas, WidgetGrid } from '../components/widgetGrid.js'
|
||||
import { recordParentLifecycle } from '../lib/parentLog.js'
|
||||
|
||||
import { openWidget, updateWidget } from './host.js'
|
||||
import { defineWidgetApp, listWidgetApps, removeWidgetApp } from './registry.js'
|
||||
import { isCtrl } from './types.js'
|
||||
|
||||
/**
|
||||
* User widget apps — Hermes authors its own TUI widgets, mirroring the
|
||||
* Python plugin contract: drop `<name>.mjs` into `$HERMES_HOME/tui-widgets/`,
|
||||
* default-export `register(sdk)`, and the app surfaces in `/` completions
|
||||
* and dispatch automatically (the registry is the catalog). Plain ESM so the
|
||||
* production bundle can import it — no bundler, no JSX; `sdk.h` is
|
||||
* React.createElement.
|
||||
*
|
||||
* Trust model matches `~/.hermes/plugins/`: files under HERMES_HOME execute
|
||||
* with the TUI's privileges. Load errors log and skip — a broken widget
|
||||
* never takes the TUI down.
|
||||
*/
|
||||
|
||||
/** Everything a user widget may touch, passed INTO its register() — user
|
||||
* files have no resolvable import path to the bundle. */
|
||||
export const widgetSdk = {
|
||||
Box,
|
||||
Dialog,
|
||||
GridAreas,
|
||||
Overlay,
|
||||
React,
|
||||
Text,
|
||||
WidgetGrid,
|
||||
defineWidgetApp,
|
||||
h: React.createElement,
|
||||
isCtrl,
|
||||
openWidget,
|
||||
updateWidget
|
||||
} as const
|
||||
|
||||
export type WidgetSdk = typeof widgetSdk
|
||||
|
||||
const widgetsDir = () => join(process.env.HERMES_HOME?.trim() || join(homedir(), '.hermes'), 'tui-widgets')
|
||||
|
||||
export interface UserWidgetLoadResult {
|
||||
/** App ids newly registered by this scan. */
|
||||
added: string[]
|
||||
errors: { file: string; message: string }[]
|
||||
loaded: string[]
|
||||
/** App ids unregistered because their file disappeared. */
|
||||
removed: string[]
|
||||
}
|
||||
|
||||
/** Which app ids each user file registered — the delete-sync source of
|
||||
* truth (file gone on the next scan ⇒ its apps unregister). */
|
||||
const fileApps = new Map<string, string[]>()
|
||||
|
||||
const listeners = new Set<(result: UserWidgetLoadResult) => void>()
|
||||
|
||||
/** Subscribe to scan results — the app layer announces loads in the
|
||||
* transcript so a hot-loaded widget is VISIBLY live (silent success is
|
||||
* indistinguishable from failure). */
|
||||
export function onUserWidgets(listener: (result: UserWidgetLoadResult) => void): () => void {
|
||||
listeners.add(listener)
|
||||
|
||||
return () => listeners.delete(listener)
|
||||
}
|
||||
|
||||
/** Scan + import + register, diffing the registry per file. Cache-busted so
|
||||
* edits reload without restarting the TUI (last-writer-wins shadows stale
|
||||
* definitions). Files that vanished unregister their apps. */
|
||||
export async function loadUserWidgets(dir = widgetsDir()): Promise<UserWidgetLoadResult> {
|
||||
const result: UserWidgetLoadResult = { added: [], errors: [], loaded: [], removed: [] }
|
||||
|
||||
let files: string[] = []
|
||||
|
||||
try {
|
||||
files = (await readdir(dir)).filter(f => f.endsWith('.mjs')).sort()
|
||||
} catch {
|
||||
// No directory: fall through so previously-loaded files still delete-sync.
|
||||
}
|
||||
|
||||
for (const [file, ids] of fileApps) {
|
||||
if (!files.includes(file)) {
|
||||
fileApps.delete(file)
|
||||
|
||||
for (const id of ids) {
|
||||
if (removeWidgetApp(id)) {
|
||||
result.removed.push(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const file of files) {
|
||||
const before = new Set(listWidgetApps().map(app => app.id))
|
||||
|
||||
try {
|
||||
const mod = (await import(`${pathToFileURL(join(dir, file)).href}?t=${Date.now()}`)) as {
|
||||
default?: (sdk: WidgetSdk) => void
|
||||
}
|
||||
|
||||
if (typeof mod.default !== 'function') {
|
||||
throw new Error('default export must be register(sdk)')
|
||||
}
|
||||
|
||||
mod.default(widgetSdk)
|
||||
result.loaded.push(file)
|
||||
|
||||
const ids = listWidgetApps()
|
||||
.map(app => app.id)
|
||||
.filter(id => !before.has(id))
|
||||
|
||||
// Re-registrations of existing ids keep their prior file attribution.
|
||||
if (ids.length) {
|
||||
fileApps.set(file, ids)
|
||||
result.added.push(...ids)
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
|
||||
result.errors.push({ file, message })
|
||||
recordParentLifecycle(`user widget ${file} failed to load: ${message}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (result.added.length) {
|
||||
recordParentLifecycle(`user widgets registered: ${result.added.join(', ')}`)
|
||||
}
|
||||
|
||||
for (const listener of listeners) {
|
||||
listener(result)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
let watching = false
|
||||
|
||||
/** Generative-UI hot loading: watch the widgets directory and re-scan on
|
||||
* every change, so a widget Hermes writes appears within ~a second — no
|
||||
* `/widgets-reload`, no restart (GUI parity). Debounced (editors and
|
||||
* write_file emit bursts); polls until the directory exists so the very
|
||||
* first widget ever written also hot-loads. */
|
||||
export function watchUserWidgets(dir = widgetsDir()): void {
|
||||
if (watching) {
|
||||
return
|
||||
}
|
||||
|
||||
watching = true
|
||||
|
||||
let timer: NodeJS.Timeout | undefined
|
||||
|
||||
const attach = () => {
|
||||
try {
|
||||
const watcher = watch(dir, () => {
|
||||
clearTimeout(timer)
|
||||
timer = setTimeout(() => void loadUserWidgets(dir), 300)
|
||||
timer.unref?.()
|
||||
})
|
||||
|
||||
watcher.unref?.()
|
||||
|
||||
return true
|
||||
} catch {
|
||||
return false // directory doesn't exist yet
|
||||
}
|
||||
}
|
||||
|
||||
if (!attach()) {
|
||||
// Event-driven first-creation: watch the PARENT for the widgets dir to
|
||||
// appear, attach + scan the instant it does. The very first widget a
|
||||
// user (or Hermes) ever writes must hot-load too — a 10s poll here read
|
||||
// as "requires a restart" in live use.
|
||||
try {
|
||||
const parent = watch(dirname(dir), () => {
|
||||
if (attach()) {
|
||||
parent.close()
|
||||
void loadUserWidgets(dir)
|
||||
}
|
||||
})
|
||||
|
||||
parent.unref?.()
|
||||
} catch {
|
||||
const poll = setInterval(() => {
|
||||
if (attach()) {
|
||||
clearInterval(poll)
|
||||
void loadUserWidgets(dir)
|
||||
}
|
||||
}, 2_000)
|
||||
|
||||
poll.unref?.()
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue