-
Notifications
You must be signed in to change notification settings - Fork 56
fix(hooks): narrow inject matchers — Mac CPU fix D #1479
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -10,7 +10,7 @@ import { mkdir, readFile, writeFile } from 'node:fs/promises'; | |
| import { homedir } from 'node:os'; | ||
| import { join } from 'node:path'; | ||
| import { fileURLToPath } from 'node:url'; | ||
| import { DISPATCHED_EVENTS } from './types.js'; | ||
| import { DISPATCHED_EVENTS, DISPATCHED_EVENT_MATCHERS } from './types.js'; | ||
|
|
||
| interface HookEntry { | ||
| type: string; | ||
|
|
@@ -56,10 +56,13 @@ function buildHooksConfig(): HooksConfig { | |
| const hooks: HooksConfig = {}; | ||
| const dispatchCommand = buildDispatchCommand(); | ||
|
|
||
| for (const event of DISPATCHED_EVENTS) { | ||
| // Mac-CPU fix D — wire each event with its declared matcher. | ||
| // Events absent from DISPATCHED_EVENT_MATCHERS are NOT wired (avoids | ||
| // useless `bun` cold-starts for events with zero handlers). | ||
| for (const [event, matcher] of Object.entries(DISPATCHED_EVENT_MATCHERS)) { | ||
| hooks[event] = [ | ||
| { | ||
| matcher: '*', | ||
| matcher, | ||
| hooks: [ | ||
| { | ||
| type: 'command', | ||
|
|
@@ -74,56 +77,106 @@ function buildHooksConfig(): HooksConfig { | |
| return hooks; | ||
| } | ||
|
|
||
| /** | ||
| * Inject genie hook dispatch into a settings.json file. | ||
| * Preserves existing non-hook settings. Overwrites existing hooks. | ||
| */ | ||
| async function injectIntoFile(settingsPath: string): Promise<boolean> { | ||
| let settings: Record<string, unknown> = {}; | ||
|
|
||
| if (existsSync(settingsPath)) { | ||
| try { | ||
| const content = await readFile(settingsPath, 'utf-8'); | ||
| settings = JSON.parse(content); | ||
| } catch { | ||
| // Corrupted or empty — start fresh | ||
| } | ||
| /** Read existing settings (or start fresh on missing/corrupt). */ | ||
| async function readSettings(settingsPath: string): Promise<Record<string, unknown>> { | ||
| if (!existsSync(settingsPath)) return {}; | ||
| try { | ||
| return JSON.parse(await readFile(settingsPath, 'utf-8')); | ||
| } catch { | ||
| return {}; | ||
| } | ||
| } | ||
|
|
||
| const hooksConfig = buildHooksConfig(); | ||
| /** True if every dispatched event already matches both desired matcher AND command. */ | ||
| function allEventsAlreadyInjected(existingHooks: HooksConfig, hooksConfig: HooksConfig): boolean { | ||
| return DISPATCHED_EVENTS.every((event) => { | ||
| const existing = existingHooks[event]; | ||
| const desiredCommand = hooksConfig[event][0].hooks[0].command; | ||
| const desiredMatcher = hooksConfig[event][0].matcher; | ||
| return existing?.some((m) => m.matcher === desiredMatcher && m.hooks?.some((h) => h.command === desiredCommand)); | ||
| }); | ||
| } | ||
|
|
||
| // Check if already injected (avoid unnecessary writes) | ||
| const existingHooks = settings.hooks as HooksConfig | undefined; | ||
| if (existingHooks) { | ||
| const allInjected = DISPATCHED_EVENTS.every((event) => { | ||
| const existing = existingHooks[event]; | ||
| const desiredCommand = hooksConfig[event][0].hooks[0].command; | ||
| return existing?.some((m) => m.hooks?.some((h) => h.command === desiredCommand)); | ||
| }); | ||
| if (allInjected) { | ||
| return false; // already injected | ||
| /** True if no obsolete events (removed from DISPATCHED_EVENT_MATCHERS) still carry a genie entry. */ | ||
| function hasNoObsoleteGenieEntries(existingHooks: HooksConfig): boolean { | ||
| return Object.keys(existingHooks).every((event) => { | ||
| if (DISPATCHED_EVENTS.includes(event as never)) return true; | ||
| const entries = existingHooks[event]; | ||
| return !entries?.some((m) => m.hooks?.some((h) => isGenieDispatchCommand(h.command))); | ||
| }); | ||
| } | ||
|
|
||
| /** | ||
| * Mac-CPU fix D — prune genie-dispatch entries from events that are no | ||
| * longer in DISPATCHED_EVENT_MATCHERS (SessionStart/SessionEnd/TeammateIdle/ | ||
| * TaskCompleted). User-defined hooks under those events are preserved. | ||
| */ | ||
| function pruneObsoleteGenieEntries(mergedHooks: HooksConfig): void { | ||
| for (const event of Object.keys(mergedHooks)) { | ||
| if (DISPATCHED_EVENTS.includes(event as never)) continue; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Useful? React with 👍 / 👎. |
||
| const cleaned = (mergedHooks[event] ?? []) | ||
| .map((matcher) => ({ | ||
| ...matcher, | ||
| hooks: matcher.hooks?.filter((hook) => !isGenieDispatchCommand(hook.command)), | ||
| })) | ||
| .filter((matcher) => (matcher.hooks?.length ?? 0) > 0); | ||
| if (cleaned.length === 0) { | ||
| delete mergedHooks[event]; | ||
| } else { | ||
| mergedHooks[event] = cleaned; | ||
| } | ||
| } | ||
| } | ||
|
|
||
| // Merge genie hook entries into existing hooks (preserve user-defined hooks) | ||
| const mergedHooks: HooksConfig = existingHooks ? { ...existingHooks } : {}; | ||
| for (const event of DISPATCHED_EVENTS) { | ||
| const genieEntry = hooksConfig[event][0]; | ||
| const existingEntries = (mergedHooks[event] ?? []).map((matcher) => ({ | ||
| /** | ||
| * Refresh existing matcher entries: any matcher with a genie-dispatch hook | ||
| * inside it gets its `matcher` field rewritten to the desired value (so | ||
| * PostToolUse '*' → 'SendMessage' on next inject) and its command + timeout | ||
| * refreshed. | ||
| */ | ||
| function refreshMatcherEntries(entries: HookMatcher[], genieEntry: HookMatcher): HookMatcher[] { | ||
| return entries.map((matcher) => { | ||
| const hasGenieHook = matcher.hooks?.some((h) => isGenieDispatchCommand(h.command)); | ||
| return { | ||
| ...matcher, | ||
| matcher: hasGenieHook ? genieEntry.matcher : matcher.matcher, | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a matcher block contains both the genie dispatch hook and user-defined hooks, Useful? React with 👍 / 👎. |
||
| hooks: matcher.hooks?.map((hook) => | ||
| isGenieDispatchCommand(hook.command) | ||
| ? { ...hook, command: genieEntry.hooks[0].command, timeout: DISPATCH_TIMEOUT } | ||
| : hook, | ||
| ), | ||
| })); | ||
| // Only add if not already present | ||
| const alreadyPresent = existingEntries.some((m) => m.hooks?.some((h) => isGenieDispatchCommand(h.command))); | ||
| if (!alreadyPresent) { | ||
| mergedHooks[event] = [...existingEntries, genieEntry]; | ||
| } else { | ||
| mergedHooks[event] = existingEntries; | ||
| } | ||
| }; | ||
| }); | ||
| } | ||
|
|
||
| /** Add or refresh the genie entry for one event in-place on mergedHooks. */ | ||
| function upsertGenieEntry(mergedHooks: HooksConfig, event: string, genieEntry: HookMatcher): void { | ||
| const existingEntries = refreshMatcherEntries(mergedHooks[event] ?? [], genieEntry); | ||
| const alreadyPresent = existingEntries.some((m) => m.hooks?.some((h) => isGenieDispatchCommand(h.command))); | ||
| mergedHooks[event] = alreadyPresent ? existingEntries : [...existingEntries, genieEntry]; | ||
| } | ||
|
|
||
| /** | ||
| * Inject genie hook dispatch into a settings.json file. | ||
| * Preserves existing non-hook settings. Overwrites existing hooks. | ||
| */ | ||
| async function injectIntoFile(settingsPath: string): Promise<boolean> { | ||
| const settings = await readSettings(settingsPath); | ||
| const hooksConfig = buildHooksConfig(); | ||
| const existingHooks = settings.hooks as HooksConfig | undefined; | ||
|
|
||
| if ( | ||
| existingHooks && | ||
| allEventsAlreadyInjected(existingHooks, hooksConfig) && | ||
| hasNoObsoleteGenieEntries(existingHooks) | ||
| ) { | ||
| return false; // already injected and clean — nothing to do | ||
| } | ||
|
|
||
| const mergedHooks: HooksConfig = existingHooks ? { ...existingHooks } : {}; | ||
| pruneObsoleteGenieEntries(mergedHooks); | ||
| for (const event of DISPATCHED_EVENTS) { | ||
| upsertGenieEntry(mergedHooks, event, hooksConfig[event][0]); | ||
| } | ||
| settings.hooks = mergedHooks; | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The short-circuit logic in allEventsAlreadyInjected is too lenient. It returns true if it finds at least one matching genie entry, but it does not verify that the timeout matches DISPATCH_TIMEOUT, nor does it ensure the absence of stale genie entries (e.g., an old * matcher remaining alongside a new SendMessage matcher). This could lead to the injection process skipping necessary updates or leaving redundant dispatcher invocations in settings.json if the configuration is in a partially updated state.