From fa3fd3ae02602966c8c19a65f20b6e7b227ba76f Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 25 Jun 2026 19:24:04 -0700 Subject: [PATCH] Fix slow Codex hook statuses on Windows (#6398) Co-authored-by: Neil --- src/main/agent-hooks/installer-utils.test.ts | 24 ++-- src/main/agent-hooks/installer-utils.ts | 30 +++-- src/main/codex/hook-service.test.ts | 114 ++++++++++++------- src/main/codex/hook-service.ts | 33 ++++-- 4 files changed, 125 insertions(+), 76 deletions(-) diff --git a/src/main/agent-hooks/installer-utils.test.ts b/src/main/agent-hooks/installer-utils.test.ts index d316c869d..a5e157b79 100644 --- a/src/main/agent-hooks/installer-utils.test.ts +++ b/src/main/agent-hooks/installer-utils.test.ts @@ -345,12 +345,9 @@ describe('wrapWindowsHookCommand', () => { return Buffer.from(encodedCommand!, 'base64').toString('utf16le') } - it('invokes the .cmd through an encoded PowerShell command', () => { + it('uses the script path directly when no cmd escaping is needed', () => { const command = wrapWindowsHookCommand('C:\\Users\\alice\\.orca\\agent-hooks\\codex-hook.cmd') - expect(command).toMatch(/^powershell -NoProfile -ExecutionPolicy Bypass -EncodedCommand \S+$/) - expect(decodeWindowsHookCommand(command)).toBe( - "& 'C:\\Users\\alice\\.orca\\agent-hooks\\codex-hook.cmd'; exit $LASTEXITCODE" - ) + expect(command).toBe('C:\\Users\\alice\\.orca\\agent-hooks\\codex-hook.cmd') }) // Why: a user profile path like `C:\Users\Jane Doe` is the regression from @@ -358,6 +355,7 @@ describe('wrapWindowsHookCommand', () => { // the whole path inside the encoded command so shells do not split it. it('preserves spaces in the script path (user profile with space case)', () => { const cmd = wrapWindowsHookCommand('C:\\Users\\Jorge Silva\\.orca\\agent-hooks\\codex-hook.cmd') + expect(cmd).toMatch(/^powershell -NoProfile -ExecutionPolicy Bypass -EncodedCommand \S+$/) expect(decodeWindowsHookCommand(cmd)).toBe( "& 'C:\\Users\\Jorge Silva\\.orca\\agent-hooks\\codex-hook.cmd'; exit $LASTEXITCODE" ) @@ -390,15 +388,17 @@ describe('wrapWindowsHookCommand', () => { }) describe('buildWindowsAgentHookPostCommand', () => { - it('forces UTF-8 for redirected hook stdin and POST bodies', () => { + it('posts hook stdin through bounded curl without spawning PowerShell', () => { const command = buildWindowsAgentHookPostCommand('codex') - expect(command).toContain('[Console]::InputEncoding=$utf8') - expect(command).toContain('[Console]::OutputEncoding=$utf8') - expect(command).toContain('$bodyBytes=$utf8.GetBytes($body)') - expect(command).toContain("-ContentType 'application/json; charset=utf-8'") - expect(command).toContain('-TimeoutSec 2') + expect(command).toContain('curl.exe -sS -X POST') + expect(command).toContain('--connect-timeout 0.5 --max-time 1.5') + expect(command).toContain('-H "Content-Type: application/x-www-form-urlencoded"') + expect(command).toContain('-H "X-Orca-Agent-Hook-Token: %ORCA_AGENT_HOOK_TOKEN%"') + expect(command).toContain('--data-urlencode "paneKey=%ORCA_PANE_KEY%"') + expect(command).toContain('--data-urlencode "payload@-"') expect(command).toContain('/hook/codex') - expect(command).not.toContain("'Content-Type'='application/json'") + expect(command).not.toContain('powershell') + expect(command).not.toContain('Invoke-WebRequest') }) }) diff --git a/src/main/agent-hooks/installer-utils.ts b/src/main/agent-hooks/installer-utils.ts index c4c112f8e..446a28f73 100644 --- a/src/main/agent-hooks/installer-utils.ts +++ b/src/main/agent-hooks/installer-utils.ts @@ -147,23 +147,33 @@ function quotePowerShellString(value: string): string { return `'${value.replaceAll("'", "''")}'` } -// Why: Windows splits a raw hook command on whitespace, so a user profile path -// like `C:\Users\Jane Doe` makes the agent try to execute `C:\Users\Jane` and -// fail with exit code 1. Keep the script path inside an encoded PowerShell -// command so cmd.exe never gets a chance to expand legal path characters like -// `%` or `^` before the .cmd is invoked. #6078. export function wrapWindowsHookCommand(scriptPath: string): string { + if (!/[ \t&()^|<>%!"]/u.test(scriptPath)) { + return scriptPath + } + // Why: Windows splits raw hook commands on whitespace, and cmd.exe expands + // `%`/`^`; use PowerShell only for paths that need that compatibility shim. const command = `& ${quotePowerShellString(scriptPath)}; exit $LASTEXITCODE` const encodedCommand = Buffer.from(command, 'utf16le').toString('base64') return `powershell -NoProfile -ExecutionPolicy Bypass -EncodedCommand ${encodedCommand}` } export function buildWindowsAgentHookPostCommand(source: AgentHookSource): string { - // Why: Windows PowerShell 5.1 defaults redirected stdin/request bodies to the - // active code page. Hook payloads are UTF-8 JSON, so force UTF-8 on both read - // and POST or CJK prompts arrive in Orca as literal question marks. Timeout - // caps best-effort hook posts if the local listener stalls. - return `powershell -NoProfile -ExecutionPolicy Bypass -Command "$utf8=[System.Text.UTF8Encoding]::new($false); [Console]::InputEncoding=$utf8; [Console]::OutputEncoding=$utf8; $inputData=[Console]::In.ReadToEnd(); if ([string]::IsNullOrWhiteSpace($inputData)) { exit 0 }; try { $body=@{ paneKey=$env:ORCA_PANE_KEY; launchToken=$env:ORCA_AGENT_LAUNCH_TOKEN; tabId=$env:ORCA_TAB_ID; worktreeId=$env:ORCA_WORKTREE_ID; env=$env:ORCA_AGENT_HOOK_ENV; version=$env:ORCA_AGENT_HOOK_VERSION; payload=($inputData | ConvertFrom-Json) } | ConvertTo-Json -Depth 100 -Compress; $bodyBytes=$utf8.GetBytes($body); Invoke-WebRequest -UseBasicParsing -Method Post -Uri ('http://127.0.0.1:' + $env:ORCA_AGENT_HOOK_PORT + '/hook/${source}') -ContentType 'application/json; charset=utf-8' -Headers @{ 'X-Orca-Agent-Hook-Token'=$env:ORCA_AGENT_HOOK_TOKEN } -Body $bodyBytes -TimeoutSec 2 | Out-Null } catch {}"` + // Why: Codex runs these hooks inline on every turn. PowerShell startup alone + // makes trusted Windows hooks visibly slow, so mirror the POSIX curl path. + return [ + `curl.exe -sS -X POST "http://127.0.0.1:%ORCA_AGENT_HOOK_PORT%/hook/${source}" ^`, + ' --connect-timeout 0.5 --max-time 1.5 ^', + ' -H "Content-Type: application/x-www-form-urlencoded" ^', + ' -H "X-Orca-Agent-Hook-Token: %ORCA_AGENT_HOOK_TOKEN%" ^', + ' --data-urlencode "paneKey=%ORCA_PANE_KEY%" ^', + ' --data-urlencode "tabId=%ORCA_TAB_ID%" ^', + ' --data-urlencode "launchToken=%ORCA_AGENT_LAUNCH_TOKEN%" ^', + ' --data-urlencode "worktreeId=%ORCA_WORKTREE_ID%" ^', + ' --data-urlencode "env=%ORCA_AGENT_HOOK_ENV%" ^', + ' --data-urlencode "version=%ORCA_AGENT_HOOK_VERSION%" ^', + ' --data-urlencode "payload@-" >nul 2>nul' + ].join('\r\n') } export function removeManagedCommands( diff --git a/src/main/codex/hook-service.test.ts b/src/main/codex/hook-service.test.ts index e41154d2e..155cd51c0 100644 --- a/src/main/codex/hook-service.test.ts +++ b/src/main/codex/hook-service.test.ts @@ -111,6 +111,16 @@ function markHookTrustDisabled(toml: string, header: string): string { return `${toml.slice(0, headerIndex)}${block.replace('enabled = true', 'enabled = false')}${toml.slice(blockEnd)}` } +function localManagedCodexEvents(): string[] { + return process.platform === 'win32' + ? ['PermissionRequest', 'PostToolUse', 'PreToolUse'] + : ['PermissionRequest', 'PostToolUse', 'PreToolUse', 'SessionStart', 'Stop', 'UserPromptSubmit'] +} + +function localHasManagedCodexLifecycleHooks(): boolean { + return process.platform !== 'win32' +} + describe('CodexHookService', () => { it('installs PermissionRequest with trust so Codex approval prompts reach Orca', () => { const systemCodexHome = join(tmpHome, '.codex') @@ -130,16 +140,7 @@ describe('CodexHookService', () => { hooks: Record } - expect(Object.keys(hooksConfig.hooks).sort()).toEqual( - [ - 'PermissionRequest', - 'PostToolUse', - 'PreToolUse', - 'SessionStart', - 'Stop', - 'UserPromptSubmit' - ].sort() - ) + expect(Object.keys(hooksConfig.hooks).sort()).toEqual(localManagedCodexEvents()) expect( isCodexManagedCommand(hooksConfig.hooks.PermissionRequest?.[0]?.hooks?.[0]?.command) ).toBe(true) @@ -198,7 +199,7 @@ describe('CodexHookService', () => { readFileSync(join(managedCodexHome, 'hooks.json'), 'utf-8') ) as { hooks: Record } - for (const eventName of ['SessionStart', 'UserPromptSubmit', 'Stop']) { + for (const eventName of localManagedCodexEvents()) { const command = hooksConfig.hooks[eventName]?.[0]?.hooks?.[0]?.command expect(command).toMatch( /^powershell -NoProfile -ExecutionPolicy Bypass -EncodedCommand \S+$/ @@ -259,12 +260,12 @@ describe('CodexHookService', () => { ) ).toBe(true) expect( - devHooks.hooks.Stop?.some((definition) => + devHooks.hooks.PreToolUse?.some((definition) => isCodexManagedCommand(definition.hooks?.[0]?.command) ) ).toBe(true) expect( - prodHooks.hooks.Stop?.some((definition) => + prodHooks.hooks.PreToolUse?.some((definition) => isCodexManagedCommand(definition.hooks?.[0]?.command) ) ).toBe(true) @@ -334,13 +335,18 @@ describe('CodexHookService', () => { { matcher?: string; hooks?: { command?: string; statusMessage?: string }[] }[] > } - expect(runtimeHooks.hooks.Stop?.[1]?.matcher).toBe('*') - expect(runtimeHooks.hooks.Stop?.[1]?.hooks?.[0]?.command).toBe('user-hook') - expect(runtimeHooks.hooks.Stop?.[1]?.hooks?.[0]?.statusMessage).toBe('Running user hook') + const userStopIndex = localHasManagedCodexLifecycleHooks() ? 1 : 0 + expect(runtimeHooks.hooks.Stop?.[userStopIndex]?.matcher).toBe('*') + expect(runtimeHooks.hooks.Stop?.[userStopIndex]?.hooks?.[0]?.command).toBe('user-hook') + expect(runtimeHooks.hooks.Stop?.[userStopIndex]?.hooks?.[0]?.statusMessage).toBe( + 'Running user hook' + ) const runtimeToml = readFileSync(join(managedCodexHome, 'config.toml'), 'utf-8') - expect(runtimeToml).toContain(hookTrustHeader(`${managedHooksPath}:stop:0:0`)) - expect(runtimeToml).toContain(hookTrustHeader(`${managedHooksPath}:stop:1:0`)) + expect(runtimeToml).toContain(hookTrustHeader(`${managedHooksPath}:stop:${userStopIndex}:0`)) + if (localHasManagedCodexLifecycleHooks()) { + expect(runtimeToml).toContain(hookTrustHeader(`${managedHooksPath}:stop:0:0`)) + } expect(runtimeToml).not.toContain(hookTrustHeader(`${systemHooksPath}:stop:0:0`)) }) @@ -440,7 +446,9 @@ describe('CodexHookService', () => { const runtimeToml = readFileSync(join(managedCodexHome, 'config.toml'), 'utf-8') expect(runtimeToml).toContain(hookTrustHeader(`${managedHooksPath}:stop:0:0`)) expect(runtimeToml).toContain(hookTrustHeader(`${managedHooksPath}:stop:1:0`)) - expect(runtimeToml).toContain(hookTrustHeader(`${managedHooksPath}:stop:2:0`)) + if (localHasManagedCodexLifecycleHooks()) { + expect(runtimeToml).toContain(hookTrustHeader(`${managedHooksPath}:stop:2:0`)) + } expect(runtimeToml).not.toContain(hookTrustHeader(`${systemHooksPath}:stop:0:0`)) expect(runtimeToml).not.toContain(hookTrustHeader(`${systemHooksPath}:stop:1:0`)) }) @@ -515,7 +523,9 @@ describe('CodexHookService', () => { ) ?? [] expect(stopCommands).toContain(userCommand) - expect(stopCommands.some((command) => isCodexManagedCommand(command))).toBe(true) + expect(stopCommands.some((command) => isCodexManagedCommand(command))).toBe( + localHasManagedCodexLifecycleHooks() + ) expect(runtimeHooks.hooks.PreCompact).toBeUndefined() for (const command of pluginCommands) { expect(runtimeHooksText).not.toContain(command) @@ -523,7 +533,9 @@ describe('CodexHookService', () => { const runtimeToml = readFileSync(join(managedCodexHome, 'config.toml'), 'utf-8') expect(runtimeToml).toContain(hookTrustHeader(`${managedHooksPath}:stop:0:0`)) - expect(runtimeToml).toContain(hookTrustHeader(`${managedHooksPath}:stop:1:0`)) + if (localHasManagedCodexLifecycleHooks()) { + expect(runtimeToml).toContain(hookTrustHeader(`${managedHooksPath}:stop:1:0`)) + } for (const command of pluginCommands) { expect(runtimeToml).not.toContain(command) } @@ -621,7 +633,10 @@ describe('CodexHookService', () => { const managedCodexHome = join(userDataDir, 'codex-runtime-home', 'home') const managedHooksPath = join(managedCodexHome, 'hooks.json') - const runtimeUserTrustHeader = hookTrustHeader(`${managedHooksPath}:stop:1:0`) + const runtimeUserStopIndex = localHasManagedCodexLifecycleHooks() ? 1 : 0 + const runtimeUserTrustHeader = hookTrustHeader( + `${managedHooksPath}:stop:${runtimeUserStopIndex}:0` + ) expect(readFileSync(join(managedCodexHome, 'config.toml'), 'utf-8')).toContain( runtimeUserTrustHeader ) @@ -631,7 +646,11 @@ describe('CodexHookService', () => { const runtimeToml = readFileSync(join(managedCodexHome, 'config.toml'), 'utf-8') expect(runtimeToml).not.toContain(runtimeUserTrustHeader) - expect(runtimeToml).toContain(hookTrustHeader(`${managedHooksPath}:stop:0:0`)) + if (localHasManagedCodexLifecycleHooks()) { + expect(runtimeToml).toContain(hookTrustHeader(`${managedHooksPath}:stop:0:0`)) + } else { + expect(runtimeToml).not.toContain(':stop:0:0') + } }) it('refreshes mirrored system user hooks when the system hooks file changes', () => { @@ -1079,7 +1098,9 @@ describe('CodexHookService', () => { (definition) => definition.hooks?.map((hook) => hook.command ?? '') ?? [] ) ?? [] expect(stopCommands).toContain(userCommand) - expect(stopCommands.some((command) => isCodexManagedCommand(command))).toBe(true) + expect(stopCommands.some((command) => isCodexManagedCommand(command))).toBe( + localHasManagedCodexLifecycleHooks() + ) expect( isCodexManagedCommand(runtimeHooks.hooks.PermissionRequest?.[0]?.hooks?.[0]?.command) ).toBe(true) @@ -1200,33 +1221,35 @@ describe('CodexHookService', () => { const managedCodexHome = join(userDataDir, 'codex-runtime-home', 'home') const managedHooksPath = join(managedCodexHome, 'hooks.json') const runtimeTomlPath = join(managedCodexHome, 'config.toml') - const canonicalSessionStartHeader = hookTrustHeader(`${managedHooksPath}:session_start:0:0`) - const legacySessionStartHeader = `[hooks.state."${escapeTomlBasicString( - `${realpathSync.native(managedHooksPath).replace(/\\/g, '/')}:session_start:0:0` + const canonicalPermissionHeader = hookTrustHeader( + `${managedHooksPath}:permission_request:0:0` + ) + const legacyPermissionHeader = `[hooks.state."${escapeTomlBasicString( + `${realpathSync.native(managedHooksPath).replace(/\\/g, '/')}:permission_request:0:0` )}"]` const installedToml = readFileSync(runtimeTomlPath, 'utf-8') - expect(installedToml).toContain(canonicalSessionStartHeader) + expect(installedToml).toContain(canonicalPermissionHeader) writeFileSync( runtimeTomlPath, - installedToml.replace(canonicalSessionStartHeader, legacySessionStartHeader), + installedToml.replace(canonicalPermissionHeader, legacyPermissionHeader), 'utf-8' ) const legacyToml = readFileSync(runtimeTomlPath, 'utf-8') - expect(legacyToml).toContain(legacySessionStartHeader) + expect(legacyToml).toContain(legacyPermissionHeader) expect(service.getStatus().state).toBe('installed') expect(service.install().state).toBe('installed') const repairedToml = readFileSync(runtimeTomlPath, 'utf-8') - expect(repairedToml).not.toContain(legacySessionStartHeader) - expect(repairedToml).toContain(canonicalSessionStartHeader) + expect(repairedToml).not.toContain(legacyPermissionHeader) + expect(repairedToml).toContain(canonicalPermissionHeader) expect(service.getStatus().state).toBe('installed') } ) - it('repairs duplicate managed SessionStart trust tables on restart install', () => { + it('repairs duplicate managed PermissionRequest trust tables on restart install', () => { const systemCodexHome = join(tmpHome, '.codex') mkdirSync(systemCodexHome, { recursive: true }) writeFileSync(join(systemCodexHome, 'config.toml'), 'model = "system-model"\n', 'utf-8') @@ -1237,21 +1260,24 @@ describe('CodexHookService', () => { const managedCodexHome = join(userDataDir, 'codex-runtime-home', 'home') const managedHooksPath = join(managedCodexHome, 'hooks.json') const runtimeTomlPath = join(managedCodexHome, 'config.toml') - const sessionStartHeader = hookTrustHeader(`${managedHooksPath}:session_start:0:0`) + const permissionRequestHeader = hookTrustHeader(`${managedHooksPath}:permission_request:0:0`) const installedToml = readFileSync(runtimeTomlPath, 'utf-8') - const sessionStartIndex = installedToml.indexOf(sessionStartHeader) - expect(sessionStartIndex).not.toBe(-1) + const permissionRequestIndex = installedToml.indexOf(permissionRequestHeader) + expect(permissionRequestIndex).not.toBe(-1) const nextHeaderIndex = installedToml.indexOf( '\n[', - sessionStartIndex + sessionStartHeader.length + permissionRequestIndex + permissionRequestHeader.length ) - const sessionStartBlock = installedToml - .slice(sessionStartIndex, nextHeaderIndex === -1 ? installedToml.length : nextHeaderIndex) + const permissionRequestBlock = installedToml + .slice( + permissionRequestIndex, + nextHeaderIndex === -1 ? installedToml.length : nextHeaderIndex + ) .trimEnd() - const staleDisabledBlock = sessionStartBlock + const staleDisabledBlock = permissionRequestBlock .replace('enabled = true', 'enabled = false') .replace(/trusted_hash = "[^"]+"/, 'trusted_hash = "sha256:STALE_DISABLED"') - const staleEnabledBlock = sessionStartBlock.replace( + const staleEnabledBlock = permissionRequestBlock.replace( /trusted_hash = "[^"]+"/, 'trusted_hash = "sha256:STALE_ENABLED"' ) @@ -1259,20 +1285,20 @@ describe('CodexHookService', () => { runtimeTomlPath, `${installedToml.slice( 0, - sessionStartIndex + permissionRequestIndex )}${staleDisabledBlock}\n\n${staleEnabledBlock}${installedToml.slice( nextHeaderIndex === -1 ? installedToml.length : nextHeaderIndex )}`, 'utf-8' ) - expect(readFileSync(runtimeTomlPath, 'utf-8').split(sessionStartHeader)).toHaveLength(3) + expect(readFileSync(runtimeTomlPath, 'utf-8').split(permissionRequestHeader)).toHaveLength(3) // Why: preserving `enabled = false` is the repair contract; status can be // partial because the user-disabled managed hook remains disabled. expect(['installed', 'partial']).toContain(service.install().state) const repairedToml = readFileSync(runtimeTomlPath, 'utf-8') - expect(repairedToml.split(sessionStartHeader)).toHaveLength(2) + expect(repairedToml.split(permissionRequestHeader)).toHaveLength(2) expect(repairedToml).toContain('enabled = false') expect(repairedToml).not.toContain('STALE_DISABLED') expect(repairedToml).not.toContain('STALE_ENABLED') diff --git a/src/main/codex/hook-service.ts b/src/main/codex/hook-service.ts index b2cab5ed8..7f2840fa8 100644 --- a/src/main/codex/hook-service.ts +++ b/src/main/codex/hook-service.ts @@ -59,6 +59,16 @@ const CODEX_EVENTS = [ 'Stop' ] as const +const CODEX_WINDOWS_LOCAL_EVENTS = ['PreToolUse', 'PermissionRequest', 'PostToolUse'] as const + +function getLocalCodexManagedEvents(): readonly (typeof CODEX_EVENTS)[number][] { + // Why: Codex renders synchronous lifecycle hooks as prominent TUI progress + // rows on Windows, where even fast hook processes leave multi-second stale + // screen fragments. Keep the tool/permission hooks Orca needs for live + // status and approvals, but avoid chat lifecycle hooks in local Windows PTYs. + return process.platform === 'win32' ? CODEX_WINDOWS_LOCAL_EVENTS : CODEX_EVENTS +} + function getConfigPath(): string { return join(getOrcaManagedCodexHomePath(), 'hooks.json') } @@ -86,9 +96,9 @@ const CODEX_EVENT_LABEL: Record<(typeof CODEX_EVENTS)[number], CodexEventLabel> Stop: 'stop' } -const CODEX_MANAGED_EVENT_LABELS = new Set( - CODEX_EVENTS.map((eventName) => CODEX_EVENT_LABEL[eventName]) -) +function getLocalCodexManagedEventLabels(): Set { + return new Set(getLocalCodexManagedEvents().map((eventName) => CODEX_EVENT_LABEL[eventName])) +} const CODEX_HOOK_EVENT_LABEL: Record = { ...CODEX_EVENT_LABEL, @@ -450,7 +460,7 @@ function moveMirroredRuntimeUserTrustAfterManagedStatusHook( entries: readonly MirroredRuntimeUserHookTrustEntry[] ): MirroredRuntimeUserHookTrustEntry[] { return entries.map(({ entry, enabled }) => { - if (!CODEX_MANAGED_EVENT_LABELS.has(entry.eventLabel)) { + if (!getLocalCodexManagedEventLabels().has(entry.eventLabel)) { return { entry, enabled } } return { @@ -655,9 +665,11 @@ function removeRuntimeManagedHookTrustEntries(configPath: string): void { // recognize (and clean up) its own managed trust entries. timeoutSec: MANAGED_HOOK_TIMEOUT_SECONDS } - const expectedHash = computeTrustedHash(expectedEntry) - const legacyHash = computeTrustedHash({ ...expectedEntry, timeoutSec: undefined }) - if (state.trustedHash !== expectedHash && state.trustedHash !== legacyHash) { + const recognizedHashes = new Set([ + computeTrustedHash(expectedEntry), + computeTrustedHash({ ...expectedEntry, timeoutSec: undefined }) + ]) + if (!state.trustedHash || !recognizedHashes.has(state.trustedHash)) { continue } ourKeys.push(key) @@ -763,7 +775,7 @@ export class CodexHookService { const trustMissing: string[] = [] const disabled: string[] = [] let presentCount = 0 - for (const eventName of CODEX_EVENTS) { + for (const eventName of getLocalCodexManagedEvents()) { const definitions = Array.isArray(config.hooks?.[eventName]) ? config.hooks![eventName]! : [] // Why: older installs appended this command, while current installs // prepend it. Picking the last match keeps status repair conservative @@ -867,7 +879,8 @@ export class CodexHookService { const command = getManagedCommand(scriptPath) const hookPlan = getRuntimeHooksWithSystemUserHooks(config.hooks, isManagedCommand) const nextHooks = hookPlan.hooks - const managedEvents = new Set(CODEX_EVENTS) + const localManagedEvents = getLocalCodexManagedEvents() + const managedEvents = new Set(localManagedEvents) // Why: sweep managed entries out of events we no longer subscribe to // (e.g., PreToolUse from a prior install). Without this, users who @@ -900,7 +913,7 @@ export class CodexHookService { hookPlan.trustEntries ) const trustEntries: CodexTrustEntry[] = mirroredUserTrustEntries.map(({ entry }) => entry) - for (const eventName of CODEX_EVENTS) { + for (const eventName of localManagedEvents) { const current = Array.isArray(nextHooks[eventName]) ? nextHooks[eventName] : [] const cleaned = removeManagedCommands(current, isManagedCommand) const definition: HookDefinition = {