// NotificationDispatcher — turns high-level Dispatch events into // `sendNtfy(...)` calls, gated by the persisted user config. // // The dispatcher is transport-agnostic at the `notify(event)` interface // boundary: only `sendNtfy` is wired today, but adding another transport // (email, webhook, etc.) means changing this one file, not the call sites. // // All sends are non-blocking (fire-and-forget). A 10-second timeout in // `sendNtfy` bounds the worst case; the dispatcher additionally guards // every send in a try/catch so a transport bug can never propagate into // the agent loop. import { loadNtfyConfig } from "./config.js"; import { type FetchLike, sendNtfy } from "./ntfy.js"; import type { NotificationEvent, NtfyConfig } from "./types.js"; /** Minimal shape of an `AgentManager`-style event stream we hook into. */ export interface AgentEventSource { onEvent( listener: (event: { type: string; tabId: string; [key: string]: unknown }) => void, ): () => void; } /** Minimal shape of a `PermissionManager`-style prompt source. */ export interface PermissionPromptSource { onPromptAdded( listener: (prompt: { id: string; permission: string; description: string }) => void, ): () => void; } /** Look up a human-readable tab title for nicer notification text. */ export type TabTitleLookup = (tabId: string) => string | null; /** * Look up a tab's `parentTabId`. Returns `null` for top-level tabs (no * parent) and `undefined` when the lookup can't be performed (no DB, tab * not found). Both non-strings cause the dispatcher to fall back to * "treat as top-level" to avoid silently dropping notifications when the * lookup is broken. */ export type TabParentLookup = (tabId: string) => string | null | undefined; export interface DispatcherOptions { /** Override the config loader (tests). Defaults to `loadNtfyConfig`. */ loadConfig?: () => NtfyConfig; /** Override the transport (tests). Defaults to the real `sendNtfy`. */ send?: (config: NtfyConfig, event: NotificationEvent) => Promise; /** Optional fetch override (forwarded to `sendNtfy` when `send` not set). */ fetchImpl?: FetchLike; /** Look up a tab title for richer titles. */ getTabTitle?: TabTitleLookup; /** * Look up a tab's `parentTabId`. Used to honour the * `notifySubagents` config flag — when false, `turn-completed` / * `turn-error` from subagent tabs (those with a parent) are * suppressed. */ getTabParentId?: TabParentLookup; /** * How long (ms) a dedupeKey is suppressed for. Permission prompts re-emit * the whole pending list on every change, so dedupe is essential. */ dedupeWindowMs?: number; } export class NotificationDispatcher { private loadConfig: () => NtfyConfig; private send: (config: NtfyConfig, event: NotificationEvent) => Promise; private getTabTitle: TabTitleLookup | undefined; private getTabParentId: TabParentLookup | undefined; private dedupeWindowMs: number; /** Recently-sent dedupeKey → expiresAt epoch ms. */ private recentlySent = new Map(); private unsubs: Array<() => void> = []; constructor(opts: DispatcherOptions = {}) { this.loadConfig = opts.loadConfig ?? loadNtfyConfig; this.send = opts.send ?? ((config, event) => sendNtfy(config, event, opts.fetchImpl ?? undefined)); this.getTabTitle = opts.getTabTitle; this.getTabParentId = opts.getTabParentId; this.dedupeWindowMs = opts.dedupeWindowMs ?? 5_000; } /** * Single internal entry point — every public hook funnels through here. * Public so a future caller can synthesize an arbitrary notification * (e.g. a CLI `dispatch notify` command); kept narrow. */ notify(event: NotificationEvent): void { const config = this.loadConfig(); if (!config.enabled) return; if (!config.events[event.type]) return; if (event.dedupeKey && this.isDuplicate(event.dedupeKey)) return; if (event.dedupeKey) this.markSent(event.dedupeKey); // Fire-and-forget: never await, never throw. try { void Promise.resolve(this.send(config, event)).catch((err) => { console.warn( `[ntfy] send failed for ${event.type}: ${err instanceof Error ? err.message : String(err)}`, ); }); } catch (err) { // Guard the synchronous portion of `send` too. console.warn( `[ntfy] dispatch threw for ${event.type}: ${err instanceof Error ? err.message : String(err)}`, ); } } /** * Hook into an `AgentManager`-style event stream. * * Maps: * - `done` → `turn-completed` * - `error` → `turn-error` * - `tab-created` → `agent-spawned` (only top-level user-agent tabs) * * `status` events are ignored — they fire on every transition and we'd * either spam or duplicate the `done`/`error` notifications. * * Turn events from subagent tabs are suppressed when * `config.notifySubagents === false` (the default). A parent agent * spawning 8 subagents would otherwise produce 9 "Turn complete" * pushes per round; almost always noise. Permission prompts are NOT * gated this way — a subagent's permission request still needs human * input to proceed, so suppressing those would silently hang the * subagent. */ attachToAgentManager(source: AgentEventSource): () => void { const unsub = source.onEvent((event) => { if (event.type === "done") { if (this.isSubagentSuppressed(event.tabId)) return; this.notify(this.buildTurnCompleted(event)); } else if (event.type === "error") { if (this.isSubagentSuppressed(event.tabId)) return; this.notify(this.buildTurnError(event)); } else if (event.type === "tab-created") { const ev = event as unknown as { tabId: string; id: string; title: string; parentTabId: string | null; agentSlug?: string | null; }; // Only notify for top-level user-agent tabs spawned via `summon`. // Filtering on `agentSlug` skips "blank" new tabs the user opened // manually, which would be noisy. if (ev.parentTabId === null && ev.agentSlug) { this.notify(this.buildAgentSpawned(ev)); } } }); this.unsubs.push(unsub); return unsub; } /** Hook into a `PermissionManager`-style prompt source. */ attachToPermissionManager(source: PermissionPromptSource): () => void { const unsub = source.onPromptAdded((prompt) => { this.notify(this.buildPermissionRequired(prompt)); }); this.unsubs.push(unsub); return unsub; } /** Release all hooks acquired via `attachTo*`. */ dispose(): void { for (const u of this.unsubs) { try { u(); } catch { // best-effort } } this.unsubs = []; this.recentlySent.clear(); } // ─── Event builders (internal) ──────────────────────────────── private buildTurnCompleted(event: { tabId: string }): NotificationEvent { const tabLabel = this.tabLabel(event.tabId); return { type: "turn-completed", title: `Turn complete — ${tabLabel}`, message: `Assistant finished a turn in ${tabLabel}.`, tabId: event.tabId, }; } private buildTurnError(event: { tabId: string; error?: unknown; statusCode?: unknown; }): NotificationEvent { const tabLabel = this.tabLabel(event.tabId); const errText = typeof event.error === "string" ? event.error : "Unknown error"; const statusText = typeof event.statusCode === "number" ? ` (status ${event.statusCode})` : ""; return { type: "turn-error", title: `Turn failed — ${tabLabel}`, message: `${errText}${statusText}`, tabId: event.tabId, }; } private buildPermissionRequired(prompt: { id: string; permission: string; description: string; }): NotificationEvent { return { type: "permission-required", title: `Permission required: ${prompt.permission}`, message: prompt.description || `Agent is requesting ${prompt.permission} permission.`, // Permission prompts can re-emit (e.g. another prompt arrives while // this one is still pending) — dedupe on the prompt id. dedupeKey: `permission:${prompt.id}`, }; } private buildAgentSpawned(ev: { tabId: string; id: string; title: string; agentSlug?: string | null; }): NotificationEvent { return { type: "agent-spawned", title: `User agent spawned — ${ev.agentSlug ?? "agent"}`, message: ev.title, tabId: ev.tabId ?? ev.id, }; } private tabLabel(tabId: string): string { const title = this.getTabTitle?.(tabId); if (title?.trim()) return title.trim(); return `tab ${tabId.slice(0, 8)}`; } /** * Returns true when this `tabId` belongs to a subagent AND the user has * opted out of subagent turn notifications. On lookup failure * (`getTabParentId` returns `undefined` or throws) we err on the side * of "not a subagent" — better to over-notify than to silently drop * legitimate top-level events when the DB is briefly unreadable. */ private isSubagentSuppressed(tabId: string): boolean { const config = this.loadConfig(); if (config.notifySubagents) return false; if (!this.getTabParentId) return false; let parent: string | null | undefined; try { parent = this.getTabParentId(tabId); } catch { return false; } // Only a non-empty string parent id means "this tab is a subagent". return typeof parent === "string" && parent.length > 0; } // ─── Dedupe helpers ─────────────────────────────────────────── private isDuplicate(key: string): boolean { const expires = this.recentlySent.get(key); if (expires === undefined) return false; if (expires <= Date.now()) { this.recentlySent.delete(key); return false; } return true; } private markSent(key: string): void { // Lazy-evict expired entries when the map gets large. if (this.recentlySent.size > 256) { const now = Date.now(); for (const [k, exp] of this.recentlySent) { if (exp <= now) this.recentlySent.delete(k); } } this.recentlySent.set(key, Date.now() + this.dedupeWindowMs); } }