From 3c4a4c2fcc50dfbfa5d8c388565ac5b29dc0b982 Mon Sep 17 00:00:00 2001 From: bbingz Date: Sat, 11 Jul 2026 19:24:35 +0800 Subject: [PATCH 1/3] fix(codex): preserve hook-driven agent status --- src/main/agent-hooks/server.test.ts | 36 ++- src/main/agent-hooks/server.ts | 5 + src/main/codex/codex-hook-identity.ts | 4 + src/main/codex/config-toml-trust.ts | 53 +++- .../codex/hook-service-wsl-runtime.test.ts | 2 + src/main/codex/hook-service.test.ts | 143 +++++++++++ src/main/codex/hook-service.ts | 91 ++++++- src/relay/agent-hook-server.test.ts | 40 +++ ...-approval-notification-suppression.test.ts | 22 ++ ...-auto-approval-notification-suppression.ts | 6 +- src/renderer/src/lib/agent-status.test.ts | 7 + src/shared/agent-detection.ts | 2 + src/shared/agent-hook-listener.test.ts | 234 ++++++++++++++++++ src/shared/agent-hook-listener.ts | 103 ++++++-- src/shared/agent-title-status.ts | 12 + 15 files changed, 725 insertions(+), 35 deletions(-) diff --git a/src/main/agent-hooks/server.test.ts b/src/main/agent-hooks/server.test.ts index 9e9676c2243..f0b355ecd27 100644 --- a/src/main/agent-hooks/server.test.ts +++ b/src/main/agent-hooks/server.test.ts @@ -4157,7 +4157,7 @@ describe('Codex hook normalization', () => { expect(result?.payload.toolInput).toBeUndefined() }) - it('SessionStart clears cached tool state from a prior session', () => { + it('SessionStart clears cached tool state without reporting working', () => { // Seed a Stop snapshot with an assistant message. _internals.normalizeHookPayload( 'codex', @@ -4167,13 +4167,20 @@ describe('Codex hook normalization', () => { }), 'production' ) - const result = _internals.normalizeHookPayload( + const started = _internals.normalizeHookPayload( 'codex', - buildBody({ hook_event_name: 'SessionStart' }), + buildBody({ hook_event_name: 'SessionStart', session_id: 'codex-session-next' }), 'production' ) - expect(result?.payload.state).toBe('working') - expect(result?.payload.lastAssistantMessage).toBeUndefined() + const prompted = _internals.normalizeHookPayload( + 'codex', + buildBody({ hook_event_name: 'UserPromptSubmit', prompt: 'Next turn' }), + 'production' + ) + expect(started).toBeNull() + expect(prompted?.payload.state).toBe('working') + expect(prompted?.payload.lastAssistantMessage).toBeUndefined() + expect(prompted?.providerSession).toEqual({ key: 'session_id', id: 'codex-session-next' }) }) it('SessionStart clears the cached prompt from a prior session until a new prompt arrives', () => { @@ -4185,13 +4192,24 @@ describe('Codex hook normalization', () => { }), 'production' ) - const result = _internals.normalizeHookPayload( + const started = _internals.normalizeHookPayload( 'codex', - buildBody({ hook_event_name: 'SessionStart' }), + buildBody({ hook_event_name: 'SessionStart', session_id: 'codex-session-fresh' }), 'production' ) - expect(result?.payload.state).toBe('working') - expect(result?.payload.prompt).toBe('') + const nextTool = _internals.normalizeHookPayload( + 'codex', + buildBody({ + hook_event_name: 'PreToolUse', + tool_name: 'Bash', + tool_input: { command: 'pwd' } + }), + 'production' + ) + expect(started).toBeNull() + expect(nextTool?.payload.state).toBe('working') + expect(nextTool?.payload.prompt).toBe('') + expect(nextTool?.providerSession).toEqual({ key: 'session_id', id: 'codex-session-fresh' }) }) }) diff --git a/src/main/agent-hooks/server.ts b/src/main/agent-hooks/server.ts index f859277303d..971909b230b 100644 --- a/src/main/agent-hooks/server.ts +++ b/src/main/agent-hooks/server.ts @@ -1393,6 +1393,11 @@ export class AgentHookServer { paneKeysToClear.add(key.split('\0', 1)[0] ?? key) } } + for (const key of this.state.lastProviderSessionByPaneKey.keys()) { + if (paneCacheKeyMatchesTab(key, tabId)) { + paneKeysToClear.add(key.split('\0', 1)[0] ?? key) + } + } for (const key of this.state.antigravityCompletedTranscriptByPaneKey.keys()) { if (paneCacheKeyMatchesTab(key, tabId)) { paneKeysToClear.add(key.split('\0', 1)[0] ?? key) diff --git a/src/main/codex/codex-hook-identity.ts b/src/main/codex/codex-hook-identity.ts index cc78a9acb76..9eef40e098a 100644 --- a/src/main/codex/codex-hook-identity.ts +++ b/src/main/codex/codex-hook-identity.ts @@ -11,6 +11,8 @@ export const CODEX_HOOK_EVENT_LABEL: Record = { PreToolUse: 'pre_tool_use', PermissionRequest: 'permission_request', PostToolUse: 'post_tool_use', + SubagentStart: 'subagent_start', + SubagentStop: 'subagent_stop', Stop: 'stop', PreCompact: 'pre_compact', PostCompact: 'post_compact' @@ -22,6 +24,8 @@ export const CODEX_EVENT_NAME_BY_LABEL: Record = { pre_tool_use: 'PreToolUse', permission_request: 'PermissionRequest', post_tool_use: 'PostToolUse', + subagent_start: 'SubagentStart', + subagent_stop: 'SubagentStop', stop: 'Stop', pre_compact: 'PreCompact', post_compact: 'PostCompact' diff --git a/src/main/codex/config-toml-trust.ts b/src/main/codex/config-toml-trust.ts index eede0efea32..81fca51b35f 100644 --- a/src/main/codex/config-toml-trust.ts +++ b/src/main/codex/config-toml-trust.ts @@ -33,6 +33,8 @@ export type CodexEventLabel = | 'post_compact' | 'session_start' | 'user_prompt_submit' + | 'subagent_start' + | 'subagent_stop' | 'stop' export type CodexTrustEntry = { @@ -69,6 +71,11 @@ export type CodexHookTrustState = { enabled?: boolean } +export type CodexHookTrustKeyMove = { + fromKey: string + toKey: string +} + export type CodexProjectTrustLevel = 'trusted' | 'untrusted' // Why: callers use computeTrustKey() for lookups, while existing TOML can carry @@ -129,6 +136,8 @@ function matcherPatternForEvent( case 'pre_compact': case 'post_compact': case 'session_start': + case 'subagent_start': + case 'subagent_stop': return matcher } } @@ -288,6 +297,8 @@ function isCodexEventLabel(value: string): value is CodexEventLabel { value === 'post_compact' || value === 'session_start' || value === 'user_prompt_submit' || + value === 'subagent_start' || + value === 'subagent_stop' || value === 'stop' ) } @@ -345,6 +356,39 @@ export function upsertHookTrustEntriesInContent( return updated } +// Why: Codex trust is index-addressed, so prepending a hook must move the +// existing approval block before the new hook claims the old index. +export function moveHookTrustEntriesInContent( + existingContent: string, + moves: readonly CodexHookTrustKeyMove[] +): string { + const existing = + existingContent.charCodeAt(0) === 0xfeff ? existingContent.slice(1) : existingContent + const states = readHookTrustEntriesInContent(existing) + const resolvedMoves = moves.flatMap(({ fromKey, toKey }) => { + if (normalizeHookTrustKeyForLookup(fromKey) === normalizeHookTrustKeyForLookup(toKey)) { + return [] + } + const state = states.get(fromKey) + return state?.trustedHash + ? [{ fromKey, toKey, trustedHash: state.trustedHash, enabled: state.enabled }] + : [] + }) + let updated = existing + for (const fromKey of new Set(resolvedMoves.map(({ fromKey }) => fromKey))) { + updated = removeTrustBlock(updated, fromKey) + } + for (const { toKey, trustedHash, enabled } of resolvedMoves) { + updated = upsertTrustBlocks( + updated, + getTrustKeyWriteVariants(toKey), + trustedHash, + enabled ?? true + ) + } + return updated +} + export function upsertProjectTrustLevel( configPath: string, projectPath: string, @@ -833,11 +877,14 @@ function removeTrustBlock(content: string, key: string): string { } export function readHookTrustEntries(configPath: string): Map { - const result = new HookTrustEntryMap() if (!existsSync(configPath)) { - return result + return new HookTrustEntryMap() } - const content = readTomlFile(configPath) + return readHookTrustEntriesInContent(readTomlFile(configPath)) +} + +function readHookTrustEntriesInContent(content: string): HookTrustEntryMap { + const result = new HookTrustEntryMap() // Why: walk line-by-line so `[hooks.state."..."]` inside a `"""..."""` or // `'''...'''` multi-line string isn't mistaken for a real header. let cursor = 0 diff --git a/src/main/codex/hook-service-wsl-runtime.test.ts b/src/main/codex/hook-service-wsl-runtime.test.ts index 232725e5f66..501013880d9 100644 --- a/src/main/codex/hook-service-wsl-runtime.test.ts +++ b/src/main/codex/hook-service-wsl-runtime.test.ts @@ -27,6 +27,8 @@ const managedEvents = [ 'PreToolUse', 'PermissionRequest', 'PostToolUse', + 'SubagentStart', + 'SubagentStop', 'Stop' ] as const diff --git a/src/main/codex/hook-service.test.ts b/src/main/codex/hook-service.test.ts index 526a64d99ab..1aba7306378 100644 --- a/src/main/codex/hook-service.test.ts +++ b/src/main/codex/hook-service.test.ts @@ -88,6 +88,16 @@ function hookTrustHeader(key: string): string { : `[hooks.state."${escapeTomlBasicString(canonicalKey)}"]` } +function hookTrustBlock(content: string, key: string): string { + const header = hookTrustHeader(key) + const start = content.indexOf(header) + if (start === -1) { + return '' + } + const nextHeader = content.indexOf('\n[', start + header.length) + return content.slice(start, nextHeader === -1 ? content.length : nextHeader) +} + function canonicalizeHookTrustKeyForTest(key: string): string { const lastColon = key.lastIndexOf(':') const secondLast = lastColon === -1 ? -1 : key.lastIndexOf(':', lastColon - 1) @@ -124,6 +134,8 @@ function localManagedCodexEvents(): string[] { 'PreToolUse', 'SessionStart', 'Stop', + 'SubagentStart', + 'SubagentStop', 'UserPromptSubmit' ] } @@ -156,6 +168,137 @@ describe('CodexHookService', () => { expect(trustConfig).toContain('model = "gpt-5.2-codex"') expect(trustConfig).toContain('approval_policy = "on-request"') expect(trustConfig).toContain(':permission_request:0:0') + expect(trustConfig).toContain(':subagent_start:0:0') + expect(trustConfig).toContain(':subagent_stop:0:0') + expect(isCodexManagedCommand(hooksConfig.hooks.SubagentStart?.[0]?.hooks?.[0]?.command)).toBe( + true + ) + expect(isCodexManagedCommand(hooksConfig.hooks.SubagentStop?.[0]?.hooks?.[0]?.command)).toBe( + true + ) + }) + + it('prepends remote hooks without invalidating existing user hook trust', async () => { + const remoteHooksPath = '/home/dev/.codex/hooks.json' + const userStopCommand = 'echo user-stop-hook' + const userTrustedHash = 'sha256:user-approved-stop-hook' + const files = new Map([ + [ + remoteHooksPath, + `${JSON.stringify({ + hooks: { + Stop: [{ hooks: [{ type: 'command', command: userStopCommand }] }] + } + })}\n` + ], + [ + '/home/dev/.codex/config.toml', + upsertHookTrustEntriesInContent('', [ + { + sourcePath: remoteHooksPath, + eventLabel: 'stop', + groupIndex: 0, + handlerIndex: 0, + command: userStopCommand, + trustedHash: userTrustedHash, + enabled: false + } + ]) + ] + ]) + const modes = new Map() + const dirs = new Set(['/']) + const noEntry = (path: string): { code: number; message: string } => ({ + code: 2, + message: `ENOENT ${path}` + }) + const sftp = { + readFile: (path: string, _enc: string, cb: (err: unknown, data?: string) => void): void => { + const value = files.get(path) + if (value === undefined) { + cb(noEntry(path)) + return + } + cb(null, value) + }, + writeFile: ( + path: string, + content: string, + options: string | { mode?: number }, + cb: (err: unknown) => void + ): void => { + files.set(path, content) + if (typeof options !== 'string' && options.mode !== undefined) { + modes.set(path, options.mode) + } + cb(null) + }, + rename: (src: string, dst: string, cb: (err: unknown) => void): void => { + const value = files.get(src) + if (value === undefined) { + cb(noEntry(src)) + return + } + files.set(dst, value) + files.delete(src) + const mode = modes.get(src) + if (mode !== undefined) { + modes.set(dst, mode) + modes.delete(src) + } + cb(null) + }, + unlink: (path: string, cb: (err: unknown) => void): void => { + files.delete(path) + modes.delete(path) + cb(null) + }, + chmod: (path: string, mode: number, cb: (err: unknown) => void): void => { + modes.set(path, mode) + cb(null) + }, + stat: (path: string, cb: (err: unknown, stats?: { mode: number }) => void): void => { + if (!files.has(path)) { + cb(noEntry(path)) + return + } + cb(null, { mode: modes.get(path) ?? 0o100644 }) + }, + readdir: (path: string, cb: (err: unknown, list?: { filename: string }[]) => void): void => { + if (!dirs.has(path)) { + cb(noEntry(path)) + return + } + cb(null, []) + }, + mkdir: (path: string, cb: (err: unknown) => void): void => { + dirs.add(path) + cb(null) + } + } + + const service = new CodexHookService() + const status = await service.installRemote(sftp as never, '/home/dev') + const repeatedStatus = await service.installRemote(sftp as never, '/home/dev') + + expect(status.state).toBe('installed') + expect(repeatedStatus.state).toBe('installed') + const hooks = JSON.parse(files.get(remoteHooksPath)!) as { + hooks: Record + } + expect(hooks.hooks.Stop?.[0]?.hooks?.[0]?.command).toContain('codex-hook.sh') + expect(hooks.hooks.Stop?.[1]?.hooks?.[0]?.command).toBe(userStopCommand) + expect(hooks.hooks.SubagentStart?.[0]?.hooks?.[0]?.command).toContain('codex-hook.sh') + expect(hooks.hooks.SubagentStop?.[0]?.hooks?.[0]?.command).toContain('codex-hook.sh') + const toml = files.get('/home/dev/.codex/config.toml') ?? '' + expect(toml).toContain(':stop:0:0') + expect(toml).toContain(':subagent_start:0:0') + expect(toml).toContain(':subagent_stop:0:0') + const userStopTrust = hookTrustBlock(toml, `${remoteHooksPath}:stop:1:0`) + expect(userStopTrust).toContain('enabled = false') + expect(userStopTrust).toContain(`trusted_hash = "${userTrustedHash}"`) + expect(hookTrustBlock(toml, `${remoteHooksPath}:stop:0:0`)).not.toContain(userTrustedHash) + expect(toml).not.toContain(`${remoteHooksPath}:stop:2:0`) }) it('drops plugin manager metadata from runtime hooks.json during install', () => { diff --git a/src/main/codex/hook-service.ts b/src/main/codex/hook-service.ts index 0ad81dede26..a8b5fc236d8 100644 --- a/src/main/codex/hook-service.ts +++ b/src/main/codex/hook-service.ts @@ -30,6 +30,7 @@ import { computeTrustedHash, escapeTomlString, getCodexCanonicalTrustPath, + moveHookTrustEntriesInContent, normalizeCodexProjectPathForLookup, normalizeHookTrustKeyForLookup, parseTrustKey, @@ -39,6 +40,7 @@ import { upsertHookTrustEntries, writeConfigAtomically, type CodexEventLabel, + type CodexHookTrustKeyMove, type CodexHookTrustState, type CodexTrustEntry } from './config-toml-trust' @@ -63,15 +65,17 @@ import { // Why: PreToolUse/PostToolUse give the dashboard a live readout of the // in-flight tool (name + input preview) between UserPromptSubmit and Stop. -// PermissionRequest is the human-input boundary: the managed script exits -// without a decision so Codex still shows its normal approval UI, while Orca -// can flip the pane to the red waiting state. +// SubagentStart/SubagentStop keep the parent turn visible while a subagent +// runs. PermissionRequest remains the human-input boundary: the managed script +// exits without a decision so Codex still shows its normal approval UI. const CODEX_EVENTS = [ 'SessionStart', 'UserPromptSubmit', 'PreToolUse', 'PermissionRequest', 'PostToolUse', + 'SubagentStart', + 'SubagentStop', 'Stop' ] as const @@ -97,6 +101,8 @@ const CODEX_EVENT_LABEL: Record<(typeof CODEX_EVENTS)[number], CodexEventLabel> PreToolUse: CODEX_HOOK_EVENT_LABEL.PreToolUse!, PermissionRequest: CODEX_HOOK_EVENT_LABEL.PermissionRequest!, PostToolUse: CODEX_HOOK_EVENT_LABEL.PostToolUse!, + SubagentStart: CODEX_HOOK_EVENT_LABEL.SubagentStart!, + SubagentStop: CODEX_HOOK_EVENT_LABEL.SubagentStop!, Stop: CODEX_HOOK_EVENT_LABEL.Stop! } @@ -465,6 +471,66 @@ function moveMirroredRuntimeUserTrustAfterManagedStatusHook( }) } +// Why: repeated remote installs may find Orca before or after user hooks. Match +// user content across cleanup so only approvals whose real index changed move. +function collectPrependedRemoteUserTrustMoves( + sourcePath: string, + eventName: (typeof CODEX_EVENTS)[number], + current: readonly HookDefinition[], + cleaned: readonly HookDefinition[], + isManagedCommand: (command: string | undefined) => boolean +): CodexHookTrustKeyMove[] { + const oldEntriesBySignature = new Map() + current.forEach((definition, groupIndex) => { + const hooks = Array.isArray(definition.hooks) ? definition.hooks : [] + hooks.forEach((hook, handlerIndex) => { + if (isManagedCommand(hook.command)) { + return + } + const entry = createCodexHookTrustEntry( + sourcePath, + eventName, + groupIndex, + handlerIndex, + definition, + hook + ) + if (!entry) { + return + } + const signature = getCodexHookTrustSignature(entry) + const entries = oldEntriesBySignature.get(signature) ?? [] + entries.push(entry) + oldEntriesBySignature.set(signature, entries) + }) + }) + + const moves: CodexHookTrustKeyMove[] = [] + cleaned.forEach((definition, cleanedGroupIndex) => { + const hooks = Array.isArray(definition.hooks) ? definition.hooks : [] + hooks.forEach((hook, handlerIndex) => { + const nextEntry = createCodexHookTrustEntry( + sourcePath, + eventName, + cleanedGroupIndex + 1, + handlerIndex, + definition, + hook + ) + if (!nextEntry) { + return + } + const oldEntries = oldEntriesBySignature.get(getCodexHookTrustSignature(nextEntry)) + const oldEntry = oldEntries?.shift() + if (!oldEntry) { + return + } + moves.push({ fromKey: computeTrustKey(oldEntry), toKey: computeTrustKey(nextEntry) }) + }) + }) + return moves +} + function escapeRegex(value: string): string { return value.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&') } @@ -1384,17 +1450,29 @@ export class CodexHookService { } const trustEntries: CodexTrustEntry[] = [] + const userTrustMoves: CodexHookTrustKeyMove[] = [] for (const eventName of CODEX_EVENTS) { const current = Array.isArray(nextHooks[eventName]) ? nextHooks[eventName] : [] const cleaned = removeManagedCommands(current, isManagedCommand) const definition: HookDefinition = { hooks: [buildManagedCommandHook(command)] } - nextHooks[eventName] = [...cleaned, definition] + // Why: local installs already place Orca first; remote installs must + // not wait behind slow user hooks before publishing terminal status. + nextHooks[eventName] = [definition, ...cleaned] + userTrustMoves.push( + ...collectPrependedRemoteUserTrustMoves( + remoteConfigPath, + eventName, + current, + cleaned, + isManagedCommand + ) + ) trustEntries.push({ sourcePath: remoteConfigPath, eventLabel: CODEX_EVENT_LABEL[eventName], - groupIndex: cleaned.length, + groupIndex: 0, handlerIndex: 0, command, timeoutSec: MANAGED_HOOK_TIMEOUT_SECONDS @@ -1422,7 +1500,8 @@ export class CodexHookService { } } const existingToml = existingTomlRaw ?? '' - const updatedToml = upsertHookTrustEntriesInContent(existingToml, trustEntries) + const movedUserTrust = moveHookTrustEntriesInContent(existingToml, userTrustMoves) + const updatedToml = upsertHookTrustEntriesInContent(movedUserTrust, trustEntries) if (updatedToml !== existingToml) { await writeTextFileRemoteAtomic(sftp, remoteTomlPath, updatedToml) } diff --git a/src/relay/agent-hook-server.test.ts b/src/relay/agent-hook-server.test.ts index 4edb3dde409..9174d0e35e6 100644 --- a/src/relay/agent-hook-server.test.ts +++ b/src/relay/agent-hook-server.test.ts @@ -74,6 +74,46 @@ describe('RelayAgentHookServer', () => { } }) + it('forwards Codex SessionStart identity with the next real status event', async () => { + const forward = vi.fn<(envelope: AgentHookRelayEnvelope) => void>() + const server = new RelayAgentHookServer({ endpointDir: dir, forward }) + await server.start() + try { + const { port, token } = server.getCoordinates() + const post = (payload: Record): Promise => + fetch(`http://127.0.0.1:${port}/hook/codex`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Orca-Agent-Hook-Token': token + }, + body: JSON.stringify({ paneKey: PANE_KEY, tabId: 'tab-1', payload }) + }) + + expect( + ( + await post({ + hook_event_name: 'SessionStart', + session_id: 'codex-relay-session' + }) + ).status + ).toBe(204) + expect(forward).not.toHaveBeenCalled() + + expect( + (await post({ hook_event_name: 'UserPromptSubmit', prompt: 'relay this status' })).status + ).toBe(204) + expect(forward).toHaveBeenCalledTimes(1) + expect(forward.mock.calls[0][0]).toMatchObject({ + source: 'codex', + providerSession: { key: 'session_id', id: 'codex-relay-session' }, + payload: { state: 'working', prompt: 'relay this status' } + }) + } finally { + server.stop() + } + }) + it('rejects requests with the wrong bearer token (403)', async () => { const forward = vi.fn() const server = new RelayAgentHookServer({ endpointDir: dir, forward }) diff --git a/src/renderer/src/components/terminal-pane/codex-auto-approval-notification-suppression.test.ts b/src/renderer/src/components/terminal-pane/codex-auto-approval-notification-suppression.test.ts index 43dd1c9cb2b..ba5963f5e2a 100644 --- a/src/renderer/src/components/terminal-pane/codex-auto-approval-notification-suppression.test.ts +++ b/src/renderer/src/components/terminal-pane/codex-auto-approval-notification-suppression.test.ts @@ -237,4 +237,26 @@ describe('Codex auto-approval status suppression', () => { }) ).toBe(false) }) + + it('suppresses Codex native Action Required titles when launch is yolo', () => { + registerCodexLaunchConfig({ + agentArgs: YOLO_TUI_AGENT_ARGS.codex ?? '', + launchToken + }) + + expect( + shouldSuppressCodexAutoApprovalSyntheticTitle('[ ! ] Action Required | my-project', { + paneKey, + tabId: 'tab-1', + launchToken + }) + ).toBe(true) + expect( + shouldSuppressCodexAutoApprovalSyntheticTitle('[ . ] Action Required | my-project', { + paneKey, + tabId: 'tab-1', + launchToken + }) + ).toBe(true) + }) }) diff --git a/src/renderer/src/components/terminal-pane/codex-auto-approval-notification-suppression.ts b/src/renderer/src/components/terminal-pane/codex-auto-approval-notification-suppression.ts index dadede174df..17917895218 100644 --- a/src/renderer/src/components/terminal-pane/codex-auto-approval-notification-suppression.ts +++ b/src/renderer/src/components/terminal-pane/codex-auto-approval-notification-suppression.ts @@ -1,3 +1,4 @@ +import { isCodexNativeActionRequiredTitle } from '../../../../shared/agent-detection' import type { AgentProviderSessionMetadata } from '../../../../shared/agent-session-resume' import { getSyntheticAgentTitleProfile } from '../../../../shared/synthetic-agent-title' import { resolveTuiAgentPermissionMode } from '../../../../shared/tui-agent-permissions' @@ -60,7 +61,10 @@ export function shouldSuppressCodexAutoApprovalSyntheticTitle( title: string, context: CodexAutoApprovalStatusContext ): boolean { - if (title !== getSyntheticAgentTitleProfile('codex')?.permissionLabel) { + const isPermissionTitle = + title === getSyntheticAgentTitleProfile('codex')?.permissionLabel || + isCodexNativeActionRequiredTitle(title) + if (!isPermissionTitle) { return false } diff --git a/src/renderer/src/lib/agent-status.test.ts b/src/renderer/src/lib/agent-status.test.ts index 820741031d7..9c065cc7869 100644 --- a/src/renderer/src/lib/agent-status.test.ts +++ b/src/renderer/src/lib/agent-status.test.ts @@ -84,6 +84,13 @@ describe('detectAgentStatusFromTitle', () => { expect(detectAgentStatusFromTitle('Claude Code - action required')).toBe('permission') }) + it('detects Codex native Action Required titles without a codex token', () => { + expect(detectAgentStatusFromTitle('[ ! ] Action Required | my-project')).toBe('permission') + expect(detectAgentStatusFromTitle('[ . ] Action Required | my-project')).toBe('permission') + expect(detectAgentStatusFromTitle('[!] Action Required | project')).toBe('permission') + expect(detectAgentStatusFromTitle('Action Required | project')).toBeNull() + }) + it('detects "permission" keyword with agent name', () => { expect(detectAgentStatusFromTitle('codex - permission needed')).toBe('permission') }) diff --git a/src/shared/agent-detection.ts b/src/shared/agent-detection.ts index dbb13895a1a..a8cd79a1cd8 100644 --- a/src/shared/agent-detection.ts +++ b/src/shared/agent-detection.ts @@ -19,9 +19,11 @@ export { } from './agent-title-core' export { getAgentLabel, isClaudeAgent } from './agent-title-identity' export { + CODEX_NATIVE_ACTION_REQUIRED_TITLE_RE, clearWorkingIndicators, createAgentStatusTracker, detectAgentStatusFromTitle, + isCodexNativeActionRequiredTitle, normalizeTerminalTitle } from './agent-title-status' diff --git a/src/shared/agent-hook-listener.test.ts b/src/shared/agent-hook-listener.test.ts index 083783328d3..6ebdb279b3d 100644 --- a/src/shared/agent-hook-listener.test.ts +++ b/src/shared/agent-hook-listener.test.ts @@ -303,6 +303,240 @@ describe('shared agent-hook-listener', () => { ) }) + it('keeps Codex SessionStart metadata without treating it as working', () => { + const started = normalizeHookPayload( + state, + 'codex', + { + paneKey: PANE_KEY, + payload: { + hook_event_name: 'SessionStart', + source: 'startup', + session_id: 'codex-session-start' + } + }, + 'production' + ) + const prompted = normalizeHookPayload( + state, + 'codex', + { + paneKey: PANE_KEY, + payload: { hook_event_name: 'UserPromptSubmit', prompt: 'ship the fix' } + }, + 'production' + ) + + expect(started).toBeNull() + expect(prompted?.payload).toMatchObject({ state: 'working', prompt: 'ship the fix' }) + expect(prompted?.providerSession).toEqual({ key: 'session_id', id: 'codex-session-start' }) + }) + + it('keeps Codex subagent events working and only root Stop done', () => { + normalizeHookPayload( + state, + 'codex', + { + paneKey: PANE_KEY, + payload: { hook_event_name: 'UserPromptSubmit', prompt: 'review this PR' } + }, + 'production' + ) + const subStart = normalizeHookPayload( + state, + 'codex', + { + paneKey: PANE_KEY, + payload: { + hook_event_name: 'SubagentStart', + agent_id: 'agent-1', + agent_type: 'explorer' + } + }, + 'production' + ) + const subStop = normalizeHookPayload( + state, + 'codex', + { + paneKey: PANE_KEY, + payload: { + hook_event_name: 'SubagentStop', + agent_id: 'agent-1', + agent_type: 'explorer', + last_assistant_message: 'Found 3 call sites' + } + }, + 'production' + ) + const stop = normalizeHookPayload( + state, + 'codex', + { + paneKey: PANE_KEY, + payload: { + hook_event_name: 'Stop', + last_assistant_message: 'PR review complete' + } + }, + 'production' + ) + + expect(subStart?.payload).toMatchObject({ + state: 'working', + agentType: 'codex', + toolName: 'explorer', + prompt: 'review this PR' + }) + expect(subStart?.toolAgentId).toBe('agent-1') + expect(subStart?.toolAgentType).toBe('explorer') + expect(subStop?.payload).toMatchObject({ + state: 'working', + agentType: 'codex', + lastAssistantMessage: 'Found 3 call sites', + prompt: 'review this PR' + }) + expect(stop?.payload).toMatchObject({ + state: 'done', + agentType: 'codex', + lastAssistantMessage: 'PR review complete' + }) + }) + + it('promotes Codex PostToolUse tool_response into lastAssistantMessage', () => { + const event = normalizeHookPayload( + state, + 'codex', + { + paneKey: PANE_KEY, + payload: { + hook_event_name: 'PostToolUse', + tool_name: 'Bash', + tool_input: { command: 'ls' }, + tool_response: 'README.md\nsrc\n' + } + }, + 'production' + ) + + expect(event?.payload).toMatchObject({ + state: 'working', + toolName: 'Bash', + toolInput: 'ls', + lastAssistantMessage: 'README.md\nsrc' + }) + }) + + it('previews Codex Bash cmd, apply_patch command, and spawn_agent prompt inputs', () => { + const bash = normalizeHookPayload( + state, + 'codex', + { + paneKey: PANE_KEY, + payload: { + hook_event_name: 'PreToolUse', + tool_name: 'Bash', + tool_input: { cmd: 'pwd' } + } + }, + 'production' + ) + const patch = normalizeHookPayload( + state, + 'codex', + { + paneKey: PANE_KEY, + payload: { + hook_event_name: 'PreToolUse', + tool_name: 'apply_patch', + tool_input: { command: '*** Begin Patch\n*** Update File: a.ts\n*** End Patch' } + } + }, + 'production' + ) + const spawn = normalizeHookPayload( + state, + 'codex', + { + paneKey: PANE_KEY, + payload: { + hook_event_name: 'PreToolUse', + tool_name: 'spawn_agent', + tool_input: { agent_type: 'explorer', prompt: 'find auth handlers' } + } + }, + 'production' + ) + + expect(bash?.payload.toolInput).toBe('pwd') + expect(patch?.payload.toolInput).toContain('Begin Patch') + expect(spawn?.payload.toolInput).toBe('find auth handlers') + }) + + it('clears sticky Codex working when a tool event carries an interrupt marker', () => { + normalizeHookPayload( + state, + 'codex', + { + paneKey: PANE_KEY, + payload: { hook_event_name: 'UserPromptSubmit', prompt: 'run tests' } + }, + 'production' + ) + const interrupted = normalizeHookPayload( + state, + 'codex', + { + paneKey: PANE_KEY, + payload: { + hook_event_name: 'PostToolUse', + tool_name: 'Bash', + tool_input: { command: 'pnpm test' }, + is_interrupt: true + } + }, + 'production' + ) + + expect(interrupted?.payload).toMatchObject({ + state: 'done', + agentType: 'codex', + interrupted: true, + prompt: 'run tests' + }) + }) + + it('marks Codex Stop with is_interrupt as interrupted done', () => { + normalizeHookPayload( + state, + 'codex', + { + paneKey: PANE_KEY, + payload: { hook_event_name: 'UserPromptSubmit', prompt: 'say hi' } + }, + 'production' + ) + const stop = normalizeHookPayload( + state, + 'codex', + { + paneKey: PANE_KEY, + payload: { + hook_event_name: 'Stop', + is_interrupt: true, + last_assistant_message: 'partial' + } + }, + 'production' + ) + + expect(stop?.payload).toMatchObject({ + state: 'done', + interrupted: true, + lastAssistantMessage: 'partial' + }) + }) + it('clears interactivePrompt on the next tool event after AskUserQuestion', () => { normalizeHookPayload( state, diff --git a/src/shared/agent-hook-listener.ts b/src/shared/agent-hook-listener.ts index 59724b64322..9177e260f5b 100644 --- a/src/shared/agent-hook-listener.ts +++ b/src/shared/agent-hook-listener.ts @@ -101,6 +101,9 @@ export type HookListenerState = { warnedEnvs: Set lastPromptByPaneKey: Map lastToolByPaneKey: Map + /** Provider session identity can arrive on a metadata-only SessionStart before + * the first visible status event. Keep it pane-scoped until that event. */ + lastProviderSessionByPaneKey: Map lastStatusByPaneKey: Map antigravityCompletedTranscriptByPaneKey: Map ampCompletedCacheKeys: Set @@ -136,6 +139,7 @@ export function createHookListenerState(): HookListenerState { warnedEnvs: new Set(), lastPromptByPaneKey: new Map(), lastToolByPaneKey: new Map(), + lastProviderSessionByPaneKey: new Map(), lastStatusByPaneKey: new Map(), antigravityCompletedTranscriptByPaneKey: new Map(), ampCompletedCacheKeys: new Set(), @@ -147,6 +151,7 @@ export function createHookListenerState(): HookListenerState { export function clearPaneCacheState(state: HookListenerState, paneKey: string): void { deletePaneScopedCacheEntry(state.lastPromptByPaneKey, paneKey) deletePaneScopedCacheEntry(state.lastToolByPaneKey, paneKey) + deletePaneScopedCacheEntry(state.lastProviderSessionByPaneKey, paneKey) deletePaneScopedCacheEntry(state.lastStatusByPaneKey, paneKey) deletePaneScopedCacheEntry(state.antigravityCompletedTranscriptByPaneKey, paneKey) deletePaneScopedSetEntry(state.ampCompletedCacheKeys, paneKey) @@ -184,6 +189,7 @@ function deletePaneScopedSetEntry(set: Set, paneKey: string): void { export function clearAllListenerCaches(state: HookListenerState): void { state.lastPromptByPaneKey.clear() state.lastToolByPaneKey.clear() + state.lastProviderSessionByPaneKey.clear() state.lastStatusByPaneKey.clear() state.antigravityCompletedTranscriptByPaneKey.clear() state.ampCompletedCacheKeys.clear() @@ -530,7 +536,7 @@ const TOOL_INPUT_KEYS_BY_TOOL: Record = { Execute: ['command'], MultiEdit: ['file_path', 'filePath', 'path'], NotebookEdit: ['file_path', 'filePath', 'path'], - Bash: ['command'], + Bash: ['command', 'cmd'], Glob: ['pattern'], Grep: ['pattern'], WebFetch: ['url'], @@ -557,7 +563,8 @@ const TOOL_INPUT_KEYS_BY_TOOL: Record = { search_replace: ['file_path', 'path', 'filePath'], write_to_file: ['TargetFile', 'path', 'file_path'], execute_code: ['code', 'command', 'cmd'], - apply_patch: ['path', 'file_path'], + apply_patch: ['command', 'path', 'file_path'], + spawn_agent: ['prompt', 'agent_type', 'description', 'name'], view_image: ['path', 'file_path'], AskUser: ['question', 'prompt', 'message'], ask_user: ['question', 'prompt', 'message'], @@ -1438,7 +1445,7 @@ function extractCodexToolFields( deriveToolInputPreview(toolName, hookPayload.tool_input) ?? deriveToolInputPreview(toolName, hookPayload.input) ?? deriveToolInputPreview(toolName, hookPayload.arguments) - return toolUpdate( + const update = toolUpdate( { toolName, toolInput, @@ -1446,8 +1453,19 @@ function extractCodexToolFields( }, { hasToolInputField: hasAnyOwnField(hookPayload, ['tool_input', 'input', 'arguments']) } ) + if (eventName === 'PostToolUse') { + const responseText = extractToolResponseText(hookPayload.tool_response) + if (responseText) { + update.lastAssistantMessage = responseText + } + } + return update } - if (eventName === 'Stop') { + if (eventName === 'SubagentStart') { + const agentType = readString(hookPayload, 'agent_type') + return agentType ? toolUpdate({ toolName: agentType }) : {} + } + if (eventName === 'SubagentStop' || eventName === 'Stop') { const message = readString(hookPayload, 'last_assistant_message') if (message) { return { lastAssistantMessage: message } @@ -2200,7 +2218,7 @@ function isNewTurnEvent(source: AgentHookSource, eventName: unknown): boolean { case 'kimi': return eventName === 'UserPromptSubmit' case 'codex': - return eventName === 'SessionStart' || eventName === 'UserPromptSubmit' + return eventName === 'UserPromptSubmit' case 'gemini': return eventName === 'BeforeAgent' case 'antigravity': @@ -3081,6 +3099,35 @@ function hasExplicitPromptForSource( return eventName === 'agent.start' && promptText.length > 0 } +function resolveHookProviderSession( + state: HookListenerState, + source: AgentHookSource, + paneKey: string, + hookPayload: Record +): AgentProviderSessionMetadata | undefined { + const extracted = extractAgentProviderSession(source, hookPayload) ?? undefined + if (source !== 'codex') { + return extracted + } + if (extracted) { + state.lastProviderSessionByPaneKey.set(paneKey, extracted) + return extracted + } + return state.lastProviderSessionByPaneKey.get(paneKey) +} + +function codexPayloadIsInterrupted(hookPayload: Record): boolean { + if (hookPayload['is_interrupt'] === true || hookPayload['interrupted'] === true) { + return true + } + const stopReason = readFirstString(hookPayload, ['stop_reason', 'stopReason'])?.toLowerCase() + return ( + stopReason?.includes('interrupt') === true || + stopReason?.includes('abort') === true || + stopReason?.includes('cancel') === true + ) +} + function normalizeCodexEvent( state: HookListenerState, eventName: unknown, @@ -3088,17 +3135,38 @@ function normalizeCodexEvent( paneKey: string, hookPayload: Record ): ParsedAgentStatusPayload | null { - const stateName = - eventName === 'SessionStart' || + if (eventName === 'SessionStart') { + // Why: Codex fires SessionStart when opening or resuming an idle TUI, before + // a user prompt exists; reset stale turn/session cache without emitting state. + clearPaneTurnCacheState(state, paneKey) + state.lastProviderSessionByPaneKey.delete(paneKey) + return null + } + + let stateName: 'working' | 'waiting' | 'done' | null = null + if ( eventName === 'UserPromptSubmit' || eventName === 'PreToolUse' || - eventName === 'PostToolUse' - ? 'working' - : eventName === 'PermissionRequest' - ? 'waiting' - : eventName === 'Stop' - ? 'done' - : null + eventName === 'PostToolUse' || + eventName === 'SubagentStart' || + eventName === 'SubagentStop' + ) { + // Why: a child stopping does not end its parent's turn; only root Stop does. + stateName = 'working' + } else if (eventName === 'PermissionRequest') { + stateName = 'waiting' + } else if (eventName === 'Stop') { + stateName = 'done' + } + + if ( + stateName === 'working' && + eventName !== 'UserPromptSubmit' && + eventName !== 'SubagentStart' && + codexPayloadIsInterrupted(hookPayload) + ) { + stateName = 'done' + } if (!stateName) { return null @@ -3110,6 +3178,8 @@ function normalizeCodexEvent( extractToolFields('codex', eventName, hookPayload), { resetOnNewTurn: isNewTurnEvent('codex', eventName) } ) + const interrupted = + stateName === 'done' && codexPayloadIsInterrupted(hookPayload) ? true : undefined return parseAgentStatusPayload( JSON.stringify({ @@ -3121,7 +3191,8 @@ function normalizeCodexEvent( toolName: snapshot.toolName, toolInput: snapshot.toolInput, interactivePrompt: snapshot.interactivePrompt, - lastAssistantMessage: snapshot.lastAssistantMessage + lastAssistantMessage: snapshot.lastAssistantMessage, + interrupted }) ) } @@ -3781,7 +3852,7 @@ export function normalizeHookPayload( // it null; the relay forwards null on the wire and Orca's `ingestRemote` // stamps the real value from `mux` identity on receive. See // docs/design/agent-status-over-ssh.md ยง5. - const providerSession = extractAgentProviderSession(source, hookPayloadRecord) + const providerSession = resolveHookProviderSession(state, source, paneKey, hookPayloadRecord) return payload ? { paneKey, diff --git a/src/shared/agent-title-status.ts b/src/shared/agent-title-status.ts index 7c589e9935e..27341f6eca2 100644 --- a/src/shared/agent-title-status.ts +++ b/src/shared/agent-title-status.ts @@ -25,6 +25,14 @@ import type { AgentStatus } from './agent-title-core' import { getPiCompatibleSyntheticAgentStatus } from './pi-compatible-synthetic-title' import { isGrokRotatingWorkingTitle } from './terminal-title-agent-type' +// Why: Codex permission titles omit the agent name, so the fixed native prefix +// is the only reliable title-only signal for its waiting state. +export const CODEX_NATIVE_ACTION_REQUIRED_TITLE_RE = /^\[\s*[!.]\s*\]\s*Action Required\b/i + +export function isCodexNativeActionRequiredTitle(title: string): boolean { + return CODEX_NATIVE_ACTION_REQUIRED_TITLE_RE.test(title) +} + /** * Strip working-status indicators so stale exit titles stop reporting working. */ @@ -142,6 +150,10 @@ export function detectAgentStatusFromTitle(title: string): AgentStatus | null { return null } + if (isCodexNativeActionRequiredTitle(title)) { + return 'permission' + } + if (title.includes(GEMINI_PERMISSION)) { return 'permission' } From c0cfe124760c233896a05bad946bf51de014ebab Mon Sep 17 00:00:00 2001 From: bbingz Date: Sun, 12 Jul 2026 08:53:18 +0800 Subject: [PATCH 2/3] fix(hooks): clear stale Codex session status --- src/main/agent-hooks/server.test.ts | 139 +++++++++++++++++++++++++ src/main/agent-hooks/server.ts | 46 ++++++++ src/relay/agent-hook-server.test.ts | 46 ++++++++ src/relay/agent-hook-server.ts | 40 +++++++ src/shared/agent-hook-listener.test.ts | 69 ++++++++++++ src/shared/agent-hook-listener.ts | 3 + 6 files changed, 343 insertions(+) diff --git a/src/main/agent-hooks/server.test.ts b/src/main/agent-hooks/server.test.ts index f0b355ecd27..73cd84f5ea5 100644 --- a/src/main/agent-hooks/server.test.ts +++ b/src/main/agent-hooks/server.test.ts @@ -2349,6 +2349,44 @@ describe('AgentHookServer listener replay', () => { } }) + it('broadcasts a clear when Codex SessionStart replaces a same-pane status', async () => { + const server = new AgentHookServer() + await server.start({ env: 'production' }) + try { + const env = server.buildPtyEnv() + const statusListener = vi.fn() + const clearListener = vi.fn() + const changeListener = vi.fn() + server.setListener(statusListener) + server.setPaneStatusClearListener(clearListener) + server.subscribeStatusChanges(changeListener) + const postCodexHook = (payload: Record): Promise => + fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/codex`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN + }, + body: JSON.stringify(buildBody(payload)) + }) + + await postCodexHook({ hook_event_name: 'UserPromptSubmit', prompt: 'old session' }) + await postCodexHook({ hook_event_name: 'SessionStart', session_id: 'new-session' }) + await postCodexHook({ hook_event_name: 'SessionStart', session_id: 'new-session' }) + + expect(server.getStatusSnapshot()).toEqual([]) + expect(statusListener).toHaveBeenCalledTimes(1) + expect(clearListener).toHaveBeenCalledTimes(1) + expect(clearListener).toHaveBeenCalledWith(PANE) + expect(changeListener).toHaveBeenLastCalledWith([]) + const replayListener = vi.fn() + server.setListener(replayListener) + expect(replayListener).not.toHaveBeenCalled() + } finally { + server.stop() + } + }) + it('ignores local nested Claude Stop while a parent Codex hook status is active', async () => { const server = new AgentHookServer() await server.start({ env: 'production' }) @@ -6317,6 +6355,107 @@ describe('Last-status persistence', () => { }) describe('AgentHookServer ingestRemote', () => { + it('treats a relayed Codex SessionStart control event as an idempotent clear', () => { + const server = new AgentHookServer() + const clearListener = vi.fn() + const changeListener = vi.fn() + server.setPaneStatusClearListener(clearListener) + server.subscribeStatusChanges(changeListener) + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + payload: { state: 'working', prompt: 'remote old session', agentType: 'codex' } + }, + 'conn-1' + ) + const clearEnvelope = { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + hookEventName: 'SessionStart', + payload: { state: 'done' as const, prompt: '', agentType: 'codex' as const } + } + + server.ingestRemote(clearEnvelope, 'conn-1') + server.ingestRemote(clearEnvelope, 'conn-1') + + expect(server.getStatusSnapshot()).toEqual([]) + expect(clearListener).toHaveBeenCalledTimes(1) + expect(clearListener).toHaveBeenCalledWith(PANE) + expect(changeListener).toHaveBeenLastCalledWith([]) + }) + + it('does not let a relayed Codex SessionStart clear a different agent status', () => { + const server = new AgentHookServer() + const clearListener = vi.fn() + server.setPaneStatusClearListener(clearListener) + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + payload: { state: 'working', prompt: 'parent session', agentType: 'claude' } + }, + 'conn-1' + ) + + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + hookEventName: 'SessionStart', + payload: { state: 'done', prompt: '', agentType: 'codex' } + }, + 'conn-1' + ) + + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ state: 'working', prompt: 'parent session', agentType: 'claude' }) + ]) + expect(clearListener).not.toHaveBeenCalled() + }) + + it('does not let a stale connection clear a newer Codex status', () => { + const server = new AgentHookServer() + const clearListener = vi.fn() + server.setPaneStatusClearListener(clearListener) + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + payload: { state: 'working', prompt: 'new connection status', agentType: 'codex' } + }, + 'conn-new' + ) + const clearEnvelope = { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + hookEventName: 'SessionStart', + payload: { state: 'done' as const, prompt: '', agentType: 'codex' as const } + } + + server.ingestRemote(clearEnvelope, 'conn-old') + + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ + state: 'working', + prompt: 'new connection status', + agentType: 'codex', + connectionId: 'conn-new' + }) + ]) + expect(clearListener).not.toHaveBeenCalled() + + server.ingestRemote(clearEnvelope, 'conn-new') + expect(server.getStatusSnapshot()).toEqual([]) + expect(clearListener).toHaveBeenCalledTimes(1) + }) + it('stamps connectionId and forwards a valid relay envelope to the listener', () => { const server = new AgentHookServer() const payload = parseAgentStatusPayload( diff --git a/src/main/agent-hooks/server.ts b/src/main/agent-hooks/server.ts index 971909b230b..7abad08aad1 100644 --- a/src/main/agent-hooks/server.ts +++ b/src/main/agent-hooks/server.ts @@ -286,6 +286,17 @@ function trackEmptyPaneKeyHook(body: unknown): void { track('agent_hook_unattributed', { reason: 'empty_pane_key' }) } +function hookBodyPaneKey(body: unknown): string | null { + if (typeof body !== 'object' || body === null) { + return null + } + const paneKey = (body as Record).paneKey + if (typeof paneKey !== 'string') { + return null + } + return paneKey.trim() || null +} + function isToolProgressWorkingAfterInterrupt(next: AgentHookEventPayload): boolean { if (next.payload.state !== 'working') { return false @@ -814,6 +825,31 @@ export class AgentHookServer { return enriched } + private clearStatusForSessionStart( + paneKey: string, + previousStatus?: AgentHookEventPayload, + expectedConnectionId?: string + ): void { + const status = previousStatus ?? this.state.lastStatusByPaneKey.get(paneKey) + // Why: a delayed relay notification must not clear a newer remote target's + // status if pane identity is ever reused across logical connections. + if ( + status?.payload.agentType !== 'codex' || + (expectedConnectionId !== undefined && status.connectionId !== expectedConnectionId) + ) { + return + } + this.state.lastStatusByPaneKey.delete(paneKey) + // Why: SessionStart is an idle metadata boundary, so remove the stale row + // without emitting a synthetic visible status for the new session. + this.clearAssistantMessageRetry(paneKey) + this.runtimeObservedStatusPaneKeys.delete(paneKey) + this.promptSentDedupeByPaneKey.delete(paneKey) + this.scheduleStatusPersist() + this.notifyStatusChangeListeners() + this.onPaneStatusCleared?.(paneKey) + } + private clearAssistantMessageRetry(paneKey: string): void { const timer = this.assistantMessageRetryTimers.get(paneKey) if (!timer) { @@ -1194,6 +1230,12 @@ export class AgentHookServer { env: envelope.env, expectedEnv: this.env }) + if (hookEventName === 'SessionStart' && normalizedPayload.agentType === 'codex') { + // New relays encode the metadata-only SessionStart as an empty done frame; + // pre-fix relays may encode it as working. Neither should become visible. + this.clearStatusForSessionStart(paneKey, undefined, trimmedConnectionId) + return + } const event: AgentHookEventPayload = { paneKey, launchToken: envelope.launchToken, @@ -1277,10 +1319,14 @@ export class AgentHookServer { trackEmptyPaneKeyHook(body) const aliasedBody = this.normalizeHookBodyPaneKeyAlias(body) + const paneKey = hookBodyPaneKey(aliasedBody) + const previousStatus = paneKey ? this.state.lastStatusByPaneKey.get(paneKey) : undefined const normalized = normalizeHookPayload(this.state, source, aliasedBody, this.env) if (normalized && !this.shouldSuppressClosedTabStatus(normalized.paneKey)) { const enriched = this.applyNormalizedStatus(normalized) this.scheduleAssistantMessageRetry(source, aliasedBody, enriched) + } else if (paneKey && previousStatus && !this.state.lastStatusByPaneKey.has(paneKey)) { + this.clearStatusForSessionStart(paneKey, previousStatus) } res.writeHead(204) diff --git a/src/relay/agent-hook-server.test.ts b/src/relay/agent-hook-server.test.ts index 9174d0e35e6..7f28178a759 100644 --- a/src/relay/agent-hook-server.test.ts +++ b/src/relay/agent-hook-server.test.ts @@ -114,6 +114,52 @@ describe('RelayAgentHookServer', () => { } }) + it('replays a Codex SessionStart tombstone after clearing the stale status', async () => { + const forward = vi.fn<(envelope: AgentHookRelayEnvelope) => void>() + const server = new RelayAgentHookServer({ endpointDir: dir, forward }) + await server.start() + try { + const { port, token } = server.getCoordinates() + const post = (payload: Record): Promise => + fetch(`http://127.0.0.1:${port}/hook/codex`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Orca-Agent-Hook-Token': token + }, + body: JSON.stringify({ paneKey: PANE_KEY, tabId: 'tab-1', payload }) + }) + + await post({ hook_event_name: 'UserPromptSubmit', prompt: 'remote old session' }) + forward.mockClear() + await post({ hook_event_name: 'SessionStart', session_id: 'relay-new-session' }) + + expect(forward).toHaveBeenCalledTimes(1) + expect(forward.mock.calls[0][0]).toMatchObject({ + source: 'codex', + paneKey: PANE_KEY, + hookEventName: 'SessionStart', + providerSession: { key: 'session_id', id: 'relay-new-session' }, + payload: { state: 'done', prompt: '', agentType: 'codex' } + }) + + // Simulate the live notification being lost while the SSH mux is down: + // reconnect replay must still carry the clear control event. + forward.mockClear() + expect(server.replayCachedPayloadsForPanes()).toBe(1) + expect(forward).toHaveBeenCalledTimes(1) + expect(forward.mock.calls[0][0]).toMatchObject({ + source: 'codex', + paneKey: PANE_KEY, + hookEventName: 'SessionStart', + isReplay: true, + payload: { state: 'done', prompt: '', agentType: 'codex' } + }) + } finally { + server.stop() + } + }) + it('rejects requests with the wrong bearer token (403)', async () => { const forward = vi.fn() const server = new RelayAgentHookServer({ endpointDir: dir, forward }) diff --git a/src/relay/agent-hook-server.ts b/src/relay/agent-hook-server.ts index f5200a2b178..b90da6f9317 100644 --- a/src/relay/agent-hook-server.ts +++ b/src/relay/agent-hook-server.ts @@ -314,6 +314,8 @@ export class RelayAgentHookServer { res.end() return } + const paneKey = this.bodyPaneKey(body) + const previousStatus = paneKey ? this.state.lastStatusByPaneKey.get(paneKey) : undefined const event = normalizeHookPayload(this.state, source, body, this.env) if (event) { // TODO: once normalizeHookPayload returns validated env/version, drop @@ -322,6 +324,13 @@ export class RelayAgentHookServer { const version = this.bodyVersion(body) this.applyEvent(event, source, env, version) this.scheduleAssistantMessageRetry(source, body, event, env, version) + } else if ( + source === 'codex' && + previousStatus && + paneKey && + !this.state.lastStatusByPaneKey.has(paneKey) + ) { + this.forwardSessionStartClear(previousStatus, this.bodyEnv(body), this.bodyVersion(body)) } res.writeHead(204) res.end() @@ -392,6 +401,29 @@ export class RelayAgentHookServer { this.forwardEvent(event, source, env, version) } + private forwardSessionStartClear( + previous: AgentHookEventPayload, + env?: string, + version?: string + ): void { + this.clearAssistantMessageRetry(previous.paneKey) + const providerSession = this.state.lastProviderSessionByPaneKey.get(previous.paneKey) + // Why: old Orca builds treat this as an idle done row; new builds recognize + // SessionStart and clear it. Cache the tombstone so reconnect replay cannot + // lose the clear while the SSH notification channel is unavailable. + this.applyEvent( + { + ...previous, + hookEventName: 'SessionStart', + ...(providerSession ? { providerSession } : {}), + payload: { state: 'done', prompt: '', agentType: 'codex' } + }, + 'codex', + env, + version + ) + } + private clearAssistantMessageRetry(paneKey: string): void { const timer = this.assistantMessageRetryTimers.get(paneKey) if (!timer) { @@ -507,6 +539,14 @@ export class RelayAgentHookServer { return v } + private bodyPaneKey(body: unknown): string | null { + if (typeof body !== 'object' || body === null) { + return null + } + const value = (body as Record).paneKey + return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null + } + private bodyVersion(body: unknown): string | undefined { if (typeof body !== 'object' || body === null) { return undefined diff --git a/src/shared/agent-hook-listener.test.ts b/src/shared/agent-hook-listener.test.ts index 6ebdb279b3d..76f531b5071 100644 --- a/src/shared/agent-hook-listener.test.ts +++ b/src/shared/agent-hook-listener.test.ts @@ -332,6 +332,75 @@ describe('shared agent-hook-listener', () => { expect(prompted?.providerSession).toEqual({ key: 'session_id', id: 'codex-session-start' }) }) + it('clears only the same-pane cached Codex status on SessionStart', () => { + const otherPane = makePaneKey('tab-2', '22222222-2222-4222-8222-222222222222') + const current = normalizeHookPayload( + state, + 'codex', + { + paneKey: PANE_KEY, + payload: { hook_event_name: 'UserPromptSubmit', prompt: 'old session' } + }, + 'production' + ) + const other = normalizeHookPayload( + state, + 'codex', + { + paneKey: otherPane, + payload: { hook_event_name: 'UserPromptSubmit', prompt: 'other pane' } + }, + 'production' + ) + if (!current || !other) { + throw new Error('expected working status fixtures') + } + state.lastStatusByPaneKey.set(PANE_KEY, current) + state.lastStatusByPaneKey.set(otherPane, other) + + const started = normalizeHookPayload( + state, + 'codex', + { + paneKey: PANE_KEY, + payload: { hook_event_name: 'SessionStart', session_id: 'new-session' } + }, + 'production' + ) + + expect(started).toBeNull() + expect(state.lastStatusByPaneKey.has(PANE_KEY)).toBe(false) + expect(state.lastStatusByPaneKey.get(otherPane)).toBe(other) + }) + + it('does not clear a different agent status on Codex SessionStart', () => { + const claude = normalizeHookPayload( + state, + 'claude', + { + paneKey: PANE_KEY, + payload: { hook_event_name: 'UserPromptSubmit', prompt: 'parent session' } + }, + 'production' + ) + if (!claude) { + throw new Error('expected Claude working status fixture') + } + state.lastStatusByPaneKey.set(PANE_KEY, claude) + + normalizeHookPayload( + state, + 'codex', + { + paneKey: PANE_KEY, + payload: { hook_event_name: 'SessionStart', session_id: 'nested-codex' } + }, + 'production' + ) + + expect(state.lastStatusByPaneKey.get(PANE_KEY)).toBe(claude) + }) + it('keeps Codex subagent events working and only root Stop done', () => { normalizeHookPayload( state, diff --git a/src/shared/agent-hook-listener.ts b/src/shared/agent-hook-listener.ts index 9177e260f5b..6f9e05dcc85 100644 --- a/src/shared/agent-hook-listener.ts +++ b/src/shared/agent-hook-listener.ts @@ -3140,6 +3140,9 @@ function normalizeCodexEvent( // a user prompt exists; reset stale turn/session cache without emitting state. clearPaneTurnCacheState(state, paneKey) state.lastProviderSessionByPaneKey.delete(paneKey) + if (state.lastStatusByPaneKey.get(paneKey)?.payload.agentType === 'codex') { + state.lastStatusByPaneKey.delete(paneKey) + } return null } From 6c3381838b0d8c2deb2074d05c00a7277b88915c Mon Sep 17 00:00:00 2001 From: bbingz Date: Sun, 12 Jul 2026 09:28:28 +0800 Subject: [PATCH 3/3] fix(hooks): deduplicate relayed session starts --- src/relay/agent-hook-server.test.ts | 23 ++++++++++++++++++++++- src/relay/agent-hook-server.ts | 6 ++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/src/relay/agent-hook-server.test.ts b/src/relay/agent-hook-server.test.ts index 7f28178a759..54d443efd48 100644 --- a/src/relay/agent-hook-server.test.ts +++ b/src/relay/agent-hook-server.test.ts @@ -114,7 +114,7 @@ describe('RelayAgentHookServer', () => { } }) - it('replays a Codex SessionStart tombstone after clearing the stale status', async () => { + it('deduplicates Codex SessionStart while retaining replay until working resumes', async () => { const forward = vi.fn<(envelope: AgentHookRelayEnvelope) => void>() const server = new RelayAgentHookServer({ endpointDir: dir, forward }) await server.start() @@ -133,6 +133,7 @@ describe('RelayAgentHookServer', () => { await post({ hook_event_name: 'UserPromptSubmit', prompt: 'remote old session' }) forward.mockClear() await post({ hook_event_name: 'SessionStart', session_id: 'relay-new-session' }) + await post({ hook_event_name: 'SessionStart', session_id: 'relay-new-session' }) expect(forward).toHaveBeenCalledTimes(1) expect(forward.mock.calls[0][0]).toMatchObject({ @@ -155,6 +156,26 @@ describe('RelayAgentHookServer', () => { isReplay: true, payload: { state: 'done', prompt: '', agentType: 'codex' } }) + + forward.mockClear() + await post({ hook_event_name: 'UserPromptSubmit', prompt: 'new session working' }) + expect(forward).toHaveBeenCalledTimes(1) + expect(forward.mock.calls[0][0]).toMatchObject({ + source: 'codex', + paneKey: PANE_KEY, + hookEventName: 'UserPromptSubmit', + providerSession: { key: 'session_id', id: 'relay-new-session' }, + payload: { state: 'working', prompt: 'new session working', agentType: 'codex' } + }) + + forward.mockClear() + expect(server.replayCachedPayloadsForPanes()).toBe(1) + expect(forward).toHaveBeenCalledTimes(1) + expect(forward.mock.calls[0][0]).toMatchObject({ + hookEventName: 'UserPromptSubmit', + isReplay: true, + payload: { state: 'working', prompt: 'new session working', agentType: 'codex' } + }) } finally { server.stop() } diff --git a/src/relay/agent-hook-server.ts b/src/relay/agent-hook-server.ts index b90da6f9317..f644dd8aaa3 100644 --- a/src/relay/agent-hook-server.ts +++ b/src/relay/agent-hook-server.ts @@ -406,6 +406,12 @@ export class RelayAgentHookServer { env?: string, version?: string ): void { + if (previous.hookEventName === 'SessionStart') { + // Why: normalization removes the prior Codex status before returning null; + // restore its tombstone so duplicate hooks neither rebroadcast nor erase replay. + this.state.lastStatusByPaneKey.set(previous.paneKey, previous) + return + } this.clearAssistantMessageRetry(previous.paneKey) const providerSession = this.state.lastProviderSessionByPaneKey.get(previous.paneKey) // Why: old Orca builds treat this as an idle done row; new builds recognize