fix(claude): keep a user-set async flag on Orca-managed hooks (#26736)

applyManagedHooks rebuilt each managed Claude hook from scratch, so an
explicit "async" a user added to an Orca hook in settings.json was wiped
on every install. Carry the boolean over from the managed hook being
replaced for the same event; fresh installs stay synchronous and
user-owned hooks never donate their value.

Fixes #26718

Co-authored-by: Neil <neil@stably.ai>
This commit is contained in:
Naveen Chatlapalli
2026-10-26 13:34:39 -07:00
committed by GitHub
co-authored by Neil
parent d58ab9762c
commit 1df8501246
3 changed files with 48 additions and 7 deletions
+12
View File
@@ -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
@@ -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(
+5 -7
View File
@@ -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) {