Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 45 additions & 5 deletions src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -340,6 +340,12 @@ import {
import { LocalPtyProvider } from './providers/local-pty-provider'
import { KeybindingService } from './keybindings/keybinding-service'
import { applyElectronProxySettings } from './network/proxy-settings'
import {
SYNTHETIC_PERMISSION_BELL,
buildSyntheticTerminalTitleFrame,
shouldDeferSyntheticPermissionBell
} from '../shared/codex-attention-quiet-window'
import { SyntheticPermissionBellDeferral } from './synthetic-permission-bell-deferral'
import { preserveAgentAuthBeforeRestart } from './agent-auth-restart-preservation'
import { CliInstaller } from './cli/cli-installer'
import { installLinuxBareOrcaDispatcher } from './cli/linux-bare-orca-dispatcher'
Expand Down Expand Up @@ -1504,6 +1510,7 @@ function openMainWindow(options: { revealOnDidFinishLoad?: boolean } = {}): Brow
setMigrationUnsupportedPtyListener(null)
// Why: stop the spinner timer here — it would fire into destroyed webContents, and per-pane teardown may never run for restored-but-untorn panes.
stopAllSyntheticTitleSpinners()
syntheticPermissionBellDeferral.cancelAll()
})
mainWindow = window
window.on('show', resumeSyntheticTitleSpinnerTimer)
Expand Down Expand Up @@ -1583,21 +1590,36 @@ function openMainWindow(options: { revealOnDidFinishLoad?: boolean } = {}): Brow
getDashboardPopoutWindow()?.webContents.send('agentStatus:set', statusEvent)
}
recordAgentStateCrashBreadcrumb(payload.agentType ?? 'unknown', payload.state)
// Why: a pause that resolved itself must drop its deferred BEL even when this event
// suppresses the synthetic title entirely (#13600).
if (
!shouldDeferSyntheticPermissionBell({
agentType: payload.agentType,
state: payload.state,
toolName: payload.toolName
})
) {
syntheticPermissionBellDeferral.cancel(paneKey)
}
// Why: native OSC titles miss some idle/permission frames, so inject hook-derived ones to keep the renderer title tracker in sync.
const profile = getSyntheticAgentTitleProfile(payload.agentType)
if (
profile &&
shouldDriveSyntheticAgentTitleFromHook(payload.agentType, payload.state) &&
!suppressSyntheticCodexAutoApprovalTitle
) {
driveSyntheticTitleFromHook(paneKey, payload.state, profile)
driveSyntheticTitleFromHook(paneKey, payload, profile)
}
}
)
agentHookServer.setPaneStatusClearListener((clear) => {
if (mainWindow?.isDestroyed()) {
return
}
// Why: the pane's status is gone, so its held-back permission BEL has nothing left to announce.
if ('paneKey' in clear) {
syntheticPermissionBellDeferral.cancel(clear.paneKey)
}
mainWindow?.webContents.send('agentStatus:clear', clear)
getDashboardPopoutWindow()?.webContents.send('agentStatus:clear', clear)
})
Expand Down Expand Up @@ -1812,6 +1834,7 @@ const syntheticTitleSpinnerByPaneKey = new Map<
SyntheticTitleSpinnerEntry<SyntheticAgentTitleProfile>
>()
let syntheticTitleSpinnerTimer: ReturnType<typeof setInterval> | null = null
const syntheticPermissionBellDeferral = new SyntheticPermissionBellDeferral()

