From bbc85ff04b6009ff77a72b93c5853eecf9cb3e82 Mon Sep 17 00:00:00 2001 From: Adam Malczewski Date: Mon, 1 Jun 2026 09:13:44 +0900 Subject: feat(header): remove copy + theme buttons; keep title, status, sidebar toggle These move to dedicated sidebar panels (Debug panel and Settings panel respectively) in follow-up commits. Header is now visibly cleaner: only the Dispatch title (left), connection status indicator, and the Sidebar toggle (right) remain. --- packages/frontend/src/lib/components/Header.svelte | 41 ---------------------- 1 file changed, 41 deletions(-) (limited to 'packages') diff --git a/packages/frontend/src/lib/components/Header.svelte b/packages/frontend/src/lib/components/Header.svelte index 713e916..3066e81 100644 --- a/packages/frontend/src/lib/components/Header.svelte +++ b/packages/frontend/src/lib/components/Header.svelte @@ -1,29 +1,8 @@ - -{#if showThemeSwitcher} - (showThemeSwitcher = false)} /> -{/if} -- cgit v1.2.3 From dd3c71e3d5c8c1b9b23bcf3fdbc34dc306a80570 Mon Sep 17 00:00:00 2001 From: Adam Malczewski Date: Mon, 1 Jun 2026 09:14:30 +0900 Subject: feat(sidebar): add Debug panel with copy-conversation action New "Debug" panel option in the sidebar, grouping dev-facing actions. Currently exposes the Copy-conversation button (ported from the old header). Leaves room for additional debug actions without re-cluttering the header. The Copy action wraps `tabStore.copyConversation()` and shows a "Copied"/"Failed" affordance for 1.5s, matching the previous header behavior. --- .../frontend/src/lib/components/DebugPanel.svelte | 35 ++++++++++++++++++++++ .../src/lib/components/SidebarPanel.svelte | 4 +++ 2 files changed, 39 insertions(+) create mode 100644 packages/frontend/src/lib/components/DebugPanel.svelte (limited to 'packages') diff --git a/packages/frontend/src/lib/components/DebugPanel.svelte b/packages/frontend/src/lib/components/DebugPanel.svelte new file mode 100644 index 0000000..aea1ccb --- /dev/null +++ b/packages/frontend/src/lib/components/DebugPanel.svelte @@ -0,0 +1,35 @@ + + +
+
Debug
+ +
+

Conversation

+

+ Copy a structured plain-text dump of the active tab's conversation + (chunk shape included) for bug reports. +

+ +
+
diff --git a/packages/frontend/src/lib/components/SidebarPanel.svelte b/packages/frontend/src/lib/components/SidebarPanel.svelte index 206ed09..66fa6a4 100644 --- a/packages/frontend/src/lib/components/SidebarPanel.svelte +++ b/packages/frontend/src/lib/components/SidebarPanel.svelte @@ -4,6 +4,7 @@ import type { CacheStats, KeyInfo, LogEntry, TaskItem } from "../types.js"; import CacheRatePanel from "./CacheRatePanel.svelte"; import ClaudeReset from "./ClaudeReset.svelte"; import ConfigPanel from "./ConfigPanel.svelte"; +import DebugPanel from "./DebugPanel.svelte"; import KeyUsage from "./KeyUsage.svelte"; import ModelSelector from "./ModelSelector.svelte"; import ModelStatus from "./ModelStatus.svelte"; @@ -95,6 +96,7 @@ const viewOptions = [ "Skills", "Tools", "Settings", + "Debug", ]; function addPanel() { @@ -181,6 +183,8 @@ function contentClass(_selected: string): string { {:else if panel.selected === "Settings"} + {:else if panel.selected === "Debug"} + {/if} -- cgit v1.2.3 From 751e411b3ab321129083f86f0be53687185abd87 Mon Sep 17 00:00:00 2001 From: Adam Malczewski Date: Mon, 1 Jun 2026 09:15:43 +0900 Subject: feat(settings): inline theme picker into Settings panel The Theme button + ThemeSwitcher modal were a header-triggered modal. That doesn't belong in a sidebar-panel architecture, and theme picking is a UI preference that belongs alongside the other Settings entries. - Add a "Theme" section as the first block in SettingsPanel with the same theme list as ThemeSwitcher. - The localStorage key (`dispatch-theme`) and apply-on-change behavior are unchanged, so the boot-time theme apply in App.svelte's onMount keeps working without modification. - Delete the now-unused ThemeSwitcher.svelte component; no remaining importers. --- .../src/lib/components/SettingsPanel.svelte | 52 +++++++++++++++++++ .../src/lib/components/ThemeSwitcher.svelte | 58 ---------------------- 2 files changed, 52 insertions(+), 58 deletions(-) delete mode 100644 packages/frontend/src/lib/components/ThemeSwitcher.svelte (limited to 'packages') diff --git a/packages/frontend/src/lib/components/SettingsPanel.svelte b/packages/frontend/src/lib/components/SettingsPanel.svelte index 392852a..efbaf5f 100644 --- a/packages/frontend/src/lib/components/SettingsPanel.svelte +++ b/packages/frontend/src/lib/components/SettingsPanel.svelte @@ -11,6 +11,42 @@ const { apiBase?: string; } = $props(); +// Theme picker — was a header-triggered modal (`ThemeSwitcher.svelte`); +// inlined here so theme picking lives in Settings alongside other UI +// preferences. The list and localStorage key must stay in sync with the +// boot-time theme apply in `App.svelte`'s `onMount`. +const THEMES = [ + "light", + "dark", + "dracula", + "night", + "nord", + "sunset", + "cyberpunk", + "forest", + "cmyk", + "coffee", + "caramellatte", + "garden", + "luxury", +] as const; + +const THEME_STORAGE_KEY = "dispatch-theme"; + +let currentTheme = $state( + (typeof localStorage !== "undefined" && localStorage.getItem(THEME_STORAGE_KEY)) || "dark", +); + +function selectTheme(theme: string): void { + currentTheme = theme; + document.documentElement.setAttribute("data-theme", theme); + try { + localStorage.setItem(THEME_STORAGE_KEY, theme); + } catch { + // Best-effort — private mode / quota. + } +} + let titleKeyId = $state(null); let titleModelId = $state(null); let availableModels = $state([]); @@ -136,6 +172,22 @@ $effect(() => {
Settings
+

Theme

+ + +
+

Title Generation Model

Used to generate short titles for new tabs after the first message.

diff --git a/packages/frontend/src/lib/components/ThemeSwitcher.svelte b/packages/frontend/src/lib/components/ThemeSwitcher.svelte deleted file mode 100644 index 418fcea..0000000 --- a/packages/frontend/src/lib/components/ThemeSwitcher.svelte +++ /dev/null @@ -1,58 +0,0 @@ - - - - - - -- cgit v1.2.3 From 5e72191cac9469c2ade91aaba1e62f69fa1ad94a Mon Sep 17 00:00:00 2001 From: Adam Malczewski Date: Mon, 1 Jun 2026 09:28:21 +0900 Subject: feat(core): ntfy.sh notification dispatcher module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a transport-agnostic NotificationDispatcher and a fire-and-forget ntfy.sh transport (no SDK; just fetch). Configuration is persisted as a single global JSON blob under the 'ntfy_config' settings key. Event taxonomy (per-event toggles): - turn-completed — assistant turn finished cleanly - turn-error — final turn error (after all fallbacks) - permission-required — new permission prompt was created - agent-spawned — top-level user-agent tab spawned via 'summon' Design: - Single internal notify(event) interface so a future transport (email, webhook) plugs in without changing call sites. - attachToAgentManager + attachToPermissionManager subscribe to the existing event streams via narrow listener interfaces (no @dispatch/api dependency back into core). - 5s in-memory dedupe window on dedupeKey suppresses permission re-emits. - 10s per-request abort timeout so a hung ntfy server can't pin a worker. - All sends are fire-and-forget: void Promise.resolve(...).catch(warn). Tests (39 new): - ntfy transport: URL/headers/body/auth/click, header sanitization, per-event-type defaults, error paths. - config: defaults, normalization tolerance, round-trip, redaction. - dispatcher: master switch, per-event toggle, dedupe, agent/permission hookups, top-level-only filtering for agent-spawned, dispose. --- packages/core/src/index.ts | 2 + packages/core/src/notifications/config.ts | 74 +++++ packages/core/src/notifications/dispatcher.ts | 238 +++++++++++++++ packages/core/src/notifications/index.ts | 29 ++ packages/core/src/notifications/ntfy.ts | 136 +++++++++ packages/core/src/notifications/types.ts | 98 +++++++ packages/core/tests/notifications/config.test.ts | 128 ++++++++ .../core/tests/notifications/dispatcher.test.ts | 323 +++++++++++++++++++++ packages/core/tests/notifications/ntfy.test.ts | 168 +++++++++++ 9 files changed, 1196 insertions(+) create mode 100644 packages/core/src/notifications/config.ts create mode 100644 packages/core/src/notifications/dispatcher.ts create mode 100644 packages/core/src/notifications/index.ts create mode 100644 packages/core/src/notifications/ntfy.ts create mode 100644 packages/core/src/notifications/types.ts create mode 100644 packages/core/tests/notifications/config.test.ts create mode 100644 packages/core/tests/notifications/dispatcher.test.ts create mode 100644 packages/core/tests/notifications/ntfy.test.ts (limited to 'packages') 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..310c606 --- /dev/null +++ b/packages/core/src/notifications/config.ts @@ -0,0 +1,74 @@ +// 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, + topicUrl: "", + authToken: "", + events: { ...NTFY_DEFAULT_EVENTS }, + }; +} + +/** + * 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; + const out: NtfyConfig = { + enabled: typeof obj.enabled === "boolean" ? obj.enabled : base.enabled, + topicUrl: typeof obj.topicUrl === "string" ? obj.topicUrl : base.topicUrl, + authToken: typeof obj.authToken === "string" ? obj.authToken : base.authToken, + events: { ...base.events }, + }; + const rawEvents = obj.events; + if (rawEvents && typeof rawEvents === "object") { + const evObj = rawEvents as Record; + 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..4f4fc79 --- /dev/null +++ b/packages/core/src/notifications/dispatcher.ts @@ -0,0 +1,238 @@ +// 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; + +export interface DispatcherOptions { + /** Override the config loader (tests). Defaults to `loadNtfyConfig`. */ + loadConfig?: () => NtfyConfig; + /** Override the transport (tests). Defaults to the real `sendNtfy`. */ + send?: (config: NtfyConfig, event: NotificationEvent) => Promise; + /** Optional fetch override (forwarded to `sendNtfy` when `send` not set). */ + fetchImpl?: FetchLike; + /** Look up a tab title for richer titles. */ + getTabTitle?: TabTitleLookup; + /** + * How long (ms) a dedupeKey is suppressed for. Permission prompts re-emit + * the whole pending list on every change, so dedupe is essential. + */ + dedupeWindowMs?: number; +} + +export class NotificationDispatcher { + private loadConfig: () => NtfyConfig; + private send: (config: NtfyConfig, event: NotificationEvent) => Promise; + private getTabTitle: TabTitleLookup | undefined; + private dedupeWindowMs: number; + /** Recently-sent dedupeKey → expiresAt epoch ms. */ + private recentlySent = new Map(); + private unsubs: Array<() => void> = []; + + constructor(opts: DispatcherOptions = {}) { + this.loadConfig = opts.loadConfig ?? loadNtfyConfig; + this.send = + opts.send ?? ((config, event) => sendNtfy(config, event, opts.fetchImpl ?? undefined)); + this.getTabTitle = opts.getTabTitle; + this.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. + */ + attachToAgentManager(source: AgentEventSource): () => void { + const unsub = source.onEvent((event) => { + if (event.type === "done") { + this.notify(this.buildTurnCompleted(event)); + } else if (event.type === "error") { + 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)}`; + } + + // ─── 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..ea99a58 --- /dev/null +++ b/packages/core/src/notifications/index.ts @@ -0,0 +1,29 @@ +// @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 TabTitleLookup, +} from "./dispatcher.js"; +export { type FetchLike, type NtfySendResult, sendNtfy, validateTopicUrl } 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..07ce33b --- /dev/null +++ b/packages/core/src/notifications/ntfy.ts @@ -0,0 +1,136 @@ +// ntfy.sh HTTP transport. +// +// ntfy's API is a simple POST to `https:///` 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 +// +// 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; body: string; signal?: AbortSignal }, +) => Promise<{ ok: boolean; status: number; statusText?: string; text(): Promise }>; + +/** + * Validate a ntfy topic URL. Accepts only `http(s)://host/topic` with a + * non-empty topic path. Returns `null` on success, a human-readable error + * string on failure. + */ +export function validateTopicUrl(topicUrl: string): string | null { + const trimmed = topicUrl.trim(); + if (!trimmed) return "Topic URL is required"; + let url: URL; + try { + url = new URL(trimmed); + } catch { + return "Topic URL is not a valid URL"; + } + if (url.protocol !== "http:" && url.protocol !== "https:") { + return "Topic URL must use http:// or https://"; + } + // Path must be a non-empty topic (more than just "/") + const topic = url.pathname.replace(/^\/+|\/+$/g, ""); + if (!topic) return "Topic URL must include a topic name (e.g. https://ntfy.sh/my-topic)"; + return null; +} + +/** + * 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 { + if (!config.enabled) return { ok: false, error: "Notifications are disabled" }; + const topicErr = validateTopicUrl(config.topicUrl); + if (topicErr) return { ok: false, error: topicErr }; + + 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 = { + // ntfy treats the title/priority/tags/click headers as ASCII-only. Strip + // control chars from the title; the body is sent UTF-8 verbatim. + 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 = event.clickUrl; + if (config.authToken && config.authToken.trim() !== "") { + headers.Authorization = `Bearer ${config.authToken.trim()}`; + } + + // 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(config.topicUrl.trim(), { + 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); + } +} + +function sanitizeHeader(value: string): string { + // Strip CR/LF (header injection guard) and trim. ntfy is tolerant of + // non-ASCII in titles, but we still drop control chars. + // biome-ignore lint/suspicious/noControlCharactersInRegex: intentional + return value.replace(/[\r\n\u0000-\u001f]+/g, " ").trim(); +} + +async function safeReadText(res: { text(): Promise }): Promise { + 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..f6baa27 --- /dev/null +++ b/packages/core/src/notifications/types.ts @@ -0,0 +1,98 @@ +// 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:`). */ + 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. + * - `topicUrl` — full URL, e.g. `https://ntfy.sh/my-secret-topic`. Missing + * ⇒ dispatcher never sends. + * - `authToken` — optional bearer token for private ntfy servers. + * - `events` — per-event-type enable map. Missing entries default to OFF + * so a newly-added event type doesn't silently start firing. + */ +export interface NtfyConfig { + enabled: boolean; + topicUrl: string; + authToken: string; + events: Record; +} + +/** 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 = { + "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 = { + "turn-completed": 3, + "turn-error": 4, + "permission-required": 4, + "agent-spawned": 2, +}; + +/** Default tag (emoji) per event type. */ +export const NTFY_DEFAULT_TAGS: Record = { + "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..64a9637 --- /dev/null +++ b/packages/core/tests/notifications/config.test.ts @@ -0,0 +1,128 @@ +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(); + +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.topicUrl).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); + }); +}); + +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, + topicUrl: "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 + topicUrl: 42, // wrong type ⇒ default + authToken: null, // wrong type ⇒ default + events: { "turn-completed": "no", bogus: true }, + extra: "ignored", + }); + expect(normalized.enabled).toBe(false); + expect(normalized.topicUrl).toBe(""); + expect(normalized.authToken).toBe(""); + expect(normalized.events["turn-completed"]).toBe(true); // default kept + expect((normalized.events as Record).bogus).toBeUndefined(); + }); +}); + +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, + topicUrl: "https://ntfy.sh/team", + authToken: "tk_abc", + events: { + "turn-completed": false, + "turn-error": true, + "permission-required": true, + "agent-spawned": 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, topicUrl: "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..db05de4 --- /dev/null +++ b/packages/core/tests/notifications/dispatcher.test.ts @@ -0,0 +1,323 @@ +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 { + return { + enabled: true, + topicUrl: "https://ntfy.sh/topic", + authToken: "", + events: { + "turn-completed": true, + "turn-error": true, + "permission-required": true, + "agent-spawned": 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 { + await Promise.resolve(); + await Promise.resolve(); +} + +describe("NotificationDispatcher.notify", () => { + let warnSpy: ReturnType; + 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; + 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; + 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(); + }); +}); diff --git a/packages/core/tests/notifications/ntfy.test.ts b/packages/core/tests/notifications/ntfy.test.ts new file mode 100644 index 0000000..3fb1d51 --- /dev/null +++ b/packages/core/tests/notifications/ntfy.test.ts @@ -0,0 +1,168 @@ +import { describe, expect, it, vi } from "vitest"; +import { sendNtfy, validateTopicUrl } from "../../src/notifications/ntfy.js"; +import type { NotificationEvent, NtfyConfig } from "../../src/notifications/types.js"; + +function makeConfig(overrides: Partial = {}): NtfyConfig { + return { + enabled: true, + topicUrl: "https://ntfy.sh/my-topic", + authToken: "", + events: { + "turn-completed": true, + "turn-error": true, + "permission-required": true, + "agent-spawned": true, + }, + ...overrides, + }; +} + +function makeEvent(overrides: Partial = {}): 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("validateTopicUrl", () => { + it("accepts ntfy.sh-style URLs", () => { + expect(validateTopicUrl("https://ntfy.sh/my-topic")).toBeNull(); + expect(validateTopicUrl("http://ntfy.example.com/team-alerts")).toBeNull(); + }); + + it("rejects empty / whitespace", () => { + expect(validateTopicUrl("")).toMatch(/required/); + expect(validateTopicUrl(" ")).toMatch(/required/); + }); + + it("rejects malformed URLs", () => { + expect(validateTopicUrl("not a url")).toMatch(/valid URL/); + }); + + it("rejects non-http(s) schemes", () => { + expect(validateTopicUrl("ftp://ntfy.sh/topic")).toMatch(/http/); + }); + + it("rejects URLs missing a topic path", () => { + expect(validateTopicUrl("https://ntfy.sh")).toMatch(/topic/); + expect(validateTopicUrl("https://ntfy.sh/")).toMatch(/topic/); + }); +}); + +describe("sendNtfy", () => { + it("POSTs to the topic URL 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("https://ntfy.sh/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("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 when authToken is set", 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("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("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 on invalid topic URL without calling fetch", async () => { + const fetchImpl = makeFetch(); + const result = await sendNtfy(makeConfig({ topicUrl: "not a url" }), makeEvent(), fetchImpl); + expect(result.ok).toBe(false); + expect(result.error).toBeDefined(); + 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/); + }); +}); -- cgit v1.2.3 From 21cdb1199599c4dc6e2a941e52713ba6511cd675 Mon Sep 17 00:00:00 2001 From: Adam Malczewski Date: Mon, 1 Jun 2026 09:28:30 +0900 Subject: feat(api): wire notification dispatcher into app + /notifications routes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PermissionManager: add onPromptAdded(listener) callback. Fires exactly once per unique pending prompt id, even when broadcastPending is called repeatedly for unrelated mutations (e.g. another prompt resolving while this one is still pending). app.ts: instantiate NotificationDispatcher, attach to both AgentManager and PermissionManager. Tab-title lookup via core's getTab so the notifications carry human-readable context instead of raw UUIDs. routes/notifications.ts: - GET /notifications — current config (auth token redacted) plus the event-type catalog and defaults - PUT /notifications — partial update; auth token semantics are undefined=keep, ''=clear, otherwise replace - POST /notifications/test — sends a test notification with the current config (rejects if disabled or topic invalid) Tests: - new permission-manager.test.ts covers the onPromptAdded contract (one-fire-per-prompt, dedup across rebroadcasts, unsubscribe, listener throws don't break siblings) - existing routes.test.ts gets stubs for the new core notification exports so the @dispatch/core mock stays complete --- packages/api/src/app.ts | 18 +++++ packages/api/src/permission-manager.ts | 55 +++++++++++++++ packages/api/src/routes/notifications.ts | 81 ++++++++++++++++++++++ packages/api/tests/permission-manager.test.ts | 99 +++++++++++++++++++++++++++ packages/api/tests/routes.test.ts | 51 ++++++++++++++ 5 files changed, 304 insertions(+) create mode 100644 packages/api/src/routes/notifications.ts create mode 100644 packages/api/tests/permission-manager.test.ts (limited to 'packages') diff --git a/packages/api/src/app.ts b/packages/api/src/app.ts index 19cc193..24cef24 100644 --- a/packages/api/src/app.ts +++ b/packages/api/src/app.ts @@ -1,3 +1,4 @@ +import { getTab, NotificationDispatcher } from "@dispatch/core"; import { Hono } from "hono"; import { cors } from "hono/cors"; import { AgentManager } from "./agent-manager.js"; @@ -5,12 +6,28 @@ import { PermissionManager } from "./permission-manager.js"; import { agentsRoutes } from "./routes/agents.js"; import { configRoutes } from "./routes/config.js"; import { modelsRoutes, startWakeScheduler } from "./routes/models.js"; +import { notificationsRoutes } from "./routes/notifications.js"; import { skillsRoutes } from "./routes/skills.js"; import { tabsRoutes } from "./routes/tabs.js"; export const permissionManager = new PermissionManager(); export const agentManager = new AgentManager(permissionManager); +// ntfy.sh push notifications. The dispatcher reads its config from the +// `settings` table on every send, so config changes apply immediately — +// no restart, no re-attach needed. +export const notificationDispatcher = new NotificationDispatcher({ + getTabTitle: (tabId) => { + try { + return getTab(tabId)?.title ?? null; + } catch { + return null; + } + }, +}); +notificationDispatcher.attachToAgentManager(agentManager); +notificationDispatcher.attachToPermissionManager(permissionManager); + export const app = new Hono(); app.use( @@ -112,6 +129,7 @@ app.route("/skills", skillsRoutes); app.route("/models", modelsRoutes); app.route("/tabs", tabsRoutes); app.route("/agents", agentsRoutes); +app.route("/notifications", notificationsRoutes); // Start the wake scheduler on boot (restores persisted schedule) startWakeScheduler(); diff --git a/packages/api/src/permission-manager.ts b/packages/api/src/permission-manager.ts index d98dc52..3a24d03 100644 --- a/packages/api/src/permission-manager.ts +++ b/packages/api/src/permission-manager.ts @@ -5,9 +5,25 @@ import { type Ruleset, } from "@dispatch/core"; +/** + * Listener fired exactly once per newly-created pending prompt. Used by + * the notification dispatcher so that a permission request triggers a + * push notification on the user's phone (without re-firing every time + * the pending list mutates for an unrelated reason). + */ +export type PromptAddedListener = (prompt: { + id: string; + permission: string; + description: string; + metadata: Record; +}) => void; + export class PermissionManager { private service = new PermissionService(); private wsClients: Map void> = new Map(); + private promptAddedListeners: Set = new Set(); + /** Ids that have already been broadcast as "added" — guards against re-emits. */ + private announcedPromptIds: Set = new Set(); registerClient(id: string, send: (data: unknown) => void): void { this.wsClients.set(id, send); @@ -25,6 +41,33 @@ export class PermissionManager { for (const send of this.wsClients.values()) { send(message); } + + // Detect newly-added prompts (ids present now that weren't before) and + // fire `promptAddedListeners` once for each. Resolved/rejected ids are + // pruned from `announcedPromptIds` so a future prompt that reuses an + // id (theoretical, given the monotonic counter) would still notify. + const currentIds = new Set(pending.map((p) => p.id)); + for (const id of this.announcedPromptIds) { + if (!currentIds.has(id)) this.announcedPromptIds.delete(id); + } + for (const p of pending) { + if (this.announcedPromptIds.has(p.id)) continue; + this.announcedPromptIds.add(p.id); + for (const listener of this.promptAddedListeners) { + try { + listener({ + id: p.id, + permission: p.request.permission, + description: p.request.description, + metadata: p.request.metadata, + }); + } catch (err) { + console.warn( + `[permission] promptAdded listener threw: ${err instanceof Error ? err.message : String(err)}`, + ); + } + } + } } async ask(request: PermissionRequest, rulesets: Ruleset[] = []): Promise { @@ -45,4 +88,16 @@ export class PermissionManager { getService(): PermissionService { return this.service; } + + /** + * Subscribe to "a new prompt is now pending" events. Fires once per + * unique prompt id, even if `broadcastPending` is called repeatedly + * for unrelated mutations. Returns an unsubscribe function. + */ + onPromptAdded(listener: PromptAddedListener): () => void { + this.promptAddedListeners.add(listener); + return () => { + this.promptAddedListeners.delete(listener); + }; + } } diff --git a/packages/api/src/routes/notifications.ts b/packages/api/src/routes/notifications.ts new file mode 100644 index 0000000..57519bc --- /dev/null +++ b/packages/api/src/routes/notifications.ts @@ -0,0 +1,81 @@ +// `/notifications` — ntfy.sh config + test-send route. + +import { + defaultNtfyConfig, + loadNtfyConfig, + type NotificationEventType, + NTFY_EVENT_TYPES, + type NtfyConfig, + normalizeNtfyConfig, + redactNtfyConfig, + saveNtfyConfig, + sendNtfy, + validateTopicUrl, +} from "@dispatch/core"; +import { Hono } from "hono"; + +export const notificationsRoutes = new Hono(); + +notificationsRoutes.get("/", (c) => { + const config = loadNtfyConfig(); + return c.json({ + config: redactNtfyConfig(config), + eventTypes: NTFY_EVENT_TYPES, + defaults: defaultNtfyConfig(), + }); +}); + +notificationsRoutes.put("/", async (c) => { + const body = await c.req.json & { authToken?: string }>(); + const existing = loadNtfyConfig(); + + // `authToken === ""` ⇒ explicit clear; `authToken === undefined` ⇒ keep + // the existing token (the GET response redacts it, so the frontend doesn't + // have it to send back). Any other string ⇒ replace. + let nextAuthToken = existing.authToken; + if (typeof body.authToken === "string") nextAuthToken = body.authToken; + + const merged = normalizeNtfyConfig({ + enabled: typeof body.enabled === "boolean" ? body.enabled : existing.enabled, + topicUrl: typeof body.topicUrl === "string" ? body.topicUrl : existing.topicUrl, + authToken: nextAuthToken, + events: { ...existing.events, ...(body.events ?? {}) }, + }); + + if (merged.enabled) { + const err = validateTopicUrl(merged.topicUrl); + if (err) return c.json({ error: err }, 400); + } + + saveNtfyConfig(merged); + return c.json({ config: redactNtfyConfig(merged) }); +}); + +notificationsRoutes.post("/test", async (c) => { + const config = loadNtfyConfig(); + if (!config.enabled) { + return c.json({ ok: false, error: "Notifications are disabled" }, 400); + } + const err = validateTopicUrl(config.topicUrl); + if (err) return c.json({ ok: false, error: err }, 400); + + // Use a real event type so the per-event toggle is honored when wiring + // is tested end-to-end; pick `turn-completed` since it's the most + // common enabled-by-default event. + const eventType: NotificationEventType = "turn-completed"; + if (!config.events[eventType]) { + return c.json( + { ok: false, error: `Event type "${eventType}" is disabled — enable it to test.` }, + 400, + ); + } + + const result = await sendNtfy(config, { + type: eventType, + title: "Dispatch test notification", + message: "If you can see this, ntfy.sh notifications are wired up correctly.", + tags: ["bell"], + }); + if (!result.ok) return c.json(result, 502); + return c.json(result); +}); diff --git a/packages/api/tests/permission-manager.test.ts b/packages/api/tests/permission-manager.test.ts new file mode 100644 index 0000000..172adb3 --- /dev/null +++ b/packages/api/tests/permission-manager.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it, vi } from "vitest"; + +// Mock @dispatch/core to provide only the PermissionService impl this test +// touches — the core barrel transitively pulls in bun:sqlite, which vitest +// running under Node cannot resolve. +vi.mock("@dispatch/core", async () => { + const mod = await import("../../core/src/permission/service.js"); + return { + PermissionService: mod.PermissionService, + }; +}); + +const { PermissionManager } = await import("../src/permission-manager.js"); + +interface PermissionRequest { + permission: string; + patterns: string[]; + always: string[]; + description: string; + metadata: Record; +} + +function makeRequest(overrides: Partial = {}): PermissionRequest { + return { + permission: "bash", + patterns: ["git *"], + always: ["git status"], + description: "Run git status", + metadata: {}, + ...overrides, + }; +} + +describe("PermissionManager.onPromptAdded", () => { + it("fires once per newly-added pending prompt", () => { + const mgr = new PermissionManager(); + const seen: Array<{ id: string; permission: string }> = []; + mgr.onPromptAdded((p) => { + seen.push({ id: p.id, permission: p.permission }); + }); + + void mgr.ask(makeRequest(), []); + void mgr.ask(makeRequest({ permission: "read", description: "Read X" }), []); + + expect(seen).toHaveLength(2); + expect(seen[0].permission).toBe("bash"); + expect(seen[1].permission).toBe("read"); + // Distinct ids + expect(seen[0].id).not.toBe(seen[1].id); + }); + + it("does not re-fire when the pending list is rebroadcast for an unrelated change", async () => { + const mgr = new PermissionManager(); + const seen: string[] = []; + mgr.onPromptAdded((p) => seen.push(p.id)); + + // Two prompts in; should see two notifications. + const p1 = mgr.ask(makeRequest(), []); + void mgr.ask(makeRequest({ permission: "read" }), []); + expect(seen).toHaveLength(2); + + // Resolve the first one — broadcastPending fires again, but the + // remaining (already-announced) prompt must NOT re-notify. + const pending = mgr.getPending(); + const firstId = pending[0].id; + mgr.reply(firstId, "once"); + await p1; + + expect(seen).toHaveLength(2); + }); + + it("unsubscribe stops further notifications", () => { + const mgr = new PermissionManager(); + const seen: string[] = []; + const unsub = mgr.onPromptAdded((p) => seen.push(p.id)); + void mgr.ask(makeRequest(), []); + unsub(); + void mgr.ask(makeRequest({ permission: "read" }), []); + expect(seen).toHaveLength(1); + }); + + it("listener throws are caught and don't break other listeners", () => { + const mgr = new PermissionManager(); + const seen: string[] = []; + mgr.onPromptAdded(() => { + throw new Error("boom"); + }); + mgr.onPromptAdded((p) => seen.push(p.id)); + // Swallow the warn during this test. + const origWarn = console.warn; + console.warn = () => {}; + try { + void mgr.ask(makeRequest(), []); + } finally { + console.warn = origWarn; + } + expect(seen).toHaveLength(1); + }); +}); diff --git a/packages/api/tests/routes.test.ts b/packages/api/tests/routes.test.ts index 9ab2afe..c07f932 100644 --- a/packages/api/tests/routes.test.ts +++ b/packages/api/tests/routes.test.ts @@ -268,6 +268,57 @@ vi.mock("@dispatch/core", () => ({ execute: async () => "mock", }; }, + // ── ntfy notifications stubs ────────────────────────────────── + NotificationDispatcher: class MockNotificationDispatcher { + attachToAgentManager() { + return () => {}; + } + attachToPermissionManager() { + return () => {}; + } + notify() {} + dispose() {} + }, + loadNtfyConfig() { + return { + enabled: false, + topicUrl: "", + authToken: "", + events: { + "turn-completed": true, + "turn-error": true, + "permission-required": true, + "agent-spawned": false, + }, + }; + }, + saveNtfyConfig() {}, + normalizeNtfyConfig(c: unknown) { + return c; + }, + defaultNtfyConfig() { + return { + enabled: false, + topicUrl: "", + authToken: "", + events: { + "turn-completed": true, + "turn-error": true, + "permission-required": true, + "agent-spawned": false, + }, + }; + }, + redactNtfyConfig(c: { authToken?: string }) { + return { ...c, authToken: "", hasAuthToken: false }; + }, + NTFY_EVENT_TYPES: ["turn-completed", "turn-error", "permission-required", "agent-spawned"], + async sendNtfy() { + return { ok: true }; + }, + validateTopicUrl() { + return null; + }, })); const { app } = await import("../src/app.js"); -- cgit v1.2.3 From 786bc4336c9e4619385e9bce105f95727bcbb6ca Mon Sep 17 00:00:00 2001 From: Adam Malczewski Date: Mon, 1 Jun 2026 09:28:37 +0900 Subject: feat(frontend): ntfy.sh settings block in SettingsPanel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a 'Notifications (ntfy.sh)' section below 'Backend URL' with: - Enable toggle (master switch) - Topic URL field (with security hint: anyone with the URL can read) - Optional auth token (password input; placeholder reflects whether one is already stored, and a 'Clear stored token' button surfaces only when hasAuthToken=true) - Per-event-type checkboxes driven by the eventTypes catalog returned from GET /notifications (so adding a new event type in core doesn't require a frontend change) - Save + Send test buttons, with inline success/error feedback The component hand-mirrors the NtfyConfig shape rather than importing it from @dispatch/core — matching the existing pattern (lib/types.ts mirrors a few core types) to keep node-only barrels out of the browser bundle. --- .../src/lib/components/SettingsPanel.svelte | 256 +++++++++++++++++++++ 1 file changed, 256 insertions(+) (limited to 'packages') diff --git a/packages/frontend/src/lib/components/SettingsPanel.svelte b/packages/frontend/src/lib/components/SettingsPanel.svelte index 392852a..1d9ebf7 100644 --- a/packages/frontend/src/lib/components/SettingsPanel.svelte +++ b/packages/frontend/src/lib/components/SettingsPanel.svelte @@ -20,6 +20,59 @@ let localChunkLimit = $state(appSettings.chunkLimit); let backendUrl = $state(config.apiBase); let backendUrlSaved = $state(false); +// ─── ntfy.sh push notifications ────────────────────────────────── +// Server-side schema mirror — kept inline rather than imported to avoid +// pulling a node-only barrel into the browser bundle (frontend already +// hand-mirrors a few core types in lib/types.ts for the same reason). +type NotificationEventType = + | "turn-completed" + | "turn-error" + | "permission-required" + | "agent-spawned"; + +interface NtfyConfigView { + enabled: boolean; + topicUrl: string; + authToken: string; + hasAuthToken?: boolean; + events: Record; +} + +const NTFY_EVENT_LABELS: Record = { + "turn-completed": "Turn completed", + "turn-error": "Turn error", + "permission-required": "Permission requested", + "agent-spawned": "User agent spawned", +}; + +const DEFAULT_NTFY: NtfyConfigView = { + enabled: false, + topicUrl: "", + authToken: "", + hasAuthToken: false, + events: { + "turn-completed": true, + "turn-error": true, + "permission-required": true, + "agent-spawned": false, + }, +}; + +let ntfy = $state({ ...DEFAULT_NTFY, events: { ...DEFAULT_NTFY.events } }); +let ntfyAuthTokenInput = $state(""); // empty == leave unchanged on save +let ntfyEventOrder = $state([ + "turn-completed", + "turn-error", + "permission-required", + "agent-spawned", +]); +let ntfySaving = $state(false); +let ntfySaveError = $state(null); +let ntfySaveOk = $state(false); +let ntfyTesting = $state(false); +let ntfyTestResult = $state(null); +let ntfyTestOk = $state(false); + function onChunkLimitChange(e: Event): void { const input = e.target as HTMLInputElement; const val = parseInt(input.value, 10); @@ -73,6 +126,108 @@ async function loadSettings(): Promise { } catch { // ignore } + await loadNtfy(); +} + +async function loadNtfy(): Promise { + try { + const res = await fetch(`${apiBase}/notifications`); + if (!res.ok) return; + const data = (await res.json()) as { + config: NtfyConfigView; + eventTypes?: NotificationEventType[]; + }; + ntfy = { + ...DEFAULT_NTFY, + ...data.config, + events: { ...DEFAULT_NTFY.events, ...(data.config.events ?? {}) }, + }; + if (Array.isArray(data.eventTypes) && data.eventTypes.length > 0) { + ntfyEventOrder = data.eventTypes; + } + } catch { + // ignore + } +} + +async function saveNtfy(): Promise { + ntfySaving = true; + ntfySaveError = null; + ntfySaveOk = false; + try { + // `authToken: undefined` ⇒ server keeps the existing token. + // `authToken: ""` ⇒ explicit clear (the user typed and cleared). + const payload: Partial & { authToken?: string } = { + enabled: ntfy.enabled, + topicUrl: ntfy.topicUrl, + events: ntfy.events, + }; + if (ntfyAuthTokenInput !== "") payload.authToken = ntfyAuthTokenInput; + const res = await fetch(`${apiBase}/notifications`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + const data = (await res.json()) as { config?: NtfyConfigView; error?: string }; + if (!res.ok) { + ntfySaveError = data.error ?? `Save failed (HTTP ${res.status})`; + return; + } + if (data.config) { + ntfy = { + ...DEFAULT_NTFY, + ...data.config, + events: { ...DEFAULT_NTFY.events, ...(data.config.events ?? {}) }, + }; + } + ntfyAuthTokenInput = ""; + ntfySaveOk = true; + setTimeout(() => { + ntfySaveOk = false; + }, 2000); + } catch (e) { + ntfySaveError = e instanceof Error ? e.message : "Network error"; + } finally { + ntfySaving = false; + } +} + +async function sendNtfyTest(): Promise { + ntfyTesting = true; + ntfyTestResult = null; + ntfyTestOk = false; + try { + const res = await fetch(`${apiBase}/notifications/test`, { method: "POST" }); + const data = (await res.json()) as { ok?: boolean; error?: string; status?: number }; + if (!res.ok || !data.ok) { + ntfyTestResult = data.error ?? `Test failed (HTTP ${res.status})`; + return; + } + ntfyTestOk = true; + ntfyTestResult = "Sent — check your ntfy client."; + } catch (e) { + ntfyTestResult = e instanceof Error ? e.message : "Network error"; + } finally { + ntfyTesting = false; + } +} + +function clearNtfyAuthToken(): void { + // `""` ⇒ explicit clear on save (vs. `undefined` which keeps existing). + ntfyAuthTokenInput = ""; + ntfy = { ...ntfy, hasAuthToken: false }; + // Send a save with explicit empty string to clear server-side. + void (async () => { + try { + await fetch(`${apiBase}/notifications`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ authToken: "" }), + }); + } catch { + // ignore + } + })(); } async function toggleAutoExpand(): Promise { @@ -222,5 +377,106 @@ $effect(() => { {#if backendUrlSaved}

Saved. Reload the page to apply.

{/if} + +
+ +

Notifications (ntfy.sh)

+

+ Push notifications to your phone when things happen here. Subscribe to your topic in the + ntfy.sh app to receive them. +

+ + + + + + + +
+ Notify me on: + {#each ntfyEventOrder as evType (evType)} + + {/each} +
+ +
+ + +
+ {#if ntfySaveOk} +

Saved.

+ {/if} + {#if ntfySaveError} +

{ntfySaveError}

+ {/if} + {#if ntfyTestResult} +

{ntfyTestResult}

+ {/if}
-- cgit v1.2.3 From 6c377fba7d516ea89ef2d906a40785a997299b0c Mon Sep 17 00:00:00 2001 From: Adam Malczewski Date: Mon, 1 Jun 2026 09:45:36 +0900 Subject: fix(theme): consolidate boot apply and Settings picker into shared module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gemini review surfaced that App.svelte (onMount theme apply) and SettingsPanel.svelte (theme