Keep computer awake while agents run (#1937)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Brennan Benson 2026-05-15 11:19:19 -07:00 committed by GitHub
parent cc0272e5c4
commit fd813e7639
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 901 additions and 6 deletions

View File

@ -0,0 +1,208 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { AgentAwakeService, AGENT_AWAKE_STATUS_STALE_AFTER_MS } from './agent-awake-service'
import type { AgentAwakeStatus } from './agent-awake-service'
vi.mock('electron', () => ({
powerSaveBlocker: {
start: vi.fn(),
stop: vi.fn(),
isStarted: vi.fn()
}
}))
function workingStatus(overrides: Partial<AgentAwakeStatus> = {}): AgentAwakeStatus {
return {
state: 'working',
receivedAt: 1_000,
observedInCurrentRuntime: true,
...overrides
}
}
function createBlocker() {
const startedIds = new Set<number>()
let nextId = 1
return {
start: vi.fn(() => {
const id = nextId++
startedIds.add(id)
return id
}),
stop: vi.fn((id: number) => {
startedIds.delete(id)
}),
isStarted: vi.fn((id: number) => startedIds.has(id)),
startedIds
}
}
function createService(now: () => number, blocker = createBlocker()): AgentAwakeService {
return new AgentAwakeService({
blocker,
now,
logger: {
debug: vi.fn(),
warn: vi.fn()
}
})
}
describe('AgentAwakeService', () => {
beforeEach(() => {
vi.useRealTimers()
})
it('does not start when disabled even with a running status', () => {
const blocker = createBlocker()
const service = createService(() => 1_000, blocker)
service.setStatuses([workingStatus()])
expect(blocker.start).not.toHaveBeenCalled()
})
it('starts one prevent-app-suspension blocker when enabled with a fresh working status', () => {
const blocker = createBlocker()
const service = createService(() => 1_000, blocker)
service.setEnabled(true)
service.setStatuses([workingStatus()])
expect(blocker.start).toHaveBeenCalledTimes(1)
expect(blocker.start).toHaveBeenCalledWith('prevent-app-suspension')
})
it('starts and stops from settings flips around an already-running status', () => {
const blocker = createBlocker()
const service = createService(() => 1_000, blocker)
service.setStatuses([workingStatus()])
service.setEnabled(true)
service.setEnabled(false)
expect(blocker.start).toHaveBeenCalledTimes(1)
expect(blocker.stop).toHaveBeenCalledWith(1)
})
it('ignores startup-hydrated working statuses that were not observed in this runtime', () => {
const blocker = createBlocker()
const service = createService(() => 1_000, blocker)
service.setEnabled(true)
service.setStatuses([workingStatus({ observedInCurrentRuntime: false })])
expect(blocker.start).not.toHaveBeenCalled()
})
it('does not start for blocked, waiting, or done statuses', () => {
const blocker = createBlocker()
const service = createService(() => 1_000, blocker)
service.setEnabled(true)
service.setStatuses([
workingStatus({ state: 'blocked' }),
workingStatus({ state: 'waiting' }),
workingStatus({ state: 'done' })
])
expect(blocker.start).not.toHaveBeenCalled()
})
it('does not start a second blocker when one working status replaces another', () => {
const blocker = createBlocker()
const service = createService(() => 1_000, blocker)
service.setEnabled(true)
service.setStatuses([workingStatus({ receivedAt: 1_000 })])
service.setStatuses([workingStatus({ receivedAt: 1_100 })])
expect(blocker.start).toHaveBeenCalledTimes(1)
})
it('stops when the last running status is dropped', () => {
const blocker = createBlocker()
const service = createService(() => 1_000, blocker)
service.setEnabled(true)
service.setStatuses([workingStatus()])
service.setStatuses([])
expect(blocker.stop).toHaveBeenCalledWith(1)
})
it('does not start for a stale working status', () => {
const blocker = createBlocker()
const service = createService(() => AGENT_AWAKE_STATUS_STALE_AFTER_MS + 1_001, blocker)
service.setEnabled(true)
service.setStatuses([workingStatus({ receivedAt: 1_000 })])
expect(blocker.start).not.toHaveBeenCalled()
})
it('stops when the only running status becomes stale without another event', () => {
vi.useFakeTimers()
let now = 1_000
const blocker = createBlocker()
const service = createService(() => now, blocker)
service.setEnabled(true)
service.setStatuses([workingStatus({ receivedAt: 1_000 })])
now = 1_000 + AGENT_AWAKE_STATUS_STALE_AFTER_MS + 1
vi.advanceTimersByTime(AGENT_AWAKE_STATUS_STALE_AFTER_MS)
expect(blocker.stop).toHaveBeenCalledWith(1)
service.dispose()
})
it('reschedules stale expiry for a newer running status', () => {
vi.useFakeTimers()
let now = 1_000
const blocker = createBlocker()
const service = createService(() => now, blocker)
service.setEnabled(true)
service.setStatuses([workingStatus({ receivedAt: 1_000 })])
now = 2_000
service.setStatuses([workingStatus({ receivedAt: 2_000 })])
now = 1_000 + AGENT_AWAKE_STATUS_STALE_AFTER_MS + 1
vi.advanceTimersByTime(AGENT_AWAKE_STATUS_STALE_AFTER_MS)
expect(blocker.stop).not.toHaveBeenCalled()
now = 2_000 + AGENT_AWAKE_STATUS_STALE_AFTER_MS + 1
vi.advanceTimersByTime(1_000)
expect(blocker.stop).toHaveBeenCalledWith(1)
service.dispose()
})
it('keeps the blocker id when stop fails and Electron reports it is still started', () => {
const blocker = createBlocker()
blocker.stop.mockImplementation(() => {
throw new Error('stop failed')
})
const service = createService(() => 1_000, blocker)
service.setEnabled(true)
service.setStatuses([workingStatus()])
service.setStatuses([])
service.setStatuses([])
expect(blocker.stop).toHaveBeenCalledTimes(2)
expect(blocker.stop).toHaveBeenCalledWith(1)
})
it('disposes by clearing timers and stopping an active blocker once', () => {
vi.useFakeTimers()
const blocker = createBlocker()
const service = createService(() => 1_000, blocker)
service.setEnabled(true)
service.setStatuses([workingStatus()])
service.dispose()
vi.advanceTimersByTime(AGENT_AWAKE_STATUS_STALE_AFTER_MS)
expect(blocker.stop).toHaveBeenCalledTimes(1)
expect(blocker.stop).toHaveBeenCalledWith(1)
})
})

View File

@ -0,0 +1,206 @@
import { powerSaveBlocker } from 'electron'
import type { AgentStatusState } from '../shared/agent-status-types'
export const AGENT_AWAKE_STATUS_STALE_AFTER_MS = 2 * 60 * 60 * 1000
export type AgentAwakeStatus = {
state: AgentStatusState
receivedAt: number
observedInCurrentRuntime: boolean
}
type PowerSaveBlocker = {
start: (type: 'prevent-app-suspension') => number
stop: (id: number) => void
isStarted: (id: number) => boolean
}
type Logger = Pick<Console, 'debug' | 'warn'>
type AgentAwakeServiceOptions = {
blocker?: PowerSaveBlocker
logger?: Logger
now?: () => number
}
export class AgentAwakeService {
private enabled = false
private statuses: AgentAwakeStatus[] = []
private blockerId: number | null = null
private staleTimer: ReturnType<typeof setTimeout> | null = null
private readonly blocker: PowerSaveBlocker
private readonly logger: Logger
private readonly now: () => number
constructor(options: AgentAwakeServiceOptions = {}) {
this.blocker = options.blocker ?? powerSaveBlocker
this.logger = options.logger ?? console
this.now = options.now ?? Date.now
}
setEnabled(enabled: boolean): void {
if (this.enabled === enabled) {
return
}
this.enabled = enabled
this.refresh('settings-change')
}
setStatuses(statuses: AgentAwakeStatus[]): void {
this.statuses = statuses.map((status) => ({ ...status }))
this.refresh('status-change')
}
dispose(): void {
this.clearStaleTimer()
this.stopBlocker('dispose')
}
private refresh(reason: string): void {
this.scheduleStaleTimer()
const runningStatusCount = this.getEligibleRunningStatusCount()
const shouldBlock = this.enabled && runningStatusCount > 0
this.logger.debug('[agent-awake] refresh', {
reason,
enabled: this.enabled,
runningStatusCount,
shouldBlock,
blockerId: this.blockerId
})
if (shouldBlock) {
this.startBlocker(reason, runningStatusCount)
} else {
this.stopBlocker(reason, runningStatusCount)
}
}
private getEligibleRunningStatusCount(): number {
const now = this.now()
return this.statuses.filter((status) => this.isWakeEligible(status, now)).length
}
private isWakeEligible(status: AgentAwakeStatus, now: number): boolean {
return (
status.observedInCurrentRuntime &&
status.state === 'working' &&
Number.isFinite(status.receivedAt) &&
now - status.receivedAt <= AGENT_AWAKE_STATUS_STALE_AFTER_MS
)
}
private scheduleStaleTimer(): void {
this.clearStaleTimer()
const now = this.now()
let earliestExpiry: number | null = null
for (const status of this.statuses) {
if (
!status.observedInCurrentRuntime ||
status.state !== 'working' ||
!Number.isFinite(status.receivedAt)
) {
continue
}
const expiry = status.receivedAt + AGENT_AWAKE_STATUS_STALE_AFTER_MS
if (expiry <= now) {
continue
}
earliestExpiry = earliestExpiry === null ? expiry : Math.min(earliestExpiry, expiry)
}
if (earliestExpiry === null) {
return
}
this.staleTimer = setTimeout(() => {
this.staleTimer = null
this.refresh('stale-expiry')
}, earliestExpiry - now)
if (typeof this.staleTimer.unref === 'function') {
this.staleTimer.unref()
}
}
private clearStaleTimer(): void {
if (!this.staleTimer) {
return
}
clearTimeout(this.staleTimer)
this.staleTimer = null
}
private startBlocker(reason: string, runningStatusCount: number): void {
if (this.blockerId !== null) {
if (this.reconcileBlocker('start-reconcile')) {
return
}
}
try {
const id = this.blocker.start('prevent-app-suspension')
this.blockerId = id
this.logger.debug('[agent-awake] started blocker', {
reason,
enabled: this.enabled,
runningStatusCount,
blockerId: id
})
this.reconcileBlocker('post-start')
} catch (err) {
this.logger.warn('[agent-awake] failed to start blocker', {
reason,
enabled: this.enabled,
runningStatusCount,
error: err
})
}
}
private stopBlocker(reason: string, runningStatusCount = 0): void {
if (this.blockerId === null) {
return
}
const id = this.blockerId
try {
this.blocker.stop(id)
} catch (err) {
this.logger.warn('[agent-awake] failed to stop blocker', {
reason,
enabled: this.enabled,
runningStatusCount,
blockerId: id,
error: err
})
}
if (!this.reconcileBlocker('post-stop')) {
this.logger.debug('[agent-awake] stopped blocker', {
reason,
enabled: this.enabled,
runningStatusCount,
blockerId: id
})
}
}
private reconcileBlocker(reason: string): boolean {
if (this.blockerId === null) {
return false
}
const id = this.blockerId
try {
const isStarted = this.blocker.isStarted(id)
this.logger.debug('[agent-awake] reconciled blocker', {
reason,
blockerId: id,
isStarted
})
if (!isStarted) {
this.blockerId = null
}
return isStarted
} catch (err) {
this.logger.warn('[agent-awake] failed to reconcile blocker', {
reason,
blockerId: id,
error: err
})
return true
}
}
}

View File

@ -59,6 +59,151 @@ afterEach(() => {
})
describe('AgentHookServer listener replay', () => {
it('allows multiple status-change subscribers to observe the same update', () => {
const server = new AgentHookServer()
const first = vi.fn()
const second = vi.fn()
server.subscribeStatusChanges(first)
server.subscribeStatusChanges(second)
server.ingestRemote(
{
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
payload: { state: 'working', agentType: 'claude' }
},
'conn-1'
)
expect(first).toHaveBeenCalledWith([
expect.objectContaining({
state: 'working',
receivedAt: expect.any(Number),
observedInCurrentRuntime: true
})
])
expect(second).toHaveBeenCalledWith([
expect.objectContaining({
state: 'working',
receivedAt: expect.any(Number),
observedInCurrentRuntime: true
})
])
})
it('keeps status-change subscribers when renderer fanout listener is cleared', () => {
const server = new AgentHookServer()
const statusChangeListener = vi.fn()
const rendererListener = vi.fn()
server.subscribeStatusChanges(statusChangeListener)
server.setListener(rendererListener)
server.setListener(null)
server.ingestRemote(
{
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
payload: { state: 'working', agentType: 'claude' }
},
'conn-1'
)
expect(statusChangeListener).toHaveBeenCalledTimes(1)
expect(rendererListener).not.toHaveBeenCalled()
})
it('unsubscribes status-change listeners without removing the remaining listeners', () => {
const server = new AgentHookServer()
const removed = vi.fn()
const remaining = vi.fn()
const unsubscribe = server.subscribeStatusChanges(removed)
server.subscribeStatusChanges(remaining)
unsubscribe()
server.ingestRemote(
{
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
payload: { state: 'working', agentType: 'claude' }
},
'conn-1'
)
expect(removed).not.toHaveBeenCalled()
expect(remaining).toHaveBeenCalledWith([
expect.objectContaining({
state: 'working',
observedInCurrentRuntime: true
})
])
})
it('notifies status-change subscribers when a working status is dropped or cleared', () => {
const server = new AgentHookServer()
const listener = vi.fn()
server.subscribeStatusChanges(listener)
server.ingestRemote(
{
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
payload: { state: 'working', agentType: 'claude' }
},
'conn-1'
)
server.dropStatusEntry(PANE)
server.ingestRemote(
{
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
payload: { state: 'working', agentType: 'claude' }
},
'conn-1'
)
server.clearPaneState(PANE)
expect(listener).toHaveBeenNthCalledWith(2, [])
expect(listener).toHaveBeenNthCalledWith(4, [])
})
it('hydrates cached statuses as not observed in the current runtime', async () => {
const dir = mkdtempSync(join(tmpdir(), 'orca-agent-hooks-'))
const firstServer = new AgentHookServer()
const secondServer = new AgentHookServer()
try {
await firstServer.start({ env: 'production', userDataPath: dir })
firstServer.ingestRemote(
{
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
payload: { state: 'working', agentType: 'claude' }
},
'conn-1'
)
firstServer.flushStatusPersistSync()
firstServer.stop()
await secondServer.start({ env: 'production', userDataPath: dir })
expect(secondServer.getStatusChangeSnapshot()).toEqual([
expect.objectContaining({
state: 'working',
observedInCurrentRuntime: false
})
])
} finally {
firstServer.stop()
secondServer.stop()
rmSync(dir, { recursive: true, force: true })
}
})
it('replays the latest retained pane status when a listener attaches after windowless events', async () => {
const server = new AgentHookServer()
await server.start({ env: 'production' })

View File

@ -36,6 +36,7 @@ import {
import type { AgentHookSource } from '../../shared/agent-hook-relay'
import {
type AgentStatusIpcPayload,
type AgentStatusState,
normalizeAgentStatusPayload
} from '../../shared/agent-status-types'
@ -54,6 +55,14 @@ type EnrichedAgentHookEventPayload = AgentHookEventPayload & {
stateStartedAt: number
}
export type AgentHookStatusChangeEntry = {
state: AgentStatusState
receivedAt: number
observedInCurrentRuntime: boolean
}
type StatusChangeListener = (statuses: AgentHookStatusChangeEntry[]) => void
// Why: name of the on-disk cache that survives Orca restart. Lives next to
// the endpoint file in userData/agent-hooks/ so all hook-server-owned cross-
// restart artifacts stay co-located.
@ -199,6 +208,7 @@ export class AgentHookServer {
// caller's knowledge of whether this is a packaged build.
private env = 'production'
private onAgentStatus: ((payload: EnrichedAgentHookEventPayload) => void) | null = null
private statusChangeListeners = new Set<StatusChangeListener>()
// Why: directory that holds the on-disk endpoint file. Set via start()'s
// `userDataPath` option so the class has no direct Electron dependency
// (keeps it mockable in the vitest node environment).
@ -209,6 +219,9 @@ export class AgentHookServer {
// by paneKey). Held on the instance instead of as module-level Maps so
// tests can spin up multiple servers without state cross-contamination.
private state: HookListenerState = createHookListenerState()
// Why: hydrated last-status rows are useful UI continuity, but they are not
// evidence of live agent work in this main-process runtime.
private runtimeObservedStatusPaneKeys = new Set<string>()
// Why: full path to the on-disk last-status cache. Set in start() from
// userDataPath. Null when the server runs without a userDataPath (e.g.
// tests that skip the userDataPath option) — in that case, persistence is
@ -243,6 +256,13 @@ export class AgentHookServer {
}
}
subscribeStatusChanges(listener: StatusChangeListener): () => void {
this.statusChangeListeners.add(listener)
return () => {
this.statusChangeListeners.delete(listener)
}
}
/** Snapshot of the current cached statuses, in the IPC-shaped form the
* renderer consumes. Used by the `agentStatus:getSnapshot` IPC after
* workspace tabs have hydrated, so the dashboard catches up on any
@ -253,6 +273,31 @@ export class AgentHookServer {
)
}
getStatusChangeSnapshot(): AgentHookStatusChangeEntry[] {
return Array.from(this.state.lastStatusByPaneKey.entries(), ([paneKey, entry]) => {
const enriched = entry as EnrichedAgentHookEventPayload
return {
state: enriched.payload.state,
receivedAt: enriched.receivedAt,
observedInCurrentRuntime: this.runtimeObservedStatusPaneKeys.has(paneKey)
}
})
}
private notifyStatusChangeListeners(): void {
if (this.statusChangeListeners.size === 0) {
return
}
const snapshot = this.getStatusChangeSnapshot()
for (const listener of this.statusChangeListeners) {
try {
listener(snapshot)
} catch (err) {
console.error('[agent-hooks] status-change listener threw', err)
}
}
}
private attachStatusTiming(payload: AgentHookEventPayload): EnrichedAgentHookEventPayload {
const now = Date.now()
const previous = this.state.lastStatusByPaneKey.get(payload.paneKey) as
@ -354,8 +399,10 @@ export class AgentHookServer {
payload: normalizedPayload
}
const enriched = this.attachStatusTiming(event)
this.runtimeObservedStatusPaneKeys.add(paneKey)
this.state.lastStatusByPaneKey.set(paneKey, enriched)
this.scheduleStatusPersist()
this.notifyStatusChangeListeners()
this.onAgentStatus?.(enriched)
}
@ -416,8 +463,10 @@ export class AgentHookServer {
const normalized = normalizeHookPayload(this.state, source, body, this.env)
if (normalized) {
const enriched = this.attachStatusTiming(normalized)
this.runtimeObservedStatusPaneKeys.add(enriched.paneKey)
this.state.lastStatusByPaneKey.set(enriched.paneKey, enriched)
this.scheduleStatusPersist()
this.notifyStatusChangeListeners()
this.onAgentStatus?.(enriched)
}
@ -477,7 +526,9 @@ export class AgentHookServer {
this.endpointFileWritten = false
this.lastStatusFilePath = null
this.lastWrittenJson = null
this.runtimeObservedStatusPaneKeys.clear()
clearAllListenerCaches(this.state)
this.notifyStatusChangeListeners()
}
/** Why: invoked from the renderer-driven agentStatus:drop IPC when a user
@ -492,7 +543,9 @@ export class AgentHookServer {
return
}
this.state.lastStatusByPaneKey.delete(paneKey)
this.runtimeObservedStatusPaneKeys.delete(paneKey)
this.scheduleStatusPersist()
this.notifyStatusChangeListeners()
}
clearPaneState(paneKey: string): void {
@ -503,7 +556,9 @@ export class AgentHookServer {
const hadStatus = this.state.lastStatusByPaneKey.has(paneKey)
clearPaneCacheState(this.state, paneKey)
if (hadStatus) {
this.runtimeObservedStatusPaneKeys.delete(paneKey)
this.scheduleStatusPersist()
this.notifyStatusChangeListeners()
}
}

View File

@ -98,6 +98,7 @@ function createSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings
opencodeWorkspaceId: '',
geminiCliOAuthEnabled: false,
agentCmdOverrides: {},
keepComputerAwakeWhileAgentsRun: false,
terminalMacOptionAsAlt: 'false',
terminalMacOptionAsAltMigrated: true,
experimentalMobile: false,

View File

@ -91,6 +91,7 @@ function createSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings
opencodeWorkspaceId: '',
geminiCliOAuthEnabled: false,
agentCmdOverrides: {},
keepComputerAwakeWhileAgentsRun: false,
terminalMacOptionAsAlt: 'false',
terminalMacOptionAsAltMigrated: true,
experimentalMobile: false,

View File

@ -65,6 +65,7 @@ import { browserManager } from './browser/browser-manager'
import { setUnreadDockBadgeCount } from './dock/unread-badge'
import { registerFeatureWallFirstAgentTour } from './feature-wall/first-agent-tour'
import { AutomationService } from './automations/service'
import { AgentAwakeService } from './agent-awake-service'
let mainWindow: BrowserWindow | null = null
/** Whether a manual app.quit() (Cmd+Q, etc.) is in progress. Shared with the
@ -83,6 +84,8 @@ let runtime: OrcaRuntimeService | null = null
let rateLimits: RateLimitService | null = null
let runtimeRpc: OrcaRuntimeRpcServer | null = null
let starNag: StarNagService | null = null
let agentAwakeService: AgentAwakeService | null = null
let unsubscribeAgentAwakeStatusChanges: (() => void) | null = null
let disposeFeatureWallFirstAgentTour: (() => void) | null = null
let watcherShutdownPromise: Promise<void> | null = null
let watcherShutdownDone = false
@ -278,7 +281,8 @@ function openMainWindow(): BrowserWindow {
? codexRuntimeHome!.prepareForCodexLaunch()
: null,
prepareForClaudeLaunch: () => claudeRuntimeAuth!.prepareForClaudeLaunch()
}
},
agentAwakeService ?? undefined
)
automations.setWebContents(window.webContents)
automations.start()
@ -618,6 +622,15 @@ app.whenReady().then(async () => {
}
store = new Store()
agentAwakeService = new AgentAwakeService()
agentAwakeService.setEnabled(store.getSettings().keepComputerAwakeWhileAgentsRun)
// Why: disk-hydrated status rows are UI continuity only. The service starts
// from an empty snapshot; only hook events observed in this runtime can keep
// the local computer awake.
agentAwakeService.setStatuses([])
unsubscribeAgentAwakeStatusChanges = agentHookServer.subscribeStatusChanges((statuses) => {
agentAwakeService?.setStatuses(statuses)
})
// Why: telemetry must initialize before any IPC handler / renderer can
// call `track()`. The client is a no-op in dev/contributor builds
// (`IS_OFFICIAL_BUILD === false`) and a no-op while `TELEMETRY_ENABLED`
@ -860,6 +873,10 @@ app.whenReady().then(async () => {
app.on('before-quit', () => {
isQuitting = true
unsubscribeAgentAwakeStatusChanges?.()
unsubscribeAgentAwakeStatusChanges = null
agentAwakeService?.dispose()
agentAwakeService = null
disposeFeatureWallFirstAgentTour?.()
disposeFeatureWallFirstAgentTour = null
// Why: PTY cleanup is deferred to will-quit so the renderer has a chance to

View File

@ -328,7 +328,7 @@ describe('registerCoreHandlers', () => {
expect(registerNotificationHandlersMock).toHaveBeenCalledWith(store, runtime)
expect(registerDeveloperPermissionHandlersMock).toHaveBeenCalled()
expect(registerComputerUsePermissionHandlersMock).toHaveBeenCalled()
expect(registerSettingsHandlersMock).toHaveBeenCalledWith(store)
expect(registerSettingsHandlersMock).toHaveBeenCalledWith(store, undefined)
expect(registerWorkspaceSpaceHandlersMock).toHaveBeenCalledWith(store)
expect(registerTelemetryHandlersMock).toHaveBeenCalledWith(store)
expect(registerSessionHandlersMock).toHaveBeenCalledWith(store)

View File

@ -55,6 +55,7 @@ import type { RateLimitService } from '../rate-limits/service'
import type { CodexAccountService } from '../codex-accounts/service'
import type { ClaudeAccountService } from '../claude-accounts/service'
import type { AutomationService } from '../automations/service'
import type { AgentAwakeService } from '../agent-awake-service'
let registered = false
@ -69,7 +70,8 @@ export function registerCoreHandlers(
rateLimits: RateLimitService,
mainWindowWebContentsId: number | null = null,
automations?: AutomationService,
commitMessageAgentEnv?: CommitMessageAgentEnvironmentResolvers
commitMessageAgentEnv?: CommitMessageAgentEnvironmentResolvers,
agentAwakeService?: AgentAwakeService
): void {
// Why: on macOS the app can stay alive after all windows close, then
// openMainWindow() is called again on 'activate'. ipcMain.handle() throws
@ -105,7 +107,7 @@ export function registerCoreHandlers(
registerOnboardingHandlers(store)
registerDeveloperPermissionHandlers()
registerComputerUsePermissionHandlers()
registerSettingsHandlers(store)
registerSettingsHandlers(store, agentAwakeService)
if (automations) {
registerAutomationHandlers(store, automations)
}

View File

@ -6,7 +6,8 @@ const { handleMock, previewGhosttyImportMock } = vi.hoisted(() => ({
}))
vi.mock('electron', () => ({
ipcMain: { handle: handleMock }
ipcMain: { handle: handleMock },
nativeTheme: { themeSource: 'system' }
}))
vi.mock('../ghostty/index', () => ({
@ -26,6 +27,8 @@ describe('registerSettingsHandlers', () => {
beforeEach(() => {
handleMock.mockClear()
previewGhosttyImportMock.mockClear()
store.getSettings.mockReset()
store.updateSettings.mockReset()
})
it('registers settings:previewGhosttyImport handler', () => {
@ -47,4 +50,36 @@ describe('registerSettingsHandlers', () => {
expect(result).toEqual(expected)
expect(previewGhosttyImportMock).toHaveBeenCalledWith(store)
})
it('updates the agent awake service when the keep-awake setting changes', () => {
const agentAwakeService = { setEnabled: vi.fn() }
store.getSettings.mockReturnValue({ keepComputerAwakeWhileAgentsRun: false })
store.updateSettings.mockReturnValue({ keepComputerAwakeWhileAgentsRun: true })
registerSettingsHandlers(store as never, agentAwakeService as never)
const handler = handleMock.mock.calls.find((call) => call[0] === 'settings:set')?.[1] as (
_event: unknown,
args: unknown
) => unknown
handler(null, { keepComputerAwakeWhileAgentsRun: true })
expect(agentAwakeService.setEnabled).toHaveBeenCalledWith(true)
})
it('does not notify the agent awake service for unrelated setting changes', () => {
const agentAwakeService = { setEnabled: vi.fn() }
store.getSettings.mockReturnValue({ keepComputerAwakeWhileAgentsRun: false })
store.updateSettings.mockReturnValue({ keepComputerAwakeWhileAgentsRun: false })
registerSettingsHandlers(store as never, agentAwakeService as never)
const handler = handleMock.mock.calls.find((call) => call[0] === 'settings:set')?.[1] as (
_event: unknown,
args: unknown
) => unknown
handler(null, { defaultTuiAgent: 'codex' })
expect(agentAwakeService.setEnabled).not.toHaveBeenCalled()
})
})

View File

@ -6,6 +6,7 @@ import { previewGhosttyImport } from '../ghostty/index'
import { rebuildAppMenu } from '../menu/register-app-menu'
import { track } from '../telemetry/client'
import { SETTINGS_CHANGED_WHITELIST, type SettingsChangedKey } from '../../shared/telemetry-events'
import type { AgentAwakeService } from '../agent-awake-service'
// Why: the whitelist is the source-of-truth for which keys we emit on. Casting
// to a Set once at module load lets the IPC handler's per-key membership
@ -21,7 +22,10 @@ const APPEARANCE_MENU_KEYS: readonly (keyof GlobalSettings)[] = [
'showTitlebarAppName'
]
export function registerSettingsHandlers(store: Store): void {
export function registerSettingsHandlers(
store: Store,
agentAwakeService?: AgentAwakeService
): void {
ipcMain.handle('settings:get', () => {
return store.getSettings()
})
@ -36,6 +40,9 @@ export function registerSettingsHandlers(store: Store): void {
// no-op flip would inflate the experimental-feature-adoption signal.
const before = store.getSettings()
const result = store.updateSettings(args)
if ('keepComputerAwakeWhileAgentsRun' in args) {
agentAwakeService?.setEnabled(result.keepComputerAwakeWhileAgentsRun)
}
if (APPEARANCE_MENU_KEYS.some((key) => key in args)) {
rebuildAppMenu()
}

View File

@ -0,0 +1,51 @@
import type { GlobalSettings } from '../../../../shared/types'
import { Label } from '../ui/label'
import { SearchableSetting } from './SearchableSetting'
type AgentAwakeSettingProps = {
settings: GlobalSettings
updateSettings: (updates: Partial<GlobalSettings>) => void
}
export function AgentAwakeSetting({
settings,
updateSettings
}: AgentAwakeSettingProps): React.JSX.Element {
return (
<section className="space-y-3">
<SearchableSetting
title="Keep computer awake when Orca sees agents running"
description="Prevents this computer from sleeping while Orca sees an agent working. The display can still turn off."
keywords={['awake', 'sleep', 'power', 'agent', 'running']}
className="flex items-start justify-between gap-4 px-1 py-2"
>
<div className="min-w-0 shrink space-y-0.5">
<Label>Keep computer awake when Orca sees agents running</Label>
<p className="text-xs text-muted-foreground">
Prevents this computer from sleeping while Orca sees an agent working. The display can
still turn off.
</p>
</div>
<button
role="switch"
aria-label="Keep computer awake when Orca sees agents running"
aria-checked={settings.keepComputerAwakeWhileAgentsRun}
onClick={() =>
updateSettings({
keepComputerAwakeWhileAgentsRun: !settings.keepComputerAwakeWhileAgentsRun
})
}
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent transition-colors ${
settings.keepComputerAwakeWhileAgentsRun ? 'bg-foreground' : 'bg-muted-foreground/30'
}`}
>
<span
className={`pointer-events-none block size-3.5 rounded-full bg-background shadow-sm transition-transform ${
settings.keepComputerAwakeWhileAgentsRun ? 'translate-x-4' : 'translate-x-0.5'
}`}
/>
</button>
</SearchableSetting>
</section>
)
}

View File

@ -0,0 +1,101 @@
import React from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { getDefaultSettings } from '../../../../shared/constants'
import type { GlobalSettings } from '../../../../shared/types'
import { useAppStore } from '../../store'
import { AgentAwakeSetting } from './AgentAwakeSetting'
import { AgentsPane, AGENTS_PANE_SEARCH_ENTRIES } from './AgentsPane'
import { matchesSettingsSearch } from './settings-search'
type ReactElementLike = {
type: unknown
props: Record<string, unknown>
}
function renderPane(settings: GlobalSettings): string {
return renderToStaticMarkup(
React.createElement(AgentsPane, {
settings,
updateSettings: vi.fn()
})
)
}
function visit(node: unknown, cb: (node: ReactElementLike) => void): void {
if (node == null || typeof node === 'string' || typeof node === 'number') {
return
}
if (Array.isArray(node)) {
node.forEach((entry) => visit(entry, cb))
return
}
const element = node as ReactElementLike
cb(element)
if (element.props?.children) {
visit(element.props.children, cb)
}
}
function findSwitch(node: unknown): ReactElementLike {
let found: ReactElementLike | null = null
visit(node, (entry) => {
if (entry.props.role === 'switch') {
found = entry
}
})
if (!found) {
throw new Error('switch not found')
}
return found
}
describe('AgentsPane', () => {
beforeEach(() => {
useAppStore.setState({
settingsSearchQuery: '',
detectedAgentIds: ['claude'],
isDetectingAgents: false,
isRefreshingAgents: false
})
})
it('renders the keep-awake toggle from settings', () => {
const markup = renderPane(getDefaultSettings('/tmp'))
expect(markup).toContain('Keep computer awake when Orca sees agents running')
expect(markup).toContain(
'Prevents this computer from sleeping while Orca sees an agent working. The display can still turn off.'
)
expect(markup).toContain('aria-checked="false"')
})
it('toggles the keep-awake setting with the next value', () => {
const updateSettings = vi.fn()
const element = AgentAwakeSetting({
settings: {
...getDefaultSettings('/tmp'),
keepComputerAwakeWhileAgentsRun: false
},
updateSettings
})
const keepAwakeSwitch = findSwitch(element)
expect(keepAwakeSwitch.props['aria-label']).toBe(
'Keep computer awake when Orca sees agents running'
)
expect(keepAwakeSwitch.props['aria-checked']).toBe(false)
const onClick = keepAwakeSwitch.props.onClick as () => void
onClick()
expect(updateSettings).toHaveBeenCalledWith({
keepComputerAwakeWhileAgentsRun: true
})
})
it('includes awake and sleep search metadata for the setting', () => {
expect(matchesSettingsSearch('awake', AGENTS_PANE_SEARCH_ENTRIES)).toBe(true)
expect(matchesSettingsSearch('sleep', AGENTS_PANE_SEARCH_ENTRIES)).toBe(true)
})
})

View File

@ -6,6 +6,7 @@ import { useDetectedAgents } from '@/hooks/useDetectedAgents'
import { Button } from '../ui/button'
import { Input } from '../ui/input'
import { cn } from '@/lib/utils'
import { AgentAwakeSetting } from './AgentAwakeSetting'
export { AGENTS_PANE_SEARCH_ENTRIES } from './agents-search'
@ -336,6 +337,8 @@ export function AgentsPane({ settings, updateSettings }: AgentsPaneProps): React
</div>
</section>
<AgentAwakeSetting settings={settings} updateSettings={updateSettings} />
{/* Detected agents */}
{detectedAgents.length > 0 && (
<section className="space-y-3">

View File

@ -37,5 +37,11 @@ export const AGENTS_PANE_SEARCH_ENTRIES: SettingsSearchEntry[] = [
'install',
'detected'
]
},
{
title: 'Keep computer awake when Orca sees agents running',
description:
'Prevents this computer from sleeping while Orca sees an agent working. The display can still turn off.',
keywords: ['awake', 'sleep', 'power', 'agent', 'running']
}
]

View File

@ -234,6 +234,7 @@ export function getDefaultSettings(homedir: string): GlobalSettings {
opencodeWorkspaceId: '',
geminiCliOAuthEnabled: false,
agentCmdOverrides: {},
keepComputerAwakeWhileAgentsRun: false,
// Why: 'auto' runs a layout-aware probe at boot (see
// src/renderer/src/lib/keyboard-layout/*) that picks 'true' for US and
// US-International and 'false' for every other layout. This mirrors

View File

@ -1349,6 +1349,8 @@ export type GlobalSettings = {
geminiCliOAuthEnabled: boolean
/** Per-agent CLI command overrides. A missing key means use the catalog default binary name. */
agentCmdOverrides: Partial<Record<TuiAgent, string>>
/** When true, Orca prevents local app suspension while hook-reported agents are working. */
keepComputerAwakeWhileAgentsRun: boolean
/** Why: macOS terminals must choose between letting Option compose layout
* characters (@ on German, on French) or treating Option as Meta/Esc for
* readline shortcuts. Mirrors Ghostty's macos-option-as-alt setting and

View File

@ -0,0 +1,54 @@
import { test, expect } from './helpers/orca-app'
import { waitForSessionReady } from './helpers/store'
import type { GlobalSettings } from '../../src/shared/types'
async function getSettings(
page: Parameters<typeof waitForSessionReady>[0]
): Promise<GlobalSettings> {
return page.evaluate(() => window.api.settings.get())
}
async function openSettings(page: Parameters<typeof waitForSessionReady>[0]): Promise<void> {
await page.evaluate(() => {
window.__store!.getState().openSettingsPage()
})
await expect(page.getByPlaceholder('Search settings')).toBeVisible({ timeout: 10_000 })
}
test.describe('Agent awake setting', () => {
test.beforeEach(async ({ orcaPage }) => {
await waitForSessionReady(orcaPage)
})
test('can be toggled from Agents settings and persists through IPC', async ({ orcaPage }) => {
await openSettings(orcaPage)
await orcaPage.getByPlaceholder('Search settings').fill('awake')
await expect(
orcaPage.getByText('Keep computer awake when Orca sees agents running').first()
).toBeVisible()
const keepAwakeSwitch = orcaPage.getByRole('switch', {
name: 'Keep computer awake when Orca sees agents running'
})
await expect(keepAwakeSwitch).toHaveAttribute('aria-checked', 'false')
await keepAwakeSwitch.click()
await expect(keepAwakeSwitch).toHaveAttribute('aria-checked', 'true')
await expect
.poll(async () => (await getSettings(orcaPage)).keepComputerAwakeWhileAgentsRun, {
timeout: 5_000,
message: 'keep-awake setting did not persist after enabling'
})
.toBe(true)
await keepAwakeSwitch.click()
await expect(keepAwakeSwitch).toHaveAttribute('aria-checked', 'false')
await expect
.poll(async () => (await getSettings(orcaPage)).keepComputerAwakeWhileAgentsRun, {
timeout: 5_000,
message: 'keep-awake setting did not persist after disabling'
})
.toBe(false)
})
})