From c14ae3796e31eed04afbe1bfa22376f7bfe693c3 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 28 Jul 2026 15:45:45 -0500 Subject: [PATCH] feat(desktop): fs-watch the plugin dir + demote the status snapshot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- apps/desktop/electron/main.ts | 41 +++++++++++++ apps/desktop/electron/preload.ts | 1 + .../app/shell/hooks/use-status-snapshot.ts | 26 +++++++- apps/desktop/src/contrib/runtime-loader.ts | 61 ++++++++++++++++--- apps/desktop/src/global.d.ts | 4 ++ 5 files changed, 124 insertions(+), 9 deletions(-) diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index 4111ed4721f6..59bc792c7c6d 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -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 diff --git a/apps/desktop/electron/preload.ts b/apps/desktop/electron/preload.ts index 815f048359b3..b110eb1670b7 100644 --- a/apps/desktop/electron/preload.ts +++ b/apps/desktop/electron/preload.ts @@ -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), diff --git a/apps/desktop/src/app/shell/hooks/use-status-snapshot.ts b/apps/desktop/src/app/shell/hooks/use-status-snapshot.ts index 7c531d9cf811..d2b714904b8b 100644 --- a/apps/desktop/src/app/shell/hooks/use-status-snapshot.ts +++ b/apps/desktop/src/app/shell/hooks/use-status-snapshot.ts @@ -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 = (method: string, params?: Record) => Promise @@ -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) diff --git a/apps/desktop/src/contrib/runtime-loader.ts b/apps/desktop/src/contrib/runtime-loader.ts index 11545d5a996d..8ac7c60d63fd 100644 --- a/apps/desktop/src/contrib/runtime-loader.ts +++ b/apps/desktop/src/contrib/runtime-loader.ts @@ -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 { 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 => { + 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) + }) } diff --git a/apps/desktop/src/global.d.ts b/apps/desktop/src/global.d.ts index 80c8104376e5..4a7eea4dbd8e 100644 --- a/apps/desktop/src/global.d.ts +++ b/apps/desktop/src/global.d.ts @@ -128,6 +128,10 @@ declare global { getPathForFile: (file: File) => string normalizePreviewTarget: (target: string, baseDir?: string) => Promise watchPreviewFile: (url: string) => Promise + /** 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 stopPreviewFileWatch: (id: string) => Promise setActiveWork?: (payload: HermesActiveWork) => void setTitleBarTheme?: (payload: HermesTitleBarTheme) => void