Skip to content

feat: Web Push notifications for command-finished events - #13

Merged
attson merged 24 commits into
mainfrom
feat/web-push-notifications
May 15, 2026
Merged

feat: Web Push notifications for command-finished events#13
attson merged 24 commits into
mainfrom
feat/web-push-notifications

Conversation

@attson

@attson attson commented May 15, 2026

Copy link
Copy Markdown
Owner

Summary

  • New self-hosted Web Push delivery for OSC 133 command-finished events.
    Browsers / PWAs receive notifications even when the page is not open;
    relay is the push origin — no third-party services.
  • New internal/webpush/ package: VAPID keypair auto-generated and
    persisted to <ATTERM_RELAY_CONFIG_DIR>/web-push.json, token-hash
    keyed subscription registry, goroutine fan-out dispatch with 410 prune.
  • Relay protocol: new TypeCommandEvent (0x35) frame, direction uplink
    → relay only, with manifest-based spoofing prevention. Four new HTTP
    endpoints /api/push/{key,subscribe,unsubscribe,test} under existing
    token auth.
  • Desktop: BroadcastCommandFinished Wails binding; TerminalView fires
    it alongside the existing local OS notification (same three gates).
  • Web/PWA: web/app-core.js adds Web Push capability helpers; web/sw.js
    push handler; web/app.js enable/disable flow + status-row button.
    iOS PWA supported (Add to Home Screen required).
  • New flags on atterm-relay: --config-dir / ATTERM_RELAY_CONFIG_DIR,
    --vapid-subject / ATTERM_VAPID_SUBJECT.
  • Docs: docs/web-push.md user guide, protocol spec entry, README capability.

Test plan

  • Fresh relay: web-push.json auto-generated; curl /api/push/key returns base64url VAPID public key
  • Chrome desktop: 🔔 Enable → permission Allow → backend records subscribe
  • Chrome desktop: `sleep 12; ls` + blur window → notification "Command finished · exit 0 · 12s"
  • iOS Safari: Add to Home Screen → open PWA → 🔔 Enable → iOS permission Allow
  • iOS PWA: same long-command flow → lock-screen notification
  • Close the browser entirely → command finishes → push still arrives
  • 🔔 OFF → unsubscribe POST → next long command produces no notification
  • Restart relay (keep web-push.json) → existing subscriptions still receive
  • Focus the AT Term window → no local notification, no relay event, no push
  • Framework smoke (oh-my-zsh / starship): prompt normal + notifications work
  • Two browsers same token: both receive push
  • Cast-attached remote pane on a different desktop: no push fires there (local-session gate)

attson added 24 commits May 15, 2026 12:02
Self-hosted Web Push delivery for OSC 133 command-finished events,
building on the desktop-side shell integration shipped in v0.1.55.

Relay generates a VAPID keypair on first start and persists it alongside
browser subscriptions under RELAY_CONFIG_DIR. Desktop frontend reuses the
existing OSC 133 CommandTracker and gate logic; once a notification
passes the gate, the desktop emits a new TypeCommandEvent frame upstream
via uplink. Relay's webpush package resolves which token-hashes can view
the session and fans out to all registered subscriptions. Browser
service worker shows the notification via Push API.

MVP scope: only OSC 133 command-finished, token-scoped subscriptions,
desktop pre-gates (relay is a pure forwarder), no third-party push
services.
Export enablePushFlow / disablePushFlow from app.js so tests can assert
outcomes without DOM coupling. Guard all top-level browser-only side
effects behind _isBrowser so the module is safely importable in Node.js.
Render a push-toggle button into the status element when pushSupported().
setStatus was clobbering all children of #status via textContent
assignment, destroying the push-toggle button on every connect/
disconnect cycle. Wrap the initial text in a .status-text span at
_isBrowser init time; setStatus now targets only that span.

