diff options
| -rw-r--r-- | HANDOFF.md | 526 | ||||
| -rw-r--r-- | packages/api/src/app.ts | 29 | ||||
| -rw-r--r-- | packages/api/src/permission-manager.ts | 55 | ||||
| -rw-r--r-- | packages/api/src/routes/notifications.ts | 88 | ||||
| -rw-r--r-- | packages/api/tests/permission-manager.test.ts | 99 | ||||
| -rw-r--r-- | packages/api/tests/routes.test.ts | 50 | ||||
| -rw-r--r-- | packages/core/src/index.ts | 2 | ||||
| -rw-r--r-- | packages/core/src/notifications/config.ts | 77 | ||||
| -rw-r--r-- | packages/core/src/notifications/dispatcher.ts | 287 | ||||
| -rw-r--r-- | packages/core/src/notifications/index.ts | 36 | ||||
| -rw-r--r-- | packages/core/src/notifications/ntfy.ts | 149 | ||||
| -rw-r--r-- | packages/core/src/notifications/types.ts | 108 | ||||
| -rw-r--r-- | packages/core/tests/notifications/config.test.ts | 158 | ||||
| -rw-r--r-- | packages/core/tests/notifications/dispatcher.test.ts | 461 | ||||
| -rw-r--r-- | packages/core/tests/notifications/ntfy.test.ts | 204 | ||||
| -rw-r--r-- | packages/frontend/src/lib/components/SettingsPanel.svelte | 300 |
16 files changed, 2392 insertions, 237 deletions
@@ -1,259 +1,311 @@ -# H3 — Header Declutter - -Branch: `h3/header-declutter` (branched off `dev`) -Worktree: `/home/tradam/projects/dispatch/h3-header-declutter` +# Handoff — n2/ntfy-notifications ## Summary -The app header used to be: **`Dispatch | … | Connection · Copy · Theme · Sidebar`** — four right-aligned buttons, two of them unrelated to navigation. - -It is now: **`Dispatch | … | Connection · Sidebar`**. - -The two removed buttons moved into the sidebar where the rest of the app's -controls already live: - -| Button | Old location | New location | -| ---------- | -------------------------- | ---------------------------------------------- | -| **Copy** | Header | New **Debug** sidebar panel | -| **Theme** | Header → `ThemeSwitcher` modal | Inlined as a section in the **Settings** sidebar panel | - -`ThemeSwitcher.svelte` was deleted; its theme list + apply-and-persist logic -was inlined into `SettingsPanel.svelte` (a sidebar panel doesn't need a -modal, and Settings already owns all other UI preferences). - -A post-implementation Gemini review surfaced a real bug in the first cut -(the Settings panel's hardcoded `"dark"` default disagreed with the -DOM's actual first-paint theme, which was `light` via daisyUI fallback). -That's fixed by a shared `lib/theme.ts` module; see commit `6c377fb`. - -## Files - -### Modified -- `packages/frontend/src/lib/components/Header.svelte` — removed Copy - button, Theme button, ThemeSwitcher import, `showThemeSwitcher` state, - `copyLabel` state, `handleCopy` / `resetCopyLabel` helpers, and the - `{#if showThemeSwitcher}` block. Only the Dispatch title (left), - connection status indicator, and Sidebar toggle (right) remain. -- `packages/frontend/src/lib/components/SidebarPanel.svelte` — registered - `"Debug"` as a new entry in `viewOptions` (last in the list) and added - the corresponding `{:else if panel.selected === "Debug"} <DebugPanel />` - branch. Imported `DebugPanel`. Also added `aria-label="Remove panel"` - to the per-slot ✕ button (pre-existing a11y nit found in review). -- `packages/frontend/src/lib/components/SettingsPanel.svelte` — added a - Theme `<select>` as the first section in the panel body. After the - Gemini-review fix this now uses `lib/theme.ts` for constants, default - value, and apply/persist (removed the duplicated `THEMES` array, - `THEME_STORAGE_KEY` const, and inline `selectTheme` logic that - hardcoded a conflicting `"dark"` fallback). -- `packages/frontend/src/App.svelte` — onMount theme apply switched from - the bare `localStorage.getItem(...)` + conditional `setAttribute` to - `applyTheme(loadStoredTheme())` from `lib/theme.js`, so the first - paint always matches what Settings will show as the selected option - (including on a fresh install where nothing is stored — previously - this branched and the DOM was left untouched). -- `.gitignore` — added `claude-report.md` so the Gemini review artifact - in the working tree doesn't get accidentally committed. - -### Added -- `packages/frontend/src/lib/components/DebugPanel.svelte` — new - sidebar panel grouping dev-facing actions. Currently exposes one - button: "Copy conversation" (the old header behavior, ported as-is — - wraps `tabStore.copyConversation()` and shows a 1.5 s - "Copied" / "Failed" affordance). Sized as a full-width primary button - in a labeled "Conversation" sub-section; layout/style matches sibling - panels (`bg-base-200` wrapper handled by `SidebarPanel`, uppercase - section header, body in `flex flex-col gap-3`). -- `packages/frontend/src/lib/theme.ts` — single source of truth for the - theme picker: `THEMES` tuple, `Theme` type, `THEME_STORAGE_KEY`, - `DEFAULT_THEME` (= `"dark"`), `loadStoredTheme()`, `applyTheme()`. - Handles SSR (`localStorage` undefined), `SecurityError` on read, - `QuotaExceededError` on write, and unknown stored values (e.g. a - theme that was removed from the list). -- `packages/frontend/tests/theme.test.ts` — 11 unit tests covering the - default-fallback, known/unknown stored values, SecurityError on - read, SSR (no `localStorage`), DOM-attribute write, persistence - round-trip, and the "DOM still updates even if storage write throws" - contract. Follows the same in-memory-mock pattern as - `sidebar-storage.test.ts`. - -### Deleted -- `packages/frontend/src/lib/components/ThemeSwitcher.svelte` — was a - 58-line `<dialog>` modal; its only consumer was `Header.svelte`. All - of its contents (theme list, current-theme state, selectTheme, the - localStorage key) were inlined into `SettingsPanel.svelte` and (post- - review) extracted further into `lib/theme.ts`. - -## Public surface changes - -- **New sidebar panel type id**: `"Debug"` — selectable from the - `<select>` dropdown on any sidebar slot. -- **Removed component**: `ThemeSwitcher` (no other importers; safe). -- **New module**: `packages/frontend/src/lib/theme.ts` (exports - `THEMES`, `Theme`, `THEME_STORAGE_KEY`, `DEFAULT_THEME`, - `loadStoredTheme`, `applyTheme`). Anyone needing to read or write - the app theme should import from here. -- **Component prop changes**: none. `Header.svelte`'s only prop - (`onToggleSidebar: () => void`) is unchanged. `SidebarPanel.svelte`, - `SettingsPanel.svelte`, and `DebugPanel.svelte` keep / introduce - prop shapes consistent with neighboring panels. - -## LocalStorage migration - -No migration is needed. - -- `dispatch-theme` localStorage key: unchanged shape, unchanged - consumers (boot apply in `App.svelte`'s `onMount`, write in - `applyTheme`). A user reloading after this branch sees their - previously-selected theme intact. A user with a stored value that - is no longer in `THEMES` (e.g. someone removed a daisyUI theme later) - now falls back to `DEFAULT_THEME` cleanly — previously they'd have - silently rendered the bad value as a `data-theme` attribute that - daisyUI doesn't recognize. -- `dispatch-sidebar-panels` (the sidebar layout): the existing - `loadSidebarPanels` already filters non-string entries and falls - back to the default layout when nothing valid is stored. Adding - `"Debug"` to `viewOptions` is purely additive: existing users - with stored layouts continue to render exactly what they had - before, and the new option becomes available to anyone who opens - the dropdown. No code change to `sidebar-storage.ts` was required. - -## Verification +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: -### `bun run check` ``` -$ biome check . -Checked 142 files in 171ms. No fixes applied. +AgentManager.onEvent ─┐ ┌─→ sendNtfy (fetch) + ├─→ NotificationDispatcher.notify(event) +PermissionMgr ────────┘ (filter / dedupe) └─→ (other transports later) +.onPromptAdded ``` -Exit code: 0. -### `bun run test` +### 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 + ``` -Test Files 25 passed (25) - Tests 404 passed (404) - Start at 09:45:10 - Duration 2.76s +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) ``` -Exit code: 0. (393 pre-existing tests + 11 new `theme.test.ts` tests.) -### `bun run --cwd packages/frontend typecheck` (svelte-check) +Seven commits on `n2/ntfy-notifications`: + ``` -svelte-check found 0 errors and 0 warnings +<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 ``` -### Build +## 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` + ``` -vite v6.4.2 building for production... -✓ 167 modules transformed. -✓ built in 3.91s +$ biome check . +Checked 150 files in 158ms. No fixes applied. ``` -### Manual UI smoke (programmatic) -Full interactive `dev:frontend` + `dev:api` boot wasn't run from this -CLI environment, but the equivalents were exercised: - -- Production build (`vite build`) succeeds — confirms all components - compile and Svelte's reactivity contracts are satisfied. -- `vite preview` boots and serves the HTML shell on port 4173. -- Static grep over the built bundle confirms the post-refactor wiring: - - `"Debug"` appears in the bundle as a panel option ✓ - - `"Copy conversation"` button label is present ✓ - - `ThemeSwitcher` / `showThemeSwitcher` symbols are absent ✓ - - Theme list (`dracula`, `cyberpunk`, `caramellatte`) and - `dispatch-theme` storage key are bundled ✓ - -For a human visual pass: `bun run dev:api` + `bun run dev:frontend`, -then verify: -1. Header has only `Dispatch | … | Connection · Sidebar`. -2. **Fresh install** (clear `dispatch-theme` from localStorage): first - paint should be **dark** (the new `DEFAULT_THEME`), matching what - the Settings dropdown shows. Before the post-review fix the first - paint was *light* while Settings showed *dark*. -3. Add a sidebar slot via `+`, choose "Settings" → "Theme → Appearance" - `<select>` shows all 13 themes and applies immediately. -4. Add another sidebar slot, choose "Debug" → "Copy conversation" - button works and flashes "Copied" for 1.5 s. - -## Code-review pass - -`HANDOFF.md` committed at `60999dc` triggered a second-opinion code -review by Gemini 3 Flash Preview (the user asked for "flash 3.1 -preview"; the API doesn't expose a 3.1 flash variant yet — only -`gemini-3-flash-preview` and `gemini-3.1-pro-preview` exist as of this -writing). The review report (untracked, gitignored) is at -`claude-report.md` in the worktree root. - -Findings triaged: - -| # | Severity | Verdict | Disposition | -|---|----------|---------|-------------| -| 1 | Major | **Real bug.** App.svelte left DOM untouched on first load (daisyUI fell back to `light`); SettingsPanel hardcoded `"dark"` UI fallback. | **Fixed** in commit `6c377fb` via shared `lib/theme.ts`. | -| 2 | Minor | **Real, pre-existing pattern**, not specific to this branch. Two `SettingsPanel` instances would also drift on `autoExpandThinking`, `localChunkLimit`, `backendUrl` — all of which already worked this way before this branch. | **Deferred.** Out of scope for "declutter the header." Would require lifting all of Settings' state into `appSettings.svelte.ts`, which is a separate refactor. | -| 3 | Minor/nit | **Real, fixed by #1.** | Subsumed by `6c377fb`. (Only the daisyUI `@plugin` block in `app.css` remains as a parallel theme list; that's a CSS-time concern and can't be imported from TS — kept in sync by convention, noted in the new module's doc comment.) | -| 4 | Nit | **Real, pre-existing.** ✕ button on remove-panel had no aria-label. | **Fixed** in commit `5ea668d` while touching the file. | - -Open recommendations from Gemini that were intentionally *not* taken: - -- **"Add a 'System' theme option** that reads - `prefers-color-scheme`." — Out of scope for this branch (it's a - feature, not a bug). The existing daisyUI theme list already - includes both `light` and `dark`; users can pick. Worth filing as a - separate ticket. -- **"Move all `localStorage` keys to `lib/config.ts` or - `lib/storage.ts`."** — This branch already adds one such module - (`lib/theme.ts`) for the theme key. A broader audit (chunk limit, - auto-expand, backend URL, sidebar layout, theme) belongs in its own - refactor; doing it here would balloon the diff. -- **"Theme should not be first in Settings; functional things first."** - — Subjective. Current order (Theme → Title Generation → Chat → - Memory → Backend URL) puts user-visible appearance up top; happy to - swap if preferred. +✅ Pass (0 errors, 0 warnings). -## Assumptions / known gaps +### `bun run test` + +``` +Test Files 28 passed (28) + Tests 451 passed (451) + Duration 2.85s +``` -- **No dev-only gate on the Debug panel.** The brief flagged this as - a possible question; I chose consistency — all other panel types are - always available, and there's no existing dev-flag plumbing to hook - into. Easy to add later if desired. -- **Theme picker is a `<select>`, not a vertical menu.** The original - `ThemeSwitcher` used a menu of buttons because it was a modal with - the room to display all 13 themes at once. Inside the sidebar a - `<select>` is more compact and matches the existing pattern for - every other "pick one of N" control in `SettingsPanel` - (key, model, etc.). -- **Settings section order.** Theme is now first - (Theme → Title Generation → Chat → Memory → Backend URL); rationale - is that Theme is the most user-visible preference. If a different - order is preferred this is a one-block move. -- **Multiple Settings panels desync** (Gemini #2). Open `Settings` - twice in the sidebar, change theme in one — only that one's `<select>` - updates; the other still shows the previous value (the DOM and - storage are correct, only the second component's local `currentTheme` - state is stale). Same is true today for `autoExpandThinking`, - `localChunkLimit`, `backendUrl`. Fixing it requires hoisting state - into a shared reactive store — out of scope for "declutter the - header" and noted for follow-up. -- **No interactive-browser smoke test was run.** The environment - doesn't ship a browser harness; build + preview + bundle-grep is the - closest equivalent, and svelte-check confirms type/template - correctness. Recommend a 30-second manual eyeball before merge — - particularly to confirm the fresh-install theme now matches Settings. -- **`ThemeSwitcher.svelte` was deleted, not retained.** Worth noting - because the brief said either was fine. Choice rationale: it had - one importer (`Header.svelte`), 58 lines, and using a modal from - inside a sidebar panel would be a UX regression. - -## Commits (atomic) +✅ 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 ``` -5ea668d a11y(sidebar): label the remove-panel button for screen readers -6c377fb fix(theme): consolidate boot apply and Settings picker into shared module -60999dc docs: add HANDOFF.md for h3 header declutter -751e411 feat(settings): inline theme picker into Settings panel -dd3c71e feat(sidebar): add Debug panel with copy-conversation action -bbc85ff feat(header): remove copy + theme buttons; keep title, status, sidebar toggle + +### 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 -The first three (`bbc85ff` → `751e411`) are the original refactor. -`60999dc` is the initial handoff. `6c377fb` and `5ea668d` are the -post-Gemini-review fixes. Each commit is independent and reviewable on -its own. +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/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 9ab2afe..5606754 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/lib/components/SettingsPanel.svelte b/packages/frontend/src/lib/components/SettingsPanel.svelte index b6a44bc..7a810d5 100644 --- a/packages/frontend/src/lib/components/SettingsPanel.svelte +++ b/packages/frontend/src/lib/components/SettingsPanel.svelte @@ -32,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); @@ -85,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> { @@ -250,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> |
