Fix agent completion notification dedupe (#2304)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Brennan Benson
2026-05-19 00:28:55 -07:00
committed by GitHub
co-authored by Orca
parent 32866da8f0
commit cf614b5f4f
25 changed files with 2531 additions and 82 deletions
+37
View File
@@ -2179,6 +2179,43 @@ describe('Endpoint file lifecycle', () => {
}
})
it('buildPtyEnv includes namespaced ORCA_AGENT_HOOK_ENDPOINT for development servers', async () => {
const server = new AgentHookServer()
await server.start({
env: 'development',
userDataPath,
endpointNamespace: 'com.stablyai.orca.dev.test123'
})
try {
const env = server.buildPtyEnv()
expect(env.ORCA_AGENT_HOOK_ENDPOINT).toBe(server.endpointFilePath)
expect(env.ORCA_AGENT_HOOK_ENDPOINT).toContain('com.stablyai.orca.dev.test123')
expect(env.ORCA_AGENT_HOOK_PORT).toBeTruthy()
expect(env.ORCA_AGENT_HOOK_TOKEN).toBeTruthy()
} finally {
server.stop()
}
})
it('keeps endpoint files separate for parallel dev namespaces', async () => {
const firstServer = new AgentHookServer()
const secondServer = new AgentHookServer()
await firstServer.start({ env: 'development', userDataPath, endpointNamespace: 'dev-a' })
await secondServer.start({ env: 'development', userDataPath, endpointNamespace: 'dev-b' })
try {
expect(firstServer.endpointFilePath).not.toBe(secondServer.endpointFilePath)
expect(firstServer.buildPtyEnv().ORCA_AGENT_HOOK_ENDPOINT).toBe(firstServer.endpointFilePath)
expect(secondServer.buildPtyEnv().ORCA_AGENT_HOOK_ENDPOINT).toBe(
secondServer.endpointFilePath
)
expect(existsSync(firstServer.endpointFilePath!)).toBe(true)
expect(existsSync(secondServer.endpointFilePath!)).toBe(true)
} finally {
firstServer.stop()
secondServer.stop()
}
})
it('buildPtyEnv omits ORCA_AGENT_HOOK_ENDPOINT when no userDataPath was provided', async () => {
// Why: the endpoint file is opt-in via start({ userDataPath }). In tests
// and in the packaged main-process path where userData is unset for any
+14 -2
View File
@@ -597,7 +597,11 @@ export class AgentHookServer {
this.applyNormalizedStatus(event)
}
async start(options?: { env?: string; userDataPath?: string }): Promise<void> {
async start(options?: {
env?: string
userDataPath?: string
endpointNamespace?: string
}): Promise<void> {
if (this.server) {
return
}
@@ -606,7 +610,12 @@ export class AgentHookServer {
this.env = options.env
}
if (options?.userDataPath) {
this.endpointDir = join(options.userDataPath, 'agent-hooks')
// Why: dev builds share one userData path, so callers can namespace the
// endpoint file by dev instance while packaged builds keep the stable path
// that lets long-lived PTYs reconnect after app restart.
this.endpointDir = options.endpointNamespace
? join(options.userDataPath, 'agent-hooks', options.endpointNamespace)
: join(options.userDataPath, 'agent-hooks')
this.endpointFilePathCache = join(this.endpointDir, getEndpointFileName())
this.lastStatusFilePath = join(this.endpointDir, LAST_STATUS_FILE_NAME)
}
@@ -781,6 +790,9 @@ export class AgentHookServer {
ORCA_AGENT_HOOK_ENV: this.env,
ORCA_AGENT_HOOK_VERSION: ORCA_HOOK_PROTOCOL_VERSION
}
// Why: managed hooks source this file at invocation time. Packaged builds
// use a stable file for restart handoff; dev callers pass a per-instance
// namespace so parallel `pnpm dev` runs do not steal each other's hooks.
if (this.endpointFileWritten && this.endpointFilePathCache) {
env.ORCA_AGENT_HOOK_ENDPOINT = this.endpointFilePathCache
}
+11 -7
View File
@@ -108,6 +108,9 @@ let watcherShutdownDone = false
let automations: AutomationService | null = null
const isServeMode = process.argv.includes('--serve')
const devInstanceIdentity = getDevInstanceIdentity(is.dev)
const devAgentHookEndpointNamespace = devInstanceIdentity.isDev
? devInstanceIdentity.appUserModelId
: undefined
installUncaughtPipeErrorGuard()
// Why: propagate the Orca app version into `process.env` so PTY-env
@@ -164,12 +167,11 @@ function focusExistingWindow(): void {
//
// Why skip in dev: engineers routinely run `pnpm dev` in parallel from
// multiple worktrees while shipping features, and the lock makes the second
// `pnpm dev` exit silently. In dev we accept that `orca-runtime.json` and
// `endpoint.env` may race (the bundled `orca-dev` CLI / agent hooks route
// to whichever instance wrote last). The dev build is not used for real
// agent work, so that routing ambiguity is acceptable. Packaged Orca keeps
// the lock to protect against the corruption documented in PR #1326 /
// issue #1312.
// `pnpm dev` exit silently. In dev we accept that `orca-runtime.json` may race
// (the bundled `orca-dev` CLI routes to whichever instance wrote last). Agent
// hook endpoint files are namespaced per dev instance when the hook server
// starts below. Packaged Orca keeps the lock to protect against the corruption
// documented in PR #1326 / issue #1312.
const hasSingleInstanceLock =
is.dev && !isServeMode ? true : acquireSingleInstanceLock(app, focusExistingWindow)
if (!hasSingleInstanceLock) {
@@ -941,7 +943,9 @@ app.whenReady().then(async () => {
env: app.isPackaged ? 'production' : 'development',
// Why: hooks source this endpoint file at invocation time, so old PTY
// env still reaches the current Orca process after an app restart.
userDataPath: app.getPath('userData')
// Dev uses a namespace because all worktrees share `orca-dev`.
userDataPath: app.getPath('userData'),
endpointNamespace: devAgentHookEndpointNamespace
}),
onDaemonError: (error) => {
console.error('[daemon] Failed to start daemon PTY provider, falling back to local:', error)
+34
View File
@@ -579,6 +579,40 @@ describe('registerPtyHandlers', () => {
expect(env.ORCA_AGENT_HOOK_TOKEN).toBe('agent-token')
})
it('strips stale inherited hook receiver env before injecting this runtime', async () => {
const env = await spawnAndGetEnv({
ORCA_AGENT_HOOK_PORT: '1111',
ORCA_AGENT_HOOK_TOKEN: 'stale-token',
ORCA_AGENT_HOOK_ENV: 'production',
ORCA_AGENT_HOOK_VERSION: 'stale-version',
ORCA_AGENT_HOOK_ENDPOINT: '/tmp/stale-endpoint.env'
})
expect(env.ORCA_AGENT_HOOK_PORT).toBe('5678')
expect(env.ORCA_AGENT_HOOK_TOKEN).toBe('agent-token')
expect(env.ORCA_AGENT_HOOK_ENV).toBeUndefined()
expect(env.ORCA_AGENT_HOOK_VERSION).toBeUndefined()
expect(env.ORCA_AGENT_HOOK_ENDPOINT).toBeUndefined()
})
it('does not leak inherited hook receiver env if the hook server is unavailable', async () => {
buildAgentHookEnvMock.mockReturnValueOnce({})
const env = await spawnAndGetEnv({
ORCA_AGENT_HOOK_PORT: '1111',
ORCA_AGENT_HOOK_TOKEN: 'stale-token',
ORCA_AGENT_HOOK_ENV: 'production',
ORCA_AGENT_HOOK_VERSION: 'stale-version',
ORCA_AGENT_HOOK_ENDPOINT: '/tmp/stale-endpoint.env'
})
expect(env.ORCA_AGENT_HOOK_PORT).toBeUndefined()
expect(env.ORCA_AGENT_HOOK_TOKEN).toBeUndefined()
expect(env.ORCA_AGENT_HOOK_ENV).toBeUndefined()
expect(env.ORCA_AGENT_HOOK_VERSION).toBeUndefined()
expect(env.ORCA_AGENT_HOOK_ENDPOINT).toBeUndefined()
})
it('prepends local git/gh attribution shims when attribution is enabled', async () => {
const env = await spawnAndGetEnv(undefined, undefined, undefined, () => ({
enableGitHubAttribution: true
+11
View File
@@ -80,6 +80,14 @@ const ptyPaneKey = new Map<string, string>()
// Kept in lock-step with ptyPaneKey via the same spawn and teardown sites.
const paneKeyPtyId = new Map<string, string>()
const AGENT_HOOK_RUNTIME_ENV_KEYS = [
'ORCA_AGENT_HOOK_PORT',
'ORCA_AGENT_HOOK_TOKEN',
'ORCA_AGENT_HOOK_ENV',
'ORCA_AGENT_HOOK_VERSION',
'ORCA_AGENT_HOOK_ENDPOINT'
] as const
export function getPtyIdForPaneKey(paneKey: string): string | undefined {
return paneKeyPtyId.get(paneKey)
}
@@ -305,6 +313,9 @@ export function buildPtyHostEnv(
// must inject the loopback receiver coordinates before the agent starts.
// Without these env vars the global hook config cannot map callbacks back
// to the correct Orca pane.
for (const key of AGENT_HOOK_RUNTIME_ENV_KEYS) {
delete baseEnv[key]
}
Object.assign(baseEnv, agentHookServer.buildPtyEnv())
// Why: PI_CODING_AGENT_DIR owns Pi's full config/session root. Build a
@@ -47,6 +47,25 @@ type NotificationsPaneProps = {
updateSettings: (updates: Partial<GlobalSettings>) => void
}
function getRendererNotificationPermission(): NotificationPermission | null {
if (typeof window.Notification === 'undefined') {
return null
}
return window.Notification.permission
}
function showNotificationPermissionDeniedToast(): void {
toast.error('Notifications are blocked in macOS', {
description: 'Enable notifications for this Orca app in System Settings.',
action: {
label: 'Open Settings',
onClick: () => {
void window.api.notifications.openSystemSettings()
}
}
})
}
export function NotificationsPane({
settings,
updateSettings
@@ -64,6 +83,20 @@ export function NotificationsPane({
}
const handleSendTestNotification = async (): Promise<void> => {
// Why: Electron main cannot reliably read macOS notification authorization,
// but the renderer exposes it. Without this check, dev builds can report
// "sent" while macOS silently drops the notification.
if (getRendererNotificationPermission() === 'denied') {
showNotificationPermissionDeniedToast()
return
}
const permissionStatus = await window.api.notifications.getPermissionStatus()
if (!permissionStatus.supported) {
toast.error('Notifications are not supported on this system')
return
}
const result = await window.api.notifications.dispatch({ source: 'test' })
if (result.delivered) {
// Why: the Test button must always play through, even if the user clicks
@@ -77,7 +110,19 @@ export function NotificationsPane({
return
}
toast.success('Test notification sent')
return
}
if (getRendererNotificationPermission() === 'denied') {
showNotificationPermissionDeniedToast()
return
}
toast.error(
result.reason === 'disabled'
? 'Notifications are disabled'
: 'Test notification was not delivered'
)
}
const handleChooseSound = async (): Promise<void> => {
@@ -0,0 +1,25 @@
import type { ParsedAgentStatusPayload } from '../../../../shared/agent-status-types'
import type { GlobalSettings } from '../../../../shared/types'
import type { RuntimeTerminalProcessInspection } from '@/runtime/runtime-terminal-inspection'
export type AgentCompletionCoordinatorOptions = {
paneKey: string
getPtyId: () => string | null
getSettings: () => Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined
inspectProcess: (
settings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined,
ptyId: string
) => Promise<RuntimeTerminalProcessInspection>
dispatchCompletion: (title: string) => void
isLive: () => boolean
}
export type AgentCompletionCoordinator = {
observeTitle: (title: string) => void
observeClassifiedTitleCompletion: (title: string) => void
observeTitleWorking: () => void
observeHookStatus: (payload: ParsedAgentStatusPayload) => void
startProcessTracking: () => void
resetCompletionState: (options?: { requireFreshWorking?: boolean }) => void
dispose: () => void
}
@@ -0,0 +1,410 @@
/* oxlint-disable max-lines */
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createAgentCompletionCoordinator } from './agent-completion-coordinator'
import { resetAgentProcessInspectionQueueForTests } from './agent-process-inspection-queue'
import type { RuntimeTerminalProcessInspection } from '@/runtime/runtime-terminal-inspection'
async function flushAsyncTicks(count = 4): Promise<void> {
for (let i = 0; i < count; i++) {
await Promise.resolve()
}
}
function processResult(foregroundProcess: string | null): RuntimeTerminalProcessInspection {
return { foregroundProcess, hasChildProcesses: foregroundProcess !== null }
}
function createDeferred<T>(): { promise: Promise<T>; resolve: (value: T) => void } {
let resolveDeferred!: (value: T) => void
const promise = new Promise<T>((resolve) => {
resolveDeferred = resolve
})
return { promise, resolve: resolveDeferred }
}
describe('agent completion coordinator', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.spyOn(Math, 'random').mockReturnValue(0.5)
})
afterEach(() => {
resetAgentProcessInspectionQueueForTests()
vi.useRealTimers()
vi.restoreAllMocks()
})
it('clears process evidence after agent exit so later non-agent spinner titles do not notify', async () => {
let foregroundProcess: string | null = 'codex'
const dispatchCompletion = vi.fn()
const coordinator = createAgentCompletionCoordinator({
paneKey: 'tab-1:leaf-1',
getPtyId: () => 'pty-1',
getSettings: () => null,
inspectProcess: vi.fn(async () => processResult(foregroundProcess)),
dispatchCompletion,
isLive: () => true
})
coordinator.startProcessTracking()
vi.advanceTimersByTime(2_000)
await flushAsyncTicks()
coordinator.observeTitle('⠋ codex')
coordinator.observeTitle('codex done')
expect(dispatchCompletion).toHaveBeenCalledTimes(1)
foregroundProcess = 'zsh'
vi.advanceTimersByTime(750)
await flushAsyncTicks()
expect(dispatchCompletion).toHaveBeenCalledTimes(1)
dispatchCompletion.mockClear()
coordinator.observeTitle('⠋ experimental-agent-observability')
coordinator.observeTitle('experimental-agent-observability')
await flushAsyncTicks()
expect(dispatchCompletion).not.toHaveBeenCalled()
})
it('suppresses process-exit backstop after a title completion already notified the turn', async () => {
let foregroundProcess: string | null = 'codex'
const dispatchCompletion = vi.fn()
const coordinator = createAgentCompletionCoordinator({
paneKey: 'tab-1:leaf-1',
getPtyId: () => 'pty-1',
getSettings: () => null,
inspectProcess: vi.fn(async () => processResult(foregroundProcess)),
dispatchCompletion,
isLive: () => true
})
coordinator.startProcessTracking()
vi.advanceTimersByTime(2_000)
await flushAsyncTicks()
coordinator.observeTitle('⠋ codex')
coordinator.observeTitle('codex done')
foregroundProcess = null
vi.advanceTimersByTime(750)
await flushAsyncTicks()
expect(dispatchCompletion).toHaveBeenCalledTimes(1)
expect(dispatchCompletion).toHaveBeenCalledWith('codex done')
})
it('suppresses same-turn title completion after a hook completion already notified', () => {
const dispatchCompletion = vi.fn()
const coordinator = createAgentCompletionCoordinator({
paneKey: 'tab-1:leaf-1',
getPtyId: () => 'pty-1',
getSettings: () => null,
inspectProcess: vi.fn(),
dispatchCompletion,
isLive: () => true
})
coordinator.observeHookStatus({
state: 'working',
prompt: '',
agentType: 'codex'
})
coordinator.observeHookStatus({
state: 'done',
prompt: '',
agentType: 'codex'
})
coordinator.observeClassifiedTitleCompletion('codex done')
expect(dispatchCompletion).toHaveBeenCalledTimes(1)
expect(dispatchCompletion).toHaveBeenCalledWith('codex')
})
it('ignores stale working title state after a hook completion already notified', () => {
const dispatchCompletion = vi.fn()
const coordinator = createAgentCompletionCoordinator({
paneKey: 'tab-1:leaf-1',
getPtyId: () => 'pty-1',
getSettings: () => null,
inspectProcess: vi.fn(),
dispatchCompletion,
isLive: () => true
})
coordinator.observeHookStatus({
state: 'done',
prompt: '',
agentType: 'codex'
})
coordinator.observeTitle('⠋ codex')
coordinator.observeTitle('codex done')
expect(dispatchCompletion).toHaveBeenCalledTimes(1)
expect(dispatchCompletion).toHaveBeenCalledWith('codex')
})
it('suppresses delayed title completion after process inspection changes sessions', async () => {
const dispatchCompletion = vi.fn()
const coordinator = createAgentCompletionCoordinator({
paneKey: 'tab-1:leaf-1',
getPtyId: () => 'pty-1',
getSettings: () => null,
inspectProcess: vi.fn(async () => processResult('codex')),
dispatchCompletion,
isLive: () => true
})
coordinator.observeHookStatus({
state: 'done',
prompt: '',
agentType: 'codex'
})
coordinator.startProcessTracking()
vi.advanceTimersByTime(2_000)
await flushAsyncTicks()
coordinator.observeClassifiedTitleCompletion('codex done')
expect(dispatchCompletion).toHaveBeenCalledTimes(1)
expect(dispatchCompletion).toHaveBeenCalledWith('codex')
})
it('suppresses late process-exit backstop after process inspection follows hook completion', async () => {
let foregroundProcess: string | null = 'codex'
const dispatchCompletion = vi.fn()
const coordinator = createAgentCompletionCoordinator({
paneKey: 'tab-1:leaf-1',
getPtyId: () => 'pty-1',
getSettings: () => null,
inspectProcess: vi.fn(async () => processResult(foregroundProcess)),
dispatchCompletion,
isLive: () => true
})
coordinator.observeHookStatus({
state: 'done',
prompt: '',
agentType: 'codex'
})
coordinator.startProcessTracking()
vi.advanceTimersByTime(2_000)
await flushAsyncTicks()
foregroundProcess = null
vi.advanceTimersByTime(750)
await flushAsyncTicks()
expect(dispatchCompletion).toHaveBeenCalledTimes(1)
expect(dispatchCompletion).toHaveBeenCalledWith('codex')
})
it('keeps duplicate done-only hooks inside replay guard suppressed after process inspection', async () => {
const inspection = createDeferred<RuntimeTerminalProcessInspection>()
const dispatchCompletion = vi.fn()
const coordinator = createAgentCompletionCoordinator({
paneKey: 'tab-1:leaf-1',
getPtyId: () => 'pty-1',
getSettings: () => null,
inspectProcess: vi.fn(() => inspection.promise),
dispatchCompletion,
isLive: () => true
})
coordinator.startProcessTracking()
vi.advanceTimersByTime(2_000)
await flushAsyncTicks()
coordinator.observeHookStatus({
state: 'done',
prompt: '',
agentType: 'codex'
})
inspection.resolve(processResult('codex'))
await flushAsyncTicks()
coordinator.observeHookStatus({
state: 'done',
prompt: '',
agentType: 'codex'
})
expect(dispatchCompletion).toHaveBeenCalledTimes(1)
})
it('can require a fresh working signal after completion state reset', () => {
const dispatchCompletion = vi.fn()
const coordinator = createAgentCompletionCoordinator({
paneKey: 'tab-1:leaf-1',
getPtyId: () => 'pty-1',
getSettings: () => null,
inspectProcess: vi.fn(),
dispatchCompletion,
isLive: () => true
})
coordinator.observeHookStatus({
state: 'done',
prompt: '',
agentType: 'codex'
})
coordinator.resetCompletionState({ requireFreshWorking: true })
coordinator.observeClassifiedTitleCompletion('codex done')
coordinator.observeHookStatus({
state: 'done',
prompt: '',
agentType: 'codex'
})
expect(dispatchCompletion).toHaveBeenCalledTimes(1)
coordinator.observeHookStatus({
state: 'working',
prompt: '',
agentType: 'codex'
})
coordinator.observeHookStatus({
state: 'done',
prompt: '',
agentType: 'codex'
})
expect(dispatchCompletion).toHaveBeenCalledTimes(2)
})
it('ignores process inspections that resolve after completion state reset', async () => {
const inspection = createDeferred<RuntimeTerminalProcessInspection>()
const dispatchCompletion = vi.fn()
const coordinator = createAgentCompletionCoordinator({
paneKey: 'tab-1:leaf-1',
getPtyId: () => 'pty-1',
getSettings: () => null,
inspectProcess: vi.fn(() => inspection.promise),
dispatchCompletion,
isLive: () => true
})
coordinator.startProcessTracking()
vi.advanceTimersByTime(2_000)
coordinator.resetCompletionState({ requireFreshWorking: true })
inspection.resolve(processResult('codex'))
await flushAsyncTicks()
coordinator.observeTitle('⠋ experimental-agent-observability')
coordinator.observeTitle('experimental-agent-observability')
expect(dispatchCompletion).not.toHaveBeenCalled()
})
it('starts a fresh pending-title inspection after stale inspection resolves', async () => {
const firstInspection = createDeferred<RuntimeTerminalProcessInspection>()
const secondInspection = createDeferred<RuntimeTerminalProcessInspection>()
const inspectProcess = vi
.fn()
.mockReturnValueOnce(firstInspection.promise)
.mockReturnValueOnce(secondInspection.promise)
const dispatchCompletion = vi.fn()
const coordinator = createAgentCompletionCoordinator({
paneKey: 'tab-1:leaf-1',
getPtyId: () => 'pty-1',
getSettings: () => null,
inspectProcess,
dispatchCompletion,
isLive: () => true
})
coordinator.startProcessTracking()
vi.advanceTimersByTime(2_000)
await flushAsyncTicks()
coordinator.resetCompletionState({ requireFreshWorking: true })
coordinator.observeTitle('⠋ experimental-agent-observability')
coordinator.observeTitle('experimental-agent-observability')
firstInspection.resolve(processResult('codex'))
await flushAsyncTicks()
vi.advanceTimersByTime(2_000)
await flushAsyncTicks()
secondInspection.resolve(processResult('codex'))
await flushAsyncTicks()
expect(inspectProcess).toHaveBeenCalledTimes(2)
expect(dispatchCompletion).toHaveBeenCalledWith('experimental-agent-observability')
})
it('allows later done-only hook completions from the same long-lived process', () => {
const dispatchCompletion = vi.fn()
const coordinator = createAgentCompletionCoordinator({
paneKey: 'tab-1:leaf-1',
getPtyId: () => 'pty-1',
getSettings: () => null,
inspectProcess: vi.fn(),
dispatchCompletion,
isLive: () => true
})
coordinator.observeHookStatus({
state: 'done',
prompt: 'first task',
agentType: 'codex'
})
coordinator.observeHookStatus({
state: 'done',
prompt: 'first task',
agentType: 'codex'
})
expect(dispatchCompletion).toHaveBeenCalledTimes(1)
vi.advanceTimersByTime(1_000)
coordinator.observeHookStatus({
state: 'done',
prompt: 'second task',
agentType: 'codex'
})
expect(dispatchCompletion).toHaveBeenCalledTimes(2)
})
it.each([
'claude',
'codex',
'gemini',
'opencode',
'cursor',
'pi',
'droid',
'grok',
'copilot',
'hermes'
])('recognizes %s hook agent ids even when the binary name differs', (agentType) => {
const dispatchCompletion = vi.fn()
const coordinator = createAgentCompletionCoordinator({
paneKey: 'tab-1:leaf-1',
getPtyId: () => 'pty-1',
getSettings: () => null,
inspectProcess: vi.fn(),
dispatchCompletion,
isLive: () => true
})
coordinator.observeHookStatus({
state: 'done',
prompt: '',
agentType
})
expect(dispatchCompletion).toHaveBeenCalledWith(agentType)
})
it('keeps a generic title completion pending long enough for the first remote inspection', async () => {
const inspection = createDeferred<RuntimeTerminalProcessInspection>()
const dispatchCompletion = vi.fn()
const coordinator = createAgentCompletionCoordinator({
paneKey: 'tab-1:leaf-1',
getPtyId: () => 'remote:terminal-1',
getSettings: () => ({ activeRuntimeEnvironmentId: 'env-1' }),
inspectProcess: vi.fn(() => inspection.promise),
dispatchCompletion,
isLive: () => true
})
coordinator.observeTitle('⠋ experimental-agent-observability')
coordinator.observeTitle('experimental-agent-observability')
vi.advanceTimersByTime(10_500)
inspection.resolve(processResult('codex'))
await flushAsyncTicks()
expect(dispatchCompletion).toHaveBeenCalledWith('experimental-agent-observability')
})
})
@@ -0,0 +1,388 @@
/* oxlint-disable max-lines */
import { detectAgentStatusFromTitle, type AgentStatus } from '../../../../shared/agent-detection'
import type { ParsedAgentStatusPayload } from '../../../../shared/agent-status-types'
import {
isRecognizedAgentType,
recognizeAgentProcess,
type RecognizedAgentProcess
} from '../../../../shared/agent-process-recognition'
import {
enqueueAgentProcessInspection,
type InspectionPriority
} from './agent-process-inspection-queue'
import type {
AgentCompletionCoordinator,
AgentCompletionCoordinatorOptions
} from './agent-completion-coordinator-types'
import type { RuntimeTerminalProcessInspection } from '@/runtime/runtime-terminal-inspection'
import {
titleHasExplicitAgentIdentity,
titleIsInconclusiveNativeDroidTitle
} from './title-agent-identity'
type CompletionSource = 'hook' | 'title' | 'process-exit'
const IDLE_POLL_INTERVAL_MS = 2_000
const ACTIVE_POLL_INTERVAL_MS = 750
const INSPECTION_TIMEOUT_MS = 15_000
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
function isCompletionHookState(state: ParsedAgentStatusPayload['state']): boolean {
return state === 'done' || state === 'waiting' || state === 'blocked'
}
export function createAgentCompletionCoordinator(
options: AgentCompletionCoordinatorOptions
): AgentCompletionCoordinator {
let disposed = false
let agentIdentityEstablished = false
let hasAgentRunEvidence = false
let workingStatusObserved = false
let lastTitleStatus: AgentStatus | null = null
let currentTurn = 0
let processSession = 0
let lastCompletionToken: string | null = null
let lastCompletionAt = 0
let lastCompletedTurn: number | null = null
let lastCompletionSource: CompletionSource | null = null
let lastForegroundAgent: RecognizedAgentProcess | null = null
let requiresFreshWorking = false
let pollTimer: ReturnType<typeof setTimeout> | null = null
let pendingTitleTimer: ReturnType<typeof setTimeout> | null = null
let pendingTitle: {
title: string
expiresAt: number
maxExpiresAt: number
firstInspectionFinished: boolean
} | null = null
let inspectionInFlight = false
let inspectionGeneration = 0
let consecutiveInspectionErrors = 0
function clearPollTimer(): void {
if (pollTimer === null) {
return
}
clearTimeout(pollTimer)
pollTimer = null
}
function clearPendingTitleTimer(): void {
if (pendingTitleTimer === null) {
return
}
clearTimeout(pendingTitleTimer)
pendingTitleTimer = null
}
function establishAgentEvidence(): void {
agentIdentityEstablished = true
hasAgentRunEvidence = true
dispatchPendingTitleIfEligible()
}
function clearAgentRunEvidence(): void {
agentIdentityEstablished = false
hasAgentRunEvidence = false
workingStatusObserved = false
dropPendingTitle()
}
function completionToken(source: CompletionSource): string {
if (workingStatusObserved) {
return `turn:${currentTurn}`
}
if (lastForegroundAgent) {
return `process:${processSession}`
}
return `${source}:${currentTurn}:${processSession}`
}
function dispatchCompletion(source: CompletionSource, title: string): void {
if (requiresFreshWorking || lastCompletedTurn === currentTurn) {
return
}
if (!options.isLive() || !hasAgentRunEvidence) {
return
}
const now = Date.now()
const token = completionToken(source)
if (token === lastCompletionToken && now - lastCompletionAt < COMPLETION_REPLAY_GUARD_MS) {
return
}
lastCompletionToken = token
lastCompletionAt = now
lastCompletedTurn = currentTurn
lastCompletionSource = source
workingStatusObserved = false
options.dispatchCompletion(title)
}
function dropPendingTitle(): void {
clearPendingTitleTimer()
pendingTitle = null
}
function dispatchPendingTitleIfEligible(): void {
if (!pendingTitle || !agentIdentityEstablished || !hasAgentRunEvidence) {
return
}
const title = pendingTitle.title
dropPendingTitle()
dispatchCompletion('title', title)
}
function schedulePendingTitleExpiry(): void {
clearPendingTitleTimer()
const pending = pendingTitle
if (!pending) {
return
}
const remaining = pending.expiresAt - Date.now()
if (remaining <= 0) {
pendingTitle = null
scheduleNextPoll()
return
}
pendingTitleTimer = setTimeout(() => {
pendingTitleTimer = null
if (!pendingTitle) {
return
}
if (!pendingTitle.firstInspectionFinished && Date.now() < pendingTitle.maxExpiresAt) {
pendingTitle.expiresAt = Math.min(Date.now() + 500, pendingTitle.maxExpiresAt)
schedulePendingTitleExpiry()
return
}
pendingTitle = null
scheduleNextPoll()
}, remaining)
}
function holdTitleCompletionPending(title: string): void {
const now = Date.now()
// Why: generic spinner titles can be just "⠋ cwd"; hold the completion
// only long enough for one foreground-process probe to prove an agent owns it.
pendingTitle = {
title,
expiresAt: Math.min(now + PENDING_TITLE_TTL_MS, now + PENDING_TITLE_MAX_TTL_MS),
maxExpiresAt: now + PENDING_TITLE_MAX_TTL_MS,
firstInspectionFinished: false
}
schedulePendingTitleExpiry()
requestInspection('pending-title')
}
function handleRecognizedProcess(process: RecognizedAgentProcess): void {
if (lastForegroundAgent?.agent !== process.agent) {
if (lastForegroundAgent && hasAgentRunEvidence) {
dispatchCompletion('process-exit', lastForegroundAgent.processName)
}
processSession += 1
}
lastForegroundAgent = process
establishAgentEvidence()
}
function handleProcessInspectionResult(result: RuntimeTerminalProcessInspection): void {
consecutiveInspectionErrors = 0
const recognized = recognizeAgentProcess(result.foregroundProcess)
if (recognized) {
handleRecognizedProcess(recognized)
} else if (lastForegroundAgent && hasAgentRunEvidence) {
const exited = lastForegroundAgent
dispatchCompletion('process-exit', exited.processName)
lastForegroundAgent = null
clearAgentRunEvidence()
} else {
lastForegroundAgent = null
clearAgentRunEvidence()
}
}
function requestInspection(priority: InspectionPriority): void {
if (disposed || inspectionInFlight || !options.isLive()) {
return
}
const ptyId = options.getPtyId()
if (!ptyId) {
return
}
inspectionInFlight = true
const generationAtRequest = inspectionGeneration
enqueueAgentProcessInspection({
priority,
run: async () => {
try {
const result = await options.inspectProcess(options.getSettings(), ptyId)
if (!disposed && generationAtRequest === inspectionGeneration) {
handleProcessInspectionResult(result)
}
} catch {
consecutiveInspectionErrors += 1
} finally {
inspectionInFlight = false
if (generationAtRequest !== inspectionGeneration) {
if (pendingTitle) {
requestInspection('pending-title')
} else {
scheduleNextPoll()
}
} else {
if (pendingTitle) {
pendingTitle.firstInspectionFinished = true
dispatchPendingTitleIfEligible()
schedulePendingTitleExpiry()
}
scheduleNextPoll()
}
}
}
})
}
function nextPollInterval(): number {
const base = lastForegroundAgent ? ACTIVE_POLL_INTERVAL_MS : IDLE_POLL_INTERVAL_MS
const backoff =
consecutiveInspectionErrors > 0
? Math.min(10_000, base * 2 ** consecutiveInspectionErrors)
: base
const jitter = 1 + (Math.random() * 0.2 - 0.1)
return Math.round(backoff * jitter)
}
function scheduleNextPoll(): void {
if (disposed || !options.isLive() || pollTimer !== null || pendingTitle) {
return
}
const ptyId = options.getPtyId()
if (!ptyId) {
return
}
pollTimer = setTimeout(() => {
pollTimer = null
requestInspection('cadence')
}, nextPollInterval())
}
function recordTitleWorking(): boolean {
if (
lastCompletionSource === 'hook' &&
Date.now() - lastCompletionAt < COMPLETION_REPLAY_GUARD_MS
) {
return false
}
workingStatusObserved = true
requiresFreshWorking = false
currentTurn += 1
dropPendingTitle()
return true
}
function observeTitleWorking(): void {
recordTitleWorking()
}
function observeTitle(title: string): void {
const status = detectAgentStatusFromTitle(title)
const isInconclusiveNativeDroidTitle = titleIsInconclusiveNativeDroidTitle(title)
if (titleHasExplicitAgentIdentity(title) && !isInconclusiveNativeDroidTitle) {
establishAgentEvidence()
}
if (status === 'working') {
if (!recordTitleWorking()) {
return
}
} else if (lastTitleStatus === 'working') {
if (isInconclusiveNativeDroidTitle) {
lastTitleStatus = status
return
}
if (agentIdentityEstablished && hasAgentRunEvidence) {
dispatchCompletion('title', title)
} else {
holdTitleCompletionPending(title)
}
}
lastTitleStatus = status
}
function observeClassifiedTitleCompletion(title: string): void {
if (titleHasExplicitAgentIdentity(title)) {
establishAgentEvidence()
}
if (agentIdentityEstablished && hasAgentRunEvidence) {
dispatchCompletion('title', title)
} else {
holdTitleCompletionPending(title)
}
}
function observeHookStatus(payload: ParsedAgentStatusPayload): void {
if (isRecognizedAgentType(payload.agentType)) {
establishAgentEvidence()
}
if (payload.state === 'working') {
workingStatusObserved = true
requiresFreshWorking = false
currentTurn += 1
dropPendingTitle()
return
}
if (isCompletionHookState(payload.state)) {
if (isRecognizedAgentType(payload.agentType)) {
establishAgentEvidence()
}
if (
!workingStatusObserved &&
lastCompletionSource === 'hook' &&
lastCompletedTurn === currentTurn &&
Date.now() - lastCompletionAt >= COMPLETION_REPLAY_GUARD_MS
) {
// Why: some hook producers only emit terminal states. Treat later
// done-only hook completions as new turns without letting title/process
// backstops duplicate the same completion.
currentTurn += 1
}
dispatchCompletion('hook', payload.agentType ?? options.paneKey)
}
}
function startProcessTracking(): void {
scheduleNextPoll()
}
function resetCompletionState(options: { requireFreshWorking?: boolean } = {}): void {
dropPendingTitle()
agentIdentityEstablished = false
hasAgentRunEvidence = false
workingStatusObserved = false
lastTitleStatus = null
lastCompletionToken = null
lastCompletionAt = 0
lastCompletedTurn = null
lastCompletionSource = null
lastForegroundAgent = null
requiresFreshWorking = options.requireFreshWorking ?? false
inspectionGeneration += 1
}
function dispose(): void {
disposed = true
clearPollTimer()
dropPendingTitle()
}
return {
observeTitle,
observeClassifiedTitleCompletion,
observeTitleWorking,
observeHookStatus,
startProcessTracking,
resetCompletionState,
dispose
}
}
@@ -0,0 +1,80 @@
export type InspectionPriority = 'cadence' | 'pending-title'
type InspectionTask = {
priority: InspectionPriority
run: () => Promise<void>
}
const MAX_CONCURRENT_INSPECTIONS = 4
const MAX_INSPECTION_STARTS_PER_SECOND = 8
let activeInspections = 0
let inspectionPumpTimer: ReturnType<typeof setTimeout> | null = null
const inspectionStarts: number[] = []
const inspectionQueue: InspectionTask[] = []
function canStartInspection(now: number): boolean {
if (inspectionStarts.length > 0 && now < inspectionStarts[0]!) {
inspectionStarts.length = 0
}
while (inspectionStarts.length > 0 && now - inspectionStarts[0]! >= 1_000) {
inspectionStarts.shift()
}
return (
activeInspections < MAX_CONCURRENT_INSPECTIONS &&
inspectionStarts.length < MAX_INSPECTION_STARTS_PER_SECOND
)
}
function scheduleInspectionPump(delayMs = 0): void {
if (inspectionPumpTimer !== null) {
return
}
inspectionPumpTimer = setTimeout(() => {
inspectionPumpTimer = null
pumpInspectionQueue()
}, delayMs)
}
function pumpInspectionQueue(): void {
const now = Date.now()
if (!canStartInspection(now)) {
scheduleInspectionPump(100)
return
}
const priorityIndex = inspectionQueue.findIndex((task) => task.priority === 'pending-title')
const next =
priorityIndex >= 0 ? inspectionQueue.splice(priorityIndex, 1)[0] : inspectionQueue.shift()
if (!next) {
return
}
activeInspections += 1
inspectionStarts.push(now)
void next.run().finally(() => {
activeInspections = Math.max(0, activeInspections - 1)
if (inspectionQueue.length > 0) {
scheduleInspectionPump()
}
})
if (inspectionQueue.length > 0) {
scheduleInspectionPump()
}
}
export function enqueueAgentProcessInspection(task: InspectionTask): void {
inspectionQueue.push(task)
pumpInspectionQueue()
}
export function resetAgentProcessInspectionQueueForTests(): void {
if (inspectionPumpTimer !== null) {
clearTimeout(inspectionPumpTimer)
inspectionPumpTimer = null
}
activeInspections = 0
inspectionStarts.length = 0
inspectionQueue.length = 0
}
@@ -63,6 +63,7 @@ type StoreState = {
consumePendingColdRestore: ReturnType<typeof vi.fn>
consumePendingSnapshot: ReturnType<typeof vi.fn>
agentStatusByPaneKey: Record<string, unknown>
setAgentStatus: ReturnType<typeof vi.fn>
removeAgentStatus: ReturnType<typeof vi.fn>
dropAgentStatus: ReturnType<typeof vi.fn>
}
@@ -308,6 +309,15 @@ describe('connectPanePty', () => {
consumePendingColdRestore: vi.fn(() => null),
consumePendingSnapshot: vi.fn(() => null),
agentStatusByPaneKey: {},
setAgentStatus: vi.fn((paneKey: string, payload: Record<string, unknown>) => {
mockStoreState.agentStatusByPaneKey[paneKey] = {
...payload,
paneKey,
updatedAt: Date.now(),
stateStartedAt: Date.now(),
stateHistory: []
}
}),
removeAgentStatus: vi.fn(),
dropAgentStatus: vi.fn()
} as StoreState
@@ -319,6 +329,8 @@ describe('connectPanePty', () => {
},
pty: {
signal: vi.fn(),
getForegroundProcess: vi.fn().mockResolvedValue(null),
hasChildProcesses: vi.fn().mockResolvedValue(false),
ackColdRestore: vi.fn(),
onClearBufferRequest: vi.fn(() => vi.fn()),
onSerializeBufferRequest: vi.fn(() => vi.fn()),
@@ -1333,6 +1345,480 @@ describe('connectPanePty', () => {
)
})
it('does not dispatch generic title completions when agent-complete notifications are disabled', async () => {
const { connectPanePty } = await import('./pty-connection')
const transport = createMockTransport('pty-codex')
transportFactoryQueue.push(transport)
vi.useFakeTimers()
mockStoreState.settings = {
...mockStoreState.settings,
notifications: {
enabled: true,
agentTaskComplete: false,
terminalBell: true,
suppressWhenFocused: false,
customSoundPath: null
}
}
const api = (
globalThis as unknown as {
window: { api: { pty: { getForegroundProcess: ReturnType<typeof vi.fn> } } }
}
).window.api
api.pty.getForegroundProcess.mockResolvedValue('codex')
const pane = createPane(1)
const manager = createManager(1)
const deps = createDeps()
connectPanePty(pane as never, manager as never, deps as never)
const titleHandler = createdTransportOptions[0]?.onTitleChange as
| ((title: string, rawTitle: string) => void)
| undefined
if (!titleHandler) {
throw new Error('Expected onTitleChange to be registered')
}
titleHandler('⠋ experimental-agent-observability', '⠋ experimental-agent-observability')
titleHandler('experimental-agent-observability', 'experimental-agent-observability')
await flushAsyncTicks()
vi.advanceTimersByTime(1_000)
expect(deps.dispatchNotification).not.toHaveBeenCalledWith(
expect.objectContaining({ source: 'agent-task-complete' })
)
})
it('does not replay disabled generic title completions after notifications are re-enabled', async () => {
const { connectPanePty } = await import('./pty-connection')
const transport = createMockTransport('pty-codex')
transportFactoryQueue.push(transport)
vi.useFakeTimers()
mockStoreState.settings = {
...mockStoreState.settings,
notifications: {
enabled: true,
agentTaskComplete: false,
terminalBell: true,
suppressWhenFocused: false,
customSoundPath: null
}
}
const inspection = createDeferred<string | null>()
const api = (
globalThis as unknown as {
window: { api: { pty: { getForegroundProcess: ReturnType<typeof vi.fn> } } }
}
).window.api
api.pty.getForegroundProcess.mockReturnValue(inspection.promise)
const pane = createPane(1)
const manager = createManager(1)
const deps = createDeps()
connectPanePty(pane as never, manager as never, deps as never)
const titleHandler = createdTransportOptions[0]?.onTitleChange as
| ((title: string, rawTitle: string) => void)
| undefined
if (!titleHandler) {
throw new Error('Expected onTitleChange to be registered')
}
titleHandler('⠋ experimental-agent-observability', '⠋ experimental-agent-observability')
titleHandler('experimental-agent-observability', 'experimental-agent-observability')
mockStoreState.settings = {
...mockStoreState.settings,
notifications: {
...mockStoreState.settings.notifications,
agentTaskComplete: true
}
}
inspection.resolve('codex')
await flushAsyncTicks()
vi.advanceTimersByTime(1_000)
expect(deps.dispatchNotification).not.toHaveBeenCalledWith(
expect.objectContaining({ source: 'agent-task-complete' })
)
})
it('clears title completion state when notifications are disabled', async () => {
const { connectPanePty } = await import('./pty-connection')
const transport = createMockTransport('pty-codex')
transportFactoryQueue.push(transport)
vi.useFakeTimers()
mockStoreState.settings = {
...mockStoreState.settings,
notifications: {
enabled: true,
agentTaskComplete: true,
terminalBell: true,
suppressWhenFocused: false,
customSoundPath: null
}
}
const pane = createPane(1)
const manager = createManager(1)
const deps = createDeps()
connectPanePty(pane as never, manager as never, deps as never)
const titleHandler = createdTransportOptions[0]?.onTitleChange as
| ((title: string, rawTitle: string) => void)
| undefined
if (!titleHandler) {
throw new Error('Expected onTitleChange to be registered')
}
titleHandler('Claude working', 'Claude working')
mockStoreState.settings = {
...mockStoreState.settings,
notifications: {
...mockStoreState.settings.notifications,
agentTaskComplete: false
}
}
notifyStoreSubscribers()
titleHandler('Claude done', 'Claude done')
mockStoreState.settings = {
...mockStoreState.settings,
notifications: {
...mockStoreState.settings.notifications,
agentTaskComplete: true
}
}
notifyStoreSubscribers()
titleHandler('Claude done', 'Claude done')
vi.advanceTimersByTime(1_000)
expect(deps.dispatchNotification).not.toHaveBeenCalledWith(
expect.objectContaining({ source: 'agent-task-complete' })
)
})
it('cancels scheduled agent completion when notifications are disabled before dispatch', async () => {
const { connectPanePty } = await import('./pty-connection')
const transport = createMockTransport('pty-codex')
transportFactoryQueue.push(transport)
vi.useFakeTimers()
mockStoreState.settings = {
...mockStoreState.settings,
notifications: {
enabled: true,
agentTaskComplete: true,
terminalBell: true,
suppressWhenFocused: false,
customSoundPath: null
}
}
const pane = createPane(1)
const manager = createManager(1)
const deps = createDeps()
connectPanePty(pane as never, manager as never, deps as never)
const idleHandler = createdTransportOptions[0]?.onAgentBecameIdle as
| ((title: string) => void)
| undefined
if (!idleHandler) {
throw new Error('Expected onAgentBecameIdle to be registered')
}
idleHandler('* Codex done')
mockStoreState.settings = {
...mockStoreState.settings,
notifications: {
...mockStoreState.settings.notifications,
agentTaskComplete: false
}
}
notifyStoreSubscribers()
mockStoreState.settings = {
...mockStoreState.settings,
notifications: {
...mockStoreState.settings.notifications,
agentTaskComplete: true
}
}
notifyStoreSubscribers()
vi.advanceTimersByTime(1_000)
expect(deps.dispatchNotification).not.toHaveBeenCalledWith(
expect.objectContaining({ source: 'agent-task-complete' })
)
})
it('restores a suppressed terminal bell when disabling pending agent completion', async () => {
const { connectPanePty } = await import('./pty-connection')
const transport = createMockTransport('pty-codex')
transportFactoryQueue.push(transport)
vi.useFakeTimers()
mockStoreState.settings = {
...mockStoreState.settings,
notifications: {
enabled: true,
agentTaskComplete: true,
terminalBell: true,
suppressWhenFocused: false,
customSoundPath: null
}
}
const pane = createPane(1)
const manager = createManager(1)
const deps = createDeps()
connectPanePty(pane as never, manager as never, deps as never)
const bellHandler = createdTransportOptions[0]?.onBell as (() => void) | undefined
const idleHandler = createdTransportOptions[0]?.onAgentBecameIdle as
| ((title: string) => void)
| undefined
if (!bellHandler || !idleHandler) {
throw new Error('Expected bell and idle handlers to be registered')
}
bellHandler()
idleHandler('* Codex done')
vi.advanceTimersByTime(250)
expect(deps.dispatchNotification).not.toHaveBeenCalledWith({ source: 'terminal-bell' })
mockStoreState.settings = {
...mockStoreState.settings,
notifications: {
...mockStoreState.settings.notifications,
agentTaskComplete: false
}
}
notifyStoreSubscribers()
vi.advanceTimersByTime(250)
expect(deps.dispatchNotification).toHaveBeenCalledWith({ source: 'terminal-bell' })
})
it('requires fresh working evidence after notifications are disabled', async () => {
const { connectPanePty } = await import('./pty-connection')
const transport = createMockTransport('pty-codex')
transportFactoryQueue.push(transport)
vi.useFakeTimers()
mockStoreState.settings = {
...mockStoreState.settings,
notifications: {
enabled: true,
agentTaskComplete: true,
terminalBell: true,
suppressWhenFocused: false,
customSoundPath: null
}
}
const pane = createPane(1)
const manager = createManager(1)
const deps = createDeps()
connectPanePty(pane as never, manager as never, deps as never)
const workingHandler = createdTransportOptions[0]?.onAgentBecameWorking as
| (() => void)
| undefined
const idleHandler = createdTransportOptions[0]?.onAgentBecameIdle as
| ((title: string) => void)
| undefined
if (!workingHandler || !idleHandler) {
throw new Error('Expected working and idle handlers to be registered')
}
workingHandler()
mockStoreState.settings = {
...mockStoreState.settings,
notifications: {
...mockStoreState.settings.notifications,
agentTaskComplete: false
}
}
notifyStoreSubscribers()
mockStoreState.settings = {
...mockStoreState.settings,
notifications: {
...mockStoreState.settings.notifications,
agentTaskComplete: true
}
}
notifyStoreSubscribers()
idleHandler('* Codex done')
vi.advanceTimersByTime(1_000)
expect(deps.dispatchNotification).not.toHaveBeenCalledWith(
expect.objectContaining({ source: 'agent-task-complete' })
)
})
it('requires fresh working evidence when notifications start disabled then re-enable', async () => {
const { connectPanePty } = await import('./pty-connection')
const transport = createMockTransport('pty-codex')
transportFactoryQueue.push(transport)
vi.useFakeTimers()
mockStoreState.settings = {
...mockStoreState.settings,
notifications: {
enabled: true,
agentTaskComplete: false,
terminalBell: true,
suppressWhenFocused: false,
customSoundPath: null
}
}
const pane = createPane(1)
const manager = createManager(1)
const deps = createDeps()
connectPanePty(pane as never, manager as never, deps as never)
const workingHandler = createdTransportOptions[0]?.onAgentBecameWorking as
| (() => void)
| undefined
const idleHandler = createdTransportOptions[0]?.onAgentBecameIdle as
| ((title: string) => void)
| undefined
if (!workingHandler || !idleHandler) {
throw new Error('Expected working and idle handlers to be registered')
}
workingHandler()
mockStoreState.settings = {
...mockStoreState.settings,
notifications: {
...mockStoreState.settings.notifications,
agentTaskComplete: true
}
}
notifyStoreSubscribers()
idleHandler('* Codex done')
vi.advanceTimersByTime(1_000)
expect(deps.dispatchNotification).not.toHaveBeenCalledWith(
expect.objectContaining({ source: 'agent-task-complete' })
)
})
it('dispatches agent-task-complete for generic Codex spinner titles after process identity is confirmed', async () => {
const { connectPanePty } = await import('./pty-connection')
const transport = createMockTransport('pty-codex')
transportFactoryQueue.push(transport)
vi.useFakeTimers()
const api = (
globalThis as unknown as {
window: { api: { pty: { getForegroundProcess: ReturnType<typeof vi.fn> } } }
}
).window.api
api.pty.getForegroundProcess.mockResolvedValue('codex')
const pane = createPane(1)
const manager = createManager(1)
const deps = createDeps()
connectPanePty(pane as never, manager as never, deps as never)
const titleHandler = createdTransportOptions[0]?.onTitleChange as
| ((title: string, rawTitle: string) => void)
| undefined
if (!titleHandler) {
throw new Error('Expected onTitleChange to be registered')
}
titleHandler('⠋ experimental-agent-observability', '⠋ experimental-agent-observability')
titleHandler('experimental-agent-observability', 'experimental-agent-observability')
await flushAsyncTicks()
vi.advanceTimersByTime(1000)
expect(deps.dispatchNotification).toHaveBeenCalledWith({
source: 'agent-task-complete',
terminalTitle: 'experimental-agent-observability',
paneKey: makePaneKey('tab-1', LEAF_1)
})
})
it('does not dispatch generic spinner completions when process inspection finds no agent', async () => {
const { connectPanePty } = await import('./pty-connection')
const transport = createMockTransport('pty-shell')
transportFactoryQueue.push(transport)
vi.useFakeTimers()
const api = (
globalThis as unknown as {
window: { api: { pty: { getForegroundProcess: ReturnType<typeof vi.fn> } } }
}
).window.api
api.pty.getForegroundProcess.mockResolvedValue('zsh')
const pane = createPane(1)
const manager = createManager(1)
const deps = createDeps()
connectPanePty(pane as never, manager as never, deps as never)
const titleHandler = createdTransportOptions[0]?.onTitleChange as
| ((title: string, rawTitle: string) => void)
| undefined
if (!titleHandler) {
throw new Error('Expected onTitleChange to be registered')
}
titleHandler('⠋ experimental-agent-observability', '⠋ experimental-agent-observability')
titleHandler('experimental-agent-observability', 'experimental-agent-observability')
await flushAsyncTicks()
vi.advanceTimersByTime(16_000)
expect(deps.dispatchNotification).not.toHaveBeenCalledWith(
expect.objectContaining({ source: 'agent-task-complete' })
)
})
it('dispatches agent-task-complete from recognized hook completion events', async () => {
const { connectPanePty } = await import('./pty-connection')
const transport = createMockTransport('pty-hook')
transportFactoryQueue.push(transport)
vi.useFakeTimers()
const pane = createPane(1)
const manager = createManager(1)
const deps = createDeps()
connectPanePty(pane as never, manager as never, deps as never)
const statusHandler = createdTransportOptions[0]?.onAgentStatus as
| ((payload: {
state: 'done'
prompt: string
agentType: 'codex'
lastAssistantMessage: string
}) => void)
| undefined
if (!statusHandler) {
throw new Error('Expected onAgentStatus to be registered')
}
statusHandler({
state: 'done',
prompt: 'finish the implementation',
agentType: 'codex',
lastAssistantMessage: 'Done.'
})
vi.advanceTimersByTime(250)
expect(deps.dispatchNotification).toHaveBeenCalledWith({
source: 'agent-task-complete',
terminalTitle: 'codex',
paneKey: makePaneKey('tab-1', LEAF_1)
})
})
it('restores a suppressed terminal bell when the pending agent completion is canceled', async () => {
const { connectPanePty } = await import('./pty-connection')
const transport = createMockTransport()
@@ -18,6 +18,7 @@ import { POST_REPLAY_MODE_RESET, POST_REPLAY_FOCUS_REPORTING_RESET } from './lay
import { warnTerminalLifecycleAnomaly } from './terminal-lifecycle-diagnostics'
import { registerPtySerializer, registerPtyTitleSource } from './pty-buffer-serializer'
import { getRemoteRuntimePtyEnvironmentId } from '@/runtime/runtime-terminal-stream'
import { inspectRuntimeTerminalProcess } from '@/runtime/runtime-terminal-inspection'
import {
discardTerminalOutput,
flushTerminalOutput,
@@ -29,6 +30,7 @@ import { createTerminalCommandLifecycle } from './terminal-command-lifecycle'
import { e2eConfig } from '@/lib/e2e-config'
import type { AgentStatusEntry } from '../../../../shared/agent-status-types'
import { isWebTerminalSurfaceTabId } from '@/runtime/web-terminal-surface-id'
import { createAgentCompletionCoordinator } from './agent-completion-coordinator'
const pendingSpawnByPaneKey = new Map<string, Promise<string | null>>()
const SSH_SESSION_EXPIRED_ERROR = 'SSH_SESSION_EXPIRED'
@@ -167,6 +169,9 @@ export function connectPanePty(
let agentTaskCompleteNotificationGraceTimer: ReturnType<typeof setTimeout> | null = null
let agentTaskCompleteNotificationMaxTimer: ReturnType<typeof setTimeout> | null = null
let agentTaskCompleteStatusUnsubscribe: (() => void) | null = null
let agentTaskCompleteSettingsUnsubscribe: (() => void) | null = null
let agentTaskCompleteNotificationGeneration = 0
let wasAgentTaskCompleteNotificationEnabled = isAgentTaskCompleteNotificationEnabled()
let terminalBellNotificationTimer: ReturnType<typeof setTimeout> | null = null
let pendingTerminalBellNotification = false
// Why: passphrase-gate waits register a teardown here so dispose() can
@@ -202,7 +207,25 @@ export function connectPanePty(
})
commandLifecycle.attachXtermConsumer(pane.terminal)
const agentCompletionCoordinator = createAgentCompletionCoordinator({
paneKey: cacheKey,
getPtyId: () => transport.getPtyId(),
getSettings: () => useAppStore.getState().settings,
inspectProcess: inspectRuntimeTerminalProcess,
dispatchCompletion: (title) => scheduleAgentTaskCompleteNotification(title),
isLive: () => {
if (disposed) {
return false
}
if (transport.getPtyId()) {
return true
}
return (useAppStore.getState().ptyIdsByTabId[deps.tabId] ?? []).length > 0
}
})
const onExit = (ptyId: string): void => {
agentCompletionCoordinator.dispose()
deps.syncPanePtyLayoutBinding(pane.id, null)
deps.clearRuntimePaneTitle(deps.tabId, pane.id)
deps.clearTabPtyId(deps.tabId, ptyId)
@@ -245,6 +268,9 @@ export function connectPanePty(
const onTitleChange = (title: string, rawTitle: string): void => {
manager.setPaneGpuRendering(pane.id, !isGeminiTerminalTitle(rawTitle))
deps.setRuntimePaneTitle(deps.tabId, pane.id, title)
if (syncAgentTaskCompleteNotificationEnabled()) {
agentCompletionCoordinator.observeTitle(rawTitle)
}
// Why: only the focused pane should drive the tab title — otherwise two
// agents in split panes cause rapid title flickering as each emits OSC
// sequences. Only the active split's title propagates to the tab. When
@@ -278,6 +304,7 @@ export function connectPanePty(
// Spawn completion is when a pane gains a concrete PTY ID. The initial
// frame-level sync often runs before that async result arrives.
scheduleRuntimeGraphSync()
agentCompletionCoordinator.startProcessTracking()
}
// ─── Attention signal: BEL ────────────────────────────────────────────
//
@@ -357,12 +384,43 @@ export function connectPanePty(
}
}
const syncAgentTaskCompleteNotificationEnabled = (): boolean => {
const enabled = isAgentTaskCompleteNotificationEnabled()
if (!enabled && wasAgentTaskCompleteNotificationEnabled) {
// Why: disabling notifications is an event-time boundary. Drop pending
// timers and coordinator state so completions observed while off cannot
// replay if the user turns the setting back on.
agentTaskCompleteNotificationGeneration += 1
clearPendingAgentTaskCompleteNotification()
agentCompletionCoordinator.resetCompletionState({ requireFreshWorking: true })
if (pendingTerminalBellNotification) {
scheduleTerminalBellNotification()
}
} else if (enabled && !wasAgentTaskCompleteNotificationEnabled) {
// Why: a pane may have observed work while agent-complete was disabled.
// Re-enabling should not let the next idle event notify for that old task.
agentCompletionCoordinator.resetCompletionState({ requireFreshWorking: true })
}
wasAgentTaskCompleteNotificationEnabled = enabled
return enabled
}
const scheduleAgentTaskCompleteNotification = (title: string): void => {
if (!syncAgentTaskCompleteNotificationEnabled()) {
return
}
clearPendingAgentTaskCompleteNotification()
let graceElapsed = false
const generationAtSchedule = agentTaskCompleteNotificationGeneration
const dispatch = (): void => {
clearPendingAgentTaskCompleteNotification()
if (
generationAtSchedule !== agentTaskCompleteNotificationGeneration ||
!syncAgentTaskCompleteNotificationEnabled()
) {
return
}
pendingTerminalBellNotification = false
clearTerminalBellNotificationTimer()
if (disposed) {
@@ -398,6 +456,9 @@ export function connectPanePty(
AGENT_TASK_COMPLETE_NOTIFICATION_MAX_WAIT_MS
)
}
agentTaskCompleteSettingsUnsubscribe = useAppStore.subscribe(() => {
syncAgentTaskCompleteNotificationEnabled()
})
// ─── Agent task-complete: OS notification, not tab attention ──────────
//
@@ -429,18 +490,14 @@ export function connectPanePty(
if (isClaudeAgent(title) && (settings === null || settings.promptCacheTimerEnabled)) {
deps.setCacheTimerStartedAt(cacheKey, Date.now())
}
// Why: this is the sole producer of 'agent-task-complete' in the renderer;
// removing it (as #944 did) leaves the user-facing Settings toggle with no
// events to fire. Dispatch is gated per-source in main; the main-process
// dedupe also collapses concurrent BEL + task-complete for the same
// worktree into a single notification.
// Why: title idle can beat the final hook status update by one event-loop
// turn; delay slightly so the notification can snapshot the richer status.
if (isAgentTaskCompleteNotificationEnabled()) {
scheduleAgentTaskCompleteNotification(title)
if (syncAgentTaskCompleteNotificationEnabled()) {
agentCompletionCoordinator.observeClassifiedTitleCompletion(title)
}
}
const onAgentBecameWorking = (): void => {
if (syncAgentTaskCompleteNotificationEnabled()) {
agentCompletionCoordinator.observeTitleWorking()
}
// Why: a new API call refreshes the prompt-cache TTL, so clear any running
// countdown. The timer will restart when the agent becomes idle again.
deps.setCacheTimerStartedAt(cacheKey, null)
@@ -524,6 +581,9 @@ export function connectPanePty(
const currentState = useAppStore.getState()
const title = currentState.runtimePaneTitlesByTabId?.[deps.tabId]?.[pane.id]
currentState.setAgentStatus(cacheKey, payload, title)
if (syncAgentTaskCompleteNotificationEnabled()) {
agentCompletionCoordinator.observeHookStatus(payload)
}
}
}
const transport = runtimeEnvironmentId
@@ -883,6 +943,7 @@ export function connectPanePty(
pane.container.dataset.ptyId = ptyId
deps.syncPanePtyLayoutBinding(pane.id, ptyId)
deps.updateTabPtyId(deps.tabId, ptyId)
agentCompletionCoordinator.startProcessTracking()
// Why: mobile terminal streaming needs the exact screen state from
// xterm.js. The shared helper installs both the SerializeAddon-backed
@@ -1356,6 +1417,7 @@ export function connectPanePty(
})
deps.syncPanePtyLayoutBinding(pane.id, attachPtyId)
deps.updateTabPtyId(deps.tabId, attachPtyId)
agentCompletionCoordinator.startProcessTracking()
} catch (err) {
reportError(err instanceof Error ? err.message : String(err))
deps.clearTabPtyId(deps.tabId, attachPtyId)
@@ -1403,6 +1465,9 @@ export function connectPanePty(
onError: reportError
}
})
// Why: attach sets the transport's PTY id; starting process
// tracking before this point no-ops because getPtyId() is empty.
agentCompletionCoordinator.startProcessTracking()
})
.catch((err) => {
reportError(err instanceof Error ? err.message : String(err))
@@ -1433,6 +1498,10 @@ export function connectPanePty(
pendingTerminalBellNotification = false
clearTerminalBellNotificationTimer()
discardTerminalOutput(pane.terminal)
if (agentTaskCompleteSettingsUnsubscribe !== null) {
agentTaskCompleteSettingsUnsubscribe()
agentTaskCompleteSettingsUnsubscribe = null
}
if (connectFrame !== null) {
// Why: StrictMode and split-group remounts can dispose a pane binding
// before its deferred PTY attach/spawn work runs. Cancel that queued
@@ -1449,6 +1518,7 @@ export function connectPanePty(
pendingGeometryReportRaf = null
}
commandLifecycle.dispose()
agentCompletionCoordinator.dispose()
}
}
}
@@ -0,0 +1,28 @@
import {
detectAgentStatusFromTitle,
isGeminiTerminalTitle,
isPiTerminalTitle
} from '../../../../shared/agent-detection'
const TITLE_AGENT_TOKEN_RE =
/(?<![\w./\\-])(claude|codex|gemini|opencode|openclaw|aider|copilot|cursor-agent|cursor|droid|hermes|grok|pi)(?![\w./\\-])/i
export function titleHasExplicitAgentIdentity(title: string): boolean {
if (!title) {
return false
}
if (
title.startsWith('. ') ||
title.startsWith('* ') ||
title.startsWith('\u2733') ||
isGeminiTerminalTitle(title) ||
isPiTerminalTitle(title)
) {
return true
}
return TITLE_AGENT_TOKEN_RE.test(title)
}
export function titleIsInconclusiveNativeDroidTitle(title: string): boolean {
return /\bDroid\b/i.test(title) && detectAgentStatusFromTitle(title) === null
}
@@ -119,72 +119,77 @@ function countReposNeedingNotificationDisambiguation(
* creates a new array reference on every store update and triggers
* excessive re-renders of TerminalPane.
*/
export function dispatchTerminalNotification(
worktreeId: string,
event: TerminalNotificationEvent
): void {
const state = useAppStore.getState()
// Why: shutdownWorktreeTerminals clears ptyIdsByTabId synchronously
// before killing PTYs asynchronously. Any notification arriving after
// that point is stale — e.g. a staleTitleTimer that fires 3 s after
// shutdown, or an agent tracker transition from accumulated closure
// state. Checking for live PTYs at dispatch time catches ALL phantom
// notification sources regardless of which timer or callback produced
// them, rather than trying to cancel each one individually.
if (!hasLivePtyForWorktree(state, worktreeId)) {
return
}
// Why: prefer worktree.repoId over string-parsing the worktreeId. The
// `${repoId}::${path}` format is an implementation detail of id
// construction; coupling the notification dispatcher to it would silently
// drop the repo label if that format ever changes. The worktree object
// itself is the source of truth for its owning repo.
const worktree = getWorktreeMapFromState(state).get(worktreeId)
const repo = worktree ? getRepoMapFromState(state).get(worktree.repoId) : null
const customSoundPath = state.settings?.notifications?.customSoundPath ?? null
const agentStatus =
event.source === 'agent-task-complete' && event.paneKey
? state.agentStatusByPaneKey[event.paneKey]
: undefined
// Why: pane keys are reused across turns. A rich OS notification must not
// expose the previous turn's prompt if the current turn has no fresh hook snapshot yet.
const hasFreshAgentStatus =
agentStatus && Date.now() - agentStatus.updatedAt <= AGENT_NOTIFICATION_SNAPSHOT_MAX_AGE_MS
const agentSnapshot = hasFreshAgentStatus
? {
agentType: agentStatus.agentType,
agentState: agentStatus.state,
agentPrompt: agentStatus.prompt,
agentToolName: agentStatus.toolName,
agentToolInput: agentStatus.toolInput,
agentLastAssistantMessage: agentStatus.lastAssistantMessage,
agentInterrupted: agentStatus.interrupted
}
: {}
void window.api.notifications
.dispatch({
source: event.source,
worktreeId,
repoLabel: repo?.displayName,
worktreeLabel: worktree?.displayName || worktree?.branch || worktreeId,
hasMultipleActiveRepos: countReposNeedingNotificationDisambiguation(state) > 1,
terminalTitle: event.terminalTitle,
isActiveWorktree: state.activeWorktreeId === worktreeId,
...agentSnapshot
})
.then((result) => {
if (result.delivered) {
void playDesktopNotificationSound(customSoundPath)
}
})
.catch((err) => {
console.warn('Failed to dispatch notification:', err)
})
}
export function useNotificationDispatch(
worktreeId: string
): (event: TerminalNotificationEvent) => void {
return useCallback(
(event: TerminalNotificationEvent) => {
const state = useAppStore.getState()
// Why: shutdownWorktreeTerminals clears ptyIdsByTabId synchronously
// before killing PTYs asynchronously. Any notification arriving after
// that point is stale — e.g. a staleTitleTimer that fires 3 s after
// shutdown, or an agent tracker transition from accumulated closure
// state. Checking for live PTYs at dispatch time catches ALL phantom
// notification sources regardless of which timer or callback produced
// them, rather than trying to cancel each one individually.
if (!hasLivePtyForWorktree(state, worktreeId)) {
return
}
// Why: prefer worktree.repoId over string-parsing the worktreeId. The
// `${repoId}::${path}` format is an implementation detail of id
// construction; coupling the notification dispatcher to it would silently
// drop the repo label if that format ever changes. The worktree object
// itself is the source of truth for its owning repo.
const worktree = getWorktreeMapFromState(state).get(worktreeId)
const repo = worktree ? getRepoMapFromState(state).get(worktree.repoId) : null
const customSoundPath = state.settings?.notifications?.customSoundPath ?? null
const agentStatus =
event.source === 'agent-task-complete' && event.paneKey
? state.agentStatusByPaneKey[event.paneKey]
: undefined
// Why: pane keys are reused across turns. A rich OS notification must not
// expose the previous turn's prompt if the current turn has no fresh hook snapshot yet.
const hasFreshAgentStatus =
agentStatus && Date.now() - agentStatus.updatedAt <= AGENT_NOTIFICATION_SNAPSHOT_MAX_AGE_MS
const agentSnapshot = hasFreshAgentStatus
? {
agentType: agentStatus.agentType,
agentState: agentStatus.state,
agentPrompt: agentStatus.prompt,
agentToolName: agentStatus.toolName,
agentToolInput: agentStatus.toolInput,
agentLastAssistantMessage: agentStatus.lastAssistantMessage,
agentInterrupted: agentStatus.interrupted
}
: {}
void window.api.notifications
.dispatch({
source: event.source,
worktreeId,
repoLabel: repo?.displayName,
worktreeLabel: worktree?.displayName || worktree?.branch || worktreeId,
hasMultipleActiveRepos: countReposNeedingNotificationDisambiguation(state) > 1,
terminalTitle: event.terminalTitle,
isActiveWorktree: state.activeWorktreeId === worktreeId,
...agentSnapshot
})
.then((result) => {
if (result.delivered) {
void playDesktopNotificationSound(customSoundPath)
}
})
.catch((err) => {
console.warn('Failed to dispatch notification:', err)
})
},
(event: TerminalNotificationEvent) => dispatchTerminalNotification(worktreeId, event),
[worktreeId]
)
}
@@ -0,0 +1,93 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { ParsedAgentStatusPayload } from '../../../shared/agent-status-types'
const dispatchTerminalNotification = vi.fn()
type MockStoreState = {
settings: {
notifications: {
enabled: boolean
agentTaskComplete: boolean
}
}
ptyIdsByTabId: Record<string, string[]>
}
let mockStoreState: MockStoreState
vi.mock('@/store', () => ({
useAppStore: {
getState: () => mockStoreState
}
}))
vi.mock('@/components/terminal-pane/use-notification-dispatch', () => ({
dispatchTerminalNotification
}))
function hookStatus(state: ParsedAgentStatusPayload['state']): ParsedAgentStatusPayload {
return {
state,
prompt: 'implement notifications',
agentType: 'codex',
lastAssistantMessage: state === 'done' ? 'Done.' : undefined
}
}
describe('agent hook completion notifications', () => {
const paneKey = 'tab-1:11111111-1111-4111-8111-111111111111'
beforeEach(() => {
vi.resetModules()
dispatchTerminalNotification.mockClear()
mockStoreState = {
settings: {
notifications: {
enabled: true,
agentTaskComplete: true
}
},
ptyIdsByTabId: {
'tab-1': ['pty-1']
}
}
})
it('requires fresh working after notifications start disabled and later re-enable', async () => {
mockStoreState.settings.notifications.agentTaskComplete = false
const {
observeAgentHookCompletionForNotification,
syncAgentHookCompletionNotificationSettings
} = await import('./agent-hook-completion-notifications')
mockStoreState.settings.notifications.agentTaskComplete = true
syncAgentHookCompletionNotificationSettings()
observeAgentHookCompletionForNotification({
paneKey,
worktreeId: 'wt-1',
payload: hookStatus('done')
})
expect(dispatchTerminalNotification).not.toHaveBeenCalled()
observeAgentHookCompletionForNotification({
paneKey,
worktreeId: 'wt-1',
payload: hookStatus('working')
})
observeAgentHookCompletionForNotification({
paneKey,
worktreeId: 'wt-1',
payload: hookStatus('done')
})
expect(dispatchTerminalNotification).toHaveBeenCalledWith(
'wt-1',
expect.objectContaining({
source: 'agent-task-complete',
paneKey
})
)
})
})
@@ -0,0 +1,123 @@
import { useAppStore } from '@/store'
import type { ParsedAgentStatusPayload } from '../../../shared/agent-status-types'
import { parsePaneKey } from '../../../shared/stable-pane-id'
import { createAgentCompletionCoordinator } from '@/components/terminal-pane/agent-completion-coordinator'
import type { AgentCompletionCoordinator } from '@/components/terminal-pane/agent-completion-coordinator-types'
import type { RuntimeTerminalProcessInspection } from '@/runtime/runtime-terminal-inspection'
import { dispatchTerminalNotification } from '@/components/terminal-pane/use-notification-dispatch'
type CoordinatorEntry = {
worktreeId: string
coordinator: AgentCompletionCoordinator
}
const coordinatorsByPaneKey = new Map<string, CoordinatorEntry>()
const paneKeysRequiringFreshWorking = new Set<string>()
let wasAgentTaskCompleteNotificationEnabled = isAgentTaskCompleteNotificationEnabled()
let requireFreshWorkingForNewCoordinators = !wasAgentTaskCompleteNotificationEnabled
function isAgentTaskCompleteNotificationEnabled(): boolean {
const notifications = useAppStore.getState().settings?.notifications
return notifications?.enabled !== false && notifications?.agentTaskComplete !== false
}
export function syncAgentHookCompletionNotificationSettings(): boolean {
const enabled = isAgentTaskCompleteNotificationEnabled()
if (!enabled || (!wasAgentTaskCompleteNotificationEnabled && enabled)) {
requireFreshWorkingForNewCoordinators = true
for (const [paneKey, entry] of coordinatorsByPaneKey) {
paneKeysRequiringFreshWorking.add(paneKey)
entry.coordinator.resetCompletionState({ requireFreshWorking: true })
}
}
wasAgentTaskCompleteNotificationEnabled = enabled
return enabled
}
function getPtyIdForPaneKey(paneKey: string): string | null {
const parsed = parsePaneKey(paneKey)
if (!parsed) {
return null
}
return useAppStore.getState().ptyIdsByTabId?.[parsed.tabId]?.[0] ?? null
}
function paneHasLivePty(paneKey: string): boolean {
return getPtyIdForPaneKey(paneKey) !== null
}
function createCoordinator(paneKey: string, worktreeId: string): AgentCompletionCoordinator {
return createAgentCompletionCoordinator({
paneKey,
getPtyId: () => getPtyIdForPaneKey(paneKey),
getSettings: () => useAppStore.getState().settings,
inspectProcess: async (): Promise<RuntimeTerminalProcessInspection> => ({
foregroundProcess: null,
hasChildProcesses: false
}),
dispatchCompletion: (title) => {
dispatchTerminalNotification(worktreeId, {
source: 'agent-task-complete',
terminalTitle: title,
paneKey
})
},
isLive: () => paneHasLivePty(paneKey)
})
}
export function observeAgentHookCompletionForNotification({
paneKey,
worktreeId,
payload
}: {
paneKey: string
worktreeId: string
payload: ParsedAgentStatusPayload
}): void {
if (!paneHasLivePty(paneKey)) {
coordinatorsByPaneKey.get(paneKey)?.coordinator.dispose()
coordinatorsByPaneKey.delete(paneKey)
paneKeysRequiringFreshWorking.delete(paneKey)
return
}
if (!syncAgentHookCompletionNotificationSettings()) {
paneKeysRequiringFreshWorking.add(paneKey)
coordinatorsByPaneKey
.get(paneKey)
?.coordinator.resetCompletionState({ requireFreshWorking: true })
return
}
let entry = coordinatorsByPaneKey.get(paneKey)
if (!entry || entry.worktreeId !== worktreeId) {
entry?.coordinator.dispose()
entry = {
worktreeId,
coordinator: createCoordinator(paneKey, worktreeId)
}
coordinatorsByPaneKey.set(paneKey, entry)
if (requireFreshWorkingForNewCoordinators) {
paneKeysRequiringFreshWorking.add(paneKey)
}
}
if (paneKeysRequiringFreshWorking.has(paneKey)) {
entry.coordinator.resetCompletionState({ requireFreshWorking: true })
}
entry.coordinator.observeHookStatus(payload)
if (payload.state === 'working') {
paneKeysRequiringFreshWorking.delete(paneKey)
}
}
export function resetAgentHookCompletionNotificationCoordinators(): void {
for (const entry of coordinatorsByPaneKey.values()) {
entry.coordinator.dispose()
}
coordinatorsByPaneKey.clear()
paneKeysRequiringFreshWorking.clear()
wasAgentTaskCompleteNotificationEnabled = isAgentTaskCompleteNotificationEnabled()
requireFreshWorkingForNewCoordinators = !wasAgentTaskCompleteNotificationEnabled
}
+26 -2
View File
@@ -67,6 +67,11 @@ import {
createWebRuntimeSessionTerminal,
isWebRuntimeSessionActive
} from '@/runtime/web-runtime-session'
import {
observeAgentHookCompletionForNotification,
resetAgentHookCompletionNotificationCoordinators,
syncAgentHookCompletionNotificationSettings
} from './agent-hook-completion-notifications'
export { resolveZoomTarget } from './resolve-zoom-target'
@@ -1835,6 +1840,17 @@ export function useIpcEvents(): void {
updatedAt: data.receivedAt,
stateStartedAt: data.stateStartedAt
})
const statusWorktreeId = data.worktreeId ?? owningWorktreeId
if (options?.replay !== true && statusWorktreeId) {
// Why: local Codex/Claude hooks arrive through this main-process IPC
// path, not the PTY OSC fallback, so task-complete notifications must
// observe accepted hook state here as well.
observeAgentHookCompletionForNotification({
paneKey: data.paneKey,
worktreeId: statusWorktreeId,
payload
})
}
}
let snapshotRequestedForReadyWindow = false
@@ -1927,7 +1943,12 @@ export function useIpcEvents(): void {
// can be safely ignored instead of buffered against partially hydrated
// renderer state.
requestAgentStatusSnapshotIfReady()
unsubs.push(useAppStore.subscribe(() => requestAgentStatusSnapshotIfReady()))
unsubs.push(
useAppStore.subscribe(() => {
requestAgentStatusSnapshotIfReady()
syncAgentHookCompletionNotificationSettings()
})
)
let mobileStateHydrated = isRuntimeEnvironmentActive()
type PendingMobileStateEvent =
@@ -2036,7 +2057,10 @@ export function useIpcEvents(): void {
})
}
return () => unsubs.forEach((fn) => fn())
return () => {
unsubs.forEach((fn) => fn())
resetAgentHookCompletionNotificationCoordinators()
}
}, [])
}
+1
View File
@@ -312,6 +312,7 @@ export type UISlice = {
| 'input'
| 'tasks'
| 'terminal'
| 'notifications'
| 'computer-use'
| 'developer-permissions'
| 'shortcuts'
@@ -0,0 +1,65 @@
import { describe, expect, it } from 'vitest'
import { isAgentHookEndpointFileName, parseAgentHookEndpointFile } from './agent-hook-endpoint-file'
describe('agent hook endpoint files', () => {
it('recognizes POSIX and Windows endpoint file names', () => {
expect(isAgentHookEndpointFileName('endpoint.env')).toBe(true)
expect(isAgentHookEndpointFileName('endpoint.cmd')).toBe(true)
expect(isAgentHookEndpointFileName('endpoint.ps1')).toBe(false)
})
it('parses POSIX endpoint.env contents', () => {
expect(
parseAgentHookEndpointFile(
[
'ORCA_AGENT_HOOK_PORT=12345',
'ORCA_AGENT_HOOK_TOKEN=token-123',
'ORCA_AGENT_HOOK_ENV=production',
'ORCA_AGENT_HOOK_VERSION=1'
].join('\n')
)
).toEqual({
port: '12345',
token: 'token-123',
env: 'production',
version: '1'
})
})
it('parses Windows endpoint.cmd contents', () => {
expect(
parseAgentHookEndpointFile(
[
'set ORCA_AGENT_HOOK_PORT=54321',
'set ORCA_AGENT_HOOK_TOKEN=token-abc',
'set ORCA_AGENT_HOOK_ENV=development',
'set ORCA_AGENT_HOOK_VERSION=1'
].join('\r\n')
)
).toEqual({
port: '54321',
token: 'token-abc',
env: 'development',
version: '1'
})
})
it('preserves equals signs in endpoint values', () => {
expect(
parseAgentHookEndpointFile(
[
'ORCA_AGENT_HOOK_PORT=12345',
'ORCA_AGENT_HOOK_TOKEN=token=with=equals',
'ORCA_AGENT_HOOK_ENV=production',
'ORCA_AGENT_HOOK_VERSION=1'
].join('\n')
).token
).toBe('token=with=equals')
})
it('throws when required endpoint fields are missing', () => {
expect(() => parseAgentHookEndpointFile('ORCA_AGENT_HOOK_PORT=12345')).toThrow(
'Agent hook endpoint file is missing required fields'
)
})
})
+42
View File
@@ -0,0 +1,42 @@
export const AGENT_HOOK_ENDPOINT_FILE_NAMES = ['endpoint.env', 'endpoint.cmd'] as const
export type AgentHookEndpointFileName = (typeof AGENT_HOOK_ENDPOINT_FILE_NAMES)[number]
export type AgentHookEndpoint = {
port: string
token: string
env: string
version: string
}
export function isAgentHookEndpointFileName(name: string): name is AgentHookEndpointFileName {
return AGENT_HOOK_ENDPOINT_FILE_NAMES.some((fileName) => fileName === name)
}
export function parseAgentHookEndpointFile(contents: string): AgentHookEndpoint {
const values = Object.fromEntries(
contents
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean)
.map((line) => {
const normalizedLine = line.replace(/^set\s+/i, '')
const [key, ...rest] = normalizedLine.split('=')
return [key, rest.join('=')]
})
)
if (
!values.ORCA_AGENT_HOOK_PORT ||
!values.ORCA_AGENT_HOOK_TOKEN ||
!values.ORCA_AGENT_HOOK_ENV ||
!values.ORCA_AGENT_HOOK_VERSION
) {
throw new Error('Agent hook endpoint file is missing required fields')
}
return {
port: values.ORCA_AGENT_HOOK_PORT,
token: values.ORCA_AGENT_HOOK_TOKEN,
env: values.ORCA_AGENT_HOOK_ENV,
version: values.ORCA_AGENT_HOOK_VERSION
}
}
@@ -0,0 +1,12 @@
import { describe, expect, it } from 'vitest'
import { isRecognizedAgentType, recognizeAgentProcess } from './agent-process-recognition'
describe('agent process recognition', () => {
it('recognizes packaged Codex foreground process names', () => {
expect(recognizeAgentProcess('codex-aarch64-ap')).toEqual({
agent: 'codex',
processName: 'codex-aarch64-ap'
})
expect(isRecognizedAgentType('codex-aarch64-ap')).toBe(true)
})
})
+77
View File
@@ -0,0 +1,77 @@
import { TUI_AGENT_CONFIG } from './tui-agent-config'
import type { AgentType } from './agent-status-types'
import type { TuiAgent } from './types'
export type RecognizedAgentProcess = {
agent: TuiAgent
processName: string
}
const EXTENSION_RE = /\.(?:exe|cmd|bat|ps1)$/i
function normalizeProcessName(processName: string | null | undefined): string {
if (!processName) {
return ''
}
const unquoted = processName.trim().replace(/^["']|["']$/g, '')
const basename = unquoted.split(/[\\/]/).pop() ?? unquoted
return basename.toLowerCase().replace(EXTENSION_RE, '')
}
function firstCommandToken(command: string): string {
return command.trim().split(/\s+/)[0] ?? ''
}
const PROCESS_TO_AGENT = new Map<string, TuiAgent>()
const AGENT_TYPE_IDS = new Set<TuiAgent>()
for (const [agent, config] of Object.entries(TUI_AGENT_CONFIG) as [
TuiAgent,
(typeof TUI_AGENT_CONFIG)[TuiAgent]
][]) {
AGENT_TYPE_IDS.add(agent)
for (const candidate of [
config.expectedProcess,
config.detectCmd,
firstCommandToken(config.launchCmd)
]) {
const normalized = normalizeProcessName(candidate)
if (normalized) {
PROCESS_TO_AGENT.set(normalized, agent)
}
}
}
function agentForNormalizedProcess(normalized: string): TuiAgent | undefined {
const exact = PROCESS_TO_AGENT.get(normalized)
if (exact) {
return exact
}
// Why: node-pty can report Codex's packaged platform binary
// (for example codex-aarch64-ap) instead of the launch command.
if (normalized.startsWith('codex-')) {
return PROCESS_TO_AGENT.get('codex')
}
return undefined
}
export function recognizeAgentProcess(
processName: string | null | undefined
): RecognizedAgentProcess | null {
const normalized = normalizeProcessName(processName)
const agent = agentForNormalizedProcess(normalized)
if (!agent) {
return null
}
return { agent, processName: normalized }
}
export function isRecognizedAgentType(agentType: AgentType | null | undefined): boolean {
if (typeof agentType !== 'string') {
return false
}
return (
AGENT_TYPE_IDS.has(agentType as TuiAgent) ||
agentForNormalizedProcess(normalizeProcessName(agentType)) !== undefined
)
}
+204
View File
@@ -8,10 +8,16 @@ import {
waitForTerminalOutput
} from './helpers/terminal'
import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store'
import { emitCodexHookStatus, readHookEndpoint } from './helpers/agent-hook-endpoint'
type NotificationDispatch = {
source?: string
terminalTitle?: string
paneKey?: string
isActiveWorktree?: boolean
agentType?: string
agentPrompt?: string
agentLastAssistantMessage?: string
}
async function emitOscTitle(page: Page, ptyId: string, title: string) {
@@ -46,7 +52,205 @@ async function getNotificationDispatches(
})
}
async function createAndSwitchToOtherWorktree(page: Page): Promise<string> {
return page.evaluate(async () => {
const store = window.__store
if (!store) {
throw new Error('Store unavailable')
}
const state = store.getState()
const activeWorktreeId = state.activeWorktreeId
if (!activeWorktreeId) {
throw new Error('No active worktree')
}
const activeWorktree = Object.values(state.worktreesByRepo)
.flat()
.find((worktree) => worktree.id === activeWorktreeId)
if (!activeWorktree) {
throw new Error(`Active worktree ${activeWorktreeId} not found`)
}
const result = await state.createWorktree(
activeWorktree.repoId,
`codex-hook-notify-${Date.now()}`
)
await state.fetchWorktrees(activeWorktree.repoId)
state.setActiveWorktree(result.worktree.id)
return result.worktree.id
})
}
async function getAgentStatuses(page: Page): Promise<
{
paneKey: string
state: string
agentType?: string
prompt?: string
lastAssistantMessage?: string
}[]
> {
return page.evaluate(() => {
const store = window.__store
if (!store) {
return []
}
return Object.values(store.getState().agentStatusByPaneKey ?? {}).map((entry) => ({
paneKey: entry.paneKey,
state: entry.state,
agentType: entry.agentType,
prompt: entry.prompt,
lastAssistantMessage: entry.lastAssistantMessage
}))
})
}
async function getActivePaneDescriptor(
page: Page
): Promise<{ paneKey: string; worktreeId: string }> {
return page.evaluate(() => {
const store = window.__store
if (!store) {
throw new Error('Store unavailable')
}
const state = store.getState()
const worktreeId = state.activeWorktreeId
if (!worktreeId) {
throw new Error('No active worktree')
}
const tabId = state.activeTabIdByWorktree[worktreeId] ?? state.activeTabId
if (!tabId) {
throw new Error('No active tab')
}
const manager = window.__paneManagers?.get(tabId)
const activePane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0]
const leafId = activePane ? manager?.getLeafIdMap?.().get(activePane.id) : null
if (!leafId) {
throw new Error('No active pane leaf id')
}
return { paneKey: `${tabId}:${leafId}`, worktreeId }
})
}
test.describe('Droid notifications', () => {
test('Codex hook completion dispatches while its worktree is inactive', async ({
orcaPage,
electronApp
}) => {
await waitForSessionReady(orcaPage)
await waitForActiveWorktree(orcaPage)
await ensureTerminalVisible(orcaPage)
await waitForActiveTerminalManager(orcaPage, 30_000)
await installMainProcessNotificationDispatchSpy(electronApp)
const endpoint = await readHookEndpoint(electronApp)
const { paneKey, worktreeId } = await getActivePaneDescriptor(orcaPage)
const prompt = `codex-hook-notify-${Date.now()}`
await emitCodexHookStatus(endpoint, {
paneKey,
worktreeId,
state: 'working',
prompt
})
await expect
.poll(
async () =>
(await getAgentStatuses(orcaPage)).some(
(status) =>
status.agentType === 'codex' && status.state === 'working' && status.prompt === prompt
),
{
timeout: 10_000,
message: 'Codex UserPromptSubmit hook did not reach renderer agent status'
}
)
.toBe(true)
await createAndSwitchToOtherWorktree(orcaPage)
const finalMessage = `Codex hook completed ${Date.now()}`
await emitCodexHookStatus(endpoint, {
paneKey,
worktreeId,
state: 'done',
prompt,
lastAssistantMessage: finalMessage
})
await expect
.poll(
async () =>
(await getAgentStatuses(orcaPage)).some(
(status) =>
status.agentType === 'codex' &&
status.state === 'done' &&
status.prompt === prompt &&
status.lastAssistantMessage === finalMessage
),
{
timeout: 10_000,
message: 'Codex Stop hook did not reach renderer agent status'
}
)
.toBe(true)
await expect
.poll(
async () => {
const dispatches = await getNotificationDispatches(electronApp)
return dispatches.filter((dispatch) => dispatch.source === 'agent-task-complete')
},
{
timeout: 10_000,
message: 'Codex hook Stop did not dispatch task-complete while worktree was inactive'
}
)
.toEqual([
expect.objectContaining({
source: 'agent-task-complete',
terminalTitle: 'codex',
isActiveWorktree: false,
agentType: 'codex',
agentPrompt: prompt,
agentLastAssistantMessage: finalMessage
})
])
})
test('recognized agent title completion dispatches one task-complete notification', async ({
orcaPage,
electronApp
}) => {
await waitForSessionReady(orcaPage)
await waitForActiveWorktree(orcaPage)
await ensureTerminalVisible(orcaPage)
await waitForActiveTerminalManager(orcaPage, 30_000)
// Why: contextBridge freezes window.api, so notification invokes must be
// observed in Electron's main process rather than monkey-patched renderer-side.
await installMainProcessNotificationDispatchSpy(electronApp)
await installRendererTitleLog(orcaPage)
const ptyId = await waitForActivePanePtyId(orcaPage)
const marker = `__CODEX_NOTIFY_READY_${Date.now()}__`
await sendToTerminal(orcaPage, ptyId, `printf '${marker}\\n'\r`)
await waitForTerminalOutput(orcaPage, marker)
await emitOscTitle(orcaPage, ptyId, 'Codex working')
await emitOscTitle(orcaPage, ptyId, 'Codex done')
await expect
.poll(
async () => {
const dispatches = await getNotificationDispatches(electronApp)
return dispatches.filter((dispatch) => dispatch.source === 'agent-task-complete')
},
{
timeout: 10_000,
message: 'Codex working->done title transition did not dispatch task-complete'
}
)
.toEqual([
expect.objectContaining({ source: 'agent-task-complete', terminalTitle: 'Codex done' })
])
})
test('Factory Droid needs-input native title does not dispatch a task-complete notification', async ({
orcaPage,
electronApp
+79
View File
@@ -0,0 +1,79 @@
import type { ElectronApplication } from '@stablyai/playwright-test'
import { existsSync, readdirSync, readFileSync } from 'fs'
import path from 'path'
import {
isAgentHookEndpointFileName,
parseAgentHookEndpointFile,
type AgentHookEndpoint
} from '../../../src/shared/agent-hook-endpoint-file'
function findEndpointEnvFile(root: string): string | null {
if (!existsSync(root)) {
return null
}
const entries = readdirSync(root, { withFileTypes: true })
for (const entry of entries) {
const fullPath = path.join(root, entry.name)
if (entry.isFile() && isAgentHookEndpointFileName(entry.name)) {
return fullPath
}
if (entry.isDirectory()) {
const nested = findEndpointEnvFile(fullPath)
if (nested) {
return nested
}
}
}
return null
}
export async function readHookEndpoint(app: ElectronApplication): Promise<AgentHookEndpoint> {
const userDataPath = await app.evaluate(({ app: electronApp }) => electronApp.getPath('userData'))
const hookRoot = path.join(userDataPath, 'agent-hooks')
const endpointPath = findEndpointEnvFile(hookRoot)
if (!endpointPath) {
throw new Error(`Agent hook endpoint file not found under ${hookRoot}`)
}
return parseAgentHookEndpointFile(readFileSync(endpointPath, 'utf8'))
}
export async function emitCodexHookStatus(
endpoint: AgentHookEndpoint,
status: {
paneKey: string
worktreeId: string
state: 'working' | 'done'
prompt?: string
lastAssistantMessage?: string
}
): Promise<void> {
const [tabId] = status.paneKey.split(':')
const payload =
status.state === 'working'
? {
hook_event_name: 'UserPromptSubmit',
prompt: status.prompt
}
: {
hook_event_name: 'Stop',
last_assistant_message: status.lastAssistantMessage
}
const response = await fetch(`http://127.0.0.1:${endpoint.port}/hook/codex`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Orca-Agent-Hook-Token': endpoint.token
},
body: JSON.stringify({
paneKey: status.paneKey,
tabId,
worktreeId: status.worktreeId,
env: endpoint.env,
version: endpoint.version,
payload
})
})
if (response.status !== 204) {
throw new Error(`Codex hook POST returned ${response.status}`)
}
}
+94
View File
@@ -0,0 +1,94 @@
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 openNotificationSettings(
page: Parameters<typeof waitForSessionReady>[0]
): Promise<void> {
await page.evaluate(() => {
const state = window.__store!.getState()
state.openSettingsTarget({ pane: 'notifications', repoId: null })
state.openSettingsPage()
})
await expect(page.getByPlaceholder('Search settings')).toBeVisible({ timeout: 10_000 })
const featureTipDialog = page.getByRole('dialog', { name: 'Voice Dictation is here' })
if (await featureTipDialog.isVisible().catch(() => false)) {
await page.getByRole('button', { name: 'Maybe Later' }).click()
}
await expect(
page
.locator('[data-settings-section="notifications"]')
.getByRole('heading', { name: 'Notifications', exact: true })
).toBeInViewport({ timeout: 10_000 })
}
test.describe('Notification settings', () => {
test.beforeEach(async ({ orcaPage }) => {
await waitForSessionReady(orcaPage)
})
test('can be toggled from settings and disables child controls', async ({ orcaPage }) => {
await openNotificationSettings(orcaPage)
const notificationsSection = orcaPage.locator('[data-settings-section="notifications"]')
const enableNotificationsSwitch = notificationsSection.getByRole('switch', {
name: 'Enable Notifications'
})
const agentTaskCompleteSwitch = notificationsSection.getByRole('switch', {
name: 'Agent Task Complete'
})
const terminalBellSwitch = notificationsSection.getByRole('switch', { name: 'Terminal Bell' })
const suppressWhileFocusedSwitch = notificationsSection.getByRole('switch', {
name: 'Suppress While Focused'
})
const sendTestButton = notificationsSection.getByRole('button', {
name: 'Send Test Notification'
})
await expect(enableNotificationsSwitch).toHaveAttribute('aria-checked', 'true')
await expect(agentTaskCompleteSwitch).toBeEnabled()
await expect(terminalBellSwitch).toBeEnabled()
await expect(suppressWhileFocusedSwitch).toBeEnabled()
await expect(sendTestButton).toBeEnabled()
await agentTaskCompleteSwitch.click()
await expect(agentTaskCompleteSwitch).toHaveAttribute('aria-checked', 'false')
await expect
.poll(async () => (await getSettings(orcaPage)).notifications.agentTaskComplete, {
timeout: 5_000,
message: 'agent task-complete notification setting did not persist after disabling'
})
.toBe(false)
await enableNotificationsSwitch.click()
await expect(enableNotificationsSwitch).toHaveAttribute('aria-checked', 'false')
await expect(agentTaskCompleteSwitch).toBeDisabled()
await expect(terminalBellSwitch).toBeDisabled()
await expect(suppressWhileFocusedSwitch).toBeDisabled()
await expect(sendTestButton).toBeDisabled()
await expect
.poll(async () => (await getSettings(orcaPage)).notifications.enabled, {
timeout: 5_000,
message: 'master notification setting did not persist after disabling'
})
.toBe(false)
await enableNotificationsSwitch.click()
await agentTaskCompleteSwitch.click()
await expect
.poll(async () => {
const settings = await getSettings(orcaPage)
return {
enabled: settings.notifications.enabled,
agentTaskComplete: settings.notifications.agentTaskComplete
}
})
.toEqual({ enabled: true, agentTaskComplete: true })
})
})