diff options
| author | Adam Malczewski <[email protected]> | 2026-06-04 21:21:20 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-04 21:21:20 +0900 |
| commit | 394f1ed37ce860da6fdc385769bf29f9737105cd (patch) | |
| tree | 4b825dc642cb6eb9a060e54bf8d69288fbee4904 /packages/core/src/notifications | |
| parent | 81a9cdbadf8c9d940d4fe9a2a0de607dee1f5f1a (diff) | |
| download | dispatch-394f1ed37ce860da6fdc385769bf29f9737105cd.tar.gz dispatch-394f1ed37ce860da6fdc385769bf29f9737105cd.zip | |
chore: genesis — remove all files to rebuild from scratch (arch rewrite)
Diffstat (limited to 'packages/core/src/notifications')
| -rw-r--r-- | packages/core/src/notifications/config.ts | 77 | ||||
| -rw-r--r-- | packages/core/src/notifications/dispatcher.ts | 287 | ||||
| -rw-r--r-- | packages/core/src/notifications/index.ts | 36 | ||||
| -rw-r--r-- | packages/core/src/notifications/ntfy.ts | 149 | ||||
| -rw-r--r-- | packages/core/src/notifications/types.ts | 108 |
5 files changed, 0 insertions, 657 deletions
diff --git a/packages/core/src/notifications/config.ts b/packages/core/src/notifications/config.ts deleted file mode 100644 index 49e6ff4..0000000 --- a/packages/core/src/notifications/config.ts +++ /dev/null @@ -1,77 +0,0 @@ -// Persisted ntfy config — single global JSON blob under one settings key. -// -// One global config (no per-user split): the rest of Dispatch's settings -// table is global today (cf. `title_model_*`, `perm_*`), so notification -// config follows the same pattern. - -import { deleteSetting, getSetting, setSetting } from "../db/settings.js"; -import type { NotificationEventType, NtfyConfig } from "./types.js"; -import { NTFY_DEFAULT_EVENTS, NTFY_EVENT_TYPES } from "./types.js"; - -export const NTFY_CONFIG_KEY = "ntfy_config"; - -/** Defaults returned when nothing is persisted yet. */ -export function defaultNtfyConfig(): NtfyConfig { - return { - enabled: false, - topic: "", - authToken: "", - events: { ...NTFY_DEFAULT_EVENTS }, - notifySubagents: false, - }; -} - -/** - * Normalize an arbitrary parsed JSON value into a complete `NtfyConfig`. - * Tolerant of missing / unexpected fields so a config from an older build - * never throws — missing event toggles fall back to defaults. - */ -export function normalizeNtfyConfig(raw: unknown): NtfyConfig { - const base = defaultNtfyConfig(); - if (!raw || typeof raw !== "object") return base; - const obj = raw as Record<string, unknown>; - const out: NtfyConfig = { - enabled: typeof obj.enabled === "boolean" ? obj.enabled : base.enabled, - topic: typeof obj.topic === "string" ? obj.topic : base.topic, - authToken: typeof obj.authToken === "string" ? obj.authToken : base.authToken, - events: { ...base.events }, - notifySubagents: - typeof obj.notifySubagents === "boolean" ? obj.notifySubagents : base.notifySubagents, - }; - const rawEvents = obj.events; - if (rawEvents && typeof rawEvents === "object") { - const evObj = rawEvents as Record<string, unknown>; - for (const key of NTFY_EVENT_TYPES) { - const v = evObj[key]; - if (typeof v === "boolean") out.events[key as NotificationEventType] = v; - } - } - return out; -} - -/** Load the persisted config (or defaults if none/corrupt). */ -export function loadNtfyConfig(): NtfyConfig { - const raw = getSetting(NTFY_CONFIG_KEY); - if (!raw) return defaultNtfyConfig(); - try { - return normalizeNtfyConfig(JSON.parse(raw)); - } catch { - return defaultNtfyConfig(); - } -} - -/** Persist a complete config (after server-side normalization). */ -export function saveNtfyConfig(config: NtfyConfig): void { - const normalized = normalizeNtfyConfig(config); - setSetting(NTFY_CONFIG_KEY, JSON.stringify(normalized)); -} - -/** Wipe the persisted config (revert to defaults on next load). */ -export function clearNtfyConfig(): void { - deleteSetting(NTFY_CONFIG_KEY); -} - -/** Strip the auth token from a config before returning it over the API. */ -export function redactNtfyConfig(config: NtfyConfig): NtfyConfig & { hasAuthToken: boolean } { - return { ...config, authToken: "", hasAuthToken: config.authToken.trim().length > 0 }; -} diff --git a/packages/core/src/notifications/dispatcher.ts b/packages/core/src/notifications/dispatcher.ts deleted file mode 100644 index 01ce00c..0000000 --- a/packages/core/src/notifications/dispatcher.ts +++ /dev/null @@ -1,287 +0,0 @@ -// 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<unknown>; - /** 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<unknown>; - private getTabTitle: TabTitleLookup | undefined; - private getTabParentId: TabParentLookup | undefined; - private dedupeWindowMs: number; - /** Recently-sent dedupeKey → expiresAt epoch ms. */ - private recentlySent = new Map<string, number>(); - 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); - } -} diff --git a/packages/core/src/notifications/index.ts b/packages/core/src/notifications/index.ts deleted file mode 100644 index d1e7891..0000000 --- a/packages/core/src/notifications/index.ts +++ /dev/null @@ -1,36 +0,0 @@ -// @dispatch/core — ntfy.sh push notifications - -export { - clearNtfyConfig, - defaultNtfyConfig, - loadNtfyConfig, - NTFY_CONFIG_KEY, - normalizeNtfyConfig, - redactNtfyConfig, - saveNtfyConfig, -} from "./config.js"; -export { - type AgentEventSource, - type DispatcherOptions, - NotificationDispatcher, - type PermissionPromptSource, - type TabParentLookup, - type TabTitleLookup, -} from "./dispatcher.js"; -export { - buildNtfyUrl, - type FetchLike, - NTFY_BASE_URL, - type NtfySendResult, - sendNtfy, -} from "./ntfy.js"; -export { - type NotificationEvent, - type NotificationEventType, - NTFY_DEFAULT_EVENTS, - NTFY_DEFAULT_PRIORITIES, - NTFY_DEFAULT_TAGS, - NTFY_EVENT_TYPES, - type NtfyConfig, - type NtfyPriority, -} from "./types.js"; diff --git a/packages/core/src/notifications/ntfy.ts b/packages/core/src/notifications/ntfy.ts deleted file mode 100644 index eb5de9e..0000000 --- a/packages/core/src/notifications/ntfy.ts +++ /dev/null @@ -1,149 +0,0 @@ -// ntfy.sh HTTP transport. -// -// ntfy's API is a simple POST to `https://ntfy.sh/<topic>` with the body -// as the message and metadata passed via HTTP headers: -// Title: notification title -// Priority: 1..5 (3 = default) -// Tags: comma-separated emoji shortcodes -// Click: URL opened when the notification is tapped -// -// The server is hardcoded to the public ntfy.sh instance; the user only -// configures a topic name. We intentionally use `fetch` directly — no -// SDK, no extra deps. - -import type { NotificationEvent, NtfyConfig } from "./types.js"; -import { NTFY_DEFAULT_PRIORITIES, NTFY_DEFAULT_TAGS } from "./types.js"; - -export interface NtfySendResult { - ok: boolean; - status?: number; - error?: string; -} - -/** - * Lightweight fetch shape so callers (and tests) can inject a mock without - * pulling in the DOM `fetch` type from a `Headers` instance. - */ -export type FetchLike = ( - input: string, - init: { method: string; headers: Record<string, string>; body: string; signal?: AbortSignal }, -) => Promise<{ ok: boolean; status: number; statusText?: string; text(): Promise<string> }>; - -/** Base URL of the public ntfy.sh server. */ -export const NTFY_BASE_URL = "https://ntfy.sh"; - -/** - * Build the publish URL for a topic name. - * - * No client-side validation of the topic content: ntfy.sh's accepted - * character set has changed over time and a regex here only locks users - * out of legitimate topics. The topic is URL-encoded so the resulting - * URL is always syntactically valid; if ntfy rejects the name the HTTP - * error surfaces on the first send / `Send test`. - */ -export function buildNtfyUrl(topic: string): string { - return `${NTFY_BASE_URL}/${encodeURIComponent(topic.trim())}`; -} - -/** - * Send a single notification to the configured ntfy topic. - * - * Fire-and-forget at call sites: the dispatcher uses - * `void sendNtfy(...).catch(...)` so a slow/broken ntfy server never blocks - * a turn. We still return a structured result so the explicit - * `POST /notifications/test` route can surface failures back to the UI. - * - * Pure with respect to `config` / `event` — no DB, no module state. - */ -export async function sendNtfy( - config: NtfyConfig, - event: NotificationEvent, - fetchImpl: FetchLike = globalThis.fetch as unknown as FetchLike, - timeoutMs = 10_000, -): Promise<NtfySendResult> { - if (!config.enabled) return { ok: false, error: "Notifications are disabled" }; - if (!config.topic.trim()) return { ok: false, error: "Topic is required" }; - const targetUrl = buildNtfyUrl(config.topic); - - const priority = event.priority ?? NTFY_DEFAULT_PRIORITIES[event.type] ?? 3; - const baseTags = event.tags ?? NTFY_DEFAULT_TAGS[event.type] ?? []; - const tags = [...baseTags]; - if (event.tabId) { - // Short, ASCII-only tag so ntfy's comma-separated header parser is happy. - tags.push(`tab-${event.tabId.slice(0, 8)}`); - } - - const headers: Record<string, string> = { - // ntfy is tolerant of non-ASCII in the Title header but many proxies - // aren't — sanitizeHeader strips CR/LF/control chars (injection guard) - // and leaves UTF-8 in place. Body is sent verbatim as UTF-8. - Title: sanitizeHeader(event.title), - Priority: String(priority), - "Content-Type": "text/plain; charset=utf-8", - }; - if (tags.length > 0) headers.Tags = tags.map(sanitizeHeader).join(","); - if (event.clickUrl) headers.Click = sanitizeHeader(event.clickUrl); - const authValue = buildAuthHeaderValue(config.authToken); - if (authValue) headers.Authorization = authValue; - - // Per-request abort so a hung server doesn't pin a Bun worker forever. - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), timeoutMs); - - try { - const res = await fetchImpl(targetUrl, { - method: "POST", - headers, - body: event.message, - signal: controller.signal, - }); - if (!res.ok) { - const text = await safeReadText(res); - return { - ok: false, - status: res.status, - error: `ntfy responded ${res.status} ${res.statusText ?? ""}: ${text}`.trim(), - }; - } - return { ok: true, status: res.status }; - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - return { ok: false, error: msg }; - } finally { - clearTimeout(timer); - } -} - -/** - * Build the `Authorization` header value from the user-configured token. - * - * - Empty/blank ⇒ no header. - * - Already starts with `Bearer ` / `Basic ` / etc. (RFC 7235 scheme + space) - * ⇒ used verbatim. Lets users paste a complete header for Basic auth or - * any other scheme their private ntfy server supports. - * - Otherwise ⇒ prefixed with `Bearer ` (the common case). - */ -function buildAuthHeaderValue(rawToken: string): string | null { - const trimmed = (rawToken ?? "").trim(); - if (!trimmed) return null; - if (/^[A-Za-z][A-Za-z0-9._~+/-]*\s+\S/.test(trimmed)) { - // Already includes a scheme token (e.g. "Bearer xyz", "Basic dXNlcjpw"). - return sanitizeHeader(trimmed); - } - return `Bearer ${sanitizeHeader(trimmed)}`; -} - -function sanitizeHeader(value: string): string { - // Strip CR/LF (header injection guard) and other control chars, then trim. - // biome-ignore lint/suspicious/noControlCharactersInRegex: intentional - return value.replace(/[\r\n\u0000-\u001f]+/g, " ").trim(); -} - -async function safeReadText(res: { text(): Promise<string> }): Promise<string> { - try { - const t = await res.text(); - return t.length > 200 ? `${t.slice(0, 200)}…` : t; - } catch { - return ""; - } -} diff --git a/packages/core/src/notifications/types.ts b/packages/core/src/notifications/types.ts deleted file mode 100644 index ef6c059..0000000 --- a/packages/core/src/notifications/types.ts +++ /dev/null @@ -1,108 +0,0 @@ -// ntfy.sh push notifications — types - -/** - * Catalog of notification-worthy events. - * - * Kept intentionally small and stable: each entry is something a human - * actually wants pushed to their phone. New event types should be added - * with a sensible default (`NTFY_DEFAULT_EVENTS`) and a mapping in the - * dispatcher. - */ -export type NotificationEventType = - | "turn-completed" - | "turn-error" - | "permission-required" - | "agent-spawned"; - -/** ntfy priority levels (1=min … 5=max). */ -export type NtfyPriority = 1 | 2 | 3 | 4 | 5; - -/** - * A single notification request. Synthesised by the dispatcher from a - * higher-level event source (AgentManager / PermissionManager); fed to - * the ntfy transport. - * - * `dedupeKey` lets the dispatcher suppress duplicate sends (e.g. the - * permission system re-emits the pending list on every change). - */ -export interface NotificationEvent { - type: NotificationEventType; - /** Notification title (short). */ - title: string; - /** Notification body. */ - message: string; - /** Optional ntfy tags (emoji shortcodes — e.g. `["white_check_mark"]`). */ - tags?: string[]; - /** Optional priority override. Defaults are per-event-type. */ - priority?: NtfyPriority; - /** Optional URL the notification deep-links to when tapped. */ - clickUrl?: string; - /** Origin tab id (informational; included in tags as `tab:<short>`). */ - tabId?: string; - /** - * Stable key for suppressing duplicates. Same key + same type within a - * short window ⇒ dropped silently. - */ - dedupeKey?: string; -} - -/** - * Persisted ntfy configuration. Lives in the `settings` table under a - * single key (`ntfy_config`) — one global config, matching the codebase's - * existing single-user assumption (cf. `title_model_*`, `perm_*`). - * - * - `enabled` — master switch. Off ⇒ dispatcher never sends. - * - `topic` — bare ntfy.sh topic name, e.g. `my-secret-topic`. The - * server is hardcoded to https://ntfy.sh; the user only picks a topic. - * Missing ⇒ dispatcher never sends. - * - `authToken` — optional bearer token (rarely needed against ntfy.sh - * directly; preserved for users behind an auth-protected proxy). - * - `events` — per-event-type enable map. Missing entries default to OFF - * so a newly-added event type doesn't silently start firing. - * - `notifySubagents` — when false (default), `turn-completed` and - * `turn-error` notifications from subagent tabs (tabs with a - * `parentTabId`) are suppressed. A parent agent that spawns 8 - * subagents would otherwise push 9 "Turn complete" notifications per - * round — usually noise. `permission-required` is NOT gated: even a - * subagent's permission prompt needs a human tap to proceed. - * `agent-spawned` is already top-level-only by construction. - */ -export interface NtfyConfig { - enabled: boolean; - topic: string; - authToken: string; - events: Record<NotificationEventType, boolean>; - notifySubagents: boolean; -} - -/** All event types this build knows about (the source of truth for UI). */ -export const NTFY_EVENT_TYPES: NotificationEventType[] = [ - "turn-completed", - "turn-error", - "permission-required", - "agent-spawned", -]; - -/** Default per-event-type toggles. */ -export const NTFY_DEFAULT_EVENTS: Record<NotificationEventType, boolean> = { - "turn-completed": true, - "turn-error": true, - "permission-required": true, - "agent-spawned": false, -}; - -/** Default priority per event type (when the event itself doesn't override). */ -export const NTFY_DEFAULT_PRIORITIES: Record<NotificationEventType, NtfyPriority> = { - "turn-completed": 3, - "turn-error": 4, - "permission-required": 4, - "agent-spawned": 2, -}; - -/** Default tag (emoji) per event type. */ -export const NTFY_DEFAULT_TAGS: Record<NotificationEventType, string[]> = { - "turn-completed": ["white_check_mark"], - "turn-error": ["rotating_light"], - "permission-required": ["lock"], - "agent-spawned": ["sparkles"], -}; |
