diff options
| author | Adam Malczewski <[email protected]> | 2026-06-01 11:44:27 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-01 11:44:27 +0900 |
| commit | 0a5eea4c06371df756aea40f53bb6dbe71df664a (patch) | |
| tree | 443e454e1edf1814f1a5c8e77507f63812739122 | |
| parent | 00922f6136ff0c6e047bb4a6165682f236971450 (diff) | |
| parent | 03e58f69e77b7a27e235210158f3f8e499a817c3 (diff) | |
| download | dispatch-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
25 files changed, 2727 insertions, 106 deletions
@@ -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"); + }); +}); |
