summaryrefslogtreecommitdiffhomepage
path: root/packages/core/src/notifications
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-04 21:21:20 +0900
committerAdam Malczewski <[email protected]>2026-06-04 21:21:20 +0900
commit394f1ed37ce860da6fdc385769bf29f9737105cd (patch)
tree4b825dc642cb6eb9a060e54bf8d69288fbee4904 /packages/core/src/notifications
parent81a9cdbadf8c9d940d4fe9a2a0de607dee1f5f1a (diff)
downloaddispatch-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.ts77
-rw-r--r--packages/core/src/notifications/dispatcher.ts287
-rw-r--r--packages/core/src/notifications/index.ts36
-rw-r--r--packages/core/src/notifications/ntfy.ts149
-rw-r--r--packages/core/src/notifications/types.ts108
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"],
-};