Skip to content

feat(macos+web): system power events → event bus, with SSE reconnect (LUM-1974)#32651

Merged
ashleeradka merged 4 commits into
mainfrom
claude/lum-1974-power-events
May 30, 2026
Merged

feat(macos+web): system power events → event bus, with SSE reconnect (LUM-1974)#32651
ashleeradka merged 4 commits into
mainfrom
claude/lum-1974-power-events

Conversation

@ashleeradka
Copy link
Copy Markdown
Contributor

Summary

System power-state events from Electron's powerMonitor are 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 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.

Architecture (5 layers, one per concern)

  1. apps/macos/src/main/power-events.tsinstallPowerEvents() 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.tswindow.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 mirroring the new bridge field.
  4. apps/web/src/runtime/power-events.tssubscribeToPowerEvents(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.tsBusEventMap 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.

Convention extension

apps/web/docs/ELECTRON.md gains 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:

  • Runtime wrapper is for imperative access (setDockBadge(count), getAppVersionInfo()).
  • For push signals that multiple renderer domains care about, the wrapper publishes into the bus, and consumers subscribe via the bus.

Net effect: domain consumers stay platform-agnostic. The same bus.subscribe("power.resume", ...) works whether the signal came from powerMonitor (Electron), visibilitychange (web), or Capacitor appStateChange (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.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.

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).
  • Manual: close the lid for 10 minutes and reopen → assistant stream reconnects without user action.
  • Manual: lock the screen via Cmd+Ctrl+Q and unlock → renderer logs show power.unlock on the bus.

https://claude.ai/code/session_011sZ4p8AkqDQMSavHvWfioe


Generated by Claude Code

…(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
@linear
Copy link
Copy Markdown

linear Bot commented May 30, 2026

LUM-1974

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment on lines +238 to +239
const unsubPowerResume = bus.subscribe("power.resume", handleResume);
const unsubPowerUnlock = bus.subscribe("power.unlock", handleResume);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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
Copy link
Copy Markdown
Contributor Author

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:

  • 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: a sleep that ALSO triggered a visibility change doesn't double-bounce; whichever signal arrives first wins.

Two tests added for the central case:

  • 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.

25/25 bus-init tests green.


Generated by Claude Code

Copy link
Copy Markdown
Contributor

@devin-ai-integration devin-ai-integration Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 2 potential issues.

View 2 additional findings in Devin Review.

Open in Devin Review

Comment on lines 220 to 227
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;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 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).
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines 228 to +239
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();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚩 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.hiddenpower.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.

Open in Devin Review

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
Copy link
Copy Markdown
Contributor Author

Both Devin findings now addressed:

  • BUG_0001 (power.resume doesn't bounce live SSE in tray-resident case) — this is the same finding as Codex P1, already closed in d998562 (your review was at 54997af, before the fix push). handlePowerResume now teardowns + reopens unconditionally; handleAppResume keeps the open-if-null semantics.

  • ANALYSIS_0004 (no power.suspend handler for SSE teardown) — closed in 661c2d4. power.suspend now teardowns the SSE the same way app.hidden does, so the daemon sees us go away cleanly instead of waiting for TCP timeouts. The bounce-on-resume path stays as defense-in-depth in case the suspend event is missed (Electron has historically dropped events on macOS, which is also why apps/macos/src/main/power-events.ts has per-kind debouncing).

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
Copy link
Copy Markdown
Contributor Author

Audit-driven follow-up — one real bug + one doc gap, both addressed in 61099b7

Real bug: shared dedup window let app.resume's no-op suppress a needed power.resume bounce

Trace the tray-resident wake path more carefully than my earlier commit did:

  1. System sleeps 5 min. Renderer stays visible (tray-resident / full-screen, no app.hidden).
  2. On wake, wifi reconnects ~200ms later → online event → bus publishes app.resume(signal: "online").
  3. handleAppResume runs: dedup check passes (first fire), updates the shared lastResumeAt, calls checkAssistant, sees current is non-null (we never went hidden), returns without action.
  4. ~50ms later power.resume reaches handlePowerResume.
  5. handlePowerResume checks lastResumeAt — recent — deduped, returns without bouncing.
  6. 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.

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 much more common tray-resident case.

Doc gap: EVENT_BUS.md event-protocol table didn't include power.*

Added all five entries with bus-owned action + the future-ticket note for power.lock / power.active (no consumer today).

Tests

  • New: app.resume no-op (current non-null) does NOT suppress a follow-up power.resume bounce. The regression case.
  • Updated: "app.resume then power.resume" — the old assertion documented the (buggy) shared-dedup behavior; the new assertion documents the independent-dedup behavior + the wasted-bounce tradeoff explicitly.

28/28 bus-init tests green.

Audit checklist coverage

Other items reviewed and clean: file location, runtime-wrapper shape (matches dock.ts / app-info.ts), IPC channel-per-family discriminator (matches PATTERNS.md), three-file bridge convention (per ELECTRON.md), Zustand integration, no barrel files, no internal-tracker refs. File size on use-event-bus-init.ts is approaching the ~300-line threshold but Effect 1 (sources) and Effect 2 (SSE policy) are still coherent — a "sources" / "SSE-policy" split is a separate refactor PR if it ever becomes useful.


Generated by Claude Code

@ashleeradka
Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector
Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Already looking forward to the next diff.

ℹ️ 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".

Copy link
Copy Markdown
Contributor

@vex-assistant-bot vex-assistant-bot Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

  1. 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 via BrowserWindow.getAllWindows() with isDestroyed() skip, idempotent install via module-scope installed flag, before-quit clears debounce timestamps so dev hot-reload re-arms from zero. __resetForTesting is the right test seam (named for what it is, no production reachability).
  2. apps/macos/src/main/index.ts: installPowerEvents() called once at line 254 — verified single install site.
  3. apps/macos/src/preload/index.ts: window.vellum.power.onEvent(callback) → unsubscribe. PowerEventKind + PowerEvent inlined for the same reason as VellumCommand / 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.
  4. apps/web/src/runtime/is-electron.ts: ambient power: { onEvent(...) } field mirrored.
  5. apps/web/src/runtime/power-events.ts (40 lines): no-op-off-Electron wrapper, returns unsubscribe-noop, optional-chains window.vellum?.power.onEvent for safety. Matches the shape of dock.ts / app-info.ts.
  6. apps/web/src/stores/event-bus-store.ts: BusEventMap grows power.suspend/resume/lock/unlock/active with Record<string, never> payload (correct — no payload needed, the discriminator is the event name).
  7. apps/web/src/hooks/use-event-bus-init.ts: Effect 1 fans PowerEvent into 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

  1. Codex P1 (54997af) + Devin BUG_0001 (61099b7) — same finding: power.resume / power.unlock reuse handleResume which has if (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 in d9985623b1 by 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 preceding app.hidden.

  2. Devin ANALYSIS_0004 — no power.suspend handler for SSE teardown. Closed in 661c2d4c0d: bus.subscribe("power.suspend", teardownIfOpen) mirrors the app.hidden teardown. 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).

  3. Self-caught audit finding (61099b7) — shared dedup window lastResumeAt was wrong. Trace: tray-resident wake → wifi reconnects → onlineapp.resume(signal:"online")handleAppResume updates lastResumeAt, no-ops on non-null current → 50ms later power.resume arrives → suppressed by shared dedup → half-dead SSE persists. The exact case the PR exists to fix. Split into lastAppResumeAt + lastPowerActionAt so 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

  • subscribeToPowerEvents returns the unsubscribe closure; Effect 1 cleanup calls it (unsubPower() at the cleanup return). No leak on hook unmount or hot-reload.
  • window.vellum?.power.onEvent optional-chains the bridge field — safe if the preload hasn't initialized yet.
  • ✓ Effect 1 (lifecycle) and Effect 2 (SSE) are separate useEffect blocks. 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-quit cleanup 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

  1. About-window double-fire. The About window is also a BrowserWindow, so it receives every power broadcast via getAllWindows(). Its preload doesn't subscribe today, so the renderer never registers an IPC listener for vellum: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.

  2. PowerEvent payload 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-side Date.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.

  3. lastAppResumeAt / lastPowerActionAt shared across all power events. power.unlock and power.resume share lastPowerActionAt, so unlocking the screen within 1s of waking the system collapses to one bounce — that's the desired behavior. But power.lock and power.active (which don't have a handler today) also fall in the power.* 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 reuse lastPowerActionAt for an unrelated handler.

  4. subscribeToPowerEvents returns () => undefined() => void would be cleaner. Minor TS style nit; void vs undefined return is interchangeable in practice but the type signature uses () => void, and an arrow returning undefined works 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.

@ashleeradka ashleeradka merged commit 0cf7027 into main May 30, 2026
11 checks passed
@ashleeradka ashleeradka deleted the claude/lum-1974-power-events branch May 30, 2026 01:02
vex-assistant-bot Bot pushed a commit that referenced this pull request May 30, 2026
…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.
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.

2 participants