🤖 feat: keep screen awake while agents are working - #4360
Conversation
Add an opt-in "Keep screen awake while agents are working" setting
(Settings → General → System, plus a "Toggle Keep Screen Awake" palette
command). While enabled and any local workspace is busy — streaming, or
waiting on an armed background bash monitor or an unfinished workflow run —
the Electron main process holds exactly one
`powerSaveBlocker.start("prevent-display-sleep")` blocker and releases it as
soon as every workspace is idle, the setting is turned off, or the app quits.
- `keepScreenAwake` persisted in ~/.xum/config.json (absent = off; turning
the toggle off removes the key), exposed via `config.getConfig` /
`config.updateKeepScreenAwake`.
- `isWorkspaceActivityBusy` (src/common/utils/workspaceActivity.ts) is the
shared busy predicate matching the sidebar's "working" notion.
- `KeepAwakeController` (src/desktop/keepAwake.ts) subscribes to
WorkspaceService "activity" events and config changes, seeds from
`getActivityList()` with live events winning over the older snapshot,
ignores goal-only activity pushes, and isolates listener failures so they
never propagate into the WorkspaceService emit path. Injected blocker keeps
it unit-testable without Electron.
- Default off; server mode persists the flag but nothing consumes it, and
remote-backend windows are not tracked.
---
_Generated with `xum` • Model: `coder:anthropic/claude-fable-5-1` • Thinking: `xhigh` • Cost: `$5.40`_
<!-- mux-attribution: model=coder:anthropic/claude-fable-5-1 thinking=xhigh costs=5.40 -->
Restore the last confirmed value when a keep-awake config write fails. Keep rapid writes ordered and ignore stale failures so a previous request cannot overwrite the user's latest selection. Add regression coverage for both initial values and overlapping writes with successful and failed predecessors. --- _Generated with `xum` • Model: `coder:openai/gpt-6-astra` • Thinking: `high` • Cost: `$23.21`_ <!-- mux-attribution: model=coder:openai/gpt-6-astra thinking=high costs=23.21 -->
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 335ca5a361
ℹ️ About Codex in GitHub
Your team has set up Codex to 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 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
- Do not block desktop window creation on the initial activity seed; live events already reconcile while it is in flight, and dispose is safe mid-seed. - Settings switch follows keep-awake changes made outside the section (e.g. the palette command), without overriding an in-flight local save. - Stop promising idle-lock suppression; Electron only blocks display/system sleep. - Correct the busy-predicate doc: ask_user_question pauses still count as busy. --- _Generated with `xum` • Model: `anthropic:claude-opus-5-5` • Thinking: `high` • Cost: `$87.98`_ <!-- mux-attribution: model=anthropic:claude-opus-5-5 thinking=high costs=87.98 -->
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: acdd745839
ℹ️ About Codex in GitHub
Your team has set up Codex to 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 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
🛡️ Codex Security Review · Automatically triggeredSecurity review completed. No security issues were found in this pull request. Reviewed commit: Only the user who started this review can view the report in Codex. ℹ️ About Codex security reviews in GitHubThis is an experimental Codex feature. Security reviews are triggered when:
Once complete, Codex will leave suggestions, or a comment if no findings are found. |
A config change that arrives while a Settings-originated keep-awake save is in flight is now deferred and replayed once the last save settles, so a later value accepted by the backend (e.g. from the palette command) is not lost. --- _Generated with `xum` • Model: `anthropic:claude-opus-5-5` • Thinking: `high` • Cost: `$87.98`_ <!-- mux-attribution: model=anthropic:claude-opus-5-5 thinking=high costs=87.98 -->
|
@codex review |
|
Codex Review: Didn't find any major issues. Already looking forward to the next diff. Reviewed commit: ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
If Codex has suggestions, it will comment; otherwise it will react with 👍. Codex can also answer questions or update the PR. Try commenting "@codex address that feedback". |
🛡️ Codex Security Review · Automatically triggeredSecurity review completed. No security issues were found in this pull request. Reviewed commit: Only the user who started this review can view the report in Codex. ℹ️ About Codex security reviews in GitHubThis is an experimental Codex feature. Security reviews are triggered when:
Once complete, Codex will leave suggestions, or a comment if no findings are found. |
|
Review record for
|
Summary
Adds an opt-in Keep screen awake while agents are working setting (Settings → General → System, default off). While it is on and any local workspace is busy (streaming, a background bash monitor, or an active workflow run), the Electron main process holds a single
powerSaveBlockerprevent-display-sleepblocker. It releases the blocker as soon as every workspace is idle, the setting is turned off, or the app quits.Background
Long agent runs can let the display sleep or trigger the idle screen lock. This follows Codex Desktop's "prevent sleep while running" behaviour instead of an always-on toggle.
Implementation
src/desktop/keepAwake.ts:KeepAwakeController. Desktop startup does not wait for its initial activity seed. It is driven by events from workspaceactivityand config changes, with no timers. It holds exactly one blocker ID and asserts thatisStartedis true after acquiring. It subscribes before seeding fromgetActivityList(), so live events received during seeding take precedence over the older snapshot. Electronmain.tsowns it: startup failures are logged and never crash the app, andbefore-quitdisposes it.src/common/utils/workspaceActivity.ts: busy predicate (stream, bash monitor, or workflow run) used by the keep-awake controller.HeartbeatServiceis unchanged.keepScreenAwakeis written to disk only whentrueand removed when it is false. Addsconfig.updateKeepScreenAwake.config.onConfigChanged, so it stays in sync when the palette command runs while Settings is open.Only local backend activity counts. Remote-connection windows and
xum servermode persist the flag, but nothing reads it there.Validation
Tests for the controller cover deduplication, toggling mid-stream, the seed race, and dispose. Also tested: router config round-trip, Settings hydration, persistence, and rollback, and the palette command.
make static-checkpasses on this head.All 968
srctest files were run once each, using the CI partition frompr.yml(15 isolated files and 953 in a shared process): 19,921 passed and 0 failed.Known pre-existing failure:
tests/ui/storybook/budget.test.ts(run only by localmake test-unit, not CI) fails on this branch, on its base, and on currentmain. It counts 115 enabled story files and 605 estimated snapshots against hardcoded limits of 79 and 305. This PR adds no stories, so that failure is out of scope here.Remote dogfood UAT on this exact SHA ran through Coder Agents, with real Electron on Xvfb and a loopback fixture model (no paid calls). These scenarios passed:
The Settings row renders correctly at desktop width and at 375px. A real
xfce4-power-manageracceptedorg.freedesktop.PowerManagement.InhibitandUnInhibit, and each call balanced 1:1 with the app log.Limits: the headless container cannot show that a physical display stays on or that DPMS or the idle lock is prevented. No owner for
org.freedesktop.ScreenSaverororg.gnome.SessionManagercould be obtained, so those routes were probed but not exercised. macOS and Windows were not tested.Risks
Low. The feature is off by default. Watch for a leaked blocker, which would keep the display awake. The controller releases on every idle, toggle-off, and quit path, and the tests and UAT cover those paths.
Follow-ups (deferred from Codex review)
ask_user_question: a stream paused on a question still counts as busy, per the accepted plan's definition of busy. Releasing while the agent waits for input needs a new backend activity signal.isWorkspaceActivityBusycomment wrongly says heartbeats share the predicate (they don't). Correct it in the next change touching this file.XUM_ALLOW_MULTIPLE_INSTANCES:Config.onConfigChangedis process-local (pre-existing), so a toggle made in one instance reaches another only on that instance's next activity edge. This needs a cross-process config watcher.activeWorkflowRunCount(pre-existing shared accounting, also used by the sidebar and heartbeats). The fix belongs in the workflow subsystem.📋 Implementation Plan
Keep screen awake while agents are working
Result
Add an opt-in "Keep screen awake while agents are working" toggle (Settings → General). While it is on and any local workspace is busy, the Electron main process holds a
powerSaveBlocker.start("prevent-display-sleep")blocker; it is released as soon as every workspace is idle. Semantics match Codex Desktop's Prevent sleep while running (chosen over Claude Desktop's always-on toggle). Default: off.Net product code: ~200 LoC (+ ~150 LoC tests).
Behaviour contract
busy(workspace)=activity.streaming || activeBashMonitorCount > 0 || activeWorkflowRunCount > 0— the existing definition used byHeartbeatService(src/node/services/heartbeatService.tsonActivity). Sub-agent tasks are workspaces with their own streams, so they are covered automatically.config.keepScreenAwake === true && busySet.size > 0. Exactly one blocker id at a time; never leak or double-start.activityevents (start/stop/clear), config changes (toggle on mid-stream acquires immediately; toggle off releases immediately), app quit (release).prevent-display-sleepimpliesprevent-app-suspension(Electron docs), so system idle-sleep is blocked too. macOS → IOPMPreventUserIdleDisplaySleep; Windows →SetThreadExecutionState(ES_DISPLAY_REQUIRED|ES_SYSTEM_REQUIRED); Linux → D-Busorg.freedesktop.ScreenSaver.Inhibit/org.gnome.SessionManager.Inhibit.Architecture
Backend services already run in-process in Electron main (
src/desktop/main.ts:753–770loadServices()), so main can subscribe directly — no new IPC. The controller lives insrc/desktop/(Electron-only; server mode has no display to keep awake) and receivespowerSaveBlockerby injection so it is unit-testable likedesktopWindowManager.test.ts.Implementation steps
1. Persist the setting (backend) → verify:
router.test.tsround-trip passesMirror
llmDebugLogsend-to-end:src/common/config/schemas/appConfigOnDisk.ts(~L156)keepScreenAwake: z.boolean().optional()with a comment: holds a display-sleep blocker only while agents are working.src/common/types/project.ts(~L95)keepScreenAwake?: booleansrc/node/config/index.tsparseOptionalBoolean), serialize (~L2150, write only whentrue, delete when false likechatTranscriptFullWidthL2597–2600),getConfigview (~L2572keepScreenAwake: config.keepScreenAwake === true),updateKeepScreenAwake(enabled)viaeditConfig(~L2606), gettergetKeepScreenAwakeEnabled(): boolean(~L3012).src/common/orpc/schemas/api.tskeepScreenAwake: z.boolean()ingetConfigoutput (~L2641);updateKeepScreenAwake: booleanToggleRoute(~L2776).src/node/orpc/router.ts(~L467)updateKeepScreenAwakehandler →context.config.updateKeepScreenAwake(input.enabled).src/node/orpc/router.test.ts(~L336)2. Shared busy predicate → verify:
heartbeatServicetests still greensrc/common/utils/workspaceActivity.ts:isWorkspaceActivityBusy(activity: WorkspaceActivitySnapshot | null | undefined): boolean(type fromsrc/common/types/workspace.ts:52).src/node/services/heartbeatService.tsonActivitywith the helper (DRY; 3-line swap, no behaviour change).3.
KeepAwakeController(src/desktop/keepAwake.ts, new) → verify:keepAwake.test.tsgreenonActivity({ workspaceId, activity }):isWorkspaceActivityBusy(activity) ? busy.add(id) : busy.delete(id), thenreconcile().activity === null(workspace removed) clears the id.getActivityList(); ids already touched by a live event during the await win over the (older) snapshot, so a stream that ended mid-seed cannot pin the blocker.reconcile():want = isEnabled() && busy.size > 0. Acquire:assert(blockerId === null),id = blocker.start("prevent-display-sleep"),assert(blocker.isStarted(id)),log.debug("keep-awake: acquired display-sleep blocker"). Release:blocker.stop(id),blockerId = null,log.debug("keep-awake: released ..."). Idempotent when state already matches.4. Wire into Electron main (
src/desktop/main.ts) → verify: typecheck + manual dogfood log linespowerSaveBlockerfrom"electron"(existing import block ~L67–80); module-levellet keepAwake: KeepAwakeController | null = null.loadServices()right afterservices.updateService.onStatus(...)(~L771): construct withstores.config.getKeepScreenAwakeEnabled,stores.config.onConfigChanged,services.workspaceService,powerSaveBlocker;await keepAwake.start()insidetry/catch+log.error(startup must never crash).app.on("before-quit")handler (~L1402), afterremoteConnectionManager?.dispose():keepAwake?.dispose(); keepAwake = null;.5. Settings UI (
src/browser/features/Settings/Sections/GeneralSection.tsx) → verify:GeneralSection.test.tsxgreen, Storybook renders<h3>block "System" between "Terminal" (~L843) and "Archiving" (~L991), same markup as the "API Debug Logs" row (L1097–1109):<Switch aria-label="Toggle keep screen awake while agents are working" />useState(false); hydrate fromapi.config.getConfig().keepScreenAwakein the existing config-load effect (~L335–368); handler mirrorshandleLlmDebugLogsChange(~L475–502): optimistic set,api.config.updateKeepScreenAwake({ enabled }), revert on failure.isDesktopMode()gate) so tests/stories need nowindow.apistub; the description states the desktop scope.src/browser/stories/mocks/orpc.ts: addkeepScreenAwakeoption/state (~L172, 426, 578) to thegetConfigpayload (~L824) and anupdateKeepScreenAwakehandler (~L925). Existing GeneralSection/SettingsPage stories pick it up; expect a Pixel visual diff on the General section only.GeneralSection.test.tsx: extendMockConfig/MockAPIClient(L27–48) andrenderGeneralSectionoptions; add "loads and persists the keep screen awake toggle" mirroring L609–625.6. Command palette entry → verify: palette shows "Toggle Keep Screen Awake"
src/browser/utils/commandIds.ts:settingsToggleKeepScreenAwake: () => "settings:toggle-keep-screen-awake" as const.src/browser/utils/commands/sources.ts(section.settings, pattern of the asyncupdateChannelaction L1566–1575):run: async () => { if (!p.api) return; const cfg = await p.api.config.getConfig(); await p.api.config.updateKeepScreenAwake({ enabled: !cfg.keepScreenAwake }); }, keywords["awake", "sleep", "screen", "display", "lock", "power", "caffeinate"]. Satisfies the "every operation has a keyboard route" rule beyond the focusable Switch.Tests
src/desktop/keepAwake.test.ts(new, bun; fake blocker recordsstart/stop, fakeEventEmitteractivity source, mutableenabledflag + manual config-changed emitter):start; ws B bash monitor → still one; A idle → held; B idle → onestop.activity: nullclears.start()seeds fromgetActivityList()containing a streaming workspace → acquires; event during seed overrides snapshot.dispose()releases and later activity events do not start a blocker.start()asserts on a blocker whoseisStartedis false (defensive path).router.test.ts: round-trip (step 1).GeneralSection.test.tsx: hydrate + persist (step 5).Acceptance criteria
make static-checkandmake test-unitpass; new tests above pass.powerSaveBlocker.start(log shows nokeep-awake:lines).keep-awake: acquiredlogged once even with two concurrent busy workspaces → all idle →keep-awake: releasedlogged once.released; toggle back on → immediateacquired.pmset -g assertionslistsPreventUserIdleDisplaySleepfor xum; Windowspowercfg /requestsDISPLAY; Linuxdbus-monitorshowsInhibit/UnInhibit), and gone after release/quit.~/.xum/config.jsonhas"keepScreenAwake": true; off state removes the key).Dogfooding
bun test src/desktop/keepAwake.test.ts src/node/orpc/router.test.tsthenbun test ./src/browser/features/Settings/Sections/GeneralSection.test.tsx; finallymake static-check.XUM_E2E=1 make dev-desktop-sandbox DEV_DESKTOP_SANDBOX_ARGS="--clean-providers --clean-projects"as a monitored background bash (filterVite|ready|Electron|ERROR|failed). Point the sandbox at the loopback OpenAI-compatible SSE fixture with a held first token (see project memoryxum-local-uat-fixtures) so a stream stays busy deterministically without paid calls.snapshot -i, toggle the switch, screenshot desktop + 375px widths;attach_fileboth. Verifyconfig.jsonin the sandboxXUM_ROOTgainskeepScreenAwake: true.agent-browser record start; with toggle on, send a prompt to the fixture workspace → confirmkeep-awake: acquiredin the Electron log (XUM_LOG_LEVEL=debug); on Linux rundbus-monitor --session "interface=org.freedesktop.ScreenSaver"(ororg.gnome.SessionManager) in parallel and capture theInhibitcall; release the held stream (or interrupt) →released+UnInhibit. Repeat with toggle off mid-stream.record stop; attach the video.Ctrl+Shift+P→ "Toggle Keep Screen Awake" flips the switch state in Settings (screenshot).Accepted trade-offs / limitations
RemoteConnectionManagerrun agents on the remote host and are not tracked; toggling the setting inside such a window edits the remote's config.xum server) persists the flag but nothing consumes it.keepScreenAwaketo an enum without a migration (absent key = off).Generated with
xum• Model:anthropic:claude-opus-5-5• Thinking:high• Cost:$85.22