Keep computer awake while agents run (#1937)
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
cc0272e5c4
commit
fd813e7639
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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' })
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -98,6 +98,7 @@ function createSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings
|
|||
opencodeWorkspaceId: '',
|
||||
geminiCliOAuthEnabled: false,
|
||||
agentCmdOverrides: {},
|
||||
keepComputerAwakeWhileAgentsRun: false,
|
||||
terminalMacOptionAsAlt: 'false',
|
||||
terminalMacOptionAsAltMigrated: true,
|
||||
experimentalMobile: false,
|
||||
|
|
|
|||
|
|
@ -91,6 +91,7 @@ function createSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings
|
|||
opencodeWorkspaceId: '',
|
||||
geminiCliOAuthEnabled: false,
|
||||
agentCmdOverrides: {},
|
||||
keepComputerAwakeWhileAgentsRun: false,
|
||||
terminalMacOptionAsAlt: 'false',
|
||||
terminalMacOptionAsAltMigrated: true,
|
||||
experimentalMobile: false,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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']
|
||||
}
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue