feat(desktop): rich plugin OS notifications with deeplink activation

Extends ctx.os.notify (the curated plugin OS door from #78685) with icon,
action buttons, and a serializable `activate` target. Body/action clicks
focus the window and navigate to the plugin's screen; activation paths
share one resolver (hermes-open-target.ts) with hermes:// OS deep links,
so `hermes://index-network/intent/1`, `/index-network/intent/1`, and
{ path, params } all land on the same hash-router route. Approval
notifications keep their existing session-scoped channel.

Salvaged from PR #84192 by @serefyarar (net diff of the PR branch applied
onto current main; branch carried merge commits so a single authored
commit preserves attribution).
This commit is contained in:
seref
2026-08-18 13:31:26 -07:00
committed by Teknium
parent 72b7c6c8d1
commit 73ddf6665c
13 changed files with 638 additions and 29 deletions
+29 -2
View File
@@ -13636,11 +13636,13 @@ ipcMain.handle('hermes:notify', (_event, payload) => {
// Action buttons render only on signed macOS builds; elsewhere they're dropped
// and the body click still works.
const actions = Array.isArray(payload?.actions) ? payload.actions : []
const icon = typeof payload?.icon === 'string' && payload.icon.trim() ? payload.icon.trim() : undefined
const notification = new Notification({
title: payload?.title || 'Hermes',
body: payload?.body || '',
silent: Boolean(payload?.silent),
...(icon ? { icon } : {}),
actions: actions.map(action => ({ type: 'button', text: String(action?.text || '') }))
})
@@ -13654,6 +13656,16 @@ ipcMain.handle('hermes:notify', (_event, payload) => {
if (payload?.sessionId) {
mainWindow.webContents.send('hermes:focus-session', payload.sessionId)
}
// Plugin / session-less activation — serializable path (+ optional notifyId
// for renderer callbacks). Same vocabulary as hermes://index-network/….
if (payload?.activate || payload?.notifyId) {
mainWindow.webContents.send('hermes:notification-activate', {
activate: payload?.activate,
notifyId: payload?.notifyId,
tag: payload?.tag
})
}
})
notification.on('action', (_actionEvent, index) => {
if (!mainWindow || mainWindow.isDestroyed()) {
@@ -13662,9 +13674,24 @@ ipcMain.handle('hermes:notify', (_event, payload) => {
const action = actions[index]
if (action?.id) {
mainWindow.webContents.send('hermes:notification-action', { sessionId: payload?.sessionId, actionId: action.id })
if (!action?.id) {
return
}
// Approvals keep the existing session-scoped channel.
if (payload?.sessionId && !payload?.notifyId && !payload?.activate) {
mainWindow.webContents.send('hermes:notification-action', { sessionId: payload.sessionId, actionId: action.id })
return
}
focusWindow(mainWindow)
mainWindow.webContents.send('hermes:notification-activate', {
actionId: action.id,
activate: action.activate || payload?.activate,
notifyId: payload?.notifyId,
tag: payload?.tag
})
})
notification.show()
+6
View File
@@ -341,6 +341,12 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
return () => ipcRenderer.removeListener('hermes:notification-action', listener)
},
onNotificationActivate: callback => {
const listener = (_event, payload) => callback(payload)
ipcRenderer.on('hermes:notification-activate', listener)
return () => ipcRenderer.removeListener('hermes:notification-activate', listener)
},
onPreviewFileChanged: callback => {
const listener = (_event, payload) => callback(payload)
ipcRenderer.on('hermes:preview-file-changed', listener)
@@ -1,6 +1,7 @@
import { renderHook } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { requestMcpInstallFromDeepLink } from '@/store/mcp-deeplink-install'
import { _resetLegacyDiscardForTests } from '@/store/session'
import type * as WindowsStore from '@/store/windows'
import type { SessionInfo } from '@/types/hermes'
@@ -12,6 +13,10 @@ import { useDesktopIntegrations } from './use-desktop-integrations'
// coverage exercising the real main-window path.
const { hudWindowMock } = vi.hoisted(() => ({ hudWindowMock: vi.fn(() => false) }))
vi.mock('@/store/mcp-deeplink-install', () => ({
requestMcpInstallFromDeepLink: vi.fn()
}))
vi.mock('@/store/windows', async importOriginal => {
const actual = await importOriginal<typeof WindowsStore>()
@@ -56,6 +61,7 @@ describe('useDesktopIntegrations', () => {
beforeEach(() => {
window.localStorage.clear()
_resetLegacyDiscardForTests()
vi.mocked(requestMcpInstallFromDeepLink).mockClear()
navigate = vi.fn()
// Every test starts as a main window; only the HUD describe flips this.
hudWindowMock.mockReturnValue(false)
@@ -68,6 +74,7 @@ describe('useDesktopIntegrations', () => {
onOpenUpdatesRequested: vi.fn(),
onFocusSession: vi.fn(),
onNotificationAction: vi.fn(),
onNotificationActivate: vi.fn(),
onDeepLink: vi.fn(),
signalDeepLinkReady: vi.fn(),
onClosePreviewRequested: vi.fn(),
@@ -466,4 +473,57 @@ describe('useDesktopIntegrations', () => {
expect(window.localStorage.getItem('hermes.desktop.lastSessionId.profile.default')).toBe('other-session')
})
})
describe('notification activate + plugin deep links', () => {
it('navigates when a plugin notification activate payload arrives', () => {
let activate: ((payload: { activate?: string }) => void) | undefined
desktopWindow.hermesDesktop = {
...desktopWindow.hermesDesktop,
onNotificationActivate: (cb: (payload: { activate?: string }) => void) => {
activate = cb
return () => undefined
}
} as unknown as Window['hermesDesktop']
render({ profileReady: true, sessions: [] })
activate?.({ activate: '/index-network/intent/1' })
expect(navigate).toHaveBeenCalledWith('/index-network/intent/1')
})
it('navigates hermes://index-network/intent/1 deep links through the same path vocabulary', () => {
let deepLink: ((payload: { kind: string; name: string; params: Record<string, string> }) => void) | undefined
desktopWindow.hermesDesktop = {
...desktopWindow.hermesDesktop,
onDeepLink: (cb: (payload: { kind: string; name: string; params: Record<string, string> }) => void) => {
deepLink = cb
return () => undefined
},
signalDeepLinkReady: vi.fn()
} as unknown as Window['hermesDesktop']
render({ profileReady: true, sessions: [] })
deepLink?.({ kind: 'index-network', name: 'intent/1', params: {} })
expect(navigate).toHaveBeenCalledWith('/index-network/intent/1')
})
it('routes hermes://mcp/install to the pending-install dialog, not navigation', () => {
let deepLink: ((payload: { kind: string; name: string; params: Record<string, string> }) => void) | undefined
desktopWindow.hermesDesktop = {
...desktopWindow.hermesDesktop,
onDeepLink: (cb: (payload: { kind: string; name: string; params: Record<string, string> }) => void) => {
deepLink = cb
return () => undefined
},
signalDeepLinkReady: vi.fn()
} as unknown as Window['hermesDesktop']
render({ profileReady: true, sessions: [] })
deepLink?.({ kind: 'mcp', name: 'install', params: { name: 'context7' } })
expect(requestMcpInstallFromDeepLink).toHaveBeenCalledWith({ name: 'context7' })
expect(navigate).not.toHaveBeenCalled()
})
})
})
@@ -3,10 +3,16 @@ import { useEffect, useRef } from 'react'
import { closeActiveTab } from '@/app/chat/close-tab'
import { commandFocusedPreview } from '@/app/chat/right-rail/preview-nav'
import { openSession } from '@/app/open-session'
import { pathFromHermesDeepLink } from '@/lib/hermes-open-target'
import { storedSessionIdForNotification } from '@/lib/session-ids'
import { requestMcpInstallFromDeepLink } from '@/store/mcp-deeplink-install'
import { startMcpHealthChecker, stopMcpHealthChecker } from '@/store/mcp-health'
import { respondToApprovalAction } from '@/store/native-notifications'
import {
clearPluginNotifyHandlers,
invokePluginNotifyAction,
invokePluginNotifyActivate,
respondToApprovalAction
} from '@/store/native-notifications'
import { openFolderAsProject } from '@/store/projects'
import {
getRememberedRoute,
@@ -194,12 +200,39 @@ export function useDesktopIntegrations({
return () => unsubscribe?.()
}, [])
// hermes:// deep links -> a reviewable /blueprint command in the composer,
// or (hermes://mcp/install) a pending MCP install awaiting explicit
// confirmation in McpInstallDeepLinkDialog. Never auto-installs.
// Plugin OS notification body/action → optional callback + navigate. Activation
// is user-driven (click), so this is offer-not-hijack. Paths share the
// hermes://index-network/intent/1 vocabulary with deep links.
useEffect(() => {
const unsubscribe = window.hermesDesktop?.onNotificationActivate?.(payload => {
if (!payload) {
return
}
if (payload.actionId) {
invokePluginNotifyAction(payload.notifyId, payload.actionId)
} else {
invokePluginNotifyActivate(payload.notifyId)
}
if (payload.activate) {
navigate(payload.activate)
}
clearPluginNotifyHandlers(payload.notifyId)
})
return () => unsubscribe?.()
}, [navigate])
// hermes:// deep links:
// - mcp/install?… → pending MCP install (explicit confirm, never auto-install)
// - <plugin>/<path>?… → in-app navigate (e.g. index-network/intent/1)
// - open/<path>?… → in-app navigate (generic)
// - blueprint/<name>?… → reviewable /blueprint command in the composer
useEffect(() => {
const unsubscribe = window.hermesDesktop?.onDeepLink?.(payload => {
if (!payload) {
if (!payload?.kind) {
return
}
@@ -209,27 +242,37 @@ export function useDesktopIntegrations({
return
}
if (payload.kind !== 'blueprint' || !payload.name) {
if (payload.kind === 'blueprint') {
if (!payload.name) {
return
}
const slots = Object.entries(payload.params || {})
.map(([k, v]) => {
const sval = /\s/.test(v) ? `"${v.replace(/"/g, '\\"')}"` : v
return `${k}=${sval}`
})
.join(' ')
const command = `/blueprint ${payload.name}${slots ? ' ' + slots : ''}`
requestComposerInsert(command, { mode: 'block', target: 'main' })
requestComposerFocus('main')
return
}
const slots = Object.entries(payload.params || {})
.map(([k, v]) => {
const sval = /\s/.test(v) ? `"${v.replace(/"/g, '\\"')}"` : v
const path = pathFromHermesDeepLink(payload.kind, payload.name || '', payload.params || {})
return `${k}=${sval}`
})
.join(' ')
const command = `/blueprint ${payload.name}${slots ? ' ' + slots : ''}`
requestComposerInsert(command, { mode: 'block', target: 'main' })
requestComposerFocus('main')
if (path) {
navigate(path)
}
})
void window.hermesDesktop?.signalDeepLinkReady?.()
return () => unsubscribe?.()
}, [])
}, [navigate])
// ⌘W via the macOS menu accelerator → close the focused tab; if nothing is
// closeable, fall back to closing the window (so ⌘W still works as the
+8 -2
View File
@@ -21,7 +21,11 @@ import { registry } from './registry'
import type { Contribution } from './types'
export type { PluginRestOptions } from '@/hermes'
export type { PluginNativeNotificationInput } from '@/store/native-notifications'
export type { HermesOpenTarget } from '@/lib/hermes-open-target'
export type {
PluginNativeNotificationInput,
PluginNotificationAction
} 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. */
@@ -44,7 +48,9 @@ export interface PluginOs {
/** 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.
* Throttled per plugin; reserve it for genuinely notable events. */
* Throttled per plugin; reserve it for genuinely notable events.
* Supports `icon`, `activate` (e.g. `hermes://index-network/intent/1`),
* action buttons, and renderer `onActivate` / `onAction` callbacks. */
notify: (input: PluginNativeNotificationInput) => void
/** Open a URL with the OS default handler (browser, mail client, custom
* schemes like `spotify:`). Resolves false when the shell can't. */
+16 -1
View File
@@ -362,6 +362,15 @@ declare global {
onWindowStateChanged?: (callback: (payload: HermesWindowState) => void) => () => void
onFocusSession?: (callback: (sessionId: string) => void) => () => void
onNotificationAction?: (callback: (payload: { actionId: string; sessionId?: string }) => void) => () => void
/** Plugin (and other session-less) notification body/action activation. */
onNotificationActivate?: (
callback: (payload: {
actionId?: string
activate?: string
notifyId?: string
tag?: string
}) => void
) => () => void
onPreviewFileChanged: (callback: (payload: HermesPreviewFileChanged) => void) => () => void
onBackendExit: (callback: (payload: BackendExit) => void) => () => void
// Soft gateway-mode apply: primary backend was torn down without a window
@@ -1052,7 +1061,13 @@ export interface HermesNotification {
sessionId?: string
/** Dedupe discriminator for session-less notifications (e.g. plugin id). */
tag?: string
actions?: { id: string; text: string }[]
/** Absolute icon path for Electron `Notification`. */
icon?: string
/** Resolved hash-router path opened on body click (plugin / deeplink-compatible). */
activate?: string
/** Renderer handle for onActivate / onAction callbacks. */
notifyId?: string
actions?: { id: string; text: string; activate?: string }[]
}
export interface HermesPreviewTarget {
@@ -0,0 +1,63 @@
import { describe, expect, it } from 'vitest'
import {
normalizeHermesOpenString,
pathFromHermesDeepLink,
pathFromOpenDeepLink,
resolveHermesOpenPath
} from './hermes-open-target'
describe('normalizeHermesOpenString', () => {
it('accepts hash-router paths and strips a leading hash', () => {
expect(normalizeHermesOpenString('/index-network/intent/1')).toBe('/index-network/intent/1')
expect(normalizeHermesOpenString('#/index-network/intent/1')).toBe('/index-network/intent/1')
})
it('maps plugin-scoped hermes:// deep links to the same path', () => {
expect(normalizeHermesOpenString('hermes://index-network/intent/1')).toBe('/index-network/intent/1')
expect(normalizeHermesOpenString('hermes://index-network/intent/1?focus=true')).toBe(
'/index-network/intent/1?focus=true'
)
})
it('maps hermes://open/… deep links by stripping the open host', () => {
expect(normalizeHermesOpenString('hermes://open/index-network/intent/1')).toBe('/index-network/intent/1')
expect(normalizeHermesOpenString('hermes://open/settings/plugins')).toBe('/settings/plugins')
})
it('rejects reserved hermes kinds and unsafe paths', () => {
expect(normalizeHermesOpenString('hermes://blueprint/morning-brief')).toBeNull()
expect(normalizeHermesOpenString('hermes://plugin/install')).toBeNull()
expect(normalizeHermesOpenString('https://example.com/x')).toBeNull()
expect(normalizeHermesOpenString('/../etc/passwd')).toBeNull()
expect(normalizeHermesOpenString('index-network')).toBeNull()
})
})
describe('resolveHermesOpenPath', () => {
it('merges structured path + params', () => {
expect(resolveHermesOpenPath({ path: '/index-network/intent/1', params: { focus: 'true' } })).toBe(
'/index-network/intent/1?focus=true'
)
})
it('resolves href the same as a bare string', () => {
expect(resolveHermesOpenPath({ href: 'hermes://index-network/intent/1' })).toBe('/index-network/intent/1')
})
})
describe('pathFromHermesDeepLink', () => {
it('builds the navigate path from a plugin-scoped deep-link payload', () => {
expect(pathFromHermesDeepLink('index-network', 'intent/1')).toBe('/index-network/intent/1')
})
it('builds the navigate path from hermes://open/… payloads', () => {
expect(pathFromOpenDeepLink('index-network/intent/1')).toBe('/index-network/intent/1')
expect(pathFromHermesDeepLink('open', 'agent/42')).toBe('/agent/42')
})
it('ignores reserved kinds', () => {
expect(pathFromHermesDeepLink('blueprint', 'morning-brief')).toBeNull()
expect(pathFromHermesDeepLink('plugin', 'install')).toBeNull()
})
})
+184
View File
@@ -0,0 +1,184 @@
/**
* Shared resolver for in-app navigation targets that must stay compatible with
* `hermes://` deep links and `host.navigate('/path?…')`.
*
* Notification activation, deep-link delivery, and plugin `activate` payloads
* all funnel through here so a toast click and an OS deep link land on the
* same hash-router path.
*
* Supported deep-link shapes:
* - `hermes://index-network/intent/1` → `/index-network/intent/1` (plugin-scoped)
* - `hermes://open/my-page?item=x` → `/my-page?item=x` (generic open)
* - `/my-page?item=x` / `#/my-page?item=x` (hash-router paths)
*/
export type HermesOpenTarget =
| string
| { href: string }
| { path: string; params?: Record<string, string> }
const HERMES_PROTOCOL = 'hermes:'
/** Hostnames owned by core deep-link handlers — never treated as plugin routes. */
const RESERVED_DEEP_LINK_KINDS = new Set([
'blueprint',
'chat',
'install',
'mcp',
'open',
'plugin',
'plugin-agent',
'plugin-desktop',
'settings'
])
function appendSearch(path: string, params: URLSearchParams | Record<string, string> | undefined): string {
if (!params) {
return path
}
const search =
params instanceof URLSearchParams
? params
: new URLSearchParams(Object.entries(params).filter(([, v]) => v != null && v !== ''))
const qs = search.toString()
if (!qs) {
return path
}
return path.includes('?') ? `${path}&${qs}` : `${path}?${qs}`
}
function isSafeAppPath(path: string): boolean {
if (!path.startsWith('/') || path.startsWith('//')) {
return false
}
// Block traversal and scheme smuggling in the path segment.
if (path.includes('..') || path.includes('\\') || path.includes(':')) {
return false
}
return true
}
function isPluginDeepLinkHost(host: string): boolean {
return /^[a-z0-9][a-z0-9-]*$/.test(host) && !RESERVED_DEEP_LINK_KINDS.has(host)
}
/** Normalize a string target to a hash-router path, or null. */
export function normalizeHermesOpenString(raw: string): string | null {
const trimmed = raw.trim()
if (!trimmed) {
return null
}
if (trimmed.startsWith('hermes://') || trimmed.startsWith(`${HERMES_PROTOCOL}//`)) {
try {
const url = new URL(trimmed)
const host = url.hostname || ''
const rest = decodeURIComponent((url.pathname || '').replace(/^\//, ''))
// hermes://open/<path>?… → /<path>?…
if (host === 'open') {
if (!rest) {
return null
}
const path = `/${rest}`
if (!isSafeAppPath(path.split('?')[0] ?? path)) {
return null
}
return appendSearch(path, url.searchParams)
}
// hermes://index-network/intent/1 → /index-network/intent/1
if (!isPluginDeepLinkHost(host) || !rest) {
return null
}
const path = `/${host}/${rest}`
if (!isSafeAppPath(path.split('?')[0] ?? path)) {
return null
}
return appendSearch(path, url.searchParams)
} catch {
return null
}
}
const path = trimmed.startsWith('#') ? trimmed.slice(1) : trimmed
if (!isSafeAppPath(path.split('?')[0] ?? path)) {
return null
}
return path
}
/** Resolve any supported activate/open target to a hash-router path, or null. */
export function resolveHermesOpenPath(target: HermesOpenTarget | null | undefined): string | null {
if (target == null) {
return null
}
if (typeof target === 'string') {
return normalizeHermesOpenString(target)
}
if (typeof target !== 'object') {
return null
}
if ('href' in target && typeof target.href === 'string') {
return normalizeHermesOpenString(target.href)
}
if ('path' in target && typeof target.path === 'string') {
const base = normalizeHermesOpenString(target.path)
if (!base) {
return null
}
return appendSearch(base, target.params)
}
return null
}
/**
* Build a navigate path from a parsed deep-link payload
* (`hermes://<kind>/<name>?…` → kind/name/params).
*/
export function pathFromHermesDeepLink(
kind: string,
name: string,
params: Record<string, string> = {}
): string | null {
if (!kind || !name) {
return null
}
if (kind === 'open') {
return resolveHermesOpenPath({ path: `/${name.replace(/^\//, '')}`, params })
}
if (!isPluginDeepLinkHost(kind)) {
return null
}
return resolveHermesOpenPath({ path: `/${kind}/${name.replace(/^\//, '')}`, params })
}
/** Convenience for `hermes://open/<name>?…` payloads. */
export function pathFromOpenDeepLink(name: string, params: Record<string, string> = {}): string | null {
return pathFromHermesDeepLink('open', name, params)
}
+2
View File
@@ -794,6 +794,7 @@ export type {
PluginContext,
PluginContribution,
PluginNativeNotificationInput,
PluginNotificationAction,
PluginOs,
PluginRestOptions,
PluginStorage
@@ -836,6 +837,7 @@ export { type BudgetedLoop, type BudgetedLoopOptions, createBudgetedLoop } from
* through here (1230 → "1.2k", 1_500_000 → "1.5M"). Don't hand-roll `/1000`. */
export { compactNumber } from '@/lib/format'
export { triggerHaptic as haptic } from '@/lib/haptics'
export type { HermesOpenTarget } from '@/lib/hermes-open-target'
/** The app's lucide icon set (RefreshCw, LayoutDashboard, Activity, …). */
export * as icons from '@/lib/icons'
export { type KeybindContribution, KEYBINDS_AREA } from '@/lib/keybinds/actions'
@@ -2,8 +2,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { $gateway } from './gateway'
import {
clearPluginNotifyHandlers,
dispatchNativeNotification,
dispatchPluginNativeNotification,
invokePluginNotifyAction,
invokePluginNotifyActivate,
NATIVE_NOTIFICATION_KINDS,
respondToApprovalAction,
sendTestNativeNotification,
@@ -49,6 +52,8 @@ beforeEach(() => {
})
afterEach(() => {
clearPluginNotifyHandlers()
if (initialHermesDesktop) {
desktopWindow.hermesDesktop = initialHermesDesktop
} else {
@@ -197,6 +202,54 @@ describe('dispatchPluginNativeNotification', () => {
dispatchPluginNativeNotification('plugin-b', { title: 'b' })
expect(notify).toHaveBeenCalledTimes(2)
})
it('forwards icon, resolved activate path, and action buttons (deeplink-compatible)', () => {
// Unique tag (throttle is per plugin id); activate still uses the plugin deep link.
dispatchPluginNativeNotification('index-network-alerts', {
actions: [
{ id: 'open', label: 'Open', activate: 'hermes://index-network/intent/1' },
{ id: 'dismiss', label: 'Dismiss', onAction: () => undefined }
],
activate: 'hermes://index-network/intent/1',
body: 'New match',
icon: '/tmp/index-network.png',
title: 'Opportunity'
})
expect(notify).toHaveBeenCalledWith(
expect.objectContaining({
activate: '/index-network/intent/1',
actions: [
{ activate: '/index-network/intent/1', id: 'open', text: 'Open' },
{ activate: undefined, id: 'dismiss', text: 'Dismiss' }
],
icon: '/tmp/index-network.png',
kind: 'plugin',
notifyId: expect.stringMatching(/^index-network-alerts:/),
tag: 'index-network-alerts',
title: 'Opportunity'
})
)
})
it('registers onActivate / onAction handlers keyed by notifyId', () => {
const onActivate = vi.fn()
const onAction = vi.fn()
dispatchPluginNativeNotification('handlers-plugin', {
activate: 'hermes://index-network/intent/1',
onActivate,
actions: [{ id: 'dismiss', label: 'Dismiss', onAction }],
title: 'Opportunity'
})
const payload = notify.mock.calls[0]?.[0] as { notifyId?: string }
expect(payload.notifyId).toBeTruthy()
invokePluginNotifyActivate(payload.notifyId)
expect(onActivate).toHaveBeenCalledTimes(1)
expect(invokePluginNotifyAction(payload.notifyId, 'dismiss')).toBe(true)
expect(onAction).toHaveBeenCalledTimes(1)
})
})
describe('dispatchNativeNotification throttle', () => {
+119 -1
View File
@@ -1,5 +1,6 @@
import { atom } from 'nanostores'
import { type HermesOpenTarget, resolveHermesOpenPath } from '@/lib/hermes-open-target'
import { persistString, storedString } from '@/lib/storage'
import { $gateway } from './gateway'
@@ -7,6 +8,8 @@ import { withinNativeNotifyBaseline } from './notify-baseline'
import { clearApprovalRequest } from './prompts'
import { $activeSessionId } from './session'
export type { HermesOpenTarget }
// Native OS notifications (Electron `Notification`), separate from the in-app
// toast feed in `notifications.ts`. Each kind toggles independently.
export type NativeNotificationKind =
@@ -147,6 +150,8 @@ function shouldFire(kind: NativeNotificationKind, sessionId?: null | string, glo
export interface NativeNotificationAction {
id: string
text: string
/** Serializable activate target echoed back on button press (plugin path). */
activate?: string
}
export interface NativeNotificationInput {
@@ -168,6 +173,15 @@ export interface NativeNotificationInput {
* into one another. Never drives click-to-focus like `sessionId` does.
*/
tag?: string
/** Absolute file path for the OS notification icon (Electron). */
icon?: string
/**
* Resolved hash-router path to open on body click when there is no
* `sessionId` (plugins). Same vocabulary as `hermes://index-network/intent/1`.
*/
activate?: string
/** Renderer-side handle so click/action can invoke registered callbacks. */
notifyId?: string
}
export function dispatchNativeNotification(input: NativeNotificationInput): void {
@@ -191,8 +205,11 @@ export function dispatchNativeNotification(input: NativeNotificationInput): void
void window.hermesDesktop?.notify({
actions: input.actions,
activate: input.activate,
body: input.body,
icon: input.icon,
kind: input.kind,
notifyId: input.notifyId,
sessionId: input.sessionId ?? undefined,
silent: input.silent,
tag: input.tag,
@@ -202,10 +219,79 @@ export function dispatchNativeNotification(input: NativeNotificationInput): void
// -- the plugin door (`ctx.os.notify`) ----------------------------------------
export interface PluginNotificationAction {
id: string
label: string
/** Navigate here on button press (path or `hermes://index-network/intent/1`). */
activate?: HermesOpenTarget
/** Renderer callback — only `id` crosses IPC; this stays in-process. */
onAction?: () => void
}
export interface PluginNativeNotificationInput {
title: string
body?: string
silent?: boolean
/** Absolute filesystem path for the notification icon. */
icon?: string
/**
* Where body-click should land. Accepts a plugin deep link
* (`hermes://index-network/intent/1`), a hash path (`/index-network/intent/1`),
* or `{ path, params }` — all resolve through the same helper as OS deep links.
*/
activate?: HermesOpenTarget
/** Extra work on body click (runs in addition to `activate` navigation). */
onActivate?: () => void
actions?: PluginNotificationAction[]
}
interface PendingPluginNotify {
onActivate?: () => void
actions: Map<string, () => void>
}
const pendingPluginNotify = new Map<string, PendingPluginNotify>()
function mintNotifyId(pluginId: string): string {
return `${pluginId}:${Date.now().toString(36)}:${Math.random().toString(36).slice(2, 8)}`
}
/** Invoke body-click callback if one was registered for this notify id. */
export function invokePluginNotifyActivate(notifyId: string | undefined): void {
if (!notifyId) {
return
}
const pending = pendingPluginNotify.get(notifyId)
pending?.onActivate?.()
}
/** Invoke an action-button callback. Returns true when a handler ran. */
export function invokePluginNotifyAction(notifyId: string | undefined, actionId: string | undefined): boolean {
if (!notifyId || !actionId) {
return false
}
const handler = pendingPluginNotify.get(notifyId)?.actions.get(actionId)
if (!handler) {
return false
}
handler()
return true
}
/** Drop pending handlers (tests / after a click consumed the toast). */
export function clearPluginNotifyHandlers(notifyId?: string): void {
if (notifyId) {
pendingPluginNotify.delete(notifyId)
return
}
pendingPluginNotify.clear()
}
/** Native OS notification on behalf of a plugin. One "Plugin notifications"
@@ -214,7 +300,39 @@ export interface PluginNativeNotificationInput {
* 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 })
const activate = resolveHermesOpenPath(input.activate) ?? undefined
const notifyId = input.onActivate || input.actions?.some(a => a.onAction) ? mintNotifyId(pluginId) : undefined
if (notifyId) {
const actions = new Map<string, () => void>()
for (const action of input.actions ?? []) {
if (action.onAction) {
actions.set(action.id, action.onAction)
}
}
pendingPluginNotify.set(notifyId, { actions, onActivate: input.onActivate })
}
const actions: NativeNotificationAction[] | undefined = input.actions?.map(action => ({
activate: resolveHermesOpenPath(action.activate) ?? undefined,
id: action.id,
text: action.label
}))
dispatchNativeNotification({
actions,
activate,
body: input.body,
global: true,
icon: input.icon,
kind: 'plugin',
notifyId,
silent: input.silent,
tag: pluginId,
title: input.title
})
}
// Resolve a pending approval from a notification button, mirroring the in-app
@@ -103,10 +103,15 @@ The ONLY import surface is `@hermes/plugin-sdk` (plus `react` /
user's instructions) — it won't discover the name on its own.
- `ctx.storage.get/set/remove` — persistence namespaced to your plugin.
- `ctx.os` — the curated OS door, attributed to your plugin:
`ctx.os.notify({ title, body?, silent? })` posts a native OS notification.
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.os.notify({ title, body?, silent?, icon?, activate?, onActivate?, actions? })`
posts a native OS notification. 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. `activate` accepts a plugin deep link
(`hermes://index-network/intent/1`), a hash path (`/index-network/intent/1`),
or `{ path, params }` — same resolver as OS deep links. Action buttons may
set their own `activate` or an `onAction`
callback (renderer-only; only the action id crosses IPC).
`ctx.os.openExternal(url)`, `ctx.os.revealPath(path)`, and
`ctx.os.writeClipboard(text)` resolve `false` (never throw) when the
capability isn't available.
@@ -453,7 +453,8 @@ busy flag.
```ts
host.notify({ kind, message, title?, detail?, action? }) // toast; returns id
host.notifyError(error, fallbackMessage) // toast an error
ctx.os.notify({ title, body?, silent? }) // native OS notification (attributed to your plugin)
ctx.os.notify({ title, body?, silent?, icon?, activate?, onActivate?, actions? })
// native OS notification (attributed to your plugin)
ctx.os.openExternal(url) // OS default handler (browser, mail, spotify:) → Promise<boolean>
ctx.os.revealPath(path) // reveal in Finder / Explorer → Promise<boolean>
ctx.os.writeClipboard(text) // system clipboard → Promise<boolean>
@@ -558,6 +559,32 @@ approval/turn alerts use. It fires only while the user is away from Hermes
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.
Rich presentation + activation (extends the original `ctx.os` door):
```ts
ctx.os.notify({
title: 'New match found',
body: 'Someone matched your signal',
icon: '/abs/path/to/icon.png', // Electron Notification icon
// Body click → focus Hermes + navigate. Same vocabulary as OS deep links:
activate: 'hermes://index-network/intent/1',
// or: activate: '/index-network/intent/1'
// or: activate: { path: '/index-network/intent/1' }
onActivate: () => focusLocalState('1'), // optional renderer callback
actions: [
{ id: 'open', label: 'Open', activate: 'hermes://index-network/intent/1' },
{ id: 'dismiss', label: 'Dismiss', onAction: () => dismiss('1') },
],
})
```
`activate` is deeplink-compatible: `hermes://index-network/intent/1` and the
hash path `/index-network/intent/1` resolve to the same in-app route (and the
same `hermes://…` URL works as an OS deep link). Action buttons only render on
signed macOS builds; elsewhere the body click still activates. Navigation only
happens on user click — never from a background event alone.
The other doors (`openExternal`, `revealPath`, `writeClipboard`) resolve
`false` instead of throwing when the capability isn't available (older desktop
shell, plain browser) — branch on the result rather than sniffing the bridge.
@@ -812,7 +839,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`, `PluginOs`, `PluginRestOptions`, `PluginNativeNotificationInput`, `Contribution` |
| Plugin contract | `HermesPlugin`, `PluginContext`, `PluginContribution`, `PluginStorage`, `PluginOs`, `PluginRestOptions`, `PluginNativeNotificationInput`, `PluginNotificationAction`, `HermesOpenTarget`, `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` |