Skip to content

feat: notify a person when a terminal finishes, including Web Push - #123

Merged
code0xff merged 5 commits into
devfrom
feat/web-push-notifications
Oct 29, 2026
Merged

code0xff merged 5 commits into
devfrom
feat/web-push-notifications

Conversation

@code0xff

Copy link
Copy Markdown
Owner

Purpose

Let a terminal pane tell its person that work is done, and reach that person on a phone whose browser is closed, without installing a separate app.

Changes (one commit each, each builds and tests on its own)

  1. nightcrow notify [MESSAGE]: run inside a pane (for example from a Claude Code Stop hook or Codex notify). It identifies the pane by NIGHTCROW_PANE_TOKEN through a one-shot daemon request. The hub broadcasts attention with an optional message. Hubs also publish to a new session-wide Notices registry, as does entering the recovery plugin's needs_attention state (once per entry). The viewer shows the message as a toast; the TUI keeps its tab marker.
  2. Web Push in the daemon (web::viewer::push): RFC 8291 aes128gcm encryption to the browser's key, checked byte for byte against the RFC's worked example. RFC 8292 VAPID ES256 signing. One sender thread with a 64-entry queue and a 10 s per-pane coalescing window; 404/410 drops the subscription. The key and subscriptions are stored owner-only in ~/.nightcrow/push. Routes: GET /api/push/key, POST /api/push/{subscribe,unsubscribe,status,test}. An endpoint must be https on a public host name. There is deliberately no push-service allowlist: Samsung Internet's push host could not be confirmed, and anyone able to subscribe can already run shell commands here.
  3. [web_viewer] allowed_hosts: a loopback-bound viewer refused the *.ts.net Host that tailscale serve forwards. Listed names now pass the rebinding check.
  4. .dockerignore: the visual-test image copies nothing from the checkout, but docker build . uploaded a 22 GB target/.
  5. Viewer: a service worker, and a Notifications section in settings with a per-browser switch, a test button, and an explanation when push is not offered (insecure page, iOS outside a Home Screen app, blocked, unsupported, no daemon key). Tapping a notification opens that project with that terminal selected.

New dependencies: p256, hkdf, aes-gcm, base64. Docs: new docs/notifications.md, plus configuration.md, web-viewer.md, architecture/web.md and plugin-host.md.

Verification

  • cargo test --workspace and cargo clippy --workspace --all-targets --all-features -D warnings pass. The suite also passes at each intermediate commit.
  • Viewer: npm test 954 passed, tsc -b, lint, and build. Visual regressions 7/7 in the pinned Docker runtime.
  • End to end against an isolated daemon on localhost:
    • nightcrow notify build done typed into a pane showed the toast.
    • The service worker registered.
    • In Chrome for Testing, the switch created a real FCM subscription, and FCM accepted both a notice and a test push: there was no failure log, and a 4xx is logged.
  • Not verified: a notification actually appearing on a device. Headless Chrome does not display pushes, so this needs a phone over tailscale serve, and iOS needs a Home Screen install.
  • The tailscale serve Host behaviour is taken from reports, not tested here.

🤖 Generated with Claude Code

https://claude.ai/code/session_01K9Y6nr4J3kMp6GVQNUd9Ry

code0xff and others added 5 commits October 29, 2026 10:30
`nightcrow notify [MESSAGE]`, run inside a pane, sends the pane's
NIGHTCROW_PANE_TOKEN to the daemon as a one-shot request. The daemon asks
each repository's hub which live pane holds the token; that hub broadcasts
an `attention` frame, which now carries an optional message.

Hubs also publish these to a session-wide `Notices` registry, for listeners
not attached to the repository (Web Push, next). Entering a plugin's
`needs_attention` recovery state is published the same way, once per entry.

The web viewer decodes `attention` and shows the message as a toast. The
TUI keeps its project-tab marker and ignores the message.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K9Y6nr4J3kMp6GVQNUd9Ry
`web::viewer::push` listens to the session's `Notices` and sends each as a
Web Push message to every subscribed browser. One background thread
encrypts to the browser's key (RFC 8291 aes128gcm, checked against the
RFC's worked example), signs a VAPID token (RFC 8292 ES256) and posts over
https with ureq. Notices for one pane are coalesced for 10 seconds, and a
404/410 answer drops the subscription.

The key and subscriptions live owner-only in ~/.nightcrow/push. New
authenticated routes: GET /api/push/key and POST /api/push/{subscribe,
unsubscribe,status,test}. An endpoint must be https on a public host name;
there is no push-service allowlist, since anyone able to subscribe can
already run shell commands here.

Adds p256, hkdf, aes-gcm and base64.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K9Y6nr4J3kMp6GVQNUd9Ry
A loopback-bound viewer refuses any Host that is not a loopback name, to
stop DNS rebinding. A local HTTPS proxy such as `tailscale serve` forwards
the browser's host name (machine.tailnet.ts.net), so it was refused too,
leaving off-loopback binding as the only way to put HTTPS in front.

`[web_viewer] allowed_hosts` lists names the loopback check also accepts,
matched without the port and ignoring case. A listed name stays safe from
rebinding because the operator controls what it resolves to.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K9Y6nr4J3kMp6GVQNUd9Ry
scripts/visual/Dockerfile copies nothing from the checkout, which is
bind-mounted at run time, but `docker build .` still uploaded the whole
tree, including a multi-gigabyte target/. Ignore everything.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K9Y6nr4J3kMp6GVQNUd9Ry
A service worker (public/sw.js) shows each push and, when tapped, opens the
viewer with `notice-repo`/`notice-pane` in the address. The page applies
that once at startup: it remembers the pane as the project's last one and
puts the project in front before its first poll, so the usual attach
focus selects it. The worker caches nothing and answers no fetch.

The settings menu gains a Notifications section: a per-browser switch, a
test button, and the reason when push cannot be offered (not a secure
page, iOS outside a Home Screen app, permission blocked, no browser
support, or no daemon key). Permission is asked only from that switch. A
subscription the daemon has dropped is dropped in the browser on the next
read, so the switch does not claim "on" falsely.

docs/notifications.md covers enabling it, `tailscale serve` with
`allowed_hosts`, iOS, and why notifications may not arrive.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K9Y6nr4J3kMp6GVQNUd9Ry
@code0xff
code0xff force-pushed the feat/web-push-notifications branch from 1af5d67 to e008143 Compare October 29, 2026 01:31
@code0xff
code0xff merged commit d26fc71 into dev Oct 29, 2026
6 checks passed
@code0xff
code0xff deleted the feat/web-push-notifications branch October 29, 2026 01:43
@whackur whackur mentioned this pull request Oct 31, 2026
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