Design: push notifications — “Needs you” and “milestone” pushes, with reply
Status: blocking pushes + replies implemented; milestone pushes remain planned.
For the current operational contract and end-to-end phone setup, see iPhone: Live Mode + notifications . That guide covers Home Screen installation, screen lock, permissions, delivery tests, and the Apple VAPID contact requirement. Milestone sections below describe future work, not shipped behavior.
The problem
Every pane’s output already flows through an LLM that decides what matters. But the result only reaches you when you’re looking at the app. An agent hits a permission prompt at minute 2 of a 40-minute session and sits blocked for 38 minutes; the tests you kicked off finish and nobody tells you. The phone should tap you on the shoulder — when your attention is genuinely needed, or when something you care about completed — and let you answer right from the notification.
The failure mode to design against is the opposite one: a push for every parse. A notification channel that cries wolf gets muted within a day and the feature is dead. Almost all of this design is about not sending notifications.
What already exists (most of the intelligence)
The importance judgment is already in the pipeline; it just isn’t routed anywhere:
parser_prompt.txtinstructs headlines to be written “like a mobile push notification the user will REACT to” — the copy already exists, per parse.waiting_on: "user" | "external"(#65) is precisely the “needs you” bit: an input affordance the human must fill, vs. background work the agent spawned.question: {prompt, options}is a ready-made notification body plus the reply affordance — options become action buttons.POST /api/panes/{id}/sendis the reply path: audited, and it already forces a re-parse so the answered question clears promptly.- The watcher already detects the relevant transitions (
_deck_fp,_question_prompt) to wake the long-poll — the same change-detection a notifier needs.
So this is a routing + transport problem. No second classifier, no notification LLM pass.
Two kinds of notification, two different mechanisms
Blocking: derived, not asked for
An agent blocked on the user is already fully described by existing fields:
activity == "waiting" and waiting_on == "user". The daemon derives the
notification server-side — zero prompt change, zero new schema — with title from
the pane title (falling back to its label), body from question.prompt (falling back to headline), actions
from question.options.
Not every user-wait carries a question: classify() also promotes a detected
rewind picker to waiting_on: "user" with no question object. Those still notify —
they’re real user-waits — but body falls back to the headline and there are no
action buttons; a tap deep-links into the card. Actions exist only when there’s a
structured question to answer.
Alternative considered and rejected: a dedicated notify: {urgency, title, body}
object emitted by the parser (as sketched in #139). Rejected because it duplicates
copy the model already writes (headline, question, events) — two phrasings of the
same thing that can disagree — and because for the blocking case the decision is
already structural. Asking the model to re-decide it just adds a place to be wrong.
Also rejected: a numeric importance: 0–10 + threshold. LLM scores are poorly
calibrated, drift across prompt edits, and there’s no ground truth to tune a
threshold against. A score also can’t express why — buckets defined by example can.
Milestone: one boolean on events
“Major task done” genuinely needs fuzzy judgment — tests passing after a long red
streak matters; the 14th file edit doesn’t. The one schema addition is an optional
flag on the existing event object: "milestone": true on an event worth interrupting
a person for (a build/test run finishing with a verdict, a PR merged, a deploy
completing, an error the agent can’t recover from). The prompt defines the bucket by
example and — like copyables — says explicitly that omitting it is the correct
answer far more often than not.
This rides the existing event machinery for free: events are already deduplicated at the source (the model sees what it already reported), already written as standalone notification-feed lines, and already timestamped in the burst ring. The notification body is the event text — single-sourced, no drift.
The dedup interaction is deliberate: the notifier consumes events at append time
from live parses only — bootstrap-seeded events are excluded. Session bootstrap
reconstructs history from scrollback and prepends it to the log (it already flags
these entries); a daemon restart must repopulate the feed, not buzz the phone about
milestones that happened an hour ago. The flag therefore only matters on the live
parse that first reports the event. One accepted edge: bootstrap also feeds its
texts into the model’s already-reported list, so a genuine live re-run of a
bootstrap-seen event inside RECENT_EVENT_TTL won’t re-emit and can miss a
milestone — the same TTL-bounded suppression the activity feed already accepts,
and not worth a special case. If the model
re-emits the same text later with the flag, the text-keyed duplicate guard drops it
before the notifier sees it — there is no “upgrade to milestone” path. Marking
happens when the event is written or not at all, which is also the only behavior the
prompt asks for.
Cadence: the actual hard problem
The watcher re-parses on every real content change. Suppression lives server-side in a small notifier that observes state transitions — never in the LLM, and never in the service worker (by the time a push arrives, the decision must already be made).
1. Notify even while someone is watching. A visible browser window or recent tmux input does not suppress pushes. An open desktop tab must not silence the phone, and working in one pane does not mean the user has seen another pane’s question. Existing presence endpoints remain compatible with clients, but presence does not gate delivery.
2. Once per question, not once per tick. A blocked pane stays blocked across
dozens of parses. The notifier keys on a fingerprint of (pane incarnation, full
answer contract) — the pane’s birth pid (the watcher already tracks it, because
tmux recycles %N ids) plus a hash of question.prompt, options, and
answer_style (or the headline, for questionless waits like rewind). The full
contract matters for the reply path below: options can reorder or the style can flip
while the prompt text stays identical, and an action minted against the old contract
must not validate against the new one. The notifier pushes at most once per
fingerprint. This is deliberately
level-triggered with a notified-set, not edge-triggered: a question that appears
while parsing is temporarily stale and remains unanswered should fire when valid
state resumes. Pure edge-triggering misses that case; the
notified-set makes “fire once, whenever conditions first allow” fall out naturally.
Fingerprints are forgotten when the question clears, so a re-blocked pane can
notify again.
3. Milestones will coalesce; blocking never waits. A blocking push goes out
immediately (modulo a short settle delay, below). Milestone events buffer ~60s and
merge: three panes finishing inside a minute is one “3 tasks finished” push, not
three buzzes. Same shape as _maybe_summarize’s burst collapsing. This milestone
path remains planned and is not part of the blocking-push implementation.
4. Settle before pushing. Agent screens flap — a menu flashes, redraws, and the next parse sees it differently (#64 lists the same traps for Live Mode’s completion detection). A blocking push waits ~5s of the fingerprint holding stable before sending. This also absorbs the answered-instantly case: user was at the terminal, answered within seconds, no push.
5. Rate cap as runaway insurance. At most one milestone push per pane per 5 minutes — and blocking gets its own emergency ceiling (first push immediate, then at most ~3 per pane per 15 minutes). Blocking can’t be exempt from all caps: a prompt regression that rephrases a persistent question on every parse mints a fresh fingerprint each time, and the notified-set would wave every one through. No single pane may be able to buzz a pocket continuously, whatever the model does.
Transport: Web Push, sent by the daemon
The app is already a proper installable PWA (manifest fetched with
use-credentials, apple metas) — the precondition for iOS Web Push (16.4+, installed
to home screen) is cleared. The daemon sends via pywebpush with a VAPID keypair.
No third-party service holds content beyond the platform push relays themselves
(payloads are encrypted end-to-end to the browser; FCM/APNs see ciphertext).
Alternative considered: ntfy / Pushover — one plain HTTP POST from the daemon, no service worker at all. Simpler, but: content transits a third party in cleartext (pane text includes code and secrets-adjacent material), replies land in their app instead of ours, and there’s no deep link back to the pane card. Kept as the possible future fallback transport — the notifier’s decision layer is transport-agnostic, and if iOS Web Push proves too fragile (see Risks), swapping the sender is a small, isolated change.
The service-worker wrinkle
web/sw.js handles push and notificationclick with no fetch handler and no
asset caching. Its one cache entry is the last “Chat needs you” tap, a small record
the page reads back because iOS can lose the worker’s message to a page it is waking
(see live-mode.md); it holds no asset and nothing is ever served from it. This replaced the old self-destructing tombstone, which existed
because a caching worker once served stale app.js and hid new UI. Asset requests
still go to the network; activation clears old caches. web/push.js explicitly
registers the worker and subscribes through its pushManager. The worker file is
served no-store so update checks see the current version. Keep this push-only:
adding asset caching would reintroduce the stale-UI failure mode.
Subscriptions and keys
Push subscriptions must survive a daemon restart — push exists precisely for when no
client is open to re-register, so “re-POST on next app load” can’t be the only copy.
This is a narrow, justified exception to the no-persistence rule (see the
activity-log design): a subscription is a client credential, config-like (peer of
.env), not observed pane state — losing it doesn’t thin out history, it severs the
channel. One small JSON file in the XDG state dir (~/.local/state/tmux-rc/push.json —
not in the checkout, where a credential-bearing file sits one git add from a
commit, and which doesn’t exist when running installed as a wheel) — owner-only
(0600) and written atomically (temp file + rename), since it holds credentials,
not observations — holds the VAPID keypair (generated on
first use; it must stay stable, since subscriptions bind to the public key), the
subscription list. The VAPID sub contact claim comes from TMUXRC_PUSH_SUBJECT at
send time, defaulting to mailto:tmux-rc@openbus.io. Operators may override this
contact; it is independent of the tunnel URL. The old localhost default caused
Apple 403 BadJwtToken in field testing. Clients still re-POST their subscription at boot as
self-healing — the store upserts keyed by endpoint, so a re-register is idempotent,
never a duplicate delivery — and prunes endpoints on 410 Gone.
POST /api/push/subscribe is audited like /send — and, like /send, it inherits
whatever front end the operator put in front of the daemon rather than authenticating
anything itself (see Deploying
). Registering a subscription is
therefore exactly as reachable as typing into a pane already is: it adds no new
exposure, but it is not a permission check either. It is paired with an unsubscribe
(the client calls it when the user flips the bell off or permission is revoked) plus
a revoke-all — a lost or replaced device must not keep receiving pane
content until the platform happens to return 404 or 410. It’s single-user,
multi-device: every stored subscription gets every push. The store retains at most
16 validated browser subscriptions, evicting the oldest when that cap is crossed, so
registration abuse cannot grow the state file or fan-out without bound.
Egress
Push endpoints (fcm.googleapis.com, web.push.apple.com) would hit the same
broken-IPv6 serial-connect hang the Gemini websocket did — but the daemon’s
socket.getaddrinfo IPv4-first sort is process-wide, so pywebpush/requests
inherit it for free. That sort doesn’t bound a request that connects and then
stalls, though: the sender is a synchronous requests call, so it runs off the
watcher and request hot paths (its own worker thread, fed by a queue) with
explicit connect/read timeouts. An FCM/APNs outage must degrade to dropped pushes —
never to stalled parsing or presence updates (blocked-event-loop slowness is already
a known failure class in this daemon). The queue is small and bounded. A full queue
rejects the new enqueue without evicting already-accepted work; the notifier leaves
that wait unmarked and can retry while it remains current. Relay failures after
dequeue are not retried: a notification that couldn’t send for minutes is stale, and the right failure mode
for this feature is silence, not a burst of old buzzes when the outage clears —
the push-service TTLs bound staleness on their side for the ones that did leave.
Blocking pushes go with Urgency: high and a short TTL (~10 min — a stale question
has probably been answered from another surface). Their tag is block:<pane_id>, so
a newer question on the same pane replaces the older one. Milestone urgency, TTL, and
tag behavior remain part of the planned milestone phase.
Replying from the notification
- Options (
question.optionspresent): the first two renderable options become action buttons (Web Push caps actions at two on most platforms; tapping the body opens the card for the rest). “Renderable” means the same pseudo-option filtering the card already applies (“Other”, “type something…”) — the push must never offer an action the card wouldn’t, and indices are minted against that same filtered list, which is also what the answer-contract fingerprint hashes, so filtering can’t skew index→keystroke mapping. iOS does not render action buttons at all — there, every tap deep-links into the app. Accept the asymmetry rather than fighting it. - Free text: deep link with the composer focused — on every platform. (The Web
Notifications API has no inline text-input field; Android’s RemoteInput is a
native-notification feature that
showNotificationcannot express, so there is no web-push inline reply to offer.) - Deep link:
/m#pane=<id>handled at boot in the mobile app (selects that pane’s card). Free-text waits addcompose=1; cursor questions deliberately do not.
An action tap is send-keys from the lock screen, so the reply path is narrower
than /send: the SW posts {nonce, option_index} to a dedicated
/api/push/answer. Three server-side gates before anything is typed:
- The nonce is one-shot, and it binds the offered actions. Each action-bearing push carries a random nonce the daemon minted and remembers — along with exactly which option indices that push offered. Answering consumes the nonce atomically, and an index outside the minted set is rejected: the SW is not a trust boundary, so a valid nonce must not be able to select an option the notification never showed. Nonce lifetime is tied to the question occurrence, not the contract: when the wait clears (the notified-set forgets the fingerprint), its nonces are invalidated too — otherwise an unconsumed nonce from an old push could answer a later re-occurrence of the byte-identical question it wasn’t minted for. Fingerprint validation alone can’t prevent a double-tap or replayed push from injecting the option twice — both taps can arrive before the forced re-parse clears the question. (The card UI has the same hazard and disables its option buttons once an answer is in flight; the nonce is the server-side equivalent.)
- The fingerprint must still match. The nonce binds to the full answer-contract fingerprint above; if the pane’s current question has changed in prompt, options, order, or style — or the pane id was recycled to a new pane — the tap is rejected rather than typed into whatever is there now. Validation checks the pane’s live pid (not just the cached parse) before injecting; what remains is the sub-second window between the last parse and the keystroke, which the card’s own option taps share — a forced re-parse-then-send would still race the screen, so we accept the same residual window the existing answer path already has rather than pretend a second read closes it.
- Any attempted pane input invalidates the action conservatively. The nonce also binds to a per-pane input generation. Ordinary sends, clicks, composers, image pastes, and Live input advance it before competing for the pane lock; the push path checks it again only after taking that same lock. A delayed action therefore cannot race an answer from another surface and submit into the next prompt.
- The daemon owns the option→keystroke mapping.
answer_stylematters:"menu"options are on-screen widgets answered with a keystroke (the row’s digit), not their label text — typing the label into a numbered menu selects nothing or the wrong thing. The card does this mapping inapp.jstoday;/api/push/answermirrors those exact semantics server-side (a later cleanup can point the card at the same endpoint so the mapping has one home). The SW never sends strings at all — only the nonce and an index.
The endpoint audits with a distinguishable actor (push-action) through the same
_audit path as /send.
Build order
- Signal only. Add the
milestoneflag to the prompt; log every would-be notification decision (blocking derivations included) into the digest/telemetry. Send nothing. Decision logs carry pane-derived text (headline, question, event text), so they ride the existingTMUXRC_QSDEBUGcontent gate in telemetry — the decision fields (kind, pane, suppression reason) export always; the prose only when content export is already enabled. Live with it for a day or two and read the log against what you’d actually have wanted buzzing your pocket — this is the only honest way to calibrate the fuzzy bucket, and it’s free. The prompt change ships with eval cases: milestone-positive and milestone-negative samples in the existing prompt-eval corpus (research/eval/samples/), and a full eval run green — that’s a requirement of this step, not a someday-before-step-4. - Blocking pushes. Real SW, subscribe endpoint, VAPID sender, fingerprint + settle suppression. If only this ships, most of the value is captured: “an agent is waiting on you” is the high-signal, low-noise case.
- Replies. Action buttons →
/api/push/answer; deep link. - Milestones with coalescing. Last, because it’s where the noise lives — tune it with step 1’s data.
Risks
- iOS Web Push is the fragile leg. Permission is only grantable inside the
installed PWA from a user gesture; the subscription silently dies if the user
removes/reinstalls the app icon; SW registration behind an authenticating front end
can fail in obscure ways (the manifest already needs
use-credentialsfor exactly this reason, and each tunnel in Deploying gets there differently). Time-box it: if step 2 fights for more than a day, switch the sender to the ntfy/Pushover fallback and revisit. Step 1’s decision layer carries over unchanged either way. - Calibration is the whole game. Step 1 exists so the milestone bucket is tuned on real data, not vibes. Don’t skip it.
- Prompt regressions become pocket buzzes. A parser-prompt edit that inflates
waiting_on: "user"or milestone flags now has a physical blast radius. The rate caps (including the blocking ceiling) bound the damage; the eval cases required in step 1 catch it before it ships.
Related: #139 (the ask), #65 (waiting_on — the “needs you” signal), #64 (Live
Mode’s completion notify — same settle/never-completes traps, different consumer),
#46 (high-churn panes — the same panes that would spam milestones).