Add web push notifications for new inbox items

This commit is contained in:
Ian Gulliver
2026-10-14 21:25:01 -07:00
parent 01c60d6618
commit 8e4c40eb5f
40 changed files with 1724 additions and 202 deletions
+13
View File
@@ -9,6 +9,7 @@
- `internal/git`: worktrees, the task changes view (see "Changes"), unmerged-change summaries and squash merges.
- `internal/agents`: the agent session manager. It owns the running Claude processes and records their activity as events.
- `internal/claude`: the Claude Code stream-json client (see `docs/claude-protocol.md`).
- `internal/push`: web push notifications for new inbox items (see "Notifications").
- `web/`: the SPA, frameworkless TypeScript compiled file-by-file to ES modules (no bundler). `web/src/*.ts` is the source; `web/src/api.gen.ts` holds types generated from `api/openapi.yaml`, and `web/src/api.ts` is the typed client over them. `go generate ./web` runs `npm ci`, regenerates the types and compiles `web/dist/`, which is committed so `go install` needs no Node. The binary embeds `index.html`, `style.css`, `dist/` and `src/` (for source maps). `byre serve --dev` serves `./web` from disk instead (run from the repo root); pair it with `npm --prefix web exec tsc -- --watch`.
## Data
@@ -103,6 +104,18 @@ Agents ask questions with the `ask_human` MCP tool. Each question carries a `con
| `snooze` | any | stays `open`, hidden until `snooze_until` |
| `dismiss` | reports, errors | `dismissed` |
## Notifications
New inbox items are sent as web push notifications to every browser a user has turned them on in. Web push needs a secure origin and a service worker; byre gets the origin from the `tailscale serve` certificate, and serves the worker at `/sw.js` (compiled from `web/src/sw.ts` with its own `web/tsconfig.sw.json`, since it needs the WebWorker library instead of the DOM one) so its scope is the whole SPA.
- **Keys.** byre generates a VAPID key pair on its first start and keeps it in the `vapid_keys` table; `GET /api/push/key` returns the public half, which browsers pass to `PushManager.subscribe`. Replacing the keys invalidates every subscription.
- **Subscriptions.** `POST /api/me/push-subscriptions` stores a browser's endpoint, `p256dh` and `auth` in `push_subscriptions` under the calling user (re-posting an endpoint replaces it), along with `https://` plus the request's `Host`, which becomes the VAPID `sub` claim. `DELETE /api/me/push-subscriptions?endpoint=` removes one. A push service answering 404 or 410 means the subscription expired, and byre deletes it.
- **Preferences.** `users.notification_prefs` holds which item kinds notify the user, returned as `notification_prefs` on `GET /api/me` and set with `PUT /api/me/notifications`: `blocking_questions` (on by default), `questions` (non-blocking), `reports`, `errors` (`session_error`) and `rate_limits`.
- **Sending.** `internal/push` watches the event stream for `inbox.created`. The first item starts a 3-second window (`pushWindow` in `internal/server/server.go`); everything that arrives inside it goes out together, so each subscribed user gets at most one notification per window, built from the items their preferences select. One item reads `byre · <task title>` with the question (or the report's first line, the error, or "Rate limited") as the body; several read `byre · N new inbox items` with one `<task title>: <text>` line each. Encryption and VAPID signing are `github.com/SherClockHolmes/webpush-go`. Notifications carry a 24-hour TTL and `Urgency: high` when any item is blocking. Delivery failures are logged, not retried.
- **Clicking** a notification opens the first item's task (`/#/task/<id>`): the worker focuses an open byre window and tells it to switch task, or opens a new one.
The **Settings** tab turns notifications on or off for the current browser (asking for permission on the way) and edits the preferences. Opening it re-registers the browser's existing subscription, which restores one byre deleted as expired.
In the UI a question offers its options plus Other, whose text box is the only text field; a report has only an OK button, which dismisses it at once; an error offers Dismiss, Reply (`other`) and Snooze.
Answers reach the agent as one message, listing each answer under the question or error it responds to, once the session is idle and none of its blocking questions are still waiting for the human. An `other` answer to a question tells the agent that if it asks something back, it should answer with `ask_human` using the item's `thread_id`. Every item change is an `inbox.created` or `inbox.updated` event carrying the whole item, including who resolved it.