Files
hermes-agent/apps/desktop/electron/power-save.ts
John Paul Soliva 3acd949217 feat(desktop): keep-awake follows the turn — a "while working" mode alongside off/always
"Keep computer awake" was a boolean: off, or a machine-global power-save
blocker held for as long as Hermes Desktop is open. That is the wrong shape
for its own stated purpose. An unattended overnight run dies when the
machine sleeps unless the user predicted, before starting, that they would
need the toggle — and a user who did flip it leaves a laptop pinned awake
all week, including the idle hours after the run finished (#101991).

Main already knows, live, whether any turn is in flight:
`mergeActiveWork(activeWorkByWebContents.values()).count > 0` is the exact
expression that drives stream throttling. This change wires the blocker to
that same signal behind a three-way mode:

  off            never hold the blocker (unchanged default)
  while-working  hold it only while a turn is in flight; release at idle
  always         hold it whenever the app is open (what `on` used to mean)

Both persisted copies migrate in place. The renderer's localStorage boolean
(`hermes.desktop.keepAwake.v1`) and main's `keep-awake.json` `{ on }` are
read once and mapped `true -> always`, `false -> off`, so an existing user
sees exactly the behaviour they had. The IPC channel keeps its name and
accepts either the mode or the old boolean. An unrecognised saved value is
refused rather than trusted and falls back to `off`.

No new listener, timer, or IPC channel: `updateStreamThrottleFromActiveWork`
computes the merged picture once and hands it to both consumers, and the
cold-launch apply reads the mode instead of the flag. No trailing delay
between back-to-back turns — a sub-second release cannot let a machine
sleep, and `powerSaveBlocker` start/stop are cheap.

The policy is three pure functions in power-save.ts (parse, read, wanted)
so it is testable without the main process; main only wires them.

(cherry picked from commit c6dab931a2891ea7026eea2c07c271bcf48446b9)
2026-10-03 17:11:39 +05:30

95 lines
2.9 KiB
TypeScript

/**
* Keep-awake — hold a single machine-global power-save blocker.
*
* `prevent-app-suspension` stops the system from sleeping (long overnight
* agent runs keep going) while still letting the display dim. The renderer
* owns the preference (persisted in localStorage) and mirrors it here over
* IPC; the main process owns the one native blocker, same authority split as
* translucency/zoom. Electron auto-releases the blocker on quit.
*/
export type KeepAwakeType = 'prevent-app-suspension' | 'prevent-display-sleep'
/** The slice of Electron's `powerSaveBlocker` we use (injected for testing). */
export interface PowerSaveBlockerLike {
start(type: KeepAwakeType): number
stop(id: number): void
isStarted(id: number): boolean
}
export interface KeepAwake {
/** Turn the blocker on/off (idempotent). Returns the resulting state. */
set(on: boolean): boolean
isActive(): boolean
}
export function createKeepAwake(
blocker: PowerSaveBlockerLike,
type: KeepAwakeType = 'prevent-app-suspension'
): KeepAwake {
let id: null | number = null
const isActive = () => id !== null && blocker.isStarted(id)
return {
isActive,
set(on) {
if (on && !isActive()) {
id = blocker.start(type)
} else if (!on && id !== null) {
if (blocker.isStarted(id)) {
blocker.stop(id)
}
id = null
}
return isActive()
}
}
}
/**
* How the blocker follows the user: never, only while a turn is in flight, or
* around the clock. 'while-working' is the mode the module doc-comment always
* described — overnight runs survive without the user predicting, before they
* start, that they will need it, and without a laptop pinned awake all week.
*/
export type KeepAwakeMode = 'always' | 'off' | 'while-working'
const KEEP_AWAKE_MODES: readonly KeepAwakeMode[] = ['off', 'while-working', 'always']
/**
* Coerce a wire or persisted value to a mode. The pre-mode toggle sent and
* stored a boolean; `true` meant what 'always' means now, `false` meant 'off'.
*/
export function parseKeepAwakeMode(value: unknown): KeepAwakeMode | null {
if (value === true) {
return 'always'
}
if (value === false) {
return 'off'
}
return typeof value === 'string' && (KEEP_AWAKE_MODES as readonly string[]).includes(value)
? (value as KeepAwakeMode)
: null
}
/** The main process's persisted copy: the `{ mode }` shape, or the legacy `{ on }`. */
export function readKeepAwakeMode(persisted: unknown): KeepAwakeMode {
if (typeof persisted !== 'object' || persisted === null) {
return 'off'
}
const record = persisted as Record<string, unknown>
return parseKeepAwakeMode(record.mode) ?? parseKeepAwakeMode(record.on) ?? 'off'
}
/** Whether the blocker should be held right now, given the mode and the live turn picture. */
export function keepAwakeWanted(mode: KeepAwakeMode, working: boolean): boolean {
return mode === 'always' || (mode === 'while-working' && working)
}