mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-01 12:02:05 +00:00
Adds a native OS notification system (Electron Notification, routed cross-OS) distinct from the in-app toast feed. Before this, one hardcoded cue existed (message.complete while document.hidden) with no settings or event coverage. - Engine (store/native-notifications.ts): localStorage-backed prefs (master switch + per-kind toggles) and a gated dispatcher over five kinds — approval, input, turnDone, turnError, backgroundDone — with a 1s per-(kind,session) self-evicting throttle. - Gating: "backgrounded" = document.hidden OR !document.hasFocus(), so an alt-tabbed window still counts as away. Completion kinds fire only when backgrounded and for the active session (no spam from a busy gateway); attention kinds (approval/input) also break through for off-screen sessions. - Wired into real event sites (use-message-stream.ts): message.complete, error, approval/clarify/sudo/secret.request; backgroundDone from composer-status at the running -> exited transition. - Click focuses the window and jumps to the originating session; approval notifications carry Approve/Reject buttons that resolve in place over approval.respond, mirroring the in-app Run/Reject bar. - Settings: new Notifications panel (master + per-kind switches, test button with real OS-result feedback). Full i18n (en/ja/zh/zh-hant).
203 lines
5.6 KiB
TypeScript
203 lines
5.6 KiB
TypeScript
import { atom } from 'nanostores'
|
|
|
|
import { persistString, storedString } from '@/lib/storage'
|
|
|
|
import { $gateway } from './gateway'
|
|
import { clearApprovalRequest } from './prompts'
|
|
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' | 'input' | 'turnDone' | 'turnError'
|
|
|
|
export const NATIVE_NOTIFICATION_KINDS: readonly NativeNotificationKind[] = [
|
|
'approval',
|
|
'input',
|
|
'turnDone',
|
|
'turnError',
|
|
'backgroundDone'
|
|
]
|
|
|
|
// Blocking prompts — surface even while focused if they're for another session.
|
|
const ATTENTION_KINDS = new Set<NativeNotificationKind>(['approval', 'input'])
|
|
|
|
export interface NativeNotificationPrefs {
|
|
enabled: boolean
|
|
kinds: Record<NativeNotificationKind, boolean>
|
|
}
|
|
|
|
const STORAGE_KEY = 'hermes:native-notifications'
|
|
|
|
const DEFAULT_PREFS: NativeNotificationPrefs = {
|
|
enabled: true,
|
|
kinds: { approval: true, backgroundDone: true, input: true, turnDone: true, turnError: true }
|
|
}
|
|
|
|
function readPrefs(): NativeNotificationPrefs {
|
|
const raw = storedString(STORAGE_KEY)
|
|
|
|
if (!raw) {
|
|
return DEFAULT_PREFS
|
|
}
|
|
|
|
try {
|
|
const parsed = JSON.parse(raw) as Partial<NativeNotificationPrefs>
|
|
const kinds = { ...DEFAULT_PREFS.kinds }
|
|
|
|
for (const kind of NATIVE_NOTIFICATION_KINDS) {
|
|
const value = parsed.kinds?.[kind]
|
|
|
|
if (typeof value === 'boolean') {
|
|
kinds[kind] = value
|
|
}
|
|
}
|
|
|
|
return {
|
|
enabled: typeof parsed.enabled === 'boolean' ? parsed.enabled : DEFAULT_PREFS.enabled,
|
|
kinds
|
|
}
|
|
} catch {
|
|
return DEFAULT_PREFS
|
|
}
|
|
}
|
|
|
|
export const $nativeNotifyPrefs = atom<NativeNotificationPrefs>(readPrefs())
|
|
|
|
function writePrefs(next: NativeNotificationPrefs) {
|
|
$nativeNotifyPrefs.set(next)
|
|
persistString(STORAGE_KEY, JSON.stringify(next))
|
|
}
|
|
|
|
export function setNativeNotifyEnabled(enabled: boolean) {
|
|
writePrefs({ ...$nativeNotifyPrefs.get(), enabled })
|
|
}
|
|
|
|
export function setNativeNotifyKind(kind: NativeNotificationKind, on: boolean) {
|
|
const prev = $nativeNotifyPrefs.get()
|
|
writePrefs({ ...prev, kinds: { ...prev.kinds, [kind]: on } })
|
|
}
|
|
|
|
// De-dupe replayed events for the same kind+session. Self-evicting: entries
|
|
// older than the window are pruned on every dispatch, so the map can't grow.
|
|
const THROTTLE_MS = 1000
|
|
const lastFiredAt = new Map<string, number>()
|
|
|
|
function throttled(key: string, now: number): boolean {
|
|
for (const [k, at] of lastFiredAt) {
|
|
if (now - at >= THROTTLE_MS) {
|
|
lastFiredAt.delete(k)
|
|
}
|
|
}
|
|
|
|
if (lastFiredAt.has(key)) {
|
|
return true
|
|
}
|
|
|
|
lastFiredAt.set(key, now)
|
|
|
|
return false
|
|
}
|
|
|
|
// "Backgrounded" = the user isn't on Hermes. `document.hidden` only flips when
|
|
// minimized/occluded; an alt-tabbed window is visible-but-unfocused, so we also
|
|
// check `document.hasFocus()`.
|
|
function isBackgrounded(): boolean {
|
|
if (typeof document === 'undefined') {
|
|
return false
|
|
}
|
|
|
|
if (document.hidden) {
|
|
return true
|
|
}
|
|
|
|
return typeof document.hasFocus === 'function' && !document.hasFocus()
|
|
}
|
|
|
|
function shouldFire(kind: NativeNotificationKind, sessionId?: null | string): boolean {
|
|
// Attention kinds break through for an off-screen session even while focused.
|
|
if (ATTENTION_KINDS.has(kind)) {
|
|
return isBackgrounded() || (Boolean(sessionId) && sessionId !== $activeSessionId.get())
|
|
}
|
|
|
|
// Completion kinds: only the active session, only while away — so a busy
|
|
// gateway (messaging, kanban, cron) can't spam a toast per background session.
|
|
return isBackgrounded() && Boolean(sessionId) && sessionId === $activeSessionId.get()
|
|
}
|
|
|
|
export interface NativeNotificationAction {
|
|
id: string
|
|
text: string
|
|
}
|
|
|
|
export interface NativeNotificationInput {
|
|
kind: NativeNotificationKind
|
|
title: string
|
|
body?: string
|
|
sessionId?: null | string
|
|
silent?: boolean
|
|
actions?: NativeNotificationAction[]
|
|
}
|
|
|
|
export function dispatchNativeNotification(input: NativeNotificationInput): void {
|
|
const prefs = $nativeNotifyPrefs.get()
|
|
|
|
if (!prefs.enabled || !prefs.kinds[input.kind]) {
|
|
return
|
|
}
|
|
|
|
if (!shouldFire(input.kind, input.sessionId)) {
|
|
return
|
|
}
|
|
|
|
if (throttled(`${input.kind}:${input.sessionId ?? ''}`, Date.now())) {
|
|
return
|
|
}
|
|
|
|
void window.hermesDesktop?.notify({
|
|
actions: input.actions,
|
|
body: input.body,
|
|
kind: input.kind,
|
|
sessionId: input.sessionId ?? undefined,
|
|
silent: input.silent,
|
|
title: input.title
|
|
})
|
|
}
|
|
|
|
// 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> {
|
|
const choice = actionId === 'approve' ? 'once' : actionId === 'reject' ? 'deny' : null
|
|
|
|
if (!choice) {
|
|
return
|
|
}
|
|
|
|
const gateway = $gateway.get()
|
|
|
|
if (!gateway) {
|
|
return
|
|
}
|
|
|
|
try {
|
|
await gateway.request('approval.respond', { choice, session_id: sessionId ?? undefined })
|
|
clearApprovalRequest(sessionId)
|
|
} catch {
|
|
// Leave the prompt parked so the user can still resolve it in-app.
|
|
}
|
|
}
|
|
|
|
// Settings "send test" — bypasses gating. Returns whether the OS accepted it so
|
|
// the panel can flag a silent permission failure instead of looking dead.
|
|
export async function sendTestNativeNotification(title: string, body: string): Promise<boolean> {
|
|
const bridge = window.hermesDesktop
|
|
|
|
if (!bridge?.notify) {
|
|
return false
|
|
}
|
|
|
|
try {
|
|
return await bridge.notify({ body, kind: 'turnDone', title })
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|