Repository navigation
feat: web push alerts with the app closed - #4
Merged
Merged
Conversation
The server now sends pane alerts to subscribed devices through their browser's push service, so an agent that becomes blocked or finishes, or a terminal that ends, reaches a phone with the app closed. - server/push.ts keeps a VAPID key pair and the device subscriptions in HERDR_WEB_STATE_DIR (default ~/.config/herdr-web-ui), owner-only and written atomically. A malformed key file is an error rather than a silent rotation, because every subscription is bound to that key. A 404/410 from a push service drops the subscription. - Requests are built with web-push's generateRequestDetails (aes128gcm, VAPID) and sent with fetch. Payloads carry pane_id, title, body and a per-pane tag. TTL is 12h; blocked alerts go out as high urgency. - The alert policy moves to shared/notify-policy.ts so the tab and the server fire on the same transitions. The collector now reports every pane from each reconcile (onBaseline), so the first change after a server restart is measured against herdr's snapshot instead of being dropped as a first sighting. - Routes, all behind the token gate: GET /api/push (key), POST/DELETE /api/push/subscribe, POST /api/push/test (confirmation push to one device). A broken state file costs the alert, never the server. Tests: a fake push service (server/push.fake.ts) decrypts every push with an independent RFC 8291 implementation and verifies the VAPID JWT. Unit tests cover key and subscription persistence, the baseline rule, titles and 404/410 handling. Contract tests cover the routes, the 401 gate, a live pane's status push, and the first alert after a restart. Mutations are all caught: without the collector's onBaseline call, without the index.ts wiring for baseline or for status, the web push tests fail. Every test server keeps its push state in a temp dir. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Tapping the bell now subscribes the device to web push where the browser supports it (a secure context; on iPhone the home-screen app), registers the subscription with the server and asks for a confirmation push. A device that already allowed alerts re-registers on every load, so it comes back by itself if the server lost its state, and a subscription made for another server key is replaced. Locking the app unsubscribes the device before signing out. Without push support the bell keeps tab-only alerts, and its title says which of the two applies. The service worker shows each push as a notification with the pane's tag. It shows it silently when a window of the app is visible (Safari revokes subscriptions whose pushes show nothing). A tapped notification focuses an open window and selects the pane, or opens the app on /?pane=<id>. pushsubscriptionchange hands the server the new endpoint. Tab alerts now go through the service worker as well. Android Chrome has no `new Notification()`, so the constructor is only the fallback. A device with push skips tab alerts, so it doesn't get the same alert twice. Browser QA (headless Chrome, stand-in PushManager, pushes driven into the real service worker over CDP): 14/14. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A real-browser check through FCM (headed Chrome under Xvfb, no stubbed PushManager) showed that the bell's first tap called subscribe() twice: once from the click and once from the load-time registration that the permission grant starts. Chrome answered with two different subscriptions and kept only the later one. The server stored both, and the confirmation push could go to the dead endpoint, so it never arrived (3 of 4 runs). ensurePushSubscription is now single-flight: callers that arrive while a subscription is being made share that attempt. With the fix, the same FCM check made one subscribe() call and one endpoint, and the confirmation arrived in under 300ms (3/3 runs). A client test whose stand-in PushManager issues a new subscription on every call, like Chrome, fails without the single-flight (issued 2, expected 1). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This was referenced Oct 29, 2026
Merged
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Pane alerts (agent blocked / done, terminal ended) now reach a device with the app closed. Tapping the bell on a device served over HTTPS subscribes it to Web Push. On iPhone this needs the home-screen app, iOS 16.4+. A confirmation push ("Alerts are on for this device") checks the whole path end to end. Without push support the bell falls back to the old tab-only alerts, and its title says which of the two you have.
How
Server (
server/push.ts)web-push'sgenerateRequestDetails(RFC 8291 aes128gcm + RFC 8292 VAPID) and sends it withfetch.sendNotificationisn't used: it goes throughnode:httpsand a proxy agent.HERDR_WEB_STATE_DIR(default~/.config/herdr-web-ui), owner-only, written atomically.shared/notify-policy.ts, so the tab and the server fire on the same transitions.onBaseline), so the first change after a server restart still alerts. Without it, that change would count as a "first sighting" and be dropped.GET /api/push,POST|DELETE /api/push/subscribe,POST /api/push/test.{ pane_id, title, body, tag }, TTL 12h,Urgency: highfor blocked.Client (
src/lib/push.ts,src/App.tsx,public/sw.js)signOut.select-pane, or open/?pane=<id>.pushsubscriptionchangere-registers the new endpoint.registration.showNotification. Android Chrome has nonew Notification(). A device with push skips tab alerts, so it doesn't get the same alert twice.New dependency:
web-push@^3.6.7(runtime),@types/web-push(dev).Verification
bun testbun run typecheck/bun run buildonBaselinefirst push after restart not received)index.tsdoesn't forward status to pushindex.tsignores the baselinepush-subscriptions.jsonprobeweb push: JSON Parse error…PushManager; pushes delivered into the real SW over CDP)select-panelistener (synthetic message) →?pane=open + strip → lock removes the subscription server- and browser-sidepushManager.subscribe, no stubssubscribe()on the first tap~/.config/herdr-web-uinever created; no leftover test workspacesThe fake push service (
server/push.fake.ts) decrypts with an independent RFC 8291 implementation and verifies the VAPID JWT. The tests check the payload a device would actually show.Found by the real-FCM check (fixed in e429a8d)
The bell's first tap called
subscribe()twice: the click, plus the load-time registration that the permission grant starts. Real Chrome issued two different subscriptions and kept only the later one, so the confirmation push often went to the dead endpoint and never arrived (3 of 4 runs).ensurePushSubscriptionis now single-flight.src/lib/push.test.tscovers it with a stand-in PushManager that, like Chrome, issues a new subscription on every call; without the fix that test fails. After the fix: one subscribe, one endpoint, confirmation delivered 3/3.Not covered / follow-ups
notificationclickhandler wasn't executed. CDP can't click a notification, so focusing the window, postingselect-paneandopenWindow('/?pane=…')were checked by reading the code only. Both ends were exercised separately: App's listener and the?pane=open.bun test's own panes. If your real server has devices subscribed, the suite'sreport_agentcalls onherdr-web-ui-test-*panes will reach your phone, the same way tab alerts already did.HERDR_WEB_TOKENdoesn't unsubscribe existing devices. This is documented: lock them, or deletepush-subscriptions.json.🤖 Generated with Claude Code