type ServeOptions = {
json: boolean
Expand Down Expand Up @@ -2051,9 +2074,10 @@ function resumeSyntheticTitleSpinnerTimer(): void {

function driveSyntheticTitleFromHook(
paneKey: string,
state: AgentStatusState,
status: { agentType?: string | null; state: AgentStatusState; toolName?: string },
profile: SyntheticAgentTitleProfile
): void {
const { agentType, state, toolName } = status
const ptyId = getPtyIdForPaneKey(paneKey)
if (!ptyId) {
return
Expand All @@ -2077,9 +2101,25 @@ function driveSyntheticTitleFromHook(
stopSyntheticTitleSpinner(paneKey)
const needsUserInput = state === 'blocked' || state === 'waiting'
const label = needsUserInput ? profile.permissionLabel : profile.idleLabel
sendSyntheticTitle(ptyId, `\x1b]0;${label}\x07${needsUserInput ? '\x07' : ''}`, {
force: true
})
// Why: this fabricated BEL is Orca's own attention signal, so it must clear the same Codex
// quiet window the renderer applies to the OS notification — ringing it inline let an
// "Approve for me" pause raise "Attention requested" before the auto-reviewer replied (#13600).
const { frame, deferBell } = buildSyntheticTerminalTitleFrame({
agentType,
state,
toolName,
label
})
sendSyntheticTitle(ptyId, frame, { force: true })
if (deferBell) {
syntheticPermissionBellDeferral.defer(paneKey, () => {
// Why: re-resolve the PTY — the pane can be torn down or re-bound inside the quiet window.
const livePtyId = getPtyIdForPaneKey(paneKey)
if (livePtyId) {
sendSyntheticTitle(livePtyId, SYNTHETIC_PERMISSION_BELL, { force: true })
}
})
}
}

function shouldSuppressCodexAutoApprovalSyntheticTitleFromHook(args: {
Expand Down
101 changes: 101 additions & 0 deletions src/main/synthetic-permission-bell-deferral.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { SyntheticPermissionBellDeferral } from './synthetic-permission-bell-deferral'
import { CODEX_ATTENTION_QUIET_MS } from '../shared/codex-attention-quiet-window'

const PANE = 'tab-1:leaf-a'
const OTHER_PANE = 'tab-1:leaf-b'

describe('SyntheticPermissionBellDeferral', () => {
beforeEach(() => {
vi.useFakeTimers()
})

afterEach(() => {
vi.useRealTimers()
})

it('rings a pause the agent never resolves, one quiet window later', () => {
const deferral = new SyntheticPermissionBellDeferral()
const emit = vi.fn()

deferral.defer(PANE, emit)
vi.advanceTimersByTime(CODEX_ATTENTION_QUIET_MS - 1)
expect(emit).not.toHaveBeenCalled()

vi.advanceTimersByTime(1)
expect(emit).toHaveBeenCalledTimes(1)
expect(deferral.hasPending(PANE)).toBe(false)
})

it('drops the BEL when the pause resolves inside the window (#13600)', () => {
const deferral = new SyntheticPermissionBellDeferral()
const emit = vi.fn()

deferral.defer(PANE, emit)
vi.advanceTimersByTime(CODEX_ATTENTION_QUIET_MS - 1)
expect(deferral.cancel(PANE)).toBe(true)

vi.advanceTimersByTime(CODEX_ATTENTION_QUIET_MS * 4)
expect(emit).not.toHaveBeenCalled()
})

it('cancels only the named pane', () => {
const deferral = new SyntheticPermissionBellDeferral()
const resolved = vi.fn()
const stillWaiting = vi.fn()

deferral.defer(PANE, resolved)
deferral.defer(OTHER_PANE, stillWaiting)
deferral.cancel(PANE)
vi.advanceTimersByTime(CODEX_ATTENTION_QUIET_MS)

expect(resolved).not.toHaveBeenCalled()
expect(stillWaiting).toHaveBeenCalledTimes(1)
})

it('re-arming a pane replaces its pending BEL instead of queueing a second ring', () => {
const deferral = new SyntheticPermissionBellDeferral()
const first = vi.fn()
const second = vi.fn()

deferral.defer(PANE, first)
vi.advanceTimersByTime(CODEX_ATTENTION_QUIET_MS - 1)
deferral.defer(PANE, second)
vi.advanceTimersByTime(CODEX_ATTENTION_QUIET_MS)

expect(first).not.toHaveBeenCalled()
expect(second).toHaveBeenCalledTimes(1)
})

it('reports nothing to cancel for an unarmed pane', () => {
const deferral = new SyntheticPermissionBellDeferral()

expect(deferral.cancel(PANE)).toBe(false)
expect(deferral.hasPending(PANE)).toBe(false)
})

it('cancelAll drops every pending BEL at window teardown', () => {
const deferral = new SyntheticPermissionBellDeferral()
const first = vi.fn()
const second = vi.fn()

deferral.defer(PANE, first)
deferral.defer(OTHER_PANE, second)
deferral.cancelAll()
vi.advanceTimersByTime(CODEX_ATTENTION_QUIET_MS * 4)

expect(first).not.toHaveBeenCalled()
expect(second).not.toHaveBeenCalled()
expect(deferral.hasPending(OTHER_PANE)).toBe(false)
})

it('honors an injected window for callers that need a different cadence', () => {
const deferral = new SyntheticPermissionBellDeferral(50)
const emit = vi.fn()

deferral.defer(PANE, emit)
vi.advanceTimersByTime(50)

expect(emit).toHaveBeenCalledTimes(1)
})
})
48 changes: 48 additions & 0 deletions src/main/synthetic-permission-bell-deferral.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { CODEX_ATTENTION_QUIET_MS } from '../shared/codex-attention-quiet-window'

/**
* Holds back the BEL that main fabricates for a Codex permission pause until the quiet window
* elapses, so an "Approve for me" pause Codex resolves itself never rings the terminal bell
* (#13600). The OSC title still lands immediately — only the attention signal waits.
*
* Cancellation is the whole point: any later non-permission state for the pane drops the pending
* BEL, and a re-armed pause replaces its predecessor rather than queueing a second ring.
*/
export class SyntheticPermissionBellDeferral {
private readonly timers = new Map<string, ReturnType<typeof setTimeout>>()

constructor(private readonly quietMs: number = CODEX_ATTENTION_QUIET_MS) {}

/** Arm (or re-arm) the deferred BEL for `paneKey`; `emit` runs only if the window elapses uncancelled. */
defer(paneKey: string, emit: () => void): void {
this.cancel(paneKey)
const timer = setTimeout(() => {
this.timers.delete(paneKey)
emit()
}, this.quietMs)
// Why: a pending decorative bell must never hold the app open at quit.
timer.unref?.()
this.timers.set(paneKey, timer)
}

cancel(paneKey: string): boolean {
const timer = this.timers.get(paneKey)
if (timer === undefined) {
return false
}
clearTimeout(timer)
this.timers.delete(paneKey)
return true
}

cancelAll(): void {
for (const timer of this.timers.values()) {
clearTimeout(timer)
}
this.timers.clear()
}

hasPending(paneKey: string): boolean {
return this.timers.has(paneKey)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import type {
} from './agent-completion-coordinator-types'
import type { RuntimeTerminalProcessInspection } from '@/runtime/runtime-terminal-inspection'
import { isPiCompatibleAgentType } from '../../../../shared/pi-agent-kind'
import { CODEX_ATTENTION_QUIET_MS } from '../../../../shared/codex-attention-quiet-window'
import {
titleHasExplicitAgentIdentity,
titleIsInconclusiveNativeDroidTitle
Expand Down Expand Up @@ -49,8 +50,6 @@ const PENDING_TITLE_TTL_MS = Math.max(2_000, INSPECTION_TIMEOUT_MS + 500)
const PENDING_TITLE_MAX_TTL_MS = Math.max(30_000, PENDING_TITLE_TTL_MS)
const COMPLETION_REPLAY_GUARD_MS = 1_000
const HOOK_DONE_QUIET_MS = 1_500
// Why: under "Approve for me" Codex resumes almost immediately, so debounce the OS attention notification so a self-resolving pause raises no false banner (#8387).
const CODEX_ATTENTION_QUIET_MS = 1_500

const POLL_TIER_INTERVAL_MS: Record<PollCadenceTier, number> = {
active: ACTIVE_POLL_INTERVAL_MS,
Expand Down
Loading
Loading