feat: Web Push notifications for command-finished events - #13
Merged
Conversation
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
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).
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.
Summary
Browsers / PWAs receive notifications even when the page is not open;
relay is the push origin — no third-party services.
internal/webpush/package: VAPID keypair auto-generated andpersisted to
<ATTERM_RELAY_CONFIG_DIR>/web-push.json, token-hashkeyed subscription registry, goroutine fan-out dispatch with 410 prune.
TypeCommandEvent (0x35)frame, direction uplink→ relay only, with manifest-based spoofing prevention. Four new HTTP
endpoints
/api/push/{key,subscribe,unsubscribe,test}under existingtoken auth.
BroadcastCommandFinishedWails binding; TerminalView firesit alongside the existing local OS notification (same three gates).
web/app-core.jsadds Web Push capability helpers;web/sw.jspush handler;
web/app.jsenable/disable flow + status-row button.iOS PWA supported (Add to Home Screen required).
atterm-relay:--config-dir/ATTERM_RELAY_CONFIG_DIR,--vapid-subject/ATTERM_VAPID_SUBJECT.docs/web-push.mduser guide, protocol spec entry, README capability.Test plan
web-push.jsonauto-generated;curl /api/push/keyreturns base64url VAPID public key