diff --git a/src/main/agent-hooks/installer-utils.ts b/src/main/agent-hooks/installer-utils.ts index 5161da2cfa1..ae178a6eedf 100644 --- a/src/main/agent-hooks/installer-utils.ts +++ b/src/main/agent-hooks/installer-utils.ts @@ -216,6 +216,18 @@ function hookHasManagedCommand(hook: HookCommandConfig, matches: (value?: string return matches(hook.command) || args.some((arg) => typeof arg === 'string' && matches(arg)) } +// Users may opt a managed hook into async; keep that choice when the hook is rewritten. +export function withPreviousManagedAsync( + hook: HookCommandConfig, + definitions: HookDefinition[], + matches: (command: string | undefined) => boolean +): HookCommandConfig { + const async = definitions + .flatMap((definition) => (Array.isArray(definition.hooks) ? definition.hooks : [])) + .find((prev) => typeof prev.async === 'boolean' && hookHasManagedCommand(prev, matches))?.async + return async === undefined ? hook : { ...hook, async } +} + export function hookDefinitionHasManagedCommand( definition: HookDefinition, isManagedCommand: (command: string | undefined) => boolean diff --git a/src/main/claude/claude-hook-event-install.test.ts b/src/main/claude/claude-hook-event-install.test.ts index 65ce8761e38..efcf3d1cea2 100644 --- a/src/main/claude/claude-hook-event-install.test.ts +++ b/src/main/claude/claude-hook-event-install.test.ts @@ -53,6 +53,37 @@ function expectUserSettingsKept(config: HooksConfig): void { } describe('Claude managed hook events by resolved version', () => { + it.each([true, false])( + 'keeps a managed hook async=%s across reinstall and migration', + (async) => { + const userAsyncHook = { type: 'command' as const, command: 'echo user', async: !async } + const oldManagedHook = { + type: 'command' as const, + command: '/bin/sh', + args: ['/old/.orca/agent-hooks/claude-hook.sh'], + async + } + const written = install( + { + hooks: { + PreToolUse: [{ hooks: [userAsyncHook, oldManagedHook] }], + PostToolUse: [{ hooks: [userAsyncHook] }] + } + }, + '2.1.32' + ) + expect(written.hooks?.PreToolUse).toEqual([ + { hooks: [userAsyncHook] }, + { matcher: '*', hooks: [{ ...managedHook, async }] } + ]) + expect(written.hooks?.PostToolUse).toEqual([ + { hooks: [userAsyncHook] }, + { matcher: '*', hooks: [managedHook] } + ]) + expect(install(written, '2.1.32')).toEqual(written) + } + ) + it.each(Object.keys(enums))('writes only events Claude %s knows', (version) => { const written = install({ hooks: {} }, version) const unknown = Object.keys(written.hooks ?? {}).filter( diff --git a/src/main/claude/hook-settings.ts b/src/main/claude/hook-settings.ts index 7c8ce0a9573..edfc429d4b8 100644 --- a/src/main/claude/hook-settings.ts +++ b/src/main/claude/hook-settings.ts @@ -8,9 +8,9 @@ import { isSafeUnicodeWindowsBatchHookPath, MANAGED_HOOK_TIMEOUT_SECONDS, removeManagedCommands, + withPreviousManagedAsync, wrapWindowsPowerShellEncodedCommand, type HookCommandConfig, - type HookDefinition, type HooksConfig } from '../agent-hooks/installer-utils' import { quotePowerShellLiteral } from '../../shared/powershell-native-argument' @@ -238,12 +238,10 @@ export function applyManagedHooks( for (const event of plan.install) { const current = nextHooks[event.eventName] - const cleaned = Array.isArray(current) ? removeManagedCommands(current, isManagedCommand) : [] - const definition: HookDefinition = { - ...event.definition, - hooks: [hookForEvent(event.eventName)] - } - nextHooks[event.eventName] = [...cleaned, definition] + const previous = Array.isArray(current) ? current : [] + const hook = withPreviousManagedAsync(hookForEvent(event.eventName), previous, isManagedCommand) + const cleaned = removeManagedCommands(previous, isManagedCommand) + nextHooks[event.eventName] = [...cleaned, { ...event.definition, hooks: [hook] }] } for (const event of plan.retire) {