summaryrefslogtreecommitdiffhomepage
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
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
-rw-r--r--.gitignore3
-rw-r--r--notes/ntfy-notifications-handoff.md311
-rw-r--r--notes/wake-schedule-handoff.md (renamed from HANDOFF.md)0
-rw-r--r--packages/api/src/app.ts29
-rw-r--r--packages/api/src/permission-manager.ts55
-rw-r--r--packages/api/src/routes/notifications.ts88
-rw-r--r--packages/api/tests/permission-manager.test.ts99
-rw-r--r--packages/api/tests/routes.test.ts50
-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
-rw-r--r--packages/frontend/src/App.svelte13
-rw-r--r--packages/frontend/src/lib/components/DebugPanel.svelte35
-rw-r--r--packages/frontend/src/lib/components/Header.svelte41
-rw-r--r--packages/frontend/src/lib/components/SettingsPanel.svelte328
-rw-r--r--packages/frontend/src/lib/components/SidebarPanel.svelte5
-rw-r--r--packages/frontend/src/lib/components/ThemeSwitcher.svelte58
-rw-r--r--packages/frontend/src/lib/theme.ts92
-rw-r--r--packages/frontend/tests/theme.test.ts144
25 files changed, 2727 insertions, 106 deletions
diff --git a/.gitignore b/.gitignore
index 51c3006..960e309 100644
--- a/.gitignore
+++ b/.gitignore
@@ -23,3 +23,6 @@ packaging/electron/pkg/
packaging/electron/*.pkg.tar.zst
packaging/electron/*.tar.zst
packages/frontend/release/
+
+# Code-review artifacts
+claude-report.md
diff --git a/notes/ntfy-notifications-handoff.md b/notes/ntfy-notifications-handoff.md
new file mode 100644
index 0000000..fde84c8
--- /dev/null
+++ b/notes/ntfy-notifications-handoff.md
@@ -0,0 +1,311 @@
+# Handoff — n2/ntfy-notifications
+
+## Summary
+
+Adds **ntfy.sh push notifications** to Dispatch: a configurable per-event
+notification dispatcher that POSTs to a user-supplied ntfy topic URL when
+notable events happen in the running agent process.
+
+The architecture is intentionally layered so a future transport (email,
+Slack webhook, custom backend) plugs in without touching call sites:
+
+```
+AgentManager.onEvent ─┐ ┌─→ sendNtfy (fetch)
+ ├─→ NotificationDispatcher.notify(event)
+PermissionMgr ────────┘ (filter / dedupe) └─→ (other transports later)
+.onPromptAdded
+```
+
+### Event taxonomy
+
+The user toggles each one independently in Settings:
+
+| event | trigger | default | priority | tags |
+|-----------------------|--------------------------------------------------------------------------|---------|----------|------------------|
+| `turn-completed` | assistant `done` event (one per cleanly-finished turn) | on | 3 | white_check_mark |
+| `turn-error` | assistant `error` event (final, after all fallback retries) | on | 4 | rotating_light |
+| `permission-required` | `PermissionManager` newly admits a prompt to its pending list | on | 4 | lock |
+| `agent-spawned` | `tab-created` for a **top-level user agent** (parent=null, slug present) | off | 2 | sparkles |
+
+Each notification carries a short tab tag (`tab-<first8>`) so multi-tab users
+can tell which conversation pinged them.
+
+**Subagent gating**: `turn-completed` and `turn-error` from subagent tabs
+(any tab with a `parentTabId`) are suppressed by default — a parent
+agent that spawns 8 subagents would otherwise push 9 "Turn complete"
+notifications per round. Toggle on "Include subagent tabs" in Settings
+to opt in. `permission-required` is deliberately NOT gated: a
+subagent's permission prompt still needs a human tap to proceed, so
+suppressing it would silently hang the subagent. `agent-spawned` is
+already top-level-only by construction.
+
+### Design notes
+
+- **Non-blocking**: `dispatcher.notify` does `void Promise.resolve(send(...)).catch(warn)`.
+ A slow or unreachable ntfy server never stalls a turn. Worst case is a
+ 10s per-request abort timeout in the transport.
+- **Dedupe**: 5 s in-memory window keyed by `dedupeKey`. Used for
+ `permission-required` because the permission system rebroadcasts the
+ whole pending list on every change (we'd otherwise re-fire on every
+ unrelated mutation).
+- **Master switch + per-event toggle**: both must allow before a send.
+ Disabled config is a fast no-op (no fetch, no `loadConfig` work past
+ the early return).
+- **Single global config**: matches the rest of the codebase's settings
+ table (`perm_*`, `title_model_*` are also global). Stored as one JSON
+ blob under `settings.key = 'ntfy_config'`.
+- **Auth token round-trip**: `GET /notifications` redacts the token but
+ surfaces `hasAuthToken: boolean`. `PUT /notifications` semantics:
+ `authToken === undefined` keeps the stored value, `""` clears it, any
+ other string replaces it. The frontend's "Clear stored token" button
+ uses the explicit-`""` path **and awaits the server response** before
+ flipping local UI state (post-review fix — see Review section).
+- **Auth header scheme**: tokens that already start with a scheme
+ (`Bearer foo`, `Basic dXNlcjpwYXNz`) pass through verbatim; bare tokens
+ get a `Bearer ` prefix automatically. Lets users of private ntfy
+ servers use any HTTP auth scheme without code changes.
+- **Topic-URL validation**: tightened to ntfy's actual constraints —
+ exactly one path segment, 1–64 chars, `[A-Za-z0-9_-]` only. Catches
+ topics that would silently 404 at publish time.
+- **Header injection guard**: CR/LF and control chars are stripped from
+ every header value the transport writes (`Title`, `Tags`, `Click`,
+ `Authorization`).
+- **Permission "added" detection**: `PermissionManager.broadcastPending`
+ now diffs the current pending-id set against an `announcedPromptIds`
+ set and fires `onPromptAdded` only for genuinely new ids. Resolved ids
+ are pruned. This keeps the contract "one notification per prompt".
+
+## Files changed / added
+
+```
+packages/core/src/notifications/types.ts (new)
+packages/core/src/notifications/ntfy.ts (new)
+packages/core/src/notifications/config.ts (new)
+packages/core/src/notifications/dispatcher.ts (new)
+packages/core/src/notifications/index.ts (new)
+packages/core/src/index.ts (barrel re-export)
+packages/core/tests/notifications/ntfy.test.ts (new, 22 tests)
+packages/core/tests/notifications/config.test.ts (new, 10 tests)
+packages/core/tests/notifications/dispatcher.test.ts (new, 13 tests)
+
+packages/api/src/permission-manager.ts (+ onPromptAdded contract)
+packages/api/src/routes/notifications.ts (new — GET/PUT/POST routes)
+packages/api/src/app.ts (wire dispatcher + mount routes)
+packages/api/tests/permission-manager.test.ts (new, 4 tests)
+packages/api/tests/routes.test.ts (add mocks for new core exports)
+
+packages/frontend/src/lib/components/SettingsPanel.svelte (new ntfy section)
+```
+
+Seven commits on `n2/ntfy-notifications`:
+
+```
+<new> docs: update HANDOFF.md with notifySubagents
+9c93086 feat(notifications): add notifySubagents toggle to suppress subagent turn pings
+4185789 docs: update HANDOFF.md with Gemini review triage + post-fix state
+1870d0b fix(notifications): address Gemini review — tighten validation, sanitize Click, support Basic auth, non-optimistic UI clear
+29bdd00 docs: add HANDOFF.md for ntfy notifications feature
+786bc43 feat(frontend): ntfy.sh settings block in SettingsPanel
+21cdb11 feat(api): wire notification dispatcher into app + /notifications routes
+5e72191 feat(core): ntfy.sh notification dispatcher module
+```
+
+## Public surface added
+
+### New config (persisted in `settings` table)
+
+- `settings.key = "ntfy_config"` → JSON-serialized `NtfyConfig`:
+ ```ts
+ {
+ enabled: boolean,
+ topicUrl: string,
+ authToken: string,
+ events: {
+ "turn-completed": boolean,
+ "turn-error": boolean,
+ "permission-required": boolean,
+ "agent-spawned": boolean,
+ },
+ notifySubagents: boolean, // default false; gates turn-* from subagent tabs
+ }
+ ```
+
+### New API routes
+
+- `GET /notifications` →
+ `{ config: NtfyConfig & { hasAuthToken: boolean }, eventTypes: string[], defaults: NtfyConfig }`
+ (authToken is always returned as `""`; `hasAuthToken` reflects what's stored)
+- `PUT /notifications` → accepts partial `NtfyConfig`. Validates topic URL
+ when `enabled === true`. Returns the saved (redacted) config or `400`.
+- `POST /notifications/test` → sends a `turn-completed`-typed test
+ notification using the saved config. Returns `{ ok, status?, error? }`,
+ or `400` if disabled / invalid topic / event-type disabled, or `502` on
+ ntfy server failure.
+
+### New core exports (via `@dispatch/core` barrel)
+
+Types: `NotificationEvent`, `NotificationEventType`, `NtfyConfig`,
+`NtfyPriority`, `NtfySendResult`, `FetchLike`, `DispatcherOptions`,
+`AgentEventSource`, `PermissionPromptSource`, `TabTitleLookup`.
+
+Values: `NotificationDispatcher`, `sendNtfy`, `validateTopicUrl`,
+`loadNtfyConfig`, `saveNtfyConfig`, `clearNtfyConfig`,
+`normalizeNtfyConfig`, `defaultNtfyConfig`, `redactNtfyConfig`,
+`NTFY_EVENT_TYPES`, `NTFY_DEFAULT_EVENTS`, `NTFY_DEFAULT_PRIORITIES`,
+`NTFY_DEFAULT_TAGS`, `NTFY_CONFIG_KEY`.
+
+### New API surface on `PermissionManager`
+
+- `onPromptAdded(listener) => unsubscribe` — fires exactly once per
+ genuinely-new pending prompt id (with `{ id, permission, description, metadata }`).
+
+### New exported singleton in `packages/api/src/app.ts`
+
+- `notificationDispatcher: NotificationDispatcher` — already wired to
+ the module-level `agentManager` and `permissionManager`. Exposed so
+ tests / future callers can `dispose()` or `notify(...)` directly.
+
+### Frontend
+
+No new exported props — the change is entirely inside `SettingsPanel.svelte`
+and uses its existing `{ keys, apiBase }` props.
+
+## Verification status
+
+### `bun run check`
+
+```
+$ biome check .
+Checked 150 files in 158ms. No fixes applied.
+```
+
+✅ Pass (0 errors, 0 warnings).
+
+### `bun run test`
+
+```
+Test Files 28 passed (28)
+ Tests 451 passed (451)
+ Duration 2.85s
+```
+
+✅ Pass. Baseline was 393 tests in 24 files; this branch adds 58 tests
+across 4 new files (`notifications/ntfy.test.ts` ×22,
+`notifications/config.test.ts` ×13, `notifications/dispatcher.test.ts` ×19,
+`permission-manager.test.ts` ×4) and modifies 0 existing tests.
+
+### Per-package strict typecheck
+
+```
+@dispatch/core tsc --noEmit — 0 errors
+@dispatch/api tsc --noEmit — 0 errors
+@dispatch/frontend svelte-check — 0 errors, 0 warnings
+```
+
+### Manual smoke test
+
+Verified end-to-end against the real `ntfy.sh` server twice (initial
+build + post-review):
+
+```
+$ bun -e 'import { sendNtfy } from "./packages/core/src/notifications/ntfy.js"; ...'
+validate: null
+{"ok":true,"status":200}
+```
+
+Topics were throwaway and only used for these smoke tests. Full UI flow
+(Settings → topic URL → Save → Send test → push lands in ntfy app)
+was not executed because that requires a live `bun run dev:api` plus
+`dev:frontend` plus a phone with the ntfy app — but the same code path
+that the "Send test" button exercises is what the smoke test above hit,
+and the route logic on top of it is covered by unit tests.
+
+## Second-opinion review (Gemini)
+
+After the initial implementation I ran a broad, open-ended code review
+via `gemini-3-flash-preview` in YOLO mode (read-only, instructed to find
+bugs/flaws/edge-cases). Findings were triaged as follows:
+
+| # | Severity | Finding | Action |
+|---|----------|---------|--------|
+| 1 | Medium | "Duplicate error+done notifications on LLM fallback retries" | **Rejected.** Verified against `agent-manager.ts:1525–1532` and `:1611`: inner per-attempt errors set `attemptError` and `break` out of the stream loop; only the final terminal error is `this.emit({type:"error"})`-ed. The dispatcher cannot see intermediate retry errors. |
+| 2 | Medium | "Loose topic URL validation" | **Fixed.** `validateTopicUrl` now enforces one segment + `[A-Za-z0-9_-]{1,64}`. |
+| 3 | Low | "`Click` header not sanitized" | **Fixed.** Passed through `sanitizeHeader`. |
+| 4 | Low | "Use JSON publishing for UTF-8 safety in headers" | **Deferred.** Per ntfy docs UTF-8 in `Title` is supported and works in practice; the only realistic risk is non-ASCII tab titles being mangled by an exotic intermediate proxy. Switching to JSON-body publishing would re-architect the transport (and invalidate all header-shape tests) for a hypothetical edge case. Worth a follow-up if anyone reports mangled titles. |
+| 5 | Low | "Optimistic UI clear of auth token" | **Fixed.** `clearNtfyAuthToken` now awaits the response and only mutates local state on success; failures surface via the existing error banner. Added a `ntfyClearingToken` loading flag so the button disables + spins during the request. |
+| 6 | Nit | "DB read on every notify()" | **Skipped.** SQLite reads are sub-millisecond, events are human-scale infrequent (one per turn at most, dominated by an LLM round-trip taking seconds). Cache adds invalidation complexity for no measurable win. |
+| OQ | — | "Support Basic auth, not just Bearer" | **Fixed.** Tokens with a scheme prefix already (`Bearer xyz`, `Basic dXNlcjpwYXNz`) now pass through verbatim; bare tokens still get the `Bearer ` prefix. |
+
+The other open questions: click-URL deep link is still a known gap
+below; subagent noise was follow-up-fixed in commit 9c93086 (the
+`notifySubagents` flag, off by default).
+
+## Assumptions / known gaps
+
+Decisions made without product input (the spec said "ask if ambiguous";
+each of these felt unambiguous in the context of Dispatch's current
+single-user, single-process design):
+
+1. **Single global config, not per-user.** The existing `settings` table
+ is global (e.g. `title_model_*`, `perm_*` — all single-tenant). When
+ Dispatch grows real multi-tenancy this'll need a `user_id` column and
+ a load-by-user helper, but that's a much bigger refactor than this
+ feature.
+
+2. **Auth token persisted in plain text.** Same as the existing
+ `credentials` / `api_keys` tables in this DB; SQLite at-rest
+ encryption isn't a thing in this codebase. Token never leaves the
+ DB on the read path (`GET /notifications` redacts).
+
+3. **No rate-limiting or burst grouping** beyond the 5 s permission
+ dedupe. Notification-worthy events are human-scale infrequent (one
+ per turn, one per permission prompt). If someone hammers `summon` and
+ ships 50 user agents in 10 seconds, they'll get 50 pushes — that
+ matches "agent-spawned is off by default" being the right call.
+
+4. **No click-URL deep-link to the originating tab.** The frontend
+ doesn't currently route tabs by URL (`router.svelte.ts` just toggles
+ between `dashboard` and `agent-builder`), so I left `clickUrl`
+ plumbing in the transport layer (now sanitized as of the Gemini-fix
+ commit) but didn't synthesize one in the dispatcher. A future "open
+ this tab" router change would make this a 4-line addition in
+ `buildTurnCompleted` / etc.
+
+5. **Event taxonomy is intentionally small.** I considered `model-changed`
+ and `queue-overflow`/`auto-wake-budget-exhausted` notices but they
+ felt like "annoying push" rather than "useful push"; easy to add
+ later by extending `NotificationEventType`, `NTFY_DEFAULT_EVENTS`,
+ and adding a builder + dispatch hook.
+
+6. **No topic validation client-side.** The Settings field accepts any
+ non-empty string as the topic name and the transport posts to
+ `https://ntfy.sh/<topic>` (URL-encoded). Earlier revisions enforced
+ ntfy.sh's documented `^[A-Za-z0-9_-]{1,64}$` rule, but the project
+ relaxes those rules over time (issue #1451) and a regex here just
+ locks users out of valid configurations. The server is the final
+ authority; any rejection surfaces through the "Send test" button or
+ the first real notification.
+
+7. **Header-based publishing (vs. JSON-body publishing).** Per the
+ Gemini-review triage above, the transport sends `Title`/`Tags`/`Click`
+ as HTTP headers. UTF-8 titles work against ntfy.sh itself; non-ASCII
+ tab titles through an exotic intermediate proxy could theoretically
+ be mangled. Switching to ntfy's JSON publish mode (`Content-Type:
+ application/json`, body `{topic, message, title, ...}`) would side-
+ step that entirely — leaving as a follow-up if anyone hits it.
+
+Working tree is clean; seven commits on `n2/ntfy-notifications`; nothing
+merged.
+
+## Update — topic-only input (post-merge of this branch's seventh commit)
+
+The `topicUrl` field was replaced with `topic`. The user now enters just
+the ntfy topic name (e.g. `my-secret-topic`); the transport always posts
+to `https://ntfy.sh/<topic>`. `validateTopicUrl` is gone — only an empty
+check remains (server-side, and only when `enabled === true`). This
+eliminates the "string does not match the expected pattern" error users
+hit when entering a bare topic. Tests, the `/notifications` PUT route,
+the persisted JSON shape, and the SettingsPanel UI were updated together.
+Also fixed a small pre-existing bug: the `/notifications` PUT handler now
+honours `notifySubagents` on save (previously it was silently dropped
+because the field wasn't passed to `normalizeNtfyConfig`).
diff --git a/HANDOFF.md b/notes/wake-schedule-handoff.md
index 3ea711a..3ea711a 100644
--- a/HANDOFF.md
+++ b/notes/wake-schedule-handoff.md
diff --git a/packages/api/src/app.ts b/packages/api/src/app.ts
index 19cc193..0dabb0d 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,39 @@ 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;
+ }
+ },
+ getTabParentId: (tabId) => {
+ try {
+ // `undefined` when the lookup fails (tab not found / DB unavailable)
+ // so the dispatcher falls back to "treat as top-level" rather than
+ // silently dropping notifications.
+ const row = getTab(tabId);
+ return row ? row.parentTabId : undefined;
+ } catch {
+ return undefined;
+ }
+ },
+});
+notificationDispatcher.attachToAgentManager(agentManager);
+notificationDispatcher.attachToPermissionManager(permissionManager);
+
export const app = new Hono();
app.use(
@@ -112,6 +140,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<string, unknown>;
+}) => void;
+
export class PermissionManager {
private service = new PermissionService();
private wsClients: Map<string, (data: unknown) => void> = new Map();
+ private promptAddedListeners: Set<PromptAddedListener> = new Set();
+ /** Ids that have already been broadcast as "added" — guards against re-emits. */
+ private announcedPromptIds: Set<string> = 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<PermissionReply> {
@@ -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..473e837
--- /dev/null
+++ b/packages/api/src/routes/notifications.ts
@@ -0,0 +1,88 @@
+// `/notifications` — ntfy.sh config + test-send route.
+
+import {
+ defaultNtfyConfig,
+ loadNtfyConfig,
+ type NotificationEventType,
+ NTFY_EVENT_TYPES,
+ type NtfyConfig,
+ normalizeNtfyConfig,
+ redactNtfyConfig,
+ saveNtfyConfig,
+ sendNtfy,
+} 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<Partial<NtfyConfig> & { 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,
+ topic: typeof body.topic === "string" ? body.topic : existing.topic,
+ authToken: nextAuthToken,
+ events: { ...existing.events, ...(body.events ?? {}) },
+ notifySubagents:
+ typeof body.notifySubagents === "boolean" ? body.notifySubagents : existing.notifySubagents,
+ });
+
+ // Only validation: if notifications are turned on, the topic must be
+ // non-empty. Any other "is this a valid ntfy topic name?" check is
+ // punted to the ntfy server itself — its rules vary and have changed
+ // over time, and a syntactically-valid name still might be rejected
+ // (e.g. reserved words), so a clear server error is more useful than
+ // a client-side guess.
+ if (merged.enabled && !merged.topic.trim()) {
+ return c.json({ error: "Topic is required" }, 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);
+ }
+ if (!config.topic.trim()) {
+ return c.json({ ok: false, error: "Topic is required" }, 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<string, unknown>;
+}
+
+function makeRequest(overrides: Partial<PermissionRequest> = {}): 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 f92b94c..c768cee 100644
--- a/packages/api/tests/routes.test.ts
+++ b/packages/api/tests/routes.test.ts
@@ -268,6 +268,56 @@ vi.mock("@dispatch/core", () => ({
execute: async () => "mock",
};
},
+ // ── ntfy notifications stubs ──────────────────────────────────
+ NotificationDispatcher: class MockNotificationDispatcher {
+ attachToAgentManager() {
+ return () => {};
+ }
+ attachToPermissionManager() {
+ return () => {};
+ }
+ notify() {}
+ dispose() {}
+ },
+ loadNtfyConfig() {
+ return {
+ enabled: false,
+ topic: "",
+ authToken: "",
+ events: {
+ "turn-completed": true,
+ "turn-error": true,
+ "permission-required": true,
+ "agent-spawned": false,
+ },
+ notifySubagents: false,
+ };
+ },
+ saveNtfyConfig() {},
+ normalizeNtfyConfig(c: unknown) {
+ return c;
+ },
+ defaultNtfyConfig() {
+ return {
+ enabled: false,
+ topic: "",
+ authToken: "",
+ events: {
+ "turn-completed": true,
+ "turn-error": true,
+ "permission-required": true,
+ "agent-spawned": false,
+ },
+ notifySubagents: 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 };
+ },
}));
const { app } = await import("../src/app.js");
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/);
+ });
+});
diff --git a/packages/frontend/src/App.svelte b/packages/frontend/src/App.svelte
index 1bae000..eaa28e8 100644
--- a/packages/frontend/src/App.svelte
+++ b/packages/frontend/src/App.svelte
@@ -11,11 +11,10 @@ import TabBar from "./lib/components/TabBar.svelte";
import { config } from "./lib/config.js";
import { router } from "./lib/router.svelte.js";
import { tabStore } from "./lib/tabs.svelte.js";
+import { applyTheme, loadStoredTheme } from "./lib/theme.js";
import type { KeyInfo } from "./lib/types.js";
import { wsClient } from "./lib/ws.svelte.js";
-const STORAGE_KEY = "dispatch-theme";
-
let modelsData = $state<{ keys: KeyInfo[] }>({
keys: [],
});
@@ -76,11 +75,11 @@ $effect(() => {
});
onMount(() => {
- // Apply saved theme
- const saved = localStorage.getItem(STORAGE_KEY);
- if (saved) {
- document.documentElement.setAttribute("data-theme", saved);
- }
+ // Apply persisted theme (or the shared DEFAULT_THEME if nothing is
+ // stored) so the first paint matches what the Settings panel will
+ // show as the selected option. Without this, daisyUI falls back to
+ // the first theme in `app.css` (light) while Settings shows "dark".
+ applyTheme(loadStoredTheme());
// Connect WebSocket in parallel with hydration. The `statuses`
// snapshot delivered on WS open is idempotent against
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 @@
+<script lang="ts">
+import { tabStore } from "../tabs.svelte.js";
+
+let copyLabel = $state("Copy conversation");
+
+function resetCopyLabel(): void {
+ copyLabel = "Copy conversation";
+}
+
+async function handleCopy(): Promise<void> {
+ const text = tabStore.copyConversation();
+ try {
+ await navigator.clipboard.writeText(text);
+ copyLabel = "Copied";
+ } catch {
+ copyLabel = "Failed";
+ }
+ setTimeout(resetCopyLabel, 1500);
+}
+</script>
+
+<div class="flex flex-col gap-3">
+ <div class="text-xs font-semibold text-base-content/50 uppercase tracking-wide">Debug</div>
+
+ <div class="flex flex-col gap-2">
+ <p class="text-xs text-base-content/70">Conversation</p>
+ <p class="text-xs text-base-content/40">
+ Copy a structured plain-text dump of the active tab's conversation
+ (chunk shape included) for bug reports.
+ </p>
+ <button type="button" class="btn btn-sm btn-primary w-full" onclick={handleCopy}>
+ {copyLabel}
+ </button>
+ </div>
+</div>
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 @@
<script lang="ts">
import { router } from "../router.svelte.js";
-import { tabStore } from "../tabs.svelte.js";
import { wsClient } from "../ws.svelte.js";
-import ThemeSwitcher from "./ThemeSwitcher.svelte";
const { onToggleSidebar }: { onToggleSidebar: () => void } = $props();
-
-let showThemeSwitcher = $state(false);
-let copyLabel = $state("Copy");
-
-function resetCopyLabel() {
- copyLabel = "Copy";
-}
-
-async function handleCopy() {
- const text = tabStore.copyConversation();
- try {
- await navigator.clipboard.writeText(text);
- copyLabel = "Copied";
- setTimeout(resetCopyLabel, 1500);
- } catch {
- copyLabel = "Failed";
- setTimeout(resetCopyLabel, 1500);
- }
-}
</script>
<header class="navbar bg-base-200 px-4 min-h-14 flex-shrink-0">
@@ -38,22 +17,6 @@ async function handleCopy() {
<button
type="button"
class="btn btn-ghost btn-sm"
- onclick={handleCopy}
- aria-label="Copy conversation"
- >
- {copyLabel}
- </button>
- <button
- type="button"
- class="btn btn-ghost btn-sm"
- onclick={() => (showThemeSwitcher = !showThemeSwitcher)}
- aria-label="Switch theme"
- >
- Theme
- </button>
- <button
- type="button"
- class="btn btn-ghost btn-sm"
onclick={onToggleSidebar}
aria-label="Toggle sidebar"
>
@@ -61,7 +24,3 @@ async function handleCopy() {
</button>
</div>
</header>
-
-{#if showThemeSwitcher}
- <ThemeSwitcher onclose={() => (showThemeSwitcher = false)} />
-{/if}
diff --git a/packages/frontend/src/lib/components/SettingsPanel.svelte b/packages/frontend/src/lib/components/SettingsPanel.svelte
index 392852a..7a810d5 100644
--- a/packages/frontend/src/lib/components/SettingsPanel.svelte
+++ b/packages/frontend/src/lib/components/SettingsPanel.svelte
@@ -1,6 +1,7 @@
<script lang="ts">
import { config } from "../config.js";
import { appSettings } from "../settings.svelte.js";
+import { applyTheme, loadStoredTheme, THEMES, type Theme } from "../theme.js";
import type { KeyInfo } from "../types.js";
const {
@@ -11,6 +12,17 @@ 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. Theme constants and apply/persist live in `../theme.ts`
+// so the boot-time apply in `App.svelte` and this picker can't drift.
+let currentTheme = $state<Theme>(loadStoredTheme());
+
+function selectTheme(theme: Theme): void {
+ currentTheme = theme;
+ applyTheme(theme);
+}
+
let titleKeyId = $state<string | null>(null);
let titleModelId = $state<string | null>(null);
let availableModels = $state<string[]>([]);
@@ -20,6 +32,62 @@ 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;
+ topic: string;
+ authToken: string;
+ hasAuthToken?: boolean;
+ events: Record<NotificationEventType, boolean>;
+ notifySubagents: boolean;
+}
+
+const NTFY_EVENT_LABELS: Record<NotificationEventType, string> = {
+ "turn-completed": "Turn completed",
+ "turn-error": "Turn error",
+ "permission-required": "Permission requested",
+ "agent-spawned": "User agent spawned",
+};
+
+const DEFAULT_NTFY: NtfyConfigView = {
+ enabled: false,
+ topic: "",
+ authToken: "",
+ hasAuthToken: false,
+ events: {
+ "turn-completed": true,
+ "turn-error": true,
+ "permission-required": true,
+ "agent-spawned": false,
+ },
+ notifySubagents: false,
+};
+
+let ntfy = $state<NtfyConfigView>({ ...DEFAULT_NTFY, events: { ...DEFAULT_NTFY.events } });
+let ntfyAuthTokenInput = $state(""); // empty == leave unchanged on save
+let ntfyEventOrder = $state<NotificationEventType[]>([
+ "turn-completed",
+ "turn-error",
+ "permission-required",
+ "agent-spawned",
+]);
+let ntfySaving = $state(false);
+let ntfySaveError = $state<string | null>(null);
+let ntfySaveOk = $state(false);
+let ntfyTesting = $state(false);
+let ntfyTestResult = $state<string | null>(null);
+let ntfyTestOk = $state(false);
+let ntfyClearingToken = $state(false);
+
function onChunkLimitChange(e: Event): void {
const input = e.target as HTMLInputElement;
const val = parseInt(input.value, 10);
@@ -73,6 +141,130 @@ async function loadSettings(): Promise<void> {
} catch {
// ignore
}
+ await loadNtfy();
+}
+
+async function loadNtfy(): Promise<void> {
+ 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<void> {
+ 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<NtfyConfigView> & { authToken?: string } = {
+ enabled: ntfy.enabled,
+ topic: ntfy.topic,
+ events: ntfy.events,
+ notifySubagents: ntfy.notifySubagents,
+ };
+ 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<void> {
+ 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;
+ }
+}
+
+async function clearNtfyAuthToken(): Promise<void> {
+ // `""` ⇒ explicit clear on save (vs. `undefined` which keeps existing).
+ // Optimistic local state on failure caused a real bug pre-review: UI showed
+ // the token cleared while the server still held it, then "Save" treated the
+ // blank input as "keep existing" and silently re-armed the old token. Await
+ // the response and only flip local state on success.
+ ntfyClearingToken = true;
+ ntfySaveError = null;
+ try {
+ const res = await fetch(`${apiBase}/notifications`, {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ authToken: "" }),
+ });
+ const data = (await res.json().catch(() => ({}))) as {
+ config?: NtfyConfigView;
+ error?: string;
+ };
+ if (!res.ok) {
+ ntfySaveError = data.error ?? `Clear failed (HTTP ${res.status})`;
+ return;
+ }
+ ntfyAuthTokenInput = "";
+ if (data.config) {
+ ntfy = {
+ ...DEFAULT_NTFY,
+ ...data.config,
+ events: { ...DEFAULT_NTFY.events, ...(data.config.events ?? {}) },
+ };
+ } else {
+ ntfy = { ...ntfy, hasAuthToken: false };
+ }
+ } catch (e) {
+ ntfySaveError = e instanceof Error ? e.message : "Network error";
+ } finally {
+ ntfyClearingToken = false;
+ }
}
async function toggleAutoExpand(): Promise<void> {
@@ -136,6 +328,22 @@ $effect(() => {
<div class="text-xs font-semibold text-base-content/50 uppercase tracking-wide">Settings</div>
<div class="flex flex-col gap-2">
+ <p class="text-xs text-base-content/70">Theme</p>
+ <label class="text-xs text-base-content/60">
+ Appearance
+ <select
+ class="select select-bordered select-sm w-full capitalize"
+ value={currentTheme}
+ onchange={(e) => selectTheme(e.currentTarget.value as Theme)}
+ >
+ {#each THEMES as theme (theme)}
+ <option value={theme} class="capitalize">{theme}</option>
+ {/each}
+ </select>
+ </label>
+
+ <div class="divider my-0"></div>
+
<p class="text-xs text-base-content/70">Title Generation Model</p>
<p class="text-xs text-base-content/40">Used to generate short titles for new tabs after the first message.</p>
@@ -222,5 +430,125 @@ $effect(() => {
{#if backendUrlSaved}
<p class="text-xs text-success">Saved. Reload the page to apply.</p>
{/if}
+
+ <div class="divider my-0"></div>
+
+ <p class="text-xs text-base-content/70">Notifications (ntfy.sh)</p>
+ <p class="text-xs text-base-content/40">
+ Push notifications to your phone when things happen here. Subscribe to your topic in the
+ <a href="https://ntfy.sh/" target="_blank" rel="noopener" class="link">ntfy.sh</a> app to receive them.
+ </p>
+
+ <label class="flex items-center gap-2 cursor-pointer">
+ <input
+ type="checkbox"
+ class="checkbox checkbox-sm rounded-sm"
+ bind:checked={ntfy.enabled}
+ />
+ <span class="text-xs text-base-content/70">Enable notifications</span>
+ </label>
+
+ <label class="text-xs text-base-content/60 flex flex-col gap-1">
+ Topic
+ <input
+ type="text"
+ class="input input-bordered input-sm w-full"
+ placeholder="your-secret-topic"
+ bind:value={ntfy.topic}
+ />
+ <span class="text-[10px] text-base-content/40">
+ Any string — pick something unguessable, since anyone with the topic name can read your notifications. Subscribe to the same topic in the ntfy app.
+ </span>
+ </label>
+
+ <label class="text-xs text-base-content/60 flex flex-col gap-1">
+ Auth token (optional, for private ntfy servers)
+ <input
+ type="password"
+ class="input input-bordered input-sm w-full"
+ placeholder={ntfy.hasAuthToken ? "•••• (stored — type to replace)" : "Leave blank for public ntfy.sh"}
+ bind:value={ntfyAuthTokenInput}
+ autocomplete="off"
+ />
+ {#if ntfy.hasAuthToken}
+ <button
+ type="button"
+ class="btn btn-xs btn-ghost btn-outline self-start"
+ disabled={ntfyClearingToken}
+ onclick={clearNtfyAuthToken}
+ >
+ {#if ntfyClearingToken}
+ <span class="loading loading-spinner loading-xs"></span>
+ {:else}
+ Clear stored token
+ {/if}
+ </button>
+ {/if}
+ </label>
+
+ <div class="flex flex-col gap-1 mt-1">
+ <span class="text-xs text-base-content/60">Notify me on:</span>
+ {#each ntfyEventOrder as evType (evType)}
+ <label class="flex items-center gap-2 cursor-pointer">
+ <input
+ type="checkbox"
+ class="checkbox checkbox-sm rounded-sm"
+ bind:checked={ntfy.events[evType]}
+ />
+ <span class="text-xs text-base-content/70">{NTFY_EVENT_LABELS[evType] ?? evType}</span>
+ </label>
+ {/each}
+ </div>
+
+ <div class="flex flex-col gap-1 mt-1">
+ <label class="flex items-center gap-2 cursor-pointer">
+ <input
+ type="checkbox"
+ class="checkbox checkbox-sm rounded-sm"
+ bind:checked={ntfy.notifySubagents}
+ />
+ <span class="text-xs text-base-content/70">Include subagent tabs</span>
+ </label>
+ <span class="text-[10px] text-base-content/40 pl-6">
+ Off (default): turn-completed/turn-error from subagents are suppressed. Permission prompts still fire so subagents don't silently hang.
+ </span>
+ </div>
+
+ <div class="flex gap-1 mt-1">
+ <button
+ type="button"
+ class="btn btn-sm btn-primary flex-1"
+ disabled={ntfySaving}
+ onclick={saveNtfy}
+ >
+ {#if ntfySaving}
+ <span class="loading loading-spinner loading-xs"></span>
+ {:else}
+ Save
+ {/if}
+ </button>
+ <button
+ type="button"
+ class="btn btn-sm btn-outline"
+ disabled={ntfyTesting || !ntfy.enabled}
+ onclick={sendNtfyTest}
+ title={ntfy.enabled ? "Send a test notification with current settings" : "Enable notifications first"}
+ >
+ {#if ntfyTesting}
+ <span class="loading loading-spinner loading-xs"></span>
+ {:else}
+ Send test
+ {/if}
+ </button>
+ </div>
+ {#if ntfySaveOk}
+ <p class="text-xs text-success">Saved.</p>
+ {/if}
+ {#if ntfySaveError}
+ <p class="text-xs text-error">{ntfySaveError}</p>
+ {/if}
+ {#if ntfyTestResult}
+ <p class="text-xs {ntfyTestOk ? 'text-success' : 'text-error'}">{ntfyTestResult}</p>
+ {/if}
</div>
</div>
diff --git a/packages/frontend/src/lib/components/SidebarPanel.svelte b/packages/frontend/src/lib/components/SidebarPanel.svelte
index 206ed09..491b1bd 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() {
@@ -137,6 +139,7 @@ function contentClass(_selected: string): string {
<button
type="button"
class="btn btn-sm btn-ghost btn-square shrink-0"
+ aria-label="Remove panel"
onclick={() => {
panels = panels.filter((p) => p.id !== panel.id);
}}
@@ -181,6 +184,8 @@ function contentClass(_selected: string): string {
<ToolPermissions entries={permissionLog} {apiBase} />
{:else if panel.selected === "Settings"}
<SettingsPanel {keys} {apiBase} />
+ {:else if panel.selected === "Debug"}
+ <DebugPanel />
{/if}
</div>
</div>
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 @@
-<script lang="ts">
-const THEMES = [
- "light",
- "dark",
- "dracula",
- "night",
- "nord",
- "sunset",
- "cyberpunk",
- "forest",
- "cmyk",
- "coffee",
- "caramellatte",
- "garden",
- "luxury",
-] as const;
-
-const STORAGE_KEY = "dispatch-theme";
-
-const { onclose }: { onclose: () => void } = $props();
-
-let currentTheme = $state(
- (typeof localStorage !== "undefined" && localStorage.getItem(STORAGE_KEY)) || "dark",
-);
-
-let dialogEl: HTMLDialogElement | undefined = $state();
-
-$effect(() => {
- if (dialogEl && !dialogEl.open) dialogEl.showModal();
-});
-
-function selectTheme(theme: string) {
- currentTheme = theme;
- document.documentElement.setAttribute("data-theme", theme);
- localStorage.setItem(STORAGE_KEY, theme);
- onclose();
-}
-</script>
-
-<dialog class="modal" bind:this={dialogEl} oncancel={onclose}>
- <div class="modal-box w-56">
- <h3 class="text-sm font-semibold mb-3">Select Theme</h3>
- <ul class="menu menu-sm">
- {#each THEMES as theme}
- <li>
- <button
- type="button"
- class="capitalize {currentTheme === theme ? 'menu-active' : ''}"
- onclick={() => selectTheme(theme)}
- >
- {theme}
- </button>
- </li>
- {/each}
- </ul>
- </div>
- <form method="dialog" class="modal-backdrop"><button>close</button></form>
-</dialog>
diff --git a/packages/frontend/src/lib/theme.ts b/packages/frontend/src/lib/theme.ts
new file mode 100644
index 0000000..2b4bad0
--- /dev/null
+++ b/packages/frontend/src/lib/theme.ts
@@ -0,0 +1,92 @@
+/**
+ * Single source of truth for the app's theme picker.
+ *
+ * Two callers care about themes:
+ * - `App.svelte`'s `onMount`, which applies the persisted theme on boot
+ * so the first paint is the right color.
+ * - `SettingsPanel.svelte`'s theme `<select>`, which lets the user
+ * change theme at runtime.
+ *
+ * Both used to hand-roll their own `localStorage` key, default value,
+ * and DOM-attribute write. That drift produced a real bug: `App.svelte`
+ * left the DOM untouched on first load (so daisyUI fell back to the
+ * first theme in `app.css`, `light`), while `SettingsPanel` showed
+ * `"dark"` as the selected value — UI and reality disagreed until the
+ * user manually picked a theme. Centralizing here closes that gap.
+ *
+ * The theme list also intentionally mirrors the `@plugin "daisyui"`
+ * block in `app.css`. Drift between the two is harmless (a theme name
+ * present here but not in CSS just falls back to the default daisyUI
+ * theme at render time), but they should be kept in sync by hand —
+ * daisyUI's plugin config is a CSS-time concern and can't be imported
+ * from TS.
+ */
+
+export const THEMES = [
+ "light",
+ "dark",
+ "dracula",
+ "night",
+ "nord",
+ "sunset",
+ "cyberpunk",
+ "forest",
+ "cmyk",
+ "coffee",
+ "caramellatte",
+ "garden",
+ "luxury",
+] as const;
+
+export type Theme = (typeof THEMES)[number];
+
+export const THEME_STORAGE_KEY = "dispatch-theme";
+
+/**
+ * The fallback theme used both at first-boot apply (when nothing is
+ * persisted) and as the UI's default-selected value. They MUST match —
+ * if they ever diverged again, the UI would show one theme and the
+ * page would render another.
+ */
+export const DEFAULT_THEME: Theme = "dark";
+
+/**
+ * Read the persisted theme. Returns `DEFAULT_THEME` when nothing is
+ * stored, when access throws (private mode / SecurityError), or when
+ * the stored value isn't one of the known themes. Never throws.
+ *
+ * SSR-safe: if `localStorage` is undefined (e.g. `vite build` during
+ * prerender, if that's ever wired up), returns the default.
+ */
+export function loadStoredTheme(): Theme {
+ try {
+ if (typeof localStorage === "undefined") return DEFAULT_THEME;
+ const raw = localStorage.getItem(THEME_STORAGE_KEY);
+ if (raw && (THEMES as readonly string[]).includes(raw)) {
+ return raw as Theme;
+ }
+ return DEFAULT_THEME;
+ } catch {
+ return DEFAULT_THEME;
+ }
+}
+
+/**
+ * Apply a theme to the live document and persist it. The DOM write is
+ * unconditional (daisyUI keys off `data-theme` on the `<html>`
+ * element); the storage write is best-effort and swallows quota /
+ * SecurityError failures because the session continues to work either
+ * way — only cross-reload persistence degrades.
+ */
+export function applyTheme(theme: Theme): void {
+ if (typeof document !== "undefined") {
+ document.documentElement.setAttribute("data-theme", theme);
+ }
+ try {
+ if (typeof localStorage !== "undefined") {
+ localStorage.setItem(THEME_STORAGE_KEY, theme);
+ }
+ } catch {
+ // Best-effort.
+ }
+}
diff --git a/packages/frontend/tests/theme.test.ts b/packages/frontend/tests/theme.test.ts
new file mode 100644
index 0000000..63a343d
--- /dev/null
+++ b/packages/frontend/tests/theme.test.ts
@@ -0,0 +1,144 @@
+/**
+ * Tests for `src/lib/theme.ts` — the shared theme picker module.
+ *
+ * Covers the post-Gemini-review fix where `App.svelte` (boot apply)
+ * and `SettingsPanel.svelte` (UI picker) used to hand-roll their own
+ * defaults and could disagree. After consolidation, both call into
+ * this module and the bug class is gone — these tests pin that
+ * invariant.
+ */
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { applyTheme, DEFAULT_THEME, loadStoredTheme, THEMES } from "../src/lib/theme.js";
+
+const LS_KEY = "dispatch-theme";
+
+function makeLocalStorageMock(): Storage {
+ const store = new Map<string, string>();
+ return {
+ getItem: (k: string) => store.get(k) ?? null,
+ setItem: (k: string, v: string) => {
+ store.set(k, v);
+ },
+ removeItem: (k: string) => {
+ store.delete(k);
+ },
+ clear: () => {
+ store.clear();
+ },
+ get length() {
+ return store.size;
+ },
+ key: (i: number) => Array.from(store.keys())[i] ?? null,
+ };
+}
+
+function makeDocumentMock(): { documentElement: { setAttribute: (k: string, v: string) => void } } {
+ const attrs = new Map<string, string>();
+ return {
+ documentElement: {
+ setAttribute: (k: string, v: string) => {
+ attrs.set(k, v);
+ },
+ // expose for assertions
+ // @ts-expect-error — test-only escape hatch
+ _attrs: attrs,
+ },
+ };
+}
+
+beforeEach(() => {
+ vi.stubGlobal("localStorage", makeLocalStorageMock());
+ vi.stubGlobal("document", makeDocumentMock());
+});
+
+describe("DEFAULT_THEME", () => {
+ it("is one of the THEMES (sanity check that they can't drift)", () => {
+ expect((THEMES as readonly string[]).includes(DEFAULT_THEME)).toBe(true);
+ });
+});
+
+describe("loadStoredTheme", () => {
+ it("returns DEFAULT_THEME when localStorage is empty (first-ever load)", () => {
+ expect(loadStoredTheme()).toBe(DEFAULT_THEME);
+ });
+
+ it("returns the stored theme when it's a known theme", () => {
+ localStorage.setItem(LS_KEY, "dracula");
+ expect(loadStoredTheme()).toBe("dracula");
+ });
+
+ it("returns DEFAULT_THEME when the stored value isn't a known theme", () => {
+ // Guards against a stale storage entry from a removed theme,
+ // or a hand-edited bad value, falling through to daisyUI's own
+ // fallback (which is `light`, not our DEFAULT).
+ localStorage.setItem(LS_KEY, "solarized-rainbow");
+ expect(loadStoredTheme()).toBe(DEFAULT_THEME);
+ });
+
+ it("returns DEFAULT_THEME when localStorage.getItem throws", () => {
+ vi.stubGlobal("localStorage", {
+ getItem: () => {
+ throw new Error("SecurityError");
+ },
+ setItem: () => {},
+ removeItem: () => {},
+ clear: () => {},
+ length: 0,
+ key: () => null,
+ });
+ expect(loadStoredTheme()).toBe(DEFAULT_THEME);
+ });
+
+ it("returns DEFAULT_THEME when localStorage is undefined (SSR)", () => {
+ vi.stubGlobal("localStorage", undefined);
+ expect(loadStoredTheme()).toBe(DEFAULT_THEME);
+ });
+});
+
+describe("applyTheme", () => {
+ it("writes data-theme on the document element", () => {
+ applyTheme("nord");
+ // @ts-expect-error — test mock exposes `_attrs`
+ expect(document.documentElement._attrs.get("data-theme")).toBe("nord");
+ });
+
+ it("persists the theme to localStorage", () => {
+ applyTheme("forest");
+ expect(localStorage.getItem(LS_KEY)).toBe("forest");
+ });
+
+ it("round-trips through loadStoredTheme", () => {
+ applyTheme("luxury");
+ expect(loadStoredTheme()).toBe("luxury");
+ });
+
+ it("does not throw when localStorage.setItem throws (quota etc.)", () => {
+ vi.stubGlobal("localStorage", {
+ getItem: () => null,
+ setItem: () => {
+ throw new Error("QuotaExceededError");
+ },
+ removeItem: () => {},
+ clear: () => {},
+ length: 0,
+ key: () => null,
+ });
+ expect(() => applyTheme("cyberpunk")).not.toThrow();
+ });
+
+ it("still writes to the DOM even if localStorage write throws", () => {
+ vi.stubGlobal("localStorage", {
+ getItem: () => null,
+ setItem: () => {
+ throw new Error("QuotaExceededError");
+ },
+ removeItem: () => {},
+ clear: () => {},
+ length: 0,
+ key: () => null,
+ });
+ applyTheme("coffee");
+ // @ts-expect-error — test mock exposes `_attrs`
+ expect(document.documentElement._attrs.get("data-theme")).toBe("coffee");
+ });
+});