The unified agent-plugin root now loads its desktop halves disabled by
default — inventoried in Settings → Plugins, off until the user toggles
— so ~/.hermes/plugins keeps its installed-but-inert posture
(GHSA-mcfc-hp25-cjv7) on the desktop side too. The root-level cap only
lowers a plugin's own defaultEnabled; an explicit user enable still wins.
Also guards the folder-named error-record drop: with two roots, a broken
plugin folder can share its name with a healthy plugin's id from the
other root, and the unconditional drop clobbered the healthy inventory
row.
The disk-plugin door now scans two Electron-local roots through one
pipeline: the standalone <HERMES_HOME>/desktop-plugins/<name>/plugin.js
door, and <HERMES_HOME>/plugins/<name>/desktop/plugin.js — the desktop
half of a regular agent-plugin package. A feature that needs both SDKs
ships as one installable folder instead of two co-dependent plugins.
Records are keyed by entry-file path (folder names can collide across
roots), each root gets its own fs watch with the poll staying alive
until every root is covered, and older Electron shells without the new
agentPluginsRoot resolver simply skip the unified root.
HermesPlugin/PluginRecord gain a description one-liner (kanban gets
one) shown in the inventory instead of the raw file path, and the
agent plugins section can open the backend's plugins dir — path from
config.get profile so it's profile-aware, local backends only since
openDir mkdir-creates.
Fold ctx.notifyNative into a ctx.os namespace so every way a plugin
reaches outside the app window lives behind one attributed door instead
of accreting one top-level ctx method per capability:
- ctx.os.notify — the native-notification door from the previous commit,
unchanged semantics (plugin kind pref, away-gating, per-plugin throttle).
- ctx.os.openExternal / ctx.os.revealPath / ctx.os.writeClipboard — the
existing window.hermesDesktop bridge capabilities, now sanctioned and
result-shaped: each resolves false (never throws) when the bridge or
member is missing, so a plugin branches on the result instead of
sniffing the preload surface or crashing on an older shell.
No new Electron surface: everything routes through bridge members the
app already ships; the notification path keeps every existing gate.
Desktop plugins can toast in-app (host.notify) but have no sanctioned way to
reach the OS notification pipeline the app's own approval/turn alerts use, so
a plugin surfacing a genuinely notable background event (e.g. a discovery
plugin finding a match) stays invisible once the user steps away from Hermes.
Add a curated per-plugin door instead of exporting the raw dispatcher:
- ctx.notifyNative({ title, body?, silent? }) on PluginContext — attributed
to the plugin id, routed through dispatchNativeNotification so every
existing gate applies (master + per-kind prefs, post-connect baseline,
away-from-app gating, throttle).
- New 'plugin' native-notification kind with its own Settings ▸ Notifications
toggle (default on), so users silence plugins without losing app alerts.
- New optional `tag` discriminator on the notify payload keys the renderer
throttle and main-process cross-window dedupe per plugin, so two plugins
can't collapse each other's session-less notifications.
Consumer: the Index Network desktop plugin wants background opportunity
alerts; anything in ~/.hermes/desktop-plugins gets the same door.
The plugin context only tracked contribution/socket disposers, so a plugin's
other side effects (store subscriptions) leaked across disable/re-enable. Add
ctx.onDispose(fn) — an arbitrary cleanup collected alongside the rest and run
on deactivate. bindApi now returns a disposer (unsubscribes its persisted-atom
listeners, closes the socket, drops the rest handle) and the kanban plugin
registers it via ctx.onDispose, so a toggle leaves nothing behind and never
duplicates listeners. Also DRYs the atom-persistence into one `persist` helper.
Follow-ups on top of #66911's salvaged commit:
- hermes:fs:desktopPluginsRoot now resolves the ACTIVE desktop profile
(readActiveDesktopProfile) so named profiles keep their own
profiles/<name>/desktop-plugins root instead of sharing the global one
(profile-scope concern raised on the PR thread).
- startDirWatch in runtime-loader.ts was a third sibling site still
deriving the watch path from the backend's hermes_home (added by the
later fs-watch commit); routed through the same Electron-local
resolver, with regression coverage.
The Settings "Open plugins folder" action and the runtime disk-plugin
scanner both derived the plugin directory from getStatus().hermes_home.
Against a remote backend that value is a path on the REMOTE box (or
undefined), producing `undefined/desktop-plugins` — the folder action
errors ("Could not open the plugins folder undefined") and disk-plugin
discovery silently finds nothing, even with a valid local plugin.js.
Add an Electron-owned IPC resolver (hermes:fs:desktopPluginsRoot) that
returns <HERMES_HOME>/desktop-plugins computed from the main-process
HERMES_HOME — the local Electron path, valid in every connection mode —
creating it on demand. Route both the Settings folder action and the
runtime scanner through it, so a remote backend never determines the
local filesystem location used for Desktop runtime plugins.
Fixes#66899
- 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.
Wire the scoped translator into the plugin context alongside storage/rest/
socket, and export usePluginI18n + the bundle types from @hermes/plugin-sdk.
The runtime shim re-exports the barrel automatically, so disk/third-party
plugins get the same door as bundled ones.