diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index 200ecb5fed2a4..2f5340d6e9a0c 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -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 } diff --git a/apps/desktop/src/contrib/plugin.test.ts b/apps/desktop/src/contrib/plugin.test.ts index ca32f49e891b7..95bb250458774 100644 --- a/apps/desktop/src/contrib/plugin.test.ts +++ b/apps/desktop/src/contrib/plugin.test.ts @@ -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' }) + }) +}) diff --git a/apps/desktop/src/contrib/plugin.ts b/apps/desktop/src/contrib/plugin.ts index a88aa078823e2..0f9bc849cb94b 100644 --- a/apps/desktop/src/contrib/plugin.ts +++ b/apps/desktop/src/contrib/plugin.ts @@ -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: (path: string, opts?: PluginRestOptions) => pluginRest(pluginId, path, opts), socket: (path, onMessage) => track(pluginSocket(pluginId, path, onMessage)), + notifyNative: input => dispatchPluginNativeNotification(pluginId, input), storage: createPluginStorage(pluginId), i18n: createPluginI18n(pluginId, track) } diff --git a/apps/desktop/src/global.d.ts b/apps/desktop/src/global.d.ts index c2b677619ec41..548badf5e6b7a 100644 --- a/apps/desktop/src/global.d.ts +++ b/apps/desktop/src/global.d.ts @@ -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 }[] } diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index fe764eb310a4a..2ec60d2859a19 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -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', diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts index 16d58834c62f3..d1913d24d83e1 100644 --- a/apps/desktop/src/i18n/ja.ts +++ b/apps/desktop/src/i18n/ja.ts @@ -269,6 +269,10 @@ export const ja = defineLocale({ credits: { label: 'クレジット通知', description: 'クレジットの利用が停止または復旧しました。' + }, + plugin: { + label: 'プラグイン通知', + description: 'Hermes がバックグラウンドの間に、デスクトッププラグインが通知を送信しました。' } }, test: 'テスト通知を送信', diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index b82feb40011e1..e2a49f4234dc1 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -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 diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts index 31a0b62a53e88..bb71f3d094ec3 100644 --- a/apps/desktop/src/i18n/zh-hant.ts +++ b/apps/desktop/src/i18n/zh-hant.ts @@ -263,6 +263,10 @@ export const zhHant = defineLocale({ credits: { label: '額度提醒', description: '額度存取被暫停或恢復。' + }, + plugin: { + label: '外掛通知', + description: 'Hermes 在背景時,桌面外掛傳送了通知。' } }, test: '傳送測試通知', diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index 10b472724597a..79a9a1c143eb2 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -387,6 +387,10 @@ export const zh: Translations = { credits: { label: '额度提醒', description: '额度访问被暂停或恢复。' + }, + plugin: { + label: '插件通知', + description: 'Hermes 在后台时,桌面插件发送了通知。' } }, test: '发送测试通知', diff --git a/apps/desktop/src/sdk/index.ts b/apps/desktop/src/sdk/index.ts index 1c1b696d37638..60fa6f48ded4c 100644 --- a/apps/desktop/src/sdk/index.ts +++ b/apps/desktop/src/sdk/index.ts @@ -200,6 +200,7 @@ export type { HermesPlugin, PluginContext, PluginContribution, + PluginNativeNotificationInput, PluginRestOptions, PluginStorage } from '@/contrib/plugin' diff --git a/apps/desktop/src/store/native-notifications.test.ts b/apps/desktop/src/store/native-notifications.test.ts index 4ebfd95a88e9f..8e4feccf51569 100644 --- a/apps/desktop/src/store/native-notifications.test.ts +++ b/apps/desktop/src/store/native-notifications.test.ts @@ -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() diff --git a/apps/desktop/src/store/native-notifications.ts b/apps/desktop/src/store/native-notifications.ts index 450360b7666cb..dba7bb58de26f 100644 --- a/apps/desktop/src/store/native-notifications.ts +++ b/apps/desktop/src/store/native-notifications.ts @@ -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 { diff --git a/skills/autonomous-ai-agents/hermes-agent/references/desktop-plugins.md b/skills/autonomous-ai-agents/hermes-agent/references/desktop-plugins.md index 3d7d5f2a925ac..6e79fefaf6a98 100644 --- a/skills/autonomous-ai-agents/hermes-agent/references/desktop-plugins.md +++ b/skills/autonomous-ai-agents/hermes-agent/references/desktop-plugins.md @@ -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 diff --git a/website/docs/developer-guide/desktop-plugin-sdk.md b/website/docs/developer-guide/desktop-plugin-sdk.md index efc4fb3e83ce5..f77a6473914f6 100644 --- a/website/docs/developer-guide/desktop-plugin-sdk.md +++ b/website/docs/developer-guide/desktop-plugin-sdk.md @@ -168,6 +168,8 @@ interface PluginContext { rest: (path: string, opts?: PluginRestOptions) => Promise /** 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..`). */ 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` |