Skip to content

feat: web push alerts with the app closed - #4

Merged
devswha merged 4 commits into
mainfrom
feat/web-push
Sep 19, 2026
Merged

devswha merged 4 commits into
mainfrom
feat/web-push

Conversation

@devswha

@devswha devswha commented Sep 19, 2026 •

Copy link
Copy Markdown
Owner

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)

  • Builds each request with web-push's generateRequestDetails (RFC 8291 aes128gcm + RFC 8292 VAPID) and sends it with fetch. sendNotification isn't used: it goes through node:https and a proxy agent.
  • The VAPID key pair and the subscriptions persist in HERDR_WEB_STATE_DIR (default ~/.config/herdr-web-ui), owner-only, written atomically.
  • A malformed key file is an error, not a silent rotation, because every subscription is bound to that key. A 404/410 from a push service drops the subscription.
  • The alert policy lives in 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 still alerts. Without it, that change would count as a "first sighting" and be dropped.
  • Routes, all behind the token gate: GET /api/push, POST|DELETE /api/push/subscribe, POST /api/push/test.
  • Payload { pane_id, title, body, tag }, TTL 12h, Urgency: high for blocked.
  • A broken state file only costs the alert; the rejection is caught, so the server keeps running.

Client (src/lib/push.ts, src/App.tsx, public/sw.js)

  • Bell flow: permission → subscribe (reusing an existing subscription, replacing one made for another server key) → register → confirmation push.
  • A device that already allowed alerts re-registers on every load (idempotent). Lock unsubscribes the device before signOut.
  • The SW shows each push with the pane's tag, silently when a window of the app is visible. It still shows it then, because Safari revokes subscriptions whose pushes show nothing.
  • Tap: focus the window and select-pane, or open /?pane=<id>. pushsubscriptionchange re-registers the new endpoint.
  • Tab alerts now go through registration.showNotification. Android Chrome has no new 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

Check Result
bun test 86 pass / 0 fail, 11 files
bun run typecheck / bun run build clean
Mutation: collector never calls onBaseline restart test fails (first push after restart not received)
Mutation: index.ts doesn't forward status to push live status test + restart test fail
Mutation: index.ts ignores the baseline restart test fails
Contract file ×3 29 pass each run
Corrupt push-subscriptions.json probe server stays up, logs web push: JSON Parse error…
Browser QA (headless Chrome; stand-in PushManager; pushes delivered into the real SW over CDP) 14/14: token gate → bell → confirmation push decrypted → subscription stored 0600 → live status push → SW notification with pane tag/data, silent while visible → App's select-pane listener (synthetic message) → ?pane= open + strip → lock removes the subscription server- and browser-side
Real push service (FCM): headed Chrome under Xvfb, real pushManager.subscribe, no stubs 3/3 runs: FCM endpoint stored; confirmation push arrived through Google's push service in under 300 ms; a live pane going blocked arrived as a notification with its pane tag and data
Browser QA: one subscribe() on the first tap pass (15/15 total)
Test isolation every test server uses a temp state dir; ~/.config/herdr-web-ui never created; no leftover test workspaces

The 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). ensurePushSubscription is now single-flight. src/lib/push.test.ts covers 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

  • The SW's notificationclick handler wasn't executed. CDP can't click a notification, so focusing the window, posting select-pane and openWindow('/?pane=…') were checked by reading the code only. Both ends were exercised separately: App's listener and the ?pane= open.
  • Not tested on a phone. Delivery through FCM was verified end to end with a real desktop Chrome (above). APNs (iPhone) and display on a phone still have to be checked by hand: see the README section "Alerts with the app closed".
  • A running real server alerts on bun test's own panes. If your real server has devices subscribed, the suite's report_agent calls on herdr-web-ui-test-* panes will reach your phone, the same way tab alerts already did.
  • Changing HERDR_WEB_TOKEN doesn't unsubscribe existing devices. This is documented: lock them, or delete push-subscriptions.json.

🤖 Generated with Claude Code

devswha and others added 4 commits September 19, 2026 14:54
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant