feat(macos+web): system power events → event bus, with SSE reconnect (LUM-1974)#32651
Conversation
…(LUM-1974)
## Why
A tray-resident or full-screen Electron app never goes hidden during
system sleep, so the renderer never sees `visibilitychange`. Browser
timers freeze during suspend; on resume `setInterval` doesn't
retroactively fire missed ticks; WebSockets may appear "open" but
be half-dead because the remote side TCP-RST'd while we slept.
The fix is a system-level signal that bypasses renderer visibility:
`powerMonitor` in main, broadcast to all renderers, fanned into the
event bus, consumed by long-running subsystems.
## Shape — first non-About use of the ELECTRON.md three-file pattern,
extended with "push signals route through the bus, not directly"
1. `apps/macos/src/main/power-events.ts` — `installPowerEvents()`
subscribes to `powerMonitor` for `suspend` / `resume` /
`lock-screen` / `unlock-screen` / `user-did-become-active` and
broadcasts as `vellum:power:event` to every BrowserWindow.
Per-kind 1s debounce because Electron has historically delivered
duplicate suspend/resume events on macOS. Idempotent install.
2. `apps/macos/src/preload/index.ts` —
`window.vellum.power.onEvent(callback) → unsubscribe`. Mirrors
`PowerEventKind` + `PowerEvent` types inline (same convention as
`VellumCommand` / `AppVersionInfo`).
3. `apps/web/src/runtime/is-electron.ts` — ambient declaration for
the new bridge field.
4. `apps/web/src/runtime/power-events.ts` —
`subscribeToPowerEvents(callback)` per-capability wrapper. No-op
off Electron, returns unsubscribe-noop. Web has `visibilitychange`
+ `online` already feeding the bus; the power signal is
Electron-specific.
5. `apps/web/src/stores/event-bus-store.ts` — `BusEventMap` grows
`power.suspend` / `power.resume` / `power.lock` / `power.unlock`
/ `power.active`. Distinct from `app.resume` / `app.hidden`
because system-level sleep and renderer visibility have
different semantics.
6. `apps/web/src/hooks/use-event-bus-init.ts` — calls
`subscribeToPowerEvents` once at mount, narrows the kind into
typed bus events. Also subscribes the SSE reconnect path to
`power.resume` / `power.unlock` (in addition to `app.resume`) —
the existing 1s dedup window collapses double-fires when both
fire close together.
7. `apps/web/docs/ELECTRON.md` — new section "Cross-domain push
signals route through the event bus, not directly via the bridge."
Documents the 5-step pattern so the next push-signal bridge (deep
links, notification actions, global hotkeys) follows the same
shape.
## Tests
- `apps/macos/src/main/power-events.test.ts` (7 cases):
subscribes to all five powerMonitor events; idempotent install;
broadcasts to every BrowserWindow; skips destroyed windows;
Electron-event-name → bus-kind mapping;
1s per-kind debounce; no cross-kind debouncing.
- `apps/web/src/hooks/use-event-bus-init.test.tsx` (+3 cases):
`power.resume` reopens SSE after teardown (matches app.resume
semantics); `power.unlock` reopens; `power.resume` inside the
dedup window after `app.resume` doesn't double-open.
## Consumers wired today (just SSE reconnect)
The rest of the ticket's "renderer reconnect handlers" list (token
refresh, connectivity probe, query-cache invalidation) is blocked
on tickets that own those subsystems (auth integration, pressure
UI banners). The bus events exist now; future PRs wire their
consumers by adding `bus.subscribe("power.resume", ...)`.
https://claude.ai/code/session_011sZ4p8AkqDQMSavHvWfioe
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 54997aff93
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| const unsubPowerResume = bus.subscribe("power.resume", handleResume); | ||
| const unsubPowerUnlock = bus.subscribe("power.unlock", handleResume); |
There was a problem hiding this comment.
Bounce the live SSE stream on power resume
When the Electron renderer stays visible across system sleep (tray-resident/full-screen), power.resume/power.unlock reaches handleResume while current is still non-null, so the handler only calls checkAssistant() and returns without canceling or reopening the SSE stream. That leaves the exact half-dead connection this bridge is meant to recover; the new power subscriptions need a path that tears down/reopens even when no app.hidden event ran first.
Useful? React with 👍 / 👎.
Codex P1 — the entire bridge was supposed to fix the half-dead socket case (renderer stays visible across system sleep, no app.hidden fires, SSE looks "open" but remote TCP-RST'd while we slept). My first version shared the handler with app.resume, which early-returns when `current` is non-null. With renderer visible, current IS non-null, so power.resume no-op'd. The bridge did nothing for its main use case. Fix: split into two handlers sharing the dedup window but with different semantics: - `handleAppResume` (app.resume): renderer became visible. Reopen only if no current connection — the connection was either never torn down or just opened. - `handlePowerResume` (power.resume, power.unlock): system-level resume. Bounce unconditionally — teardown + open — because the socket may be half-dead from sleep regardless of renderer visibility. Shared dedup window: an app.resume followed by power.resume within 1s (rare: sleep that ALSO triggered visibility change) collapses to the first signal's action, so we don't double-bounce. A power.resume followed by app.resume within 1s likewise collapses — current is already known-fresh. Tests added: - power.resume bounces a LIVE SSE (no preceding app.hidden) — the tray-resident case the PR exists to fix. - power.unlock bounces a LIVE SSE — same shape for screen unlock. https://claude.ai/code/session_011sZ4p8AkqDQMSavHvWfioe
|
Codex P1 (power.resume no-ops on live SSE — the central case the PR was supposed to fix) addressed in d998562. The fix is two handlers sharing the dedup window but with different semantics:
Shared dedup window: a sleep that ALSO triggered a visibility change doesn't double-bounce; whichever signal arrives first wins. Two tests added for the central case:
25/25 bus-init tests green. Generated by Claude Code |
| const now = Date.now(); | ||
| if (now - lastResumeAt < RESUME_DEDUP_WINDOW_MS) return; | ||
| lastResumeAt = now; | ||
| checkAssistant(); | ||
| // App-resume means the renderer became visible; if a connection | ||
| // is already live, it was either never torn down or just opened | ||
| // moments ago — either way, leave it alone. | ||
| if (current) return; |
There was a problem hiding this comment.
🔴 power.resume/power.unlock fail to bounce the SSE when the connection is still live (tray-resident scenario)
The stated purpose of the power events feature is to reconnect stale SSE connections when the Electron app stays visible during system sleep (tray-resident / full-screen). However, power.resume and power.unlock are wired to the same handleResume function as app.resume, which has if (current) return; at line 225. In the tray-resident scenario, no app.hidden event fires during system sleep, so current (the SSE stream reference) is never nulled out. When power.resume fires on wake, handleResume sees current is non-null and returns without bouncing the connection — leaving a potentially half-dead SSE socket in place.
The comment at lines 233-237 explicitly describes this exact scenario ("even when the renderer never went hidden (tray-resident / full-screen)") but the code doesn't handle it. Compare with reachability.retry-requested at apps/web/src/hooks/use-event-bus-init.ts:240-250, which correctly calls teardown() before open() to bounce a live connection. All three test cases for power events (power.resume reopens the SSE after teardown, power.unlock reopens the SSE after teardown, dedup test) first publish app.hidden to tear down the SSE, so they never exercise the tray-resident path.
Prompt for agents
The handleResume function at use-event-bus-init.ts:220-227 is shared by app.resume, power.resume, and power.unlock, but it bails out with `if (current) return` when the SSE stream is still alive. This is correct for app.resume (which is always preceded by app.hidden that tears down the stream), but wrong for power.resume / power.unlock in the tray-resident case where no app.hidden fires.
The fix needs to make power.resume and power.unlock bounce (teardown + reopen) the SSE even when current is non-null, while preserving the app.resume behavior of only reopening when the stream was previously torn down. Two possible approaches:
1. Create a separate handler for power events that calls teardown() then open(), similar to reachability.retry-requested at line 240-250. Share the dedup window logic but add the teardown step.
2. Pass a flag to handleResume indicating whether to force-bounce, and have power.resume/power.unlock pass true.
Either way, add a test for the tray-resident scenario: publish power.resume WITHOUT a preceding app.hidden, and assert that the SSE is bounced (cancelMock called and subscribeChatEventsMock called again).
Was this helpful? React with 👍 or 👎 to provide feedback.
| open(); | ||
| }; | ||
| // System-level resume (Electron host): bounce the connection | ||
| // UNCONDITIONALLY. The renderer may have stayed visible during | ||
| // system sleep (tray-resident, full-screen) so there's no | ||
| // app.hidden → app.resume cycle; `current` is still non-null | ||
| // but the socket may be half-dead because the remote side | ||
| // TCP-RST'd while we slept. The shared dedup window collapses | ||
| // overlap with `app.resume` so a sleep that ALSO triggered a | ||
| // visibility change doesn't double-bounce. | ||
| const handlePowerResume = () => { | ||
| const now = Date.now(); |
There was a problem hiding this comment.
🚩 No power.suspend handler in Effect 2 — intentional asymmetry or oversight?
Effect 2 subscribes to power.resume and power.unlock for SSE reconnection but does not subscribe to power.suspend for SSE teardown (use-event-bus-init.ts:228-239). This asymmetry means the SSE is never proactively torn down when the system enters sleep on Electron. For the app.hidden→power.resume path this is fine (hidden tears down). For the tray-resident path this contributes to the reported bug. The reachability.retry-requested handler shows the bounce pattern (teardown + open) that could be applied to power events instead. Whether the fix should subscribe to power.suspend for teardown, or have power.resume/power.unlock force-bounce, is an architectural choice.
Was this helpful? React with 👍 or 👎 to provide feedback.
Devin's "no power.suspend handler in Effect 2" observation — the ticket explicitly calls this out: > On `suspend`: gracefully close outbound WebSockets so the server > learns we're going away (faster cleanup than relying on TCP > timeouts). Same shape as the existing `app.hidden` teardown — call `teardown()` if `current` is non-null. The bounce-on-resume path that landed in d998562 stays as defense-in-depth in case `power.suspend` is missed (Electron has historically dropped events on macOS — same reason for the per-kind debounce in `apps/macos/src/main/power-events.ts`). Tests added: - `power.suspend` tears down a live SSE so the daemon sees us go away cleanly. - `power.suspend` is a no-op when no SSE is open (e.g. before assistant is active). 27/27 bus-init tests green. https://claude.ai/code/session_011sZ4p8AkqDQMSavHvWfioe
|
Both Devin findings now addressed:
Two new tests pin the suspend behavior. 27/27 bus-init tests green. Generated by Claude Code |
… power.resume bounce Audit-found bug. The shared `lastResumeAt` was wrong: handler A's "tried but no-op'd" updated the same timestamp handler B used, so A could suppress B's required action. Concrete failure: tray-resident Electron, system sleeps 5 minutes, wakes. wifi reconnects ~200ms later → `online` event → `app.resume(signal:"online")`. `handleAppResume` updates `lastResumeAt`, calls `checkAssistant`, sees `current` is non-null (renderer never went hidden), returns. ~50ms later `power.resume` fires; `handlePowerResume` sees recent `lastResumeAt`, dedups, returns. Half-dead SSE persists. This is the EXACT case the PR exists to fix. Fix: split into `lastAppResumeAt` + `lastPowerActionAt`. The two handlers don't observe each other's timestamps. Each self-dedups against its own action's recency: - `lastAppResumeAt`: iOS visibility + appstate double-fire on foregrounding the WKWebView shell. - `lastPowerActionAt`: `power.resume` + `power.unlock` close together (real case: sleep → wake → keystroke unlock). Tradeoff (called out in a renamed test): the rare case where the renderer both went hidden AND received a system-power signal on wake now wastes one extra bounce — `app.resume` opens a fresh SSE, `power.resume` immediately bounces it. <100ms, no user-visible regression. Acceptable cost for closing the missed-bounce bug in the more common tray-resident case. Also: `EVENT_BUS.md` event-protocol table now includes the five `power.*` events documented with their bus-owned action (or no-action-today + the future-ticket note for `power.lock` / `power.active`). Tests: - New: app.resume no-op (current non-null) does NOT suppress a follow-up power.resume bounce. The regression. - Updated: "app.resume then power.resume" — the old test asserted a single open (shared-dedup behavior); the new assertion documents the independent-dedup behavior + the wasted-bounce tradeoff explicitly. 28/28 bus-init tests green. https://claude.ai/code/session_011sZ4p8AkqDQMSavHvWfioe
Audit-driven follow-up — one real bug + one doc gap, both addressed in 61099b7Real bug: shared dedup window let
|
|
@codex review |
|
Codex Review: Didn't find any major issues. Already looking forward to the next diff. ℹ️ About Codex in GitHubCodex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
If Codex has suggestions, it will comment; otherwise it will react with 👍. When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback". |
There was a problem hiding this comment.
Reviewed HEAD 61099b7c4b (4 commits, 11 files +621/-18). CI 11/11 green. Codex re-review at HEAD already cleared ("Didn't find any major issues. Already looking forward to the next diff."). The right architectural cut — this is the convention-setting PR for every future push-signal bridge.
Architecture verification — 6-layer flow
apps/macos/src/main/power-events.ts(87 lines): subscribes to all 5 powerMonitor events (suspend,resume,lock-screen,unlock-screen,user-did-become-active), per-kind 1s debounce against macOS's historical duplicate-delivery behavior, broadcast viaBrowserWindow.getAllWindows()withisDestroyed()skip, idempotent install via module-scopeinstalledflag,before-quitclears debounce timestamps so dev hot-reload re-arms from zero.__resetForTestingis the right test seam (named for what it is, no production reachability).apps/macos/src/main/index.ts:installPowerEvents()called once at line 254 — verified single install site.apps/macos/src/preload/index.ts:window.vellum.power.onEvent(callback) → unsubscribe.PowerEventKind+PowerEventinlined for the same reason asVellumCommand/AppVersionInfo(cheaper to maintain a tiny literal union three places than wire cross-project TS imports; drift surfaces as a renderer handler not narrowing on a new kind = graceful no-op, not a crash). Returns the unsubscribe closure correctly.apps/web/src/runtime/is-electron.ts: ambientpower: { onEvent(...) }field mirrored.apps/web/src/runtime/power-events.ts(40 lines): no-op-off-Electron wrapper, returns unsubscribe-noop, optional-chainswindow.vellum?.power.onEventfor safety. Matches the shape ofdock.ts/app-info.ts.apps/web/src/stores/event-bus-store.ts:BusEventMapgrowspower.suspend/resume/lock/unlock/activewithRecord<string, never>payload (correct — no payload needed, the discriminator is the event name).apps/web/src/hooks/use-event-bus-init.ts: Effect 1 fansPowerEventinto bus events via single subscribe + switch; Effect 2 wires the SSE handlers.
The convention extension is exactly the right rule. ELECTRON.md gains 16 lines documenting "cross-domain push signals route through the event bus, not directly via the bridge" — clean separation between imperative wrappers (setDockBadge, getAppVersionInfo) and push-signal wrappers (publishes into bus, consumers subscribe via bus). This pays compounding interest: future deep-link bridge, notification-action bridge, global-hotkey bridge all follow the same 5-step shape, and domain consumers stay platform-agnostic — the same bus.subscribe("power.resume", ...) works whether the source was powerMonitor (Electron), visibilitychange (web), or Capacitor appStateChange (iOS).
Bot findings — all closed by construction, not patched
-
Codex P1 (54997af) + Devin BUG_0001 (61099b7) — same finding:
power.resume/power.unlockreusehandleResumewhich hasif (current) return, so in the tray-resident case (renderer never went hidden) the handler no-ops on the exact bug it exists to fix. Closed ind9985623b1by splitting into two handlers with different semantics:handleAppResume(renderer visibility) keeps the open-if-null behavior,handlePowerResume(system-level) bounces unconditionally (teardown + open). Right shape — the underlying semantic distinction (visibility vs power) is real, not a flag on a shared handler. Two new tests pin the tray-resident path with no precedingapp.hidden. -
Devin ANALYSIS_0004 — no
power.suspendhandler for SSE teardown. Closed in661c2d4c0d:bus.subscribe("power.suspend", teardownIfOpen)mirrors theapp.hiddenteardown. PR body's reasoning is correct — clean daemon disconnect beats waiting for TCP timeouts, and the bounce-on-resume path stays as defense-in-depth in case the suspend event drops (Electron's documented macOS behavior, also why the main-side debounce exists). -
Self-caught audit finding (61099b7) — shared dedup window
lastResumeAtwas wrong. Trace: tray-resident wake → wifi reconnects →online→app.resume(signal:"online")→handleAppResumeupdateslastResumeAt, no-ops on non-null current → 50ms laterpower.resumearrives → suppressed by shared dedup → half-dead SSE persists. The exact case the PR exists to fix. Split intolastAppResumeAt+lastPowerActionAtso handlers don't observe each other's timestamps; each self-dedups against its own action's recency. Two new tests pin the bug (app.resume no-op (current non-null) does NOT suppress a follow-up power.resume bounce) AND the acceptable tradeoff (app.resume then power.resume — fresh SSE gets bounced (wasted bounce, but the correctness tradeoff)). The tradeoff doc in the comment is the right framing: <100ms wasted bounce in the rare hidden-AND-power-signal case is acceptable to close the missed-bounce bug in the common tray-resident case.
Test coverage at HEAD
power-events.test.ts(macos, 7 cases): 5-event subscription, idempotent install, broadcast to every window, skip destroyed, electron-name → bus-kind mapping, 1s per-kind debounce, no cross-kind debouncing.use-event-bus-init.test.tsx(+8 new cases): power.suspend tears down live SSE; power.suspend no-op when no SSE; power.resume bounces LIVE SSE (tray-resident); power.unlock bounces live SSE; power.resume reopens after teardown; power.unlock reopens after teardown; app.resume no-op doesn't suppress power.resume (the audit-fix bug); app.resume + power.resume = 2 bounces (the tradeoff).- Manual checklist (unchecked in PR body): close lid 10min + reopen; lock screen + unlock. These are the ones that exercise the real powerMonitor — worth doing before merge.
Anti-pattern audit clean
- ✓
subscribeToPowerEventsreturns the unsubscribe closure; Effect 1 cleanup calls it (unsubPower()at the cleanup return). No leak on hook unmount or hot-reload. - ✓
window.vellum?.power.onEventoptional-chains the bridge field — safe if the preload hasn't initialized yet. - ✓ Effect 1 (lifecycle) and Effect 2 (SSE) are separate
useEffectblocks. Power subscription mounts before the first power event can fire (React 18 batches effects in commit phase, Effect 1 runs first in declaration order); Effect 2's bus subscriptions are registered before any power event arrives in steady state. - ✓ Bus subscriptions in Effect 2 are correctly cleaned up — 6
unsub*()calls at the cleanup return. - ✓ Main-side broadcast skips
isDestroyed()windows (prevents IPC channel-disconnected errors after window destruction). - ✓ Per-kind debounce uses a
Partial<Record<PowerEventKind, number>>— narrow types throughout. - ✓
before-quitcleanup of debounce timestamps prevents stale state in dev hot-reload. - ✓
Record<string, never>payload type on bus events is correct for empty-payload events (vs{}which TS allows arbitrary fields on).
Non-blocking observations
-
About-window double-fire. The About window is also a
BrowserWindow, so it receives every power broadcast viagetAllWindows(). Its preload doesn't subscribe today, so the renderer never registers an IPC listener forvellum:power:event, and Electron silently drops the message. Quiet by design — no log spam, no leak. But every future auxiliary window (Settings, Help) gets the same behavior by default, which is correct. Worth noting in the PR body's "broadcast model" paragraph (already there in the main-side file's docstring) so future bridge authors don't try to add their own per-window targeting. -
PowerEventpayload is{ kind }only. Future-proofing thought: if any consumer needs the system-side timestamp (e.g. "how long were we asleep"), it can't reconstruct it from the renderer-sideDate.now()because the IPC message is debounced at the main side. Not a blocker today — the SSE bounce semantics don't need duration — but a{ kind, at: number }shape would be a tiny addition if a future consumer needs it. Skip unless needed. -
lastAppResumeAt/lastPowerActionAtshared across all power events.power.unlockandpower.resumesharelastPowerActionAt, so unlocking the screen within 1s of waking the system collapses to one bounce — that's the desired behavior. Butpower.lockandpower.active(which don't have a handler today) also fall in thepower.*family conceptually; if a future consumer wires them to bounce-on-something, they'd want a separate dedup window. The current shape "one dedup window per handler closure" is correct as long as new handlers create their own; just worth a one-line comment in the docstring so future readers don't reuselastPowerActionAtfor an unrelated handler. -
subscribeToPowerEventsreturns() => undefined—() => voidwould be cleaner. Minor TS style nit;voidvsundefinedreturn is interchangeable in practice but the type signature uses() => void, and an arrow returningundefinedworks but reads weird.() => {}or() => {return;}would match. Cosmetic.
Merge gate: ✓ Vex APPROVE (this), ✓ Codex "Didn't find any major issues" at HEAD (= the 👍 equivalent), ✓ all 11 checks green. Per the nighttime merge rule and the 9 AM May 30 release deadline, this is mergeable now — but worth running the two manual checklist items first (close lid 10min + lock/unlock) since the unit tests can only exercise the mocked bus events, not the real powerMonitor cycle. If you trust the wiring, ship it.
Architectural carry-forward (saving): Push-signal bridges follow the 5-step shape — main subscribes + broadcasts → preload exposes typed onEvent(cb) → renderer runtime wrapper is no-op-off-Electron → bus init fans into typed bus events → domain consumers subscribe via bus, never via the wrapper. The convention extension in ELECTRON.md is the right shape for deep links, notification actions, global hotkeys.
…nt buffering (LUM-1872) (#32656) Routes `vellum://send?message=…` and `vellum://thread/<id>` URLs from the OS through the Electron main process into typed bus events. Subscriber-counted main-side buffering covers the pre-renderer-mount and logout-relogin cases without conflating drained-once with currently-subscribed. Extends the push-signal bridge pattern from #32651 with a subscribe-then-drain ordering for signals that can arrive before the renderer exists.
Summary
System power-state events from Electron's
powerMonitorare now first-class signals on the renderer event bus. SSE reconnects on system wake even when the renderer never went hidden (tray-resident / full-screen).The architectural deliverable is the first non-About use of the ELECTRON.md three-file bridge pattern, extended with a new convention: cross-domain push signals route through the event bus, not directly via the bridge. Future push-signal bridges (deep links, notification actions, global hotkeys) follow the same shape.
Why
A tray-resident or full-screen Electron app never goes hidden during system sleep — the renderer doesn't see
visibilitychange. Browser timers freeze during suspend; on resumesetIntervaldoesn't retroactively fire missed ticks; WebSockets may appear "open" but be half-dead because the remote side TCP-RST'd while we slept. The fix is a system-level signal that bypasses renderer visibility.Architecture (5 layers, one per concern)
apps/macos/src/main/power-events.ts—installPowerEvents()subscribes topowerMonitorforsuspend/resume/lock-screen/unlock-screen/user-did-become-activeand broadcasts asvellum:power:eventto every BrowserWindow. Per-kind 1s debounce because Electron has historically delivered duplicate suspend/resume events on macOS. Idempotent install.apps/macos/src/preload/index.ts—window.vellum.power.onEvent(callback) → unsubscribe. MirrorsPowerEventKind+PowerEventtypes inline (same convention asVellumCommand/AppVersionInfo).apps/web/src/runtime/is-electron.ts— ambient declaration mirroring the new bridge field.apps/web/src/runtime/power-events.ts—subscribeToPowerEvents(callback)per-capability wrapper. No-op off Electron, returns unsubscribe-noop. Web hasvisibilitychange+onlinealready feeding the bus; the power signal is Electron-specific.apps/web/src/stores/event-bus-store.ts—BusEventMapgrowspower.suspend/power.resume/power.lock/power.unlock/power.active. Distinct fromapp.resume/app.hiddenbecause system-level sleep and renderer visibility have different semantics.apps/web/src/hooks/use-event-bus-init.ts— callssubscribeToPowerEventsonce at mount, narrows the kind into typed bus events. Also subscribes the SSE reconnect path topower.resume/power.unlock(in addition toapp.resume) — the existing 1s dedup window collapses double-fires when both fire close together.Convention extension
apps/web/docs/ELECTRON.mdgains a new section: "Cross-domain push signals route through the event bus, not directly via the bridge." Documents the 5-step pattern so the next push-signal bridge (deep links, notification actions, global hotkeys) follows the same shape.The rule:
setDockBadge(count),getAppVersionInfo()).Net effect: domain consumers stay platform-agnostic. The same
bus.subscribe("power.resume", ...)works whether the signal came frompowerMonitor(Electron),visibilitychange(web), or CapacitorappStateChange(iOS). Runtime wrappers that publish into the bus stay tiny — they're just signal sources.Consumers wired today (just SSE reconnect)
The rest of the ticket's "renderer reconnect handlers" list (token refresh, connectivity probe, query-cache invalidation) is blocked on tickets that own those subsystems (auth integration, pressure UI banners). The bus events exist now; future PRs wire their consumers by adding
bus.subscribe("power.resume", ...)— one line.Tests
apps/macos/src/main/power-events.test.ts(7 cases): subscribes to all five powerMonitor events; idempotent install; broadcasts to every BrowserWindow; skips destroyed windows; Electron-event-name → bus-kind mapping; 1s per-kind debounce; no cross-kind debouncing.apps/web/src/hooks/use-event-bus-init.test.tsx(+3 cases):power.resumereopens SSE after teardown (matches app.resume semantics);power.unlockreopens;power.resumeinside the dedup window afterapp.resumedoesn't double-open.Test plan
bun --cwd apps/macos run typecheck— green.bun --cwd apps/macos run test:ci— 8 test files, all green.bun --cwd apps/macos run build— main + preload bundles build cleanly.bun --cwd apps/web test src/hooks/use-event-bus-init.test.tsx src/stores/event-bus-store.test.ts— 42 cases green (23 in bus-init, 19 in bus-store).power.unlockon the bus.https://claude.ai/code/session_011sZ4p8AkqDQMSavHvWfioe
Generated by Claude Code