diff --git a/src/electron/main.ts b/src/electron/main.ts index 014732b..bdf6a1b 100644 --- a/src/electron/main.ts +++ b/src/electron/main.ts @@ -74,6 +74,13 @@ import { MAX_LIVE_WORKFLOW_ITEM_ACTIVITY_EVENTS, } from '@/shared/workflow/activity'; import { shouldScheduleItemAssignmentTask } from '@/shared/workflow/item-assignment'; +import { NotificationManager } from '@/electron/main/notifications/notification-manager'; +import { MacOSNotifier } from '@/electron/main/notifications/macos-notifier'; +import { TelegramNotifier } from '@/electron/main/notifications/telegram-notifier'; +import { + NotificationTrigger, + type NotificationSettingsPatch, +} from '@/electron/main/notifications/types'; if (started) { app.quit(); @@ -82,6 +89,7 @@ if (started) { let mainWindow: BrowserWindow | null = null; let networkProxyManager: NetworkProxyManager | null = null; let runtimeController: DesktopRuntimeController | null = null; +let notificationManager: NotificationManager | null = null; let nudgeScheduled = false; let nudgeIntervalHandle: ReturnType | null = null; let taskSweepIntervalHandle: ReturnType | null = null; @@ -292,6 +300,7 @@ const quitCoordinator = createQuitCoordinator({ clearInterval(taskSweepIntervalHandle); taskSweepIntervalHandle = null; } + notificationManager?.stop(); stopPowerBlocker(); await runtimeController?.shutdown(); }, @@ -315,6 +324,15 @@ function requireNetworkProxyManager() { return networkProxyManager; } +/** Returns notification manager or throws. */ +function requireNotificationManager() { + if (!notificationManager) { + throw new Error('Notification manager is unavailable.'); + } + + return notificationManager; +} + /** Creates window. */ const createWindow = () => { mainWindow = new BrowserWindow( @@ -437,6 +455,59 @@ function recordDuneScheduledTaskEvent( ); } +function formatCurrency(value: number) { + return `$${value.toFixed(value >= 10 ? 0 : 2).replace(/\.00$/, '')}`; +} + +function describeAgentErrorContext(context: string) { + switch (context) { + case 'message-dispatch': + return 'while dispatching a message'; + case 'runtime-restart': + return 'while restarting'; + case 'runtime-rotate': + return 'while rotating its compacted session'; + case 'runtime-start': + return 'while starting'; + case 'scheduled-task': + return 'while running a scheduled task'; + default: + return 'during runtime work'; + } +} + +async function notifyWorkflowStatusTransitions(previous: unknown, next: unknown) { + if (!notificationManager || !isWorkflowSnapshotLike(previous) || !isWorkflowSnapshotLike(next)) { + return; + } + + const previousItemsById = new Map(previous.items.map((item) => [item.id, item])); + + for (const item of next.items) { + const previousItem = previousItemsById.get(item.id); + + if (!previousItem || previousItem.status === item.status) { + continue; + } + + if (item.status === 'review') { + await notificationManager.notify(NotificationTrigger.ItemReview, { + title: 'Work item moved to review', + body: item.title, + itemId: item.id, + }); + } + + if (item.status === 'acceptance') { + await notificationManager.notify(NotificationTrigger.ItemAcceptance, { + title: 'Work item moved to acceptance', + body: item.title, + itemId: item.id, + }); + } + } +} + void app.whenReady().then(async () => { const agentLiteHomeDir = process.env.DUNE_AGENTLITE_HOME_DIR; const duneHomeDir = agentLiteHomeDir ?? os.homedir(); @@ -723,9 +794,17 @@ void app.whenReady().then(async () => { await compactWorkflowActivity(value); } await stores.workflow.set(key, value); + await notifyWorkflowStatusTransitions(previous, value); }, } satisfies AppStorage; + notificationManager = new NotificationManager({ + getAgents: () => runtimeController?.getSnapshot().agents ?? [], + macosNotifier: new MacOSNotifier(() => mainWindow), + store: stores.settings, + telegramNotifier: new TelegramNotifier(() => runtimeController?.getTelegramBridge() ?? null), + }); + /** Resolves store. */ function resolveStore(name: string): AppStorage { if (name === 'workflow') { @@ -791,9 +870,23 @@ void app.whenReady().then(async () => { agentStore: stores.agents, bundledAgentDir: path.join(app.getAppPath(), 'agent'), ...(agentLiteHomeDir ? { homeDir: agentLiteHomeDir } : {}), + onAgentError: ({ agentId, agentName, context, error }) => { + void requireNotificationManager().notify(NotificationTrigger.AgentError, { + title: 'Agent error', + body: `${agentName} hit an error ${describeAgentErrorContext(context)}. ${error}`, + itemId: agentId, + }); + }, onAgentIdle: (_agentId) => { void nudgeIdleMainAgents(requireRuntimeController, workflowStore); }, + onBudgetWarning: ({ agentId, agentName, thresholdUsd, totalCostUsd }) => { + void requireNotificationManager().notify(NotificationTrigger.BudgetWarning, { + title: 'Budget warning', + body: `${agentName} crossed the ${formatCurrency(thresholdUsd)} warning threshold at ${formatCurrency(totalCostUsd)}.`, + itemId: agentId, + }); + }, onItemActivityChanged: (payload) => { for (const window of BrowserWindow.getAllWindows()) { window.webContents.send(ipcChannels.itemActivityUpdated, payload); @@ -832,6 +925,7 @@ void app.whenReady().then(async () => { settingsStore: stores.settings, }); await runtimeController.start(); + requireNotificationManager().startIdleCheck(); // Periodic check: nudge idle project-main agents when inbox is empty nudgeIntervalHandle = setInterval(() => { @@ -1023,6 +1117,20 @@ void app.whenReady().then(async () => { return requireRuntimeController().runIsolatedResearch(agentId, input); }, ); + ipcMain.handle(ipcChannels.getNotificationSettings, async () => + requireNotificationManager().getSettings(), + ); + ipcMain.handle( + ipcChannels.updateNotificationSettings, + async (_event, patch: NotificationSettingsPatch) => + requireNotificationManager().updateSettings(patch), + ); + ipcMain.handle(ipcChannels.getNotificationHistory, async () => + requireNotificationManager().getHistory(), + ); + ipcMain.handle(ipcChannels.clearNotificationHistory, async () => + requireNotificationManager().clearHistory(), + ); ipcMain.handle(ipcChannels.storageGet, async (_event, store: string, key: string) => resolveStore(store).get(key), diff --git a/src/electron/main/notifications/macos-notifier.ts b/src/electron/main/notifications/macos-notifier.ts new file mode 100644 index 0000000..bed54e5 --- /dev/null +++ b/src/electron/main/notifications/macos-notifier.ts @@ -0,0 +1,57 @@ +// macOS notification wrapper. + +import { + BrowserWindow, + Notification, +} from 'electron'; + +export interface MacOSNotificationPayload { + title: string; + body: string; +} + +/** Sends Electron main-process notifications on macOS. */ +export class MacOSNotifier { + constructor( + private readonly getMainWindow: () => BrowserWindow | null = () => null, + ) {} + + /** Sends a system notification when available. */ + send(payload: MacOSNotificationPayload): boolean { + if (process.platform !== 'darwin' || !Notification.isSupported()) { + return false; + } + + const notification = new Notification({ + title: payload.title, + body: payload.body, + }); + + notification.on('click', () => { + this.focusAppWindow(); + }); + notification.show(); + + return true; + } + + private focusAppWindow() { + const mainWindow = this.getMainWindow() + ?? BrowserWindow.getAllWindows().find((window) => !window.isDestroyed()) + ?? null; + + if (!mainWindow) { + return; + } + + if (mainWindow.isMinimized()) { + mainWindow.restore(); + } + + if (!mainWindow.isVisible()) { + mainWindow.show(); + } + + mainWindow.focus(); + } +} diff --git a/src/electron/main/notifications/notification-history.test.ts b/src/electron/main/notifications/notification-history.test.ts new file mode 100644 index 0000000..c57d848 --- /dev/null +++ b/src/electron/main/notifications/notification-history.test.ts @@ -0,0 +1,48 @@ +// Notification history tests. + +import { describe, expect, it } from 'vitest'; + +import { NotificationHistory } from './notification-history'; +import { + NotificationChannel, + NotificationTrigger, +} from './types'; + +describe('NotificationHistory', () => { + it('keeps only the latest 50 records in reverse chronological order', () => { + const history = new NotificationHistory(); + + for (let index = 0; index < 55; index += 1) { + history.add({ + id: `notification-${index}`, + timestamp: index, + trigger: NotificationTrigger.ItemReview, + channel: NotificationChannel.MacOS, + title: `Title ${index}`, + body: `Body ${index}`, + }); + } + + const records = history.getAll(); + + expect(records).toHaveLength(50); + expect(records[0]?.id).toBe('notification-54'); + expect(records.at(-1)?.id).toBe('notification-5'); + }); + + it('clears the in-memory log', () => { + const history = new NotificationHistory(); + + history.add({ + id: 'notification-1', + timestamp: 1, + trigger: NotificationTrigger.ItemReview, + channel: NotificationChannel.MacOS, + title: 'Title', + body: 'Body', + }); + history.clear(); + + expect(history.getAll()).toEqual([]); + }); +}); diff --git a/src/electron/main/notifications/notification-history.ts b/src/electron/main/notifications/notification-history.ts new file mode 100644 index 0000000..dc013ba --- /dev/null +++ b/src/electron/main/notifications/notification-history.ts @@ -0,0 +1,25 @@ +// In-memory rolling notification history. + +import type { NotificationRecord } from './types'; + +const MAX_HISTORY_ENTRIES = 50; + +/** Rolling notification history store. */ +export class NotificationHistory { + private records: NotificationRecord[] = []; + + /** Adds a record to the top of the history. */ + add(record: NotificationRecord) { + this.records = [record, ...this.records].slice(0, MAX_HISTORY_ENTRIES); + } + + /** Returns the current history, newest first. */ + getAll(): NotificationRecord[] { + return this.records.map((record) => ({ ...record })); + } + + /** Clears the current history. */ + clear() { + this.records = []; + } +} diff --git a/src/electron/main/notifications/notification-manager.test.ts b/src/electron/main/notifications/notification-manager.test.ts new file mode 100644 index 0000000..17c0be9 --- /dev/null +++ b/src/electron/main/notifications/notification-manager.test.ts @@ -0,0 +1,259 @@ +// Notification manager tests. + +import { describe, expect, it, vi } from 'vitest'; + +import type { AppStorage } from '@/electron/main/storage'; + +import { NotificationManager } from './notification-manager'; +import { + createDefaultNotificationSettings, + NotificationChannel, + NotificationTrigger, + type NotificationSettings, +} from './types'; + +function createMemoryStore(initialData: Record = {}): AppStorage { + const data = new Map(Object.entries(initialData)); + + return { + delete: async (key) => { + data.delete(key); + }, + get: async (key: string) => (data.get(key) as T) ?? null, + keys: async () => [...data.keys()], + set: async (key: string, value: T) => { + data.set(key, value); + }, + }; +} + +function createNotificationSettings( + partial: { + channels?: Partial; + doNotDisturb?: Partial; + telegramNotifyChatId?: string; + triggers?: Partial; + } = {}, +): NotificationSettings { + const defaults = createDefaultNotificationSettings(); + + return { + triggers: { + ...defaults.triggers, + ...(partial.triggers ?? {}), + }, + channels: { + ...defaults.channels, + ...(partial.channels ?? {}), + }, + doNotDisturb: { + ...defaults.doNotDisturb, + ...(partial.doNotDisturb ?? {}), + }, + telegramNotifyChatId: + partial.telegramNotifyChatId ?? defaults.telegramNotifyChatId, + }; +} + +function flushAsyncWork() { + return new Promise((resolve) => { + globalThis.setTimeout(resolve, 0); + }); +} + +describe('NotificationManager', () => { + it('loads defaults and deep-merges settings updates', async () => { + const manager = new NotificationManager({ + store: createMemoryStore(), + }); + + expect(await manager.getSettings()).toEqual(createNotificationSettings()); + + const nextSettings = await manager.updateSettings({ + channels: { [NotificationChannel.Telegram]: true }, + doNotDisturb: { enabled: true, startHour: 21 }, + telegramNotifyChatId: 'tg:12345', + triggers: { [NotificationTrigger.AgentIdle]: true }, + }); + + expect(nextSettings).toEqual(createNotificationSettings({ + channels: { [NotificationChannel.Telegram]: true }, + doNotDisturb: { enabled: true, startHour: 21 }, + telegramNotifyChatId: 'tg:12345', + triggers: { [NotificationTrigger.AgentIdle]: true }, + })); + }); + + it('suppresses notifications during a midnight-crossing DnD window', async () => { + const macosSend = vi.fn(); + const manager = new NotificationManager({ + macosNotifier: { send: macosSend }, + now: () => new Date(2026, 0, 1, 2, 0, 0, 0).getTime(), + store: createMemoryStore({ + notifications: createNotificationSettings({ + doNotDisturb: { + enabled: true, + startHour: 23, + endHour: 8, + }, + }), + }), + }); + + const notification = await manager.notify(NotificationTrigger.ItemReview, { + title: 'Review ready', + body: 'Landing page QA pass', + itemId: 'item-1', + }); + + expect(notification).toBeNull(); + expect(macosSend).not.toHaveBeenCalled(); + expect(manager.getHistory()).toEqual([]); + }); + + it('throttles notifications per trigger and item and records one entry per channel', async () => { + let now = 1_000; + const macosSend = vi.fn(() => true); + const telegramSend = vi.fn(async () => true); + const manager = new NotificationManager({ + macosNotifier: { send: macosSend }, + telegramNotifier: { send: telegramSend }, + now: () => now, + store: createMemoryStore({ + notifications: createNotificationSettings({ + channels: { + [NotificationChannel.MacOS]: true, + [NotificationChannel.Telegram]: true, + }, + telegramNotifyChatId: 'tg:12345', + }), + }), + }); + + const first = await manager.notify(NotificationTrigger.ItemReview, { + title: 'Review ready', + body: 'Landing page QA pass', + itemId: 'item-1', + }); + const second = await manager.notify(NotificationTrigger.ItemReview, { + title: 'Review ready', + body: 'Landing page QA pass', + itemId: 'item-1', + }); + + now += 10; + + const third = await manager.notify(NotificationTrigger.ItemReview, { + title: 'Review ready', + body: 'Email flow checklist', + itemId: 'item-2', + }); + + expect(first).not.toBeNull(); + expect(second).toBeNull(); + expect(third).not.toBeNull(); + expect(macosSend).toHaveBeenCalledTimes(2); + expect(telegramSend).toHaveBeenCalledTimes(2); + expect( + manager.getHistory().map((record) => `${record.itemId}:${record.channel}`), + ).toEqual([ + `item-2:${NotificationChannel.MacOS}`, + `item-2:${NotificationChannel.Telegram}`, + `item-1:${NotificationChannel.MacOS}`, + `item-1:${NotificationChannel.Telegram}`, + ]); + }); + + it('does not throttle or record history when no channel actually delivers', async () => { + const manager = new NotificationManager({ + macosNotifier: { send: () => false }, + telegramNotifier: { send: async () => false }, + store: createMemoryStore({ + notifications: createNotificationSettings({ + channels: { + [NotificationChannel.MacOS]: true, + [NotificationChannel.Telegram]: true, + }, + telegramNotifyChatId: 'tg:12345', + }), + }), + }); + + const first = await manager.notify(NotificationTrigger.ItemReview, { + title: 'Review ready', + body: 'Landing page QA pass', + itemId: 'item-1', + }); + const second = await manager.notify(NotificationTrigger.ItemReview, { + title: 'Review ready', + body: 'Landing page QA pass', + itemId: 'item-1', + }); + + expect(first).toBeNull(); + expect(second).toBeNull(); + expect(manager.getHistory()).toEqual([]); + }); + + it('notifies idle agents only once until they become active again', async () => { + let now = 31 * 60 * 1000; + let agents = [ + { + id: 'agent-1', + name: 'Navigator', + updatedAt: 0, + }, + ]; + const macosSend = vi.fn(() => true); + const callbacks: Array<() => void> = []; + const manager = new NotificationManager({ + getAgents: () => agents, + macosNotifier: { send: macosSend }, + now: () => now, + setIntervalFn: (((handler: TimerHandler) => { + callbacks.push(handler as () => void); + return 1 as unknown as ReturnType; + }) as unknown) as typeof globalThis.setInterval, + store: createMemoryStore({ + notifications: createNotificationSettings({ + triggers: { [NotificationTrigger.AgentIdle]: true }, + }), + }), + }); + + await manager.getSettings(); + manager.startIdleCheck(); + await flushAsyncWork(); + + expect(macosSend).toHaveBeenCalledTimes(1); + + callbacks[0]!(); + await flushAsyncWork(); + + expect(macosSend).toHaveBeenCalledTimes(1); + + now += 1_000; + agents = [ + { + id: 'agent-1', + name: 'Navigator', + updatedAt: now, + }, + ]; + callbacks[0]!(); + await flushAsyncWork(); + + now += 31 * 60 * 1000; + agents = [ + { + id: 'agent-1', + name: 'Navigator', + updatedAt: now - (31 * 60 * 1000), + }, + ]; + callbacks[0]!(); + await flushAsyncWork(); + + expect(macosSend).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/electron/main/notifications/notification-manager.ts b/src/electron/main/notifications/notification-manager.ts new file mode 100644 index 0000000..fbb86ec --- /dev/null +++ b/src/electron/main/notifications/notification-manager.ts @@ -0,0 +1,381 @@ +// Notification coordination. + +import { createId } from '@/shared/id'; +import type { AppStorage } from '@/electron/main/storage'; + +import { NotificationHistory } from './notification-history'; +import { + MacOSNotifier, + type MacOSNotificationPayload, +} from './macos-notifier'; +import { + TelegramNotifier, + type TelegramNotificationPayload, +} from './telegram-notifier'; +import { + createDefaultNotificationSettings, + notificationTriggers, + NotificationChannel, + NotificationTrigger, + type NotificationRecord, + type NotificationSettings, + type NotificationSettingsPatch, +} from './types'; + +const SETTINGS_KEY = 'notifications'; +const NOTIFICATION_THROTTLE_MS = 5 * 60 * 1000; +const IDLE_CHECK_INTERVAL_MS = 5 * 60 * 1000; +const IDLE_THRESHOLD_MS = 30 * 60 * 1000; + +interface AgentLike { + id: string; + name: string; + updatedAt: number; +} + +interface NotifyOptions { + title: string; + body: string; + itemId?: string; +} + +export interface NotificationManagerOptions { + store: AppStorage; + getAgents?: () => AgentLike[]; + macosNotifier?: Pick; + telegramNotifier?: Pick; + now?: () => number; + setIntervalFn?: typeof globalThis.setInterval; + clearIntervalFn?: typeof globalThis.clearInterval; +} + +function clampHour(value: unknown, fallback: number) { + if (typeof value !== 'number' || !Number.isFinite(value)) { + return fallback; + } + + return Math.max(0, Math.min(23, Math.trunc(value))); +} + +function normalizeNotificationSettings(value: unknown): NotificationSettings { + const defaults = createDefaultNotificationSettings(); + + if (!value || typeof value !== 'object') { + return defaults; + } + + const record = value as Record; + const triggers = record.triggers && typeof record.triggers === 'object' + ? record.triggers as Record + : {}; + const channels = record.channels && typeof record.channels === 'object' + ? record.channels as Record + : {}; + const doNotDisturb = record.doNotDisturb && typeof record.doNotDisturb === 'object' + ? record.doNotDisturb as Record + : {}; + + return { + triggers: Object.fromEntries( + notificationTriggers.map((trigger) => [ + trigger, + typeof triggers[trigger] === 'boolean' + ? triggers[trigger] + : defaults.triggers[trigger], + ]), + ) as NotificationSettings['triggers'], + channels: { + [NotificationChannel.MacOS]: + typeof channels[NotificationChannel.MacOS] === 'boolean' + ? channels[NotificationChannel.MacOS] + : defaults.channels[NotificationChannel.MacOS], + [NotificationChannel.Telegram]: + typeof channels[NotificationChannel.Telegram] === 'boolean' + ? channels[NotificationChannel.Telegram] + : defaults.channels[NotificationChannel.Telegram], + }, + doNotDisturb: { + enabled: + typeof doNotDisturb.enabled === 'boolean' + ? doNotDisturb.enabled + : defaults.doNotDisturb.enabled, + startHour: clampHour(doNotDisturb.startHour, defaults.doNotDisturb.startHour), + endHour: clampHour(doNotDisturb.endHour, defaults.doNotDisturb.endHour), + }, + telegramNotifyChatId: + typeof record.telegramNotifyChatId === 'string' + ? record.telegramNotifyChatId.trim() + : defaults.telegramNotifyChatId, + }; +} + +function mergeNotificationSettings( + current: NotificationSettings, + patch: NotificationSettingsPatch, +): NotificationSettings { + return normalizeNotificationSettings({ + ...current, + ...(patch.telegramNotifyChatId !== undefined + ? { telegramNotifyChatId: patch.telegramNotifyChatId } + : {}), + triggers: { + ...current.triggers, + ...(patch.triggers ?? {}), + }, + channels: { + ...current.channels, + ...(patch.channels ?? {}), + }, + doNotDisturb: { + ...current.doNotDisturb, + ...(patch.doNotDisturb ?? {}), + }, + }); +} + +/** Coordinates settings, history, throttling, and delivery. */ +export class NotificationManager { + private readonly history = new NotificationHistory(); + + private readonly throttleMap = new Map(); + + private readonly idleAgentNotifications = new Set(); + + private readonly now: () => number; + + private readonly setIntervalFn: typeof globalThis.setInterval; + + private readonly clearIntervalFn: typeof globalThis.clearInterval; + + private readonly macosNotifier: Pick; + + private readonly telegramNotifier: Pick; + + private readonly getAgents: (() => AgentLike[]) | null; + + private settings = createDefaultNotificationSettings(); + + private settingsLoaded = false; + + private settingsPromise: Promise | null = null; + + private idleIntervalHandle: ReturnType | null = null; + + constructor(private readonly options: NotificationManagerOptions) { + this.now = options.now ?? Date.now; + this.setIntervalFn = options.setIntervalFn ?? globalThis.setInterval; + this.clearIntervalFn = options.clearIntervalFn ?? globalThis.clearInterval; + this.macosNotifier = options.macosNotifier ?? { send: () => false }; + this.telegramNotifier = options.telegramNotifier ?? { send: async () => false }; + this.getAgents = options.getAgents ?? null; + } + + /** Returns the current settings. */ + async getSettings(): Promise { + return this.ensureSettingsLoaded(); + } + + /** Returns the current history. */ + getHistory(): NotificationRecord[] { + return this.history.getAll(); + } + + /** Clears the in-memory history. */ + clearHistory(): NotificationRecord[] { + this.history.clear(); + return this.history.getAll(); + } + + /** Updates persisted settings with a deep merge. */ + async updateSettings(patch: NotificationSettingsPatch): Promise { + const currentSettings = await this.ensureSettingsLoaded(); + const nextSettings = mergeNotificationSettings(currentSettings, patch); + + await this.options.store.set(SETTINGS_KEY, nextSettings); + this.settings = nextSettings; + this.settingsLoaded = true; + + return nextSettings; + } + + /** Returns true when the current time falls inside the DnD window. */ + async isInDoNotDisturb(timestamp: number = this.now()) { + const settings = await this.ensureSettingsLoaded(); + const { + enabled, + startHour, + endHour, + } = settings.doNotDisturb; + + if (!enabled) { + return false; + } + + if (startHour === endHour) { + return true; + } + + const currentHour = new Date(timestamp).getHours(); + + if (startHour < endHour) { + return currentHour >= startHour && currentHour < endHour; + } + + return currentHour >= startHour || currentHour < endHour; + } + + /** Returns true when a key is still inside the throttle window. */ + isThrottled(key: string, timestamp: number = this.now()) { + const lastSentAt = this.throttleMap.get(key); + + return typeof lastSentAt === 'number' && timestamp - lastSentAt < NOTIFICATION_THROTTLE_MS; + } + + /** Dispatches a notification across enabled channels. */ + async notify( + trigger: NotificationTrigger, + options: NotifyOptions, + ): Promise { + const settings = await this.ensureSettingsLoaded(); + + if (!settings.triggers[trigger]) { + return null; + } + + const timestamp = this.now(); + + if (await this.isInDoNotDisturb(timestamp)) { + return null; + } + + const throttleKey = `${trigger}:${options.itemId ?? 'global'}`; + + if (this.isThrottled(throttleKey, timestamp)) { + return null; + } + + const macosPayload: MacOSNotificationPayload = { + title: options.title, + body: options.body, + }; + const telegramPayload: TelegramNotificationPayload = { + title: options.title, + body: options.body, + chatId: settings.telegramNotifyChatId, + }; + const deliveredChannels: NotificationChannel[] = []; + + if (settings.channels[NotificationChannel.MacOS]) { + try { + if (this.macosNotifier.send(macosPayload)) { + deliveredChannels.push(NotificationChannel.MacOS); + } + } catch (error) { + console.error('Failed to send a macOS notification.', error); + } + } + + if (settings.channels[NotificationChannel.Telegram]) { + try { + if (await this.telegramNotifier.send(telegramPayload)) { + deliveredChannels.push(NotificationChannel.Telegram); + } + } catch (error) { + console.error('Failed to send a Telegram notification.', error); + } + } + + if (deliveredChannels.length === 0) { + return null; + } + + const records = deliveredChannels.map((channel) => ({ + ...(options.itemId ? { itemId: options.itemId } : {}), + id: createId('notification'), + timestamp, + trigger, + channel, + title: options.title, + body: options.body, + } satisfies NotificationRecord)); + + this.throttleMap.set(throttleKey, timestamp); + for (const record of [...records].reverse()) { + this.history.add(record); + } + + return records[0] ?? null; + } + + /** Starts a periodic idle-agent check. */ + startIdleCheck(getAgents: (() => AgentLike[]) | undefined = this.getAgents ?? undefined) { + if (this.idleIntervalHandle || !getAgents) { + return; + } + + this.idleIntervalHandle = this.setIntervalFn(() => { + void this.checkIdleAgents(getAgents); + }, IDLE_CHECK_INTERVAL_MS); + + void this.checkIdleAgents(getAgents); + } + + /** Stops any active idle polling. */ + stop() { + if (!this.idleIntervalHandle) { + return; + } + + this.clearIntervalFn(this.idleIntervalHandle); + this.idleIntervalHandle = null; + } + + private async ensureSettingsLoaded(): Promise { + if (this.settingsLoaded) { + return this.settings; + } + + if (!this.settingsPromise) { + this.settingsPromise = this.options.store + .get(SETTINGS_KEY) + .then((storedSettings) => { + const normalizedSettings = normalizeNotificationSettings(storedSettings); + + this.settings = normalizedSettings; + this.settingsLoaded = true; + + return normalizedSettings; + }) + .finally(() => { + this.settingsPromise = null; + }); + } + + return this.settingsPromise; + } + + private async checkIdleAgents(getAgents: () => AgentLike[]) { + const idleCutoff = this.now() - IDLE_THRESHOLD_MS; + + for (const agent of getAgents()) { + if (agent.updatedAt >= idleCutoff) { + this.idleAgentNotifications.delete(agent.id); + continue; + } + + if (this.idleAgentNotifications.has(agent.id)) { + continue; + } + + const notification = await this.notify(NotificationTrigger.AgentIdle, { + title: 'Agent idle', + body: `${agent.name} has been idle for more than 30 minutes.`, + itemId: agent.id, + }); + + if (notification) { + this.idleAgentNotifications.add(agent.id); + } + } + } +} diff --git a/src/electron/main/notifications/telegram-notifier.ts b/src/electron/main/notifications/telegram-notifier.ts new file mode 100644 index 0000000..7b726a2 --- /dev/null +++ b/src/electron/main/notifications/telegram-notifier.ts @@ -0,0 +1,40 @@ +// Telegram notification wrapper. + +import type { TelegramBridge } from '@/electron/main/runtime/telegram-bridge'; + +export interface TelegramNotificationPayload { + title: string; + body: string; + chatId: string; +} + +/** Normalizes a Telegram notification chat id to AgentLite's jid shape. */ +export function normalizeNotificationChatId(chatId: string) { + const trimmedChatId = chatId.trim(); + + if (!trimmedChatId) { + return ''; + } + + return trimmedChatId.startsWith('tg:') ? trimmedChatId : `tg:${trimmedChatId}`; +} + +/** Sends notification messages through the existing Telegram bridge. */ +export class TelegramNotifier { + constructor( + private readonly getTelegramBridge: () => TelegramBridge | null = () => null, + ) {} + + /** Sends a Telegram notification when a bridge and chat id are available. */ + async send(payload: TelegramNotificationPayload): Promise { + const telegramBridge = this.getTelegramBridge(); + const normalizedChatId = normalizeNotificationChatId(payload.chatId); + const lines = [payload.title.trim(), payload.body.trim()].filter(Boolean); + + if (!telegramBridge || !normalizedChatId || lines.length === 0) { + return false; + } + + return telegramBridge.sendSystemMessage(normalizedChatId, lines.join('\n')); + } +} diff --git a/src/electron/main/notifications/types.ts b/src/electron/main/notifications/types.ts new file mode 100644 index 0000000..e23274e --- /dev/null +++ b/src/electron/main/notifications/types.ts @@ -0,0 +1,79 @@ +// Notification system types. + +export enum NotificationTrigger { + ItemReview = 'item_review', + ItemAcceptance = 'item_acceptance', + AgentError = 'agent_error', + BudgetWarning = 'budget_warning', + AgentIdle = 'agent_idle', +} + +export enum NotificationChannel { + MacOS = 'macos', + Telegram = 'telegram', +} + +export interface NotificationSettings { + triggers: Record; + channels: Record; + doNotDisturb: { + enabled: boolean; + startHour: number; + endHour: number; + }; + telegramNotifyChatId: string; +} + +export interface NotificationSettingsPatch { + triggers?: Partial>; + channels?: Partial>; + doNotDisturb?: Partial; + telegramNotifyChatId?: string; +} + +export interface NotificationRecord { + id: string; + timestamp: number; + trigger: NotificationTrigger; + channel: NotificationChannel; + title: string; + body: string; + itemId?: string; +} + +export const notificationTriggers: NotificationTrigger[] = [ + NotificationTrigger.ItemReview, + NotificationTrigger.ItemAcceptance, + NotificationTrigger.AgentError, + NotificationTrigger.BudgetWarning, + NotificationTrigger.AgentIdle, +]; + +export const defaultNotificationSettings: NotificationSettings = { + triggers: { + [NotificationTrigger.ItemReview]: true, + [NotificationTrigger.ItemAcceptance]: true, + [NotificationTrigger.AgentError]: true, + [NotificationTrigger.BudgetWarning]: true, + [NotificationTrigger.AgentIdle]: false, + }, + channels: { + [NotificationChannel.MacOS]: true, + [NotificationChannel.Telegram]: false, + }, + doNotDisturb: { + enabled: false, + startHour: 23, + endHour: 8, + }, + telegramNotifyChatId: '', +}; + +export function createDefaultNotificationSettings(): NotificationSettings { + return { + triggers: { ...defaultNotificationSettings.triggers }, + channels: { ...defaultNotificationSettings.channels }, + doNotDisturb: { ...defaultNotificationSettings.doNotDisturb }, + telegramNotifyChatId: defaultNotificationSettings.telegramNotifyChatId, + }; +} diff --git a/src/electron/main/runtime/agent-runtime.test.ts b/src/electron/main/runtime/agent-runtime.test.ts index 9afabbb..01f7026 100644 --- a/src/electron/main/runtime/agent-runtime.test.ts +++ b/src/electron/main/runtime/agent-runtime.test.ts @@ -2661,7 +2661,11 @@ describe('AgentRuntime', () => { await flushMicrotasks(); - expect(mockAgent.getTask(taskId!)?.status).toBe('completed'); + const getTask = mockAgent.getTask as unknown as ( + taskId: string, + ) => { status?: string } | undefined; + + expect(getTask(taskId!)?.status).toBe('completed'); expect(host.service.isItemTaskKnown(agentId, taskId!)).toBe(false); }); diff --git a/src/electron/main/runtime/agent-runtime/index.ts b/src/electron/main/runtime/agent-runtime/index.ts index 6cfb7dd..0899ed0 100644 --- a/src/electron/main/runtime/agent-runtime/index.ts +++ b/src/electron/main/runtime/agent-runtime/index.ts @@ -119,6 +119,7 @@ const MAX_RESEARCH_CONCURRENCY = 8; const MAX_RESEARCH_TARGETS = 24; const RESEARCH_RUN_TIMEOUT_MS = 3 * 60 * 1000; const SHUTDOWN_TIMEOUT_MS = 5_000; +const BUDGET_WARNING_THRESHOLD_USD = 1; const TRANSCRIPT_SUMMARY_CARD_ID = 'transcript-rolling-summary'; const ISOLATED_RESEARCH_GROUP_FOLDER_PREFIX = 'isolated-research-slot-'; @@ -415,6 +416,20 @@ const importTelegramModule = globalThis.Function( export type { TelegramSecretsStore }; +export interface AgentErrorEvent { + agentId: string; + agentName: string; + context: string; + error: string; +} + +export interface BudgetWarningEvent { + agentId: string; + agentName: string; + thresholdUsd: number; + totalCostUsd: number; +} + /** Agent runtime options. */ export interface AgentRuntimeOptions { actionServices?: ActionHostServices; @@ -425,7 +440,9 @@ export interface AgentRuntimeOptions { homeDir?: string; loadAgentLiteModule?: () => Promise; now?: () => number; + onAgentError?: (event: AgentErrorEvent) => void; onAgentIdle?: (agentId: string) => void; + onBudgetWarning?: (event: BudgetWarningEvent) => void; onItemActivityChanged?: (payload: { itemId: string; isWorking: boolean }) => void; resolveProjectName?: (projectId: string) => Promise; resolveProjectRootPath?: (projectId: string) => Promise; @@ -476,6 +493,10 @@ export class AgentRuntime implements AgentRuntimeContract { private readonly sessionTokenTotals = new Map(); + private readonly sessionCostTotals = new Map(); + + private readonly budgetWarningAgents = new Set(); + /** Per-agent set of currently running agentlite scheduled task IDs. */ private readonly runningTaskIds = new Map>(); @@ -489,6 +510,10 @@ export class AgentRuntime implements AgentRuntimeContract { private readonly onAgentIdle: AgentRuntimeOptions['onAgentIdle']; + private readonly onAgentError: AgentRuntimeOptions['onAgentError']; + + private readonly onBudgetWarning: AgentRuntimeOptions['onBudgetWarning']; + private readonly onItemActivityChanged: AgentRuntimeOptions['onItemActivityChanged']; /** Per-item ephemeral run state driven by AgentLite task.run.* events. */ @@ -530,7 +555,9 @@ export class AgentRuntime implements AgentRuntimeContract { this.agentStore = options.agentStore; this.actionServices = options.actionServices; this.homeDir = options.homeDir ?? os.homedir(); + this.onAgentError = options.onAgentError; this.onAgentIdle = options.onAgentIdle; + this.onBudgetWarning = options.onBudgetWarning; this.onItemActivityChanged = options.onItemActivityChanged; this.runtimeRoot = resolveAgentLiteRuntimeRoot(options.homeDir); this.bundledAgentDir = options.bundledAgentDir ?? resolveBundledAgentDir(); @@ -640,6 +667,11 @@ export class AgentRuntime implements AgentRuntimeContract { return cloneSnapshot(this.snapshot); } + /** Returns the runtime Telegram bridge for main-process integrations. */ + getTelegramBridge() { + return this.telegram; + } + /** Subscribes to agent updates. */ subscribe(listener: AgentServiceListener) { this.listeners.add(listener); @@ -670,6 +702,7 @@ export class AgentRuntime implements AgentRuntimeContract { `Failed to start agent runtime for "${record.agent.name}" (${record.agent.id}).`, error, ); + this.emitAgentError(record.agent.id, error, 'runtime-start'); } } @@ -696,6 +729,8 @@ export class AgentRuntime implements AgentRuntimeContract { this.pendingTokenUsage.clear(); this.pendingTokenUsageSummaries.clear(); this.sessionTokenTotals.clear(); + this.sessionCostTotals.clear(); + this.budgetWarningAgents.clear(); this.runningTaskIds.clear(); this.telegram.clearAllSetupSessions(); @@ -729,6 +764,8 @@ export class AgentRuntime implements AgentRuntimeContract { this.pendingTokenUsage.clear(); this.pendingTokenUsageSummaries.clear(); this.sessionTokenTotals.clear(); + this.sessionCostTotals.clear(); + this.budgetWarningAgents.clear(); this.runningTaskIds.clear(); this.telegram.reset(); this.records.clear(); @@ -838,6 +875,7 @@ export class AgentRuntime implements AgentRuntimeContract { await this.ensureAgentRuntime(persistedRecord); } catch (error) { console.error(`Failed to start agent runtime for "${trimmedName}".`, error); + this.emitAgentError(agentId, error, 'runtime-start'); if (input.channelId === 'telegram') { await this.telegram.deleteAgentToken(agentId); await this.telegram.refreshRuntimeState(); @@ -981,6 +1019,7 @@ export class AgentRuntime implements AgentRuntimeContract { await this.ensureAgentRuntime(record); } catch (error) { console.error(`Failed to restart agent runtime after definition update.`, error); + this.emitAgentError(trimmedId, error, 'runtime-restart'); } } } @@ -1156,6 +1195,7 @@ export class AgentRuntime implements AgentRuntimeContract { await this.ensureAgentRuntime(persistedRecord); } catch (error) { console.error(`Failed to start project main agent for "${expectedName}".`, error); + this.emitAgentError(agentId, error, 'runtime-start'); this.rollbackOptimisticAgent(agentId); throw error; } @@ -1172,6 +1212,8 @@ export class AgentRuntime implements AgentRuntimeContract { this.pendingTokenUsage.delete(agentId); this.pendingTokenUsageSummaries.delete(agentId); this.sessionTokenTotals.delete(agentId); + this.sessionCostTotals.delete(agentId); + this.budgetWarningAgents.delete(agentId); this.runningTaskIds.delete(agentId); const deletedRecord = this.records.get(agentId)!; const deletedAgent = deletedRecord.agent; @@ -1620,6 +1662,7 @@ export class AgentRuntime implements AgentRuntimeContract { await this.ensureAgentRuntime(record); } catch (error) { console.error(`Failed to rotate compacted runtime session for "${record.agent.name}".`, error); + this.emitAgentError(agentId, error, 'runtime-rotate'); } } @@ -1792,11 +1835,44 @@ export class AgentRuntime implements AgentRuntimeContract { this.persistState(); this.emit(); - await duneAgent.pushUserMessage( - toAgentChatJid(agentId), - options.rawText, - options.senderName, - ); + try { + await duneAgent.pushUserMessage( + toAgentChatJid(agentId), + options.rawText, + options.senderName, + ); + } catch (error) { + console.error(`Failed to dispatch input to "${persistedRecord.agent.name}".`, error); + this.emitAgentError(agentId, error, 'message-dispatch'); + this.messageStream.forget(agentId); + this.pendingTokenUsage.delete(agentId); + this.pendingTokenUsageSummaries.delete(agentId); + this.snapshot = { + ...this.snapshot, + agents: this.snapshot.agents.map((item) => + item.id === agentId + ? { + ...item, + messages: item.messages.map((message) => + message.id === assistantMessage.id + ? { + ...message, + content: error instanceof Error ? error.message : String(error), + status: 'complete', + } + : message, + ), + status: 'ready', + updatedAt: this.now(), + } + : item, + ), + isStreaming: this.messageStream.isStreaming, + }; + this.persistState(); + this.emit(); + throw error; + } } private handleExternalInboundMessage(agentId: string, text: string, senderName: string, attachmentSources: string[] = []) { @@ -1886,6 +1962,53 @@ export class AgentRuntime implements AgentRuntimeContract { }); this.pendingTokenUsage.set(agentId, usage); this.pendingTokenUsageSummaries.set(agentId, formatAgentTokenUsageSummary(usage)); + this.maybeEmitBudgetWarning(agentId, usage.costUsd); + } + + private emitAgentError(agentId: string, error: unknown, context: string) { + if (!this.onAgentError) { + return; + } + + const agentName = this.snapshot.agents.find((agent) => agent.id === agentId)?.name + ?? this.records.get(agentId)?.agent.name + ?? agentId; + + this.onAgentError({ + agentId, + agentName, + context, + error: error instanceof Error ? error.message : String(error), + }); + } + + private maybeEmitBudgetWarning(agentId: string, costUsd?: number) { + if (!this.onBudgetWarning || typeof costUsd !== 'number' || costUsd <= 0) { + return; + } + + const nextTotal = (this.sessionCostTotals.get(agentId) ?? 0) + costUsd; + this.sessionCostTotals.set(agentId, nextTotal); + + if ( + this.budgetWarningAgents.has(agentId) + || nextTotal < BUDGET_WARNING_THRESHOLD_USD + ) { + return; + } + + this.budgetWarningAgents.add(agentId); + + const agentName = this.snapshot.agents.find((agent) => agent.id === agentId)?.name + ?? this.records.get(agentId)?.agent.name + ?? agentId; + + this.onBudgetWarning({ + agentId, + agentName, + thresholdUsd: BUDGET_WARNING_THRESHOLD_USD, + totalCostUsd: nextTotal, + }); } private decorateOutboundMessage(chatJid: string, text: string) { @@ -2502,6 +2625,8 @@ export class AgentRuntime implements AgentRuntimeContract { alAgent.on('task.run.failed', (event) => { this.markTaskRunning(agentId, event.taskId, false); void this.updateItemActivityForTask(event.taskId, false); + console.error(`Scheduled task failed for "${record.agent.name}".`, event.error); + this.emitAgentError(agentId, event.error, 'scheduled-task'); }); alAgent.on('task.run.skipped', (event) => { this.markTaskRunning(agentId, event.taskId, false); @@ -2560,6 +2685,8 @@ export class AgentRuntime implements AgentRuntimeContract { this.pendingTokenUsage.delete(agentId); this.pendingTokenUsageSummaries.delete(agentId); this.sessionTokenTotals.delete(agentId); + this.sessionCostTotals.delete(agentId); + this.budgetWarningAgents.delete(agentId); this.runningTaskIds.delete(agentId); this.records.delete(agentId); this.lifecycle.deleteRuntime(agentId); diff --git a/src/electron/main/runtime/desktop-runtime-controller.ts b/src/electron/main/runtime/desktop-runtime-controller.ts index 11a0f8d..e7f70bd 100644 --- a/src/electron/main/runtime/desktop-runtime-controller.ts +++ b/src/electron/main/runtime/desktop-runtime-controller.ts @@ -21,6 +21,7 @@ import { /** Active runtime shape. */ type ActiveRuntime = AgentRuntimeContract & { + getTelegramBridge?: () => ReturnType; reloadExternalChannels?: () => Promise; shutdown?: () => Promise; }; @@ -90,6 +91,11 @@ export class DesktopRuntimeController { return this.activeRuntime.getSnapshot(); } + /** Returns the live Telegram bridge when the real runtime is active. */ + getTelegramBridge() { + return this.activeRuntime.getTelegramBridge?.() ?? null; + } + /** Subscribes to desktop runtime updates. */ subscribe(listener: AgentServiceListener) { this.listeners.add(listener); diff --git a/src/electron/main/runtime/telegram-bridge.ts b/src/electron/main/runtime/telegram-bridge.ts index b9bfee6..485ca75 100644 --- a/src/electron/main/runtime/telegram-bridge.ts +++ b/src/electron/main/runtime/telegram-bridge.ts @@ -531,7 +531,43 @@ export class TelegramBridge { return patches; } - // sendReply removed — DuneChannel's external driver handles outbound delivery. + /** Sends a one-off system message with any configured Telegram bot token. */ + async sendSystemMessage(chatJid: string, text: string): Promise { + const trimmedChatJid = chatJid.trim(); + const trimmedText = text.trim(); + + if (!trimmedChatJid || !trimmedText) { + return false; + } + + const connectedObserver = [...this.observers.values()] + .find((observer) => observer.status === 'connected' && observer.driver); + + if (connectedObserver?.driver) { + await connectedObserver.driver.sendMessage(trimmedChatJid, trimmedText); + return true; + } + + const token = await this.resolveNotificationToken(); + + if (!token) { + return false; + } + + const driver = await this.createStandaloneDriver(token); + + try { + await connectDriverWithTimeout(driver, TELEGRAM_DRIVER_CONNECT_TIMEOUT_MS); + await driver.sendMessage(trimmedChatJid, trimmedText); + return true; + } finally { + try { + await driver.disconnect(); + } catch (error) { + console.warn('Failed to disconnect the Telegram notification driver cleanly.', error); + } + } + } /** Disconnects all. */ async disconnectAll() { @@ -658,6 +694,16 @@ export class TelegramBridge { return createChannel(config); } + private async createStandaloneDriver(token: string) { + const createChannel = await this.createChannelFactory(token); + + return createChannel({ + onChatMetadata: () => undefined, + onMessage: () => undefined, + registeredGroups: () => ({}), + }); + } + private async disconnectObserver(fingerprint: string) { const observer = this.observers.get(fingerprint); @@ -808,4 +854,44 @@ export class TelegramBridge { status: session.status, }; } + + private async resolveNotificationToken(): Promise { + for (const observer of this.observers.values()) { + if (observer.token.trim()) { + return observer.token.trim(); + } + } + + for (const session of this.setupSessions.values()) { + if (session.token.trim()) { + return session.token.trim(); + } + } + + for (const agent of this.callbacks.getAgents()) { + const token = await this.readAgentToken(agent.id); + + if (token) { + return token; + } + } + + if (!this.secretsStore.keys) { + return null; + } + + for (const key of await this.secretsStore.keys()) { + if (!key.endsWith(':telegram:bot-token')) { + continue; + } + + const token = await this.secretsStore.get(key); + + if (typeof token === 'string' && token.trim()) { + return token.trim(); + } + } + + return null; + } } diff --git a/src/electron/preload.test.ts b/src/electron/preload.test.ts index 9ad7d25..b0de877 100644 --- a/src/electron/preload.test.ts +++ b/src/electron/preload.test.ts @@ -70,6 +70,7 @@ describe('preload bridge', () => { await desktopBridge?.getRuntimeSnapshot?.(); await desktopBridge?.applyNetworkSettings?.(); await desktopBridge?.cancelTelegramSetupSession?.('telegram-session-1'); + await desktopBridge?.clearNotificationHistory?.(); await desktopBridge?.createAgent?.({ channelId: 'dune-chat', name: 'Navigator', @@ -80,6 +81,8 @@ describe('preload bridge', () => { await desktopBridge?.ensureProjectMainAgent?.('project-1', 'Alpha', '/tmp/project-1'); await desktopBridge?.ensureProjectArtifactFolder?.('/tmp/project-1', 'homepage-copy-abcd1234'); await desktopBridge?.getAgentTranscriptPage?.('agent-1', { beforeMessageId: 'message-1', limit: 20 }); + await desktopBridge?.getNotificationHistory?.(); + await desktopBridge?.getNotificationSettings?.(); await desktopBridge?.getProjectActivityPage?.('project-1', { beforeEntryId: 'event-1', limit: 20 }); await desktopBridge?.listProjectArtifactEntries?.('/tmp/project-1', 'homepage-copy-abcd1234'); await desktopBridge?.copyText?.('@agentlite_test_bot'); @@ -100,6 +103,10 @@ describe('preload bridge', () => { agentId: 'agent-1', channelId: 'dune-chat', }); + await desktopBridge?.updateNotificationSettings?.({ + channels: { telegram: true }, + telegramNotifyChatId: 'tg:12345', + }); const listener = vi.fn(); const unsubscribe = desktopBridge?.subscribe?.(listener); @@ -110,6 +117,7 @@ describe('preload bridge', () => { ipcChannels.cancelTelegramSetupSession, 'telegram-session-1', ); + expect(invoke).toHaveBeenCalledWith(ipcChannels.clearNotificationHistory); expect(invoke).toHaveBeenCalledWith(ipcChannels.createAgent, { channelId: 'dune-chat', name: 'Navigator', @@ -133,6 +141,8 @@ describe('preload bridge', () => { 'agent-1', { beforeMessageId: 'message-1', limit: 20 }, ); + expect(invoke).toHaveBeenCalledWith(ipcChannels.getNotificationHistory); + expect(invoke).toHaveBeenCalledWith(ipcChannels.getNotificationSettings); expect(invoke).toHaveBeenCalledWith( ipcChannels.getProjectActivityPage, 'project-1', @@ -178,6 +188,13 @@ describe('preload bridge', () => { channelId: 'dune-chat', }, ); + expect(invoke).toHaveBeenCalledWith( + ipcChannels.updateNotificationSettings, + { + channels: { telegram: true }, + telegramNotifyChatId: 'tg:12345', + }, + ); expect(on).toHaveBeenCalledWith( ipcChannels.runtimeSnapshotUpdated, expect.any(Function), @@ -223,12 +240,15 @@ describe('preload bridge', () => { const expectedMethods = [ 'applyNetworkSettings', 'cancelTelegramSetupSession', + 'clearNotificationHistory', 'createAgent', 'copyText', 'deleteLocalData', 'ensureProjectArtifactFolder', 'ensureProjectMainAgent', 'getAgentTranscriptPage', + 'getNotificationHistory', + 'getNotificationSettings', 'getProjectActivityPage', 'getRuntimeSnapshot', 'getTelegramSetupSession', @@ -250,6 +270,7 @@ describe('preload bridge', () => { 'storageKeys', 'storageSet', 'subscribe', + 'updateNotificationSettings', ]; for (const method of expectedMethods) { diff --git a/src/electron/preload.ts b/src/electron/preload.ts index 83312ed..77117fe 100644 --- a/src/electron/preload.ts +++ b/src/electron/preload.ts @@ -12,6 +12,7 @@ const bridge: DesktopBridge = { applyNetworkSettings: () => ipcRenderer.invoke(ipcChannels.applyNetworkSettings), cancelTelegramSetupSession: (sessionId) => ipcRenderer.invoke(ipcChannels.cancelTelegramSetupSession, sessionId), + clearNotificationHistory: () => ipcRenderer.invoke(ipcChannels.clearNotificationHistory), copyText: (text) => ipcRenderer.invoke(ipcChannels.copyText, text), platform: process.platform, createAgent: (input) => ipcRenderer.invoke(ipcChannels.createAgent, input), @@ -26,6 +27,8 @@ const bridge: DesktopBridge = { projectName, projectRootPath, ), + getNotificationHistory: () => ipcRenderer.invoke(ipcChannels.getNotificationHistory), + getNotificationSettings: () => ipcRenderer.invoke(ipcChannels.getNotificationSettings), getProjectActivityPage: (projectId, options) => ipcRenderer.invoke(ipcChannels.getProjectActivityPage, projectId, options), getAgentTranscriptPage: (agentId, options) => @@ -57,6 +60,8 @@ const bridge: DesktopBridge = { storageKeys: (store) => ipcRenderer.invoke(ipcChannels.storageKeys, store), storageSet: (store, key, value) => ipcRenderer.invoke(ipcChannels.storageSet, store, key, value), selectProjectDirectory: () => ipcRenderer.invoke(ipcChannels.selectProjectDirectory), + updateNotificationSettings: (patch) => + ipcRenderer.invoke(ipcChannels.updateNotificationSettings, patch), subscribe: (listener) => { /** Handles snapshot. */ const handleSnapshot = ( diff --git a/src/renderer/app/testing/setup.ts b/src/renderer/app/testing/setup.ts index 2782807..61d9517 100644 --- a/src/renderer/app/testing/setup.ts +++ b/src/renderer/app/testing/setup.ts @@ -65,10 +65,31 @@ beforeEach(() => { window.duneDesktop = { applyNetworkSettings: vi.fn(() => Promise.resolve(undefined)), cancelTelegramSetupSession: vi.fn(() => Promise.resolve(undefined)), + clearNotificationHistory: vi.fn(() => Promise.resolve([])), copyText: vi.fn(() => Promise.resolve(undefined)), ensureProjectArtifactFolder: vi.fn(() => Promise.resolve('/tmp/project/item-123')), ensureProjectMainAgent: vi.fn(() => Promise.resolve('agent-project-main')), deleteLocalData: vi.fn(() => Promise.resolve(undefined)), + getNotificationHistory: vi.fn(() => Promise.resolve([])), + getNotificationSettings: vi.fn(() => Promise.resolve({ + channels: { + macos: true, + telegram: false, + }, + doNotDisturb: { + enabled: false, + endHour: 8, + startHour: 23, + }, + telegramNotifyChatId: '', + triggers: { + agent_error: true, + agent_idle: false, + budget_warning: true, + item_acceptance: true, + item_review: true, + }, + })), getRuntimeSnapshot: vi.fn(() => Promise.resolve({ agents: [], codingEngines: [], @@ -95,6 +116,36 @@ beforeEach(() => { storageGet: vi.fn(() => Promise.resolve(null)), storageKeys: vi.fn(() => Promise.resolve([])), storageSet: vi.fn(() => Promise.resolve(undefined)), + updateNotificationSettings: vi.fn((patch: { + channels?: { macos?: boolean; telegram?: boolean }; + doNotDisturb?: { enabled?: boolean; endHour?: number; startHour?: number }; + telegramNotifyChatId?: string; + triggers?: { + agent_error?: boolean; + agent_idle?: boolean; + budget_warning?: boolean; + item_acceptance?: boolean; + item_review?: boolean; + }; + }) => Promise.resolve({ + channels: { + macos: patch.channels?.macos ?? true, + telegram: patch.channels?.telegram ?? false, + }, + doNotDisturb: { + enabled: patch.doNotDisturb?.enabled ?? false, + endHour: patch.doNotDisturb?.endHour ?? 8, + startHour: patch.doNotDisturb?.startHour ?? 23, + }, + telegramNotifyChatId: patch.telegramNotifyChatId ?? '', + triggers: { + agent_error: patch.triggers?.agent_error ?? true, + agent_idle: patch.triggers?.agent_idle ?? false, + budget_warning: patch.triggers?.budget_warning ?? true, + item_acceptance: patch.triggers?.item_acceptance ?? true, + item_review: patch.triggers?.item_review ?? true, + }, + })), }; document.documentElement.dataset.theme = 'light'; }); diff --git a/src/renderer/features/settings/config/settings-sections.test.ts b/src/renderer/features/settings/config/settings-sections.test.ts index 2b2104b..23e715f 100644 --- a/src/renderer/features/settings/config/settings-sections.test.ts +++ b/src/renderer/features/settings/config/settings-sections.test.ts @@ -6,7 +6,7 @@ import type { SettingsRoute } from '@/renderer/features/settings/types'; import { settingsSectionRegistry, settingsSections } from './settings-sections'; -const allRoutes: SettingsRoute[] = ['appearance', 'models', 'network', 'shortcuts', 'nuclear']; +const allRoutes = settingsSections.map((section) => section.id) as SettingsRoute[]; describe('settingsSectionRegistry', () => { it('has an entry for every SettingsRoute', () => { @@ -15,11 +15,13 @@ describe('settingsSectionRegistry', () => { } }); - it('renders network below models in the section order', () => { + it('keeps notifications near network and preserves the full section order', () => { expect(settingsSections.map((section) => section.id)).toEqual([ 'appearance', 'models', 'network', + 'notifications', + 'artifacts', 'shortcuts', 'nuclear', ]); diff --git a/src/renderer/features/settings/config/settings-sections.ts b/src/renderer/features/settings/config/settings-sections.ts index 6fbe04e..e1d9071 100644 --- a/src/renderer/features/settings/config/settings-sections.ts +++ b/src/renderer/features/settings/config/settings-sections.ts @@ -7,6 +7,7 @@ import { ArtifactsSettings } from '@/renderer/features/settings/components/Artif import { ModelsSettings } from '@/renderer/features/settings/components/ModelsSettings'; import { NuclearSettings } from '@/renderer/features/settings/components/NuclearSettings'; import { NetworkSettings } from '@/renderer/features/settings/components/NetworkSettings'; +import { NotificationsSettingsPanel } from '@/renderer/features/settings/notifications'; import { ShortcutsSettings } from '@/renderer/features/settings/components/ShortcutsSettings'; import type { @@ -54,6 +55,12 @@ export const settingsSections: SettingsSectionDefinition[] = [ description: 'Proxy and transport path', Component: NetworkSettings, }, + { + id: 'notifications', + title: 'Notifications', + description: 'Alerts and quiet hours', + Component: NotificationsSettingsPanel, + }, { id: 'artifacts', title: 'Artifacts', diff --git a/src/renderer/features/settings/notifications/NotificationsSettingsPanel.test.tsx b/src/renderer/features/settings/notifications/NotificationsSettingsPanel.test.tsx new file mode 100644 index 0000000..c07316f --- /dev/null +++ b/src/renderer/features/settings/notifications/NotificationsSettingsPanel.test.tsx @@ -0,0 +1,59 @@ +// Notification settings panel tests. + +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi } from 'vitest'; + +import { createDefaultExternalChannelsState } from '@/renderer/features/agents/model/channels'; + +import { NotificationsSettingsPanel } from './NotificationsSettingsPanel'; + +function renderPanel() { + render( + , + ); +} + +describe('NotificationsSettingsPanel', () => { + it('auto-saves trigger changes', async () => { + const user = userEvent.setup(); + + renderPanel(); + + expect(await screen.findByRole('heading', { name: 'Delivery and quiet hours' })).toBeInTheDocument(); + await user.click(screen.getByRole('switch', { name: /Agent idle/ })); + + await waitFor(() => { + expect(window.duneDesktop?.updateNotificationSettings).toHaveBeenCalledWith( + expect.objectContaining({ + triggers: expect.objectContaining({ + agent_idle: true, + }), + }), + ); + }); + }); + + it('requires a Telegram chat id before saving that channel', async () => { + const user = userEvent.setup(); + + renderPanel(); + + await screen.findByRole('heading', { name: 'Delivery and quiet hours' }); + await user.click(screen.getByRole('switch', { name: /Telegram messages/ })); + await new Promise((resolve) => { + globalThis.setTimeout(resolve, 400); + }); + + expect( + screen.getByText('Telegram delivery stays paused until you enter a chat id.'), + ).toBeInTheDocument(); + expect(window.duneDesktop?.updateNotificationSettings).not.toHaveBeenCalled(); + }); +}); diff --git a/src/renderer/features/settings/notifications/NotificationsSettingsPanel.tsx b/src/renderer/features/settings/notifications/NotificationsSettingsPanel.tsx new file mode 100644 index 0000000..da11c5b --- /dev/null +++ b/src/renderer/features/settings/notifications/NotificationsSettingsPanel.tsx @@ -0,0 +1,522 @@ +// Notification settings UI. + +import { useEffect, useRef, useState } from 'react'; +import { Bell, ChevronDown, ChevronUp, Clock3, Send } from 'lucide-react'; + +import type { SettingsSectionComponentProps } from '@/renderer/features/settings/config/settings-sections'; +import { cn } from '@/renderer/shared/lib/utils'; +import { Button } from '@/renderer/shared/ui/button'; +import { Input } from '@/renderer/shared/ui/input'; +import { + createDefaultNotificationSettings, + notificationTriggers, + NotificationChannel, + NotificationTrigger, + type NotificationRecord, + type NotificationSettings, +} from '@/electron/main/notifications/types'; + +import { SettingsSectionIntro } from '../components/SettingsSectionIntro'; + +type FeedbackState = + | { kind: 'error'; message: string } + | null; + +interface ToggleCardProps { + checked: boolean; + description: string; + disabled?: boolean; + label: string; + onToggle: () => void; +} + +const AUTO_SAVE_DELAY_MS = 350; +const hourOptions = Array.from({ length: 24 }, (_, hour) => hour); + +const triggerCopy: Record = { + [NotificationTrigger.AgentError]: { + description: 'Alert when an agent runtime or scheduled task reports a failure.', + label: 'Agent errors', + }, + [NotificationTrigger.AgentIdle]: { + description: 'Alert when an agent stays inactive for more than 30 minutes.', + label: 'Agent idle', + }, + [NotificationTrigger.BudgetWarning]: { + description: 'Alert when tracked session cost crosses the warning threshold.', + label: 'Budget warnings', + }, + [NotificationTrigger.ItemAcceptance]: { + description: 'Alert when work moves into acceptance and needs human sign-off.', + label: 'Items moved to acceptance', + }, + [NotificationTrigger.ItemReview]: { + description: 'Alert when work moves into review and is ready for feedback.', + label: 'Items moved to review', + }, +}; + +function formatTimestamp(timestamp: number) { + return new Date(timestamp).toLocaleString(); +} + +function formatTriggerLabel(trigger: NotificationTrigger) { + return triggerCopy[trigger].label; +} + +function formatChannelLabel(channel: NotificationChannel) { + return channel === NotificationChannel.MacOS ? 'macOS' : 'Telegram'; +} + +/** Reusable switch-style settings card. */ +function ToggleCard({ + checked, + description, + disabled = false, + label, + onToggle, +}: ToggleCardProps) { + return ( + + ); +} + +/** Renders notification settings. */ +export function NotificationsSettingsPanel(props: SettingsSectionComponentProps) { + void props; + const [feedback, setFeedback] = useState(null); + const [history, setHistory] = useState([]); + const [isHistoryOpen, setHistoryOpen] = useState(false); + const [isLoading, setLoading] = useState(true); + const [isSaving, setSaving] = useState(false); + const [settings, setSettings] = useState( + createDefaultNotificationSettings(), + ); + const hasLoadedRef = useRef(false); + const lastPersistedSettingsRef = useRef(JSON.stringify(createDefaultNotificationSettings())); + const latestSaveRevisionRef = useRef(0); + + useEffect(() => { + let disposed = false; + + const load = async () => { + setLoading(true); + + try { + const [nextSettings, nextHistory] = await Promise.all([ + window.duneDesktop?.getNotificationSettings?.() + ?? Promise.resolve(createDefaultNotificationSettings()), + window.duneDesktop?.getNotificationHistory?.() + ?? Promise.resolve([]), + ]); + + if (disposed) { + return; + } + + setSettings(nextSettings); + setHistory(nextHistory); + setFeedback(null); + lastPersistedSettingsRef.current = JSON.stringify(nextSettings); + hasLoadedRef.current = true; + } catch (error) { + if (disposed) { + return; + } + + setFeedback({ + kind: 'error', + message: `Failed to load notification settings. ${String(error)}`, + }); + } finally { + if (!disposed) { + setLoading(false); + } + } + }; + + void load(); + + return () => { + disposed = true; + }; + }, []); + + const needsTelegramChatId = settings.channels[NotificationChannel.Telegram] + && !settings.telegramNotifyChatId.trim(); + + useEffect(() => { + if (!hasLoadedRef.current || isLoading) { + return undefined; + } + + const serializedSettings = JSON.stringify(settings); + + if (serializedSettings === lastPersistedSettingsRef.current || needsTelegramChatId) { + setSaving(false); + return undefined; + } + + const saveRevision = latestSaveRevisionRef.current + 1; + latestSaveRevisionRef.current = saveRevision; + setSaving(true); + setFeedback(null); + + const timeoutId = globalThis.setTimeout(() => { + void (async () => { + try { + const nextSettings = await window.duneDesktop?.updateNotificationSettings?.(settings) + ?? settings; + + if (saveRevision !== latestSaveRevisionRef.current) { + return; + } + + lastPersistedSettingsRef.current = JSON.stringify(nextSettings); + setSettings(nextSettings); + } catch (error) { + if (saveRevision !== latestSaveRevisionRef.current) { + return; + } + + setFeedback({ + kind: 'error', + message: `Failed to save notification settings. ${String(error)}`, + }); + } finally { + if (saveRevision === latestSaveRevisionRef.current) { + setSaving(false); + } + } + })(); + }, AUTO_SAVE_DELAY_MS); + + return () => { + globalThis.clearTimeout(timeoutId); + }; + }, [isLoading, needsTelegramChatId, settings]); + + const setTrigger = (trigger: NotificationTrigger) => { + setSettings((current) => ({ + ...current, + triggers: { + ...current.triggers, + [trigger]: !current.triggers[trigger], + }, + })); + }; + + const handleClearHistory = async () => { + try { + const nextHistory = await window.duneDesktop?.clearNotificationHistory?.() + ?? []; + + setHistory(nextHistory); + } catch (error) { + setFeedback({ + kind: 'error', + message: `Failed to clear notification history. ${String(error)}`, + }); + } + }; + + const refreshHistory = async () => { + try { + const nextHistory = await window.duneDesktop?.getNotificationHistory?.() + ?? []; + setHistory(nextHistory); + } catch (error) { + setFeedback({ + kind: 'error', + message: `Failed to refresh notification history. ${String(error)}`, + }); + } + }; + + return ( + <> + + +
+ Telegram notifications reuse an already configured Telegram bot token. Supply the target + chat id below when you enable that channel. +
+ +
+
+ + Triggers +
+
+ {notificationTriggers.map((trigger) => ( + setTrigger(trigger)} + /> + ))} +
+
+ +
+
+ + Channels +
+
+ setSettings((current) => ({ + ...current, + channels: { + ...current.channels, + [NotificationChannel.MacOS]: !current.channels[NotificationChannel.MacOS], + }, + }))} + /> + setSettings((current) => ({ + ...current, + channels: { + ...current.channels, + [NotificationChannel.Telegram]: !current.channels[NotificationChannel.Telegram], + }, + }))} + /> +
+ + {settings.channels[NotificationChannel.Telegram] ? ( +
+ + setSettings((current) => ({ + ...current, + telegramNotifyChatId: event.target.value, + }))} + placeholder="-1001234567890 or tg:-1001234567890" + value={settings.telegramNotifyChatId} + /> +

+ Use the raw Telegram chat id or the `tg:`-prefixed jid. +

+ {needsTelegramChatId ? ( +

+ Enter a chat id before Telegram delivery can be saved. +

+ ) : null} +
+ ) : null} +
+ +
+
+ + Do Not Disturb +
+ +
+ setSettings((current) => ({ + ...current, + doNotDisturb: { + ...current.doNotDisturb, + enabled: !current.doNotDisturb.enabled, + }, + }))} + /> +
+ + {settings.doNotDisturb.enabled ? ( +
+
+ + +
+ +
+ + +
+
+ ) : null} +
+ +
+
+ + + +
+ + {isHistoryOpen ? ( + history.length === 0 ? ( +
+ No notifications have been recorded in this session. +
+ ) : ( +
+ {history.map((record) => ( +
+
+ {formatTriggerLabel(record.trigger)} + {formatChannelLabel(record.channel)} + {formatTimestamp(record.timestamp)} +
+
{record.title}
+
{record.body}
+
+ ))} +
+ ) + ) : null} +
+ + {feedback ? ( +
+ {feedback.message} +
+ ) : null} + +
+ {isLoading + ? 'Loading notification settings…' + : needsTelegramChatId + ? 'Telegram delivery stays paused until you enter a chat id.' + : isSaving + ? 'Saving changes…' + : 'Changes save automatically.'} +
+ + ); +} diff --git a/src/renderer/features/settings/notifications/index.ts b/src/renderer/features/settings/notifications/index.ts new file mode 100644 index 0000000..9c96da4 --- /dev/null +++ b/src/renderer/features/settings/notifications/index.ts @@ -0,0 +1 @@ +export { NotificationsSettingsPanel } from './NotificationsSettingsPanel'; diff --git a/src/renderer/features/settings/types.ts b/src/renderer/features/settings/types.ts index 5d5f72f..75bc4fb 100644 --- a/src/renderer/features/settings/types.ts +++ b/src/renderer/features/settings/types.ts @@ -6,6 +6,7 @@ export type SettingsRoute = | 'artifacts' | 'models' | 'network' + | 'notifications' | 'shortcuts' | 'nuclear'; /** Theme preference shape. */ diff --git a/src/shared/electron/desktop-bridge.ts b/src/shared/electron/desktop-bridge.ts index fa699ab..6555a10 100644 --- a/src/shared/electron/desktop-bridge.ts +++ b/src/shared/electron/desktop-bridge.ts @@ -13,11 +13,17 @@ import type { } from '@/renderer/features/agents/types'; import type { WorkflowProjectActivityPage } from '@/renderer/features/workflow/types'; import type { ProjectArtifactEntry } from '@/shared/workflow/project-artifacts'; +import type { + NotificationRecord, + NotificationSettings, + NotificationSettingsPatch, +} from '@/electron/main/notifications/types'; /** Methods are optional to support browser-only fallback (no Electron preload). */ export interface DesktopBridge { applyNetworkSettings?: () => Promise; cancelTelegramSetupSession?: (sessionId: string) => Promise; + clearNotificationHistory?: () => Promise; copyText?: (text: string) => Promise; platform: NodeJS.Platform; createAgent?: (input: CreateAgentInput) => Promise; @@ -29,6 +35,8 @@ export interface DesktopBridge { projectName: string, projectRootPath?: string | null, ) => Promise; + getNotificationHistory?: () => Promise; + getNotificationSettings?: () => Promise; getProjectActivityPage?: ( projectId: string, options?: { beforeEntryId?: string | null; limit?: number }, @@ -66,6 +74,7 @@ export interface DesktopBridge { subscribeItemActivity?: ( listener: (payload: { itemId: string; isWorking: boolean }) => void, ) => () => void; + updateNotificationSettings?: (patch: NotificationSettingsPatch) => Promise; updateAgentChannel?: (input: UpdateAgentChannelInput) => Promise; updateAgentDefinition?: (agentId: string, definition: AgentDefinition) => Promise; } diff --git a/src/shared/electron/ipc-channels.ts b/src/shared/electron/ipc-channels.ts index 04d2db3..652a946 100644 --- a/src/shared/electron/ipc-channels.ts +++ b/src/shared/electron/ipc-channels.ts @@ -8,9 +8,12 @@ export const ipcChannels = { createAgent: 'dune:runtime:create-agent', deleteLocalData: 'dune:runtime:delete-local-data', deleteAgent: 'dune:runtime:delete-agent', + clearNotificationHistory: 'dune:notifications:clear-history', ensureProjectArtifactFolder: 'dune:runtime:ensure-project-artifact-folder', ensureProjectMainAgent: 'dune:runtime:ensure-project-main-agent', getAgentTranscriptPage: 'dune:runtime:get-agent-transcript-page', + getNotificationHistory: 'dune:notifications:get-history', + getNotificationSettings: 'dune:notifications:get-settings', getProjectActivityPage: 'dune:workflow:get-project-activity-page', getRuntimeSnapshot: 'dune:runtime:get-snapshot', getTelegramSetupSession: 'dune:runtime:get-telegram-setup-session', @@ -35,4 +38,5 @@ export const ipcChannels = { storageGet: 'dune:storage:get', storageKeys: 'dune:storage:keys', storageSet: 'dune:storage:set', + updateNotificationSettings: 'dune:notifications:update-settings', } as const;