Add three tests for disablePushFlow (no-op, happy-path unsubscribe,
error-swallow) to bring coverage to 7 push-flow tests total.
WebPushSessionResolver now iterates ReadOnlyTokenHashes (stripping the
sha256: prefix) so admin-managed read-only tokens receive push delivery.
BroadcastCommandFinished acquires App.mu before reading a.uplink to
eliminate the data race with concurrent applyRelayConfig writes.
@attson
attson merged commit 2e6d8d4 into main May 15, 2026
5 checks passed
@attson
attson deleted the feat/web-push-notifications branch May 15, 2026 07:41
attson added a commit that referenced this pull request Jun 6, 2026
* docs: session attention/seen model spec + roadmap reconcile

Add the design spec for the session attention/seen model (seen/unread
inbox, type-aware notification de-noising, host rollup) — the
personal-use session-state UX track, inspired by herdr's
done-unseen / idle-seen split.

Also reconcile docs/roadmap.md with reality: P1 #6/#7/#9/#10 and all of
P2 (#11/#12/#13) shipped (#86#89, #97#99, #110/#111) but were never
checked off; #8 (signing) and #15 (presence) annotated as partial.

* docs: implementation plan — session attention backend (relay)

* proto: add attention_at/unread to SessionInfo, attention_at to MetaPayload

* session: add SubscriberCount() accessor

* session: bump attention_at on waiting_input and non-shell completion

* userstore: session_seen table + SetSeen/SeenAt/PruneSeenSession

* userstore: monotonic seen upsert + migration comment + error wrap

* relay: compute per-user Unread in the session list

sessionInfoListForOwner now takes a seen map[string]int64 and sets
info.Unread when AttentionAt > 0 && AttentionAt > seen[id] &&
SubscriberCount() == 0. writeSessionList, handleSessionsHTTP, and the
TypeList handler in client_conn.go all load SeenAt from the store
(when configured) before building the list. A nil seen map safely
treats every session as never-seen.

* relay: extract seenForOwner helper + log SeenAt errors + subscriber-suppression test

* relay: auto-mark-seen on client ATTACH

* relay: funnel session removal through removeSession + prune seen

* relay: POST /api/sessions/seen (mark all / mark ids read)

* relay: cap /api/sessions/seen request body size

* relay: suppress Web Push while a session is being watched

When a session has ≥1 subscriber (viewer attached), skip DispatchCommandFinished
and notifySession for Web Push — watching means the user is already looking.
Webhook dispatch is deliberately unchanged (machine-to-machine, not gated on
human attention). Tests added in notify_suppress_test.go: subscriber→0 pushes,
no subscriber→1 push; webhook fires in both cases.

* relay: tidy notify-suppress test (drop unused import + Phase 1 sleep)

* docs: desktop task state display spec (sidebar/tab/dialog + presets)

* docs: implementation plan — desktop task state display

* desktop/types: add unread + attention_at to RemoteSession

* desktop/i18n: add tasks.* namespace (en + zh-CN)

* desktop/i18n test: tighten tasks.* parity + cover settings.preset

* desktop/lib: taskState preset registry (Vivid + Quiet)

* desktop/lib/taskState test: assert literal palette colors

* desktop: Wails bindings for task preset, sidebar collapsed, mark-seen

Add GetTaskPreset/SetTaskPreset, GetTaskSidebarCollapsed/SetTaskSidebarCollapsed,
and MarkSessionsSeen Go↔TS bindings with frontend wrappers and passing tests.

* desktop: validate task preset on Set + bytes.NewReader + tighten binding type

- isSupportedTaskPreset helper in config.go (mirrors isSupportedTerminalTheme)
- SetTaskPreset rejects unknown presets with a descriptive error
- MarkSessionsSeen uses bytes.NewReader(body) instead of strings.NewReader(string(body))
- AppBindings.SetTaskPreset typed as PresetId (imported from taskState); wrapper updated to match

* desktop/composable: useTaskPreset (Wails-backed, html dataset, localStorage fallback)

* desktop/test: drop misleading dataset = undefined line in useTaskPreset test

* desktop/composable: useSessions (merge + derive byHost/unread/primary/fold)

* desktop/useSessions: document non-reactive primaryStateForHost contract

* desktop/component: TaskStateIcon (state+preset → glyph/color/animation)

* desktop/TaskStateIcon: comment CSS spinner placeholder + aria-hidden on glyph

* desktop/component: TaskGroupedList (shared host groups + rows + fold + emits)

* desktop/TaskGroupedList: row-mark-read a11y + fold-mark-all data-test

* desktop/component: TaskSidebar (expand/collapse + rail + mark-all)

* desktop/TaskSidebar: rail icon a11y + urgency order test

* desktop/component: SettingsTasks tab (preset radio + expand-by-default)

* desktop/TabBar: state icon + unread dot

Replace the static green dot with TaskStateIcon (uses task_state from
activeSession). Add an unread dot after the title text. Add unread?
optional field to SessionInfo in connection.ts.

* desktop/RemoteSessionsDialog: render via TaskGroupedList + mark-all-read

* desktop/SettingsDialog test: include Task display tab in nav assertions

* desktop/App: mount TaskSidebar + useSessions + Cmd/Ctrl+B hotkey + markSeen

* desktop/SettingsShortcuts: show Sidebar group + i18n

* desktop/useTaskPreset: drop dead html.dataset write (no CSS consumes it)
attson added a commit that referenced this pull request Aug 10, 2026
* feat: AI pet companion window as a plugin

A frameless, always-on-top companion window showing which sessions are
running, failed, or waiting for input, with one row per session. Click a
row to raise the main window and jump to that tab/pane.

Runs as a second process of the SAME executable (`AT Term --pet`) rather
than a separate binary: reusing the already-signed executable keeps the
CI artifact matrix, macOS notarization, and the signed-update
verification chain (red line #8) completely unchanged.

The pet process connects to nothing. The remote session list is a
separate WS stream whose contents may be E2EE-sealed, and red line #21
forbids account_key leaving the main process — so the main app pushes an
already-merged, already-unsealed projection over the child's stdin as
NDJSON, and the child reports user intent back on stdout. No port, no
auth, no credentials in the child. Remote sessions still show up,
because the list was merged before it was projected.

Shipped as a "companion-window" plugin so it gets the same Settings
toggle and persisted-config path as the others; PluginHost skips the
slot (nothing to mount in this window's tree) and usePetCompanion drives
the process lifecycle from the enabled flag.

Layout A only (expanded 252x172 / collapsed 252x54). Collapsing keeps
the header so a folded pet still answers "does anything need me?", and a
new waiting/failed session auto-peeks for 3s before folding back.

macOS needed three fixes Wails v2.12 gives no option for, all verified
by running the window:
  - setActivationPolicy:Accessory, dispatched to the main queue —
    OnStartup runs on a Go goroutine and AppKit ignores off-main-thread
    calls, and Wails hardcodes Regular in applicationWillFinishLaunching
  - setOpaque:NO + clearColor for real transparency; WindowIsTranslucent
    is not the fix, its NSVisualEffectView is what caused a white fringe
    outside the card's rounded corners
  - no CSS box-shadow: a non-opaque window gets a content-derived shadow

Tests: 27 for the projection (priority ordering, aggregate mood,
headline/subline wording, truncation, remote marking, id fallbacks), 11
for the process manager and window (NDJSON, dedupe, throttle, EOF
suicide, entry rewrite, and an assertion that the bootstrap payload
carries no credentials), 8 for the config block.

* fix: apply plugin config defaults on the first run

loadConfig() returned early when config.json could not be read, which is
exactly what happens on a first run — so applyDefaults() never executed
and the frontend was handed a zero-valued plugin block.

ValidatePluginConfig rejects panelWidthPx=0, so enabling ANY plugin in
Settings failed with:

  plugin config invalid: fileExplorer.panelWidthPx out of bounds [240, 2000]

The second launch reads the file and does apply defaults, which is what
made this look intermittent rather than a hard first-run failure.

Predates the pet plugin (the early return is unchanged since 347eae4);
the pet just made it easy to hit, because a dev build routes config to a
fresh project-local .atterm-dev directory.

Both tests fail with the old early-return restored, reporting that exact
error message.

* fix: size the pet window from the rendered card, and replay startup messages

Two defects, both found by measuring the live window rather than by eye.

Clipped card. The window height was hardcoded (54 collapsed / 172
expanded) while the real card measures 60 and 213 — so 45px and 41px
were cut off the bottom, taking the rounded corner and the last session
row with them. Any constant is wrong here, because the height varies
with row count, font and locale. The frontend now reports its measured
border-box height through a ResizeObserver and Go applies it, which
covers collapse, expand, peek and row-count changes with one mechanism;
Go only clamps against nonsense values. Peek and SetCollapsed no longer
resize anything themselves — the DOM change does it.

Dropped startup messages. Wails discards events emitted before the
webview subscribes, and the parent writes both the bootstrap line and
the first state snapshot immediately after spawn. The pet therefore
ignored the persisted collapsed preference and sat on the "连接中…"
placeholder until the next session-list change, which can be minutes.
Both are now parked in Go and replayed on a Ready() handshake the
frontend sends last in onMounted, state before bootstrap so the window
is already showing real content when it becomes visible.

Measured after the fix: 252x60 collapsed, 252x213 with four rows.

Also switches the default bottom-right placement off Screen.Width/Height
(deprecated, physical pixels on some platforms) to Screen.Size, which is
the logical space WindowSetPosition works in.

* fix: count idle sessions, and keep the peek open across the whole card

Idle was not a band. headlineFor checked waiting/failed/running/completed
and fell through to "没有会话" for anything else — but a shell sitting at
its prompt is `idle`, and it is the most common session state there is.
A window listing ten live sessions therefore announced that there were
none. The two header lines are now generated from one ordered band list
(waiting, failed, running, completed, idle): the first non-empty band
becomes the headline and the rest the subline, so every band is
accounted for exactly once and none can be silently dropped again.
Completed sessions are subtracted out of the idle count so a finished
session is never counted twice.

Peek closed under the pointer. @Mouseleave sat on the header, so moving
down onto a session row left the header and collapsed the list — the
rows it revealed could never actually be clicked. The hover region is
now the whole card, so the peek only ends when the pointer leaves the
window.

* feat: show session paths, add an AI-only filter, rename to Desk Widget

Rows now read title + path instead of title + command. A truncated
`claude --permission-mode by…` repeats what the title already said,
while the directory is what distinguishes one of five claude sessions
from the others. Uses the sidebar's shortenCwd so both surfaces elide
paths identically, and drops the second line entirely when the path
would only repeat the title (a shell whose OSC title is already "~").

Adds an AI-only filter (Settings → Plugins, or the widget's own context
menu). It runs before every count, so the header can never advertise
sessions the list does not show, and an empty filtered list says
"没有 AI 会话" rather than "没有会话" — the user may well have ten shells
open. Off by default: session state comes from OSC 133 and works for any
command, which is the whole reason this does not need per-agent hooks.

Renames the plugin from "AI 宠物" to 桌面挂件 / Desk Widget. It lists every
session, not just AI ones, so the old name was both inaccurate and
undersold the part that makes it different from hook-based tools. The
internal identifiers stay `pet` (PluginID, PetConfig, pet_*.go,
petState.ts, --pet) — a short code name that appears in no UI string.

* refactor: rename pet -> widget throughout the code

The plugin is user-facing as 桌面挂件 / Desk Widget, but every identifier
still said "pet". Leaving two vocabularies in place invites exactly the
misreading the rename was meant to prevent — that this is an AI-mascot
feature rather than a session widget that happens to have a face.

Renamed across Go, the frontend, the generated Wails bindings and the
spec:
  - files: desktop/pet_*.go -> widget_*.go, src/pet/ -> src/widget/,
    plugins/pet/ -> plugins/deskWidget/, lib/petState.ts ->
    widgetState.ts, usePetCompanion.ts -> useDeskWidget.ts,
    index.pet.html -> index.widget.html, main.pet.ts -> main.widget.ts
  - types: PetConfig/PetState/PetMood/PetBridge/petProcess/petEvent ->
    Widget*, projectPetState -> projectWidgetState
  - bindings: StartPet/StopPet/PushPetState -> Start/Stop/PushWidgetState
  - wire and config: CLI flag --pet -> --widget, Wails events pet:state /
    pet:bootstrap -> widget:*, log tag [pet] -> [widget], PluginID "pet"
    -> "desk-widget", config key plugins.pet -> plugins.widget
  - the spec file itself, plus its now-stale sections: hardcoded window
    sizes (heights are measured), the WidgetState shape (idleCount,
    aiOnly, overflowCount), and the peek hover region

Done with an explicit identifier map rather than a blanket pet->widget
substitution, which would have mangled "perpetually" and "competing".
The third-party project name claude-pet in the prior-art table is
deliberately untouched.

Verified end to end after the rename: `--widget` starts, [widget] logs,
widget:state renders four rows at a content-measured 252x213, EOF exits
0. go vet on darwin/windows/linux, ./desktop tests, and 1722 frontend
tests all pass.

* feat: pixel-cat sprite for the desk widget

Replaces the geometric blob with a 12x12 pixel cat. Same four moods,
same inline-SVG approach (red line #13 rules out emoji), but drawn as
crispEdges rects.

Pixel art needs different rules than the vector sprite it replaces, and
all three were violated by the old animations:

  - Size must be a multiple of 12. 40px gave 3.33 screen pixels per
    source pixel, so some columns rendered a pixel wider than others.
    Now 36 = exactly 3.
  - Translations must land on integer pixels and use steps() timing, so
    it reads as animation frames instead of a slide.
  - No scale() and no rotate() — both resample the grid. The previous
    idle breathe was scale(1.05) and the failed state was rotate(-5deg);
    either would have turned the cat to mush.

.sprite-wrap is now a fixed 36x36 box: the animations translate the svg
itself, and an auto-sized parent would drag the waiting-count badge
along with it.

Measured after the change: 252x85 collapsed, 252x209 with four rows —
the window follows the slightly shorter sprite on its own, because the
height comes from the ResizeObserver rather than a constant.

* fix: coalesce widget state pushes instead of dropping them

The throttle was leading-edge only: any push arriving within 200ms of
the previous one was discarded outright, on the reasoning that "the next
push carries the whole snapshot". That only holds if there is a next
push.

A command like `ls` starts and finishes inside a single window: it
pushes "running", then "completed" a few tens of ms later, and the
second was dropped. Nothing else changes after a command ends, so the
widget kept showing a finished command as running until some unrelated
session-list change happened to land outside a window.

The throttle now keeps the newest held-back payload in `pending` and
delivers it from a one-shot timer, so the last state always arrives
within one interval. Intermediates are still coalesced away — each
snapshot is complete, so replaying them would be wasted IPC and visible
flicker. Stop() cancels a queued flush; otherwise it would write into a
closed pipe.

Also renames the TestPet* functions the earlier rename missed: the
pattern used a trailing word boundary, which never matches inside
TestPetPushStateWritesNDJSON.

Both new tests fail against the old drop-on-throttle code, reporting the
terminal state as never delivered. go test -race passes, which matters
here because the flush timer contends with PushState for the mutex.

* docs: cover the desk widget in AGENTS.md, roadmap and the user guide

AGENTS.md gets red line #37 with the five invariants that each cost a
debugging round: same-binary subprocess, the child connecting to
nothing, coalescing (not dropping) the push throttle, frontend-measured
window height, and the macOS main-queue/setOpaque/WindowIsTranslucent
trio. Plus a "何时改哪里" row and the matching ❌ entries, including
"don't verify the window with screencapture" — that one produced a wrong
diagnosis during development.

The user guide describes it under 远程接管与会话侧栏 rather than under AI
Agent: it lists every session, not just AI ones, and that is the point.

Site tests still pass (27).
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