feat(desktop): expose native OS notifications to plugins via ctx.notifyNative

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.
This commit is contained in:
seref 2026-08-02 18:53:29 -04:00 committed by Brooklyn Nicholson
parent 3fa318a50c
commit 5d24594ab3
14 changed files with 131 additions and 8 deletions

View File

@ -10333,7 +10333,7 @@ ipcMain.handle('hermes:notify', (_event, payload) => {
// kind+session can arrive here twice. Collapse it at this single choke point.
// Return true (not false): a notification for the event IS being shown by the
// first caller, so the settings "send test" success probe stays honest.
if (isDuplicateNotification(`${payload?.kind ?? ''}:${payload?.sessionId ?? ''}`)) {
if (isDuplicateNotification(`${payload?.kind ?? ''}:${payload?.sessionId ?? payload?.tag ?? ''}`)) {
return true
}

View File

@ -1,7 +1,11 @@
import { describe, expect, it } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import { dispatchPluginNativeNotification } from '@/store/native-notifications'
import { createPluginContext } from './plugin'
vi.mock('@/store/native-notifications', () => ({ dispatchPluginNativeNotification: vi.fn() }))
describe('createPluginContext.onDispose', () => {
it('collects arbitrary cleanups so the host runs them on deactivate', () => {
const disposers: Array<() => void> = []
@ -19,3 +23,11 @@ describe('createPluginContext.onDispose', () => {
expect(cleaned).toBe(true)
})
})
describe('createPluginContext.notifyNative', () => {
it('dispatches a native notification attributed to the plugin', () => {
const ctx = createPluginContext('demo')
ctx.notifyNative({ body: 'b', title: 't' })
expect(dispatchPluginNativeNotification).toHaveBeenCalledWith('demo', { body: 'b', title: 't' })
})
})

View File

@ -15,11 +15,13 @@
import { pluginRest, type PluginRestOptions, pluginSocket } from '@/hermes'
import { createPluginI18n, type PluginI18n } from '@/i18n'
import { readKey, writeKey } from '@/lib/storage'
import { dispatchPluginNativeNotification, type PluginNativeNotificationInput } from '@/store/native-notifications'
import { registry } from './registry'
import type { Contribution } from './types'
export type { PluginRestOptions } from '@/hermes'
export type { PluginNativeNotificationInput } from '@/store/native-notifications'
/** A contribution as a plugin author writes it provenance + id scoping are
* the host's job, so those fields are off-limits here. */
@ -54,6 +56,10 @@ export interface PluginContext {
* returned. Resolves to a no-op on OAuth remotes treat it as an
* accelerator over your polling, never a replacement. */
socket: (path: string, onMessage: (data: unknown) => void) => () => void
/** Native OS notification (Electron), attributed to this plugin. Gated by
* Settings Notifications "Plugin notifications" and fires only while
* the user is away from Hermes use `host.notify` for the in-app toast. */
notifyNative: (input: PluginNativeNotificationInput) => void
/** Plugin-scoped persistence. */
storage: PluginStorage
/** Plugin-scoped i18n: ship + register locale bundles under this plugin,
@ -115,6 +121,7 @@ export function createPluginContext(pluginId: string, onDispose?: (dispose: () =
onDispose: fn => void track(fn),
rest: <T>(path: string, opts?: PluginRestOptions) => pluginRest<T>(pluginId, path, opts),
socket: (path, onMessage) => track(pluginSocket(pluginId, path, onMessage)),
notifyNative: input => dispatchPluginNativeNotification(pluginId, input),
storage: createPluginStorage(pluginId),
i18n: createPluginI18n(pluginId, track)
}

View File

@ -769,6 +769,8 @@ export interface HermesNotification {
silent?: boolean
kind?: string
sessionId?: string
/** Dedupe discriminator for session-less notifications (e.g. plugin id). */
tag?: string
actions?: { id: string; text: string }[]
}

View File

@ -395,6 +395,10 @@ export const en: Translations = {
credits: {
label: 'Credit alerts',
description: 'Credit access is paused or restored.'
},
plugin: {
label: 'Plugin notifications',
description: 'A desktop plugin sent a notification while Hermes was in the background.'
}
},
test: 'Send test notification',

View File

@ -269,6 +269,10 @@ export const ja = defineLocale({
credits: {
label: 'クレジット通知',
description: 'クレジットの利用が停止または復旧しました。'
},
plugin: {
label: 'プラグイン通知',
description: 'Hermes がバックグラウンドの間に、デスクトッププラグインが通知を送信しました。'
}
},
test: 'テスト通知を送信',

View File

@ -324,7 +324,7 @@ export interface Translations {
enableAllDesc: string
focusedHint: string
kinds: Record<
'approval' | 'backgroundDone' | 'credits' | 'input' | 'turnDone' | 'turnError',
'approval' | 'backgroundDone' | 'credits' | 'input' | 'plugin' | 'turnDone' | 'turnError',
{ label: string; description: string }
>
test: string

View File

@ -263,6 +263,10 @@ export const zhHant = defineLocale({
credits: {
label: '額度提醒',
description: '額度存取被暫停或恢復。'
},
plugin: {
label: '外掛通知',
description: 'Hermes 在背景時,桌面外掛傳送了通知。'
}
},
test: '傳送測試通知',

View File

@ -387,6 +387,10 @@ export const zh: Translations = {
credits: {
label: '额度提醒',
description: '额度访问被暂停或恢复。'
},
plugin: {
label: '插件通知',
description: 'Hermes 在后台时,桌面插件发送了通知。'
}
},
test: '发送测试通知',

View File

@ -200,6 +200,7 @@ export type {
HermesPlugin,
PluginContext,
PluginContribution,
PluginNativeNotificationInput,
PluginRestOptions,
PluginStorage
} from '@/contrib/plugin'

View File

@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { $gateway } from './gateway'
import {
dispatchNativeNotification,
dispatchPluginNativeNotification,
NATIVE_NOTIFICATION_KINDS,
respondToApprovalAction,
sendTestNativeNotification,
@ -170,6 +171,34 @@ describe('dispatchNativeNotification post-connect baseline', () => {
})
})
describe('dispatchPluginNativeNotification', () => {
it('fires while the user is away and tags the plugin id for dedupe', () => {
dispatchPluginNativeNotification('index-network', { body: 'New match', title: 'Opportunity' })
expect(notify).toHaveBeenCalledWith(
expect.objectContaining({ body: 'New match', kind: 'plugin', tag: 'index-network', title: 'Opportunity' })
)
})
it('suppresses while the window is focused (the in-app toast covers foreground)', () => {
setWindowState({ focused: true, hidden: false })
dispatchPluginNativeNotification('focused-plugin', { title: 'Opportunity' })
expect(notify).not.toHaveBeenCalled()
})
it('is gated by the "plugin" kind preference', () => {
setNativeNotifyKind('plugin', false)
dispatchPluginNativeNotification('muted-plugin', { title: 'Opportunity' })
expect(notify).not.toHaveBeenCalled()
})
it('throttles per plugin, so two plugins cannot collapse each other', () => {
dispatchPluginNativeNotification('plugin-a', { title: 'a' })
dispatchPluginNativeNotification('plugin-a', { title: 'a again' })
dispatchPluginNativeNotification('plugin-b', { title: 'b' })
expect(notify).toHaveBeenCalledTimes(2)
})
})
describe('dispatchNativeNotification throttle', () => {
it('collapses duplicate kind+session within the throttle window', () => {
const sessionId = freshSession()

View File

@ -9,7 +9,14 @@ import { $activeSessionId } from './session'
// Native OS notifications (Electron `Notification`), separate from the in-app
// toast feed in `notifications.ts`. Each kind toggles independently.
export type NativeNotificationKind = 'approval' | 'backgroundDone' | 'credits' | 'input' | 'turnDone' | 'turnError'
export type NativeNotificationKind =
| 'approval'
| 'backgroundDone'
| 'credits'
| 'input'
| 'plugin'
| 'turnDone'
| 'turnError'
export const NATIVE_NOTIFICATION_KINDS: readonly NativeNotificationKind[] = [
'approval',
@ -17,7 +24,8 @@ export const NATIVE_NOTIFICATION_KINDS: readonly NativeNotificationKind[] = [
'turnDone',
'turnError',
'backgroundDone',
'credits'
'credits',
'plugin'
]
// Blocking prompts — surface even while focused if they're for another session.
@ -32,7 +40,15 @@ const STORAGE_KEY = 'hermes:native-notifications'
const DEFAULT_PREFS: NativeNotificationPrefs = {
enabled: true,
kinds: { approval: true, backgroundDone: true, credits: true, input: true, turnDone: true, turnError: true }
kinds: {
approval: true,
backgroundDone: true,
credits: true,
input: true,
plugin: true,
turnDone: true,
turnError: true
}
}
function readPrefs(): NativeNotificationPrefs {
@ -152,6 +168,12 @@ export interface NativeNotificationInput {
global?: boolean
silent?: boolean
actions?: NativeNotificationAction[]
/**
* Extra throttle/dedupe discriminator for session-less notifications (e.g.
* the plugin id), so unrelated emitters of the same kind don't collapse
* into one another. Never drives click-to-focus like `sessionId` does.
*/
tag?: string
}
export function dispatchNativeNotification(input: NativeNotificationInput): void {
@ -169,7 +191,7 @@ export function dispatchNativeNotification(input: NativeNotificationInput): void
return
}
if (throttled(`${input.kind}:${input.sessionId ?? (input.global ? 'global' : '')}`, Date.now())) {
if (throttled(`${input.kind}:${input.sessionId ?? input.tag ?? (input.global ? 'global' : '')}`, Date.now())) {
return
}
@ -179,10 +201,28 @@ export function dispatchNativeNotification(input: NativeNotificationInput): void
kind: input.kind,
sessionId: input.sessionId ?? undefined,
silent: input.silent,
tag: input.tag,
title: input.title
})
}
// -- the plugin door (`ctx.notifyNative`) -------------------------------------
export interface PluginNativeNotificationInput {
title: string
body?: string
silent?: boolean
}
/** Native OS notification on behalf of a plugin. One "Plugin notifications"
* preference gates all plugins; the plugin id keys throttling/dedupe so two
* plugins can't collapse each other's notifications. Fires only while the
* user is away from Hermes the in-app toast (`host.notify`) covers the
* foreground case. */
export function dispatchPluginNativeNotification(pluginId: string, input: PluginNativeNotificationInput): void {
dispatchNativeNotification({ ...input, global: true, kind: 'plugin', tag: pluginId })
}
// Resolve a pending approval from a notification button, mirroring the in-app
// Run/Reject bar. Keyed by session id — a background approval has no local guard.
export async function respondToApprovalAction(sessionId: null | string, actionId: string): Promise<void> {

View File

@ -71,6 +71,11 @@ The ONLY import surface is `@hermes/plugin-sdk` (plus `react` /
(renders below Artifacts, lights up at the route) — and/or a
`PALETTE_AREA` command calling `host.navigate('/my-page')`.
- `ctx.storage.get/set/remove` — persistence namespaced to your plugin.
- `ctx.notifyNative({ title, body?, silent? })` — native OS notification
attributed to your plugin. Fires only while the user is away from Hermes
(use `host.notify` for the in-app toast); gated by Settings ▸ Notifications ▸
"Plugin notifications" and throttled per plugin — reserve it for genuinely
notable events.
- `ctx.i18n.register({ en, ja, ... })` — ship your OWN locale bundles, scoped
to your plugin (never edit core `en.ts`). Values are literal strings or
interpolator functions; nested trees are addressed by dot-path. Read them

View File

@ -168,6 +168,8 @@ interface PluginContext {
rest: <T>(path: string, opts?: PluginRestOptions) => Promise<T>
/** Live WebSocket to this plugin's own namespace. Returns a disposer. */
socket: (path: string, onMessage: (data: unknown) => void) => () => void
/** Native OS notification (Electron), attributed to this plugin. */
notifyNative: (input: { title: string; body?: string; silent?: boolean }) => void
/** Plugin-scoped JSON persistence (keys live under `hermes.plugin.<id>.`). */
storage: PluginStorage
}
@ -370,6 +372,7 @@ host.state.viewport // ReadableAtom<{ width, height, narrow }>
host.notify({ kind, message, title?, detail?, action? }) // toast; returns id
host.notifyError(error, fallbackMessage) // toast an error
ctx.notifyNative({ title, body?, silent? }) // native OS notification
host.navigate('/route') // hash-route navigation
host.onEvent(type, fn) // gateway event stream ('*' = all); returns disposer
host.logs(...) // tail an app log file
@ -385,6 +388,14 @@ listener can't affect app dispatch. Every `host` door is async-safe: a sync thro
from an internal helper (e.g. no desktop bridge in a plain browser) becomes a
rejection your `.catch()` sees, never an error-boundary crash.
`ctx.notifyNative` (on the plugin context, so the notification is attributed to
your plugin) posts a **native OS notification** — the same Electron pipeline the
app's own approval/turn alerts use. It fires only while the user is away from
Hermes (backgrounded / unfocused); use `host.notify` for the in-app toast when
they're looking at the app. Users can silence it per device under Settings ▸
Notifications ▸ "Plugin notifications", and repeats from the same plugin are
throttled, so treat it as a signal for genuinely notable events — not a log.
## Data layer — React Query + nanostores
Plugins share the app's single `QueryClient`, so plugin queries cache, dedupe,
@ -597,7 +608,7 @@ not treat this pipeline as a trust boundary.
| Category | Exports |
|----------|---------|
| Host | `host` (`.state.*`, `.notify`, `.notifyError`, `.navigate`, `.onEvent`, `.logs`, `.status`, `.restartGateway`, `.request`) |
| Plugin contract | `HermesPlugin`, `PluginContext`, `PluginContribution`, `PluginStorage`, `PluginRestOptions`, `Contribution` |
| Plugin contract | `HermesPlugin`, `PluginContext`, `PluginContribution`, `PluginStorage`, `PluginRestOptions`, `PluginNativeNotificationInput`, `Contribution` |
| Area constants | `PANES_AREA`, `ROUTES_AREA`, `SIDEBAR_NAV_AREA`, `STATUSBAR_AREAS`, `TITLEBAR_AREAS`, `PALETTE_AREA`, `KEYBINDS_AREA`, `THEMES_AREA`, `COMPOSER_AREAS` |
| Area payloads | `RouteContribution`, `SidebarNavContribution`, `StatusbarItem`, `TitlebarTool`, `PaletteContribution`, `KeybindContribution`, `ComposerMiddleware`, `ComposerAttachmentProvider` |
| React / state | `useValue`, `atom`, `computed`, `useQuery`, `useMutation`, `useQueryClient`, `queryClient`, `Contribute` |