Show Codex permission requests as waiting

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
brennanb2025
2026-05-13 18:45:08 -07:00
co-authored by Orca
parent 575b1d42ed
commit 4d1c911edf
4 changed files with 113 additions and 10 deletions
+21 -1
View File
@@ -473,7 +473,7 @@ describe('Codex hook normalization', () => {
// Why: Codex's PreToolUse is NOT an approval prompt — it fires for every
// tool call. We map it to `working` (never `waiting`) and use it only to
// give the dashboard a live readout during the gap between prompt and
// Stop. Real approval signals flow through Codex's `notify` callback.
// Stop. Real approval signals flow through PermissionRequest.
const result = _internals.normalizeHookPayload(
'codex',
buildBody({
@@ -488,6 +488,26 @@ describe('Codex hook normalization', () => {
expect(result?.payload.toolInput).toBe('git status')
})
it('PermissionRequest maps to waiting and surfaces the pending tool input', () => {
// Why: Codex asks for user attention through PermissionRequest. Orca's
// sidebar red dot depends on this becoming `waiting`; treating it like
// PreToolUse would leave the pane looking busy while it is blocked on the
// user.
const result = _internals.normalizeHookPayload(
'codex',
buildBody({
hook_event_name: 'PermissionRequest',
tool_name: 'exec_command',
tool_input: { cmd: 'rm -rf build', workdir: '/tmp' }
}),
'production'
)
expect(result?.payload.state).toBe('waiting')
expect(result?.payload.agentType).toBe('codex')
expect(result?.payload.toolName).toBe('exec_command')
expect(result?.payload.toolInput).toBe('rm -rf build')
})
it('UserPromptSubmit does not extract tool fields even when the payload carries them', () => {
// Why: UserPromptSubmit is a turn-boundary event; any tool_name on it
// would be leftover noise and should not leak into the working-state
+77
View File
@@ -0,0 +1,77 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mkdtempSync, readFileSync, rmSync } from 'fs'
import { tmpdir } from 'os'
import type * as Os from 'os'
import { join } from 'path'
const { getPathMock, homedirMock } = vi.hoisted(() => ({
getPathMock: vi.fn<(name: string) => string>(),
homedirMock: vi.fn<() => string>()
}))
vi.mock('electron', () => ({
app: {
getPath: getPathMock
}
}))
vi.mock('os', async (importOriginal) => {
const actual = await importOriginal<typeof Os>()
return {
...actual,
homedir: homedirMock
}
})
import { CodexHookService } from './hook-service'
let tmpHome: string
let userDataDir: string
beforeEach(() => {
tmpHome = mkdtempSync(join(tmpdir(), 'orca-codex-home-'))
userDataDir = mkdtempSync(join(tmpdir(), 'orca-codex-user-data-'))
homedirMock.mockReturnValue(tmpHome)
getPathMock.mockImplementation((name: string) => {
if (name === 'userData') {
return userDataDir
}
throw new Error(`unexpected app.getPath(${name})`)
})
})
afterEach(() => {
rmSync(tmpHome, { recursive: true, force: true })
rmSync(userDataDir, { recursive: true, force: true })
vi.clearAllMocks()
})
describe('CodexHookService', () => {
it('installs PermissionRequest with trust so Codex approval prompts reach Orca', () => {
const status = new CodexHookService().install()
expect(status.state).toBe('installed')
const hooksConfig = JSON.parse(
readFileSync(join(tmpHome, '.codex', 'hooks.json'), 'utf-8')
) as {
hooks: Record<string, { hooks?: { command?: string }[] }[]>
}
expect(Object.keys(hooksConfig.hooks).sort()).toEqual(
[
'PermissionRequest',
'PostToolUse',
'PreToolUse',
'SessionStart',
'Stop',
'UserPromptSubmit'
].sort()
)
expect(hooksConfig.hooks.PermissionRequest?.[0]?.hooks?.[0]?.command).toContain('agent-hooks')
expect(hooksConfig.hooks.PermissionRequest?.[0]?.hooks?.[0]?.command).toContain('codex-hook')
const trustConfig = readFileSync(join(tmpHome, '.codex', 'config.toml'), 'utf-8')
expect(trustConfig).toContain(':permission_request:0:0')
})
})
+5 -5
View File
@@ -26,15 +26,14 @@ import {
// Why: PreToolUse/PostToolUse give the dashboard a live readout of the
// in-flight tool (name + input preview) between UserPromptSubmit and Stop.
// Without them, a long-running Codex turn looks like a silent gap after the
// prompt is submitted. We keep both events mapped to `working` (see
// normalizeCodexEvent); they do NOT mean "approval needed" — Codex's real
// approval signal flows through its separate `notify` callback (different
// install surface, different payload shape) and is deferred as a follow-up.
// 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.
const CODEX_EVENTS = [
'SessionStart',
'UserPromptSubmit',
'PreToolUse',
'PermissionRequest',
'PostToolUse',
'Stop'
] as const
@@ -55,6 +54,7 @@ const CODEX_EVENT_LABEL: Record<(typeof CODEX_EVENTS)[number], CodexEventLabel>
SessionStart: 'session_start',
UserPromptSubmit: 'user_prompt_submit',
PreToolUse: 'pre_tool_use',
PermissionRequest: 'permission_request',
PostToolUse: 'post_tool_use',
Stop: 'stop'
}
+10 -4
View File
@@ -481,7 +481,11 @@ function extractCodexToolFields(
eventName: unknown,
hookPayload: Record<string, unknown>
): ToolSnapshot {
if (eventName === 'PreToolUse' || eventName === 'PostToolUse') {
if (
eventName === 'PreToolUse' ||
eventName === 'PermissionRequest' ||
eventName === 'PostToolUse'
) {
const toolName = readString(hookPayload, 'tool_name') ?? readString(hookPayload, 'name')
const toolInput =
deriveToolInputPreview(toolName, hookPayload.tool_input) ??
@@ -759,9 +763,11 @@ function normalizeCodexEvent(
eventName === 'PreToolUse' ||
eventName === 'PostToolUse'
? 'working'
: eventName === 'Stop'
? 'done'
: null
: eventName === 'PermissionRequest'
? 'waiting'
: eventName === 'Stop'
? 'done'
: null
if (!stateName) {
return null