diff --git a/src/main/agent-awake-service.test.ts b/src/main/agent-awake-service.test.ts new file mode 100644 index 000000000..d71b6c37b --- /dev/null +++ b/src/main/agent-awake-service.test.ts @@ -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 { + return { + state: 'working', + receivedAt: 1_000, + observedInCurrentRuntime: true, + ...overrides + } +} + +function createBlocker() { + const startedIds = new Set() + 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) + }) +}) diff --git a/src/main/agent-awake-service.ts b/src/main/agent-awake-service.ts new file mode 100644 index 000000000..ae1463f75 --- /dev/null +++ b/src/main/agent-awake-service.ts @@ -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 + +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 | 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 + } + } +} diff --git a/src/main/agent-hooks/server.test.ts b/src/main/agent-hooks/server.test.ts index 724fc3a4a..6033bf302 100644 --- a/src/main/agent-hooks/server.test.ts +++ b/src/main/agent-hooks/server.test.ts @@ -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' }) diff --git a/src/main/agent-hooks/server.ts b/src/main/agent-hooks/server.ts index cd715bcc5..9c3f73682 100644 --- a/src/main/agent-hooks/server.ts +++ b/src/main/agent-hooks/server.ts @@ -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() // 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() // 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() } } diff --git a/src/main/codex-accounts/runtime-home-service.test.ts b/src/main/codex-accounts/runtime-home-service.test.ts index 35164220f..d2d914e14 100644 --- a/src/main/codex-accounts/runtime-home-service.test.ts +++ b/src/main/codex-accounts/runtime-home-service.test.ts @@ -98,6 +98,7 @@ function createSettings(overrides: Partial = {}): GlobalSettings opencodeWorkspaceId: '', geminiCliOAuthEnabled: false, agentCmdOverrides: {}, + keepComputerAwakeWhileAgentsRun: false, terminalMacOptionAsAlt: 'false', terminalMacOptionAsAltMigrated: true, experimentalMobile: false, diff --git a/src/main/codex-accounts/service.test.ts b/src/main/codex-accounts/service.test.ts index 3a44b1bf7..89f82473d 100644 --- a/src/main/codex-accounts/service.test.ts +++ b/src/main/codex-accounts/service.test.ts @@ -91,6 +91,7 @@ function createSettings(overrides: Partial = {}): GlobalSettings opencodeWorkspaceId: '', geminiCliOAuthEnabled: false, agentCmdOverrides: {}, + keepComputerAwakeWhileAgentsRun: false, terminalMacOptionAsAlt: 'false', terminalMacOptionAsAltMigrated: true, experimentalMobile: false, diff --git a/src/main/index.ts b/src/main/index.ts index 7da6e4fa7..d034bac1f 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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 | 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 diff --git a/src/main/ipc/register-core-handlers.test.ts b/src/main/ipc/register-core-handlers.test.ts index aab98e262..7ff589620 100644 --- a/src/main/ipc/register-core-handlers.test.ts +++ b/src/main/ipc/register-core-handlers.test.ts @@ -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) diff --git a/src/main/ipc/register-core-handlers.ts b/src/main/ipc/register-core-handlers.ts index 2bfb4c71d..b6d87da39 100644 --- a/src/main/ipc/register-core-handlers.ts +++ b/src/main/ipc/register-core-handlers.ts @@ -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) } diff --git a/src/main/ipc/settings.test.ts b/src/main/ipc/settings.test.ts index 75d66845b..7f50d8fa8 100644 --- a/src/main/ipc/settings.test.ts +++ b/src/main/ipc/settings.test.ts @@ -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() + }) }) diff --git a/src/main/ipc/settings.ts b/src/main/ipc/settings.ts index 2029227ef..9783340bf 100644 --- a/src/main/ipc/settings.ts +++ b/src/main/ipc/settings.ts @@ -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() } diff --git a/src/renderer/src/components/settings/AgentAwakeSetting.tsx b/src/renderer/src/components/settings/AgentAwakeSetting.tsx new file mode 100644 index 000000000..375f849f5 --- /dev/null +++ b/src/renderer/src/components/settings/AgentAwakeSetting.tsx @@ -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) => void +} + +export function AgentAwakeSetting({ + settings, + updateSettings +}: AgentAwakeSettingProps): React.JSX.Element { + return ( +
+ +
+ +

+ Prevents this computer from sleeping while Orca sees an agent working. The display can + still turn off. +

+
+ +
+
+ ) +} diff --git a/src/renderer/src/components/settings/AgentsPane.test.tsx b/src/renderer/src/components/settings/AgentsPane.test.tsx new file mode 100644 index 000000000..00a7059e1 --- /dev/null +++ b/src/renderer/src/components/settings/AgentsPane.test.tsx @@ -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 +} + +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) + }) +}) diff --git a/src/renderer/src/components/settings/AgentsPane.tsx b/src/renderer/src/components/settings/AgentsPane.tsx index 5b55b96a5..01e1c4bcd 100644 --- a/src/renderer/src/components/settings/AgentsPane.tsx +++ b/src/renderer/src/components/settings/AgentsPane.tsx @@ -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 + + {/* Detected agents */} {detectedAgents.length > 0 && (
diff --git a/src/renderer/src/components/settings/agents-search.ts b/src/renderer/src/components/settings/agents-search.ts index 5be399750..b82fe2e1d 100644 --- a/src/renderer/src/components/settings/agents-search.ts +++ b/src/renderer/src/components/settings/agents-search.ts @@ -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'] } ] diff --git a/src/shared/constants.ts b/src/shared/constants.ts index b7e77955f..9e602188d 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -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 diff --git a/src/shared/types.ts b/src/shared/types.ts index 3142a33f2..53ec7a173 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -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> + /** 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 diff --git a/tests/e2e/settings-agent-awake.spec.ts b/tests/e2e/settings-agent-awake.spec.ts new file mode 100644 index 000000000..8627ca1d1 --- /dev/null +++ b/tests/e2e/settings-agent-awake.spec.ts @@ -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[0] +): Promise { + return page.evaluate(() => window.api.settings.get()) +} + +async function openSettings(page: Parameters[0]): Promise { + 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) + }) +})