summaryrefslogtreecommitdiffhomepage
path: root/packages/core
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-01 11:44:27 +0900
committerAdam Malczewski <[email protected]>2026-06-01 11:44:27 +0900
commit0a5eea4c06371df756aea40f53bb6dbe71df664a (patch)
tree443e454e1edf1814f1a5c8e77507f63812739122 /packages/core
parent00922f6136ff0c6e047bb4a6165682f236971450 (diff)
parent03e58f69e77b7a27e235210158f3f8e499a817c3 (diff)
downloaddispatch-0a5eea4c06371df756aea40f53bb6dbe71df664a.tar.gz
dispatch-0a5eea4c06371df756aea40f53bb6dbe71df664a.zip
merge: dev into r1/claude-reset-fix
Brings in the n2/ntfy-notifications feature (ntfy.sh push notifications with per-event toggles, subagent-suppression flag, topic-only input, Settings UI, dispatcher + transport + config modules, 12+ new tests), the header declutter (theme picker + Debug panel moved into Settings / sidebar), the shared theme boot-apply module, and an a11y label for the remove-panel button. No code changes from this branch were touched by the merge — the overlap was purely textual. Conflict resolution: 1. HANDOFF.md (add/add conflict). Both branches independently put a single-purpose HANDOFF.md at the repo root for their respective in-flight feature, matching the existing convention (c351719 did the same for this branch; 29bdd00 did the same for ntfy). After this merge both features ship, so neither is in-flight anymore. Archive both into notes/: - notes/wake-schedule-handoff.md (this branch — git tracks as a rename from HANDOFF.md) - notes/ntfy-notifications-handoff.md (dev — recovered from MERGE_HEAD before deletion) The root HANDOFF.md is intentionally absent post-merge; the next in-flight branch will create its own. 2. packages/api/tests/routes.test.ts (auto-merged). dev appended ntfy stubs to the vi.mock('@dispatch/core', ...) factory; this branch appended a 'Wake schedule routes' describe block at the bottom. The two regions don't overlap and the textual auto-merge is correct (verified: 6 describe blocks, both mock-stub regions and the new describe present, no conflict markers). Verification on the merge commit: bun run test → 31 files, 495 / 495 passing (was 431 on the branch + 64 from dev) bun run check → biome clean, 156 files bun run --cwd packages/frontend typecheck → svelte-check 0 errors, 0 warnings dev can now fast-forward to this commit: git checkout dev && git merge --ff-only r1/claude-reset-fix
Diffstat (limited to 'packages/core')
-rw-r--r--packages/core/src/index.ts2
-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
-rw-r--r--packages/core/tests/notifications/config.test.ts158
-rw-r--r--packages/core/tests/notifications/dispatcher.test.ts461
-rw-r--r--packages/core/tests/notifications/ntfy.test.ts204
9 files changed, 1482 insertions, 0 deletions
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index b1b17cc..327b0a5 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -68,6 +68,8 @@ export {
export { createProvider } from "./llm/provider.js";
// Models
export { ModelRegistry } from "./models/index.js";
+// Notifications (ntfy.sh)
+export * from "./notifications/index.js";
export * from "./permission/index.js";
// Skills
export {
diff --git a/packages/core/src/notifications/config.ts b/packages/core/src/notifications/config.ts
new file mode 100644
index 0000000..49e6ff4
--- /dev/null
+++ b/packages/core/src/notifications/config.ts
@@ -0,0 +1,77 @@
+// 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
new file mode 100644
index 0000000..01ce00c
--- /dev/null
+++ b/packages/core/src/notifications/dispatcher.ts
@@ -0,0 +1,287 @@
+// 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
new file mode 100644
index 0000000..d1e7891
--- /dev/null
+++ b/packages/core/src/notifications/index.ts
@@ -0,0 +1,36 @@
+// @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
new file mode 100644
index 0000000..eb5de9e
--- /dev/null
+++ b/packages/core/src/notifications/ntfy.ts
@@ -0,0 +1,149 @@
+// 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
new file mode 100644
index 0000000..ef6c059
--- /dev/null
+++ b/packages/core/src/notifications/types.ts
@@ -0,0 +1,108 @@
+// 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"],
+};
diff --git a/packages/core/tests/notifications/config.test.ts b/packages/core/tests/notifications/config.test.ts
new file mode 100644
index 0000000..71dc00c
--- /dev/null
+++ b/packages/core/tests/notifications/config.test.ts
@@ -0,0 +1,158 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+// In-memory fake for the settings table — mounted before the module under
+// test is imported (vi.mock is hoisted).
+const fakeSettings = new Map<string, string>();
+
+vi.mock("../../src/db/settings.js", () => ({
+ getSetting: vi.fn((key: string) => fakeSettings.get(key) ?? null),
+ setSetting: vi.fn((key: string, value: string) => {
+ fakeSettings.set(key, value);
+ }),
+ deleteSetting: vi.fn((key: string) => {
+ fakeSettings.delete(key);
+ }),
+}));
+
+const {
+ clearNtfyConfig,
+ defaultNtfyConfig,
+ loadNtfyConfig,
+ normalizeNtfyConfig,
+ NTFY_CONFIG_KEY,
+ redactNtfyConfig,
+ saveNtfyConfig,
+} = await import("../../src/notifications/config.js");
+
+describe("defaultNtfyConfig", () => {
+ it("disables notifications and ships sane per-event defaults", () => {
+ const cfg = defaultNtfyConfig();
+ expect(cfg.enabled).toBe(false);
+ expect(cfg.topic).toBe("");
+ expect(cfg.authToken).toBe("");
+ expect(cfg.events["turn-completed"]).toBe(true);
+ expect(cfg.events["turn-error"]).toBe(true);
+ expect(cfg.events["permission-required"]).toBe(true);
+ expect(cfg.events["agent-spawned"]).toBe(false);
+ expect(cfg.notifySubagents).toBe(false);
+ });
+});
+
+describe("normalizeNtfyConfig", () => {
+ it("returns defaults for non-object input", () => {
+ expect(normalizeNtfyConfig(null)).toEqual(defaultNtfyConfig());
+ expect(normalizeNtfyConfig(undefined)).toEqual(defaultNtfyConfig());
+ expect(normalizeNtfyConfig(42)).toEqual(defaultNtfyConfig());
+ });
+
+ it("fills in missing event toggles with defaults (newly-added types default OFF)", () => {
+ const normalized = normalizeNtfyConfig({
+ enabled: true,
+ topic: "https://ntfy.sh/x",
+ events: { "turn-completed": false },
+ });
+ expect(normalized.events["turn-completed"]).toBe(false);
+ // Defaults preserved for fields the persisted blob doesn't have.
+ expect(normalized.events["turn-error"]).toBe(true);
+ expect(normalized.events["agent-spawned"]).toBe(false);
+ });
+
+ it("ignores extraneous fields and wrong-typed values", () => {
+ const normalized = normalizeNtfyConfig({
+ enabled: "yes", // wrong type ⇒ default
+ topic: 42, // wrong type ⇒ default
+ authToken: null, // wrong type ⇒ default
+ events: { "turn-completed": "no", bogus: true },
+ extra: "ignored",
+ });
+ expect(normalized.enabled).toBe(false);
+ expect(normalized.topic).toBe("");
+ expect(normalized.authToken).toBe("");
+ expect(normalized.events["turn-completed"]).toBe(true); // default kept
+ expect((normalized.events as Record<string, boolean>).bogus).toBeUndefined();
+ });
+});
+
+describe("normalizeNtfyConfig — notifySubagents", () => {
+ it("defaults notifySubagents to false when absent", () => {
+ const normalized = normalizeNtfyConfig({
+ enabled: true,
+ topic: "https://ntfy.sh/x",
+ });
+ expect(normalized.notifySubagents).toBe(false);
+ });
+
+ it("respects an explicit notifySubagents=true", () => {
+ const normalized = normalizeNtfyConfig({
+ enabled: true,
+ topic: "https://ntfy.sh/x",
+ notifySubagents: true,
+ });
+ expect(normalized.notifySubagents).toBe(true);
+ });
+
+ it("falls back to default when notifySubagents is wrong-typed", () => {
+ const normalized = normalizeNtfyConfig({
+ enabled: true,
+ topic: "https://ntfy.sh/x",
+ notifySubagents: "yes" as unknown,
+ });
+ expect(normalized.notifySubagents).toBe(false);
+ });
+});
+
+describe("load/save round-trip", () => {
+ beforeEach(() => {
+ fakeSettings.clear();
+ });
+
+ it("returns defaults when nothing is persisted", () => {
+ expect(loadNtfyConfig()).toEqual(defaultNtfyConfig());
+ });
+
+ it("round-trips a complete config", () => {
+ const cfg = {
+ enabled: true,
+ topic: "https://ntfy.sh/team",
+ authToken: "tk_abc",
+ events: {
+ "turn-completed": false,
+ "turn-error": true,
+ "permission-required": true,
+ "agent-spawned": true,
+ },
+ notifySubagents: true,
+ } as const;
+ saveNtfyConfig({ ...cfg });
+ const loaded = loadNtfyConfig();
+ expect(loaded).toEqual(cfg);
+ // Persisted as a JSON string under the documented key.
+ expect(fakeSettings.has(NTFY_CONFIG_KEY)).toBe(true);
+ });
+
+ it("returns defaults when stored JSON is corrupt", () => {
+ fakeSettings.set(NTFY_CONFIG_KEY, "{ not json");
+ expect(loadNtfyConfig()).toEqual(defaultNtfyConfig());
+ });
+
+ it("clearNtfyConfig removes the persisted entry", () => {
+ saveNtfyConfig({ ...defaultNtfyConfig(), enabled: true, topic: "https://ntfy.sh/x" });
+ expect(fakeSettings.has(NTFY_CONFIG_KEY)).toBe(true);
+ clearNtfyConfig();
+ expect(fakeSettings.has(NTFY_CONFIG_KEY)).toBe(false);
+ });
+});
+
+describe("redactNtfyConfig", () => {
+ it("strips authToken and surfaces a hasAuthToken flag", () => {
+ const cfg = { ...defaultNtfyConfig(), authToken: "tk_secret" };
+ const redacted = redactNtfyConfig(cfg);
+ expect(redacted.authToken).toBe("");
+ expect(redacted.hasAuthToken).toBe(true);
+ });
+
+ it("hasAuthToken is false for blank tokens", () => {
+ expect(redactNtfyConfig({ ...defaultNtfyConfig(), authToken: "" }).hasAuthToken).toBe(false);
+ expect(redactNtfyConfig({ ...defaultNtfyConfig(), authToken: " " }).hasAuthToken).toBe(false);
+ });
+});
diff --git a/packages/core/tests/notifications/dispatcher.test.ts b/packages/core/tests/notifications/dispatcher.test.ts
new file mode 100644
index 0000000..c2faba6
--- /dev/null
+++ b/packages/core/tests/notifications/dispatcher.test.ts
@@ -0,0 +1,461 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import type { NotificationEvent, NtfyConfig } from "../../src/notifications/types.js";
+
+// The dispatcher imports `loadNtfyConfig` from config.ts, which transitively
+// pulls in `db/index.js` (bun:sqlite). Stub the DB so vitest under Node can
+// load this file. All tests inject `loadConfig` explicitly, so the real
+// settings table is never read.
+vi.mock("../../src/db/index.js", () => ({
+ getDatabase: vi.fn(() => ({
+ query: () => ({ get: () => null, run: () => {} }),
+ run: () => {},
+ })),
+}));
+
+const { NotificationDispatcher } = await import("../../src/notifications/dispatcher.js");
+
+function makeConfig(overrides: Partial<NtfyConfig> = {}): NtfyConfig {
+ return {
+ enabled: true,
+ topic: "test-topic",
+ authToken: "",
+ events: {
+ "turn-completed": true,
+ "turn-error": true,
+ "permission-required": true,
+ "agent-spawned": true,
+ },
+ // Default to true in the test config so existing tests (which never
+ // configure a getTabParentId lookup) keep firing for tab-1 / tab-2 / etc.
+ // Tests of the new subagent gating override this explicitly.
+ notifySubagents: true,
+ ...overrides,
+ };
+}
+
+interface FakeAgentSource {
+ onEvent(
+ listener: (event: { type: string; tabId: string; [k: string]: unknown }) => void,
+ ): () => void;
+ emit(event: { type: string; tabId: string; [k: string]: unknown }): void;
+}
+
+function makeAgentSource(): FakeAgentSource {
+ let l: ((event: { type: string; tabId: string; [k: string]: unknown }) => void) | null = null;
+ return {
+ onEvent(listener) {
+ l = listener;
+ return () => {
+ l = null;
+ };
+ },
+ emit(event) {
+ l?.(event);
+ },
+ };
+}
+
+interface FakePermissionSource {
+ onPromptAdded(
+ listener: (prompt: { id: string; permission: string; description: string }) => void,
+ ): () => void;
+ emit(prompt: { id: string; permission: string; description: string }): void;
+}
+
+function makePermissionSource(): FakePermissionSource {
+ let l: ((prompt: { id: string; permission: string; description: string }) => void) | null = null;
+ return {
+ onPromptAdded(listener) {
+ l = listener;
+ return () => {
+ l = null;
+ };
+ },
+ emit(p) {
+ l?.(p);
+ },
+ };
+}
+
+// Microtask flush so the dispatcher's `void Promise.resolve(...).catch(...)`
+// has a chance to settle before assertions.
+async function flush(): Promise<void> {
+ await Promise.resolve();
+ await Promise.resolve();
+}
+
+describe("NotificationDispatcher.notify", () => {
+ let warnSpy: ReturnType<typeof vi.spyOn>;
+ beforeEach(() => {
+ warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
+ });
+ afterEach(() => {
+ warnSpy.mockRestore();
+ });
+
+ it("does not send when master switch is disabled", async () => {
+ const send = vi.fn(async () => ({ ok: true }));
+ const d = new NotificationDispatcher({
+ loadConfig: () => makeConfig({ enabled: false }),
+ send,
+ });
+ d.notify({ type: "turn-completed", title: "x", message: "y" });
+ await flush();
+ expect(send).not.toHaveBeenCalled();
+ });
+
+ it("does not send when per-event-type toggle is off", async () => {
+ const send = vi.fn(async () => ({ ok: true }));
+ const d = new NotificationDispatcher({
+ loadConfig: () =>
+ makeConfig({
+ events: {
+ "turn-completed": false,
+ "turn-error": true,
+ "permission-required": true,
+ "agent-spawned": false,
+ },
+ }),
+ send,
+ });
+ d.notify({ type: "turn-completed", title: "x", message: "y" });
+ await flush();
+ expect(send).not.toHaveBeenCalled();
+ });
+
+ it("sends when enabled and toggle is on", async () => {
+ const send = vi.fn(async () => ({ ok: true }));
+ const d = new NotificationDispatcher({ loadConfig: () => makeConfig(), send });
+ d.notify({ type: "turn-completed", title: "x", message: "y" });
+ await flush();
+ expect(send).toHaveBeenCalledTimes(1);
+ });
+
+ it("does not throw or block when the transport rejects", async () => {
+ const send = vi.fn(async () => {
+ throw new Error("boom");
+ });
+ const d = new NotificationDispatcher({ loadConfig: () => makeConfig(), send });
+ expect(() => d.notify({ type: "turn-completed", title: "x", message: "y" })).not.toThrow();
+ await flush();
+ expect(send).toHaveBeenCalledTimes(1);
+ expect(warnSpy).toHaveBeenCalled();
+ });
+
+ it("dedupes events with the same dedupeKey within the window", async () => {
+ const send = vi.fn(async () => ({ ok: true }));
+ const d = new NotificationDispatcher({
+ loadConfig: () => makeConfig(),
+ send,
+ dedupeWindowMs: 1000,
+ });
+ const event: NotificationEvent = {
+ type: "permission-required",
+ title: "p",
+ message: "p",
+ dedupeKey: "permission:42",
+ };
+ d.notify(event);
+ d.notify(event);
+ d.notify(event);
+ await flush();
+ expect(send).toHaveBeenCalledTimes(1);
+ });
+
+ it("does not dedupe events without a dedupeKey", async () => {
+ const send = vi.fn(async () => ({ ok: true }));
+ const d = new NotificationDispatcher({ loadConfig: () => makeConfig(), send });
+ d.notify({ type: "turn-completed", title: "x", message: "y" });
+ d.notify({ type: "turn-completed", title: "x", message: "y" });
+ await flush();
+ expect(send).toHaveBeenCalledTimes(2);
+ });
+});
+
+describe("NotificationDispatcher.attachToAgentManager", () => {
+ let warnSpy: ReturnType<typeof vi.spyOn>;
+ beforeEach(() => {
+ warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
+ });
+ afterEach(() => {
+ warnSpy.mockRestore();
+ });
+
+ it("maps `done` → turn-completed (with tab title in the body)", async () => {
+ const send = vi.fn(async () => ({ ok: true }));
+ const source = makeAgentSource();
+ const d = new NotificationDispatcher({
+ loadConfig: () => makeConfig(),
+ send,
+ getTabTitle: (id) => (id === "tab-1" ? "My chat" : null),
+ });
+ d.attachToAgentManager(source);
+ source.emit({ type: "done", tabId: "tab-1", message: { role: "assistant", chunks: [] } });
+ await flush();
+ expect(send).toHaveBeenCalledTimes(1);
+ const event = send.mock.calls[0][1] as NotificationEvent;
+ expect(event.type).toBe("turn-completed");
+ expect(event.title).toContain("My chat");
+ expect(event.tabId).toBe("tab-1");
+ });
+
+ it("maps `error` → turn-error and includes the error text", async () => {
+ const send = vi.fn(async () => ({ ok: true }));
+ const source = makeAgentSource();
+ const d = new NotificationDispatcher({ loadConfig: () => makeConfig(), send });
+ d.attachToAgentManager(source);
+ source.emit({ type: "error", tabId: "tab-1", error: "Rate limit", statusCode: 429 });
+ await flush();
+ expect(send).toHaveBeenCalledTimes(1);
+ const event = send.mock.calls[0][1] as NotificationEvent;
+ expect(event.type).toBe("turn-error");
+ expect(event.message).toContain("Rate limit");
+ expect(event.message).toContain("429");
+ });
+
+ it("ignores `status` events (would spam every transition)", async () => {
+ const send = vi.fn(async () => ({ ok: true }));
+ const source = makeAgentSource();
+ const d = new NotificationDispatcher({ loadConfig: () => makeConfig(), send });
+ d.attachToAgentManager(source);
+ source.emit({ type: "status", tabId: "tab-1", status: "running" });
+ source.emit({ type: "status", tabId: "tab-1", status: "idle" });
+ await flush();
+ expect(send).not.toHaveBeenCalled();
+ });
+
+ it("maps `tab-created` to agent-spawned only for top-level user agents (parentTabId=null AND agentSlug set)", async () => {
+ const send = vi.fn(async () => ({ ok: true }));
+ const source = makeAgentSource();
+ const d = new NotificationDispatcher({ loadConfig: () => makeConfig(), send });
+ d.attachToAgentManager(source);
+
+ // Manual "new tab" with no agent slug ⇒ no notification.
+ source.emit({
+ type: "tab-created",
+ tabId: "tab-1",
+ id: "tab-1",
+ title: "New Tab",
+ parentTabId: null,
+ agentSlug: null,
+ });
+ // Subagent (has a parent) ⇒ no notification.
+ source.emit({
+ type: "tab-created",
+ tabId: "tab-2",
+ id: "tab-2",
+ title: "Subagent",
+ parentTabId: "tab-1",
+ agentSlug: "researcher",
+ });
+ // Top-level user agent ⇒ notify.
+ source.emit({
+ type: "tab-created",
+ tabId: "tab-3",
+ id: "tab-3",
+ title: "Refactor auth code",
+ parentTabId: null,
+ agentSlug: "engineer",
+ });
+ await flush();
+ expect(send).toHaveBeenCalledTimes(1);
+ const event = send.mock.calls[0][1] as NotificationEvent;
+ expect(event.type).toBe("agent-spawned");
+ expect(event.message).toBe("Refactor auth code");
+ expect(event.title).toContain("engineer");
+ });
+
+ it("respects the per-event-type toggle (turn-completed off ⇒ silent)", async () => {
+ const send = vi.fn(async () => ({ ok: true }));
+ const source = makeAgentSource();
+ const d = new NotificationDispatcher({
+ loadConfig: () =>
+ makeConfig({
+ events: {
+ "turn-completed": false,
+ "turn-error": true,
+ "permission-required": true,
+ "agent-spawned": false,
+ },
+ }),
+ send,
+ });
+ d.attachToAgentManager(source);
+ source.emit({ type: "done", tabId: "tab-1", message: { role: "assistant", chunks: [] } });
+ await flush();
+ expect(send).not.toHaveBeenCalled();
+ });
+});
+
+describe("NotificationDispatcher.attachToPermissionManager", () => {
+ let warnSpy: ReturnType<typeof vi.spyOn>;
+ beforeEach(() => {
+ warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
+ });
+ afterEach(() => {
+ warnSpy.mockRestore();
+ });
+
+ it("notifies once per unique prompt id (dedupes re-emits)", async () => {
+ const send = vi.fn(async () => ({ ok: true }));
+ const source = makePermissionSource();
+ const d = new NotificationDispatcher({ loadConfig: () => makeConfig(), send });
+ d.attachToPermissionManager(source);
+
+ source.emit({ id: "1", permission: "bash", description: "Run git status" });
+ source.emit({ id: "1", permission: "bash", description: "Run git status" });
+ source.emit({ id: "2", permission: "read", description: "Read /etc/hosts" });
+ await flush();
+ expect(send).toHaveBeenCalledTimes(2);
+ const events = send.mock.calls.map((c) => c[1] as NotificationEvent);
+ expect(events.map((e) => e.type)).toEqual(["permission-required", "permission-required"]);
+ expect(events.every((e) => e.dedupeKey?.startsWith("permission:"))).toBe(true);
+ });
+});
+
+describe("NotificationDispatcher.dispose", () => {
+ it("releases attached subscriptions", async () => {
+ const send = vi.fn(async () => ({ ok: true }));
+ const source = makeAgentSource();
+ const d = new NotificationDispatcher({ loadConfig: () => makeConfig(), send });
+ d.attachToAgentManager(source);
+ d.dispose();
+ source.emit({ type: "done", tabId: "tab-1", message: { role: "assistant", chunks: [] } });
+ await flush();
+ expect(send).not.toHaveBeenCalled();
+ });
+});
+
+describe("NotificationDispatcher subagent suppression (notifySubagents flag)", () => {
+ let warnSpy: ReturnType<typeof vi.spyOn>;
+ beforeEach(() => {
+ warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
+ });
+ afterEach(() => {
+ warnSpy.mockRestore();
+ });
+
+ const parents = new Map<string, string | null>([
+ ["top-level", null],
+ ["subagent", "top-level"],
+ ]);
+ const getTabParentId = (id: string): string | null | undefined => parents.get(id);
+
+ it("suppresses turn-completed from subagent tabs when notifySubagents=false (default)", async () => {
+ const send = vi.fn(async () => ({ ok: true }));
+ const source = makeAgentSource();
+ const d = new NotificationDispatcher({
+ loadConfig: () => makeConfig({ notifySubagents: false }),
+ send,
+ getTabParentId,
+ });
+ d.attachToAgentManager(source);
+
+ source.emit({ type: "done", tabId: "subagent", message: { role: "assistant", chunks: [] } });
+ source.emit({ type: "done", tabId: "top-level", message: { role: "assistant", chunks: [] } });
+ await flush();
+
+ expect(send).toHaveBeenCalledTimes(1);
+ expect((send.mock.calls[0][1] as NotificationEvent).tabId).toBe("top-level");
+ });
+
+ it("suppresses turn-error from subagent tabs when notifySubagents=false", async () => {
+ const send = vi.fn(async () => ({ ok: true }));
+ const source = makeAgentSource();
+ const d = new NotificationDispatcher({
+ loadConfig: () => makeConfig({ notifySubagents: false }),
+ send,
+ getTabParentId,
+ });
+ d.attachToAgentManager(source);
+
+ source.emit({ type: "error", tabId: "subagent", error: "boom" });
+ source.emit({ type: "error", tabId: "top-level", error: "boom" });
+ await flush();
+
+ expect(send).toHaveBeenCalledTimes(1);
+ expect((send.mock.calls[0][1] as NotificationEvent).tabId).toBe("top-level");
+ });
+
+ it("still notifies subagents when notifySubagents=true", async () => {
+ const send = vi.fn(async () => ({ ok: true }));
+ const source = makeAgentSource();
+ const d = new NotificationDispatcher({
+ loadConfig: () => makeConfig({ notifySubagents: true }),
+ send,
+ getTabParentId,
+ });
+ d.attachToAgentManager(source);
+
+ source.emit({ type: "done", tabId: "subagent", message: { role: "assistant", chunks: [] } });
+ source.emit({ type: "done", tabId: "top-level", message: { role: "assistant", chunks: [] } });
+ await flush();
+
+ expect(send).toHaveBeenCalledTimes(2);
+ });
+
+ it("does NOT gate permission-required (subagents must still get human input)", async () => {
+ const send = vi.fn(async () => ({ ok: true }));
+ const psource = makePermissionSource();
+ const d = new NotificationDispatcher({
+ loadConfig: () => makeConfig({ notifySubagents: false }),
+ send,
+ getTabParentId,
+ });
+ d.attachToPermissionManager(psource);
+
+ psource.emit({ id: "p1", permission: "bash", description: "git status" });
+ await flush();
+
+ expect(send).toHaveBeenCalledTimes(1);
+ expect((send.mock.calls[0][1] as NotificationEvent).type).toBe("permission-required");
+ });
+
+ it("falls back to notifying when getTabParentId is not provided (treat as top-level)", async () => {
+ const send = vi.fn(async () => ({ ok: true }));
+ const source = makeAgentSource();
+ const d = new NotificationDispatcher({
+ loadConfig: () => makeConfig({ notifySubagents: false }),
+ send,
+ // intentionally NO getTabParentId
+ });
+ d.attachToAgentManager(source);
+
+ source.emit({ type: "done", tabId: "anything", message: { role: "assistant", chunks: [] } });
+ await flush();
+
+ // Without a lookup, the dispatcher can't prove this is a subagent; it
+ // must err on the side of notifying so legitimate top-level events
+ // aren't silently dropped.
+ expect(send).toHaveBeenCalledTimes(1);
+ });
+
+ it("falls back to notifying when getTabParentId throws or returns undefined", async () => {
+ const send = vi.fn(async () => ({ ok: true }));
+ const source = makeAgentSource();
+ const d = new NotificationDispatcher({
+ loadConfig: () => makeConfig({ notifySubagents: false }),
+ send,
+ getTabParentId: () => {
+ throw new Error("db unavailable");
+ },
+ });
+ d.attachToAgentManager(source);
+
+ source.emit({ type: "done", tabId: "x", message: { role: "assistant", chunks: [] } });
+ await flush();
+ expect(send).toHaveBeenCalledTimes(1);
+
+ const send2 = vi.fn(async () => ({ ok: true }));
+ const source2 = makeAgentSource();
+ const d2 = new NotificationDispatcher({
+ loadConfig: () => makeConfig({ notifySubagents: false }),
+ send: send2,
+ getTabParentId: () => undefined,
+ });
+ d2.attachToAgentManager(source2);
+ source2.emit({ type: "done", tabId: "x", message: { role: "assistant", chunks: [] } });
+ await flush();
+ expect(send2).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/packages/core/tests/notifications/ntfy.test.ts b/packages/core/tests/notifications/ntfy.test.ts
new file mode 100644
index 0000000..5f14a60
--- /dev/null
+++ b/packages/core/tests/notifications/ntfy.test.ts
@@ -0,0 +1,204 @@
+import { describe, expect, it, vi } from "vitest";
+import { buildNtfyUrl, NTFY_BASE_URL, sendNtfy } from "../../src/notifications/ntfy.js";
+import type { NotificationEvent, NtfyConfig } from "../../src/notifications/types.js";
+
+function makeConfig(overrides: Partial<NtfyConfig> = {}): NtfyConfig {
+ return {
+ enabled: true,
+ topic: "my-topic",
+ authToken: "",
+ events: {
+ "turn-completed": true,
+ "turn-error": true,
+ "permission-required": true,
+ "agent-spawned": true,
+ },
+ notifySubagents: false,
+ ...overrides,
+ };
+}
+
+function makeEvent(overrides: Partial<NotificationEvent> = {}): NotificationEvent {
+ return {
+ type: "turn-completed",
+ title: "Done",
+ message: "all good",
+ ...overrides,
+ };
+}
+
+function makeFetch(
+ response: Partial<{ ok: boolean; status: number; statusText: string; body: string }> = {},
+) {
+ const fetchImpl = vi.fn(async () => ({
+ ok: response.ok ?? true,
+ status: response.status ?? 200,
+ statusText: response.statusText ?? "OK",
+ text: async () => response.body ?? "",
+ }));
+ return fetchImpl;
+}
+
+describe("buildNtfyUrl", () => {
+ it("prefixes the public ntfy.sh host", () => {
+ expect(buildNtfyUrl("my-topic")).toBe(`${NTFY_BASE_URL}/my-topic`);
+ });
+
+ it("trims surrounding whitespace", () => {
+ expect(buildNtfyUrl(" hello ")).toBe(`${NTFY_BASE_URL}/hello`);
+ });
+
+ it("URL-encodes the topic so any string yields a valid URL", () => {
+ // Spaces, slashes, unicode — all preserved as encoded bytes; the ntfy
+ // server is the final authority on what it accepts.
+ expect(buildNtfyUrl("has space")).toBe(`${NTFY_BASE_URL}/has%20space`);
+ expect(buildNtfyUrl("a/b")).toBe(`${NTFY_BASE_URL}/a%2Fb`);
+ expect(buildNtfyUrl("日本語")).toBe(`${NTFY_BASE_URL}/${encodeURIComponent("日本語")}`);
+ });
+});
+
+describe("sendNtfy", () => {
+ it("POSTs to https://ntfy.sh/<topic> with Title/Priority/Tags/Content-Type headers and body", async () => {
+ const fetchImpl = makeFetch();
+ const result = await sendNtfy(
+ makeConfig(),
+ makeEvent({ title: "Hello", message: "World", tags: ["bell"], priority: 4 }),
+ fetchImpl,
+ );
+ expect(result.ok).toBe(true);
+ expect(fetchImpl).toHaveBeenCalledTimes(1);
+ const [url, init] = fetchImpl.mock.calls[0];
+ expect(url).toBe(`${NTFY_BASE_URL}/my-topic`);
+ expect(init.method).toBe("POST");
+ expect(init.headers.Title).toBe("Hello");
+ expect(init.headers.Priority).toBe("4");
+ expect(init.headers.Tags).toBe("bell");
+ expect(init.headers["Content-Type"]).toMatch(/text\/plain/);
+ expect(init.body).toBe("World");
+ });
+
+ it("accepts arbitrary topic strings without a client-side pattern check", async () => {
+ const fetchImpl = makeFetch();
+ // Things the old validator would have rejected — dots, spaces, unicode,
+ // a single-word "any topic". All should POST and let ntfy decide.
+ await sendNtfy(makeConfig({ topic: "release.notes" }), makeEvent(), fetchImpl);
+ await sendNtfy(makeConfig({ topic: "with space" }), makeEvent(), fetchImpl);
+ await sendNtfy(makeConfig({ topic: "Any Topic Whatsoever" }), makeEvent(), fetchImpl);
+ await sendNtfy(makeConfig({ topic: "日本語" }), makeEvent(), fetchImpl);
+ expect(fetchImpl).toHaveBeenCalledTimes(4);
+ expect(fetchImpl.mock.calls[0][0]).toBe(`${NTFY_BASE_URL}/release.notes`);
+ expect(fetchImpl.mock.calls[1][0]).toBe(`${NTFY_BASE_URL}/with%20space`);
+ expect(fetchImpl.mock.calls[2][0]).toBe(`${NTFY_BASE_URL}/Any%20Topic%20Whatsoever`);
+ expect(fetchImpl.mock.calls[3][0]).toBe(`${NTFY_BASE_URL}/${encodeURIComponent("日本語")}`);
+ });
+
+ it("uses per-event-type defaults for priority and tags", async () => {
+ const fetchImpl = makeFetch();
+ await sendNtfy(makeConfig(), makeEvent({ type: "turn-error" }), fetchImpl);
+ const init = fetchImpl.mock.calls[0][1];
+ expect(init.headers.Priority).toBe("4"); // NTFY_DEFAULT_PRIORITIES["turn-error"]
+ expect(init.headers.Tags).toBe("rotating_light");
+ });
+
+ it("attaches Authorization header with Bearer prefix when authToken is a bare token", async () => {
+ const fetchImpl = makeFetch();
+ await sendNtfy(makeConfig({ authToken: "tk_secret " }), makeEvent(), fetchImpl);
+ const init = fetchImpl.mock.calls[0][1];
+ expect(init.headers.Authorization).toBe("Bearer tk_secret");
+ });
+
+ it("passes a pre-prefixed Authorization value (Basic, custom schemes) through verbatim", async () => {
+ const fetchImpl = makeFetch();
+ await sendNtfy(makeConfig({ authToken: "Basic dXNlcjpwYXNz" }), makeEvent(), fetchImpl);
+ expect(fetchImpl.mock.calls[0][1].headers.Authorization).toBe("Basic dXNlcjpwYXNz");
+
+ const fetchImpl2 = makeFetch();
+ await sendNtfy(makeConfig({ authToken: "Bearer already_prefixed" }), makeEvent(), fetchImpl2);
+ expect(fetchImpl2.mock.calls[0][1].headers.Authorization).toBe("Bearer already_prefixed");
+ });
+
+ it("omits Authorization when authToken is blank", async () => {
+ const fetchImpl = makeFetch();
+ await sendNtfy(makeConfig({ authToken: " " }), makeEvent(), fetchImpl);
+ const init = fetchImpl.mock.calls[0][1];
+ expect(init.headers.Authorization).toBeUndefined();
+ });
+
+ it("attaches Click header when clickUrl is set", async () => {
+ const fetchImpl = makeFetch();
+ await sendNtfy(makeConfig(), makeEvent({ clickUrl: "https://example.com/tab/abc" }), fetchImpl);
+ const init = fetchImpl.mock.calls[0][1];
+ expect(init.headers.Click).toBe("https://example.com/tab/abc");
+ });
+
+ it("sanitizes Click header (CR/LF injection guard)", async () => {
+ const fetchImpl = makeFetch();
+ await sendNtfy(
+ makeConfig(),
+ makeEvent({ clickUrl: "https://example.com/\r\nInjected: yes" }),
+ fetchImpl,
+ );
+ const v = fetchImpl.mock.calls[0][1].headers.Click;
+ expect(v).not.toContain("\n");
+ expect(v).not.toContain("\r");
+ });
+
+ it("appends short tab tag when tabId is set", async () => {
+ const fetchImpl = makeFetch();
+ await sendNtfy(
+ makeConfig(),
+ makeEvent({ tabId: "abcdef0123456789", tags: ["bell"] }),
+ fetchImpl,
+ );
+ const init = fetchImpl.mock.calls[0][1];
+ expect(init.headers.Tags).toBe("bell,tab-abcdef01");
+ });
+
+ it("strips CR/LF/control chars from header values (injection guard)", async () => {
+ const fetchImpl = makeFetch();
+ await sendNtfy(makeConfig(), makeEvent({ title: "line1\r\nInjected: yes" }), fetchImpl);
+ const init = fetchImpl.mock.calls[0][1];
+ expect(init.headers.Title).not.toContain("\n");
+ expect(init.headers.Title).not.toContain("\r");
+ expect(init.headers.Title).toBe("line1 Injected: yes");
+ });
+
+ it("returns ok:false when notifications are disabled", async () => {
+ const fetchImpl = makeFetch();
+ const result = await sendNtfy(makeConfig({ enabled: false }), makeEvent(), fetchImpl);
+ expect(result.ok).toBe(false);
+ expect(result.error).toMatch(/disabled/);
+ expect(fetchImpl).not.toHaveBeenCalled();
+ });
+
+ it("returns ok:false when topic is empty / whitespace, without calling fetch", async () => {
+ const fetchImpl = makeFetch();
+ const empty = await sendNtfy(makeConfig({ topic: "" }), makeEvent(), fetchImpl);
+ expect(empty.ok).toBe(false);
+ expect(empty.error).toMatch(/required/i);
+
+ const ws = await sendNtfy(makeConfig({ topic: " " }), makeEvent(), fetchImpl);
+ expect(ws.ok).toBe(false);
+ expect(ws.error).toMatch(/required/i);
+
+ expect(fetchImpl).not.toHaveBeenCalled();
+ });
+
+ it("returns ok:false with status on non-2xx response", async () => {
+ const fetchImpl = makeFetch({ ok: false, status: 403, statusText: "Forbidden", body: "nope" });
+ const result = await sendNtfy(makeConfig(), makeEvent(), fetchImpl);
+ expect(result.ok).toBe(false);
+ expect(result.status).toBe(403);
+ expect(result.error).toMatch(/403/);
+ expect(result.error).toMatch(/nope/);
+ });
+
+ it("returns ok:false with error message on fetch throwing", async () => {
+ const fetchImpl = vi.fn(async () => {
+ throw new Error("ECONNREFUSED");
+ });
+ const result = await sendNtfy(makeConfig(), makeEvent(), fetchImpl);
+ expect(result.ok).toBe(false);
+ expect(result.error).toMatch(/ECONNREFUSED/);
+ });
+});