mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-29 18:46:59 +00:00
feat(desktop): fs-watch the plugin dir + demote the status snapshot
- watchDirectory IPC (same registry/channel as the preview file watchers) replaces the disk-plugin door's 5s readdir poll; older shells without the capability keep the poll, which self-upgrades to the watch once the plugins dir exists. - Status snapshot: 15s → 60s, skips round-trips while hidden, and refreshes immediately on visibilitychange so re-focus never shows stale health. Part of #73618.
This commit is contained in:
parent
fe64cafa33
commit
c14ae3796e
5 changed files with 124 additions and 9 deletions
|
|
@ -4927,6 +4927,45 @@ function closePreviewWatchers() {
|
|||
}
|
||||
}
|
||||
|
||||
/** Watch a DIRECTORY for entry churn (folders appearing/vanishing) — the
|
||||
* disk-plugin door's "new plugin folder" signal, replacing the renderer's 5s
|
||||
* readdir poll. Same registry + change channel as the preview file watchers
|
||||
* (the renderer reconciles on any tick; per-file edits stay on their own
|
||||
* watches), so stopPreviewFileWatch/closePreviewWatchers manage these too. */
|
||||
function watchDirectory(rawDir) {
|
||||
const watchDir = path.resolve(String(rawDir || ''))
|
||||
|
||||
if (!fs.existsSync(watchDir) || !fs.statSync(watchDir).isDirectory()) {
|
||||
throw new Error(`Not a directory: ${watchDir}`)
|
||||
}
|
||||
|
||||
const id = crypto.randomBytes(12).toString('base64url')
|
||||
let timer = null
|
||||
|
||||
const watcher = fs.watch(watchDir, () => {
|
||||
if (timer) {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
|
||||
timer = setTimeout(() => {
|
||||
timer = null
|
||||
sendPreviewFileChanged({ id, path: watchDir, url: pathToFileURL(watchDir).toString() })
|
||||
}, PREVIEW_WATCH_DEBOUNCE_MS)
|
||||
})
|
||||
|
||||
previewWatchers.set(id, {
|
||||
close: () => {
|
||||
if (timer) {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
|
||||
watcher.close()
|
||||
}
|
||||
})
|
||||
|
||||
return { id, path: watchDir }
|
||||
}
|
||||
|
||||
// Best-effort read of a gateway's advertised auth providers, cached per base
|
||||
// URL for the life of the process. Used by the oauth pre-flight guard to tell
|
||||
// a password-provider gateway (which cannot satisfy the bearer/cookie checks
|
||||
|
|
@ -10273,6 +10312,8 @@ ipcMain.handle('hermes:normalizePreviewTarget', (_event, target, baseDir) =>
|
|||
|
||||
ipcMain.handle('hermes:watchPreviewFile', (_event, url) => watchPreviewFile(String(url || '')))
|
||||
|
||||
ipcMain.handle('hermes:watchDirectory', (_event, dir) => watchDirectory(String(dir || '')))
|
||||
|
||||
ipcMain.handle('hermes:stopPreviewFileWatch', (_event, id) => stopPreviewFileWatch(String(id || '')))
|
||||
|
||||
// Each renderer reports the turns it has in flight; the quit guard reads the
|
||||
|
|
|
|||
|
|
@ -117,6 +117,7 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
|
|||
},
|
||||
normalizePreviewTarget: (target, baseDir) => ipcRenderer.invoke('hermes:normalizePreviewTarget', target, baseDir),
|
||||
watchPreviewFile: url => ipcRenderer.invoke('hermes:watchPreviewFile', url),
|
||||
watchDirectory: dir => ipcRenderer.invoke('hermes:watchDirectory', dir),
|
||||
stopPreviewFileWatch: id => ipcRenderer.invoke('hermes:stopPreviewFileWatch', id),
|
||||
setActiveWork: payload => ipcRenderer.send('hermes:active-work', payload),
|
||||
setTitleBarTheme: payload => ipcRenderer.send('hermes:titlebar-theme', payload),
|
||||
|
|
|
|||
|
|
@ -4,7 +4,11 @@ import { getStatus } from '@/hermes'
|
|||
import { evaluateRuntimeReadiness, type RuntimeReadinessResult } from '@/lib/runtime-readiness'
|
||||
import type { StatusResponse } from '@/types/hermes'
|
||||
|
||||
const REFRESH_MS = 15_000
|
||||
// Statusbar health is ambient chrome, not live data — nothing the user acts on
|
||||
// within seconds. 60s + a hidden-tab skip keeps it honest at a quarter of the
|
||||
// old traffic; the visibility listener refreshes immediately on return so a
|
||||
// backgrounded window never shows stale health after re-focus.
|
||||
const REFRESH_MS = 60_000
|
||||
|
||||
type GatewayRequester = <T = unknown>(method: string, params?: Record<string, unknown>) => Promise<T>
|
||||
|
||||
|
|
@ -30,6 +34,14 @@ export function useStatusSnapshot(gatewayState: string | undefined, requestGatew
|
|||
}
|
||||
|
||||
const refresh = async () => {
|
||||
// Hidden window: skip the round-trips, keep the timer alive; the
|
||||
// visibilitychange listener repaints immediately on return.
|
||||
if (document.visibilityState !== 'visible') {
|
||||
scheduleRefresh()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// Wait for both legs before scheduling the next refresh. setInterval
|
||||
// allowed a slow runtime check to overlap with later polls, which
|
||||
|
|
@ -67,10 +79,22 @@ export function useStatusSnapshot(gatewayState: string | undefined, requestGatew
|
|||
}
|
||||
}
|
||||
|
||||
const onVisible = () => {
|
||||
if (document.visibilityState === 'visible' && !cancelled) {
|
||||
if (timer !== undefined) {
|
||||
window.clearTimeout(timer)
|
||||
}
|
||||
|
||||
void refresh()
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('visibilitychange', onVisible)
|
||||
void refresh()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
document.removeEventListener('visibilitychange', onVisible)
|
||||
|
||||
if (timer !== undefined) {
|
||||
window.clearTimeout(timer)
|
||||
|
|
|
|||
|
|
@ -183,8 +183,9 @@ export async function loadRuntimePlugin(
|
|||
// (agent- or user-written). SELF-MAINTAINING — no reload ceremony:
|
||||
// - each plugin.js is fs-watched (the preview watcher IPC, debounced in
|
||||
// main): saving the file hot-reloads the plugin in place;
|
||||
// - a slow visible-tab poll of the directory picks up new folders (load +
|
||||
// watch) and removed ones (unload + unwatch).
|
||||
// - the directory itself is fs-watched too (watchDirectory IPC), so new
|
||||
// folders load + removed ones unload on the change tick; older Electron
|
||||
// shells without watchDirectory fall back to the slow visible-tab poll.
|
||||
// Panes land via placement adoption and STAY where the user drags them —
|
||||
// the tree treats not-yet-loaded pane ids as hidden, so boot and reload are
|
||||
// collapse -> appear, never a placeholder flash.
|
||||
|
|
@ -307,7 +308,7 @@ async function scanDiskPlugins(): Promise<void> {
|
|||
export const discoverRuntimePlugins = scanDiskPlugins
|
||||
|
||||
/** Start the self-maintaining disk door: initial scan, per-file hot reload,
|
||||
* slow folder reconciliation while the window is visible. Idempotent. */
|
||||
* fs-watched folder reconciliation (poll fallback on older shells). Idempotent. */
|
||||
export function watchRuntimePlugins(): void {
|
||||
const desktop = window.hermesDesktop
|
||||
|
||||
|
|
@ -317,7 +318,16 @@ export function watchRuntimePlugins(): void {
|
|||
|
||||
watching = true
|
||||
|
||||
let dirWatchId: null | string = null
|
||||
|
||||
desktop.onPreviewFileChanged(({ id }) => {
|
||||
// Directory tick: a plugin folder appeared or vanished — reconcile.
|
||||
if (dirWatchId && id === dirWatchId) {
|
||||
void scanDiskPlugins()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
for (const [name, record] of disk) {
|
||||
if (record.watchId === id) {
|
||||
void loadDiskPlugin(name, record.file)
|
||||
|
|
@ -327,10 +337,45 @@ export function watchRuntimePlugins(): void {
|
|||
}
|
||||
})
|
||||
|
||||
void scanDiskPlugins()
|
||||
window.setInterval(() => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
void scanDiskPlugins()
|
||||
const startDirWatch = async (): Promise<boolean> => {
|
||||
if (!desktop.watchDirectory) {
|
||||
return false
|
||||
}
|
||||
}, DISK_POLL_MS)
|
||||
|
||||
try {
|
||||
const { hermes_home } = await getStatus()
|
||||
dirWatchId = (await desktop.watchDirectory(`${hermes_home}/desktop-plugins`)).id
|
||||
|
||||
return true
|
||||
} catch {
|
||||
// Dir missing (no plugins yet) or unwatchable — fall back to the poll,
|
||||
// which also handles the dir being created later.
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
void scanDiskPlugins()
|
||||
void startDirWatch().then(watched => {
|
||||
if (watched) {
|
||||
return
|
||||
}
|
||||
|
||||
const timer = window.setInterval(() => {
|
||||
if (document.visibilityState !== 'visible') {
|
||||
return
|
||||
}
|
||||
|
||||
void scanDiskPlugins()
|
||||
|
||||
// The dir may have been created since — upgrade to the watch and retire
|
||||
// this poll once it lands.
|
||||
if (dirWatchId === null) {
|
||||
void startDirWatch().then(upgraded => {
|
||||
if (upgraded) {
|
||||
window.clearInterval(timer)
|
||||
}
|
||||
})
|
||||
}
|
||||
}, DISK_POLL_MS)
|
||||
})
|
||||
}
|
||||
|
|
|
|||
4
apps/desktop/src/global.d.ts
vendored
4
apps/desktop/src/global.d.ts
vendored
|
|
@ -128,6 +128,10 @@ declare global {
|
|||
getPathForFile: (file: File) => string
|
||||
normalizePreviewTarget: (target: string, baseDir?: string) => Promise<HermesPreviewTarget | null>
|
||||
watchPreviewFile: (url: string) => Promise<HermesPreviewWatch>
|
||||
/** Watch a directory for entry churn (disk-plugin door); same watcher
|
||||
* registry + onPreviewFileChanged channel as watchPreviewFile. Optional:
|
||||
* older Electron shells predate it and fall back to the readdir poll. */
|
||||
watchDirectory?: (dir: string) => Promise<HermesPreviewWatch>
|
||||
stopPreviewFileWatch: (id: string) => Promise<boolean>
|
||||
setActiveWork?: (payload: HermesActiveWork) => void
|
||||
setTitleBarTheme?: (payload: HermesTitleBarTheme) => void
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue